Exercise: measuring a segmentation#
Duration ~35 min · Session Day 2, Python notebooks
You will use relevant metrics to settle the question the last exercise left open: which segmentation was actually best?
Data: data/bbbc020/ field jw-24h 3_c1. This field is used deliberately: it
is one dataset where every cell was outlined by the
annotator. Scoring against a partly-annotated field punishes a method for finding
cells that are really there.
import numpy as np
import tifffile
import matplotlib.pyplot as plt
import os; os.environ["DISPLAY"] = ":1"
from skimage.measure import label
from skimage.color import label2rgb
from skimage.filters import threshold_otsu, gaussian
from skimage.morphology import remove_small_objects
from scipy.ndimage import binary_fill_holes, distance_transform_edt
from skimage.segmentation import watershed
from course import DATA, show
image = tifffile.imread(DATA / "bbbc020" / "images" / "jw-24h 3_c1.tif")
truth = tifffile.imread(DATA / "bbbc020" / "gt" / "jw-24h 3_c1 [1].tif")
print("annotated cells:", len(np.unique(truth)))
Task 1: build two segmentations to compare#
A classical pipeline and Cellpose, both on this field.
Hint
The classical pipeline is the one from 02_image_processing: smooth → threshold
→ fill holes → remove small objects → watershed seeded from distance > t.
For Cellpose use models.Cellpose(model_type="cyto", gpu=False).
from cellpose import models
from skimage.color import rgb2gray
# --- your turn ---
smoothed = ...
mask = ... # TODO: threshold, fill holes, remove small objects
distance = ...
classical = ... # TODO: watershed seeded from a distance cut-off
cellpose_labels, _, _, _ = ... # TODO: run Cellpose
results = {"classical": classical, "Cellpose": cellpose_labels}
for name, r in results.items():
print(f"{name:10s}: {r.max():3d} objects (annotated: {len(np.unique(truth))})")
Task 2: score them on pixels#
Print IoU and Dice for both.
Hint
Your functions already accept label images, because they convert with > 0.
# --- your turn ---
print(f"{'method':12s} {'IoU':>7s} {'Dice':>7s} {'objects':>9s}")
for name, result in results.items():
print(...) # TODO
Task 3: score them on objects#
Now the part the pixel scores cannot do. Complete match_objects, which pairs
each predicted object with the ground-truth object it overlaps most and accepts
the pairing when their IoU clears min_iou.
Hint 1, which ground-truth objects can possibly match?
Only those that overlap the prediction at all:
np.unique(truth[predicted]), skipping 0.
Hint 2: one prediction per truth object
Keep a set of already-matched ground-truth ids so two predictions cannot both
claim the same cell.
Hint 3: the counts
false_positives = prediction.max() - true_positives and
false_negatives = truth.max() - true_positives.
def match_objects(prediction, truth, min_iou=0.5):
"""Pair predicted objects with ground-truth objects. Returns (TP, FP, FN)."""
matched_truth = set()
true_positives = 0
for p in range(1, prediction.max() + 1):
predicted = prediction == p
if not predicted.any():
continue
# --- your turn ---
best_score, best_id = 0.0, None
for t in ...: # TODO: truth ids overlapping this prediction
... # TODO: keep the best-scoring one
if ...: # TODO: accept if it clears min_iou and is unclaimed
true_positives += 1
matched_truth.add(best_id)
false_positives = prediction.max() - true_positives
false_negatives = truth.max() - true_positives
return true_positives, false_positives, false_negatives
def scores(tp, fp, fn):
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return precision, recall, f1
# --- your turn ---
for name, result in results.items():
tp, fp, fn = ... # TODO
p, r, f1 = scores(tp, fp, fn)
print(...) # TODO: one row per method
Let’s analyze the table.
Do the pixel scores and the object scores rank the two methods the same way?
If they disagree, which would you report, and why does the answer depend on what you are trying to measure?
(edit this cell to add your answer)
Task 4: how strict should the matching be?#
Recompute F1 at several matching thresholds and plot it.
Hint
match_objects already takes min_iou. Loop over [0.3, 0.5, 0.7, 0.9],
collect the F1 values, and use plt.plot.
# --- your turn ---
thresholds = [0.3, 0.5, 0.7, 0.9]
plt.figure(figsize=(7, 4))
for name, result in results.items():
f1s = ... # TODO: F1 at each threshold
plt.plot(...)
plt.xlabel("matching IoU threshold"); plt.ylabel("F1")
plt.ylim(0, 1); plt.legend(); plt.show()
The plot shows that every curve falls as the threshold rises.
What would it mean if one method’s curve fell much faster than another’s?
(edit this cell to add your answer)