blocky-6502/index.html
2023-08-19 12:59:01 -07:00

245 lines
9.4 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Block video encoding test</title>
<style type="text/css">
.stretchy {
width: 320px;
height: 192px;
object-fit: fill;
image-rendering: pixelated;
}
</style>
</head>
<body>
<h1>Block video encoding test</h1>
<p>
Test work for a video encoding using Atari 800-family GTIA grayscale mode on top of text cells.
This allows a "block dictionary" of 128 2x8-pixel blocks on an 80x160 grayscale image,
at cost of 1840 bytes per full frame or 800 bytes to reuse previous blocks.
</p>
<p>
Currently converts to grayscale and reuses existing similar blocks as it goes,
increasing a similarity threshold (from 0) until the set fits in 128 chars.
'Inverse video' characters should be correctly taken into account.
Further step: don't end up stuck between 64 and 128 blocks :D Try to weight decimation by use or something.
</p>
<div>
<video id="source" src="llamigos.webm" class="stretchy" muted controls playsinline></video>
<canvas id="work" width="80" height="160" class="stretchy"></canvas>
<canvas id="font" width="80" height="160" class="stretchy"></canvas>
</div>
<div>
<span id="block-count">n/a</span> unique blocks per frame
</div>
<script type="text/javascript">
let width = 80;
let height = 160;
let blockWidth = 2;
let blockHeight = 8;
let widthBlocks = width / blockWidth;
let heightBlocks = height / blockHeight;
function fromSRGB(val) {
if (val <= 0.04045) {
return val / 12.92;
}
return ((val + 0.055) / 1.055) ** 2.4;
}
function toSRGB(val) {
if (val <= 0.0031308) {
return val * 12.92;
}
return (val * 1.055) ** (1.0 / 2.4) - 0.055;
}
function luma(r, g, b) {
return r * 0.299 + g * 0.586 + b * 0.114;
}
function hexify(block) {
return Array.from(block).map((n) => n.toString(16)).join('');
}
function inverse(pixel) {
return ~pixel & 0xf;
}
function matchBlocks(a, b, threshold) {
for (let i = 0; i < blockWidth * blockHeight; i++) {
if (Math.abs(a[i] - b[i]) > threshold) {
return false;
}
}
return true;
}
function matchBlocksInverse(a, b, threshold) {
for (let i = 0; i < blockWidth * blockHeight; i++) {
if (Math.abs(a[i] - inverse(b[i])) > threshold) {
return false;
}
}
return true;
}
function drawChar(imageData, cx, cy, char, charset) {
let invert = Boolean(char & 0x80);
char &= 0x7f;
if (char >= charset.length) {
return;
}
let block = charset[char];
for (let y = 0; y < blockHeight; y++) {
for (let x = 0; x < blockWidth; x++) {
let i = y * blockWidth + x;
let ii = (y + cy * blockHeight) * imageData.width + (x + cx * blockWidth);
let gray16 = block[i];
if (invert) {
gray16 = inverse(gray16);
}
let gray256 = Math.round(gray16 * 255 / 15);
imageData.data[ii * 4] = gray256;
imageData.data[ii * 4 + 1] = gray256;
imageData.data[ii * 4 + 2] = gray256;
imageData.data[ii * 4 + 3] = 255;
}
}
}
function update() {
let ctx = work.getContext('2d');
let pixels = new Uint8Array(width * height);
// Extract the luma
ctx.drawImage(source, 0, 0);
let bits = ctx.getImageData(0, 0, width, height);
let data = bits.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let i = y * width + x;
let r = fromSRGB(data[i * 4] / 255);
let g = fromSRGB(data[i * 4 + 1] / 255);
let b = fromSRGB(data[i * 4 + 2] / 255);
let grayLinear = luma(r, g, b);
let gray = toSRGB(grayLinear);
let gray16 = Math.round(gray * 15);
pixels[i] = gray16;
}
}
let blocks = [];
let chars = new Uint16Array(widthBlocks * heightBlocks);
for (y = 0; y < heightBlocks; y++) {
for (let x = 0; x < widthBlocks; x++) {
let i = y * widthBlocks + x;
blocks[i] = new Uint8Array(blockWidth * blockHeight);
chars[i] = i;
for (let yy = 0; yy < blockHeight; yy++) {
for (let xx = 0; xx < blockWidth; xx++) {
let ii = yy * blockWidth + xx;
blocks[i][ii] = pixels[(y * blockHeight + yy) * width + (x * blockWidth + xx)];
}
}
}
}
// Now we have 800 blocks for 80x160 image
// But we can only use 128 + their mirror images
//
// First pass: uniques extraction
// Convert the 4bpp pixel indices into hex strings
let uniques = [];
for (let threshold = 0; threshold < 16; threshold++) {
charIter:
for (let i = 0; i < blocks.length; i++) {
let block = blocks[i];
fontMatch:
for (let j = 0; j < uniques.length; j++) {
let other = uniques[j];
if (!block) {
debugger
throw new Error('missing other');
}
if (matchBlocks(block, other, threshold)) {
// we're close enough to reuse a character
chars[i] = j;
continue charIter;
} else if (matchBlocksInverse(block, other, threshold)) {
chars[i] = j | 0x8000;
continue charIter;
} else {
continue fontMatch;
}
}
// add a new char
chars[i] = uniques.push(block) - 1;
}
if (uniques.length < 128) {
break;
}
// We need to decimate further
uniques = [];
}
let span = document.querySelector('#block-count');
span.textContent = `${uniques.length}`;
// Font
let fontCtx = document.querySelector('#font').getContext('2d');
let font = fontCtx.createImageData(16 * blockWidth, 16 * blockHeight);
for (let hi = 0; hi < 16; hi++) {
for (let lo = 0; lo < 16; lo++) {
let char = (hi << 4) | lo;
drawChar(font, lo, hi, char, uniques);
}
}
fontCtx.putImageData(font, 0, 0);
// Redraw the blocks
for (let cy = 0; cy < heightBlocks; cy++) {
for (let cx = 0; cx < widthBlocks; cx++) {
let i = cy * widthBlocks + cx;
let char = chars[i];
if (char & 0x8000) {
// we use a bigger bit during earlier stages
char &= 0x7f;
char |= 0x80;
}
drawChar(bits, cx, cy, char, uniques);
}
}
ctx.putImageData(bits, 0, 0);
}
let timer = null;
source.addEventListener('playing', () => {
if (!timer) {
// target 8 fps
// not sure we can get any faster
// downloads over sio
timer = setInterval(update, 1000 / 8);
}
update();
});
source.addEventListener('pause', () => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
</body>
</html>