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:
parent
6a78c84bcd
commit
8f5be59b7e
219
sample_data/make_receipt_video.py
Normal file
219
sample_data/make_receipt_video.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user