LiangLabUMB commited on
Commit
14a3279
·
verified ·
1 Parent(s): eda2fcc

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +0 -0
  2. grid_seeded.py +866 -0
  3. requirements.txt +24 -0
app.py ADDED
The diff for this file is too large to render. See raw diff
 
grid_seeded.py ADDED
@@ -0,0 +1,866 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seeded 4x4 haemocytometer block detection: one tap -> four corners.
2
+
3
+ Pure OpenCV/NumPy, CPU only -- do NOT decorate the caller with @spaces.GPU,
4
+ this must not spend ZeroGPU quota. ~1.0 s on a 12 MP photo.
5
+
6
+
7
+ What changed in v3d, and why
8
+ ----------------------------
9
+ v3c sized its analysis window as a fixed fraction of the IMAGE (55% of the
10
+ short side). That silently couples the algorithm to how zoomed-in the photo
11
+ happens to be. On the 08-17 photos the 4-period block filled 78% of that
12
+ window and everything worked; on the 08-18 photos, taken more zoomed-in, it
13
+ filled 93%, the phase and period search ran out of room, and the period came
14
+ back 10-20% SHORT -- with a plausible-looking quad and no error. Eight of eight
15
+ photos from that session were rejected or mis-fitted.
16
+
17
+ v3d sizes the window in RULING PERIODS instead (WIN_PERIODS = 6.2) and
18
+ resamples it so that one period is always PER_WORK pixels. The period is not
19
+ known in advance, so it is a fixed point: fit once at v3c's window to get a
20
+ seed, then re-window and re-fit until the period stops moving (typically two
21
+ passes, always fewer than four). Every constant downstream -- the high-pass
22
+ width, the relocation patch, the QC scan span -- is then in a fixed ratio to
23
+ the grid, so the algorithm no longer knows or cares how zoomed-in the photo is.
24
+
25
+ Sizing in periods also makes a 2x harmonic unfittable: a comb of period 2p
26
+ needs 8 periods of room and only 6.2 exist. The remaining ambiguity -- a
27
+ uniform lattice cannot distinguish p from 2p by periodicity alone -- is settled
28
+ against the image by _midpoint_ratio.
29
+
30
+ Four further changes, each measured rather than assumed:
31
+
32
+ * Tilt is scored by autocorrelation energy at grid-scale lags, not by the
33
+ standard deviation of the projection profile. The old score is
34
+ amplitude-driven and a few bright cell clumps can outvote rulings that are
35
+ 5-10 grey levels deep. Identical answers on all 12 real photos (same angle
36
+ to 0.1 deg), far better conditioned on hard ones.
37
+ * The window is flat-fielded before profiling, so a vignette or a shadow
38
+ across the field no longer suppresses the comb.
39
+ * ACCEPTANCE IS NOW ON MEASURED QUANTITIES ONLY. v3c gated mainly on
40
+ tooth_snr. On 12 real photos that number tracks how cluttered the field is,
41
+ not how accurate the quad is: 3a scores 0.59 yet lands its interior rulings
42
+ within 0.05 of a square -- better than any of the four 08-17 photos, which
43
+ score 1.1-2.3. snr is now reported and never gated.
44
+ * De-drift is weighted by the significance of its own estimate instead of
45
+ switched on at z >= 2. The hard switch is bistable: on 4c, nine taps around
46
+ one block gave the correction on eight times and off once, moving the block
47
+ 5% in area -- 5% straight onto the cell count.
48
+
49
+
50
+ Why nothing simpler works
51
+ -------------------------
52
+ In these phone-through-eyepiece photos the rulings are only ~5-10 grey levels
53
+ darker than background, inside a circular illuminated field, with hundreds of
54
+ BRIGHT cells over them. Every threshold / Canny / Hough pipeline throws the
55
+ rulings away -- measured: three such prototypes each resolved 1 of 4 photos.
56
+ What survives is averaging ALONG a ruling: signal adds coherently, cells do
57
+ not. Everything here is built on that one idea.
58
+
59
+ Stage 1 -- similarity init, at a normalised scale (above).
60
+ Stage 2 -- relocate 25 intersections, then choose between similarity (4 DOF),
61
+ affine (6) and homography (8). A richer model is accepted only if it beats
62
+ the simpler one by more than the improvement expected from its extra
63
+ parameters alone. Bootstrapping the homography gives a keystone SD of
64
+ 0.011-0.020 against estimates of 1.006-1.045, so on a single photo
65
+ perspective below ~4% is not distinguishable from relocation noise, and 8
66
+ free parameters will happily absorb that noise into a skewed quad.
67
+ Stage 2b -- de-drift, significance-weighted (above).
68
+ Stage 3 -- independent QC: scan each of the 10 fitted lattice lines
69
+ perpendicular and find where the truly darkest line is, taking the MEDIAN
70
+ along the line (the median is what makes this work -- bright cells destroy
71
+ a mean). This is the only number measured against the image rather than
72
+ against the fit's own residual, and it is what `ok` is gated on.
73
+
74
+ Do not iterate the QC into the fit: at ~5 grey levels of contrast the
75
+ darkest-offset estimate itself carries 10-20 px of noise, so refitting on
76
+ it oscillates rather than converging. It is a check, not a correction.
77
+
78
+
79
+ Measured
80
+ --------
81
+ 12 real photos (4 from 20260817_test at 3060x4080, 8 from misgana/20260818 at
82
+ 1364x2425 and 2268x4032 -- the two sessions differ 1.4x in zoom):
83
+
84
+ accepted 12/12 from the frame centre; 107/108 taps
85
+ jittered +/-0.20 period (v3c: 4/12 and 4x4 only)
86
+ period consistent within each session to ~1%
87
+ interior ruling error median 0.022 of a square, worst 0.047
88
+ 5 negative controls 0/5 accepted
89
+
90
+ Synthetic phantoms with exactly known corners, over period 140-320 px, tilt
91
+ 0-4 deg, ruling contrast 3-7 grey levels, defocus, vignetting and 3x cell load:
92
+
93
+ corner error 0.003 of a square (worst 0.007)
94
+ block area 1.0002 of truth, worst single case 1.0008
95
+ a phantom degraded past refused on 9 of 9 taps -- and would have been
96
+ usefulness wrong by 22-90% in area had it been accepted
97
+
98
+ The returned quad is exactly what warp_polygon_to_square() wants. Corners come
99
+ back in the SAME pixel space as the image passed in, ordered top-left,
100
+ top-right, bottom-right, bottom-left.
101
+ """
102
+ import numpy as np
103
+ import cv2
104
+
105
+ # ---- geometry of the analysis window ------------------------------------
106
+ WORK = 1000.0 # nominal work canvas, px
107
+ WIN_PERIODS = 6.2 # window width in ruling periods: 4 for the block,
108
+ # the rest for phase search and the angle crop
109
+ PER_WORK = WORK / WIN_PERIODS # a period is ALWAYS this many work px
110
+ HP_W = int(0.25 * PER_WORK) | 1
111
+ ANG_RANGE = 30.0 # deg, rotation half-range. Widened from 14: phone-through-
112
+ # eyepiece photos are routinely tilted 15-17 deg (measured on
113
+ # the 20260901 cos7 set), which sat AT/OUTSIDE the old +/-14 box.
114
+ # The coarse tilt search then pinned at the boundary or locked
115
+ # onto a spurious negative-angle autocorrelation peak, and every
116
+ # downstream period and line offset came out wrong -- the fit was
117
+ # then (correctly) rejected by QC, so the tap "failed". Set to 30
118
+ # for headroom against steeper future tilts. Low-tilt photos are
119
+ # unaffected: their autocorrelation peak is in the same place, and
120
+ # widening only scans extra angles that score lower.
121
+ # HARD CEILING: keep this < 45. A square grid repeats every 90 deg,
122
+ # so a tilt past 45 aliases into the perpendicular axis (the two
123
+ # ruling directions swap) and the reported angle becomes ambiguous.
124
+ BOOT_FRAC = 0.55 # v3c's fixed window -- used only to seed the fixed point
125
+ MAX_ITER = 4
126
+ REPEAT_TOL = 0.03 # period change below this ends the fixed point
127
+ N = 5 # a 4x4 block has exactly 5 rulings per axis
128
+
129
+ PATCH_SCHED = (0.45, 0.30, 0.22) # relocation patch half-size, in periods
130
+ MIN_STRENGTH = 1.0
131
+
132
+ # ---- acceptance ---------------------------------------------------------
133
+ # Every threshold is on a quantity measured against the IMAGE, or on the
134
+ # geometry of the returned quad. Nothing here is a function of the fit's own
135
+ # residual alone, and nothing is a function of tooth depth.
136
+ MAX_LINE_OFF = 0.085 # mean |offset| of the 10 lattice lines, in periods.
137
+ # 12 real photos: 0.020-0.063. 5 negatives: 0.111-0.198.
138
+ MAX_ASPECT = 1.12 # a counting square is square
139
+ MAX_TAP = 0.70 # further from a block centre than this is ambiguous
140
+ MIN_WIN_PER = 4.7 # window must hold the block plus the phase search
141
+ MIN_CONTRAST = 0.8 # weakest of the 10 rulings, grey levels. Deliberately
142
+ # a floor against a blank field and nothing more: across
143
+ # 12 real photos this runs 1.5-8.0 and on a pure-noise
144
+ # negative it reaches 4.5, so it does not separate good
145
+ # fits from bad ones. MAX_LINE_OFF does that.
146
+ MIN_AC = 0.06 # autocorrelation peak height
147
+ MAX_RMS_FR = 0.09 # model residual, in periods
148
+ MAX_LINE_OFF_FR = MAX_LINE_OFF # v3c name, kept for callers
149
+
150
+ # ---- sub-harmonic guard -------------------------------------------------
151
+ SUBHARM = 0.45 # midpoint-ruling darkness as a fraction of the block
152
+ # rulings. Measured 0.10-0.20 on all 12 real photos and
153
+ # 1.01 on a synthetic image that really did lock onto 2x.
154
+ SUBHARM_MAX = 2
155
+
156
+ # ---- de-drift -----------------------------------------------------------
157
+ DEDRIFT_S = 900 # rectification size for the drift measurement
158
+ DEDRIFT_MAX = 0.06 # cap on the implied size change, per axis
159
+
160
+ # ---- cross-image consensus ----------------------------------------------
161
+ CONS_TOL = 0.025
162
+ CONS_SPREAD = 0.10
163
+ CONS_LOCK = 0.03
164
+
165
+ FLATTEN = True
166
+ def _hp(p, w=41):
167
+ """High-pass a projection profile. Positive => darker than local mean."""
168
+ b = cv2.blur(p.reshape(-1, 1).astype(np.float32), (1, w)).ravel()
169
+ return b - p
170
+
171
+
172
+ def _profiles(a):
173
+ return _hp(a.mean(1)), _hp(a.mean(0))
174
+
175
+
176
+ def _rot(a, deg, ctr):
177
+ M = cv2.getRotationMatrix2D(ctr, deg, 1.0)
178
+ out = cv2.warpAffine(a, M, (a.shape[1], a.shape[0]),
179
+ flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
180
+ return out, M
181
+
182
+
183
+ def _period(v, lo, hi):
184
+ x = v - v.mean()
185
+ ac = np.correlate(x, x, 'full')[len(x) - 1:]
186
+ if ac[0] <= 0:
187
+ return None, 0.0
188
+ ac = ac / ac[0]
189
+ hi = min(hi, len(ac) - 1)
190
+ if hi <= lo:
191
+ return None, 0.0
192
+ seg = ac[lo:hi]
193
+ k = int(np.argmax(seg))
194
+ return lo + k, float(seg[k])
195
+
196
+
197
+ def _fit_axis(v, seed, per0):
198
+ """Joint (period, phase) for a 5-tooth comb centred near `seed`."""
199
+ best = None
200
+ k = np.arange(N) - (N - 1) / 2.0
201
+ for per in np.arange(per0 * 0.85, per0 * 1.15 + 1e-9, 0.5):
202
+ for d in np.arange(-per / 2.0, per / 2.0 + 1e-9, 1.0):
203
+ pos = seed + d + per * k
204
+ if pos[0] < 0 or pos[-1] > len(v) - 1:
205
+ continue
206
+ t = v[np.round(pos).astype(int)]
207
+ sc = float(t.min())
208
+ if best is None or sc > best[0]:
209
+ best = (sc, float(seed + d), float(per), t.copy())
210
+ return best
211
+
212
+
213
+ def _lattice():
214
+ j, i = np.meshgrid(np.arange(N), np.arange(N), indexing='ij')
215
+ return np.stack([i.ravel(), j.ravel()], 1).astype(np.float32)
216
+
217
+
218
+ def _apply(Hm, pts):
219
+ p = np.hstack([pts, np.ones((len(pts), 1), np.float32)])
220
+ q = (Hm @ p.T).T
221
+ return (q[:, :2] / q[:, 2:3]).astype(np.float32)
222
+
223
+
224
+ def _to_h(M):
225
+ """3x2 affine -> 3x3 homography."""
226
+ return np.vstack([M, [0.0, 0.0, 1.0]]).astype(np.float64)
227
+
228
+
229
+ def _relocate(g, p, du, dv, r):
230
+ """Find the true ruling intersection near predicted point `p`."""
231
+ r = int(max(8, r))
232
+ M = np.array([[du[0], dv[0], p[0] - r * du[0] - r * dv[0]],
233
+ [du[1], dv[1], p[1] - r * du[1] - r * dv[1]]], np.float32)
234
+ patch = cv2.warpAffine(g, M, (2 * r, 2 * r),
235
+ flags=cv2.INTER_LINEAR | cv2.WARP_INVERSE_MAP,
236
+ borderMode=cv2.BORDER_REFLECT).astype(np.float32)
237
+ w = max(11, (r | 1))
238
+ vb = _hp(patch.mean(1), w) # varies along dv -> locates the du-ruling
239
+ va = _hp(patch.mean(0), w) # varies along du -> locates the dv-ruling
240
+ m = max(1, int(r * 0.15))
241
+ if len(vb) - 2 * m < 3:
242
+ return None
243
+ # Prior: the true intersection should be near the prediction. Without this
244
+ # a strong cell edge or the neighbouring ruling can outvote the real line.
245
+ idx = np.arange(len(vb), dtype=np.float32)
246
+ prior = np.exp(-0.5 * ((idx - r) / (0.40 * r)) ** 2)
247
+ nb = max(float(vb.std()), 1e-6)
248
+ na = max(float(va.std()), 1e-6)
249
+ b = m + int(np.argmax((vb * prior)[m:-m]))
250
+ a = m + int(np.argmax((va * prior)[m:-m]))
251
+ s = min(float(vb[b]) / nb, float(va[a]) / na)
252
+ if s < MIN_STRENGTH:
253
+ return None
254
+ q = np.array(p, np.float32) + (a - r) * np.asarray(du) + (b - r) * np.asarray(dv)
255
+ return q.astype(np.float32), s
256
+
257
+
258
+ def _correspondences(g, Hm, per_px, patch_fr):
259
+ """Predict 25 intersections and relocate each. Returns (src, dst, strength)."""
260
+ lat = _lattice()
261
+ pred = _apply(Hm, lat)
262
+ dx = _apply(Hm, lat + np.array([[0.06, 0]], np.float32)) - pred
263
+ dy = _apply(Hm, lat + np.array([[0, 0.06]], np.float32)) - pred
264
+ src, dst, st = [], [], []
265
+ for k in range(len(lat)):
266
+ nu, nv = np.linalg.norm(dx[k]), np.linalg.norm(dy[k])
267
+ if nu < 1e-6 or nv < 1e-6:
268
+ continue
269
+ out = _relocate(g, pred[k], dx[k] / nu, dy[k] / nv, patch_fr * per_px)
270
+ if out is None:
271
+ continue
272
+ q, s = out
273
+ if not (0 <= q[0] < g.shape[1] and 0 <= q[1] < g.shape[0]):
274
+ continue
275
+ src.append(lat[k]); dst.append(q); st.append(s)
276
+ if len(src) < 8:
277
+ return None
278
+ return np.array(src, np.float32), np.array(dst, np.float32), float(np.mean(st))
279
+
280
+
281
+ def _select_model(src, dst, per_px):
282
+ """Fit similarity / affine / homography and pick the justified one.
283
+
284
+ A richer model is accepted only if it beats the simpler fit by more than
285
+ the RMS reduction expected from its extra free parameters alone. Otherwise
286
+ 8 parameters silently absorb relocation noise into a skewed quad -- which
287
+ is exactly how an auto-crop ends up wider on one side than the grid is.
288
+ """
289
+ n = len(src)
290
+ cands = []
291
+
292
+ Ms, _ = cv2.estimateAffinePartial2D(src, dst, method=cv2.RANSAC,
293
+ ransacReprojThreshold=0.07 * per_px)
294
+ if Ms is not None:
295
+ cands.append(("similarity", 4, _to_h(Ms)))
296
+ Ma, _ = cv2.estimateAffine2D(src, dst, method=cv2.RANSAC,
297
+ ransacReprojThreshold=0.07 * per_px)
298
+ if Ma is not None:
299
+ cands.append(("affine", 6, _to_h(Ma)))
300
+ Hh, _ = cv2.findHomography(src, dst, cv2.RANSAC, 0.07 * per_px)
301
+ if Hh is not None:
302
+ cands.append(("homography", 8, Hh.astype(np.float64)))
303
+ if not cands:
304
+ return None
305
+
306
+ def rms(Hm):
307
+ return float(np.sqrt((np.linalg.norm(_apply(Hm, src) - dst, axis=1) ** 2).mean()))
308
+
309
+ scored = [(name, k, Hm, rms(Hm)) for name, k, Hm in cands]
310
+ best = scored[0]
311
+ for name, k, Hm, r in scored[1:]:
312
+ if k <= best[1]:
313
+ continue
314
+ if n - k <= 1:
315
+ continue
316
+ expected = np.sqrt((n - best[1]) / float(n - k)) # chance improvement
317
+ if best[3] / max(r, 1e-6) > expected * 1.05: # 5% margin
318
+ best = (name, k, Hm, r)
319
+ return {"model": best[0], "dof": best[1], "H": best[2], "rms": best[3],
320
+ "rms_all": {s[0]: round(s[3], 2) for s in scored}}
321
+
322
+
323
+ def _ruling_offsets(g, quad, S=DEDRIFT_S):
324
+ """Rectify by `quad`, then find where the 5 true rulings actually sit.
325
+
326
+ Returns {'x': array(5), 'y': array(5)} in rectified px, where (S-1)/4 px is
327
+ one cell. Median along each line, so the bright cells cannot dominate.
328
+ """
329
+ M = cv2.getPerspectiveTransform(
330
+ np.asarray(quad, np.float32),
331
+ np.array([[0, 0], [S - 1, 0], [S - 1, S - 1], [0, S - 1]], np.float32))
332
+ w = cv2.warpPerspective(g, M, (S, S)).astype(np.float32)
333
+ win = int(0.20 * (S - 1) / 4)
334
+ out = {}
335
+ for nm, p in (('x', np.median(w, axis=0)), ('y', np.median(w, axis=1))):
336
+ v = []
337
+ for t in range(N):
338
+ c = int(round(t * (S - 1) / 4.0))
339
+ a, b = max(0, c - win), min(S, c + win + 1)
340
+ v.append(a + int(np.argmin(p[a:b])) - c)
341
+ out[nm] = np.array(v, float)
342
+ return out
343
+
344
+
345
+ def _drift_fit(offs):
346
+ """Least squares on offset-vs-index: (intercept, slope, slope std error)."""
347
+ t = np.arange(float(len(offs)))
348
+ slope, intercept = np.polyfit(t, offs, 1)
349
+ resid = offs - (intercept + slope * t)
350
+ dof = max(1, len(offs) - 2)
351
+ se = np.sqrt((resid ** 2).sum() / dof / ((t - t.mean()) ** 2).sum())
352
+ return float(intercept), float(slope), float(se)
353
+
354
+
355
+ def _scan_line(g, Hm, axis, t, per, n=140, span=0.22, step=0.01):
356
+ """Offset (lattice units) of the truly darkest line near fitted line t."""
357
+ s = np.linspace(0.08, 3.92, n)
358
+ offs = np.arange(-span, span + 1e-9, step)
359
+ vals = np.empty(len(offs), np.float32)
360
+ for k, dt in enumerate(offs):
361
+ L = (np.stack([np.full(n, t + dt), s], 1) if axis == 0
362
+ else np.stack([s, np.full(n, t + dt)], 1))
363
+ P = _apply(Hm, L.astype(np.float32))
364
+ x = np.clip(P[:, 0], 0, g.shape[1] - 1).astype(int)
365
+ y = np.clip(P[:, 1], 0, g.shape[0] - 1).astype(int)
366
+ vals[k] = np.median(g[y, x].astype(np.float32)) # median kills cells
367
+ k = int(np.argmin(vals))
368
+ return float(offs[k]), float(np.median(vals) - vals[k])
369
+
370
+
371
+ def verify_lines(g, Hm, per):
372
+ """How far the 10 fitted lattice lines sit from the real rulings, in px."""
373
+ o, c = [], []
374
+ for axis in (0, 1):
375
+ for t in range(N):
376
+ dt, con = _scan_line(g, Hm, axis, t, per)
377
+ o.append(abs(dt) * per)
378
+ c.append(con)
379
+ return (float(np.mean(o)), float(np.max(o)), float(np.mean(c)),
380
+ float(np.min(c)))
381
+ # ------------------------------------------------------- scale normalisation
382
+ def _mad(v):
383
+ """Robust spread. A few bright cell clumps inflate a standard deviation."""
384
+ return float(1.4826 * np.median(np.abs(v - np.median(v))) + 1e-6)
385
+
386
+
387
+ def _hpw(p, w=HP_W):
388
+ b = cv2.blur(np.asarray(p, np.float32).reshape(-1, 1), (1, int(w) | 1)).ravel()
389
+ return b - p
390
+
391
+
392
+ def _flat(a, per):
393
+ """Divide out illumination varying much more slowly than the grid."""
394
+ k = int(max(3, round(1.7 * per))) | 1
395
+ bg = cv2.GaussianBlur(a, (k, k), 0)
396
+ return np.clip(a / np.maximum(bg, 1e-3) * float(np.median(bg)),
397
+ 0, 255).astype(np.float32)
398
+
399
+
400
+ def _ac_power(v, lo, hi):
401
+ """Autocorrelation energy at grid-scale lags, NOT normalised by the
402
+ profile's own variance.
403
+
404
+ v3c scored tilt by the standard deviation of the projection profile, which
405
+ is amplitude-driven: a few bright cell clumps carry far more profile
406
+ variance than rulings 5-10 grey levels deep, so the sweep can lock onto
407
+ whichever angle best lines the CELLS up. Autocorrelation at grid-scale lags
408
+ sees only what repeats at the grid pitch, and leaving it unnormalised stops
409
+ a smeared, low-variance profile from winning by having little else in it.
410
+ """
411
+ x = np.asarray(v, np.float64)
412
+ x = x - x.mean()
413
+ if len(x) < 8:
414
+ return 0.0
415
+ ac = np.correlate(x, x, 'full')[len(x) - 1:]
416
+ hi = min(int(hi), len(ac) - 1)
417
+ lo = int(lo)
418
+ if hi <= lo:
419
+ return 0.0
420
+ return float(ac[lo:hi].max()) / len(x)
421
+
422
+
423
+ def _find_angle(sq, ctr, lo, hi, hint=None):
424
+ """With `hint` (the angle from the previous pass of the fixed point) only
425
+ the fine sweep is run -- the tilt cannot change between passes, only the
426
+ window around it does."""
427
+ def score(th):
428
+ r, _ = _rot(sq, th, ctr)
429
+ c = int(r.shape[0] * 0.12)
430
+ r = r[c:-c, c:-c]
431
+ return (_ac_power(_hpw(r.mean(1)), lo, hi)
432
+ + _ac_power(_hpw(r.mean(0)), lo, hi))
433
+ if hint is None:
434
+ coarse = max(np.arange(-ANG_RANGE, ANG_RANGE + 1e-9, 1.0), key=score)
435
+ span = 1.0
436
+ else:
437
+ coarse, span = float(hint), 1.5
438
+ return float(max(np.arange(coarse - span, coarse + span + 1e-9, 0.1), key=score))
439
+
440
+
441
+ def _fit_axis_locked(v, seed, per0, tol=CONS_LOCK):
442
+ """_fit_axis with the period pinned near `per0` instead of free to +/-15%."""
443
+ best = None
444
+ k = np.arange(N) - (N - 1) / 2.0
445
+ for per in np.arange(per0 * (1 - tol), per0 * (1 + tol) + 1e-9, 0.5):
446
+ for d in np.arange(-per / 2.0, per / 2.0 + 1e-9, 1.0):
447
+ pos = seed + d + per * k
448
+ if pos[0] < 0 or pos[-1] > len(v) - 1:
449
+ continue
450
+ t = v[np.round(pos).astype(int)]
451
+ sc = float(t.min())
452
+ if best is None or sc > best[0]:
453
+ best = (sc, float(seed + d), float(per), t.copy())
454
+ return best
455
+
456
+
457
+ def _pass(g, sx, sy, half, scale, lo, hi, flatten_per=None, lock=None,
458
+ ang_hint=None):
459
+ """One stage-1 fit. `half` sizes the window, `scale` resamples it."""
460
+ H, W = g.shape[:2]
461
+ half = int(max(60, half))
462
+ x0 = int(np.clip(sx - half, 0, max(0, W - 2 * half)))
463
+ y0 = int(np.clip(sy - half, 0, max(0, H - 2 * half)))
464
+ win = g[y0:min(H, y0 + 2 * half), x0:min(W, x0 + 2 * half)]
465
+ if min(win.shape) < 150:
466
+ return None
467
+ sq = cv2.resize(win, (max(16, int(win.shape[1] * scale)),
468
+ max(16, int(win.shape[0] * scale))),
469
+ interpolation=cv2.INTER_AREA).astype(np.float32)
470
+ if flatten_per and FLATTEN:
471
+ sq = _flat(sq, flatten_per)
472
+ su, sv = (sx - x0) * scale, (sy - y0) * scale
473
+ ctr = (sq.shape[1] / 2.0, sq.shape[0] / 2.0)
474
+
475
+ ang = _find_angle(sq, ctr, lo, hi, ang_hint)
476
+ rot, M = _rot(sq, ang, ctr)
477
+ su_r, sv_r = M @ np.array([su, sv, 1.0])
478
+ vy, vx = _hpw(rot.mean(1)), _hpw(rot.mean(0))
479
+ py, acy = _period(vy, lo, min(hi, len(vy) - 1))
480
+ px, acx = _period(vx, lo, min(hi, len(vx) - 1))
481
+ if py is None or px is None:
482
+ return None
483
+ per0 = 0.5 * (py + px)
484
+ if lock:
485
+ fy, fx = _fit_axis_locked(vy, sv_r, lock), _fit_axis_locked(vx, su_r, lock)
486
+ else:
487
+ fy, fx = _fit_axis(vy, sv_r, per0), _fit_axis(vx, su_r, per0)
488
+ if fy is None or fx is None:
489
+ return None
490
+ noise = 0.5 * (_mad(vy) + _mad(vx))
491
+ snr = min(float(fy[3].min()), float(fx[3].min())) / noise
492
+ cy_r, pery = fy[1], fy[2]
493
+ cx_r, perx = fx[1], fx[2]
494
+ quad_r = np.array([[cx_r - 2 * perx, cy_r - 2 * pery],
495
+ [cx_r + 2 * perx, cy_r - 2 * pery],
496
+ [cx_r + 2 * perx, cy_r + 2 * pery],
497
+ [cx_r - 2 * perx, cy_r + 2 * pery]], np.float32)
498
+ Minv = cv2.invertAffineTransform(M)
499
+ q1 = cv2.transform(quad_r.reshape(-1, 1, 2), Minv).reshape(-1, 2) / scale \
500
+ + np.array([x0, y0], np.float32)
501
+ return {"q1": q1, "per": 0.5 * (perx + pery) / scale, "ang": ang, "snr": snr,
502
+ "tap": float(np.hypot(cx_r - su_r, cy_r - sv_r)) / per0,
503
+ "ac": min(acx, acy),
504
+ "win_periods": min(sq.shape) / (0.5 * (perx + pery))}
505
+
506
+
507
+ def _lock_from(g, sx, sy, per):
508
+ """Fixed point on the window size, started from `per`."""
509
+ lo, hi = int(0.72 * PER_WORK), int(1.45 * PER_WORK)
510
+ best, trace, ang = None, [], None
511
+ for _ in range(MAX_ITER):
512
+ r = _pass(g, sx, sy, 0.5 * WIN_PERIODS * per, PER_WORK / per, lo, hi,
513
+ flatten_per=PER_WORK, ang_hint=ang)
514
+ if r is None:
515
+ return best, trace
516
+ best, ang = r, r["ang"]
517
+ trace.append(round(r["per"], 1))
518
+ if abs(r["per"] / per - 1.0) <= REPEAT_TOL:
519
+ break
520
+ per = r["per"]
521
+ return best, trace
522
+
523
+
524
+ def _scale_lock(g, sx, sy):
525
+ """The whole fix: the window is WIN_PERIODS ruling periods wide, so the
526
+ period sets the window and the window sets the period. Seed the fixed point
527
+ with one pass at v3c's image-fraction window, then iterate."""
528
+ boot = _pass(g, sx, sy, 0.5 * BOOT_FRAC * min(g.shape[:2]),
529
+ WORK / (BOOT_FRAC * min(g.shape[:2])),
530
+ int(0.10 * WORK), int(0.32 * WORK))
531
+ if boot is None:
532
+ return None, []
533
+ best, trace = _lock_from(g, sx, sy, boot["per"])
534
+ return (best or boot), [round(boot["per"], 1)] + trace
535
+
536
+
537
+ def _midpoint_ratio(g, quad, S=720):
538
+ """Is there a ruling halfway between the fitted ones?
539
+
540
+ If so the comb has locked onto every SECOND ruling. A uniform lattice
541
+ cannot tell p from 2p by periodicity alone -- both put a ruling under every
542
+ tooth -- so this has to be asked of the image, after the fact.
543
+ """
544
+ try:
545
+ M = cv2.getPerspectiveTransform(
546
+ np.asarray(quad, np.float32),
547
+ np.array([[0, 0], [S, 0], [S, S], [0, S]], np.float32))
548
+ w = cv2.warpPerspective(g, M, (S, S), flags=cv2.INTER_AREA,
549
+ borderMode=cv2.BORDER_REPLICATE).astype(np.float32)
550
+ except cv2.error:
551
+ return 0.0
552
+ cell = S / 4.0
553
+ win = max(2, int(0.10 * cell))
554
+ qs, hs = [], []
555
+ for p in (np.median(w, axis=1), np.median(w, axis=0)):
556
+ hp = cv2.blur(p.reshape(-1, 1), (1, 91)).ravel() - p
557
+ for k in (1, 2, 3):
558
+ t = int(k * cell)
559
+ qs.append(hp[max(0, t - win):t + win].max())
560
+ for k in (0.5, 1.5, 2.5, 3.5):
561
+ t = int(k * cell)
562
+ hs.append(hp[max(0, t - win):t + win].max())
563
+ q = float(np.median(qs))
564
+ return float(np.median(hs)) / q if q > 1e-6 else 0.0
565
+ # ------------------------------------------- stage 2b: de-drift the block
566
+ def _dedrift(g, quad, S=DEDRIFT_S):
567
+ """Shift and scale the block so its edges sit on the outer rulings.
568
+
569
+ The correction is weighted by the significance of its own estimate,
570
+ w = 1 - 1/z^2, rather than switched on at z >= 2 as in v3c. The hard switch
571
+ is bistable exactly where it matters: on photo 4c, nine taps around one
572
+ block put z at 3.1-4.6 eight times and 1.8 once, so the block came out 5%
573
+ larger on that one tap -- 5% straight onto the cell count. Against
574
+ synthetic phantoms with exactly known corners the weighted form is also the
575
+ more accurate of the two (mean |area bias| 0.03% vs 0.05%, worst 0.08% vs
576
+ 0.14%), so nothing is being traded away for the stability.
577
+
578
+ One pass, never iterated -- iterating oscillates.
579
+ """
580
+ m = _ruling_offsets(g, quad, S)
581
+ H = cv2.getPerspectiveTransform(
582
+ np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32),
583
+ np.asarray(quad, np.float32))
584
+ k = 4.0 / (S - 1)
585
+ bounds, info, any_applied = {}, {}, False
586
+ for nm in ('x', 'y'):
587
+ a, b, se = _drift_fit(m[nm])
588
+ z = abs(b) / max(se, 1e-9)
589
+ w = float(np.clip(1.0 - 1.0 / max(z, 1e-9) ** 2, 0.0, 1.0))
590
+ corr = abs(4 * b / (S - 1)) * w
591
+ if corr > DEDRIFT_MAX: # cap, never abandon
592
+ w *= DEDRIFT_MAX / max(corr, 1e-9)
593
+ aw, bw = a * w, b * w
594
+ bounds[nm] = (4 * aw / (S - 1), 4 + k * (aw + 4 * bw))
595
+ info[nm] = {"weight": round(w, 2), "z": round(z, 1),
596
+ "size_change_pct": round(-100 * 4 * bw / (S - 1), 2)}
597
+ any_applied |= w > 0.01
598
+ if not any_applied:
599
+ return None, info
600
+ (u0, u1), (v0, v1) = bounds['x'], bounds['y']
601
+ lat = np.array([[u0, v0], [u1, v0], [u1, v1], [u0, v1]], np.float32)
602
+ return _apply(H, lat), info
603
+
604
+
605
+ # ------------------------------------------------------------------ public
606
+ def _finish(g, s1, verify):
607
+ """Stages 2, 2b and 3, plus the acceptance rule."""
608
+ per_orig, q1 = s1["per"], s1["q1"]
609
+ ideal = np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32)
610
+ Hm = cv2.getPerspectiveTransform(ideal, q1.astype(np.float32))
611
+ sel, strength = None, 0.0
612
+ for pf in PATCH_SCHED:
613
+ co = _correspondences(g, Hm, per_orig, pf)
614
+ if co is None:
615
+ break
616
+ src, dst, strength = co
617
+ s = _select_model(src, dst, per_orig)
618
+ if s is None:
619
+ break
620
+ sel, Hm = s, s["H"]
621
+ quad = _apply(Hm, ideal) if sel else q1
622
+
623
+ drift_info = None
624
+ if sel:
625
+ qq, drift_info = _dedrift(g, quad)
626
+ if qq is not None:
627
+ quad = qq
628
+ Hm = cv2.getPerspectiveTransform(ideal, quad.astype(np.float32))
629
+
630
+ qc = None
631
+ if verify and sel:
632
+ mo, mx, mc, minc = verify_lines(g, Hm, per_orig)
633
+ qc = {"line_offset_mean_px": round(mo, 1), "line_offset_max_px": round(mx, 1),
634
+ "line_offset_mean_frac": round(mo / per_orig, 4),
635
+ "ruling_contrast_mean": round(mc, 1),
636
+ "ruling_contrast_min": round(minc, 1)}
637
+
638
+ def side(a, b):
639
+ return float(np.linalg.norm(quad[a] - quad[b]))
640
+ top, rgt, bot, lft = side(0, 1), side(1, 2), side(3, 2), side(0, 3)
641
+ keystone = max(max(top, bot) / max(min(top, bot), 1e-6),
642
+ max(lft, rgt) / max(min(lft, rgt), 1e-6))
643
+ bw, bh = 0.5 * (top + bot), 0.5 * (lft + rgt)
644
+ aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
645
+ rms_fr = None if not sel else sel["rms"] / per_orig
646
+ off = None if not qc else qc["line_offset_mean_frac"]
647
+
648
+ # `confidence` is for display. `ok` is the rule below it, so that what the
649
+ # app refuses on is a stated threshold and not a product of ramps.
650
+ conf = float(np.clip((MAX_LINE_OFF - (off if off is not None else 1.0))
651
+ / MAX_LINE_OFF, 0, 1) ** 0.5
652
+ * np.clip((MAX_TAP + 0.05 - s1["tap"]) / 0.35, 0, 1)
653
+ * np.clip((MAX_ASPECT - aspect) / (0.5 * (MAX_ASPECT - 1.0)), 0, 1)
654
+ * np.clip(s1["ac"] / 0.15, 0, 1))
655
+ conf *= 0.5 if rms_fr is None else float(
656
+ np.clip((MAX_RMS_FR + 0.07 - rms_fr) / 0.10, 0, 1))
657
+
658
+ checks = [
659
+ (qc is not None, "could not lock onto the rulings"),
660
+ (s1["win_periods"] >= MIN_WIN_PER,
661
+ "the block nearly fills the frame -- take the photo slightly zoomed out"),
662
+ (off is not None and off <= MAX_LINE_OFF,
663
+ "the fitted lines do not sit on the rulings"),
664
+ (aspect <= MAX_ASPECT, "the fitted square is not square"),
665
+ (s1["tap"] <= MAX_TAP, "tap was too far from the centre of a block"),
666
+ (qc is not None and qc["ruling_contrast_min"] >= MIN_CONTRAST,
667
+ "one of the rulings is not visible"),
668
+ (s1["ac"] >= MIN_AC, "no repeating grid found near the tap"),
669
+ (rms_fr is not None and rms_fr <= MAX_RMS_FR,
670
+ "the 25 intersections do not form a regular lattice"),
671
+ ]
672
+ ok = all(c for c, _ in checks)
673
+ return {
674
+ "ok": bool(ok), "confidence": round(conf, 3),
675
+ "corners": [[int(round(float(x))), int(round(float(y)))] for x, y in quad],
676
+ "model": sel["model"] if sel else "similarity(comb only)",
677
+ "model_rms_px": None if not sel else round(sel["rms"], 2),
678
+ "model_rms_frac": None if rms_fr is None else round(rms_fr, 3),
679
+ "model_rms_all": None if not sel else sel["rms_all"],
680
+ "angle_deg": round(float(s1["ang"]), 2),
681
+ "period_px": round(float(per_orig), 1),
682
+ "block_px": [int(round(bw)), int(round(bh))],
683
+ "block_aspect": round(aspect, 3),
684
+ "drift_correction": drift_info,
685
+ "keystone_ratio": round(float(keystone), 4),
686
+ # Honest about this one: bootstrapping gives a keystone SD of
687
+ # 0.011-0.020, so a ratio under ~1.04 is not distinguishable from
688
+ # relocation noise on a single photo. Do not report it as tilt.
689
+ "keystone_significant": bool(keystone > 1.04),
690
+ "tap_offset_periods": round(float(s1["tap"]), 3),
691
+ "tooth_snr": round(float(s1["snr"]), 2), # reported, never gated
692
+ "ac_peak": round(float(s1["ac"]), 3),
693
+ "window_periods": round(float(s1["win_periods"]), 2),
694
+ "mean_line_strength": round(float(strength), 2) if sel else None,
695
+ "qc": qc,
696
+ "reason": "ok" if ok else next(m for c, m in checks if not c),
697
+ "_Hm": Hm,
698
+ }
699
+
700
+
701
+ # ---- multi-start retry --------------------------------------------------
702
+ # One tap fits ONE analysis window. On a faint, cluttered or tilted grid that
703
+ # window can land on a patch where the period fixed point wanders and the fit
704
+ # is (correctly) rejected by QC -- while the SAME block, seeded a little to one
705
+ # side, fits cleanly. Measured on the 20260901 cos7 set: a single centre tap
706
+ # failed on 2 of 4 quadrants, yet every quadrant fit at some nearby tap. A user
707
+ # re-tapping is doing exactly this by hand. So on failure we retry from a small
708
+ # ring of nearby seeds and keep the first that PASSES THE SAME QC gate -- this
709
+ # never loosens acceptance, it only gives the fitter more starting points.
710
+ RETRY_RING = 8 # seeds tried on failure, evenly spaced on a ring
711
+ RETRY_RADIUS_FRAC = 0.08 # ring radius as a fraction of the short image side.
712
+ # ~0.5-0.8 of a ruling period on these photos, so the
713
+ # retries stay well inside the tapped block and cannot
714
+ # jump to a neighbouring one (block ~4 periods across).
715
+
716
+
717
+ def detect_from_seed(image, seed_xy, verify=True, period_hint=None,
718
+ lock=False, want_debug=False,
719
+ retries=RETRY_RING, retry_radius_frac=RETRY_RADIUS_FRAC):
720
+ """One tap -> the four corners of the surrounding 4x4 counting block.
721
+
722
+ Fits at the tap; if that is rejected, retries from a ring of nearby seeds
723
+ (see RETRY_RING above) and returns the first fit that passes QC, otherwise
724
+ the original rejection. Set retries=0 for the old single-shot behaviour.
725
+
726
+ Parameters
727
+ ----------
728
+ image : ndarray, HxW grey or HxWx3 RGB. Use the FULL-resolution image;
729
+ corners come back in that same pixel space.
730
+ seed_xy : (x, y) tap position in that same pixel space.
731
+ verify : run the independent line-offset QC. Strongly recommended -- it is
732
+ the only check that looks at the image rather than at the fit's own
733
+ residual, and `ok` is gated on it.
734
+ period_hint : start the scale fixed point from a known ruling period, e.g.
735
+ the consensus of several photos from one session. See detect_batch.
736
+ lock : with period_hint, pin the comb to that period instead of letting it
737
+ search +/-15%.
738
+
739
+ Returns a JSON-safe dict. Always check ``ok`` before using ``corners``.
740
+ """
741
+ import math
742
+ out = _detect_from_seed_once(image, seed_xy, verify, period_hint,
743
+ lock, want_debug)
744
+ if out.get("ok") or retries <= 0:
745
+ return out
746
+ H, W = image.shape[:2]
747
+ r = retry_radius_frac * min(H, W)
748
+ sx0, sy0 = float(seed_xy[0]), float(seed_xy[1])
749
+ for k in range(int(retries)):
750
+ a = 2.0 * math.pi * k / int(retries)
751
+ s2 = (sx0 + r * math.cos(a), sy0 + r * math.sin(a))
752
+ if not (0 <= s2[0] < W and 0 <= s2[1] < H):
753
+ continue
754
+ alt = _detect_from_seed_once(image, s2, verify, period_hint,
755
+ lock, want_debug)
756
+ if alt.get("ok"):
757
+ alt["retry_seed"] = [int(s2[0]), int(s2[1])]
758
+ return alt
759
+ return out
760
+
761
+
762
+ def _detect_from_seed_once(image, seed_xy, verify=True, period_hint=None,
763
+ lock=False, want_debug=False):
764
+ """Single-window fit at exactly `seed_xy`. See detect_from_seed."""
765
+ g = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) if image.ndim == 3 else image
766
+ g = np.ascontiguousarray(g)
767
+ H, W = g.shape[:2]
768
+ sx, sy = float(seed_xy[0]), float(seed_xy[1])
769
+ if not (0 <= sx < W and 0 <= sy < H):
770
+ return {"ok": False, "reason": "tap was outside the image"}
771
+ if min(H, W) < 300:
772
+ return {"ok": False, "reason": "image too small for grid detection"}
773
+
774
+ if period_hint and period_hint > 0:
775
+ lo, hi = int(0.72 * PER_WORK), int(1.45 * PER_WORK)
776
+ s1 = _pass(g, sx, sy, 0.5 * WIN_PERIODS * period_hint,
777
+ PER_WORK / period_hint, lo, hi, flatten_per=PER_WORK,
778
+ lock=(PER_WORK if lock else None))
779
+ trace = ["hint %.1f" % period_hint]
780
+ if s1 is None:
781
+ s1, trace = _scale_lock(g, sx, sy)
782
+ else:
783
+ s1, trace = _scale_lock(g, sx, sy)
784
+ if s1 is None:
785
+ return {"ok": False, "reason": "no repeating grid found near the tap"}
786
+
787
+ out = _finish(g, s1, verify)
788
+
789
+ # Sub-harmonic guard. Only ever replaces the answer with one whose own
790
+ # image-measured QC is at least as good, so it cannot make things worse.
791
+ halved = 0
792
+ while halved < SUBHARM_MAX and _midpoint_ratio(g, out["corners"]) >= SUBHARM:
793
+ s2, t2 = _lock_from(g, sx, sy, s1["per"] / 2.0)
794
+ if s2 is None:
795
+ break
796
+ alt = _finish(g, s2, verify)
797
+ a_off = (alt["qc"] or {}).get("line_offset_mean_frac", 9.9)
798
+ o_off = (out["qc"] or {}).get("line_offset_mean_frac", 9.9)
799
+ better = (alt["ok"] and not out["ok"]) or (a_off <= o_off + 1e-6)
800
+ if not better or _midpoint_ratio(g, alt["corners"]) >= SUBHARM:
801
+ break
802
+ s1, out, halved = s2, alt, halved + 1
803
+ trace = list(trace) + ["halved -> %.1f" % s1["per"]] + t2
804
+
805
+ out["scale_trace"] = trace
806
+ out["subharmonic_halvings"] = halved
807
+ Hm = out.pop("_Hm")
808
+ if want_debug:
809
+ out["_H"], out["_q1"] = Hm, s1["q1"]
810
+ return out
811
+
812
+
813
+ def detect_batch(images, seeds=None, verify=True):
814
+ """Detect on several squares photographed in one session.
815
+
816
+ The squares come off the same haemocytometer with the phone in the same
817
+ place, so they share one ruling period and differ only in phase. Fitting
818
+ each alone throws that away. So: fit each alone, take the median period of
819
+ the accepted fits, and refit any photo that disagrees by more than CONS_TOL
820
+ with the comb pinned to the consensus -- keeping the refit only if its own
821
+ image-measured QC is no worse.
822
+
823
+ Guard: if the raw periods disagree by more than CONS_SPREAD the photos were
824
+ not taken at one zoom and no consensus is applied. On the three real sets
825
+ the periods already agree to 3-4%, inside CONS_TOL, so this is a safety net
826
+ for the odd photo rather than something that fires routinely.
827
+ """
828
+ n = len(images)
829
+ seeds = list(seeds) if seeds else [None] * n
830
+ out = []
831
+ for im, s in zip(images, seeds):
832
+ if s is None:
833
+ s = (im.shape[1] / 2.0, im.shape[0] / 2.0)
834
+ out.append(detect_from_seed(im, s, verify=verify))
835
+ pers = [r["period_px"] for r in out if r["ok"]]
836
+ if len(pers) < 2:
837
+ for r in out:
838
+ r["consensus"] = {"applied": False, "why": "fewer than two accepted fits"}
839
+ return out
840
+ med = float(np.median(pers))
841
+ spread = (max(pers) - min(pers)) / med
842
+ if spread > CONS_SPREAD:
843
+ for r in out:
844
+ r["consensus"] = {"applied": False, "why": "photos not at one zoom",
845
+ "spread": round(spread, 3)}
846
+ return out
847
+ for i, r in enumerate(out):
848
+ info = {"applied": False, "median_period": round(med, 1),
849
+ "spread": round(spread, 3)}
850
+ if r["ok"] and abs(r["period_px"] / med - 1.0) <= CONS_TOL:
851
+ info["why"] = "already agrees"
852
+ r["consensus"] = info
853
+ continue
854
+ s = seeds[i] or (images[i].shape[1] / 2.0, images[i].shape[0] / 2.0)
855
+ alt = detect_from_seed(images[i], s, verify=verify, period_hint=med, lock=True)
856
+ old = (r["qc"] or {}).get("line_offset_mean_frac", 9.9)
857
+ new = (alt["qc"] or {}).get("line_offset_mean_frac", 9.9)
858
+ if alt["ok"] and new <= old * 1.15 + 1e-6:
859
+ info.update({"applied": True, "was_period": r["period_px"],
860
+ "qc_before": old, "qc_after": new})
861
+ alt["consensus"] = info
862
+ out[i] = alt
863
+ else:
864
+ info["why"] = "refit was not better"
865
+ r["consensus"] = info
866
+ return out
requirements.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pinned deliberately. Two of these have already broken this app once:
2
+ # gradio 6 removed show_download_button and the UI failed to build;
3
+ # cellpose 4.0.1 deprecated the channels= argument, and a future major version
4
+ # could change model loading again. The Space rebuilds from this file on every
5
+ # restart, so an unpinned range means the app can break with no code change.
6
+ cellpose==4.1.1
7
+ gradio==5.35.0
8
+ spaces
9
+ opencv-python
10
+ numpy
11
+ scipy
12
+ matplotlib
13
+ Pillow
14
+ huggingface_hub
15
+
16
+ # joblib and scikit-learn are needed ONLY if VIABILITY_METHOD is set back to
17
+ # "model". The default local-contrast rule uses neither. Kept so that switch
18
+ # still works; the sklearn pin matches the version viability_clf.pkl was fitted
19
+ # under, since unpickling across versions is not guaranteed.
20
+ joblib
21
+ scikit-learn==1.6.1
22
+
23
+ # xgboost removed: never imported by app.py. It was only used by an offline
24
+ # comparison script, where it scored measurably worse than the linear model.