Segmentation: pixel measures, tiles and empty images

A segmentation is a binary classification of every pixel, so the measures of the package apply to it directly. What changes is the unit that gets averaged. A metric can be pooled over all pixels, or computed per image and then averaged, and the choice is the micro-versus-macro choice of notebook 02 with images in place of classes. This notebook uses real microscopy images and three deliberately simple segmenters to look at four things: how much pixel accuracy says when most pixels are background, whether pooled and per-image IoU agree, what happens to images that contain no object at all, and what pixel measures cannot see.

The notebook does not rank segmentation methods. The three methods are thresholds, chosen to be easy to read and to fail in different ways.

# Installs the package only where it is missing (for example in Colab).
import importlib.util
if importlib.util.find_spec("aggmetrics") is None:
    %pip install -q aggmetrics scikit-learn matplotlib pandas
import io
import urllib.request
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage as ndi

from aggmetrics import IndicatorMatrix

BLUE, ORANGE, AQUA, YELLOW = "#2a78d6", "#eb6834", "#1baf7a", "#eda100"
INK, INK2, GRID = "#0b0b0b", "#52514e", "#e4e3df"
plt.rcParams.update({
    "figure.facecolor": "white", "axes.facecolor": "white", "savefig.facecolor": "white",
    "axes.edgecolor": INK2, "axes.labelcolor": INK2, "xtick.color": INK2, "ytick.color": INK2,
    "text.color": INK, "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.8, "axes.axisbelow": True,
    "lines.linewidth": 2, "font.size": 10, "figure.dpi": 100,
})

The data

BBBC039 (Caicedo et al., 2019; Broad Bioimage Benchmark Collection) holds fluorescence images of U2OS cell nuclei with every nucleus annotated by hand, released under CC0. The repository bundles a derived subset: 12 fields from the official test split, each cropped to 384 by 512 pixels and rescaled to 8 bits. The processing is described in examples/data/bbbc039/README.md. The annotation has the value 0 for background and 1 to 3 for the nuclei (touching nuclei get different values), so that nuclei can be counted later.

URL = "https://raw.githubusercontent.com/rcprati/aggmetrics/main/examples/data/bbbc039/subset.npz"
local = Path("../data/bbbc039/subset.npz")
source = local if local.exists() else io.BytesIO(urllib.request.urlopen(URL, timeout=60).read())
with np.load(source) as z:
    images, labels = z["images"], z["labels"]
truth = labels > 0
print("fields, rows, columns:", images.shape)
print("foreground share of the 12 fields:", truth.mean().round(3))
fields, rows, columns: (12, 384, 512)
foreground share of the 12 fields: 0.182

Three segmenters

All three smooth the image with a Gaussian of width 1 pixel and then threshold it.

  • otsu: one global threshold per image, chosen by Otsu’s method.

  • local: a pixel is foreground if it exceeds the mean of its 41 by 41 neighbourhood by 4 grey levels.

  • low: a global threshold at half of Otsu’s, which marks the dim halo around each nucleus as foreground.

A fourth predictor, all background, predicts no foreground anywhere. It is the reference that any measure of a segmentation should be able to reject.

def otsu(x):
    # threshold that maximises the between-class variance of the 256-bin histogram
    h, _ = np.histogram(x, bins=256, range=(0, 255))
    p = h / h.sum()
    w, mu = np.cumsum(p), np.cumsum(p * np.arange(256))
    return float(np.argmax((mu[-1] * w - mu) ** 2 / (w * (1 - w) + 1e-12)))

def smooth(img):
    return ndi.gaussian_filter(img.astype(float), 1)

def seg_otsu(img):
    g = smooth(img)
    return g > otsu(g)

def seg_local(img, window=41, offset=4):
    g = smooth(img)
    return g > ndi.uniform_filter(g, window) + offset

def seg_low(img):
    return smooth(img) > 0.5 * otsu(img)

methods = {"otsu": seg_otsu, "local": seg_local, "low": seg_low}
pred = {k: np.stack([f(x) for x in images]) for k, f in methods.items()}
pred["all background"] = np.zeros_like(pred["otsu"])
print("foreground share, truth:", truth.mean().round(3))
for k, v in pred.items():
    print(f"  {k:15s}", v.mean().round(3))
foreground share, truth: 0.182
  otsu            0.174
  local           0.187
  low             0.213
  all background  0.0
field = 3
fig, ax = plt.subplots(1, 5, figsize=(15, 3.2))
ax[0].imshow(images[field], cmap="gray"); ax[0].set_title("image")
ax[1].imshow(truth[field], cmap="gray"); ax[1].set_title("annotation")
for a, k in zip(ax[2:], ["otsu", "local", "low"]):
    a.imshow(pred[k][field], cmap="gray"); a.set_title(k)
for a in ax:
    a.axis("off"); a.grid(False)
plt.tight_layout(); plt.show()
../_images/fb9e1fad112360f0b19415059db376a0d30ba81def07cbe8b842f146f1c57aa3.png

One column per tile

The package takes a matrix with one column per label, so the layout has to be chosen. Here each image is cut into 128 by 128 tiles (12 fields give 144 tiles) and each tile is a column, with its 16,384 pixels as the rows, as a multilabel problem. Then the micro operator pools all pixels, and the macro operator computes the measure tile by tile and averages, with the convention for undefined entries applying to tiles that have nothing to measure. The weighted operator weights each tile by its number of foreground pixels.

Pixel accuracy first, next to the other pooled measures.

def tiles(x, size=128):
    n, h, w = x.shape
    return x.reshape(n, h // size, size, w // size, size).transpose(0, 1, 3, 2, 4).reshape(-1, size * size)

Y = tiles(truth).astype(int).T                       # (pixels in a tile, tiles): one column per tile
P = {k: tiles(v).astype(int).T for k, v in pred.items()}
ims = {k: IndicatorMatrix(Y, v, task="multilabel") for k, v in P.items()}
print("tiles:", Y.shape[1], "| pixels per tile:", Y.shape[0])
print("tiles with no foreground in the annotation:", int((Y.sum(axis=0) == 0).sum()))
tiles: 144 | pixels per tile: 16384
tiles with no foreground in the annotation: 4
rows = {}
for k, im in ims.items():
    rows[k] = {
        "pixel accuracy": im.compute("label_accuracy", "micro"),
        "specificity": im.compute("specificity", "micro"),
        "recall": im.compute("recall", "micro"),
        "precision": im.compute("precision", "micro"),
        "IoU, pooled": im.compute("jaccard", "micro"),
        "Dice, pooled": im.compute("f1", "micro"),
    }
table = pd.DataFrame(rows).round(4)
table
otsu local low all background
pixel accuracy 0.9850 0.9817 0.9682 0.8181
specificity 0.9954 0.9855 0.9617 1.0000
recall 0.9381 0.9643 0.9972 0.0000
precision 0.9784 0.9368 0.8528 NaN
IoU, pooled 0.9191 0.9054 0.8508 0.0000
Dice, pooled 0.9579 0.9503 0.9194 0.0000

Pixel accuracy is 0.818 for the predictor that outputs nothing, and 0.968 to 0.985 for the three segmenters. The whole range of useful segmenters sits in the last 0.18 of the scale, and the worst of them, low, is 0.15 above the trivial one. Recall ranks the segmenters in the opposite order to IoU: low has recall 0.997 and the lowest IoU, because it labels the halo of every nucleus as foreground. A measure that ignores one of the two error types is maximised by predicting everything, as in notebook 07.

IoU and Dice are zero for all background, as they should be. They are the better choice here, and the question is now which average of them to report.

Pooled, per tile and weighted

rows = {}
for k, im in ims.items():
    rows[k] = {
        "IoU pooled (micro)": im.compute("jaccard", "micro"),
        "IoU per tile (macro)": im.compute("jaccard", "macro"),
        "IoU weighted by foreground": im.compute("jaccard", "weighted"),
        "Dice pooled (micro)": im.compute("f1", "micro"),
        "Dice per tile (macro)": im.compute("f1", "macro"),
    }
pd.DataFrame(rows).round(4)
otsu local low all background
IoU pooled (micro) 0.9191 0.9054 0.8508 0.0
IoU per tile (macro) 0.9165 0.8841 0.8296 0.0
IoU weighted by foreground 0.9192 0.9059 0.8523 0.0
Dice pooled (micro) 0.9579 0.9503 0.9194 0.0
Dice per tile (macro) 0.9556 0.9327 0.9014 0.0

The ranking otsu, local, low is the same under all five variants, so for these three methods the choice of operator does not change the verdict. It changes the numbers. For otsu the pooled and per-tile IoU differ by 0.003, and for local and low by 0.021 (0.9054 against 0.8841, and 0.8508 against 0.8296): the per-tile mean is lower for them, which means that some tiles are segmented worse than the pool suggests. The weighted IoU is within 0.002 of the pooled one for every method, as expected when the weights are the tile foreground sizes.

Tiles with nothing to segment

Four of the 144 tiles contain no nucleus. For a method that also predicts nothing there, the per-tile IoU is 0/0. The package leaves the choice to the caller (convention): exclude drops the tile, zero scores it 0 and one scores it 1. The column undefined tiles counts them, and a method that predicts something in an empty tile gets an IoU of 0 there, defined, which is why local and low have three undefined tiles and not four.

rows = {}
for k, im in ims.items():
    parts = im.parts("jaccard", "macro")
    rows[k] = {
        "undefined tiles": int(np.isnan(parts).sum()),
        "exclude": im.compute("jaccard", "macro", convention="exclude"),
        "zero": im.compute("jaccard", "macro", convention="zero"),
        "one": im.compute("jaccard", "macro", convention="one"),
    }
pd.DataFrame(rows).T.round(4)
undefined tiles exclude zero one
otsu 4.0 0.9165 0.8911 0.9188
local 3.0 0.8841 0.8657 0.8865
low 3.0 0.8296 0.8124 0.8332
all background 4.0 0.0000 0.0000 0.0278

The convention moves the mean per-tile IoU of otsu between 0.8911 and 0.9188, a spread of 0.028, which is about the gap between otsu and local under exclude (0.032). Under one, the predictor that outputs nothing scores 0.028, a positive score for doing nothing, earned from the four empty tiles. None of this reverses the order of the segmenters, and none of it can be decided from the data: it is a choice about what an empty image should count for, and it should be reported.

Where the per-tile average goes down

The per-tile mean is lower for local and low because of small tiles. Splitting the tiles by foreground share shows the pattern. The pooled measure sees only the pixels, and the tiles with under 5% foreground hold under 1% of them.

share = Y.mean(axis=0)
edges = [0.0001, 0.05, 0.15, 0.30]
names = ["empty", "< 5%", "5-15%", "15-30%", "> 30%"]
bin_of = np.digitize(share, edges)
summary = pd.DataFrame({
    "tiles": np.bincount(bin_of, minlength=5),
    "share of all foreground pixels": [Y[:, bin_of == b].sum() / Y.sum() for b in range(5)],
}, index=names).round(3)
iou_tile = {k: ims[k].parts("jaccard", "macro") for k in ("otsu", "local", "low")}
for k, v in iou_tile.items():
    summary[f"mean IoU, {k}"] = [np.nanmean(v[bin_of == b]) if (~np.isnan(v[bin_of == b])).any() else np.nan for b in range(5)]
summary.round(3)
tiles share of all foreground pixels mean IoU, otsu mean IoU, local mean IoU, low
empty 4 0.000 NaN 0.000 0.000
< 5% 10 0.008 0.902 0.716 0.663
5-15% 36 0.153 0.905 0.889 0.835
15-30% 84 0.709 0.922 0.910 0.853
> 30% 10 0.129 0.927 0.904 0.859
fig, ax = plt.subplots(figsize=(7, 3.6))
width = 0.26
for i, (k, c) in enumerate(zip(["otsu", "local", "low"], [BLUE, ORANGE, AQUA])):
    ax.bar(np.arange(5) + (i - 1) * width, summary[f"mean IoU, {k}"].fillna(0), width, color=c, label=k)
ax.set_xticks(range(5)); ax.set_xticklabels(names)
ax.set_xlabel("foreground share of the tile"); ax.set_ylabel("mean IoU of the tile")
ax.legend(frameon=False); plt.tight_layout(); plt.show()
../_images/5e5a46d1e04345731112ff6b781bc2c5ebdce8434d3fc1c98d82bbdefbad72ed.png

In the tiles with under 5% foreground, local reaches a mean IoU of 0.716 and low 0.663, against 0.902 for otsu, while in the tiles above 15% all three are within 0.07 of each other. A pooled IoU is dominated by the large tiles and hides this; the per-tile mean shows it, and the empty tiles (IoU 0 for local and low in the first row of the table, where one empty tile has false positives) are the extreme case. Which of the two is the right number depends on whether a small field of view matters as much as a crowded one.

What a pixel measure cannot see

A segmentation is usually a step towards counting or measuring objects. The annotation holds 768 nuclei. The number of connected components in each prediction is below.

def count_true(lab):
    return sum(ndi.label(lab == v)[1] for v in (1, 2, 3))

n_true = int(sum(count_true(l) for l in labels))
print("nuclei in the 12 fields:", n_true)
for k in ("otsu", "local", "low"):
    n_pred = int(sum(ndi.label(m)[1] for m in pred[k]))
    print(f"  {k:6s} connected components: {n_pred:4d}   pooled IoU {ims[k].compute('jaccard', 'micro'):.4f}")
nuclei in the 12 fields: 768
  otsu   connected components:  643   pooled IoU 0.9191
  local  connected components:  719   pooled IoU 0.9054
  low    connected components:  613   pooled IoU 0.8508

otsu has the highest IoU and 643 components against 768 nuclei, and local has a lower IoU and the count closest to the truth (719). The count is not an accuracy: a method can split some nuclei and merge others and land on the right total. The point is the other direction. A pixel IoU of 0.92 is compatible with a component count that is off by a sixth, so a pixel measure cannot say whether touching nuclei were separated. Object-level measures (matching predicted and annotated objects, as in Panoptic Quality) address that and are not covered by this package.

Summary

  • Pixel accuracy compresses the differences between segmenters into the last 0.18 of its scale and gives 0.818 to a predictor that outputs nothing. Use a measure that is zero for it, such as IoU or Dice.

  • Pooling pixels and averaging per image are different measures. Here they rank the methods alike and differ by up to 0.02, and the difference comes from images with little foreground.

  • Images with no object make the per-image mean undefined, and the convention for them moves the number by as much as the gap between two of the methods. Report it.

  • The tile layout (one column per tile) is a modelling choice, so diagnose findings that compare operators across columns, such as the exemplar check, are not meaningful for it.