Compare commits
10 commits
0dcfa88c29
...
71c19887c7
Author | SHA1 | Date | |
---|---|---|---|
71c19887c7 | |||
0c8ca1380d | |||
37b06789b0 | |||
363cf21ba5 | |||
527ef1ef05 | |||
1d3908410b | |||
325102021b | |||
fe6314e2a0 | |||
1d3712be5c | |||
f5c8d219e8 |
5 changed files with 143 additions and 36 deletions
3
Makefile
3
Makefile
|
@ -20,4 +20,7 @@ all : sample0.xex \
|
||||||
clean :
|
clean :
|
||||||
rm -f *.o
|
rm -f *.o
|
||||||
rm -f *.s.png
|
rm -f *.s.png
|
||||||
|
rm -f sample[0-9].s
|
||||||
|
rm -f fruit.s mapclock.s sailboat.s sunset.s train404.s
|
||||||
|
rm -f potato.s selfie.s kitty.s meme.s
|
||||||
rm -f *.xex
|
rm -f *.xex
|
||||||
|
|
135
dither-image.js
135
dither-image.js
|
@ -28,6 +28,17 @@ function fromLinear(val) {
|
||||||
return unit * 255;
|
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) {
|
function toSRGB(val) {
|
||||||
val /= 255;
|
val /= 255;
|
||||||
if (val <= 0.0031308) {
|
if (val <= 0.0031308) {
|
||||||
|
@ -57,6 +68,39 @@ class RGB {
|
||||||
return new RGB(r,g,b);
|
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) {
|
map(callback) {
|
||||||
return new RGB(
|
return new RGB(
|
||||||
callback(this.r),
|
callback(this.r),
|
||||||
|
@ -65,14 +109,18 @@ class RGB {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toLinear() {
|
fromNTSC() {
|
||||||
return this.map(toLinear);
|
return this.map(toLinear);
|
||||||
}
|
}
|
||||||
|
|
||||||
fromLinear() {
|
toNTSC() {
|
||||||
return this.map(fromLinear);
|
return this.map(fromLinear);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fromSRGB() {
|
||||||
|
return this.map(fromSRGB);
|
||||||
|
}
|
||||||
|
|
||||||
toSRGB() {
|
toSRGB() {
|
||||||
return this.map(toSRGB);
|
return this.map(toSRGB);
|
||||||
}
|
}
|
||||||
|
@ -134,13 +182,24 @@ class RGB {
|
||||||
this.b * this.b;
|
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() {
|
luma() {
|
||||||
return this.r * 0.299 + this.g * 0.587 + this.b * 0.114;
|
return this.lumaScale().sum();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxDist = (new RGB(255, 255, 255)).magnitude();
|
/*
|
||||||
|
|
||||||
// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia
|
// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia
|
||||||
// which was calculated with Retrospecs App's Atari 800 emulator
|
// which was calculated with Retrospecs App's Atari 800 emulator
|
||||||
let atariRGB = [
|
let atariRGB = [
|
||||||
|
@ -400,8 +459,16 @@ let atariRGB = [
|
||||||
0xf6e46f,
|
0xf6e46f,
|
||||||
0xfffa84,
|
0xfffa84,
|
||||||
0xffff99,
|
0xffff99,
|
||||||
].map((hex) => RGB.fromHex(hex).toLinear());
|
].map((hex) => RGB.fromHex(hex).fromNTSC());
|
||||||
//].map((hex) => RGB.fromHex(hex));
|
//].map((hex) => RGB.fromHex(hex));
|
||||||
|
*/
|
||||||
|
|
||||||
|
let atariRGB = [];
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
atariRGB[i] = RGB.fromGTIA(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dither RGB input data with a target palette size.
|
* Dither RGB input data with a target palette size.
|
||||||
|
@ -414,19 +481,6 @@ let atariRGB = [
|
||||||
* @returns {{output: number[], palette: number[], error: RGB[]}}
|
* @returns {{output: number[], palette: number[], error: RGB[]}}
|
||||||
*/
|
*/
|
||||||
function decimate(input, palette, n) {
|
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 width = input.length;
|
let width = input.length;
|
||||||
|
|
||||||
|
@ -440,7 +494,6 @@ function decimate(input, palette, n) {
|
||||||
|
|
||||||
// Apply dithering with given palette and collect color usage stats
|
// Apply dithering with given palette and collect color usage stats
|
||||||
let dither = (palette) => {
|
let dither = (palette) => {
|
||||||
let fitness = zeroes(width);
|
|
||||||
let error = {
|
let error = {
|
||||||
cur: [],
|
cur: [],
|
||||||
next: [],
|
next: [],
|
||||||
|
@ -467,7 +520,7 @@ function decimate(input, palette, n) {
|
||||||
|
|
||||||
for (let i = 0; i < palette.length; i++) {
|
for (let i = 0; i < palette.length; i++) {
|
||||||
let diff = rgb.difference(atariRGB[palette[i]]);
|
let diff = rgb.difference(atariRGB[palette[i]]);
|
||||||
let dist = diff.magnitude2();
|
let dist = diff.magnitude();
|
||||||
if (dist < shortest) {
|
if (dist < shortest) {
|
||||||
nextError = diff;
|
nextError = diff;
|
||||||
shortest = dist;
|
shortest = dist;
|
||||||
|
@ -484,21 +537,10 @@ function decimate(input, palette, n) {
|
||||||
error.next[x - 1]?.inc(share(3));
|
error.next[x - 1]?.inc(share(3));
|
||||||
error.next[x ]?.inc(share(5));
|
error.next[x ]?.inc(share(5));
|
||||||
error.next[x + 1]?.inc(share(1));
|
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 {
|
return {
|
||||||
output,
|
output,
|
||||||
palette,
|
palette,
|
||||||
fitness,
|
|
||||||
distance2,
|
distance2,
|
||||||
popularity,
|
popularity,
|
||||||
error: error.next
|
error: error.next
|
||||||
|
@ -538,7 +580,11 @@ function decimate(input, palette, n) {
|
||||||
|
|
||||||
// Median cut!
|
// Median cut!
|
||||||
// https://en.wikipedia.org/wiki/Median_cut
|
// https://en.wikipedia.org/wiki/Median_cut
|
||||||
let buckets = [input.slice()];
|
//let buckets = [input.slice()];
|
||||||
|
|
||||||
|
// preface the reserved bits
|
||||||
|
let buckets = reserved.slice().map((c) => [atariRGB[c]]).concat([input.slice()]);
|
||||||
|
|
||||||
let medianCut = (bucket, range) => {
|
let medianCut = (bucket, range) => {
|
||||||
if (bucket.length < 2) {
|
if (bucket.length < 2) {
|
||||||
console.log(bucket);
|
console.log(bucket);
|
||||||
|
@ -571,8 +617,23 @@ function decimate(input, palette, n) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
let topRanges = ranges.map((rgb) => Math.max(rgb.r, rgb.g, rgb.b));
|
let topRanges = ranges.map((rgb) => Math.max(rgb.r, rgb.g, rgb.b));
|
||||||
let greatest = Math.max(...topRanges);
|
//let greatest = Math.max(...topRanges);
|
||||||
let index = topRanges.indexOf(greatest);
|
//let index = topRanges.indexOf(greatest);
|
||||||
|
let greatest = 0;
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < topRanges.length; i++) {
|
||||||
|
if (topRanges[i] >= greatest) {
|
||||||
|
greatest = topRanges[i];
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index == -1) {
|
||||||
|
// We just ran out of colors! Pad the buckets.
|
||||||
|
while (buckets.length < n) {
|
||||||
|
buckets.push([new RGB(0, 0, 0)]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
let [lo, hi] = medianCut(buckets[index], ranges[index]);
|
let [lo, hi] = medianCut(buckets[index], ranges[index]);
|
||||||
buckets.splice(index, 1, lo, hi);
|
buckets.splice(index, 1, lo, hi);
|
||||||
}
|
}
|
||||||
|
@ -604,9 +665,11 @@ function decimate(input, palette, n) {
|
||||||
//let rgb = bucket[bucket.length - 1];
|
//let rgb = bucket[bucket.length - 1];
|
||||||
|
|
||||||
// Take the luma-brightest color in the bucket
|
// Take the luma-brightest color in the bucket
|
||||||
|
// wrong? bad
|
||||||
//let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length - 1];
|
//let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length - 1];
|
||||||
|
|
||||||
// Take the median color in the bucket
|
// Take the median color in the bucket
|
||||||
|
// bad
|
||||||
//let rgb = bucket[bucket.length >> 1];
|
//let rgb = bucket[bucket.length >> 1];
|
||||||
|
|
||||||
// Combine the brightest of each channel
|
// Combine the brightest of each channel
|
||||||
|
@ -682,7 +745,7 @@ function imageToLinearRGB(rgba) {
|
||||||
rgba[i + 0],
|
rgba[i + 0],
|
||||||
rgba[i + 1],
|
rgba[i + 1],
|
||||||
rgba[i + 2]
|
rgba[i + 2]
|
||||||
).toLinear());
|
).fromSRGB());
|
||||||
}
|
}
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
9
video/combine.sh
Normal file
9
video/combine.sh
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
ffmpeg \
|
||||||
|
-r 60000/1001 \
|
||||||
|
-i 'frames/dither-%04d.png' \
|
||||||
|
-i 'colamath-audio.wav' \
|
||||||
|
-ac 2 \
|
||||||
|
-ar 48000 \
|
||||||
|
-vf 'pad=w=640:h=360:x=52:y=20' \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-y colamath-dither.mp4
|
17
video/extract.sh
Normal file
17
video/extract.sh
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
set -a
|
||||||
|
|
||||||
|
mkdir -p frames
|
||||||
|
|
||||||
|
ffmpeg \
|
||||||
|
-i colamath-dv.avi \
|
||||||
|
-vf 'yadif=1,scale=160:200,crop=h=160' \
|
||||||
|
-an \
|
||||||
|
-y 'frames/colamath-%04d.png'
|
||||||
|
|
||||||
|
ffmpeg \
|
||||||
|
-i colamath-dv.avi \
|
||||||
|
-vn \
|
||||||
|
-ac 1 \
|
||||||
|
-ar 15734 \
|
||||||
|
-acodec pcm_u8 \
|
||||||
|
-y 'colamath-audio.wav'
|
15
video/video.sh
Normal file
15
video/video.sh
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
set -e
|
||||||
|
|
||||||
|
for frame in frames/colamath-[0-9][0-9][0-9][0-9].png
|
||||||
|
do
|
||||||
|
n="${frame#frames/colamath-}"
|
||||||
|
n="${n%.png}"
|
||||||
|
out="frames/dither-${n}"
|
||||||
|
last="${n:0-1}"
|
||||||
|
node ../dither-image.js "$frame" "$out" &
|
||||||
|
if (( last == 9 ))
|
||||||
|
then
|
||||||
|
wait
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
wait
|
Loading…
Reference in a new issue