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 +78 -0
- framesig/_coerce.py +47 -0
- framesig/cache.py +85 -0
- framesig/cli.py +243 -0
- framesig/config.py +202 -0
- framesig/detectors.py +256 -0
- framesig/errors.py +29 -0
- framesig/events.py +150 -0
- framesig/regions.py +121 -0
- framesig/scanner.py +211 -0
- framesig/videogen.py +173 -0
- framesig/viz.py +108 -0
- framesig-0.1.0.dist-info/METADATA +261 -0
- framesig-0.1.0.dist-info/RECORD +18 -0
- framesig-0.1.0.dist-info/WHEEL +5 -0
- framesig-0.1.0.dist-info/entry_points.txt +2 -0
- framesig-0.1.0.dist-info/licenses/LICENSE +21 -0
- framesig-0.1.0.dist-info/top_level.txt +1 -0
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)
|