587 lines
No EOL
13 KiB
JavaScript
587 lines
No EOL
13 KiB
JavaScript
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.
|
|
* @param {RGB[]} input source scanline data, in linear RGB
|
|
* @returns {{output: number[], palette: number[], error: RGB[]}}
|
|
*/
|
|
function colorize(input) {
|
|
|
|
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;
|
|
|
|
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.magnitude();
|
|
if (dist < shortest) {
|
|
nextError = diff;
|
|
shortest = dist;
|
|
pick = i;
|
|
}
|
|
}
|
|
|
|
output[x] = pick;
|
|
distance += shortest;
|
|
|
|
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,
|
|
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;
|
|
}
|
|
let variant = dither(palette);
|
|
if (!best || variant.distance < best.distance) {
|
|
best = variant;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (aspect > ((320 / 1.2) / 192)) {
|
|
// wide
|
|
width = 80;
|
|
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.
|
|
*
|
|
* @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`);
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
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
|
|
.addr frame1_top
|
|
.repeat ${height / 2 - 1}
|
|
.byte $0f
|
|
.endrep
|
|
.byte $4f
|
|
.addr frame1_bottom
|
|
.repeat ${height / 2 - 1}
|
|
.byte $0f
|
|
.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;
|
|
|
|
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(); |