"""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)