diff --git a/README.md b/README.md index 3469c098aace3875ffa679998444539671b1404b..5356ba44145f524bdf2cedab11a5d9e7f9c4ff4a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,17 @@ func main() { ![example](https://anthonynsimon.github.io/projects/bild/gamma.jpg) +### Hue + result := adjust.Hue(img, -42) + +![example](https://anthonynsimon.github.io/projects/bild/hue.jpg) + +### Saturation + result := adjust.Saturation(img, 0.5) + +![example](https://anthonynsimon.github.io/projects/bild/saturation.jpg) + + ## Blend modes import "github.com/anthonynsimon/bild/blend" diff --git a/adjust/adjustment.go b/adjust/adjustment.go index 44a6b8390ab8aa7e7eee5fd9b5999992910629a3..6a5cef9b9600e22724f1c6a19fa832c7ad5b1603 100644 --- a/adjust/adjustment.go +++ b/adjust/adjustment.go @@ -7,6 +7,7 @@ import ( "math" "github.com/anthonynsimon/bild/math/f64" + "github.com/anthonynsimon/bild/util" ) // Brightness returns a copy of the image with the adjusted brightness. @@ -64,3 +65,35 @@ func Contrast(src image.Image, change float64) *image.RGBA { return img } + +// Hue adjusts the overall hue of the provided image and returns the result. +// Parameter change is the amount of change to be applied and is of the range +// -360 to 360. It corresponds to the hue angle in the HSL color model. +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) + outColor := util.HSLToRGB(h, s, l) + outColor.A = c.A + return outColor + } + + return Apply(img, fn) +} + +// Saturation adjusts the saturation of the image and returns the result. +// Parameter change is the amount of change to be applied and is of the range +// -1.0 to 1.0. It's applied as relative change. For example if the current color +// saturation is 1.0 and the saturation change is set to -0.5, a change of -50% +// will be applied so that the resulting saturation is 0.5 in the HSL color model. +func Saturation(img image.Image, change float64) *image.RGBA { + fn := func(c color.RGBA) color.RGBA { + h, s, l := util.RGBToHSL(c) + s = f64.Clamp(s*(1+change), 0.0, 1.0) + outColor := util.HSLToRGB(h, s, l) + outColor.A = c.A + return outColor + } + + return Apply(img, fn) +} diff --git a/adjust/adjustment_test.go b/adjust/adjustment_test.go index c94d4481188fed62cad14cfbbceca8b1c9a91612..3c1f73a7e9c5bc153688c25e88d3c7ec6b213905 100644 --- a/adjust/adjustment_test.go +++ b/adjust/adjustment_test.go @@ -317,3 +317,225 @@ func TestContrast(t *testing.T) { } } } + +func TestSaturation(t *testing.T) { + cases := []struct { + change float64 + value image.Image + expected *image.RGBA + }{ + { + change: 0.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: 1.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: -1.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: 0.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0xFF, 0x00, 0x80, 0x00, 0xFF, + 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0x00, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0xFF, 0x00, 0x80, 0x00, 0xFF, + 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0x00, 0xFF, + }, + }, + }, + { + change: 1.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x7A, 0x44, 0x44, 0xFF, 0x44, 0x7A, 0x44, 0xFF, + 0x44, 0x44, 0x7A, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x95, 0x29, 0x29, 0xFF, 0x29, 0x95, 0x29, 0xFF, + 0x29, 0x29, 0x95, 0xFF, 0x00, 0x00, 0x00, 0xFF, + }, + }, + }, + { + change: -1.0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0xFF, 0x00, 0x80, 0x00, 0xFF, + 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0x00, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x40, 0x40, 0x40, 0xFF, 0x40, 0x40, 0x40, 0xFF, + 0x40, 0x40, 0x40, 0xFF, 0x00, 0x00, 0x00, 0xFF, + }, + }, + }, + } + + for _, c := range cases { + actual := Saturation(c.value, c.change) + if !util.RGBAImageEqual(actual, c.expected) { + t.Errorf("%s:\nexpected: %v\nactual: %v", "Saturation", util.RGBAToString(c.expected), util.RGBAToString(actual)) + } + } +} + +func TestHue(t *testing.T) { + cases := []struct { + change int + value image.Image + expected *image.RGBA + }{ + { + change: 0, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: 360, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: 40, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x55, 0x0, 0x80, 0xFF, 0xAA, 0x0, 0xFF, + 0x0, 0x55, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + { + change: -67, + value: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x00, 0xFF, + 0x00, 0xFF, 0xFF, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + expected: &image.RGBA{ + Rect: image.Rect(0, 0, 2, 2), + Stride: 8, + Pix: []uint8{ + 0x71, 0x0, 0x80, 0x80, 0xE1, 0x0, 0xFF, 0xFF, + 0x1E, 0xFF, 0x0, 0xFF, 0x0, 0x0, 0x0, 0xFF, + }, + }, + }, + } + + for _, c := range cases { + actual := Hue(c.value, c.change) + if !util.RGBAImageEqual(actual, c.expected) { + t.Errorf("%s:\nexpected: %v\nactual: %v", "Hue", util.RGBAToString(c.expected), util.RGBAToString(actual)) + } + } +} diff --git a/util/colormodel.go b/util/colormodel.go new file mode 100644 index 0000000000000000000000000000000000000000..03db1df24553af432496f629903d7d45789a4752 --- /dev/null +++ b/util/colormodel.go @@ -0,0 +1,200 @@ +package util + +import ( + "image/color" + "math" + + "github.com/anthonynsimon/bild/math/f64" +) + +// RGBToHSL converts from RGB to HSL color model. +// Parameter c is the RGBA color and must implement the color.RGBA interface. +// Returned values h, s and l correspond to the hue, saturation and lightness. +// The hue is of range 0 to 360 and the saturation and lightness are of range 0.0 to 1.0. +func RGBToHSL(c color.RGBA) (float64, float64, float64) { + r, g, b := float64(c.R)/255, float64(c.G)/255, float64(c.B)/255 + max := math.Max(r, math.Max(g, b)) + min := math.Min(r, math.Min(g, b)) + delta := max - min + + var h, s, l float64 + l = (max + min) / 2 + + // Achromatic + if delta <= 0 { + return h, s, l + } + + // Should it be smaller than or equals instead? + if l < 0.5 { + s = delta / (max + min) + } else { + s = delta / (2 - max - min) + } + + if r >= max { + h = (g - b) / delta + } else if g >= max { + h = (b-r)/delta + 2 + } else { + h = (r-g)/delta + 4 + } + + h *= 60 + if h < 0 { + h += 360 + } + + return h, s, l +} + +// HSLToRGB converts from HSL to RGB color model. +// Parameter h is the hue and its range is from 0 to 360 degrees. +// Parameter s is the saturation and its range is from 0.0 to 1.0. +// Parameter l is the lightness and its range is from 0.0 to 1.0. +func HSLToRGB(h, s, l float64) color.RGBA { + + var r, g, b float64 + if s == 0 { + r = l + g = l + b = l + } else { + var temp0, temp1 float64 + if l < 0.5 { + temp0 = l * (1 + s) + } else { + temp0 = (l + s) - (s * l) + } + temp1 = 2*l - temp0 + + h /= 360 + + hueFn := func(v float64) float64 { + if v < 0 { + v++ + } else if v > 1 { + v-- + } + + if v < 1.0/6.0 { + return temp1 + (temp0-temp1)*6*v + } + if v < 1.0/2.0 { + return temp0 + } + if v < 2.0/3.0 { + return temp1 + (temp0-temp1)*(2.0/3.0-v)*6 + } + return temp1 + } + + r = hueFn(h + 1.0/3.0) + g = hueFn(h) + b = hueFn(h - 1.0/3.0) + + } + + outR := uint8(f64.Clamp(r*255+0.5, 0, 255)) + outG := uint8(f64.Clamp(g*255+0.5, 0, 255)) + outB := uint8(f64.Clamp(b*255+0.5, 0, 255)) + + return color.RGBA{outR, outG, outB, 0xFF} +} + +// RGBToHSV converts from RGB to HSV color model. +// Parameter c is the RGBA color and must implement the color.RGBA interface. +// Returned values h, s and v correspond to the hue, saturation and value. +// The hue is of range 0 to 360 and the saturation and value are of range 0.0 to 1.0. +func RGBToHSV(c color.RGBA) (h, s, v float64) { + r, g, b := float64(c.R)/255, float64(c.G)/255, float64(c.B)/255 + + max := math.Max(r, math.Max(g, b)) + min := math.Min(r, math.Min(g, b)) + v = max + delta := max - min + + // Avoid division by zero + if max > 0 { + s = delta / max + } else { + h = 0 + s = 0 + return + } + + // Achromatic + if max == min { + h = 0 + return + } + + if r >= max { + h = (g - b) / delta + } else if g >= max { + h = (b-r)/delta + 2 + } else { + h = (r-g)/delta + 4 + } + + h *= 60 + if h < 0 { + h += 360 + } + + return +} + +// HSVToRGB converts from HSV to RGB color model. +// Parameter h is the hue and its range is from 0 to 360 degrees. +// Parameter s is the saturation and its range is from 0.0 to 1.0. +// Parameter v is the value and its range is from 0.0 to 1.0. +func HSVToRGB(h, s, v float64) color.RGBA { + var i, f, p, q, t float64 + + // Achromatic + if s == 0 { + outV := uint8(f64.Clamp(v*255+0.5, 0, 255)) + return color.RGBA{outV, outV, outV, 0xFF} + } + + h /= 60 + i = math.Floor(h) + f = h - i + p = v * (1 - s) + q = v * (1 - s*f) + t = v * (1 - s*(1-f)) + + var r, g, b float64 + switch i { + case 0: + r = v + g = t + b = p + case 1: + r = q + g = v + b = p + case 2: + r = p + g = v + b = t + case 3: + r = p + g = q + b = v + case 4: + r = t + g = p + b = v + default: + r = v + g = p + b = q + } + + outR := uint8(f64.Clamp(r*255+0.5, 0, 255)) + outG := uint8(f64.Clamp(g*255+0.5, 0, 255)) + outB := uint8(f64.Clamp(b*255+0.5, 0, 255)) + return color.RGBA{outR, outG, outB, 0xFF} +} diff --git a/util/colormodel_test.go b/util/colormodel_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ae7d56d9b4b9fef131aaafb640cb3c96b7dc24b3 --- /dev/null +++ b/util/colormodel_test.go @@ -0,0 +1,209 @@ +package util + +import ( + "image/color" + "math" + "testing" +) + +func TestRGBToHSV(t *testing.T) { + cases := []struct { + input color.RGBA + expected [3]float64 + }{ + { + input: color.RGBA{45, 166, 115, 255}, + expected: [3]float64{155, 0.73, 0.65}, + }, + { + input: color.RGBA{0, 255, 0, 255}, + expected: [3]float64{120, 1, 1}, + }, + { + input: color.RGBA{242, 220, 97, 255}, + expected: [3]float64{51, 0.6, 0.95}, + }, + { + input: color.RGBA{10, 10, 10, 255}, + expected: [3]float64{0, 0.0, 0.04}, + }, + { + input: color.RGBA{255, 255, 255, 255}, + expected: [3]float64{0, 0.0, 1.0}, + }, + { + input: color.RGBA{0, 0, 0, 255}, + expected: [3]float64{0, 0.0, 0.0}, + }, + { + input: color.RGBA{255, 0, 0, 255}, + expected: [3]float64{0, 1.0, 1.0}, + }, + { + input: color.RGBA{255, 0, 255, 255}, + expected: [3]float64{300, 1.0, 1.0}, + }, + } + + for _, c := range cases { + h, s, v := RGBToHSV(c.input) + h = math.Floor(h + 0.5) + s = math.Floor((s*100)+0.5) / 100 + v = math.Floor((v*100)+0.5) / 100 + if h != c.expected[0] || s != c.expected[1] || v != c.expected[2] { + t.Errorf("RGBToHSV failed: expected: %#v, actual: %#v, %#v, %#v", c.expected, h, s, v) + } + } +} + +func TestHSVToRGB(t *testing.T) { + cases := []struct { + input [3]float64 + expected color.RGBA + }{ + { + input: [3]float64{155, 0.73, 0.65}, + expected: color.RGBA{45, 166, 115, 255}, + }, + { + input: [3]float64{120, 1, 1}, + expected: color.RGBA{0, 255, 0, 255}, + }, + { + input: [3]float64{51, 0.6, 0.95}, + expected: color.RGBA{242, 220, 97, 255}, + }, + { + input: [3]float64{0, 0.0, 0.04}, + expected: color.RGBA{10, 10, 10, 255}, + }, + { + input: [3]float64{0, 0.0, 1.0}, + expected: color.RGBA{255, 255, 255, 255}, + }, + { + input: [3]float64{0, 0.0, 0.0}, + expected: color.RGBA{0, 0, 0, 255}, + }, + { + input: [3]float64{0, 1.0, 1.0}, + expected: color.RGBA{255, 0, 0, 255}, + }, + { + input: [3]float64{300, 1.0, 1.0}, + expected: color.RGBA{255, 0, 255, 255}, + }, + } + + for _, c := range cases { + actual := HSVToRGB(c.input[0], c.input[1], c.input[2]) + if actual != c.expected { + t.Errorf("HSVToRGB failed: expected: %#v, actual: %#v", c.expected, actual) + } + } +} + +func TestRGBToHSL(t *testing.T) { + cases := []struct { + input color.RGBA + expected [3]float64 + }{ + { + input: color.RGBA{45, 166, 115, 255}, + expected: [3]float64{155, 0.57, 0.41}, + }, + { + input: color.RGBA{0, 255, 0, 255}, + expected: [3]float64{120, 1, 0.5}, + }, + { + input: color.RGBA{242, 220, 97, 255}, + expected: [3]float64{51, 0.85, 0.66}, + }, + { + input: color.RGBA{10, 10, 10, 255}, + expected: [3]float64{0, 0.0, 0.04}, + }, + { + input: color.RGBA{255, 255, 255, 255}, + expected: [3]float64{0, 0.0, 1.0}, + }, + { + input: color.RGBA{0, 0, 0, 255}, + expected: [3]float64{0, 0.0, 0.0}, + }, + { + input: color.RGBA{255, 0, 0, 255}, + expected: [3]float64{0, 1.0, 0.5}, + }, + { + input: color.RGBA{0, 0, 255, 255}, + expected: [3]float64{240, 1.0, 0.5}, + }, + { + input: color.RGBA{255, 0, 255, 255}, + expected: [3]float64{300, 1.0, 0.5}, + }, + } + + for _, c := range cases { + h, s, l := RGBToHSL(c.input) + h = math.Floor(h + 0.5) + s = math.Floor((s*100)+0.5) / 100 + l = math.Floor((l*100)+0.5) / 100 + if h != c.expected[0] || s != c.expected[1] || l != c.expected[2] { + t.Errorf("RGBToHSL failed: expected: %#v, actual: %#v, %#v, %#v", c.expected, h, s, l) + } + } +} + +func TestHSLToRGB(t *testing.T) { + cases := []struct { + input [3]float64 + expected color.RGBA + }{ + { + input: [3]float64{155, 0.57, 0.41}, + expected: color.RGBA{0x2d, 0xa4, 0x72, 0xff}, + }, + { + input: [3]float64{120, 1, 0.5}, + expected: color.RGBA{0, 255, 0, 255}, + }, + { + input: [3]float64{51, 0.85, 0.66}, + expected: color.RGBA{0xf2, 0xdc, 0x5f, 0xff}, + }, + { + input: [3]float64{0, 0.0, 0.04}, + expected: color.RGBA{10, 10, 10, 255}, + }, + { + input: [3]float64{0, 0.0, 1.0}, + expected: color.RGBA{255, 255, 255, 255}, + }, + { + input: [3]float64{0, 0.0, 0.0}, + expected: color.RGBA{0, 0, 0, 255}, + }, + { + input: [3]float64{0, 1.0, 0.5}, + expected: color.RGBA{255, 0, 0, 255}, + }, + { + input: [3]float64{240, 1.0, 0.5}, + expected: color.RGBA{0, 0, 255, 255}, + }, + { + input: [3]float64{300, 1.0, 0.5}, + expected: color.RGBA{255, 0, 255, 255}, + }, + } + + for _, c := range cases { + actual := HSLToRGB(c.input[0], c.input[1], c.input[2]) + if actual != c.expected { + t.Errorf("HSLToRGB failed: expected: %#v, actual: %#v", c.expected, actual) + } + } +}