- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
709 lines
22 KiB
Go
709 lines
22 KiB
Go
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]
|
|
}
|