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.
151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
"""
|
|
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()
|