captchakraken 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +94 -712
  28. package/dist/solver.js +923 -3362
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +75 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1052 -3098
  60. package/python/src/captchakraken/planner.py +50 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -4,314 +4,109 @@ import os
4
4
  import tempfile
5
5
  from dataclasses import dataclass
6
6
  from typing import List, Tuple, Optional
7
+ from ..kinds import LabelPosition
7
8
  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
- # SECOND CUE — the colour comb (_comb_lines) + the seal test (_seal_fraction).
23
- # The tracer is a LOCAL walk, so it fails on the one board captcha vendors draw
24
- # often: a gutter whose neighbours are nearly its own colour. It cannot tell the
25
- # two apart pixel by pixel, every neighbour seeds a trace of its own, and the
26
- # cluster average lands off the separator. The comb asks the complementary GLOBAL
27
- # question — is this whole straight line the gutter colour, end to end? — and the
28
- # seal asks it of a candidate LATTICE: does every separator run the full width of
29
- # the grid it implies, so the cells are closed boxes and the rectangle really does
30
- # repeat? That is positive, content-independent proof of a grid, which is what
31
- # lets the content gates stand down (SEALED_DIVERGE_TOL) on a 4x4 of open sky
32
- # whose tiles are photographs of nothing. Both only ever look for a colour the
33
- # TRACER already proved is painted here, so neither can conjure a lattice out of a
34
- # flat image. Measured over the 1423 real grid captures: 15 misses -> 3, with
35
- # end-to-end false positives (find_grid + solver._is_real_grid) 23 -> 16.
36
- # =============================================================================
37
-
38
-
39
- # ── Tuning constants ────────────────────────────────────────────────────────
40
- COLOR_TOL = 10.0 # LAB ΔE: same-color test for ridge build + perp thickness
41
- CONT_TOL = 14.0 # LAB ΔE: LOCAL continuation test vs the running line mean
42
- # — tolerates JPEG / anti-alias / lighting jitter along a
43
- # real gutter. A real tile edge (ΔE >> this) ends the line.
44
- CONT_TOL2 = CONT_TOL * CONT_TOL # squared, for the hot continuation test
45
- SEED_L_TOL = 6.0 # LAB L: max deviation of ANY line pixel's LIGHTNESS from
46
- # the seed's. L is the stable, perceptually-dominant signal:
47
- # a real gutter's L span is ~3 across its length, while a
48
- # shaded surface (grass/water) drifts ~32. We gate on L (not
49
- # full ΔE) because a/b are JPEG chroma noise — visually
50
- # irrelevant at gutter lightness, but CIE76 ΔE over-weights
51
- # them and wrongly dropped real tinted-grey gutters.
52
- STEP_L_TOL = 4.0 # LAB L: max lightness change between CONSECUTIVE pixels.
53
- # A gutter never jumps (consecutive |ΔL| <= ~2); grass
54
- # jumps up to ~16. Stops a line crossing a tile edge whose
55
- # far side happens to share the seed's lightness band.
56
- MIN_RUN = 10 # px: min consecutive same-color pixels to seed a line
57
- MAX_THICKNESS = 14 # px: a separator thicker than this is a band, not a line
58
- # (no longer a hard reject in the perp walk — see PERP_SCAN)
59
- PERP_SCAN = 60 # px: half-width of the perpendicular strip the thickness
60
- # walk searches. Wide enough to reach the boundary when a
61
- # gutter is fused with same-colour tile content, instead of
62
- # truncating at +/-MAX_THICKNESS and rejecting the gutter.
63
- PERP_REFIND = 3 # px: when the along-walk hits a cell, look this far
64
- # perpendicular (up then down) for the gutter colour. Found
65
- # -> the gutter slanted away, step there and continue. Not
66
- # found -> a genuine wall, stop. (Replaces thickness/midpoint
67
- # band re-centering entirely.) ~3px tolerates ~25deg tilt.
68
- NOISE_GAIN = 2.0 # how far the walk tolerances widen per unit of measured
69
- # image noise (see image_noise / walk_tolerances). 0 restores
70
- # the old fixed thresholds exactly.
71
- MAX_PERP_JUMP = 6.0 # px: max perpendicular re-center per save (bounds drift)
72
- MERGE_PX = 8.0 # px: cluster lines whose midline positions are this close
73
- MERGE_ANGLE = 0.07 # rad (~4°): and whose angles are this close
74
- MIN_CELL = 36 # px: minimum cell pitch (cells have a minimum size)
75
- FLANK_PITCH_FRACS = (0.18, 0.26, 0.34, 0.42) # probe distances as a fraction of the CELL
76
- # PITCH, not absolute px. hcaptcha's gutters are ~13px median and can
77
- # run far wider, so any fixed offset lands INSIDE the gutter and
78
- # compares gutter to gutter; scaling to the pitch always reaches cell
79
- # content whatever the provider's gutter width.
80
- GRID_FLANK_MIN_DE = 16.0 # LAB ΔE: median flank contrast the CHOSEN separators must show —
81
- # does this lattice actually SEPARATE anything? Two things this gate
82
- # got wrong first, both measured: (1) as a per-LINE filter it deletes
83
- # the strays the off-lattice gate counts and ADDS false positives, so
84
- # it runs on the chosen grid only; (2) averaging the two sides beats
85
- # taking the weaker one — `min` punishes a real gutter with one pale
86
- # neighbour, which is most of hcaptcha (lowest true grid 2.7 under
87
- # `min` vs 17.4 under mean, against false positives at 9.0/7.2 and
88
- # 18.0/14.1). At 16.0 a false positive goes with no target, guard or
89
- # fixture cost; 18.1 clears both but sits 0.3 under the lowest real
90
- # reCAPTCHA 4x4, which is fitting to samples rather than separating.
91
- LINE_STD_TOL = 7.0 # LAB: max std of color along a line (consistency gate)
92
- STEP = 1 # px: walk one pixel at a time (cheap: just a color test
93
- # per step; perpendicular thickness only computed on save)
94
- SLANT_CAP = 0.47 # tan(~25°): reject lines tilted beyond the stated max
95
- SUPPORT_FRAC = 0.42 # traced length must be >= this fraction of the span
96
- TERM_FRAC = 0.30 # >= this fraction of traced points must terminate both sides
97
- MAX_SEED_FRAC = 0.34 # seed rows/cols only within +/- this fraction of center.
98
- # A grid gutter spans the FULL image, so it is hit by seeds
99
- # from a central band; the trace then walks outward to the
100
- # full extent on its own. Narrowing the band (from 0.46) skips
101
- # the content seeds near the top/bottom margins that almost
102
- # always die, cutting per-image trace attempts ~25% with no
103
- # loss of detection (every gutter still seeded many times for
104
- # the cluster-average).
105
- EDGE_MARGIN = 0.02 # drop lines within this fraction of an image edge
106
- # Stage-C structural gates (the primary negative-rejection — a real grid's
107
- # internal separators are one colour, one thickness, one coherent tilt):
108
- GRID_COLOR_TOL = 8.0 # LAB ΔE: max spread of chosen separators' colours
109
- GRID_THICK_TOL = 4.0 # px: max thickness spread across chosen separators
110
- GRID_ANGLE_TOL = 0.09 # rad (~5°): H tilt must ≈ -V tilt for a coherent grid
111
- XAXIS_COLOR_TOL = 6.0 # LAB ΔE: H-mean gutter colour must ≈ V-mean (real grids
112
- # share ONE gutter colour across both axes; photo "grids"
113
- # pair an H edge of one colour with a V edge of another)
114
- LATTICE_TOL = 0.18 # a same-colour line within this fraction of a pitch of a
115
- # lattice node counts as on-grid (a border line), not noise
116
- MAX_OFF_LATTICE = 1 # max same-colour internal lines allowed OFF the lattice
117
- # before the structure is judged photo texture, not a grid
118
- EVEN_TOL = 0.18 # consecutive cell gaps must match the median pitch within
119
- # this fraction (the equidistant-cells rule), checked on the
120
- # candidate LINE positions before the lattice is built
121
- MIN_IMAGE_AREA_COVERAGE = 0.30 # frac of the image AREA the detected grid must
122
- # occupy. Distinct from MIN_GRID_COVERAGE below, which is a
123
- # per-axis pitch ratio and currently unused. A
124
- # captcha grid IS the puzzle, so it fills most of the widget;
125
- # a lattice found in a corner is scenery. Measured over 2239
126
- # correctly-detected real grids the floor is 0.348 (hcaptcha's
127
- # low tail; recaptcha sits at 0.62+), so 0.30 keeps a ~14%
128
- # margin. Deliberately NOT set to 0.33 — that would also catch
129
- # an observed video-keyframe false positive at 0.322, but
130
- # leaves only 5% headroom under real grids, which is buying a
131
- # false-positive fix with a future missed grid.
132
- CELL_REGULARITY_TOL = 0.12 # the same rule applied to the EMITTED cells: every cell's
133
- # width/height must be within this fraction of the median. The
134
- # line-position check above cannot see degenerate boxes built
135
- # from near-duplicate lines, which is how a 1px-pitch "grid"
136
- # reached the caller. See _boxes_are_regular.
137
- MIN_GRID_DIM = 3 # min cells per axis (currently >=3x3; rectangles like 6x4
138
- # are allowed — rows and cols may differ, each >= this)
139
- CORROB_FRAC = 0.9 # frac of a cell a perpendicular line must extend PAST a
140
- # candidate line (on both sides) to corroborate it as a real
141
- # internal separator. Set near 1.0 (a FULL cell): a true
142
- # internal line has a real cell — so the perpendicular gutters
143
- # run ~a full cell — beyond it on each side. A frame/edge line
144
- # fails: the perpendicular gutters reach only the grid border,
145
- # LESS than a cell past it (no cell beyond the edge). This is
146
- # what drops both the inset-V and full-width-H frame lines.
147
- # Always treat the grid as OPEN (extrapolate outer cells).
148
- GRID_OVERSHOOT = 0.35 # frac of pitch: the grid extrapolated one cell past each
149
- # outer INTERNAL line may exceed the image edge by at most
150
- # this much. Real grids bleed to the edge (no reliable outer
151
- # border), so a small overshoot is expected; a large one
152
- # means the chosen internal lines don't actually frame a grid.
153
- FULL_SPAN_MARGIN = 0.5 # frac of pitch: every chosen lattice line must span at
154
- # least (N - FULL_SPAN_MARGIN)*pitch end-to-end, i.e. cross
155
- # essentially the WHOLE grid (all N cells), allowing only a
156
- # half-cell fade/occlusion at the ends. Rejects short central
157
- # edges (e.g. an object in a reference photo) masquerading as
158
- # grid lines — the whole lattice must be present, not just the
159
- # central closed region.
160
- MIN_GRID_COVERAGE = 0.72 # frac: the grid's total extent (N*pitch) must cover at
161
- # least this fraction of the image on each axis. A real
162
- # captcha grid FILLS the frame (3x3 pitch ~ img/3 -> coverage
163
- # ~1.0); the main FP mode is two object-edges crammed near the
164
- # centre (small pitch -> the implied grid is a small central
165
- # patch). This single ratio kills that whole class.
166
- CELL_INSET = 0.22 # frac of a cell's side to trim off each edge before
167
- # sampling the cell INTERIOR (keeps gutter pixels out of the
168
- # cell-content mean).
169
- CELL_DIVERGE_TOL = 12.0 # LAB ΔE: a cell's interior mean must differ from the
170
- # gutter colour by at least this much to count as a real
171
- # (content-filled) cell. Real captcha cells hold photo/object
172
- # content -> large ΔE; a flat region (white wall, watermark
173
- # haze, sky) has cells the same colour as its "gutters".
174
- CELL_DIVERGE_FRAC = 0.6 # frac of cells that must diverge (>CELL_DIVERGE_TOL) from
175
- # the gutter. A real grid: most cells are filled. A flat-image
176
- # FP: few/none diverge. Robust to a couple of genuinely light
177
- # cells (e.g. an all-sky tile) in an otherwise real grid.
178
- CLEAN_GUTTER_STD = 2.3 # LAB: mean color_std of the chosen separators below which
179
- # the gutters are judged PAINTED (a real captcha grid), not
180
- # texture. Measured: real-grid gutters have color_std ~0-2
181
- # (a uniform drawn line); textured-photo pseudo-gutters
182
- # (grass/foliage/tessellated bg) run ~3-6. This is a content-
183
- # INDEPENDENT grid signal, so when it holds we can trust the
184
- # lattice even if several cells are pale (sky / faint sketches).
185
- CELL_DIVERGE_FRAC_CLEAN = 0.42 # relaxed content frac used ONLY when the gutters are
186
- # clean (< CLEAN_GUTTER_STD). A painted-gutter grid with sky /
187
- # pale-sketch tiles legitimately has fewer content-bearing
188
- # cells; the clean gutter already proves it is a grid, so we
189
- # do not also demand a content majority. FPs keep the strict
190
- # 0.6 frac because their pseudo-gutters are noisy (std >= 2.8),
191
- # well above CLEAN_GUTTER_STD — they never qualify for relaxation.
192
- SEED_RUN_FRAC = 0.5 # frac of the along-scan width a seed's ridge run must
193
- # cover to be walked. A grid gutter spans the scan (>=0.88
194
- # measured); content fragments are short (p90 ~0.35). This
195
- # pre-filter drops ~95% of dead seeds (which were ~73% of all
196
- # walk steps) before the walk, with no loss of real gutters.
197
- SEED_DECIMATE = 2 # seed every 2nd row/col: robust multi-position seeding
198
- # within a run still catches 2px-tall gutters, at ~half cost
199
- # Lattice completion (recovers grids whose internal gutter between two same-colour
200
- # tiles — sky/horizon — could not be traced, so a row/col is missing). Only fires
201
- # on CLEAN painted anchors; the content gate still decides FPs.
202
- CLEAN_LATTICE_STD = 2.0 # LAB: an anchor line used for lattice completion must be
203
- # at least this uniform. Real painted gutters read std ~0-1.5;
204
- # textured photo edges run >= 2.8, so they never anchor a
205
- # completed lattice (matches CLEAN_GUTTER_STD's intent).
206
- MAX_VIRTUAL_FRAC = 0.5 # at most this fraction of a completed lattice's internal
207
- # nodes may be EXTRAPOLATED/INTERPOLATED (the rest must be real
208
- # clean lines). A 4-cell grid (3 internal lines) may invent 1;
209
- # we never reconstruct a grid that is mostly invented.
210
- MAX_VIRTUAL_NODES = 2 # hard cap on invented internal lines per completed run.
211
- # Recovers a single missing internal gutter (the common
212
- # sky-bordered-row case) and at most one outer-line extension;
213
- # prevents conjuring a whole grid from a 2-line fragment.
214
- UNUSED_LINE_PENALTY = 400.0 # score added per corroborated internal line the
215
- # candidate does NOT incorporate, so a 4x4's
216
- # [r1,r2,r3] is not silently dropped to a 3-row [r2,r3].
217
- VIRTUAL_NODE_PENALTY = 600.0 # score added per invented node, so a fully-real
218
- # lattice of the same dimension always outranks a completed
219
- # one and fewer interpolations win.
220
- SPAN_FIT_PENALTY = 900.0 # score added per WHOLE UNCOVERED CELL the perpendicular
221
- # gutters run past a grid border (see the grid-span-fit block).
222
- # Outweighs the virtual-node penalty so a completed 4x4 beats
223
- # the 3-row subset whose gutters run a cell past the border.
224
- MISSING_LINE_FRAC = 0.8 # frac of a pitch: a gutter must overshoot a grid border by
225
- # at least this much to count as an uncovered (missing) row/col.
226
- # A real grid's gutters can BLEED a little past the frame (into
227
- # a submit bar / header — hcaptcha overshoots ~0.65 cell) without
228
- # implying another cell; only a near-full-cell overshoot does.
229
- EDGE_BLEED_PX = 6 # px: if a gutter's traced span reaches this close to the image
230
- # edge, an overshoot on that side is gutter-colour bleeding into
231
- # a margin / white footer / header (which touches the edge), not
232
- # a real extra cell — so it does NOT trigger a missing row/col.
233
- # Real captcha grids are inset from the edges (the gutters stop
234
- # ~60-120px short), so this never suppresses a true missing line.
235
- OFF_LATTICE_CLUSTER_PX = 20 # px: off-lattice lines closer together than this are
236
- # ONE object's edges (a roofline, a railing), not
237
- # distinct cell boundaries, so they count once. Measured
238
- # plateau is 17..23 on the real corpus: below it the two
239
- # busy-tile 4x4s are lost, at 24 a textured drag puzzle
240
- # becomes a false positive.
241
- MAX_OFF_LATTICE_CLEAN = 1 # max stray CLEAN off-lattice lines forgiven on a proven
242
- # clean grid (a horizon / wire / UI rule). Beyond this the
243
- # strays are counted — a textured photo whose white-ish edges
244
- # are clean produces several, so the off-lattice FP gate still
245
- # fires; a real sky-bordered grid has at most one or two.
246
- # The colour comb (second detection cue — see _comb_lines):
247
- COMB_TOL = 3.0 # LAB ΔE: every pixel of a comb line must be this close to the
248
- # gutter colour. TIGHT on purpose, because this is the whole
249
- # discriminator: on the failing 4x4s the gutter is pure white
250
- # (L 100.0) against sky at L 95-97, so ~4 upward re-admits the
251
- # very neighbours the tracer already drowned in, while real
252
- # gutters read 0-1 along their entire length.
253
- COMB_COVER = 0.9 # frac of the central scan a comb line must match. Below 1.0
254
- # because a V gutter's scan band runs THROUGH the vendor's
255
- # header/footer chrome — the grid does not fill the widget
256
- # vertically — which costs a true gutter ~8% of its scan.
257
- # A non-gutter line scores ~0 at COMB_TOL, so the gate sits in
258
- # an empty gap rather than on a measured edge.
259
- COMB_GAP = 8 # px: gaps this small are closed before a comb line's extent is
260
- # measured. COMB_COVER already admits a line that misses a
261
- # tenth of the scan, so the extent must tolerate the same
262
- # misses: taking the strictly CONTIGUOUS run instead reported a
263
- # full-width 520px gutter as a 43px stub (JPEG ringing where
264
- # tile content meets the gutter) and killed it on the full-span
265
- # gate — a line the tracer had read correctly before.
266
- SEALED_DIVERGE_TOL = 2.0 # LAB ΔE: the cell-content bar for a lattice EVERY separator
267
- # of which is sealed (see _seal_fraction). A sealed lattice is
268
- # already proven structurally — a repeated rectangle closed on
269
- # all sides by one painted colour — so the content gate is no
270
- # longer carrying the proof and only has to rule out the flat
271
- # image it exists for. It is deliberately NOT zero: a blank
272
- # canvas seals every lattice you can draw on it and its cells
273
- # read EXACTLY 0.0, while a 4x4 of open sky reads 2.3-11.2
274
- # against a pure-white gutter. Sky is the whole point — those
275
- # tiles are real photographs of nothing, and at the standard
276
- # 12.0 they scored 1/16 and the grid was thrown away.
277
- SEALED_FLANK_MIN_DE = 6.0 # LAB ΔE: the flank bar under the same proof. It asks the
278
- # same question as GRID_FLANK_MIN_DE — do these separators
279
- # divide anything? — of a lattice that has already answered it
280
- # a stronger way, so it drops to a floor a uniform region still
281
- # cannot clear. Real 4x4s over unbroken SKY measure 6.x-7.1,
282
- # because both flanks of every gutter are the same sky; at 8.0
283
- # two of them stayed undetected.
284
- #
285
- # What this admits, and why it is still the right number:
286
- # geetest_v4_svg is line art, so its drawn strokes ARE sealed
287
- # lattices, and 86 of them start returning a grid here (0 at
288
- # 7.0+). They cost nothing. Measured end to end — find_grid
289
- # followed by solver._is_real_grid, which is the only path a
290
- # detection can reach a caller through — every one is rejected
291
- # as a sprite board, by 0.31 against a bar of 6.0. False
292
- # positives that actually REACH _solve_grid are 16 at 6.0, 7.0
293
- # and 8.0 alike, against 23 before this cue existed. The
294
- # threshold is therefore free on the FP side and only decides
295
- # how many real grids are found, so it is set where the real
296
- # grids are.
297
- GRID_SEAL_MIN = 0.85 # frac of a chosen separator's length, ACROSS THE CANDIDATE
298
- # GRID'S OWN EXTENT, that must be the gutter colour for the
299
- # cells it borders to count as sealed. See _seal_fraction.
300
- UNSEALED_PENALTY = 700.0 # score added per chosen separator that fails GRID_SEAL_MIN.
301
- # Set ABOVE UNUSED_LINE_PENALTY: leaving a real internal line
302
- # out costs 400, so without this a candidate that swallowed a
303
- # half-width sky belt to avoid that charge outscored the true
304
- # lattice. Sealing is the stronger evidence and must outrank it.
305
- COMB_MAX_THICK = MAX_THICKNESS # px: a matching block wider than this is a BAND (a
306
- # white footer, a sky belt, a blank margin), not a separator.
307
- # Measured: real comb blocks are 2-12px, chrome bands 19-83.
308
- OFF_LATTICE_CLEAN_STD = 2.6 # in the off-lattice FP gate, a same-colour internal
309
- # line this clean that lies OFF the chosen pitch is treated as
310
- # a stray painted edge (sky horizon / UI rule), not proof the
311
- # cells are irregular: it is NOT counted. Noisy pseudo-gutters
312
- # (std above this) still count, so the textured-photo FP gate
313
- # is unchanged. Lets a correct lattice survive a couple of
314
- # spurious clean full-span lines (horizon, power line).
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
315
110
 
316
111
 
317
112
  @dataclass
@@ -333,30 +128,17 @@ def _de(a, b):
333
128
 
334
129
 
335
130
  def _de2(a, b):
336
- """Squared LAB distance — avoids the sqrt in hot comparison loops."""
337
131
  d0 = a[0] - b[0]; d1 = a[1] - b[1]; d2 = a[2] - b[2]
338
132
  return d0 * d0 + d1 * d1 + d2 * d2
339
133
 
340
134
 
341
135
  def _line_extent(line):
342
- """Endpoint-to-endpoint span of a fitted line along its PRIMARY axis (the
343
- direction it runs): H lines -> |end.x - start.x|, V lines -> |end.y - start.y|.
344
- This is the fit extent (how far the gutter was actually traced across the
345
- image), used to require that each lattice line spans the WHOLE grid, not just
346
- the central cell — a short edge near the centre (e.g. an object inside a
347
- reference photo) is not a grid line."""
348
136
  if line.orientation == 'h':
349
137
  return abs(line.end[0] - line.start[0])
350
138
  return abs(line.end[1] - line.start[1])
351
139
 
352
140
 
353
141
  def _cell_divergences(lab, boxes, gutter_color):
354
- """For each cell box, mean LAB of its INTERIOR (shrunk inward by CELL_INSET so
355
- we don't sample the gutter pixels on the cell edges), and its ΔE from the
356
- gutter colour. Returns a list of per-cell ΔE. A REAL grid's cells are filled
357
- with photo/object content distinct from the gutter -> large divergence; a flat
358
- or near-uniform region (white wall, watermark haze, sky) yields cells the SAME
359
- colour as the 'gutters' -> tiny divergence (there are no real cells at all)."""
360
142
  h, w = lab.shape[:2]
361
143
  out = []
362
144
  for (x1, y1, x2, y2) in boxes:
@@ -374,35 +156,20 @@ def _cell_divergences(lab, boxes, gutter_color):
374
156
 
375
157
 
376
158
  def _cells_have_content(lab, boxes, rows, cols, gutter_lines, sealed=False):
377
- """True if a MAJORITY of cells diverge from the gutter colour (real content,
378
- not a flat region). This is the FP killer. We do NOT require every row/column
379
- to have content: a legitimate grid can have an extrapolated OUTER row/column
380
- that lands on page background (e.g. a reCAPTCHA 4x4 whose top row reaches above
381
- the photo into the header) — that is expected, not a reason to reject. Dimension
382
- is decided by line corroboration + the unused-lines preference, not by content
383
- per row/col."""
159
+ """The false-positive killer. Not every row must have content: a reCAPTCHA 4x4's top row can reach into the header."""
384
160
  if lab is None:
385
161
  return True
386
162
  gutter_color = np.mean([l.color_lab for l in gutter_lines], axis=0)
387
163
  divs = _cell_divergences(lab, boxes, gutter_color)
388
164
  if not divs or len(divs) != rows * cols:
389
165
  return False
390
- # Clean-gutter relaxation: if the chosen separators are painted-uniform
391
- # (mean color_std < CLEAN_GUTTER_STD) the lattice is already proven to be a
392
- # real grid by the gutters alone — sky / pale-sketch tiles are then allowed,
393
- # so we require only a smaller fraction of content-bearing cells. Textured-
394
- # photo pseudo-gutters (the FP mode) are noisy (std well above the threshold)
395
- # and stay on the strict content fraction.
396
166
  gutter_std = float(np.mean([l.color_std for l in gutter_lines]))
397
167
  frac = CELL_DIVERGE_FRAC_CLEAN if gutter_std < CLEAN_GUTTER_STD else CELL_DIVERGE_FRAC
398
168
  tol = SEALED_DIVERGE_TOL if sealed else CELL_DIVERGE_TOL
399
169
  return sum(1 for d in divs if d > tol) >= frac * len(divs)
400
170
 
401
171
 
402
- #: Scale OKLab's native ranges (L 0..1, a/b ~±0.4) onto the CIELAB conventions the
403
- #: thresholds in this module are written in (L 0..100, a/b in the same units). This
404
- #: is a units change only — it keeps every tuned constant meaningful instead of
405
- #: forcing a simultaneous recalibration of all of them.
172
+ # OKLab scaled onto CIELAB units so every tuned constant keeps its meaning.
406
173
  _OK_L_SCALE = 100.0
407
174
  _OK_AB_SCALE = 300.0
408
175
 
@@ -413,9 +180,6 @@ _OK_M2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468],
413
180
  [1.9779984951, -2.4285922050, 0.4505937099],
414
181
  [0.0259040371, 0.7827717662, -0.8086757660]], dtype=np.float32)
415
182
 
416
- #: sRGB -> linear, as a 256-entry lookup. The transfer function is per-CHANNEL and
417
- #: the input is 8-bit, so there are only 256 possible answers; a table turns the
418
- #: pow() over every pixel into a gather. This is what keeps OKLab affordable.
419
183
  _SRGB_TO_LINEAR = np.where(
420
184
  np.arange(256, dtype=np.float32) / 255.0 <= 0.04045,
421
185
  (np.arange(256, dtype=np.float32) / 255.0) / 12.92,
@@ -424,21 +188,8 @@ _SRGB_TO_LINEAR = np.where(
424
188
 
425
189
 
426
190
  def _to_lab(img_bgr):
427
- """Perceptual colour for the whole detector — OKLab, scaled to CIELAB units.
428
-
429
- CIELAB via CIE76 (what this used) is not perceptually uniform: it over-weights
430
- a/b, so two colours that look identical can sit far apart when their chroma is
431
- just JPEG noise. That is why the walk gates on the L channel alone — a
432
- workaround for the colour space rather than a property of gutters. OKLab IS
433
- close to uniform, so a single distance behaves sensibly across hues, which is
434
- what a separator in some colour we have not seen yet needs.
435
-
436
- Cost is a 256-entry LUT plus two 3x3 matrix products and a cube root:
437
- ~2-3 ms on a captcha-sized image against find_grid's ~53 ms, measured. A GPU
438
- was considered and rejected — the transfer would cost as much as the work, and
439
- the line walk is sequential per step anyway.
440
- """
441
- rgb_lin = _SRGB_TO_LINEAR[img_bgr[:, :, ::-1]] # BGR -> RGB, linearised
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]]
442
193
  lms = rgb_lin @ _OK_M1.T
443
194
  np.cbrt(lms, out=lms)
444
195
  ok = lms @ _OK_M2.T
@@ -448,9 +199,7 @@ def _to_lab(img_bgr):
448
199
  return ok
449
200
 
450
201
 
451
- # ── Stage A: vectorized O(n*m) ridge / run-length map ───────────────────────
452
202
  def _runlength(same, axis):
453
- """Inclusive running count of consecutive True along axis; resets on False."""
454
203
  s = same.astype(np.int32)
455
204
  out = np.zeros_like(s)
456
205
  if axis == 1:
@@ -465,8 +214,6 @@ def _runlength(same, axis):
465
214
 
466
215
 
467
216
  def _build_ridge_map(lab, axis):
468
- """Boolean map: True where the pixel belongs to a consistent-color run of
469
- length >= MIN_RUN along the axis (axis=1 horizontal, axis=0 vertical)."""
470
217
  h, w = lab.shape[:2]
471
218
  if axis == 1:
472
219
  diff = lab[:, 1:, :] - lab[:, :-1, :]
@@ -489,148 +236,22 @@ def _build_ridge_map(lab, axis):
489
236
  return ridge
490
237
 
491
238
 
492
- # ── Vectorized perpendicular thickness + centerline ─────────────────────────
493
- def _perp_from_strip(strip, center_idx, ref, max_t):
494
- """`strip` is an (N,3) LAB slice perpendicular to the line, `center_idx` the
495
- index of the query point within it. Compute thickness + centerline by
496
- measuring the contiguous run of within-COLOR_TOL pixels around center_idx.
497
- Returns (offset_from_center, thickness, terminated_both) or None.
498
-
499
- Fully vectorized: one ΔE vector over the (small) strip, then run extents."""
500
- n = len(strip)
501
- if not (0 <= center_idx < n):
502
- return None
503
- diff = strip - ref
504
- de = np.sqrt(np.einsum('ij,ij->i', diff, diff)) # (N,)
505
- same = de < COLOR_TOL
506
- if not same[center_idx]:
507
- return None
508
- # up = consecutive same going to lower indices
509
- up = 0
510
- i = center_idx - 1
511
- while i >= 0 and same[i]:
512
- up += 1; i -= 1
513
- up_term = (i >= 0) and (not same[i])
514
- down = 0
515
- i = center_idx + 1
516
- while i < n and same[i]:
517
- down += 1; i += 1
518
- down_term = (i < n) and (not same[i])
519
- thick = up + down + 1
520
- # NOTE: the old `if thick > max_t: return None` rejection is REMOVED. A real
521
- # white gutter adjacent to white tile content reads as one band far thicker
522
- # than max_t, which wrongly rejected the gutter. `max_t` is no longer used as
523
- # a hard thickness gate here.
524
- offset = (down - up) / 2.0
525
- return offset, float(thick), (up_term and down_term)
526
-
527
-
528
- def _find_band(lab, axis, along, perp, ref, max_t, search=None):
529
- """Search perpendicular to the line for the nearest contiguous band of
530
- line-colored (within CONT_TOL of `ref`) pixels around `perp`, at the given
531
- `along` coordinate. Returns (band_midpoint_perp, thickness) or None.
532
-
533
- Used by the SAVE maneuver: when the slant has shifted the band away from our
534
- fixed perp coordinate, find where it went and re-center. The band must be
535
- <= max_t thick (a real gutter); a wider run means we've reached a background
536
- region, so return None to end the line."""
537
- h, w = lab.shape[:2]
538
- if search is None:
539
- # The save fires when the slant drifts us off the band. With a 1px along
540
- # step and <=25deg tilt the band moves <=~0.5px/step, but we only save
541
- # after color fails (drift ~ thickness). Search a window of one
542
- # thickness + a small margin — NOT wide enough to jump to the next
543
- # parallel gutter (that would corrupt the trace).
544
- search = max_t + 2
545
- # gather the perpendicular strip and a same-color mask
546
- if axis == 1: # perp is vertical -> column `along`
547
- ix = int(round(along))
548
- if not (0 <= ix < w):
549
- return None
550
- lo = max(0, int(round(perp)) - search); hi = min(h, int(round(perp)) + search + 1)
551
- strip = lab[lo:hi, ix, :]
552
- else: # perp is horizontal -> row `along`
553
- iy = int(round(along))
554
- if not (0 <= iy < h):
555
- return None
556
- lo = max(0, int(round(perp)) - search); hi = min(w, int(round(perp)) + search + 1)
557
- strip = lab[iy, lo:hi, :]
558
- diff = strip - ref
559
- de = np.sqrt(np.einsum('ij,ij->i', diff, diff))
560
- same = de < CONT_TOL
561
- if not same.any():
562
- return None
563
- # find contiguous runs; choose the one nearest the query perp
564
- q = int(round(perp)) - lo
565
- idx = np.where(same)[0]
566
- runs = []
567
- s = idx[0]; p = idx[0]
568
- for v in idx[1:]:
569
- if v == p + 1:
570
- p = v
571
- else:
572
- runs.append((s, p)); s = v; p = v
573
- runs.append((s, p))
574
- best = min(runs, key=lambda r: min(abs(r[0] - q), abs(r[1] - q),
575
- 0 if r[0] <= q <= r[1] else 10**9))
576
- thickness = best[1] - best[0] + 1
577
- if thickness > max_t:
578
- return None
579
- mid = (best[0] + best[1]) / 2.0 + lo
580
- return mid, float(thickness)
581
-
582
-
583
- def _perp_centerline(lab, axis, px, py, ref, max_t):
584
- """Centerline + thickness perpendicular to the line at (px,py).
585
- Horizontal line (axis=1): normal is vertical -> scan column px.
586
- Vertical line (axis=0): normal is horizontal -> scan row py.
587
- Reads a short strip from `lab` directly (no full-image mask)."""
588
- h, w = lab.shape[:2]
589
- ix, iy = int(round(px)), int(round(py))
590
- # Strip must be wide enough to reach the band boundary even when the gutter
591
- # is fused with same-colour tile content; max_t no longer bounds the walk.
592
- pad = PERP_SCAN
593
- if axis == 1:
594
- if not (0 <= ix < w) or not (0 <= iy < h):
595
- return None
596
- lo = max(0, iy - pad); hi = min(h, iy + pad + 1)
597
- strip = lab[lo:hi, ix, :]
598
- r = _perp_from_strip(strip, iy - lo, ref, max_t)
599
- if r is None:
600
- return None
601
- off, thick, term = r
602
- return px, iy + off, thick, term
603
- else:
604
- if not (0 <= iy < h) or not (0 <= ix < w):
605
- return None
606
- lo = max(0, ix - pad); hi = min(w, ix + pad + 1)
607
- strip = lab[iy, lo:hi, :]
608
- r = _perp_from_strip(strip, ix - lo, ref, max_t)
609
- if r is None:
610
- return None
611
- off, thick, term = r
612
- return ix + off, py, thick, term
613
239
 
614
240
 
615
241
  def _seed_thickness(lab, axis, cx, cy, ref):
616
- """Count contiguous pixels perpendicular to the gutter at (cx,cy) that match
617
- `ref` (within COLOR_TOL), capped at PERP_SCAN each side. Used ONLY to populate
618
- PotentialGridLine.thickness for the relative GRID_THICK_TOL gate — it never
619
- rejects a trace, so a gutter fused with same-colour tile content (large count)
620
- is fine."""
621
242
  h, w = lab.shape[:2]
622
243
  ix, iy = int(round(cx)), int(round(cy))
623
244
  if not (0 <= iy < h and 0 <= ix < w):
624
245
  return 1.0
625
246
  up = dn = 0
626
- if axis == 1: # perpendicular = vertical (column)
247
+ if axis == 1:
627
248
  i = iy - 1
628
249
  while i >= 0 and up < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
629
250
  up += 1; i -= 1
630
251
  i = iy + 1
631
252
  while i < h and dn < PERP_SCAN and _de2(lab[i, ix], ref) <= COLOR_TOL ** 2:
632
253
  dn += 1; i += 1
633
- else: # perpendicular = horizontal (row)
254
+ else:
634
255
  i = ix - 1
635
256
  while i >= 0 and up < PERP_SCAN and _de2(lab[iy, i], ref) <= COLOR_TOL ** 2:
636
257
  up += 1; i -= 1
@@ -640,27 +261,7 @@ def _seed_thickness(lab, axis, cx, cy, ref):
640
261
  return float(up + dn + 1)
641
262
 
642
263
 
643
- # ── Stage B: center-out tracer ──────────────────────────────────────────────
644
- def _seed_order(mid, lo, hi, step=1):
645
- yield mid
646
- d = step
647
- while True:
648
- a, b = mid - d, mid + d
649
- emitted = False
650
- if a >= lo:
651
- yield a; emitted = True
652
- if b <= hi:
653
- yield b; emitted = True
654
- if not emitted:
655
- break
656
- d += step
657
-
658
-
659
264
  def _split_runs(sorted_idx):
660
- """Split a sorted index array into maximal runs of consecutive integers.
661
- Vectorized: a run boundary is wherever the gap to the next index is > 1, so
662
- np.diff locates all breaks in one pass and np.split cuts there. Returns a list
663
- of int arrays (callers do len()/indexing only)."""
664
265
  sorted_idx = np.asarray(sorted_idx)
665
266
  if sorted_idx.size == 0:
666
267
  return []
@@ -668,36 +269,14 @@ def _split_runs(sorted_idx):
668
269
  return np.split(sorted_idx, breaks)
669
270
 
670
271
 
671
- # ── Vectorized batched walk ──────────────────────────────────────────────────
672
- # All seeds of one axis are walked in LOCKSTEP: at step k, every still-active
673
- # trace is advanced one pixel along the gutter together with a single fancy-index
674
- # gather + a vectorized accept test (replacing ~1976 sequential python pixel
675
- # walks/img with ~image-dim vectorized steps). The accept test and the
676
- # perpendicular slant re-find reproduce _trace_one's scalar `ok`/`extend` exactly:
677
- # ok: |L - seed_L| <= SEED_L_TOL AND |L - prev_L| <= STEP_L_TOL
678
- # AND ΔE(pix, running line mean)^2 <= CONT_TOL2
679
- # refind: on a miss, look ±1..PERP_REFIND perpendicular (nearest first, up then
680
- # down) for a pixel that passes ok; if found, follow the slant, else stop.
681
272
  def _batch_gather(lab, axis, along_i, perp_i):
682
- """lab pixels at integer (along, perp) for this axis -> (M,3)."""
683
- if axis == 1: # H line: along=x, perp=y
273
+ if axis == 1:
684
274
  return lab[perp_i, along_i]
685
- return lab[along_i, perp_i] # V line: along=y, perp=x
275
+ return lab[along_i, perp_i]
686
276
 
687
277
 
688
278
  def image_noise(lab):
689
- """This image's noise floor: the 90th-percentile lightness difference between
690
- ADJACENT BRIGHT pixels.
691
-
692
- A gutter painted by a vendor's compositor is bit-identical white, so this is
693
- ~0 and the walk tolerances stay exactly where they were tuned. Anything that
694
- perturbs flat areas — JPEG, rescaling, fractional device-pixel-ratio, a
695
- generator that dithers — raises it, and the tolerances widen to match.
696
-
697
- Measured on flat BRIGHT regions specifically, not the whole image: tile
698
- content is supposed to vary, and including it would report "noisy" for every
699
- busy photo and defeat the point.
700
- """
279
+ """p90 lightness step between adjacent BRIGHT pixels: ~0 for a compositor-painted gutter, higher after JPEG or rescaling."""
701
280
  L = lab[:, :, 0]
702
281
  bright = L > 85
703
282
  dh = np.abs(np.diff(L, axis=1)); mh = bright[:, :-1] & bright[:, 1:]
@@ -709,22 +288,13 @@ def image_noise(lab):
709
288
 
710
289
 
711
290
  def walk_tolerances(noise):
712
- """(seed_tol, step_tol, cont_tol^2) for an image with this noise floor.
713
-
714
- Scaled rather than raised outright. A flat widening costs real detections —
715
- measured on the corpus, SEED_L/STEP_L of 10/8 applied to every image drops a
716
- pristine sample, and 14/12 drops four — because a pristine gutter given a
717
- loose band lets the walk wander into tile content. Scaling leaves those
718
- images at exactly the old thresholds (their noise is ~0) and spends the extra
719
- slack only where the render actually is noisy.
720
- """
291
+ """Scaled, not raised: a flat SEED_L/STEP_L of 10/8 drops a pristine sample and 14/12 drops four."""
721
292
  return (SEED_L_TOL + NOISE_GAIN * noise,
722
293
  STEP_L_TOL + NOISE_GAIN * noise,
723
294
  (CONT_TOL + NOISE_GAIN * noise) ** 2)
724
295
 
725
296
 
726
297
  def _batch_accept(pix, seed_L, prev_L, line_color, tols):
727
- """Vectorized copy of _trace_one.ok over M traces at once."""
728
298
  seed_tol, step_tol, cont_tol2 = tols
729
299
  L = pix[:, 0]
730
300
  d0 = pix[:, 0] - line_color[:, 0]
@@ -737,11 +307,6 @@ def _batch_accept(pix, seed_L, prev_L, line_color, tols):
737
307
 
738
308
 
739
309
  def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
740
- """Walk all N seeds one direction in lockstep. Returns (last_along[N],
741
- last_perp[N], support[N]). Reproduces _trace_one.extend exactly: per-step
742
- accept test + ±PERP_REFIND perpendicular slant re-find (nearest first, up
743
- then down). State arrays are indexed by ORIGINAL seed id; an `active` mask
744
- compacts the work each step."""
745
310
  h, w = lab.shape[:2]
746
311
  N = along_seed.shape[0]
747
312
  along_max = (w - 1) if axis == 1 else (h - 1)
@@ -759,7 +324,7 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
759
324
  active = np.ones(N, dtype=bool)
760
325
 
761
326
  while active.any():
762
- ai = np.where(active)[0] # original ids still walking
327
+ ai = np.where(active)[0]
763
328
  al = along[ai] + direction * STEP
764
329
  in_b = (al >= 0) & (al <= along_max)
765
330
  if not in_b.all():
@@ -778,9 +343,6 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
778
343
  accepted = ok.copy()
779
344
 
780
345
  if not ok.all():
781
- # `still` is a boolean over positions-within-ai: traces still looking
782
- # for a slant continuation. Nearest offset first (d=1..), up then down;
783
- # once a trace finds the gutter colour it drops out of `still`.
784
346
  still = ~ok
785
347
  for d in range(1, PERP_REFIND + 1):
786
348
  for s in (-1, +1):
@@ -817,14 +379,12 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
817
379
  last_perp[gid] = new_perp[adv]
818
380
  last_along[gid] = al[adv]
819
381
  support[gid] += 1
820
- active[ai[~accepted]] = False # no continuation -> stop
382
+ active[ai[~accepted]] = False
821
383
 
822
384
  return last_along, last_perp, support
823
385
 
824
386
 
825
387
  def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols):
826
- """Walk N seeds both directions in lockstep. Returns a_lo,a_hi (min/max along
827
- reached), perp_lo,perp_hi (perp at those ends), support (steps both dirs)."""
828
388
  fa, fp, ns_f = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, +1, tols)
829
389
  ba, bp, ns_b = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, -1, tols)
830
390
  a_hi = np.maximum(along_seed.astype(np.float64), fa)
@@ -832,205 +392,12 @@ def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols):
832
392
  return a_lo, a_hi, bp, fp, ns_f + ns_b
833
393
 
834
394
 
835
- def _trace_one(lab, axis, run, seed_along, seed_bias):
836
- """Trace a line. `run` is the contiguous ridge-run of perpendicular-axis
837
- indices (x's for a horizontal line, y's for a vertical line); `seed_along`
838
- is the fixed coordinate on the seed row/col. We pick a seed position within
839
- the run whose perpendicular probe succeeds (the run midpoint can land on a
840
- 1-px dead spot of a real gutter), so a single unlucky pixel doesn't drop the
841
- whole line."""
842
- h, w = lab.shape[:2]
843
- # Seed = the run midpoint (or quarters) whose pixel is a clean gutter colour.
844
- # No thickness/termination check here — a gutter fused with same-colour tile
845
- # content has no measurable thickness, but it is still a real gutter.
846
- mid = len(run) // 2
847
- order = [mid]
848
- for frac in (0.25, 0.75):
849
- idx = int(len(run) * frac)
850
- if 0 <= idx < len(run) and idx not in order:
851
- order.append(idx)
852
- cx = cy = None
853
- for idx in order:
854
- along = float(run[idx])
855
- if axis == 1:
856
- sx, sy = along, float(seed_along)
857
- else:
858
- sx, sy = float(seed_along), along
859
- iy, ix = int(round(sy)), int(round(sx))
860
- if 0 <= iy < h and 0 <= ix < w:
861
- cx, cy = sx, sy
862
- break
863
- if cx is None:
864
- return None
865
- if axis == 1: # horizontal line: along=x, perp=y
866
- along_seed, perp_seed = cx, cy
867
- else: # vertical line: along=y, perp=x
868
- along_seed, perp_seed = cy, cx
869
- seed_ref = lab[int(round(cy)), int(round(cx))].astype(np.float64)
870
-
871
- def at(x, y):
872
- # nearest-pixel LAB lookup; int() truncation is fine at our scale and
873
- # avoids the per-call cost of round()+astype() in the inner walk loop.
874
- return lab[int(y + 0.5), int(x + 0.5)]
875
-
876
- def perp_at(along, perp):
877
- """The (x,y) pixel at along/perp for this axis."""
878
- return (along, perp) if axis == 1 else (perp, along)
879
-
880
- def drift():
881
- """Walk along the gutter one pixel at a time. At each step compare the
882
- pixel to the running gutter colour. If it still matches -> advance. If it
883
- DOESN'T (we hit a cell), it may just be the SLANT carrying the gutter off
884
- our current perp row/col: look a few px perpendicular (up then down) for
885
- the gutter colour and, if found, STEP THERE and keep going. Only if no
886
- gutter colour exists within +/-PERP_REFIND perpendicular is it a genuine
887
- wall -> stop. No thickness, no midpoint: perp just tracks the gutter."""
888
- line_color = seed_ref.copy()
889
- line_n = 1
890
- endpoints = []
891
- seed_L = float(seed_ref[0])
892
-
893
- def ok(pix, prev_L):
894
- """Accept `pix` as part of the gutter. Lightness (L) is the stable
895
- signal — a real gutter's L barely moves (measured span ~3) while a
896
- shaded surface like grass drifts wildly (span ~32). a/b are noisy
897
- (JPEG chroma) and visually irrelevant at these lightnesses, so we do
898
- NOT gate on full ΔE-from-seed (that over-penalised chroma noise and
899
- dropped real tinted-grey gutters). Three checks:
900
- 1. |L - seed_L| <= SEED_L_TOL (same lightness throughout)
901
- 2. |L - prev_L| <= STEP_L_TOL (no lightness jump step->step)
902
- 3. ΔE(pix, running mean) <= CONT_TOL (local colour continuity)"""
903
- L = float(pix[0])
904
- return (abs(L - seed_L) <= SEED_L_TOL
905
- and abs(L - prev_L) <= STEP_L_TOL
906
- and _de2(pix, line_color) <= CONT_TOL2)
907
-
908
- def extend(direction):
909
- nonlocal line_color, line_n
910
- along, perp = along_seed, perp_seed
911
- prev_L = seed_L
912
- n = 0
913
- while True:
914
- along += direction * STEP
915
- px, py = perp_at(along, perp)
916
- if px < 0 or py < 0 or px > w - 1 or py > h - 1:
917
- break
918
- cpix = at(px, py)
919
- if ok(cpix, prev_L):
920
- line_color = (line_color * line_n + cpix) / (line_n + 1); line_n += 1
921
- prev_L = float(cpix[0])
922
- n += 1
923
- continue
924
- # Colour changed: hit a cell. Is the gutter just slanted away?
925
- # Look perpendicular for the gutter colour (same test), nearest
926
- # offset first.
927
- found = None
928
- for d in range(1, PERP_REFIND + 1):
929
- for s in (-1, +1): # up then down
930
- np_ = perp + s * d
931
- qx, qy = perp_at(along, np_)
932
- if qx < 0 or qy < 0 or qx > w - 1 or qy > h - 1:
933
- continue
934
- qpix = at(qx, qy)
935
- if ok(qpix, prev_L):
936
- found = np_; break
937
- if found is not None:
938
- break
939
- if found is None:
940
- break # genuine wall -> stop
941
- perp = found # follow the slant
942
- qx, qy = perp_at(along, perp)
943
- cpix = at(qx, qy)
944
- line_color = (line_color * line_n + cpix) / (line_n + 1); line_n += 1
945
- prev_L = float(cpix[0])
946
- n += 1
947
- endpoints.append(perp_at(along, perp))
948
- return n
949
-
950
- n_fwd = extend(+1)
951
- n_bwd = extend(-1)
952
- return endpoints, n_fwd + n_bwd
953
-
954
- def fit(endpoints):
955
- """Fit (ang, slant, midline, span) through seed + traced endpoints."""
956
- pts = np.array([(cx, cy)] + endpoints, dtype=np.float64)
957
- mean = pts.mean(axis=0)
958
- dv = _principal_dir_2d(pts - mean)
959
- if axis == 1:
960
- ang = np.arctan2(dv[1], dv[0])
961
- if abs(ang) > np.pi / 2:
962
- ang -= np.copysign(np.pi, ang)
963
- slant = np.tan(ang)
964
- midline = mean[1] + slant * (w / 2.0 - mean[0])
965
- span = float(pts[:, 0].max() - pts[:, 0].min())
966
- else:
967
- ang = np.arctan2(dv[0], dv[1])
968
- if abs(ang) > np.pi / 2:
969
- ang -= np.copysign(np.pi, ang)
970
- slant = np.tan(ang)
971
- midline = mean[0] + slant * (h / 2.0 - mean[1])
972
- span = float(pts[:, 1].max() - pts[:, 1].min())
973
- return pts, ang, slant, midline, span
974
-
975
- # Walk the gutter both directions (slant followed by perpendicular re-find,
976
- # not by thickness/midpoint). The slant is recovered from the fit through the
977
- # traced endpoints.
978
- endpoints, support = drift()
979
- if support < MIN_RUN:
980
- return None
981
- pts, ang, slant, midline, span = fit(endpoints)
982
- full = w if axis == 1 else h
983
- if abs(slant) > SLANT_CAP or span < SUPPORT_FRAC * full:
984
- return None
985
- a_lo = float(pts[:, 0].min()) if axis == 1 else float(pts[:, 1].min())
986
- a_hi = float(pts[:, 0].max()) if axis == 1 else float(pts[:, 1].max())
987
-
988
- # Thickness for the consistency gate is measured once at the seed (it does NOT
989
- # gate the trace). Count contiguous gutter-colour pixels perpendicular to the
990
- # seed; if the gutter is fused with same-colour tile content the count is
991
- # large but bounded by PERP_SCAN — only used for the relative thickness gate.
992
- th_med = _seed_thickness(lab, axis, cx, cy, seed_ref)
993
- # Sample colours along the validated line for the consistency gate + mean.
994
- cols = []
995
- for a in np.arange(a_lo, a_hi + 1, 3.0):
996
- if axis == 1:
997
- x = a; y = midline + slant * (x - w / 2.0)
998
- else:
999
- y = a; x = midline + slant * (y - h / 2.0)
1000
- jx, jy = int(round(x)), int(round(y))
1001
- if 0 <= jy < h and 0 <= jx < w:
1002
- cols.append(lab[jy, jx])
1003
- if not cols:
1004
- return None
1005
- cols = np.array(cols, dtype=np.float64)
1006
- color_std = float(np.mean(np.std(cols, axis=0)))
1007
- if color_std > LINE_STD_TOL:
1008
- return None
1009
-
1010
- # Endpoints from the validated (a_lo,a_hi) span on the fitted line.
1011
- if axis == 1:
1012
- start = (a_lo, midline + slant * (a_lo - w / 2.0))
1013
- end = (a_hi, midline + slant * (a_hi - w / 2.0))
1014
- else:
1015
- start = (midline + slant * (a_lo - h / 2.0), a_lo)
1016
- end = (midline + slant * (a_hi - h / 2.0), a_hi)
1017
-
1018
- return PotentialGridLine(
1019
- orientation='h' if axis == 1 else 'v',
1020
- angle=float(ang), thickness=th_med,
1021
- start=start, end=end,
1022
- color_lab=cols.mean(axis=0), color_std=color_std,
1023
- midline_pos=float(midline), support=int(support),
1024
- )
1025
395
 
1026
396
 
1027
397
  def _principal_dir_2d(centered):
1028
- """Unit eigenvector of the largest eigenvalue of the 2x2 covariance of
1029
- `centered` (N,2) points. Closed form — no SVD."""
1030
398
  cxx = float(np.dot(centered[:, 0], centered[:, 0]))
1031
399
  cyy = float(np.dot(centered[:, 1], centered[:, 1]))
1032
400
  cxy = float(np.dot(centered[:, 0], centered[:, 1]))
1033
- # eigenvector of [[cxx,cxy],[cxy,cyy]] for the larger eigenvalue
1034
401
  tr = cxx + cyy
1035
402
  det = cxx * cyy - cxy * cxy
1036
403
  disc = max(0.0, (tr * tr) / 4.0 - det)
@@ -1045,60 +412,19 @@ def _principal_dir_2d(centered):
1045
412
  return v / n if n > 1e-9 else np.array([1.0, 0.0])
1046
413
 
1047
414
 
1048
- def _scan_seeds(lab, axis, seed_bias, ridge, c_lo, c_hi, span, lines):
1049
- """Seed EVERY row/col (no skip, no order-based dedup) and collect ALL valid
1050
- traces. The same gutter is intentionally re-seeded from each of its rows; the
1051
- duplicates are clustered and averaged later by _merge_lines. This replaces the
1052
- old 'skip a band-width / first-found-wins' scheme — which let a weak slanted
1053
- fragment found first block a stronger straight gutter found a few rows later
1054
- (the missed-straight-gutter bug). Checking every row also recentres the line
1055
- on the cluster midpoint, like find_grid's original cluster-and-average."""
1056
- h, w = lab.shape[:2]
1057
- lo, hi = int(span * (0.5 - MAX_SEED_FRAC)), int(span * (0.5 + MAX_SEED_FRAC))
1058
- # Decimate seed rows/cols: a gutter is >= MIN_RUN px in the perpendicular
1059
- # direction, so stepping by SEED_DECIMATE still seeds EVERY gutter from
1060
- # multiple rows (enough for the cluster-average) while doing a fraction of the
1061
- # per-seed trace work. The dominant cost is tracing the thousands of seeds that
1062
- # land on tile CONTENT and die after a few px; decimation cuts that linearly.
1063
- for c in range(lo, hi + 1, SEED_DECIMATE):
1064
- if axis == 1:
1065
- runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
1066
- else:
1067
- runs = _split_runs(np.where(ridge[c_lo:c_hi, c])[0] + c_lo)
1068
- for run in runs:
1069
- if len(run) < MIN_RUN:
1070
- continue
1071
- ln = _trace_one(lab, axis, run, float(c), seed_bias)
1072
- if ln is not None:
1073
- lines.append(ln)
1074
-
1075
-
1076
415
  def _collect_seeds(lab, axis, ridge, c_lo, c_hi, span):
1077
- """Gather one seed per (decimated row/col, ridge-run >= MIN_RUN), choosing the
1078
- seed pixel exactly as _trace_one does (run midpoint, then 1/4 and 3/4 as
1079
- fallbacks) restricted to in-bounds. Returns arrays along_seed[N], perp_seed[N],
1080
- seed_ref[N,3] for the batch walk."""
1081
416
  h, w = lab.shape[:2]
1082
417
  lo, hi = int(span * (0.5 - MAX_SEED_FRAC)), int(span * (0.5 + MAX_SEED_FRAC))
1083
418
  scan_w = c_hi - c_lo
1084
419
  along_s, perp_s = [], []
1085
420
  for c in range(lo, hi + 1, SEED_DECIMATE):
1086
- if axis == 1: # H line: c is a row (y=perp), run is x (along)
421
+ if axis == 1:
1087
422
  runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
1088
- else: # V line: c is a col (x=perp), run is y (along)
423
+ else:
1089
424
  runs = _split_runs(np.where(ridge[c_lo:c_hi, c])[0] + c_lo)
1090
425
  for run in runs:
1091
426
  if len(run) < MIN_RUN or len(run) < SEED_RUN_FRAC * scan_w:
1092
- # A real grid gutter is seeded from a row/col that lies ON it, so
1093
- # its ridge run already spans almost the whole scan (measured:
1094
- # survivors >= 0.88 of scan; dying content fragments median ~0.09,
1095
- # p90 ~0.35). Requiring the run to cover >= SEED_RUN_FRAC of the
1096
- # scan drops ~95% of the dead content seeds BEFORE the expensive
1097
- # walk (they accounted for ~73% of all walk steps) without losing a
1098
- # real gutter. Slant is recovered DURING the walk (perp re-find),
1099
- # not from the seed run, so this does not hurt tilted grids.
1100
427
  continue
1101
- # seed-pixel choice (mirror _trace_one): midpoint, then quarters
1102
428
  chosen = None
1103
429
  mid = len(run) // 2
1104
430
  for idx in [mid, int(len(run) * 0.25), int(len(run) * 0.75)]:
@@ -1118,17 +444,14 @@ def _collect_seeds(lab, axis, ridge, c_lo, c_hi, span):
1118
444
  return (np.empty(0), np.empty(0), np.empty((0, 3)))
1119
445
  along_seed = np.array(along_s, dtype=np.float64)
1120
446
  perp_seed = np.array(perp_s, dtype=np.float64)
1121
- if axis == 1: # gather at (perp=y, along=x)
447
+ if axis == 1:
1122
448
  seed_ref = lab[perp_seed.astype(np.intp), along_seed.astype(np.intp)].astype(np.float64)
1123
- else: # gather at (along=y, perp=x)
449
+ else:
1124
450
  seed_ref = lab[along_seed.astype(np.intp), perp_seed.astype(np.intp)].astype(np.float64)
1125
451
  return along_seed, perp_seed, seed_ref
1126
452
 
1127
453
 
1128
454
  def _trace_lines(lab, axis, seed_bias, tols=None):
1129
- """Vectorized: collect all seeds, walk them in lockstep (_walk_batch), then
1130
- build a PotentialGridLine per surviving trace with the same per-line gates as
1131
- the scalar path (SLANT_CAP, SUPPORT_FRAC span, LINE_STD_TOL)."""
1132
455
  h, w = lab.shape[:2]
1133
456
  if tols is None:
1134
457
  tols = walk_tolerances(image_noise(lab))
@@ -1150,8 +473,6 @@ def _trace_lines(lab, axis, seed_bias, tols=None):
1150
473
  span_i = a_hi[i] - a_lo[i]
1151
474
  if span_i < SUPPORT_FRAC * full:
1152
475
  continue
1153
- # endpoints traced: (a_lo, perp_lo) and (a_hi, perp_hi) in along/perp;
1154
- # plus the seed. Fit a line through them (PCA), exactly like _trace_one.fit.
1155
476
  if axis == 1:
1156
477
  pts = np.array([(along_seed[i], perp_seed[i]),
1157
478
  (a_lo[i], perp_lo[i]), (a_hi[i], perp_hi[i])], dtype=np.float64)
@@ -1175,7 +496,6 @@ def _trace_lines(lab, axis, seed_bias, tols=None):
1175
496
  if abs(slant) > SLANT_CAP:
1176
497
  continue
1177
498
  a0 = a_lo[i]; a1 = a_hi[i]
1178
- # sample colours along the validated line for the consistency gate + mean
1179
499
  aa = np.arange(a0, a1 + 1, 3.0)
1180
500
  if axis == 1:
1181
501
  xs = aa; ys = midline + slant * (xs - w / 2.0)
@@ -1206,13 +526,7 @@ def _trace_lines(lab, axis, seed_bias, tols=None):
1206
526
 
1207
527
 
1208
528
  def _merge_lines(lines):
1209
- """Cluster duplicate traces of the same gutter (seeded from each of its rows)
1210
- by POSITION ONLY and collapse each cluster to one line. We deliberately do
1211
- NOT also gate on angle: noisy slanted fragments of the SAME gutter get
1212
- slightly different angle estimates, and gating on angle left them as separate
1213
- near-duplicate lines that polluted the line list and broke extraction. Two
1214
- genuinely different gutters are >= MIN_CELL apart, far beyond MERGE_PX, so
1215
- position-only clustering can't fuse distinct gutters."""
529
+ """Cluster by position only: angle-gating left noisy fragments of one gutter as separate near-duplicates."""
1216
530
  if not lines:
1217
531
  return []
1218
532
  lines = sorted(lines, key=lambda l: l.midline_pos)
@@ -1228,31 +542,14 @@ def _merge_lines(lines):
1228
542
 
1229
543
 
1230
544
  def _pick(group):
1231
- """Collapse a cluster of duplicate traces of the same gutter into one line.
1232
- Keep the strongest member's attributes (angle, colour, thickness) but set the
1233
- position to the SUPPORT-WEIGHTED mean — so a real straight full-width gutter
1234
- (large support, many duplicate rows) dominates the centre and a weak slanted
1235
- fragment that happens to fall in the same cluster barely shifts it. `support`
1236
- becomes the cluster's max (its real length), and we record the cluster size."""
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
+ """
1237
550
  best = max(group, key=lambda l: l.support)
1238
551
  wsum = sum(l.support for l in group)
1239
552
  centre = sum(l.midline_pos * l.support for l in group) / wsum
1240
- # COLOUR comes from the member sitting on that centre, not from the longest
1241
- # one. The two are routinely different members: a gutter is seeded from each
1242
- # of its rows, and the longest trace is often the one seeded at its EDGE,
1243
- # which then runs along tile content rather than the separator. Measured on
1244
- # hcaptcha_images_ice_cream4: the kept trace sat 11 px off the gutter, on a
1245
- # pale watermark that exists at some x and not others, and carried
1246
- # color_std 2.80 while the on-centre traces of the same gutter measured
1247
- # 0.55-0.80. Position was already re-centred here; the colour was not, so
1248
- # every downstream colour gate judged the grid by a line that was not on it —
1249
- # and `CLEAN_GUTTER_STD` (2.3) then withheld the pale-tile relaxation in
1250
- # `_cells_have_content`, failing a correct 3x3 (2026-08-11).
1251
- # Ordered by CLEANLINESS, not by distance to the centre: a member a hair
1252
- # nearer the centre can still be the dirtier read, and preferring it swapped a
1253
- # 0.00 trace for a 3.43 one on recaptcha_1774954808237_rhitt and lost a grid
1254
- # that had detected fine. Restricted to members with comparable support, so a
1255
- # three-sample fragment cannot win on a trivially perfect std.
1256
553
  strong = [l for l in group if l.support >= 0.5 * best.support] or group
1257
554
  cleanest = min(strong, key=lambda l: (l.color_std, abs(l.midline_pos - centre)))
1258
555
  best.midline_pos = centre
@@ -1262,36 +559,19 @@ def _pick(group):
1262
559
  return best
1263
560
 
1264
561
 
1265
- # ── Second cue: the full-span colour comb ────────────────────────────
1266
- # The tracer is a LOCAL walk, so it loses a gutter whose NEIGHBOURS are nearly its
1267
- # own colour: every near-colour row seeds a trace of its own, _merge_lines clusters
1268
- # the whole neighbourhood together, and the 2px painted line is averaged away by the
1269
- # hundreds of pixels either side of it. Measured on recaptcha_1775818840074_rrv7m (a
1270
- # 4x4 over open sky): pure-white gutters at x=103/200/297 came back as 66/96/215, and
1271
- # no lattice was recoverable from them. hCaptcha's white-background artwork tiles fail
1272
- # the same way on the other axis.
1273
- #
1274
- # The comb is the complementary GLOBAL test — is this whole straight line the gutter
1275
- # colour, end to end? — which a near-colour neighbour fails outright at COMB_TOL even
1276
- # though the local walk cannot tell the two apart. It is the "grid-like section
1277
- # repeated" cue: the lattice is found from the REPETITION of whole matching lines
1278
- # rather than from any one of them being individually traceable.
1279
- #
1280
- # The safety property is that the comb only ever looks for a colour the TRACER ALREADY
1281
- # PROVED is painted in this image — a clean, uniform line it walked itself — so it
1282
- # cannot conjure a lattice out of an image with no painted line at all, and everything
1283
- # it proposes still faces every existing structural, colour and content gate.
1284
562
  def _comb_lines(lab, axis, color):
1285
- """Straight full-span lines every pixel of which is `color`: the gutters the
1286
- tracer's local walk lost. Axis-aligned by construction, so this cue does not
1287
- recover TILTED grids — the tracer still owns those."""
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
+ """
1288
568
  h, w = lab.shape[:2]
1289
569
  d = lab - color
1290
570
  m = (d * d).sum(axis=2) < COMB_TOL * COMB_TOL
1291
- if axis == 1: # H lines: spread over y, measured across x
571
+ if axis == 1:
1292
572
  lo, hi = int(w * (0.5 - MAX_SEED_FRAC)), int(w * (0.5 + MAX_SEED_FRAC))
1293
573
  cov = m[:, lo:hi].mean(axis=1)
1294
- else: # V lines: spread over x, measured across y
574
+ else:
1295
575
  lo, hi = int(h * (0.5 - MAX_SEED_FRAC)), int(h * (0.5 + MAX_SEED_FRAC))
1296
576
  cov = m[lo:hi, :].mean(axis=0)
1297
577
  mid = (lo + hi) // 2
@@ -1301,18 +581,11 @@ def _comb_lines(lab, axis, color):
1301
581
  if len(blk) > COMB_MAX_THICK:
1302
582
  continue
1303
583
  pos = float(blk.mean())
1304
- # The page MARGIN is the gutter colour too, and it runs the whole image.
1305
- # _internal drops such a line from its own axis, but _corroborate reads the
1306
- # perpendicular lines unfiltered — so a margin left in here corroborates
1307
- # every line on the other axis, including the grid's own borders, and a 3x3
1308
- # is reported as a 4x3. Apply the same edge rule at the source.
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.
1309
585
  if not (total * EDGE_MARGIN < pos < total * (1 - EDGE_MARGIN)):
1310
586
  continue
1311
587
  i = int(round(pos))
1312
588
  along = m[i] if axis == 1 else m[:, i]
1313
- # EXTENT is measured, not assumed: the run of gutter colour through the
1314
- # scan centre. The full-span gate downstream compares it against the grid
1315
- # width, which the scan band alone would always fail.
1316
589
  idx = np.where(along)[0]
1317
590
  grp = (np.split(idx, np.where(np.diff(idx) > COMB_GAP)[0] + 1)
1318
591
  if idx.size else [])
@@ -1333,17 +606,7 @@ def _comb_lines(lab, axis, color):
1333
606
 
1334
607
 
1335
608
  def _comb_axis(lines, comb):
1336
- """One axis' lines with the comb's findings folded in.
1337
-
1338
- Where the two overlap the comb wins on POSITION and only on position: it is the
1339
- exact measurement (THIS line is the gutter colour, the one 7px away is not),
1340
- whereas a trace is a cluster average that a near-colour neighbourhood drags off
1341
- the gutter — rrv7m's true x=103 came back as x=96, and averaging the two would
1342
- just split the difference. Everything else is kept from the longer TRACE, which
1343
- walked the gutter's real extent and tilt; a comb line's extent stops at the first
1344
- stretch of the line that is not quite the colour, so promoting it wholesale
1345
- reported a full-width 520px gutter as a 43px stub and failed it on the full-span
1346
- gate. The comb is only additive where it finds a line the tracer missed."""
609
+ """The comb wins on position only; extent comes from the longer trace (a comb extent stops at the first off-colour stretch)."""
1347
610
  out, used = [], set()
1348
611
  for c in comb:
1349
612
  near = [l for l in lines if abs(l.midline_pos - c.midline_pos) < MERGE_PX]
@@ -1358,10 +621,7 @@ def _comb_axis(lines, comb):
1358
621
 
1359
622
 
1360
623
  def _add_comb_lines(lab, h_lines, v_lines):
1361
- """Both axes' lines with the colour comb applied. The colour is the median of the
1362
- CLEAN traced lines of BOTH axes: a real grid's gutters are one colour, so either
1363
- axis can supply it — which is precisely what lets a starved axis be rescued by
1364
- the evidence the other one found."""
624
+ """Colour is the median of the clean traces of BOTH axes, so a starved axis is rescued by the other's evidence."""
1365
625
  clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
1366
626
  if not clean:
1367
627
  return h_lines, v_lines
@@ -1370,14 +630,7 @@ def _add_comb_lines(lab, h_lines, v_lines):
1370
630
  _comb_axis(v_lines, _comb_lines(lab, 0, color)))
1371
631
 
1372
632
 
1373
- # ── _generate_grid: cell boxes for a (rows x cols) grid of any size ──────────
1374
633
  def _generate_grid(rows, cols, hs, vs, hd, vd, h, w, slant):
1375
- """Cell bounding boxes for a `rows` x `cols` grid. `hs` are the `rows-1`
1376
- internal H-line positions (pitch hd), `vs` the `cols-1` internal V-lines
1377
- (pitch vd). The outer borders are extrapolated one pitch beyond the outer
1378
- internal lines (grids have no reliable outer frame; the central pitch is the
1379
- signal). Returns rows*cols boxes row-major, or None if any clip empty.
1380
- Generalises the old 3x3/4x4-only generator to any dimensions."""
1381
634
  mid_x, mid_y = w / 2, h / 2
1382
635
  y_bounds = [hs[0] - hd] + list(hs) + [hs[-1] + hd]
1383
636
  x_bounds = [vs[0] - vd] + list(vs) + [vs[-1] + vd]
@@ -1402,59 +655,33 @@ def _generate_grid(rows, cols, hs, vs, hd, vd, h, w, slant):
1402
655
  return grid_boxes if len(grid_boxes) == rows * cols else None
1403
656
 
1404
657
 
1405
- # ── Stage C: extract grid from traced lines ─────────────────────────────────
1406
658
  def _internal(lines, total):
1407
659
  return [l for l in lines
1408
660
  if total * EDGE_MARGIN < l.midline_pos < total * (1 - EDGE_MARGIN)]
1409
661
 
1410
662
 
1411
663
  def _boxes_are_regular(boxes, rows, cols):
1412
- """Do the EMITTED cells actually form a regular grid — equal widths, equal
1413
- heights, none degenerate?
1414
-
1415
- `_even_spacing_ok` already checks the candidate LINE positions, but it runs
1416
- before the lattice is generated and so cannot see what the boxes came out as.
1417
- A lattice built from near-duplicate lines passes it and then emits cells one
1418
- pixel wide: an observed false positive had column pitches
1419
- [1, 1, 151, 1, 1, 151, 1, 1], which is "evenly spaced" by no useful definition
1420
- yet cleared every gate upstream. Tightening EVEN_TOL does not catch this —
1421
- measured from 0.18 down to 0.03, the result does not move — because the
1422
- irregularity is created after that check, not before it.
1423
-
1424
- This is the last word on geometry: whatever the tracer believed, the thing we
1425
- hand back has to look like a grid.
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.
1426
668
  """
1427
669
  if not boxes or rows < 1 or cols < 1:
1428
670
  return False
1429
671
 
1430
- # The EDGES first. Cell sizes alone are not enough: a lattice built from two
1431
- # lines one pixel apart emits overlapping cells that each measure a plausible
1432
- # ~50px, so a width/height check passes it. The tell is in the edge pitch —
1433
- # an observed false positive on a text captcha had column edges
1434
- # [206, 254, 255, 303] (pitch 48, 1, 48) and rows [1, 49, 1, 52, 47, 1].
1435
- # "Evenly spaced" has to mean the LINES are evenly spaced.
1436
- # Column edges from ONE row and row edges from ONE column — boxes are
1437
- # row-major, so these index directly.
1438
- #
1439
- # NOT the set of every box's left edge. A grid with any slant rounds row 1's
1440
- # second column to x=201 and row 2's to x=202, so the union contains both and
1441
- # the pitch list reads [110, 1, 110, 1] — a phantom 1px separator invented by
1442
- # inter-row rounding, on cells whose widths agree to 0.9%. That rejected 101
1443
- # real hcaptcha grids and 4 prosopo ones. One row's edges are mutually
1444
- # consistent by construction.
1445
672
  if len(boxes) < rows * cols:
1446
673
  return False
1447
674
  col_edges = [boxes[c][0] for c in range(cols)]
1448
675
  row_edges = [boxes[r * cols][1] for r in range(rows)]
1449
676
  for edges in (col_edges, row_edges):
1450
677
  if len(edges) < 2:
1451
- continue # a single row/column has no pitch
678
+ continue
1452
679
  pitches = np.diff(np.array(sorted(edges), dtype=np.float64))
1453
680
  if np.any(pitches < MIN_CELL):
1454
- return False # duplicate / near-duplicate lines
681
+ return False
1455
682
  mp = float(np.median(pitches))
1456
683
  if np.any(np.abs(pitches - mp) > CELL_REGULARITY_TOL * mp):
1457
- return False # unevenly spaced separators
684
+ return False
1458
685
 
1459
686
  widths = np.array([b[2] - b[0] for b in boxes], dtype=np.float64)
1460
687
  heights = np.array([b[3] - b[1] for b in boxes], dtype=np.float64)
@@ -1471,28 +698,14 @@ def _boxes_are_regular(boxes, rows, cols):
1471
698
 
1472
699
 
1473
700
  def _even_spacing_ok(positions, total):
1474
- """True if `positions` (sorted internal-line coords) are EVENLY spaced — every
1475
- consecutive INTERNAL gap is within EVEN_TOL of the median. This is the core
1476
- "cells are the same size" rule, measured from the central closed cells (the
1477
- reliable part). We do NOT require or validate an outer border: real captchas
1478
- often have no clean frame and the grid bleeds to the image edge. Instead we
1479
- take the pitch from the internal gaps and only sanity-check that extrapolating
1480
- one pitch beyond each outer internal line keeps the implied grid inside the
1481
- image (it can't extend far past the edge). Returns (ok, pitch)."""
1482
701
  p = sorted(positions)
1483
702
  gaps = [p[i + 1] - p[i] for i in range(len(p) - 1)]
1484
703
  pitch = float(np.median(gaps))
1485
704
  if pitch < MIN_CELL:
1486
705
  return False, pitch
1487
- # equal-size cells: every internal gap matches the median pitch
1488
706
  for g in gaps:
1489
707
  if abs(g - pitch) > EVEN_TOL * pitch:
1490
708
  return False, pitch
1491
- # The implied grid spans [p[0]-pitch, p[-1]+pitch] (one cell beyond each outer
1492
- # internal line). Require it to fit within the image with only a small
1493
- # overshoot — the grid can reach/slightly exceed the edge (no border needed),
1494
- # but a pair of lines whose extrapolated grid falls far outside the image is
1495
- # not a real grid. Allows up to GRID_OVERSHOOT*pitch past each edge.
1496
709
  if (p[0] - pitch) < -GRID_OVERSHOOT * pitch:
1497
710
  return False, pitch
1498
711
  if (p[-1] + pitch) > total + GRID_OVERSHOOT * pitch:
@@ -1501,10 +714,7 @@ def _even_spacing_ok(positions, total):
1501
714
 
1502
715
 
1503
716
  def _flank_contrast(lab, line, pitch):
1504
- """Median over the line's length of the WEAKER side's colour distance to the line
1505
- itself, probing several fractions of the cell pitch each side and keeping the
1506
- nearest real content. High for a separator between two filled cells; ~0 for a line
1507
- that separates nothing."""
717
+ """Median over the line of the mean of both flanks' distance to the line colour: does this separator separate anything?"""
1508
718
  h, w = lab.shape[:2]
1509
719
  (x0, y0), (x1, y1) = line.start, line.end
1510
720
  if line.orientation == 'h':
@@ -1533,21 +743,7 @@ def _flank_contrast(lab, line, pitch):
1533
743
 
1534
744
 
1535
745
  def _seal_fraction(lab, color, orientation, pos, angle, lo, hi):
1536
- """Does this separator actually SEAL the cells it is supposed to divide?
1537
-
1538
- A grid is one rectangle REPEATED, so every internal separator runs the whole way
1539
- across the grid — the cells either side are closed boxes. A line that is the
1540
- gutter colour for only part of that span is not sealing anything: it is a sky
1541
- belt, a roofline or a chrome edge that happens to be pale where it was traced.
1542
- That distinction is invisible to the per-line gates (which judge a line by the
1543
- stretch it WAS traced along) and it is what the candidate loop needs, because the
1544
- comb hands it real, clean, full-length white lines that are nonetheless not
1545
- gutters.
1546
-
1547
- Measured across the CANDIDATE's extent (lo..hi on the perpendicular axis), not the
1548
- line's own or the image's: the same line seals a 3x3 and fails a 4x4 whose implied
1549
- grid reaches further, which is exactly the discrimination the dimension choice
1550
- needs."""
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."""
1551
747
  h, w = lab.shape[:2]
1552
748
  a = np.arange(max(0.0, lo), min(float(w if orientation == 'h' else h), hi), 2.0)
1553
749
  if a.size < 3:
@@ -1566,66 +762,25 @@ def _seal_fraction(lab, color, orientation, pos, angle, lo, hi):
1566
762
 
1567
763
 
1568
764
  def _line_span_perp(line):
1569
- """(lo, hi) extent of a line along its OWN run direction (H -> x span, V -> y
1570
- span) — i.e. how far it reaches in the perpendicular axis' coordinate."""
1571
765
  if line.orientation == 'h':
1572
766
  return (min(line.start[0], line.end[0]), max(line.start[0], line.end[0]))
1573
767
  return (min(line.start[1], line.end[1]), max(line.start[1], line.end[1]))
1574
768
 
1575
769
 
1576
770
  def _corroborate(lines, perp_lines, total):
1577
- """Keep only lines that are REAL grid separators, corroborated by the
1578
- perpendicular lines: a true internal line has >=2 perpendicular lines crossing
1579
- it that extend at least ~CORROB_FRAC of a cell PAST it on BOTH sides. A frame /
1580
- chrome line at the grid edge fails — the perpendicular lines die at the edge and
1581
- do not continue a full cell beyond it (the giveaway the user pointed out: the V
1582
- gutters above the top H line only continue a pixel or two). The grid is then
1583
- always treated as OPEN (no border reliance): the surviving lines are internal
1584
- separators and the outer cells are extrapolated one pitch out.
1585
-
1586
- `lines` are candidates on one axis; `perp_lines` the traced lines of the other
1587
- axis; `total` the image dim along `lines`' position axis. Cell size ~ the
1588
- perpendicular lines' spacing (square cells)."""
1589
771
  lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
1590
772
  perp = sorted(perp_lines, key=lambda l: l.midline_pos)
1591
773
  if len(lines) < 2:
1592
774
  return lines
1593
- # Cell size estimate: median gap between consecutive candidate lines (this
1594
- # axis) — over the gaps that COULD be a cell. A gap under MIN_CELL is not a
1595
- # cell by this module's own definition, so letting one into the median can only
1596
- # understate the pitch, and the pitch is a BAR: it is how far the perpendicular
1597
- # lines must reach past a candidate before it counts as a real internal
1598
- # separator. Understate it and chrome clears it.
1599
- #
1600
- # The comb is what made that reachable. It reports vendor chrome BELOW a board
1601
- # — a button row, a brand mark — as clean full-span lines of the gutter colour,
1602
- # which the tracer never produced, and they arrive clustered a few px apart
1603
- # rather than at the cell pitch. On a 3x3 above a footer they took the median
1604
- # from the true 87 px pitch to 55.8, which is low enough that the grid's own
1605
- # BOTTOM BORDER became corroborated: the correct 3x3 was then charged
1606
- # UNUSED_LINE_PENALTY for declining to build a row out of its own border, the
1607
- # 4x3 that swallowed it scored 320 lower and won, and that lattice died outside
1608
- # the candidate loop on _boxes_are_regular with nothing to fall back to.
1609
- # Detection went 20/20 -> 5/20 on a generator whose boards are that shape.
1610
- #
1611
- # The GUARD below deliberately still reads the RAW median. Where strays drag it
1612
- # under MIN_CELL outright, the perpendicular pitch is the better estimate and is
1613
- # what should be used; filtering first lifts such an axis back over the line and
1614
- # silently skips that rescue, which costs a real 4x4 its fourth row
1615
- # (recaptcha_1776250807779_ttvu9 — raw median 34.5, filtered 80.9, perpendicular
1616
- # 97.0, and only the last of those is the cell). So the two estimates answer two
1617
- # different questions and both are kept.
1618
775
  pos = [l.midline_pos for l in lines]
1619
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).
1620
780
  raw_cell = float(np.median(gaps)) if len(gaps) else 0.0
1621
781
  cell_gaps = [g for g in gaps if g >= MIN_CELL]
1622
782
  cell = float(np.median(cell_gaps)) if cell_gaps else 0.0
1623
783
  if raw_cell < MIN_CELL:
1624
- # This axis' own spacing is not a usable cell estimate — a few stray lines
1625
- # close together drag the median under one cell and corroboration was then
1626
- # SKIPPED ENTIRELY, which is how a grid's own border survived as an internal
1627
- # separator and a 3x3 came back as a 4x3. Cells are square, so fall back to
1628
- # what this docstring always claimed to use: the perpendicular lines' pitch.
1629
784
  pgaps = np.diff([l.midline_pos for l in perp])
1630
785
  cell = float(np.median(pgaps)) if len(pgaps) else 0.0
1631
786
  if cell < MIN_CELL:
@@ -1633,41 +788,19 @@ def _corroborate(lines, perp_lines, total):
1633
788
  need = CORROB_FRAC * cell
1634
789
  kept = []
1635
790
  for l in lines:
1636
- p = l.midline_pos # this line's position (y for H, x for V)
791
+ p = l.midline_pos
1637
792
  n_ok = 0
1638
793
  for q in perp:
1639
- lo, hi = _line_span_perp(q) # the perp line's extent in THIS axis' coord
1640
- # does q cross l and extend >= need (~1 FULL cell) past it on BOTH
1641
- # sides? A true internal separator has the perpendicular gutters
1642
- # running a full cell beyond it on each side (there is a real cell
1643
- # there). A frame/edge line fails: the perpendicular gutters only reach
1644
- # the grid border — LESS than a full cell past — because there is no
1645
- # cell beyond the edge.
794
+ lo, hi = _line_span_perp(q)
1646
795
  if (p - lo) >= need and (hi - p) >= need:
1647
796
  n_ok += 1
1648
797
  if n_ok >= 2:
1649
798
  kept.append(l)
1650
- # need at least 2 corroborated lines to define an open grid (>=3 cells)
1651
799
  return kept if len(kept) >= 2 else lines
1652
800
 
1653
801
 
1654
802
  def _complete_one_run(positions, grp, pitch, total):
1655
- """Given a REAL evenly-spaced run of internal lines (positions == grp midlines,
1656
- common pitch), yield completed runs that add at most MAX_VIRTUAL_NODES virtual
1657
- internal lines by:
1658
- * INTERPOLATING any ~k*pitch interior gap (a missing internal gutter between
1659
- two same-colour cells — the sky-bordered-row case), and
1660
- * EXTRAPOLATING one extra node beyond each end (a missing OUTER internal line
1661
- that would complete a larger grid).
1662
- `positions` carry the real midlines; virtual nodes sit exactly on the run's
1663
- pitch. Yields (completed_positions, n_virtual). The caller scores + gates them;
1664
- the cell-content gate is what ultimately rejects an over-extrapolation onto
1665
- background. Anchoring on a real consecutive run (not arbitrary clean pairs)
1666
- means a spurious off-pitch clean line can never seed a wrong lattice."""
1667
803
  real = sorted(positions)
1668
- # Candidate TRUE pitches: the run's own pitch, plus its sub-multiples — a
1669
- # 2-line run's "pitch" is the whole gap, which may actually straddle k missing
1670
- # cells (e.g. 222..413 is one missing internal line at 317, true pitch ~95).
1671
804
  sub_pitches = [pitch]
1672
805
  for k in (2, 3):
1673
806
  sp = pitch / k
@@ -1676,7 +809,6 @@ def _complete_one_run(positions, grp, pitch, total):
1676
809
  bases = []
1677
810
  seen_runs = set()
1678
811
  for tp in sub_pitches:
1679
- # 1) interior fill: insert virtual nodes wherever a gap is ~m*tp (m>=2).
1680
812
  filled = [real[0]]
1681
813
  interior_virtual = 0
1682
814
  ok = True
@@ -1696,7 +828,6 @@ def _complete_one_run(positions, grp, pitch, total):
1696
828
  continue
1697
829
  seen_runs.add(kf)
1698
830
  bases.append((list(filled), interior_virtual))
1699
- # 2) extrapolation: 0 or 1 node beyond each end, on the base's own pitch.
1700
831
  out = []
1701
832
  for base, ivirt in bases:
1702
833
  bp = (base[-1] - base[0]) / (len(base) - 1) if len(base) > 1 else pitch
@@ -1720,32 +851,11 @@ def _complete_one_run(positions, grp, pitch, total):
1720
851
 
1721
852
 
1722
853
  def _completed_candidates(lines, total, real_cand):
1723
- """Lattice completion from clean PARTIAL runs.
1724
-
1725
- Some real grids are missing one or more internal gutters because that gutter
1726
- borders two same-colour cells (e.g. a reCAPTCHA 4x4 whose top rows are sky:
1727
- the internal line between two sky tiles has no colour change to trace). The
1728
- present gutters still establish the pitch; the missing line's position is fully
1729
- determined by it. For every REAL candidate run (built by _axis_candidates from
1730
- actual lines), emit completed runs whose `positions` add EXTRAPOLATED /
1731
- INTERPOLATED virtual nodes, while `grp` keeps only the REAL clean lines (so the
1732
- colour / span / angle gates downstream judge real evidence only).
1733
-
1734
- Guard rails (this never invents grids on texture / FP images):
1735
- * we only extend runs whose real anchor lines are CLEAN (color_std <
1736
- CLEAN_LATTICE_STD) — painted gutters, not noisy photo edges;
1737
- * a spurious off-pitch line cannot seed a lattice: we extend the REAL even
1738
- runs, never arbitrary clean-line pairs;
1739
- * at most MAX_VIRTUAL_NODES virtual nodes / MAX_VIRTUAL_FRAC of the lattice;
1740
- * the completed run stays evenly spaced (re-checked via _even_spacing_ok).
1741
- The decisive content gate (_cells_have_content) still runs in the extraction
1742
- loop, so a completed lattice over a flat region (cells == gutter colour) is
1743
- rejected there exactly like any other candidate."""
854
+ """Lattice completion for a missing sky-bordered gutter; only clean painted anchors, never arbitrary clean pairs."""
1744
855
  out = {}
1745
856
  seen = set()
1746
857
  for dim, runs in real_cand.items():
1747
858
  for positions, _score, pitch, grp in runs:
1748
- # only extend clean painted runs
1749
859
  if any(l.color_std >= CLEAN_LATTICE_STD for l in grp):
1750
860
  continue
1751
861
  for run, n_virtual in _complete_one_run(positions, grp, pitch, total):
@@ -1761,8 +871,6 @@ def _completed_candidates(lines, total, real_cand):
1761
871
  ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
1762
872
  center_off = abs((run[0] + run[-1]) / 2 - total / 2) / total
1763
873
  scale_err = abs(fpitch - total / cdim) / total
1764
- # Penalise each invented node so a fully-real lattice of the SAME
1765
- # dim always outranks a completed one, and fewer virtuals win.
1766
874
  score = (center_off * 1000 + scale_err * 500 + ang_pen * 200
1767
875
  + n_virtual * VIRTUAL_NODE_PENALTY)
1768
876
  out.setdefault(cdim, []).append((run, score, fpitch, grp))
@@ -1770,13 +878,6 @@ def _completed_candidates(lines, total, real_cand):
1770
878
 
1771
879
 
1772
880
  def _axis_candidates(lines, total):
1773
- """Enumerate evenly-spaced INTERNAL-separator runs of any length K>=2 -> a grid
1774
- of K+1 cells along this axis (OPEN model: lines are internal, outer cells
1775
- extrapolated one pitch beyond the ends; no border reliance). `lines` here are
1776
- already corroboration-filtered, so frame/chrome lines are gone. Greedy run-grow
1777
- from each (start, pitch) seed, snapping to the nearest line within EVEN_TOL.
1778
- Returns {dim: [(internal_positions, score, pitch, internal_lines), ...]} keyed
1779
- by cell-dimension dim = K+1."""
1780
881
  lines = sorted(_internal(lines, total), key=lambda l: l.midline_pos)
1781
882
  n = len(lines)
1782
883
  cand = {}
@@ -1803,15 +904,7 @@ def _axis_candidates(lines, total):
1803
904
  run_idx.append(best_k)
1804
905
  pos = lines[best_k].midline_pos
1805
906
  jj = best_k
1806
- # Emit EVERY prefix of the grown run, not just the maximal one. The
1807
- # run's last line may be the grid's outer BORDER rather than an
1808
- # internal separator (a geetest/prosopo panel draws one), and then the
1809
- # correct lattice is the shorter prefix — which the greedy grow had
1810
- # already swallowed, so it was never a candidate and detection fell
1811
- # through to a half-pitch completed lattice. Longer runs still win on
1812
- # score: the `unused` penalty in extract_grid_from_lines charges every
1813
- # corroborated line a candidate leaves out, so a genuine 4x4 is never
1814
- # dropped to its 3-row prefix.
907
+ # Every prefix, not just the maximal run: the last line may be a geetest/prosopo panel border.
1815
908
  for end in range(2, len(run_idx) + 1):
1816
909
  sub = run_idx[:end]
1817
910
  positions = [lines[r].midline_pos for r in sub]
@@ -1819,7 +912,7 @@ def _axis_candidates(lines, total):
1819
912
  if not ok:
1820
913
  continue
1821
914
  grp = [lines[r] for r in sub]
1822
- dim = len(sub) + 1 # OPEN: K internal lines -> K+1 cells
915
+ dim = len(sub) + 1
1823
916
  if dim < MIN_GRID_DIM:
1824
917
  continue
1825
918
  key = (dim, tuple(round(p) for p in positions))
@@ -1831,10 +924,6 @@ def _axis_candidates(lines, total):
1831
924
  scale_err = abs(pitch - total / dim) / total
1832
925
  score = center_off * 1000 + scale_err * 500 + ang_pen * 200
1833
926
  cand.setdefault(dim, []).append((positions, score, pitch, grp))
1834
- # Lattice completion: add candidates that interpolate/extrapolate a missing
1835
- # internal gutter from a clean partial run (sky-bordered grids). They carry a
1836
- # virtual-node penalty so they only win when no fully-real lattice of the same
1837
- # dimension exists, and are deduped against the real candidates above.
1838
927
  for dim, comps in _completed_candidates(lines, total, dict(cand)).items():
1839
928
  bucket = cand.setdefault(dim, [])
1840
929
  existing = {tuple(round(p) for p in c[0]) for c in bucket}
@@ -1849,30 +938,17 @@ def _axis_candidates(lines, total):
1849
938
 
1850
939
 
1851
940
  def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1852
- """Identify a 3x3 or 4x4 grid from traced lines and emit row-major boxes.
1853
- Returns (boxes, size, slant) or (None, None, None). All colour gates are
1854
- RELATIVE — colour spread among the chosen separators, H-mean vs V-mean,
1855
- same-as-gutter for the off-lattice count — never a check against a specific
1856
- colour, so a grid of ANY uniform border colour is detectable. (`lab` is
1857
- accepted for API symmetry; the active gates are line-based.)"""
1858
- # Corroboration filter: drop frame/chrome lines (only KEEP lines crossed by
1859
- # >=2 perpendicular lines that extend ~1 cell past them on both sides). Mutual:
1860
- # filter H using V and V using H, on the raw traced lines.
941
+ """Every colour gate is RELATIVE, so a grid of any uniform border colour is detectable."""
1861
942
  h_keep = _corroborate(h_lines, v_lines, h)
1862
943
  v_keep = _corroborate(v_lines, h_lines, w)
1863
- h_keep_int = _internal(h_keep, h) # corroborated internal lines per axis
1864
- v_keep_int = _internal(v_keep, w) # — a candidate should USE all of them
1865
- # One reference colour for every seal test, so the answers can be cached across
1866
- # candidates: it is the PAINTED colour the tracer proved is in this image, the
1867
- # same one the comb went looking for, not a per-candidate mean.
944
+ h_keep_int = _internal(h_keep, h)
945
+ v_keep_int = _internal(v_keep, w)
946
+ # One painted reference colour for every seal test (the one the comb looked for), so answers cache across candidates.
1868
947
  clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
1869
948
  seal_col = np.median(np.array([l.color_lab for l in clean]), axis=0) if clean else None
1870
949
  seal_cache = {}
1871
950
 
1872
951
  def sealed(orientation, pos, angle, lo, hi):
1873
- """Is this separator the gutter colour the whole way across the candidate's
1874
- grid? Asked of INVENTED lattice positions as well as traced ones — a node the
1875
- image seals is not an invention, it is a gutter the tracer happened to miss."""
1876
952
  if lab is None or seal_col is None:
1877
953
  return True
1878
954
  k = (orientation, round(pos), round(angle, 3), int(lo), int(hi))
@@ -1884,10 +960,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1884
960
  v_cand = _axis_candidates(v_keep, w)
1885
961
  best = None
1886
962
  best_score = float('inf')
1887
- # rows = (#internal H lines)+1, cols = (#internal V lines)+1; allow them to
1888
- # DIFFER (rectangular grids like 6x4) — cells stay square (hd~vd), the grid
1889
- # need not be. Enumerate every (rows, cols) with both dims >= MIN_GRID_DIM.
1890
- for rows in sorted(h_cand): # keyed by cell-dimension now
963
+ for rows in sorted(h_cand):
1891
964
  if rows < MIN_GRID_DIM:
1892
965
  continue
1893
966
  for cols in sorted(v_cand):
@@ -1895,90 +968,44 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1895
968
  continue
1896
969
  for hpos, hsc, hd, hlns in h_cand[rows][:25]:
1897
970
  for vpos, vsc, vd, vlns in v_cand[cols][:25]:
1898
- # square-ness: H pitch ~ V pitch (CELLS are roughly square,
1899
- # even when the grid is rectangular)
1900
971
  s_diff = abs(hd - vd) / max(hd, vd)
1901
972
  if s_diff > 0.22:
1902
973
  continue
1903
- # full-span gate: every chosen lattice line must cross
1904
- # essentially the WHOLE grid (>= (dim-0.5)*pitch end to end). H
1905
- # lines span the grid's HEIGHT (rows*hd); V lines its WIDTH
1906
- # (cols*vd). A short edge spanning only the central cell (object
1907
- # in a reference photo, stray texture) is rejected.
1908
974
  h_min = (cols - FULL_SPAN_MARGIN) * vd
1909
975
  v_min = (rows - FULL_SPAN_MARGIN) * hd
1910
976
  if (min(_line_extent(l) for l in hlns) < h_min
1911
977
  or min(_line_extent(l) for l in vlns) < v_min):
1912
978
  continue
1913
979
  alll = hlns + vlns
1914
- # Colour consistency: ALL gutters of a real grid share one
1915
- # colour. Photo "grids" mix unrelated edges -> wide spread.
1916
980
  ccols = np.array([l.color_lab for l in alll])
1917
981
  avg = ccols.mean(axis=0)
1918
982
  de = np.sqrt(np.sum((ccols - avg) ** 2, axis=1))
1919
983
  if np.max(de) > GRID_COLOR_TOL:
1920
984
  continue
1921
- # angle coherence: H slant ~ -V slant (consistent global tilt)
1922
985
  h_ang = np.mean([l.angle for l in hlns])
1923
986
  v_ang = np.mean([l.angle for l in vlns])
1924
987
  if abs(h_ang + v_ang) > GRID_ANGLE_TOL:
1925
988
  continue
1926
- # cross-axis colour match: H gutters' colour ~ V gutters' colour
1927
989
  h_col = np.mean([l.color_lab for l in hlns], axis=0)
1928
990
  v_col = np.mean([l.color_lab for l in vlns], axis=0)
1929
991
  if _de(h_col, v_col) > XAXIS_COLOR_TOL:
1930
992
  continue
1931
993
  slant = np.tan(h_ang)
1932
994
  ang_inc = abs(h_ang + v_ang) * 200
1933
- # Prefer the candidate that USES ALL corroborated lines. After
1934
- # corroboration the surviving lines are all real internal
1935
- # separators, so the correct grid is the one that incorporates
1936
- # every one of them — not a sub-set that skips some (which would
1937
- # under-count, e.g. a 4x4's [r1,r2,r3] dropped to a 3-row
1938
- # [r2,r3]). Penalise each corroborated line the candidate leaves
1939
- # UNUSED. (rows-1) H lines and (cols-1) V lines are used.
1940
- # CLAMPED AT 0 PER AXIS: a completed lattice can use MORE lines
1941
- # than were corroborated (the extras are invented virtual nodes),
1942
- # and an unclamped count turns negative there — paying a BONUS
1943
- # for inventing rows, which is how a half-pitch 5x4 once outscored
1944
- # a 4x4 built from the same two real gutters.
1945
- # Grid-span fit: the perpendicular gutters run the WHOLE grid and
1946
- # stop at its outer borders. The grid's extrapolated borders are
1947
- # one pitch beyond the outer internal lines: H rows occupy
1948
- # [hpos[0]-hd, hpos[-1]+hd]; V cols [vpos[0]-vd, vpos[-1]+vd]. If
1949
- # the V gutters extend a FULL CELL past a row border (or H gutters
1950
- # past a col border), there is an UNCOVERED cell there — the chosen
1951
- # dimension is too small (a 4-row grid mislabelled 3 rows leaves
1952
- # the top sky row uncovered while its V gutters still run all 4
1953
- # cells). Penalise only an uncovered overshoot >= MISSING_LINE_FRAC
1954
- # of a pitch, so a gutter that merely BLEEDS a little past the grid
1955
- # (hcaptcha's V gutters reach the submit bar, ~0.65 cell) does NOT
1956
- # invent a row. This is what lets the completed 4x4 beat the 3-row
1957
- # subset without over-counting hcaptcha 3x3.
1958
995
  hsorted = sorted(hpos); vsorted = sorted(vpos)
1959
996
  row_top, row_bot = hsorted[0] - hd, hsorted[-1] + hd
1960
997
  col_lft, col_rgt = vsorted[0] - vd, vsorted[-1] + vd
1961
- vy = [_line_span_perp(l) for l in vlns] # V gutters' y-extent
1962
- hx = [_line_span_perp(l) for l in hlns] # H gutters' x-extent
998
+ vy = [_line_span_perp(l) for l in vlns]
999
+ hx = [_line_span_perp(l) for l in hlns]
1963
1000
  vy_lo = float(np.median([s[0] for s in vy]))
1964
1001
  vy_hi = float(np.median([s[1] for s in vy]))
1965
1002
  hx_lo = float(np.median([s[0] for s in hx]))
1966
1003
  hx_hi = float(np.median([s[1] for s in hx]))
1967
- # An overshoot only signals a MISSING row/col when it is a real
1968
- # extra cell, not the gutter colour bleeding into a one-sided
1969
- # margin / white footer / header bar. The bleed signature is
1970
- # ASYMMETRY: the gutter runs to the image EDGE on the overshoot
1971
- # side while its OTHER end stays well inside the image (e.g.
1972
- # hcaptcha's white footer touches the bottom edge but the grid top
1973
- # is inset). A grid that genuinely fills an axis reaches BOTH edges
1974
- # symmetrically (its outer cells are real) — and then has no
1975
- # overshoot to suppress anyway (its border sits at the edge). So we
1976
- # suppress only a one-sided edge bleed. EDGE_BLEED_PX absorbs a
1977
- # 1-2px crop border.
1978
1004
  v_top_edge = vy_lo <= EDGE_BLEED_PX
1979
1005
  v_bot_edge = vy_hi >= h - EDGE_BLEED_PX
1980
1006
  h_lft_edge = hx_lo <= EDGE_BLEED_PX
1981
1007
  h_rgt_edge = hx_hi >= w - EDGE_BLEED_PX
1008
+ # A one-sided edge bleed (hCaptcha's white footer) is not a missing row; a grid that fills an axis reaches both edges.
1982
1009
  def _missing(uncov, pitch, bleed):
1983
1010
  if bleed:
1984
1011
  return 0.0
@@ -1989,31 +1016,15 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1989
1016
  + _missing(max(0.0, vy_hi - row_bot), hd, v_bot_edge and not v_top_edge)
1990
1017
  + _missing(max(0.0, col_lft - hx_lo), vd, h_lft_edge and not h_rgt_edge)
1991
1018
  + _missing(max(0.0, hx_hi - col_rgt), vd, h_rgt_edge and not h_lft_edge))
1992
- # Does the repeated rectangle actually close? Every chosen
1993
- # separator must be the gutter colour the whole way across the
1994
- # grid this candidate implies.
1995
1019
  def _seal(orientation, pos, angle):
1996
1020
  return sealed(orientation, pos, angle,
1997
1021
  *((col_lft, col_rgt) if orientation == 'h'
1998
1022
  else (row_top, row_bot)))
1999
1023
  unsealed = sum(1 for l in alll
2000
1024
  if not _seal(l.orientation, l.midline_pos, l.angle))
2001
- # Refund the invention charge for every node the IMAGE confirms.
2002
- # A completed lattice pays VIRTUAL_NODE_PENALTY per interpolated
2003
- # line because a guessed line is unsupported — but a guess the
2004
- # gutter colour runs straight through is not a guess, it is the
2005
- # gutter the tracer lost. Without the refund the true 4x4 on every
2006
- # sky-backed reCAPTCHA lost by ~470 to a 3-row lattice built on a
2007
- # pale belt: the correct answer was being charged 600 for the one
2008
- # thing that made it correct.
2009
- # Only INTERIOR nodes — ones bracketed by a real gutter on both
2010
- # sides — can be refunded. An interpolated line sits in a span the
2011
- # tracer has already proved is grid, so sealing it confirms a
2012
- # gutter that was missed. An EXTRAPOLATED one grows the lattice
2013
- # into unproven ground, and on a white-backgrounded hCaptcha board
2014
- # the margin beyond the last gutter is the gutter colour too, so it
2015
- # seals trivially: refunding it turned a correct 3x3 into a 3x4
2016
- # whose extra column boundary ran down the middle of a tile.
1025
+ # Refund the invention charge for INTERIOR nodes the image seals (a missed gutter, not a guess): without it
1026
+ # the true 4x4 on every sky-backed reCAPTCHA lost by ~470. Extrapolated nodes seal trivially on a white
1027
+ # margin and refunding them turned a correct 3x3 into a 3x4.
2017
1028
  def _confirm(pos, lns, orientation, angle):
2018
1029
  if len(lns) < 2:
2019
1030
  return 0
@@ -2025,14 +1036,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
2025
1036
  and _seal(orientation, p, angle))
2026
1037
  confirmed = (_confirm(hpos, hlns, 'h', h_ang)
2027
1038
  + _confirm(vpos, vlns, 'v', v_ang))
2028
- # Prefer the candidate that USES every real internal separator, so
2029
- # a 4x4's [r1,r2,r3] is not silently dropped to a 3-row [r2,r3].
2030
- # Only SEALED lines are counted: the comb hands this loop clean,
2031
- # full-length lines of the gutter colour that are nonetheless not
2032
- # cell boundaries (a white sky belt, a chrome rule), and charging
2033
- # a candidate 400 for declining to build a grid out of one is how
2034
- # the true 4x4 lost on every sky-backed reCAPTCHA. A line that
2035
- # seals nothing is not a separator, so leaving it out is free.
1039
+ # Only SEALED lines count as unused: the comb hands over clean sky belts that are not cell boundaries.
2036
1040
  chosen = {id(l) for l in alll}
2037
1041
  unused = (sum(1 for l in h_keep_int if id(l) not in chosen
2038
1042
  and _seal('h', l.midline_pos, l.angle))
@@ -2043,10 +1047,8 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
2043
1047
  + unsealed * UNSEALED_PENALTY
2044
1048
  - confirmed * VIRTUAL_NODE_PENALTY)
2045
1049
  if score < best_score:
1050
+ # Content gate IN the loop so a rejected over-count lets a smaller valid candidate win.
2046
1051
  boxes = _generate_grid(rows, cols, hpos, vpos, hd, vd, h, w, slant)
2047
- # Cell-content gate IN the loop so a rejected (over-counted)
2048
- # candidate lets a smaller valid one win, instead of killing
2049
- # detection outright.
2050
1052
  if boxes and _cells_have_content(lab, boxes, rows, cols,
2051
1053
  hlns + vlns, unsealed == 0):
2052
1054
  best_score = score
@@ -2057,66 +1059,30 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
2057
1059
  return None, None, None
2058
1060
  (boxes, rows, cols, slant, gutter_color, hpos, hd, vpos, vd, chosen_lns,
2059
1061
  fully_sealed) = best
2060
- # Is the CHOSEN grid built from clean painted gutters? If so the lattice is
2061
- # already proven a real grid (a textured-photo FP has noisy pseudo-gutters,
2062
- # std well above the threshold). For such a proven grid we do NOT count a few
2063
- # stray CLEAN full-span lines (a sky horizon, a power line, a UI rule) as
2064
- # off-lattice evidence — they are painted edges in the scene, not extra cell
2065
- # boundaries. FPs keep the strict count because their own gutters are noisy,
2066
- # so this relaxation never applies to them.
2067
1062
  grid_gutters_clean = (float(np.mean([l.color_std for l in chosen_lns]))
2068
1063
  < CLEAN_GUTTER_STD) if chosen_lns else False
2069
- # Off-lattice gate (the main FP killer for textured photos): within the grid's
2070
- # OWN span, a REAL grid has no extra same-colour lines that break the regular
2071
- # cell spacing — every cell boundary sits ON the lattice (k*pitch from the
2072
- # chosen internal lines). A textured photo (grass, foliage, fences) yields many
2073
- # parallel same-colour edges scattered OFF the lattice. So count same-colour
2074
- # lines that fall OFF the lattice; too many -> photo noise, not a grid.
2075
- #
2076
- # CRITICAL: only consider lines INSIDE the chosen internal-line span
2077
- # (anchors[0]..anchors[-1]). The grid has no reliable outer border, so the
2078
- # real grid's top edge / a UI footer bar can sit a non-pitch distance ABOVE
2079
- # the first internal line or BELOW the last — those are frame/chrome, not
2080
- # evidence the central cells are irregular, and must not count. The central
2081
- # closed region is the only reliable signal (per design).
2082
1064
  def _off_lattice(lines, total, anchors, pitch, orientation, ext):
1065
+ """Same-colour lines off the pitch inside the grid's own span; the textured-photo false-positive killer."""
2083
1066
  off_pos = []
2084
1067
  clean_skipped = 0
2085
1068
  lo, hi = anchors[0], anchors[-1]
2086
1069
  for l in _internal(lines, total):
2087
1070
  if not (lo - LATTICE_TOL * pitch <= l.midline_pos <= hi + LATTICE_TOL * pitch):
2088
- continue # outside the central span — frame/chrome
1071
+ continue
2089
1072
  if _de(l.color_lab, gutter_color) > GRID_COLOR_TOL:
2090
- continue # different colour — not a gutter
1073
+ continue
1074
+ # Gutter-coloured for only part of the width divides nothing; three such belts once rejected a correct 4x4.
2091
1075
  if not sealed(orientation, l.midline_pos, l.angle, *ext):
2092
- # Gutter-coloured for only PART of the grid's width, so it divides
2093
- # nothing: a pale belt across a sky, a roofline, a chrome rule. This
2094
- # gate exists to catch cell boundaries that break the pitch, and a
2095
- # line that is not a boundary anywhere is not evidence of that. Three
2096
- # such belts on one sky-backed 4x4 were enough to reject the correct
2097
- # lattice on a gate that allows one stray.
2098
1076
  continue
2099
- # distance to the nearest lattice node (anchor + k*pitch)
2100
1077
  off = min(abs((l.midline_pos - anchors[0]) - round((l.midline_pos - anchors[0]) / pitch) * pitch),
2101
1078
  abs((l.midline_pos - anchors[-1]) - round((l.midline_pos - anchors[-1]) / pitch) * pitch))
2102
1079
  if off > LATTICE_TOL * pitch:
2103
- # On a proven clean grid we forgive a FEW stray CLEAN full-span lines
2104
- # (a sky horizon, a power line, a UI rule sitting off the lattice) —
2105
- # but only up to MAX_OFF_LATTICE_CLEAN of them. A textured photo whose
2106
- # white-ish edges happen to be clean produces MANY such strays; once
2107
- # they exceed the small allowance we count the rest, so the off-lattice
2108
- # FP gate still fires on texture. Noisy strays always count.
1080
+ # On a proven clean grid forgive a few clean strays (a horizon, a UI rule); noisy strays always count.
2109
1081
  if (grid_gutters_clean and l.color_std < OFF_LATTICE_CLEAN_STD
2110
1082
  and clean_skipped < MAX_OFF_LATTICE_CLEAN):
2111
1083
  clean_skipped += 1
2112
1084
  continue
2113
1085
  off_pos.append(l.midline_pos)
2114
- # Count CLUSTERS, not lines. Two off-lattice lines closer together than
2115
- # OFF_LATTICE_CLUSTER_PX cannot both be cell boundaries — cells have a minimum
2116
- # size — so they are one busy tile's internal edges (a roofline, a railing, a
2117
- # horizon) and are one piece of evidence, not several. The textured-photo FP
2118
- # mode scatters strays across the WHOLE grid, so it still counts many clusters
2119
- # and is still rejected.
2120
1086
  off_pos.sort()
2121
1087
  return sum(1 for i, p in enumerate(off_pos)
2122
1088
  if i == 0 or p - off_pos[i - 1] > OFF_LATTICE_CLUSTER_PX)
@@ -2127,8 +1093,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
2127
1093
  return None, None, None
2128
1094
  if not _boxes_are_regular(boxes, rows, cols):
2129
1095
  return None, None, None
2130
- # Do the chosen separators actually SEPARATE anything? (see GRID_FLANK_MIN_DE).
2131
- # An H separator's flanks lie a row pitch away, a V separator's a column pitch away.
1096
+ # On the chosen grid only: as a per-line filter it deleted the strays the off-lattice gate counts (FP 2 -> 4 -> 6).
2132
1097
  if lab is not None:
2133
1098
  if float(np.median([_flank_contrast(lab, l, hd if l.orientation == 'h' else vd)
2134
1099
  for l in chosen_lns])) < (SEALED_FLANK_MIN_DE if fully_sealed
@@ -2147,12 +1112,10 @@ def _detect_grid(image_path, seed_bias=0.0):
2147
1112
  return None
2148
1113
  h, w = img.shape[:2]
2149
1114
  lab = _to_lab(img)
2150
- tols = walk_tolerances(image_noise(lab)) # once per image, not per axis
1115
+ tols = walk_tolerances(image_noise(lab))
2151
1116
  h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias, tols=tols)
2152
1117
  v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias, tols=tols)
2153
- # Both axes are traced before either is judged: the comb takes its colour from
2154
- # whichever axis found a clean painted line, so an axis with too few traces of
2155
- # its own is exactly the case the other axis' evidence is there to rescue.
1118
+ # Both axes traced before either is judged: the comb takes its colour from whichever axis found a clean line.
2156
1119
  h_lines, v_lines = _add_comb_lines(lab, h_lines, v_lines)
2157
1120
  if len(_internal(h_lines, h)) < 2 or len(_internal(v_lines, w)) < 2:
2158
1121
  return None
@@ -2160,77 +1123,38 @@ def _detect_grid(image_path, seed_bias=0.0):
2160
1123
  return boxes
2161
1124
 
2162
1125
 
2163
- def find_grid(image_path: str, debug_manager=None, slant_to_try: Optional[float] = None) -> Optional[List[Tuple[int, int, int, int]]]:
2164
- """Main entry point for grid detection (public contract unchanged).
2165
-
2166
- Detects a 3x3 or 4x4 grid by tracing consistent-colour separator lines of any
2167
- border colour and small tilt. `slant_to_try` is accepted for backward
2168
- compatibility; the tracer recovers slant on its own so it is used only as a
2169
- seed bias hint. Returns row-major cell boxes, or None.
2170
- """
1126
+ def find_grid(image_path: str, slant_to_try: Optional[float] = None) -> Optional[List[Tuple[int, int, int, int]]]:
2171
1127
  seed_bias = float(slant_to_try) if slant_to_try is not None else 0.0
2172
- boxes = _detect_grid(image_path, seed_bias=seed_bias)
2173
- if debug_manager and getattr(debug_manager, 'enabled', False) and boxes:
2174
- image_basename = os.path.basename(image_path)
2175
- debug_path = os.path.join(str(getattr(debug_manager, 'base_dir', ".")),
2176
- f"grid_final_{image_basename}")
2177
- try:
2178
- get_numbered_grid_overlay(image_path, boxes, output_path=debug_path)
2179
- except Exception:
2180
- pass
2181
- return boxes
2182
-
1128
+ return _detect_grid(image_path, seed_bias=seed_bias)
2183
1129
 
2184
- def detect_selected_cells(image_path, grid_boxes, debug_manager=None):
2185
- """
2186
- Checks each grid box to see if it contains a 'selected' badge (blue checkmark)
2187
- or a 'loading' spinner.
2188
1130
 
2189
- - reCAPTCHA puts a small blue badge in the **top-left** of the tile.
2190
- - hCaptcha puts a blue circle-with-check overlay in the **top-right** AND
2191
- darkens the entire tile.
2192
-
2193
- Returns (list of selected indices, list of loading indices).
2194
- """
1131
+ def detect_selected_cells(image_path, grid_boxes):
2195
1132
  img = cv2.imread(image_path)
2196
1133
  if img is None: return [], []
2197
1134
  sel, ld = [], []
2198
- # OpenCV uses BGR not RGB, so these are swapped from the doc colors.
2199
- recap_blue = (27, 115, 232) # reCAPTCHA blue badge (#1B73E8)
2200
- hcap_blue = (188, 117, 15) # hCaptcha blue check (#0F75BC) in BGR
1135
+ recap_blue = (27, 115, 232)
1136
+ hcap_blue = (188, 117, 15)
2201
1137
  for i, box in enumerate(grid_boxes):
2202
1138
  cell = img[box[1]:box[3], box[0]:box[2]]
2203
1139
  if cell.size == 0: continue
2204
1140
 
2205
1141
  h_cell, w_cell = cell.shape[:2]
2206
- # Top-left (reCAPTCHA).
2207
1142
  tl = cell[0:int(h_cell * 0.4), 0:int(w_cell * 0.4)]
2208
1143
  if tl.size > 0 and _has_badge(tl, recap_blue):
2209
1144
  sel.append(i + 1)
2210
1145
  continue
2211
- # Top-right (hCaptcha). The badge is a small filled blue circle
2212
- # (~10-14 px); _has_badge's circularity check is too strict at that
2213
- # size, so we use a simple color-presence test: "is there a strongly
2214
- # blue-dominant cluster in the top-right corner?"
2215
1146
  tr = cell[0:max(8, int(h_cell * 0.22)), int(w_cell * 0.78):]
2216
1147
  if tr.size > 0 and _has_hcaptcha_check(tr):
2217
1148
  sel.append(i + 1)
2218
1149
  continue
2219
1150
 
2220
- # Center for loading spinner.
2221
1151
  cntr = cell[int(h_cell * 0.3):int(h_cell * 0.7), int(w_cell * 0.3):int(w_cell * 0.7)]
2222
1152
  if cntr.size > 0 and _is_loading(cntr, recap_blue):
2223
1153
  ld.append(i + 1)
2224
1154
  return sel, ld
2225
1155
 
2226
- # --- per-cell state helpers ---
2227
- # These take a 1-indexed `cell_number` (matching detect_selected_cells and the
2228
- # grid_boxes[v - 1] click mapping in solver.py) and read pixel values from the
2229
- # cropped cell. All guard against a missing image, empty crop, or out-of-range
2230
- # index and return a safe default rather than raising.
2231
1156
 
2232
1157
  def _crop_cell(image_path, grid_boxes, cell_number):
2233
- """Load image and return the BGR crop for a 1-indexed cell, or None."""
2234
1158
  img = cv2.imread(image_path)
2235
1159
  if img is None:
2236
1160
  return None
@@ -2242,16 +1166,11 @@ def _crop_cell(image_path, grid_boxes, cell_number):
2242
1166
 
2243
1167
  def is_empty_cell(image_path, grid_boxes, cell_number,
2244
1168
  white_frac=0.97, l_thresh=92.0, chroma_thresh=6.0):
2245
- """True if the cell is effectively blank: an overwhelming majority of
2246
- pixels are near-white AND near-neutral (low chroma). Uses LAB to match the
2247
- grid-line whiteness test used elsewhere in this module, so a faintly tinted
2248
- "white" still counts while a saturated bright tile (e.g. sky) does not.
2249
- 1-indexed cell."""
2250
1169
  cell = _crop_cell(image_path, grid_boxes, cell_number)
2251
1170
  if cell is None:
2252
1171
  return False
2253
1172
  lab = cv2.cvtColor(cell, cv2.COLOR_BGR2LAB).astype(np.float32)
2254
- L = lab[:, :, 0] * (100.0 / 255.0) # OpenCV packs L into 0..255
1173
+ L = lab[:, :, 0] * (100.0 / 255.0)
2255
1174
  a = lab[:, :, 1] - 128.0
2256
1175
  b = lab[:, :, 2] - 128.0
2257
1176
  chroma = np.sqrt(a * a + b * b)
@@ -2260,10 +1179,6 @@ def is_empty_cell(image_path, grid_boxes, cell_number,
2260
1179
 
2261
1180
  def is_cell_opacity_changing(image_path_a, image_path_b, grid_boxes,
2262
1181
  cell_number, change_thresh=0.02):
2263
- """True if the cell visibly changed between two frames (still fading/loading).
2264
- Mirrors the absdiff -> gray -> threshold -> ratio approach used by
2265
- check-movement. 1-indexed cell. Returns False if either crop is
2266
- unavailable or the crops differ in shape."""
2267
1182
  a = _crop_cell(image_path_a, grid_boxes, cell_number)
2268
1183
  b = _crop_cell(image_path_b, grid_boxes, cell_number)
2269
1184
  if a is None or b is None or a.shape != b.shape:
@@ -2274,76 +1189,29 @@ def is_cell_opacity_changing(image_path_a, image_path_b, grid_boxes,
2274
1189
  ratio = cv2.countNonZero(thr) / (thr.shape[0] * thr.shape[1])
2275
1190
  return ratio > change_thresh
2276
1191
 
2277
- def wait_for_cell_loaded(frame_paths, grid_boxes, cell_number):
2278
- """Given >=1 chronological frame screenshots, return True once the cell is
2279
- loaded: NOT empty in the latest frame AND (if >=2 frames) NOT changing
2280
- between the last two. Composes is_empty_cell + is_cell_opacity_changing.
2281
-
2282
- The CLI does not own the browser loop, so this can't do a time-based wait;
2283
- the JS caller captures frames over time and passes the most recent ones.
2284
- The grid_boxes must come from a single reference frame. 1-indexed cell."""
2285
- if not frame_paths:
2286
- return False
2287
- last = frame_paths[-1]
2288
- if is_empty_cell(last, grid_boxes, cell_number):
2289
- return False
2290
- if len(frame_paths) >= 2:
2291
- if is_cell_opacity_changing(frame_paths[-2], last, grid_boxes, cell_number):
2292
- return False
2293
- return True
2294
1192
 
2295
- def is_cell_selected(image_path, grid_boxes, cell_number, debug_manager=None):
2296
- """True if the given 1-indexed cell is selected. Thin wrapper over
2297
- detect_selected_cells (the canonical badge detector) for API symmetry."""
2298
- selected, _ = detect_selected_cells(image_path, grid_boxes, debug_manager)
2299
- return cell_number in selected
2300
-
2301
- # How far outside the teal blob a white pixel may sit and still be read as part
2302
- # of the mark. Two pixels, because the ringed rendering draws the white ON the
2303
- # disc's rim and fragments the teal underneath it, so a strict inside-the-hull
2304
- # test finds only a quarter of those. Wider does not pay: at 4px the phantom
2305
- # rate doubles for ~1 point of recall.
1193
+ # 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.
2306
1194
  _HCAPTCHA_GLYPH_SLACK_PX = 2.0
2307
1195
 
2308
1196
 
2309
1197
  def _has_hcaptcha_check(roi):
2310
- """Detect hCaptcha's selected-state blue circle in the top-right corner.
2311
-
2312
- The badge is a small cyan-teal circle (~10-14 px) carrying a white glyph.
2313
- Counting pixels is not enough on its own and never was: >=8 teal pixels
2314
- ANYWHERE in the corner plus >=2 near-white pixels ANYWHERE in the same patch
2315
- is satisfied by blue sky with a white pole in it, and that tile is then
2316
- reported as already selected — so the solver drops it from the model's
2317
- answer and never clicks it. Measured at 74 phantom selections over 3051
2318
- corners of boards with nothing selected on them.
2319
-
2320
- What a badge has and a photograph does not is that the white BELONGS TO the
2321
- teal mark: it sits inside the disc, or hugs its rim. So the counts stay as a
2322
- cheap gate, and the verdict is that >=2 of the white pixels lie within
2323
- `_HCAPTCHA_GLYPH_SLACK_PX` of the largest teal blob's convex hull. A photo
2324
- puts the teal in one place and the white in another, and fails that.
2325
- """
1198
+ """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)."""
2326
1199
  if roi is None or roi.size == 0:
2327
1200
  return False
2328
1201
  flat = roi.reshape(-1, 3).astype(np.int32)
2329
- # Teal/cyan: B>120, G>80, R<80, AND B-R gap > 60. Deliberately loose — the
2330
- # badge's exact colour is not known to a delta-E's precision, and pinning it
2331
- # to one costs more real badges than it saves phantoms.
1202
+ # Deliberately loose: pinning the badge colour to a delta-E costs more real badges than it saves phantoms.
2332
1203
  teal = (
2333
1204
  (flat[:, 0] > 120)
2334
1205
  & (flat[:, 1] > 80)
2335
1206
  & (flat[:, 2] < 80)
2336
1207
  & (flat[:, 0] - flat[:, 2] > 60)
2337
1208
  )
2338
- # Bright white check mark inside the circle.
2339
1209
  white = (flat[:, 0] > 220) & (flat[:, 1] > 220) & (flat[:, 2] > 220)
2340
1210
  if int(teal.sum()) < 8 or int(white.sum()) < 2:
2341
1211
  return False
2342
1212
 
2343
1213
  h, w = roi.shape[:2]
2344
- # Close first: the glyph cuts the disc into pieces, and the mark is one
2345
- # blob, so the pieces have to be put back together before the largest is
2346
- # taken.
1214
+ # Close first: the glyph cuts the disc into pieces, and the largest blob must be the whole mark.
2347
1215
  mask = cv2.morphologyEx(
2348
1216
  teal.reshape(h, w).astype(np.uint8) * 255, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8)
2349
1217
  )
@@ -2361,54 +1229,33 @@ def _has_hcaptcha_check(roi):
2361
1229
 
2362
1230
 
2363
1231
  def _has_badge(roi, rgb):
2364
- """
2365
- Uses color segmentation and shape analysis to detect the reCAPTCHA
2366
- selection badge (a blue circle/checkmark).
2367
- """
2368
1232
  mask = _create_delta_e_mask(roi, rgb, 8.0)
2369
- # Check if there's enough blue color
2370
1233
  if cv2.countNonZero(mask) <= roi.size * 0.003: return False
2371
-
2372
- # Shape analysis: look for a circular-ish contour
1234
+
2373
1235
  contours, _ = cv2.findContours(cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8)), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
2374
1236
  if not contours: return False
2375
1237
  cnt = max(contours, key=cv2.contourArea)
2376
1238
  hull = cv2.convexHull(cnt)
2377
1239
  area, perim = cv2.contourArea(hull), cv2.arcLength(hull, True)
2378
-
2379
- # Circularity check
1240
+
2380
1241
  if perim == 0 or (4*np.pi*area/(perim*perim)) < 0.8: return False
2381
-
2382
- # Position check: badge should be in the top-left portion of its ROI
1242
+
2383
1243
  M = cv2.moments(hull)
2384
1244
  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
2385
1245
  return True
2386
1246
 
2387
1247
  def _is_loading(roi, rgb):
2388
- """
2389
- Detects if a cell is in a 'loading' state based on the amount
2390
- of blue color in the center.
2391
- """
2392
1248
  mask = _create_delta_e_mask(roi, rgb, 12.0)
2393
- # Loading state usually has a specific range of blue pixels
2394
1249
  return 0.05 < (cv2.countNonZero(mask) / (roi.shape[0]*roi.shape[1])) < 0.6 if roi.size > 0 else False
2395
1250
 
2396
1251
  def _create_delta_e_mask(img, rgb, thr):
2397
- """
2398
- Creates a binary mask of pixels that are within a certain
2399
- perceptual distance (Delta E) from the target RGB color.
2400
- """
2401
1252
  t_lab = cv2.cvtColor(np.array([[[rgb[2], rgb[1], rgb[0]]]], dtype=np.uint8), cv2.COLOR_BGR2LAB)[0, 0]
2402
1253
  diff = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) - t_lab.astype(np.float32)
2403
1254
  return (np.sqrt(np.sum(diff**2, axis=2)) <= thr).astype(np.uint8)*255
2404
1255
 
2405
1256
  def get_numbered_grid_overlay(image_path, grid_boxes, output_path=None):
2406
- """
2407
- Generates a debug image with numbered boxes overlaid on the original image.
2408
- Uses high-visibility red labels with white text in the top-right.
2409
- """
2410
1257
  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)]
2411
1258
  if output_path is None:
2412
1259
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf: output_path = tf.name
2413
- add_overlays_to_image(image_path, ov, output_path=output_path, label_position="top-right")
1260
+ add_overlays_to_image(image_path, ov, output_path=output_path, label_position=LabelPosition.TOP_RIGHT)
2414
1261
  return output_path