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