diff --git a/README.md b/README.md index 6c8f5e9..0a09557 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Each 1920×1080 frame contains a 30×16 grid of 64×64 pixel blocks: +--------+--------+--------+--------+--------+ ... +--------+ | SYNC 0 | SYNC 1 | SYNC 2 | SYNC 3 | SYNC 4 | | SYNC 7 | <- row 0, cols 0-7: sync pattern +--------+--------+--------+--------+--------+ +--------+ -| IDX 0 | IDX 1 | IDX 2 | ... | IDX 15 | <- row 0, cols 8-23: frame index +| IDX 0 | IDX 1 | IDX 2 | ... | IDX 15 | <- row 0, cols 8-23: frame index (16-bit default; 32-bit spans cols 8-39) +--------+--------+--------+ +--------+ | DATA | DATA | DATA | DATA | DATA | ... | DATA | <- remaining 456 blocks: data +--------+--------+--------+--------+--------+ +--------+ @@ -72,12 +72,15 @@ Each 1920×1080 frame contains a 30×16 grid of 64×64 pixel blocks: ``` - Sync pattern: `10101100` (8 bits, fixed). Used to validate frames and reject corrupted ones. -- Frame index: 16-bit big-endian integer. Supports up to 65,535 frames (~36 minutes at 30fps). -- Data: 456 bits = 57 bytes of Reed-Solomon encoded payload per frame. +- Frame index: big-endian integer, 16-bit by default. Encoder switches to 32-bit when needed (hard limit). + When 32-bit is used, the index continues into the next blocks and data capacity per frame shrinks. +- Data: 456 bits (16-bit index) or 440 bits (32-bit index) of Reed-Solomon encoded payload per frame. The first 1 second (`METADATA_DURATION_SEC`) in the video is reserved for QR metadata and does **not** contain data blocks. The decoder uses these frames to learn the expected ECC length, frame count, and SHA256 before assembling payload data. +QR metadata also carries the frame index width (`i`) along with filename, size, +ECC length, and metadata frame count. Each block is sampled at its center 32×32 region (the inner half, margin = 16px). Block edges are where DCT compression artifacts accumulate; the center is clean. @@ -221,15 +224,15 @@ This tests the full encode/decode cycle locally. If this fails, the issue is in | Frame dimensions | 1920 × 1080 | | Block size | 64 × 64 px | | Grid | 30 × 16 = 480 blocks/frame | -| Header bits/frame | 24 (8 sync + 16 index) | -| Data bits/frame | 456 | -| Data bytes/frame | 57 | -| Video data rate | 1,710 bytes/sec at 30fps | +| Header bits/frame | 24 (8 sync + 16 index) / 40 (8 sync + 32 index) | +| Data bits/frame | 456 (16-bit) / 440 (32-bit) | +| Data bytes/frame | 57 (16-bit) / 55 (32-bit) | +| Video data rate | 1,710 bytes/sec (16-bit) / 1,650 bytes/sec (32-bit) | | Audio data rate | 25 bytes/sec (4-FSK, 100 baud) | | ECC overhead | ~14% (RS-32 over GF(2^8)) | -| Net video throughput | ~1,500 bytes/sec after ECC | -| Max video duration | ~36 min (16-bit frame index) | -| Max file size (video) | ~3.2 GB before ECC | +| Net video throughput | ~1,500 bytes/sec (16-bit) / ~1,450 bytes/sec (32-bit) after ECC | +| Max video duration | ~36 min (16-bit) / ~4.5 years (32-bit) | +| Max file size (video) | Scales with index width (16-bit default, 32-bit hard limit) | Audio capacity is 1.5% of video capacity at these settings. For small files (under ~1.5 KB after ECC), audio covers 100% of the payload and provides full redundancy. For larger files it covers a prefix. @@ -239,7 +242,7 @@ To increase audio coverage: raise `BAUD_RATE` in both scripts. 200 baud doubles ## Known limitations -**Frame index ceiling.** The 16-bit frame index supports 65,535 frames, which is ~36 minutes at 30fps and ~3.2 GB of raw file data after ECC. For larger files, increase `FRAME_INDEX_BITS` to 24 or 32 in both scripts and adjust `HEADER_BITS` and `DATA_BITS_PER_FRAME` accordingly. +**Frame index ceiling.** The encoder uses 16-bit frame indices by default and automatically switches to 32-bit if the payload needs more than 16-bit indices. 32-bit is the hard limit. **YouTube re-encoding is untested.** The local round-trip works. YouTube's actual VP9/H.264 output has not yet been tested against this codec. The 64×64 block size was chosen conservatively for this reason. If YouTube's encoder corrupts blocks, the first thing to try is increasing `BLOCK_SIZE` to 128. @@ -273,7 +276,7 @@ Payload layout (before ECC): +-------------------+---------------------------+-------------------+ Metadata JSON fields: - v encoding version (integer, currently 2) + v encoding version (integer, currently 3) filename original filename (string) size original file size in bytes (integer) sha256 SHA256 hex digest of the original file bytes (string) diff --git a/decode.py b/decode.py index 47a2408..26ea4d0 100644 --- a/decode.py +++ b/decode.py @@ -25,10 +25,9 @@ TOTAL_BLOCKS = COLS * ROWS # 480 SYNC_PATTERN = np.array([1, 0, 1, 0, 1, 1, 0, 0], dtype=np.uint8) -FRAME_INDEX_BITS = 16 -HEADER_BITS = len(SYNC_PATTERN) + FRAME_INDEX_BITS # 24 -DATA_BITS_PER_FRAME = TOTAL_BLOCKS - HEADER_BITS # 456 -DATA_BYTES_PER_FRAME = DATA_BITS_PER_FRAME // 8 # 57 +SYNC_BITS = len(SYNC_PATTERN) +DEFAULT_FRAME_INDEX_BITS = 16 +EXTENDED_FRAME_INDEX_BITS = 32 SAMPLE_RATE = 44100 BAUD_RATE = 100 @@ -87,18 +86,30 @@ def read_blocks(frame: np.ndarray) -> np.ndarray: return bits -def decode_frame(bits: np.ndarray): +def frame_layout(index_bits: int) -> tuple[int, int]: + header_bits = SYNC_BITS + index_bits + data_bits_per_frame = TOTAL_BLOCKS - header_bits + if data_bits_per_frame <= 0: + raise ValueError( + f"Frame index bits ({index_bits}) exceed available block capacity ({TOTAL_BLOCKS} blocks)." + ) + if data_bits_per_frame % 8 != 0: + raise ValueError( + f"Frame data bits ({data_bits_per_frame}) must align to full bytes (multiples of 8)." + ) + return header_bits, data_bits_per_frame + + +def decode_frame(bits: np.ndarray, index_bits: int, header_bits: int): # Returns (frame_idx, data_bits) or (None, None) if sync check fails. - sync = bits[:len(SYNC_PATTERN)] + sync = bits[:SYNC_BITS] if not np.array_equal(sync, SYNC_PATTERN): return None, None - idx_bits = bits[len(SYNC_PATTERN):HEADER_BITS] - frame_idx = int(np.packbits(np.pad(idx_bits, (16 - FRAME_INDEX_BITS, 0)))[1]) - # Cleaner index decode: + idx_bits = bits[SYNC_BITS:header_bits] frame_idx = 0 for b in idx_bits: frame_idx = (frame_idx << 1) | int(b) - data_bits = bits[HEADER_BITS:] + data_bits = bits[header_bits:] return frame_idx, data_bits @@ -193,6 +204,7 @@ def to_int(value): "sha256": raw.get("h") if "h" in raw else raw.get("sha256"), "ecc_bytes": to_int(raw.get("e") if "e" in raw else raw.get("ecc_bytes")), "frames": to_int(raw.get("n") if "n" in raw else raw.get("frames")), + "index_bits": to_int(raw.get("i") if "i" in raw else raw.get("index_bits")), "metadata_frames": to_int(raw.get("m") if "m" in raw else raw.get("metadata_frames")), } @@ -223,6 +235,9 @@ def decode(video_path: str, output_dir: str = "."): # Step 1: video channel print("\n[1/5] Decoding video channel...") + index_bits = DEFAULT_FRAME_INDEX_BITS + header_bits, data_bits_per_frame = frame_layout(index_bits) + data_bytes_per_frame = data_bits_per_frame // 8 frames = {} total = 0 sync_fail = 0 @@ -236,8 +251,28 @@ def decode(video_path: str, output_dir: str = "."): qr_meta = decode_qr_metadata(frame_np, qr_detector) if qr_meta: print(f" QR metadata decoded from frame {total}.") + qr_index_bits = qr_meta.get("index_bits") + if qr_index_bits is not None: + if qr_index_bits not in (DEFAULT_FRAME_INDEX_BITS, EXTENDED_FRAME_INDEX_BITS): + print( + f" Warning: unsupported index width {qr_index_bits}; falling back to " + f"{index_bits}-bit decoding (may fail)." + ) + elif qr_index_bits != index_bits: + old_index_bits = index_bits + if frames or early_frames: + print( + f" Warning: unexpected index width change from {old_index_bits} to " + f"{qr_index_bits} bits; this may indicate corrupted or mixed sources. " + "Discarding previously decoded frames." + ) + frames = {} + early_frames = {} + index_bits = qr_index_bits + header_bits, data_bits_per_frame = frame_layout(index_bits) + data_bytes_per_frame = data_bits_per_frame // 8 bits = read_blocks(frame_np) - idx, data_bits = decode_frame(bits) + idx, data_bits = decode_frame(bits, index_bits, header_bits) if idx is None: sync_fail += 1 else: @@ -270,19 +305,19 @@ def decode(video_path: str, output_dir: str = "."): else: frame_count = max(frames.keys()) + 1 - total_bits = frame_count * DATA_BITS_PER_FRAME + total_bits = frame_count * data_bits_per_frame all_bits = np.zeros(total_bits, dtype=np.uint8) missing = [] for i in range(frame_count): if i in frames: - all_bits[i * DATA_BITS_PER_FRAME:(i + 1) * DATA_BITS_PER_FRAME] = frames[i] + all_bits[i * data_bits_per_frame:(i + 1) * data_bits_per_frame] = frames[i] else: missing.append(i) if missing: print(f" Missing {len(missing)} frames: {missing[:10]}{'...' if len(missing)>10 else ''}") - print(f" Reed-Solomon will attempt recovery ({len(missing)*DATA_BYTES_PER_FRAME} bytes zeroed).") + print(f" Reed-Solomon will attempt recovery ({len(missing)*data_bytes_per_frame} bytes zeroed).") else: print(f" All {frame_count} frames present.") @@ -314,9 +349,9 @@ def decode(video_path: str, output_dir: str = "."): if expected_ecc_bytes is None and len(ecc_video) >= rs_block_size: ecc_video = ecc_video[: (len(ecc_video) // rs_block_size) * rs_block_size] missing_byte_positions = [ - i * DATA_BYTES_PER_FRAME + j + i * data_bytes_per_frame + j for i in missing - for j in range(DATA_BYTES_PER_FRAME) + for j in range(data_bytes_per_frame) ] erasures_video = [pos for pos in missing_byte_positions if pos < len(ecc_video)] diff --git a/encode.py b/encode.py index 8e0f77e..5d79b1a 100644 --- a/encode.py +++ b/encode.py @@ -4,6 +4,7 @@ import os import json import hashlib +import math import struct import subprocess import tempfile @@ -23,11 +24,14 @@ ROWS = FRAME_HEIGHT // BLOCK_SIZE # 16 TOTAL_BLOCKS = COLS * ROWS # 480 -# Frame layout: [8 sync bits][16 index bits][456 data bits] +# Frame layout: [8 sync bits][frame index bits][data bits] SYNC_PATTERN = np.array([1, 0, 1, 0, 1, 1, 0, 0], dtype=np.uint8) -FRAME_INDEX_BITS = 16 # max 65535 frames (~36 min at 30fps) -HEADER_BITS = len(SYNC_PATTERN) + FRAME_INDEX_BITS # 24 -DATA_BITS_PER_FRAME = TOTAL_BLOCKS - HEADER_BITS # 456 bits = 57 bytes/frame +SYNC_BITS = len(SYNC_PATTERN) +DEFAULT_FRAME_INDEX_BITS = 16 +EXTENDED_FRAME_INDEX_BITS = 32 +MAX_FRAME_INDEX = (1 << EXTENDED_FRAME_INDEX_BITS) - 1 +MAX_FRAME_COUNT_DEFAULT = 1 << DEFAULT_FRAME_INDEX_BITS +MAX_FRAME_COUNT_EXTENDED = MAX_FRAME_INDEX + 1 # Audio (4-FSK) SAMPLE_RATE = 44100 @@ -47,7 +51,7 @@ QR_ERROR_CORRECTION = qrcode.constants.ERROR_CORRECT_Q QR_MIN_MODULE_PX = 6 -ENCODING_VERSION = 2 +ENCODING_VERSION = 3 def build_payload(filepath: str): @@ -66,7 +70,7 @@ def build_payload(filepath: str): return payload, meta -def build_qr_metadata(meta: dict, ecc_len: int, num_frames: int) -> bytes: +def build_qr_metadata(meta: dict, ecc_len: int, num_frames: int, index_bits: int) -> bytes: qr_meta = { "v": ENCODING_VERSION, "f": meta["filename"], @@ -74,6 +78,7 @@ def build_qr_metadata(meta: dict, ecc_len: int, num_frames: int) -> bytes: "h": meta["sha256"], "e": ecc_len, "n": num_frames, + "i": index_bits, "m": METADATA_FRAMES, } return json.dumps(qr_meta, separators=(",", ":")).encode() @@ -89,15 +94,33 @@ def ecc_encode(data: bytes) -> bytes: return encoded -def index_to_bits(idx: int) -> np.ndarray: +def frame_layout(index_bits: int) -> tuple[int, int]: + header_bits = SYNC_BITS + index_bits + data_bits_per_frame = TOTAL_BLOCKS - header_bits + if data_bits_per_frame <= 0: + raise ValueError( + f"Frame index bits ({index_bits}) exceed available block capacity ({TOTAL_BLOCKS} blocks)." + ) + if data_bits_per_frame % 8 != 0: + raise ValueError( + f"Frame data bits ({data_bits_per_frame}) must align to full bytes (multiples of 8)." + ) + return header_bits, data_bits_per_frame + + +def frames_needed(total_bits: int, data_bits_per_frame: int) -> int: + return math.ceil(total_bits / data_bits_per_frame) + + +def index_to_bits(idx: int, index_bits: int) -> np.ndarray: return np.array( - [(idx >> (FRAME_INDEX_BITS - 1 - i)) & 1 for i in range(FRAME_INDEX_BITS)], + [(idx >> (index_bits - 1 - i)) & 1 for i in range(index_bits)], dtype=np.uint8 ) -def make_frame(frame_idx: int, data_bits: np.ndarray) -> bytes: - header = np.concatenate([SYNC_PATTERN, index_to_bits(frame_idx)]) +def make_frame(frame_idx: int, data_bits: np.ndarray, index_bits: int) -> bytes: + header = np.concatenate([SYNC_PATTERN, index_to_bits(frame_idx, index_bits)]) block_bits = np.concatenate([header, data_bits]) # (480,) grid = (block_bits.reshape(ROWS, COLS) * 255).astype(np.uint8) # (16, 30) frame_2d = np.repeat(np.repeat(grid, BLOCK_SIZE, axis=0), BLOCK_SIZE, axis=1) # (1080, 1920) @@ -187,18 +210,35 @@ def encode(input_path: str, output_path: str): print("\n[2/5] Reed-Solomon ECC...") ecc_data = ecc_encode(payload) + total_bits = len(ecc_data) * 8 + _, data_bits_default = frame_layout(DEFAULT_FRAME_INDEX_BITS) + frames_16 = frames_needed(total_bits, data_bits_default) + if frames_16 <= MAX_FRAME_COUNT_DEFAULT: + index_bits = DEFAULT_FRAME_INDEX_BITS + data_bits_per_frame = data_bits_default + num_frames = frames_16 + else: + _, data_bits_per_frame = frame_layout(EXTENDED_FRAME_INDEX_BITS) + num_frames = frames_needed(total_bits, data_bits_per_frame) + if num_frames > MAX_FRAME_COUNT_EXTENDED: + raise ValueError( + f"Payload needs {num_frames} frames which exceeds 32-bit frame index capacity " + f"(max frame count {MAX_FRAME_COUNT_EXTENDED})." + ) + index_bits = EXTENDED_FRAME_INDEX_BITS + all_bits = np.unpackbits(np.frombuffer(ecc_data, dtype=np.uint8)) - rem = len(all_bits) % DATA_BITS_PER_FRAME + rem = len(all_bits) % data_bits_per_frame if rem: - all_bits = np.concatenate([all_bits, np.zeros(DATA_BITS_PER_FRAME - rem, dtype=np.uint8)]) + all_bits = np.concatenate([all_bits, np.zeros(data_bits_per_frame - rem, dtype=np.uint8)]) - num_frames = len(all_bits) // DATA_BITS_PER_FRAME - qr_payload = build_qr_metadata(meta, len(ecc_data), num_frames) + num_frames = len(all_bits) // data_bits_per_frame + qr_payload = build_qr_metadata(meta, len(ecc_data), num_frames, index_bits) qr_frame = make_qr_frame(qr_payload) total_frames = num_frames + METADATA_FRAMES duration_sec = total_frames / FRAME_RATE - video_bps = DATA_BITS_PER_FRAME * FRAME_RATE // 8 + video_bps = (data_bits_per_frame * FRAME_RATE) // 8 audio_byte_count = min(len(ecc_data), int(duration_sec * BYTES_PER_SEC_AUDIO)) audio_coverage_pct = audio_byte_count / len(ecc_data) * 100 @@ -206,7 +246,8 @@ def encode(input_path: str, output_path: str): print(f" Metadata frames:{METADATA_FRAMES:>10} ({METADATA_DURATION_SEC:.1f}s)") print(f" Data frames: {num_frames:>10}") print(f" Total frames: {total_frames:>10} @ {FRAME_RATE}fps ({duration_sec:.1f}s)") - print(f" Video channel: {video_bps:,} bytes/sec ({DATA_BITS_PER_FRAME} bits/frame)") + print(f" Frame index: {index_bits} bits") + print(f" Video channel: {video_bps:,} bytes/sec ({data_bits_per_frame} bits/frame)") print(f" Audio channel: {BYTES_PER_SEC_AUDIO} bytes/sec (4-FSK @ {BAUD_RATE} baud)") print(f" Audio covers: {audio_byte_count:,}/{len(ecc_data):,} bytes ({audio_coverage_pct:.1f}%)") if audio_coverage_pct < 100: @@ -251,8 +292,8 @@ def encode(input_path: str, output_path: str): written = METADATA_FRAMES for i in range(num_frames): - chunk = all_bits[i * DATA_BITS_PER_FRAME:(i + 1) * DATA_BITS_PER_FRAME] - proc.stdin.write(make_frame(i, chunk)) + chunk = all_bits[i * data_bits_per_frame:(i + 1) * data_bits_per_frame] + proc.stdin.write(make_frame(i, chunk, index_bits)) written += 1 if written % 30 == 0 or written == total_frames: pct = written / total_frames * 100