IoU сегментации: как пустые маски и усреднение меняют результат

Две реализации оценки сегментации показывают разные IoU на одних масках. В одной системе качество выглядит приемлемым, в другой — почти нулевым. Особенно заметна разница на контроле дефектов: большинство кадров чистые, а повреждение занимает небольшую часть изображения. Прежде чем искать ошибку в весах, стоит восстановить правило, по которому отдельные маски превращаются в итоговое число.

Для одного класса IoU — число пикселей, одновременно отмеченных предсказанием и разметкой, делённое на число пикселей, отмеченных хотя бы в одной маске. Здесь считаем только класс «дефект». Пиксели фона не приносят ему правильных ответов: совпадение двух пустых изображений не создаёт пересечения переднего класса.

Что делать с двумя пустыми масками

Если разметка и предсказание пусты, пересечение и объединение равны нулю. Формула даёт 0/0. Дальше начинается соглашение оценщика: исключить такой случай, назначить ему 1 или сохранить специальное значение отсутствия. Эти правила дают разные средние. Их необходимо выбрать до сравнения моделей и записать в отчёте.

Пустая разметка и непустое предсказание — другой случай. Объединение положительно, пересечение нулевое, IoU равен нулю. Исключать все чистые изображения по условию «в разметке ничего нет» нельзя: тогда пропадёт часть ложных тревог. В примере исключаем только случаи, где пусты обе маски.

Среднее изображений и сумма пикселей

Есть два естественных способа собрать результат по набору. Можно вычислить IoU для каждого кадра и усреднить. Тогда маленький и большой дефект получают одинаковый вес как отдельные изображения. Можно сложить пересечения и объединения всех кадров и разделить суммы. Тогда вклад изображения зависит от размера объединения его масок.

На четырёх маленьких масках различие видно без модели и файлов. Первый кадр чистый с обеих сторон. На втором пропущен дефект площадью четыре пикселя. На третьем точно найден один пиксель. На четвёртом предсказан лишний пиксель на чистом фоне.

import numpy as np

# Четыре изображения 2x2, один передний класс «дефект».
target = np.zeros((4, 2, 2), dtype=bool)
target[1] = True                    # крупный дефект пропущен
target[2, 0, 0] = True             # маленький найден точно
pred = np.zeros_like(target)
pred[2, 0, 0] = True
pred[3, 1, 1] = True               # ложный дефект на чистом кадре

def summarize(pred, target):
    intersection = np.logical_and(pred, target).sum(axis=(1, 2))
    union = np.logical_or(pred, target).sum(axis=(1, 2))
    per_image = np.divide(intersection, union,
                          out=np.full(union.shape, np.nan), where=union > 0)
    global_iou = float(intersection.sum() / union.sum()) if union.sum() else None
    mean_nonempty = float(np.nanmean(per_image)) if np.any(union > 0) else None
    mean_empty_as_one = float(np.where(union == 0, 1.0, per_image).mean())
    return global_iou, mean_nonempty, mean_empty_as_one

for name, mask in [("Модель", pred), ("Всегда пусто", np.zeros_like(pred))]:
    global_iou, mean_nonempty, mean_one = summarize(mask, target)
    print(name, f"global={global_iou:.3f}",
          f"mean_nonempty={mean_nonempty:.3f}",
          f"mean_empty_as_one={mean_one:.3f}")
np.testing.assert_allclose(summarize(pred, target), (1/6, 1/3, 0.5))
assert summarize(np.zeros_like(pred), target) == (0.0, 0.0, 0.5)
assert summarize(np.zeros((1, 2, 2), bool), np.zeros((1, 2, 2), bool)) == (None, None, 1.0)

Для модели получаются три разных числа: общий foreground IoU — 0.167, среднее по изображениям с ненулевым объединением — 0.333, среднее с оценкой 1 для двух пустых масок — 0.500. Все расчёты используют одни и те же данные. Различается ответ на вопрос, чему дать одинаковый вес и что считать успехом на полностью пустом кадре.

Особенно показателен вариант «всегда пусто». Он пропускает оба настоящих дефекта, поэтому общий IoU и среднее по непустым объединениям равны нулю. Но среднее с единицей за два чистых кадра снова равно 0.500. Если на производстве чистых кадров гораздо больше, этот показатель может скрыть серьёзную проблему с обнаружением дефектов.

Как подписать метрику в отчёте

Не оставляйте рядом с числом только слово mIoU. Укажите, какие классы включены, усредняется ли результат по классам или изображениям, суммируются ли пиксельные счётчики по датасету и что происходит при нулевом объединении. Среднее по классам — ещё одна отдельная операция: её нельзя подменить средним по кадрам.

У готовых библиотек встречаются специальные соглашения. Например, TorchMetrics MeanIoU возвращает -1 для класса, полностью отсутствующего в предсказании и разметке. Если такую величину передать дальше в обычное среднее без учёта контракта, получится уже другая метрика. Приведённый код явно использует NaN для отсутствующего объединения внутри собственного расчёта.

Для приёмки контроля дефектов добавьте отдельный отчёт по кадрам с повреждениями и частоту ложных тревог на чистых деталях. Это позволит связать геометрию маски с последствиями ошибки. Порог бинаризации вероятностей также фиксируйте: изменение порога меняет маски предсказания и сравниваемые числа.

В платном курсе Computer Vision Engineer: от детекции и Doc-AI до продакшна вы разберёте метрики, цену ошибки, обучение сегментации на дефектах и постпроцесс масок. Затем сможете связать оценку качества с мониторингом работающего сервиса. Откройте программу курса и начните с пробного урока «IoU: как сравнить две рамки» — геометрия пересечения пригодится и при работе с масками.