Theme

Blog · Research infrastructure ·

Every number you can call 'accuracy' for a semantic map

Point accuracy, mean class accuracy, IoU, mIoU and FIoU disagree about the same prediction. Ported from a 2019 benchmark harness's metrics.py, with a widget that lets you win on one and lose on another.

  • Interactive
  • python
  • semantic-segmentation
  • evaluation-metrics
  • computer-vision
  • research-infrastructure

Look at one row of the table my evaluation platform produced for SemanticFusion on ScanNet: 54.5% accuracy on Wall, 43.0% on Floor, 0.05% on TV. That is not SemanticFusion being bad at televisions specifically. It is what “accuracy” does to a class that is a few dozen pixels in a room where the walls and floor are most of the points. A method that has never seen a television can still score 99.95% accuracy on “is this a television” by never predicting one, because “accuracy” only asks whether the label at each point was right, and almost no points are TVs.

This is the unglamorous half of the masters project I keep coming back to in this series: not the algorithms, but the harness that scored them the same way, every time, so the numbers above were comparable at all. segtester/metrics/seg.py is 124 lines and five different definitions of “how good is this prediction” that all sound like the same question and are not. This post ports every one of them to TypeScript, quotes the real numbers they produced, and (because two of the four algorithms under test were never even trained on three of the thirteen classes) spends a section on the part nobody writes about: before you can average anything, two datasets have to agree on what a “class” is.

Point accuracy lies by omission

# segtester/metrics/seg.py:4-5
def point_accuracy(est_labels, gt_labels):
    return np.count_nonzero(est_labels == gt_labels), gt_labels.size

Two numbers, a numerator and a denominator, and that is the entire function: every point where the prediction matches the label, over every point. It is the number everyone quotes first because it is the easiest to explain, and it is also the number that rewards predicting the majority class and ignoring everything else, because most points in an indoor scan are wall or floor. The widget at the end of this post lets you prove that to yourself in one click.

Mean class accuracy: average over classes, not points

# segtester/metrics/seg.py:8-19
def class_accuracy(est_labels, gt_labels, all_labels=None):
    if all_labels is None:
        all_labels = np.unique([est_labels, gt_labels])
    all_to_gt = all_labels[:, None] == gt_labels[None]
    all_to_est = all_labels[:, None] == est_labels[None]
    return np.count_nonzero(np.logical_and(all_to_gt, all_to_est), axis=1), \
           np.count_nonzero(all_to_gt, axis=1), \
           all_labels


def mean_class_accuracy(class_accuracies, exclude_background=True):
    return np.nanmean(class_accuracies[int(exclude_background):])

class_accuracy builds two boolean matrices (“is this ground-truth point class c”, “is this predicted point class c”) for every class in all_labels at once, and counts true positives and ground-truth totals per class in two broadcasted comparisons rather than one loop per class. mean_class_accuracy then averages per-class accuracy, not per-point: a class with 40 points and 90% accuracy counts exactly as much as one with 40,000 points and 90% accuracy. exclude_background=True slices off index 0 before averaging, which is why int(exclude_background) (True is 1 in Python) is doing double duty as a boolean flag and a slice offset in the same expression. This is the metric that punishes “predict wall everywhere” properly: every class the prediction never touches scores 0%, and 0% counts the same as any other class’s 0%, majority or not.

IoU, mIoU, and the NaN that has to survive

# segtester/metrics/seg.py:22-33
def iou(est_labels, gt_labels, all_labels=None):
    if all_labels is None:
        all_labels = np.unique([est_labels, gt_labels])
    gt_eq_label = all_labels[:, None] == gt_labels[None]
    est_eq_label = all_labels[:, None] == est_labels[None]
    intersection = np.count_nonzero(np.logical_and(gt_eq_label, est_eq_label), axis=1)
    union = np.count_nonzero(np.logical_or(gt_eq_label, est_eq_label), axis=1)
    return intersection, union


def miou(iou):
    return np.nanmean(iou)

Intersection over union per class, then averaged. The part worth pausing on is what happens when a class is absent from both the prediction and this particular comparison: union is 0, intersection / union is 0/0, and numpy gives you nan, not an error, not a silent zero. np.nanmean in miou then skips that class rather than counting it as a zero. That distinction is the entire reason the - marks in the tables below exist, and I nearly missed it the first time I read this file, because a stray np.mean instead of np.nanmean would silently turn “this class doesn’t apply here” into “this class scored zero here”, a very different number.

FIoU sits between the other two on purpose

# segtester/metrics/seg.py:36-43
def fiou(est_labels, gt_labels, all_labels=None):
    if all_labels is None:
        all_labels = np.unique([est_labels, gt_labels])
    gt_eq_label = all_labels[:, None] == gt_labels[None]
    est_eq_label = all_labels[:, None] == est_labels[None]
    t_i = np.count_nonzero(gt_eq_label, axis=1)
    return np.nansum(t_i * np.count_nonzero(np.logical_and(gt_eq_label, est_eq_label), axis=1) /
                     np.count_nonzero(np.logical_or(gt_eq_label, est_eq_label), axis=1)) / t_i.sum()

Frequency-weighted IoU: the same per-class IoU as above, but weighted by t_i, how many ground-truth points that class actually has, before averaging. A class with one point that scores IoU 1.0 barely moves FIoU; the same class would move mIoU exactly as much as Wall does. Every row of seg3d_all_c.tex below shows FIoU landing between raw accuracy and mIoU, which makes sense once you see it as “accuracy, but a class only gets credit for the fraction of its points it also didn’t steal from someone else,” rather than “accuracy” straight.

Two datasets never agree on what a “class” is

Every metric above takes all_labels (the fixed set of classes to score, passed in explicitly rather than computed from whatever happens to be present in one scene. That matters because ScanNet’s raw labels, NYUv2’s raw labels, and “the 13 classes I actually want a comparable number for” are three different vocabularies, and someone has to collapse them onto one before any of the metrics above mean anything across datasets. That collapse is a CSV and about a dozen lines of numpy:

# segtester/labelmaps/csv_label_map.py:9-29
def get_label_text(self, id_col_name, text_col_name, default_value='object', default_key=0):
    max_from_label = 0
    label_map = {}
    default_set = False
    with open(self.csv_path, mode='r') as csv_file:
        reader = csv.DictReader(csv_file)
        for row in reader:
            if str(default_key) == row[id_col_name]:
                default_set = True
            key = int(row[id_col_name]) if row[id_col_name] != '' else default_key
            val = row[text_col_name]
            max_from_label = max(max_from_label, key)
            if key not in label_map:  # take the first one matching
                label_map[key] = val
    if not default_set:
        label_map[default_key] = default_value
    labels = np.array(list(label_map.values()))
    np_label_map = np.repeat(np.array(default_value, dtype=labels.dtype), max_from_label+1)
    np_label_map[list(label_map.keys())] = labels
    return np_label_map

get_label_text reads a CSV where one column is a raw dataset id (ScanNet’s nyu40id, say) and another is the target taxonomy’s id (eigen13id), and builds a plain numpy array where array[raw_id] gives you the target id, so remapping an entire label image is one fancy-index operation, np_label_map[image], not a per-pixel loop. get_label_map (the wrapper Segmentation3DAssessment.__init__ actually calls, segtester/labelmaps/csv_label_map.py:51-57) does the same thing for numeric ids on both sides. Neither the CSV nor the raw dataset ids are committed to the repository (this is personal-filesystem plumbing I never checked in) but the effect is checkable, because the harness passes a fixed, explicit all_class_ids into every metric call rather than deriving it per scene:

# segtester/assessments/segmentation3d.py:55, 141-157
all_class_ids = np.array(self.label_map.get_unique_values(self.conf.label_map_dest_col), dtype=np.int)
...
@staticmethod
def get_results(est_labels, gt_labels, class_ids=None):
    pt_acc_num, pt_acc_den = smet.point_accuracy(est_labels, gt_labels)
    inst_acc_num, inst_acc_den, _ = smet.class_accuracy(est_labels, gt_labels, class_ids)
    mca = smet.mean_class_accuracy(inst_acc_num / inst_acc_den)
    i, u = smet.iou(est_labels, gt_labels, class_ids)
    with np.errstate(divide='ignore', invalid='ignore'):
        iou = i/u
    miou = smet.miou(iou)
    fiou = smet.fiou(est_labels, gt_labels, class_ids)
    ...

class_ids here is always the full 14-id set from the label map (13 named classes plus background), never np.unique([est, gt]) for the scene at hand. That is a deliberate choice with a real consequence: a scene that happens to contain no chairs still gets a “Chair” slot in its per-class arrays (nan, not absent) so averaging across scenes later doesn’t silently redefine what “mIoU” means from one scene to the next depending on what walked into frame. Derive all_labels from the data instead, and a scene with no chairs quietly stops being penalised for missing chairs at all, because there was never a chair-shaped slot to score.

What the - in the table actually means

ClassAcc. 3DMVAcc. ME 2cmAcc. ME 5cmAcc. SFIoU 3DMVIoU ME 2cmIoU ME 5cmIoU SF
Bed58.12195.18830.59725.7440.0890.3100.1020.039
Books47.4570.012
Ceiling7.5700.023
Chair80.99789.51151.55514.1460.3530.6380.2360.077
Floor94.19297.95895.76442.9920.7850.8590.7370.345
Furniture59.14086.05732.74728.3170.2610.5910.1510.132
Objects4.5109.8550.13822.6900.0460.0940.0020.109
Picture15.99072.2111.25914.1150.0540.3580.0110.027
Sofa26.60797.11211.98917.4980.0620.3450.0360.029
TV0.0480.000
Table72.07595.24235.99512.7630.3500.5910.1440.061
Wall66.77198.40693.80254.4810.4630.6760.5030.299
Window67.98390.78847.80316.4640.1550.5520.2090.059

assets/SCANNET/seg3d_per_class_{acc,iou}.tex, both tables merged.

Objects is the row I keep pointing people at: 3DMV 4.5%, ME 5cm 0.14%, ME 2cm 9.9%, every voxel-grid method is close to blind to it, against SemanticFusion’s 22.7%. A coarse voxel loses anything smaller than a voxel; an image-space method that fuses per-frame CNN predictions doesn’t have that floor.

The full picture, and where FIoU sits

AlgorithmClass. Acc. [%]Class. FIoUClass. MIoUInst. Acc. [%]Inst. FIoUInst. MIoUSem. Acc. [%]Sem. FIoUSem. MIoU
3DMV57.3160.4590.37348.0340.3920.04440.8150.3400.032
ME (2cm)73.5920.6950.68154.4070.4480.21450.8310.4190.175
ME (5cm)54.9220.4420.28838.3490.2760.08531.5510.2190.044
SemanticFusion34.3250.2070.16126.0360.2080.01020.9420.1600.007

assets/SCANNET/seg3d_all_c.tex, full names in seg3d_all_i.tex/seg3d_all_s.tex.

Every row, FIoU sits between accuracy and MIoU, exactly as the weighted-average argument above predicts. ME (2cm) also makes the earlier “voxel size mattered more than architecture” point on its own: the same network at 5cm loses 18.7 points of classification accuracy, a bigger drop than the 16.3-point gap between two entirely different architectures (3DMV vs ME 2cm).

The same three metrics, reprojected into 2D: a discrepancy I found

The NYUv2 numbers come from a second pipeline, segmentation2d_reproj.py, that reprojects SemanticFusion’s 3D map back through a camera and scores it against the 2D ground truth with the identical get_results function:

SemanticFusionAccuracy [%]FIoUMIoU
Classification37.1660.2480.145
Instance Segmentation1.4330.0100.003
Semantic Segmentation21.4710.1820.045

assets/NYUv2/seg2d_all_c.tex. The instance-segmentation collapse is Post 4’s story, not this one.

Reading the two assessment drivers side by side turned up something the report (there isn’t one, only the code) never says out loud. segmentation3d.py masks out every point whose ground truth is unlabelled before it ever calls get_results:

# segtester/assessments/segmentation3d.py:99-101
non_zero_mask = mapped_gt_seg.classes!=0
seg_3d_est = seg_3d_est.get_masked_seg(non_zero_mask)
mapped_gt_seg = mapped_gt_seg.get_masked_seg(non_zero_mask)

segmentation2d_reproj.py has no equivalent line anywhere before its own get_results calls (segtester/assessments/segmentation2d_reproj.py:106-121). The 3D numbers above are computed only over points where the ground truth actually says something; the 2D NYUv2 numbers include every pixel NYUv2 left unlabelled, scored as class 0 against whatever SemanticFusion happened to predict there. That is not a bug I can point to a single wrong line for, both pipelines call the same, correct get_results, but it is a genuine, checkable difference in what “ground truth” means between the two assessments in the same codebase, and it means the 3D and 2D numbers in this post are not measuring quite the same thing even though they share every metric function.

Decoding a label image with no key

The renders in this post and the widget below use a fixed colour scheme with no legend file anywhere: segtester/types/seg3d.py::get_labelled_pcd (used for the ScanNet point clouds) and segtester/types/seg2d.py::vis_labels (used for the NYUv2 pairs below) both do the same three lines:

# segtester/types/seg2d.py:39-48 (seg3d.py's get_labelled_pcd is identical in spirit)
cmap = plt.get_cmap("hsv")
if labels_to_vis is None:
    labels_to_vis = self.classes
max_class = labels_to_vis.max()
vis_cmap = cmap((np.arange(max_class+1)-1)/max_class)
vis_cmap[0] = (0.3, 0.3, 0.3, 1)

Class 0 always renders as a fixed grey; class i ≥ 1 renders as hsv_cmap((i - 1) / max_class). The part that makes this genuinely undecodable without reading the source: max_class is labels_to_vis.max(), the highest class id present in that one image, neither function is ever called with a fixed value in this codebase. Two renders of the same 13-class taxonomy can legitimately use two different palettes, because they happened to top out at different ids. I checked this against every one of the 34 NYUv2/res/{gt,est}_*.png pairs: max_class is 7, 12 or 13 depending on which classes happen to be in frame.

I also learned, the annoying way, that matplotlib’s "hsv" colormap is not a plain hue = x wheel: it disagrees with colorsys.hsv_to_rgb(x, 1, 1) by up to 24 of 255 in a channel, and it quantises to a fixed 256-entry internal table rather than evaluating continuously. Decoding these images means reproducing that exact table, not a mathematically-tidier approximation of it.

A ScanNet scene rendered as a coloured semantic point cloud, with a 13-class colour legend baked into the image: object (grey), Bed, Books, Ceiling, Chair, Floor, Furniture, Objects, Picture, Sofa, Table, TV, Wall, Window.

assets/SCANNET/common/scene0278_00.png, the one render in this repository with its own legend baked in. Reading its swatches off confirmed the class order used throughout this post: alphabetical, object (background) first. Credit: ScanNet (Dai et al.) scene geometry and labels; render is the harness’s own code.

Paint a prediction

Two 32×24 grids. The left one is seeded from a real NYUv2 frame, decoded client-side from the actual PNG, the same technique as the section above, not a synthetic bitmap. The right one starts blank; paint it with the 14-class brush and every metric on this page recomputes on every stroke. Predict the majority class everywhere and watch point accuracy jump while mIoU collapses to whatever one class’s IoU happens to be, because every other class the ground truth actually contains just scored zero. Get the small objects right, miss the rest does the opposite on purpose: it copies the ground truth everywhere except the frame’s two most common classes, which it deliberately gets wrong. Point accuracy drops hard, because those two classes are most of the image, but mean class accuracy and mIoU read far better than the first preset, because every minority class scores a perfect 1.0. The same prediction, two verdicts.

The second tab switches from a painting exercise to the real thing: the actual gt_XXXXXX.png/est_XXXXXX.png pair for the selected frame, decoded and scored in your browser exactly the way the table two sections up was produced, not read from seg2d_per_class_acc.tex, computed from the pixels. A confusion matrix (hover or focus a cell for its count), an undo stack, and a copy-the-metrics button are there because a widget that only shows you numbers once is a demo; one you can poke at your own frame is a small tool.

InteractivePaint a prediction, then see the real one

With JavaScript enabled, this becomes a 32×24 painting grid with a live metrics readout, two one-click presets, a confusion matrix, and a second tab that decodes and scores one of 34 real NYUv2 ground-truth/estimate pairs in your browser.

The same ScanNet legend render, shown as the static fallback.

Static fallback: the ScanNet legend render above.

What I’d tell past me

Report one number and you’ve made a choice, whether or not you say so. Point accuracy tells you what fraction of a scan you’d colour correctly if you were painting by hand, useful, and it will make a wall-and-floor detector look excellent. Mean class accuracy tells you how the method does on an average class, not an average point, which is the number that actually punishes ignoring TVs. IoU per class, and its two averages, tell you whether a class’s predicted extent overlaps its true extent, which accuracy alone can’t: a method that predicts “wall” for exactly the wall pixels and a method that predicts “wall” for the wall pixels plus half the room can have identical wall accuracy and very different wall IoU. None of the five is wrong. They’re different questions, and “accuracy” is not specific enough to say which one you asked, which is exactly why every table in seg3d_all_c.tex reports all three, side by side, instead of picking a winner.