246 lines
No EOL
9.5 KiB
HTML
246 lines
No EOL
9.5 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 just converts to grayscale and counts up unique blocks.
|
|
Next step: decimate if > 128 unique blocks per image, and combine
|
|
the most similar blocks in the output.
|
|
</p>
|
|
|
|
<h2>Source video</h2>
|
|
<div>
|
|
<video id="source" src="llamigos.webm" class="stretchy" muted controls playsinline></video>
|
|
</div>
|
|
|
|
<h2>Work canvas</h2>
|
|
<div>
|
|
<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(block) {
|
|
return block.map((n) => ~n & 0xf);
|
|
}
|
|
|
|
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 blockMap = {};
|
|
let uniques = [];
|
|
/*
|
|
for (let i = 0; i < chars.length; i++) {
|
|
let char = chars[i];
|
|
let block = blocks[char];
|
|
let key = hexify(blocks[i]);
|
|
let keyInverse = hexify(inverse(block));
|
|
if (blockMap[key]) {
|
|
char = blockMap[key];
|
|
} else if (blockMap[keyInverse]) {
|
|
char = blockMap[keyInverse];
|
|
} else {
|
|
char = uniques.push(block) - 1;
|
|
blockMap[key] = char;
|
|
blockMap[keyInverse] = char;
|
|
}
|
|
chars[i] = char;
|
|
}
|
|
*/
|
|
for (let threshold = 0; threshold < 16; threshold++) {
|
|
charIter:
|
|
for (let i = 0; i < chars.length; i++) {
|
|
let char = chars[i];
|
|
let block = blocks[char];
|
|
if (!block) {
|
|
debugger
|
|
throw new Error('missing block');
|
|
}
|
|
|
|
fontMatch:
|
|
for (let j = 0; j < uniques.length; j++) {
|
|
let other = uniques[j];
|
|
if (!block) {
|
|
debugger
|
|
throw new Error('missing other');
|
|
}
|
|
for (let k = 0; k < blockWidth * blockHeight; k++) {
|
|
if (Math.abs(block[k] - other[k]) > threshold) {
|
|
continue fontMatch;
|
|
}
|
|
}
|
|
// we're close enough to reuse a character
|
|
chars[i] = j;
|
|
continue charIter;
|
|
}
|
|
// add a new char
|
|
chars[i] = uniques.push(block) - 1;
|
|
}
|
|
if (uniques.length < 128) {
|
|
break;
|
|
}
|
|
// We need to decimate further
|
|
blocks = uniques;
|
|
uniques = [];
|
|
}
|
|
let span = document.querySelector('#block-count');
|
|
span.textContent = `${uniques.length}`;
|
|
|
|
// Font (currently wrong! :D)
|
|
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;
|
|
let invert = Boolean(char & 0x80);
|
|
char &= 0x7f;
|
|
if (char >= uniques.length) {
|
|
continue;
|
|
}
|
|
let block = uniques[char];
|
|
for (let y = 0; y < blockHeight; y++) {
|
|
for (let x = 0; x < blockWidth; x++) {
|
|
let i = y * blockWidth + x;
|
|
let ii = (y + hi * blockHeight) * 16 * blockWidth + (x + lo * blockWidth);
|
|
if (block.length < i) {
|
|
debugger;
|
|
}
|
|
let gray16 = block[i];
|
|
if (invert) {
|
|
gray16 = ~gray16 & 0x0f;
|
|
}
|
|
let gray256 = Math.round(gray16 * 255 / 15);
|
|
font.data[ii * 4] = gray256;
|
|
font.data[ii * 4 + 1] = gray256;
|
|
font.data[ii * 4 + 2] = gray256;
|
|
font.data[ii * 4 + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fontCtx.putImageData(font, 0, 0);
|
|
|
|
// Redraw the blocks
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
let i = y * width + x;
|
|
let gray16 = pixels[i];
|
|
let gray256 = Math.round(gray16 * 255 / 15);
|
|
data[i * 4] = gray256;
|
|
data[i * 4 + 1] = gray256;
|
|
data[i * 4 + 2] = gray256;
|
|
}
|
|
}
|
|
ctx.putImageData(bits, 0, 0);
|
|
}
|
|
|
|
let timer = null;
|
|
source.addEventListener('playing', () => {
|
|
if (!timer) {
|
|
timer = setInterval(update, 1000 / 10);
|
|
}
|
|
update();
|
|
});
|
|
source.addEventListener('pause', () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |