diff --git a/adjust/adjustment.go b/adjust/adjustment.go index 13b1cdc..84bb01f 100644 --- a/adjust/adjustment.go +++ b/adjust/adjustment.go @@ -72,7 +72,8 @@ func Contrast(src image.Image, change float64) *image.RGBA { func Hue(img image.Image, change int) *image.RGBA { fn := func(c color.RGBA) color.RGBA { h, s, l := util.RGBToHSL(c) - h = float64((int(h) + change) % 360) + // Go's % keeps the sign of the dividend, so a negative rotation needs re-normalizing + h = float64(((int(h)+change)%360 + 360) % 360) outColor := util.HSLToRGB(h, s, l) outColor.A = c.A return outColor diff --git a/adjust/adjustment_test.go b/adjust/adjustment_test.go index 3c1f73a..7822d06 100644 --- a/adjust/adjustment_test.go +++ b/adjust/adjustment_test.go @@ -539,3 +539,33 @@ func TestHue(t *testing.T) { } } } + +func TestHueFullRotation(t *testing.T) { + // Desaturated samples: a fully saturated pixel hides the wrap-around error + // because the out-of-range channel clamps back onto the correct value. + src := &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0xC0, 0x80, 0x40, 0xFF, 0x80, 0x40, 0xC0, 0xFF, + 0x90, 0x70, 0x50, 0x80, 0x40, 0xC0, 0x80, 0xFF, + }, + } + + cases := []struct { + name string + change int + }{ + {"positive", 360}, + {"negative", -360}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + actual := Hue(src, c.change) + if !util.RGBAImageEqual(actual, src) { + t.Errorf("Hue(src, %d):\nexpected: %v\nactual: %v", c.change, util.RGBAToString(src), util.RGBAToString(actual)) + } + }) + } +}