CountAnywhere / focus_qc.py
LiangLabUMB's picture
Upload 5 files
f86d93e verified
Raw History Blame Contribute Delete
5.61 kB
"""
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