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] }