captchakraken 3.2.0 → 4.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 (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +621 -259
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +224 -131
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -1,1283 +1,365 @@
1
- import cv2
2
- import numpy as np
3
- import os
4
- import tempfile
5
- from dataclasses import dataclass
6
- from typing import List, Tuple, Optional
7
- from ..kinds import LabelPosition
8
- from ..overlay import add_overlays_to_image
9
-
10
- # Grid detection: trace every gutter as a consistent-colour walk, cluster the traces, form an evenly spaced
11
- # lattice, then gate it on structure and on cell content. The colour comb and seal test are the second cue
12
- # (see _comb_lines and TRIBAL_KNOWLEDGE.md). Every threshold below is measured; each gate has a test named for it.
13
-
14
- COLOR_TOL = 10.0
15
- # Tolerates JPEG and lighting jitter along a real gutter; a real tile edge is far beyond it.
16
- CONT_TOL = 14.0
17
- CONT_TOL2 = CONT_TOL * CONT_TOL
18
- # Gate on L alone: a gutter's L span is ~3 while grass drifts ~32, and a/b are JPEG chroma noise that CIE76
19
- # over-weights (it wrongly dropped real tinted-grey gutters).
20
- SEED_L_TOL = 6.0
21
- # A gutter never jumps between consecutive pixels (|dL| <= ~2); grass jumps up to ~16.
22
- STEP_L_TOL = 4.0
23
- MIN_RUN = 10
24
- # No longer a hard reject: a white gutter adjacent to white tile content reads as one band far thicker.
25
- MAX_THICKNESS = 14
26
- PERP_SCAN = 60
27
- # On a miss, look this far perpendicular for the gutter colour before calling it a wall; ~3px tolerates ~25 deg.
28
- PERP_REFIND = 3
29
- # How far the walk tolerances widen per unit of measured noise; 0 restores the fixed thresholds exactly.
30
- NOISE_GAIN = 2.0
31
- MAX_PERP_JUMP = 6.0
32
- MERGE_PX = 8.0
33
- MERGE_ANGLE = 0.07
34
- MIN_CELL = 36
35
- # Fractions of the cell PITCH, not pixels: hCaptcha gutters are ~13px median, so a fixed offset probed gutter
36
- # against gutter and read zero contrast.
37
- FLANK_PITCH_FRACS = (0.18, 0.26, 0.34, 0.42)
38
- # Per grid and as a MEAN of both flanks: under `min` the lowest true hCaptcha grid scored 2.7 against false
39
- # positives at 9.0, under mean 17.4 against 18.0/14.1. 18.1 clears both but sits 0.3 under a real reCAPTCHA 4x4.
40
- GRID_FLANK_MIN_DE = 16.0
41
- LINE_STD_TOL = 7.0
42
- STEP = 1
43
- SLANT_CAP = 0.47
44
- SUPPORT_FRAC = 0.42
45
- TERM_FRAC = 0.30
46
- # A gutter spans the full image so a central band seeds it; narrowing from 0.46 cut trace attempts ~25%.
47
- MAX_SEED_FRAC = 0.34
48
- EDGE_MARGIN = 0.02
49
- GRID_COLOR_TOL = 8.0
50
- GRID_THICK_TOL = 4.0
51
- GRID_ANGLE_TOL = 0.09
52
- # Real grids share one gutter colour across both axes; photo "grids" pair an H edge of one colour with a V edge of another.
53
- XAXIS_COLOR_TOL = 6.0
54
- LATTICE_TOL = 0.18
55
- MAX_OFF_LATTICE = 1
56
- EVEN_TOL = 0.18
57
- # Measured floor over 2239 real grids is 0.348. Deliberately not 0.33: that also catches one observed video
58
- # false positive at 0.322 but leaves 5% headroom, buying a false-positive fix with a future missed grid.
59
- MIN_IMAGE_AREA_COVERAGE = 0.30
60
- # Applied to the EMITTED cells: the line check cannot see degenerate boxes built from near-duplicate lines.
61
- CELL_REGULARITY_TOL = 0.12
62
- MIN_GRID_DIM = 3
63
- # A true internal line has perpendicular gutters running ~a full cell past it on both sides; a frame line does not.
64
- CORROB_FRAC = 0.9
65
- GRID_OVERSHOOT = 0.35
66
- FULL_SPAN_MARGIN = 0.5
67
- MIN_GRID_COVERAGE = 0.72
68
- CELL_INSET = 0.22
69
- # A flat region (white wall, sky, watermark haze) has "cells" the same colour as its "gutters".
70
- CELL_DIVERGE_TOL = 12.0
71
- CELL_DIVERGE_FRAC = 0.6
72
- # Real-grid gutters measure color_std ~0-2; textured-photo pseudo-gutters run ~3-6.
73
- CLEAN_GUTTER_STD = 2.3
74
- # Only when the gutters are clean: a painted-gutter grid with sky tiles legitimately has fewer content cells.
75
- CELL_DIVERGE_FRAC_CLEAN = 0.42
76
- # A gutter's ridge run covers >= 0.88 of the scan, content fragments p90 ~0.35; drops ~95% of dead seeds (~73% of walk steps).
77
- SEED_RUN_FRAC = 0.5
78
- SEED_DECIMATE = 2
79
- CLEAN_LATTICE_STD = 2.0
80
- MAX_VIRTUAL_FRAC = 0.5
81
- MAX_VIRTUAL_NODES = 2
82
- # Per corroborated internal line a candidate leaves out, so a 4x4's [r1,r2,r3] is not dropped to [r2,r3].
83
- UNUSED_LINE_PENALTY = 400.0
84
- VIRTUAL_NODE_PENALTY = 600.0
85
- # Outweighs the virtual-node penalty so a completed 4x4 beats the 3-row subset whose gutters run a cell past the border.
86
- SPAN_FIT_PENALTY = 900.0
87
- # hCaptcha's V gutters reach the submit bar (~0.65 cell) without implying another cell.
88
- MISSING_LINE_FRAC = 0.8
89
- # Real grids are inset ~60-120px; a gutter reaching the image edge is bleeding into a margin, not a cell.
90
- EDGE_BLEED_PX = 6
91
- # Count clusters, not lines: the plateau on the real corpus is 17..23; at 24 a textured drag puzzle becomes a false positive.
92
- OFF_LATTICE_CLUSTER_PX = 20
93
- MAX_OFF_LATTICE_CLEAN = 1
94
- # Tight on purpose: on the failing 4x4s the gutter is pure white (L 100.0) against sky at L 95-97.
95
- COMB_TOL = 3.0
96
- # The scan band crosses the vendor's header/footer chrome, which costs a true gutter ~8% of its scan.
97
- COMB_COVER = 0.9
98
- # Taking the strictly contiguous run reported a full-width 520px gutter as a 43px stub (JPEG ringing).
99
- COMB_GAP = 8
100
- # Not zero: a blank canvas seals every lattice and its cells read exactly 0.0; a 4x4 of open sky reads 2.3-11.2.
101
- SEALED_DIVERGE_TOL = 2.0
102
- # Real 4x4s over unbroken sky measure 6.x-7.1; at 8.0 two stayed undetected. Free on the FP side (16 at 6/7/8 alike).
103
- SEALED_FLANK_MIN_DE = 6.0
104
- GRID_SEAL_MIN = 0.85
105
- # Above UNUSED_LINE_PENALTY, or a candidate that swallowed a half-width sky belt outscores the true lattice.
106
- UNSEALED_PENALTY = 700.0
107
- # Real comb blocks are 2-12px; chrome bands 19-83.
108
- COMB_MAX_THICK = MAX_THICKNESS
109
- OFF_LATTICE_CLEAN_STD = 2.6
110
-
111
-
112
- @dataclass
113
- class PotentialGridLine:
114
- orientation: str
115
- angle: float
116
- thickness: float
117
- start: Tuple[float, float]
118
- end: Tuple[float, float]
119
- color_lab: np.ndarray
120
- color_std: float
121
- midline_pos: float
122
- support: int
123
-
124
-
125
- def _de(a, b):
126
- d = a - b
127
- return float(np.sqrt(np.dot(d, d)))
1
+ """A board's grid of tiles, and the state of each tile.
128
2
 
3
+ `find_grid` asks the element detector (`pointer.detector`) for the board's grid
4
+ and the cells inside it, and returns the cells in reading order when they make
5
+ a whole lattice. The detector was trained on every vendor's boards, grids and
6
+ not-grids alike, so a board of cards, a sprite sheet or a keyframe of moving
7
+ tiles is simply not a `grid` to it.
129
8
 
130
- def _de2(a, b):
131
- d0 = a[0] - b[0]; d1 = a[1] - b[1]; d2 = a[2] - b[2]
132
- return d0 * d0 + d1 * d1 + d2 * d2
133
-
134
-
135
- def _line_extent(line):
136
- if line.orientation == 'h':
137
- return abs(line.end[0] - line.start[0])
138
- return abs(line.end[1] - line.start[1])
139
-
140
-
141
- def _cell_divergences(lab, boxes, gutter_color):
142
- h, w = lab.shape[:2]
143
- out = []
144
- for (x1, y1, x2, y2) in boxes:
145
- bw, bh = x2 - x1, y2 - y1
146
- ix1 = int(x1 + CELL_INSET * bw); ix2 = int(x2 - CELL_INSET * bw)
147
- iy1 = int(y1 + CELL_INSET * bh); iy2 = int(y2 - CELL_INSET * bh)
148
- ix1 = max(0, min(w - 1, ix1)); ix2 = max(ix1 + 1, min(w, ix2))
149
- iy1 = max(0, min(h - 1, iy1)); iy2 = max(iy1 + 1, min(h, iy2))
150
- patch = lab[iy1:iy2, ix1:ix2].reshape(-1, 3)
151
- if patch.shape[0] == 0:
152
- out.append(0.0); continue
153
- mean = patch.mean(axis=0)
154
- out.append(_de(mean, gutter_color))
155
- return out
156
-
157
-
158
- def _cells_have_content(lab, boxes, rows, cols, gutter_lines, sealed=False):
159
- """The false-positive killer. Not every row must have content: a reCAPTCHA 4x4's top row can reach into the header."""
160
- if lab is None:
161
- return True
162
- gutter_color = np.mean([l.color_lab for l in gutter_lines], axis=0)
163
- divs = _cell_divergences(lab, boxes, gutter_color)
164
- if not divs or len(divs) != rows * cols:
165
- return False
166
- gutter_std = float(np.mean([l.color_std for l in gutter_lines]))
167
- frac = CELL_DIVERGE_FRAC_CLEAN if gutter_std < CLEAN_GUTTER_STD else CELL_DIVERGE_FRAC
168
- tol = SEALED_DIVERGE_TOL if sealed else CELL_DIVERGE_TOL
169
- return sum(1 for d in divs if d > tol) >= frac * len(divs)
170
-
9
+ The rest reads pixels inside cells the grid has already given: a vendor's
10
+ "selected" mark, a tile still loading, an empty slot, a tile fading in.
11
+ """
171
12
 
172
- # OKLab scaled onto CIELAB units so every tuned constant keeps its meaning.
173
- _OK_L_SCALE = 100.0
174
- _OK_AB_SCALE = 300.0
175
-
176
- _OK_M1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929],
177
- [0.2119034982, 0.6806995451, 0.1073969566],
178
- [0.0883024619, 0.2817188376, 0.6299787005]], dtype=np.float32)
179
- _OK_M2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468],
180
- [1.9779984951, -2.4285922050, 0.4505937099],
181
- [0.0259040371, 0.7827717662, -0.8086757660]], dtype=np.float32)
182
-
183
- _SRGB_TO_LINEAR = np.where(
184
- np.arange(256, dtype=np.float32) / 255.0 <= 0.04045,
185
- (np.arange(256, dtype=np.float32) / 255.0) / 12.92,
186
- (((np.arange(256, dtype=np.float32) / 255.0) + 0.055) / 1.055) ** 2.4,
187
- ).astype(np.float32)
13
+ from __future__ import annotations
188
14
 
15
+ import tempfile
16
+ from typing import List, Optional, Sequence, Tuple
189
17
 
190
- def _to_lab(img_bgr):
191
- """OKLab: CIE76 over-weights a/b, which is why the walk gates on L alone. ~2-3ms against find_grid's ~53ms; a GPU was rejected."""
192
- rgb_lin = _SRGB_TO_LINEAR[img_bgr[:, :, ::-1]]
193
- lms = rgb_lin @ _OK_M1.T
194
- np.cbrt(lms, out=lms)
195
- ok = lms @ _OK_M2.T
196
- ok[:, :, 0] *= _OK_L_SCALE
197
- ok[:, :, 1] *= _OK_AB_SCALE
198
- ok[:, :, 2] *= _OK_AB_SCALE
199
- return ok
18
+ import numpy as np
19
+ from PIL import Image
20
+ from scipy import ndimage
200
21
 
22
+ from ..kinds import LabelPosition
23
+ from ..overlay import add_overlays_to_image
201
24
 
202
- def _runlength(same, axis):
203
- s = same.astype(np.int32)
204
- out = np.zeros_like(s)
205
- if axis == 1:
206
- out[:, 0] = s[:, 0]
207
- for j in range(1, s.shape[1]):
208
- out[:, j] = (out[:, j - 1] + 1) * s[:, j]
209
- else:
210
- out[0, :] = s[0, :]
211
- for i in range(1, s.shape[0]):
212
- out[i, :] = (out[i - 1, :] + 1) * s[i, :]
25
+ Box = Tuple[int, int, int, int]
26
+
27
+ #: Below these the detector is guessing: measured on real captures, every true
28
+ #: grid and its cells score above them and no not-grid board's grid does.
29
+ GRID_MIN_SCORE = 0.5
30
+ CELL_MIN_SCORE = 0.5
31
+ #: A row starts where a cell's top sits more than this share of a cell below the row's.
32
+ ROW_BREAK = 0.5
33
+ #: A tile is a cell whatever else it looks like: a flat-coloured tile (a blank one
34
+ #: mid-fade, a plain colour) reads as a jigsaw piece (`draggable`) or an `item`.
35
+ #: Inside the grid, one the size of the cells (within this share) that no cell box
36
+ #: already covers fills its slot.
37
+ TILE_CLASSES = ("draggable", "item")
38
+ TILE_SIZE_TOL = 0.25
39
+ #: A lattice the detector saw most of: when its cells do not form whole rows (a
40
+ #: gutter it missed merges two tiles, a faint one splits none), a grid box whose
41
+ #: slots are this share filled, every row and column with at least one, is the
42
+ #: lattice. Two real reCAPTCHA 4x4 photo boards came back with 14 and 17 cells.
43
+ LATTICE_MIN_FILLED = 0.75
44
+
45
+
46
+ def _iou(a: Sequence[float], b: Sequence[float]) -> float:
47
+ ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
48
+ iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
49
+ inter = ix * iy
50
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
51
+ return inter / union if union > 0 else 0.0
52
+
53
+
54
+ def _with_tiles(cells: List[List[float]], tiles: List[List[float]]) -> List[List[float]]:
55
+ """`cells` plus every tile the size of a cell that no cell (or tile already taken) overlaps."""
56
+ if not cells:
57
+ return cells
58
+ w = float(np.median([c[2] - c[0] for c in cells]))
59
+ h = float(np.median([c[3] - c[1] for c in cells]))
60
+ out = list(cells)
61
+ for t in tiles:
62
+ if abs((t[2] - t[0]) - w) <= TILE_SIZE_TOL * w and abs((t[3] - t[1]) - h) <= TILE_SIZE_TOL * h \
63
+ and all(_iou(t, c) < 0.3 for c in out):
64
+ out.append(t)
213
65
  return out
214
66
 
215
67
 
216
- def _build_ridge_map(lab, axis):
217
- h, w = lab.shape[:2]
218
- if axis == 1:
219
- diff = lab[:, 1:, :] - lab[:, :-1, :]
220
- de = np.sqrt(np.sum(diff * diff, axis=2))
221
- same = np.concatenate([np.zeros((h, 1), bool), de < COLOR_TOL], axis=1)
222
- run = _runlength(same, 1)
223
- end_ok = run >= (MIN_RUN - 1)
224
- ridge = end_ok.copy()
225
- for j in range(w - 2, -1, -1):
226
- ridge[:, j] |= ridge[:, j + 1] & same[:, j + 1]
227
- else:
228
- diff = lab[1:, :, :] - lab[:-1, :, :]
229
- de = np.sqrt(np.sum(diff * diff, axis=2))
230
- same = np.concatenate([np.zeros((1, w), bool), de < COLOR_TOL], axis=0)
231
- run = _runlength(same, 0)
232
- end_ok = run >= (MIN_RUN - 1)
233
- ridge = end_ok.copy()
234
- for i in range(h - 2, -1, -1):
235
- ridge[i, :] |= ridge[i + 1, :] & same[i + 1, :]
236
- return ridge
237
-
238
-
239
-
240
-
241
- def _seed_thickness(lab, axis, cx, cy, ref):
242
- h, w = lab.shape[:2]
243
- ix, iy = int(round(cx)), int(round(cy))
244
- if not (0 <= iy < h and 0 <= ix < w):
245
- return 1.0
246
- up = dn = 0
247
- if axis == 1:
248
- i = iy - 1
249
- while i >= 0 and up < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
250
- up += 1; i -= 1
251
- i = iy + 1
252
- while i < h and dn < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
253
- dn += 1; i += 1
254
- else:
255
- i = ix - 1
256
- while i >= 0 and up < PERP_SCAN and _de2(lab[iy, i], ref) <= COLOR_TOL ** 2:
257
- up += 1; i -= 1
258
- i = ix + 1
259
- while i < w and dn < PERP_SCAN and _de2(lab[iy, i], ref) <= COLOR_TOL ** 2:
260
- dn += 1; i += 1
261
- return float(up + dn + 1)
262
-
263
-
264
- def _split_runs(sorted_idx):
265
- sorted_idx = np.asarray(sorted_idx)
266
- if sorted_idx.size == 0:
267
- return []
268
- breaks = np.where(np.diff(sorted_idx) > 1)[0] + 1
269
- return np.split(sorted_idx, breaks)
270
-
271
-
272
- def _batch_gather(lab, axis, along_i, perp_i):
273
- if axis == 1:
274
- return lab[perp_i, along_i]
275
- return lab[along_i, perp_i]
276
-
277
-
278
- def image_noise(lab):
279
- """p90 lightness step between adjacent BRIGHT pixels: ~0 for a compositor-painted gutter, higher after JPEG or rescaling."""
280
- L = lab[:, :, 0]
281
- bright = L > 85
282
- dh = np.abs(np.diff(L, axis=1)); mh = bright[:, :-1] & bright[:, 1:]
283
- dv = np.abs(np.diff(L, axis=0)); mv = bright[:-1, :] & bright[1:, :]
284
- vals = np.concatenate([dh[mh], dv[mv]])
285
- if vals.size < 100:
286
- return 0.0
287
- return float(np.percentile(vals, 90))
288
-
289
-
290
- def walk_tolerances(noise):
291
- """Scaled, not raised: a flat SEED_L/STEP_L of 10/8 drops a pristine sample and 14/12 drops four."""
292
- return (SEED_L_TOL + NOISE_GAIN * noise,
293
- STEP_L_TOL + NOISE_GAIN * noise,
294
- (CONT_TOL + NOISE_GAIN * noise) ** 2)
68
+ def _cells_in(grid: Sequence[float], cells: List[List[float]]) -> List[List[float]]:
69
+ x0, y0, x1, y1 = grid
70
+ return [c for c in cells if x0 <= (c[0] + c[2]) / 2 <= x1 and y0 <= (c[1] + c[3]) / 2 <= y1]
295
71
 
296
72
 
297
- def _batch_accept(pix, seed_L, prev_L, line_color, tols):
298
- seed_tol, step_tol, cont_tol2 = tols
299
- L = pix[:, 0]
300
- d0 = pix[:, 0] - line_color[:, 0]
301
- d1 = pix[:, 1] - line_color[:, 1]
302
- d2 = pix[:, 2] - line_color[:, 2]
303
- de2 = d0 * d0 + d1 * d1 + d2 * d2
304
- return ((np.abs(L - seed_L) <= seed_tol)
305
- & (np.abs(L - prev_L) <= step_tol)
306
- & (de2 <= cont_tol2))
307
-
308
-
309
- def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
310
- h, w = lab.shape[:2]
311
- N = along_seed.shape[0]
312
- along_max = (w - 1) if axis == 1 else (h - 1)
313
- perp_max = (h - 1) if axis == 1 else (w - 1)
314
- seed_L = seed_ref[:, 0]
315
-
316
- perp = perp_seed.astype(np.float64).copy()
317
- along = along_seed.astype(np.float64).copy()
318
- prev_L = seed_L.copy()
319
- line_color = seed_ref.astype(np.float64).copy()
320
- line_n = np.ones(N, dtype=np.float64)
321
- last_along = along_seed.astype(np.float64).copy()
322
- last_perp = perp_seed.astype(np.float64).copy()
323
- support = np.zeros(N, dtype=np.int64)
324
- active = np.ones(N, dtype=bool)
325
-
326
- while active.any():
327
- ai = np.where(active)[0]
328
- al = along[ai] + direction * STEP
329
- in_b = (al >= 0) & (al <= along_max)
330
- if not in_b.all():
331
- active[ai[~in_b]] = False
332
- keep = in_b
333
- ai = ai[keep]; al = al[keep]
334
- if ai.size == 0:
335
- break
336
- pe = perp[ai]
337
- ali = al.astype(np.intp); pei = pe.astype(np.intp)
338
- pix = _batch_gather(lab, axis, ali, pei)
339
- ok = _batch_accept(pix, seed_L[ai], prev_L[ai], line_color[ai], tols)
340
-
341
- new_perp = pe.copy()
342
- new_pix = pix.copy()
343
- accepted = ok.copy()
344
-
345
- if not ok.all():
346
- still = ~ok
347
- for d in range(1, PERP_REFIND + 1):
348
- for s in (-1, +1):
349
- sid = np.where(still)[0]
350
- if sid.size == 0:
351
- break
352
- cand_pe = pe[sid] + s * d
353
- inb = (cand_pe >= 0) & (cand_pe <= perp_max)
354
- sid = sid[inb]; cand_pe = cand_pe[inb]
355
- if sid.size == 0:
356
- continue
357
- cpx = _batch_gather(lab, axis, al[sid].astype(np.intp),
358
- cand_pe.astype(np.intp))
359
- passc = _batch_accept(cpx, seed_L[ai[sid]],
360
- prev_L[ai[sid]], line_color[ai[sid]], tols)
361
- hit = sid[passc]
362
- if hit.size:
363
- new_perp[hit] = cand_pe[passc]
364
- new_pix[hit] = cpx[passc]
365
- accepted[hit] = True
366
- still[hit] = False
367
- if not still.any():
368
- break
369
-
370
- adv = np.where(accepted)[0]
371
- if adv.size:
372
- gid = ai[adv]
373
- ln = line_n[gid]
374
- line_color[gid] = (line_color[gid] * ln[:, None] + new_pix[adv]) / (ln[:, None] + 1)
375
- line_n[gid] = ln + 1
376
- prev_L[gid] = new_pix[adv, 0]
377
- perp[gid] = new_perp[adv]
378
- along[gid] = al[adv]
379
- last_perp[gid] = new_perp[adv]
380
- last_along[gid] = al[adv]
381
- support[gid] += 1
382
- active[ai[~accepted]] = False
383
-
384
- return last_along, last_perp, support
385
-
386
-
387
- def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols):
388
- fa, fp, ns_f = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, +1, tols)
389
- ba, bp, ns_b = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, -1, tols)
390
- a_hi = np.maximum(along_seed.astype(np.float64), fa)
391
- a_lo = np.minimum(along_seed.astype(np.float64), ba)
392
- return a_lo, a_hi, bp, fp, ns_f + ns_b
393
-
394
-
395
-
396
-
397
- def _principal_dir_2d(centered):
398
- cxx = float(np.dot(centered[:, 0], centered[:, 0]))
399
- cyy = float(np.dot(centered[:, 1], centered[:, 1]))
400
- cxy = float(np.dot(centered[:, 0], centered[:, 1]))
401
- tr = cxx + cyy
402
- det = cxx * cyy - cxy * cxy
403
- disc = max(0.0, (tr * tr) / 4.0 - det)
404
- lam = tr / 2.0 + np.sqrt(disc)
405
- if abs(cxy) > 1e-9:
406
- v = np.array([lam - cyy, cxy], dtype=np.float64)
407
- elif cxx >= cyy:
408
- v = np.array([1.0, 0.0])
409
- else:
410
- v = np.array([0.0, 1.0])
411
- n = np.hypot(*v)
412
- return v / n if n > 1e-9 else np.array([1.0, 0.0])
413
-
414
-
415
- def _collect_seeds(lab, axis, ridge, c_lo, c_hi, span):
416
- h, w = lab.shape[:2]
417
- lo, hi = int(span * (0.5 - MAX_SEED_FRAC)), int(span * (0.5 + MAX_SEED_FRAC))
418
- scan_w = c_hi - c_lo
419
- along_s, perp_s = [], []
420
- for c in range(lo, hi + 1, SEED_DECIMATE):
421
- if axis == 1:
422
- runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
423
- else:
424
- runs = _split_runs(np.where(ridge[c_lo:c_hi, c])[0] + c_lo)
425
- for run in runs:
426
- if len(run) < MIN_RUN or len(run) < SEED_RUN_FRAC * scan_w:
427
- continue
428
- chosen = None
429
- mid = len(run) // 2
430
- for idx in [mid, int(len(run) * 0.25), int(len(run) * 0.75)]:
431
- if 0 <= idx < len(run):
432
- a = float(run[idx])
433
- if axis == 1:
434
- sx, sy = a, float(c)
435
- else:
436
- sx, sy = float(c), a
437
- if 0 <= int(sy + 0.5) < h and 0 <= int(sx + 0.5) < w:
438
- chosen = a; break
439
- if chosen is None:
440
- continue
441
- along_s.append(chosen)
442
- perp_s.append(float(c))
443
- if not along_s:
444
- return (np.empty(0), np.empty(0), np.empty((0, 3)))
445
- along_seed = np.array(along_s, dtype=np.float64)
446
- perp_seed = np.array(perp_s, dtype=np.float64)
447
- if axis == 1:
448
- seed_ref = lab[perp_seed.astype(np.intp), along_seed.astype(np.intp)].astype(np.float64)
449
- else:
450
- seed_ref = lab[along_seed.astype(np.intp), perp_seed.astype(np.intp)].astype(np.float64)
451
- return along_seed, perp_seed, seed_ref
452
-
453
-
454
- def _trace_lines(lab, axis, seed_bias, tols=None):
455
- h, w = lab.shape[:2]
456
- if tols is None:
457
- tols = walk_tolerances(image_noise(lab))
458
- ridge = _build_ridge_map(lab, axis)
459
- if axis == 1:
460
- span = h; c_lo, c_hi = int(w * 0.2), int(w * 0.8)
461
- else:
462
- span = w; c_lo, c_hi = int(h * 0.2), int(h * 0.8)
463
- along_seed, perp_seed, seed_ref = _collect_seeds(lab, axis, ridge, c_lo, c_hi, span)
464
- if along_seed.size == 0:
465
- return []
466
- a_lo, a_hi, perp_lo, perp_hi, support = _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols)
467
-
468
- full = w if axis == 1 else h
469
- lines = []
470
- for i in range(along_seed.size):
471
- if support[i] < MIN_RUN:
472
- continue
473
- span_i = a_hi[i] - a_lo[i]
474
- if span_i < SUPPORT_FRAC * full:
475
- continue
476
- if axis == 1:
477
- pts = np.array([(along_seed[i], perp_seed[i]),
478
- (a_lo[i], perp_lo[i]), (a_hi[i], perp_hi[i])], dtype=np.float64)
479
- else:
480
- pts = np.array([(perp_seed[i], along_seed[i]),
481
- (perp_lo[i], a_lo[i]), (perp_hi[i], a_hi[i])], dtype=np.float64)
482
- mean = pts.mean(axis=0)
483
- dv = _principal_dir_2d(pts - mean)
484
- if axis == 1:
485
- ang = np.arctan2(dv[1], dv[0])
486
- if abs(ang) > np.pi / 2:
487
- ang -= np.copysign(np.pi, ang)
488
- slant = np.tan(ang)
489
- midline = mean[1] + slant * (w / 2.0 - mean[0])
490
- else:
491
- ang = np.arctan2(dv[0], dv[1])
492
- if abs(ang) > np.pi / 2:
493
- ang -= np.copysign(np.pi, ang)
494
- slant = np.tan(ang)
495
- midline = mean[0] + slant * (h / 2.0 - mean[1])
496
- if abs(slant) > SLANT_CAP:
497
- continue
498
- a0 = a_lo[i]; a1 = a_hi[i]
499
- aa = np.arange(a0, a1 + 1, 3.0)
500
- if axis == 1:
501
- xs = aa; ys = midline + slant * (xs - w / 2.0)
502
- else:
503
- ys = aa; xs = midline + slant * (ys - h / 2.0)
504
- jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
505
- inb = (jy >= 0) & (jy < h) & (jx >= 0) & (jx < w)
506
- if not inb.any():
507
- continue
508
- cols = lab[jy[inb], jx[inb]].astype(np.float64)
509
- color_std = float(np.mean(np.std(cols, axis=0)))
510
- if color_std > LINE_STD_TOL:
511
- continue
512
- if axis == 1:
513
- start = (a0, midline + slant * (a0 - w / 2.0))
514
- end = (a1, midline + slant * (a1 - w / 2.0))
73
+ def _rows(cells: List[List[float]]) -> List[List[List[float]]]:
74
+ """Cells grouped into rows, top to bottom, each row left to right."""
75
+ h = float(np.median([c[3] - c[1] for c in cells]))
76
+ rows: List[List[List[float]]] = []
77
+ for c in sorted(cells, key=lambda c: c[1]):
78
+ if rows and c[1] - rows[-1][0][1] <= ROW_BREAK * h:
79
+ rows[-1].append(c)
515
80
  else:
516
- start = (midline + slant * (a0 - h / 2.0), a0)
517
- end = (midline + slant * (a1 - h / 2.0), a1)
518
- lines.append(PotentialGridLine(
519
- orientation='h' if axis == 1 else 'v',
520
- angle=float(ang), thickness=0.0,
521
- start=start, end=end,
522
- color_lab=cols.mean(axis=0), color_std=color_std,
523
- midline_pos=float(midline), support=int(support[i]),
524
- ))
525
- return _merge_lines(lines)
526
-
527
-
528
- def _merge_lines(lines):
529
- """Cluster by position only: angle-gating left noisy fragments of one gutter as separate near-duplicates."""
530
- if not lines:
531
- return []
532
- lines = sorted(lines, key=lambda l: l.midline_pos)
533
- merged = []
534
- cur = [lines[0]]
535
- for ln in lines[1:]:
536
- if abs(ln.midline_pos - cur[-1].midline_pos) < MERGE_PX:
537
- cur.append(ln)
538
- else:
539
- merged.append(_pick(cur)); cur = [ln]
540
- merged.append(_pick(cur))
541
- return merged
542
-
543
-
544
- def _pick(group):
545
- """Position is the support-weighted mean; colour comes from the cleanest member of comparable support.
546
-
547
- The longest trace is often seeded at the gutter's edge and runs along tile content (ice_cream4: 11px off,
548
- std 2.80 vs 0.55-0.80 on-centre, 2026-08-11); nearest-to-centre swapped a 0.00 trace for a 3.43 one on rhitt.
549
- """
550
- best = max(group, key=lambda l: l.support)
551
- wsum = sum(l.support for l in group)
552
- centre = sum(l.midline_pos * l.support for l in group) / wsum
553
- strong = [l for l in group if l.support >= 0.5 * best.support] or group
554
- cleanest = min(strong, key=lambda l: (l.color_std, abs(l.midline_pos - centre)))
555
- best.midline_pos = centre
556
- best.support = max(l.support for l in group)
557
- best.color_lab = cleanest.color_lab
558
- best.color_std = cleanest.color_std
559
- return best
560
-
561
-
562
- def _comb_lines(lab, axis, color):
563
- """Second cue: full-span lines every pixel of which is the gutter colour the tracer already proved is painted.
564
-
565
- The local walk loses a gutter whose neighbours are nearly its colour (rrv7m: gutters at 103/200/297 came
566
- back as 66/96/215). Axis-aligned by construction, so tilted grids stay the tracer's.
567
- """
568
- h, w = lab.shape[:2]
569
- d = lab - color
570
- m = (d * d).sum(axis=2) < COMB_TOL * COMB_TOL
571
- if axis == 1:
572
- lo, hi = int(w * (0.5 - MAX_SEED_FRAC)), int(w * (0.5 + MAX_SEED_FRAC))
573
- cov = m[:, lo:hi].mean(axis=1)
574
- else:
575
- lo, hi = int(h * (0.5 - MAX_SEED_FRAC)), int(h * (0.5 + MAX_SEED_FRAC))
576
- cov = m[lo:hi, :].mean(axis=0)
577
- mid = (lo + hi) // 2
578
- total = h if axis == 1 else w
579
- out = []
580
- for blk in _split_runs(np.where(cov >= COMB_COVER)[0]):
581
- if len(blk) > COMB_MAX_THICK:
582
- continue
583
- pos = float(blk.mean())
584
- # The page margin is the gutter colour too; left in, it corroborates every line on the other axis and a 3x3 reads as 4x3.
585
- if not (total * EDGE_MARGIN < pos < total * (1 - EDGE_MARGIN)):
586
- continue
587
- i = int(round(pos))
588
- along = m[i] if axis == 1 else m[:, i]
589
- idx = np.where(along)[0]
590
- grp = (np.split(idx, np.where(np.diff(idx) > COMB_GAP)[0] + 1)
591
- if idx.size else [])
592
- run = next((r for r in grp if r[0] <= mid <= r[-1]), None)
593
- if run is None or len(run) < MIN_RUN:
594
- continue
595
- a, b = float(run[0]), float(run[-1])
596
- cols = (lab[i, run] if axis == 1 else lab[run, i]).astype(np.float64)
597
- out.append(PotentialGridLine(
598
- orientation='h' if axis == 1 else 'v', angle=0.0,
599
- thickness=float(len(blk)),
600
- start=(a, pos) if axis == 1 else (pos, a),
601
- end=(b, pos) if axis == 1 else (pos, b),
602
- color_lab=cols.mean(axis=0),
603
- color_std=float(np.mean(np.std(cols, axis=0))),
604
- midline_pos=pos, support=int(len(run))))
605
- return out
606
-
607
-
608
- def _comb_axis(lines, comb):
609
- """The comb wins on position only; extent comes from the longer trace (a comb extent stops at the first off-colour stretch)."""
610
- out, used = [], set()
611
- for c in comb:
612
- near = [l for l in lines if abs(l.midline_pos - c.midline_pos) < MERGE_PX]
613
- best = max(near, key=_line_extent, default=None)
614
- if best is not None and _line_extent(best) > _line_extent(c):
615
- best.midline_pos = c.midline_pos
616
- c = best
617
- used.update(id(l) for l in near)
618
- out.append(c)
619
- return sorted(out + [l for l in lines if id(l) not in used],
620
- key=lambda l: l.midline_pos)
621
-
622
-
623
- def _add_comb_lines(lab, h_lines, v_lines):
624
- """Colour is the median of the clean traces of BOTH axes, so a starved axis is rescued by the other's evidence."""
625
- clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
626
- if not clean:
627
- return h_lines, v_lines
628
- color = np.median(np.array([l.color_lab for l in clean]), axis=0)
629
- return (_comb_axis(h_lines, _comb_lines(lab, 1, color)),
630
- _comb_axis(v_lines, _comb_lines(lab, 0, color)))
631
-
632
-
633
- def _generate_grid(rows, cols, hs, vs, hd, vd, h, w, slant):
634
- mid_x, mid_y = w / 2, h / 2
635
- y_bounds = [hs[0] - hd] + list(hs) + [hs[-1] + hd]
636
- x_bounds = [vs[0] - vd] + list(vs) + [vs[-1] + vd]
637
- grid_boxes = []
638
- s2 = slant * slant
639
- denom = 1 + s2
640
- for i in range(len(y_bounds) - 1):
641
- for j in range(len(x_bounds) - 1):
642
- corners = []
643
- for y_i in [y_bounds[i], y_bounds[i + 1]]:
644
- for x_j in [x_bounds[j], x_bounds[j + 1]]:
645
- cy = (y_i + slant * (x_j - mid_x) + s2 * mid_y) / denom
646
- cx = x_j - slant * (cy - mid_y)
647
- corners.append((cx, cy))
648
- pts = np.array(corners, dtype=np.float32)
649
- x1, y1 = np.min(pts, axis=0)
650
- x2, y2 = np.max(pts, axis=0)
651
- x1_c, y1_c = int(max(0, min(w, x1))), int(max(0, min(h, y1)))
652
- x2_c, y2_c = int(max(0, min(w, x2))), int(max(0, min(h, y2)))
653
- if x2_c > x1_c and y2_c > y1_c:
654
- grid_boxes.append((x1_c, y1_c, x2_c, y2_c))
655
- return grid_boxes if len(grid_boxes) == rows * cols else None
656
-
657
-
658
- def _internal(lines, total):
659
- return [l for l in lines
660
- if total * EDGE_MARGIN < l.midline_pos < total * (1 - EDGE_MARGIN)]
661
-
662
-
663
- def _boxes_are_regular(boxes, rows, cols):
664
- """The last word on geometry: an observed false positive had column pitches [1, 1, 151, 1, 1, 151, 1, 1].
665
-
666
- Edges come from ONE row / ONE column: the union of every box's edge invents a 1px phantom separator from
667
- inter-row rounding on any slant, which rejected 101 real hCaptcha grids.
668
- """
669
- if not boxes or rows < 1 or cols < 1:
670
- return False
671
-
672
- if len(boxes) < rows * cols:
673
- return False
674
- col_edges = [boxes[c][0] for c in range(cols)]
675
- row_edges = [boxes[r * cols][1] for r in range(rows)]
676
- for edges in (col_edges, row_edges):
677
- if len(edges) < 2:
81
+ rows.append([c])
82
+ return [sorted(r, key=lambda c: c[0]) for r in rows]
83
+
84
+
85
+ def _lattice(grid: Sequence[float], cells: List[List[float]]) -> Optional[List[List[float]]]:
86
+ """The rows x columns lattice `cells` mostly fill inside `grid`, each slot at its
87
+ column's and row's median edges; None when they do not fill one."""
88
+ w = float(np.median([c[2] - c[0] for c in cells]))
89
+ h = float(np.median([c[3] - c[1] for c in cells]))
90
+ gx0, gy0, gx1, gy1 = grid
91
+ centres = [((c[0] + c[2]) / 2, (c[1] + c[3]) / 2) for c in cells]
92
+
93
+ def pitch(axis: int, size: float, other: float) -> Optional[float]:
94
+ """Median step to the next cell along `axis`, so gutters of any width count."""
95
+ steps = [min((q[axis] - p[axis] for q in centres
96
+ if q[axis] - p[axis] > 0.5 * size and abs(q[1 - axis] - p[1 - axis]) < 0.5 * other),
97
+ default=None) for p in centres]
98
+ steps = [d for d in steps if d is not None]
99
+ return float(np.median(steps)) if steps else None
100
+ px, py = pitch(0, w, h), pitch(1, h, w)
101
+ if px is None or py is None:
102
+ return None
103
+ cols, rows = int(round((gx1 - gx0 - w) / px)) + 1, int(round((gy1 - gy0 - h) / py)) + 1
104
+ if cols < 2 or rows < 2:
105
+ return None
106
+ # Slot k along an axis is centred at the grid edge + half a cell + k pitches.
107
+ sx, sy = gx0 + ((gx1 - gx0) - (cols - 1) * px - w) / 2, gy0 + ((gy1 - gy0) - (rows - 1) * py - h) / 2
108
+ slot = {}
109
+ for c in cells:
110
+ cw, ch = c[2] - c[0], c[3] - c[1]
111
+ if abs(cw - w) > TILE_SIZE_TOL * w or abs(ch - h) > TILE_SIZE_TOL * h:
112
+ continue # two tiles read as one, or a piece of one
113
+ k = (int(round(((c[1] + c[3]) / 2 - sy - h / 2) / py)), int(round(((c[0] + c[2]) / 2 - sx - w / 2) / px)))
114
+ if not (0 <= k[0] < rows and 0 <= k[1] < cols):
678
115
  continue
679
- pitches = np.diff(np.array(sorted(edges), dtype=np.float64))
680
- if np.any(pitches < MIN_CELL):
681
- return False
682
- mp = float(np.median(pitches))
683
- if np.any(np.abs(pitches - mp) > CELL_REGULARITY_TOL * mp):
684
- return False
685
-
686
- widths = np.array([b[2] - b[0] for b in boxes], dtype=np.float64)
687
- heights = np.array([b[3] - b[1] for b in boxes], dtype=np.float64)
688
- if widths.size == 0 or heights.size == 0:
689
- return False
690
- mw, mh = float(np.median(widths)), float(np.median(heights))
691
- if mw < MIN_CELL or mh < MIN_CELL:
692
- return False
693
- if np.any(np.abs(widths - mw) > CELL_REGULARITY_TOL * mw):
694
- return False
695
- if np.any(np.abs(heights - mh) > CELL_REGULARITY_TOL * mh):
696
- return False
697
- return True
698
-
699
-
700
- def _even_spacing_ok(positions, total):
701
- p = sorted(positions)
702
- gaps = [p[i + 1] - p[i] for i in range(len(p) - 1)]
703
- pitch = float(np.median(gaps))
704
- if pitch < MIN_CELL:
705
- return False, pitch
706
- for g in gaps:
707
- if abs(g - pitch) > EVEN_TOL * pitch:
708
- return False, pitch
709
- if (p[0] - pitch) < -GRID_OVERSHOOT * pitch:
710
- return False, pitch
711
- if (p[-1] + pitch) > total + GRID_OVERSHOOT * pitch:
712
- return False, pitch
713
- return True, pitch
714
-
715
-
716
- def _flank_contrast(lab, line, pitch):
717
- """Median over the line of the mean of both flanks' distance to the line colour: does this separator separate anything?"""
718
- h, w = lab.shape[:2]
719
- (x0, y0), (x1, y1) = line.start, line.end
720
- if line.orientation == 'h':
721
- xs = np.arange(min(x0, x1), max(x0, x1) + 1, 3.0)
722
- ys = line.midline_pos + np.tan(line.angle) * (xs - w / 2.0)
723
- else:
724
- ys = np.arange(min(y0, y1), max(y0, y1) + 1, 3.0)
725
- xs = line.midline_pos + np.tan(line.angle) * (ys - h / 2.0)
726
- jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
727
- ok = (jx >= 0) & (jx < w) & (jy >= 0) & (jy < h)
728
- if ok.sum() < 3:
729
- return 0.0
730
- jx, jy = jx[ok], jy[ok]
731
- d_lo = d_hi = None
732
- for fr in FLANK_PITCH_FRACS:
733
- fo = max(3, int(round(fr * pitch)))
734
- if line.orientation == 'h':
735
- a = lab[np.clip(jy - fo, 0, h - 1), jx]; b = lab[np.clip(jy + fo, 0, h - 1), jx]
736
- else:
737
- a = lab[jy, np.clip(jx - fo, 0, w - 1)]; b = lab[jy, np.clip(jx + fo, 0, w - 1)]
738
- da = np.sqrt(np.sum((a.astype(np.float64) - line.color_lab) ** 2, axis=1))
739
- db = np.sqrt(np.sum((b.astype(np.float64) - line.color_lab) ** 2, axis=1))
740
- d_lo = da if d_lo is None else np.maximum(d_lo, da)
741
- d_hi = db if d_hi is None else np.maximum(d_hi, db)
742
- return float(np.median((d_lo + d_hi) / 2.0))
743
-
744
-
745
- def _seal_fraction(lab, color, orientation, pos, angle, lo, hi):
746
- """Share of the CANDIDATE's extent along which the line is the gutter colour: the same line seals a 3x3 and fails a 4x4."""
747
- h, w = lab.shape[:2]
748
- a = np.arange(max(0.0, lo), min(float(w if orientation == 'h' else h), hi), 2.0)
749
- if a.size < 3:
750
- return 0.0
751
- t = np.tan(angle)
752
- if orientation == 'h':
753
- xs, ys = a, pos + t * (a - w / 2.0)
754
- else:
755
- ys, xs = a, pos + t * (a - h / 2.0)
756
- jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
757
- ok = (jx >= 0) & (jx < w) & (jy >= 0) & (jy < h)
758
- if ok.sum() < 3:
759
- return 0.0
760
- d = lab[jy[ok], jx[ok]].astype(np.float64) - color
761
- return float(((d * d).sum(axis=1) < COMB_TOL * COMB_TOL).mean())
762
-
763
-
764
- def _line_span_perp(line):
765
- if line.orientation == 'h':
766
- return (min(line.start[0], line.end[0]), max(line.start[0], line.end[0]))
767
- return (min(line.start[1], line.end[1]), max(line.start[1], line.end[1]))
768
-
769
-
770
- def _corroborate(lines, perp_lines, total):
771
- lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
772
- perp = sorted(perp_lines, key=lambda l: l.midline_pos)
773
- if len(lines) < 2:
774
- return lines
775
- pos = [l.midline_pos for l in lines]
776
- gaps = np.diff(pos)
777
- # Pitch over gaps that could be a cell: comb-reported footer bands took the median from 87 to 55.8 and detection
778
- # 20/20 -> 5/20. The guard below still reads the RAW median: filtering first skipped the perpendicular rescue
779
- # and cost a 4x4 its fourth row (ttvu9: raw 34.5, filtered 80.9, perpendicular 97.0).
780
- raw_cell = float(np.median(gaps)) if len(gaps) else 0.0
781
- cell_gaps = [g for g in gaps if g >= MIN_CELL]
782
- cell = float(np.median(cell_gaps)) if cell_gaps else 0.0
783
- if raw_cell < MIN_CELL:
784
- pgaps = np.diff([l.midline_pos for l in perp])
785
- cell = float(np.median(pgaps)) if len(pgaps) else 0.0
786
- if cell < MIN_CELL:
787
- return lines
788
- need = CORROB_FRAC * cell
789
- kept = []
790
- for l in lines:
791
- p = l.midline_pos
792
- n_ok = 0
793
- for q in perp:
794
- lo, hi = _line_span_perp(q)
795
- if (p - lo) >= need and (hi - p) >= need:
796
- n_ok += 1
797
- if n_ok >= 2:
798
- kept.append(l)
799
- return kept if len(kept) >= 2 else lines
800
-
116
+ slot.setdefault(k, c)
117
+ if len(slot) < LATTICE_MIN_FILLED * rows * cols \
118
+ or {r for r, _ in slot} != set(range(rows)) or {c for _, c in slot} != set(range(cols)):
119
+ return None
120
+ xs = [(float(np.median([c[0] for (_, j), c in slot.items() if j == col])),
121
+ float(np.median([c[2] for (_, j), c in slot.items() if j == col]))) for col in range(cols)]
122
+ ys = [(float(np.median([c[1] for (i, _), c in slot.items() if i == row])),
123
+ float(np.median([c[3] for (i, _), c in slot.items() if i == row]))) for row in range(rows)]
124
+ return [[xs[j][0], ys[i][0], xs[j][1], ys[i][1]] for i in range(rows) for j in range(cols)]
801
125
 
802
- def _complete_one_run(positions, grp, pitch, total):
803
- real = sorted(positions)
804
- sub_pitches = [pitch]
805
- for k in (2, 3):
806
- sp = pitch / k
807
- if sp >= MIN_CELL:
808
- sub_pitches.append(sp)
809
- bases = []
810
- seen_runs = set()
811
- for tp in sub_pitches:
812
- filled = [real[0]]
813
- interior_virtual = 0
814
- ok = True
815
- for a, b in zip(real, real[1:]):
816
- m = int(round((b - a) / tp))
817
- if m < 1 or abs((b - a) - m * tp) > EVEN_TOL * tp:
818
- ok = False
819
- break
820
- for j in range(1, m):
821
- filled.append(a + j * (b - a) / m)
822
- interior_virtual += 1
823
- filled.append(b)
824
- if not ok:
825
- continue
826
- kf = tuple(round(x) for x in filled)
827
- if kf in seen_runs:
828
- continue
829
- seen_runs.add(kf)
830
- bases.append((list(filled), interior_virtual))
831
- out = []
832
- for base, ivirt in bases:
833
- bp = (base[-1] - base[0]) / (len(base) - 1) if len(base) > 1 else pitch
834
- for lo_add in (0, 1):
835
- for hi_add in (0, 1):
836
- nv = ivirt + lo_add + hi_add
837
- if nv == 0 or nv > MAX_VIRTUAL_NODES:
838
- continue
839
- run = list(base)
840
- if lo_add:
841
- run.insert(0, run[0] - bp)
842
- if hi_add:
843
- run.append(run[-1] + bp)
844
- dim = len(run) + 1
845
- if dim < MIN_GRID_DIM or dim > 6:
846
- continue
847
- if nv > MAX_VIRTUAL_FRAC * len(run):
848
- continue
849
- out.append((run, nv))
850
- return out
851
126
 
127
+ def find_grid(image_path: str) -> Optional[List[Box]]:
128
+ """The cells of the board's grid in reading order, as (x0, y0, x1, y1) pixels;
129
+ None when the board has no grid, or its cells do not form whole rows of one length."""
130
+ from ..pointer.detector import detect_file
852
131
 
853
- def _completed_candidates(lines, total, real_cand):
854
- """Lattice completion for a missing sky-bordered gutter; only clean painted anchors, never arbitrary clean pairs."""
855
- out = {}
856
- seen = set()
857
- for dim, runs in real_cand.items():
858
- for positions, _score, pitch, grp in runs:
859
- if any(l.color_std >= CLEAN_LATTICE_STD for l in grp):
860
- continue
861
- for run, n_virtual in _complete_one_run(positions, grp, pitch, total):
862
- run = sorted(run)
863
- cdim = len(run) + 1
864
- ok, fpitch = _even_spacing_ok(run, total)
865
- if not ok:
866
- continue
867
- key = (cdim, tuple(round(p) for p in run))
868
- if key in seen:
869
- continue
870
- seen.add(key)
871
- ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
872
- center_off = abs((run[0] + run[-1]) / 2 - total / 2) / total
873
- scale_err = abs(fpitch - total / cdim) / total
874
- score = (center_off * 1000 + scale_err * 500 + ang_pen * 200
875
- + n_virtual * VIRTUAL_NODE_PENALTY)
876
- out.setdefault(cdim, []).append((run, score, fpitch, grp))
877
- return out
132
+ dets = detect_file(image_path)
133
+ grids = [d for d in dets if d["class"] == "grid" and d["score"] >= GRID_MIN_SCORE]
134
+ if not grids:
135
+ return None
136
+ grid = max(grids, key=lambda d: d["score"])["box"]
137
+ cells = _cells_in(grid, [d["box"] for d in dets if d["class"] == "cell" and d["score"] >= CELL_MIN_SCORE])
138
+ cells = _with_tiles(cells, _cells_in(grid, [d["box"] for d in dets
139
+ if d["class"] in TILE_CLASSES and d["score"] >= CELL_MIN_SCORE]))
140
+ if len(cells) < 4:
141
+ return None
142
+ rows = _rows(cells)
143
+ if len(rows) < 2 or len({len(r) for r in rows}) != 1 or len(rows[0]) < 2:
144
+ lattice = _lattice(grid, cells)
145
+ return None if lattice is None else [tuple(int(round(v)) for v in c) for c in lattice]
146
+ return [tuple(int(round(v)) for v in c) for r in rows for c in r]
878
147
 
879
148
 
880
- def _axis_candidates(lines, total):
881
- lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
882
- n = len(lines)
883
- cand = {}
884
- seen = set()
885
- for i in range(n):
886
- for j in range(i + 1, n):
887
- pitch0 = lines[j].midline_pos - lines[i].midline_pos
888
- if pitch0 < MIN_CELL:
889
- continue
890
- run_idx = [i, j]
891
- pos = lines[j].midline_pos
892
- jj = j
893
- while True:
894
- target = pos + pitch0
895
- best_k = None; best_d = EVEN_TOL * pitch0
896
- for k in range(jj + 1, n):
897
- d = abs(lines[k].midline_pos - target)
898
- if d < best_d:
899
- best_d = d; best_k = k
900
- if lines[k].midline_pos - target > EVEN_TOL * pitch0:
901
- break
902
- if best_k is None:
903
- break
904
- run_idx.append(best_k)
905
- pos = lines[best_k].midline_pos
906
- jj = best_k
907
- # Every prefix, not just the maximal run: the last line may be a geetest/prosopo panel border.
908
- for end in range(2, len(run_idx) + 1):
909
- sub = run_idx[:end]
910
- positions = [lines[r].midline_pos for r in sub]
911
- ok, pitch = _even_spacing_ok(positions, total)
912
- if not ok:
913
- continue
914
- grp = [lines[r] for r in sub]
915
- dim = len(sub) + 1
916
- if dim < MIN_GRID_DIM:
917
- continue
918
- key = (dim, tuple(round(p) for p in positions))
919
- if key in seen:
920
- continue
921
- seen.add(key)
922
- ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
923
- center_off = abs((positions[0] + positions[-1]) / 2 - total / 2) / total
924
- scale_err = abs(pitch - total / dim) / total
925
- score = center_off * 1000 + scale_err * 500 + ang_pen * 200
926
- cand.setdefault(dim, []).append((positions, score, pitch, grp))
927
- for dim, comps in _completed_candidates(lines, total, dict(cand)).items():
928
- bucket = cand.setdefault(dim, [])
929
- existing = {tuple(round(p) for p in c[0]) for c in bucket}
930
- for c in comps:
931
- kpos = tuple(round(p) for p in c[0])
932
- if kpos not in existing:
933
- bucket.append(c)
934
- existing.add(kpos)
935
- for k in cand:
936
- cand[k].sort(key=lambda x: x[1])
937
- return cand
149
+ # ── colour ──────────────────────────────────────────────────────────────
938
150
 
151
+ def _read_rgb(image_path: str) -> Optional[np.ndarray]:
152
+ try:
153
+ with Image.open(image_path) as im:
154
+ return np.asarray(im.convert("RGB"))
155
+ except OSError:
156
+ return None
939
157
 
940
- # How near a cell's midpoint a detected gutter must sit, as a fraction of the pitch, to say the cell is two cells.
941
- HALVED_CELL_TOL = 0.12
942
158
 
159
+ def _lab8(rgb: np.ndarray) -> np.ndarray:
160
+ """CIELAB (D65) on the 8-bit scale the cell thresholds were measured on:
161
+ L x 255/100, a and b offset by 128."""
162
+ c = rgb.astype(np.float32) / 255.0
163
+ lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
164
+ xyz = lin @ np.array([[0.412453, 0.357580, 0.180423],
165
+ [0.212671, 0.715160, 0.072169],
166
+ [0.019334, 0.119193, 0.950227]], np.float32).T
167
+ xyz /= np.array([0.950456, 1.0, 1.088754], np.float32)
168
+ f = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16.0 / 116.0)
169
+ L = np.where(xyz[..., 1] > 0.008856, 116.0 * f[..., 1] - 16.0, 903.3 * xyz[..., 1])
170
+ a = 500.0 * (f[..., 0] - f[..., 1]) + 128.0
171
+ b = 200.0 * (f[..., 1] - f[..., 2]) + 128.0
172
+ return np.stack([L * 255.0 / 100.0, a, b], axis=-1)
943
173
 
944
- def _halves_a_finer_lattice(positions, pitch, kept_lines):
945
- """True when a detected gutter splits EVERY cell of this axis down its middle.
946
174
 
947
- Tall tiles fail the square-cell gate as themselves, but every other gutter spaces cells two tiles wide that
948
- pass it, so a 6x3 board read as a 3x3 of tile pairs.
949
- """
950
- if not positions or pitch <= 0:
951
- return False
952
- lines = sorted(positions)
953
- mids = ([lines[0] - pitch / 2.0] + [(a + b) / 2.0 for a, b in zip(lines, lines[1:])]
954
- + [lines[-1] + pitch / 2.0])
955
- tol = HALVED_CELL_TOL * pitch
956
- return all(any(abs(l.midline_pos - m) <= tol for l in kept_lines) for m in mids)
175
+ def _delta_e_mask(rgb: np.ndarray, colour: Tuple[int, int, int], thr: float) -> np.ndarray:
176
+ target = _lab8(np.array([[colour]], np.uint8))[0, 0]
177
+ return np.sqrt(np.sum((_lab8(rgb) - target) ** 2, axis=2)) <= thr
957
178
 
958
179
 
959
- def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
960
- """Every colour gate is RELATIVE, so a grid of any uniform border colour is detectable."""
961
- h_keep = _corroborate(h_lines, v_lines, h)
962
- v_keep = _corroborate(v_lines, h_lines, w)
963
- h_keep_int = _internal(h_keep, h)
964
- v_keep_int = _internal(v_keep, w)
965
- # One painted reference colour for every seal test (the one the comb looked for), so answers cache across candidates.
966
- clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
967
- seal_col = np.median(np.array([l.color_lab for l in clean]), axis=0) if clean else None
968
- seal_cache = {}
180
+ # ── shapes ──────────────────────────────────────────────────────────────
969
181
 
970
- def sealed(orientation, pos, angle, lo, hi):
971
- if lab is None or seal_col is None:
972
- return True
973
- k = (orientation, round(pos), round(angle, 3), int(lo), int(hi))
974
- if k not in seal_cache:
975
- seal_cache[k] = (_seal_fraction(lab, seal_col, orientation, pos, angle,
976
- lo, hi) >= GRID_SEAL_MIN)
977
- return seal_cache[k]
978
- h_cand = _axis_candidates(h_keep, h)
979
- v_cand = _axis_candidates(v_keep, w)
980
- best = None
981
- best_score = float('inf')
982
- for rows in sorted(h_cand):
983
- if rows < MIN_GRID_DIM:
984
- continue
985
- for cols in sorted(v_cand):
986
- if cols < MIN_GRID_DIM:
987
- continue
988
- for hpos, hsc, hd, hlns in h_cand[rows][:25]:
989
- for vpos, vsc, vd, vlns in v_cand[cols][:25]:
990
- s_diff = abs(hd - vd) / max(hd, vd)
991
- if s_diff > 0.22:
992
- continue
993
- h_min = (cols - FULL_SPAN_MARGIN) * vd
994
- v_min = (rows - FULL_SPAN_MARGIN) * hd
995
- if (min(_line_extent(l) for l in hlns) < h_min
996
- or min(_line_extent(l) for l in vlns) < v_min):
997
- continue
998
- alll = hlns + vlns
999
- ccols = np.array([l.color_lab for l in alll])
1000
- avg = ccols.mean(axis=0)
1001
- de = np.sqrt(np.sum((ccols - avg) ** 2, axis=1))
1002
- if np.max(de) > GRID_COLOR_TOL:
1003
- continue
1004
- h_ang = np.mean([l.angle for l in hlns])
1005
- v_ang = np.mean([l.angle for l in vlns])
1006
- if abs(h_ang + v_ang) > GRID_ANGLE_TOL:
1007
- continue
1008
- h_col = np.mean([l.color_lab for l in hlns], axis=0)
1009
- v_col = np.mean([l.color_lab for l in vlns], axis=0)
1010
- if _de(h_col, v_col) > XAXIS_COLOR_TOL:
1011
- continue
1012
- if (_halves_a_finer_lattice(vpos, vd, v_keep_int)
1013
- or _halves_a_finer_lattice(hpos, hd, h_keep_int)):
1014
- continue
1015
- slant = np.tan(h_ang)
1016
- ang_inc = abs(h_ang + v_ang) * 200
1017
- hsorted = sorted(hpos); vsorted = sorted(vpos)
1018
- row_top, row_bot = hsorted[0] - hd, hsorted[-1] + hd
1019
- col_lft, col_rgt = vsorted[0] - vd, vsorted[-1] + vd
1020
- vy = [_line_span_perp(l) for l in vlns]
1021
- hx = [_line_span_perp(l) for l in hlns]
1022
- vy_lo = float(np.median([s[0] for s in vy]))
1023
- vy_hi = float(np.median([s[1] for s in vy]))
1024
- hx_lo = float(np.median([s[0] for s in hx]))
1025
- hx_hi = float(np.median([s[1] for s in hx]))
1026
- v_top_edge = vy_lo <= EDGE_BLEED_PX
1027
- v_bot_edge = vy_hi >= h - EDGE_BLEED_PX
1028
- h_lft_edge = hx_lo <= EDGE_BLEED_PX
1029
- h_rgt_edge = hx_hi >= w - EDGE_BLEED_PX
1030
- # A one-sided edge bleed (hCaptcha's white footer) is not a missing row; a grid that fills an axis reaches both edges.
1031
- def _missing(uncov, pitch, bleed):
1032
- if bleed:
1033
- return 0.0
1034
- f = uncov / pitch
1035
- return f if f >= MISSING_LINE_FRAC else 0.0
1036
- span_pen = SPAN_FIT_PENALTY * (
1037
- _missing(max(0.0, row_top - vy_lo), hd, v_top_edge and not v_bot_edge)
1038
- + _missing(max(0.0, vy_hi - row_bot), hd, v_bot_edge and not v_top_edge)
1039
- + _missing(max(0.0, col_lft - hx_lo), vd, h_lft_edge and not h_rgt_edge)
1040
- + _missing(max(0.0, hx_hi - col_rgt), vd, h_rgt_edge and not h_lft_edge))
1041
- def _seal(orientation, pos, angle):
1042
- return sealed(orientation, pos, angle,
1043
- *((col_lft, col_rgt) if orientation == 'h'
1044
- else (row_top, row_bot)))
1045
- unsealed = sum(1 for l in alll
1046
- if not _seal(l.orientation, l.midline_pos, l.angle))
1047
- # Refund the invention charge for INTERIOR nodes the image seals (a missed gutter, not a guess): without it
1048
- # the true 4x4 on every sky-backed reCAPTCHA lost by ~470. Extrapolated nodes seal trivially on a white
1049
- # margin and refunding them turned a correct 3x3 into a 3x4.
1050
- def _confirm(pos, lns, orientation, angle):
1051
- if len(lns) < 2:
1052
- return 0
1053
- lo = min(l.midline_pos for l in lns)
1054
- hi = max(l.midline_pos for l in lns)
1055
- return sum(1 for p in pos
1056
- if lo < p < hi
1057
- and all(abs(p - l.midline_pos) >= 1.0 for l in lns)
1058
- and _seal(orientation, p, angle))
1059
- confirmed = (_confirm(hpos, hlns, 'h', h_ang)
1060
- + _confirm(vpos, vlns, 'v', v_ang))
1061
- # Only SEALED lines count as unused: the comb hands over clean sky belts that are not cell boundaries.
1062
- chosen = {id(l) for l in alll}
1063
- unused = (sum(1 for l in h_keep_int if id(l) not in chosen
1064
- and _seal('h', l.midline_pos, l.angle))
1065
- + sum(1 for l in v_keep_int if id(l) not in chosen
1066
- and _seal('v', l.midline_pos, l.angle)))
1067
- score = (hsc + vsc + s_diff * 1000 + abs(slant) * 500
1068
- + unused * UNUSED_LINE_PENALTY + ang_inc + span_pen
1069
- + unsealed * UNSEALED_PENALTY
1070
- - confirmed * VIRTUAL_NODE_PENALTY)
1071
- if score < best_score:
1072
- # Content gate IN the loop so a rejected over-count lets a smaller valid candidate win.
1073
- boxes = _generate_grid(rows, cols, hpos, vpos, hd, vd, h, w, slant)
1074
- if boxes and _cells_have_content(lab, boxes, rows, cols,
1075
- hlns + vlns, unsealed == 0):
1076
- best_score = score
1077
- best = (boxes, rows, cols, slant, avg,
1078
- sorted(hpos), hd, sorted(vpos), vd, hlns + vlns,
1079
- unsealed == 0)
1080
- if best is None:
1081
- return None, None, None
1082
- (boxes, rows, cols, slant, gutter_color, hpos, hd, vpos, vd, chosen_lns,
1083
- fully_sealed) = best
1084
- grid_gutters_clean = (float(np.mean([l.color_std for l in chosen_lns]))
1085
- < CLEAN_GUTTER_STD) if chosen_lns else False
1086
- def _off_lattice(lines, total, anchors, pitch, orientation, ext):
1087
- """Same-colour lines off the pitch inside the grid's own span; the textured-photo false-positive killer."""
1088
- off_pos = []
1089
- clean_skipped = 0
1090
- lo, hi = anchors[0], anchors[-1]
1091
- for l in _internal(lines, total):
1092
- if not (lo - LATTICE_TOL * pitch <= l.midline_pos <= hi + LATTICE_TOL * pitch):
1093
- continue
1094
- if _de(l.color_lab, gutter_color) > GRID_COLOR_TOL:
1095
- continue
1096
- # Gutter-coloured for only part of the width divides nothing; three such belts once rejected a correct 4x4.
1097
- if not sealed(orientation, l.midline_pos, l.angle, *ext):
1098
- continue
1099
- off = min(abs((l.midline_pos - anchors[0]) - round((l.midline_pos - anchors[0]) / pitch) * pitch),
1100
- abs((l.midline_pos - anchors[-1]) - round((l.midline_pos - anchors[-1]) / pitch) * pitch))
1101
- if off > LATTICE_TOL * pitch:
1102
- # On a proven clean grid forgive a few clean strays (a horizon, a UI rule); noisy strays always count.
1103
- if (grid_gutters_clean and l.color_std < OFF_LATTICE_CLEAN_STD
1104
- and clean_skipped < MAX_OFF_LATTICE_CLEAN):
1105
- clean_skipped += 1
1106
- continue
1107
- off_pos.append(l.midline_pos)
1108
- off_pos.sort()
1109
- return sum(1 for i, p in enumerate(off_pos)
1110
- if i == 0 or p - off_pos[i - 1] > OFF_LATTICE_CLUSTER_PX)
1111
- rows_ext = (hpos[0] - hd, hpos[-1] + hd)
1112
- cols_ext = (vpos[0] - vd, vpos[-1] + vd)
1113
- if (_off_lattice(h_lines, h, hpos, hd, 'h', cols_ext) > MAX_OFF_LATTICE
1114
- or _off_lattice(v_lines, w, vpos, vd, 'v', rows_ext) > MAX_OFF_LATTICE):
1115
- return None, None, None
1116
- if not _boxes_are_regular(boxes, rows, cols):
1117
- return None, None, None
1118
- # On the chosen grid only: as a per-line filter it deleted the strays the off-lattice gate counts (FP 2 -> 4 -> 6).
1119
- if lab is not None:
1120
- if float(np.median([_flank_contrast(lab, l, hd if l.orientation == 'h' else vd)
1121
- for l in chosen_lns])) < (SEALED_FLANK_MIN_DE if fully_sealed
1122
- else GRID_FLANK_MIN_DE):
1123
- return None, None, None
1124
- x0 = min(b[0] for b in boxes); x1 = max(b[2] for b in boxes)
1125
- y0 = min(b[1] for b in boxes); y1 = max(b[3] for b in boxes)
1126
- if (x1 - x0) * (y1 - y0) < MIN_IMAGE_AREA_COVERAGE * w * h:
1127
- return None, None, None
1128
- return boxes, (rows, cols), slant
182
+ def _close(mask: np.ndarray) -> np.ndarray:
183
+ """A 3x3 closing that neither grows nor eats the image border."""
184
+ s = np.ones((3, 3), bool)
185
+ return ndimage.binary_erosion(ndimage.binary_dilation(mask, s, border_value=0), s, border_value=1)
1129
186
 
1130
187
 
1131
- def _detect_grid(image_path, seed_bias=0.0):
1132
- img = cv2.imread(image_path)
1133
- if img is None:
188
+ def _largest_blob(mask: np.ndarray) -> Optional[np.ndarray]:
189
+ """(x, y) of every pixel of the 8-connected component whose outline encloses
190
+ the most area, or None. The outline runs through boundary pixel centres, so
191
+ by Pick's theorem it encloses (filled pixels - boundary pixels / 2 - 1): two
192
+ blobs of nearly equal size are ranked as their outlines rank them."""
193
+ lab, n = ndimage.label(mask, structure=np.ones((3, 3), bool))
194
+ if not n:
1134
195
  return None
1135
- h, w = img.shape[:2]
1136
- lab = _to_lab(img)
1137
- tols = walk_tolerances(image_noise(lab))
1138
- h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias, tols=tols)
1139
- v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias, tols=tols)
1140
- # Both axes traced before either is judged: the comb takes its colour from whichever axis found a clean line.
1141
- h_lines, v_lines = _add_comb_lines(lab, h_lines, v_lines)
1142
- if len(_internal(h_lines, h)) < 2 or len(_internal(v_lines, w)) < 2:
1143
- return None
1144
- boxes, dims, slant = extract_grid_from_lines(h_lines, v_lines, h, w, lab=lab)
1145
- return boxes
1146
-
1147
-
1148
- def find_grid(image_path: str, slant_to_try: Optional[float] = None) -> Optional[List[Tuple[int, int, int, int]]]:
1149
- seed_bias = float(slant_to_try) if slant_to_try is not None else 0.0
1150
- return _detect_grid(image_path, seed_bias=seed_bias)
196
+ best, best_area = 0, -np.inf
197
+ for i, sl in enumerate(ndimage.find_objects(lab), 1):
198
+ filled = np.pad(ndimage.binary_fill_holes(lab[sl] == i), 1)
199
+ boundary = filled & ~ndimage.binary_erosion(filled, border_value=0)
200
+ area = int(filled.sum()) - int(boundary.sum()) / 2 - 1
201
+ if area > best_area:
202
+ best, best_area = i, area
203
+ ys, xs = np.nonzero(lab == best)
204
+ return np.stack([xs, ys], axis=1).astype(np.float64)
205
+
206
+
207
+ def _cross(u: np.ndarray, v: np.ndarray) -> float:
208
+ return float(u[0] * v[1] - u[1] * v[0])
209
+
210
+
211
+ def _hull(points: np.ndarray) -> np.ndarray:
212
+ """Convex hull of pixel centres, counter-clockwise (Andrew's monotone chain)."""
213
+ pts = np.unique(points, axis=0)
214
+ if len(pts) < 3:
215
+ return pts
216
+
217
+ def half(seq):
218
+ out: List[np.ndarray] = []
219
+ for p in seq:
220
+ while len(out) >= 2 and _cross(out[-1] - out[-2], p - out[-2]) <= 0:
221
+ out.pop()
222
+ out.append(p)
223
+ return out
224
+ lower, upper = half(pts), half(pts[::-1])
225
+ return np.array(lower[:-1] + upper[:-1])
226
+
227
+
228
+ def _area_perimeter_centroid(poly: np.ndarray) -> Tuple[float, float, Tuple[float, float]]:
229
+ if len(poly) < 3:
230
+ return 0.0, 0.0, ((float(poly[:, 0].mean()), float(poly[:, 1].mean())) if len(poly) else (0.0, 0.0))
231
+ x, y = poly[:, 0], poly[:, 1]
232
+ xn, yn = np.roll(x, -1), np.roll(y, -1)
233
+ cross = x * yn - xn * y
234
+ a2 = float(cross.sum())
235
+ perim = float(np.hypot(xn - x, yn - y).sum())
236
+ if a2 == 0:
237
+ return 0.0, perim, (float(x.mean()), float(y.mean()))
238
+ return abs(a2) / 2, perim, (float(((x + xn) * cross).sum() / (3 * a2)), float(((y + yn) * cross).sum() / (3 * a2)))
239
+
240
+
241
+ def _distance_outside(poly: np.ndarray, pts: np.ndarray) -> np.ndarray:
242
+ """0 for points inside the convex polygon (counter-clockwise), else the distance to its boundary."""
243
+ if len(poly) < 3:
244
+ return np.full(len(pts), np.inf)
245
+ a, b = poly, np.roll(poly, -1, axis=0)
246
+ ab = b - a
247
+ ap = pts[:, None, :] - a[None]
248
+ inside = np.all(ab[None, :, 0] * ap[..., 1] - ab[None, :, 1] * ap[..., 0] >= 0, axis=1)
249
+ t = np.clip(np.einsum("nkd,kd->nk", ap, ab) / np.maximum(np.einsum("kd,kd->k", ab, ab), 1e-9), 0, 1)
250
+ d = np.min(np.linalg.norm(ap - t[..., None] * ab[None], axis=2), axis=1)
251
+ return np.where(inside, 0.0, d)
252
+
253
+
254
+ # ── per-cell state ──────────────────────────────────────────────────────
255
+
256
+ _RECAPTCHA_BLUE = (27, 115, 232)
257
+ # Two, not zero: the ringed rendering draws the white on the rim and fragments the teal. At 4px the phantom rate doubles for ~1 point of recall.
258
+ _HCAPTCHA_GLYPH_SLACK_PX = 2.0
1151
259
 
1152
260
 
1153
261
  def detect_selected_cells(image_path, grid_boxes):
1154
- img = cv2.imread(image_path)
1155
- if img is None: return [], []
262
+ """([selected], [loading]) 1-based cells: reCAPTCHA's blue tick badge or hCaptcha's
263
+ teal check marks a selection, reCAPTCHA's blue spinner a tile still loading."""
264
+ img = _read_rgb(image_path)
265
+ if img is None:
266
+ return [], []
1156
267
  sel, ld = [], []
1157
- recap_blue = (27, 115, 232)
1158
- hcap_blue = (188, 117, 15)
1159
268
  for i, box in enumerate(grid_boxes):
1160
269
  cell = img[box[1]:box[3], box[0]:box[2]]
1161
- if cell.size == 0: continue
1162
-
270
+ if cell.size == 0:
271
+ continue
1163
272
  h_cell, w_cell = cell.shape[:2]
1164
273
  tl = cell[0:int(h_cell * 0.4), 0:int(w_cell * 0.4)]
1165
- if tl.size > 0 and _has_badge(tl, recap_blue):
274
+ if tl.size > 0 and _has_badge(tl, _RECAPTCHA_BLUE):
1166
275
  sel.append(i + 1)
1167
276
  continue
1168
277
  tr = cell[0:max(8, int(h_cell * 0.22)), int(w_cell * 0.78):]
1169
278
  if tr.size > 0 and _has_hcaptcha_check(tr):
1170
279
  sel.append(i + 1)
1171
280
  continue
1172
-
1173
281
  cntr = cell[int(h_cell * 0.3):int(h_cell * 0.7), int(w_cell * 0.3):int(w_cell * 0.7)]
1174
- if cntr.size > 0 and _is_loading(cntr, recap_blue):
282
+ if cntr.size > 0 and _is_loading(cntr, _RECAPTCHA_BLUE):
1175
283
  ld.append(i + 1)
1176
284
  return sel, ld
1177
285
 
1178
286
 
1179
- def _crop_cell(image_path, grid_boxes, cell_number):
1180
- img = cv2.imread(image_path)
1181
- if img is None:
1182
- return None
1183
- if cell_number < 1 or cell_number > len(grid_boxes):
287
+ def _crop_cell(image_path, grid_boxes, cell_number) -> Optional[np.ndarray]:
288
+ img = _read_rgb(image_path)
289
+ if img is None or cell_number < 1 or cell_number > len(grid_boxes):
1184
290
  return None
1185
291
  x1, y1, x2, y2 = grid_boxes[cell_number - 1]
1186
292
  cell = img[y1:y2, x1:x2]
1187
293
  return cell if cell.size else None
1188
294
 
295
+
1189
296
  def is_empty_cell(image_path, grid_boxes, cell_number,
1190
297
  white_frac=0.97, l_thresh=92.0, chroma_thresh=6.0):
1191
298
  cell = _crop_cell(image_path, grid_boxes, cell_number)
1192
299
  if cell is None:
1193
300
  return False
1194
- lab = cv2.cvtColor(cell, cv2.COLOR_BGR2LAB).astype(np.float32)
301
+ lab = _lab8(cell)
1195
302
  L = lab[:, :, 0] * (100.0 / 255.0)
1196
- a = lab[:, :, 1] - 128.0
1197
- b = lab[:, :, 2] - 128.0
1198
- chroma = np.sqrt(a * a + b * b)
303
+ chroma = np.hypot(lab[:, :, 1] - 128.0, lab[:, :, 2] - 128.0)
1199
304
  white = (L > l_thresh) & (chroma < chroma_thresh)
1200
305
  return float(white.mean()) >= white_frac
1201
306
 
307
+
1202
308
  def is_cell_opacity_changing(image_path_a, image_path_b, grid_boxes,
1203
309
  cell_number, change_thresh=0.02):
1204
310
  a = _crop_cell(image_path_a, grid_boxes, cell_number)
1205
311
  b = _crop_cell(image_path_b, grid_boxes, cell_number)
1206
312
  if a is None or b is None or a.shape != b.shape:
1207
313
  return False
1208
- diff = cv2.absdiff(a, b)
1209
- gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
1210
- _, thr = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
1211
- ratio = cv2.countNonZero(thr) / (thr.shape[0] * thr.shape[1])
1212
- return ratio > change_thresh
1213
-
1214
-
1215
- # Two, not zero: the ringed rendering draws the white on the rim and fragments the teal. At 4px the phantom rate doubles for ~1 point of recall.
1216
- _HCAPTCHA_GLYPH_SLACK_PX = 2.0
314
+ diff = np.abs(a.astype(np.int16) - b.astype(np.int16)).astype(np.float32)
315
+ grey = diff @ np.array([0.299, 0.587, 0.114], np.float32)
316
+ return float((grey > 30).mean()) > change_thresh
1217
317
 
1218
318
 
1219
319
  def _has_hcaptcha_check(roi):
1220
320
  """The white glyph must BELONG to the teal mark: pixel counts alone called blue sky with a white pole a selection (74 phantoms over 3051 corners)."""
1221
321
  if roi is None or roi.size == 0:
1222
322
  return False
1223
- flat = roi.reshape(-1, 3).astype(np.int32)
323
+ px = roi.astype(np.int32)
324
+ r, g, b = px[..., 0], px[..., 1], px[..., 2]
1224
325
  # Deliberately loose: pinning the badge colour to a delta-E costs more real badges than it saves phantoms.
1225
- teal = (
1226
- (flat[:, 0] > 120)
1227
- & (flat[:, 1] > 80)
1228
- & (flat[:, 2] < 80)
1229
- & (flat[:, 0] - flat[:, 2] > 60)
1230
- )
1231
- white = (flat[:, 0] > 220) & (flat[:, 1] > 220) & (flat[:, 2] > 220)
326
+ teal = (b > 120) & (g > 80) & (r < 80) & (b - r > 60)
327
+ white = (r > 220) & (g > 220) & (b > 220)
1232
328
  if int(teal.sum()) < 8 or int(white.sum()) < 2:
1233
329
  return False
1234
-
1235
- h, w = roi.shape[:2]
1236
330
  # Close first: the glyph cuts the disc into pieces, and the largest blob must be the whole mark.
1237
- mask = cv2.morphologyEx(
1238
- teal.reshape(h, w).astype(np.uint8) * 255, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8)
1239
- )
1240
- contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1241
- if not contours:
331
+ blob = _largest_blob(_close(teal))
332
+ if blob is None:
1242
333
  return False
1243
- hull = cv2.convexHull(max(contours, key=cv2.contourArea))
1244
- ys, xs = np.nonzero(white.reshape(h, w))
1245
- inside = sum(
1246
- 1
1247
- for y, x in zip(ys, xs)
1248
- if cv2.pointPolygonTest(hull, (float(x), float(y)), True) >= -_HCAPTCHA_GLYPH_SLACK_PX
1249
- )
1250
- return inside >= 2
334
+ ys, xs = np.nonzero(white)
335
+ near = _distance_outside(_hull(blob), np.stack([xs, ys], axis=1).astype(np.float64)) <= _HCAPTCHA_GLYPH_SLACK_PX
336
+ return int(near.sum()) >= 2
1251
337
 
1252
338
 
1253
339
  def _has_badge(roi, rgb):
1254
- mask = _create_delta_e_mask(roi, rgb, 8.0)
1255
- if cv2.countNonZero(mask) <= roi.size * 0.003: return False
1256
-
1257
- contours, _ = cv2.findContours(cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8)), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1258
- if not contours: return False
1259
- cnt = max(contours, key=cv2.contourArea)
1260
- hull = cv2.convexHull(cnt)
1261
- area, perim = cv2.contourArea(hull), cv2.arcLength(hull, True)
1262
-
1263
- if perim == 0 or (4*np.pi*area/(perim*perim)) < 0.8: return False
340
+ mask = _delta_e_mask(roi, rgb, 8.0)
341
+ if int(mask.sum()) <= roi.size * 0.003:
342
+ return False
343
+ blob = _largest_blob(_close(mask))
344
+ if blob is None:
345
+ return False
346
+ area, perim, (cx, cy) = _area_perimeter_centroid(_hull(blob))
347
+ if perim == 0 or (4 * np.pi * area / (perim * perim)) < 0.8:
348
+ return False
349
+ return not (area == 0 or cx > roi.shape[1] * 0.7 or cy > roi.shape[0] * 0.7)
1264
350
 
1265
- M = cv2.moments(hull)
1266
- if M["m00"] == 0 or (M["m10"]/M["m00"]) > roi.shape[1]*0.7 or (M["m01"]/M["m00"]) > roi.shape[0]*0.7: return False
1267
- return True
1268
351
 
1269
352
  def _is_loading(roi, rgb):
1270
- mask = _create_delta_e_mask(roi, rgb, 12.0)
1271
- return 0.05 < (cv2.countNonZero(mask) / (roi.shape[0]*roi.shape[1])) < 0.6 if roi.size > 0 else False
353
+ if roi.size == 0:
354
+ return False
355
+ return 0.05 < float(_delta_e_mask(roi, rgb, 12.0).mean()) < 0.6
1272
356
 
1273
- def _create_delta_e_mask(img, rgb, thr):
1274
- t_lab = cv2.cvtColor(np.array([[[rgb[2], rgb[1], rgb[0]]]], dtype=np.uint8), cv2.COLOR_BGR2LAB)[0, 0]
1275
- diff = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) - t_lab.astype(np.float32)
1276
- return (np.sqrt(np.sum(diff**2, axis=2)) <= thr).astype(np.uint8)*255
1277
357
 
1278
358
  def get_numbered_grid_overlay(image_path, grid_boxes, output_path=None):
1279
- ov = [{"bbox": [b[0], b[1], b[2]-b[0], b[3]-b[1]], "number": i+1, "color": "#FF0000", "box_style": "solid"} for i, b in enumerate(grid_boxes)]
359
+ ov = [{"bbox": [b[0], b[1], b[2] - b[0], b[3] - b[1]], "number": i + 1, "color": "#FF0000", "box_style": "solid"}
360
+ for i, b in enumerate(grid_boxes)]
1280
361
  if output_path is None:
1281
- with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf: output_path = tf.name
362
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
363
+ output_path = tf.name
1282
364
  add_overlays_to_image(image_path, ov, output_path=output_path, label_position=LabelPosition.TOP_RIGHT)
1283
365
  return output_path