mono16/dither-image.js

581 lines
13 KiB
JavaScript
Raw Normal View History

2024-08-24 03:52:21 +00:00
import {
writeFileSync
} from 'fs';
import Jimp from 'Jimp';
function repeat(val, n) {
let arr = [];
for (let i = 0; i < n; i++) {
arr.push(val);
}
return arr;
}
function zeroes(n) {
return repeat(0, n);
}
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;
}
function fromSRGB(val) {
val /= 255;
if (val <= 0.04045) {
val /= 12.92;
} else {
val = ((val + 0.055) / 1.055) ** 2.4;
}
val *= 255;
return val;
}
function toSRGB(val) {
val /= 255;
if (val <= 0.0031308) {
val *= 12.92;
} else {
val = (val * 1.055) ** (1.0 / 2.4) - 0.055;
}
val *= 255;
return val;
}
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);
}
static fromGTIA(val) {
// This seems off from what Atari800 does
// https://forums.atariage.com/topic/107853-need-the-256-colors/page/2/#comment-1312467
let cr = (val >> 4) & 15;
let lm = val & 15;
let crlv = cr ? 50 : 0;
/*
let phase = ((cr - 1) * 25 - 58) * (2 * Math.PI / 360);
let y = 255 * (lm + 1) / 16;
let i = crlv * Math.cos(phase);
let q = crlv * Math.sin(phase);
let r = y + 0.956 * i + 0.621 * q;
let g = y - 0.272 * i - 0.647 * q;
let b = y - 1.107 * i + 1.704 * q;
*/
// PAL
let phase = ((cr - 1) * 25.7 - 15) * (2 * Math.PI / 360);
let y = 255 * (lm + 1) / 16;
let i = crlv * Math.cos(phase);
let q = crlv * Math.sin(phase);
let r = y + 0.956 * i + 0.621 * q;
let g = y - 0.272 * i - 0.647 * q;
let b = y - 1.107 * i + 1.704 * q;
return new RGB(r, g, b).clamp().fromSRGB();
}
map(callback) {
return new RGB(
callback(this.r),
callback(this.g),
callback(this.b)
);
}
fromNTSC() {
return this.map(toLinear);
}
toNTSC() {
return this.map(fromLinear);
}
fromSRGB() {
return this.map(fromSRGB);
}
toSRGB() {
return this.map(toSRGB);
}
clamp() {
return this.map((val) => {
if (val < 0) return 0;
if (val > 255) return 255;
return val;
});
}
inc(other) {
this.r += other.r;
this.g += other.g;
this.b += other.b;
return this;
}
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
);
}
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;
}
sum() {
return this.r + this.g + this.b;
}
lumaScale() {
return new RGB(
this.r * 0.299,
this.g * 0.586,
this.b * 0.114
);
}
luma() {
return this.lumaScale().sum();
}
}
let atariRGB = [];
for (let i = 0; i < 256; i++) {
atariRGB[i] = RGB.fromGTIA(i);
}
/**
* Dither RGB input data and pick a monochrome palette.
2024-08-24 03:52:21 +00:00
* @param {RGB[]} input source scanline data, in linear RGB
* @returns {{output: number[], palette: number[], error: RGB[]}}
*/
function colorize(input) {
2024-08-24 03:52:21 +00:00
let width = input.length;
let inputPixel = (x, error) => {
let rgb = input[x].clone();
if (error) {
rgb = rgb.add(error.cur[x]);
}
return rgb.clamp();
};
// Apply dithering with given palette and collect color usage stats
let dither = (palette) => {
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 distance = 0;
2024-08-24 04:46:06 +00:00
let shortest = Infinity;
2024-08-24 03:52:21 +00:00
let nextError = new RGB(0, 0, 0);
// Try dithering with this palette.
for (let x = 0; x < width; x++) {
let rgb = inputPixel(x, error);
2024-08-28 02:09:28 +00:00
let luma = Math.round(rgb.luma() / 15);
let pick = luma;
2024-08-24 03:52:21 +00:00
2024-08-28 02:09:28 +00:00
let diff = rgb.difference(atariRGB[palette[pick]]);
let dist = diff.magnitude();
nextError = diff;
shortest = dist;
2024-08-24 03:52:21 +00:00
output[x] = pick;
distance += shortest;
2024-08-24 03:52:21 +00:00
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));
}
return {
output,
palette,
distance,
2024-08-24 04:46:06 +00:00
shortest,
2024-08-24 03:52:21 +00:00
error: error.next
};
};
let best = null;
for (let hue = 0; hue < 16; hue++) {
let palette = [];
for (let i = 0; i < 16; i++) {
palette[i] = (hue << 4) | i;
2024-08-24 03:52:21 +00:00
}
let variant = dither(palette);
if (!best || variant.distance < best.distance) {
best = variant;
2024-08-24 03:52:21 +00:00
}
}
return best;
2024-08-24 03:52:21 +00:00
}
/**
* 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;
let aspect = width / height;
let dar = 4 / 1.2;
2024-08-24 03:52:21 +00:00
if (aspect > ((320 / 1.2) / 192)) {
// wide
width = 80;
2024-08-24 03:52:21 +00:00
height = Math.round((width * image.bitmap.height / image.bitmap.width) * dar);
if (height & 1) {
height++;
}
} else {
// tall
height = 192;
width = Math.round((height * image.bitmap.width / image.bitmap.height) / dar);
if (width & 1) {
width++;
}
}
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]
).fromSRGB());
}
return input;
}
/**
* Read an image file, squish to 80px if necessary,
* and dither to 16 monochrome levels per scan line.
2024-08-24 03:52:21 +00:00
*
* @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 > 80) {
throw new Error(`expected <80px width, got ${width} pixels`);
2024-08-24 03:52:21 +00:00
}
if (height > 192) {
throw new Error(`expected <192px 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');
}
let left = [], right = [];
let padding = 0;
if (width < 80) {
padding = 80 - width;
2024-08-24 03:52:21 +00:00
let black = new RGB(0, 0, 0);
left = repeat(black, padding >> 1);
right = repeat(black, padding + 1 >> 1);
}
let lines = [];
for (let y = 0; y < height; y++) {
let inputLine = input
.slice(y * width, (y + 1) * width);
if (padding) {
inputLine = left.concat(inputLine, right);
}
if (y > 0) {
let error = lines[y - 1].error;
inputLine = inputLine.map((rgb, x) => rgb.add(error[x]).clamp());
}
let line = colorize(inputLine);
2024-08-24 03:52:21 +00:00
lines.push(line);
}
return {
width: width + padding,
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),
bitmap: new Uint8Array(stride * height),
};
for (let y = 0; y < height; y++) {
let base = 0;
frame.palette1[y] = lines[y + base].palette[1];
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 displaylist
.segment "BUFFERS"
.align 4096
frame1_top:
${byte2byte(frame.bitmap.slice(0, half))}
.align 4096
frame1_bottom:
${byte2byte(frame.bitmap.slice(half))}
.align 128
frame1_palette1_even:
${byte2byte(even(frame.palette1))}
.align 128
frame1_palette1_odd:
${byte2byte(odd(frame.palette1))}
.align 1024
displaylist:
; 24 lines overscan
.repeat 2
.byte $70 ; 8 blank lines
.endrep
; include a DLI to mark us as frame 0
.byte $f0 ; 8 blank lines
; ${height} lines graphics
; ANTIC mode f (320px 1bpp, 1 scan line per line)
; this is modified into GTIA grayscale mode
.byte $4f
2024-08-24 03:52:21 +00:00
.addr frame1_top
.repeat ${height / 2 - 1}
.byte $0f
2024-08-24 03:52:21 +00:00
.endrep
.byte $4f
2024-08-24 03:52:21 +00:00
.addr frame1_bottom
.repeat ${height / 2 - 1}
.byte $0f
2024-08-24 03:52:21 +00:00
.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]]].fromLinear();
let rgb = atariRGB[palette[output[i]]].toSRGB();
rgba[y * stride + x * 4 + 0] = rgb.r;
rgba[y * stride + x * 4 + 1] = rgb.g;
rgba[y * stride + x * 4 + 2] = 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 = 4;
2024-08-24 03:52:21 +00:00
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();