diff --git a/bookmark/config.py b/bookmark/config.py new file mode 100644 index 0000000..c28963e --- /dev/null +++ b/bookmark/config.py @@ -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"], + ) diff --git a/calibrate.py b/calibrate.py new file mode 100644 index 0000000..40391a3 --- /dev/null +++ b/calibrate.py @@ -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() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..d9013c9 --- /dev/null +++ b/config.example.json @@ -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" +} diff --git a/run.py b/run.py new file mode 100644 index 0000000..b08ba85 --- /dev/null +++ b/run.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9144a41 --- /dev/null +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..83d59d3 --- /dev/null +++ b/tests/test_run.py @@ -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))