Upload 3 files
Browse files- app.py +0 -0
- grid_seeded.py +866 -0
- 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.
|