function toLinear(val) { // use a 2.4 gamma approximation // this is BT.1886 compatible // and simpler than sRGB let unit = val / 255; unit **= 2.4; return unit * 255; } function fromLinear(val) { let unit = val / 255; unit **= (1 / 2.4); return unit * 255; } 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); } toLinear() { return new RGB( toLinear(this.r), toLinear(this.g), toLinear(this.b) ); } fromLinear() { return new RGB( fromLinear(this.r), fromLinear(this.g), fromLinear(this.b) ); } cap() { if (this.r < 0) { this.r = 0; } if (this.g < 0) { this.g = 0; } if (this.b < 0) { this.b = 0; } if (this.r > 255) { this.r = 255; } if (this.g > 255) { this.g = 255; } if (this.b > 255) { this.b = 255; } } 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).toLinear()); 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 = new Float64Array(line.length); 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.add(error.right); //rgb.cap(); // find the closest possible color let shortest = Infinity; let pick = 1; for (let i = 0; i < palette.length; i++) { let diff = rgb.difference(palette[i]); let dist = diff.magnitude(); if (dist < shortest) { nextError = diff; shortest = dist; pick = i; } } output[x] = pick; popularity[pick]++; /* // horiz only error.right.r = nextError.r; error.right.g = nextError.g; error.right.b = nextError.b; */ /* error.red[x] += nextError.r; error.green[x] += nextError.g; error.blue[x] += nextError.b; */ /* error.right.r = nextError.r / 2; error.right.g = nextError.g / 2; error.right.b = nextError.b / 2; error.red[x] += nextError.r / 2; error.green[x] += nextError.g / 2; error.blue[x] += nextError.b / 2; */ if (x == 159) { error.red[x] += error.right.r; error.green[x] += error.right.g; error.blue[x] += error.right.b; } else { error.right.r = nextError.r / 4; error.right.g = nextError.g / 4; error.right.b = nextError.b / 4; error.red[x - 1] += nextError.r / 4; error.green[x - 1] += nextError.g / 4; error.blue[x - 1] += nextError.b / 4; error.red[x] += nextError.r / 4; error.green[x] += nextError.g / 4; error.blue[x] += nextError.b / 4; error.red[x + 1] += nextError.r / 4; error.green[x + 1] += nextError.g / 4; error.blue[x + 1] += nextError.b / 4; } /* error.right.r = nextError.r / 4; error.right.g = nextError.g / 4; error.right.b = nextError.b / 4; error.red[x - 1] += nextError.r / 4; error.green[x - 1] += nextError.g / 4; error.blue[x - 1] += nextError.b / 4; error.red[x] += nextError.r / 4; error.green[x] += nextError.g / 4; error.blue[x] += nextError.b / 4; error.red[x + 1] += nextError.r / 4; error.green[x + 1] += nextError.g / 4; error.blue[x + 1] += nextError.b / 4; */ // 442 is the 3d distance across the rgb cube //fitness[x] = 442 - (nextError.magnitude()); //fitness[x] = 442 / (442 - nextError.magnitude()); fitness[x] = 255 / (256 - Math.max(0, nextError.r, nextError.g, nextError.b)); /* fitness[x] = Math.max( 255 - Math.abs(nextError.r), 255 - Math.abs(nextError.g), 255 - Math.abs(nextError.b), ); */ } return { output, palette, fitness, popularity, error }; }; // black, red, blue, white let rbw = [ palette256[0x00], palette256[0x87], palette256[0xf7], palette256[0x0f], ]; let rgb = [ palette256[0x00], palette256[0x87], palette256[0xc7], palette256[0xf7], ]; // grayscale let gray = [ palette256[0x00], palette256[0x05], palette256[0x0a], palette256[0x0f], ]; //palette = rgb; //palette = rbw; //palette = gray; let start = Date.now(); let decimated = palette.slice(); while (decimated.length > n) { let {popularity, fitness, output} = 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 (i == 0) { continue; // keep black always } //let coolFactor = popularity[i]; let coolFactor = 0; if (popularity[i]) { for (let x = 0; x < line.length; x++) { if (output[x] == i) { //coolFactor += (fitness[x] ** 2); coolFactor += (fitness[x] ** 4); } } } if (coolFactor < least) { pick = i; least = coolFactor; } } let old = decimated.length; decimated = decimated.filter((rgb, i) => { if (i == pick) { return false; } if (popularity[i] == 0) { return false; } return true; }); if (decimated.length >= old) { console.log(decimated); debugger; 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 ).toLinear(); if (nextError) { rgb.r += nextError.red[i]; rgb.g += nextError.green[i]; rgb.b += nextError.blue[i]; //rgb.cap(); } 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]].fromLinear(); 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(); }