"""Configuration loading with built-in defaults, overridable via config.yaml.""" from __future__ import annotations from dataclasses import dataclass, field, fields, is_dataclass from pathlib import Path from typing import Any import yaml @dataclass class CaptureConfig: sample_fps: float = 8.0 processing_width: int = 960 @dataclass class AudioConfig: sample_rate: int = 16000 @dataclass class MotionConfig: blur_ksize: int = 21 pixel_threshold: int = 12 threshold: float = 0.02 stable_min_duration_s: float = 1.0 @dataclass class DocumentConfig: min_area_ratio: float = 0.04 canny_low: int = 50 canny_high: int = 150 enhance: bool = False @dataclass class SpeechConfig: window_before_s: float = 3.0 window_after_s: float = 5.0 @dataclass class OutputConfig: dir: str = "./output" consume_dir: str | None = None @dataclass class Config: capture: CaptureConfig = field(default_factory=CaptureConfig) audio: AudioConfig = field(default_factory=AudioConfig) motion: MotionConfig = field(default_factory=MotionConfig) document: DocumentConfig = field(default_factory=DocumentConfig) speech: SpeechConfig = field(default_factory=SpeechConfig) output: OutputConfig = field(default_factory=OutputConfig) def _merge_into(instance: Any, data: dict[str, Any]) -> Any: """Overlay a dict of overrides onto a dataclass instance, recursively.""" for f in fields(instance): if f.name not in data: continue value = data[f.name] current = getattr(instance, f.name) if is_dataclass(current) and isinstance(value, dict): _merge_into(current, value) else: setattr(instance, f.name, value) return instance def load_config(path: str | Path | None = None) -> Config: """Load config.yaml if present; unknown keys are ignored, missing keys use defaults.""" cfg = Config() if path is None: return cfg path = Path(path) if not path.exists(): raise FileNotFoundError(f"Config file not found: {path}") data = yaml.safe_load(path.read_text()) or {} return _merge_into(cfg, data) def config_to_dict(cfg: Config) -> dict[str, Any]: """Serialize the effective config (for embedding in run reports).""" from dataclasses import asdict return asdict(cfg)