captchakraken 2.8.0 → 3.0.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.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +9 -40
  4. package/dist/cli-invocation.js +16 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +112 -539
  28. package/dist/solver.js +1088 -2950
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +77 -483
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +12 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -879
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +69 -434
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +79 -21
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1123 -2564
  60. package/python/src/captchakraken/planner.py +70 -428
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,20 +1,8 @@
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.
1
+ """Reduce a recorded clip to the few frames the model is shown.
2
+
3
+ Ported from the training-side extractor; the model answers with a frame NUMBER into this slicing, so a
4
+ divergence names a picture that does not exist (see TRIBAL_KNOWLEDGE.md). `region_box` / `region_diff_ratio`
5
+ are also the driver's wait-for-state gate, with the same box the label was chosen with.
18
6
  """
19
7
 
20
8
  from __future__ import annotations
@@ -28,77 +16,32 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
28
16
  import cv2
29
17
  import numpy as np
30
18
 
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).
19
+ from .kinds import KeyframeMode
20
+
37
21
 
38
22
  DEFAULT_MAX_KEYFRAMES = 6
39
23
 
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.
24
+ # Measured over 20 real clips: same picture <= 0.000067, smallest real state change >= 0.004282. The old 0.005
25
+ # sat ABOVE that change and collapsed ssqr8 to one still; it was calibrated on 64x64 fixtures.
61
26
  DEFAULT_STEADY_RATIO = 0.001
62
27
 
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.
28
+ # Licenses `cycle`'s collapse, where being generous discards the middle of a clip; deliberately not scaled
29
+ # with steady_ratio, and never applied to steady_screens (GeeTest svg screens are 0.005 apart).
73
30
  DEFAULT_DISTINCT_RATIO = 0.02
74
31
 
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.
32
+ # A hold is a deliberate resting state, not a frame that repeated mid-animation: 3 frames @ 10 fps ~ 0.3 s.
77
33
  DEFAULT_MIN_HOLD_FRAMES = 3
78
34
 
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.
35
+ # A 3-state cycle is ~0.9 held, a cross-fade ~0.0; the gap is wide.
82
36
  DEFAULT_MIN_STEADY_COVERAGE = 0.5
83
37
 
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).
38
+ # The one pixel threshold shared with every other movement check in the project.
87
39
  _PIXEL_DELTA = 30
88
40
 
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.
41
+ # One constant for both choosing the label's frame and the live wait: the two must ask the same question.
96
42
  MATCH_REGION_HALF = 0.06
97
43
 
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.
44
+ # Looser than steady_ratio: the live page carries antialiasing and cursor artefacts a keyframe does not.
102
45
  MATCH_REGION_TOLERANCE = 0.05
103
46
 
104
47
  MANIFEST_NAME = "keyframes.json"
@@ -107,20 +50,13 @@ KEYFRAME_DIR_NAME = "keyframes"
107
50
 
108
51
  @dataclass(frozen=True)
109
52
  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."""
53
+ """Recorded in the manifest so a threshold change re-cuts a set instead of silently mixing two slicings."""
114
54
 
115
55
  max_keyframes: int = DEFAULT_MAX_KEYFRAMES
116
56
  steady_ratio: float = DEFAULT_STEADY_RATIO
117
57
  distinct_ratio: float = DEFAULT_DISTINCT_RATIO
118
58
  min_hold_frames: int = DEFAULT_MIN_HOLD_FRAMES
119
59
  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
60
  dedupe: bool = True
125
61
 
126
62
  def as_dict(self) -> Dict[str, Any]:
@@ -136,96 +72,34 @@ class KeyframeParams:
136
72
 
137
73
  @dataclass
138
74
  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
- """
75
+ """`number` is 1-based and is what the model returns as `frame`; `source_index` keeps the provenance."""
147
76
 
148
77
  number: int
149
78
  source_index: int
150
79
  timestamp_ms: float
151
- image: Optional[np.ndarray] = None # BGR uint8; None once written to disk
80
+ image: Optional[np.ndarray] = None
152
81
 
153
82
 
154
83
  @dataclass
155
84
  class KeyframeSet:
156
- mode: str # "cycle" | "even" | "static"
85
+ mode: KeyframeMode
157
86
  keyframes: List[Keyframe]
158
87
  source_frames: int
159
88
  fps: float
160
89
  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
90
  frame_states: List[int] = field(default_factory=list)
91
+ # Independent of `mode`: EVEN says recurrence could not be proved from one burst, this says how many
92
+ # screens the board sits on. Reading `mode` for that put the driver's wait off on 100% of real animated captchas.
93
+ steady_screens: int = 0
165
94
 
166
95
  def __len__(self) -> int:
167
96
  return len(self.keyframes)
168
97
 
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
98
  def manifest(self, *, stem: str, filenames: Sequence[str]) -> Dict[str, Any]:
226
99
  return {
227
100
  "stem": stem,
228
101
  "mode": self.mode,
102
+ "steady_screens": self.steady_screens,
229
103
  "fps": self.fps,
230
104
  "source_frames": self.source_frames,
231
105
  "params": self.params.as_dict(),
@@ -241,21 +115,12 @@ class KeyframeSet:
241
115
  }
242
116
 
243
117
 
244
- # ── similarity ──────────────────────────────────────────────────────────────
245
-
246
-
247
118
  def region_box(
248
119
  size_wh: Tuple[int, int],
249
120
  point_norm: Tuple[float, float],
250
121
  half: float = MATCH_REGION_HALF,
251
122
  ) -> 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
- """
123
+ """Never empty: a comparison over no pixels reads as a perfect match and the gate would open on any state."""
259
124
  w, h = int(size_wh[0]), int(size_wh[1])
260
125
  cx, cy = float(point_norm[0]) * w, float(point_norm[1]) * h
261
126
  rx, ry = max(1.0, half * w), max(1.0, half * h)
@@ -269,11 +134,6 @@ def region_box(
269
134
  def region_diff_ratio(
270
135
  a: np.ndarray, b: np.ndarray, box: Optional[Tuple[int, int, int, int]] = None
271
136
  ) -> 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
137
  if a is None or b is None or a.shape != b.shape:
278
138
  return 1.0
279
139
  if box is None:
@@ -283,12 +143,6 @@ def region_diff_ratio(
283
143
 
284
144
 
285
145
  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
146
  if a is None or b is None:
293
147
  return 1.0
294
148
  if a.shape != b.shape:
@@ -303,47 +157,9 @@ def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
303
157
  return cv2.countNonZero(thr) / float(h * w)
304
158
 
305
159
 
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
160
 
339
161
  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
- """
162
+ """Anchored on the run's first frame: a slow pan has every consecutive diff under the threshold."""
347
163
  runs: List[Tuple[int, int]] = []
348
164
  i = 0
349
165
  n = len(frames)
@@ -357,12 +173,6 @@ def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tupl
357
173
 
358
174
 
359
175
  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
176
  if len(indices) <= 2:
367
177
  return indices[0]
368
178
  best_idx, best_cost = indices[0], float("inf")
@@ -374,7 +184,6 @@ def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
374
184
 
375
185
 
376
186
  def _even_indices(n: int, count: int) -> List[int]:
377
- """`count` indices spread across 0..n-1, endpoints included, deduped."""
378
187
  if n <= count:
379
188
  return list(range(n))
380
189
  if count <= 1:
@@ -390,36 +199,10 @@ def _even_indices(n: int, count: int) -> List[int]:
390
199
  def _distinct_indices(
391
200
  frames: Sequence[np.ndarray], candidates: Sequence[int], params: KeyframeParams
392
201
  ) -> 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.
202
+ """Drop repeats, then backfill: a duplicate makes the `frame` label ambiguous, a silently unanswerable question.
203
+
204
+ Safer than a better cycle detector (a 2px/frame pan and a real 3-state board score alike on every path
205
+ statistic tried); the backfill exists because symza holds nine screens and six samples found four.
423
206
  """
424
207
  kept: List[int] = []
425
208
 
@@ -440,15 +223,9 @@ def _distinct_indices(
440
223
  return sorted(kept)
441
224
 
442
225
 
443
- def _detect_cycle(
226
+ def _steady_screens(
444
227
  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
- """
228
+ ) -> Optional[Tuple[List[Tuple[int, int]], List[int], List[int]]]:
452
229
  n = len(frames)
453
230
  runs = _anchor_runs(frames, params.steady_ratio)
454
231
  holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
@@ -459,11 +236,8 @@ def _detect_cycle(
459
236
  if covered / float(n) < params.min_steady_coverage:
460
237
  return None
461
238
 
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`
239
+ state_reps: List[int] = []
240
+ hold_state: List[int] = []
467
241
  for s, e in holds:
468
242
  rep = _medoid(frames, list(range(s, e + 1)))
469
243
  for k, existing in enumerate(state_reps):
@@ -472,33 +246,36 @@ def _detect_cycle(
472
246
  break
473
247
  else:
474
248
  if len(state_reps) >= params.max_keyframes:
475
- return None # too many distinct holds to be a tidy cycle
249
+ return None
476
250
  state_reps.append(rep)
477
251
  hold_state.append(len(state_reps) - 1)
478
252
 
479
253
  if len(state_reps) < 1:
480
254
  return None
481
255
 
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.
256
+ # No distinct_ratio here: the merge above already separated states by the measured noise floor.
257
+ return holds, state_reps, hold_state
258
+
259
+
260
+ def steady_screens(frames: Sequence[np.ndarray], params: KeyframeParams) -> int:
261
+ got = _steady_screens(frames, params)
262
+ return len(got[1]) if got else 0
263
+
264
+
265
+ def _detect_cycle(
266
+ frames: Sequence[np.ndarray], params: KeyframeParams
267
+ ) -> Optional[Tuple[List[int], List[int]]]:
268
+ n = len(frames)
269
+ got = _steady_screens(frames, params)
270
+ if got is None:
271
+ return None
272
+ holds, state_reps, hold_state = got
273
+
274
+ # A cycle REVISITS. Equal counts is a one-way progression: a slow fade also decomposes into 2 long holds
275
+ # (verified), and collapsing that to 2 frames discards the middle of the clip.
497
276
  if len(holds) <= len(state_reps) and len(state_reps) > 1:
498
277
  return None
499
278
 
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
279
  for a in range(len(state_reps)):
503
280
  for b in range(a + 1, len(state_reps)):
504
281
  if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
@@ -514,55 +291,16 @@ def _detect_cycle(
514
291
  def _drop_smeared(
515
292
  frames: Sequence[np.ndarray], indices: Sequence[int], params: KeyframeParams
516
293
  ) -> List[int]:
517
- """Drop an index caught BETWEEN two holds, when both of them are already
518
- represented in the cut.
519
-
520
- These clips hold a picture, swap, hold the next. An evenly spaced sample can
521
- land on the instant of the swap — a board part-way between two states, which
522
- is a picture the puzzle never actually shows. If the model already has the
523
- board before the swap AND the board after it, that frame adds nothing but a
524
- smear; if either endpoint is missing, it is the only thing standing in for a
525
- state and must stay.
526
-
527
- THE MACHINERY ALREADY EXISTED AND THIS PATH NEVER ASKED IT. `_anchor_runs`
528
- plus `min_hold_frames` is how `_detect_cycle` separates holds from
529
- transitions (it labels the latter -1 in `frame_states`), and
530
- `min_hold_frames` had exactly one reader — inside `_detect_cycle`. On the
531
- `even` path the parameter was declared, serialised into every manifest, and
532
- inert.
533
-
534
- WHY THE TEST IS "BOTH NEIGHBOURING HOLDS ARE KEPT" AND NOT A PIXEL DIFF.
535
- Two earlier versions were measured against both trees and both were wrong:
536
-
537
- * *Snap to the nearest held frame* turned `[0, 16, 39]` into `[0, 16]` on
538
- six eval clips, dropping a SCREEN. A page that never reaches the model is
539
- unanswerable whenever the target lived on it.
540
- * *Drop when some other still is within `distinct_ratio`* cut
541
- `hcaptcha_tile_flip_video` from 6 stills to 3, because two boards of that
542
- puzzle differ by ONE TILE — well inside `distinct_ratio` — so a diff
543
- threshold cannot tell "the same picture again" from "the next board".
544
- EXPECTED_SLICING says it directly: fewer than 6 "means dedup merged two
545
- boards that differ only by the tile mid-flip, and that tile is frequently
546
- the answer".
547
-
548
- Anchoring on the HOLDS either side asks the question that actually matters —
549
- does the model already see both ends of this swap — and needs no threshold of
550
- its own.
551
-
552
- A clip with no holds at all (a rotation, a continuous fade, anything in
553
- permanent motion) has no transitions to speak of and is returned untouched.
294
+ """Drop a sample caught mid-swap when both neighbouring holds are already kept.
295
+
296
+ Not a pixel diff: snapping to the nearest hold turned [0, 16, 39] into [0, 16] on six clips, and a
297
+ distinct_ratio test cut an hCaptcha tile-flip from 6 stills to 3 (two boards differ by one tile).
554
298
  """
555
299
  runs = _anchor_runs(frames, params.steady_ratio)
556
300
  holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
557
301
  if not holds:
558
- return list(indices) # nothing holds; nothing is a smear
559
-
560
- # Holds showing the same picture are ONE state — the same merge
561
- # `_detect_cycle` does, at the same threshold. It matters here because a
562
- # cycle revisits: on a real `icon_2x2` clip the hold at 53..74 is the
563
- # opening board coming back, so the still at index 0 already covers it.
564
- # Counting holds instead of states kept a smear whose neighbouring picture
565
- # was on screen the whole time.
302
+ return list(indices)
303
+
566
304
  state_of_hold: List[int] = []
567
305
  reps: List[int] = []
568
306
  for start, end in holds:
@@ -590,9 +328,6 @@ def _drop_smeared(
590
328
  continue
591
329
  before = max((k for k, (s, e) in enumerate(holds) if e < i), default=None)
592
330
  after = min((k for k, (s, e) in enumerate(holds) if s > i), default=None)
593
- # Only a swap BETWEEN two kept holds is redundant. A transition at the
594
- # very start or end of a clip has an endpoint that was never recorded,
595
- # so it is evidence of a state and stays.
596
331
  if before is not None and after is not None \
597
332
  and state_of_hold[before] in covered \
598
333
  and state_of_hold[after] in covered:
@@ -607,11 +342,6 @@ def extract_keyframes(
607
342
  fps: float = 10.0,
608
343
  params: Optional[KeyframeParams] = None,
609
344
  ) -> KeyframeSet:
610
- """Slice a decoded clip into the frames the model will be shown.
611
-
612
- `frames` are BGR uint8 arrays in capture order (what `cv2` hands back and
613
- what the collector's burst produces).
614
- """
615
345
  if not frames:
616
346
  raise ValueError("no frames to extract keyframes from")
617
347
  p = params or KeyframeParams()
@@ -623,8 +353,9 @@ def extract_keyframes(
623
353
  cycle = _detect_cycle(frames, p)
624
354
  if cycle is not None:
625
355
  reps, per_frame = cycle
626
- mode = "static" if len(reps) == 1 else "cycle"
356
+ mode = KeyframeMode.STATIC if len(reps) == 1 else KeyframeMode.CYCLE
627
357
  return KeyframeSet(
358
+ steady_screens=len(reps),
628
359
  mode=mode,
629
360
  keyframes=[
630
361
  Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
@@ -638,23 +369,15 @@ def extract_keyframes(
638
369
  )
639
370
 
640
371
  indices = _even_indices(n, p.max_keyframes)
641
- # BEFORE dedup, so a smear does not spend budget a real state could use...
372
+ # Before dedup so a smear does not spend budget, and after because the backfill re-picks smears
373
+ # (measured: filtering only before returned [0, 10, 12, 21, 25] with 10 and 21 the two smears).
642
374
  indices = _drop_smeared(frames, indices, p)
643
375
  if p.dedupe:
644
- # Applied to `even` only. `cycle` states are distinct by construction (the
645
- # merge in `_detect_cycle` uses this same threshold) and `static` is one
646
- # frame, so there is nothing to remove; and dropping a cycle keyframe here
647
- # would desynchronise `frame_states`, which indexes keyframes by position.
648
376
  indices = _distinct_indices(frames, indices, p)
649
- # ...AND AFTER, because `_distinct_indices` BACKFILLS. Dedup frees
650
- # budget and the backfill walks the clip for anything not already
651
- # represented — which is exactly what a smeared frame is, so it gets
652
- # picked straight back up. Measured on a 3-hold fixture: filtering only
653
- # before dedup returned [0, 10, 12, 21, 25] with 10 and 21 the two
654
- # smears the first pass had just removed.
655
377
  indices = _drop_smeared(frames, indices, p)
656
378
  return KeyframeSet(
657
- mode="even",
379
+ steady_screens=steady_screens(frames, p),
380
+ mode=KeyframeMode.EVEN,
658
381
  keyframes=[
659
382
  Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
660
383
  image=frames[idx])
@@ -666,47 +389,19 @@ def extract_keyframes(
666
389
  )
667
390
 
668
391
 
669
- def extract_keyframes_from_video(
670
- path: str | os.PathLike, *, params: Optional[KeyframeParams] = None
671
- ) -> KeyframeSet:
672
- frames, fps = decode_video(path)
673
- return extract_keyframes(frames, fps=fps, params=params)
674
-
675
-
676
- # ── on-disk layout ──────────────────────────────────────────────────────────
677
- #
678
- # <media_dir>/keyframes/<stem>/frame_01.png ... frame_0N.png
679
- # <media_dir>/keyframes/<stem>/keyframes.json
680
- #
681
- # Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
682
- # matters most under `cleanSamples/test/`, which is irreplaceable hand-labeled
683
- # data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
684
- # new sibling directory there.
685
-
686
-
687
- def keyframe_dir_for(media_path: str | os.PathLike) -> Path:
688
- """Where this clip's keyframes live. Pure path arithmetic, no I/O."""
689
- p = Path(media_path)
690
- return p.parent / KEYFRAME_DIR_NAME / p.stem
691
392
 
692
393
 
693
394
  def _frame_filename(number: int) -> str:
395
+ """Zero-padded because readers sort by name."""
694
396
  return f"frame_{number:02d}.png"
695
397
 
696
398
 
697
399
  def write_keyframes(
698
400
  kfset: KeyframeSet, out_dir: str | os.PathLike, *, stem: str
699
401
  ) -> List[Path]:
700
- """Write `frame_NN.png` + `keyframes.json` into `out_dir`. Returns the PNG
701
- paths in model order.
702
-
703
- Stale files from a previous slicing are removed: a clip re-sliced from 6
704
- keyframes down to 3 would otherwise leave `frame_04..06.png` behind, and
705
- `read_keyframe_paths` globs the directory — the model would be handed six
706
- images while the manifest and the label said three.
707
- """
708
402
  out = Path(out_dir)
709
403
  out.mkdir(parents=True, exist_ok=True)
404
+ # A re-slice from 6 to 3 would otherwise leave frame_04..06 for a glob to hand the model.
710
405
  for stale in out.glob("frame_*.png"):
711
406
  stale.unlink()
712
407
 
@@ -726,64 +421,4 @@ def write_keyframes(
726
421
  return paths
727
422
 
728
423
 
729
- def load_manifest(kf_dir: str | os.PathLike) -> Optional[Dict[str, Any]]:
730
- p = Path(kf_dir) / MANIFEST_NAME
731
- if not p.exists():
732
- return None
733
- try:
734
- with p.open() as f:
735
- data = json.load(f)
736
- except (json.JSONDecodeError, OSError):
737
- return None
738
- return data if isinstance(data, dict) else None
739
-
740
-
741
- def read_keyframe_paths(kf_dir: str | os.PathLike) -> List[Path]:
742
- """The keyframe PNGs in model order, or [] if the directory isn't usable.
743
-
744
- Order comes from the manifest when present and from the zero-padded filenames
745
- otherwise; both give frame 1 first. A manifest naming a file that isn't there
746
- yields [] rather than a short list, because a gap would silently renumber
747
- every later frame and invalidate the stored `frame` label.
748
- """
749
- d = Path(kf_dir)
750
- if not d.is_dir():
751
- return []
752
- manifest = load_manifest(d)
753
- if manifest and isinstance(manifest.get("keyframes"), list):
754
- out: List[Path] = []
755
- for entry in sorted(manifest["keyframes"], key=lambda e: e.get("number", 0)):
756
- p = d / str(entry.get("file", ""))
757
- if not p.exists():
758
- return []
759
- out.append(p)
760
- return out
761
- return sorted(d.glob("frame_*.png"))
762
-
763
-
764
- def materialize_keyframes(
765
- media_path: str | os.PathLike,
766
- *,
767
- params: Optional[KeyframeParams] = None,
768
- force: bool = False,
769
- ) -> List[Path]:
770
- """The keyframes for a clip, extracting them on first use.
771
-
772
- Idempotent and cheap on the second call: an existing manifest whose params
773
- match is reused as-is. A params CHANGE re-slices, because reusing a set cut
774
- with different thresholds would mix two slicings in one dataset — the failure
775
- the manifest records params to prevent.
776
- """
777
- media = Path(media_path)
778
- kf_dir = keyframe_dir_for(media)
779
- p = params or KeyframeParams()
780
-
781
- if not force:
782
- manifest = load_manifest(kf_dir)
783
- if manifest and manifest.get("params") == p.as_dict():
784
- existing = read_keyframe_paths(kf_dir)
785
- if existing:
786
- return existing
787
424
 
788
- kfset = extract_keyframes_from_video(media, params=p)
789
- return write_keyframes(kfset, kf_dir, stem=media.stem)