import { writeFileSync } from 'fs'; import Jimp from 'Jimp'; function zeroes(n) { let arr = []; for (let i = 0; i < n; i++) { arr.push(0); } return arr; } 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); } static fromHex(val) { let r = (val >>> 16) & 0xff; let g = (val >>> 8) & 0xff; let b = val & 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; } } inc(other) { this.r += other.r; this.g += other.g; this.b += other.b; return this; } static add(a, b) { return new RGB( a.r + b.r, a.g + b.g, a.b + b.b ); } difference(other) { return new RGB( this.r - other.r, this.g - other.g, this.b - other.b ); } multiply(scalar) { return new RGB( this.r * scalar, this.g * scalar, this.b * scalar, ); } divide(scalar) { return new RGB( this.r / scalar, this.g / scalar, this.b / scalar, ); } magnitude() { return Math.sqrt(this.magnitude2()); } magnitude2() { return this.r * this.r + this.g * this.g + this.b * this.b; } luma() { return this.r * 0.299 + this.g * 0.587 + this.b * 0.114; } } const maxDist = (new RGB(255, 255, 255)).magnitude(); // 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 * @param {RGB[]} inputError * @param {number} y * @returns {{output: number[], palette: number[], error: RGB[]}} */ function decimate(input, palette, n, inputError, y) { // 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; let inputPixel = (x, error) => { let rgb = input[x].clone(); if (error) { rgb.inc(error.cur[x]); } if (inputError) { rgb.inc(inputError[x]); } rgb.cap(); return rgb; }; // Apply dithering with given palette and collect color usage stats let dither = (palette) => { let fitness = zeroes(width); let error = { cur: [], next: [], }; for (let i = 0; i < width; i++) { error.cur[i] = new RGB(0, 0, 0); error.next[i] = new RGB(0, 0, 0); } let output = zeroes(width); let popularity = zeroes(palette.length); let distance2 = 0; let nextError = new RGB(0, 0, 0); // Try dithering with this palette. for (let x = 0; x < width; x++) { let rgb = inputPixel(x, error); // 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]]); let dist = diff.magnitude2(); if (dist < shortest) { nextError = diff; shortest = dist; pick = i; } } output[x] = pick; popularity[pick]++; let share = (n) => nextError.multiply(n / 16); error.cur[x + 1]?.inc(share(7)); error.next[x - 1]?.inc(share(3)); error.next[x ]?.inc(share(5)); error.next[x + 1]?.inc(share(1)); let mag = nextError.magnitude(); fitness[x] = maxDist / mag; // 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)); let mag2 = nextError.magnitude2(); distance2 += mag2; } return { output, palette, fitness, distance2, 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]; let reserved = [0]; // black //reserved = [0, 15]; // black, white //reserved = [0, 5, 10, 15]; // grayscale //reserved = [0, 0x48, 0x78, 15]; // vaporwave //reserved = [0, 0x3c, 0x78, 15]; // red/blue/white /* if (( y & 1 ) === 0) { reserved = [0, 0x3c, 0x1e, 15]; // red/yellow/white } else { reserved = [0, 0x76, 0x9e, 0xb8]; // blue/cyan/green } */ let keepers = zeroes(256); for (let i of reserved) { keepers[i & 0xfe] = 1; // drop that 0 luminance bit! } // Median cut! // https://en.wikipedia.org/wiki/Median_cut let buckets = [input.slice()]; //let initial = dither(palette); //let buckets = [initial.output.map((i) => atariRGB[palette[i]])]; let medianCut = (bucket, range) => { if (bucket.length < 2) { console.log(bucket); throw new Error('short bucket'); } //console.log('medianCut', bucket, range); // Sort by the channel with the greatest range, // then cut the bucket in two at the median. if (range.g >= range.r && range.g >= range.b) { bucket.sort((a, b) => b.g - a.g); } else if (range.r >= range.g && range.r >= range.b) { bucket.sort((a, b) => b.r - a.r); } else if (range.b >= range.g && range.b >= range.r) { bucket.sort((a, b) => b.b - a.b); } let half = bucket.length >> 1; //console.log('cutting', half, bucket.length); return [bucket.slice(0, half), bucket.slice(half)]; }; while (buckets.length < n) { // Find the bucket with the greatest range in any channel let ranges = buckets.map((bucket) => { let red = bucket.map((rgb) => rgb.r); let green = bucket.map((rgb) => rgb.g); let blue = bucket.map((rgb) => rgb.b); return new RGB( Math.max(...red) - Math.min(...red), Math.max(...green) - Math.min(...green), Math.max(...blue) - Math.min(...blue) ); }); let topRanges = ranges.map((rgb) => Math.max(rgb.r, rgb.g, rgb.b)); let greatest = Math.max(...topRanges); let index = topRanges.indexOf(greatest); let [lo, hi] = medianCut(buckets[index], ranges[index]); buckets.splice(index, 1, lo, hi); } decimated = buckets.map((bucket) => { // Average the RGB colors in this chunk //let rgb = bucket // .reduce((acc, rgb) => acc.inc(rgb), new RGB(0, 0, 0)) // .divide(bucket.length); // scale to the max luma let lumas = bucket.map((rgb) => rgb.luma()); console.log(lumas); let luma = Math.max(...lumas); let rgb = bucket[lumas.indexOf(luma)]; console.log(rgb, luma); let from = rgb.luma(); if (from > 0) { rgb = rgb.multiply(luma / rgb.luma()); } console.log(rgb, luma); if (!rgb) { throw new Error('xxx'); } // Take the channel-brightest color in the bucket // bad //rgb = bucket[bucket.length - 1]; // Take the luma-brightest color in the bucket //let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length - 1]; // Take the median color in the bucket //let rgb = bucket[bucket.length >> 1]; // Combine the brightest of each channel // this is kinda good /* let rgb = new RGB( Math.max(...bucket.map((rgb) => rgb.r)), Math.max(...bucket.map((rgb) => rgb.g)), Math.max(...bucket.map((rgb) => rgb.b)) ); */ // combine the median of each channel // sux /* let rgb = new RGB( bucket.map((rgb) => rgb.r).sort((a, b) => b - a)[bucket.length >> 1], bucket.map((rgb) => rgb.g).sort((a, b) => b - a)[bucket.length >> 1], bucket.map((rgb) => rgb.b).sort((a, b) => b - a)[bucket.length >> 1] ); */ // Take the luma-median color in the bucket //let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length >> 1]; // Take the brightest-channel median //let rgb = bucket.slice() // .sort((a, b) => Math.max(b.r, b.g, b.b) - Math.max(a.r, b.g, b.b))[bucket.length >> 1]; // And map into the Atari palette let dists = palette.map((i) => rgb.difference(atariRGB[i]).magnitude()); let closest = Math.min(...dists); let index = dists.indexOf(closest); return palette[index]; }); // hack decimated.sort((a, b) => a - b); //console.log(decimated); decimated[0] = 0; // 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); let width = image.bitmap.width; let height = image.bitmap.height; if (width != 160 || height != 160) { width = 160; height = 160; image = image.resize(width, height); } 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}[]}} */ async function convert(source) { let { width, height, rgba } = await loadImage(source); if (width !== 160) { throw new Error(`expected 160px-compatible width, got ${width} pixels`); } if (height !== 160) { // @fixme support up to 240px throw new Error(`expected 160px height, got ${height} pixels`); } 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 = []; for (let i = 0; i < 256; i += 2) { 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); let line = decimate(inputLine, allColors, 4, error, y); lines.push(line); } return { width, height, lines }; } function indexedToBitmap(width, nbits, src, dest) { let nbytes = width * nbits / 8; let x = 0; for (let i = 0; i < nbytes; i++) { let a = 0; for (let b = 0; b < 8; b += nbits) { a <<= nbits; a |= src[x++]; } dest[i] = a; } } function byte2byte(arr) { let lines = []; for (let i=0; i < arr.length; i++) { lines.push(`.byte ${arr[i]}`); } return lines.join('\n'); } //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)); } function genAssembly(width, height, nbits, lines) { let stride = width * nbits / 8; 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) ); } return `.data .export frame1_top .export frame1_bottom .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 .segment "BUFFERS" .align 4096 frame1_top: ${byte2byte(frame.bitmap.slice(0, half))} .align 128 frame1_palette1_even: ${byte2byte(even(frame.palette1))} .align 128 frame1_palette1_odd: ${byte2byte(odd(frame.palette1))} .align 128 frame1_palette2_even: ${byte2byte(even(frame.palette2))} .align 128 frame1_palette2_odd: ${byte2byte(odd(frame.palette2))} .align 128 frame1_palette3_even: ${byte2byte(even(frame.palette3))} .align 128 frame1_palette3_odd: ${byte2byte(odd(frame.palette3))} .align 4096 frame1_bottom: ${byte2byte(frame.bitmap.slice(half))} .align 1024 displaylist: ; 40 lines overscan .repeat 4 .byte $70 ; 8 blank lines .endrep ; 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 .repeat ${height / 2 - 1} .byte $0e .endrep .byte $4e .addr frame1_bottom .repeat ${height / 2 - 1} .byte $0e .endrep .byte $41 ; jump and blank .addr displaylist `; } /** * 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.resize(Math.round(width2 * 2 / 1.2), height * 2); await image.writeAsync(dest); } async function main() { if (process.argv.length < 3) { console.error("Usage: node dither-image.js source-image.jpg dest-asm.s"); process.exit(1); } let nbits = 2; let {width, height, lines} = await convert(process.argv[2], nbits); let asm = genAssembly(width, height, nbits, lines); writeFileSync(process.argv[3], asm, "utf-8"); await saveImage(width, height, lines, `${process.argv[3]}.png`); process.exit(0); } main();