feat(photos): добавить эффекты и настройки панели редактора

- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
iu
2026-10-24 22:57:30 +03:00
parent 4cc5c0f340
commit c953008a86
18 changed files with 3548 additions and 173 deletions
+708
View File
@@ -0,0 +1,708 @@
package photos
import (
"bytes"
"encoding/json"
"image"
"math"
"reflect"
"testing"
"github.com/disintegration/imaging"
)
func TestEffectsParams(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
got := (EffectsParams{}).Normalized()
if got.Texture != 0 || got.Clarity != 0 || got.Dehaze != 0 {
t.Fatalf("strengths changed: %+v", got)
}
v := got.Vignette
if v.Mode != VignetteLight || v.Midpoint != DefaultVignetteMidpoint ||
v.Feather != DefaultVignetteFeather || v.Roundness != 0 || v.Highlights != 0 ||
v.Color != DefaultVignetteColor || v.Amount != 0 {
t.Fatalf("vignette defaults = %+v", v)
}
g := got.Grain
if g.Amount != 0 || g.Size != DefaultGrainSize || g.Roughness != DefaultGrainRoughness {
t.Fatalf("grain defaults = %+v", g)
}
})
t.Run("clamps", func(t *testing.T) {
got := EffectsParams{
Texture: 250,
Clarity: -250,
Dehaze: 150,
Vignette: VignetteParams{
Mode: "sepia", Amount: -150, Midpoint: 150, Feather: 150,
Roundness: -150, Highlights: 150, Color: "#AABBCC",
},
Grain: GrainParams{Amount: 150, Size: 150, Roughness: 150},
}.Normalized()
if got.Texture != MaxPercent || got.Clarity != -MaxPercent || got.Dehaze != MaxPercent {
t.Fatalf("strengths = %v/%v/%v, want clamped", got.Texture, got.Clarity, got.Dehaze)
}
v := got.Vignette
if v.Mode != VignetteLight {
t.Fatalf("unknown mode = %q, want %q", v.Mode, VignetteLight)
}
if v.Amount != -MaxPercent || v.Midpoint != MaxPercent || v.Feather != MaxPercent ||
v.Roundness != -MaxPercent || v.Highlights != MaxPercent {
t.Fatalf("vignette not clamped: %+v", v)
}
if v.Color != "#aabbcc" {
t.Fatalf("color = %q, want lower-case", v.Color)
}
if got.Grain.Amount != MaxPercent || got.Grain.Size != MaxPercent || got.Grain.Roughness != MaxPercent {
t.Fatalf("grain not clamped: %+v", got.Grain)
}
})
t.Run("modes and colors", func(t *testing.T) {
cases := []struct {
mode, want string
}{
{"", VignetteLight},
{"light", VignetteLight},
{"LIGHT", VignetteLight},
{"color", VignetteColor},
{"Colorize", VignetteColorize},
{"unknown", VignetteLight},
}
for _, tc := range cases {
if got := (VignetteParams{Mode: tc.mode}).Normalized().Mode; got != tc.want {
t.Fatalf("mode %q = %q, want %q", tc.mode, got, tc.want)
}
}
colors := []struct {
in, want string
}{
{"#A1B2C3", "#a1b2c3"},
{"#abcdef", "#abcdef"},
{"#12345", DefaultVignetteColor},
{"#gggggg", DefaultVignetteColor},
{"aabbcc", DefaultVignetteColor},
{"red", DefaultVignetteColor},
{"", DefaultVignetteColor},
}
for _, tc := range colors {
if got := normalizeHexColor(tc.in); got != tc.want {
t.Fatalf("color %q = %q, want %q", tc.in, got, tc.want)
}
}
})
t.Run("idempotent", func(t *testing.T) {
p := EffectsParams{
Texture: 12.5, Clarity: -8, Dehaze: 33,
Vignette: VignetteParams{Mode: "COLOR", Amount: -25, Midpoint: 40, Feather: 70, Roundness: 20, Highlights: 10, Color: "#FF8000"},
Grain: GrainParams{Amount: 45, Size: 33, Roughness: 66},
}
once := p.Normalized()
twice := once.Normalized()
if !reflect.DeepEqual(once, twice) {
t.Fatalf("normalization is not idempotent:\n once = %+v\ntwice = %+v", once, twice)
}
})
t.Run("is empty", func(t *testing.T) {
cases := []struct {
name string
p EffectsParams
empty bool
}{
{"zero", EffectsParams{}, true},
{
"geometry only",
EffectsParams{
Vignette: VignetteParams{
Mode: VignetteColor, Midpoint: 20, Feather: 80,
Roundness: 40, Highlights: 50, Color: "#ff0000",
},
Grain: GrainParams{Size: 90, Roughness: 90},
},
true,
},
{"texture", EffectsParams{Texture: 1}, false},
{"clarity", EffectsParams{Clarity: -1}, false},
{"dehaze", EffectsParams{Dehaze: 1}, false},
{"vignette", EffectsParams{Vignette: VignetteParams{Amount: -1}}, false},
{"grain", EffectsParams{Grain: GrainParams{Amount: 1}}, false},
}
for _, tc := range cases {
if got := tc.p.IsEmpty(); got != tc.empty {
t.Fatalf("%s: IsEmpty = %v, want %v", tc.name, got, tc.empty)
}
}
})
t.Run("explicit zero geometry", func(t *testing.T) {
// An explicit zero is a valid geometry value and must survive both
// parsing and normalization; omitted fields still get the defaults.
p, err := ParseEditParams([]byte(`{"effects":{"vignette":{"mode":"light","amount":10,"midpoint":0,"feather":0},"grain":{"amount":10,"size":0,"roughness":0}}}`))
if err != nil {
t.Fatalf("parse explicit zero: %v", err)
}
if p.Effects.Vignette.Midpoint != 0 || p.Effects.Vignette.Feather != 0 {
t.Fatalf("explicit zero vignette geometry was defaulted: %+v", p.Effects.Vignette)
}
if p.Effects.Grain.Size != 0 || p.Effects.Grain.Roughness != 0 {
t.Fatalf("explicit zero grain geometry was defaulted: %+v", p.Effects.Grain)
}
omitted, err := ParseEditParams([]byte(`{"effects":{"vignette":{"amount":10},"grain":{"amount":10}}}`))
if err != nil {
t.Fatalf("parse omitted: %v", err)
}
if omitted.Effects.Vignette.Midpoint != DefaultVignetteMidpoint ||
omitted.Effects.Vignette.Feather != DefaultVignetteFeather ||
omitted.Effects.Grain.Size != DefaultGrainSize ||
omitted.Effects.Grain.Roughness != DefaultGrainRoughness {
t.Fatalf("omitted geometry was not defaulted: %+v", omitted.Effects)
}
})
t.Run("json", func(t *testing.T) {
p := EditParams{Effects: EffectsParams{Dehaze: 10}}
data := p.JSON()
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := raw["effects"]; !ok {
t.Fatalf("effects key missing from %s", data)
}
var effects EffectsParams
if err := json.Unmarshal(raw["effects"], &effects); err != nil {
t.Fatalf("unmarshal effects: %v", err)
}
if effects.Dehaze != 10 {
t.Fatalf("dehaze = %v, want 10", effects.Dehaze)
}
var body string
if err := json.Unmarshal(data, &body); err == nil {
t.Fatalf("unexpected string body %q", body)
}
})
t.Run("edit params count effects", func(t *testing.T) {
if (EditParams{Effects: EffectsParams{Texture: 1}}).IsEmpty() {
t.Fatal("params with texture must not be empty")
}
if (EditParams{Effects: EffectsParams{Vignette: VignetteParams{Mode: VignetteColor}}}).IsEmpty() != true {
t.Fatal("vignette defaults without strength must count as empty")
}
})
}
func TestEditParamsLegacy(t *testing.T) {
empty, err := ParseEditParams([]byte(`{}`))
if err != nil {
t.Fatalf("parse empty: %v", err)
}
if !empty.IsEmpty() || !empty.Effects.IsEmpty() {
t.Fatalf("empty params = %+v", empty)
}
if empty.Effects.Vignette.Midpoint != DefaultVignetteMidpoint ||
empty.Effects.Vignette.Color != DefaultVignetteColor ||
empty.Effects.Grain.Size != DefaultGrainSize {
t.Fatalf("legacy defaults not filled: %+v", empty.Effects)
}
legacy, err := ParseEditParams([]byte(`{"light":{"exposure":0.4},"color":{"temperature":15,"saturation":10}}`))
if err != nil {
t.Fatalf("parse legacy: %v", err)
}
if legacy.IsEmpty() {
t.Fatal("legacy light/color params must not be empty")
}
if !legacy.Effects.IsEmpty() {
t.Fatalf("params without effects key read as effects: %+v", legacy.Effects)
}
// The old pipeline (light, then color) renders exactly the same pixels when
// the parameters carry no effects.
src := gradientImage(16, 12)
data := mustEncodePNG(t, src)
got, err := decodeAndAdjust(data, legacy, editSeed("legacy"))
if err != nil {
t.Fatalf("decode and adjust: %v", err)
}
decoded, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
t.Fatalf("decode: %v", err)
}
want := applyColor(applyLight(decoded, legacy.Light), legacy.Color)
if !bytes.Equal(got.Pix, want.Pix) {
t.Fatal("legacy render changed when effects are absent")
}
// An untouched photo decodes to the original pixels.
plain, err := decodeAndAdjust(data, EditParams{}, editSeed("legacy"))
if err != nil {
t.Fatalf("decode and adjust untouched: %v", err)
}
if !bytes.Equal(plain.Pix, imaging.Clone(decoded).Pix) {
t.Fatal("empty params changed the original")
}
}
func TestBoxBlur(t *testing.T) {
if got := boxBlur([]uint8{1, 2, 3, 4}, 2, 2, 0); !bytes.Equal(got, []uint8{1, 2, 3, 4}) {
t.Fatalf("radius 0 = %v, want identity", got)
}
constant := make([]uint8, 9*7)
for i := range constant {
constant[i] = 77
}
blurred := boxBlur3(constant, 9, 7, 3)
for i, v := range blurred {
if v != 77 {
t.Fatalf("constant image changed at %d: %d", i, v)
}
}
w, h := 13, 9
src := make([]uint8, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src[y*w+x] = uint8((x*37 + y*61) % 256)
}
}
for _, radius := range []int{1, 2, 5} {
if want, got := naiveBoxPass(src, w, h, radius), boxBlur(src, w, h, radius); !bytes.Equal(got, want) {
t.Fatalf("radius %d: boxBlur = %v, want %v", radius, got, want)
}
want := src
for i := 0; i < 3; i++ {
want = naiveBoxPass(want, w, h, radius)
}
if got := boxBlur3(src, w, h, radius); !bytes.Equal(got, want) {
t.Fatalf("radius %d: boxBlur3 = %v, want %v", radius, got, want)
}
}
// A one-pixel image is a fixed point of any radius.
if got := boxBlur3([]uint8{42}, 1, 1, 4); len(got) != 1 || got[0] != 42 {
t.Fatalf("1x1 blur = %v, want [42]", got)
}
}
// naiveBoxPass is the reference implementation of one box-blur pass: the
// window is sampled explicitly with edge-clamped coordinates.
func naiveBoxPass(src []uint8, w, h, radius int) []uint8 {
window := 2*radius + 1
tmp := make([]uint8, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sum := 0
for kx := -radius; kx <= radius; kx++ {
sum += int(src[y*w+clampInt(x+kx, 0, w-1)])
}
tmp[y*w+x] = uint8((sum + window/2) / window)
}
}
out := make([]uint8, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sum := 0
for ky := -radius; ky <= radius; ky++ {
sum += int(tmp[clampInt(y+ky, 0, h-1)*w+x])
}
out[y*w+x] = uint8((sum + window/2) / window)
}
}
return out
}
func TestTextureClarity(t *testing.T) {
src := checkerImage(64, 64, 96, 160)
base := lumaLocalVariance(src)
for _, effect := range []string{"texture", "clarity"} {
up := applyEffects(imaging.Clone(src), strengths(effect, 100), 1)
down := applyEffects(imaging.Clone(src), strengths(effect, -100), 1)
if got := lumaLocalVariance(up); got <= base {
t.Fatalf("%s +100: local variance %v must grow above %v", effect, got, base)
}
if got := lumaLocalVariance(down); got >= base {
t.Fatalf("%s -100: local variance %v must drop below %v", effect, got, base)
}
}
}
// lumaLocalVariance is the average variance of the luminance inside a 3x3
// window: the property that texture and clarity change.
func lumaLocalVariance(img *image.NRGBA) float64 {
plane := lumaPlane(img)
w, h := img.Rect.Dx(), img.Rect.Dy()
var total float64
count := 0
for y := 1; y < h-1; y++ {
for x := 1; x < w-1; x++ {
var sum, sumSq float64
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
v := float64(plane[(y+dy)*w+x+dx])
sum += v
sumSq += v * v
}
}
mean := sum / 9
total += sumSq/9 - mean*mean
count++
}
}
return total / float64(count)
}
// strengths builds a single-effect parameter block.
func strengths(effect string, value float64) EffectsParams {
switch effect {
case "texture":
return EffectsParams{Texture: value}
case "clarity":
return EffectsParams{Clarity: value}
}
return EffectsParams{}
}
func TestDehaze(t *testing.T) {
clear := hazyScene(128, 96)
hazy := addHaze(clear, 0.45, 235)
baseContrast := lumaStdDev(hazy)
baseSaturation := meanSaturation(hazy)
removed := applyEffects(imaging.Clone(hazy), EffectsParams{Dehaze: 100}, 1)
t.Logf("contrast %.2f -> %.2f, saturation %.2f -> %.2f",
baseContrast, lumaStdDev(removed), baseSaturation, meanSaturation(removed))
if got := lumaStdDev(removed); got <= baseContrast {
t.Fatalf("dehaze +100: contrast %v did not grow above %v", got, baseContrast)
}
if got := meanSaturation(removed); got <= baseSaturation {
t.Fatalf("dehaze +100: saturation %v did not grow above %v", got, baseSaturation)
}
clearContrast := lumaStdDev(clear)
clearSaturation := meanSaturation(clear)
added := applyEffects(imaging.Clone(clear), EffectsParams{Dehaze: -100}, 1)
t.Logf("clear contrast %.2f -> %.2f, saturation %.2f -> %.2f",
clearContrast, lumaStdDev(added), clearSaturation, meanSaturation(added))
if got := lumaStdDev(added); got >= clearContrast {
t.Fatalf("dehaze -100: contrast %v did not drop below %v", got, clearContrast)
}
if got := meanSaturation(added); got >= clearSaturation {
t.Fatalf("dehaze -100: saturation %v did not drop below %v", got, clearSaturation)
}
}
func TestResizeTransmission(t *testing.T) {
// A vertical step map: the top half is dark, the bottom half is bright.
sw, sh := 16, 12
transmission := make([]uint8, sw*sh)
for y := 0; y < sh; y++ {
for x := 0; x < sw; x++ {
if y < sh/2 {
transmission[y*sw+x] = 20
} else {
transmission[y*sw+x] = 230
}
}
}
gray := image.NewGray(image.Rect(0, 0, sw, sh))
copy(gray.Pix, transmission)
out := resizeTransmission(gray, 128, 96)
if len(out) != 128*96 {
t.Fatalf("resized map length = %d, want %d", len(out), 128*96)
}
// The transition band is blurred by the interpolation, so the edges are
// checked away from the step.
for y := 0; y < 96; y++ {
if y >= 40 && y < 56 {
continue
}
want := uint8(20)
if y >= 56 {
want = 230
}
for x := 0; x < 128; x++ {
if got := out[y*128+x]; got < want-4 || got > want+4 {
t.Fatalf("pixel %d,%d = %d, want around %d", x, y, got, want)
}
}
}
}
func TestVignette(t *testing.T) {
src := solidImage(64, 64, 128)
const centerX, centerY = 32, 32
const cornerX, cornerY = 0, 0
t.Run("light mode", func(t *testing.T) {
dark := applyEffects(imaging.Clone(src), EffectsParams{
Vignette: VignetteParams{Mode: VignetteLight, Amount: 80},
}, 0)
if pixelAt(dark, centerX, centerY) != 128 {
t.Fatalf("center changed: %d", pixelAt(dark, centerX, centerY))
}
if pixelAt(dark, cornerX, cornerY) >= 128 {
t.Fatalf("positive amount did not darken the corner: %d", pixelAt(dark, cornerX, cornerY))
}
bright := applyEffects(imaging.Clone(src), EffectsParams{
Vignette: VignetteParams{Mode: VignetteLight, Amount: -80},
}, 0)
if pixelAt(bright, centerX, centerY) != 128 {
t.Fatalf("center changed: %d", pixelAt(bright, centerX, centerY))
}
if pixelAt(bright, cornerX, cornerY) <= 128 {
t.Fatalf("negative amount did not brighten the corner: %d", pixelAt(bright, cornerX, cornerY))
}
})
t.Run("zero amount is identity", func(t *testing.T) {
out := applyEffects(imaging.Clone(src), EffectsParams{
Vignette: VignetteParams{Mode: VignetteColor, Amount: 0, Color: "#ff0000"},
}, 0)
if !bytes.Equal(out.Pix, src.Pix) {
t.Fatal("zero vignette changed the image")
}
})
t.Run("color mode tints the edges", func(t *testing.T) {
out := applyEffects(imaging.Clone(src), EffectsParams{
Vignette: VignetteParams{Mode: VignetteColor, Amount: 100, Color: "#ff0000"},
}, 0)
r, g, b := rgbAt(out, cornerX, cornerY)
if !(r > g && r > b) {
t.Fatalf("color vignette corner = %d/%d/%d, want a red tint", r, g, b)
}
if r, g, b := rgbAt(out, centerX, centerY); r != 128 || g != 128 || b != 128 {
t.Fatalf("color vignette changed the center: %d/%d/%d", r, g, b)
}
})
t.Run("colorize mode fills the edges", func(t *testing.T) {
out := applyEffects(imaging.Clone(src), EffectsParams{
Vignette: VignetteParams{Mode: VignetteColorize, Amount: 100, Color: "#ff0000"},
}, 0)
if r, g, b := rgbAt(out, cornerX, cornerY); r != 255 || g != 0 || b != 0 {
t.Fatalf("colorize corner = %d/%d/%d, want 255/0/0", r, g, b)
}
if r, g, b := rgbAt(out, centerX, centerY); r != 128 || g != 128 || b != 128 {
t.Fatalf("colorize changed the center: %d/%d/%d", r, g, b)
}
})
t.Run("highlight protection", func(t *testing.T) {
bright := solidImage(64, 64, 240)
plain := applyEffects(imaging.Clone(bright), EffectsParams{
Vignette: VignetteParams{Mode: VignetteLight, Amount: 100},
}, 0)
protected := applyEffects(imaging.Clone(bright), EffectsParams{
Vignette: VignetteParams{Mode: VignetteLight, Amount: 100, Highlights: 100},
}, 0)
if pixelAt(protected, cornerX, cornerY) <= pixelAt(plain, cornerX, cornerY) {
t.Fatalf("highlight protection did not preserve the corner: %d <= %d",
pixelAt(protected, cornerX, cornerY), pixelAt(plain, cornerX, cornerY))
}
})
}
func TestGrainDeterminism(t *testing.T) {
src := gradientImage(64, 64)
grain := EffectsParams{Grain: GrainParams{Amount: 70, Size: 30, Roughness: 60}}
first := applyEffects(imaging.Clone(src), grain, 12345)
second := applyEffects(imaging.Clone(src), grain, 12345)
if !bytes.Equal(first.Pix, second.Pix) {
t.Fatal("two renders with the same parameters and seed differ")
}
if bytes.Equal(first.Pix, src.Pix) {
t.Fatal("grain did not change the image")
}
other := applyEffects(imaging.Clone(src), grain, 999)
if bytes.Equal(first.Pix, other.Pix) {
t.Fatal("a different seed produced the same grain")
}
zero := applyEffects(imaging.Clone(src), EffectsParams{
Grain: GrainParams{Amount: 0, Size: 30, Roughness: 60},
}, 12345)
if !bytes.Equal(zero.Pix, src.Pix) {
t.Fatal("zero grain strength changed the image")
}
// The full render path seeds the same noise from the photo id.
data := mustEncodePNG(t, src)
a, err := decodeAndAdjust(data, EditParams{Effects: grain}, editSeed("photo-1"))
if err != nil {
t.Fatalf("decode and adjust: %v", err)
}
b, err := decodeAndAdjust(data, EditParams{Effects: grain}, editSeed("photo-1"))
if err != nil {
t.Fatalf("decode and adjust again: %v", err)
}
if !bytes.Equal(a.Pix, b.Pix) {
t.Fatal("repeated decodeAndAdjust renders differ")
}
}
func TestEffectsKeepTransparentPixels(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 32, 32))
for i := 0; i < len(src.Pix); i += 4 {
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 200, 10, 30, 0
}
before := append([]uint8(nil), src.Pix...)
out := applyEffects(src, EffectsParams{
Texture: 60,
Clarity: 60,
Dehaze: 60,
Vignette: VignetteParams{
Mode: VignetteColorize, Amount: 100, Color: "#00ff00",
},
Grain: GrainParams{Amount: 100, Size: 40, Roughness: 80},
}, 7)
if !bytes.Equal(out.Pix, before) {
t.Fatal("effects changed transparent pixels")
}
}
// solidImage returns a uniform opaque image.
func solidImage(w, h int, value uint8) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for i := 0; i < len(img.Pix); i += 4 {
img.Pix[i] = value
img.Pix[i+1] = value
img.Pix[i+2] = value
img.Pix[i+3] = 255
}
return img
}
// gradientImage returns an opaque image with smooth horizontal and vertical
// variation, so every effect has something to work with.
func gradientImage(w, h int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*img.Stride + x*4
img.Pix[i] = uint8(20 + x*3)
img.Pix[i+1] = uint8(30 + y*3)
img.Pix[i+2] = uint8(40 + (x + y))
img.Pix[i+3] = 255
}
}
return img
}
// checkerImage returns a fine checkerboard, the high-frequency input of the
// local-contrast tests.
func checkerImage(w, h int, dark, light uint8) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*img.Stride + x*4
v := dark
if (x+y)%2 == 0 {
v = light
}
img.Pix[i] = v
img.Pix[i+1] = v
img.Pix[i+2] = v
img.Pix[i+3] = 255
}
}
return img
}
// hazyScene builds a synthetic landscape: a bright sky band and a colored
// checker with a brightness gradient. It represents the "clear" frame.
func hazyScene(w, h int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
sky := h / 6
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*img.Stride + x*4
if y < sky {
img.Pix[i], img.Pix[i+1], img.Pix[i+2] = 235, 235, 235
img.Pix[i+3] = 255
continue
}
t := float64(x) / float64(w-1)
if (x/16+y/16)%2 == 0 {
img.Pix[i] = uint8(40 + 180*t)
img.Pix[i+1] = uint8(40 + 60*t)
img.Pix[i+2] = uint8(40 + 40*t)
} else {
img.Pix[i] = uint8(40 + 40*t)
img.Pix[i+1] = uint8(40 + 120*t)
img.Pix[i+2] = uint8(40 + 180*t)
}
img.Pix[i+3] = 255
}
}
return img
}
// addHaze mixes the frame toward an atmospheric color.
func addHaze(src *image.NRGBA, haze float64, atmosphere uint8) *image.NRGBA {
out := imaging.Clone(src)
w, h := out.Rect.Dx(), out.Rect.Dy()
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*out.Stride + x*4
for c := 0; c < 3; c++ {
out.Pix[i+c] = uint8(float64(out.Pix[i+c])*(1-haze) + float64(atmosphere)*haze + 0.5)
}
}
}
return out
}
// lumaVariance is the global variance of the luminance.
func lumaVariance(img *image.NRGBA) float64 {
plane := lumaPlane(img)
var sum float64
for _, v := range plane {
sum += float64(v)
}
mean := sum / float64(len(plane))
var variance float64
for _, v := range plane {
d := float64(v) - mean
variance += d * d
}
return variance / float64(len(plane))
}
// lumaStdDev is the contrast measure of the dehaze test.
func lumaStdDev(img *image.NRGBA) float64 {
return math.Sqrt(lumaVariance(img))
}
// meanSaturation is the average channel spread of the image.
func meanSaturation(img *image.NRGBA) float64 {
w, h := img.Rect.Dx(), img.Rect.Dy()
var sum float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*img.Stride + x*4
mx := max(img.Pix[i], max(img.Pix[i+1], img.Pix[i+2]))
mn := min(img.Pix[i], min(img.Pix[i+1], img.Pix[i+2]))
sum += float64(mx - mn)
}
}
return sum / float64(w*h)
}
func pixelAt(img *image.NRGBA, x, y int) uint8 {
return img.Pix[y*img.Stride+x*4]
}
func rgbAt(img *image.NRGBA, x, y int) (uint8, uint8, uint8) {
i := y*img.Stride + x*4
return img.Pix[i], img.Pix[i+1], img.Pix[i+2]
}