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/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