Add realistic receipt test video generator

Renders three thermal-paper style receipts with real text (Home Depot,
Metro, Petro-Canada) placed one at a time on a black mat with hand motion
and drop shadows, matching the recommended real-world recording setup.
Verified: detect finds all 3 receipts, 7 stable windows, clean crops.
This commit is contained in:
ilia 2026-07-07 17:11:28 -04:00
parent 6a78c84bcd
commit 8f5be59b7e

View File

@ -0,0 +1,219 @@
"""Generate a realistic receipt test video: three printed-style receipts placed
one at a time on a black mat, with hand motion between placements.
Simulates the recommended real-world setup: dark non-reflective background,
receipts with real text (so future OCR can be tested on the same crops).
Usage: python sample_data/make_receipt_video.py [output.mp4]
"""
from __future__ import annotations
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
FPS = 24
W, H = 1280, 720
RNG = np.random.default_rng(7)
FONT_CANDIDATES = [
"/System/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Monaco.ttf",
"/System/Library/Fonts/Supplemental/Courier New.ttf",
]
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in FONT_CANDIDATES:
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
RECEIPTS = [
{
"header": ["THE HOME DEPOT", "1000 GERRARD ST E", "TORONTO, ON M4M 3G6", "(416) 462-3330"],
"lines": [
"2023-03-14 09:41 #4821",
"",
"2X4X8 SPF LUMBER 6.45",
" QTY 6 38.70",
"DECK SCREWS #8 12.97",
"WOOD GLUE 118ML 5.49",
"PAINT ROLLER KIT 14.98",
"",
"SUBTOTAL 72.14",
"HST 13% 9.38",
"TOTAL 81.52",
"",
"VISA ************4412",
"AUTH 048221",
],
"footer": ["THANK YOU FOR SHOPPING", "AT THE HOME DEPOT"],
},
{
"header": ["METRO", "665 DANFORTH AVE", "TORONTO ON", "STORE 227"],
"lines": [
"2023-06-02 18:22",
"",
"BANANAS 1.2KG 2.14",
"MILK 2% 4L 6.49",
"BREAD WHOLE WHEAT 3.79",
"EGGS LARGE 12 4.99",
"CHEDDAR 400G 8.99",
"TOMATOES ON VINE 4.23",
"",
"SUBTOTAL 30.63",
"TOTAL 30.63",
"",
"DEBIT APPROVED",
],
"footer": ["THANK YOU", "POINTS EARNED: 30"],
},
{
"header": ["PETRO-CANADA", "SITE 09217", "1181 QUEEN ST E", "TORONTO ON"],
"lines": [
"2023-08-19 07:55",
"",
"PUMP 04 REGULAR",
"LITRES 41.230",
"PRICE/L 1.579",
"",
"FUEL TOTAL 65.10",
"HST INCLUDED 7.49",
"",
"MASTERCARD ******7719",
"APPROVED 00",
],
"footer": ["PETRO-POINTS: 650", "DRIVE SAFELY"],
},
]
def make_receipt_image(spec: dict) -> np.ndarray:
"""Render one receipt as a BGR numpy image (thermal-paper style)."""
width = 380
header_font = _font(26)
body_font = _font(19)
line_h = 28
rows = len(spec["header"]) + len(spec["lines"]) + len(spec["footer"]) + 4
height = 40 + rows * line_h
img = Image.new("RGB", (width, height), (250, 249, 244))
draw = ImageDraw.Draw(img)
y = 20
for text in spec["header"]:
bbox = draw.textbbox((0, 0), text, font=header_font)
draw.text(((width - bbox[2]) // 2, y), text, fill=(35, 35, 40), font=header_font)
y += line_h + 2
y += line_h // 2
for text in spec["lines"]:
draw.text((24, y), text, fill=(45, 45, 50), font=body_font)
y += line_h
y += line_h // 2
for text in spec["footer"]:
bbox = draw.textbbox((0, 0), text, font=body_font)
draw.text(((width - bbox[2]) // 2, y), text, fill=(45, 45, 50), font=body_font)
y += line_h
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
# Thermal paper isn't uniform: faint vertical shading + speckle.
shade = (8 * np.sin(np.linspace(0, 3 * np.pi, arr.shape[1]))).astype(np.int16)
arr = np.clip(arr.astype(np.int16) + shade[None, :, None], 0, 255)
speckle = RNG.integers(-4, 4, size=arr.shape[:2] + (1,), dtype=np.int16)
return np.clip(arr + speckle, 0, 255).astype(np.uint8)
def black_mat() -> np.ndarray:
"""Dark matte background with mild texture."""
bg = np.full((H, W, 3), (28, 26, 25), dtype=np.uint8)
noise = RNG.integers(-5, 5, size=(H, W, 1), dtype=np.int16)
return np.clip(bg.astype(np.int16) + noise, 0, 255).astype(np.uint8)
def paste_rotated(canvas: np.ndarray, doc: np.ndarray, center: tuple[int, int], angle: float) -> None:
dh, dw = doc.shape[:2]
scale = min(1.0, (H - 60) / dh)
if scale < 1.0:
doc = cv2.resize(doc, (int(dw * scale), int(dh * scale)), interpolation=cv2.INTER_AREA)
dh, dw = doc.shape[:2]
big = np.zeros((H, W, 3), dtype=np.uint8)
mask = np.zeros((H, W), dtype=np.uint8)
x0 = int(np.clip(center[0] - dw // 2, -dw + 10, W - 10))
y0 = int(np.clip(center[1] - dh // 2, 0, max(0, H - dh)))
# Clip paste region to the canvas.
sx0, sy0 = max(0, -x0), max(0, -y0)
dx0, dy0 = max(0, x0), max(0, y0)
pw = min(dw - sx0, W - dx0)
ph = min(dh - sy0, H - dy0)
if pw <= 0 or ph <= 0:
return
big[dy0 : dy0 + ph, dx0 : dx0 + pw] = doc[sy0 : sy0 + ph, sx0 : sx0 + pw]
mask[dy0 : dy0 + ph, dx0 : dx0 + pw] = 255
matrix = cv2.getRotationMatrix2D((dx0 + pw / 2, dy0 + ph / 2), angle, 1.0)
big = cv2.warpAffine(big, matrix, (W, H))
mask = cv2.warpAffine(mask, matrix, (W, H))
# Soft drop shadow under the paper.
shadow = cv2.dilate(mask, np.ones((15, 15), np.uint8))
shadow = cv2.GaussianBlur(shadow, (31, 31), 0)
dark = (canvas.astype(np.int16) - (shadow[..., None] // 12)).clip(0, 255)
canvas[:] = dark.astype(np.uint8)
canvas[mask > 0] = big[mask > 0]
def draw_hand(canvas: np.ndarray, progress: float) -> None:
cx = int(W * (1.15 - progress * 0.65))
cy = int(H * 0.55 + 50 * np.sin(progress * np.pi))
cv2.ellipse(canvas, (cx, cy), (120, 75), 25, 0, 360, (105, 135, 185), -1)
cv2.ellipse(canvas, (cx + 80, cy - 30), (55, 30), 40, 0, 360, (105, 135, 185), -1)
def render(out_path: Path) -> None:
receipts = [make_receipt_image(spec) for spec in RECEIPTS]
angles = [3.5, -2.0, 5.0]
bg = black_mat()
writer = cv2.VideoWriter(str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), FPS, (W, H))
def emit(seconds: float, draw_fn) -> None:
for i in range(int(seconds * FPS)):
frame = bg.copy()
draw_fn(frame, i / max(1, int(seconds * FPS) - 1))
noise = RNG.integers(-2, 2, size=(H, W, 1), dtype=np.int16)
frame = np.clip(frame.astype(np.int16) + noise, 0, 255).astype(np.uint8)
writer.write(frame)
emit(2.0, lambda f, p: None) # empty mat
for doc, angle in zip(receipts, angles):
center = (W // 2, H // 2)
def entering(f, p, doc=doc, angle=angle, center=center):
paste_rotated(f, doc, (int(W * 1.1 - p * (W * 1.1 - center[0])), center[1]), angle)
draw_hand(f, p)
def stable(f, p, doc=doc, angle=angle, center=center):
paste_rotated(f, doc, center, angle)
def leaving(f, p, doc=doc, angle=angle, center=center):
paste_rotated(f, doc, (int(center[0] - p * W * 0.8), center[1]), angle)
draw_hand(f, 1.0 - p)
emit(1.0, entering)
emit(4.0, stable)
emit(1.0, leaving)
emit(1.5, lambda f, p: None)
writer.release()
print(f"Wrote {out_path}")
if __name__ == "__main__":
default = Path(__file__).parent / "videos" / "receipts_sample.mp4"
out = Path(sys.argv[1]) if len(sys.argv) > 1 else default
out.parent.mkdir(parents=True, exist_ok=True)
render(out)