feat: add FastAPI backend for intake session
This commit is contained in:
parent
a74a5d7c38
commit
ae73ab2329
4 changed files with 152 additions and 0 deletions
47
bookmark/api/routes.py
Normal file
47
bookmark/api/routes.py
Normal file
|
|
@ -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")
|
||||
17
bookmark/main.py
Normal file
17
bookmark/main.py
Normal file
|
|
@ -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
|
||||
5
bookmark/static/index.html
Normal file
5
bookmark/static/index.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8" /><title>Books in Hand — Intake</title></head>
|
||||
<body><h1>Books in Hand — Intake</h1></body>
|
||||
</html>
|
||||
83
tests/test_api.py
Normal file
83
tests/test_api.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue