captchakraken 2.9.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +7 -36
- package/dist/cli-invocation.js +8 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +131 -708
- package/dist/solver.js +1319 -3382
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -493
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +11 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -883
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +46 -484
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +36 -4
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1282 -3101
- package/python/src/captchakraken/planner.py +65 -487
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- 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
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"""
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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]
|
|
275
|
+
return lab[along_i, perp_i]
|
|
686
276
|
|
|
687
277
|
|
|
688
278
|
def image_noise(lab):
|
|
689
|
-
"""
|
|
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
|
-
"""
|
|
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]
|
|
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
|
|
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:
|
|
421
|
+
if axis == 1:
|
|
1087
422
|
runs = _split_runs(np.where(ridge[c, c_lo:c_hi])[0] + c_lo)
|
|
1088
|
-
else:
|
|
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:
|
|
447
|
+
if axis == 1:
|
|
1122
448
|
seed_ref = lab[perp_seed.astype(np.intp), along_seed.astype(np.intp)].astype(np.float64)
|
|
1123
|
-
else:
|
|
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
|
|
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
|
-
"""
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
-
"""
|
|
1286
|
-
|
|
1287
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
791
|
+
p = l.midline_pos
|
|
1637
792
|
n_ok = 0
|
|
1638
793
|
for q in perp:
|
|
1639
|
-
lo, hi = _line_span_perp(q)
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"""
|
|
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)
|
|
1864
|
-
v_keep_int = _internal(v_keep, w)
|
|
1865
|
-
# One reference colour for every seal test
|
|
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
|
-
|
|
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]
|
|
1962
|
-
hx = [_line_span_perp(l) for l in hlns]
|
|
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
|
|
2002
|
-
#
|
|
2003
|
-
#
|
|
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
|
-
#
|
|
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
|
|
1071
|
+
continue
|
|
2089
1072
|
if _de(l.color_lab, gutter_color) > GRID_COLOR_TOL:
|
|
2090
|
-
continue
|
|
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
|
|
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
|
-
#
|
|
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))
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2199
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
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
|
|
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=
|
|
1260
|
+
add_overlays_to_image(image_path, ov, output_path=output_path, label_position=LabelPosition.TOP_RIGHT)
|
|
2414
1261
|
return output_path
|