Compare commits

...

7 Commits

5 changed files with 340 additions and 59 deletions

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# dither
This repo implements a number of the dithering methods detailed on <https://surma.dev/things/ditherpunk/>.
light.png, light-hires.jpg, dark.png, and dark-hires.jpg are from Surma's post, used under CC BY-NC-SA 4.0.
david.png is taken from the wikipedia article on dithering, where it was released into the public domain.
I took cricket.jpg.

120
color_quant.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"image/color"
"math/rand"
)
var bwpallette = color.Palette{
color.Black,
color.White,
}
var sixteencolors = color.Palette{
color.RGBA{0, 0, 0, 255}, // black
color.RGBA{0, 0, 127, 255}, // navy
color.RGBA{0, 0, 255, 255}, // blue
color.RGBA{0, 127, 0, 255}, // green
color.RGBA{0, 255, 0, 255}, // lime
color.RGBA{127, 0, 0, 255}, // maroon
color.RGBA{255, 0, 0, 255}, // red
color.RGBA{0, 127, 127, 255}, // teal
color.RGBA{127, 0, 127, 255}, // purple
color.RGBA{127, 127, 0, 255}, // olive
color.RGBA{0, 255, 255, 255}, // aqua
color.RGBA{255, 0, 255, 255}, // fuchsia
color.RGBA{255, 255, 0, 255}, // yellow
color.RGBA{127, 127, 127, 255}, // gray
color.RGBA{192, 192, 192, 255}, // silver
color.RGBA{255, 255, 255, 255}, // white
}
func permuteColor(c color.Color, i uint8) color.Color {
r, g, b, a := c.RGBA()
return color.RGBA{
uint8(r>>8) + i,
uint8(g>>8) + i,
uint8(b>>8) + i,
uint8(a >> 8),
}
}
// naivePalette smashes each pixel to its closest color in the pallette.
func naivePalette(p color.Palette) quantizerFunction {
return func(_, _ int, c color.Color) color.Color {
return p[p.Index(c)]
}
}
// randomNoisePalette injects random noise into the quantization step
func randomNoisePalette(p color.Palette) quantizerFunction {
r := len(p)
return func(_, _ int, c color.Color) color.Color {
// I think the proper theory here is probably "only try and randomize within one palette swatch in either direction".
// it might be possible to instead permute the color selected _from the palette_ (i.e. modify the result of p.Index(c))...
// ...but I think for that to work you'd need a proper "ordering" for the colors.
noise := rand.Intn(256) / r
rc := permuteColor(c, uint8(noise))
return p[p.Index(rc)]
}
}
// color permutation algo: https://en.wikipedia.org/wiki/Ordered_dithering#Algorithm
func colorBayer(level int, p color.Palette) quantizerFunction {
b := newBayer(level)
r := len(p)
return func(x, y int, c color.Color) color.Color {
v := float64(r) * (b.valueAt(x, y) - 0.5)
rc := permuteColor(c, uint8(v))
return p[p.Index(rc)]
}
}
func colorError(errMap map[coord]float64, d diffusion, palette color.Palette) quantizerFunction {
r := float64(len(palette))
return func(x int, y int, c color.Color) color.Color {
p := coord{x: x, y: y}
rc := permuteColor(c, uint8(r*errMap[p]))
delete(errMap, p) // don't let the error map grow too big
nc := palette[palette.Index(rc)]
l := luminence(c) - luminence(nc)
applyError(d, l, p, errMap)
return nc
}
}
func simpleColorErrorDiffusion() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 2.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 1.0,
{x: 0, y: 1}: 1.0,
},
}
return colorError(errMap, d, sixteencolors)
}
func colorJarvisJudiceNinke() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 48.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 7.0,
{x: 2, y: 0}: 5.0,
{x: -2, y: 1}: 3.0,
{x: -1, y: 1}: 5.0,
{x: 0, y: 1}: 7.0,
{x: 1, y: 1}: 5.0,
{x: 2, y: 1}: 3.0,
{x: -2, y: 2}: 1.0,
{x: -1, y: 2}: 3.0,
{x: 0, y: 2}: 5.0,
{x: 1, y: 2}: 3.0,
{x: 2, y: 2}: 1.0,
},
}
return colorError(errMap, d, sixteencolors)
}

BIN
example/cricket.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

160
main.go
View File

@@ -1,8 +1,10 @@
package main
import (
"flag"
"fmt"
"image"
"image/color"
_ "image/jpeg"
"image/png"
"math/rand"
@@ -10,25 +12,94 @@ import (
"time"
)
func main() {
if len(os.Args) == 1 || len(os.Args) > 4 || os.Args[1] == "help" {
fmt.Printf(`usage: %s <path/to/image.ext> <dither_option> <path/to/output.png>
Supported dither options are: noop; naive; randomnoise; bayer{0,1}; bayer{0,1}n
Supported input image formats are jpg, png
`, os.Args[0])
os.Exit(0)
type coord struct {
x, y int
}
// 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)))
}
}
filename := os.Args[1]
ditherer := os.Args[2]
outfile := os.Args[3]
return out
}
type ditherOpts struct {
raw string
level int
invert bool
}
func (o *ditherOpts) Set(input string) error {
for _, p := range input {
switch p {
case 'n':
o.invert = true
case '0':
o.level = 0
case '1':
o.level = 1
default:
return fmt.Errorf("unknown option %s", string(p))
}
}
return nil
}
func (o *ditherOpts) String() string {
return o.raw
}
func main() {
ditherOpts := &ditherOpts{}
filenameFlag := flag.String("in", "", "Input filename. PNG or JPG formats accepted.")
verboseFlag := flag.Bool("v", false, "Include diagnostic log lines.")
paletteFlag := flag.String("p", "bw",
`Palette to use. Defaults to 'bw': black-and-white.
Pass 'sixteen' for the classic sixteen-color palette.
Not all dither options support palettes; unsupported ditherers will ignore this flag.`)
dithererFlag := flag.String("d", "naive",
`Ditherer to use. Defaults to 'naive', which just flattens to nearest color.
Other options: 'noise', 'bayer', 'error', 'floydsteinberg', 'jjn', 'atkinson'.`)
flag.Var(ditherOpts, "o",
`Supply options for the selected ditherer.
Currently only relevant to the bayer ditherer, which defaults to 'level 0, non-inverted'.
To change: '1' applies a level-one bayer diffuser; '1n' would invert it, '0' would be the default.`)
outputFlag := flag.String("out", "output.png", "Output filename. Outputs in PNG format.")
flag.Parse()
// extract all those pointers
filename := *filenameFlag
ditherer := *dithererFlag
palette := *paletteFlag
outfile := *outputFlag
verbose := *verboseFlag
if filename == "" || outfile == "" {
fmt.Printf("Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
os.Exit(0)
}
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)
if verbose {
fmt.Printf("loaded %s using %s\n", filename, codex)
}
t := time.Now()
@@ -37,33 +108,70 @@ func main() {
case "noop":
new = apply(i, noOp)
case "naive":
new = apply(i, naiveBW)
case "randomnoise":
if palette == "bw" {
new = apply(i, naiveBW)
} else if palette == "sixteen" {
new = apply(i, naivePalette(sixteencolors))
}
case "noise":
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))
case "simpleerror":
new = apply(i, simpleErrorDiffusion())
if palette == "bw" {
new = apply(i, randomNoise)
} else if palette == "sixteen" {
new = apply(i, randomNoisePalette(sixteencolors))
} else {
fmt.Printf("unknown palette option for %s ditherer: %s\n", ditherer, palette)
os.Exit(2)
}
case "bayer":
if palette == "bw" {
new = apply(i, bayerDithering(ditherOpts.level, ditherOpts.invert))
} else if palette == "sixteen" {
new = apply(i, colorBayer(ditherOpts.level, sixteencolors))
} else {
fmt.Printf("unknown palette option for %s ditherer: %s\n", ditherer, palette)
os.Exit(2)
}
case "error":
if palette == "bw" {
new = apply(i, simpleErrorDiffusion())
} else if palette == "sixteen" {
new = apply(i, simpleColorErrorDiffusion())
} else {
fmt.Printf("unknown palette option for %s ditherer: %s\n", ditherer, palette)
os.Exit(2)
}
case "floydsteinberg":
new = apply(i, floydSteinberg())
case "jjn":
if palette == "bw" {
new = apply(i, jarvisJudiceNinke())
} else if palette == "sixteen" {
new = apply(i, colorJarvisJudiceNinke())
} else {
fmt.Printf("unknown palette option for %s ditherer: %s\n", ditherer, palette)
os.Exit(2)
}
case "atkinson":
new = apply(i, atkinson())
default:
fmt.Printf("unknown ditherer option: %s\n", ditherer)
os.Exit(2)
}
fmt.Printf("dithering took %s\n", time.Since(t))
if verbose {
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)
if verbose {
fmt.Printf("saved output to %s\n", outfile)
}
}
func loadImage(filename string) (image.Image, string, error) {

View File

@@ -1,28 +1,10 @@
package main
import (
"image"
"image/color"
"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
@@ -57,10 +39,6 @@ type bayer struct {
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}]
}
@@ -122,32 +100,98 @@ func bayerDithering(level int, invert bool) quantizerFunction {
}
}
func applyError(diffusionMatrix map[coord]float64, divisor float64, quantError float64, currentPixel coord, errMap map[coord]float64) {
for c, i := range diffusionMatrix {
type diffusion struct {
matrix map[coord]float64
divisor float64
}
func applyError(diffusion diffusion, quantError float64, currentPixel coord, errMap map[coord]float64) {
for c, i := range diffusion.matrix {
target := coord{x: currentPixel.x + c.x, y: currentPixel.y + c.y}
errMap[target] = errMap[target] + (quantError * (i / divisor))
errMap[target] = errMap[target] + (quantError * (i / diffusion.divisor))
}
}
func simpleErrorDiffusion() quantizerFunction {
errMap := make(map[coord]float64)
diffusionMatrix := map[coord]float64{
{x: 1, y: 0}: 1.0,
{x: 0, y: 1}: 1.0,
}
func diffuser(errMap map[coord]float64, d diffusion) quantizerFunction {
return func(x int, y int, c color.Color) color.Color {
p := coord{x: x, y: y}
l := luminence(c) + errMap[p]
delete(errMap, p) // don't let the error map grow too big
if l > 0.5 {
applyError(diffusionMatrix, 2.0, l-1.0, p, errMap)
applyError(d, l-1.0, p, errMap)
return color.White
}
applyError(diffusionMatrix, 2.0, l, p, errMap)
applyError(d, l, p, errMap)
return color.Black
}
}
func simpleErrorDiffusion() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 2.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 1.0,
{x: 0, y: 1}: 1.0,
},
}
return diffuser(errMap, d)
}
func floydSteinberg() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 16.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 7.0,
{x: -1, y: 1}: 3.0,
{x: 0, y: 1}: 5.0,
{x: 1, y: 1}: 1.0,
},
}
return diffuser(errMap, d)
}
func jarvisJudiceNinke() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 48.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 7.0,
{x: 2, y: 0}: 5.0,
{x: -2, y: 1}: 3.0,
{x: -1, y: 1}: 5.0,
{x: 0, y: 1}: 7.0,
{x: 1, y: 1}: 5.0,
{x: 2, y: 1}: 3.0,
{x: -2, y: 2}: 1.0,
{x: -1, y: 2}: 3.0,
{x: 0, y: 2}: 5.0,
{x: 1, y: 2}: 3.0,
{x: 2, y: 2}: 1.0,
},
}
return diffuser(errMap, d)
}
func atkinson() quantizerFunction {
errMap := make(map[coord]float64)
d := diffusion{
divisor: 8.0,
matrix: map[coord]float64{
{x: 1, y: 0}: 1.0,
{x: 2, y: 0}: 1.0,
{x: -1, y: 1}: 1.0,
{x: 0, y: 1}: 1.0,
{x: 1, y: 1}: 1.0,
{x: 0, y: 2}: 1.0,
},
}
return diffuser(errMap, d)
}
// That is, "relative luminance": https://en.wikipedia.org/wiki/Relative_luminance.
// go's color library doesn't give any information on what "color space" the RGBA is derived from,
// so we convert to Y'CbCr, which returns luminence directly as the Y component.