Обучать нельзя фильтровать: убираем тени с дороги без нейросетей

Где поставить запятую в заголовке? В этой задаче я поставил её после «нельзя»: «Обучать нельзя, фильтровать».
Продолжаю разбирать задачи, которые возникают при анализе дорог. На этот раз - тени на снимках линейной камеры.
Нужно искать трещины и заплатки. Но половина асфальта на изображении тёмная, граница тени добавляет собственный контур, а разметка одновременно попадает и на солнце, и в тень. Всё это потом достаётся алгоритму поиска дефектов.
Хочется просто поднять яркость и пойти дальше. Вместе с яркостью, правда, поднимаются шум и зернистость. Если добавить локальный контраст, можно ещё сильнее подчеркнуть ту самую границу, от которой собирались избавиться. Разметка тоже участвует: она белая и довольно активно портит статистику асфальта.
В итоге получился пайплайн из коррекции камеры, оценки освещения, ограничений на контраст и отдельной обработки разметки. Работает на CPU, без нейросетевого инференса. Гауссовы фильтры, локальная статистика, морфология и немного терпения к uint8 :)
Сначала покажу результат, а дальше разберу, откуда он берётся.

Почему здесь без нейросети
Предобученные модели я пробовал, но на этих дорожных снимках они не давали достаточно хорошего результата. Взять готовую модель, прогнать через неё кадры и закрыть задачу не получилось. А адаптация под нашу съёмку - это уже отдельная работа с данными и обучением.
Есть и более неприятный момент: вместе с тенью можно убрать то, ради чего вообще снимали дорогу. Особенно осторожно я отношусь здесь к генеративным подходам. При восстановлении текстуры модель может сгладить тонкую трещину или изменить границу заплатки. На вид асфальт станет приятнее, а алгоритму поиска дефектов уже нечего будет искать в этом месте. Красивый асфальт без настоящей трещины - довольно сомнительный успех для дорожной диагностики :)
Наконец, рабочее решение требовалось получить достаточно быстро - без отдельного этапа разметки и обучения. Сбор обучающих данных, их подготовка, дообучение и проверка модели в эти условия не укладывались.
Так я и выбрал классическую обработку изображений: автоматически подбирать коррекцию камеры по серии и использовать статистику дорожного покрытия, явно задавать преобразования и смотреть их действие на каждом этапе.
Что у нас на входе и чего я хочу на выходе
Я работаю с последовательностью монохромных TIFF: 8 бит, 2048 × 1024 или 1024 × 2048.
Это довольно удобное ограничение. Тип поверхности повторяется, геометрия кадров известна. И главное - есть целая серия: можно искать светлые участки и использовать их статистику как опору.
На выходе мне нужно изображение для дальнейшего поиска трещин и заплаток. Поэтому здесь будет и нормализация яркости, и заметное изменение контраста.
Дальше - один реальный кадр, от начала до конца
Возьмём снимок с широкой поперечной тенью, полосой разметки и выраженной фактурой асфальта. На нём и будем смотреть шесть крупных преобразований. Без смены дороги посреди объяснения :)

На рисунках шести крупных преобразований сверху показан весь кадр, снизу - один и тот же увеличенный фрагмент с границей тени и полосой разметки. Так проще увидеть, что меняется после каждого преобразования.
Под каждым этапом можно раскрыть формулы и промежуточные расчёты.
Кнопками «Назад» и «Вперёд» можно пройти все шесть преобразований: слева показано изображение до выбранного шага, справа - после. В пятом шаге соединяются две ветки; к этой развилке ещё вернёмся.
Начнём чуть издалека. В упрощённом виде пиксель можно представить так:
I(x, y) ≈ R(x, y) · L(x, y) + n(x, y),
где R - свойства поверхности, L - освещение, n - шум и всё, что мы не учли. Уже здесь хватает упрощений: влажность, отражения, оптика и сенсор тоже влияют на картинку.
Имея только I, однозначно разделить поверхность и освещение не получится. Тёмный пиксель может быть тенью, заплаткой или трещиной. Поэтому я использую несколько опор: масштаб изменения яркости, соседние области, статистику светлых кадров и маски разметки.
Весь пайплайн выглядит так:

На схеме стоит задержаться. Ветка выравнивания освещения использует яркостную основу и результат обработки разметки. Весь результат коррекции контраста через неё как через очередной фильтр не проходит. Если цели двух веток совпадают, общие вычисления переиспользуются.
Ещё договоримся о терминах. Изображение - то, что в конце получит детектор. Поле коррекции - карта коэффициентов, которыми меняется яркость. Маска - ограничение, где и что разрешено делать. Дальше эта разница пригодится: сгладить поле коррекции и размыть сам асфальт - две очень разные операции.
1. Убираем особенности камеры
Сначала убираю то, что добавила камера: полосы, неодинаковую чувствительность и пространственную неравномерность. Большая тень здесь остаётся - до работы с освещением мы ещё не дошли.

Как это считается: профили камеры, выбор силы коррекции и отбор опорных кадров
У камеры есть свои особенности: полосы, разная чувствительность столбцов, пространственная неравномерность. Если сразу начать оценивать освещение, всё это тоже попадёт в оценку. Алгоритму ведь никто не объяснил, где тень, а где особенность сенсора.
Поэтому первым шагом алгоритм автоматически подбирает силу коррекции полос по серии снимков. Сами профили поправок для камеры заданы заранее: они описывают смещение яркости столбцов, разную чувствительность и пространственную неравномерность. По текущей серии выбирается сила применения полосового профиля. Полосовая часть до компенсации общего уровня выглядит так:
I₁(x, y) = [I₀(x, y) − s · a(x)] / max(1 + s · k(x), 0.5),
I₀ - исходное изображение, I₁ - изображение после полосовой коррекции; a - аддитивная поправка в единицах яркости, k - относительное отклонение чувствительности столбца от единицы, s - сила коррекции от 0 до 1. Ограничение знаменателя снизу не даёт чрезмерно усилить сигнал. Затем возвращается медианный уровень допустимых пикселей, значения ограничиваются диапазоном и округляются. Следом компенсируется пространственная неравномерность.

Силу s выбираю из нескольких фиксированных значений на достаточно однородных кадрах. Проверка идёт на разных поднаборах: если улучшение не подтверждается, полосовая поправка отключается. Коррекция должна помогать конкретной серии; применять её на максимуме просто потому, что она есть, смысла мало.
Здесь легко перепутать два шага. Светлые опорные кадры отбираются по RAW, а их фотометрическая статистика считается уже после коррекции камеры. Так я сохраняю исходный критерий отбора, но не тащу дефекты камеры в целевые значения яркости.
Сам отбор устроен через уменьшенное изображение и окна нескольких размеров. В каждом окне беру 20-й процентиль, затем высокий процентиль по окнам, а оценки разных масштабов объединяю медианой. Ищу достаточно светлые участки, при этом отдельный белый пиксель не должен решать судьбу всего кадра.
2. Выравниваем освещение
Вот здесь уже меняется сама тень. По статистике серии выбираю целевую яркость, строю поле коэффициентов и добавляю локальную коррекцию яркости там, где хватает опоры. Всё это вместе даёт одну яркостную основу. Число T, две оценки освещения и проверка окружения - внутренние расчёты этого преобразования.

Как это считается: целевая яркость, две оценки освещения, опора и локальная коррекция яркости
Захардкодить какое-нибудь приятное серое значение было бы удобно. Только у следующей серии условия съёмки могут оказаться другими.
Поэтому целевую яркость я получаю из светлых опорных кадров этой же серии. Исключаю разметку и непригодные пиксели, считаю статистики по ячейкам 8 × 8, объединяю кадры, сглаживаю поле в логарифмическом представлении и использую опорные области после коррекции камеры. Для ветки выравнивания освещения из этого получается одна пространственно постоянная цель серии. Для оценки по предварительно сглаженным кадрам целевая яркость рассчитывается отдельно.
В этой серии из 128 кадров выбрано 102 опорных. Полученные целевые уровни - примерно 75,05 и 75,30 DN. DN - единица интенсивности: у 8-битного изображения значения лежат от 0 до 255.
Постоянная цель не означает одинаковую поправку везде. Тёмный участок и светлый участок получают разные коэффициенты, чтобы приблизиться к одному уровню.
Если светлых опорных кадров нет, блок выравнивания освещения завершает работу с ошибкой. Подставить число можно, но тогда мы уже сами придумали опору, которой не было в данных.
И ещё: от соседних кадров здесь нужна только статистика. Куски «хорошего асфальта» между изображениями не копируются.

Строим поле освещения
Пусть M - текущая средняя интенсивность на сетке ячеек, а T - целевое поле. Тогда базовая коррекция:
g = ln[max(T, 2) / max(M, 2)]
I′ ≈ I · exp(ĝ).
Здесь I - изображение до преобразования, I′ - после него, ĝ - поле g, интерполированное до полного разрешения. Первое такое поле на рисунке обозначено g₀. ln обозначает натуральный логарифм, exp - экспоненту. Логика простая: сравниваем текущую яркость с желаемой, интерполируем поле до полного разрешения и меняем интенсивности. Двойка в знаменателе и числителе нужна, чтобы почти чёрные значения не превращали расчёт в деление на ноль или огромный коэффициент.

В логарифмической форме удобно добавлять поправки: мультипликативное изменение яркости превращается в сложение или вычитание из g. Коэффициент при этом может и осветлять, и затемнять. В конце остаются ограничение диапазона и округление.
Но локальное среднее смешивает сразу всё: освещение, фактуру, границу материалов. Особенно неприятно это на резкой границе тени. В одно окно попадают две области, которым нужны разные поправки.
Локальная коррекция яркости: смотрим на кадр двумя способами
Здесь я строю два варианта:
A: поле оценивается по исходному кадру и применяется к нему же;
B: поле оценивается по предварительно сглаженному кадру, но применяется снова к исходному.
То есть в B сохраняется исходная фактура. Сглаживание используется при оценке освещения.
Теперь можно посмотреть на A − B. Разность показывает места, где результат сильно зависит от масштаба оценки. Положительную часть беру как кандидата на локальную поправку, а опору ищу в окружении, где сглаженный модуль разности A − B невелик. Разметку и непригодные пиксели из этой опоры исключаю.

Средний уровень такого окружения считается нормированной свёрткой:
R = Gσ(m · A) / max(Gσ(m), ε).
R - опорная яркость, Gσ - гауссово сглаживание с масштабом σ, m - маска допустимых пикселей: 1 внутри опоры, 0 за её пределами. Малое положительное ε защищает от деления на ноль. Сначала сглаживаем изображение с маской, затем делим на сглаженную маску. Без деления выкинутые пиксели фактически превратились бы в чёрные и потянули среднее вниз.

Но даже ненулевая маска ещё мало что говорит. Если в огромное окно попало несколько пикселей с краю, доверять им как полноценному окружению странно. Поэтому проверяю эффективное число наблюдений:
N = (Σwᵢ)² / Σwᵢ².
N - эффективное число наблюдений, wᵢ - вес допустимого пикселя в окне. Это характеристика распределения весов. Независимыми соседние пиксели от такой формулы, конечно, не становятся. Для быстрой проверки используется нижняя оценка: сумма весов допустимых пикселей, делённая на максимальный вес полного двумерного гауссова ядра. Если уже её достаточно, точную свёртку квадратов весов можно пропустить.

Если опоры достаточно, поправка определяется разностью опорного и локального низкочастотного уровней A. Если недостаточно - используется ограниченная резервная оценка по компонентам.
Сверху поправку ограничивает запас до 248,49 DN. Отдельно сохраняю карту того, насколько осветлили каждый пиксель, в логарифмическом виде. После коррекции контраста эта карта понадобится, чтобы сгладить переходы самой поправки.

Локальная коррекция яркости включается по отдельному критерию кадра. Для кадров, не прошедших этот критерий, остаётся обычная яркостная нормализация. Дальше на примерах будет как раз по одному случаю для каждого пути.
3. Настраиваем фактуру и тона
Свет выровняли, теперь разбираемся с деталями. Ограничиваю усиление фактуры, возвращаю средние ячеек, сглаживаю поле поправки, настраиваю тональные диапазоны и ограничиваю чрезмерно светлые детали. На рисунке - суммарное действие этого блока. Отдельные ограничения могут почти не менять конкретный участок: их задача - сдерживать поправку там, где она становится чрезмерной.

Как это считается: ограничения контраста, проекция средних, сглаживание поля, тональное преобразование и ограничение светлых деталей
После нормализации детали могут выглядеть слабо. Напрашивается идея подтянуть локальное стандартное отклонение к опорному. Но на почти однородном участке это удобный способ сделать из слабого шума очень убедительную фактуру. Для поиска трещин подарок сомнительный.
При коррекции контраста я учитываю и разницу фактуры, и неопределённость её оценки. Статистика считается на нескольких масштабах, сравнение идёт в логарифмах стандартных отклонений. Если опорная фактура сильнее текущей, получаем положительный запрос на усиление Δ.
Дальше этот запрос ослабляется:
ρ = C₀,₁(1 − U / Δ²), если Δ > 0
ρ = 0, если Δ ≤ 0
h = max(Δ, 0) · ρ · (1 − p),
ρ - доля разрешённого усиления, h - логарифм коэффициента усиления, p - доля защищённых пикселей. Здесь и далее Cₐ,ᵦ(x) = min(max(x, a), b) ограничивает значение диапазоном от a до b. U объединяет неопределённость текущей и опорной статистики; при нулевом запросе Δ усиление не добавляется. Если статистики для оценки недостаточно, усиление тоже отключается. Если само различие невелико на фоне неопределённости, усиление сокращается, вплоть до нуля. Защищённые области тоже уменьшают его силу.

Дополнительно ставлю предел по фактуре, которая уже есть во входе:
S = min(V₈(Iк), V₈(Iя))
S′ = max(S − 0.25, 0)
kₘ = 1 + √(S′ / 0.25).
V₈ - дисперсия внутри ячейки 8 × 8, Iк - изображение после коррекции камеры, Iя - яркостная основа, S′ - дисперсия после вычитания порога, kₘ - верхняя граница усиления. Беру минимум дисперсий изображения после коррекции камеры и яркостной основы, вычитаю небольшой порог и получаю верхнюю границу усиления. На защищённых пикселях коэффициент равен единице.
Число 0,25 DN² здесь связано с квадратом максимальной ошибки округления ±0,5 DN. Это инженерный порог; измеренной дисперсией шума камеры я его не называю.

Есть ещё одна тонкость. Меняя контраст, легко заодно незаметно сдвинуть среднюю яркость. Поэтому из добавленной поправки вычитаю её локальную среднюю составляющую. Затем решаю задачу с ограничениями: сохранить средний уровень ячейки, не менять защищённые пиксели и оставить остальные в диапазоне 0-255. Сохранение среднего относится к расчёту до финального целочисленного округления.

Запишем ограничение на среднее внутри ячейки. Для свободных пикселей нужно найти общий сдвиг s:
Σᵢ∈F C₀,₂₅₅(cᵢ − s) + Σᵢ∈P bᵢ = Q.
F - свободные пиксели ячейки, P - защищённые; cᵢ - яркость после усиления деталей, bᵢ - исходная яркость защищённого пикселя, Q - требуемая сумма яркостей в ячейке.

Теперь возвращаюсь к сохранённой карте осветления. Обозначу её H, а сглаженную версию - H̃. Изображение после коррекции контраста умножаю на exp(H̃ − H): так меняется плавность поправки, а сама фактура не размывается. Порядок здесь важен: это действие выполняется после проекции, перед тональным преобразованием.

Получается чуть сложнее, чем просто умножить детали на коэффициент. Зато понятно, что именно этой операции разрешено менять.
Тональное преобразование и ограничение светлых деталей
Сначала делю яркости на 13 диапазонов по квантильным порогам. Для каждого диапазона задаётся свой множитель: так можно по-разному преобразовать тёмные, средние и светлые участки. Для 8-битного изображения это удобно представить таблицей соответствия на 256 значений.
У исходного и защищённого вариантов согласованные пороги. Иначе небольшая локальная поправка контраста могла бы перестроить тональное разбиение всего кадра.
После тонального преобразования ограничиваю светлые детали - локальные отклонения яркости выше фона. Алгоритм сравнивает их относительную амплитуду с опорной статистикой и при необходимости уменьшает её. Этот шаг может ослабить светлую деталь, но не усиливает её.
Так тональное преобразование задаёт общий вид изображения, а ограничение светлых деталей сдерживает локальные выбросы яркости.


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

Как это считается: маска, проверка границы, бюджет яркости и надёжность
Почти все предыдущие рассуждения опирались на поверхность дороги. А теперь добавим большую белую полосу, которая отличается и яркостью, и фактурой, и при этом пересекает тень.
Если считать её обычным асфальтом, оценка освещения начнёт компенсировать различие материалов. Разметка станет темнее, а соседние участки получат поправку, на которую мы не рассчитывали.
Для этого есть отдельный блок обработки разметки. Маска строится классическими методами: используются интенсивность, положительное отклонение от локального фона, модуль лапласиана, GrabCut и связные компоненты. Затем идут проверки по статистике серии, областям и границам.
Кандидат должен быть светлее окружения в исходном изображении, иметь достаточно выраженную границу и подходящую внутреннюю фактуру. Одного признака мало.

Проверяем, есть ли на границе отдельная ступень
Вот этот момент мне кажется интереснее обычного порога яркости.
Вдоль границы области строю нормали и измеряю перепад между внутренней и внешней сторонами. Потом сравниваю его с продолжением локального перепада внутри и снаружи области.
Представьте плавно светлеющий асфальт. Разность соседних пикселей положительная, но отдельной ступени на границе материала нет. Если вычесть продолжение плавного градиента, такой отклик должен исчезнуть.
Пиксели контура зависимы, поэтому сначала объединяю отклики в блоки 6 × 6 на уменьшенном изображении. Уже по средним блоков сравниваю две модели: дополнительной положительной ступени нет или она есть.
В коде используется критерий с BIC-подобным штрафом:
E₀ = Σrᵢ²
E₁ = Σ(rᵢ − r̄)²
B = n · ln(E₀ / E₁)
поддержка границы:
n > 1,
r̄ > 0,
B > ln(n).
rᵢ - отклик блока контура после вычитания плавного перепада, r̄ - средний отклик, n - число блоков. E₀ и E₁ - суммы квадратов ошибок двух моделей, B - выигрыш от добавления ступени. Знаменатели защищены от нуля. Смысл проверки - понять, достаточно ли улучшилась модель, чтобы оправдать дополнительную ступень. Это эвристика выбора модели; вероятностью принадлежности разметке её считать нельзя. И она помогает принимать дополнительные области, а не заменяет все остальные проверки маски.
Для границы на следующем рисунке 30 блоков контура дают средний избыток 0,541. Выигрыш B = 55,065 превышает штраф ln(30) = 3,401: дополнительная ступень проходит проверку.

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


Результат обработки разметки сохраняю отдельно. Ниже он вернётся при сборке веток и при финальной защите разметки.
5. Собираем результат двух веток
А здесь важно не потерять развилку. Возвращаюсь к яркостной основе второго блока, подавляю локальные переусиления поля и снова выполняю тональную обработку. Затем подмешиваю обработанную разметку из четвёртого блока. На рисунке показаны оба входа и результат их сборки; результат обработки разметки не проходит целиком через ветку выравнивания освещения.

Как это считается: признаки, статистический вес, морфология поля и смешивание веток
Даже после всех ограничений локальная нормализация может переусилить необычный участок поверхности. Для таких мест в ветке выравнивания освещения есть отдельная статистическая проверка.
В ней используется заранее собранный банк признаков с 24 кадров. Это отдельная опора для оценки необычных участков; её не нужно путать со светлыми кадрами текущей серии, по которым выбирается целевая яркость. На текущем изображении считаются локальные квантили в окнах 16 × 16 с шагом 4 пикселя, яркость окружающего кольца и его зернистость.
Два основных отклика:
τ = ln[1 + max(q₅₀ − q₁₀, 0) / (max(q₉₀ − q₅₀, 0) + 1)]
d = arsinh[(μ − q₅₀) / (1.5 · ν + 1)].
q₁₀, q₅₀ и q₉₀ - 10-й, 50-й и 90-й процентили яркости в окне. μ - средняя яркость окружающего кольца, ν - его зернистость: средний модуль разности изображения и его гауссово сглаженной версии с σ = 2. Кольцо получается из окна 65 × 65 с исключённым центром 33 × 33. arsinh - обратный гиперболический синус, который сжимает большие значения.
Первый признак, τ, сравнивает разброс яркости ниже и выше медианы. Чем сильнее разброс в тёмную сторону относительно светлой, тем он выше. Второй, d, показывает, насколько участок темнее окружения, с поправкой на зернистость. Оба признака считаются по изображению после коррекции камеры; корректировать по ним будем яркостную основу.


Один порог на весь асфальт здесь был бы грубоват. Поэтому учитываю контекст: логарифм фоновой яркости и относительную зернистость. Участки группируются, а ожидаемые статистики смешиваются из близких примеров банка и локальной оценки. Когда местных данных мало, опора на банк сильнее.
На выходе получаю числовую меру аномальности и перевожу её в ограниченный вес:
w = z / (z + 1).
Здесь z ≥ 0 - мера аномальности, w - сила подавления локального переусиления. Значение от 0 до 1 выглядит знакомо, но это всё ещё не вероятность тени. Это сила воздействия на поле усиления.

Пусть b - текущий яркостный результат, I - изображение после коррекции камеры. Дальше делаю следующее:
g = ln[max(b, 1) / max(I, 1)]
gₒ = g ∘ E
δ = max(g − gₒ, 0) · w
b′ = b · exp(−δ).
Знак ∘ обозначает морфологическое открытие, E - эллиптический элемент 23 × 23. gₒ - поле после открытия, δ - подавляемая часть, b′ - скорректированная яркостная основа. Морфологическое открытие даёт более спокойную основу поля. Положительные локальные выступы относительно неё сокращаются с весом w. Защищённые области исключаются из поправки.

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



Дальше подмешиваю сохранённый результат обработки разметки. На пикселях, которые блок разметки сделал светлее своего входа, вес смешивания равен единице; около них переход сглажен. Обозначу этот вес p, результат выравнивания освещения C, а результат обработки разметки M:
J = ⌊C · (1 − p) + M · p⌉.
J - собранное изображение; скобки ⌊ · ⌉ здесь означают округление до ближайшего целого.

Банк хранит опорные статистики дорожной поверхности. Вместе с коррекцией камеры он помогает адаптировать обработку к серии без обучения нейросети.
6. Согласуем остаточную границу
Последний проход работает около остаточного перепада. Согласую яркость и фактуру с допустимым окружением, а в самом конце защищаю обработанную разметку от затемнения. Воздействие здесь небольшое по площади: это доводка уже собранного результата, а не ещё одно глобальное осветление.

Как это считается: сила коррекции, поддержка окружения, согласование и защита разметки
Основной перепад яркости уже стал слабее, но на границе может остаться заметный след. Для него есть ещё одна локальная поправка.
Вычисляю разность двух яркостных преобразований сглаженного кадра с сохранением знака. Здесь масштаб σ = 16 пикселей; у второй ветки есть дополнительное внутреннее сглаживание.
Сила коррекции растёт плавно на интервале отклика 4-12 DN:
t = C₀,₁[(max(r, 0) − 4) / 8]
α = t² · (3 − 2t).
r - отклик разности двух преобразований, t - его нормированное значение, α - сила коррекции. Кубическая функция t² · (3 − 2t) обеспечивает плавное включение поправки вместо резкого скачка на пороге. На защищённых пикселях α обнуляется.

Опорные яркость и фактуру беру из допустимого окружения с σ = 48. Если допустимых пикселей мало, воздействие дополнительно ослабляется. Изменение фактуры ограничено коэффициентами 0,65-1,5, сдвиг яркости - диапазоном −80…80 DN. Широкое сглаживание для оценки опоры выполняется на уменьшенной вдвое сетке.

В сокращённом виде получается:
J′ = J + β · [Δμ + (k − 1) · (J − μₗ)].
J - изображение до согласования, J′ - после него, β - сила коррекции с учётом поддержки окружения, Δμ - сдвиг яркости, k - коэффициент изменения фактуры, μₗ - локальная оценка фона: медианная фильтрация окном 11 × 11 с последующим гауссовым сглаживанием при σ = 3.
После ограничения диапазона и округления защищённые пиксели и пиксели с нулевой силой коррекции сохраняют прежние значения.

В самом конце ветки выравнивания освещения действует вот такое условие:
Iитог(x, y) = max(J′(x, y), M(x, y)) на маске разметки,
Iитог(x, y) = J′(x, y) вне маски.
То есть уже обработанная разметка не должна стать темнее результата коррекции контраста и обработки разметки. Принудительного значения 255 здесь нет.
Условие легко проверить. А вот правильность самой маски - отдельная история: ошибочно выбранный пиксель оно тоже будет защищать.

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

Здесь хорошо видно, ради чего вообще затевалась обработка: широкая поперечная тень с утолщением справа занимает заметную часть кадра. Есть и полоса разметки, и неоднородная фактура асфальта.
На первых двух панелях тень остаётся. Коррекция компенсирует особенности камеры, но сама по себе задачу освещения не решает. На третьей перепад уже сильно слабее; после коррекции контраста и тональной обработки гораздо заметнее мелкие детали. В финале поперечная тень подавлена, хотя её остаточный след местами различим по фактуре.
Теперь тот же участок крупнее:

На увеличении хорошо видно, что результат не сводится к прибавлению константы яркости: меняется и контраст фактуры. При этом каждую появившуюся тёмную линию объявлять трещиной рано - семантикой дефектов занимается следующий алгоритм.
Широкая тень с мягким переходом

Здесь тень тянется вдоль верхнего края, а переход к освещённому асфальту заметно мягче. Разметки нет, поэтому можно отдельно посмотреть, что происходит с освещением и фактурой.
На первых двух панелях широкая тёмная полоса ещё хорошо видна. После выравнивания яркости перепад становится значительно слабее. Затем коррекция контраста подчёркивает мелкую фактуру, а финальное согласование уменьшает остаточный след вдоль границы тени. Локальная коррекция яркости на этом кадре включена.

На увеличении тёмная область уже не скрывает фактуру так сильно. При этом по обе стороны бывшей границы текстура выглядит немного по-разному: выравнивание освещения не делает весь асфальт одинаковым.
Маска разметки и карта коррекции - отдельно

Белое слева означает: финальный пиксель должен быть не темнее результата ветки обработки разметки. Жёлтое справа означает большую заданную силу локального согласования.
У второго снимка маска разметки пустая: белой краски в кадре нет. А карта согласования выделяет полосу вдоль мягкой границы тени.
Если внутри широкой тени справа почти ничего не подсвечено - это нормально. Основная нормализация освещения уже была раньше. Эта карта реагирует на остаточные перепады, а не закрашивает всю площадь тени.
И маски на двух панелях не обязаны дополнять друг друга. При расчёте α исключаются исходные защищённые области и пиксели, уже осветлённые при обработке разметки. Нижний предел по полной маске разметки накладывается позже.
Для тех, кто хочет рассмотреть детали, оставлю все этапы в полном разрешении:
Состояние изображения | Поперечная тень на фактурном асфальте | Широкая тень с мягким переходом |
|---|---|---|
RAW | ||
После коррекции камеры | ||
Яркостная основа | ||
Коррекция контраста + обработка разметки | ||
Выравнивание освещения до согласования границ | ||
Итог |
Третья панель на рисунках показывает яркостную основу до коррекции контраста и тональной обработки.
Как алгоритм выполняет расчёты на CPU
Для каждого кадра алгоритм оценивает освещение, считает локальную статистику и выполняет несколько веток коррекции. При этом соседние окна содержат общие пиксели, а ветки используют общие промежуточные результаты.
Ниже - три приёма, на которых устроены эти вычисления: обновление статистики окна, выборочный пересчёт блоков и проверка округления. Они позволяют переиспользовать готовые данные и сохранять точный целочисленный результат.
1. Окно сдвинулось. Зачем сортировать всё заново?
Для оценки локальной яркости нужны квантили - значения, ниже которых лежит заданная доля пикселей окна. Алгоритм находит их по гистограмме окна 16 × 16: 256 счётчиков показывают, сколько раз встретилась каждая яркость от 0 до 255.
При сдвиге на четыре пикселя 192 из 256 пикселей остаются прежними. Меняются только края:

Из гистограммы вычитаются пиксели ушедших столбцов и добавляются пиксели пришедших. Затем по накопленным счётчикам находятся квантили. Общая часть окна уже учтена - заново собирать её статистику не нужно.
2. Изменился участок. Зачем пересчитывать весь кадр?
В блоке коррекции контраста есть два близких расчёта: один использует найденное усиление, второй дополнительно ограничивает его по локальной дисперсии. После этого нужно вернуть средние яркости ячеек 8 × 8 - тот самый внутренний расчёт 11.
Ограничитель меняет усиление только на части изображения. Алгоритм отмечает затронутые ячейки и выполняет для них повторную проекцию средних. Там, где вход второй ветки совпадает с первой, используется уже готовый результат.

На схеме усиление изменилось в одном блоке, но пересчитать нужно девять: интерполяция поля поправки может затронуть соседей. Поэтому отмеченная область на сетке расширяется окном 3 × 3. Если затронутых блоков много, алгоритм выбирает полный проход по кадру.
3. Пиксель всё равно станет 80. Зачем уточнять дальше?
При возвращении средних ячеек нужно подобрать сдвиг яркости. Алгоритм сначала оценивает его приближённо и проверяет допустимый интервал решения. В PNG сохраняются целые значения, поэтому дальнейшее уточнение иногда уже ничего не меняет.
Допустим, после оценки границ известно: значение пикселя лежит между 80,10 и 80,30. Где бы оно ни оказалось внутри этого интервала, после округления получим 80. А вот интервал от 80,45 до 80,55 пересекает границу округления - здесь такой гарантии нет:

Проверка охватывает границы решения и округление всех свободных пикселей ячейки. Если целочисленный результат уже определён, алгоритм принимает его. Если хотя бы один пиксель остаётся под вопросом, выполняются 28 шагов двоичного поиска. Остановка разрешена только при гарантированном результате округления.
Остальные детали реализации: память и Numba
Общие данные считаются один раз. Статистики освещения и разметки используют общий проход по опорным кадрам. Промежуточные изображения передаются между ветками в памяти. Маска переиспользуется, только если входные данные не изменились.
Для разметки используются 64-уровневые гистограммы. Они участвуют в расчёте бюджета высветления. Эти циклы и часть целочисленных редукций компилируются Numba.
Затронутые блоки упаковываются перед пересчётом. Они собираются в массив высотой 8 пикселей, проходят модуль проекции, затем результат возвращается на места. Порядок суммирования float32 фиксирован, чтобы результат округления оставался воспроизводимым.
Промежуточная оценка зернистости хранится в uint8. Абсолютная разность пикселя и медианного окружения лежит в диапазоне 0-255. Разность и результат следующей медианной фильтрации сохраняют этот тип и порядковую статистику. Переход во float32 и умножение на 1.4826 происходят после фильтрации. На элемент промежуточного массива нужен один байт.
Ещё пара примеров

В первом примере большой перепад освещения стал слабее, но вертикальная структура в центре и справа всё ещё выражена. Во втором примере диагональная тень подавлена заметно лучше, при этом белые полосы выглядят более плоскими.
После обработки освещение становится ровнее, фактура заметнее, но часть границ остаётся. Насколько хорошо подавится тень, зависит от самой сцены.
Для нашей следующей задачи - обучения моделей поиска трещин и заплаток - такой подготовки уже хватает. Результаты поиска дефектов получаются хорошими: по сравнению с RAW прибавка к качеству существенная, хотя и не в разы.
Полное разрешение: первый пример - RAW и результат; второй пример - RAW и результат.
Где этот подход заканчивается
Пайплайн завязан на конкретную съёмку: монохромные 8-битные кадры, заданные размеры и профили коррекции. Другая камера или 16-битный сигнал потребуют пересмотра этих профилей и масштабов параметров.
Ещё нужна серия со светлыми опорными кадрами. Статистика собирается по папке, поэтому здесь показана пакетная обработка. Состав серии тоже имеет значение: поменяем опорные кадры - может поменяться результат для того же самого снимка.
Для меня ценность этого решения в том, что его можно разбирать до отдельных действий. Видно, откуда взялась целевая яркость, почему поднялся контраст, где закончилась поддержка окружения и какие пиксели защищает маска. Если результат не нравится, есть конкретный этап, который можно проверить.
Нейросети здесь достаётся следующая задача - искать трещины и заплатки на подготовленных кадрах. А для работы с освещением хватило локальной статистики, фильтров и ограничений на коррекцию. Так что запятая остаётся на своём месте: «Обучать нельзя, фильтровать».
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.