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:
parent
7807e32133
commit
c28afe0ac4
6 changed files with 198 additions and 0 deletions
30
bookmark/config.py
Normal file
30
bookmark/config.py
Normal 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
30
calibrate.py
Normal 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
13
config.example.json
Normal 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
56
run.py
Normal 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
27
tests/test_config.py
Normal 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
42
tests/test_run.py
Normal 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))
|
||||
Loading…
Reference in a new issue