OpenCV: калибровка пайплайна — как добиться стабильности детекта на разных освещениях
Разберём предобработку (масштаб, шум, освещение), выбор порогов и контроль качества результатов. Научитесь делать мини-наборы для тестов, чтобы изменения не ломали детект.
Содержание
OpenCV: калибровка пайплайна — как добиться стабильности детекта на разных освещениях
Стабильность детекта на разных освещениях — одна из самых дорогих проблем в компьютерном зрении. Камера меняет экспозицию, фон «уезжает» по яркости, тени появляются там, где их не было, а шум усиливается при низкой освещённости. В результате один и тот же пайплайн в лаборатории ведёт себя нормально, а в поле — «то видит, то нет», либо даёт много ложных срабатываний.
В этой статье разберём, как подойти к калибровке пайплайна детекта в OpenCV: от предобработки (масштаб, шум, освещение) до выбора порогов и контрольных метрик качества. Отдельно покажем, как собирать мини-наборы для тестов, чтобы любые изменения в коде не ломали детект.
Почему детект «плывёт» при изменениях освещения
Под «детектом» здесь будем понимать классические подходы OpenCV (например, пороговая сегментация, детект по градиенту, шаблоны, простые каскады/контуры) и вообще любой пайплайн, где есть параметры: пороги, размеры, фильтры, нормализации. Проблема в том, что многие шаги завязаны на статистику яркости/контраста.
Типичные источники нестабильности:
- Изменение общего уровня яркости (DC компонент): картинка становится светлее/темнее.
- Изменение контраста: локальные перепады (границы объектов) становятся слабее.
- Нелинейные эффекты камеры: автоматическая экспозиция/усиление, гамма, сжатие.
- Неравномерное освещение (vignetting, тени): фон неоднороден, простой порог начинает «разъезжаться».
- Шум при низком освещении: растёт доля высокочастотных компонентов, пороги по интенсивности перестают работать.
Отсюда практический вывод: калибровка должна включать не только «подбор порога под один кадр», а привязку параметров к наблюдаемым статистикам и сценариям освещения. Иначе вы просто переобучитесь на конкретный свет.
Каркас калибровки: что именно настраиваем
Удобнее думать о пайплайне как о цепочке преобразований, каждое из которых имеет «точку контроля»:
- Нормализация входного изображения (масштаб, цвет/яркость, компенсация неравномерности).
- Снижение шума (чтобы пороги работали на структуре, а не на случайных пульсациях).
- Выделение признаков (границы, текстуры, кандидаты на объект).
- Классификация/сегментация через пороги и морфологию.
- Постобработка и валидация результата (контуры, размерные ограничения, sanity-check).
- Контроль качества на мини-наборах тестов.
Самая распространённая ошибка — начинать настройку с порогов без нормализации. Порог тогда «подстраивается» под освещение вместо того, чтобы стабилизировать детект.
Предобработка: масштаб, шум и освещение
Масштаб и неизменность геометрии
Если вы используете пороговую сегментацию или контуры, масштаб критичен: меняется отношение «размера объекта в пикселях» к выбранным параметрам фильтра/морфологии/минимальной площади.
Рекомендации:
- Приведите изображение к одному целевому размеру по короткой стороне (letterbox/центровка или аккуратный ресайз).
- Используйте параметры морфологии и минимальной площади в пересчёте от масштаба (или работайте в нормализованной сетке).
- Если камера меняет дистанцию (а не только освещение), то стабильность «по освещению» может быть съедена геометрическими изменениями — это отдельная задача.
Пример: нормализация по короткой стороне в OpenCV:
import cv2
def resize_by_short_side(img, short_side=640):
h, w = img.shape[:2]
scale = short_side / min(h, w)
new_w, new_h = int(round(w * scale)), int(round(h * scale))
return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
Шум: чтобы пороги реагировали на структуру, а не на пиксельный хаос
Разные уровни освещения часто означают разный уровень шума. Если у вас дальше идут пороги по интенсивности или градиенту, шум напрямую увеличивает число ложных срабатываний.
Базовые практики:
- Для «соляного/перечного» шума — median blur.
- Для гауссовского — Gaussian blur или bilateral filter (с осторожностью по времени).
- Для стабильности иногда полезны non-local means (fastNlMeansDenoising), но цена выше.
Параметры подбирайте не «на глаз», а под дальнейший шаг: например, если вы строите маску через порог по разности яркости, то достаточно сгладить высокочастотные колебания.
Пример комбинированного сглаживания перед порогом:
import cv2
import numpy as np
def denoise_for_threshold(gray):
# median помогает против импульсного шума
med = cv2.medianBlur(gray, 3)
# gaussian стабилизирует локальные колебания
gauss = cv2.GaussianBlur(med, (5, 5), 0)
return gauss
Освещение: нормализация яркости и компенсация неравномерности
Освещение — главная причина «плывущих» порогов. Есть несколько уровней решения, от простого к более устойчивому.
1) Порог по адаптивной модели (Adaptive Threshold)
Если фон неравномерен, глобальный порог ломается. Адаптивный порог учитывает локальную статистику: для каждого пикселя берётся оценка порога в окрестности.
OpenCV: cv2.adaptiveThreshold.
import cv2
def adaptive_mask(gray):
# gray должен быть 8-bit
blur = cv2.GaussianBlur(gray, (5, 5), 0)
mask = cv2.adaptiveThreshold(
blur, 255,
adaptiveMethod=cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
thresholdType=cv2.THRESH_BINARY,
blockSize=31, # размер локального окна
C=5 # смещение
)
return mask
Ключевые параметры:
blockSize(должен быть нечётным): слишком маленький — шум, слишком большой — опять к глобальному порогу.C: влияет на смещение порога.
2) Коррекция неравномерности через выравнивание фона (фон-Subtraction / morphological opening)
Если проблема — медленно меняющаяся составляющая фона (например, градиент или виньетка), можно оценить фон морфологическим открытием и вычесть его.
Идея: background = opening(gray), затем corrected = gray - background или gray / background (в зависимости от формата и света).
Пример:
import cv2
import numpy as np
def illumination_correct_opening(gray, ksize=51):
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
background = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel)
corrected = cv2.subtract(gray, background)
# нормализация к 8-bit полезна для устойчивых порогов
corrected = cv2.normalize(corrected, None, 0, 255, cv2.NORM_MINMAX)
return corrected
Осторожность:
ksizeвыбирайте так, чтобы он был больше масштаба объекта, но меньше масштаба фона.- Если объект тоже «похож» на фон по масштабу, вы его частично вычтете.
3) Нормализация в пространстве освещения: CLAHE (контрастное выравнивание)
Если вы работаете с яркостью, иногда помогает CLAHE — адаптивная коррекция контраста с ограничением усиления.
Вариант: применять CLAHE к L в пространствах LAB или напрямую к gray.
import cv2
def clahe_on_gray(gray, clip_limit=2.0, tile_grid_size=(8, 8)):
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
return clahe.apply(gray)
CLAHE не «лечит» неравномерность как вычитание фона, но часто стабилизирует границы и текстуры, из-за чего пороги по градиенту начинают работать ровнее.
4) Важный нюанс: работайте с калиброванной цветностью, если порог по цвету
Если детект использует цветовые признаки (например, HSV), то нужно понимать, как меняется цвет при разном освещении. Автобаланс белого, спектр лампы и отражательные свойства поверхности могут «перерисовать» оттенки. Тогда:
- либо делайте детект устойчивее (например, перейти на яркость/градиент),
- либо используйте нормализацию цветового пространства (что сложнее и требует калибровки под ваш спектр).
В этой статье фокус на яркости/границах — но принципы те же: нормализовать вход так, чтобы параметры дальше были переносимыми.
Выбор порогов: что считать «правильными» параметрами
Пороги в OpenCV бывают разными:
- порог интенсивности (
threshold,inRange), - порог адаптивный (
adaptiveThreshold), - пороги по статистике (
Otsu), - пороги по градиенту (например, Canny thresholds),
- пороги по вероятностям (если у вас есть модель — но это уже другая категория задач).
Главная ошибка — выбирать пороги по одному кадру или одной «светлой» сцене. Правильный подход: пороги должны выдерживать вариативность освещения из вашего домена.
Глобальные пороги: когда они оправданы
Глобальный порог (cv2.threshold или cv2.threshold(..., THRESH_OTSU)) оправдан, если:
- фон близок к однородному,
- объект заметно отличается по интенсивности,
- освещение меняется «мягко» и не делает фон существенно другим по структуре.
Даже тогда лучше делать:
- нормализацию яркости (например, выравнивание или CLAHE),
- затем применять глобальный порог либо Otsu.
Пример Otsu:
import cv2
def otsu_mask(gray):
# Ожидаем 8-bit
blur = cv2.GaussianBlur(gray, (3, 3), 0)
_, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return mask
Адаптивные пороги: настройка под локальную вариативность
Если фон и тени локально меняют яркость, адаптивный порог часто лучший старт. Но и здесь нужна калибровка blockSize и C.
Стратегия калибровки:
- выберите диапазон вероятных освещений,
- для каждого значения параметров посмотрите, как меняются метрики качества (о них ниже),
- старайтесь, чтобы параметры были «плоскими»: небольшое изменение освещения не должно резко разрывать метрики.
Практическая подсказка: blockSize связывают с размером объекта и масштаба тени. Если окно слишком мало — порог начинает следовать за шумом и мелкими текстурами. Если слишком большое — перестаёт учитывать тени.
Пороги градиента (Canny): не путайте чувствительность и шум
Часто детект границ идёт через Canny. Там два порога (threshold1, threshold2). На разных освещениях меняется и амплитуда градиента, и фоновые текстуры.
Правильный способ — либо оценивать пороги из статистики изображения (например, через перцентиль градиента), либо применять нормализацию контраста до Canny.
Один из устойчивых методов:
- Сначала найти карту градиента.
- Выбрать пороги как перцентили.
- Затем применить Canny.
Пример (в упрощенном виде):
import cv2
import numpy as np
def canny_with_percentiles(gray, low_q=0.1, high_q=0.3):
# Соберём градиент по Sobel
g_x = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
g_y = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = cv2.magnitude(g_x, g_y)
# Переведём в 8-bit для практичности перцентилей
mag_norm = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
low = np.quantile(mag_norm, low_q)
high = np.quantile(mag_norm, high_q)
# Canny ожидает пороги в тех же единицах, что и шкала после нормализации
edges = cv2.Canny(gray, threshold1=low, threshold2=high)
return edges
Важно: этот код демонстрационный; на практике пороги лучше считать на той же шкале, что используется в Canny (либо заменить Canny на вычисление границ более прямым способом). Но идея — «пороги из распределения» — помогает пережить изменение контраста.
Морфология и фильтрация контуров: скрытый рычаг стабильности
Даже идеальный порог создаёт маску с артефактами: дырки, мелкий мусор, разрывы границ. Стабильность часто достигается не порогом, а комбинацией:
- морфологических операций (open/close),
- фильтрации компонентов по площади/отношению сторон,
- ограничений на форму объекта.
Пример: очистка бинарной маски через open + close и фильтрация по компонентам:
import cv2
import numpy as np
def clean_mask(mask, min_area=500):
# mask: 0/255 uint8
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
# Убираем мелкий шум
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
# Заполняем небольшие дырки
closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=1)
# Фильтрация connected components
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(closed, connectivity=8)
out = np.zeros_like(mask)
for i in range(1, num_labels): # 0 — фон
area = stats[i, cv2.CC_STAT_AREA]
if area >= min_area:
out[labels == i] = 255
return out
Подводный камень: min_area тоже зависит от масштаба. Если вы ресайзите изображения — делайте min_area тоже масштабно-стабильным.
Контроль качества: как понять, что пайплайн стал устойчивым
Калибровка — это не подбор «по ощущениям», а итеративная проверка на данных. Без метрик вы будете бесконечно менять параметры, не зная, что именно улучшилось.
Минимальный набор метрик
Зависит от задачи, но часто подходят:
- IoU (если есть разметка или хотя бы контурная эталонная маска),
- Precision/Recall на бинарных детекциях,
- количество пропусков/ложных срабатываний в конкретных сценах,
- стабильность: дисперсия метрики по условиям освещения.
Если разметки мало, можно сделать «суррогатные» метрики:
- сколько найденных объектов в кадре,
- средняя/максимальная площадь найденных объектов,
- доля кадров, где детект пустой (в идеале — стабильно не пустой),
- распределение уверенности (если она есть).
Принцип: разделяйте валидацию по сценариям освещения
Ваш набор тестов должен включать разные условия:
- яркий верхний свет,
- пасмурно (плоский контраст),
- тень на половине кадра,
- подсветка снизу,
- низкая экспозиция (шум),
- разные уровни экспозиции при одинаковой сцене.
Не «перемешивайте» их в кучу без анализа: нужно понимать, где сломалось.
Визуальный контроль обязателен
Даже при метриках полезно иметь автоматическую генерацию «трейса» результата: вход, предобработка, итоговая маска/контуры, подсветка детектов. Это позволяет быстро отличить:
- деградацию из-за неправильной нормализации,
- деградацию из-за порога,
- деградацию из-за морфологии.
Как сделать мини-наборы тестов, чтобы изменения не ломали детект
Самая практичная часть калибровки — процесс. Вам не нужен огромный датасет; достаточно управляемого минимума, который покрывает вариативность освещения.
Шаг 1. Сформируйте «папки сценариев»
Например:
data/tests/bright/- `data/tests/over
Комментарии
Пока нет комментариев