captchakraken 2.1.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 (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. package/scripts/setup-python.js +104 -0
@@ -0,0 +1,1762 @@
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 ..overlay import add_overlays_to_image
8
+ from ..image_processor import ImageProcessor
9
+
10
+ # =============================================================================
11
+ # Grid detection: adaptive consistent-color line tracer.
12
+ #
13
+ # Detects 3x3 / 4x4 captcha grids of ANY uniform separator colour and small tilt
14
+ # by tracing each gutter pixel-by-pixel (consistent-colour walk + perpendicular
15
+ # slant re-find), clustering duplicate traces, then forming an evenly-spaced
16
+ # lattice. The decisive false-positive gate is CELL DIVERGENCE: a real grid's
17
+ # cells are filled with content distinct from the gutter colour, whereas a flat
18
+ # region (white wall, sky, watermark haze) has "cells" the same colour as its
19
+ # "gutters". Ported from the grid_tracer dev harness; see that harness + the
20
+ # project memory note `project_find_grid_tracer_*` for the iteration history.
21
+ # =============================================================================
22
+
23
+
24
+ # ── Tuning constants ────────────────────────────────────────────────────────
25
+ COLOR_TOL = 10.0 # LAB ΔE: same-color test for ridge build + perp thickness
26
+ CONT_TOL = 14.0 # LAB ΔE: LOCAL continuation test vs the running line mean
27
+ # — tolerates JPEG / anti-alias / lighting jitter along a
28
+ # real gutter. A real tile edge (ΔE >> this) ends the line.
29
+ CONT_TOL2 = CONT_TOL * CONT_TOL # squared, for the hot continuation test
30
+ SEED_L_TOL = 6.0 # LAB L: max deviation of ANY line pixel's LIGHTNESS from
31
+ # the seed's. L is the stable, perceptually-dominant signal:
32
+ # a real gutter's L span is ~3 across its length, while a
33
+ # shaded surface (grass/water) drifts ~32. We gate on L (not
34
+ # full ΔE) because a/b are JPEG chroma noise — visually
35
+ # irrelevant at gutter lightness, but CIE76 ΔE over-weights
36
+ # them and wrongly dropped real tinted-grey gutters.
37
+ STEP_L_TOL = 4.0 # LAB L: max lightness change between CONSECUTIVE pixels.
38
+ # A gutter never jumps (consecutive |ΔL| <= ~2); grass
39
+ # jumps up to ~16. Stops a line crossing a tile edge whose
40
+ # far side happens to share the seed's lightness band.
41
+ MIN_RUN = 10 # px: min consecutive same-color pixels to seed a line
42
+ MAX_THICKNESS = 14 # px: a separator thicker than this is a band, not a line
43
+ # (no longer a hard reject in the perp walk — see PERP_SCAN)
44
+ PERP_SCAN = 60 # px: half-width of the perpendicular strip the thickness
45
+ # walk searches. Wide enough to reach the boundary when a
46
+ # gutter is fused with same-colour tile content, instead of
47
+ # truncating at +/-MAX_THICKNESS and rejecting the gutter.
48
+ PERP_REFIND = 3 # px: when the along-walk hits a cell, look this far
49
+ # perpendicular (up then down) for the gutter colour. Found
50
+ # -> the gutter slanted away, step there and continue. Not
51
+ # found -> a genuine wall, stop. (Replaces thickness/midpoint
52
+ # band re-centering entirely.) ~3px tolerates ~25deg tilt.
53
+ MAX_PERP_JUMP = 6.0 # px: max perpendicular re-center per save (bounds drift)
54
+ MERGE_PX = 8.0 # px: cluster lines whose midline positions are this close
55
+ MERGE_ANGLE = 0.07 # rad (~4°): and whose angles are this close
56
+ MIN_CELL = 36 # px: minimum cell pitch (cells have a minimum size)
57
+ LINE_STD_TOL = 7.0 # LAB: max std of color along a line (consistency gate)
58
+ STEP = 1 # px: walk one pixel at a time (cheap: just a color test
59
+ # per step; perpendicular thickness only computed on save)
60
+ SLANT_CAP = 0.47 # tan(~25°): reject lines tilted beyond the stated max
61
+ SUPPORT_FRAC = 0.42 # traced length must be >= this fraction of the span
62
+ TERM_FRAC = 0.30 # >= this fraction of traced points must terminate both sides
63
+ MAX_SEED_FRAC = 0.34 # seed rows/cols only within +/- this fraction of center.
64
+ # A grid gutter spans the FULL image, so it is hit by seeds
65
+ # from a central band; the trace then walks outward to the
66
+ # full extent on its own. Narrowing the band (from 0.46) skips
67
+ # the content seeds near the top/bottom margins that almost
68
+ # always die, cutting per-image trace attempts ~25% with no
69
+ # loss of detection (every gutter still seeded many times for
70
+ # the cluster-average).
71
+ EDGE_MARGIN = 0.02 # drop lines within this fraction of an image edge
72
+ # Stage-C structural gates (the primary negative-rejection — a real grid's
73
+ # internal separators are one colour, one thickness, one coherent tilt):
74
+ GRID_COLOR_TOL = 8.0 # LAB ΔE: max spread of chosen separators' colours
75
+ GRID_THICK_TOL = 4.0 # px: max thickness spread across chosen separators
76
+ GRID_ANGLE_TOL = 0.09 # rad (~5°): H tilt must ≈ -V tilt for a coherent grid
77
+ XAXIS_COLOR_TOL = 6.0 # LAB ΔE: H-mean gutter colour must ≈ V-mean (real grids
78
+ # share ONE gutter colour across both axes; photo "grids"
79
+ # pair an H edge of one colour with a V edge of another)
80
+ LATTICE_TOL = 0.18 # a same-colour line within this fraction of a pitch of a
81
+ # lattice node counts as on-grid (a border line), not noise
82
+ MAX_OFF_LATTICE = 1 # max same-colour internal lines allowed OFF the lattice
83
+ # before the structure is judged photo texture, not a grid
84
+ EVEN_TOL = 0.18 # consecutive cell gaps must match the median pitch within
85
+ # this fraction (the equidistant-cells rule)
86
+ MIN_GRID_DIM = 3 # min cells per axis (currently >=3x3; rectangles like 6x4
87
+ # are allowed — rows and cols may differ, each >= this)
88
+ CORROB_FRAC = 0.9 # frac of a cell a perpendicular line must extend PAST a
89
+ # candidate line (on both sides) to corroborate it as a real
90
+ # internal separator. Set near 1.0 (a FULL cell): a true
91
+ # internal line has a real cell — so the perpendicular gutters
92
+ # run ~a full cell — beyond it on each side. A frame/edge line
93
+ # fails: the perpendicular gutters reach only the grid border,
94
+ # LESS than a cell past it (no cell beyond the edge). This is
95
+ # what drops both the inset-V and full-width-H frame lines.
96
+ # Always treat the grid as OPEN (extrapolate outer cells).
97
+ GRID_OVERSHOOT = 0.35 # frac of pitch: the grid extrapolated one cell past each
98
+ # outer INTERNAL line may exceed the image edge by at most
99
+ # this much. Real grids bleed to the edge (no reliable outer
100
+ # border), so a small overshoot is expected; a large one
101
+ # means the chosen internal lines don't actually frame a grid.
102
+ FULL_SPAN_MARGIN = 0.5 # frac of pitch: every chosen lattice line must span at
103
+ # least (N - FULL_SPAN_MARGIN)*pitch end-to-end, i.e. cross
104
+ # essentially the WHOLE grid (all N cells), allowing only a
105
+ # half-cell fade/occlusion at the ends. Rejects short central
106
+ # edges (e.g. an object in a reference photo) masquerading as
107
+ # grid lines — the whole lattice must be present, not just the
108
+ # central closed region.
109
+ MIN_GRID_COVERAGE = 0.72 # frac: the grid's total extent (N*pitch) must cover at
110
+ # least this fraction of the image on each axis. A real
111
+ # captcha grid FILLS the frame (3x3 pitch ~ img/3 -> coverage
112
+ # ~1.0); the main FP mode is two object-edges crammed near the
113
+ # centre (small pitch -> the implied grid is a small central
114
+ # patch). This single ratio kills that whole class.
115
+ CELL_INSET = 0.22 # frac of a cell's side to trim off each edge before
116
+ # sampling the cell INTERIOR (keeps gutter pixels out of the
117
+ # cell-content mean).
118
+ CELL_DIVERGE_TOL = 12.0 # LAB ΔE: a cell's interior mean must differ from the
119
+ # gutter colour by at least this much to count as a real
120
+ # (content-filled) cell. Real captcha cells hold photo/object
121
+ # content -> large ΔE; a flat region (white wall, watermark
122
+ # haze, sky) has cells the same colour as its "gutters".
123
+ CELL_DIVERGE_FRAC = 0.6 # frac of cells that must diverge (>CELL_DIVERGE_TOL) from
124
+ # the gutter. A real grid: most cells are filled. A flat-image
125
+ # FP: few/none diverge. Robust to a couple of genuinely light
126
+ # cells (e.g. an all-sky tile) in an otherwise real grid.
127
+ CLEAN_GUTTER_STD = 2.3 # LAB: mean color_std of the chosen separators below which
128
+ # the gutters are judged PAINTED (a real captcha grid), not
129
+ # texture. Measured: real-grid gutters have color_std ~0-2
130
+ # (a uniform drawn line); textured-photo pseudo-gutters
131
+ # (grass/foliage/tessellated bg) run ~3-6. This is a content-
132
+ # INDEPENDENT grid signal, so when it holds we can trust the
133
+ # lattice even if several cells are pale (sky / faint sketches).
134
+ CELL_DIVERGE_FRAC_CLEAN = 0.42 # relaxed content frac used ONLY when the gutters are
135
+ # clean (< CLEAN_GUTTER_STD). A painted-gutter grid with sky /
136
+ # pale-sketch tiles legitimately has fewer content-bearing
137
+ # cells; the clean gutter already proves it is a grid, so we
138
+ # do not also demand a content majority. FPs keep the strict
139
+ # 0.6 frac because their pseudo-gutters are noisy (std >= 2.8),
140
+ # well above CLEAN_GUTTER_STD — they never qualify for relaxation.
141
+ SEED_RUN_FRAC = 0.5 # frac of the along-scan width a seed's ridge run must
142
+ # cover to be walked. A grid gutter spans the scan (>=0.88
143
+ # measured); content fragments are short (p90 ~0.35). This
144
+ # pre-filter drops ~95% of dead seeds (which were ~73% of all
145
+ # walk steps) before the walk, with no loss of real gutters.
146
+ SEED_DECIMATE = 2 # seed every 2nd row/col: robust multi-position seeding
147
+ # within a run still catches 2px-tall gutters, at ~half cost
148
+ # Lattice completion (recovers grids whose internal gutter between two same-colour
149
+ # tiles — sky/horizon — could not be traced, so a row/col is missing). Only fires
150
+ # on CLEAN painted anchors; the content gate still decides FPs.
151
+ CLEAN_LATTICE_STD = 2.0 # LAB: an anchor line used for lattice completion must be
152
+ # at least this uniform. Real painted gutters read std ~0-1.5;
153
+ # textured photo edges run >= 2.8, so they never anchor a
154
+ # completed lattice (matches CLEAN_GUTTER_STD's intent).
155
+ MAX_VIRTUAL_FRAC = 0.5 # at most this fraction of a completed lattice's internal
156
+ # nodes may be EXTRAPOLATED/INTERPOLATED (the rest must be real
157
+ # clean lines). A 4-cell grid (3 internal lines) may invent 1;
158
+ # we never reconstruct a grid that is mostly invented.
159
+ MAX_VIRTUAL_NODES = 2 # hard cap on invented internal lines per completed run.
160
+ # Recovers a single missing internal gutter (the common
161
+ # sky-bordered-row case) and at most one outer-line extension;
162
+ # prevents conjuring a whole grid from a 2-line fragment.
163
+ VIRTUAL_NODE_PENALTY = 600.0 # score added per invented node, so a fully-real
164
+ # lattice of the same dimension always outranks a completed
165
+ # one and fewer interpolations win.
166
+ SPAN_FIT_PENALTY = 900.0 # score added per WHOLE UNCOVERED CELL the perpendicular
167
+ # gutters run past a grid border (see the grid-span-fit block).
168
+ # Outweighs the virtual-node penalty so a completed 4x4 beats
169
+ # the 3-row subset whose gutters run a cell past the border.
170
+ MISSING_LINE_FRAC = 0.8 # frac of a pitch: a gutter must overshoot a grid border by
171
+ # at least this much to count as an uncovered (missing) row/col.
172
+ # A real grid's gutters can BLEED a little past the frame (into
173
+ # a submit bar / header — hcaptcha overshoots ~0.65 cell) without
174
+ # implying another cell; only a near-full-cell overshoot does.
175
+ EDGE_BLEED_PX = 6 # px: if a gutter's traced span reaches this close to the image
176
+ # edge, an overshoot on that side is gutter-colour bleeding into
177
+ # a margin / white footer / header (which touches the edge), not
178
+ # a real extra cell — so it does NOT trigger a missing row/col.
179
+ # Real captcha grids are inset from the edges (the gutters stop
180
+ # ~60-120px short), so this never suppresses a true missing line.
181
+ MAX_OFF_LATTICE_CLEAN = 1 # max stray CLEAN off-lattice lines forgiven on a proven
182
+ # clean grid (a horizon / wire / UI rule). Beyond this the
183
+ # strays are counted — a textured photo whose white-ish edges
184
+ # are clean produces several, so the off-lattice FP gate still
185
+ # fires; a real sky-bordered grid has at most one or two.
186
+ OFF_LATTICE_CLEAN_STD = 2.6 # in the off-lattice FP gate, a same-colour internal
187
+ # line this clean that lies OFF the chosen pitch is treated as
188
+ # a stray painted edge (sky horizon / UI rule), not proof the
189
+ # cells are irregular: it is NOT counted. Noisy pseudo-gutters
190
+ # (std above this) still count, so the textured-photo FP gate
191
+ # is unchanged. Lets a correct lattice survive a couple of
192
+ # spurious clean full-span lines (horizon, power line).
193
+
194
+
195
+ @dataclass
196
+ class PotentialGridLine:
197
+ orientation: str
198
+ angle: float
199
+ thickness: float
200
+ start: Tuple[float, float]
201
+ end: Tuple[float, float]
202
+ color_lab: np.ndarray
203
+ color_std: float
204
+ midline_pos: float
205
+ support: int
206
+
207
+
208
+ def _de(a, b):
209
+ d = a - b
210
+ return float(np.sqrt(np.dot(d, d)))
211
+
212
+
213
+ def _de2(a, b):
214
+ """Squared LAB distance — avoids the sqrt in hot comparison loops."""
215
+ d0 = a[0] - b[0]; d1 = a[1] - b[1]; d2 = a[2] - b[2]
216
+ return d0 * d0 + d1 * d1 + d2 * d2
217
+
218
+
219
+ def _line_extent(line):
220
+ """Endpoint-to-endpoint span of a fitted line along its PRIMARY axis (the
221
+ direction it runs): H lines -> |end.x - start.x|, V lines -> |end.y - start.y|.
222
+ This is the fit extent (how far the gutter was actually traced across the
223
+ image), used to require that each lattice line spans the WHOLE grid, not just
224
+ the central cell — a short edge near the centre (e.g. an object inside a
225
+ reference photo) is not a grid line."""
226
+ if line.orientation == 'h':
227
+ return abs(line.end[0] - line.start[0])
228
+ return abs(line.end[1] - line.start[1])
229
+
230
+
231
+ def _cell_divergences(lab, boxes, gutter_color):
232
+ """For each cell box, mean LAB of its INTERIOR (shrunk inward by CELL_INSET so
233
+ we don't sample the gutter pixels on the cell edges), and its ΔE from the
234
+ gutter colour. Returns a list of per-cell ΔE. A REAL grid's cells are filled
235
+ with photo/object content distinct from the gutter -> large divergence; a flat
236
+ or near-uniform region (white wall, watermark haze, sky) yields cells the SAME
237
+ colour as the 'gutters' -> tiny divergence (there are no real cells at all)."""
238
+ h, w = lab.shape[:2]
239
+ out = []
240
+ for (x1, y1, x2, y2) in boxes:
241
+ bw, bh = x2 - x1, y2 - y1
242
+ ix1 = int(x1 + CELL_INSET * bw); ix2 = int(x2 - CELL_INSET * bw)
243
+ iy1 = int(y1 + CELL_INSET * bh); iy2 = int(y2 - CELL_INSET * bh)
244
+ ix1 = max(0, min(w - 1, ix1)); ix2 = max(ix1 + 1, min(w, ix2))
245
+ iy1 = max(0, min(h - 1, iy1)); iy2 = max(iy1 + 1, min(h, iy2))
246
+ patch = lab[iy1:iy2, ix1:ix2].reshape(-1, 3)
247
+ if patch.shape[0] == 0:
248
+ out.append(0.0); continue
249
+ mean = patch.mean(axis=0)
250
+ out.append(_de(mean, gutter_color))
251
+ return out
252
+
253
+
254
+ def _cells_have_content(lab, boxes, rows, cols, gutter_lines):
255
+ """True if a MAJORITY of cells diverge from the gutter colour (real content,
256
+ not a flat region). This is the FP killer. We do NOT require every row/column
257
+ to have content: a legitimate grid can have an extrapolated OUTER row/column
258
+ that lands on page background (e.g. a reCAPTCHA 4x4 whose top row reaches above
259
+ the photo into the header) — that is expected, not a reason to reject. Dimension
260
+ is decided by line corroboration + the unused-lines preference, not by content
261
+ per row/col."""
262
+ if lab is None:
263
+ return True
264
+ gutter_color = np.mean([l.color_lab for l in gutter_lines], axis=0)
265
+ divs = _cell_divergences(lab, boxes, gutter_color)
266
+ if not divs or len(divs) != rows * cols:
267
+ return False
268
+ # Clean-gutter relaxation: if the chosen separators are painted-uniform
269
+ # (mean color_std < CLEAN_GUTTER_STD) the lattice is already proven to be a
270
+ # real grid by the gutters alone — sky / pale-sketch tiles are then allowed,
271
+ # so we require only a smaller fraction of content-bearing cells. Textured-
272
+ # photo pseudo-gutters (the FP mode) are noisy (std well above the threshold)
273
+ # and stay on the strict content fraction.
274
+ gutter_std = float(np.mean([l.color_std for l in gutter_lines]))
275
+ frac = CELL_DIVERGE_FRAC_CLEAN if gutter_std < CLEAN_GUTTER_STD else CELL_DIVERGE_FRAC
276
+ return sum(1 for d in divs if d > CELL_DIVERGE_TOL) >= frac * len(divs)
277
+
278
+
279
+ def _to_lab(img_bgr):
280
+ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
281
+ lab[:, :, 0] *= (100.0 / 255.0)
282
+ lab[:, :, 1] -= 128.0
283
+ lab[:, :, 2] -= 128.0
284
+ return lab
285
+
286
+
287
+ # ── Stage A: vectorized O(n*m) ridge / run-length map ───────────────────────
288
+ def _runlength(same, axis):
289
+ """Inclusive running count of consecutive True along axis; resets on False."""
290
+ s = same.astype(np.int32)
291
+ out = np.zeros_like(s)
292
+ if axis == 1:
293
+ out[:, 0] = s[:, 0]
294
+ for j in range(1, s.shape[1]):
295
+ out[:, j] = (out[:, j - 1] + 1) * s[:, j]
296
+ else:
297
+ out[0, :] = s[0, :]
298
+ for i in range(1, s.shape[0]):
299
+ out[i, :] = (out[i - 1, :] + 1) * s[i, :]
300
+ return out
301
+
302
+
303
+ def _build_ridge_map(lab, axis):
304
+ """Boolean map: True where the pixel belongs to a consistent-color run of
305
+ length >= MIN_RUN along the axis (axis=1 horizontal, axis=0 vertical)."""
306
+ h, w = lab.shape[:2]
307
+ if axis == 1:
308
+ diff = lab[:, 1:, :] - lab[:, :-1, :]
309
+ de = np.sqrt(np.sum(diff * diff, axis=2))
310
+ same = np.concatenate([np.zeros((h, 1), bool), de < COLOR_TOL], axis=1)
311
+ run = _runlength(same, 1)
312
+ end_ok = run >= (MIN_RUN - 1)
313
+ ridge = end_ok.copy()
314
+ for j in range(w - 2, -1, -1):
315
+ ridge[:, j] |= ridge[:, j + 1] & same[:, j + 1]
316
+ else:
317
+ diff = lab[1:, :, :] - lab[:-1, :, :]
318
+ de = np.sqrt(np.sum(diff * diff, axis=2))
319
+ same = np.concatenate([np.zeros((1, w), bool), de < COLOR_TOL], axis=0)
320
+ run = _runlength(same, 0)
321
+ end_ok = run >= (MIN_RUN - 1)
322
+ ridge = end_ok.copy()
323
+ for i in range(h - 2, -1, -1):
324
+ ridge[i, :] |= ridge[i + 1, :] & same[i + 1, :]
325
+ return ridge
326
+
327
+
328
+ # ── Vectorized perpendicular thickness + centerline ─────────────────────────
329
+ def _perp_from_strip(strip, center_idx, ref, max_t):
330
+ """`strip` is an (N,3) LAB slice perpendicular to the line, `center_idx` the
331
+ index of the query point within it. Compute thickness + centerline by
332
+ measuring the contiguous run of within-COLOR_TOL pixels around center_idx.
333
+ Returns (offset_from_center, thickness, terminated_both) or None.
334
+
335
+ Fully vectorized: one ΔE vector over the (small) strip, then run extents."""
336
+ n = len(strip)
337
+ if not (0 <= center_idx < n):
338
+ return None
339
+ diff = strip - ref
340
+ de = np.sqrt(np.einsum('ij,ij->i', diff, diff)) # (N,)
341
+ same = de < COLOR_TOL
342
+ if not same[center_idx]:
343
+ return None
344
+ # up = consecutive same going to lower indices
345
+ up = 0
346
+ i = center_idx - 1
347
+ while i >= 0 and same[i]:
348
+ up += 1; i -= 1
349
+ up_term = (i >= 0) and (not same[i])
350
+ down = 0
351
+ i = center_idx + 1
352
+ while i < n and same[i]:
353
+ down += 1; i += 1
354
+ down_term = (i < n) and (not same[i])
355
+ thick = up + down + 1
356
+ # NOTE: the old `if thick > max_t: return None` rejection is REMOVED. A real
357
+ # white gutter adjacent to white tile content reads as one band far thicker
358
+ # than max_t, which wrongly rejected the gutter. `max_t` is no longer used as
359
+ # a hard thickness gate here.
360
+ offset = (down - up) / 2.0
361
+ return offset, float(thick), (up_term and down_term)
362
+
363
+
364
+ def _find_band(lab, axis, along, perp, ref, max_t, search=None):
365
+ """Search perpendicular to the line for the nearest contiguous band of
366
+ line-colored (within CONT_TOL of `ref`) pixels around `perp`, at the given
367
+ `along` coordinate. Returns (band_midpoint_perp, thickness) or None.
368
+
369
+ Used by the SAVE maneuver: when the slant has shifted the band away from our
370
+ fixed perp coordinate, find where it went and re-center. The band must be
371
+ <= max_t thick (a real gutter); a wider run means we've reached a background
372
+ region, so return None to end the line."""
373
+ h, w = lab.shape[:2]
374
+ if search is None:
375
+ # The save fires when the slant drifts us off the band. With a 1px along
376
+ # step and <=25deg tilt the band moves <=~0.5px/step, but we only save
377
+ # after color fails (drift ~ thickness). Search a window of one
378
+ # thickness + a small margin — NOT wide enough to jump to the next
379
+ # parallel gutter (that would corrupt the trace).
380
+ search = max_t + 2
381
+ # gather the perpendicular strip and a same-color mask
382
+ if axis == 1: # perp is vertical -> column `along`
383
+ ix = int(round(along))
384
+ if not (0 <= ix < w):
385
+ return None
386
+ lo = max(0, int(round(perp)) - search); hi = min(h, int(round(perp)) + search + 1)
387
+ strip = lab[lo:hi, ix, :]
388
+ else: # perp is horizontal -> row `along`
389
+ iy = int(round(along))
390
+ if not (0 <= iy < h):
391
+ return None
392
+ lo = max(0, int(round(perp)) - search); hi = min(w, int(round(perp)) + search + 1)
393
+ strip = lab[iy, lo:hi, :]
394
+ diff = strip - ref
395
+ de = np.sqrt(np.einsum('ij,ij->i', diff, diff))
396
+ same = de < CONT_TOL
397
+ if not same.any():
398
+ return None
399
+ # find contiguous runs; choose the one nearest the query perp
400
+ q = int(round(perp)) - lo
401
+ idx = np.where(same)[0]
402
+ runs = []
403
+ s = idx[0]; p = idx[0]
404
+ for v in idx[1:]:
405
+ if v == p + 1:
406
+ p = v
407
+ else:
408
+ runs.append((s, p)); s = v; p = v
409
+ runs.append((s, p))
410
+ best = min(runs, key=lambda r: min(abs(r[0] - q), abs(r[1] - q),
411
+ 0 if r[0] <= q <= r[1] else 10**9))
412
+ thickness = best[1] - best[0] + 1
413
+ if thickness > max_t:
414
+ return None
415
+ mid = (best[0] + best[1]) / 2.0 + lo
416
+ return mid, float(thickness)
417
+
418
+
419
+ def _perp_centerline(lab, axis, px, py, ref, max_t):
420
+ """Centerline + thickness perpendicular to the line at (px,py).
421
+ Horizontal line (axis=1): normal is vertical -> scan column px.
422
+ Vertical line (axis=0): normal is horizontal -> scan row py.
423
+ Reads a short strip from `lab` directly (no full-image mask)."""
424
+ h, w = lab.shape[:2]
425
+ ix, iy = int(round(px)), int(round(py))
426
+ # Strip must be wide enough to reach the band boundary even when the gutter
427
+ # is fused with same-colour tile content; max_t no longer bounds the walk.
428
+ pad = PERP_SCAN
429
+ if axis == 1:
430
+ if not (0 <= ix < w) or not (0 <= iy < h):
431
+ return None
432
+ lo = max(0, iy - pad); hi = min(h, iy + pad + 1)
433
+ strip = lab[lo:hi, ix, :]
434
+ r = _perp_from_strip(strip, iy - lo, ref, max_t)
435
+ if r is None:
436
+ return None
437
+ off, thick, term = r
438
+ return px, iy + off, thick, term
439
+ else:
440
+ if not (0 <= iy < h) or not (0 <= ix < w):
441
+ return None
442
+ lo = max(0, ix - pad); hi = min(w, ix + pad + 1)
443
+ strip = lab[iy, lo:hi, :]
444
+ r = _perp_from_strip(strip, ix - lo, ref, max_t)
445
+ if r is None:
446
+ return None
447
+ off, thick, term = r
448
+ return ix + off, py, thick, term
449
+
450
+
451
+ def _seed_thickness(lab, axis, cx, cy, ref):
452
+ """Count contiguous pixels perpendicular to the gutter at (cx,cy) that match
453
+ `ref` (within COLOR_TOL), capped at PERP_SCAN each side. Used ONLY to populate
454
+ PotentialGridLine.thickness for the relative GRID_THICK_TOL gate — it never
455
+ rejects a trace, so a gutter fused with same-colour tile content (large count)
456
+ is fine."""
457
+ h, w = lab.shape[:2]
458
+ ix, iy = int(round(cx)), int(round(cy))
459
+ if not (0 <= iy < h and 0 <= ix < w):
460
+ return 1.0
461
+ up = dn = 0
462
+ if axis == 1: # perpendicular = vertical (column)
463
+ i = iy - 1
464
+ while i >= 0 and up < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
465
+ up += 1; i -= 1
466
+ i = iy + 1
467
+ while i < h and dn < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
468
+ dn += 1; i += 1
469
+ else: # perpendicular = horizontal (row)
470
+ i = ix - 1
471
+ while i >= 0 and up < PERP_SCAN and _de2(lab[iy, i], ref) <= COLOR_TOL ** 2:
472
+ up += 1; i -= 1
473
+ i = ix + 1
474
+ while i < w and dn < PERP_SCAN and _de2(lab[iy, i], ref) <= COLOR_TOL ** 2:
475
+ dn += 1; i += 1
476
+ return float(up + dn + 1)
477
+
478
+
479
+ # ── Stage B: center-out tracer ──────────────────────────────────────────────
480
+ def _seed_order(mid, lo, hi, step=1):
481
+ yield mid
482
+ d = step
483
+ while True:
484
+ a, b = mid - d, mid + d
485
+ emitted = False
486
+ if a >= lo:
487
+ yield a; emitted = True
488
+ if b <= hi:
489
+ yield b; emitted = True
490
+ if not emitted:
491
+ break
492
+ d += step
493
+
494
+
495
+ def _split_runs(sorted_idx):
496
+ """Split a sorted index array into maximal runs of consecutive integers.
497
+ Vectorized: a run boundary is wherever the gap to the next index is > 1, so
498
+ np.diff locates all breaks in one pass and np.split cuts there. Returns a list
499
+ of int arrays (callers do len()/indexing only)."""
500
+ sorted_idx = np.asarray(sorted_idx)
501
+ if sorted_idx.size == 0:
502
+ return []
503
+ breaks = np.where(np.diff(sorted_idx) > 1)[0] + 1
504
+ return np.split(sorted_idx, breaks)
505
+
506
+
507
+ # ── Vectorized batched walk ──────────────────────────────────────────────────
508
+ # All seeds of one axis are walked in LOCKSTEP: at step k, every still-active
509
+ # trace is advanced one pixel along the gutter together with a single fancy-index
510
+ # gather + a vectorized accept test (replacing ~1976 sequential python pixel
511
+ # walks/img with ~image-dim vectorized steps). The accept test and the
512
+ # perpendicular slant re-find reproduce _trace_one's scalar `ok`/`extend` exactly:
513
+ # ok: |L - seed_L| <= SEED_L_TOL AND |L - prev_L| <= STEP_L_TOL
514
+ # AND ΔE(pix, running line mean)^2 <= CONT_TOL2
515
+ # refind: on a miss, look ±1..PERP_REFIND perpendicular (nearest first, up then
516
+ # down) for a pixel that passes ok; if found, follow the slant, else stop.
517
+ def _batch_gather(lab, axis, along_i, perp_i):
518
+ """lab pixels at integer (along, perp) for this axis -> (M,3)."""
519
+ if axis == 1: # H line: along=x, perp=y
520
+ return lab[perp_i, along_i]
521
+ return lab[along_i, perp_i] # V line: along=y, perp=x
522
+
523
+
524
+ def _batch_accept(pix, seed_L, prev_L, line_color):
525
+ """Vectorized copy of _trace_one.ok over M traces at once."""
526
+ L = pix[:, 0]
527
+ d0 = pix[:, 0] - line_color[:, 0]
528
+ d1 = pix[:, 1] - line_color[:, 1]
529
+ d2 = pix[:, 2] - line_color[:, 2]
530
+ de2 = d0 * d0 + d1 * d1 + d2 * d2
531
+ return ((np.abs(L - seed_L) <= SEED_L_TOL)
532
+ & (np.abs(L - prev_L) <= STEP_L_TOL)
533
+ & (de2 <= CONT_TOL2))
534
+
535
+
536
+ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction):
537
+ """Walk all N seeds one direction in lockstep. Returns (last_along[N],
538
+ last_perp[N], support[N]). Reproduces _trace_one.extend exactly: per-step
539
+ accept test + ±PERP_REFIND perpendicular slant re-find (nearest first, up
540
+ then down). State arrays are indexed by ORIGINAL seed id; an `active` mask
541
+ compacts the work each step."""
542
+ h, w = lab.shape[:2]
543
+ N = along_seed.shape[0]
544
+ along_max = (w - 1) if axis == 1 else (h - 1)
545
+ perp_max = (h - 1) if axis == 1 else (w - 1)
546
+ seed_L = seed_ref[:, 0]
547
+
548
+ perp = perp_seed.astype(np.float64).copy()
549
+ along = along_seed.astype(np.float64).copy()
550
+ prev_L = seed_L.copy()
551
+ line_color = seed_ref.astype(np.float64).copy()
552
+ line_n = np.ones(N, dtype=np.float64)
553
+ last_along = along_seed.astype(np.float64).copy()
554
+ last_perp = perp_seed.astype(np.float64).copy()
555
+ support = np.zeros(N, dtype=np.int64)
556
+ active = np.ones(N, dtype=bool)
557
+
558
+ while active.any():
559
+ ai = np.where(active)[0] # original ids still walking
560
+ al = along[ai] + direction * STEP
561
+ in_b = (al >= 0) & (al <= along_max)
562
+ if not in_b.all():
563
+ active[ai[~in_b]] = False
564
+ keep = in_b
565
+ ai = ai[keep]; al = al[keep]
566
+ if ai.size == 0:
567
+ break
568
+ pe = perp[ai]
569
+ ali = al.astype(np.intp); pei = pe.astype(np.intp)
570
+ pix = _batch_gather(lab, axis, ali, pei)
571
+ ok = _batch_accept(pix, seed_L[ai], prev_L[ai], line_color[ai])
572
+
573
+ new_perp = pe.copy()
574
+ new_pix = pix.copy()
575
+ accepted = ok.copy()
576
+
577
+ if not ok.all():
578
+ # `still` is a boolean over positions-within-ai: traces still looking
579
+ # for a slant continuation. Nearest offset first (d=1..), up then down;
580
+ # once a trace finds the gutter colour it drops out of `still`.
581
+ still = ~ok
582
+ for d in range(1, PERP_REFIND + 1):
583
+ for s in (-1, +1):
584
+ sid = np.where(still)[0]
585
+ if sid.size == 0:
586
+ break
587
+ cand_pe = pe[sid] + s * d
588
+ inb = (cand_pe >= 0) & (cand_pe <= perp_max)
589
+ sid = sid[inb]; cand_pe = cand_pe[inb]
590
+ if sid.size == 0:
591
+ continue
592
+ cpx = _batch_gather(lab, axis, al[sid].astype(np.intp),
593
+ cand_pe.astype(np.intp))
594
+ passc = _batch_accept(cpx, seed_L[ai[sid]],
595
+ prev_L[ai[sid]], line_color[ai[sid]])
596
+ hit = sid[passc]
597
+ if hit.size:
598
+ new_perp[hit] = cand_pe[passc]
599
+ new_pix[hit] = cpx[passc]
600
+ accepted[hit] = True
601
+ still[hit] = False
602
+ if not still.any():
603
+ break
604
+
605
+ adv = np.where(accepted)[0]
606
+ if adv.size:
607
+ gid = ai[adv]
608
+ ln = line_n[gid]
609
+ line_color[gid] = (line_color[gid] * ln[:, None] + new_pix[adv]) / (ln[:, None] + 1)
610
+ line_n[gid] = ln + 1
611
+ prev_L[gid] = new_pix[adv, 0]
612
+ perp[gid] = new_perp[adv]
613
+ along[gid] = al[adv]
614
+ last_perp[gid] = new_perp[adv]
615
+ last_along[gid] = al[adv]
616
+ support[gid] += 1
617
+ active[ai[~accepted]] = False # no continuation -> stop
618
+
619
+ return last_along, last_perp, support
620
+
621
+
622
+ def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref):
623
+ """Walk N seeds both directions in lockstep. Returns a_lo,a_hi (min/max along
624
+ reached), perp_lo,perp_hi (perp at those ends), support (steps both dirs)."""
625
+ fa, fp, ns_f = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, +1)
626
+ ba, bp, ns_b = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, -1)
627
+ a_hi = np.maximum(along_seed.astype(np.float64), fa)
628
+ a_lo = np.minimum(along_seed.astype(np.float64), ba)
629
+ return a_lo, a_hi, bp, fp, ns_f + ns_b
630
+
631
+
632
+ def _trace_one(lab, axis, run, seed_along, seed_bias):
633
+ """Trace a line. `run` is the contiguous ridge-run of perpendicular-axis
634
+ indices (x's for a horizontal line, y's for a vertical line); `seed_along`
635
+ is the fixed coordinate on the seed row/col. We pick a seed position within
636
+ the run whose perpendicular probe succeeds (the run midpoint can land on a
637
+ 1-px dead spot of a real gutter), so a single unlucky pixel doesn't drop the
638
+ whole line."""
639
+ h, w = lab.shape[:2]
640
+ # Seed = the run midpoint (or quarters) whose pixel is a clean gutter colour.
641
+ # No thickness/termination check here — a gutter fused with same-colour tile
642
+ # content has no measurable thickness, but it is still a real gutter.
643
+ mid = len(run) // 2
644
+ order = [mid]
645
+ for frac in (0.25, 0.75):
646
+ idx = int(len(run) * frac)
647
+ if 0 <= idx < len(run) and idx not in order:
648
+ order.append(idx)
649
+ cx = cy = None
650
+ for idx in order:
651
+ along = float(run[idx])
652
+ if axis == 1:
653
+ sx, sy = along, float(seed_along)
654
+ else:
655
+ sx, sy = float(seed_along), along
656
+ iy, ix = int(round(sy)), int(round(sx))
657
+ if 0 <= iy < h and 0 <= ix < w:
658
+ cx, cy = sx, sy
659
+ break
660
+ if cx is None:
661
+ return None
662
+ if axis == 1: # horizontal line: along=x, perp=y
663
+ along_seed, perp_seed = cx, cy
664
+ else: # vertical line: along=y, perp=x
665
+ along_seed, perp_seed = cy, cx
666
+ seed_ref = lab[int(round(cy)), int(round(cx))].astype(np.float64)
667
+
668
+ def at(x, y):
669
+ # nearest-pixel LAB lookup; int() truncation is fine at our scale and
670
+ # avoids the per-call cost of round()+astype() in the inner walk loop.
671
+ return lab[int(y + 0.5), int(x + 0.5)]
672
+
673
+ def perp_at(along, perp):
674
+ """The (x,y) pixel at along/perp for this axis."""
675
+ return (along, perp) if axis == 1 else (perp, along)
676
+
677
+ def drift():
678
+ """Walk along the gutter one pixel at a time. At each step compare the
679
+ pixel to the running gutter colour. If it still matches -> advance. If it
680
+ DOESN'T (we hit a cell), it may just be the SLANT carrying the gutter off
681
+ our current perp row/col: look a few px perpendicular (up then down) for
682
+ the gutter colour and, if found, STEP THERE and keep going. Only if no
683
+ gutter colour exists within +/-PERP_REFIND perpendicular is it a genuine
684
+ wall -> stop. No thickness, no midpoint: perp just tracks the gutter."""
685
+ line_color = seed_ref.copy()
686
+ line_n = 1
687
+ endpoints = []
688
+ seed_L = float(seed_ref[0])
689
+
690
+ def ok(pix, prev_L):
691
+ """Accept `pix` as part of the gutter. Lightness (L) is the stable
692
+ signal — a real gutter's L barely moves (measured span ~3) while a
693
+ shaded surface like grass drifts wildly (span ~32). a/b are noisy
694
+ (JPEG chroma) and visually irrelevant at these lightnesses, so we do
695
+ NOT gate on full ΔE-from-seed (that over-penalised chroma noise and
696
+ dropped real tinted-grey gutters). Three checks:
697
+ 1. |L - seed_L| <= SEED_L_TOL (same lightness throughout)
698
+ 2. |L - prev_L| <= STEP_L_TOL (no lightness jump step->step)
699
+ 3. ΔE(pix, running mean) <= CONT_TOL (local colour continuity)"""
700
+ L = float(pix[0])
701
+ return (abs(L - seed_L) <= SEED_L_TOL
702
+ and abs(L - prev_L) <= STEP_L_TOL
703
+ and _de2(pix, line_color) <= CONT_TOL2)
704
+
705
+ def extend(direction):
706
+ nonlocal line_color, line_n
707
+ along, perp = along_seed, perp_seed
708
+ prev_L = seed_L
709
+ n = 0
710
+ while True:
711
+ along += direction * STEP
712
+ px, py = perp_at(along, perp)
713
+ if px < 0 or py < 0 or px > w - 1 or py > h - 1:
714
+ break
715
+ cpix = at(px, py)
716
+ if ok(cpix, prev_L):
717
+ line_color = (line_color * line_n + cpix) / (line_n + 1); line_n += 1
718
+ prev_L = float(cpix[0])
719
+ n += 1
720
+ continue
721
+ # Colour changed: hit a cell. Is the gutter just slanted away?
722
+ # Look perpendicular for the gutter colour (same test), nearest
723
+ # offset first.
724
+ found = None
725
+ for d in range(1, PERP_REFIND + 1):
726
+ for s in (-1, +1): # up then down
727
+ np_ = perp + s * d
728
+ qx, qy = perp_at(along, np_)
729
+ if qx < 0 or qy < 0 or qx > w - 1 or qy > h - 1:
730
+ continue
731
+ qpix = at(qx, qy)
732
+ if ok(qpix, prev_L):
733
+ found = np_; break
734
+ if found is not None:
735
+ break
736
+ if found is None:
737
+ break # genuine wall -> stop
738
+ perp = found # follow the slant
739
+ qx, qy = perp_at(along, perp)
740
+ cpix = at(qx, qy)
741
+ line_color = (line_color * line_n + cpix) / (line_n + 1); line_n += 1
742
+ prev_L = float(cpix[0])
743
+ n += 1
744
+ endpoints.append(perp_at(along, perp))
745
+ return n
746
+
747
+ n_fwd = extend(+1)
748
+ n_bwd = extend(-1)
749
+ return endpoints, n_fwd + n_bwd
750
+
751
+ def fit(endpoints):
752
+ """Fit (ang, slant, midline, span) through seed + traced endpoints."""
753
+ pts = np.array([(cx, cy)] + endpoints, dtype=np.float64)
754
+ mean = pts.mean(axis=0)
755
+ dv = _principal_dir_2d(pts - mean)
756
+ if axis == 1:
757
+ ang = np.arctan2(dv[1], dv[0])
758
+ if abs(ang) > np.pi / 2:
759
+ ang -= np.copysign(np.pi, ang)
760
+ slant = np.tan(ang)
761
+ midline = mean[1] + slant * (w / 2.0 - mean[0])
762
+ span = float(pts[:, 0].max() - pts[:, 0].min())
763
+ else:
764
+ ang = np.arctan2(dv[0], dv[1])
765
+ if abs(ang) > np.pi / 2:
766
+ ang -= np.copysign(np.pi, ang)
767
+ slant = np.tan(ang)
768
+ midline = mean[0] + slant * (h / 2.0 - mean[1])
769
+ span = float(pts[:, 1].max() - pts[:, 1].min())
770
+ return pts, ang, slant, midline, span
771
+
772
+ # Walk the gutter both directions (slant followed by perpendicular re-find,
773
+ # not by thickness/midpoint). The slant is recovered from the fit through the
774
+ # traced endpoints.
775
+ endpoints, support = drift()
776
+ if support < MIN_RUN:
777
+ return None
778
+ pts, ang, slant, midline, span = fit(endpoints)
779
+ full = w if axis == 1 else h
780
+ if abs(slant) > SLANT_CAP or span < SUPPORT_FRAC * full:
781
+ return None
782
+ a_lo = float(pts[:, 0].min()) if axis == 1 else float(pts[:, 1].min())
783
+ a_hi = float(pts[:, 0].max()) if axis == 1 else float(pts[:, 1].max())
784
+
785
+ # Thickness for the consistency gate is measured once at the seed (it does NOT
786
+ # gate the trace). Count contiguous gutter-colour pixels perpendicular to the
787
+ # seed; if the gutter is fused with same-colour tile content the count is
788
+ # large but bounded by PERP_SCAN — only used for the relative thickness gate.
789
+ th_med = _seed_thickness(lab, axis, cx, cy, seed_ref)
790
+ # Sample colours along the validated line for the consistency gate + mean.
791
+ cols = []
792
+ for a in np.arange(a_lo, a_hi + 1, 3.0):
793
+ if axis == 1:
794
+ x = a; y = midline + slant * (x - w / 2.0)
795
+ else:
796
+ y = a; x = midline + slant * (y - h / 2.0)
797
+ jx, jy = int(round(x)), int(round(y))
798
+ if 0 <= jy < h and 0 <= jx < w:
799
+ cols.append(lab[jy, jx])
800
+ if not cols:
801
+ return None
802
+ cols = np.array(cols, dtype=np.float64)
803
+ color_std = float(np.mean(np.std(cols, axis=0)))
804
+ if color_std > LINE_STD_TOL:
805
+ return None
806
+
807
+ # Endpoints from the validated (a_lo,a_hi) span on the fitted line.
808
+ if axis == 1:
809
+ start = (a_lo, midline + slant * (a_lo - w / 2.0))
810
+ end = (a_hi, midline + slant * (a_hi - w / 2.0))
811
+ else:
812
+ start = (midline + slant * (a_lo - h / 2.0), a_lo)
813
+ end = (midline + slant * (a_hi - h / 2.0), a_hi)
814
+
815
+ return PotentialGridLine(
816
+ orientation='h' if axis == 1 else 'v',
817
+ angle=float(ang), thickness=th_med,
818
+ start=start, end=end,
819
+ color_lab=cols.mean(axis=0), color_std=color_std,
820
+ midline_pos=float(midline), support=int(support),
821
+ )
822
+
823
+
824
+ def _principal_dir_2d(centered):
825
+ """Unit eigenvector of the largest eigenvalue of the 2x2 covariance of
826
+ `centered` (N,2) points. Closed form — no SVD."""
827
+ cxx = float(np.dot(centered[:, 0], centered[:, 0]))
828
+ cyy = float(np.dot(centered[:, 1], centered[:, 1]))
829
+ cxy = float(np.dot(centered[:, 0], centered[:, 1]))
830
+ # eigenvector of [[cxx,cxy],[cxy,cyy]] for the larger eigenvalue
831
+ tr = cxx + cyy
832
+ det = cxx * cyy - cxy * cxy
833
+ disc = max(0.0, (tr * tr) / 4.0 - det)
834
+ lam = tr / 2.0 + np.sqrt(disc)
835
+ if abs(cxy) > 1e-9:
836
+ v = np.array([lam - cyy, cxy], dtype=np.float64)
837
+ elif cxx >= cyy:
838
+ v = np.array([1.0, 0.0])
839
+ else:
840
+ v = np.array([0.0, 1.0])
841
+ n = np.hypot(*v)
842
+ return v / n if n > 1e-9 else np.array([1.0, 0.0])
843
+
844
+
845
+ def _scan_seeds(lab, axis, seed_bias, ridge, c_lo, c_hi, span, lines):
846
+ """Seed EVERY row/col (no skip, no order-based dedup) and collect ALL valid
847
+ traces. The same gutter is intentionally re-seeded from each of its rows; the
848
+ duplicates are clustered and averaged later by _merge_lines. This replaces the
849
+ old 'skip a band-width / first-found-wins' scheme — which let a weak slanted
850
+ fragment found first block a stronger straight gutter found a few rows later
851
+ (the missed-straight-gutter bug). Checking every row also recentres the line
852
+ on the cluster midpoint, like find_grid's original cluster-and-average."""
853
+ h, w = lab.shape[:2]
854
+ lo, hi = int(span * (0.5 - MAX_SEED_FRAC)), int(span * (0.5 + MAX_SEED_FRAC))
855
+ # Decimate seed rows/cols: a gutter is >= MIN_RUN px in the perpendicular
856
+ # direction, so stepping by SEED_DECIMATE still seeds EVERY gutter from
857
+ # multiple rows (enough for the cluster-average) while doing a fraction of the
858
+ # per-seed trace work. The dominant cost is tracing the thousands of seeds that
859
+ # land on tile CONTENT and die after a few px; decimation cuts that linearly.
860
+ for c in range(lo, hi + 1, SEED_DECIMATE):
861
+ if axis == 1:
862
+ runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
863
+ else:
864
+ runs = _split_runs(np.where(ridge[c_lo:c_hi, c])[0] + c_lo)
865
+ for run in runs:
866
+ if len(run) < MIN_RUN:
867
+ continue
868
+ ln = _trace_one(lab, axis, run, float(c), seed_bias)
869
+ if ln is not None:
870
+ lines.append(ln)
871
+
872
+
873
+ def _collect_seeds(lab, axis, ridge, c_lo, c_hi, span):
874
+ """Gather one seed per (decimated row/col, ridge-run >= MIN_RUN), choosing the
875
+ seed pixel exactly as _trace_one does (run midpoint, then 1/4 and 3/4 as
876
+ fallbacks) restricted to in-bounds. Returns arrays along_seed[N], perp_seed[N],
877
+ seed_ref[N,3] for the batch walk."""
878
+ h, w = lab.shape[:2]
879
+ lo, hi = int(span * (0.5 - MAX_SEED_FRAC)), int(span * (0.5 + MAX_SEED_FRAC))
880
+ scan_w = c_hi - c_lo
881
+ along_s, perp_s = [], []
882
+ for c in range(lo, hi + 1, SEED_DECIMATE):
883
+ if axis == 1: # H line: c is a row (y=perp), run is x (along)
884
+ runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
885
+ else: # V line: c is a col (x=perp), run is y (along)
886
+ runs = _split_runs(np.where(ridge[c_lo:c_hi, c])[0] + c_lo)
887
+ for run in runs:
888
+ if len(run) < MIN_RUN or len(run) < SEED_RUN_FRAC * scan_w:
889
+ # A real grid gutter is seeded from a row/col that lies ON it, so
890
+ # its ridge run already spans almost the whole scan (measured:
891
+ # survivors >= 0.88 of scan; dying content fragments median ~0.09,
892
+ # p90 ~0.35). Requiring the run to cover >= SEED_RUN_FRAC of the
893
+ # scan drops ~95% of the dead content seeds BEFORE the expensive
894
+ # walk (they accounted for ~73% of all walk steps) without losing a
895
+ # real gutter. Slant is recovered DURING the walk (perp re-find),
896
+ # not from the seed run, so this does not hurt tilted grids.
897
+ continue
898
+ # seed-pixel choice (mirror _trace_one): midpoint, then quarters
899
+ chosen = None
900
+ mid = len(run) // 2
901
+ for idx in [mid, int(len(run) * 0.25), int(len(run) * 0.75)]:
902
+ if 0 <= idx < len(run):
903
+ a = float(run[idx])
904
+ if axis == 1:
905
+ sx, sy = a, float(c)
906
+ else:
907
+ sx, sy = float(c), a
908
+ if 0 <= int(sy + 0.5) < h and 0 <= int(sx + 0.5) < w:
909
+ chosen = a; break
910
+ if chosen is None:
911
+ continue
912
+ along_s.append(chosen)
913
+ perp_s.append(float(c))
914
+ if not along_s:
915
+ return (np.empty(0), np.empty(0), np.empty((0, 3)))
916
+ along_seed = np.array(along_s, dtype=np.float64)
917
+ perp_seed = np.array(perp_s, dtype=np.float64)
918
+ if axis == 1: # gather at (perp=y, along=x)
919
+ seed_ref = lab[perp_seed.astype(np.intp), along_seed.astype(np.intp)].astype(np.float64)
920
+ else: # gather at (along=y, perp=x)
921
+ seed_ref = lab[along_seed.astype(np.intp), perp_seed.astype(np.intp)].astype(np.float64)
922
+ return along_seed, perp_seed, seed_ref
923
+
924
+
925
+ def _trace_lines(lab, axis, seed_bias):
926
+ """Vectorized: collect all seeds, walk them in lockstep (_walk_batch), then
927
+ build a PotentialGridLine per surviving trace with the same per-line gates as
928
+ the scalar path (SLANT_CAP, SUPPORT_FRAC span, LINE_STD_TOL)."""
929
+ h, w = lab.shape[:2]
930
+ ridge = _build_ridge_map(lab, axis)
931
+ if axis == 1:
932
+ span = h; c_lo, c_hi = int(w * 0.2), int(w * 0.8)
933
+ else:
934
+ span = w; c_lo, c_hi = int(h * 0.2), int(h * 0.8)
935
+ along_seed, perp_seed, seed_ref = _collect_seeds(lab, axis, ridge, c_lo, c_hi, span)
936
+ if along_seed.size == 0:
937
+ return []
938
+ a_lo, a_hi, perp_lo, perp_hi, support = _walk_batch(lab, axis, along_seed, perp_seed, seed_ref)
939
+
940
+ full = w if axis == 1 else h
941
+ lines = []
942
+ for i in range(along_seed.size):
943
+ if support[i] < MIN_RUN:
944
+ continue
945
+ span_i = a_hi[i] - a_lo[i]
946
+ if span_i < SUPPORT_FRAC * full:
947
+ continue
948
+ # endpoints traced: (a_lo, perp_lo) and (a_hi, perp_hi) in along/perp;
949
+ # plus the seed. Fit a line through them (PCA), exactly like _trace_one.fit.
950
+ if axis == 1:
951
+ pts = np.array([(along_seed[i], perp_seed[i]),
952
+ (a_lo[i], perp_lo[i]), (a_hi[i], perp_hi[i])], dtype=np.float64)
953
+ else:
954
+ pts = np.array([(perp_seed[i], along_seed[i]),
955
+ (perp_lo[i], a_lo[i]), (perp_hi[i], a_hi[i])], dtype=np.float64)
956
+ mean = pts.mean(axis=0)
957
+ dv = _principal_dir_2d(pts - mean)
958
+ if axis == 1:
959
+ ang = np.arctan2(dv[1], dv[0])
960
+ if abs(ang) > np.pi / 2:
961
+ ang -= np.copysign(np.pi, ang)
962
+ slant = np.tan(ang)
963
+ midline = mean[1] + slant * (w / 2.0 - mean[0])
964
+ else:
965
+ ang = np.arctan2(dv[0], dv[1])
966
+ if abs(ang) > np.pi / 2:
967
+ ang -= np.copysign(np.pi, ang)
968
+ slant = np.tan(ang)
969
+ midline = mean[0] + slant * (h / 2.0 - mean[1])
970
+ if abs(slant) > SLANT_CAP:
971
+ continue
972
+ a0 = a_lo[i]; a1 = a_hi[i]
973
+ # sample colours along the validated line for the consistency gate + mean
974
+ aa = np.arange(a0, a1 + 1, 3.0)
975
+ if axis == 1:
976
+ xs = aa; ys = midline + slant * (xs - w / 2.0)
977
+ else:
978
+ ys = aa; xs = midline + slant * (ys - h / 2.0)
979
+ jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
980
+ inb = (jy >= 0) & (jy < h) & (jx >= 0) & (jx < w)
981
+ if not inb.any():
982
+ continue
983
+ cols = lab[jy[inb], jx[inb]].astype(np.float64)
984
+ color_std = float(np.mean(np.std(cols, axis=0)))
985
+ if color_std > LINE_STD_TOL:
986
+ continue
987
+ if axis == 1:
988
+ start = (a0, midline + slant * (a0 - w / 2.0))
989
+ end = (a1, midline + slant * (a1 - w / 2.0))
990
+ else:
991
+ start = (midline + slant * (a0 - h / 2.0), a0)
992
+ end = (midline + slant * (a1 - h / 2.0), a1)
993
+ lines.append(PotentialGridLine(
994
+ orientation='h' if axis == 1 else 'v',
995
+ angle=float(ang), thickness=0.0,
996
+ start=start, end=end,
997
+ color_lab=cols.mean(axis=0), color_std=color_std,
998
+ midline_pos=float(midline), support=int(support[i]),
999
+ ))
1000
+ return _merge_lines(lines)
1001
+
1002
+
1003
+ def _merge_lines(lines):
1004
+ """Cluster duplicate traces of the same gutter (seeded from each of its rows)
1005
+ by POSITION ONLY and collapse each cluster to one line. We deliberately do
1006
+ NOT also gate on angle: noisy slanted fragments of the SAME gutter get
1007
+ slightly different angle estimates, and gating on angle left them as separate
1008
+ near-duplicate lines that polluted the line list and broke extraction. Two
1009
+ genuinely different gutters are >= MIN_CELL apart, far beyond MERGE_PX, so
1010
+ position-only clustering can't fuse distinct gutters."""
1011
+ if not lines:
1012
+ return []
1013
+ lines = sorted(lines, key=lambda l: l.midline_pos)
1014
+ merged = []
1015
+ cur = [lines[0]]
1016
+ for ln in lines[1:]:
1017
+ if abs(ln.midline_pos - cur[-1].midline_pos) < MERGE_PX:
1018
+ cur.append(ln)
1019
+ else:
1020
+ merged.append(_pick(cur)); cur = [ln]
1021
+ merged.append(_pick(cur))
1022
+ return merged
1023
+
1024
+
1025
+ def _pick(group):
1026
+ """Collapse a cluster of duplicate traces of the same gutter into one line.
1027
+ Keep the strongest member's attributes (angle, colour, thickness) but set the
1028
+ position to the SUPPORT-WEIGHTED mean — so a real straight full-width gutter
1029
+ (large support, many duplicate rows) dominates the centre and a weak slanted
1030
+ fragment that happens to fall in the same cluster barely shifts it. `support`
1031
+ becomes the cluster's max (its real length), and we record the cluster size."""
1032
+ best = max(group, key=lambda l: l.support)
1033
+ wsum = sum(l.support for l in group)
1034
+ best.midline_pos = sum(l.midline_pos * l.support for l in group) / wsum
1035
+ best.support = max(l.support for l in group)
1036
+ return best
1037
+
1038
+
1039
+ # ── _generate_grid: cell boxes for a (rows x cols) grid of any size ──────────
1040
+ def _generate_grid(rows, cols, hs, vs, hd, vd, h, w, slant):
1041
+ """Cell bounding boxes for a `rows` x `cols` grid. `hs` are the `rows-1`
1042
+ internal H-line positions (pitch hd), `vs` the `cols-1` internal V-lines
1043
+ (pitch vd). The outer borders are extrapolated one pitch beyond the outer
1044
+ internal lines (grids have no reliable outer frame; the central pitch is the
1045
+ signal). Returns rows*cols boxes row-major, or None if any clip empty.
1046
+ Generalises the old 3x3/4x4-only generator to any dimensions."""
1047
+ mid_x, mid_y = w / 2, h / 2
1048
+ y_bounds = [hs[0] - hd] + list(hs) + [hs[-1] + hd]
1049
+ x_bounds = [vs[0] - vd] + list(vs) + [vs[-1] + vd]
1050
+ grid_boxes = []
1051
+ s2 = slant * slant
1052
+ denom = 1 + s2
1053
+ for i in range(len(y_bounds) - 1):
1054
+ for j in range(len(x_bounds) - 1):
1055
+ corners = []
1056
+ for y_i in [y_bounds[i], y_bounds[i + 1]]:
1057
+ for x_j in [x_bounds[j], x_bounds[j + 1]]:
1058
+ cy = (y_i + slant * (x_j - mid_x) + s2 * mid_y) / denom
1059
+ cx = x_j - slant * (cy - mid_y)
1060
+ corners.append((cx, cy))
1061
+ pts = np.array(corners, dtype=np.float32)
1062
+ x1, y1 = np.min(pts, axis=0)
1063
+ x2, y2 = np.max(pts, axis=0)
1064
+ x1_c, y1_c = int(max(0, min(w, x1))), int(max(0, min(h, y1)))
1065
+ x2_c, y2_c = int(max(0, min(w, x2))), int(max(0, min(h, y2)))
1066
+ if x2_c > x1_c and y2_c > y1_c:
1067
+ grid_boxes.append((x1_c, y1_c, x2_c, y2_c))
1068
+ return grid_boxes if len(grid_boxes) == rows * cols else None
1069
+
1070
+
1071
+ # ── Stage C: extract grid from traced lines ─────────────────────────────────
1072
+ def _internal(lines, total):
1073
+ return [l for l in lines
1074
+ if total * EDGE_MARGIN < l.midline_pos < total * (1 - EDGE_MARGIN)]
1075
+
1076
+
1077
+ def _even_spacing_ok(positions, total):
1078
+ """True if `positions` (sorted internal-line coords) are EVENLY spaced — every
1079
+ consecutive INTERNAL gap is within EVEN_TOL of the median. This is the core
1080
+ "cells are the same size" rule, measured from the central closed cells (the
1081
+ reliable part). We do NOT require or validate an outer border: real captchas
1082
+ often have no clean frame and the grid bleeds to the image edge. Instead we
1083
+ take the pitch from the internal gaps and only sanity-check that extrapolating
1084
+ one pitch beyond each outer internal line keeps the implied grid inside the
1085
+ image (it can't extend far past the edge). Returns (ok, pitch)."""
1086
+ p = sorted(positions)
1087
+ gaps = [p[i + 1] - p[i] for i in range(len(p) - 1)]
1088
+ pitch = float(np.median(gaps))
1089
+ if pitch < MIN_CELL:
1090
+ return False, pitch
1091
+ # equal-size cells: every internal gap matches the median pitch
1092
+ for g in gaps:
1093
+ if abs(g - pitch) > EVEN_TOL * pitch:
1094
+ return False, pitch
1095
+ # The implied grid spans [p[0]-pitch, p[-1]+pitch] (one cell beyond each outer
1096
+ # internal line). Require it to fit within the image with only a small
1097
+ # overshoot — the grid can reach/slightly exceed the edge (no border needed),
1098
+ # but a pair of lines whose extrapolated grid falls far outside the image is
1099
+ # not a real grid. Allows up to GRID_OVERSHOOT*pitch past each edge.
1100
+ if (p[0] - pitch) < -GRID_OVERSHOOT * pitch:
1101
+ return False, pitch
1102
+ if (p[-1] + pitch) > total + GRID_OVERSHOOT * pitch:
1103
+ return False, pitch
1104
+ return True, pitch
1105
+
1106
+
1107
+ def _line_span_perp(line):
1108
+ """(lo, hi) extent of a line along its OWN run direction (H -> x span, V -> y
1109
+ span) — i.e. how far it reaches in the perpendicular axis' coordinate."""
1110
+ if line.orientation == 'h':
1111
+ return (min(line.start[0], line.end[0]), max(line.start[0], line.end[0]))
1112
+ return (min(line.start[1], line.end[1]), max(line.start[1], line.end[1]))
1113
+
1114
+
1115
+ def _corroborate(lines, perp_lines, total):
1116
+ """Keep only lines that are REAL grid separators, corroborated by the
1117
+ perpendicular lines: a true internal line has >=2 perpendicular lines crossing
1118
+ it that extend at least ~CORROB_FRAC of a cell PAST it on BOTH sides. A frame /
1119
+ chrome line at the grid edge fails — the perpendicular lines die at the edge and
1120
+ do not continue a full cell beyond it (the giveaway the user pointed out: the V
1121
+ gutters above the top H line only continue a pixel or two). The grid is then
1122
+ always treated as OPEN (no border reliance): the surviving lines are internal
1123
+ separators and the outer cells are extrapolated one pitch out.
1124
+
1125
+ `lines` are candidates on one axis; `perp_lines` the traced lines of the other
1126
+ axis; `total` the image dim along `lines`' position axis. Cell size ~ the
1127
+ perpendicular lines' spacing (square cells)."""
1128
+ lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
1129
+ perp = sorted(perp_lines, key=lambda l: l.midline_pos)
1130
+ if len(lines) < 2:
1131
+ return lines
1132
+ # cell size estimate: median gap between consecutive candidate lines (this axis)
1133
+ pos = [l.midline_pos for l in lines]
1134
+ gaps = np.diff(pos)
1135
+ cell = float(np.median(gaps)) if len(gaps) else 0.0
1136
+ if cell < MIN_CELL:
1137
+ return lines
1138
+ need = CORROB_FRAC * cell
1139
+ kept = []
1140
+ for l in lines:
1141
+ p = l.midline_pos # this line's position (y for H, x for V)
1142
+ n_ok = 0
1143
+ for q in perp:
1144
+ lo, hi = _line_span_perp(q) # the perp line's extent in THIS axis' coord
1145
+ # does q cross l and extend >= need (~1 FULL cell) past it on BOTH
1146
+ # sides? A true internal separator has the perpendicular gutters
1147
+ # running a full cell beyond it on each side (there is a real cell
1148
+ # there). A frame/edge line fails: the perpendicular gutters only reach
1149
+ # the grid border — LESS than a full cell past — because there is no
1150
+ # cell beyond the edge.
1151
+ if (p - lo) >= need and (hi - p) >= need:
1152
+ n_ok += 1
1153
+ if n_ok >= 2:
1154
+ kept.append(l)
1155
+ # need at least 2 corroborated lines to define an open grid (>=3 cells)
1156
+ return kept if len(kept) >= 2 else lines
1157
+
1158
+
1159
+ def _complete_one_run(positions, grp, pitch, total):
1160
+ """Given a REAL evenly-spaced run of internal lines (positions == grp midlines,
1161
+ common pitch), yield completed runs that add at most MAX_VIRTUAL_NODES virtual
1162
+ internal lines by:
1163
+ * INTERPOLATING any ~k*pitch interior gap (a missing internal gutter between
1164
+ two same-colour cells — the sky-bordered-row case), and
1165
+ * EXTRAPOLATING one extra node beyond each end (a missing OUTER internal line
1166
+ that would complete a larger grid).
1167
+ `positions` carry the real midlines; virtual nodes sit exactly on the run's
1168
+ pitch. Yields (completed_positions, n_virtual). The caller scores + gates them;
1169
+ the cell-content gate is what ultimately rejects an over-extrapolation onto
1170
+ background. Anchoring on a real consecutive run (not arbitrary clean pairs)
1171
+ means a spurious off-pitch clean line can never seed a wrong lattice."""
1172
+ real = sorted(positions)
1173
+ # Candidate TRUE pitches: the run's own pitch, plus its sub-multiples — a
1174
+ # 2-line run's "pitch" is the whole gap, which may actually straddle k missing
1175
+ # cells (e.g. 222..413 is one missing internal line at 317, true pitch ~95).
1176
+ sub_pitches = [pitch]
1177
+ for k in (2, 3):
1178
+ sp = pitch / k
1179
+ if sp >= MIN_CELL:
1180
+ sub_pitches.append(sp)
1181
+ bases = []
1182
+ seen_runs = set()
1183
+ for tp in sub_pitches:
1184
+ # 1) interior fill: insert virtual nodes wherever a gap is ~m*tp (m>=2).
1185
+ filled = [real[0]]
1186
+ interior_virtual = 0
1187
+ ok = True
1188
+ for a, b in zip(real, real[1:]):
1189
+ m = int(round((b - a) / tp))
1190
+ if m < 1 or abs((b - a) - m * tp) > EVEN_TOL * tp:
1191
+ ok = False
1192
+ break
1193
+ for j in range(1, m):
1194
+ filled.append(a + j * (b - a) / m)
1195
+ interior_virtual += 1
1196
+ filled.append(b)
1197
+ if not ok:
1198
+ continue
1199
+ kf = tuple(round(x) for x in filled)
1200
+ if kf in seen_runs:
1201
+ continue
1202
+ seen_runs.add(kf)
1203
+ bases.append((list(filled), interior_virtual))
1204
+ # 2) extrapolation: 0 or 1 node beyond each end, on the base's own pitch.
1205
+ out = []
1206
+ for base, ivirt in bases:
1207
+ bp = (base[-1] - base[0]) / (len(base) - 1) if len(base) > 1 else pitch
1208
+ for lo_add in (0, 1):
1209
+ for hi_add in (0, 1):
1210
+ nv = ivirt + lo_add + hi_add
1211
+ if nv == 0 or nv > MAX_VIRTUAL_NODES:
1212
+ continue
1213
+ run = list(base)
1214
+ if lo_add:
1215
+ run.insert(0, run[0] - bp)
1216
+ if hi_add:
1217
+ run.append(run[-1] + bp)
1218
+ dim = len(run) + 1
1219
+ if dim < MIN_GRID_DIM or dim > 6:
1220
+ continue
1221
+ if nv > MAX_VIRTUAL_FRAC * len(run):
1222
+ continue
1223
+ out.append((run, nv))
1224
+ return out
1225
+
1226
+
1227
+ def _completed_candidates(lines, total, real_cand):
1228
+ """Lattice completion from clean PARTIAL runs.
1229
+
1230
+ Some real grids are missing one or more internal gutters because that gutter
1231
+ borders two same-colour cells (e.g. a reCAPTCHA 4x4 whose top rows are sky:
1232
+ the internal line between two sky tiles has no colour change to trace). The
1233
+ present gutters still establish the pitch; the missing line's position is fully
1234
+ determined by it. For every REAL candidate run (built by _axis_candidates from
1235
+ actual lines), emit completed runs whose `positions` add EXTRAPOLATED /
1236
+ INTERPOLATED virtual nodes, while `grp` keeps only the REAL clean lines (so the
1237
+ colour / span / angle gates downstream judge real evidence only).
1238
+
1239
+ Guard rails (this never invents grids on texture / FP images):
1240
+ * we only extend runs whose real anchor lines are CLEAN (color_std <
1241
+ CLEAN_LATTICE_STD) — painted gutters, not noisy photo edges;
1242
+ * a spurious off-pitch line cannot seed a lattice: we extend the REAL even
1243
+ runs, never arbitrary clean-line pairs;
1244
+ * at most MAX_VIRTUAL_NODES virtual nodes / MAX_VIRTUAL_FRAC of the lattice;
1245
+ * the completed run stays evenly spaced (re-checked via _even_spacing_ok).
1246
+ The decisive content gate (_cells_have_content) still runs in the extraction
1247
+ loop, so a completed lattice over a flat region (cells == gutter colour) is
1248
+ rejected there exactly like any other candidate."""
1249
+ out = {}
1250
+ seen = set()
1251
+ for dim, runs in real_cand.items():
1252
+ for positions, _score, pitch, grp in runs:
1253
+ # only extend clean painted runs
1254
+ if any(l.color_std >= CLEAN_LATTICE_STD for l in grp):
1255
+ continue
1256
+ for run, n_virtual in _complete_one_run(positions, grp, pitch, total):
1257
+ run = sorted(run)
1258
+ cdim = len(run) + 1
1259
+ ok, fpitch = _even_spacing_ok(run, total)
1260
+ if not ok:
1261
+ continue
1262
+ key = (cdim, tuple(round(p) for p in run))
1263
+ if key in seen:
1264
+ continue
1265
+ seen.add(key)
1266
+ ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
1267
+ center_off = abs((run[0] + run[-1]) / 2 - total / 2) / total
1268
+ scale_err = abs(fpitch - total / cdim) / total
1269
+ # Penalise each invented node so a fully-real lattice of the SAME
1270
+ # dim always outranks a completed one, and fewer virtuals win.
1271
+ score = (center_off * 1000 + scale_err * 500 + ang_pen * 200
1272
+ + n_virtual * VIRTUAL_NODE_PENALTY)
1273
+ out.setdefault(cdim, []).append((run, score, fpitch, grp))
1274
+ return out
1275
+
1276
+
1277
+ def _axis_candidates(lines, total):
1278
+ """Enumerate evenly-spaced INTERNAL-separator runs of any length K>=2 -> a grid
1279
+ of K+1 cells along this axis (OPEN model: lines are internal, outer cells
1280
+ extrapolated one pitch beyond the ends; no border reliance). `lines` here are
1281
+ already corroboration-filtered, so frame/chrome lines are gone. Greedy run-grow
1282
+ from each (start, pitch) seed, snapping to the nearest line within EVEN_TOL.
1283
+ Returns {dim: [(internal_positions, score, pitch, internal_lines), ...]} keyed
1284
+ by cell-dimension dim = K+1."""
1285
+ lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
1286
+ n = len(lines)
1287
+ cand = {}
1288
+ seen = set()
1289
+ for i in range(n):
1290
+ for j in range(i + 1, n):
1291
+ pitch0 = lines[j].midline_pos - lines[i].midline_pos
1292
+ if pitch0 < MIN_CELL:
1293
+ continue
1294
+ run_idx = [i, j]
1295
+ pos = lines[j].midline_pos
1296
+ jj = j
1297
+ while True:
1298
+ target = pos + pitch0
1299
+ best_k = None; best_d = EVEN_TOL * pitch0
1300
+ for k in range(jj + 1, n):
1301
+ d = abs(lines[k].midline_pos - target)
1302
+ if d < best_d:
1303
+ best_d = d; best_k = k
1304
+ if lines[k].midline_pos - target > EVEN_TOL * pitch0:
1305
+ break
1306
+ if best_k is None:
1307
+ break
1308
+ run_idx.append(best_k)
1309
+ pos = lines[best_k].midline_pos
1310
+ jj = best_k
1311
+ if len(run_idx) < 2:
1312
+ continue
1313
+ positions = [lines[r].midline_pos for r in run_idx]
1314
+ ok, pitch = _even_spacing_ok(positions, total)
1315
+ if not ok:
1316
+ continue
1317
+ grp = [lines[r] for r in run_idx]
1318
+ dim = len(run_idx) + 1 # OPEN: K internal lines -> K+1 cells
1319
+ if dim < MIN_GRID_DIM:
1320
+ continue
1321
+ key = (dim, tuple(round(p) for p in positions))
1322
+ if key in seen:
1323
+ continue
1324
+ seen.add(key)
1325
+ ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
1326
+ center_off = abs((positions[0] + positions[-1]) / 2 - total / 2) / total
1327
+ scale_err = abs(pitch - total / dim) / total
1328
+ score = center_off * 1000 + scale_err * 500 + ang_pen * 200
1329
+ cand.setdefault(dim, []).append((positions, score, pitch, grp))
1330
+ # Lattice completion: add candidates that interpolate/extrapolate a missing
1331
+ # internal gutter from a clean partial run (sky-bordered grids). They carry a
1332
+ # virtual-node penalty so they only win when no fully-real lattice of the same
1333
+ # dimension exists, and are deduped against the real candidates above.
1334
+ for dim, comps in _completed_candidates(lines, total, dict(cand)).items():
1335
+ bucket = cand.setdefault(dim, [])
1336
+ existing = {tuple(round(p) for p in c[0]) for c in bucket}
1337
+ for c in comps:
1338
+ kpos = tuple(round(p) for p in c[0])
1339
+ if kpos not in existing:
1340
+ bucket.append(c)
1341
+ existing.add(kpos)
1342
+ for k in cand:
1343
+ cand[k].sort(key=lambda x: x[1])
1344
+ return cand
1345
+
1346
+
1347
+ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1348
+ """Identify a 3x3 or 4x4 grid from traced lines and emit row-major boxes.
1349
+ Returns (boxes, size, slant) or (None, None, None). All colour gates are
1350
+ RELATIVE — colour spread among the chosen separators, H-mean vs V-mean,
1351
+ same-as-gutter for the off-lattice count — never a check against a specific
1352
+ colour, so a grid of ANY uniform border colour is detectable. (`lab` is
1353
+ accepted for API symmetry; the active gates are line-based.)"""
1354
+ # Corroboration filter: drop frame/chrome lines (only KEEP lines crossed by
1355
+ # >=2 perpendicular lines that extend ~1 cell past them on both sides). Mutual:
1356
+ # filter H using V and V using H, on the raw traced lines.
1357
+ h_keep = _corroborate(h_lines, v_lines, h)
1358
+ v_keep = _corroborate(v_lines, h_lines, w)
1359
+ n_hkeep = len(_internal(h_keep, h)) # # corroborated internal lines per axis
1360
+ n_vkeep = len(_internal(v_keep, w)) # — a candidate should USE all of them
1361
+ h_cand = _axis_candidates(h_keep, h)
1362
+ v_cand = _axis_candidates(v_keep, w)
1363
+ best = None
1364
+ best_score = float('inf')
1365
+ # rows = (#internal H lines)+1, cols = (#internal V lines)+1; allow them to
1366
+ # DIFFER (rectangular grids like 6x4) — cells stay square (hd~vd), the grid
1367
+ # need not be. Enumerate every (rows, cols) with both dims >= MIN_GRID_DIM.
1368
+ for rows in sorted(h_cand): # keyed by cell-dimension now
1369
+ if rows < MIN_GRID_DIM:
1370
+ continue
1371
+ for cols in sorted(v_cand):
1372
+ if cols < MIN_GRID_DIM:
1373
+ continue
1374
+ for hpos, hsc, hd, hlns in h_cand[rows][:25]:
1375
+ for vpos, vsc, vd, vlns in v_cand[cols][:25]:
1376
+ # square-ness: H pitch ~ V pitch (CELLS are roughly square,
1377
+ # even when the grid is rectangular)
1378
+ s_diff = abs(hd - vd) / max(hd, vd)
1379
+ if s_diff > 0.22:
1380
+ continue
1381
+ # full-span gate: every chosen lattice line must cross
1382
+ # essentially the WHOLE grid (>= (dim-0.5)*pitch end to end). H
1383
+ # lines span the grid's HEIGHT (rows*hd); V lines its WIDTH
1384
+ # (cols*vd). A short edge spanning only the central cell (object
1385
+ # in a reference photo, stray texture) is rejected.
1386
+ h_min = (cols - FULL_SPAN_MARGIN) * vd
1387
+ v_min = (rows - FULL_SPAN_MARGIN) * hd
1388
+ if (min(_line_extent(l) for l in hlns) < h_min
1389
+ or min(_line_extent(l) for l in vlns) < v_min):
1390
+ continue
1391
+ alll = hlns + vlns
1392
+ # Colour consistency: ALL gutters of a real grid share one
1393
+ # colour. Photo "grids" mix unrelated edges -> wide spread.
1394
+ ccols = np.array([l.color_lab for l in alll])
1395
+ avg = ccols.mean(axis=0)
1396
+ de = np.sqrt(np.sum((ccols - avg) ** 2, axis=1))
1397
+ if np.max(de) > GRID_COLOR_TOL:
1398
+ continue
1399
+ # angle coherence: H slant ~ -V slant (consistent global tilt)
1400
+ h_ang = np.mean([l.angle for l in hlns])
1401
+ v_ang = np.mean([l.angle for l in vlns])
1402
+ if abs(h_ang + v_ang) > GRID_ANGLE_TOL:
1403
+ continue
1404
+ # cross-axis colour match: H gutters' colour ~ V gutters' colour
1405
+ h_col = np.mean([l.color_lab for l in hlns], axis=0)
1406
+ v_col = np.mean([l.color_lab for l in vlns], axis=0)
1407
+ if _de(h_col, v_col) > XAXIS_COLOR_TOL:
1408
+ continue
1409
+ slant = np.tan(h_ang)
1410
+ ang_inc = abs(h_ang + v_ang) * 200
1411
+ # Prefer the candidate that USES ALL corroborated lines. After
1412
+ # corroboration the surviving lines are all real internal
1413
+ # separators, so the correct grid is the one that incorporates
1414
+ # every one of them — not a sub-set that skips some (which would
1415
+ # under-count, e.g. a 4x4's [r1,r2,r3] dropped to a 3-row
1416
+ # [r2,r3]). Penalise each corroborated line the candidate leaves
1417
+ # UNUSED. (rows-1) H lines and (cols-1) V lines are used.
1418
+ unused = (n_hkeep - (rows - 1)) + (n_vkeep - (cols - 1))
1419
+ # Grid-span fit: the perpendicular gutters run the WHOLE grid and
1420
+ # stop at its outer borders. The grid's extrapolated borders are
1421
+ # one pitch beyond the outer internal lines: H rows occupy
1422
+ # [hpos[0]-hd, hpos[-1]+hd]; V cols [vpos[0]-vd, vpos[-1]+vd]. If
1423
+ # the V gutters extend a FULL CELL past a row border (or H gutters
1424
+ # past a col border), there is an UNCOVERED cell there — the chosen
1425
+ # dimension is too small (a 4-row grid mislabelled 3 rows leaves
1426
+ # the top sky row uncovered while its V gutters still run all 4
1427
+ # cells). Penalise only an uncovered overshoot >= MISSING_LINE_FRAC
1428
+ # of a pitch, so a gutter that merely BLEEDS a little past the grid
1429
+ # (hcaptcha's V gutters reach the submit bar, ~0.65 cell) does NOT
1430
+ # invent a row. This is what lets the completed 4x4 beat the 3-row
1431
+ # subset without over-counting hcaptcha 3x3.
1432
+ hsorted = sorted(hpos); vsorted = sorted(vpos)
1433
+ row_top, row_bot = hsorted[0] - hd, hsorted[-1] + hd
1434
+ col_lft, col_rgt = vsorted[0] - vd, vsorted[-1] + vd
1435
+ vy = [_line_span_perp(l) for l in vlns] # V gutters' y-extent
1436
+ hx = [_line_span_perp(l) for l in hlns] # H gutters' x-extent
1437
+ vy_lo = float(np.median([s[0] for s in vy]))
1438
+ vy_hi = float(np.median([s[1] for s in vy]))
1439
+ hx_lo = float(np.median([s[0] for s in hx]))
1440
+ hx_hi = float(np.median([s[1] for s in hx]))
1441
+ # An overshoot only signals a MISSING row/col when it is a real
1442
+ # extra cell, not the gutter colour bleeding into a one-sided
1443
+ # margin / white footer / header bar. The bleed signature is
1444
+ # ASYMMETRY: the gutter runs to the image EDGE on the overshoot
1445
+ # side while its OTHER end stays well inside the image (e.g.
1446
+ # hcaptcha's white footer touches the bottom edge but the grid top
1447
+ # is inset). A grid that genuinely fills an axis reaches BOTH edges
1448
+ # symmetrically (its outer cells are real) — and then has no
1449
+ # overshoot to suppress anyway (its border sits at the edge). So we
1450
+ # suppress only a one-sided edge bleed. EDGE_BLEED_PX absorbs a
1451
+ # 1-2px crop border.
1452
+ v_top_edge = vy_lo <= EDGE_BLEED_PX
1453
+ v_bot_edge = vy_hi >= h - EDGE_BLEED_PX
1454
+ h_lft_edge = hx_lo <= EDGE_BLEED_PX
1455
+ h_rgt_edge = hx_hi >= w - EDGE_BLEED_PX
1456
+ def _missing(uncov, pitch, bleed):
1457
+ if bleed:
1458
+ return 0.0
1459
+ f = uncov / pitch
1460
+ return f if f >= MISSING_LINE_FRAC else 0.0
1461
+ span_pen = SPAN_FIT_PENALTY * (
1462
+ _missing(max(0.0, row_top - vy_lo), hd, v_top_edge and not v_bot_edge)
1463
+ + _missing(max(0.0, vy_hi - row_bot), hd, v_bot_edge and not v_top_edge)
1464
+ + _missing(max(0.0, col_lft - hx_lo), vd, h_lft_edge and not h_rgt_edge)
1465
+ + _missing(max(0.0, hx_hi - col_rgt), vd, h_rgt_edge and not h_lft_edge))
1466
+ score = (hsc + vsc + s_diff * 1000 + abs(slant) * 500
1467
+ + unused * 400 + ang_inc + span_pen)
1468
+ if score < best_score:
1469
+ boxes = _generate_grid(rows, cols, hpos, vpos, hd, vd, h, w, slant)
1470
+ # Cell-content gate IN the loop so a rejected (over-counted)
1471
+ # candidate lets a smaller valid one win, instead of killing
1472
+ # detection outright.
1473
+ if boxes and _cells_have_content(lab, boxes, rows, cols, hlns + vlns):
1474
+ best_score = score
1475
+ best = (boxes, rows, cols, slant, avg,
1476
+ sorted(hpos), hd, sorted(vpos), vd, hlns + vlns)
1477
+ if best is None:
1478
+ return None, None, None
1479
+ boxes, rows, cols, slant, gutter_color, hpos, hd, vpos, vd, chosen_lns = best
1480
+ # Is the CHOSEN grid built from clean painted gutters? If so the lattice is
1481
+ # already proven a real grid (a textured-photo FP has noisy pseudo-gutters,
1482
+ # std well above the threshold). For such a proven grid we do NOT count a few
1483
+ # stray CLEAN full-span lines (a sky horizon, a power line, a UI rule) as
1484
+ # off-lattice evidence — they are painted edges in the scene, not extra cell
1485
+ # boundaries. FPs keep the strict count because their own gutters are noisy,
1486
+ # so this relaxation never applies to them.
1487
+ grid_gutters_clean = (float(np.mean([l.color_std for l in chosen_lns]))
1488
+ < CLEAN_GUTTER_STD) if chosen_lns else False
1489
+ # Off-lattice gate (the main FP killer for textured photos): within the grid's
1490
+ # OWN span, a REAL grid has no extra same-colour lines that break the regular
1491
+ # cell spacing — every cell boundary sits ON the lattice (k*pitch from the
1492
+ # chosen internal lines). A textured photo (grass, foliage, fences) yields many
1493
+ # parallel same-colour edges scattered OFF the lattice. So count same-colour
1494
+ # lines that fall OFF the lattice; too many -> photo noise, not a grid.
1495
+ #
1496
+ # CRITICAL: only consider lines INSIDE the chosen internal-line span
1497
+ # (anchors[0]..anchors[-1]). The grid has no reliable outer border, so the
1498
+ # real grid's top edge / a UI footer bar can sit a non-pitch distance ABOVE
1499
+ # the first internal line or BELOW the last — those are frame/chrome, not
1500
+ # evidence the central cells are irregular, and must not count. The central
1501
+ # closed region is the only reliable signal (per design).
1502
+ def _off_lattice(lines, total, anchors, pitch):
1503
+ n = 0
1504
+ clean_skipped = 0
1505
+ lo, hi = anchors[0], anchors[-1]
1506
+ for l in _internal(lines, total):
1507
+ if not (lo - LATTICE_TOL * pitch <= l.midline_pos <= hi + LATTICE_TOL * pitch):
1508
+ continue # outside the central span — frame/chrome
1509
+ if _de(l.color_lab, gutter_color) > GRID_COLOR_TOL:
1510
+ continue # different colour — not a gutter
1511
+ # distance to the nearest lattice node (anchor + k*pitch)
1512
+ off = min(abs((l.midline_pos - anchors[0]) - round((l.midline_pos - anchors[0]) / pitch) * pitch),
1513
+ abs((l.midline_pos - anchors[-1]) - round((l.midline_pos - anchors[-1]) / pitch) * pitch))
1514
+ if off > LATTICE_TOL * pitch:
1515
+ # On a proven clean grid we forgive a FEW stray CLEAN full-span lines
1516
+ # (a sky horizon, a power line, a UI rule sitting off the lattice) —
1517
+ # but only up to MAX_OFF_LATTICE_CLEAN of them. A textured photo whose
1518
+ # white-ish edges happen to be clean produces MANY such strays; once
1519
+ # they exceed the small allowance we count the rest, so the off-lattice
1520
+ # FP gate still fires on texture. Noisy strays always count.
1521
+ if (grid_gutters_clean and l.color_std < OFF_LATTICE_CLEAN_STD
1522
+ and clean_skipped < MAX_OFF_LATTICE_CLEAN):
1523
+ clean_skipped += 1
1524
+ continue
1525
+ n += 1
1526
+ return n
1527
+ if (_off_lattice(h_lines, h, hpos, hd) > MAX_OFF_LATTICE
1528
+ or _off_lattice(v_lines, w, vpos, vd) > MAX_OFF_LATTICE):
1529
+ return None, None, None
1530
+ return boxes, (rows, cols), slant
1531
+
1532
+
1533
+ def _detect_grid(image_path, seed_bias=0.0):
1534
+ img = cv2.imread(image_path)
1535
+ if img is None:
1536
+ return None
1537
+ h, w = img.shape[:2]
1538
+ lab = _to_lab(img)
1539
+ h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias)
1540
+ if len(_internal(h_lines, h)) < 2:
1541
+ return None
1542
+ v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias)
1543
+ if len(_internal(v_lines, w)) < 2:
1544
+ return None
1545
+ boxes, dims, slant = extract_grid_from_lines(h_lines, v_lines, h, w, lab=lab)
1546
+ return boxes
1547
+
1548
+
1549
+ def find_grid(image_path: str, debug_manager=None, slant_to_try: Optional[float] = None) -> Optional[List[Tuple[int, int, int, int]]]:
1550
+ """Main entry point for grid detection (public contract unchanged).
1551
+
1552
+ Detects a 3x3 or 4x4 grid by tracing consistent-colour separator lines of any
1553
+ border colour and small tilt. `slant_to_try` is accepted for backward
1554
+ compatibility; the tracer recovers slant on its own so it is used only as a
1555
+ seed bias hint. Returns row-major cell boxes, or None.
1556
+ """
1557
+ seed_bias = float(slant_to_try) if slant_to_try is not None else 0.0
1558
+ boxes = _detect_grid(image_path, seed_bias=seed_bias)
1559
+ if debug_manager and getattr(debug_manager, 'enabled', False) and boxes:
1560
+ image_basename = os.path.basename(image_path)
1561
+ debug_path = os.path.join(str(getattr(debug_manager, 'base_dir', ".")),
1562
+ f"grid_final_{image_basename}")
1563
+ try:
1564
+ get_numbered_grid_overlay(image_path, boxes, output_path=debug_path)
1565
+ except Exception:
1566
+ pass
1567
+ return boxes
1568
+
1569
+
1570
+ def detect_selected_cells(image_path, grid_boxes, debug_manager=None):
1571
+ """
1572
+ Checks each grid box to see if it contains a 'selected' badge (blue checkmark)
1573
+ or a 'loading' spinner.
1574
+
1575
+ - reCAPTCHA puts a small blue badge in the **top-left** of the tile.
1576
+ - hCaptcha puts a blue circle-with-check overlay in the **top-right** AND
1577
+ darkens the entire tile.
1578
+
1579
+ Returns (list of selected indices, list of loading indices).
1580
+ """
1581
+ img = cv2.imread(image_path)
1582
+ if img is None: return [], []
1583
+ sel, ld = [], []
1584
+ # OpenCV uses BGR not RGB, so these are swapped from the doc colors.
1585
+ recap_blue = (27, 115, 232) # reCAPTCHA blue badge (#1B73E8)
1586
+ hcap_blue = (188, 117, 15) # hCaptcha blue check (#0F75BC) in BGR
1587
+ for i, box in enumerate(grid_boxes):
1588
+ cell = img[box[1]:box[3], box[0]:box[2]]
1589
+ if cell.size == 0: continue
1590
+
1591
+ h_cell, w_cell = cell.shape[:2]
1592
+ # Top-left (reCAPTCHA).
1593
+ tl = cell[0:int(h_cell * 0.4), 0:int(w_cell * 0.4)]
1594
+ if tl.size > 0 and _has_badge(tl, recap_blue):
1595
+ sel.append(i + 1)
1596
+ continue
1597
+ # Top-right (hCaptcha). The badge is a small filled blue circle
1598
+ # (~10-14 px); _has_badge's circularity check is too strict at that
1599
+ # size, so we use a simple color-presence test: "is there a strongly
1600
+ # blue-dominant cluster in the top-right corner?"
1601
+ tr = cell[0:max(8, int(h_cell * 0.22)), int(w_cell * 0.78):]
1602
+ if tr.size > 0 and _has_hcaptcha_check(tr):
1603
+ sel.append(i + 1)
1604
+ continue
1605
+
1606
+ # Center for loading spinner.
1607
+ cntr = cell[int(h_cell * 0.3):int(h_cell * 0.7), int(w_cell * 0.3):int(w_cell * 0.7)]
1608
+ if cntr.size > 0 and _is_loading(cntr, recap_blue):
1609
+ ld.append(i + 1)
1610
+ return sel, ld
1611
+
1612
+ # --- per-cell state helpers ---
1613
+ # These take a 1-indexed `cell_number` (matching detect_selected_cells and the
1614
+ # grid_boxes[v - 1] click mapping in solver.py) and read pixel values from the
1615
+ # cropped cell. All guard against a missing image, empty crop, or out-of-range
1616
+ # index and return a safe default rather than raising.
1617
+
1618
+ def _crop_cell(image_path, grid_boxes, cell_number):
1619
+ """Load image and return the BGR crop for a 1-indexed cell, or None."""
1620
+ img = cv2.imread(image_path)
1621
+ if img is None:
1622
+ return None
1623
+ if cell_number < 1 or cell_number > len(grid_boxes):
1624
+ return None
1625
+ x1, y1, x2, y2 = grid_boxes[cell_number - 1]
1626
+ cell = img[y1:y2, x1:x2]
1627
+ return cell if cell.size else None
1628
+
1629
+ def is_empty_cell(image_path, grid_boxes, cell_number,
1630
+ white_frac=0.97, l_thresh=92.0, chroma_thresh=6.0):
1631
+ """True if the cell is effectively blank: an overwhelming majority of
1632
+ pixels are near-white AND near-neutral (low chroma). Uses LAB to match the
1633
+ grid-line whiteness test used elsewhere in this module, so a faintly tinted
1634
+ "white" still counts while a saturated bright tile (e.g. sky) does not.
1635
+ 1-indexed cell."""
1636
+ cell = _crop_cell(image_path, grid_boxes, cell_number)
1637
+ if cell is None:
1638
+ return False
1639
+ lab = cv2.cvtColor(cell, cv2.COLOR_BGR2LAB).astype(np.float32)
1640
+ L = lab[:, :, 0] * (100.0 / 255.0) # OpenCV packs L into 0..255
1641
+ a = lab[:, :, 1] - 128.0
1642
+ b = lab[:, :, 2] - 128.0
1643
+ chroma = np.sqrt(a * a + b * b)
1644
+ white = (L > l_thresh) & (chroma < chroma_thresh)
1645
+ return float(white.mean()) >= white_frac
1646
+
1647
+ def is_cell_opacity_changing(image_path_a, image_path_b, grid_boxes,
1648
+ cell_number, change_thresh=0.02):
1649
+ """True if the cell visibly changed between two frames (still fading/loading).
1650
+ Mirrors the absdiff -> gray -> threshold -> ratio approach used by
1651
+ check-movement. 1-indexed cell. Returns False if either crop is
1652
+ unavailable or the crops differ in shape."""
1653
+ a = _crop_cell(image_path_a, grid_boxes, cell_number)
1654
+ b = _crop_cell(image_path_b, grid_boxes, cell_number)
1655
+ if a is None or b is None or a.shape != b.shape:
1656
+ return False
1657
+ diff = cv2.absdiff(a, b)
1658
+ gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
1659
+ _, thr = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
1660
+ ratio = cv2.countNonZero(thr) / (thr.shape[0] * thr.shape[1])
1661
+ return ratio > change_thresh
1662
+
1663
+ def wait_for_cell_loaded(frame_paths, grid_boxes, cell_number):
1664
+ """Given >=1 chronological frame screenshots, return True once the cell is
1665
+ loaded: NOT empty in the latest frame AND (if >=2 frames) NOT changing
1666
+ between the last two. Composes is_empty_cell + is_cell_opacity_changing.
1667
+
1668
+ The CLI does not own the browser loop, so this can't do a time-based wait;
1669
+ the JS caller captures frames over time and passes the most recent ones.
1670
+ The grid_boxes must come from a single reference frame. 1-indexed cell."""
1671
+ if not frame_paths:
1672
+ return False
1673
+ last = frame_paths[-1]
1674
+ if is_empty_cell(last, grid_boxes, cell_number):
1675
+ return False
1676
+ if len(frame_paths) >= 2:
1677
+ if is_cell_opacity_changing(frame_paths[-2], last, grid_boxes, cell_number):
1678
+ return False
1679
+ return True
1680
+
1681
+ def is_cell_selected(image_path, grid_boxes, cell_number, debug_manager=None):
1682
+ """True if the given 1-indexed cell is selected. Thin wrapper over
1683
+ detect_selected_cells (the canonical badge detector) for API symmetry."""
1684
+ selected, _ = detect_selected_cells(image_path, grid_boxes, debug_manager)
1685
+ return cell_number in selected
1686
+
1687
+ def _has_hcaptcha_check(roi):
1688
+ """Detect hCaptcha's selected-state blue circle in the top-right corner.
1689
+
1690
+ The badge is a small filled cyan-teal circle (~10-14 px) with a white
1691
+ checkmark glyph inside. Naively counting blue-dominant pixels matches
1692
+ sky tiles, so we require BOTH:
1693
+ - >=8 cyan-teal pixels (B high, G mid-high, R low)
1694
+ - >=2 near-white pixels (the checkmark) in the same patch
1695
+ """
1696
+ if roi is None or roi.size == 0:
1697
+ return False
1698
+ flat = roi.reshape(-1, 3).astype(np.int32)
1699
+ # Teal/cyan: B>120, G>80, R<80, AND B-R gap > 60.
1700
+ teal = (
1701
+ (flat[:, 0] > 120)
1702
+ & (flat[:, 1] > 80)
1703
+ & (flat[:, 2] < 80)
1704
+ & (flat[:, 0] - flat[:, 2] > 60)
1705
+ )
1706
+ # Bright white check mark inside the circle.
1707
+ white = (flat[:, 0] > 220) & (flat[:, 1] > 220) & (flat[:, 2] > 220)
1708
+ return int(teal.sum()) >= 8 and int(white.sum()) >= 2
1709
+
1710
+
1711
+ def _has_badge(roi, rgb):
1712
+ """
1713
+ Uses color segmentation and shape analysis to detect the reCAPTCHA
1714
+ selection badge (a blue circle/checkmark).
1715
+ """
1716
+ mask = _create_delta_e_mask(roi, rgb, 8.0)
1717
+ # Check if there's enough blue color
1718
+ if cv2.countNonZero(mask) <= roi.size * 0.003: return False
1719
+
1720
+ # Shape analysis: look for a circular-ish contour
1721
+ contours, _ = cv2.findContours(cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8)), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1722
+ if not contours: return False
1723
+ cnt = max(contours, key=cv2.contourArea)
1724
+ hull = cv2.convexHull(cnt)
1725
+ area, perim = cv2.contourArea(hull), cv2.arcLength(hull, True)
1726
+
1727
+ # Circularity check
1728
+ if perim == 0 or (4*np.pi*area/(perim*perim)) < 0.8: return False
1729
+
1730
+ # Position check: badge should be in the top-left portion of its ROI
1731
+ M = cv2.moments(hull)
1732
+ 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
1733
+ return True
1734
+
1735
+ def _is_loading(roi, rgb):
1736
+ """
1737
+ Detects if a cell is in a 'loading' state based on the amount
1738
+ of blue color in the center.
1739
+ """
1740
+ mask = _create_delta_e_mask(roi, rgb, 12.0)
1741
+ # Loading state usually has a specific range of blue pixels
1742
+ return 0.05 < (cv2.countNonZero(mask) / (roi.shape[0]*roi.shape[1])) < 0.6 if roi.size > 0 else False
1743
+
1744
+ def _create_delta_e_mask(img, rgb, thr):
1745
+ """
1746
+ Creates a binary mask of pixels that are within a certain
1747
+ perceptual distance (Delta E) from the target RGB color.
1748
+ """
1749
+ t_lab = cv2.cvtColor(np.array([[[rgb[2], rgb[1], rgb[0]]]], dtype=np.uint8), cv2.COLOR_BGR2LAB)[0, 0]
1750
+ diff = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) - t_lab.astype(np.float32)
1751
+ return (np.sqrt(np.sum(diff**2, axis=2)) <= thr).astype(np.uint8)*255
1752
+
1753
+ def get_numbered_grid_overlay(image_path, grid_boxes, output_path=None):
1754
+ """
1755
+ Generates a debug image with numbered boxes overlaid on the original image.
1756
+ Uses high-visibility red labels with white text in the top-right.
1757
+ """
1758
+ 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)]
1759
+ if output_path is None:
1760
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf: output_path = tf.name
1761
+ add_overlays_to_image(image_path, ov, output_path=output_path, label_position="top-right")
1762
+ return output_path