From 7c0d8479d46ee5562d5b5b9ca80f070a16c50bab Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 19:07:20 -0700 Subject: [PATCH] test: add synthetic platform smoke test using real book cover images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + scripts/synthetic_platform_smoke_test.py | 151 +++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 scripts/synthetic_platform_smoke_test.py diff --git a/.gitignore b/.gitignore index a9b8a98..299a0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ captures/ catalogue.csv reference.jpg config.json +.smoke_test_run/ diff --git a/scripts/synthetic_platform_smoke_test.py b/scripts/synthetic_platform_smoke_test.py new file mode 100644 index 0000000..2d5d9a5 --- /dev/null +++ b/scripts/synthetic_platform_smoke_test.py @@ -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()