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