diff --git a/backend/internal/photos/edit.go b/backend/internal/photos/edit.go index 6ef5d48..2d673d0 100644 --- a/backend/internal/photos/edit.go +++ b/backend/internal/photos/edit.go @@ -212,31 +212,58 @@ func curveEval(points []CurvePoint, x float64) float64 { return last.Y } -// adjustChannel applies the light adjustments to a single channel value and -// then maps it through the tone curves: the master curve first, the channel -// curve second. The order of the operations is fixed and mirrored by the -// client-side preview, so the live preview and the rendered result match. -func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 { +// lightAdjuster holds the precomputed factors of the light adjustments. The +// factors are calculated once per render, so the per-pixel loop stays cheap. +type lightAdjuster struct { + exposure float64 + contrast float64 + highlights float64 + shadows float64 + whites float64 + blacks float64 +} + +func newLightAdjuster(p LightParams) lightAdjuster { + return lightAdjuster{ + exposure: math.Pow(2, p.Exposure), + contrast: 1 + p.Contrast/100, + highlights: p.Highlights / 100 * 80, + shadows: p.Shadows / 100 * 80, + whites: p.Whites / 100 * 80, + blacks: p.Blacks / 100 * 80, + } +} + +// apply maps a single channel value through the adjustments and the tone +// curves: the master curve first, the channel curve second. The order of the +// operations is fixed and mirrored by the client-side preview, so the live +// preview and the rendered result match. +func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 { // Экспозиция (стопы). - v *= math.Pow(2, p.Exposure) + v *= a.exposure // Контраст вокруг средней точки. - k := 1 + p.Contrast/100 - v = 128 + (v-128)*k + v = 128 + (v-128)*a.contrast // Света: верхняя часть диапазона. - w := math.Pow(clamp01((v-128)/127), 2) - v += p.Highlights/100*80*w + w := clamp01((v - 128) / 127) + v += a.highlights * w * w // Тени: нижняя часть диапазона. - w = math.Pow(clamp01((128-v)/128), 2) - v += p.Shadows/100*80*w + w = clamp01((128 - v) / 128) + v += a.shadows * w * w // Белые: самый верх диапазона. w = clamp01((v - 192) / 63) - v += p.Whites/100*80*w + v += a.whites * w // Чёрные: самый низ диапазона. w = clamp01((64 - v) / 64) - v += p.Blacks/100*80*w + v += a.blacks * w // Тоновая кривая: общая, затем канальная. - idx := clampInt(int(math.Round(v)), 0, 255) + idx := clampInt(int(v+0.5), 0, 255) v = float64(master[idx]) - idx = clampInt(int(math.Round(v)), 0, 255) - return clampFloat(float64(channel[idx]), 0, 255) + idx = clampInt(int(v+0.5), 0, 255) + return float64(channel[idx]) +} + +// adjustChannel applies the light adjustments to a single channel value. It +// is the per-pixel helper of the renderer. +func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 { + return newLightAdjuster(p).apply(v, master, channel) } diff --git a/backend/internal/photos/edit_bench_test.go b/backend/internal/photos/edit_bench_test.go new file mode 100644 index 0000000..064ac29 --- /dev/null +++ b/backend/internal/photos/edit_bench_test.go @@ -0,0 +1,41 @@ +package photos + +import ( + "image" + "testing" + "time" +) + +func BenchmarkApplyLight(b *testing.B) { + w, h := 4000, 3000 + src := image.NewNRGBA(image.Rect(0, 0, w, h)) + for i := range src.Pix { + src.Pix[i] = uint8(i % 251) + } + p := LightParams{ + Exposure: 0.5, + Contrast: 20, + Highlights: -30, + Shadows: 40, + Curve: CurveParams{ + RGB: []CurvePoint{{X: 0, Y: 0}, {X: 0.5, Y: 0.4}, {X: 1, Y: 1}}, + }, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = applyLight(src, p) + } + b.StopTimer() + b.Logf("кадров: %d", b.N) +} + +func TestRenderSpeed(t *testing.T) { + w, h := 4000, 3000 + src := image.NewNRGBA(image.Rect(0, 0, w, h)) + for i := range src.Pix { + src.Pix[i] = uint8(i % 251) + } + start := time.Now() + _ = applyLight(src, LightParams{Exposure: 0.5, Contrast: 20}) + t.Logf("applyLight %dx%d: %s", w, h, time.Since(start)) +} diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go index 619971d..a3f3650 100644 --- a/backend/internal/photos/edit_render.go +++ b/backend/internal/photos/edit_render.go @@ -3,6 +3,8 @@ package photos import ( "bytes" "image" + "runtime" + "sync" "github.com/disintegration/imaging" "github.com/gen2brain/webp" @@ -27,20 +29,49 @@ func applyLight(src image.Image, p LightParams) *image.NRGBA { red := curveLUT(p.Curve.Red) green := curveLUT(p.Curve.Green) blue := curveLUT(p.Curve.Blue) + adj := newLightAdjuster(p) + rows := dst.Rect.Dy() + workers := runtime.GOMAXPROCS(0) + if workers > rows { + workers = rows + } + if workers <= 1 { + applyLightRows(dst, 0, rows, adj, &master, &red, &green, &blue) + return dst + } + // Рендер больших фото идёт по строкам параллельно. + chunk := (rows + workers - 1) / workers + var wg sync.WaitGroup + for from := 0; from < rows; from += chunk { + to := from + chunk + if to > rows { + to = rows + } + wg.Add(1) + go func(from, to int) { + defer wg.Done() + applyLightRows(dst, from, to, adj, &master, &red, &green, &blue) + }(from, to) + } + wg.Wait() + return dst +} + +// applyLightRows applies the adjustments to the given range of rows. +func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, red, green, blue *[256]uint8) { width := dst.Rect.Dx() - for y := 0; y < dst.Rect.Dy(); y++ { + for y := from; y < to; y++ { row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4] for i := 0; i+3 < len(row); i += 4 { if row[i+3] == 0 { continue } - row[i] = uint8(adjustChannel(float64(row[i]), p, &master, &red)) - row[i+1] = uint8(adjustChannel(float64(row[i+1]), p, &master, &green)) - row[i+2] = uint8(adjustChannel(float64(row[i+2]), p, &master, &blue)) + row[i] = uint8(adj.apply(float64(row[i]), master, red)) + row[i+1] = uint8(adj.apply(float64(row[i+1]), master, green)) + row[i+2] = uint8(adj.apply(float64(row[i+2]), master, blue)) } } - return dst } // decodeAndAdjust decodes the original and applies the light adjustments to