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