dither4/dither-image.js

840 lines
18 KiB
JavaScript
Raw Normal View History

2022-11-26 01:38:59 +00:00
import {
readFileSync,
writeFileSync
} from 'fs';
import Jimp from 'Jimp';
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;
}
clone() {
return new RGB(this.r, this.g, this.b);
}
2022-11-26 01:38:59 +00:00
static fromHex(val) {
2022-11-26 16:07:57 +00:00
let r = (val >>> 16) & 0xff;
let g = (val >>> 8) & 0xff;
let b = val & 0xff;
2022-11-26 01:38:59 +00:00
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;
}
}
inc(other) {
this.r += other.r;
this.g += other.g;
this.b += other.b;
return this;
}
2022-12-04 23:03:36 +00:00
static add(a, b) {
2022-11-26 01:38:59 +00:00
return new RGB(
2022-12-04 23:03:36 +00:00
a.r + b.r,
a.g + b.g,
a.b + b.b
2022-11-26 01:38:59 +00:00
);
}
2022-12-04 23:03:36 +00:00
add(other) {
return RGB.add(this, other);
}
2022-11-26 01:38:59 +00:00
difference(other) {
return new RGB(
this.r - other.r,
this.g - other.g,
this.b - other.b
);
}
divide(scalar) {
return new RGB(
this.r / scalar,
this.g / scalar,
this.b / scalar,
);
}
2023-03-19 07:17:53 +00:00
magnitude2() {
return this.r * this.r +
2022-11-26 01:38:59 +00:00
this.g * this.g +
2023-03-19 07:17:53 +00:00
this.b * this.b;
2022-11-26 01:38:59 +00:00
}
}
// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia
// which was calculated with Retrospecs App's Atari 800 emulator
let atariRGB = [
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());
/**
* Dither RGB input data with a target palette size.
* If the number of used colors exceeds `n`, the
* palette will be reduced until it fits.
* @param {RGB[]} input source scanline data, in linear RGB
* @param {number[]} palette - current working palette, as Atari 8-bit color values (low nybble luminance, high nybble hue)
* @param {number} n - target color count
2022-12-04 22:21:46 +00:00
* @param {RGB[]} inputError
2023-03-19 23:33:12 +00:00
* @param {number} y
2022-12-04 22:21:46 +00:00
* @returns {{output: Uint8Array, palette: number[], error: RGB[]}}
2022-11-26 01:38:59 +00:00
*/
2023-03-19 23:33:12 +00:00
function decimate(input, palette, n, inputError, y) {
2022-11-26 01:38:59 +00:00
// 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 width = input.length;
2023-03-19 04:56:36 +00:00
let inputPixel = (x, error) => {
let rgb = input[x].clone();
2023-03-19 04:56:36 +00:00
if (error) {
2023-03-19 23:33:12 +00:00
rgb.inc(error.cur[x]);
2023-03-19 04:56:36 +00:00
}
if (inputError) {
rgb.inc(inputError[x]);
}
rgb.cap();
return rgb;
};
2022-11-26 01:38:59 +00:00
// Apply dithering with given palette and collect color usage stats
let dither = (palette) => {
let fitness = new Float64Array(width);
let error = {
2022-12-04 23:03:36 +00:00
cur: [],
2022-11-26 01:38:59 +00:00
next: [],
};
for (let i = 0; i < width; i++) {
2022-12-04 23:03:36 +00:00
error.cur[i] = new RGB(0, 0, 0);
2022-11-26 01:38:59 +00:00
error.next[i] = new RGB(0, 0, 0);
}
let output = new Uint8Array(width);
let popularity = new Int32Array(width);
2023-03-19 07:17:53 +00:00
let distance2 = 0;
2022-11-26 01:38:59 +00:00
let nextError = new RGB(0, 0, 0);
// Try dithering with this palette.
for (let x = 0; x < width; x++) {
2023-03-19 04:56:36 +00:00
let rgb = inputPixel(x, error);
2022-11-26 01:38:59 +00:00
// find the closest possible color
// @todo consider doing the difference scoring in luminance and hue spaces
let shortest = Infinity;
let pick = 1;
for (let i = 0; i < palette.length; i++) {
let diff = rgb.difference(atariRGB[palette[i]]);
2023-03-19 07:17:53 +00:00
let dist = diff.magnitude2();
2022-11-26 01:38:59 +00:00
if (dist < shortest) {
nextError = diff;
shortest = dist;
pick = i;
}
}
output[x] = pick;
popularity[pick]++;
2022-12-04 23:03:36 +00:00
let shares = 8;
let single = nextError.divide(shares);
let double = nextError.divide(shares / 2);
error.cur[x + 1]?.inc(double);
error.cur[x + 2]?.inc(single);
2022-12-04 23:18:51 +00:00
error.next[x - 1]?.inc(single);
2022-12-04 23:03:36 +00:00
error.next[x]?.inc(double);
2022-12-04 23:18:51 +00:00
error.next[x + 1]?.inc(double);
2022-11-26 01:38:59 +00:00
2023-03-19 07:17:53 +00:00
// just store the distance2
let mag2 = nextError.magnitude2();
fitness[x] = mag2;
distance2 += mag2;
2022-11-26 01:38:59 +00:00
}
return {
output,
palette,
fitness,
2023-03-19 07:17:53 +00:00
distance2,
2022-11-26 01:38:59 +00:00
popularity,
error: error.next
};
};
let decimated = palette.slice();
// force to grayscale
//decimated = [0, 5, 10, 15];
// force to rgb
//decimated = [0, 0x36, 0xb6, 0x86];
// force to rWb
//decimated = [0, 0x36, 0x0f, 0x86];
2023-03-19 03:17:01 +00:00
let reserved = [0]; // black
//reserved = [0, 15]; // black, white
2023-03-19 04:56:36 +00:00
//reserved = [0, 5, 10, 15]; // grayscale
//reserved = [0, 0x48, 0x78, 15]; // vaporwave
//reserved = [0, 0x3c, 0x78, 15]; // red/blue/white
2023-03-19 23:33:12 +00:00
/*
if (( y & 1 ) === 0) {
reserved = [0, 0x3c, 0x1a, 15]; // red/yellow/white
} else {
reserved = [0, 0x76, 0x9a, 15]; // blue/cyan/white
}
*/
2023-03-19 03:17:01 +00:00
let keepers = new Uint8Array(256);
for (let i of reserved) {
keepers[i & 0xfe] = 1; // drop that 0 luminance bit!
}
2022-11-26 01:38:59 +00:00
2023-03-19 23:33:12 +00:00
let zeros = (n) => {
let arr = [];
for (let i = 0; i < n; i++) {
arr.push(0);
}
return arr;
};
// first, dither to the total atari palette
2022-11-26 01:38:59 +00:00
while (decimated.length > n) {
2023-03-19 23:33:12 +00:00
let {popularity} = dither(decimated);
let hues = zeros(16);
let lumas = zeros(16);
2023-03-19 07:17:53 +00:00
for (let i = 0; i < decimated.length; i++) {
2023-03-19 03:17:01 +00:00
let color = decimated[i];
2023-03-19 23:33:12 +00:00
let hue = color >> 4;
let luma = color & 0xe;
hues[hue] += popularity[i];
if (luma > lumas[hue]) {
lumas[hue] = luma;
2023-03-19 03:17:01 +00:00
}
2023-03-19 23:33:12 +00:00
}
2022-11-26 01:38:59 +00:00
2023-03-19 23:33:12 +00:00
let a = hues;
a = a.map((count, hue) => { return {count, hue} });
a = a.sort((a, b) => b.count - a.count);
a = a.map(({hue}) => hue);
a = a.filter((color) => !keepers[color]);
a = a.slice(0, n - reserved.length);
a = a.map((hue) => (hue << 4) | lumas[hue]);
a = a.sort((a, b) => a - b);
2022-11-26 01:38:59 +00:00
2023-03-19 23:33:12 +00:00
decimated = reserved.concat(a);
2022-11-26 01:38:59 +00:00
}
2023-03-19 07:17:53 +00:00
console.log('end', decimated);
2022-11-26 01:38:59 +00:00
// Palette fits
return dither(decimated);
}
/**
* Read an image file into a buffer
* @param {string} src
* @returns {{width: number, height: number, rgba: Uint8Array}}
*/
async function loadImage(src) {
let image = await Jimp.read(src);
2022-11-30 16:53:47 +00:00
2022-11-26 01:38:59 +00:00
let width = image.bitmap.width;
let height = image.bitmap.height;
2022-11-30 16:53:47 +00:00
if (width != 160 || height != 160) {
width = 160;
height = 160;
image = image.resize(width, height);
}
2022-11-26 01:38:59 +00:00
let rgba = image.bitmap.data.slice();
return {
width,
height,
rgba,
};
}
function imageToLinearRGB(rgba) {
let input = [];
for (let i = 0; i < rgba.length; i += 4) {
input.push(new RGB(
rgba[i + 0],
rgba[i + 1],
rgba[i + 2]
).toLinear());
}
return input;
}
/**
* Read an image file, squish to 160px if necessary,
* and dither to 4 colors per scan line.
*
* @param {string} source path to source image file
* @returns {{width: number, height: number, lines: {palette: Array, output: Uint8Array}[]}}
*/
2023-03-19 02:09:00 +00:00
async function convert(source, nbits) {
2022-11-26 01:38:59 +00:00
let {
width,
height,
rgba
} = await loadImage(source);
if (width !== 160) {
throw new Error(`expected 160px-compatible width, got ${width} pixels`);
}
2022-11-30 16:53:47 +00:00
if (height !== 160) {
2022-11-26 01:38:59 +00:00
// @fixme support up to 240px
2022-11-30 16:53:47 +00:00
throw new Error(`expected 160px height, got ${height} pixels`);
2022-11-26 01:38:59 +00:00
}
if (rgba.length != width * 4 * height) {
console.log(`
width: ${width}
height: ${height}
rgba.length: ${rgba.length}`)
throw new Error('inconsistent data size');
}
let input = imageToLinearRGB(rgba);
if (input.length != width * height) {
console.log(`
width: ${width}
height: ${height}
rgba.length: ${input.length}`)
throw new Error('inconsistent data size on input');
}
// Start with all colors usable with regular CTIA modes
// (not the 16-luminance special mode on GTIA)
let allColors = [];
2022-11-26 16:07:57 +00:00
for (let i = 0; i < 256; i += 2) {
2022-11-26 01:38:59 +00:00
allColors.push(i);
}
let lines = [];
for (let y = 0; y < height; y++) {
let error = lines[y - 1]?.error;
let inputLine = input.slice(y * width, (y + 1) * width);
2023-03-19 23:33:12 +00:00
let line = decimate(inputLine, allColors, 4, error, y);
2022-11-26 01:38:59 +00:00
lines.push(line);
}
return {
width,
height,
lines
};
}
function indexedToBitmap(width, nbits, src, dest) {
2022-11-26 01:54:58 +00:00
let nbytes = width * nbits / 8;
let x = 0;
2022-11-26 01:38:59 +00:00
for (let i = 0; i < nbytes; i++) {
let a = 0;
for (let b = 0; b < 8; b += nbits) {
a <<= nbits;
2022-11-26 01:54:58 +00:00
a |= src[x++];
2022-11-26 01:38:59 +00:00
}
dest[i] = a;
}
}
function byte2byte(arr) {
let lines = [];
for (let i=0; i < arr.length; i++) {
2022-11-26 01:54:58 +00:00
lines.push(`.byte ${arr[i]}`);
2022-11-26 01:38:59 +00:00
}
return lines.join('\n');
}
2022-12-10 21:01:29 +00:00
//let [even, odd] = [0, 1].map((bit) => (arr) => arr.filter((_item, index) => (index & 1) === bit));
function even(arr) {
return arr.filter((_item, index) => !(index & 1));
}
function odd(arr) {
return arr.filter((_item, index) => (index & 1));
}
2023-03-19 02:09:00 +00:00
function genAssembly(width, height, nbits, lines) {
2022-11-26 01:38:59 +00:00
let stride = width * nbits / 8;
2023-03-19 02:09:00 +00:00
let half = stride * height / 2;
let frame = {
palette1: new Uint8Array(height),
palette2: new Uint8Array(height),
palette3: new Uint8Array(height),
bitmap: new Uint8Array(stride * height),
};
for (let y = 0; y < height; y++) {
let base = 0;
frame.palette1[y] = lines[y + base].palette[1];
frame.palette2[y] = lines[y + base].palette[2];
frame.palette3[y] = lines[y + base].palette[3];
indexedToBitmap(
width,
nbits,
lines[y + base].output,
frame.bitmap.subarray(y * stride, (y + 1) * stride)
);
2022-11-26 01:38:59 +00:00
}
2022-11-26 01:54:58 +00:00
return `.data
2022-12-08 01:11:22 +00:00
.export frame1_top
.export frame1_bottom
2022-12-10 21:01:29 +00:00
.export frame1_palette1_even
.export frame1_palette1_odd
.export frame1_palette2_even
.export frame1_palette2_odd
.export frame1_palette3_even
.export frame1_palette3_odd
.export displaylist
2022-12-08 01:11:22 +00:00
2022-12-08 02:20:24 +00:00
.segment "BUFFERS"
2022-12-08 01:11:22 +00:00
.align 4096
2022-12-08 02:20:24 +00:00
frame1_top:
2023-03-19 02:09:00 +00:00
${byte2byte(frame.bitmap.slice(0, half))}
2022-12-08 01:11:22 +00:00
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette1_even:
2023-03-19 02:09:00 +00:00
${byte2byte(even(frame.palette1))}
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette1_odd:
2023-03-19 02:09:00 +00:00
${byte2byte(odd(frame.palette1))}
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette2_even:
2023-03-19 02:09:00 +00:00
${byte2byte(even(frame.palette2))}
2022-12-08 01:11:22 +00:00
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette2_odd:
2023-03-19 02:09:00 +00:00
${byte2byte(odd(frame.palette2))}
2022-12-08 01:11:22 +00:00
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette3_even:
2023-03-19 02:09:00 +00:00
${byte2byte(even(frame.palette3))}
2022-12-10 21:01:29 +00:00
.align 128
frame1_palette3_odd:
2023-03-19 02:09:00 +00:00
${byte2byte(odd(frame.palette3))}
2022-12-08 01:11:22 +00:00
.align 4096
frame1_bottom:
2023-03-19 02:09:00 +00:00
${byte2byte(frame.bitmap.slice(half))}
2022-12-08 01:11:22 +00:00
.align 1024
displaylist:
; 40 lines overscan
2022-12-08 04:37:52 +00:00
.repeat 4
.byte $70 ; 8 blank lines
.endrep
2022-12-08 04:37:52 +00:00
; include a DLI to mark us as frame 0
.byte $f0 ; 8 blank lines
; 160 lines graphics
; ANTIC mode e (160px 2bpp, 1 scan line per line)
.byte $4e
.addr frame1_top
2023-03-19 02:09:00 +00:00
.repeat ${height / 2 - 1}
.byte $0e
.endrep
.byte $4e
.addr frame1_bottom
2023-03-19 02:09:00 +00:00
.repeat ${height / 2 - 1}
.byte $0e
.endrep
.byte $41 ; jump and blank
.addr displaylist
2022-11-26 01:38:59 +00:00
`;
}
/**
* Double width and save as an image file
* @param {number} width
* @param {number} height
* @param {{output: number[], palette: number[]}} lines
* @param {string} dest
*/
async function saveImage(width, height, lines, dest) {
let width2 = width * 2;
let stride = width2 * 4;
let rgba = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
let {output, palette} = lines[y];
for (let x = 0; x < width2; x++) {
let i = x >> 1;
if (i >= width) {
throw new Error('i >= width');
}
let rgb = atariRGB[palette[output[i]]];
rgba[y * stride + x * 4 + 0] = fromLinear(rgb.r);
rgba[y * stride + x * 4 + 1] = fromLinear(rgb.g);
rgba[y * stride + x * 4 + 2] = fromLinear(rgb.b);
rgba[y * stride + x * 4 + 3] = 255;
}
}
let image = await new Promise((resolve, reject) => {
new Jimp({
data: rgba,
width: width2,
height,
}, (err, image) => {
if (err) reject(err);
resolve(image);
});
});
await image.writeAsync(dest);
}
async function main() {
2022-12-04 22:21:46 +00:00
if (process.argv.length < 3) {
console.error("Usage: node dither-image.js source-image.jpg dest-asm.s");
2022-11-26 01:38:59 +00:00
process.exit(1);
}
let nbits = 2;
2023-03-19 02:09:00 +00:00
let {width, height, lines} = await convert(process.argv[2], nbits);
2022-11-26 01:38:59 +00:00
2023-03-19 02:09:00 +00:00
let asm = genAssembly(width, height, nbits, lines);
2022-11-26 01:38:59 +00:00
writeFileSync(process.argv[3], asm, "utf-8");
2023-03-19 02:09:00 +00:00
await saveImage(width, height, lines, `${process.argv[3]}.png`);
2022-11-26 01:38:59 +00:00
process.exit(0);
}
main();