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/detectors.py ADDED
@@ -0,0 +1,256 @@
1
+ """Pixel-signature detectors.
2
+
3
+ A detector turns the pixels of a cropped region into a single score in
4
+ ``[0, 1]``. Higher means "the event looks more present in this frame". Detectors
5
+ are deliberately dumb and stateless-per-frame (except :class:`SceneChange`,
6
+ which remembers the previous region); turning a stream of scores into discrete
7
+ events is the job of :mod:`framesig.events`.
8
+
9
+ Everything is game- and source-agnostic: a detector only ever sees a numpy BGR
10
+ crop, never any knowledge of what produced it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Callable, Mapping
16
+
17
+ import cv2
18
+ import numpy as np
19
+
20
+ from ._coerce import as_bool
21
+ from .errors import ConfigError
22
+
23
+ # BGR channel indices, matching OpenCV's default byte order.
24
+ _CHANNEL_INDEX = {"blue": 0, "green": 1, "red": 2}
25
+
26
+
27
+ class Detector:
28
+ """Base class. Subclasses implement :meth:`score`.
29
+
30
+ A detector instance is created once per signature and reused for every
31
+ sampled frame, so it may cache small amounts of state between calls (see
32
+ :class:`SceneChange`).
33
+ """
34
+
35
+ #: Registry key used in YAML ``detector:`` fields.
36
+ type_name: str = "base"
37
+
38
+ def score(self, roi: np.ndarray) -> float:
39
+ """Return a score in ``[0, 1]`` for a single BGR region crop."""
40
+ raise NotImplementedError
41
+
42
+ def reset(self) -> None:
43
+ """Forget any per-video state. Called once before each scan."""
44
+
45
+ def fingerprint(self) -> dict[str, Any]:
46
+ """Return the parameters that affect the score, for cache keying."""
47
+ return {"type": self.type_name}
48
+
49
+ @staticmethod
50
+ def _clamp(value: float) -> float:
51
+ return float(max(0.0, min(1.0, value)))
52
+
53
+
54
+ class Brightness(Detector):
55
+ """Mean luminance of the region.
56
+
57
+ Great for full-screen white flashes (explosions, flashbangs) with
58
+ ``invert=False``, or for fades to black / death screens with ``invert=True``.
59
+ """
60
+
61
+ type_name = "brightness"
62
+
63
+ def __init__(self, *, invert: bool = False) -> None:
64
+ self.invert = bool(invert)
65
+
66
+ def score(self, roi: np.ndarray) -> float:
67
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
68
+ value = float(gray.mean()) / 255.0
69
+ return self._clamp(1.0 - value if self.invert else value)
70
+
71
+ def fingerprint(self) -> dict[str, Any]:
72
+ return {"type": self.type_name, "invert": self.invert}
73
+
74
+
75
+ class ChannelDominance(Detector):
76
+ """How strongly one BGR channel dominates the other two, region-averaged.
77
+
78
+ This is the "relative red" signature: a red kill/death flash pushes the mean
79
+ red far above mean green and blue, even under compression, without caring
80
+ about absolute brightness. ``gain`` scales the raw dominance (in 0..255)
81
+ into the ``[0, 1]`` score before clamping.
82
+ """
83
+
84
+ type_name = "channel_dominance"
85
+
86
+ def __init__(self, *, channel: str = "red", gain: float = 1.0) -> None:
87
+ if channel not in _CHANNEL_INDEX:
88
+ raise ConfigError(
89
+ f"channel_dominance: channel must be one of "
90
+ f"{sorted(_CHANNEL_INDEX)}, got {channel!r}"
91
+ )
92
+ if gain <= 0:
93
+ raise ConfigError("channel_dominance: gain must be positive")
94
+ self.channel = channel
95
+ self.gain = float(gain)
96
+
97
+ def score(self, roi: np.ndarray) -> float:
98
+ means = roi.reshape(-1, 3).mean(axis=0) # [B, G, R]
99
+ idx = _CHANNEL_INDEX[self.channel]
100
+ others = float(max(means[i] for i in range(3) if i != idx))
101
+ dominance = (float(means[idx]) - others) / 255.0
102
+ return self._clamp(dominance * self.gain)
103
+
104
+ def fingerprint(self) -> dict[str, Any]:
105
+ return {"type": self.type_name, "channel": self.channel, "gain": self.gain}
106
+
107
+
108
+ class ColorFraction(Detector):
109
+ """Fraction of region pixels that fall inside one or more HSV colour ranges.
110
+
111
+ Ideal for a coloured HUD element that occupies a known band of the frame
112
+ (a red kill-feed row, a blue objective banner). Because red wraps around the
113
+ hue circle, you may pass a second range via ``hsv_low2`` / ``hsv_high2``.
114
+
115
+ HSV bounds follow OpenCV's conventions: H in ``[0, 179]``, S and V in
116
+ ``[0, 255]``.
117
+ """
118
+
119
+ type_name = "color_fraction"
120
+
121
+ def __init__(
122
+ self,
123
+ *,
124
+ hsv_low: list[int],
125
+ hsv_high: list[int],
126
+ hsv_low2: list[int] | None = None,
127
+ hsv_high2: list[int] | None = None,
128
+ ) -> None:
129
+ self._ranges = [self._as_bound("hsv_low", hsv_low), self._as_bound("hsv_high", hsv_high)]
130
+ self.ranges = [(self._ranges[0], self._ranges[1])]
131
+ if (hsv_low2 is None) != (hsv_high2 is None):
132
+ raise ConfigError(
133
+ "color_fraction: hsv_low2 and hsv_high2 must be provided together"
134
+ )
135
+ if hsv_low2 is not None and hsv_high2 is not None:
136
+ self.ranges.append(
137
+ (self._as_bound("hsv_low2", hsv_low2), self._as_bound("hsv_high2", hsv_high2))
138
+ )
139
+
140
+ @staticmethod
141
+ def _as_bound(field: str, value: list[int]) -> np.ndarray:
142
+ try:
143
+ length = len(value)
144
+ except TypeError:
145
+ length = -1
146
+ if length != 3:
147
+ raise ConfigError(f"color_fraction: {field} must be a list of 3 integers [H, S, V]")
148
+ try:
149
+ h, s, v = (int(item) for item in value)
150
+ except (ValueError, TypeError) as exc:
151
+ raise ConfigError(
152
+ f"color_fraction: {field} must be a list of 3 integers [H, S, V], "
153
+ f"got {value!r}"
154
+ ) from exc
155
+ # Range-check before the uint8 cast: numpy 2.x raises OverflowError while
156
+ # numpy 1.x silently wraps (256 -> 0), and both are inside our pin.
157
+ if not (0 <= h <= 179 and 0 <= s <= 255 and 0 <= v <= 255):
158
+ raise ConfigError(
159
+ f"color_fraction: {field} out of range; H in [0, 179], "
160
+ f"S and V in [0, 255], got {value}"
161
+ )
162
+ return np.array([h, s, v], dtype=np.uint8)
163
+
164
+ def score(self, roi: np.ndarray) -> float:
165
+ hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
166
+ mask = None
167
+ for low, high in self.ranges:
168
+ part = cv2.inRange(hsv, low, high)
169
+ mask = part if mask is None else cv2.bitwise_or(mask, part)
170
+ assert mask is not None # at least one range always exists
171
+ return self._clamp(float(np.count_nonzero(mask)) / mask.size)
172
+
173
+ def fingerprint(self) -> dict[str, Any]:
174
+ return {
175
+ "type": self.type_name,
176
+ "ranges": [[low.tolist(), high.tolist()] for low, high in self.ranges],
177
+ }
178
+
179
+
180
+ class SceneChange(Detector):
181
+ """Mean absolute difference from the previously sampled region.
182
+
183
+ Fires on hard cuts and big visual transitions. It is stateful: the first
184
+ sampled frame of a scan always scores ``0`` because there is nothing to
185
+ compare against yet.
186
+ """
187
+
188
+ type_name = "scene_change"
189
+
190
+ def __init__(self) -> None:
191
+ self._prev: np.ndarray | None = None
192
+
193
+ def reset(self) -> None:
194
+ self._prev = None
195
+
196
+ def score(self, roi: np.ndarray) -> float:
197
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
198
+ if self._prev is None or self._prev.shape != gray.shape:
199
+ self._prev = gray
200
+ return 0.0
201
+ diff = cv2.absdiff(gray, self._prev)
202
+ self._prev = gray
203
+ return self._clamp(float(diff.mean()) / 255.0)
204
+
205
+
206
+ # --- registry ---------------------------------------------------------------
207
+
208
+ _FACTORIES: dict[str, Callable[[Mapping[str, Any]], Detector]] = {
209
+ Brightness.type_name: lambda p: Brightness(
210
+ invert=as_bool("brightness: invert", p.get("invert", False))
211
+ ),
212
+ ChannelDominance.type_name: lambda p: ChannelDominance(
213
+ channel=str(p.get("channel", "red")), gain=float(p.get("gain", 1.0))
214
+ ),
215
+ ColorFraction.type_name: lambda p: ColorFraction(
216
+ hsv_low=p["hsv_low"],
217
+ hsv_high=p["hsv_high"],
218
+ hsv_low2=p.get("hsv_low2"),
219
+ hsv_high2=p.get("hsv_high2"),
220
+ ),
221
+ SceneChange.type_name: lambda p: SceneChange(),
222
+ }
223
+
224
+
225
+ def available_detectors() -> list[str]:
226
+ """Return the sorted list of detector type names known to framesig."""
227
+ return sorted(_FACTORIES)
228
+
229
+
230
+ def build_detector(type_name: str, params: Mapping[str, Any] | None = None) -> Detector:
231
+ """Instantiate a detector from its type name and a params mapping.
232
+
233
+ Raises:
234
+ ConfigError: If ``type_name`` is unknown or the params are invalid.
235
+ """
236
+ params = params or {}
237
+ if not isinstance(params, Mapping):
238
+ raise ConfigError(
239
+ f"detector {type_name!r}: params must be a mapping, "
240
+ f"got {type(params).__name__}"
241
+ )
242
+ try:
243
+ factory = _FACTORIES[type_name]
244
+ except KeyError:
245
+ raise ConfigError(
246
+ f"unknown detector type {type_name!r}; "
247
+ f"available: {available_detectors()}"
248
+ ) from None
249
+ try:
250
+ return factory(params)
251
+ except KeyError as exc:
252
+ raise ConfigError(
253
+ f"detector {type_name!r}: missing required param {exc.args[0]!r}"
254
+ ) from exc
255
+ except (ValueError, TypeError, OverflowError) as exc:
256
+ raise ConfigError(f"detector {type_name!r}: invalid params: {exc}") from exc
framesig/errors.py ADDED
@@ -0,0 +1,29 @@
1
+ """Exception hierarchy for framesig.
2
+
3
+ Every error raised by the library inherits from :class:`FramesigError`, so
4
+ callers can catch the whole family with a single ``except`` while still being
5
+ able to distinguish the interesting cases.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class FramesigError(Exception):
12
+ """Base class for every error raised by framesig."""
13
+
14
+
15
+ class ConfigError(FramesigError):
16
+ """Raised when a configuration file is malformed or inconsistent.
17
+
18
+ For example: an unknown detector type, a signature that references a region
19
+ that was never declared, or a region whose fractional bounds fall outside
20
+ the ``[0, 1]`` range.
21
+ """
22
+
23
+
24
+ class VideoError(FramesigError):
25
+ """Raised when a video file cannot be opened or decoded."""
26
+
27
+
28
+ class DependencyError(FramesigError):
29
+ """Raised when an external dependency (e.g. the ``ffmpeg`` binary) is missing."""
framesig/events.py ADDED
@@ -0,0 +1,150 @@
1
+ """Turning score timelines into discrete events.
2
+
3
+ The scanner produces, for each signature, a score at every sampled timestamp.
4
+ This module thresholds that timeline, merges nearby hits, drops blips that are
5
+ too short, and reports one :class:`Event` per surviving run.
6
+
7
+ Detection is intentionally separated from scanning: because the (expensive)
8
+ scores are cached, you can re-run :func:`detect_events` with different
9
+ thresholds as many times as you like for free.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass
15
+ from typing import Any, Sequence
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Event:
20
+ """A single detected occurrence of a signature.
21
+
22
+ Attributes:
23
+ signature: Name of the signature that fired.
24
+ start: Timestamp (seconds) of the first sample in the run.
25
+ end: Timestamp (seconds) of the last sample in the run.
26
+ duration: ``end - start`` plus one sample period, so a single-sample
27
+ spike still reports a non-zero duration. The period is measured
28
+ from the timeline itself unless the caller supplies it; a timeline
29
+ of fewer than two samples has no measurable period, so pass
30
+ ``period=`` explicitly for one to be used.
31
+ peak_t: Timestamp of the highest-scoring sample in the run.
32
+ peak_score: The highest score in the run.
33
+ mean_score: Mean score across the run's samples.
34
+ samples: Number of sampled frames in the run.
35
+ """
36
+
37
+ signature: str
38
+ start: float
39
+ end: float
40
+ duration: float
41
+ peak_t: float
42
+ peak_score: float
43
+ mean_score: float
44
+ samples: int
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """Return a JSON-serialisable dict, with floats rounded for tidy output."""
48
+ d = asdict(self)
49
+ for key in ("start", "end", "duration", "peak_t", "peak_score", "mean_score"):
50
+ d[key] = round(float(d[key]), 4)
51
+ return d
52
+
53
+
54
+ def _sample_period(timestamps: Sequence[float]) -> float:
55
+ """Estimate the spacing between samples (median of successive deltas)."""
56
+ if len(timestamps) < 2:
57
+ return 0.0
58
+ deltas = sorted(timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1))
59
+ return deltas[len(deltas) // 2]
60
+
61
+
62
+ def detect_events(
63
+ signature: str,
64
+ timestamps: Sequence[float],
65
+ scores: Sequence[float],
66
+ *,
67
+ threshold: float,
68
+ min_duration: float = 0.0,
69
+ merge_gap: float = 0.0,
70
+ period: float | None = None,
71
+ ) -> list[Event]:
72
+ """Detect events in a single signature's score timeline.
73
+
74
+ Args:
75
+ signature: Name attached to every returned event.
76
+ timestamps: Sample timestamps in seconds, ascending.
77
+ scores: Scores aligned with ``timestamps``.
78
+ threshold: A sample is "active" when ``score >= threshold``.
79
+ min_duration: Runs shorter than this (seconds) are discarded, filtering
80
+ out single-frame noise.
81
+ merge_gap: Active runs separated by a gap no larger than this (seconds)
82
+ are merged into one event. Useful when an effect flickers.
83
+ period: The spacing between samples, in seconds, added to every event's
84
+ duration so a single-sample spike is not zero-length. Defaults to
85
+ the median gap in ``timestamps``, which is ``0.0`` when fewer than
86
+ two samples were taken; :func:`framesig.scanner.detect_all` passes
87
+ the scan's own ``sample_period`` so that case is covered.
88
+
89
+ Returns:
90
+ A list of :class:`Event`, ordered by start time.
91
+ """
92
+ if len(timestamps) != len(scores):
93
+ raise ValueError("timestamps and scores must have equal length")
94
+ if not timestamps:
95
+ return []
96
+
97
+ if period is None:
98
+ period = _sample_period(timestamps)
99
+ period = float(period)
100
+
101
+ # 1. Collect maximal runs of consecutive active samples as index ranges.
102
+ runs: list[tuple[int, int]] = []
103
+ start: int | None = None
104
+ for i, s in enumerate(scores):
105
+ if s >= threshold:
106
+ if start is None:
107
+ start = i
108
+ else:
109
+ if start is not None:
110
+ runs.append((start, i - 1))
111
+ start = None
112
+ if start is not None:
113
+ runs.append((start, len(scores) - 1))
114
+
115
+ if not runs:
116
+ return []
117
+
118
+ # 2. Merge runs whose time gap is within merge_gap.
119
+ merged: list[tuple[int, int]] = [runs[0]]
120
+ for a, b in runs[1:]:
121
+ prev_a, prev_b = merged[-1]
122
+ gap = timestamps[a] - timestamps[prev_b]
123
+ if gap <= merge_gap:
124
+ merged[-1] = (prev_a, b)
125
+ else:
126
+ merged.append((a, b))
127
+
128
+ # 3. Build events, applying the minimum-duration filter.
129
+ events: list[Event] = []
130
+ for a, b in merged:
131
+ start_t = float(timestamps[a])
132
+ end_t = float(timestamps[b])
133
+ duration = end_t - start_t + period
134
+ if duration < min_duration:
135
+ continue
136
+ run_scores = scores[a : b + 1]
137
+ peak_local = max(range(len(run_scores)), key=lambda k: run_scores[k])
138
+ events.append(
139
+ Event(
140
+ signature=signature,
141
+ start=start_t,
142
+ end=end_t,
143
+ duration=duration,
144
+ peak_t=float(timestamps[a + peak_local]),
145
+ peak_score=float(run_scores[peak_local]),
146
+ mean_score=float(sum(run_scores) / len(run_scores)),
147
+ samples=b - a + 1,
148
+ )
149
+ )
150
+ return events
framesig/regions.py ADDED
@@ -0,0 +1,121 @@
1
+ """Regions of interest (ROIs).
2
+
3
+ A :class:`Region` describes a rectangular slice of the frame. Bounds may be
4
+ given either as fractions of the frame size (the default, resolution
5
+ independent) or as absolute pixels. The scanner resolves each region to an
6
+ integer pixel box once per video and then crops every sampled frame with it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any, Mapping
13
+
14
+ import numpy as np
15
+
16
+ from ._coerce import as_number
17
+ from .errors import ConfigError
18
+
19
+ Box = tuple[int, int, int, int] # (x0, y0, x1, y1) in pixels
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Region:
24
+ """A rectangular region of interest.
25
+
26
+ Attributes:
27
+ name: Identifier used by signatures to reference this region.
28
+ x: Left edge.
29
+ y: Top edge.
30
+ w: Width.
31
+ h: Height.
32
+ unit: ``"fraction"`` (default) interprets the bounds as fractions of the
33
+ frame size, so the same region works at any resolution.
34
+ ``"pixels"`` interprets them as absolute integers.
35
+ """
36
+
37
+ name: str
38
+ x: float
39
+ y: float
40
+ w: float
41
+ h: float
42
+ unit: str = "fraction"
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.unit not in ("fraction", "pixels"):
46
+ raise ConfigError(
47
+ f"region {self.name!r}: unit must be 'fraction' or 'pixels', "
48
+ f"got {self.unit!r}"
49
+ )
50
+ if self.w <= 0 or self.h <= 0:
51
+ raise ConfigError(f"region {self.name!r}: width and height must be positive")
52
+ if self.unit == "fraction":
53
+ if not (0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0):
54
+ raise ConfigError(
55
+ f"region {self.name!r}: fractional x/y must be within [0, 1]"
56
+ )
57
+ if self.x + self.w > 1.0 + 1e-9 or self.y + self.h > 1.0 + 1e-9:
58
+ raise ConfigError(
59
+ f"region {self.name!r}: fractional box extends past the frame edge"
60
+ )
61
+
62
+ @classmethod
63
+ def from_mapping(cls, name: str, data: Mapping[str, Any]) -> "Region":
64
+ """Build a region from a parsed YAML mapping."""
65
+ if not isinstance(data, Mapping):
66
+ raise ConfigError(
67
+ f"region {name!r}: must be a mapping with x, y, w, h, "
68
+ f"got {type(data).__name__}"
69
+ )
70
+ allowed = {"x", "y", "w", "h", "unit"}
71
+ unknown = set(data) - allowed
72
+ if unknown:
73
+ raise ConfigError(
74
+ f"region {name!r}: unknown keys {sorted(unknown)}; "
75
+ f"allowed keys are {sorted(allowed)}"
76
+ )
77
+ try:
78
+ raw_x, raw_y, raw_w, raw_h = (data[key] for key in ("x", "y", "w", "h"))
79
+ except KeyError as exc:
80
+ raise ConfigError(
81
+ f"region {name!r}: missing required key {exc.args[0]!r}"
82
+ ) from exc
83
+ return cls(
84
+ name=name,
85
+ x=as_number(f"region {name!r}: x", raw_x),
86
+ y=as_number(f"region {name!r}: y", raw_y),
87
+ w=as_number(f"region {name!r}: w", raw_w),
88
+ h=as_number(f"region {name!r}: h", raw_h),
89
+ unit=str(data.get("unit", "fraction")),
90
+ )
91
+
92
+ def resolve(self, frame_w: int, frame_h: int) -> Box:
93
+ """Return the integer pixel box ``(x0, y0, x1, y1)`` for a given frame size.
94
+
95
+ The box is always at least one pixel wide and tall, and is clamped to
96
+ the frame bounds so cropping can never raise.
97
+ """
98
+ if self.unit == "fraction":
99
+ x0 = int(round(self.x * frame_w))
100
+ y0 = int(round(self.y * frame_h))
101
+ x1 = int(round((self.x + self.w) * frame_w))
102
+ y1 = int(round((self.y + self.h) * frame_h))
103
+ else:
104
+ x0, y0 = int(round(self.x)), int(round(self.y))
105
+ x1, y1 = int(round(self.x + self.w)), int(round(self.y + self.h))
106
+
107
+ x0 = max(0, min(x0, frame_w - 1))
108
+ y0 = max(0, min(y0, frame_h - 1))
109
+ x1 = max(x0 + 1, min(x1, frame_w))
110
+ y1 = max(y0 + 1, min(y1, frame_h))
111
+ return x0, y0, x1, y1
112
+
113
+ def crop(self, frame: np.ndarray) -> np.ndarray:
114
+ """Crop ``frame`` (an ``H x W x 3`` BGR array) to this region."""
115
+ h, w = frame.shape[:2]
116
+ x0, y0, x1, y1 = self.resolve(w, h)
117
+ return frame[y0:y1, x0:x1]
118
+
119
+ def fingerprint(self) -> dict[str, Any]:
120
+ """Return the score-relevant fields for cache keying."""
121
+ return {"x": self.x, "y": self.y, "w": self.w, "h": self.h, "unit": self.unit}