test: add synthetic platform smoke test using real book cover images

No camera hardware is on hand yet. This script composites public
Open Library cover images onto a synthetic platform background at
small/medium/large footprints and drives the real intake pipeline
(IntakeSession, Catalogue, capture strategies) end to end, in place
of solid-color test frames. Not part of the pytest suite — it fetches
over the network and is meant for human-visible verification.
This commit is contained in:
pyr0ball 2026-07-13 19:07:20 -07:00
parent f9553373d6
commit 7c0d8479d4
2 changed files with 152 additions and 0 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ captures/
catalogue.csv
reference.jpg
config.json
.smoke_test_run/

View file

@ -0,0 +1,151 @@
"""
Manual smoke test for the intake pipeline using real book cover images
composited onto a synthetic platform, in place of real camera hardware.
Why: no camera is on hand yet (see AGENTS.md hardware section). Unit tests
use solid-color synthetic frames, which never exercise crop_to_book's real
contour detection against actual image content. This script fetches a
handful of public book cover images from the Open Library covers API,
composites each onto a synthetic "platform" background at small/medium/
large footprint sizes, and drives the real IntakeSession/Catalogue/
CaptureStrategy pipeline end to end -- the same modules run.py wires up,
just with synthetic frames standing in for CameraSource.
Not part of the pytest suite: it fetches over the network and its point
is human-visible verification (inspect the saved crops), not CI assertion.
Usage:
.venv/bin/python scripts/synthetic_platform_smoke_test.py
"""
import shutil
import sys
import urllib.request
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bookmark.capture_strategies.manual_button import ManualButtonStrategy
from bookmark.catalogue import Catalogue
from bookmark.session import IntakeSession
from bookmark.sizing import MarkerRegion
PLATFORM_SIZE = (800, 600) # width, height
PLATFORM_COLOR = (210, 205, 195) # BGR, light neutral background
CENTER = (PLATFORM_SIZE[0] // 2, PLATFORM_SIZE[1] // 2)
# Marker regions sit along the horizontal centerline at the edge of each
# size category's footprint. A book covers a marker once its half-width
# reaches that marker's offset from center.
MARKERS = [
MarkerRegion(name="small_boundary", bucket="small", x=CENTER[0] + 65, y=CENTER[1] - 10, width=20, height=20),
MarkerRegion(name="medium_boundary", bucket="medium", x=CENTER[0] + 115, y=CENTER[1] - 10, width=20, height=20),
MarkerRegion(name="large_boundary", bucket="large", x=CENTER[0] + 190, y=CENTER[1] - 10, width=20, height=20),
]
# (half_width, half_height) footprints, chosen to straddle each marker boundary.
FOOTPRINTS = {
"small": (90, 130),
"medium": (140, 190),
"large": (220, 280),
}
COVERS = {
"small": "https://covers.openlibrary.org/b/isbn/9780141439518-L.jpg", # Pride and Prejudice
"medium": "https://covers.openlibrary.org/b/isbn/9780439708180-L.jpg", # Harry Potter
"large": "https://covers.openlibrary.org/b/isbn/9780062315007-L.jpg", # The Alchemist
}
def make_reference_frame() -> np.ndarray:
frame = np.full((PLATFORM_SIZE[1], PLATFORM_SIZE[0], 3), PLATFORM_COLOR, dtype=np.uint8)
return frame
def fetch_cover(url: str, dest: Path) -> np.ndarray:
req = urllib.request.Request(url, headers={"User-Agent": "bookmark-smoke-test/0.1"})
with urllib.request.urlopen(req, timeout=15) as response:
dest.write_bytes(response.read())
image = cv2.imread(str(dest))
if image is None:
raise RuntimeError(f"Could not decode downloaded cover from {url}")
return image
def composite_book(reference: np.ndarray, cover: np.ndarray, half_width: int, half_height: int) -> np.ndarray:
frame = reference.copy()
resized_cover = cv2.resize(cover, (half_width * 2, half_height * 2))
x0 = CENTER[0] - half_width
y0 = CENTER[1] - half_height
frame[y0:y0 + half_height * 2, x0:x0 + half_width * 2] = resized_cover
return frame
def main() -> None:
work_dir = Path(__file__).resolve().parent.parent / ".smoke_test_run"
if work_dir.exists():
shutil.rmtree(work_dir)
work_dir.mkdir()
cache_dir = work_dir / "downloaded_covers"
cache_dir.mkdir()
reference = make_reference_frame()
catalogue = Catalogue(work_dir / "catalogue.csv")
strategy = ManualButtonStrategy()
session = IntakeSession(
catalogue=catalogue,
strategy=strategy,
reference_frame=reference,
markers=MARKERS,
captures_dir=work_dir / "captures",
)
results = []
for expected_bucket, url in COVERS.items():
print(f"Fetching {expected_bucket} cover from {url} ...")
cover_path = cache_dir / f"{expected_bucket}.jpg"
cover = fetch_cover(url, cover_path)
half_w, half_h = FOOTPRINTS[expected_bucket]
front_frame = composite_book(reference, cover, half_w, half_h)
back_frame = composite_book(reference, cover, half_w, half_h) # same cover, stands in for a back cover
strategy.trigger()
fired = session.observe_frame(front_frame)
assert fired, f"front capture did not fire for {expected_bucket}"
book_id = session.current_book_id
strategy.trigger()
fired = session.observe_frame(back_frame)
assert fired, f"back capture did not fire for {expected_bucket}"
session.start_next_book()
row = next(r for r in catalogue.read_all() if r["book_id"] == book_id)
actual_bucket = row["size_bucket"]
status = "PASS" if actual_bucket == expected_bucket else "FAIL"
results.append((expected_bucket, actual_bucket, status, book_id))
print(f" book_id={book_id} expected={expected_bucket} got={actual_bucket} [{status}]")
print(f" front crop: {row['front_cropped_path']}")
print(f" back crop: {row['back_cropped_path']}")
print()
print("=" * 60)
all_pass = all(r[2] == "PASS" for r in results)
for expected, actual, status, book_id in results:
print(f"{status}: expected={expected:8s} got={actual:8s} book_id={book_id}")
print("=" * 60)
print(f"Artifacts written to: {work_dir}")
print("Overall:", "ALL PASS" if all_pass else "SOME FAILED")
if not all_pass:
sys.exit(1)
if __name__ == "__main__":
main()