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