dither4/dither4.js
Brion Vibber 94a2e40504 initial atari tests (not with image) and dither improvements
palette selection now biases to matches on the bright side of
the RGB color cube, since black is always available for dithering

does better but still not perfect

atari binary just writes some test data to text framebuffer
but builds and runs
2022-11-20 15:43:34 -08:00

647 lines
14 KiB
JavaScript

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();
let darker = Math.min(diff.r, diff.g, diff.b) < 0;
if (darker) {
dist **= 2;
}
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;
*/
error.right.r = nextError.r / 2;
error.right.g = nextError.g / 2;
error.right.b = nextError.b / 2;
error.red[x - 1] += nextError.r / 8;
error.green[x - 1] += nextError.g / 8;
error.blue[x - 1] += 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;
/*
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(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
}
if (decimated[i].r == 255 && decimated[i].g == 255 && decimated[i].b == 255) {
//continue; // keep white always
}
//let coolFactor = popularity[i];
let coolFactor = 0;
for (let x = 0; x < line.length; x++) {
if (output[x] == i) {
coolFactor += fitness[x] ** 3;
}
}
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();
}