feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Edit parameters are stored in the photo metadata and describe a
|
||||
@@ -15,12 +16,12 @@ import (
|
||||
// ranges mirror the ones Lightroom exposes: exposure in stops, everything else
|
||||
// in percent.
|
||||
type LightParams struct {
|
||||
Exposure float64 `json:"exposure"`
|
||||
Contrast float64 `json:"contrast"`
|
||||
Highlights float64 `json:"highlights"`
|
||||
Shadows float64 `json:"shadows"`
|
||||
Whites float64 `json:"whites"`
|
||||
Blacks float64 `json:"blacks"`
|
||||
Exposure float64 `json:"exposure"`
|
||||
Contrast float64 `json:"contrast"`
|
||||
Highlights float64 `json:"highlights"`
|
||||
Shadows float64 `json:"shadows"`
|
||||
Whites float64 `json:"whites"`
|
||||
Blacks float64 `json:"blacks"`
|
||||
Curve CurveParams `json:"curve"`
|
||||
}
|
||||
|
||||
@@ -95,6 +96,140 @@ type ColorCorrection struct {
|
||||
Blue float64 `json:"blue"`
|
||||
}
|
||||
|
||||
// EffectsParams holds the parameters of the "Эффекты" (effects) tool: texture,
|
||||
// clarity, dehaze, vignette and grain. It is applied after the light and color
|
||||
// adjustments, because it works on the already corrected frame.
|
||||
type EffectsParams struct {
|
||||
Texture float64 `json:"texture"`
|
||||
Clarity float64 `json:"clarity"`
|
||||
Dehaze float64 `json:"dehaze"`
|
||||
Vignette VignetteParams `json:"vignette"`
|
||||
Grain GrainParams `json:"grain"`
|
||||
}
|
||||
|
||||
// VignetteParams holds the parameters of the "Виньетка" tool. The mode picks
|
||||
// the way the edges are treated ("light" darkens or brightens them, "color"
|
||||
// tints them and "colorize" fills them with the chosen color), amount is the
|
||||
// signed strength in percent, and the rest controls the geometry of the effect.
|
||||
//
|
||||
// midpointSet and featherSet record whether the client sent the field: an
|
||||
// explicit zero is a valid geometry value and must not be replaced by the
|
||||
// default, while an omitted field gets the default.
|
||||
type VignetteParams struct {
|
||||
Mode string `json:"mode"`
|
||||
Amount float64 `json:"amount"`
|
||||
Midpoint float64 `json:"midpoint"`
|
||||
Feather float64 `json:"feather"`
|
||||
Roundness float64 `json:"roundness"`
|
||||
Highlights float64 `json:"highlights"`
|
||||
Color string `json:"color"`
|
||||
|
||||
midpointSet bool
|
||||
featherSet bool
|
||||
}
|
||||
|
||||
// UnmarshalJSON remembers which defaultable fields the client actually sent.
|
||||
func (v *VignetteParams) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Mode *string `json:"mode"`
|
||||
Amount *float64 `json:"amount"`
|
||||
Midpoint *float64 `json:"midpoint"`
|
||||
Feather *float64 `json:"feather"`
|
||||
Roundness *float64 `json:"roundness"`
|
||||
Highlights *float64 `json:"highlights"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = VignetteParams{}
|
||||
if raw.Mode != nil {
|
||||
v.Mode = *raw.Mode
|
||||
}
|
||||
if raw.Amount != nil {
|
||||
v.Amount = *raw.Amount
|
||||
}
|
||||
if raw.Midpoint != nil {
|
||||
v.Midpoint = *raw.Midpoint
|
||||
v.midpointSet = true
|
||||
}
|
||||
if raw.Feather != nil {
|
||||
v.Feather = *raw.Feather
|
||||
v.featherSet = true
|
||||
}
|
||||
if raw.Roundness != nil {
|
||||
v.Roundness = *raw.Roundness
|
||||
}
|
||||
if raw.Highlights != nil {
|
||||
v.Highlights = *raw.Highlights
|
||||
}
|
||||
if raw.Color != nil {
|
||||
v.Color = *raw.Color
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrainParams holds the parameters of the "Зерно" tool. Amount is the strength,
|
||||
// size the scale of the noise cells, roughness the mix of fine and smooth
|
||||
// noise.
|
||||
//
|
||||
// sizeSet and roughnessSet record whether the client sent the field: an
|
||||
// explicit zero is a valid value and must not be replaced by the default.
|
||||
type GrainParams struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Size float64 `json:"size"`
|
||||
Roughness float64 `json:"roughness"`
|
||||
|
||||
sizeSet bool
|
||||
roughnessSet bool
|
||||
}
|
||||
|
||||
// UnmarshalJSON remembers which defaultable fields the client actually sent.
|
||||
func (g *GrainParams) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Amount *float64 `json:"amount"`
|
||||
Size *float64 `json:"size"`
|
||||
Roughness *float64 `json:"roughness"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*g = GrainParams{}
|
||||
if raw.Amount != nil {
|
||||
g.Amount = *raw.Amount
|
||||
}
|
||||
if raw.Size != nil {
|
||||
g.Size = *raw.Size
|
||||
g.sizeSet = true
|
||||
}
|
||||
if raw.Roughness != nil {
|
||||
g.Roughness = *raw.Roughness
|
||||
g.roughnessSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Modes of the "Виньетка" tool. Any other mode is normalized to VignetteLight.
|
||||
const (
|
||||
VignetteLight = "light"
|
||||
VignetteColor = "color"
|
||||
VignetteColorize = "colorize"
|
||||
)
|
||||
|
||||
// Structural defaults of the effects. They are filled by Normalized, so the
|
||||
// zero value and an explicit "defaults" request mean the same thing and a photo
|
||||
// without the "effects" key reads as untouched.
|
||||
const (
|
||||
DefaultVignetteMidpoint = 50.0
|
||||
DefaultVignetteFeather = 50.0
|
||||
DefaultGrainSize = 25.0
|
||||
DefaultGrainRoughness = 50.0
|
||||
)
|
||||
|
||||
// DefaultVignetteColor is the color used when the request carries no usable
|
||||
// hex color.
|
||||
const DefaultVignetteColor = "#000000"
|
||||
|
||||
// mixerBand is a color band with its hue center in degrees.
|
||||
type mixerBand struct {
|
||||
center float64
|
||||
@@ -124,8 +259,9 @@ func (g ColorGrading) wheels() [3]ColorWheel {
|
||||
|
||||
// EditParams is the full set of non-destructive editing parameters of a photo.
|
||||
type EditParams struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
Effects EffectsParams `json:"effects"`
|
||||
}
|
||||
|
||||
// Limits of the editing parameters. They are enforced on write so a hand-made
|
||||
@@ -167,6 +303,7 @@ func (p EditParams) Normalized() EditParams {
|
||||
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
|
||||
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
|
||||
p.Color = p.Color.Normalized()
|
||||
p.Effects = p.Effects.Normalized()
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -214,6 +351,92 @@ func (c ColorParams) Normalized() ColorParams {
|
||||
return c
|
||||
}
|
||||
|
||||
// Normalized returns a copy of the effects clamped to their ranges, with the
|
||||
// structural defaults filled in. Normalization is idempotent: applying it to an
|
||||
// already normalized value returns it unchanged, which keeps repeated saves and
|
||||
// reads stable.
|
||||
func (e EffectsParams) Normalized() EffectsParams {
|
||||
e.Texture = clampFloat(e.Texture, -MaxPercent, MaxPercent)
|
||||
e.Clarity = clampFloat(e.Clarity, -MaxPercent, MaxPercent)
|
||||
e.Dehaze = clampFloat(e.Dehaze, -MaxPercent, MaxPercent)
|
||||
e.Vignette = e.Vignette.Normalized()
|
||||
e.Grain = e.Grain.Normalized()
|
||||
return e
|
||||
}
|
||||
|
||||
// Normalized returns a copy of the vignette parameters clamped to their ranges.
|
||||
// The midpoint and feather defaults are applied only when the client omitted the
|
||||
// field; an explicit zero stays zero. Clamping happens before defaulting so the
|
||||
// operation stays idempotent.
|
||||
func (v VignetteParams) Normalized() VignetteParams {
|
||||
v.Mode = normalizeVignetteMode(v.Mode)
|
||||
v.Amount = clampFloat(v.Amount, -MaxPercent, MaxPercent)
|
||||
v.Midpoint = clampFloat(v.Midpoint, 0, MaxPercent)
|
||||
if v.Midpoint == 0 && !v.midpointSet {
|
||||
v.Midpoint = DefaultVignetteMidpoint
|
||||
}
|
||||
v.Feather = clampFloat(v.Feather, 0, MaxPercent)
|
||||
if v.Feather == 0 && !v.featherSet {
|
||||
v.Feather = DefaultVignetteFeather
|
||||
}
|
||||
v.Roundness = clampFloat(v.Roundness, -MaxPercent, MaxPercent)
|
||||
v.Highlights = clampFloat(v.Highlights, 0, MaxPercent)
|
||||
v.Color = normalizeHexColor(v.Color)
|
||||
return v
|
||||
}
|
||||
|
||||
// Normalized returns a copy of the grain parameters clamped to their ranges.
|
||||
// The size and roughness defaults are applied only when the client omitted the
|
||||
// field; an explicit zero stays zero.
|
||||
func (g GrainParams) Normalized() GrainParams {
|
||||
g.Amount = clampFloat(g.Amount, 0, MaxPercent)
|
||||
g.Size = clampFloat(g.Size, 0, MaxPercent)
|
||||
if g.Size == 0 && !g.sizeSet {
|
||||
g.Size = DefaultGrainSize
|
||||
}
|
||||
g.Roughness = clampFloat(g.Roughness, 0, MaxPercent)
|
||||
if g.Roughness == 0 && !g.roughnessSet {
|
||||
g.Roughness = DefaultGrainRoughness
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the effects leave the image untouched. Only the
|
||||
// strengths count: the structural defaults (mode, midpoint, feather, size,
|
||||
// roughness, color) do not turn into an edit by themselves.
|
||||
func (e EffectsParams) IsEmpty() bool {
|
||||
return e.Texture == 0 && e.Clarity == 0 && e.Dehaze == 0 &&
|
||||
e.Vignette.Amount == 0 && e.Grain.Amount == 0
|
||||
}
|
||||
|
||||
// normalizeVignetteMode maps an unknown or empty mode to the light one.
|
||||
func normalizeVignetteMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case VignetteColor:
|
||||
return VignetteColor
|
||||
case VignetteColorize:
|
||||
return VignetteColorize
|
||||
default:
|
||||
return VignetteLight
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeHexColor returns the lower-case "#rrggbb" form of a valid color and
|
||||
// the default black for anything unusable.
|
||||
func normalizeHexColor(color string) string {
|
||||
color = strings.TrimSpace(color)
|
||||
if len(color) != 7 || color[0] != '#' {
|
||||
return DefaultVignetteColor
|
||||
}
|
||||
for i := 1; i < len(color); i++ {
|
||||
c := color[i]
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
return DefaultVignetteColor
|
||||
}
|
||||
}
|
||||
return strings.ToLower(color)
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the parameters describe an untouched photo.
|
||||
func (p EditParams) IsEmpty() bool {
|
||||
l := p.Light
|
||||
@@ -228,7 +451,7 @@ func (p EditParams) IsEmpty() bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.Color.IsEmpty()
|
||||
return p.Color.IsEmpty() && p.Effects.IsEmpty()
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the color parameters leave the image untouched.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +31,73 @@ func BenchmarkApplyLight(b *testing.B) {
|
||||
b.Logf("кадров: %d", b.N)
|
||||
}
|
||||
|
||||
// BenchmarkBoxBlur3 measures the O(n) box-blur passes on a 12 MP luminance
|
||||
// plane with the radius the clarity pass uses for that frame.
|
||||
func BenchmarkBoxBlur3(b *testing.B) {
|
||||
w, h := 4000, 3000
|
||||
src := make([]uint8, w*h)
|
||||
for i := range src {
|
||||
src[i] = uint8(i % 251)
|
||||
}
|
||||
radius := int(0.008 * float64(min(w, h)))
|
||||
if radius < 2 {
|
||||
radius = 2
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = boxBlur3(src, w, h, radius)
|
||||
}
|
||||
b.StopTimer()
|
||||
b.Logf("кадров: %d, радиус: %d", b.N, radius)
|
||||
}
|
||||
|
||||
// BenchmarkFullRenderWithEffects measures the whole edited render of a 12 MP
|
||||
// photo with all effects enabled: decode, light, color, dehaze, texture,
|
||||
// clarity, vignette and grain. This is the work the background worker does for
|
||||
// one edit job.
|
||||
func BenchmarkFullRenderWithEffects(b *testing.B) {
|
||||
w, h := 4000, 3000
|
||||
src := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*src.Stride + x*4
|
||||
src.Pix[i] = uint8(x*255/w) ^ uint8(y*255/h)
|
||||
src.Pix[i+1] = uint8(x * 3)
|
||||
src.Pix[i+2] = uint8(y * 5)
|
||||
src.Pix[i+3] = 255
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
encoder := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||
if err := encoder.Encode(&buf, src); err != nil {
|
||||
b.Fatalf("encode png: %v", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
params := EditParams{
|
||||
Light: LightParams{Exposure: 0.2, Contrast: 12, Shadows: 15},
|
||||
Color: ColorParams{Vibrance: 20, Temperature: 8},
|
||||
Effects: EffectsParams{
|
||||
Texture: 30,
|
||||
Clarity: 25,
|
||||
Dehaze: 20,
|
||||
Vignette: VignetteParams{
|
||||
Mode: VignetteColor, Amount: 35, Color: "#ff8800", Highlights: 25,
|
||||
},
|
||||
Grain: GrainParams{Amount: 30, Size: 30, Roughness: 55},
|
||||
},
|
||||
}
|
||||
seed := editSeed("benchmark-photo")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := decodeAndAdjust(data, params, seed); err != nil {
|
||||
b.Fatalf("decode and adjust: %v", err)
|
||||
}
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -90,19 +90,24 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAndAdjust decodes the original and applies the light and color
|
||||
// decodeAndAdjust decodes the original and applies the light, color and effect
|
||||
// adjustments to it, so the variants and the full-size version share one
|
||||
// decode. Color is applied after light.
|
||||
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
|
||||
// decode. The passes run in a fixed order: light, color, then the effects
|
||||
// (dehaze, texture, clarity, vignette, grain). The seed makes the grain render
|
||||
// deterministic; it comes from the photo id.
|
||||
func decodeAndAdjust(data []byte, p EditParams, seed uint64) (*image.NRGBA, error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edited := applyLight(img, p.Light)
|
||||
if p.Color.IsEmpty() {
|
||||
return edited, nil
|
||||
if !p.Color.IsEmpty() {
|
||||
edited = applyColor(edited, p.Color)
|
||||
}
|
||||
return applyColor(edited, p.Color), nil
|
||||
if !p.Effects.IsEmpty() {
|
||||
edited = applyEffects(edited, p.Effects, seed)
|
||||
}
|
||||
return edited, nil
|
||||
}
|
||||
|
||||
// applyColor applies the color adjustments in place to an image that already
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestBuildEditedVariants(t *testing.T) {
|
||||
src.Pix[i+3] = 255
|
||||
}
|
||||
}
|
||||
edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}})
|
||||
edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}, editSeed("test-photo"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode and adjust: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
// Spatial effects of the non-destructive editor. They run after the light and
|
||||
// color adjustments, in a fixed order: dehaze, texture, clarity, vignette,
|
||||
// grain. Texture and clarity compute a correction from the luminance and add it
|
||||
// to all three channels equally, so hue and saturation are preserved; dehaze is
|
||||
// a global tonal correction based on the dark channel prior. Vignette and grain
|
||||
// are the final, "post-crop" operations.
|
||||
//
|
||||
// Everything here is deterministic: the grain seed comes from the photo id, so
|
||||
// a repeated render of the same parameters produces the same pixels.
|
||||
|
||||
// maxDehazeSide is the longest side of the reduced copy used to estimate the
|
||||
// atmosphere and the transmission map.
|
||||
const maxDehazeSide = 512
|
||||
|
||||
// dehazeOmega is the share of the haze the dark channel prior removes.
|
||||
const dehazeOmega = 0.95
|
||||
|
||||
// dehazeMinT bounds the transmission map from below, so the darkest regions do
|
||||
// not amplify noise without limit.
|
||||
const dehazeMinT = 0.1
|
||||
|
||||
// applyEffects applies the non-empty effect passes in the documented order and
|
||||
// returns the image owned by the caller.
|
||||
func applyEffects(dst *image.NRGBA, p EffectsParams, seed uint64) *image.NRGBA {
|
||||
if p.Dehaze != 0 {
|
||||
dst = applyDehaze(dst, p.Dehaze)
|
||||
}
|
||||
if p.Texture != 0 {
|
||||
applyTexture(dst, p.Texture)
|
||||
}
|
||||
if p.Clarity != 0 {
|
||||
applyClarity(dst, p.Clarity)
|
||||
}
|
||||
if p.Vignette.Amount != 0 {
|
||||
applyVignette(dst, p.Vignette)
|
||||
}
|
||||
if p.Grain.Amount != 0 {
|
||||
applyGrain(dst, p.Grain, seed)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// luminance returns the perceived brightness Y of an RGB pixel (each 0..255).
|
||||
func luminance(r, g, b float64) float64 {
|
||||
return 0.299*r + 0.587*g + 0.114*b
|
||||
}
|
||||
|
||||
// lumaPlane builds a Y plane of the image, one byte per pixel.
|
||||
func lumaPlane(img *image.NRGBA) []uint8 {
|
||||
w, h := img.Rect.Dx(), img.Rect.Dy()
|
||||
out := make([]uint8, w*h)
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for y := from; y < to; y++ {
|
||||
row := img.Pix[y*img.Stride : y*img.Stride+w*4]
|
||||
dst := out[y*w : (y+1)*w]
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
dst[x] = uint8(luminance(float64(row[i]), float64(row[i+1]), float64(row[i+2])) + 0.5)
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// parallelChunks splits n independent items (rows or columns) into chunks and
|
||||
// runs fn over each chunk, mirroring the row parallelism of applyLight and
|
||||
// applyColor.
|
||||
func parallelChunks(n int, fn func(from, to int)) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > n {
|
||||
workers = n
|
||||
}
|
||||
if workers <= 1 {
|
||||
fn(0, n)
|
||||
return
|
||||
}
|
||||
chunk := (n + workers - 1) / workers
|
||||
var wg sync.WaitGroup
|
||||
for from := 0; from < n; from += chunk {
|
||||
to := from + chunk
|
||||
if to > n {
|
||||
to = n
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(from, to int) {
|
||||
defer wg.Done()
|
||||
fn(from, to)
|
||||
}(from, to)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// boxBlur applies one separable box blur with edge-clamped borders. The running
|
||||
// window makes the cost independent of the radius. Edge pixels repeat the
|
||||
// nearest border value, so the size of the image never changes.
|
||||
func boxBlur(src []uint8, w, h, radius int) []uint8 {
|
||||
if w <= 0 || h <= 0 || len(src) < w*h {
|
||||
return nil
|
||||
}
|
||||
out := make([]uint8, w*h)
|
||||
if radius <= 0 {
|
||||
copy(out, src[:w*h])
|
||||
return out
|
||||
}
|
||||
tmp := make([]uint8, w*h)
|
||||
blurHorizontal(src, tmp, w, h, radius)
|
||||
blurVertical(tmp, out, w, h, radius)
|
||||
return out
|
||||
}
|
||||
|
||||
// boxBlur3 approximates a Gaussian blur with three box passes.
|
||||
func boxBlur3(src []uint8, w, h, radius int) []uint8 {
|
||||
out := boxBlur(src, w, h, radius)
|
||||
out = boxBlur(out, w, h, radius)
|
||||
return boxBlur(out, w, h, radius)
|
||||
}
|
||||
|
||||
// blurHorizontal is one horizontal box-blur pass with a sliding window.
|
||||
func blurHorizontal(src, dst []uint8, w, h, radius int) {
|
||||
window := 2*radius + 1
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for y := from; y < to; y++ {
|
||||
row := src[y*w : (y+1)*w]
|
||||
out := dst[y*w : (y+1)*w]
|
||||
var sum int
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += int(row[clampInt(k, 0, w-1)])
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
out[x] = uint8((sum + window/2) / window)
|
||||
sum -= int(row[clampInt(x-radius, 0, w-1)])
|
||||
sum += int(row[clampInt(x+radius+1, 0, w-1)])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// blurVertical is one vertical box-blur pass with a sliding window. Columns are
|
||||
// independent, so they are split into chunks just like rows.
|
||||
func blurVertical(src, dst []uint8, w, h, radius int) {
|
||||
window := 2*radius + 1
|
||||
parallelChunks(w, func(from, to int) {
|
||||
for x := from; x < to; x++ {
|
||||
var sum int
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += int(src[clampInt(k, 0, h-1)*w+x])
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
dst[y*w+x] = uint8((sum + window/2) / window)
|
||||
sum -= int(src[clampInt(y-radius, 0, h-1)*w+x])
|
||||
sum += int(src[clampInt(y+radius+1, 0, h-1)*w+x])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// addLumaDelta adds the same delta to the three color channels of one pixel.
|
||||
// Transparent pixels are left untouched by the callers.
|
||||
func addLumaDelta(row []uint8, i int, delta float64) {
|
||||
row[i] = clampChannel(float64(row[i]) + delta)
|
||||
row[i+1] = clampChannel(float64(row[i+1]) + delta)
|
||||
row[i+2] = clampChannel(float64(row[i+2]) + delta)
|
||||
}
|
||||
|
||||
// clampChannel rounds and clamps a channel value to 0..255.
|
||||
func clampChannel(v float64) uint8 {
|
||||
return uint8(clampFloat(v+0.5, 0, 255))
|
||||
}
|
||||
|
||||
// applyTexture is an unsharp mask with a small radius: it boosts (or, for a
|
||||
// negative amount, smooths) the high-frequency detail of the luminance.
|
||||
func applyTexture(dst *image.NRGBA, amount float64) {
|
||||
w, h := dst.Rect.Dx(), dst.Rect.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return
|
||||
}
|
||||
y := lumaPlane(dst)
|
||||
radius := int(math.Round(0.0015 * float64(min(w, h))))
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
}
|
||||
blur := boxBlur3(y, w, h, radius)
|
||||
k := amount / 100
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
base := row * w
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
delta := k * (float64(y[base+x]) - float64(blur[base+x]))
|
||||
addLumaDelta(pix, i, delta)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyClarity is an unsharp mask with a large radius. The correction is
|
||||
// limited to +-40 levels and weighted by the midtone mask, which keeps the
|
||||
// edges of contrasty objects from ringing.
|
||||
func applyClarity(dst *image.NRGBA, amount float64) {
|
||||
w, h := dst.Rect.Dx(), dst.Rect.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return
|
||||
}
|
||||
y := lumaPlane(dst)
|
||||
radius := int(math.Round(0.008 * float64(min(w, h))))
|
||||
if radius < 2 {
|
||||
radius = 2
|
||||
}
|
||||
blur := boxBlur3(y, w, h, radius)
|
||||
k := amount / 100 * 1.25
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
base := row * w
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
yVal := float64(y[base+x])
|
||||
d := clampFloat(yVal-float64(blur[base+x]), -40, 40)
|
||||
t := math.Abs(2*yVal/255 - 1)
|
||||
weight := 1 - 0.6*t*t
|
||||
addLumaDelta(pix, i, k*d*weight)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyDehaze removes (positive amount) or adds (negative amount) haze with the
|
||||
// dark channel prior. The atmosphere and the transmission map are estimated on
|
||||
// a reduced copy, then the map is interpolated back to the full frame.
|
||||
func applyDehaze(dst *image.NRGBA, amount float64) *image.NRGBA {
|
||||
w, h := dst.Rect.Dx(), dst.Rect.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return dst
|
||||
}
|
||||
small := dst
|
||||
sw, sh := w, h
|
||||
if max(w, h) > maxDehazeSide {
|
||||
scale := float64(maxDehazeSide) / float64(max(w, h))
|
||||
sw = max(1, int(math.Round(float64(w)*scale)))
|
||||
sh = max(1, int(math.Round(float64(h)*scale)))
|
||||
small = imaging.Resize(dst, sw, sh, imaging.Linear)
|
||||
}
|
||||
sw, sh = small.Rect.Dx(), small.Rect.Dy()
|
||||
|
||||
dark := darkChannel(small)
|
||||
atmosphere := atmosphericLight(small, dark)
|
||||
|
||||
tMap := make([]uint8, sw*sh)
|
||||
for y := 0; y < sh; y++ {
|
||||
row := small.Pix[y*small.Stride : y*small.Stride+sw*4]
|
||||
for x := 0; x < sw; x++ {
|
||||
i := x * 4
|
||||
transmission := 1 - dehazeOmega*min(
|
||||
float64(row[i])/atmosphere[0],
|
||||
min(float64(row[i+1])/atmosphere[1], float64(row[i+2])/atmosphere[2]))
|
||||
transmission = clampFloat(transmission, dehazeMinT, 1)
|
||||
tMap[y*sw+x] = uint8(transmission*255 + 0.5)
|
||||
}
|
||||
}
|
||||
tRadius := int(math.Round(0.02 * float64(min(sw, sh))))
|
||||
if tRadius < 1 {
|
||||
tRadius = 1
|
||||
}
|
||||
tMap = boxBlur3(tMap, sw, sh, tRadius)
|
||||
|
||||
fullT := tMap
|
||||
if sw != w || sh != h {
|
||||
gray := image.NewGray(image.Rect(0, 0, sw, sh))
|
||||
copy(gray.Pix, tMap)
|
||||
fullT = resizeTransmission(gray, w, h)
|
||||
}
|
||||
|
||||
k := amount / 100
|
||||
if amount > 0 {
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
base := row * w
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
t := float64(fullT[base+x]) / 255
|
||||
if t < dehazeMinT {
|
||||
t = dehazeMinT
|
||||
}
|
||||
for c := 0; c < 3; c++ {
|
||||
in := float64(pix[i+c])
|
||||
clear := (in-atmosphere[c])/t + atmosphere[c]
|
||||
pix[i+c] = clampChannel(in + k*(clear-in))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return dst
|
||||
}
|
||||
|
||||
// Negative amount adds haze: the frame moves toward the atmosphere color
|
||||
// and loses contrast.
|
||||
haze := -k
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
for c := 0; c < 3; c++ {
|
||||
in := float64(pix[i+c])
|
||||
pix[i+c] = clampChannel(in*(1-0.75*haze) + atmosphere[c]*0.75*haze)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return dst
|
||||
}
|
||||
|
||||
// darkChannel returns the per-pixel minimum of the RGB channels.
|
||||
func darkChannel(img *image.NRGBA) []uint8 {
|
||||
w, h := img.Rect.Dx(), img.Rect.Dy()
|
||||
out := make([]uint8, w*h)
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for y := from; y < to; y++ {
|
||||
row := img.Pix[y*img.Stride : y*img.Stride+w*4]
|
||||
dst := out[y*w : (y+1)*w]
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
v := row[i]
|
||||
if row[i+1] < v {
|
||||
v = row[i+1]
|
||||
}
|
||||
if row[i+2] < v {
|
||||
v = row[i+2]
|
||||
}
|
||||
dst[x] = v
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// atmosphericLight estimates the color of the haze: the average RGB of the
|
||||
// pixels whose dark channel is above the 99.9th percentile, at least one pixel
|
||||
// and never below one per channel.
|
||||
func atmosphericLight(img *image.NRGBA, dark []uint8) [3]float64 {
|
||||
var atmosphere [3]float64
|
||||
if len(dark) == 0 {
|
||||
return [3]float64{1, 1, 1}
|
||||
}
|
||||
var hist [256]int
|
||||
maxDark := 0
|
||||
for _, v := range dark {
|
||||
hist[v]++
|
||||
if int(v) > maxDark {
|
||||
maxDark = int(v)
|
||||
}
|
||||
}
|
||||
target := int(math.Ceil(0.999 * float64(len(dark))))
|
||||
if target < 1 {
|
||||
target = 1
|
||||
}
|
||||
threshold := maxDark
|
||||
cumulative := 0
|
||||
for v := 0; v < 256; v++ {
|
||||
cumulative += hist[v]
|
||||
if cumulative >= target {
|
||||
threshold = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w, h := img.Rect.Dx(), img.Rect.Dy()
|
||||
var sum [3]float64
|
||||
count := 0
|
||||
add := func(x, y int) {
|
||||
i := y*img.Stride + x*4
|
||||
sum[0] += float64(img.Pix[i])
|
||||
sum[1] += float64(img.Pix[i+1])
|
||||
sum[2] += float64(img.Pix[i+2])
|
||||
count++
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if int(dark[y*w+x]) > threshold {
|
||||
add(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
// A uniform dark channel has no pixel strictly above the threshold:
|
||||
// the brightest pixel still is the best atmosphere estimate.
|
||||
found := false
|
||||
for y := 0; y < h && !found; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if int(dark[y*w+x]) == maxDark {
|
||||
add(x, y)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := 0; c < 3; c++ {
|
||||
atmosphere[c] = 1
|
||||
if count > 0 {
|
||||
atmosphere[c] = math.Max(1, sum[c]/float64(count))
|
||||
}
|
||||
}
|
||||
return atmosphere
|
||||
}
|
||||
|
||||
// resizeTransmission upsamples the gray transmission map to the full frame.
|
||||
// imaging.Resize returns an NRGBA image, so the gray value is the first byte of
|
||||
// every pixel; slicing its Pix by w*h would read only the first quarter of the
|
||||
// frame, hence the explicit per-pixel copy.
|
||||
func resizeTransmission(gray *image.Gray, w, h int) []uint8 {
|
||||
resized := imaging.Resize(gray, w, h, imaging.Linear)
|
||||
out := make([]uint8, w*h)
|
||||
for i := range out {
|
||||
out[i] = resized.Pix[i*4]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyVignette darkens, brightens, tints or fills the edges of the frame. The
|
||||
// geometry is normalized to the half-frame: roundness turns the ellipse into a
|
||||
// rounded rectangle, midpoint sets where the zone starts and feather sets how
|
||||
// soft the transition is.
|
||||
func applyVignette(dst *image.NRGBA, v VignetteParams) {
|
||||
w, h := dst.Rect.Dx(), dst.Rect.Dy()
|
||||
if w == 0 || h == 0 || v.Amount == 0 {
|
||||
return
|
||||
}
|
||||
exponent := 2 * math.Pow(6, v.Roundness/100)
|
||||
invExponent := 1 / exponent
|
||||
start := 0.15 + 0.85*v.Midpoint/100
|
||||
featherWidth := 0.02 + 0.48*v.Feather/100
|
||||
|
||||
xTerms := make([]float64, w)
|
||||
for x := 0; x < w; x++ {
|
||||
dx := (float64(x)+0.5)/float64(w)*2 - 1
|
||||
xTerms[x] = math.Pow(math.Abs(dx), exponent)
|
||||
}
|
||||
yTerms := make([]float64, h)
|
||||
for y := 0; y < h; y++ {
|
||||
dy := (float64(y)+0.5)/float64(h)*2 - 1
|
||||
yTerms[y] = math.Pow(math.Abs(dy), exponent)
|
||||
}
|
||||
|
||||
amount := v.Amount / 100
|
||||
tintR, tintG, tintB := parseHexRGB(v.Color)
|
||||
tintY := luminance(tintR, tintG, tintB)
|
||||
highlights := v.Highlights / 100
|
||||
|
||||
mode := v.Mode
|
||||
if mode != VignetteColor && mode != VignetteColorize {
|
||||
mode = VignetteLight
|
||||
}
|
||||
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
termY := yTerms[row]
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
distance := math.Pow(xTerms[x]+termY, invExponent)
|
||||
mask := smoothstep(start, start+featherWidth, distance)
|
||||
if mask == 0 {
|
||||
continue
|
||||
}
|
||||
yVal := luminance(float64(pix[i]), float64(pix[i+1]), float64(pix[i+2]))
|
||||
highlightFactor := highlights * smoothstep(0.5, 1, yVal/255)
|
||||
switch mode {
|
||||
case VignetteColor:
|
||||
strength := math.Abs(amount) * mask * (1 - highlightFactor)
|
||||
if strength == 0 {
|
||||
continue
|
||||
}
|
||||
targetR := tintR - tintY + yVal
|
||||
targetG := tintG - tintY + yVal
|
||||
targetB := tintB - tintY + yVal
|
||||
pix[i] = clampChannel(float64(pix[i]) + (targetR-float64(pix[i]))*strength)
|
||||
pix[i+1] = clampChannel(float64(pix[i+1]) + (targetG-float64(pix[i+1]))*strength)
|
||||
pix[i+2] = clampChannel(float64(pix[i+2]) + (targetB-float64(pix[i+2]))*strength)
|
||||
case VignetteColorize:
|
||||
strength := math.Abs(amount) * mask * (1 - highlightFactor)
|
||||
if strength == 0 {
|
||||
continue
|
||||
}
|
||||
pix[i] = clampChannel(float64(pix[i]) + (tintR-float64(pix[i]))*strength)
|
||||
pix[i+1] = clampChannel(float64(pix[i+1]) + (tintG-float64(pix[i+1]))*strength)
|
||||
pix[i+2] = clampChannel(float64(pix[i+2]) + (tintB-float64(pix[i+2]))*strength)
|
||||
default:
|
||||
factor := 1 - amount*0.85*mask
|
||||
factor += (1 - factor) * highlightFactor * mask
|
||||
pix[i] = clampChannel(float64(pix[i]) * factor)
|
||||
pix[i+1] = clampChannel(float64(pix[i+1]) * factor)
|
||||
pix[i+2] = clampChannel(float64(pix[i+2]) * factor)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// smoothstep is the Hermite ramp between the two edges, clamped to 0..1.
|
||||
func smoothstep(edge0, edge1, x float64) float64 {
|
||||
if edge1 <= edge0 {
|
||||
if x < edge1 {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
t := clamp01((x - edge0) / (edge1 - edge0))
|
||||
return t * t * (3 - 2*t)
|
||||
}
|
||||
|
||||
// parseHexRGB decodes a "#rrggbb" color. An unusable color reads as black.
|
||||
func parseHexRGB(color string) (float64, float64, float64) {
|
||||
if len(color) != 7 || color[0] != '#' {
|
||||
return 0, 0, 0
|
||||
}
|
||||
var rgb [3]float64
|
||||
for c := 0; c < 3; c++ {
|
||||
hi := hexNibble(color[1+c*2])
|
||||
lo := hexNibble(color[2+c*2])
|
||||
if hi < 0 || lo < 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
rgb[c] = float64(hi*16 + lo)
|
||||
}
|
||||
return rgb[0], rgb[1], rgb[2]
|
||||
}
|
||||
|
||||
// hexNibble decodes one hexadecimal digit, or -1 for an invalid character.
|
||||
func hexNibble(c byte) int {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return int(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
return int(c-'a') + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
return int(c-'A') + 10
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Grain noise is a pure function of the pixel coordinates and the seed. Using
|
||||
// the splitmix64 finalizer as a hash (instead of a stateful sequence) keeps the
|
||||
// noise independent of the row order, so the row-parallel render is bit-for-bit
|
||||
// reproducible.
|
||||
|
||||
const (
|
||||
grainSaltValue uint64 = 0x9e3779b97f4a7c15
|
||||
grainSaltWhite uint64 = 0xd1b54a32d192ed03
|
||||
)
|
||||
|
||||
// mix64 is the splitmix64 finalizer.
|
||||
func mix64(x uint64) uint64 {
|
||||
x += 0x9e3779b97f4a7c15
|
||||
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
|
||||
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
|
||||
return x ^ (x >> 31)
|
||||
}
|
||||
|
||||
// hashSigned maps a seed, a salt and integer coordinates to [-1, 1].
|
||||
func hashSigned(seed, salt uint64, x, y int) float64 {
|
||||
z := seed ^ salt
|
||||
z ^= uint64(x) * 0x9e3779b97f4a7c15
|
||||
z ^= uint64(y) * 0xc2b2ae3d27d4eb4f
|
||||
h := mix64(z)
|
||||
return float64(h>>11)/float64(uint64(1)<<53)*2 - 1
|
||||
}
|
||||
|
||||
// applyGrain adds a deterministic luminance noise. Size sets the cell of the
|
||||
// smooth value noise and roughness mixes it with the per-pixel white noise; the
|
||||
// weight fades the noise out of the darkest and brightest tones.
|
||||
func applyGrain(dst *image.NRGBA, g GrainParams, seed uint64) {
|
||||
w, h := dst.Rect.Dx(), dst.Rect.Dy()
|
||||
if w == 0 || h == 0 || g.Amount == 0 {
|
||||
return
|
||||
}
|
||||
cell := int(math.Round((0.0005 + 0.004*g.Size/100) * float64(min(w, h))))
|
||||
if cell < 1 {
|
||||
cell = 1
|
||||
}
|
||||
|
||||
gridW := (w-1)/cell + 2
|
||||
gridH := (h-1)/cell + 2
|
||||
grid := make([]float64, gridW*gridH)
|
||||
for gy := 0; gy < gridH; gy++ {
|
||||
for gx := 0; gx < gridW; gx++ {
|
||||
grid[gy*gridW+gx] = hashSigned(seed, grainSaltValue, gx, gy)
|
||||
}
|
||||
}
|
||||
|
||||
roughness := g.Roughness / 100
|
||||
smoothWeight := 1 - roughness
|
||||
strength := g.Amount / 100 * 38
|
||||
|
||||
parallelChunks(h, func(from, to int) {
|
||||
for row := from; row < to; row++ {
|
||||
pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4]
|
||||
gy := row / cell
|
||||
fy := float64(row-gy*cell) / float64(cell)
|
||||
vy := smoothstep(0, 1, fy)
|
||||
for x := 0; x < w; x++ {
|
||||
i := x * 4
|
||||
if pix[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
gx := x / cell
|
||||
fx := float64(x-gx*cell) / float64(cell)
|
||||
vx := smoothstep(0, 1, fx)
|
||||
|
||||
a := grid[gy*gridW+gx]
|
||||
b := grid[gy*gridW+gx+1]
|
||||
c := grid[(gy+1)*gridW+gx]
|
||||
d := grid[(gy+1)*gridW+gx+1]
|
||||
top := a + (b-a)*vx
|
||||
bottom := c + (d-c)*vx
|
||||
value := top + (bottom-top)*vy
|
||||
|
||||
white := hashSigned(seed, grainSaltWhite, x, row)
|
||||
noise := value*smoothWeight + white*roughness
|
||||
|
||||
yVal := luminance(float64(pix[i]), float64(pix[i+1]), float64(pix[i+2]))
|
||||
t := 2*yVal/255 - 1
|
||||
weight := 1 - t*t
|
||||
addLumaDelta(pix, i, noise*strength*weight)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -123,14 +123,15 @@ func (h *Handler) Download(c *gin.Context) {
|
||||
// the photo metadata and the edited version is rendered in the background.
|
||||
func (h *Handler) Edit(c *gin.Context) {
|
||||
var req struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
Effects EffectsParams `json:"effects"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
|
||||
return
|
||||
}
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color})
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color, Effects: req.Effects})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -202,7 +203,7 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
|
||||
w.failEdit(ctx, photoID, "fetch original: "+err.Error())
|
||||
return
|
||||
}
|
||||
edited, err := decodeAndAdjust(data, params)
|
||||
edited, err := decodeAndAdjust(data, params, editSeed(photoID))
|
||||
if err != nil {
|
||||
w.failEdit(ctx, photoID, "decode original: "+err.Error())
|
||||
return
|
||||
@@ -211,7 +212,8 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
|
||||
if err != nil {
|
||||
w.failEdit(ctx, photoID, "build edited variants: "+err.Error())
|
||||
return
|
||||
} // The edit belongs to this photo only, so the variants are not shared
|
||||
}
|
||||
// The edit belongs to this photo only, so the variants are not shared
|
||||
// with the copies of the same original.
|
||||
owner := []string{photoID}
|
||||
if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil {
|
||||
@@ -240,6 +242,15 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
|
||||
w.notify(ctx, photoID)
|
||||
}
|
||||
|
||||
// editSeed is the deterministic seed of the grain render: the FNV-1a hash of the
|
||||
// photo id. It depends on the photo and not on the parameters, so a repeated
|
||||
// render of the same edit produces the same noise pattern.
|
||||
func editSeed(photoID string) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(photoID))
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {
|
||||
slog.Warn("edit job failed", "photo", photoID, "error", msg)
|
||||
_ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts)
|
||||
|
||||
Reference in New Issue
Block a user