commit e5a9c68d0529562195807f1251329a0f24827892 Author: Brion Vibber Date: Sat Nov 5 17:57:34 2022 -0700 initial commit diff --git a/dither4.js b/dither4.js new file mode 100644 index 0000000..9cff868 --- /dev/null +++ b/dither4.js @@ -0,0 +1,505 @@ +class RGB { + constructor(r, g, b) { + this.r = r; + this.g = g; + this.b = b; + } + + static fromHex(val) { + let r = val & 0xff; + let g = (val >> 8) & 0xff; + let b = (val >> 16) & 0xff; + return new RGB(r,g,b); + } + + add(other) { + return new RGB( + this.r + other.r, + this.g + other.g, + this.b + other.b + ); + } + + difference(other) { + return new RGB( + this.r - other.r, + this.g - other.g, + this.b - other.b + ); + } + + magnitude() { + return Math.sqrt( + this.r * this.r + + this.g * this.g + + this.b * this.b + ); + } + + distance(other) { + return this.difference(other).magnitude(); + } +} + +// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia +// which was calculated with Retrospecs App's Atari 800 emulator +let palette256 = [ + 0x000000, + 0x111111, + 0x222222, + 0x333333, + 0x444444, + 0x555555, + 0x666666, + 0x777777, + 0x888888, + 0x999999, + 0xaaaaaa, + 0xbbbbbb, + 0xcccccc, + 0xdddddd, + 0xeeeeee, + 0xffffff, + 0x190700, + 0x2a1800, + 0x3b2900, + 0x4c3a00, + 0x5d4b00, + 0x6e5c00, + 0x7f6d00, + 0x907e09, + 0xa18f1a, + 0xb3a02b, + 0xc3b13c, + 0xd4c24d, + 0xe5d35e, + 0xf7e46f, + 0xfff582, + 0xffff96, + 0x310000, + 0x3f0000, + 0x531700, + 0x642800, + 0x753900, + 0x864a00, + 0x975b0a, + 0xa86c1b, + 0xb97d2c, + 0xca8e3d, + 0xdb9f4e, + 0xecb05f, + 0xfdc170, + 0xffd285, + 0xffe39c, + 0xfff4b2, + 0x420404, + 0x4f0000, + 0x600800, + 0x711900, + 0x822a0d, + 0x933b1e, + 0xa44c2f, + 0xb55d40, + 0xc66e51, + 0xd77f62, + 0xe89073, + 0xf9a183, + 0xffb298, + 0xffc3ae, + 0xffd4c4, + 0xffe5da, + 0x410103, + 0x50000f, + 0x61001b, + 0x720f2b, + 0x83203c, + 0x94314d, + 0xa5425e, + 0xb6536f, + 0xc76480, + 0xd87591, + 0xe986a2, + 0xfa97b3, + 0xffa8c8, + 0xffb9de, + 0xffcaef, + 0xfbdcf6, + 0x330035, + 0x440041, + 0x55004c, + 0x660c5c, + 0x771d6d, + 0x882e7e, + 0x993f8f, + 0xaa50a0, + 0xbb61b1, + 0xcc72c2, + 0xdd83d3, + 0xee94e4, + 0xffa5e4, + 0xffb6e9, + 0xffc7ee, + 0xffd8f3, + 0x1d005c, + 0x2e0068, + 0x400074, + 0x511084, + 0x622195, + 0x7332a6, + 0x8443b7, + 0x9554c8, + 0xa665d9, + 0xb776ea, + 0xc887eb, + 0xd998eb, + 0xe9a9ec, + 0xfbbaeb, + 0xffcbef, + 0xffdff9, + 0x020071, + 0x13007d, + 0x240b8c, + 0x351c9d, + 0x462dae, + 0x573ebf, + 0x684fd0, + 0x7960e1, + 0x8a71f2, + 0x9b82f7, + 0xac93f7, + 0xbda4f7, + 0xceb5f7, + 0xdfc6f7, + 0xf0d7f7, + 0xffe8f8, + 0x000068, + 0x000a7c, + 0x081b90, + 0x192ca1, + 0x2a3db2, + 0x3b4ec3, + 0x4c5fd4, + 0x5d70e5, + 0x6e81f6, + 0x7f92ff, + 0x90a3ff, + 0xa1b4ff, + 0xb2c5ff, + 0xc3d6ff, + 0xd4e7ff, + 0xe5f8ff, + 0x000a4d, + 0x001b63, + 0x002c79, + 0x023d8f, + 0x134ea0, + 0x245fb1, + 0x3570c2, + 0x4681d3, + 0x5792e4, + 0x68a3f5, + 0x79b4ff, + 0x8ac5ff, + 0x9bd6ff, + 0xace7ff, + 0xbdf8ff, + 0xceffff, + 0x001a26, + 0x002b3c, + 0x003c52, + 0x004d68, + 0x065e7c, + 0x176f8d, + 0x28809e, + 0x3991af, + 0x4aa2c0, + 0x5bb3d1, + 0x6cc4e2, + 0x7dd5f3, + 0x8ee6ff, + 0x9ff7ff, + 0xb0ffff, + 0xc1ffff, + 0x01250a, + 0x023610, + 0x004622, + 0x005738, + 0x05684d, + 0x16795e, + 0x278a6f, + 0x389b80, + 0x49ac91, + 0x5abda2, + 0x6bceb3, + 0x7cdfc4, + 0x8df0d5, + 0x9effe5, + 0xaffff1, + 0xc0fffd, + 0x04260d, + 0x043811, + 0x054713, + 0x005a1b, + 0x106b1b, + 0x217c2c, + 0x328d3d, + 0x439e4e, + 0x54af5f, + 0x65c070, + 0x76d181, + 0x87e292, + 0x98f3a3, + 0xa9ffb3, + 0xbaffbf, + 0xcbffcb, + 0x00230a, + 0x003510, + 0x044613, + 0x155613, + 0x266713, + 0x377813, + 0x488914, + 0x599a25, + 0x6aab36, + 0x7bbc47, + 0x8ccd58, + 0x9dde69, + 0xaeef7a, + 0xbfff8b, + 0xd0ff97, + 0xe1ffa3, + 0x001707, + 0x0e2808, + 0x1f3908, + 0x304a08, + 0x415b08, + 0x526c08, + 0x637d08, + 0x748e0d, + 0x859f1e, + 0x96b02f, + 0xa7c140, + 0xb8d251, + 0xc9e362, + 0xdaf473, + 0xebff82, + 0xfcff8e, + 0x1b0701, + 0x2c1801, + 0x3c2900, + 0x4d3b00, + 0x5f4c00, + 0x705e00, + 0x816f00, + 0x938009, + 0xa4921a, + 0xb2a02b, + 0xc7b43d, + 0xd8c64e, + 0xead760, + 0xf6e46f, + 0xfffa84, + 0xffff99, +].map((hex) => RGB.fromHex(hex)); + +function decimate(input, palette, n) { + // to brute-force, the possible palettes are: + // 255 * 254 * 253 = 16,386,810 + // + // we could brute force it but that's a lot :D + // but can do some bisection :D + // + // need a fitness metric. + // each pixel in the dithered line gives a distance + // sum/average them? median? maximum? + // summing evens out the ups/downs from dithering + // but doesn't distinguish between two close and two distant options + // consider median, 90th-percentile, and max of abs(distance) + // consider doing the distance for each channel? + + let line = input.slice(); + + // Apply dithering with given palette + let dither = (palette) => { + let fitness = 0; + let error = { + right: new RGB(0, 0, 0), + red: new Float64Array(line.length), + green: new Float64Array(line.length), + blue: new Float64Array(line.length), + } + + let output = new Int32Array(line.length); + let popularity = new Int32Array(palette.length); + + // Try dithering with this palette. + for (let x = 0; x < line.length; x++) { + let rgb = line[x]; + rgb = rgb.difference(error.right); + + // find the closest possible color + let shortest = Infinity; + let pick = -1; + + for (let i = 0; i < palette.length; i++) { + let diff = palette[i].difference(rgb); + let dist = diff.magnitude(); + if (dist < shortest) { + nextError = diff; + shortest = dist; + pick = i; + } + } + + output[x] = pick; + popularity[pick]++; + + error.right.r = nextError.r / 2; + error.right.g = nextError.g / 2; + error.right.b = nextError.b / 2; + + if (x == 0) { + error.red[x - 1] += nextError.r / 8; + error.green[x - 1] += nextError.g / 8; + error.blue[x - 1] += nextError.b / 8; + } else { + error.red[x] += nextError.r / 8; + error.green[x] += nextError.g / 8; + error.blue[x] += nextError.b / 8; + } + + error.red[x] += nextError.r / 4; + error.green[x] += nextError.g / 4; + error.blue[x] += nextError.b / 4; + + error.red[x + 1] += nextError.r / 8; + error.green[x + 1] += nextError.g / 8; + error.blue[x + 1] += nextError.b / 8; + + //fitness += error.r; + //fitness += error.g; + //fitness += error.b; + } + return { + output, + palette, + fitness, + popularity, + error + }; + }; + + let start = Date.now(); + let decimated = palette.slice(); + while (decimated.length > n) { + let {popularity} = dither(decimated); + + // Try dropping least used color on each iteration + let least = Infinity; + let pick = -1; + for (let i = 1; i < decimated.length; i++) { + if (popularity[i] < least) { + pick = i; + least = popularity[i]; + } + } + let old = decimated.length; + decimated = decimated.filter((rgb, i) => { + if (i == 0) { + return true; // keep black always + } + if (i == pick) { + return false; + } + if (popularity[i] == 0) { + return false; + } + return true; + }); + if (decimated.length >= old) { + throw new Error('logic error'); + } + } + let delta = Date.now() - start; + console.log(`${delta}ms for line`); + + // Palette fits + return dither(decimated); +} + +function convert(source, sink) { + + let width = 320; + let height = 192; + + let canvas = sink; + let ctx = canvas.getContext('2d'); + + // Draw the source image down, then grab it + // and re-draw it with custom palette & dither. + + ctx.drawImage(source, 0, 0); + + let imageData = ctx.getImageData(0, 0, width, height); + let {data} = imageData; + let nextError; + + for (let y = 0; y < height; y++) { + let line = new Uint8Array(data.buffer, y * width * 4, width * 4); + let input = []; + + // Note we take two pixels because we're using the 160-wide 4-color mode + for (let x = 0; x < width; x += 2) { + let i = x >> 1; + let rgb = new RGB( + (line[x * 4 + 0] + line[x * 4 + 4]) / 2, + (line[x * 4 + 1] + line[x * 4 + 5]) / 2, + (line[x * 4 + 2] + line[x * 4 + 6]) / 2 + ); + if (nextError) { + rgb.r -= nextError.red[i]; + rgb.g -= nextError.green[i]; + rgb.b -= nextError.blue[i]; + } else { + if (y > 0) { + debugger; + } + } + input.push(rgb); + } + + let {output, palette, error} = decimate(input, palette256, 4); + nextError = error; + + for (let x = 0; x < width; x++) { + let rgb = palette[output[x >> 1]]; + line[x * 4 + 0] = rgb.r; + line[x * 4 + 1] = rgb.g; + line[x * 4 + 2] = rgb.b; + line[x * 4 + 3] = 0xff; + } + } + + ctx.putImageData(imageData, 0, 0); +} + +function run() { + for (let i = 0; i < 7; i++) { + let source = document.querySelector('#source' + i); + let sink = document.querySelector('#sink' + i); + + let doit = () => convert(source, sink); + + if (source.complete) { + doit(); + } else { + source.addEventListener('load', doit); + } + } +} + +if (document.readyState === 'loading') { + addEventListener('DOMContentLoaded', run); +} else { + run(); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..7a72ee8 --- /dev/null +++ b/index.html @@ -0,0 +1,42 @@ + + + + + Atari 4-color palette dither generator + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample0.jpg b/sample0.jpg new file mode 100644 index 0000000..405cdea Binary files /dev/null and b/sample0.jpg differ diff --git a/sample1.jpg b/sample1.jpg new file mode 100644 index 0000000..8d7dee7 Binary files /dev/null and b/sample1.jpg differ diff --git a/sample2.jpg b/sample2.jpg new file mode 100644 index 0000000..dbb2e34 Binary files /dev/null and b/sample2.jpg differ diff --git a/sample3.jpg b/sample3.jpg new file mode 100644 index 0000000..5c232fd Binary files /dev/null and b/sample3.jpg differ diff --git a/sample4.jpg b/sample4.jpg new file mode 100644 index 0000000..bc68a8a Binary files /dev/null and b/sample4.jpg differ diff --git a/sample5.jpg b/sample5.jpg new file mode 100644 index 0000000..8f0b861 Binary files /dev/null and b/sample5.jpg differ diff --git a/sample6.jpg b/sample6.jpg new file mode 100644 index 0000000..95c20d8 Binary files /dev/null and b/sample6.jpg differ