Brion Vibber
94a2e40504
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
647 lines
14 KiB
JavaScript
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();
|
|
}
|