Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
+--------+--------+--------+--------+--------+ +--------+
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
67 changes: 51 additions & 16 deletions decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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")),
}

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -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)]

Expand Down
77 changes: 59 additions & 18 deletions encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import json
import hashlib
import math
import struct
import subprocess
import tempfile
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -66,14 +70,15 @@ 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"],
"s": meta["size"],
"h": meta["sha256"],
"e": ecc_len,
"n": num_frames,
"i": index_bits,
"m": METADATA_FRAMES,
}
return json.dumps(qr_meta, separators=(",", ":")).encode()
Expand All @@ -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)
Expand Down Expand Up @@ -187,26 +210,44 @@ 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

print(f"\n[3/5] Plan")
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:
Expand Down Expand Up @@ -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
Expand Down