package main import ( "image" "image/color" "math" "math/rand" ) // provided x, y, and color at location, return a color type quantizerFunction func(int, int, color.Color) color.Color // apply sequentially applies a quantizing function to an image and returns the result func apply(i image.Image, f quantizerFunction) image.Image { out := image.NewRGBA(image.Rect(0, 0, i.Bounds().Max.X, i.Bounds().Max.Y)) b := out.Bounds() for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { out.Set(x, y, f(x, y, i.At(x, y))) } } return out } // noOp just clones colors from one image to another, to validate file handling. func noOp(_, _ int, c color.Color) color.Color { return c } // naiveBW smashes each pixel to black or white based on lumosity. func naiveBW(_, _ int, c color.Color) color.Color { l := luminence(c) if l > 0.5 { return color.White } return color.Black } // randomNoise injects random noise into the quantization step func randomNoise(_, _ int, c color.Color) color.Color { l := luminence(c) if (l + rand.Float64() - 0.5) > 0.5 { return color.White } return color.Black } // bayer dithering applies a "value map" to our brightness range, instead of messing with the luminence itself. // the basic one is a matrix of // 0, 2 // 1, 3 // normalized by the number of cells in the matrix (i.e. divided by 4, in this case) and then compared to the luminosity. // it takes the current coordinates as input to find your location in the (tiled) matrix. type bayer struct { side int matrix map[coord]float64 } type coord struct { x, y int } func (b *bayer) valueAt(x, y int) float64 { return b.matrix[coord{x: x % b.side, y: y % b.side}] } // I could do this recursively but don't feel like it func newBayer(level int) *bayer { if level == 1 { return &bayer{ side: 4, matrix: map[coord]float64{ {0, 0}: 0 / 16.0, {1, 0}: 8 / 16.0, {0, 1}: 12 / 16.0, {1, 1}: 4 / 16.0, {2, 0}: 2 / 16.0, {3, 0}: 10 / 16.0, {2, 1}: 14 / 16.0, {3, 1}: 6 / 16.0, {0, 2}: 3 / 16.0, {1, 2}: 11 / 16.0, {0, 3}: 15 / 16.0, {1, 3}: 7 / 16.0, {2, 2}: 1 / 16.0, {3, 2}: 9 / 16.0, {2, 3}: 13 / 16.0, {3, 3}: 5 / 16.0, }, } } return &bayer{ side: 2, matrix: map[coord]float64{ {0, 0}: 0 / 4.0, {1, 0}: 2 / 4.0, {0, 1}: 3 / 4.0, {1, 1}: 1 / 4.0, }, } } func bayerDithering(level int, invert bool) quantizerFunction { b := newBayer(level) return func(x int, y int, c color.Color) color.Color { l := luminence(c) v := b.valueAt(x, y) if invert { if l > 1-v { return color.White } return color.Black } if l > v { return color.White } return color.Black } } // That is, "relative luminance": https://en.wikipedia.org/wiki/Relative_luminance. // go's color library doesn't give any information on what "color space" this RGBA is derived from. // Although the results look decent without using the sRGB->LinearRGB/CIE XYZ transformation, // it's probably subtly wrong. Will play around with it. // In particular, images seem "brighter" than they're supposed to be. func luminence(c color.Color) float64 { r, g, b, _ := c.RGBA() // we divide by 65535 because each RGB value from RGBA() returns in the range [0,65535) return (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 65535 } // seems to result in super-dark images right now. func sRGBtoXYZ(u float64) float64 { if u <= 0.04045 { return (25 * u) / 323 } return math.Pow(((200*u)+11)/211, 2.4) }