diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore new file mode 100644 index 0000000..1100c44 --- /dev/null +++ b/bad-apple/.gitignore @@ -0,0 +1,6 @@ +data/* +!data/manifest.json +preview/ +player/build/ +__pycache__/ +.pytest_cache/ diff --git a/bad-apple/README.md b/bad-apple/README.md new file mode 100644 index 0000000..a96420f --- /dev/null +++ b/bad-apple/README.md @@ -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). diff --git a/bad-apple/conftest.py b/bad-apple/conftest.py new file mode 100644 index 0000000..5334305 --- /dev/null +++ b/bad-apple/conftest.py @@ -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)) diff --git a/bad-apple/pipeline/__init__.py b/bad-apple/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bad-apple/pipeline/frames.py b/bad-apple/pipeline/frames.py new file mode 100644 index 0000000..4ba37b1 --- /dev/null +++ b/bad-apple/pipeline/frames.py @@ -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 diff --git a/bad-apple/pipeline/params.json b/bad-apple/pipeline/params.json new file mode 100644 index 0000000..25c7bc4 --- /dev/null +++ b/bad-apple/pipeline/params.json @@ -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 +} diff --git a/bad-apple/pytest.ini b/bad-apple/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/bad-apple/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/bad-apple/tests/test_frames.py b/bad-apple/tests/test_frames.py new file mode 100644 index 0000000..d072b31 --- /dev/null +++ b/bad-apple/tests/test_frames.py @@ -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