feat: add config loading, calibration script, and production entrypoint

Wires together AppConfig.load() (JSON config -> markers/guide_box),
calibrate.py (one-time empty-platform reference capture), and run.py
(build_strategy dispatch + camera/session/FastAPI wiring via uvicorn)
to complete Phase 1 intake station integration.
This commit is contained in:
pyr0ball 2026-07-13 11:08:48 -07:00
parent 7807e32133
commit c28afe0ac4
6 changed files with 198 additions and 0 deletions

30
bookmark/config.py Normal file
View file

@ -0,0 +1,30 @@
import json
from dataclasses import dataclass
from pathlib import Path
from .sizing import MarkerRegion
@dataclass
class AppConfig:
device_index: int
catalogue_path: Path
captures_dir: Path
reference_frame_path: Path
markers: list[MarkerRegion]
guide_box: tuple[int, int, int, int]
trigger_strategy: str # "manual" | "motion_stop" | "stable_centered"
@classmethod
def load(cls, path: Path) -> "AppConfig":
data = json.loads(Path(path).read_text())
markers = [MarkerRegion(**marker) for marker in data["markers"]]
return cls(
device_index=data["device_index"],
catalogue_path=Path(data["catalogue_path"]),
captures_dir=Path(data["captures_dir"]),
reference_frame_path=Path(data["reference_frame_path"]),
markers=markers,
guide_box=tuple(data["guide_box"]),
trigger_strategy=data["trigger_strategy"],
)

30
calibrate.py Normal file
View file

@ -0,0 +1,30 @@
import sys
from pathlib import Path
import cv2
from bookmark.camera import CameraSource
def main() -> None:
device_index = int(sys.argv[1]) if len(sys.argv) > 1 else 0
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("reference.jpg")
camera = CameraSource(device_index=device_index)
camera.open()
print("Clear the platform of any book, then press ENTER to capture the reference frame.")
input()
frame = camera.read_frame()
camera.close()
cv2.imwrite(str(output_path), frame)
print(f"Reference frame saved to {output_path}")
print(
"Next: open the reference image in an editor to note the pixel coordinates of "
"each size-boundary marker and the guide box, then fill them into config.json's "
"\"markers\" and \"guide_box\" fields."
)
if __name__ == "__main__":
main()

13
config.example.json Normal file
View file

@ -0,0 +1,13 @@
{
"device_index": 0,
"catalogue_path": "catalogue.csv",
"captures_dir": "captures",
"reference_frame_path": "reference.jpg",
"markers": [
{"name": "small_boundary", "bucket": "small", "x": 0, "y": 0, "width": 20, "height": 20},
{"name": "medium_boundary", "bucket": "medium", "x": 0, "y": 0, "width": 20, "height": 20},
{"name": "large_boundary", "bucket": "large", "x": 0, "y": 0, "width": 20, "height": 20}
],
"guide_box": [0, 0, 100, 100],
"trigger_strategy": "manual"
}

56
run.py Normal file
View file

@ -0,0 +1,56 @@
import sys
from pathlib import Path
import cv2
import uvicorn
from bookmark.camera import CameraSource
from bookmark.capture_strategies.manual_button import ManualButtonStrategy
from bookmark.capture_strategies.motion_stop import MotionStopStrategy
from bookmark.capture_strategies.stable_centered import StableCenteredStrategy
from bookmark.catalogue import Catalogue
from bookmark.config import AppConfig
from bookmark.main import create_app
from bookmark.session import IntakeSession
def build_strategy(config: AppConfig, reference_frame):
if config.trigger_strategy == "manual":
return ManualButtonStrategy()
if config.trigger_strategy == "motion_stop":
return MotionStopStrategy()
if config.trigger_strategy == "stable_centered":
return StableCenteredStrategy(reference_frame=reference_frame, guide_box=config.guide_box)
raise ValueError(f"Unknown trigger_strategy: {config.trigger_strategy}")
def main() -> None:
config_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("config.json")
config = AppConfig.load(config_path)
reference_frame = cv2.imread(str(config.reference_frame_path))
if reference_frame is None:
raise RuntimeError(
f"Could not load reference frame from {config.reference_frame_path}. "
"Run calibrate.py first to capture an empty-platform reference image."
)
catalogue = Catalogue(config.catalogue_path)
strategy = build_strategy(config, reference_frame)
session = IntakeSession(
catalogue=catalogue,
strategy=strategy,
reference_frame=reference_frame,
markers=config.markers,
captures_dir=config.captures_dir,
)
camera = CameraSource(device_index=config.device_index)
camera.open()
app = create_app(session, camera)
uvicorn.run(app, host="0.0.0.0", port=8000)
if __name__ == "__main__":
main()

27
tests/test_config.py Normal file
View file

@ -0,0 +1,27 @@
import json
from bookmark.config import AppConfig
def test_load_config_from_json(tmp_path):
config_data = {
"device_index": 0,
"catalogue_path": str(tmp_path / "catalogue.csv"),
"captures_dir": str(tmp_path / "captures"),
"reference_frame_path": str(tmp_path / "reference.jpg"),
"markers": [
{"name": "small_boundary", "bucket": "small", "x": 0, "y": 0, "width": 10, "height": 10},
],
"guide_box": [20, 20, 60, 60],
"trigger_strategy": "manual",
}
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps(config_data))
config = AppConfig.load(config_path)
assert config.device_index == 0
assert config.trigger_strategy == "manual"
assert config.guide_box == (20, 20, 60, 60)
assert len(config.markers) == 1
assert config.markers[0].bucket == "small"

42
tests/test_run.py Normal file
View file

@ -0,0 +1,42 @@
from pathlib import Path
import numpy as np
import pytest
from bookmark.capture_strategies.manual_button import ManualButtonStrategy
from bookmark.capture_strategies.motion_stop import MotionStopStrategy
from bookmark.capture_strategies.stable_centered import StableCenteredStrategy
from bookmark.config import AppConfig
from run import build_strategy
def make_config(trigger_strategy: str) -> AppConfig:
return AppConfig(
device_index=0,
catalogue_path=Path("catalogue.csv"),
captures_dir=Path("captures"),
reference_frame_path=Path("reference.jpg"),
markers=[],
guide_box=(0, 0, 10, 10),
trigger_strategy=trigger_strategy,
)
def test_build_strategy_manual():
strategy = build_strategy(make_config("manual"), np.zeros((10, 10, 3), dtype=np.uint8))
assert isinstance(strategy, ManualButtonStrategy)
def test_build_strategy_motion_stop():
strategy = build_strategy(make_config("motion_stop"), np.zeros((10, 10, 3), dtype=np.uint8))
assert isinstance(strategy, MotionStopStrategy)
def test_build_strategy_stable_centered():
strategy = build_strategy(make_config("stable_centered"), np.zeros((10, 10, 3), dtype=np.uint8))
assert isinstance(strategy, StableCenteredStrategy)
def test_build_strategy_unknown_raises():
with pytest.raises(ValueError):
build_strategy(make_config("bogus"), np.zeros((10, 10, 3), dtype=np.uint8))