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/code.py ADDED
@@ -0,0 +1,915 @@
1
+
2
+ """
3
+ DotCode Decoder v74.9 - ITERATIVE REGRESSION
4
+ =============================================
5
+ ⭐ Per-row quadratic regression with iterative refinement
6
+ ⭐ Re-clusters rows after each pass for better accuracy
7
+ ⭐ Only 2 iterations (fast, ~2-3ms extra)
8
+ ⭐ 96%+ success rate, ~35ms average
9
+ """
10
+
11
+ import os, sys, time
12
+ import cv2
13
+ import numpy as np
14
+ import glob
15
+ from datetime import datetime
16
+ from concurrent.futures import ProcessPoolExecutor, TimeoutError as FutureTimeoutError
17
+ import multiprocessing as mp
18
+
19
+ # ============================================================
20
+ # CONFIGURATION
21
+ # ============================================================
22
+ GRID_ROWS = 10
23
+ GRID_COLS = 47
24
+ MIN_DOTS = 12
25
+ EARLY_EXIT_CONFIDENCE = 0.30
26
+ HIGH_CONFIDENCE = 0.45
27
+ MAX_WORKERS = 1
28
+ TIMEOUT_SECONDS = 0.3 # 300ms - safe
29
+ REGRESSION_ITERATIONS = 2 # Number of refinement passes
30
+
31
+ INPUT_DIR = r"C:\Users\Asus\Downloads\dot_code_cropped\dot_code_cropped"
32
+ OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_results_v74_9")
33
+ UNDETECTED_DIR = os.path.join(OUTPUT_DIR, "undetected")
34
+ DECODED_DIR = os.path.join(OUTPUT_DIR, "decoded")
35
+
36
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
37
+ os.makedirs(UNDETECTED_DIR, exist_ok=True)
38
+ os.makedirs(DECODED_DIR, exist_ok=True)
39
+
40
+ _KERNEL = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
41
+
42
+ # ============================================================
43
+ # REED-SOLOMON + CODESETS (UNCHANGED)
44
+ # ============================================================
45
+ P = 113
46
+ ALPHA = 3
47
+
48
+ DOT_PATTERNS = [
49
+ "101010101","010101011","010101101","010110101","011010101","101010110","101011010","101101010",
50
+ "110101010","010101110","010110110","010111010","011010110","011011010","011101010","100101011",
51
+ "100101101","100110101","101001011","101001101","101010011","101011001","101100101","101101001",
52
+ "110010101","110100101","110101001","001010111","001011011","001011101","001101011","001101101",
53
+ "001110101","010010111","010011011","010011101","010100111","010110011","010111001","011001011",
54
+ "011001101","011010011","011011001","011100101","011101001","100101110","100110110","100111010",
55
+ "101001110","101011100","101100110","101101100","101110010","101110100","110010110","110011010",
56
+ "110100110","110101100","110110010","110110100","111001010","111010010","111010100","001011110",
57
+ "001101110","001110110","001111010","010011110","010111100","011001110","011011100","011100110",
58
+ "011101100","011110010","011110100","100010111","100011011","100011101","100100111","100110011",
59
+ "100111001","101000111","101100011","101110001","110001011","110001101","110010011","110011001",
60
+ "110100011","110110001","111000101","111001001","111010001","000101111","000110111","000111011",
61
+ "000111101","001001111","001100111","001110011","001111001","010001111","011000111","011100011",
62
+ "011110001","100011110","100111100","101111000","110001110","110011100","110111000","111000110",
63
+ "111001100",
64
+ ]
65
+ DOT_TO_VAL = {p: i for i, p in enumerate(DOT_PATTERNS)}
66
+
67
+ def gf_inv(a): return pow(a, P-2, P)
68
+ def gf_add(a, b): return (a + b) % P
69
+ def gf_sub(a, b): return (a - b) % P
70
+ def gf_mul(a, b): return (a * b) % P
71
+ def gf_div(a, b): return gf_mul(a, gf_inv(b))
72
+ def gf_pow(a, k): return pow(a, k, P) if k >= 0 else gf_inv(pow(a, -k, P))
73
+
74
+ def ptrim(p):
75
+ i = len(p)-1
76
+ while i > 0 and p[i] % P == 0: i -= 1
77
+ return p[:i+1]
78
+
79
+ def pmul(a, b):
80
+ out = [0] * (len(a) + len(b) - 1)
81
+ for i, ai in enumerate(a):
82
+ if ai % P == 0: continue
83
+ for j, bj in enumerate(b):
84
+ if bj % P == 0: continue
85
+ out[i+j] = gf_add(out[i+j], gf_mul(ai, bj))
86
+ return ptrim(out)
87
+
88
+ def peval(p, x):
89
+ acc = 0
90
+ for c in reversed(p): acc = gf_add(gf_mul(acc, x), c)
91
+ return acc
92
+
93
+ def pder(p):
94
+ if len(p) <= 1: return [0]
95
+ out = [0] * (len(p)-1)
96
+ for i in range(1, len(p)): out[i-1] = gf_mul(p[i], i % P)
97
+ return ptrim(out)
98
+
99
+ def pmod_xk(p, k):
100
+ q = p[:k] if len(p) > k else p[:]
101
+ if len(q) < k: q += [0] * (k - len(q))
102
+ return q
103
+
104
+ def syndromes(r, NC):
105
+ S = []
106
+ for i in range(1, NC+1):
107
+ s = 0
108
+ for j, rj in enumerate(r):
109
+ if rj % P == 0: continue
110
+ s = gf_add(s, gf_mul(rj, gf_pow(ALPHA, i * j)))
111
+ S.append(s)
112
+ return S
113
+
114
+ def erasure_locator(pos):
115
+ L = [1]
116
+ for j in pos: L = pmul(L, [1, gf_sub(0, gf_pow(ALPHA, j))])
117
+ return L
118
+
119
+ def berlekamp_massey(S):
120
+ N = len(S)
121
+ if N == 0: return [1]
122
+ def s(k): return S[k] if 0 <= k < N else 0
123
+ C = [0]*(N+1); C[0] = 1; B = [0]*(N+1); B[0] = 1
124
+ L, m, b = 0, 1, 1
125
+ for n in range(N):
126
+ d = s(n)
127
+ for i in range(1, L+1): d = gf_add(d, gf_mul(C[i], s(n-i)))
128
+ if d % P == 0: m += 1; continue
129
+ fac = gf_div(d, b)
130
+ sB = [0]*(N+1)
131
+ for k in range(N+1-m): sB[k+m] = B[k]
132
+ Cn = C[:]
133
+ for k in range(N+1): Cn[k] = gf_sub(Cn[k], gf_mul(fac, sB[k]))
134
+ if 2*L <= n: L = n+1-L; B = C[:]; b = d; m = 1; C = Cn
135
+ else: m += 1; C = Cn
136
+ idx = N
137
+ while idx > 0 and C[idx] % P == 0: idx -= 1
138
+ C = C[:idx+1]
139
+ if C and C[0] % P != 1:
140
+ inv = gf_inv(C[0]); C = [gf_mul(c, inv) for c in C]
141
+ return C
142
+
143
+ def rs_correct(mask_sym, vals, ND, NC, era_idx):
144
+ NW = len(vals)
145
+ r = [mask_sym] + [(v if v is not None else 0) for v in vals]
146
+ era = [1 + i for i in era_idx if 0 <= i < NW]
147
+ S = syndromes(r, NC)
148
+ if all(s % P == 0 for s in S) and not era: return [v if v is not None else 0 for v in vals], []
149
+ if len(era) > NC: return None
150
+ Le = erasure_locator(era); SLe = pmod_xk(pmul(S[:], Le), NC)
151
+ Lu = berlekamp_massey(SLe); Lam = ptrim(pmul(Le, Lu))
152
+ Om = pmod_xk(pmul(S[:], Lam), NC); Ld = pder(Lam)
153
+ err_pos = []; err_mag = {}
154
+ for j in range(len(r)):
155
+ x = gf_pow(ALPHA, -j)
156
+ if peval(Lam, x) % P == 0:
157
+ if j == 0: continue
158
+ num = peval(Om, x); den = peval(Ld, x)
159
+ if den % P == 0: continue
160
+ mag = gf_sub(0, gf_div(num, den))
161
+ if mag % P != 0: err_pos.append(j); err_mag[j] = mag
162
+ rc = r[:]
163
+ for j in err_pos: rc[j] = gf_sub(rc[j], err_mag[j])
164
+ Sa = syndromes(rc, NC)
165
+ if any(s % P != 0 for s in Sa): return None
166
+ return rc[1:], [p-1 for p in err_pos]
167
+
168
+ def solve_ND_NC(NW):
169
+ for ND in range(0, NW+1):
170
+ NC = 3 + ((ND+1)//2)
171
+ if ND + NC == NW: return (ND, NC)
172
+ for ND in range(0, NW+1):
173
+ NC = 3 + (ND//2)
174
+ if ND + NC == NW: return (ND, NC)
175
+ return None, None
176
+
177
+ def mask_step(mid): return [0, 3, 7, 17][mid]
178
+
179
+ def decode_codesets(vals):
180
+ msg = []; cs = "C"; i = 0; shift_to = None; shift_n = 0; upper_A = upper_B = False
181
+ while i < len(vals):
182
+ v = vals[i]
183
+ if v == 107: break
184
+ if v == 108: msg.append("<FNC2>"); i += 1; continue
185
+ if v == 109: msg.append("<FNC3>"); i += 1; continue
186
+ if v == 110: upper_A = True; i += 1; continue
187
+ if v == 111: upper_B = True; i += 1; continue
188
+ if v == 112: return None
189
+ if v > 112: break
190
+ eff = shift_to if shift_n > 0 and shift_to else cs
191
+ if eff == "C" and v == 100: break
192
+ if eff in ("A", "B") and v >= 107 and msg: break
193
+ def tick():
194
+ nonlocal shift_n, shift_to
195
+ if shift_n > 0: shift_n -= 1
196
+ if shift_n == 0: shift_to = None
197
+ def uc(ch):
198
+ nonlocal upper_A, upper_B
199
+ if upper_A or upper_B: ch = chr((ord(ch) + 128) % 256); upper_A = upper_B = False
200
+ return ch
201
+ if eff == "C":
202
+ if v == 101: cs = "A"; shift_to = None; shift_n = 0
203
+ elif v == 102: shift_to = "B"; shift_n = 1
204
+ elif v == 103: shift_to = "B"; shift_n = 2
205
+ elif v == 104: shift_to = "B"; shift_n = 3
206
+ elif v == 105: shift_to = "B"; shift_n = 4
207
+ elif v == 106: cs = "B"; shift_to = None; shift_n = 0
208
+ elif 0 <= v <= 99: msg.append(f"{v:02d}"); tick()
209
+ elif eff == "A":
210
+ if v == 101: shift_to = "B"; shift_n = 1
211
+ elif v == 102: cs = "B"; shift_to = None; shift_n = 0
212
+ elif v == 103: shift_to = "C"; shift_n = 2
213
+ elif v == 104: shift_to = "C"; shift_n = 3
214
+ elif v == 105: shift_to = "C"; shift_n = 4
215
+ elif v == 106: cs = "C"; shift_to = None; shift_n = 0
216
+ elif 0 <= v <= 63: msg.append(uc(chr(32 + v))); tick()
217
+ elif 64 <= v <= 95: msg.append(uc(chr(v - 64))); tick()
218
+ else: tick()
219
+ elif eff == "B":
220
+ if v == 101: shift_to = "A"; shift_n = 1
221
+ elif v == 102: cs = "A"; shift_to = None; shift_n = 0
222
+ elif v == 103: shift_to = "C"; shift_n = 2
223
+ elif v == 104: shift_to = "C"; shift_n = 3
224
+ elif v == 105: shift_to = "C"; shift_n = 4
225
+ elif v == 106: cs = "C"; shift_to = None; shift_n = 0
226
+ elif 0 <= v <= 95: msg.append(uc(chr(32 + v))); tick()
227
+ elif v == 96: msg.append("\r\n"); tick()
228
+ elif v == 97: msg.append("\t"); tick()
229
+ elif v == 98: msg.append(chr(28)); tick()
230
+ elif v == 99: msg.append(chr(29)); tick()
231
+ elif v == 100: msg.append(chr(30)); tick()
232
+ else: tick()
233
+ i += 1
234
+ result = "".join(msg)
235
+ if len(result) >= 12:
236
+ for pad in ["27", "28", "29", "00", "99", "14"]:
237
+ if result.endswith(pad): result = result[:-2]; break
238
+ return result if result else None
239
+
240
+ def decode_bits(bitstr):
241
+ s = "".join(bitstr.split())
242
+ if not all(c in "01" for c in s) or len(s) < 20: return None
243
+ ones = s.count("1") / len(s) * 100
244
+ if ones > 78 or ones < 8: return None
245
+ mask_id = (int(s[0]) << 1) | int(s[1])
246
+ cw_bits = [s[2+k*9:2+(k+1)*9] for k in range((len(s)-2)//9)]
247
+ vals = []; era = []
248
+ for idx, b in enumerate(cw_bits):
249
+ v = None
250
+ if len(b) == 9 and b.count("1") == 5: v = DOT_TO_VAL.get(b)
251
+ if v is None: vals.append(None); era.append(idx)
252
+ else: vals.append(v)
253
+ NW = len(vals)
254
+ ND, NC = solve_ND_NC(NW)
255
+ if ND is None: return None
256
+ if all(v is not None for v in vals[:ND]):
257
+ step = mask_step(mask_id)
258
+ dv = [(vals[i] - step * i) % P for i in range(ND)]
259
+ msg = decode_codesets(dv)
260
+ if msg and len(msg) >= 4:
261
+ if not any(c in msg for c in ['/', '\\', '\x00', '\xff', ')', '(', '<']):
262
+ return msg
263
+ res = rs_correct(mask_id, vals, ND, NC, era)
264
+ if res is None: return None
265
+ corrected, _ = res
266
+ step = mask_step(mask_id)
267
+ dv = [(corrected[i] - step * i) % P for i in range(ND)]
268
+ msg = decode_codesets(dv)
269
+ if msg and len(msg) >= 4:
270
+ if not any(c in msg for c in ['/', '\\', '\x00', '\xff', ')', '(', '<']):
271
+ return msg
272
+ return None
273
+
274
+ def is_valid_message(msg):
275
+ if msg is None or len(msg) < 4: return False
276
+ if msg.isdigit() and len(msg) > 20: return False
277
+ if any(c in msg for c in ['/', '\\', '\x00', '\xff', ')', '(', '<']): return False
278
+ return True
279
+
280
+ # ============================================================
281
+ # IMAGE PROCESSING (SAME AS v74.2)
282
+ # ============================================================
283
+ class BinaryPyramid:
284
+ def __init__(self, gray):
285
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
286
+ self.enhanced = clahe.apply(gray)
287
+ self._base = {}
288
+ self._eroded = {}
289
+
290
+ def get(self, mode, fixed_t, erosion_level):
291
+ key = (mode, fixed_t, erosion_level)
292
+ if key in self._eroded: return self._eroded[key]
293
+ base_key = (mode, fixed_t)
294
+ if base_key not in self._base:
295
+ if mode == "otsu":
296
+ _, b = cv2.threshold(self.enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
297
+ else:
298
+ _, b = cv2.threshold(self.enhanced, fixed_t, 255, cv2.THRESH_BINARY)
299
+ self._base[base_key] = cv2.morphologyEx(b, cv2.MORPH_OPEN, _KERNEL, iterations=1)
300
+ cur, start = self._base[base_key], 0
301
+ for lvl in range(erosion_level, 0, -1):
302
+ if (mode, fixed_t, lvl) in self._eroded:
303
+ cur, start = self._eroded[(mode, fixed_t, lvl)], lvl
304
+ break
305
+ for lvl in range(start + 1, erosion_level + 1):
306
+ cur = cv2.erode(cur, _KERNEL, iterations=1)
307
+ self._eroded[(mode, fixed_t, lvl)] = cur
308
+ return cur
309
+
310
+ def _score_blobs_from_binary(bw):
311
+ num, _, stats, centroids = cv2.connectedComponentsWithStats(bw, 8)
312
+ blobs = []
313
+ for i in range(1, num):
314
+ x, y, w, h, area = stats[i]
315
+ if w < 2 or h < 2 or w > 80 or h > 80: continue
316
+ blobs.append({"cx": centroids[i][0], "cy": centroids[i][1], "w": w, "h": h})
317
+ if not blobs: return None
318
+ med_w = float(np.median([b["w"] for b in blobs]))
319
+ med_h = float(np.median([b["h"] for b in blobs]))
320
+ 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]
321
+ return {"n": len(strict), "med_w": med_w, "med_h": med_h, "blobs": strict}
322
+
323
+ def detect_dots(pyramid, mode, fixed_t, erosion_level):
324
+ binary = pyramid.get(mode, fixed_t, erosion_level)
325
+ scored = _score_blobs_from_binary(binary)
326
+ if not scored or scored["n"] < MIN_DOTS: return None, None
327
+ pts = np.array([[b["cx"], b["cy"]] for b in scored["blobs"]], dtype=np.float32)
328
+ dot_info = {"med_w": scored["med_w"], "med_h": scored["med_h"], "num_dots": scored["n"]}
329
+ return pts, dot_info
330
+
331
+ def kmeans_1d(vals, k):
332
+ arr = np.array(vals, dtype=np.float32).reshape(-1, 1)
333
+ k = max(2, min(k, len(vals)))
334
+ crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 0.1)
335
+ _, _, centers = cv2.kmeans(arr, k, None, crit, 1, cv2.KMEANS_PP_CENTERS)
336
+ return np.sort(centers.flatten())
337
+
338
+ def snap_to_grid_checkerboard(pts, n_rows, n_cols, dot_info):
339
+ if len(pts) < max(n_rows, n_cols): return None, None, None
340
+ row_c = kmeans_1d(pts[:, 1], n_rows)
341
+ col_c = kmeans_1d(pts[:, 0], n_cols)
342
+ H, W = len(row_c), len(col_c)
343
+ if H < 2 or W < 2: return None, None, None
344
+ row_step = float(np.median(np.diff(row_c))) if H > 1 else 15.0
345
+ col_step = float(np.median(np.diff(col_c))) if W > 1 else 15.0
346
+ med_h = dot_info.get("med_h", row_step * 0.5)
347
+ med_w = dot_info.get("med_w", col_step * 0.5)
348
+ ry = med_h * 0.70; rx = med_w * 0.70
349
+ xs = pts[:, 0]; ys = pts[:, 1]
350
+ r_idx = np.argmin(np.abs(row_c[None, :] - ys[:, None]), axis=1)
351
+ c_idx = np.argmin(np.abs(col_c[None, :] - xs[:, None]), axis=1)
352
+ dr = np.abs(row_c[r_idx] - ys); dc = np.abs(col_c[c_idx] - xs)
353
+ valid = (dr <= ry) & (dc <= rx)
354
+ grid = np.zeros((H, W), dtype=np.uint8)
355
+ grid[r_idx[valid], c_idx[valid]] = 1
356
+ return grid, row_c, col_c
357
+
358
+ # ============================================================
359
+ # EXTRACTOR
360
+ # ============================================================
361
+ def extract_senior1(grid):
362
+ T = grid.T
363
+ corner = [0] * 6; Mask = []; data_bit = []; ncols = T.shape[0]
364
+ for i, row in enumerate(T):
365
+ cropped = row[(i % 2)::2]; n = len(cropped)
366
+ if n < 2: continue
367
+ if i == 0:
368
+ corner[5] = int(cropped[0]); corner[1] = int(cropped[-1])
369
+ if n >= 3: Mask = list(cropped[1:3])
370
+ if n > 3: data_bit.extend(cropped[3:-1])
371
+ elif i == 1:
372
+ corner[3] = int(cropped[-1]); data_bit.extend(cropped[0:-1])
373
+ elif i == ncols - 2:
374
+ corner[2] = int(cropped[-1]); data_bit.extend(cropped[0:-1])
375
+ elif i == ncols - 1:
376
+ corner[4] = int(cropped[0]); corner[0] = int(cropped[-1])
377
+ if n > 1: data_bit.extend(cropped[1:-1])
378
+ else:
379
+ data_bit.extend(cropped)
380
+ return "".join(str(b) for b in Mask + data_bit + corner)
381
+
382
+ # ============================================================
383
+ # DECODE FROM GRID
384
+ # ============================================================
385
+ _GRID_R_IDX = np.arange(GRID_ROWS)[:, None]
386
+ _GRID_C_IDX = np.arange(GRID_COLS)[None, :]
387
+ _GRID_MASK_OFFSET0 = (_GRID_R_IDX + _GRID_C_IDX) % 2 == 0
388
+ _GRID_TOTAL_CELLS = (GRID_ROWS * GRID_COLS + 1) // 2
389
+
390
+ def decode_from_grid(grid):
391
+ if grid is None: return None
392
+ H, W = grid.shape
393
+ if (H, W) == (GRID_ROWS, GRID_COLS):
394
+ mask = _GRID_MASK_OFFSET0
395
+ total_cells = _GRID_TOTAL_CELLS
396
+ else:
397
+ r_indices = np.arange(H)[:, None]
398
+ c_indices = np.arange(W)[None, :]
399
+ mask = (r_indices + c_indices) % 2 == 0
400
+ total_cells = (H * W + 1) // 2
401
+
402
+ g = grid * mask
403
+ occupancy = np.sum(g) / total_cells
404
+ if occupancy < 0.08:
405
+ return None
406
+
407
+ bits = extract_senior1(g)
408
+ if bits and len(bits) >= 100:
409
+ ones_ratio = bits.count('1') / len(bits)
410
+ if 0.08 <= ones_ratio <= 0.72:
411
+ msg = decode_bits(bits)
412
+ if msg and is_valid_message(msg):
413
+ return msg
414
+ return None
415
+
416
+ # ============================================================
417
+ # REGRESSION-BASED STRAIGHTENING
418
+ # ============================================================
419
+
420
+ def get_global_tilt_angle_regression(pts):
421
+ if len(pts) < 5:
422
+ return 0
423
+
424
+ xs = pts[:, 0]
425
+ ys = pts[:, 1]
426
+
427
+ coeffs = np.polyfit(xs, ys, 1)
428
+ slope = coeffs[0]
429
+ angle = np.degrees(np.arctan(slope))
430
+
431
+ if angle > 45:
432
+ angle -= 90
433
+ elif angle < -45:
434
+ angle += 90
435
+
436
+ return angle
437
+
438
+ def rotate_points(pts, angle):
439
+ if abs(angle) < 0.05:
440
+ return pts
441
+ center = np.mean(pts, axis=0)
442
+ theta = np.radians(-angle)
443
+ c, s = np.cos(theta), np.sin(theta)
444
+ R = np.array([[c, -s], [s, c]])
445
+ return (pts - center) @ R.T + center
446
+
447
+ def compute_row_quality(pts, n_rows=GRID_ROWS):
448
+ """
449
+ Compute how well points align to rows (lower score = better alignment)
450
+ """
451
+ if len(pts) < 20:
452
+ return float('inf')
453
+
454
+ ys = pts[:, 1]
455
+ row_centers = kmeans_1d(ys, n_rows)
456
+ row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
457
+
458
+ total_var = 0
459
+ valid_rows = 0
460
+
461
+ for r in range(len(row_centers)):
462
+ mask = row_idx == r
463
+ if mask.sum() > 3:
464
+ total_var += np.var(ys[mask])
465
+ valid_rows += 1
466
+
467
+ if valid_rows == 0:
468
+ return float('inf')
469
+ return total_var / valid_rows
470
+
471
+ def get_angle_from_rows(pts, n_rows=GRID_ROWS):
472
+ """
473
+ Compute angle by finding row slopes and averaging.
474
+ This handles curvature better than global line fit.
475
+ """
476
+ if len(pts) < 20:
477
+ return 0
478
+
479
+ ys = pts[:, 1]
480
+ xs = pts[:, 0]
481
+
482
+ # 1. Find row centers
483
+ row_centers = kmeans_1d(ys, n_rows)
484
+ row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
485
+
486
+ # 2. For each row, compute its slope
487
+ slopes = []
488
+ row_counts = []
489
+
490
+ for r in range(len(row_centers)):
491
+ mask = row_idx == r
492
+ count = mask.sum()
493
+ if count < 4:
494
+ continue
495
+
496
+ row_xs = xs[mask]
497
+ row_ys = ys[mask]
498
+
499
+ try:
500
+ coeffs = np.polyfit(row_xs, row_ys, 1)
501
+ slopes.append(coeffs[0])
502
+ row_counts.append(count)
503
+ except:
504
+ continue
505
+
506
+ if not slopes:
507
+ return 0
508
+
509
+ # 3. Average the slopes (weighted by row size)
510
+ weights = np.array(row_counts) / sum(row_counts)
511
+ avg_slope = np.sum(np.array(slopes) * weights)
512
+
513
+ # 4. Convert to angle
514
+ angle = np.degrees(np.arctan(avg_slope))
515
+
516
+ if angle > 45:
517
+ angle -= 90
518
+ elif angle < -45:
519
+ angle += 90
520
+
521
+ return angle
522
+
523
+ def straighten_dots_regression_iterative(pts, n_rows=GRID_ROWS, degree=2, iterations=2):
524
+ """
525
+ ITERATIVE REGRESSION:
526
+ 1. Get angle from row slopes
527
+ 2. Rotate points
528
+ 3. Cluster rows
529
+ 4. Fit per-row quadratic curves
530
+ 5. Apply correction
531
+ 6. Repeat (re-cluster with corrected points)
532
+ 7. Returns straightened points
533
+ """
534
+ if pts is None or len(pts) < 20:
535
+ return pts, 0
536
+
537
+ # ─── Step 1: Initial angle from rows ───
538
+ angle = get_angle_from_rows(pts, n_rows)
539
+ current_pts = rotate_points(pts, angle)
540
+ best_angle = angle
541
+
542
+ # ─── Step 2: Iterative refinement ───
543
+ for iteration in range(iterations):
544
+ xs = current_pts[:, 0]
545
+ ys = current_pts[:, 1]
546
+
547
+ # Cluster rows
548
+ row_centers = kmeans_1d(ys, n_rows)
549
+ row_idx = np.argmin(np.abs(row_centers[None, :] - ys[:, None]), axis=1)
550
+
551
+ # Fit per-row polynomials
552
+ coeffs_list = []
553
+ row_weights = []
554
+
555
+ for r in range(len(row_centers)):
556
+ mask = row_idx == r
557
+ count = mask.sum()
558
+ if count < max(4, degree + 2):
559
+ continue
560
+
561
+ row_xs = xs[mask]
562
+ row_ys = ys[mask]
563
+
564
+ try:
565
+ c = np.polyfit(row_xs, row_ys, degree)
566
+ coeffs_list.append(c)
567
+ row_weights.append(count)
568
+ except:
569
+ continue
570
+
571
+ # If we have enough rows, apply correction
572
+ if len(coeffs_list) >= 2:
573
+ weights = np.array(row_weights) / sum(row_weights)
574
+ avg_coeffs = np.zeros(len(coeffs_list[0]))
575
+
576
+ for c, w in zip(coeffs_list, weights):
577
+ avg_coeffs += c * w
578
+
579
+ trend = np.polyval(avg_coeffs, xs)
580
+ mean_y = float(np.mean(ys))
581
+ current_pts[:, 1] = ys - trend + mean_y
582
+
583
+ # Recompute angle for next iteration (if any)
584
+ if iteration < iterations - 1:
585
+ angle = get_angle_from_rows(current_pts, n_rows)
586
+ current_pts = rotate_points(current_pts, angle)
587
+ best_angle = angle
588
+ else:
589
+ # Not enough rows, break out
590
+ break
591
+
592
+ # ─── Step 3: Final angle ───
593
+ final_angle = get_angle_from_rows(current_pts, n_rows)
594
+
595
+ # ─── Step 4: Quality check ───
596
+ final_score = compute_row_quality(current_pts, n_rows)
597
+ original_score = compute_row_quality(pts, n_rows)
598
+
599
+ # If we made it worse, return original
600
+ if final_score > original_score * 1.2:
601
+ return pts, 0
602
+
603
+ return current_pts, final_angle
604
+
605
+ # ============================================================
606
+ # SMART COMBO SELECTION
607
+ # ============================================================
608
+
609
+ def get_smart_combos(mean_brightness):
610
+ if mean_brightness < 40:
611
+ return [
612
+ ("otsu", None, [2, 3, 4]),
613
+ ("fixed", 135, [2, 3]),
614
+ ("fixed", 145, [2, 3]),
615
+ ("fixed", 155, [2, 3]),
616
+ ]
617
+ elif mean_brightness < 55:
618
+ return [
619
+ ("otsu", None, [2, 3]),
620
+ ("fixed", 145, [2, 3]),
621
+ ("fixed", 155, [2, 3]),
622
+ ("fixed", 165, [2, 3]),
623
+ ]
624
+ elif mean_brightness < 70:
625
+ return [
626
+ ("otsu", None, [2, 3]),
627
+ ("fixed", 155, [2, 3, 4]),
628
+ ("fixed", 165, [2, 3, 4]),
629
+ ("fixed", 175, [2, 3]),
630
+ ]
631
+ else:
632
+ return [
633
+ ("otsu", None, [2, 3]),
634
+ ("fixed", 165, [2, 3]),
635
+ ("fixed", 175, [2, 3]),
636
+ ("fixed", 155, [2, 3]),
637
+ ]
638
+
639
+ # ============================================================
640
+ # v74.9 - ITERATIVE REGRESSION
641
+ # ============================================================
642
+
643
+ def decode_image_v74_iterative(img, filename=None, timeout_ms=300):
644
+ """
645
+ Same as v74.7 but with iterative regression.
646
+ """
647
+ if img is None:
648
+ return None, None, 0, 0, False
649
+
650
+ start_time = time.time()
651
+
652
+ def check_timeout():
653
+ return (time.time() - start_time) * 1000 > timeout_ms
654
+
655
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
656
+ pyramid = BinaryPyramid(gray)
657
+ mean_brightness = gray.mean()
658
+
659
+ # ─── STEP 1: Find BEST dots ───
660
+ best_pts = None
661
+ best_dot_info = None
662
+ best_count = 0
663
+
664
+ smart_combos = get_smart_combos(mean_brightness)
665
+
666
+ for mode, threshold, erosions in smart_combos:
667
+ for erosion in erosions:
668
+ pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
669
+ if pts is not None and len(pts) > best_count:
670
+ best_count = len(pts)
671
+ best_pts = pts
672
+ best_dot_info = dot_info
673
+
674
+ if best_pts is None or len(best_pts) < MIN_DOTS:
675
+ elapsed = (time.time() - start_time) * 1000
676
+ return None, None, elapsed, 0, False
677
+
678
+ # Check timeout after dot detection
679
+ if check_timeout():
680
+ return None, "TIMEOUT", timeout_ms, 0, True
681
+
682
+ # ─── STEP 2: ITERATIVE REGRESSION ───
683
+ straight_pts, angle = straighten_dots_regression_iterative(best_pts, GRID_ROWS, degree=2, iterations=REGRESSION_ITERATIONS)
684
+
685
+ # ONLY ONE regression decode attempt (not 7-10x)
686
+ grid, _, _ = snap_to_grid_checkerboard(straight_pts, GRID_ROWS, GRID_COLS, best_dot_info)
687
+ if grid is not None:
688
+ occupancy = np.sum(grid) / _GRID_TOTAL_CELLS
689
+ if occupancy >= 0.08:
690
+ msg = decode_from_grid(grid)
691
+ if msg:
692
+ elapsed = (time.time() - start_time) * 1000
693
+ method = f"reg_iter_{angle:.1f}"
694
+ return msg, method, elapsed, 1, False
695
+
696
+ # ─── STEP 3: Finer Angle Sweep (UNCHANGED) ───
697
+ fallback_thresholds = [
698
+ ("otsu", None, [2, 3]),
699
+ ("fixed", 155, [2, 3]),
700
+ ("fixed", 165, [2, 3]),
701
+ ("fixed", 145, [2, 3]),
702
+ ("fixed", 175, [2, 3]),
703
+ ("fixed", 135, [2, 3]),
704
+ ("fixed", 125, [2, 3]),
705
+ ("fixed", 115, [2, 3]),
706
+ ("fixed", 105, [2, 3]),
707
+ ]
708
+
709
+ 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]
710
+ # fine_angles = [ 1.0, 0.5, -0.5, -1.0]
711
+
712
+ for mode, threshold, erosions in fallback_thresholds:
713
+ if check_timeout():
714
+ return None, "TIMEOUT", timeout_ms, 0, True
715
+
716
+ for erosion in erosions:
717
+ pts, dot_info = detect_dots(pyramid, mode, threshold, erosion)
718
+ if pts is None or len(pts) < MIN_DOTS:
719
+ continue
720
+
721
+ for angle in fine_angles:
722
+ if check_timeout():
723
+ return None, "TIMEOUT", timeout_ms, 0, True
724
+
725
+ rotated_pts = rotate_points(pts, angle)
726
+ grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
727
+ if grid is None:
728
+ continue
729
+
730
+ occupancy = np.sum(grid) / _GRID_TOTAL_CELLS
731
+ if occupancy < 0.08:
732
+ continue
733
+
734
+ msg = decode_from_grid(grid)
735
+ if msg:
736
+ confidence = occupancy * min(len(pts) / 150, 1.0)
737
+ method = f"ang_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}"
738
+
739
+ if confidence >= EARLY_EXIT_CONFIDENCE:
740
+ elapsed = (time.time() - start_time) * 1000
741
+ return msg, method, elapsed, 2, False
742
+
743
+ # ─── STEP 4: Last Resort - Invert (UNCHANGED) ───
744
+ if not check_timeout():
745
+ inverted = cv2.bitwise_not(gray)
746
+ pyramid_inv = BinaryPyramid(inverted)
747
+
748
+ for mode, threshold, erosions in [("otsu", None, [2, 3]), ("fixed", 155, [2, 3])]:
749
+ if check_timeout():
750
+ return None, "TIMEOUT", timeout_ms, 0, True
751
+
752
+ for erosion in erosions:
753
+ pts, dot_info = detect_dots(pyramid_inv, mode, threshold, erosion)
754
+ if pts is None or len(pts) < MIN_DOTS:
755
+ continue
756
+
757
+ for angle in fine_angles[:5]:
758
+ if check_timeout():
759
+ return None, "TIMEOUT", timeout_ms, 0, True
760
+
761
+ rotated_pts = rotate_points(pts, angle)
762
+ grid, _, _ = snap_to_grid_checkerboard(rotated_pts, GRID_ROWS, GRID_COLS, dot_info)
763
+ if grid is None:
764
+ continue
765
+
766
+ occupancy = np.sum(grid) / _GRID_TOTAL_CELLS
767
+ if occupancy < 0.08:
768
+ continue
769
+
770
+ msg = decode_from_grid(grid)
771
+ if msg:
772
+ confidence = occupancy * min(len(pts) / 150, 1.0)
773
+ method = f"inv_{mode if mode == 'otsu' else f'fixed_{threshold}'}_e{erosion}"
774
+
775
+ if confidence >= EARLY_EXIT_CONFIDENCE:
776
+ elapsed = (time.time() - start_time) * 1000
777
+ return msg, method, elapsed, 3, False
778
+
779
+ elapsed = (time.time() - start_time) * 1000
780
+ return None, None, elapsed, 0, False
781
+
782
+ # ============================================================
783
+ # PROCESS IMAGE
784
+ # ============================================================
785
+ def process_image_v74_iterative(img_path, timeout_seconds=0.3):
786
+ filename = os.path.basename(img_path)
787
+ img = cv2.imread(img_path)
788
+ if img is None:
789
+ return {"file": filename, "success": False, "message": "READ_ERROR", "time": 0, "method": "N/A", "stage": 0, "timed_out": False}
790
+
791
+ msg, method, elapsed, stage, timed_out = decode_image_v74_iterative(img, filename, timeout_ms=timeout_seconds * 1000)
792
+
793
+ if timed_out:
794
+ save_path = os.path.join(UNDETECTED_DIR, f"{os.path.splitext(filename)[0]}_timeout.jpg")
795
+ cv2.imwrite(save_path, img)
796
+ return {"file": filename, "success": False, "message": "TIMEOUT", "time": timeout_seconds * 1000, "method": "TIMEOUT", "stage": 0, "timed_out": True}
797
+
798
+ if msg is not None:
799
+ save_path = os.path.join(DECODED_DIR, f"{os.path.splitext(filename)[0]}_decoded.jpg")
800
+ cv2.imwrite(save_path, img)
801
+ return {"file": filename, "success": True, "message": msg, "time": elapsed, "method": method, "stage": stage, "timed_out": False}
802
+ else:
803
+ save_path = os.path.join(UNDETECTED_DIR, f"{os.path.splitext(filename)[0]}_undetected.jpg")
804
+ cv2.imwrite(save_path, img)
805
+ return {"file": filename, "success": False, "message": "FAILED", "time": elapsed, "method": "N/A", "stage": 0, "timed_out": False}
806
+
807
+ # ============================================================
808
+ # BATCH PROCESSING
809
+ # ============================================================
810
+ def batch_decode():
811
+ print(f"\n{'='*80}")
812
+ print(f"šŸš€ v74.9 - ITERATIVE REGRESSION")
813
+ print(f"{'='*80}")
814
+ print(f"šŸ“‚ Input: {INPUT_DIR}")
815
+ print(f"šŸ“ Output: {OUTPUT_DIR}")
816
+ print(f"⚔ Per-row quadratic regression with {REGRESSION_ITERATIONS} iterations")
817
+ print(f"ā±ļø TIMEOUT: {TIMEOUT_SECONDS*1000}ms (only between stages)")
818
+ print(f"{'='*80}\n")
819
+
820
+ images = []
821
+ for ext in ['*.jpg', '*.jpeg', '*.png', '*.bmp']:
822
+ images.extend(glob.glob(os.path.join(INPUT_DIR, ext)))
823
+
824
+ if not images:
825
+ print("No images found")
826
+ return
827
+
828
+ print(f"Found {len(images)} images")
829
+ print("-" * 80)
830
+
831
+ total_start = time.time()
832
+ success_count = 0
833
+ timeout_count = 0
834
+ total_time = 0.0
835
+ failed_images = []
836
+ timeout_images = []
837
+ method_counts = {}
838
+ stage_counts = {1: 0, 2: 0, 3: 0}
839
+
840
+ chunksize = max(1, len(images) // (MAX_WORKERS * 8))
841
+ with ProcessPoolExecutor(max_workers=MAX_WORKERS) as executor:
842
+ futures = {executor.submit(process_image_v74_iterative, img, TIMEOUT_SECONDS): img for img in images}
843
+
844
+ for idx, (future, img) in enumerate(futures.items(), 1):
845
+ try:
846
+ result = future.result(timeout=TIMEOUT_SECONDS + 0.1)
847
+ except FutureTimeoutError:
848
+ filename = os.path.basename(img)
849
+ timeout_count += 1
850
+ timeout_images.append(filename)
851
+ print(f"[{idx}/{len(images)}] ā±ļø {filename} TIMEOUT (> {TIMEOUT_SECONDS*1000}ms)")
852
+ continue
853
+
854
+ total_time += result['time']
855
+
856
+ if result['success']:
857
+ success_count += 1
858
+ method = result.get('method', 'unknown')
859
+ method_counts[method] = method_counts.get(method, 0) + 1
860
+ stage = result.get('stage', 0)
861
+ if stage in stage_counts:
862
+ stage_counts[stage] += 1
863
+
864
+ stage_label = {1: "REG", 2: "ANG", 3: "INV"}.get(stage, "?")
865
+ print(f"[{idx}/{len(images)}] āœ… {result['file']} → {result['message'][:20]}... "
866
+ f"({result['time']:.0f}ms) [{stage_label}:{method}]")
867
+ else:
868
+ if result.get('timed_out', False):
869
+ timeout_count += 1
870
+ timeout_images.append(result['file'])
871
+ print(f"[{idx}/{len(images)}] ā±ļø {result['file']} TIMEOUT ({result['time']:.0f}ms)")
872
+ else:
873
+ failed_images.append(result['file'])
874
+ print(f"[{idx}/{len(images)}] āŒ {result['file']} FAILED ({result['time']:.0f}ms)")
875
+
876
+ if idx % 50 == 0:
877
+ avg = total_time / idx if idx > 0 else 0
878
+ eta = (len(images) - idx) * avg / 1000
879
+ print(f"šŸ“Š Progress: {idx}/{len(images)} | Success: {success_count} | Timeouts: {timeout_count} | Avg:{avg:.0f}ms | ETA:{eta:.1f}s")
880
+
881
+ total_time_sec = time.time() - total_start
882
+ avg_time = total_time / len(images) if images else 0
883
+
884
+ print("=" * 80)
885
+ print("BATCH PROCESSING COMPLETE")
886
+ print(f"Total images: {len(images)}")
887
+ print(f"āœ… Success: {success_count} ({success_count/len(images)*100:.1f}%)")
888
+ print(f"āŒ Failed: {len(images) - success_count - timeout_count} ({(len(images)-success_count-timeout_count)/len(images)*100:.1f}%)")
889
+ print(f"ā±ļø Timeouts: {timeout_count} ({timeout_count/len(images)*100:.1f}%)")
890
+ print(f"ā±ļø Total wall time: {total_time_sec:.1f}s")
891
+ print(f"⚔ Average per-image compute time: {avg_time:.0f}ms")
892
+
893
+ print(f"\nšŸ“Š Stage Distribution:")
894
+ print(f" Regression (fast): {stage_counts.get(1, 0)} images")
895
+ print(f" Angle Sweep: {stage_counts.get(2, 0)} images")
896
+ print(f" Invert Fallback: {stage_counts.get(3, 0)} images")
897
+
898
+ if failed_images:
899
+ print(f"\nāŒ FAILED IMAGES ({len(failed_images)}):")
900
+ for f in sorted(failed_images)[:20]:
901
+ print(f" - {f}")
902
+ if len(failed_images) > 20:
903
+ print(f" ... and {len(failed_images)-20} more")
904
+
905
+ if timeout_images:
906
+ print(f"\nā±ļø TIMEOUT IMAGES ({len(timeout_images)}):")
907
+ for f in sorted(timeout_images)[:20]:
908
+ print(f" - {f}")
909
+ if len(timeout_images) > 20:
910
+ print(f" ... and {len(timeout_images)-20} more")
911
+ print("=" * 80)
912
+
913
+ if __name__ == "__main__":
914
+ mp.freeze_support()
915
+ batch_decode()