framesig 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
framesig/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """framesig — detect on-screen events in any video by pixel signature.
2
+
3
+ Define a region of interest plus a colour/brightness signature in YAML, and
4
+ framesig scans the video (sub-sampling frames, caching scores) to report the
5
+ timestamps where the event appears. It is game- and source-agnostic: it only
6
+ ever reasons about pixels, never about what produced them.
7
+
8
+ Typical use::
9
+
10
+ from framesig import load_config, scan_video, detect_all
11
+
12
+ config = load_config("signatures.yaml")
13
+ result = scan_video("clip.mp4", config)
14
+ events = detect_all(config, result)
15
+ for name, evs in events.items():
16
+ for e in evs:
17
+ print(name, round(e.peak_t, 2))
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .cache import ScoreCache
23
+ from .config import Config, Signature, load_config, parse_config
24
+ from .detectors import (
25
+ Brightness,
26
+ ChannelDominance,
27
+ ColorFraction,
28
+ Detector,
29
+ SceneChange,
30
+ available_detectors,
31
+ build_detector,
32
+ )
33
+ from .errors import (
34
+ ConfigError,
35
+ DependencyError,
36
+ FramesigError,
37
+ VideoError,
38
+ )
39
+ from .events import Event, detect_events
40
+ from .regions import Region
41
+ from .scanner import ScanResult, detect_all, scan_video
42
+ from .videogen import GroundTruth, SampleVideo, generate_sample_video
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ # config
49
+ "Config",
50
+ "Signature",
51
+ "load_config",
52
+ "parse_config",
53
+ # regions & detectors
54
+ "Region",
55
+ "Detector",
56
+ "Brightness",
57
+ "ChannelDominance",
58
+ "ColorFraction",
59
+ "SceneChange",
60
+ "available_detectors",
61
+ "build_detector",
62
+ # scanning & events
63
+ "ScanResult",
64
+ "scan_video",
65
+ "detect_all",
66
+ "Event",
67
+ "detect_events",
68
+ "ScoreCache",
69
+ # sample generation
70
+ "generate_sample_video",
71
+ "SampleVideo",
72
+ "GroundTruth",
73
+ # errors
74
+ "FramesigError",
75
+ "ConfigError",
76
+ "VideoError",
77
+ "DependencyError",
78
+ ]
framesig/_coerce.py ADDED
@@ -0,0 +1,47 @@
1
+ """Shared coercions for turning parsed YAML scalars into typed values.
2
+
3
+ YAML hands us whatever the document contained, so ``threshold: high`` arrives as
4
+ a ``str`` and a bare ``gain:`` arrives as ``None``. Every conversion that can
5
+ fail on user input goes through this module, which reports the failure as a
6
+ :class:`~framesig.errors.ConfigError` — the contract stated in
7
+ :mod:`framesig.errors` and in :func:`framesig.config.parse_config`.
8
+
9
+ It lives in its own module so that both :mod:`framesig.config` and
10
+ :mod:`framesig.regions` can use it without an import cycle.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from .errors import ConfigError
18
+
19
+
20
+ def as_number(field: str, value: Any) -> float:
21
+ """Coerce ``value`` to ``float``.
22
+
23
+ Args:
24
+ field: Human-readable name of the field, used in the error message.
25
+ value: The raw value straight out of the YAML document.
26
+
27
+ Raises:
28
+ ConfigError: If ``float()`` rejects ``value``.
29
+ """
30
+ try:
31
+ return float(value)
32
+ except (ValueError, TypeError) as exc:
33
+ raise ConfigError(f"{field} must be a number, got {value!r}") from exc
34
+
35
+
36
+ def as_bool(field: str, value: Any) -> bool:
37
+ """Return ``value`` if it is a real ``bool``.
38
+
39
+ Deliberately strict: ``bool("false")`` is ``True``, so quietly coercing a
40
+ quoted YAML string would invert the user's intent.
41
+
42
+ Raises:
43
+ ConfigError: If ``value`` is not a ``bool``.
44
+ """
45
+ if not isinstance(value, bool):
46
+ raise ConfigError(f"{field} must be true or false, got {value!r}")
47
+ return value
framesig/cache.py ADDED
@@ -0,0 +1,85 @@
1
+ """On-disk caching of per-frame scores.
2
+
3
+ Scanning a video is the expensive part; thresholding the resulting scores is
4
+ practically free. framesig therefore caches the raw score timelines keyed by
5
+
6
+ * a fingerprint of the *video* (path, size, mtime), and
7
+ * a fingerprint of the *score-relevant* config (sampling rate, regions, and
8
+ detector parameters — but **not** thresholds, ``min_duration`` or
9
+ ``merge_gap``).
10
+
11
+ That split is the whole point: tweak a threshold, re-run, and framesig reuses
12
+ the cached scores for an instant answer. Change a detector's parameters and the
13
+ fingerprint changes, so a stale cache is transparently ignored.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ CACHE_VERSION = 2
24
+
25
+
26
+ def video_fingerprint(path: str | Path) -> dict[str, Any]:
27
+ """Cheap identity for a video file: absolute path, byte size and mtime."""
28
+ p = Path(path)
29
+ stat = p.stat()
30
+ return {"path": str(p.resolve()), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
31
+
32
+
33
+ def _digest(payload: dict[str, Any]) -> str:
34
+ blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
35
+ return hashlib.sha256(blob).hexdigest()[:16]
36
+
37
+
38
+ def cache_key(video_fp: dict[str, Any], config_fp: dict[str, Any]) -> str:
39
+ """Combine video and config fingerprints into a stable filename stem."""
40
+ return _digest({"video": video_fp, "config": config_fp, "v": CACHE_VERSION})
41
+
42
+
43
+ class ScoreCache:
44
+ """A tiny JSON-backed cache of score timelines.
45
+
46
+ Args:
47
+ directory: Where cache files live. Created on first write.
48
+ """
49
+
50
+ def __init__(self, directory: str | Path) -> None:
51
+ self.directory = Path(directory)
52
+
53
+ def _path_for(self, key: str) -> Path:
54
+ return self.directory / f"scores_{key}.json"
55
+
56
+ def load(self, key: str) -> dict[str, Any] | None:
57
+ """Return the cached payload for ``key``, or ``None`` on a miss.
58
+
59
+ A corrupt or unreadable cache file is treated as a miss rather than an
60
+ error, so a bad cache can never break a scan.
61
+ """
62
+ path = self._path_for(key)
63
+ if not path.exists():
64
+ return None
65
+ try:
66
+ payload = json.loads(path.read_text(encoding="utf-8"))
67
+ except (OSError, json.JSONDecodeError):
68
+ return None
69
+ if not isinstance(payload, dict) or payload.get("version") != CACHE_VERSION:
70
+ return None
71
+ return payload
72
+
73
+ def store(self, key: str, payload: dict[str, Any]) -> Path:
74
+ """Write ``payload`` under ``key`` and return the file path.
75
+
76
+ The write is atomic (temp file + replace) so a crash mid-write cannot
77
+ leave a half-written cache behind.
78
+ """
79
+ self.directory.mkdir(parents=True, exist_ok=True)
80
+ payload = {**payload, "version": CACHE_VERSION}
81
+ path = self._path_for(key)
82
+ tmp = path.with_suffix(".json.tmp")
83
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
84
+ tmp.replace(path)
85
+ return path
framesig/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """Command-line interface for framesig.
2
+
3
+ Subcommands:
4
+ * ``scan`` — scan a video against a YAML config and emit JSON events.
5
+ * ``gen-sample`` — render the self-contained synthetic test clip.
6
+ * ``demo`` — generate the clip, scan it, and write events + a chart.
7
+ * ``detectors`` — list the available detector types.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Sequence
17
+
18
+ from . import __version__
19
+ from .config import Config, load_config, parse_config
20
+ from .detectors import available_detectors
21
+ from .errors import ConfigError, FramesigError
22
+ from .events import Event
23
+ from .scanner import ScanResult, detect_all, scan_video
24
+ from .videogen import generate_sample_video
25
+
26
+ # Canonical demo config, mirrored by examples/flash.yaml. Matches the ground
27
+ # truth baked into framesig.videogen.
28
+ DEMO_CONFIG: dict[str, Any] = {
29
+ "sample_fps": 10,
30
+ "regions": {
31
+ "hud_top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.55},
32
+ "kill_feed": {"x": 0.08, "y": 0.74, "w": 0.84, "h": 0.18},
33
+ "screen_mid": {"x": 0.30, "y": 0.58, "w": 0.40, "h": 0.12},
34
+ "corner": {"x": 0.82, "y": 0.94, "w": 0.18, "h": 0.06},
35
+ },
36
+ "signatures": [
37
+ {
38
+ "name": "death_screen",
39
+ "region": "hud_top",
40
+ "detector": "channel_dominance",
41
+ "params": {"channel": "red", "gain": 2.0},
42
+ "threshold": 0.30,
43
+ "min_duration": 0.15,
44
+ "merge_gap": 0.25,
45
+ },
46
+ {
47
+ "name": "kill_feed",
48
+ "region": "kill_feed",
49
+ "detector": "color_fraction",
50
+ "params": {
51
+ "hsv_low": [0, 120, 70],
52
+ "hsv_high": [10, 255, 255],
53
+ "hsv_low2": [170, 120, 70],
54
+ "hsv_high2": [179, 255, 255],
55
+ },
56
+ "threshold": 0.20,
57
+ "min_duration": 0.10,
58
+ "merge_gap": 0.20,
59
+ },
60
+ {
61
+ "name": "white_flash",
62
+ "region": "screen_mid",
63
+ "detector": "brightness",
64
+ "threshold": 0.75,
65
+ "min_duration": 0.10,
66
+ },
67
+ {
68
+ "name": "scene_cut",
69
+ "region": "corner",
70
+ "detector": "scene_change",
71
+ "threshold": 0.15,
72
+ "min_duration": 0.0,
73
+ },
74
+ ],
75
+ }
76
+
77
+
78
+ def _events_payload(events: dict[str, list[Event]]) -> dict[str, list[dict[str, Any]]]:
79
+ return {name: [e.to_dict() for e in evs] for name, evs in events.items()}
80
+
81
+
82
+ def _print_summary(result: ScanResult, events: dict[str, list[Event]], stream) -> None:
83
+ meta = result.meta
84
+ src = "cache" if result.from_cache else "scan"
85
+ total = sum(len(v) for v in events.values())
86
+ print(
87
+ f"{Path(result.video).name} "
88
+ f"{meta.get('width')}x{meta.get('height')} "
89
+ f"{meta.get('duration', 0):.1f}s "
90
+ f"{meta.get('samples')} samples @ {meta.get('sample_fps')} fps ({src})",
91
+ file=stream,
92
+ )
93
+ print(f"{total} event(s) across {len(events)} signature(s)", file=stream)
94
+ for name, evs in events.items():
95
+ print(f" {name}: {len(evs)}", file=stream)
96
+ for e in evs:
97
+ print(
98
+ f" [{e.start:6.2f}s -> {e.end:6.2f}s] "
99
+ f"peak {e.peak_score:.2f} @ {e.peak_t:6.2f}s "
100
+ f"({e.samples} samples)",
101
+ file=stream,
102
+ )
103
+
104
+
105
+ def _write_json(obj: dict[str, Any], out: str | None) -> None:
106
+ text = json.dumps(obj, indent=2)
107
+ if out is None or out == "-":
108
+ print(text)
109
+ else:
110
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
111
+ Path(out).write_text(text + "\n", encoding="utf-8")
112
+ print(f"wrote {out}", file=sys.stderr)
113
+
114
+
115
+ def _run_scan(config: Config, video: str, *, use_cache: bool) -> tuple[ScanResult, dict[str, list[Event]]]:
116
+ result = scan_video(video, config, use_cache=use_cache)
117
+ events = detect_all(config, result)
118
+ return result, events
119
+
120
+
121
+ def cmd_scan(args: argparse.Namespace) -> int:
122
+ config = load_config(args.config)
123
+ if args.sample_fps is not None:
124
+ # Mutating the dataclass skips parse_config's validation, so repeat it
125
+ # here: the flag must obey the same rule as the YAML key.
126
+ if args.sample_fps <= 0:
127
+ raise ConfigError("--sample-fps must be positive")
128
+ config.sample_fps = float(args.sample_fps)
129
+ result, events = _run_scan(config, args.video, use_cache=not args.no_cache)
130
+
131
+ if not args.quiet:
132
+ _print_summary(result, events, sys.stderr)
133
+ payload = {
134
+ "video": result.video,
135
+ "meta": result.meta,
136
+ "from_cache": result.from_cache,
137
+ "events": _events_payload(events),
138
+ }
139
+ _write_json(payload, args.output)
140
+
141
+ if args.chart:
142
+ from .viz import render_timeline
143
+
144
+ path = render_timeline(config, result, events, args.chart)
145
+ print(f"wrote chart {path}", file=sys.stderr)
146
+ return 0
147
+
148
+
149
+ def cmd_gen_sample(args: argparse.Namespace) -> int:
150
+ sample = generate_sample_video(args.output)
151
+ print(f"wrote {sample.path} ({sample.width}x{sample.height}, {sample.duration:g}s)", file=sys.stderr)
152
+ if args.print_truth:
153
+ _write_json(
154
+ {
155
+ "intervals": {k: v for k, v in sample.ground_truth.intervals.items()},
156
+ "cuts": sample.ground_truth.cuts,
157
+ },
158
+ "-",
159
+ )
160
+ return 0
161
+
162
+
163
+ def cmd_demo(args: argparse.Namespace) -> int:
164
+ out_dir = Path(args.out_dir)
165
+ out_dir.mkdir(parents=True, exist_ok=True)
166
+ video_path = out_dir / "sample.mp4"
167
+
168
+ print("[1/3] rendering synthetic clip with ffmpeg...", file=sys.stderr)
169
+ generate_sample_video(video_path)
170
+
171
+ print("[2/3] scanning for pixel signatures...", file=sys.stderr)
172
+ config = parse_config(DEMO_CONFIG)
173
+ result, events = _run_scan(config, str(video_path), use_cache=not args.no_cache)
174
+ _print_summary(result, events, sys.stderr)
175
+
176
+ events_path = out_dir / "events.json"
177
+ _write_json(
178
+ {"video": result.video, "meta": result.meta, "events": _events_payload(events)},
179
+ str(events_path),
180
+ )
181
+
182
+ print("[3/3] rendering score-timeline chart...", file=sys.stderr)
183
+ from .viz import render_timeline
184
+
185
+ chart_path = render_timeline(config, result, events, out_dir / "timeline.png")
186
+ print(f"done. outputs in {out_dir}/", file=sys.stderr)
187
+ print(f" {video_path.name}, {events_path.name}, {chart_path.name}", file=sys.stderr)
188
+ return 0
189
+
190
+
191
+ def cmd_detectors(_args: argparse.Namespace) -> int:
192
+ for name in available_detectors():
193
+ print(name)
194
+ return 0
195
+
196
+
197
+ def build_parser() -> argparse.ArgumentParser:
198
+ parser = argparse.ArgumentParser(
199
+ prog="framesig",
200
+ description="Detect on-screen events in a video by pixel signature.",
201
+ )
202
+ parser.add_argument("--version", action="version", version=f"framesig {__version__}")
203
+ sub = parser.add_subparsers(dest="command", required=True)
204
+
205
+ p_scan = sub.add_parser("scan", help="scan a video against a YAML config")
206
+ p_scan.add_argument("video", help="path to the video file")
207
+ p_scan.add_argument("-c", "--config", required=True, help="YAML config path")
208
+ p_scan.add_argument("-o", "--output", help="write events JSON here ('-' for stdout)")
209
+ p_scan.add_argument("--sample-fps", type=float, help="override the sampling rate")
210
+ p_scan.add_argument("--no-cache", action="store_true", help="ignore and skip the score cache")
211
+ p_scan.add_argument("--chart", help="also render a score-timeline PNG here")
212
+ p_scan.add_argument("-q", "--quiet", action="store_true", help="suppress the text summary")
213
+ p_scan.set_defaults(func=cmd_scan)
214
+
215
+ p_gen = sub.add_parser("gen-sample", help="render the synthetic test clip")
216
+ p_gen.add_argument("output", help="destination .mp4 path")
217
+ p_gen.add_argument("--print-truth", action="store_true", help="print the ground-truth events")
218
+ p_gen.set_defaults(func=cmd_gen_sample)
219
+
220
+ p_demo = sub.add_parser("demo", help="generate a clip, scan it, and chart the result")
221
+ p_demo.add_argument("--out-dir", default="framesig_demo", help="output directory")
222
+ p_demo.add_argument("--no-cache", action="store_true", help="skip the score cache")
223
+ p_demo.set_defaults(func=cmd_demo)
224
+
225
+ p_det = sub.add_parser("detectors", help="list available detector types")
226
+ p_det.set_defaults(func=cmd_detectors)
227
+
228
+ return parser
229
+
230
+
231
+ def main(argv: Sequence[str] | None = None) -> int:
232
+ """Entry point. Returns a process exit code."""
233
+ parser = build_parser()
234
+ args = parser.parse_args(argv)
235
+ try:
236
+ return int(args.func(args))
237
+ except FramesigError as exc:
238
+ print(f"framesig: error: {exc}", file=sys.stderr)
239
+ return 2
240
+
241
+
242
+ if __name__ == "__main__": # pragma: no cover
243
+ raise SystemExit(main())
framesig/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """Declarative configuration.
2
+
3
+ A framesig run is described entirely by a small YAML document: how densely to
4
+ sample the video, which regions of interest exist, and which signatures to
5
+ evaluate over them. This module parses that document into validated dataclasses.
6
+
7
+ Example:
8
+ sample_fps: 10
9
+ regions:
10
+ hud_top: {x: 0.0, y: 0.0, w: 1.0, h: 0.55}
11
+ kill_feed: {x: 0.08, y: 0.74, w: 0.84, h: 0.18}
12
+ signatures:
13
+ - name: death_screen
14
+ region: hud_top
15
+ detector: channel_dominance
16
+ params: {channel: red, gain: 2.0}
17
+ threshold: 0.30
18
+ min_duration: 0.1
19
+ merge_gap: 0.25
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Mapping
27
+
28
+ import yaml
29
+
30
+ from ._coerce import as_number
31
+ from .detectors import Detector, build_detector
32
+ from .errors import ConfigError
33
+ from .regions import Region
34
+
35
+ _ALLOWED_TOP_KEYS = {"sample_fps", "regions", "signatures", "cache_dir"}
36
+
37
+ _ALLOWED_SIGNATURE_KEYS = {
38
+ "name",
39
+ "region",
40
+ "detector",
41
+ "params",
42
+ "threshold",
43
+ "min_duration",
44
+ "merge_gap",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Signature:
50
+ """One thing to look for: a detector applied to a region, plus event rules.
51
+
52
+ Attributes:
53
+ name: Unique identifier, used in output and cache keys.
54
+ region: The :class:`~framesig.regions.Region` to crop before scoring.
55
+ detector: The instantiated :class:`~framesig.detectors.Detector`.
56
+ threshold: Minimum score for a sample to count as active.
57
+ min_duration: Discard events shorter than this many seconds.
58
+ merge_gap: Merge active runs separated by at most this many seconds.
59
+ """
60
+
61
+ name: str
62
+ region: Region
63
+ detector: Detector
64
+ threshold: float = 0.5
65
+ min_duration: float = 0.0
66
+ merge_gap: float = 0.0
67
+
68
+ def score_fingerprint(self) -> dict[str, Any]:
69
+ """Fields that change the *scores* (not the thresholds)."""
70
+ return {"region": self.region.fingerprint(), "detector": self.detector.fingerprint()}
71
+
72
+
73
+ @dataclass
74
+ class Config:
75
+ """A fully validated framesig configuration.
76
+
77
+ Attributes:
78
+ sample_fps: Target sampling rate in frames per second. The scanner
79
+ processes roughly this many frames per second of video, regardless
80
+ of the source frame rate.
81
+ regions: Declared regions, keyed by name.
82
+ signatures: The signatures to evaluate.
83
+ cache_dir: Optional directory for the score cache. ``None`` defers to
84
+ the scanner's default (a ``.framesig_cache`` folder next to the video).
85
+ """
86
+
87
+ sample_fps: float = 5.0
88
+ regions: dict[str, Region] = field(default_factory=dict)
89
+ signatures: list[Signature] = field(default_factory=list)
90
+ cache_dir: str | None = None
91
+
92
+ def score_fingerprint(self) -> dict[str, Any]:
93
+ """The score-relevant projection of the config, for cache keying."""
94
+ return {
95
+ "sample_fps": self.sample_fps,
96
+ "signatures": {s.name: s.score_fingerprint() for s in self.signatures},
97
+ }
98
+
99
+
100
+ def _parse_signature(raw: Mapping[str, Any], regions: Mapping[str, Region]) -> Signature:
101
+ if not isinstance(raw, Mapping):
102
+ raise ConfigError(f"each signature must be a mapping, got {type(raw).__name__}")
103
+ unknown = set(raw) - _ALLOWED_SIGNATURE_KEYS
104
+ if unknown:
105
+ raise ConfigError(
106
+ f"signature has unknown keys {sorted(unknown)}; "
107
+ f"allowed: {sorted(_ALLOWED_SIGNATURE_KEYS)}"
108
+ )
109
+ try:
110
+ name = str(raw["name"])
111
+ region_name = str(raw["region"])
112
+ detector_type = str(raw["detector"])
113
+ except KeyError as exc:
114
+ raise ConfigError(f"signature missing required key {exc.args[0]!r}") from exc
115
+
116
+ if region_name not in regions:
117
+ raise ConfigError(
118
+ f"signature {name!r} references unknown region {region_name!r}; "
119
+ f"declared regions: {sorted(regions)}"
120
+ )
121
+
122
+ params = raw.get("params") or {}
123
+ if not isinstance(params, Mapping):
124
+ raise ConfigError(
125
+ f"signature {name!r}: 'params' must be a mapping, "
126
+ f"got {type(params).__name__}"
127
+ )
128
+
129
+ detector = build_detector(detector_type, params)
130
+ return Signature(
131
+ name=name,
132
+ region=regions[region_name],
133
+ detector=detector,
134
+ threshold=as_number(f"signature {name!r}: threshold", raw.get("threshold", 0.5)),
135
+ min_duration=as_number(
136
+ f"signature {name!r}: min_duration", raw.get("min_duration", 0.0)
137
+ ),
138
+ merge_gap=as_number(f"signature {name!r}: merge_gap", raw.get("merge_gap", 0.0)),
139
+ )
140
+
141
+
142
+ def parse_config(data: Mapping[str, Any]) -> Config:
143
+ """Build a :class:`Config` from an already-parsed mapping.
144
+
145
+ Raises:
146
+ ConfigError: On any structural or semantic problem.
147
+ """
148
+ if not isinstance(data, Mapping):
149
+ raise ConfigError("top-level config must be a mapping")
150
+
151
+ unknown = set(data) - _ALLOWED_TOP_KEYS
152
+ if unknown:
153
+ raise ConfigError(
154
+ f"unknown top-level keys {sorted(unknown)}; "
155
+ f"allowed: {sorted(_ALLOWED_TOP_KEYS)}"
156
+ )
157
+
158
+ sample_fps = as_number("sample_fps", data.get("sample_fps", 5.0))
159
+ if sample_fps <= 0:
160
+ raise ConfigError("sample_fps must be positive")
161
+
162
+ raw_regions = data.get("regions") or {}
163
+ if not isinstance(raw_regions, Mapping):
164
+ raise ConfigError("'regions' must be a mapping of name -> box")
165
+ regions = {name: Region.from_mapping(name, box) for name, box in raw_regions.items()}
166
+
167
+ raw_signatures = data.get("signatures") or []
168
+ if not isinstance(raw_signatures, list):
169
+ raise ConfigError("'signatures' must be a list")
170
+ signatures = [_parse_signature(s, regions) for s in raw_signatures]
171
+
172
+ if not signatures:
173
+ raise ConfigError("config declares no signatures; nothing to detect")
174
+
175
+ names = [s.name for s in signatures]
176
+ dupes = {n for n in names if names.count(n) > 1}
177
+ if dupes:
178
+ raise ConfigError(f"duplicate signature names: {sorted(dupes)}")
179
+
180
+ cache_dir = data.get("cache_dir")
181
+ return Config(
182
+ sample_fps=sample_fps,
183
+ regions=regions,
184
+ signatures=signatures,
185
+ cache_dir=str(cache_dir) if cache_dir is not None else None,
186
+ )
187
+
188
+
189
+ def load_config(path: str | Path) -> Config:
190
+ """Load and validate a YAML config from disk."""
191
+ path = Path(path)
192
+ try:
193
+ text = path.read_text(encoding="utf-8")
194
+ except OSError as exc:
195
+ raise ConfigError(f"cannot read config {str(path)!r}: {exc}") from exc
196
+ try:
197
+ data = yaml.safe_load(text)
198
+ except yaml.YAMLError as exc:
199
+ raise ConfigError(f"invalid YAML in {str(path)!r}: {exc}") from exc
200
+ if data is None:
201
+ raise ConfigError(f"config {str(path)!r} is empty")
202
+ return parse_config(data)