diff --git a/bookmark/api/routes.py b/bookmark/api/routes.py new file mode 100644 index 0000000..eabc9c9 --- /dev/null +++ b/bookmark/api/routes.py @@ -0,0 +1,47 @@ +import cv2 +from fastapi import APIRouter, Request, Response + +router = APIRouter() + + +@router.get("/api/session-state") +def get_session_state(request: Request): + session = request.app.state.session + return {"state": session.state.value, "book_id": session.current_book_id} + + +@router.post("/api/observe") +def observe(request: Request): + session = request.app.state.session + camera = request.app.state.camera + frame = camera.read_frame() + request.app.state.latest_frame = frame + fired = session.observe_frame(frame) + return {"fired": fired, "state": session.state.value, "book_id": session.current_book_id} + + +@router.post("/api/manual-trigger") +def manual_trigger(request: Request): + session = request.app.state.session + if not hasattr(session.strategy, "trigger"): + return Response(status_code=400, content="Active strategy does not support manual trigger") + session.strategy.trigger() + return {"ok": True} + + +@router.post("/api/next-book") +def next_book(request: Request): + session = request.app.state.session + session.start_next_book() + return {"state": session.state.value} + + +@router.get("/api/preview.jpg") +def preview(request: Request): + frame = request.app.state.latest_frame + if frame is None: + return Response(status_code=404, content="No frame captured yet") + ok, buffer = cv2.imencode(".jpg", frame) + if not ok: + return Response(status_code=500, content="Failed to encode preview frame") + return Response(content=buffer.tobytes(), media_type="image/jpeg") diff --git a/bookmark/main.py b/bookmark/main.py new file mode 100644 index 0000000..3607630 --- /dev/null +++ b/bookmark/main.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from .api.routes import router + + +def create_app(session, camera) -> FastAPI: + app = FastAPI() + app.state.session = session + app.state.camera = camera + app.state.latest_frame = None + app.include_router(router) + static_dir = Path(__file__).parent / "static" + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") + return app diff --git a/bookmark/static/index.html b/bookmark/static/index.html new file mode 100644 index 0000000..9bb88d0 --- /dev/null +++ b/bookmark/static/index.html @@ -0,0 +1,5 @@ + + +Books in Hand — Intake +

Books in Hand — Intake

+ diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..876dec9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,83 @@ +import numpy as np +from fastapi.testclient import TestClient + +from bookmark.capture_strategies.manual_button import ManualButtonStrategy +from bookmark.catalogue import Catalogue +from bookmark.main import create_app +from bookmark.session import IntakeSession +from bookmark.sizing import MarkerRegion + + +def make_reference(size=100, color=200): + return np.full((size, size, 3), color, dtype=np.uint8) + + +def make_book_frame(size=100, ref_color=200, book_color=50, box=(20, 20, 40, 40)): + frame = np.full((size, size, 3), ref_color, dtype=np.uint8) + x, y, w, h = box + frame[y:y + h, x:x + w] = book_color + return frame + + +class FakeCamera: + def __init__(self, frame: np.ndarray) -> None: + self.frame = frame + + def read_frame(self) -> np.ndarray: + return self.frame + + +def make_client(tmp_path): + catalogue = Catalogue(tmp_path / "catalogue.csv") + strategy = ManualButtonStrategy() + reference = make_reference() + markers = [MarkerRegion(name="small_boundary", bucket="small", x=0, y=0, width=10, height=10)] + session = IntakeSession( + catalogue=catalogue, + strategy=strategy, + reference_frame=reference, + markers=markers, + captures_dir=tmp_path / "captures", + ) + camera = FakeCamera(reference) + app = create_app(session, camera) + return TestClient(app), session, camera + + +def test_session_state_starts_waiting_front(tmp_path): + client, _, _ = make_client(tmp_path) + response = client.get("/api/session-state") + assert response.status_code == 200 + assert response.json()["state"] == "waiting_front" + + +def test_observe_without_trigger_does_not_fire(tmp_path): + client, _, _ = make_client(tmp_path) + response = client.post("/api/observe") + assert response.status_code == 200 + assert response.json()["fired"] is False + + +def test_manual_trigger_then_observe_captures_front(tmp_path): + client, _, camera = make_client(tmp_path) + camera.frame = make_book_frame() + client.post("/api/manual-trigger") + response = client.post("/api/observe") + body = response.json() + assert body["fired"] is True + assert body["state"] == "waiting_back" + assert body["book_id"] is not None + + +def test_preview_returns_404_before_any_observe(tmp_path): + client, _, _ = make_client(tmp_path) + response = client.get("/api/preview.jpg") + assert response.status_code == 404 + + +def test_preview_returns_jpeg_after_observe(tmp_path): + client, _, _ = make_client(tmp_path) + client.post("/api/observe") + response = client.get("/api/preview.jpg") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg"