Spaces:
Running on Zero
Running on Zero
File size: 5,607 Bytes
f86d93e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | """
focus_qc.py - input-image focus QC for hemocytometer blocks (v6, 1 Oct 2026).
Runs BEFORE segmentation. The 4-point block crop (the 1 mm x 1 mm counting
block) is warped to a 1024 x 1024 px square (0.977 um/px), so the six inner grid
lines always sit at 256, 512 and 768 px on both axes. For each inner line:
* median intensity along the line, for every position across it (the median
ignores cells lying on the line) -> a 1-D profile spanning +/-40 px;
* background = median of the outer 12 px on each side of the profile;
* contrast = (peak - background) / background
* FWHM = sub-pixel full width at half of (peak - background), in um.
Per image: median over the six lines.
Decision: **FWHM > FWHM_LIMIT_UM (9 um) = out of focus, does not pass QC.**
Contrast is measured and reported but is NOT used to decide: it depends on
illumination and phase setting as well as focus (faint grey lines on sharp
photos), so it overlaps between in- and out-of-focus fields.
Validation (20260901_Asad, 48 fields, mutHEK / HT1080 / COS7; Kevin's labels):
in-focus FWHM 2.5-8.5 um (n=43), out-of-focus 9.8-10.6 um (n=5); AUC 1.00,
48/48 correct at 9 um. Contrast AUC 0.95. Write-up:
Z:\\Manuscripts\\cellposecellcounter\\manuscript\\out-of-focus qc control.md
Caveat: 5 positives from one COS7 session; re-check the limit on more data.
The measurement is identical to 20260901_Asad/results_v5/focus_qc_gridline.py.
"""
import numpy as np
import cv2
BLOCK_PX = 1024 # 1 mm block warped to this many px
UM_PER_PX = 1000.0 / BLOCK_PX # 0.977 um/px
LINES_PX = (256, 512, 768) # the 3 inner rulings of a 4x4 block, per axis
HALF_WIN = 40 # profile half-width, px (about 39 um)
BG_PX = 12 # outer px per side used as background
FWHM_LIMIT_UM = 9.0 # out of focus above this
def _order_quad(pts):
"""(TL, TR, BR, BL) for any corner order, rotation-safe (as app.order_quad)."""
pts = np.asarray(pts, dtype=np.float32)
c = pts.mean(axis=0)
ang = np.arctan2(pts[:, 1] - c[1], pts[:, 0] - c[0])
o = pts[np.argsort(ang)]
return np.roll(o, -int(np.argmin(o.sum(axis=1))), axis=0)
def warp_block(image_np, corners, size=BLOCK_PX):
src = _order_quad(corners)
dst = np.array([[0, 0], [size, 0], [size, size], [0, size]], np.float32)
M = cv2.getPerspectiveTransform(src, dst)
return cv2.warpPerspective(image_np, M, (size, size))
def _line_feats(profile, c, W=HALF_WIN, bg=BG_PX):
seg = profile[c - W:c + W + 1].astype(float)
base = float(np.median(np.r_[seg[:bg], seg[-bg:]]))
if base <= 0:
return None
s = seg / base - 1.0
k = int(np.argmax(s[bg:-bg])) + bg
amp = float(s[k])
if amp <= 0:
return None
half = amp / 2.0
l = r = k
while l > 0 and s[l - 1] > half:
l -= 1
while r < len(s) - 1 and s[r + 1] > half:
r += 1
fl = l - (s[l] - half) / (s[l] - s[l - 1] + 1e-9) if l > 0 else l
fr = r + (s[r] - half) / (s[r] - s[r + 1] + 1e-9) if r < len(s) - 1 else r
return amp, (fr - fl) * UM_PER_PX
def measure_block(block_rgb_or_gray):
"""Grid-line contrast and FWHM (um) of a 1024 px block. Returns dict."""
g = block_rgb_or_gray
if g.ndim == 3:
g = cv2.cvtColor(g[..., :3], cv2.COLOR_RGB2GRAY)
g = g.astype(np.float32)
if g.shape != (BLOCK_PX, BLOCK_PX):
g = cv2.resize(g, (BLOCK_PX, BLOCK_PX), interpolation=cv2.INTER_AREA)
feats = []
for ax in (0, 1):
prof = np.median(g, axis=ax)
for c in LINES_PX:
f = _line_feats(prof, c)
if f is not None:
feats.append(f)
if len(feats) < 3:
return {"contrast": None, "fwhm_um": None, "n_lines": len(feats)}
con, fw = np.median(np.array(feats), axis=0)
return {"contrast": float(con), "fwhm_um": float(fw), "n_lines": len(feats)}
def check_focus(image_np, corners, limit_um=FWHM_LIMIT_UM):
"""QC an uploaded photo + its 4 block corners (full-resolution pixels).
Returns {"ok", "measured", "fwhm_um", "contrast", "limit_um", "message"}.
ok=False only when the grid lines were measured AND are too wide. If they
cannot be measured (fewer than 3 of 6 lines found), ok=True with
measured=False, so a measurement problem never blocks counting.
"""
out = {"ok": True, "measured": False, "fwhm_um": None, "contrast": None,
"limit_um": float(limit_um), "message": ""}
if corners is None or len(corners) != 4:
out["message"] = "Focus QC skipped: needs the 4-point block crop."
return out
img = np.asarray(image_np)
if img.ndim == 3 and img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
m = measure_block(warp_block(img, corners))
out.update(fwhm_um=m["fwhm_um"], contrast=m["contrast"])
if m["fwhm_um"] is None:
out["message"] = ("Focus QC could not measure the grid lines "
f"({m['n_lines']} of 6 found); not applied.")
return out
out["measured"] = True
out["ok"] = m["fwhm_um"] <= limit_um
if out["ok"]:
out["message"] = (f"Focus QC passed: grid-line width {m['fwhm_um']:.1f} um "
f"(limit {limit_um:.0f} um), contrast {m['contrast']:.3f}.")
else:
out["message"] = (f"Out of focus, does not pass QC: grid-line width "
f"{m['fwhm_um']:.1f} um (limit {limit_um:.0f} um), "
f"contrast {m['contrast']:.3f}. "
"Refocus on the grid and retake this photo.")
return out
|