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/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}
|