captchakraken 2.4.0 → 2.6.0
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.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1285 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +212 -2
- package/python/src/captchakraken/config.py +39 -5
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +253 -59
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""Reduce a recorded captcha clip to the few frames the model is shown.
|
|
2
|
+
|
|
3
|
+
VERBATIM PORT. Everything below the docstring is byte-identical to
|
|
4
|
+
`src/video/keyframes.py` in the CaptchaKrakenFinetune repo, and
|
|
5
|
+
`scripts/check_prompt_parity.py` there fails the build if the two ever diverge.
|
|
6
|
+
|
|
7
|
+
That is not tidiness, it is a correctness requirement. The model is trained on
|
|
8
|
+
keyframes cut by that code and answers with a frame NUMBER indexing into them. If
|
|
9
|
+
this copy sliced a live recording differently — a cycle collapsing here but not
|
|
10
|
+
there, a different keyframe budget — the number would name a picture that does not
|
|
11
|
+
exist, and the driver would wait for a state the page never reaches. Read that
|
|
12
|
+
file's docstring for the algorithm and the reasoning behind every threshold.
|
|
13
|
+
|
|
14
|
+
In this repo the module has one extra job: `region_box` / `region_diff_ratio` are
|
|
15
|
+
also the solver's wait-for-state gate. The driver holds the mouse until the live
|
|
16
|
+
page's neighbourhood around the click point matches the chosen keyframe's, using
|
|
17
|
+
the same metric and the same box the label was chosen with.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
import cv2
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
# ── extraction parameters ───────────────────────────────────────────────────
|
|
32
|
+
# Defaults are tuned against the collector's burst geometry (10 fps; 4 s by
|
|
33
|
+
# default, but `Source.burst_duration_ms` raises it per vendor so a burst covers
|
|
34
|
+
# a full animation cycle, so clip length varies). They are all
|
|
35
|
+
# fractions or frame counts, so they survive a change of clip length; only
|
|
36
|
+
# MIN_HOLD_FRAMES is absolute, and it is deliberately a wall-clock idea (~0.3 s).
|
|
37
|
+
|
|
38
|
+
DEFAULT_MAX_KEYFRAMES = 6
|
|
39
|
+
|
|
40
|
+
# Two frames differing in fewer than this fraction of pixels are "the same
|
|
41
|
+
# picture". A false "same" merges two distinct states, which loses an answer, so
|
|
42
|
+
# this is the most consequential number in the file.
|
|
43
|
+
#
|
|
44
|
+
# MEASURED, not guessed. Over all 20 real clips in cleanSamples/test/raw, the
|
|
45
|
+
# consecutive-frame differences fall into two clusters with a 64x gap:
|
|
46
|
+
#
|
|
47
|
+
# same picture (compression + antialiasing) <= 0.000067 (0.067 per mille)
|
|
48
|
+
# smallest genuine state change >= 0.004282 (4.28 per mille)
|
|
49
|
+
#
|
|
50
|
+
# 0.001 sits inside that gap — 15x above the noise floor, 4.3x below the faintest
|
|
51
|
+
# real swap — and the slicing is identical anywhere from 0.0005 to 0.002, so this
|
|
52
|
+
# is a plateau rather than a knife edge.
|
|
53
|
+
#
|
|
54
|
+
# It used to be 0.005, which was ABOVE the smallest real state change. That is not
|
|
55
|
+
# a tuning nicety: a 340x384 geetest icon board swaps one tile and changes ~0.5% of
|
|
56
|
+
# the frame, so half of its real transitions read as "the same picture". One clip
|
|
57
|
+
# (ssqr8) merged to a single state and was declared `static` — one still for a clip
|
|
58
|
+
# with three pictures — and another (zxpan) emitted 2 of its 3. The old value was
|
|
59
|
+
# calibrated against 64x64 fixtures whose patch covers 14% of the frame; a real
|
|
60
|
+
# captcha animates a sprite covering under 1% of the widget.
|
|
61
|
+
DEFAULT_STEADY_RATIO = 0.001
|
|
62
|
+
|
|
63
|
+
# Two states must differ by at least this much for a clip to be called a `cycle`
|
|
64
|
+
# and COLLAPSED to one frame per state. Deliberately NOT scaled down alongside
|
|
65
|
+
# `steady_ratio`: the two thresholds guard opposite risks. `steady_ratio` decides
|
|
66
|
+
# "is this the same picture", where being too generous LOSES a state — so it tracks
|
|
67
|
+
# the measured noise floor. This one licenses the aggressive collapse, where being
|
|
68
|
+
# too generous discards the middle of a clip, so it stays conservative. Real state
|
|
69
|
+
# separations top out around 0.007, well under this, so `cycle` does not fire on any
|
|
70
|
+
# real footage collected so far; `even` + dedup reaches the same answer for a rested
|
|
71
|
+
# clip without having to decide whether it recurs. Lowering this to reach cycle mode
|
|
72
|
+
# would buy nothing and risk the one failure that cannot be detected downstream.
|
|
73
|
+
DEFAULT_DISTINCT_RATIO = 0.02
|
|
74
|
+
|
|
75
|
+
# A hold has to last long enough to be a deliberate resting state, not a frame
|
|
76
|
+
# that happened to repeat mid-animation. 3 frames @ 10 fps ≈ 0.3 s.
|
|
77
|
+
DEFAULT_MIN_HOLD_FRAMES = 3
|
|
78
|
+
|
|
79
|
+
# Holds must account for at least this much of the clip. A 3-state cycle with
|
|
80
|
+
# fast swaps is ~0.9 held; a cross-fade is ~0.0. The gap is wide, so this
|
|
81
|
+
# threshold is not delicate.
|
|
82
|
+
DEFAULT_MIN_STEADY_COVERAGE = 0.5
|
|
83
|
+
|
|
84
|
+
# The pixel-difference threshold inside the similarity metric. Not tunable per
|
|
85
|
+
# call on purpose — it is the one number shared with every other movement check
|
|
86
|
+
# in the project (see module docstring).
|
|
87
|
+
_PIXEL_DELTA = 30
|
|
88
|
+
|
|
89
|
+
# Half-width of the neighbourhood around an action point, as a fraction of the
|
|
90
|
+
# image, used both to CHOOSE which keyframe a label belongs to and — in the live
|
|
91
|
+
# solver — to decide the page now looks like that keyframe. One constant on
|
|
92
|
+
# purpose: "the frame this answer is valid in" and "the moment it is safe to
|
|
93
|
+
# click" have to be the same question, or the solver waits for a state the label
|
|
94
|
+
# never described. 0.06 ≈ a 12%-of-frame box, big enough to contain a sprite and
|
|
95
|
+
# small enough that unrelated motion elsewhere doesn't veto the click.
|
|
96
|
+
MATCH_REGION_HALF = 0.06
|
|
97
|
+
|
|
98
|
+
# How different the region may be and still count as "the page looks like this
|
|
99
|
+
# keyframe". Deliberately looser than DEFAULT_STEADY_RATIO: the live page carries
|
|
100
|
+
# antialiasing and cursor artefacts a rendered keyframe does not, and a gate that
|
|
101
|
+
# never opens is worse than one that opens a frame early.
|
|
102
|
+
MATCH_REGION_TOLERANCE = 0.05
|
|
103
|
+
|
|
104
|
+
MANIFEST_NAME = "keyframes.json"
|
|
105
|
+
KEYFRAME_DIR_NAME = "keyframes"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class KeyframeParams:
|
|
110
|
+
"""Everything that decides how a clip is sliced. Recorded in the manifest so
|
|
111
|
+
a keyframe set on disk can be traced back to the settings that produced it —
|
|
112
|
+
a threshold change is otherwise invisible and silently mixes two slicings
|
|
113
|
+
into one training set."""
|
|
114
|
+
|
|
115
|
+
max_keyframes: int = DEFAULT_MAX_KEYFRAMES
|
|
116
|
+
steady_ratio: float = DEFAULT_STEADY_RATIO
|
|
117
|
+
distinct_ratio: float = DEFAULT_DISTINCT_RATIO
|
|
118
|
+
min_hold_frames: int = DEFAULT_MIN_HOLD_FRAMES
|
|
119
|
+
min_steady_coverage: float = DEFAULT_MIN_STEADY_COVERAGE
|
|
120
|
+
# Never hand the model two copies of one picture (see `_distinct_indices`).
|
|
121
|
+
# Present as a param, not a hard-coded behaviour, for the reason every other
|
|
122
|
+
# number here is: it lands in the manifest, so a set cut before this existed
|
|
123
|
+
# has params that no longer match and gets re-cut instead of silently reused.
|
|
124
|
+
dedupe: bool = True
|
|
125
|
+
|
|
126
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"max_keyframes": self.max_keyframes,
|
|
129
|
+
"steady_ratio": self.steady_ratio,
|
|
130
|
+
"distinct_ratio": self.distinct_ratio,
|
|
131
|
+
"min_hold_frames": self.min_hold_frames,
|
|
132
|
+
"min_steady_coverage": self.min_steady_coverage,
|
|
133
|
+
"dedupe": self.dedupe,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Keyframe:
|
|
139
|
+
"""One image the model will be shown.
|
|
140
|
+
|
|
141
|
+
`number` is 1-based and is what the model returns as `"frame"`. It is NOT the
|
|
142
|
+
index in the source clip: a 3-state cycle emits frames 1, 2, 3 even if their
|
|
143
|
+
source indices are 4, 17 and 29. `source_index` keeps the provenance so the
|
|
144
|
+
generator's ground-truth frame can be snapped onto a keyframe number and so a
|
|
145
|
+
human labeller can scrub back to the right moment.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
number: int
|
|
149
|
+
source_index: int
|
|
150
|
+
timestamp_ms: float
|
|
151
|
+
image: Optional[np.ndarray] = None # BGR uint8; None once written to disk
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class KeyframeSet:
|
|
156
|
+
mode: str # "cycle" | "even" | "static"
|
|
157
|
+
keyframes: List[Keyframe]
|
|
158
|
+
source_frames: int
|
|
159
|
+
fps: float
|
|
160
|
+
params: KeyframeParams = field(default_factory=KeyframeParams)
|
|
161
|
+
# cycle mode only: per-source-frame state id (0-based, aligned with
|
|
162
|
+
# `keyframes`). Empty for the other modes. Lets a caller answer "which
|
|
163
|
+
# keyframe was the clip showing at source frame i?" without re-diffing.
|
|
164
|
+
frame_states: List[int] = field(default_factory=list)
|
|
165
|
+
|
|
166
|
+
def __len__(self) -> int:
|
|
167
|
+
return len(self.keyframes)
|
|
168
|
+
|
|
169
|
+
def number_for_source_index(self, source_index: int) -> int:
|
|
170
|
+
"""The keyframe a given source frame belongs to (1-based), by POSITION.
|
|
171
|
+
|
|
172
|
+
In cycle mode this is exact — the frame's own state. Otherwise it is the
|
|
173
|
+
nearest keyframe in time. Prefer `number_for_frame` whenever the pixels are
|
|
174
|
+
available: nearest-in-time is not the same question the solver asks, and a
|
|
175
|
+
transition frame (state -1) has no state of its own to fall back on.
|
|
176
|
+
"""
|
|
177
|
+
if not self.keyframes:
|
|
178
|
+
raise ValueError("keyframe set is empty")
|
|
179
|
+
if self.mode == "cycle" and 0 <= source_index < len(self.frame_states):
|
|
180
|
+
state = self.frame_states[source_index]
|
|
181
|
+
if state >= 0:
|
|
182
|
+
return state + 1
|
|
183
|
+
return min(
|
|
184
|
+
self.keyframes, key=lambda k: abs(k.source_index - source_index)
|
|
185
|
+
).number
|
|
186
|
+
|
|
187
|
+
def number_for_frame(
|
|
188
|
+
self,
|
|
189
|
+
frame: np.ndarray,
|
|
190
|
+
*,
|
|
191
|
+
point_norm: Optional[Tuple[float, float]] = None,
|
|
192
|
+
source_index: Optional[int] = None,
|
|
193
|
+
) -> int:
|
|
194
|
+
"""The keyframe that LOOKS most like `frame`, 1-based.
|
|
195
|
+
|
|
196
|
+
This — not nearest-in-time — is how a label's frame number is chosen,
|
|
197
|
+
because it asks the same question the live solver asks. At solve time the
|
|
198
|
+
driver holds the mouse until the page's neighbourhood around the click
|
|
199
|
+
point matches the chosen keyframe; so the frame we write into the label
|
|
200
|
+
must be the one whose *appearance* matches the moment the answer was read
|
|
201
|
+
off, or the driver ends up waiting for a state where the target isn't
|
|
202
|
+
there.
|
|
203
|
+
|
|
204
|
+
`point_norm` (x, y in 0..1) restricts the comparison to the neighbourhood
|
|
205
|
+
of the action point, again matching the solver. Without it the whole frame
|
|
206
|
+
is compared, which on a multi-sprite puzzle is dominated by sprites that
|
|
207
|
+
have nothing to do with the answer.
|
|
208
|
+
|
|
209
|
+
`source_index` only breaks ties, keeping the choice stable and preferring
|
|
210
|
+
the temporally closest of two equally-similar keyframes.
|
|
211
|
+
"""
|
|
212
|
+
if not self.keyframes:
|
|
213
|
+
raise ValueError("keyframe set is empty")
|
|
214
|
+
box = region_box(frame.shape[1::-1], point_norm) if point_norm else None
|
|
215
|
+
|
|
216
|
+
def cost(kf: Keyframe) -> Tuple[float, int]:
|
|
217
|
+
if kf.image is None:
|
|
218
|
+
raise ValueError(f"keyframe {kf.number} has no image to compare")
|
|
219
|
+
d = region_diff_ratio(kf.image, frame, box)
|
|
220
|
+
tie = abs(kf.source_index - source_index) if source_index is not None else 0
|
|
221
|
+
return (d, tie)
|
|
222
|
+
|
|
223
|
+
return min(self.keyframes, key=cost).number
|
|
224
|
+
|
|
225
|
+
def manifest(self, *, stem: str, filenames: Sequence[str]) -> Dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"stem": stem,
|
|
228
|
+
"mode": self.mode,
|
|
229
|
+
"fps": self.fps,
|
|
230
|
+
"source_frames": self.source_frames,
|
|
231
|
+
"params": self.params.as_dict(),
|
|
232
|
+
"keyframes": [
|
|
233
|
+
{
|
|
234
|
+
"number": kf.number,
|
|
235
|
+
"file": name,
|
|
236
|
+
"source_index": kf.source_index,
|
|
237
|
+
"timestamp_ms": round(kf.timestamp_ms, 1),
|
|
238
|
+
}
|
|
239
|
+
for kf, name in zip(self.keyframes, filenames)
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── similarity ──────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def region_box(
|
|
248
|
+
size_wh: Tuple[int, int],
|
|
249
|
+
point_norm: Tuple[float, float],
|
|
250
|
+
half: float = MATCH_REGION_HALF,
|
|
251
|
+
) -> Tuple[int, int, int, int]:
|
|
252
|
+
"""`(x1, y1, x2, y2)` pixel box of half-width `half` around a 0..1 point.
|
|
253
|
+
|
|
254
|
+
Clamped to the image and never empty: a point on the very edge of the frame
|
|
255
|
+
(a target flush against the border) would otherwise produce a zero-area box,
|
|
256
|
+
and a comparison over no pixels reads as a perfect match — the gate would open
|
|
257
|
+
immediately on any state at all.
|
|
258
|
+
"""
|
|
259
|
+
w, h = int(size_wh[0]), int(size_wh[1])
|
|
260
|
+
cx, cy = float(point_norm[0]) * w, float(point_norm[1]) * h
|
|
261
|
+
rx, ry = max(1.0, half * w), max(1.0, half * h)
|
|
262
|
+
x1 = max(0, min(w - 1, int(round(cx - rx))))
|
|
263
|
+
y1 = max(0, min(h - 1, int(round(cy - ry))))
|
|
264
|
+
x2 = max(x1 + 1, min(w, int(round(cx + rx))))
|
|
265
|
+
y2 = max(y1 + 1, min(h, int(round(cy + ry))))
|
|
266
|
+
return x1, y1, x2, y2
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def region_diff_ratio(
|
|
270
|
+
a: np.ndarray, b: np.ndarray, box: Optional[Tuple[int, int, int, int]] = None
|
|
271
|
+
) -> float:
|
|
272
|
+
"""`frame_diff_ratio` over a sub-rectangle (the whole frame when `box` is None).
|
|
273
|
+
|
|
274
|
+
Mismatched shapes short-circuit before cropping, so a resized live screenshot
|
|
275
|
+
reads as "completely different" rather than as a crop of a different geometry.
|
|
276
|
+
"""
|
|
277
|
+
if a is None or b is None or a.shape != b.shape:
|
|
278
|
+
return 1.0
|
|
279
|
+
if box is None:
|
|
280
|
+
return frame_diff_ratio(a, b)
|
|
281
|
+
x1, y1, x2, y2 = box
|
|
282
|
+
return frame_diff_ratio(a[y1:y2, x1:x2], b[y1:y2, x1:x2])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
|
|
286
|
+
"""Fraction of pixels that differ by more than 30 in any channel.
|
|
287
|
+
|
|
288
|
+
Mismatched shapes short-circuit to 1.0 ("completely different"), matching
|
|
289
|
+
`_collect_common.max_movement_ratio`. Do not swap in a perceptual metric
|
|
290
|
+
without changing the solver's wait gate too — see module docstring.
|
|
291
|
+
"""
|
|
292
|
+
if a is None or b is None:
|
|
293
|
+
return 1.0
|
|
294
|
+
if a.shape != b.shape:
|
|
295
|
+
return 1.0
|
|
296
|
+
diff = cv2.absdiff(a, b)
|
|
297
|
+
if diff.ndim == 3:
|
|
298
|
+
diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
|
|
299
|
+
_, thr = cv2.threshold(diff, _PIXEL_DELTA, 255, cv2.THRESH_BINARY)
|
|
300
|
+
h, w = thr.shape[:2]
|
|
301
|
+
if h * w == 0:
|
|
302
|
+
return 1.0
|
|
303
|
+
return cv2.countNonZero(thr) / float(h * w)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── decoding ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def decode_video(path: str | os.PathLike) -> Tuple[List[np.ndarray], float]:
|
|
310
|
+
"""Every frame of a clip, in order, plus its fps.
|
|
311
|
+
|
|
312
|
+
Reads with `cv2.VideoCapture` rather than trusting CAP_PROP_FRAME_COUNT,
|
|
313
|
+
which lies on the `mp4v` files this project writes (the muxer puts `moov`
|
|
314
|
+
last and the count comes back as 0 often enough to matter).
|
|
315
|
+
"""
|
|
316
|
+
cap = cv2.VideoCapture(str(path))
|
|
317
|
+
if not cap.isOpened():
|
|
318
|
+
raise ValueError(f"could not open clip: {path}")
|
|
319
|
+
try:
|
|
320
|
+
fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
|
321
|
+
frames: List[np.ndarray] = []
|
|
322
|
+
while True:
|
|
323
|
+
ok, frame = cap.read()
|
|
324
|
+
if not ok:
|
|
325
|
+
break
|
|
326
|
+
frames.append(frame)
|
|
327
|
+
finally:
|
|
328
|
+
cap.release()
|
|
329
|
+
if not frames:
|
|
330
|
+
raise ValueError(f"clip decoded to zero frames: {path}")
|
|
331
|
+
if not fps or fps <= 0 or fps > 240:
|
|
332
|
+
fps = 10.0 # the collector's BURST_FPS; a sane floor beats a NaN
|
|
333
|
+
return frames, fps
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── extraction ──────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tuple[int, int]]:
|
|
340
|
+
"""Partition the clip into maximal runs that all match the run's FIRST frame.
|
|
341
|
+
|
|
342
|
+
Anchored on the first frame rather than chained frame-to-frame on purpose: a
|
|
343
|
+
slow pan has every consecutive diff under the threshold while the endpoints
|
|
344
|
+
are nothing alike, so a chained walk would call the whole pan one steady
|
|
345
|
+
state. Comparing against the anchor cannot drift.
|
|
346
|
+
"""
|
|
347
|
+
runs: List[Tuple[int, int]] = []
|
|
348
|
+
i = 0
|
|
349
|
+
n = len(frames)
|
|
350
|
+
while i < n:
|
|
351
|
+
j = i
|
|
352
|
+
while j + 1 < n and frame_diff_ratio(frames[i], frames[j + 1]) <= steady_ratio:
|
|
353
|
+
j += 1
|
|
354
|
+
runs.append((i, j))
|
|
355
|
+
i = j + 1
|
|
356
|
+
return runs
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
|
|
360
|
+
"""The index in `indices` whose frame is most typical of the group.
|
|
361
|
+
|
|
362
|
+
For a hold this is nearly arbitrary (the frames are near-identical by
|
|
363
|
+
construction) but it keeps a half-faded edge frame from representing the
|
|
364
|
+
state when a run happens to straddle a transition boundary.
|
|
365
|
+
"""
|
|
366
|
+
if len(indices) <= 2:
|
|
367
|
+
return indices[0]
|
|
368
|
+
best_idx, best_cost = indices[0], float("inf")
|
|
369
|
+
for a in indices:
|
|
370
|
+
cost = sum(frame_diff_ratio(frames[a], frames[b]) for b in indices if b != a)
|
|
371
|
+
if cost < best_cost:
|
|
372
|
+
best_idx, best_cost = a, cost
|
|
373
|
+
return best_idx
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _even_indices(n: int, count: int) -> List[int]:
|
|
377
|
+
"""`count` indices spread across 0..n-1, endpoints included, deduped."""
|
|
378
|
+
if n <= count:
|
|
379
|
+
return list(range(n))
|
|
380
|
+
if count <= 1:
|
|
381
|
+
return [0]
|
|
382
|
+
out = [int(round(k * (n - 1) / (count - 1))) for k in range(count)]
|
|
383
|
+
seen: List[int] = []
|
|
384
|
+
for i in out:
|
|
385
|
+
if i not in seen:
|
|
386
|
+
seen.append(i)
|
|
387
|
+
return seen
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _distinct_indices(
|
|
391
|
+
frames: Sequence[np.ndarray], candidates: Sequence[int], params: KeyframeParams
|
|
392
|
+
) -> List[int]:
|
|
393
|
+
"""Drop candidates that repeat a picture already kept, then spend the freed
|
|
394
|
+
budget on frames that are genuinely new.
|
|
395
|
+
|
|
396
|
+
WHY THIS IS NEEDED EVEN THOUGH `even` MODE IS ALREADY "CORRECT". Even spacing
|
|
397
|
+
asks a question about TIME, and a clip that rests answers it with duplicates: a
|
|
398
|
+
geetest icon board holding three states for 12/20/8 frames, sampled at
|
|
399
|
+
0/8/16/23/31/39, returns state A twice and state B three times. Six images, three
|
|
400
|
+
pictures. That is not merely wasteful of context — it makes the `frame` label
|
|
401
|
+
AMBIGUOUS. If frames 1 and 2 are the same picture, an answer naming either is
|
|
402
|
+
equally right, but grading compares against one number and the solver waits for
|
|
403
|
+
one of them. A duplicate is a silently unanswerable question.
|
|
404
|
+
|
|
405
|
+
Why this and not a better `cycle` detector. Distinguishing "three discrete states
|
|
406
|
+
shown once" from "smooth motion coarsely quantised" is not decidable from
|
|
407
|
+
`frame_diff_ratio`: the metric saturates once a sprite clears its old position,
|
|
408
|
+
so both shapes produce equal-distance, high-coverage "holds" and the pairwise
|
|
409
|
+
geometry that would separate them is destroyed (a 2px/frame pan and a real
|
|
410
|
+
3-state board both score ~0.55 on every path statistic tried). Deduplication
|
|
411
|
+
sidesteps the question entirely, because it is safe in the one direction that
|
|
412
|
+
matters: it only ever removes a picture that is ALREADY in the set, so no frame
|
|
413
|
+
carrying an answer can be lost. Under-sampling — the failure this module exists
|
|
414
|
+
to prevent — is impossible here by construction.
|
|
415
|
+
|
|
416
|
+
The backfill exists because dedup frees budget and a clip can have more states
|
|
417
|
+
than even spacing happened to land on. `symza` holds nine; sampling six and
|
|
418
|
+
deduplicating leaves four, so four of its pictures were never offered. Walking
|
|
419
|
+
the clip in order for anything distinct from everything kept spends the returned
|
|
420
|
+
slots on new pictures instead of retiring them.
|
|
421
|
+
|
|
422
|
+
Returns indices in ascending order, so keyframe numbers stay a time order.
|
|
423
|
+
"""
|
|
424
|
+
kept: List[int] = []
|
|
425
|
+
|
|
426
|
+
def is_new(i: int) -> bool:
|
|
427
|
+
return not any(
|
|
428
|
+
frame_diff_ratio(frames[i], frames[k]) <= params.steady_ratio for k in kept
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
for i in candidates:
|
|
432
|
+
if is_new(i):
|
|
433
|
+
kept.append(i)
|
|
434
|
+
if len(kept) < params.max_keyframes:
|
|
435
|
+
for i in range(len(frames)):
|
|
436
|
+
if len(kept) >= params.max_keyframes:
|
|
437
|
+
break
|
|
438
|
+
if i not in kept and is_new(i):
|
|
439
|
+
kept.append(i)
|
|
440
|
+
return sorted(kept)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _detect_cycle(
|
|
444
|
+
frames: Sequence[np.ndarray], params: KeyframeParams
|
|
445
|
+
) -> Optional[Tuple[List[int], List[int]]]:
|
|
446
|
+
"""`(state_medoid_indices, per_frame_state)` if the clip is a cycle of steady
|
|
447
|
+
pictures, else None.
|
|
448
|
+
|
|
449
|
+
`per_frame_state[i]` is the 0-based state of source frame i, or -1 for a
|
|
450
|
+
transition frame that belongs to no hold.
|
|
451
|
+
"""
|
|
452
|
+
n = len(frames)
|
|
453
|
+
runs = _anchor_runs(frames, params.steady_ratio)
|
|
454
|
+
holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
|
|
455
|
+
if not holds:
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
covered = sum(e - s + 1 for s, e in holds)
|
|
459
|
+
if covered / float(n) < params.min_steady_coverage:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
# Merge holds that show the same picture. Order of first appearance is the
|
|
463
|
+
# order the model will see, so a cycle that revisits state 1 does not mint a
|
|
464
|
+
# fourth image for it.
|
|
465
|
+
state_reps: List[int] = [] # medoid source index per state
|
|
466
|
+
hold_state: List[int] = [] # state id per hold, parallel to `holds`
|
|
467
|
+
for s, e in holds:
|
|
468
|
+
rep = _medoid(frames, list(range(s, e + 1)))
|
|
469
|
+
for k, existing in enumerate(state_reps):
|
|
470
|
+
if frame_diff_ratio(frames[rep], frames[existing]) <= params.steady_ratio:
|
|
471
|
+
hold_state.append(k)
|
|
472
|
+
break
|
|
473
|
+
else:
|
|
474
|
+
if len(state_reps) >= params.max_keyframes:
|
|
475
|
+
return None # too many distinct holds to be a tidy cycle
|
|
476
|
+
state_reps.append(rep)
|
|
477
|
+
hold_state.append(len(state_reps) - 1)
|
|
478
|
+
|
|
479
|
+
if len(state_reps) < 1:
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
# A cycle REVISITS. More holds than states means some picture came back, which
|
|
483
|
+
# is what "the video is just a shift between N steady images" describes. Equal
|
|
484
|
+
# counts mean every hold was a new picture — a one-way progression (a slow
|
|
485
|
+
# cross-fade reads exactly like this, see module docstring), where collapsing
|
|
486
|
+
# to one frame per hold would discard the frames carrying the answer.
|
|
487
|
+
#
|
|
488
|
+
# The cost of this rule is a genuine cycle whose period is long enough that
|
|
489
|
+
# only ONE pass fits in the recording — every real geetest_v4_svg clip
|
|
490
|
+
# is exactly this — so it falls back to `even`. That used to mean 6 frames of a
|
|
491
|
+
# 3-picture clip; since `even` deduplicates it now means 3, which is the same
|
|
492
|
+
# answer `cycle` would have given, reached without having to decide whether the
|
|
493
|
+
# clip recurs. The rule stays because dropping it WOULD be unsafe: a slow
|
|
494
|
+
# one-way fade also decomposes into 2 long holds at full coverage (verified),
|
|
495
|
+
# and collapsing that to 2 frames discards the middle of the clip. Dedup cannot
|
|
496
|
+
# make that mistake; a permissive cycle detector can.
|
|
497
|
+
if len(holds) <= len(state_reps) and len(state_reps) > 1:
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
# Every pair of states must be clearly different, or we are calling noise a
|
|
501
|
+
# state and would hand the model two images of the same thing.
|
|
502
|
+
for a in range(len(state_reps)):
|
|
503
|
+
for b in range(a + 1, len(state_reps)):
|
|
504
|
+
if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
per_frame = [-1] * n
|
|
508
|
+
for (s, e), state in zip(holds, hold_state):
|
|
509
|
+
for i in range(s, e + 1):
|
|
510
|
+
per_frame[i] = state
|
|
511
|
+
return state_reps, per_frame
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def extract_keyframes(
|
|
515
|
+
frames: Sequence[np.ndarray],
|
|
516
|
+
*,
|
|
517
|
+
fps: float = 10.0,
|
|
518
|
+
params: Optional[KeyframeParams] = None,
|
|
519
|
+
) -> KeyframeSet:
|
|
520
|
+
"""Slice a decoded clip into the frames the model will be shown.
|
|
521
|
+
|
|
522
|
+
`frames` are BGR uint8 arrays in capture order (what `cv2` hands back and
|
|
523
|
+
what the collector's burst produces).
|
|
524
|
+
"""
|
|
525
|
+
if not frames:
|
|
526
|
+
raise ValueError("no frames to extract keyframes from")
|
|
527
|
+
p = params or KeyframeParams()
|
|
528
|
+
n = len(frames)
|
|
529
|
+
|
|
530
|
+
def _ms(i: int) -> float:
|
|
531
|
+
return (i / fps) * 1000.0 if fps > 0 else 0.0
|
|
532
|
+
|
|
533
|
+
cycle = _detect_cycle(frames, p)
|
|
534
|
+
if cycle is not None:
|
|
535
|
+
reps, per_frame = cycle
|
|
536
|
+
mode = "static" if len(reps) == 1 else "cycle"
|
|
537
|
+
return KeyframeSet(
|
|
538
|
+
mode=mode,
|
|
539
|
+
keyframes=[
|
|
540
|
+
Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
|
|
541
|
+
image=frames[idx])
|
|
542
|
+
for k, idx in enumerate(reps)
|
|
543
|
+
],
|
|
544
|
+
source_frames=n,
|
|
545
|
+
fps=fps,
|
|
546
|
+
params=p,
|
|
547
|
+
frame_states=per_frame,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
indices = _even_indices(n, p.max_keyframes)
|
|
551
|
+
if p.dedupe:
|
|
552
|
+
# Applied to `even` only. `cycle` states are distinct by construction (the
|
|
553
|
+
# merge in `_detect_cycle` uses this same threshold) and `static` is one
|
|
554
|
+
# frame, so there is nothing to remove; and dropping a cycle keyframe here
|
|
555
|
+
# would desynchronise `frame_states`, which indexes keyframes by position.
|
|
556
|
+
indices = _distinct_indices(frames, indices, p)
|
|
557
|
+
return KeyframeSet(
|
|
558
|
+
mode="even",
|
|
559
|
+
keyframes=[
|
|
560
|
+
Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
|
|
561
|
+
image=frames[idx])
|
|
562
|
+
for k, idx in enumerate(indices)
|
|
563
|
+
],
|
|
564
|
+
source_frames=n,
|
|
565
|
+
fps=fps,
|
|
566
|
+
params=p,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def extract_keyframes_from_video(
|
|
571
|
+
path: str | os.PathLike, *, params: Optional[KeyframeParams] = None
|
|
572
|
+
) -> KeyframeSet:
|
|
573
|
+
frames, fps = decode_video(path)
|
|
574
|
+
return extract_keyframes(frames, fps=fps, params=params)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ── on-disk layout ──────────────────────────────────────────────────────────
|
|
578
|
+
#
|
|
579
|
+
# <media_dir>/keyframes/<stem>/frame_01.png ... frame_0N.png
|
|
580
|
+
# <media_dir>/keyframes/<stem>/keyframes.json
|
|
581
|
+
#
|
|
582
|
+
# Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
|
|
583
|
+
# matters most under `cleanSamples/test/`, which is irreplaceable hand-labeled
|
|
584
|
+
# data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
|
|
585
|
+
# new sibling directory there.
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def keyframe_dir_for(media_path: str | os.PathLike) -> Path:
|
|
589
|
+
"""Where this clip's keyframes live. Pure path arithmetic, no I/O."""
|
|
590
|
+
p = Path(media_path)
|
|
591
|
+
return p.parent / KEYFRAME_DIR_NAME / p.stem
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _frame_filename(number: int) -> str:
|
|
595
|
+
return f"frame_{number:02d}.png"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def write_keyframes(
|
|
599
|
+
kfset: KeyframeSet, out_dir: str | os.PathLike, *, stem: str
|
|
600
|
+
) -> List[Path]:
|
|
601
|
+
"""Write `frame_NN.png` + `keyframes.json` into `out_dir`. Returns the PNG
|
|
602
|
+
paths in model order.
|
|
603
|
+
|
|
604
|
+
Stale files from a previous slicing are removed: a clip re-sliced from 6
|
|
605
|
+
keyframes down to 3 would otherwise leave `frame_04..06.png` behind, and
|
|
606
|
+
`read_keyframe_paths` globs the directory — the model would be handed six
|
|
607
|
+
images while the manifest and the label said three.
|
|
608
|
+
"""
|
|
609
|
+
out = Path(out_dir)
|
|
610
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
for stale in out.glob("frame_*.png"):
|
|
612
|
+
stale.unlink()
|
|
613
|
+
|
|
614
|
+
paths: List[Path] = []
|
|
615
|
+
names: List[str] = []
|
|
616
|
+
for kf in kfset.keyframes:
|
|
617
|
+
name = _frame_filename(kf.number)
|
|
618
|
+
target = out / name
|
|
619
|
+
if kf.image is None:
|
|
620
|
+
raise ValueError(f"keyframe {kf.number} has no image to write")
|
|
621
|
+
cv2.imwrite(str(target), kf.image)
|
|
622
|
+
paths.append(target)
|
|
623
|
+
names.append(name)
|
|
624
|
+
|
|
625
|
+
with (out / MANIFEST_NAME).open("w") as f:
|
|
626
|
+
json.dump(kfset.manifest(stem=stem, filenames=names), f, indent=2)
|
|
627
|
+
return paths
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def load_manifest(kf_dir: str | os.PathLike) -> Optional[Dict[str, Any]]:
|
|
631
|
+
p = Path(kf_dir) / MANIFEST_NAME
|
|
632
|
+
if not p.exists():
|
|
633
|
+
return None
|
|
634
|
+
try:
|
|
635
|
+
with p.open() as f:
|
|
636
|
+
data = json.load(f)
|
|
637
|
+
except (json.JSONDecodeError, OSError):
|
|
638
|
+
return None
|
|
639
|
+
return data if isinstance(data, dict) else None
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def read_keyframe_paths(kf_dir: str | os.PathLike) -> List[Path]:
|
|
643
|
+
"""The keyframe PNGs in model order, or [] if the directory isn't usable.
|
|
644
|
+
|
|
645
|
+
Order comes from the manifest when present and from the zero-padded filenames
|
|
646
|
+
otherwise; both give frame 1 first. A manifest naming a file that isn't there
|
|
647
|
+
yields [] rather than a short list, because a gap would silently renumber
|
|
648
|
+
every later frame and invalidate the stored `frame` label.
|
|
649
|
+
"""
|
|
650
|
+
d = Path(kf_dir)
|
|
651
|
+
if not d.is_dir():
|
|
652
|
+
return []
|
|
653
|
+
manifest = load_manifest(d)
|
|
654
|
+
if manifest and isinstance(manifest.get("keyframes"), list):
|
|
655
|
+
out: List[Path] = []
|
|
656
|
+
for entry in sorted(manifest["keyframes"], key=lambda e: e.get("number", 0)):
|
|
657
|
+
p = d / str(entry.get("file", ""))
|
|
658
|
+
if not p.exists():
|
|
659
|
+
return []
|
|
660
|
+
out.append(p)
|
|
661
|
+
return out
|
|
662
|
+
return sorted(d.glob("frame_*.png"))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def materialize_keyframes(
|
|
666
|
+
media_path: str | os.PathLike,
|
|
667
|
+
*,
|
|
668
|
+
params: Optional[KeyframeParams] = None,
|
|
669
|
+
force: bool = False,
|
|
670
|
+
) -> List[Path]:
|
|
671
|
+
"""The keyframes for a clip, extracting them on first use.
|
|
672
|
+
|
|
673
|
+
Idempotent and cheap on the second call: an existing manifest whose params
|
|
674
|
+
match is reused as-is. A params CHANGE re-slices, because reusing a set cut
|
|
675
|
+
with different thresholds would mix two slicings in one dataset — the failure
|
|
676
|
+
the manifest records params to prevent.
|
|
677
|
+
"""
|
|
678
|
+
media = Path(media_path)
|
|
679
|
+
kf_dir = keyframe_dir_for(media)
|
|
680
|
+
p = params or KeyframeParams()
|
|
681
|
+
|
|
682
|
+
if not force:
|
|
683
|
+
manifest = load_manifest(kf_dir)
|
|
684
|
+
if manifest and manifest.get("params") == p.as_dict():
|
|
685
|
+
existing = read_keyframe_paths(kf_dir)
|
|
686
|
+
if existing:
|
|
687
|
+
return existing
|
|
688
|
+
|
|
689
|
+
kfset = extract_keyframes_from_video(media, params=p)
|
|
690
|
+
return write_keyframes(kfset, kf_dir, stem=media.stem)
|