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.py
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
# """
|
|
2
|
+
# Main decoder with iterative regression - Single image only (conveyor ready)
|
|
3
|
+
# """
|
|
4
|
+
|
|
5
|
+
# import time
|
|
6
|
+
# import cv2
|
|
7
|
+
# import numpy as np
|
|
8
|
+
# from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
# from .utils import DecodeResult, DecodeStage, GRID_ROWS, GRID_COLS
|
|
11
|
+
# from .utils import MIN_DOTS, REGRESSION_ITERATIONS
|
|
12
|
+
# from .images import BinaryPyramid, detect_dots, snap_to_grid_checkerboard
|
|
13
|
+
# from .images import decode_from_grid, kmeans_1d
|
|
14
|
+
# from .codes import is_valid_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# def get_smart_combos(mean_brightness):
|
|
18
|
+
# if mean_brightness < 40:
|
|
19
|
+
# return [
|
|
20
|
+
# ("otsu", None, [2, 3, 4]),
|
|
21
|
+
# ("fixed", 135, [2, 3]),
|
|
22
|
+
# ("fixed", 145, [2, 3]),
|
|
23
|
+
# ("fixed", 155, [2, 3]),
|
|
24
|
+
# ]
|
|
25
|
+
# elif mean_brightness < 55:
|
|
26
|
+
# return [
|
|
27
|
+
# ("otsu", None, [2, 3]),
|
|
28
|
+
# ("fixed", 145, [2, 3]),
|
|
29
|
+
# ("fixed", 155, [2, 3]),
|
|
30
|
+
# ("fixed", 165, [2, 3]),
|
|
31
|
+
# ]
|
|
32
|
+
# elif mean_brightness < 70:
|
|
33
|
+
# return [
|
|
34
|
+
# ("otsu", None, [2, 3]),
|
|
35
|
+
# ("fixed", 155, [2, 3, 4]),
|
|
36
|
+
# ("fixed", 165, [2, 3, 4]),
|
|
37
|
+
# ("fixed", 175, [2, 3]),
|
|
38
|
+
# ]
|
|
39
|
+
# else:
|
|
40
|
+
# return [
|
|
41
|
+
# ("otsu", None, [2, 3]),
|
|
42
|
+
# ("fixed", 165, [2, 3]),
|
|
43
|
+
# ("fixed", 175, [2, 3]),
|
|
44
|
+
# ("fixed", 155, [2, 3]),
|
|
45
|
+
# ]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# def rotate_points(pts, angle):
|
|
49
|
+
# if abs(angle) < 0.05:
|
|
50
|
+
# return pts
|
|
51
|
+
|
|
52
|
+
# center = np.mean(pts, axis=0)
|
|
53
|
+
# theta = np.radians(-angle)
|
|
54
|
+
# c, s = np.cos(theta), np.sin(theta)
|
|
55
|
+
# R = np.array([[c, -s], [s, c]])
|
|
56
|
+
|
|
57
|
+
# return (pts - center) @ R.T + center
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# def compute_row_quality(pts, n_rows=GRID_ROWS):
|
|
61
|
+
# if len(pts) < 20:
|
|
62
|
+
# return float('inf')
|
|
63
|
+
|
|
64
|
+
# ys = pts[:, 1]
|
|
65
|
+
# row_centers = kmeans_1d(ys, n_rows)
|
|
66
|
+
# row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
67
|
+
|
|
68
|
+
# total_var = 0
|
|
69
|
+
# valid_rows = 0
|
|
70
|
+
|
|
71
|
+
# for r in range(len(row_centers)):
|
|
72
|
+
# mask = row_idx == r
|
|
73
|
+
# if mask.sum() > 3:
|
|
74
|
+
# total_var += np.var(ys[mask])
|
|
75
|
+
# valid_rows += 1
|
|
76
|
+
|
|
77
|
+
# if valid_rows == 0:
|
|
78
|
+
# return float('inf')
|
|
79
|
+
|
|
80
|
+
# return total_var / valid_rows
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# def get_angle_from_rows(pts, n_rows=GRID_ROWS):
|
|
84
|
+
# if len(pts) < 20:
|
|
85
|
+
# return 0
|
|
86
|
+
|
|
87
|
+
# ys = pts[:, 1]
|
|
88
|
+
# xs = pts[:, 0]
|
|
89
|
+
|
|
90
|
+
# row_centers = kmeans_1d(ys, n_rows)
|
|
91
|
+
# row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
92
|
+
|
|
93
|
+
# slopes = []
|
|
94
|
+
# row_counts = []
|
|
95
|
+
|
|
96
|
+
# for r in range(len(row_centers)):
|
|
97
|
+
# mask = row_idx == r
|
|
98
|
+
# count = mask.sum()
|
|
99
|
+
|
|
100
|
+
# if count < 4:
|
|
101
|
+
# continue
|
|
102
|
+
|
|
103
|
+
# row_xs = xs[mask]
|
|
104
|
+
# row_ys = ys[mask]
|
|
105
|
+
|
|
106
|
+
# try:
|
|
107
|
+
# coeffs = np.polyfit(row_xs, row_ys, 1)
|
|
108
|
+
# slopes.append(coeffs[0])
|
|
109
|
+
# row_counts.append(count)
|
|
110
|
+
# except:
|
|
111
|
+
# continue
|
|
112
|
+
|
|
113
|
+
# if not slopes:
|
|
114
|
+
# return 0
|
|
115
|
+
|
|
116
|
+
# weights = np.array(row_counts) / sum(row_counts)
|
|
117
|
+
# avg_slope = np.sum(np.array(slopes) * weights)
|
|
118
|
+
# angle = np.degrees(np.arctan(avg_slope))
|
|
119
|
+
|
|
120
|
+
# if angle > 45:
|
|
121
|
+
# angle -= 90
|
|
122
|
+
# elif angle < -45:
|
|
123
|
+
# angle += 90
|
|
124
|
+
|
|
125
|
+
# return angle
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# def straighten_dots_iterative(pts, n_rows=GRID_ROWS, degree=2, iterations=REGRESSION_ITERATIONS):
|
|
129
|
+
# if pts is None or len(pts) < 20:
|
|
130
|
+
# return pts, 0
|
|
131
|
+
|
|
132
|
+
# angle = get_angle_from_rows(pts, n_rows)
|
|
133
|
+
# current_pts = rotate_points(pts, angle)
|
|
134
|
+
# best_angle = angle
|
|
135
|
+
|
|
136
|
+
# for iteration in range(iterations):
|
|
137
|
+
# xs = current_pts[:, 0]
|
|
138
|
+
# ys = current_pts[:, 1]
|
|
139
|
+
|
|
140
|
+
# row_centers = kmeans_1d(ys, n_rows)
|
|
141
|
+
# row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
142
|
+
|
|
143
|
+
# coeffs_list = []
|
|
144
|
+
# row_weights = []
|
|
145
|
+
|
|
146
|
+
# for r in range(len(row_centers)):
|
|
147
|
+
# mask = row_idx == r
|
|
148
|
+
# count = mask.sum()
|
|
149
|
+
|
|
150
|
+
# if count < max(4, degree + 2):
|
|
151
|
+
# continue
|
|
152
|
+
|
|
153
|
+
# row_xs = xs[mask]
|
|
154
|
+
# row_ys = ys[mask]
|
|
155
|
+
|
|
156
|
+
# try:
|
|
157
|
+
# c = np.polyfit(row_xs, row_ys, degree)
|
|
158
|
+
# coeffs_list.append(c)
|
|
159
|
+
# row_weights.append(count)
|
|
160
|
+
# except:
|
|
161
|
+
# continue
|
|
162
|
+
|
|
163
|
+
# if len(coeffs_list) >= 2:
|
|
164
|
+
# weights = np.array(row_weights) / sum(row_weights)
|
|
165
|
+
# avg_coeffs = np.zeros(len(coeffs_list[0]))
|
|
166
|
+
|
|
167
|
+
# for c, w in zip(coeffs_list, weights):
|
|
168
|
+
# avg_coeffs += c * w
|
|
169
|
+
|
|
170
|
+
# trend = np.polyval(avg_coeffs, xs)
|
|
171
|
+
# mean_y = float(np.mean(ys))
|
|
172
|
+
# current_pts[:, 1] = ys - trend + mean_y
|
|
173
|
+
|
|
174
|
+
# if iteration < iterations - 1:
|
|
175
|
+
# angle = get_angle_from_rows(current_pts, n_rows)
|
|
176
|
+
# current_pts = rotate_points(current_pts, angle)
|
|
177
|
+
# best_angle = angle
|
|
178
|
+
# else:
|
|
179
|
+
# break
|
|
180
|
+
|
|
181
|
+
# final_angle = get_angle_from_rows(current_pts, n_rows)
|
|
182
|
+
# final_score = compute_row_quality(current_pts, n_rows)
|
|
183
|
+
# original_score = compute_row_quality(pts, n_rows)
|
|
184
|
+
|
|
185
|
+
# if final_score > original_score * 1.2:
|
|
186
|
+
# return pts, 0
|
|
187
|
+
|
|
188
|
+
# return current_pts, final_angle
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# class DotCodeDecoder:
|
|
192
|
+
# def __init__(
|
|
193
|
+
# self,
|
|
194
|
+
# timeout_ms: int = 300,
|
|
195
|
+
# confidence: float = 0.25,
|
|
196
|
+
# fast_mode: bool = True
|
|
197
|
+
# ):
|
|
198
|
+
# self.timeout_ms = timeout_ms
|
|
199
|
+
# self.confidence = confidence
|
|
200
|
+
# self.fast_mode = fast_mode
|
|
201
|
+
|
|
202
|
+
# def decode(self, image, return_details: bool = True) -> Union[str, DecodeResult, None]:
|
|
203
|
+
# """Decode a single image - conveyor ready"""
|
|
204
|
+
# result = self._decode_image(image)
|
|
205
|
+
|
|
206
|
+
# if return_details:
|
|
207
|
+
# return result
|
|
208
|
+
|
|
209
|
+
# return result.message if result.success else None
|
|
210
|
+
|
|
211
|
+
# def _decode_image(self, image) -> DecodeResult:
|
|
212
|
+
# if isinstance(image, str):
|
|
213
|
+
# img = cv2.imread(image)
|
|
214
|
+
# if img is None:
|
|
215
|
+
# return DecodeResult(success=False, error="READ_ERROR", file=image)
|
|
216
|
+
# else:
|
|
217
|
+
# img = image
|
|
218
|
+
|
|
219
|
+
# start_time = time.time()
|
|
220
|
+
|
|
221
|
+
# def check_timeout():
|
|
222
|
+
# return (time.time() - start_time) * 1000 > self.timeout_ms
|
|
223
|
+
|
|
224
|
+
# gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
|
|
225
|
+
# pyramid = BinaryPyramid(gray)
|
|
226
|
+
# mean_brightness = gray.mean()
|
|
227
|
+
|
|
228
|
+
# # ─── STEP 1: Find BEST dots ───
|
|
229
|
+
# best_pts = None
|
|
230
|
+
# best_dot_info = None
|
|
231
|
+
# best_count = 0
|
|
232
|
+
|
|
233
|
+
# for mode, threshold, erosions in get_smart_combos(mean_brightness):
|
|
234
|
+
# for erosion in erosions:
|
|
235
|
+
# pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
236
|
+
# if pts is not None and len(pts) > best_count:
|
|
237
|
+
# best_count = len(pts)
|
|
238
|
+
# best_pts = pts
|
|
239
|
+
# best_dot_info = dot_info
|
|
240
|
+
|
|
241
|
+
# if best_pts is None or len(best_pts) < MIN_DOTS:
|
|
242
|
+
# elapsed = (time.time() - start_time) * 1000
|
|
243
|
+
# return DecodeResult(success=False, error="NO_DOTS", elapsed_ms=elapsed)
|
|
244
|
+
|
|
245
|
+
# if check_timeout():
|
|
246
|
+
# return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
247
|
+
|
|
248
|
+
# # ─── STEP 2: Iterative Regression ───
|
|
249
|
+
# straight_pts, angle = straighten_dots_iterative(best_pts, GRID_ROWS, degree=2)
|
|
250
|
+
|
|
251
|
+
# grid, _, _ = snap_to_grid_checkerboard(straight_pts, GRID_ROWS, GRID_COLS, best_dot_info)
|
|
252
|
+
# if grid is not None:
|
|
253
|
+
# occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
254
|
+
# if occupancy >= 0.08:
|
|
255
|
+
# msg = decode_from_grid(grid)
|
|
256
|
+
# if msg:
|
|
257
|
+
# elapsed = (time.time() - start_time) * 1000
|
|
258
|
+
# return DecodeResult(
|
|
259
|
+
# success=True,
|
|
260
|
+
# message=msg,
|
|
261
|
+
# method=f"reg_iter_{angle:.1f}",
|
|
262
|
+
# elapsed_ms=elapsed,
|
|
263
|
+
# stage=DecodeStage.REGRESSION,
|
|
264
|
+
# confidence=occupancy * min(len(best_pts) / 150, 1.0)
|
|
265
|
+
# )
|
|
266
|
+
|
|
267
|
+
# # ─── STEP 3: Angle Sweep (FAST MODE) ───
|
|
268
|
+
# if self.fast_mode:
|
|
269
|
+
# # Fast: 3 thresholds × 6 angles = 18 attempts
|
|
270
|
+
# fallback_thresholds = [
|
|
271
|
+
# ("otsu", None, [2]),
|
|
272
|
+
# ("fixed", 155, [2]),
|
|
273
|
+
# ("fixed", 165, [2]),
|
|
274
|
+
# ]
|
|
275
|
+
# fine_angles = [2.0, 1.0, -1.0, -2.0, 0.5, -0.5]
|
|
276
|
+
# else:
|
|
277
|
+
# # Slow: 9 thresholds × 12 angles = 108 attempts
|
|
278
|
+
# fallback_thresholds = [
|
|
279
|
+
# ("otsu", None, [2, 3]),
|
|
280
|
+
# ("fixed", 155, [2, 3]),
|
|
281
|
+
# ("fixed", 165, [2, 3]),
|
|
282
|
+
# ("fixed", 145, [2, 3]),
|
|
283
|
+
# ("fixed", 175, [2, 3]),
|
|
284
|
+
# ("fixed", 135, [2, 3]),
|
|
285
|
+
# ("fixed", 125, [2, 3]),
|
|
286
|
+
# ("fixed", 115, [2, 3]),
|
|
287
|
+
# ("fixed", 105, [2, 3]),
|
|
288
|
+
# ]
|
|
289
|
+
# 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]
|
|
290
|
+
|
|
291
|
+
# for mode, threshold, erosions in fallback_thresholds:
|
|
292
|
+
# if check_timeout():
|
|
293
|
+
# return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
294
|
+
|
|
295
|
+
# for erosion in erosions:
|
|
296
|
+
# pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
297
|
+
# if pts is None or len(pts) < MIN_DOTS:
|
|
298
|
+
# continue
|
|
299
|
+
|
|
300
|
+
# for angle in fine_angles:
|
|
301
|
+
# if check_timeout():
|
|
302
|
+
# return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
303
|
+
|
|
304
|
+
# rotated_pts = rotate_points(pts, angle)
|
|
305
|
+
# grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
306
|
+
|
|
307
|
+
# if grid is None:
|
|
308
|
+
# continue
|
|
309
|
+
|
|
310
|
+
# occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
311
|
+
# if occupancy < 0.08:
|
|
312
|
+
# continue
|
|
313
|
+
|
|
314
|
+
# msg = decode_from_grid(grid)
|
|
315
|
+
# if msg:
|
|
316
|
+
# confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
317
|
+
# if confidence >= self.confidence:
|
|
318
|
+
# elapsed = (time.time() - start_time) * 1000
|
|
319
|
+
# return DecodeResult(
|
|
320
|
+
# success=True,
|
|
321
|
+
# message=msg,
|
|
322
|
+
# method=f"ang_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
323
|
+
# elapsed_ms=elapsed,
|
|
324
|
+
# stage=DecodeStage.ANGLE_SWEEP,
|
|
325
|
+
# confidence=confidence
|
|
326
|
+
# )
|
|
327
|
+
|
|
328
|
+
# # ─── STEP 4: Invert ───
|
|
329
|
+
# if not check_timeout():
|
|
330
|
+
# inverted = cv2.bitwise_not(gray)
|
|
331
|
+
# pyramid_inv = BinaryPyramid(inverted)
|
|
332
|
+
|
|
333
|
+
# for mode, threshold, erosions in [("otsu", None, [2, 3]), ("fixed", 155, [2, 3])]:
|
|
334
|
+
# if check_timeout():
|
|
335
|
+
# return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
336
|
+
|
|
337
|
+
# for erosion in erosions:
|
|
338
|
+
# pts, dot_info = detect_dots(pyramid_inv, mode, threshold, erosion)
|
|
339
|
+
# if pts is None or len(pts) < MIN_DOTS:
|
|
340
|
+
# continue
|
|
341
|
+
|
|
342
|
+
# for angle in fine_angles[:5]:
|
|
343
|
+
# if check_timeout():
|
|
344
|
+
# return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
345
|
+
|
|
346
|
+
# rotated_pts = rotate_points(pts, angle)
|
|
347
|
+
# grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
348
|
+
|
|
349
|
+
# if grid is None:
|
|
350
|
+
# continue
|
|
351
|
+
|
|
352
|
+
# occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
353
|
+
# if occupancy < 0.08:
|
|
354
|
+
# continue
|
|
355
|
+
|
|
356
|
+
# msg = decode_from_grid(grid)
|
|
357
|
+
# if msg:
|
|
358
|
+
# confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
359
|
+
# if confidence >= self.confidence:
|
|
360
|
+
# elapsed = (time.time() - start_time) * 1000
|
|
361
|
+
# return DecodeResult(
|
|
362
|
+
# success=True,
|
|
363
|
+
# message=msg,
|
|
364
|
+
# method=f"inv_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
365
|
+
# elapsed_ms=elapsed,
|
|
366
|
+
# stage=DecodeStage.INVERT,
|
|
367
|
+
# confidence=confidence
|
|
368
|
+
# )
|
|
369
|
+
|
|
370
|
+
# elapsed = (time.time() - start_time) * 1000
|
|
371
|
+
# return DecodeResult(success=False, error="FAILED", elapsed_ms=elapsed)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# # Simple API - Single image only (conveyor ready)
|
|
375
|
+
# def decode(image, **kwargs):
|
|
376
|
+
# """Decode a single image - for conveyor belt use"""
|
|
377
|
+
# decoder = DotCodeDecoder(**kwargs)
|
|
378
|
+
# return decoder.decode(image)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
"""
|
|
387
|
+
Main decoder with iterative regression - Speed + Accuracy Hybrid
|
|
388
|
+
- Primary: original k-means snap (fast and proven)
|
|
389
|
+
- Fallback: Procrustes + RANSAC for difficult images (only when needed)
|
|
390
|
+
- Sauvola optional (disabled by default to keep speed)
|
|
391
|
+
- Expected: ~40ms avg, ~95-96% success
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
import time
|
|
395
|
+
import cv2
|
|
396
|
+
import numpy as np
|
|
397
|
+
from typing import Optional, Union, Tuple
|
|
398
|
+
from scipy.spatial import KDTree
|
|
399
|
+
from scipy.linalg import svd
|
|
400
|
+
|
|
401
|
+
from .utils import DecodeResult, DecodeStage, GRID_ROWS, GRID_COLS
|
|
402
|
+
from .utils import MIN_DOTS, REGRESSION_ITERATIONS
|
|
403
|
+
from .images import BinaryPyramid, detect_dots, snap_to_grid_checkerboard
|
|
404
|
+
from .images import decode_from_grid, kmeans_1d, _KERNEL
|
|
405
|
+
from .codes import is_valid_message
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ----------------------------------------------------------------------
|
|
409
|
+
# Grid fitting via Procrustes (fast, reduced iterations)
|
|
410
|
+
# ----------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def generate_ideal_grid(n_rows: int = GRID_ROWS, n_cols: int = GRID_COLS) -> np.ndarray:
|
|
413
|
+
rows, cols = np.meshgrid(np.arange(n_rows), np.arange(n_cols), indexing='ij')
|
|
414
|
+
mask = (rows + cols) % 2 == 0
|
|
415
|
+
grid_points = np.column_stack([cols.ravel(), rows.ravel()])
|
|
416
|
+
grid_points = grid_points[mask.ravel()]
|
|
417
|
+
return grid_points
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def fit_procrustes(obs_pts: np.ndarray, model_pts: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
421
|
+
mean_obs = np.mean(obs_pts, axis=0)
|
|
422
|
+
mean_model = np.mean(model_pts, axis=0)
|
|
423
|
+
obs_centered = obs_pts - mean_obs
|
|
424
|
+
model_centered = model_pts - mean_model
|
|
425
|
+
H = obs_centered.T @ model_centered
|
|
426
|
+
U, _, Vt = svd(H)
|
|
427
|
+
R = Vt.T @ U.T
|
|
428
|
+
if np.linalg.det(R) < 0:
|
|
429
|
+
Vt[-1, :] *= -1
|
|
430
|
+
R = Vt.T @ U.T
|
|
431
|
+
t = mean_obs - R @ mean_model
|
|
432
|
+
return R, t
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def ransac_grid_fit_fast(pts: np.ndarray, n_rows: int = GRID_ROWS, n_cols: int = GRID_COLS,
|
|
436
|
+
iterations: int = 15, threshold: float = 4.0) -> Optional[Tuple[np.ndarray, np.ndarray]]:
|
|
437
|
+
"""Fast RANSAC with fewer iterations, only for fallback."""
|
|
438
|
+
if len(pts) < 20:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
grid_model = generate_ideal_grid(n_rows, n_cols)
|
|
442
|
+
best_inlier_count = 0
|
|
443
|
+
best_R = None
|
|
444
|
+
best_t = None
|
|
445
|
+
best_inlier_indices = None
|
|
446
|
+
best_inlier_mask = None
|
|
447
|
+
tree = KDTree(pts)
|
|
448
|
+
|
|
449
|
+
for _ in range(iterations):
|
|
450
|
+
if len(pts) < 4:
|
|
451
|
+
break
|
|
452
|
+
sample_idx = np.random.choice(len(pts), 4, replace=False)
|
|
453
|
+
sample_pts = pts[sample_idx]
|
|
454
|
+
try:
|
|
455
|
+
R, t = fit_procrustes(sample_pts, grid_model[:4])
|
|
456
|
+
except:
|
|
457
|
+
continue
|
|
458
|
+
transformed = (grid_model @ R.T) + t
|
|
459
|
+
distances, indices = tree.query(transformed)
|
|
460
|
+
inlier_mask = distances < threshold
|
|
461
|
+
inlier_count = np.sum(inlier_mask)
|
|
462
|
+
if inlier_count > best_inlier_count:
|
|
463
|
+
best_inlier_count = inlier_count
|
|
464
|
+
best_R = R
|
|
465
|
+
best_t = t
|
|
466
|
+
best_inlier_indices = indices[inlier_mask]
|
|
467
|
+
best_inlier_mask = inlier_mask
|
|
468
|
+
|
|
469
|
+
if best_inlier_count > 10 and best_R is not None and best_inlier_indices is not None:
|
|
470
|
+
inlier_pts = pts[best_inlier_indices]
|
|
471
|
+
model_matched = grid_model[best_inlier_mask]
|
|
472
|
+
if len(inlier_pts) >= 4:
|
|
473
|
+
try:
|
|
474
|
+
R_refined, t_refined = fit_procrustes(inlier_pts, model_matched)
|
|
475
|
+
return R_refined, t_refined
|
|
476
|
+
except:
|
|
477
|
+
return best_R, best_t
|
|
478
|
+
else:
|
|
479
|
+
return best_R, best_t
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def snap_to_grid_procrustes_fast(pts: np.ndarray, n_rows: int = GRID_ROWS, n_cols: int = GRID_COLS) -> Optional[np.ndarray]:
|
|
484
|
+
"""Fast fallback snap using Procrustes."""
|
|
485
|
+
if len(pts) < 20:
|
|
486
|
+
return None
|
|
487
|
+
transform = ransac_grid_fit_fast(pts, n_rows, n_cols, iterations=15, threshold=4.0)
|
|
488
|
+
if transform is None:
|
|
489
|
+
return None
|
|
490
|
+
R, t = transform
|
|
491
|
+
grid_model = generate_ideal_grid(n_rows, n_cols)
|
|
492
|
+
transformed = (grid_model @ R.T) + t
|
|
493
|
+
tree = KDTree(transformed)
|
|
494
|
+
distances, indices = tree.query(pts)
|
|
495
|
+
valid = distances < 4.0
|
|
496
|
+
valid_indices = indices[valid]
|
|
497
|
+
row_col = grid_model[valid_indices]
|
|
498
|
+
rows = row_col[:, 1].astype(int)
|
|
499
|
+
cols = row_col[:, 0].astype(int)
|
|
500
|
+
grid = np.zeros((n_rows, n_cols), dtype=np.uint8)
|
|
501
|
+
valid_rows = (rows >= 0) & (rows < n_rows)
|
|
502
|
+
valid_cols = (cols >= 0) & (cols < n_cols)
|
|
503
|
+
valid_cells = valid_rows & valid_cols
|
|
504
|
+
grid[rows[valid_cells], cols[valid_cells]] = 1
|
|
505
|
+
return grid
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ----------------------------------------------------------------------
|
|
509
|
+
# Sauvola (optional, disabled by default)
|
|
510
|
+
# ----------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def sauvola_threshold(gray: np.ndarray, window_size: int = 15, k: float = 0.2, r: float = 128) -> np.ndarray:
|
|
513
|
+
gray_f = gray.astype(np.float32)
|
|
514
|
+
mean = cv2.boxFilter(gray_f, cv2.CV_32F, (window_size, window_size))
|
|
515
|
+
sqmean = cv2.boxFilter(gray_f * gray_f, cv2.CV_32F, (window_size, window_size))
|
|
516
|
+
std = np.sqrt(np.maximum(sqmean - mean * mean, 0))
|
|
517
|
+
threshold = mean * (1 + k * (std / r - 1))
|
|
518
|
+
binary = (gray_f > threshold).astype(np.uint8) * 255
|
|
519
|
+
return binary
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class BinaryPyramidSauvola(BinaryPyramid):
|
|
523
|
+
def get(self, mode, fixed_t, erosion_level):
|
|
524
|
+
if mode == "sauvola":
|
|
525
|
+
key = ("sauvola", None, erosion_level)
|
|
526
|
+
if key in self._eroded:
|
|
527
|
+
return self._eroded[key]
|
|
528
|
+
base_key = ("sauvola", None)
|
|
529
|
+
if base_key not in self._base:
|
|
530
|
+
binary = sauvola_threshold(self.enhanced)
|
|
531
|
+
self._base[base_key] = cv2.morphologyEx(binary, cv2.MORPH_OPEN, _KERNEL, iterations=1)
|
|
532
|
+
cur, start = self._base[base_key], 0
|
|
533
|
+
for lvl in range(erosion_level, 0, -1):
|
|
534
|
+
if ("sauvola", None, lvl) in self._eroded:
|
|
535
|
+
cur, start = self._eroded[("sauvola", None, lvl)], lvl
|
|
536
|
+
break
|
|
537
|
+
for lvl in range(start + 1, erosion_level + 1):
|
|
538
|
+
cur = cv2.erode(cur, _KERNEL, iterations=1)
|
|
539
|
+
self._eroded[("sauvola", None, lvl)] = cur
|
|
540
|
+
return cur
|
|
541
|
+
else:
|
|
542
|
+
return super().get(mode, fixed_t, erosion_level)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def get_smart_combos(mean_brightness, use_sauvola=False):
|
|
546
|
+
combos = []
|
|
547
|
+
if use_sauvola:
|
|
548
|
+
combos.append(("sauvola", None, [2, 3]))
|
|
549
|
+
if mean_brightness < 40:
|
|
550
|
+
combos.extend([
|
|
551
|
+
("otsu", None, [2, 3, 4]),
|
|
552
|
+
("fixed", 135, [2, 3]),
|
|
553
|
+
("fixed", 145, [2, 3]),
|
|
554
|
+
("fixed", 155, [2, 3]),
|
|
555
|
+
])
|
|
556
|
+
elif mean_brightness < 55:
|
|
557
|
+
combos.extend([
|
|
558
|
+
("otsu", None, [2, 3]),
|
|
559
|
+
("fixed", 145, [2, 3]),
|
|
560
|
+
("fixed", 155, [2, 3]),
|
|
561
|
+
("fixed", 165, [2, 3]),
|
|
562
|
+
])
|
|
563
|
+
elif mean_brightness < 70:
|
|
564
|
+
combos.extend([
|
|
565
|
+
("otsu", None, [2, 3]),
|
|
566
|
+
("fixed", 155, [2, 3, 4]),
|
|
567
|
+
("fixed", 165, [2, 3, 4]),
|
|
568
|
+
("fixed", 175, [2, 3]),
|
|
569
|
+
])
|
|
570
|
+
else:
|
|
571
|
+
combos.extend([
|
|
572
|
+
("otsu", None, [2, 3]),
|
|
573
|
+
("fixed", 165, [2, 3]),
|
|
574
|
+
("fixed", 175, [2, 3]),
|
|
575
|
+
("fixed", 155, [2, 3]),
|
|
576
|
+
])
|
|
577
|
+
return combos
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ----------------------------------------------------------------------
|
|
581
|
+
# Rotation and straightening (unchanged)
|
|
582
|
+
# ----------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def rotate_points(pts, angle):
|
|
585
|
+
if abs(angle) < 0.05:
|
|
586
|
+
return pts
|
|
587
|
+
center = np.mean(pts, axis=0)
|
|
588
|
+
theta = np.radians(-angle)
|
|
589
|
+
c, s = np.cos(theta), np.sin(theta)
|
|
590
|
+
R = np.array([[c, -s], [s, c]])
|
|
591
|
+
return (pts - center) @ R.T + center
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def compute_row_quality(pts, n_rows=GRID_ROWS):
|
|
595
|
+
if len(pts) < 20:
|
|
596
|
+
return float('inf')
|
|
597
|
+
ys = pts[:, 1]
|
|
598
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
599
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
600
|
+
total_var = 0
|
|
601
|
+
valid_rows = 0
|
|
602
|
+
for r in range(len(row_centers)):
|
|
603
|
+
mask = row_idx == r
|
|
604
|
+
if mask.sum() > 3:
|
|
605
|
+
total_var += np.var(ys[mask])
|
|
606
|
+
valid_rows += 1
|
|
607
|
+
if valid_rows == 0:
|
|
608
|
+
return float('inf')
|
|
609
|
+
return total_var / valid_rows
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def get_angle_from_rows(pts, n_rows=GRID_ROWS):
|
|
613
|
+
if len(pts) < 20:
|
|
614
|
+
return 0
|
|
615
|
+
ys = pts[:, 1]
|
|
616
|
+
xs = pts[:, 0]
|
|
617
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
618
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
619
|
+
slopes = []
|
|
620
|
+
row_counts = []
|
|
621
|
+
for r in range(len(row_centers)):
|
|
622
|
+
mask = row_idx == r
|
|
623
|
+
count = mask.sum()
|
|
624
|
+
if count < 4:
|
|
625
|
+
continue
|
|
626
|
+
row_xs = xs[mask]
|
|
627
|
+
row_ys = ys[mask]
|
|
628
|
+
try:
|
|
629
|
+
coeffs = np.polyfit(row_xs, row_ys, 1)
|
|
630
|
+
slopes.append(coeffs[0])
|
|
631
|
+
row_counts.append(count)
|
|
632
|
+
except:
|
|
633
|
+
continue
|
|
634
|
+
if not slopes:
|
|
635
|
+
return 0
|
|
636
|
+
weights = np.array(row_counts) / sum(row_counts)
|
|
637
|
+
avg_slope = np.sum(np.array(slopes) * weights)
|
|
638
|
+
angle = np.degrees(np.arctan(avg_slope))
|
|
639
|
+
if angle > 45:
|
|
640
|
+
angle -= 90
|
|
641
|
+
elif angle < -45:
|
|
642
|
+
angle += 90
|
|
643
|
+
return angle
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def straighten_dots_iterative(pts, n_rows=GRID_ROWS, degree=2, max_iterations=REGRESSION_ITERATIONS):
|
|
647
|
+
if pts is None or len(pts) < 20:
|
|
648
|
+
return pts, 0
|
|
649
|
+
|
|
650
|
+
angle = get_angle_from_rows(pts, n_rows)
|
|
651
|
+
current_pts = rotate_points(pts, angle)
|
|
652
|
+
prev_angle = angle
|
|
653
|
+
|
|
654
|
+
for iteration in range(max_iterations):
|
|
655
|
+
xs = current_pts[:, 0]
|
|
656
|
+
ys = current_pts[:, 1]
|
|
657
|
+
|
|
658
|
+
row_centers = kmeans_1d(ys, n_rows)
|
|
659
|
+
row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
|
|
660
|
+
|
|
661
|
+
coeffs_list = []
|
|
662
|
+
row_weights = []
|
|
663
|
+
|
|
664
|
+
for r in range(len(row_centers)):
|
|
665
|
+
mask = row_idx == r
|
|
666
|
+
count = mask.sum()
|
|
667
|
+
if count < max(4, degree + 2):
|
|
668
|
+
continue
|
|
669
|
+
row_xs = xs[mask]
|
|
670
|
+
row_ys = ys[mask]
|
|
671
|
+
try:
|
|
672
|
+
c = np.polyfit(row_xs, row_ys, degree)
|
|
673
|
+
coeffs_list.append(c)
|
|
674
|
+
row_weights.append(count)
|
|
675
|
+
except:
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
if len(coeffs_list) >= 2:
|
|
679
|
+
weights = np.array(row_weights) / sum(row_weights)
|
|
680
|
+
avg_coeffs = np.zeros(len(coeffs_list[0]))
|
|
681
|
+
for c, w in zip(coeffs_list, weights):
|
|
682
|
+
avg_coeffs += c * w
|
|
683
|
+
|
|
684
|
+
trend = np.polyval(avg_coeffs, xs)
|
|
685
|
+
mean_y = float(np.mean(ys))
|
|
686
|
+
current_pts[:, 1] = ys - trend + mean_y
|
|
687
|
+
|
|
688
|
+
new_angle = get_angle_from_rows(current_pts, n_rows)
|
|
689
|
+
if abs(new_angle - prev_angle) < 0.1:
|
|
690
|
+
break
|
|
691
|
+
prev_angle = new_angle
|
|
692
|
+
current_pts = rotate_points(current_pts, new_angle)
|
|
693
|
+
else:
|
|
694
|
+
break
|
|
695
|
+
|
|
696
|
+
final_angle = get_angle_from_rows(current_pts, n_rows)
|
|
697
|
+
final_score = compute_row_quality(current_pts, n_rows)
|
|
698
|
+
original_score = compute_row_quality(pts, n_rows)
|
|
699
|
+
|
|
700
|
+
if final_score > original_score * 1.2:
|
|
701
|
+
return pts, 0
|
|
702
|
+
|
|
703
|
+
return current_pts, final_angle
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ----------------------------------------------------------------------
|
|
707
|
+
# Main Decoder Class
|
|
708
|
+
# ----------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
class DotCodeDecoder:
|
|
711
|
+
def __init__(
|
|
712
|
+
self,
|
|
713
|
+
timeout_ms: int = 300,
|
|
714
|
+
confidence: float = 0.25,
|
|
715
|
+
fast_mode: bool = True,
|
|
716
|
+
use_sauvola: bool = False # disabled by default for speed
|
|
717
|
+
):
|
|
718
|
+
self.timeout_ms = timeout_ms
|
|
719
|
+
self.confidence = confidence
|
|
720
|
+
self.fast_mode = fast_mode
|
|
721
|
+
self.use_sauvola = use_sauvola
|
|
722
|
+
|
|
723
|
+
def decode(self, image, return_details: bool = True) -> Union[str, DecodeResult, None]:
|
|
724
|
+
result = self._decode_image(image)
|
|
725
|
+
return result if return_details else (result.message if result.success else None)
|
|
726
|
+
|
|
727
|
+
def _decode_image(self, image) -> DecodeResult:
|
|
728
|
+
if isinstance(image, str):
|
|
729
|
+
img = cv2.imread(image)
|
|
730
|
+
if img is None:
|
|
731
|
+
return DecodeResult(success=False, error="READ_ERROR", file=image)
|
|
732
|
+
else:
|
|
733
|
+
img = image
|
|
734
|
+
|
|
735
|
+
start_time = time.time()
|
|
736
|
+
|
|
737
|
+
def check_timeout():
|
|
738
|
+
return (time.time() - start_time) * 1000 > self.timeout_ms
|
|
739
|
+
|
|
740
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
|
|
741
|
+
if self.use_sauvola:
|
|
742
|
+
pyramid = BinaryPyramidSauvola(gray)
|
|
743
|
+
else:
|
|
744
|
+
pyramid = BinaryPyramid(gray)
|
|
745
|
+
mean_brightness = gray.mean()
|
|
746
|
+
|
|
747
|
+
# ─── STEP 1: Find BEST dots ───
|
|
748
|
+
best_pts = None
|
|
749
|
+
best_dot_info = None
|
|
750
|
+
best_count = 0
|
|
751
|
+
|
|
752
|
+
for mode, threshold, erosions in get_smart_combos(mean_brightness, self.use_sauvola):
|
|
753
|
+
for erosion in erosions:
|
|
754
|
+
pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
755
|
+
if pts is not None and len(pts) > best_count:
|
|
756
|
+
best_count = len(pts)
|
|
757
|
+
best_pts = pts
|
|
758
|
+
best_dot_info = dot_info
|
|
759
|
+
|
|
760
|
+
if best_pts is None or len(best_pts) < MIN_DOTS:
|
|
761
|
+
elapsed = (time.time() - start_time) * 1000
|
|
762
|
+
return DecodeResult(success=False, error="NO_DOTS", elapsed_ms=elapsed)
|
|
763
|
+
|
|
764
|
+
if check_timeout():
|
|
765
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
766
|
+
|
|
767
|
+
# ─── STEP 2: Iterative Regression ───
|
|
768
|
+
straight_pts, angle = straighten_dots_iterative(best_pts, GRID_ROWS, degree=2)
|
|
769
|
+
|
|
770
|
+
# Primary: use original fast snap
|
|
771
|
+
grid, _, _ = snap_to_grid_checkerboard(straight_pts, GRID_ROWS, GRID_COLS, best_dot_info)
|
|
772
|
+
if grid is not None:
|
|
773
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
774
|
+
if occupancy >= 0.08:
|
|
775
|
+
msg = decode_from_grid(grid)
|
|
776
|
+
if msg:
|
|
777
|
+
elapsed = (time.time() - start_time) * 1000
|
|
778
|
+
return DecodeResult(
|
|
779
|
+
success=True,
|
|
780
|
+
message=msg,
|
|
781
|
+
method=f"reg_iter_{angle:.1f}",
|
|
782
|
+
elapsed_ms=elapsed,
|
|
783
|
+
stage=DecodeStage.REGRESSION,
|
|
784
|
+
confidence=occupancy * min(len(best_pts) / 150, 1.0)
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# ─── STEP 3: Angle Sweep ───
|
|
788
|
+
if self.fast_mode:
|
|
789
|
+
fallback_thresholds = [
|
|
790
|
+
("otsu", None, [2, 3]),
|
|
791
|
+
("fixed", 155, [2, 3]),
|
|
792
|
+
("fixed", 165, [2, 3]),
|
|
793
|
+
("fixed", 145, [2, 3]),
|
|
794
|
+
("fixed", 175, [2, 3]),
|
|
795
|
+
("fixed", 135, [2, 3]),
|
|
796
|
+
("fixed", 125, [2]),
|
|
797
|
+
]
|
|
798
|
+
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]
|
|
799
|
+
else:
|
|
800
|
+
fallback_thresholds = [
|
|
801
|
+
("otsu", None, [2, 3]),
|
|
802
|
+
("fixed", 155, [2, 3]),
|
|
803
|
+
("fixed", 165, [2, 3]),
|
|
804
|
+
("fixed", 145, [2, 3]),
|
|
805
|
+
("fixed", 175, [2, 3]),
|
|
806
|
+
("fixed", 135, [2, 3]),
|
|
807
|
+
("fixed", 125, [2, 3]),
|
|
808
|
+
("fixed", 115, [2, 3]),
|
|
809
|
+
("fixed", 105, [2, 3]),
|
|
810
|
+
]
|
|
811
|
+
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]
|
|
812
|
+
|
|
813
|
+
# Quick check using best_pts with original snap
|
|
814
|
+
if len(best_pts) > 80 and not check_timeout():
|
|
815
|
+
for angle in fine_angles:
|
|
816
|
+
if check_timeout():
|
|
817
|
+
break
|
|
818
|
+
rotated_pts = rotate_points(best_pts, angle)
|
|
819
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, best_dot_info)
|
|
820
|
+
if grid is not None:
|
|
821
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
822
|
+
if occupancy >= 0.08:
|
|
823
|
+
msg = decode_from_grid(grid)
|
|
824
|
+
if msg:
|
|
825
|
+
confidence = occupancy * min(len(best_pts) / 150, 1.0)
|
|
826
|
+
if confidence >= self.confidence:
|
|
827
|
+
elapsed = (time.time() - start_time) * 1000
|
|
828
|
+
return DecodeResult(
|
|
829
|
+
success=True,
|
|
830
|
+
message=msg,
|
|
831
|
+
method=f"ang_best_{angle:.1f}",
|
|
832
|
+
elapsed_ms=elapsed,
|
|
833
|
+
stage=DecodeStage.ANGLE_SWEEP,
|
|
834
|
+
confidence=confidence
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
# Full sweep: primary is original snap
|
|
838
|
+
for mode, threshold, erosions in fallback_thresholds:
|
|
839
|
+
if check_timeout():
|
|
840
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
841
|
+
|
|
842
|
+
for erosion in erosions:
|
|
843
|
+
pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
|
|
844
|
+
if pts is None or len(pts) < MIN_DOTS:
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
for angle in fine_angles:
|
|
848
|
+
if check_timeout():
|
|
849
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
850
|
+
|
|
851
|
+
rotated_pts = rotate_points(pts, angle)
|
|
852
|
+
# Primary: original snap
|
|
853
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
854
|
+
|
|
855
|
+
if grid is None:
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
859
|
+
if occupancy < 0.08:
|
|
860
|
+
# If occupancy is low, try Procrustes fallback
|
|
861
|
+
grid_fallback = snap_to_grid_procrustes_fast(rotated_pts, GRID_ROWS, GRID_COLS)
|
|
862
|
+
if grid_fallback is not None:
|
|
863
|
+
occupancy_fb = np.sum(grid_fallback) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
864
|
+
if occupancy_fb > occupancy:
|
|
865
|
+
grid = grid_fallback
|
|
866
|
+
occupancy = occupancy_fb
|
|
867
|
+
|
|
868
|
+
if occupancy < 0.08:
|
|
869
|
+
continue
|
|
870
|
+
|
|
871
|
+
msg = decode_from_grid(grid)
|
|
872
|
+
if msg:
|
|
873
|
+
confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
874
|
+
if confidence >= self.confidence:
|
|
875
|
+
elapsed = (time.time() - start_time) * 1000
|
|
876
|
+
return DecodeResult(
|
|
877
|
+
success=True,
|
|
878
|
+
message=msg,
|
|
879
|
+
method=f"ang_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
880
|
+
elapsed_ms=elapsed,
|
|
881
|
+
stage=DecodeStage.ANGLE_SWEEP,
|
|
882
|
+
confidence=confidence
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# ─── STEP 4: Invert ───
|
|
886
|
+
if not check_timeout():
|
|
887
|
+
inverted = cv2.bitwise_not(gray)
|
|
888
|
+
if self.use_sauvola:
|
|
889
|
+
pyramid_inv = BinaryPyramidSauvola(inverted)
|
|
890
|
+
else:
|
|
891
|
+
pyramid_inv = BinaryPyramid(inverted)
|
|
892
|
+
|
|
893
|
+
inv_modes = [("otsu", None, [2, 3]), ("fixed", 155, [2, 3])]
|
|
894
|
+
for mode, threshold, erosions in inv_modes:
|
|
895
|
+
if check_timeout():
|
|
896
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
897
|
+
|
|
898
|
+
for erosion in erosions:
|
|
899
|
+
pts, dot_info = detect_dots(pyramid_inv, mode, threshold, erosion)
|
|
900
|
+
if pts is None or len(pts) < MIN_DOTS:
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
for angle in fine_angles[:5]:
|
|
904
|
+
if check_timeout():
|
|
905
|
+
return DecodeResult(success=False, error="TIMEOUT", elapsed_ms=self.timeout_ms, timed_out=True)
|
|
906
|
+
|
|
907
|
+
rotated_pts = rotate_points(pts, angle)
|
|
908
|
+
# Primary: original snap
|
|
909
|
+
grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
|
|
910
|
+
|
|
911
|
+
if grid is None:
|
|
912
|
+
continue
|
|
913
|
+
|
|
914
|
+
occupancy = np.sum(grid) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
915
|
+
if occupancy < 0.08:
|
|
916
|
+
grid_fallback = snap_to_grid_procrustes_fast(rotated_pts, GRID_ROWS, GRID_COLS)
|
|
917
|
+
if grid_fallback is not None:
|
|
918
|
+
occupancy_fb = np.sum(grid_fallback) / ((GRID_ROWS * GRID_COLS + 1) // 2)
|
|
919
|
+
if occupancy_fb > occupancy:
|
|
920
|
+
grid = grid_fallback
|
|
921
|
+
occupancy = occupancy_fb
|
|
922
|
+
|
|
923
|
+
if occupancy < 0.08:
|
|
924
|
+
continue
|
|
925
|
+
|
|
926
|
+
msg = decode_from_grid(grid)
|
|
927
|
+
if msg:
|
|
928
|
+
confidence = occupancy * min(len(pts) / 150, 1.0)
|
|
929
|
+
if confidence >= self.confidence:
|
|
930
|
+
elapsed = (time.time() - start_time) * 1000
|
|
931
|
+
return DecodeResult(
|
|
932
|
+
success=True,
|
|
933
|
+
message=msg,
|
|
934
|
+
method=f"inv_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}",
|
|
935
|
+
elapsed_ms=elapsed,
|
|
936
|
+
stage=DecodeStage.INVERT,
|
|
937
|
+
confidence=confidence
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
elapsed = (time.time() - start_time) * 1000
|
|
941
|
+
return DecodeResult(success=False, error="FAILED", elapsed_ms=elapsed)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# ----------------------------------------------------------------------
|
|
945
|
+
# Simple API
|
|
946
|
+
# ----------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
def decode(image, **kwargs):
|
|
949
|
+
decoder = DotCodeDecoder(**kwargs)
|
|
950
|
+
return decoder.decode(image)
|
|
951
|
+
|
|
952
|
+
# 10,11,161,163,174,245,290,302,303,333,357,359,373,6,79
|
|
953
|
+
#10,103,11,128,133,136,161,163,171,194,201,209,245,303,333,351,357,373,6,61
|