commit 6086d357a1f6e5e2fcb9a15262252fa29d3f29bb Author: David Ashby Date: Sat Jan 9 22:35:31 2021 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..848d5e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dither +.DS_Store + +# ignore any test outputs +/*.png diff --git a/example/dark-hires.jpg b/example/dark-hires.jpg new file mode 100644 index 0000000..380beff Binary files /dev/null and b/example/dark-hires.jpg differ diff --git a/example/dark.png b/example/dark.png new file mode 100644 index 0000000..481e085 Binary files /dev/null and b/example/dark.png differ diff --git a/example/light-hires.jpg b/example/light-hires.jpg new file mode 100644 index 0000000..44f62f5 Binary files /dev/null and b/example/light-hires.jpg differ diff --git a/example/light.png b/example/light.png new file mode 100644 index 0000000..4152f2a Binary files /dev/null and b/example/light.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4c029b4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.yetaga.in/alazyreader/dither + +go 1.15 diff --git a/main.go b/main.go new file mode 100644 index 0000000..efe94e8 --- /dev/null +++ b/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + "image" + _ "image/jpeg" + "image/png" + "math/rand" + "os" + "time" +) + +func main() { + if len(os.Args) == 1 || len(os.Args) > 4 || os.Args[1] == "help" { + fmt.Printf(`usage: %s + Supported dither options are: noop, naive, randomnoise + Supported input image formats are jpg, png +`, os.Args[0]) + os.Exit(0) + } + + filename := os.Args[1] + ditherer := os.Args[2] + outfile := os.Args[3] + + i, codex, err := loadImage(filename) + if err != nil { + fmt.Printf("error loading %s; %v\n", filename, err) + os.Exit(255) + } + fmt.Printf("loaded %s using %s\n", filename, codex) + + t := time.Now() + + var new image.Image + switch ditherer { + case "noop": + new = apply(i, noOp) + case "naive": + new = apply(i, naiveBW) + case "randomnoise": + rand.Seed(time.Now().UnixNano()) + new = apply(i, randomNoise) + case "bayer0": + new = apply(i, bayerDithering(0, false)) + case "bayer0n": + new = apply(i, bayerDithering(0, true)) + case "bayer1": + new = apply(i, bayerDithering(1, false)) + case "bayer1n": + new = apply(i, bayerDithering(1, true)) + default: + fmt.Printf("unknown ditherer option: %s\n", ditherer) + os.Exit(2) + } + + fmt.Printf("dithering took %s\n", time.Since(t)) + + err = saveImage(outfile, new) + if err != nil { + fmt.Printf("error saving %s; %v\n", outfile, err) + os.Exit(255) + } + fmt.Printf("saved output to %s\n", outfile) +} + +func loadImage(filename string) (image.Image, string, error) { + r, err := os.Open(filename) + if err != nil { + return nil, "", err + } + return image.Decode(r) +} + +func saveImage(filename string, img image.Image) error { + f, err := os.Create(filename) + if err != nil { + return err + } + + if err := png.Encode(f, img); err != nil { + f.Close() + return err + } + + if err := f.Close(); err != nil { + return err + } + + return nil +} diff --git a/quantizer.go b/quantizer.go new file mode 100644 index 0000000..6a22f17 --- /dev/null +++ b/quantizer.go @@ -0,0 +1,143 @@ +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) +}