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/scanner.py ADDED
@@ -0,0 +1,211 @@
1
+ """The scanner: decode a video, sample frames, score every signature.
2
+
3
+ This is the compute-heavy core. It walks the video once, sub-samples frames to
4
+ the configured rate, crops each signature's region and asks its detector for a
5
+ score. The resulting score timelines are what gets cached and later turned into
6
+ events.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Iterator
15
+
16
+ import cv2
17
+
18
+ from . import cache as cache_mod
19
+ from .config import Config
20
+ from .errors import VideoError
21
+ from .events import Event, detect_events
22
+
23
+ ProgressFn = Callable[[int, int], None]
24
+
25
+
26
+ @dataclass
27
+ class ScanResult:
28
+ """The scored timelines for one video under one config.
29
+
30
+ Attributes:
31
+ video: Path to the scanned video.
32
+ meta: Decode metadata (native fps, dimensions, duration, sample step...).
33
+ timestamps: The shared list of sample timestamps in seconds.
34
+ scores: ``signature name -> list of scores`` aligned with ``timestamps``.
35
+ from_cache: ``True`` if these scores were loaded from disk rather than
36
+ recomputed.
37
+ """
38
+
39
+ video: str
40
+ meta: dict[str, Any]
41
+ timestamps: list[float]
42
+ scores: dict[str, list[float]]
43
+ from_cache: bool = False
44
+
45
+ def to_payload(self) -> dict[str, Any]:
46
+ """Serialise into the dict stored by :class:`~framesig.cache.ScoreCache`."""
47
+ return {
48
+ "video": self.video,
49
+ "meta": self.meta,
50
+ "timestamps": self.timestamps,
51
+ "scores": self.scores,
52
+ }
53
+
54
+ @classmethod
55
+ def from_payload(cls, payload: dict[str, Any]) -> "ScanResult":
56
+ return cls(
57
+ video=payload["video"],
58
+ meta=payload["meta"],
59
+ timestamps=list(payload["timestamps"]),
60
+ scores={k: list(v) for k, v in payload["scores"].items()},
61
+ from_cache=True,
62
+ )
63
+
64
+
65
+ def _iter_frames(cap: "cv2.VideoCapture", step: int) -> Iterator[tuple[int, Any]]:
66
+ """Yield ``(frame_index, frame)`` for every ``step``-th decoded frame."""
67
+ index = 0
68
+ while True:
69
+ ok, frame = cap.read()
70
+ if not ok:
71
+ break
72
+ if index % step == 0:
73
+ yield index, frame
74
+ index += 1
75
+
76
+
77
+ def _default_cache_dir(video: Path, config: Config, override: str | None) -> Path:
78
+ if override is not None:
79
+ return Path(override)
80
+ if config.cache_dir is not None:
81
+ return Path(config.cache_dir)
82
+ return video.parent / ".framesig_cache"
83
+
84
+
85
+ def scan_video(
86
+ video_path: str | Path,
87
+ config: Config,
88
+ *,
89
+ use_cache: bool = True,
90
+ cache_dir: str | None = None,
91
+ progress: ProgressFn | None = None,
92
+ ) -> ScanResult:
93
+ """Scan a video and return per-signature score timelines.
94
+
95
+ Args:
96
+ video_path: Path to the video file.
97
+ config: A validated :class:`~framesig.config.Config`.
98
+ use_cache: When ``True`` (default), reuse a matching cached result and
99
+ write fresh results back to the cache.
100
+ cache_dir: Override the cache directory. Falls back to
101
+ ``config.cache_dir`` and then to ``<video>/../.framesig_cache``.
102
+ progress: Optional callback ``(processed_samples, approx_total)`` invoked
103
+ as scanning proceeds. ``approx_total`` may be ``0`` if the decoder
104
+ cannot report a frame count.
105
+
106
+ Returns:
107
+ A :class:`ScanResult`.
108
+
109
+ Raises:
110
+ VideoError: If the file cannot be opened or has an unusable frame rate.
111
+ """
112
+ video = Path(video_path)
113
+ if not video.exists():
114
+ raise VideoError(f"video not found: {str(video)!r}")
115
+
116
+ cache_directory = _default_cache_dir(video, config, cache_dir)
117
+ key = cache_mod.cache_key(
118
+ cache_mod.video_fingerprint(video), config.score_fingerprint()
119
+ )
120
+ store = cache_mod.ScoreCache(cache_directory)
121
+
122
+ if use_cache:
123
+ cached = store.load(key)
124
+ if cached is not None:
125
+ result = ScanResult.from_payload(cached)
126
+ # Guard against a stale cache missing a newly added signature.
127
+ if set(result.scores) == {s.name for s in config.signatures}:
128
+ return result
129
+
130
+ result = _scan_uncached(video, config, progress)
131
+ if use_cache:
132
+ store.store(key, result.to_payload())
133
+ return result
134
+
135
+
136
+ def _scan_uncached(video: Path, config: Config, progress: ProgressFn | None) -> ScanResult:
137
+ cap = cv2.VideoCapture(str(video))
138
+ if not cap.isOpened():
139
+ raise VideoError(f"could not open video: {str(video)!r}")
140
+
141
+ try:
142
+ native_fps = float(cap.get(cv2.CAP_PROP_FPS))
143
+ if not math.isfinite(native_fps) or native_fps <= 0:
144
+ raise VideoError(
145
+ f"video reports an invalid frame rate ({native_fps}); "
146
+ f"cannot map frames to timestamps"
147
+ )
148
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
149
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
150
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
151
+
152
+ step = max(1, int(round(native_fps / config.sample_fps)))
153
+ approx_total = frame_count // step if frame_count > 0 else 0
154
+
155
+ for sig in config.signatures:
156
+ sig.detector.reset()
157
+
158
+ timestamps: list[float] = []
159
+ scores: dict[str, list[float]] = {s.name: [] for s in config.signatures}
160
+
161
+ processed = 0
162
+ for index, frame in _iter_frames(cap, step):
163
+ timestamps.append(index / native_fps)
164
+ for sig in config.signatures:
165
+ roi = sig.region.crop(frame)
166
+ scores[sig.name].append(sig.detector.score(roi))
167
+ processed += 1
168
+ if progress is not None:
169
+ progress(processed, approx_total)
170
+ finally:
171
+ cap.release()
172
+
173
+ if not timestamps:
174
+ raise VideoError(f"decoded zero frames from {str(video)!r}")
175
+
176
+ duration = (timestamps[-1] + step / native_fps) if timestamps else 0.0
177
+ meta = {
178
+ "native_fps": native_fps,
179
+ "frame_count": frame_count,
180
+ "width": width,
181
+ "height": height,
182
+ "sample_fps": config.sample_fps,
183
+ "step": step,
184
+ "sample_period": step / native_fps,
185
+ "samples": len(timestamps),
186
+ "duration": duration,
187
+ }
188
+ return ScanResult(video=str(video), meta=meta, timestamps=timestamps, scores=scores)
189
+
190
+
191
+ def detect_all(config: Config, result: ScanResult) -> dict[str, list[Event]]:
192
+ """Apply each signature's thresholds to its cached scores.
193
+
194
+ This step is cheap, so it is kept separate from :func:`scan_video`: you can
195
+ re-run it with edited thresholds against the same (cached) scores instantly.
196
+
197
+ Returns:
198
+ ``signature name -> list of events``, preserving config order.
199
+ """
200
+ out: dict[str, list[Event]] = {}
201
+ for sig in config.signatures:
202
+ out[sig.name] = detect_events(
203
+ sig.name,
204
+ result.timestamps,
205
+ result.scores.get(sig.name, []),
206
+ threshold=sig.threshold,
207
+ min_duration=sig.min_duration,
208
+ merge_gap=sig.merge_gap,
209
+ period=result.meta.get("sample_period"),
210
+ )
211
+ return out
framesig/videogen.py ADDED
@@ -0,0 +1,173 @@
1
+ """Generate a self-contained synthetic test clip with ffmpeg.
2
+
3
+ The clip is a fake game HUD painted with solid colour boxes at *known*
4
+ timestamps, so tests (and the README demo) have deterministic ground truth to
5
+ check the detector against — no external video, model or network required.
6
+
7
+ Layout (640x360, 15 s, 30 fps, dark-blue base):
8
+
9
+ * a red flash across the top HUD area -> ``death_screen`` (channel_dominance)
10
+ * a red bar in the lower kill-feed band -> ``kill_feed`` (color_fraction)
11
+ * a white flash on a central screen panel -> ``white_flash`` (brightness)
12
+ * a hard cut to teal across the whole frame -> ``scene_cut`` (scene_change)
13
+
14
+ The four effects occupy disjoint parts of the frame, so each detector sees only
15
+ its own event and the ground truth stays clean.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import subprocess
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from .errors import DependencyError
26
+
27
+ WIDTH = 640
28
+ HEIGHT = 360
29
+ FPS = 30
30
+ DURATION = 15.0
31
+ BASE_COLOR = "0x101828" # dark blue
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class GroundTruth:
36
+ """The events baked into the synthetic clip, per signature.
37
+
38
+ ``intervals`` maps a signature name to the ``(start, end)`` windows during
39
+ which its effect is on screen. ``cuts`` lists the timestamps of hard scene
40
+ cuts (single-frame events).
41
+ """
42
+
43
+ intervals: dict[str, list[tuple[float, float]]] = field(default_factory=dict)
44
+ cuts: list[float] = field(default_factory=list)
45
+
46
+ def expected_counts(self) -> dict[str, int]:
47
+ """Number of distinct events expected for each signature."""
48
+ counts = {name: len(windows) for name, windows in self.intervals.items()}
49
+ counts["scene_cut"] = len(self.cuts)
50
+ return counts
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class SampleVideo:
55
+ """Result of :func:`generate_sample_video`."""
56
+
57
+ path: Path
58
+ width: int
59
+ height: int
60
+ fps: int
61
+ duration: float
62
+ ground_truth: GroundTruth
63
+
64
+
65
+ # Ground-truth timings, shared by the ffmpeg filter builder and the tests.
66
+ _DEATH = [(2.0, 2.4), (7.5, 7.9)]
67
+ _FEED = [(4.0, 4.3), (4.8, 5.0), (9.4, 9.8)]
68
+ _WHITE = [(11.0, 11.3)]
69
+ _CUT_T = 13.0
70
+
71
+
72
+ def ground_truth() -> GroundTruth:
73
+ """Return the ground-truth events for the synthetic clip."""
74
+ return GroundTruth(
75
+ intervals={
76
+ "death_screen": list(_DEATH),
77
+ "kill_feed": list(_FEED),
78
+ "white_flash": list(_WHITE),
79
+ },
80
+ cuts=[_CUT_T],
81
+ )
82
+
83
+
84
+ def _enable(windows: list[tuple[float, float]]) -> str:
85
+ # between(t,a,b) is inclusive; summing non-overlapping windows acts as OR.
86
+ return "+".join(f"between(t,{a},{b})" for a, b in windows)
87
+
88
+
89
+ def _filtergraph() -> str:
90
+ # Disjoint boxes; single-quoted enable values keep their commas literal.
91
+ death = (
92
+ f"drawbox=x=0:y=0:w={WIDTH}:h={int(HEIGHT * 0.55)}:color=red:t=fill:"
93
+ f"enable='{_enable(_DEATH)}'"
94
+ )
95
+ feed = (
96
+ f"drawbox=x={int(WIDTH * 0.08)}:y={int(HEIGHT * 0.74)}:"
97
+ f"w={int(WIDTH * 0.84)}:h={int(HEIGHT * 0.18)}:color=red:t=fill:"
98
+ f"enable='{_enable(_FEED)}'"
99
+ )
100
+ white = (
101
+ f"drawbox=x={int(WIDTH * 0.30)}:y={int(HEIGHT * 0.58)}:"
102
+ f"w={int(WIDTH * 0.40)}:h={int(HEIGHT * 0.12)}:color=white:t=fill:"
103
+ f"enable='{_enable(_WHITE)}'"
104
+ )
105
+ cut = (
106
+ f"drawbox=x=0:y=0:w={WIDTH}:h={HEIGHT}:color=0x18A060:t=fill:"
107
+ f"enable='gte(t,{_CUT_T})'"
108
+ )
109
+ return ",".join([death, feed, white, cut])
110
+
111
+
112
+ def generate_sample_video(
113
+ out_path: str | Path, *, ffmpeg: str = "ffmpeg", overwrite: bool = True
114
+ ) -> SampleVideo:
115
+ """Render the synthetic HUD clip to ``out_path`` using ffmpeg.
116
+
117
+ Args:
118
+ out_path: Destination ``.mp4`` file.
119
+ ffmpeg: Name or path of the ffmpeg binary.
120
+ overwrite: Overwrite an existing file (passes ``-y``).
121
+
122
+ Returns:
123
+ A :class:`SampleVideo` describing the file and its ground truth.
124
+
125
+ Raises:
126
+ DependencyError: If the ffmpeg binary is not on ``PATH`` or ffmpeg fails.
127
+ """
128
+ binary = shutil.which(ffmpeg)
129
+ if binary is None:
130
+ raise DependencyError(
131
+ f"ffmpeg binary {ffmpeg!r} not found on PATH; install ffmpeg to "
132
+ f"generate the sample clip"
133
+ )
134
+
135
+ out = Path(out_path)
136
+ out.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ cmd = [
139
+ binary,
140
+ "-y" if overwrite else "-n",
141
+ "-v",
142
+ "error",
143
+ "-f",
144
+ "lavfi",
145
+ "-i",
146
+ f"color=c={BASE_COLOR}:s={WIDTH}x{HEIGHT}:r={FPS}:d={DURATION}",
147
+ "-vf",
148
+ _filtergraph(),
149
+ "-pix_fmt",
150
+ "yuv420p",
151
+ "-c:v",
152
+ "libx264",
153
+ "-crf",
154
+ "18",
155
+ "-preset",
156
+ "veryfast",
157
+ str(out),
158
+ ]
159
+ proc = subprocess.run(cmd, capture_output=True, text=True)
160
+ if proc.returncode != 0 or not out.exists():
161
+ raise DependencyError(
162
+ f"ffmpeg failed to render the sample clip (exit {proc.returncode}):\n"
163
+ f"{proc.stderr.strip()}"
164
+ )
165
+
166
+ return SampleVideo(
167
+ path=out,
168
+ width=WIDTH,
169
+ height=HEIGHT,
170
+ fps=FPS,
171
+ duration=DURATION,
172
+ ground_truth=ground_truth(),
173
+ )
framesig/viz.py ADDED
@@ -0,0 +1,108 @@
1
+ """Render a score-timeline chart, using only OpenCV (no plotting dependency).
2
+
3
+ Given a :class:`~framesig.scanner.ScanResult` and the detected events, this draws
4
+ one stacked lane per signature: the score curve, its threshold line, and shaded
5
+ bands where events fired. It is handy for tuning thresholds by eye and for the
6
+ project's README image.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import cv2
14
+ import numpy as np
15
+
16
+ from .config import Config
17
+ from .events import Event
18
+ from .scanner import ScanResult
19
+
20
+ # Colours are BGR (OpenCV order).
21
+ _BG = (24, 20, 16)
22
+ _PANEL = (34, 30, 26)
23
+ _GRID = (60, 54, 48)
24
+ _TEXT = (210, 210, 210)
25
+ _MUTED = (140, 140, 140)
26
+ _CURVE = (235, 180, 90)
27
+ _THRESH = (110, 110, 235)
28
+ _EVENT = (90, 205, 120)
29
+
30
+
31
+ def render_timeline(
32
+ config: Config,
33
+ result: ScanResult,
34
+ events: dict[str, list[Event]],
35
+ out_path: str | Path,
36
+ *,
37
+ width: int = 1040,
38
+ lane_height: int = 118,
39
+ ) -> Path:
40
+ """Render the score timelines to a PNG and return its path."""
41
+ signatures = config.signatures
42
+ pad_l, pad_r, pad_t, pad_b = 150, 24, 54, 40
43
+ plot_w = width - pad_l - pad_r
44
+ height = pad_t + pad_b + lane_height * len(signatures)
45
+
46
+ img = np.full((height, width, 3), _BG, dtype=np.uint8)
47
+ duration = max(result.meta.get("duration", 0.0), 1e-6)
48
+ ts = result.timestamps
49
+
50
+ def x_of(t: float) -> int:
51
+ return int(pad_l + (t / duration) * plot_w)
52
+
53
+ _text(img, "framesig score timelines", (pad_l, 34), 0.72, _TEXT, 2)
54
+
55
+ for lane, sig in enumerate(signatures):
56
+ top = pad_t + lane * lane_height
57
+ bottom = top + lane_height - 26
58
+ cv2.rectangle(img, (pad_l, top), (pad_l + plot_w, bottom), _PANEL, -1)
59
+
60
+ # Horizontal grid at score 0, 0.5, 1.0.
61
+ for frac in (0.0, 0.5, 1.0):
62
+ y = int(bottom - frac * (bottom - top))
63
+ cv2.line(img, (pad_l, y), (pad_l + plot_w, y), _GRID, 1)
64
+
65
+ # Shade detected event spans.
66
+ for ev in events.get(sig.name, []):
67
+ x0, x1 = x_of(ev.start), max(x_of(ev.end), x_of(ev.start) + 2)
68
+ overlay = img.copy()
69
+ cv2.rectangle(overlay, (x0, top), (x1, bottom), _EVENT, -1)
70
+ cv2.addWeighted(overlay, 0.22, img, 0.78, 0, img)
71
+ cv2.line(img, (x_of(ev.peak_t), top), (x_of(ev.peak_t), bottom), _EVENT, 1)
72
+
73
+ # Threshold line.
74
+ yt = int(bottom - sig.threshold * (bottom - top))
75
+ for xd in range(pad_l, pad_l + plot_w, 10):
76
+ cv2.line(img, (xd, yt), (xd + 5, yt), _THRESH, 1)
77
+
78
+ # Score curve.
79
+ scores = result.scores.get(sig.name, [])
80
+ pts = [
81
+ (x_of(t), int(bottom - min(max(s, 0.0), 1.0) * (bottom - top)))
82
+ for t, s in zip(ts, scores)
83
+ ]
84
+ if len(pts) >= 2:
85
+ cv2.polylines(img, [np.array(pts, dtype=np.int32)], False, _CURVE, 2, cv2.LINE_AA)
86
+
87
+ n = len(events.get(sig.name, []))
88
+ _text(img, sig.name, (16, top + 26), 0.56, _TEXT, 1)
89
+ _text(img, f"{sig.detector.type_name}", (16, top + 48), 0.44, _MUTED, 1)
90
+ _text(img, f"thr {sig.threshold:g}", (16, top + 68), 0.44, _THRESH, 1)
91
+ _text(img, f"{n} event{'s' if n != 1 else ''}", (16, top + 88), 0.44, _EVENT, 1)
92
+
93
+ # Time axis ticks.
94
+ axis_y = height - pad_b + 16
95
+ for k in range(0, int(duration) + 1, 2):
96
+ x = x_of(k)
97
+ cv2.line(img, (x, pad_t - 6), (x, height - pad_b), _GRID, 1)
98
+ _text(img, f"{k}s", (x - 8, axis_y), 0.42, _MUTED, 1)
99
+
100
+ out = Path(out_path)
101
+ out.parent.mkdir(parents=True, exist_ok=True)
102
+ if not cv2.imwrite(str(out), img):
103
+ raise OSError(f"failed to write chart image to {str(out)!r}")
104
+ return out
105
+
106
+
107
+ def _text(img: np.ndarray, s: str, org: tuple[int, int], scale: float, color, thick: int) -> None:
108
+ cv2.putText(img, s, org, cv2.FONT_HERSHEY_SIMPLEX, scale, color, thick, cv2.LINE_AA)