dotcode-decoder 1.0.0__py3-none-any.whl
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.
- dotcode/__init__.py +9 -0
- dotcode/code.py +915 -0
- dotcode/codes.py +247 -0
- dotcode/decoder.py +953 -0
- dotcode/decoder_90.py +390 -0
- dotcode/image.py +3 -0
- dotcode/images.py +200 -0
- dotcode/rs.py +212 -0
- dotcode/setup.py +26 -0
- dotcode/utils.py +36 -0
- dotcode_decoder-1.0.0.dist-info/METADATA +248 -0
- dotcode_decoder-1.0.0.dist-info/RECORD +14 -0
- dotcode_decoder-1.0.0.dist-info/WHEEL +5 -0
- dotcode_decoder-1.0.0.dist-info/top_level.txt +1 -0
dotcode/decoder_90.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main decoder with iterative regression - Final Speed + Accuracy Balance
|
|
3
|
+
- Uses original snap_to_grid_checkerboard for reliability
|
|
4
|
+
- Fast mode: all essential thresholds with both erosion levels 2 and 3
|
|
5
|
+
- Early exit in regression
|
|
6
|
+
- Expected: ~45ms avg, ~95-96% success
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
from typing import Optional, Union
|
|
13
|
+
|
|
14
|
+
from .utils import DecodeResult, DecodeStage, GRID_ROWS, GRID_COLS
|
|
15
|
+
from .utils import MIN_DOTS, REGRESSION_ITERATIONS
|
|
16
|
+
from .images import BinaryPyramid, detect_dots, snap_to_grid_checkerboard
|
|
17
|
+
from .images import decode_from_grid, kmeans_1d
|
|
18
|
+
from .codes import is_valid_message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_smart_combos(mean_brightness):
|
|
22
|
+
if mean_brightness < 40:
|
|
23
|
+
return [
|
|
24
|
+
("otsu", None, [2, 3, 4]),
|
|
25
|
+
("fixed", 135, [2, 3]),
|
|
26
|
+
("fixed", 145, [2, 3]),
|
|
27
|
+
("fixed", 155, [2, 3]),
|
|
28
|
+
]
|
|
29
|
+
elif mean_brightness < 55:
|
|
30
|
+
return [
|
|
31
|
+
("otsu", None, [2, 3]),
|
|
32
|
+
("fixed", 145, [2, 3]),
|
|
33
|
+
("fixed", 155, [2, 3]),
|
|
34
|
+
("fixed", 165, [2, 3]),
|
|
35
|
+
]
|
|
36
|
+
elif mean_brightness < 70:
|
|
37
|
+
return [
|
|
38
|
+
("otsu", None, [2, 3]),
|
|
39
|
+
("fixed", 155, [2, 3, 4]),
|
|
40
|
+
("fixed", 165, [2, 3, 4]),
|
|
41
|
+
("fixed", 175, [2, 3]),
|
|
42
|
+
]
|
|
43
|
+
else:
|
|
44
|
+
return [
|
|
45
|
+
("otsu", None, [2, 3]),
|
|
46
|
+
("fixed", 165, [2, 3]),
|
|
47
|
+
("fixed", 175, [2, 3]),
|
|
48
|
+
("fixed", 155, [2, 3]),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rotate_points(pts, angle):
|
|
53
|
+
if abs(angle) < 0.05:
|
|
54
|
+
return pts
|
|
55
|
+
center = np.mean(pts, axis=0)
|
|
56
|
+
theta = np.radians(-angle)
|
|
57
|
+
c, s = np.cos(theta), np.sin(theta)
|
|
58
|
+
R = np.array([[c, -s], [s, c]])
|
|
59
|
+
return (pts - center) @ R.T + center
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_row_quality(pts, n_rows=GRID_ROWS):
|
|
63
|
+
if len(pts) < 20:
|
|
64
|
+
return float('inf')
|
|
65
|
+
ys = pts[:, 1]
|
|
66
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
67
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
68
|
+
total_var = 0
|
|
69
|
+
valid_rows = 0
|
|
70
|
+
for r in range(len(row_centers)):
|
|
71
|
+
mask = row_idx == r
|
|
72
|
+
if mask.sum() > 3:
|
|
73
|
+
total_var += np.var(ys[mask])
|
|
74
|
+
valid_rows += 1
|
|
75
|
+
if valid_rows == 0:
|
|
76
|
+
return float('inf')
|
|
77
|
+
return total_var / valid_rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_angle_from_rows(pts, n_rows=GRID_ROWS):
|
|
81
|
+
if len(pts) < 20:
|
|
82
|
+
return 0
|
|
83
|
+
ys = pts[:, 1]
|
|
84
|
+
xs = pts[:, 0]
|
|
85
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
86
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
87
|
+
slopes = []
|
|
88
|
+
row_counts = []
|
|
89
|
+
for r in range(len(row_centers)):
|
|
90
|
+
mask = row_idx == r
|
|
91
|
+
count = mask.sum()
|
|
92
|
+
if count < 4:
|
|
93
|
+
continue
|
|
94
|
+
row_xs = xs[mask]
|
|
95
|
+
row_ys = ys[mask]
|
|
96
|
+
try:
|
|
97
|
+
coeffs = np.polyfit(row_xs, row_ys, 1)
|
|
98
|
+
slopes.append(coeffs[0])
|
|
99
|
+
row_counts.append(count)
|
|
100
|
+
except:
|
|
101
|
+
continue
|
|
102
|
+
if not slopes:
|
|
103
|
+
return 0
|
|
104
|
+
weights = np.array(row_counts) / sum(row_counts)
|
|
105
|
+
avg_slope = np.sum(np.array(slopes) * weights)
|
|
106
|
+
angle = np.degrees(np.arctan(avg_slope))
|
|
107
|
+
if angle > 45:
|
|
108
|
+
angle -= 90
|
|
109
|
+
elif angle < -45:
|
|
110
|
+
angle += 90
|
|
111
|
+
return angle
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def straighten_dots_iterative(pts, n_rows=GRID_ROWS, degree=2, max_iterations=REGRESSION_ITERATIONS):
|
|
115
|
+
"""
|
|
116
|
+
Iterative regression with early exit when angle stabilizes.
|
|
117
|
+
"""
|
|
118
|
+
if pts is None or len(pts) < 20:
|
|
119
|
+
return pts, 0
|
|
120
|
+
|
|
121
|
+
angle = get_angle_from_rows(pts, n_rows)
|
|
122
|
+
current_pts = rotate_points(pts, angle)
|
|
123
|
+
prev_angle = angle
|
|
124
|
+
|
|
125
|
+
for iteration in range(max_iterations):
|
|
126
|
+
xs = current_pts[:, 0]
|
|
127
|
+
ys = current_pts[:, 1]
|
|
128
|
+
|
|
129
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
130
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
131
|
+
|
|
132
|
+
coeffs_list = []
|
|
133
|
+
row_weights = []
|
|
134
|
+
|
|
135
|
+
for r in range(len(row_centers)):
|
|
136
|
+
mask = row_idx == r
|
|
137
|
+
count = mask.sum()
|
|
138
|
+
if count < max(4, degree + 2):
|
|
139
|
+
continue
|
|
140
|
+
row_xs = xs[mask]
|
|
141
|
+
row_ys = ys[mask]
|
|
142
|
+
try:
|
|
143
|
+
c = np.polyfit(row_xs, row_ys, degree)
|
|
144
|
+
coeffs_list.append(c)
|
|
145
|
+
row_weights.append(count)
|
|
146
|
+
except:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if len(coeffs_list) >= 2:
|
|
150
|
+
weights = np.array(row_weights) / sum(row_weights)
|
|
151
|
+
avg_coeffs = np.zeros(len(coeffs_list[0]))
|
|
152
|
+
for c, w in zip(coeffs_list, weights):
|
|
153
|
+
avg_coeffs += c * w
|
|
154
|
+
|
|
155
|
+
trend = np.polyval(avg_coeffs, xs)
|
|
156
|
+
mean_y = float(np.mean(ys))
|
|
157
|
+
current_pts[:, 1] = ys - trend + mean_y
|
|
158
|
+
|
|
159
|
+
new_angle = get_angle_from_rows(current_pts, n_rows)
|
|
160
|
+
if abs(new_angle - prev_angle) < 0.1: # relaxed from 0.05
|
|
161
|
+
break
|
|
162
|
+
prev_angle = new_angle
|
|
163
|
+
current_pts = rotate_points(current_pts, new_angle)
|
|
164
|
+
else:
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
final_angle = get_angle_from_rows(current_pts, n_rows)
|
|
168
|
+
final_score = compute_row_quality(current_pts, n_rows)
|
|
169
|
+
original_score = compute_row_quality(pts, n_rows)
|
|
170
|
+
|
|
171
|
+
if final_score > original_score * 1.2:
|
|
172
|
+
return pts, 0
|
|
173
|
+
|
|
174
|
+
return current_pts, final_angle
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DotCodeDecoder:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
timeout_ms: int = 300,
|
|
181
|
+
confidence: float = 0.25,
|
|
182
|
+
fast_mode: bool = True
|
|
183
|
+
):
|
|
184
|
+
self.timeout_ms = timeout_ms
|
|
185
|
+
self.confidence = confidence
|
|
186
|
+
self.fast_mode = fast_mode
|
|
187
|
+
|
|
188
|
+
def decode(self, image, return_details: bool = True) -> Union[str, DecodeResult, None]:
|
|
189
|
+
result = self._decode_image(image)
|
|
190
|
+
return result if return_details else (result.message if result.success else None)
|
|
191
|
+
|
|
192
|
+
def _decode_image(self, image) -> DecodeResult:
|
|
193
|
+
if isinstance(image, str):
|
|
194
|
+
img = cv2.imread(image)
|
|
195
|
+
if img is None:
|
|
196
|
+
return DecodeResult(success=False, error="READ_ERROR", file=image)
|
|
197
|
+
else:
|
|
198
|
+
img = image
|
|
199
|
+
|
|
200
|
+
start_time = time.time()
|
|
201
|
+
|
|
202
|
+
def check_timeout():
|
|
203
|
+
return (time.time() - start_time) * 1000 > self.timeout_ms
|
|
204
|
+
|
|
205
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
|
|
206
|
+
pyramid = BinaryPyramid(gray)
|
|
207
|
+
mean_brightness = gray.mean()
|
|
208
|
+
|
|
209
|
+
# ─── STEP 1: Find BEST dots ───
|
|
210
|
+
best_pts = None
|
|
211
|
+
best_dot_info = None
|
|
212
|
+
best_count = 0
|
|
213
|
+
|
|
214
|
+
for mode, threshold, erosions in get_smart_combos(mean_brightness):
|
|
215
|
+
for erosion in erosions:
|
|
216
|
+
pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
217
|
+
if pts is not None and len(pts) > best_count:
|
|
218
|
+
best_count = len(pts)
|
|
219
|
+
best_pts = pts
|
|
220
|
+
best_dot_info = dot_info
|
|
221
|
+
|
|
222
|
+
if best_pts is None or len(best_pts) < MIN_DOTS:
|
|
223
|
+
elapsed = (time.time() - start_time) * 1000
|
|
224
|
+
return DecodeResult(success=False, error="NO_DOTS", elapsed_ms=elapsed)
|
|
225
|
+
|
|
226
|
+
if check_timeout():
|
|
227
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
228
|
+
|
|
229
|
+
# ─── STEP 2: Iterative Regression (uses original snap) ───
|
|
230
|
+
straight_pts, angle = straighten_dots_iterative(best_pts, GRID_ROWS, degree=2)
|
|
231
|
+
|
|
232
|
+
grid, _, _ = snap_to_grid_checkerboard(straight_pts, GRID_ROWS, GRID_COLS, best_dot_info)
|
|
233
|
+
if grid is not None:
|
|
234
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
235
|
+
if occupancy >= 0.08:
|
|
236
|
+
msg = decode_from_grid(grid)
|
|
237
|
+
if msg:
|
|
238
|
+
elapsed = (time.time() - start_time) * 1000
|
|
239
|
+
return DecodeResult(
|
|
240
|
+
success=True,
|
|
241
|
+
message=msg,
|
|
242
|
+
method=f"reg_iter_{angle:.1f}",
|
|
243
|
+
elapsed_ms=elapsed,
|
|
244
|
+
stage=DecodeStage.REGRESSION,
|
|
245
|
+
confidence=occupancy * min(len(best_pts) / 150, 1.0)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# ─── STEP 3: Angle Sweep ───
|
|
249
|
+
if self.fast_mode:
|
|
250
|
+
# Key thresholds with both erosion 2 and 3 for reliability
|
|
251
|
+
fallback_thresholds = [
|
|
252
|
+
("otsu", None, [2, 3]),
|
|
253
|
+
("fixed", 155, [2, 3]),
|
|
254
|
+
("fixed", 165, [2, 3]),
|
|
255
|
+
("fixed", 145, [2, 3]), # added erosion 3
|
|
256
|
+
("fixed", 175, [2, 3]), # added erosion 3
|
|
257
|
+
("fixed", 135, [2, 3]), # added erosion 3
|
|
258
|
+
("fixed", 125, [2]), # keep only 2 (rare)
|
|
259
|
+
]
|
|
260
|
+
# Full angle range for precision
|
|
261
|
+
fine_angles = [3.0, 2.5, 2.0, 1.5, 1.0, 0.5, -0.5, -1.0, -1.5, -2.0, -2.5, -3.0]
|
|
262
|
+
else:
|
|
263
|
+
# Full original search
|
|
264
|
+
fallback_thresholds = [
|
|
265
|
+
("otsu", None, [2, 3]),
|
|
266
|
+
("fixed", 155, [2, 3]),
|
|
267
|
+
("fixed", 165, [2, 3]),
|
|
268
|
+
("fixed", 145, [2, 3]),
|
|
269
|
+
("fixed", 175, [2, 3]),
|
|
270
|
+
("fixed", 135, [2, 3]),
|
|
271
|
+
("fixed", 125, [2, 3]),
|
|
272
|
+
("fixed", 115, [2, 3]),
|
|
273
|
+
("fixed", 105, [2, 3]),
|
|
274
|
+
]
|
|
275
|
+
fine_angles = [3.0, 2.5, 2.0, 1.5, 1.0, 0.5, -0.5, -1.0, -1.5, -2.0, -2.5, -3.0]
|
|
276
|
+
|
|
277
|
+
# Quick check: try best_pts with all angles first (fast path)
|
|
278
|
+
if len(best_pts) > 80 and not check_timeout():
|
|
279
|
+
for angle in fine_angles:
|
|
280
|
+
if check_timeout():
|
|
281
|
+
break
|
|
282
|
+
rotated_pts = rotate_points(best_pts, angle)
|
|
283
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, best_dot_info)
|
|
284
|
+
if grid is not None:
|
|
285
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
286
|
+
if occupancy >= 0.08:
|
|
287
|
+
msg = decode_from_grid(grid)
|
|
288
|
+
if msg:
|
|
289
|
+
confidence = occupancy * min(len(best_pts) / 150, 1.0)
|
|
290
|
+
if confidence >= self.confidence:
|
|
291
|
+
elapsed = (time.time() - start_time) * 1000
|
|
292
|
+
return DecodeResult(
|
|
293
|
+
success=True,
|
|
294
|
+
message=msg,
|
|
295
|
+
method=f"ang_best_{angle:.1f}",
|
|
296
|
+
elapsed_ms=elapsed,
|
|
297
|
+
stage=DecodeStage.ANGLE_SWEEP,
|
|
298
|
+
confidence=confidence
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Full sweep over thresholds/erosions
|
|
302
|
+
for mode, threshold, erosions in fallback_thresholds:
|
|
303
|
+
if check_timeout():
|
|
304
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
305
|
+
|
|
306
|
+
for erosion in erosions:
|
|
307
|
+
pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
308
|
+
if pts is None or len(pts) < MIN_DOTS:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
for angle in fine_angles:
|
|
312
|
+
if check_timeout():
|
|
313
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
314
|
+
|
|
315
|
+
rotated_pts = rotate_points(pts, angle)
|
|
316
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
317
|
+
|
|
318
|
+
if grid is None:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
322
|
+
if occupancy < 0.08:
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
msg = decode_from_grid(grid)
|
|
326
|
+
if msg:
|
|
327
|
+
confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
328
|
+
if confidence >= self.confidence:
|
|
329
|
+
elapsed = (time.time() - start_time) * 1000
|
|
330
|
+
return DecodeResult(
|
|
331
|
+
success=True,
|
|
332
|
+
message=msg,
|
|
333
|
+
method=f"ang_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
334
|
+
elapsed_ms=elapsed,
|
|
335
|
+
stage=DecodeStage.ANGLE_SWEEP,
|
|
336
|
+
confidence=confidence
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# ─── STEP 4: Invert ───
|
|
340
|
+
if not check_timeout():
|
|
341
|
+
inverted = cv2.bitwise_not(gray)
|
|
342
|
+
pyramid_inv = BinaryPyramid(inverted)
|
|
343
|
+
|
|
344
|
+
# Use a focused set for invert (otsu and 155 with both erosions)
|
|
345
|
+
for mode, threshold, erosions in [("otsu", None, [2, 3]), ("fixed", 155, [2, 3])]:
|
|
346
|
+
if check_timeout():
|
|
347
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
348
|
+
|
|
349
|
+
for erosion in erosions:
|
|
350
|
+
pts, dot_info = detect_dots(pyramid_inv, mode, threshold, erosion)
|
|
351
|
+
if pts is None or len(pts) < MIN_DOTS:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Full angle range for invert
|
|
355
|
+
for angle in fine_angles:
|
|
356
|
+
if check_timeout():
|
|
357
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
358
|
+
|
|
359
|
+
rotated_pts = rotate_points(pts, angle)
|
|
360
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
361
|
+
|
|
362
|
+
if grid is None:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
366
|
+
if occupancy < 0.08:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
msg = decode_from_grid(grid)
|
|
370
|
+
if msg:
|
|
371
|
+
confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
372
|
+
if confidence >= self.confidence:
|
|
373
|
+
elapsed = (time.time() - start_time) * 1000
|
|
374
|
+
return DecodeResult(
|
|
375
|
+
success=True,
|
|
376
|
+
message=msg,
|
|
377
|
+
method=f"inv_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
378
|
+
elapsed_ms=elapsed,
|
|
379
|
+
stage=DecodeStage.INVERT,
|
|
380
|
+
confidence=confidence
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
elapsed = (time.time() - start_time) * 1000
|
|
384
|
+
return DecodeResult(success=False, error="FAILED", elapsed_ms=elapsed)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Simple API
|
|
388
|
+
def decode(image, **kwargs):
|
|
389
|
+
decoder = DotCodeDecoder(**kwargs)
|
|
390
|
+
return decoder.decode(image)
|
dotcode/image.py
ADDED
dotcode/images.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image processing: thresholding, blob detection, grid snapping
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
from .utils import GRID_ROWS, GRID_COLS, MIN_DOTS
|
|
8
|
+
|
|
9
|
+
_KERNEL = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
|
10
|
+
|
|
11
|
+
# Grid constants
|
|
12
|
+
_GRID_R_IDX = np.arange(GRID_ROWS)[:, None]
|
|
13
|
+
_GRID_C_IDX = np.arange(GRID_COLS)[None, :]
|
|
14
|
+
_GRID_MASK_OFFSET0 = (_GRID_R_IDX + _GRID_C_IDX) % 2 == 0
|
|
15
|
+
_GRID_TOTAL_CELLS = (GRID_ROWS * GRID_COLS + 1) // 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BinaryPyramid:
|
|
19
|
+
def __init__(self, gray):
|
|
20
|
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
|
21
|
+
self.enhanced = clahe.apply(gray)
|
|
22
|
+
self._base = {}
|
|
23
|
+
self._eroded = {}
|
|
24
|
+
|
|
25
|
+
def get(self, mode, fixed_t, erosion_level):
|
|
26
|
+
key = (mode, fixed_t, erosion_level)
|
|
27
|
+
if key in self._eroded:
|
|
28
|
+
return self._eroded[key]
|
|
29
|
+
|
|
30
|
+
base_key = (mode, fixed_t)
|
|
31
|
+
if base_key not in self._base:
|
|
32
|
+
if mode == "otsu":
|
|
33
|
+
_, b = cv2.threshold(self.enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
34
|
+
else:
|
|
35
|
+
_, b = cv2.threshold(self.enhanced, fixed_t, 255, cv2.THRESH_BINARY)
|
|
36
|
+
self._base[base_key] = cv2.morphologyEx(b, cv2.MORPH_OPEN, _KERNEL, iterations=1)
|
|
37
|
+
|
|
38
|
+
cur, start = self._base[base_key], 0
|
|
39
|
+
for lvl in range(erosion_level, 0, -1):
|
|
40
|
+
if (mode, fixed_t, lvl) in self._eroded:
|
|
41
|
+
cur, start = self._eroded[(mode, fixed_t, lvl)], lvl
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
for lvl in range(start + 1, erosion_level + 1):
|
|
45
|
+
cur = cv2.erode(cur, _KERNEL, iterations=1)
|
|
46
|
+
self._eroded[(mode, fixed_t, lvl)] = cur
|
|
47
|
+
|
|
48
|
+
return cur
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _score_blobs_from_binary(bw):
|
|
52
|
+
num, _, stats, centroids = cv2.connectedComponentsWithStats(bw, 8)
|
|
53
|
+
blobs = []
|
|
54
|
+
|
|
55
|
+
for i in range(1, num):
|
|
56
|
+
x, y, w, h, area = stats[i]
|
|
57
|
+
if w < 2 or h < 2 or w > 80 or h > 80:
|
|
58
|
+
continue
|
|
59
|
+
blobs.append({"cx": centroids[i][0], "cy": centroids[i][1], "w": w, "h": h})
|
|
60
|
+
|
|
61
|
+
if not blobs:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
med_w = float(np.median([b["w"] for b in blobs]))
|
|
65
|
+
med_h = float(np.median([b["h"] for b in blobs]))
|
|
66
|
+
strict = [b for b in blobs if 0.70*med_w <= b["w"] <= 1.30*med_w and 0.70*med_h <= b["h"] <= 1.30*med_h]
|
|
67
|
+
|
|
68
|
+
return {"n": len(strict), "med_w": med_w, "med_h": med_h, "blobs": strict}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_dots(pyramid, mode, fixed_t, erosion_level):
|
|
72
|
+
binary = pyramid.get(mode, fixed_t, erosion_level)
|
|
73
|
+
scored = _score_blobs_from_binary(binary)
|
|
74
|
+
|
|
75
|
+
if not scored or scored["n"] < MIN_DOTS:
|
|
76
|
+
return None, None
|
|
77
|
+
|
|
78
|
+
pts = np.array([[b["cx"], b["cy"]] for b in scored["blobs"]], dtype=np.float32)
|
|
79
|
+
dot_info = {"med_w": scored["med_w"], "med_h": scored["med_h"], "num_dots": scored["n"]}
|
|
80
|
+
|
|
81
|
+
return pts, dot_info
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def kmeans_1d(vals, k):
|
|
85
|
+
arr = np.array(vals, dtype=np.float32).reshape(-1, 1)
|
|
86
|
+
k = max(2, min(k, len(vals)))
|
|
87
|
+
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 0.1)
|
|
88
|
+
_, _, centers = cv2.kmeans(arr, k, None, crit, 1, cv2.KMEANS_PP_CENTERS)
|
|
89
|
+
return np.sort(centers.flatten())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def snap_to_grid_checkerboard(pts, n_rows, n_cols, dot_info):
|
|
93
|
+
if len(pts) < max(n_rows, n_cols):
|
|
94
|
+
return None, None, None
|
|
95
|
+
|
|
96
|
+
row_c = kmeans_1d(pts[:, 1], n_rows)
|
|
97
|
+
col_c = kmeans_1d(pts[:, 0], n_cols)
|
|
98
|
+
|
|
99
|
+
H, W = len(row_c), len(col_c)
|
|
100
|
+
|
|
101
|
+
if H < 2 or W < 2:
|
|
102
|
+
return None, None, None
|
|
103
|
+
|
|
104
|
+
row_step = float(np.median(np.diff(row_c))) if H > 1 else 15.0
|
|
105
|
+
col_step = float(np.median(np.diff(col_c))) if W > 1 else 15.0
|
|
106
|
+
|
|
107
|
+
med_h = dot_info.get("med_h", row_step * 0.5)
|
|
108
|
+
med_w = dot_info.get("med_w", col_step * 0.5)
|
|
109
|
+
ry = med_h * 0.70
|
|
110
|
+
rx = med_w * 0.70
|
|
111
|
+
|
|
112
|
+
xs = pts[:, 0]
|
|
113
|
+
ys = pts[:, 1]
|
|
114
|
+
|
|
115
|
+
r_idx = np.argmin(np.abs(row_c[None, :] - ys[:, None]), axis=1)
|
|
116
|
+
c_idx = np.argmin(np.abs(col_c[None, :] - xs[:, None]), axis=1)
|
|
117
|
+
dr = np.abs(row_c[r_idx] - ys)
|
|
118
|
+
dc = np.abs(col_c[c_idx] - xs)
|
|
119
|
+
valid = (dr <= ry) & (dc <= rx)
|
|
120
|
+
|
|
121
|
+
grid = np.zeros((H, W), dtype=np.uint8)
|
|
122
|
+
grid[r_idx[valid], c_idx[valid]] = 1
|
|
123
|
+
|
|
124
|
+
return grid, row_c, col_c
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_senior1(grid):
|
|
128
|
+
T = grid.T
|
|
129
|
+
corner = [0] * 6
|
|
130
|
+
Mask = []
|
|
131
|
+
data_bit = []
|
|
132
|
+
ncols = T.shape[0]
|
|
133
|
+
|
|
134
|
+
for i, row in enumerate(T):
|
|
135
|
+
cropped = row[(i % 2)::2]
|
|
136
|
+
n = len(cropped)
|
|
137
|
+
|
|
138
|
+
if n < 2:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if i == 0:
|
|
142
|
+
corner[5] = int(cropped[0])
|
|
143
|
+
corner[1] = int(cropped[-1])
|
|
144
|
+
if n >= 3:
|
|
145
|
+
Mask = list(cropped[1:3])
|
|
146
|
+
if n > 3:
|
|
147
|
+
data_bit.extend(cropped[3:-1])
|
|
148
|
+
|
|
149
|
+
elif i == 1:
|
|
150
|
+
corner[3] = int(cropped[-1])
|
|
151
|
+
data_bit.extend(cropped[0:-1])
|
|
152
|
+
|
|
153
|
+
elif i == ncols - 2:
|
|
154
|
+
corner[2] = int(cropped[-1])
|
|
155
|
+
data_bit.extend(cropped[0:-1])
|
|
156
|
+
|
|
157
|
+
elif i == ncols - 1:
|
|
158
|
+
corner[4] = int(cropped[0])
|
|
159
|
+
corner[0] = int(cropped[-1])
|
|
160
|
+
if n > 1:
|
|
161
|
+
data_bit.extend(cropped[1:-1])
|
|
162
|
+
|
|
163
|
+
else:
|
|
164
|
+
data_bit.extend(cropped)
|
|
165
|
+
|
|
166
|
+
return "".join(str(b) for b in Mask + data_bit + corner)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def decode_from_grid(grid):
|
|
170
|
+
if grid is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
H, W = grid.shape
|
|
174
|
+
|
|
175
|
+
if (H, W) == (GRID_ROWS, GRID_COLS):
|
|
176
|
+
mask = _GRID_MASK_OFFSET0
|
|
177
|
+
total_cells = _GRID_TOTAL_CELLS
|
|
178
|
+
else:
|
|
179
|
+
r_indices = np.arange(H)[:, None]
|
|
180
|
+
c_indices = np.arange(W)[None, :]
|
|
181
|
+
mask = (r_indices + c_indices) % 2 == 0
|
|
182
|
+
total_cells = (H * W + 1) // 2
|
|
183
|
+
|
|
184
|
+
g = grid * mask
|
|
185
|
+
occupancy = np.sum(g) / total_cells
|
|
186
|
+
|
|
187
|
+
if occupancy < 0.08:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
bits = extract_senior1(g)
|
|
191
|
+
|
|
192
|
+
if bits and len(bits) >= 100:
|
|
193
|
+
ones_ratio = bits.count('1') / len(bits)
|
|
194
|
+
if 0.08 <= ones_ratio <= 0.72:
|
|
195
|
+
from .codes import decode_bits, is_valid_message
|
|
196
|
+
msg = decode_bits(bits)
|
|
197
|
+
if msg and is_valid_message(msg):
|
|
198
|
+
return msg
|
|
199
|
+
|
|
200
|
+
return None
|