feat(badapple): frame extraction primitives

This commit is contained in:
arti committed 2026-11-01 00:20:35 +02:00
1 parent 9deada6cef
commit c7569efb7d
8 files changed
+168

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
data/*
!data/manifest.json
preview/
player/build/
__pycache__/
.pytest_cache/
+12
View File
@@ -0,0 +1,12 @@
# Bad Apple on VT1200 — 30 s vector prototype
This is the standalone, polygon-styled 30-second Bad Apple demo described in
[`docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md).
The host pipeline turns the first 30 s of `hd60.mp4` into a 640×512 black-and-white
vector picture, packs only changed 32×32 tiles as rectangles, and a TMS34010
payload paints them with `FILL L`. The payload takes over the terminal, so a hang
ends at a power cycle.
Status: under construction. See the implementation plan at
[`docs/superpowers/plans/2026-11-01-bad-apple-vector.md`](../docs/superpowers/plans/2026-11-01-bad-apple-vector.md).
+6
View File
@@ -0,0 +1,6 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
View File
Whitespace-only changes.
+96
View File
@@ -0,0 +1,96 @@
"""Video extraction and the small image primitives the pipeline shares."""
from __future__ import annotations
import subprocess
from typing import Iterator, Sequence
import numpy as np
def temporal_median3(frames: Sequence[np.ndarray]) -> np.ndarray:
"""Median of three same-shaped frames, as uint8."""
if len(frames) != 3:
raise ValueError("temporal_median3 wants exactly three frames")
return np.median(np.stack(frames), axis=0).astype(np.uint8)
def box_blur3(gray: np.ndarray) -> np.ndarray:
"""3x3 box blur with edge clamping (no wraparound)."""
if gray.ndim != 2:
raise ValueError("box_blur3 wants a 2-D gray image")
p = np.pad(gray.astype(np.float32), 1, mode="edge")
total = (
p[0:-2, 0:-2] + p[0:-2, 1:-1] + p[0:-2, 2:]
+ p[1:-1, 0:-2] + p[1:-1, 1:-1] + p[1:-1, 2:]
+ p[2:, 0:-2] + p[2:, 1:-1] + p[2:, 2:]
)
return np.rint(total / 9.0).astype(np.uint8)
def binarize(gray: np.ndarray, threshold: int = 127) -> np.ndarray:
"""True means black ink (the source is dark-on-light)."""
return gray < threshold
def pack_lsb(mask: np.ndarray) -> bytes:
"""Pack rows LSB-first: bit 0 of a byte is the leftmost pixel."""
if mask.ndim != 2 or mask.shape[1] % 8:
raise ValueError("pack_lsb wants 2-D rows whose width is a multiple of 8")
return np.packbits(mask.astype(bool), axis=1, bitorder="little").tobytes()
def extract_gray(video: str, start: float, duration: float, fps: int,
width: int, height: int) -> Iterator[np.ndarray]:
"""Yield (height, width) uint8 gray frames from ffmpeg."""
vf = "fps=%s,scale=%d:%d:flags=lanczos,format=gray" % (
fps, width, height)
cmd = [
"ffmpeg", "-v", "error",
"-ss", str(start),
"-i", video,
"-t", str(duration),
"-vf", vf,
"-f", "rawvideo", "-",
]
size = width * height
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=size * 4)
try:
while True:
buf = proc.stdout.read(size)
if len(buf) < size:
break
yield np.frombuffer(buf, dtype=np.uint8).reshape(height, width)
finally:
proc.stdout.close()
proc.wait()
if proc.returncode != 0:
raise RuntimeError("ffmpeg failed with status %d" % proc.returncode)
def extract_binary(video: str, start: float, duration: float, fps: int,
width: int, height: int,
params: dict) -> Iterator[np.ndarray]:
"""Yield stylised binary frames: temporal median, blur, threshold.
Exactly ``duration * fps`` frames are produced. The first output is the
first frame against itself; each later output is the median of the
previous, current and next frame.
"""
threshold = int(params.get("threshold", 127))
want_blur = int(params.get("blur", 3))
it = extract_gray(video, start, duration, fps, width, height)
try:
first = next(it)
except StopIteration:
return
def render(median_frame: np.ndarray) -> np.ndarray:
gray = box_blur3(median_frame) if want_blur >= 3 else median_frame
return binarize(gray, threshold)
a = first
b = first
yield render(temporal_median3([a, b, first]))
for current in it:
yield render(temporal_median3([a, b, current]))
a, b = b, current
+15
View File
@@ -0,0 +1,15 @@
{
"start": 0.0,
"duration": 30.0,
"fps": 60,
"width": 640,
"height": 512,
"threshold": 127,
"temporal_median": 3,
"blur": 3,
"tile": 32,
"scale": 2,
"rdp_epsilon": 1.5,
"min_segment": 2,
"min_component": 8
}
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+31
View File
@@ -0,0 +1,31 @@
import numpy as np
from pipeline.frames import temporal_median3, box_blur3, binarize, pack_lsb
def test_temporal_median_removes_single_frame_flash():
a = np.zeros((4, 4), dtype=np.uint8)
b = np.zeros((4, 4), dtype=np.uint8)
b[1, 1] = 255
c = np.zeros((4, 4), dtype=np.uint8)
out = temporal_median3([a, b, c])
assert out[1, 1] == 0
def test_pack_lsb_is_leftmost_pixel_bit_zero():
mask = np.zeros((1, 8), dtype=bool)
mask[0, 0] = True
assert pack_lsb(mask) == b"\x01"
mask[0, 7] = True
assert pack_lsb(mask) == b"\x81"
def test_binarize_black_is_true():
gray = np.array([[0, 255]], dtype=np.uint8)
assert binarize(gray).tolist() == [[True, False]]
def test_box_blur_softens_a_single_pixel():
gray = np.zeros((3, 3), dtype=np.uint8)
gray[1, 1] = 9
out = box_blur3(gray)
assert out[1, 1] == 1