Hybrid sizing: detect@0.3 + measure@0.5 core — fixes 10% size bias
Browse files- inference.py +25 -10
inference.py
CHANGED
|
@@ -261,26 +261,41 @@ def analyze_image(
|
|
| 261 |
cell_prob = 1.0 / (1.0 + np.exp(-out[2])) # sigmoid
|
| 262 |
dist_transform = np.maximum(out[3], 0)
|
| 263 |
|
| 264 |
-
# Instance segmentation
|
| 265 |
masks = segment_instances(cell_prob, dist_transform, prob_threshold, min_size_px)
|
| 266 |
|
| 267 |
-
# Measure bubbles from instance masks
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
bubbles = []
|
| 269 |
num_rejected = 0
|
| 270 |
|
| 271 |
for region in regionprops(masks):
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
-
diam_px =
|
| 278 |
diam_um = diam_px * scale_um_per_pixel
|
| 279 |
radius_um = diam_um / 2
|
| 280 |
vol = (4 / 3) * np.pi * radius_um ** 3
|
| 281 |
|
| 282 |
-
#
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
dt_vals = dist_transform[inst_mask]
|
| 285 |
radius_dt = float(dt_vals.max()) if len(dt_vals) > 0 else 0.0
|
| 286 |
|
|
@@ -296,7 +311,7 @@ def analyze_image(
|
|
| 296 |
bubble_id=region.label,
|
| 297 |
diameter_um=diam_um,
|
| 298 |
volume_um3=vol,
|
| 299 |
-
area_px=
|
| 300 |
centroid_x=region.centroid[1],
|
| 301 |
centroid_y=region.centroid[0],
|
| 302 |
radius_dt_px=radius_dt,
|
|
|
|
| 261 |
cell_prob = 1.0 / (1.0 + np.exp(-out[2])) # sigmoid
|
| 262 |
dist_transform = np.maximum(out[3], 0)
|
| 263 |
|
| 264 |
+
# Instance segmentation (detect at prob_threshold for good recall)
|
| 265 |
masks = segment_instances(cell_prob, dist_transform, prob_threshold, min_size_px)
|
| 266 |
|
| 267 |
+
# Measure bubbles from instance masks.
|
| 268 |
+
# KEY: size each bubble using only the high-confidence core (>0.5) to
|
| 269 |
+
# avoid threshold-dependent bloating. Detection at 0.3 finds bubbles;
|
| 270 |
+
# the 0.5 interior gives accurate area → diameter.
|
| 271 |
+
SIZE_THRESHOLD = 0.5
|
| 272 |
bubbles = []
|
| 273 |
num_rejected = 0
|
| 274 |
|
| 275 |
for region in regionprops(masks):
|
| 276 |
+
inst_mask = (masks == region.label)
|
| 277 |
+
|
| 278 |
+
# Sizing: use the high-confidence interior if available
|
| 279 |
+
core = inst_mask & (cell_prob > SIZE_THRESHOLD)
|
| 280 |
+
core_area = int(core.sum())
|
| 281 |
+
if core_area >= 5:
|
| 282 |
+
sizing_area = core_area
|
| 283 |
+
else:
|
| 284 |
+
sizing_area = region.area # fallback for faint detections
|
| 285 |
|
| 286 |
+
diam_px = np.sqrt(sizing_area / np.pi) * 2
|
| 287 |
diam_um = diam_px * scale_um_per_pixel
|
| 288 |
radius_um = diam_um / 2
|
| 289 |
vol = (4 / 3) * np.pi * radius_um ** 3
|
| 290 |
|
| 291 |
+
# Shape QC from the full detection mask (not the core)
|
| 292 |
+
circ = (4 * np.pi * region.area) / (region.perimeter ** 2 + 1e-8)
|
| 293 |
+
major = getattr(region, "axis_major_length", 0) or getattr(region, "major_axis_length", 0)
|
| 294 |
+
minor = getattr(region, "axis_minor_length", 0) or getattr(region, "minor_axis_length", 0)
|
| 295 |
+
ar = major / (minor + 1e-8)
|
| 296 |
+
|
| 297 |
+
# Distance-transform peak (for reference — note: student DT is normalised,
|
| 298 |
+
# not in absolute pixels, so this is relative only)
|
| 299 |
dt_vals = dist_transform[inst_mask]
|
| 300 |
radius_dt = float(dt_vals.max()) if len(dt_vals) > 0 else 0.0
|
| 301 |
|
|
|
|
| 311 |
bubble_id=region.label,
|
| 312 |
diameter_um=diam_um,
|
| 313 |
volume_um3=vol,
|
| 314 |
+
area_px=sizing_area,
|
| 315 |
centroid_x=region.centroid[1],
|
| 316 |
centroid_y=region.centroid[0],
|
| 317 |
radius_dt_px=radius_dt,
|