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:
parent
f9553373d6
commit
7c0d8479d4
2 changed files with 152 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,3 +7,4 @@ captures/
|
|||
catalogue.csv
|
||||
reference.jpg
|
||||
config.json
|
||||
.smoke_test_run/
|
||||
|
|
|
|||
151
scripts/synthetic_platform_smoke_test.py
Normal file
151
scripts/synthetic_platform_smoke_test.py
Normal 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()
|
||||
Loading…
Reference in a new issue