
Привет, Хабр! В предыдущей части мы рассматривали базовые методы цифровой обработки изображений для задачи сегментации спутникового снимка.
В этой статье рассмотрим ещё парочку методов решения этой задачи, всё ещё «классических», то есть без применения машинного обучения или нейросетей. Помогут нам во всём разобраться, как и в прошлый раз, язык программирования Julia и среда технических расчётов Engee!
Постановка задачи (для тех, кто прогулял первую лекцию)
У нас есть «тестовый сигнал», то есть небольшой кусочек спутниковой карты района Москвы, а конкретнее – парка Сокольники и прилегающих жилых районов.

Сам парк очень зелёный, в то время как жилые дома не очень. Мы хотим в автоматическом режиме отделять области лесополосы от застройки. В цифровой обработке изображений такая задача называется сегментацией.
У нас пример ознакомительный и достаточно простой, где мы собираемся отделять объекты на условном "переднем плане" (foreground) от объектов условного "фона" (background). Результатом наших трудов должна стать бинарная маска - матрица нулей и единиц, размером с исходное изображение, где элементы 1 соответствуют интересующим нас объектам, а элементы 0 - фону. Не так важно, что мы будем принимать за 1, а что за 0, лес или город, главное, чтобы мы правильно их разграничивали.
Какие же алгоритмы мы рассмотрим в этот раз? В первой части рассматривались:
· Изменение контраста
· Бинаризация
· Морфологические операции
· Линейные и нелинейные 2-D фильтры
Но очень мало внимания уделялось цветовой составляющей, а ведь снимок-то у нас цветной, и, казалось бы, самым логичным было бы отделять зелёные насаждения от серо-бело-чёрного города. Сперва копнём в этом направлении, а затем посмотрим, как цветовая информация может объединяться с «текстурой» областей изображения (например, границами, выделенными фильтром Собеля) в комплексном алгоритме сегментации, таком как Seeded Region Growing (SRG).
Кратко (прям совсем) о применяемых алгоритмах
Для начала поговорим о том, как мы можем сопоставить пиксель изображения какому-либо классу на основе его цвета. Как нам уже известно из предыдущей части, мы можем разложить цветное изображение на отдельные каналы интенсивности. Мы работаем в цветовом пространстве RGB, когда каждая из трёх матриц отвечает за яркость одного из трёх цветов. Но для задач обработки изображений есть и другие цветовые пространства, которые могут подходить для той или иной задачи лучше.

RGB - простое, но чувствительно к освещению.
HSV/HSL - отделяет цвет (Hue) от яркости (Value/Lightness), удобно для выделения объектов.
Lab - лучше учитывает человеческое восприятие цвета.
Про различия между цветовыми пространствами достаточно много открытой информации в сети, и мы для удобства продолжим работать в RGB. Плюс – в этом пространстве легко оценивать цветовое расстояние, самую простую метрику для кластеризации пикселей.
Цветовое расстояние — это числовая мера различия между двумя цветами в заданном цветовом пространстве (например, RGB, HSV, Lab). Оно используется для сравнения цветов, кластеризации, сегментации изображений и поиска схожих оттенков.
Евклидово расстояние в RGB вычисляется как геометрическое расстояние между точками в RGB-пространстве:
Определённый недостаток - RGB не учитывает восприятие цвета человеком (разные комбинации могут давать одинаковое расстояние, но визуально отличаться).

Простыми словами – мы можем задавать пороговые границы численных значений интенсивности в каналах RGB, и проверять условие попадания трёх значений в соответствующие пределы. Чем уже границы – тем ближе цвет должен быть к «эталонному», например, тёмно-зелёному для леса.
От цвета снова перейдём к «текстуре». Продолжим знакомится с нелинейными фильтрами.
Фильтр Собеля — это оператор для выделения границ на изображениях, основанный на вычислении градиента яркости в двух направлениях (по горизонтали и вертикали). Он широко используется в задачах компьютерного зрения, таких как детекция краёв, улучшение контуров и предобработка для других алгоритмов (например, преобразование Хафа).
Фильтр использует две матрицы 3×3:
-
Горизонтальное ядро (Gₓ) — выделяет вертикальные границы:
[ -1 0 1 ] [ -2 0 2 ] [ -1 0 1 ]
-
Вертикальное ядро (Gᵧ) — выделяет горизонтальные границы:
[ -1 -2 -1 ] [ 0 0 0 ] [ 1 2 1 ]
Изображение обрабатывается обоими ядрами отдельно.
Для каждого пикселя вычисляются градиенты по X (Gₓ) и Y (Gᵧ).
Результирующая оценка границы в точке (x, y):

Ну и одним из базовых методов объединения текстурных фильтров и цветового расстояния является SRG. Seeded Region Growing (SRG) — это алгоритм сегментации изображений, основанный на идее объединения пикселей в регионы, начиная с заранее заданных "зародышевых точек" (seed points). Он относится к методам сегментации на основе областей и широко применяется в компьютерном зрении, медицинской визуализации и обработке изображений.
Основные принципы работы:
Выбор начальных точек (seeds) Пользователь или автоматический алгоритм выбирает начальные точки (пиксели), которые принадлежат интересующим объектам или регионам.
Определение критерия схожести Обычно используется разница в интенсивности пикселей (например, порог по цвету или яркости). Также могут учитываться текстура, градиент или другие признаки.
Итеративное расширение регионов
На каждом шаге рассматриваются соседние пиксели, если пиксель удовлетворяет критерию схожести с регионом, он добавляется в него. Процесс продолжается, пока все подходящие пиксели не будут присоединены.
Преимущества:
Простота реализации.
Хорошо работает для изображений с чёткими границами и однородными регионами.
Позволяет контролировать процесс через выбор начальных точек.
Недостатки:
Зависимость от выбора начальных точек.
Чувствительность к шуму и неоднородностям.
Может сливать близкие регионы, если критерий схожести слишком слабый.
Существуют алгоритмы сегментации, не требующие начальных точек, например алгоритм «водораздела» (watershed). Попробуем рассмотреть его в последующих публикациях.
Подключаемые пакеты Julia для обработки изображений в Engee
Как упоминалось в предыдущей публикации, Julia прекрасно справляется с задачами как классической обработки изображений, так и с нейросетевыми алгоритмами. Основные подключаемые библиотеки, которые решают задачи сегментации:
Images.jl — аналог OpenCV + scikit-image, но с более удобным синтаксисом.
-
ImageSegmentation.jl — встроенные алгоритмы:
Водораздел (Watershed)
Seeded Region Growing
Графовые методы (Felzenszwalb, SLIC)
Адаптивные пороги
Clustering.jl — эффективные методы кластеризации (k-means, DBSCAN).
А ещё удобное комбинировать классические методы и ML: сегментация через k-means → уточнение глубинными сетями (Flux.jl), графовые алгоритмы + CRF (условные случайные поля).
Делаем вывод, что применяемый язык решает, когда требуются:
Большие данные (медицинские/спутниковые изображения).
Сложные алгоритмы (например, водораздел + кластеризация).
Гибкость (хочется свой метод, а не только OpenCV).
Скорость (Python тормозит, а C++ сложно).
Наслаждаться всеми преимуществами Julia мы традиционно будем в среде технических расчётов и динамического моделирования Engee, единственном российском аналоге MATLAB/Simulink для самого широкого спектра инженерных задач:

Регистрируйтесь по ссылке и получайте бесплатный доступ прямо сейчас – это позволит вам самим интерактивно поизучать описываемые в статьях примеры. Весь код тут:
Сегментация спутникового снимка, часть 1
Сегментация спутникового снимка, часть 2
Переходим в основной скрипт
Подключим необходимые библиотеки Julia для фильтрации, морфологии и сегментации:
using Images, ImageShow, ImageContrastAdjustment, ImageBinarization, ImageMorphology, ImageFiltering, ImageSegmentation
А вот и наше исходное изображение (то же самое, что и в первой части):
I = load("$(@__DIR__)/map_small.jpg")

Цветовая сегментация
Прицелимся на отделение лесного массива. Наша задача - выделить области, близкие по цвету к наблюдаемому на изображении оттенку зелёного. Мы будем бинаризовать отдельные каналы изображения, а доступ к ним мы получим, как и ранее, при помощи функции channelview
:
(h, w) = size(I);
CV = channelview(I);
[ RGB.(CV[1,:,:], 0.0, 0.0) RGB.(0.0, CV[2,:,:], 0.0) RGB.(0.0, 0.0, CV[3,:,:]) ]

Типичный "зелёный пиксель" находится, как можно предположить, в центре изображения. Выделим "центральный" пиксель, зная ширину и высоту изображения, и посмотрим на значения его интенсивности в цветовых каналах:
midh = Int(round(h/2));
midw = Int(round(w/2));
midpixel = CV[:,midh, midw]

Теперь, используя стандартные логические операции сравнения, бинаризуем каждый из каналов. В красном канале логической единице будут соответствовать пиксели с интенсивностью от 0.15 до 0.25, в зелёном - от 0.2 до 0.3, а в синем - от 0.05 до 0.15.
BIN_RED = (CV[1,:,:] .> 0.15) .& (CV[1,:,:] .< 0.25) ;
BIN_GREEN = (CV[2,:,:] .> 0.2) .& (CV[2,:,:] .< 0.3);
BIN_BLUE = (CV[3,:,:] .> 0.05) .& (CV[3,:,:] .< 0.15);

А теперь объединим три бинарные маски в одну операцией логического "И" - теперь белый пиксель будет только там, где в исходном цветном изображении все три проверяемых условия (диапазона) выполняются:
BIN = BIN_RED .& BIN_GREEN .& BIN_BLUE;

Сделать из этого пёстрого набора пикселей "ровную" маску нам вновь поможет морфология. В этот раз мы возьмём небольшой структурный элемент (7х7 пикселей) в форме ромба:
se = strel_diamond((7,7))

И оценим результат операции морфологического закрытия:
closeBW = closing(BIN,se);

Результирующий вид маски мы получим, удалив небольшие "блобы", а также осуществив операцию закрытия ещё раз, теперь уже для сглаживания границ основных "крупных" областей объединённых белых пикселей:
openBW = area_opening(closeBW; min_area=500) .> 0;
se2 = Kernel.gaussian(3) .> 0.0025;
MASK_colorseg = closing(openBW,se2);

Наложим инвертированную маску на исходное изображение знакомым способом:
sv_colorseg = StackedView(CV[1,:,:] + (.!MASK_colorseg./3), CV[2,:,:] +
(.!MASK_colorseg./3), CV[3,:,:]);
view_colorseg = colorview(RGB, sv_colorseg)

Фильтр Собеля
Применяемый нелинейный фильтр - это оператор, используемый в обработке изображений для обнаружения границ (краёв объектов) на основе вычисления градиента яркости в горизонтальном и вертикальном направлениях. Это позволит отделить "резкую" текстуру города от практически не изменяющегося по градиенту парка.
Временно забываем про цвет и будем работать с картой яркости, то есть с градациями серого:
imgray = Gray.(I)

# Ядра Собеля для осей X и Y
sobel_x = Kernel.sobel()[1] # Горизонтальный градиент (вертикальные границы)
sobel_y = Kernel.sobel()[2] # Вертикальный градиент (горизонтальные границы)
# Применение свертки
gradient_x = imfilter(imgray, sobel_x)
gradient_y = imfilter(imgray, sobel_y)
# Общий градиент (объединение X и Y)
gradient_magnitude = sqrt.(gradient_x.^2 + gradient_y.^2);
imsobel = gradient_magnitude ./ maximum(gradient_magnitude)

Бинаризуем результат фильтрации методом Отсу без дополнительных аргументов:
BW = binarize(imgray, Otsu());

И немного морфологической магии для получения результирующей маски:
se = Kernel.gaussian(3) .> 0.0035;
closeBW = closing(BW,se);
noobj = area_opening(closeBW; min_area=1000) .> 0;
se2 = Kernel.gaussian(5) .> 0.0027;
smooth = closing(noobj,se2);
smooth_2 = opening(smooth,se2);
MASK_sobel = area_opening(.!smooth_2; min_area=500) .> 0;

sv_sobel = StackedView(CV[1,:,:] + (.!MASK_sobel./3), CV[2,:,:] +
(.!MASK_sobel./3), CV[3,:,:]);
view_sobel = colorview(RGB, sv_sobel)

SRG-алгоритм
Мы помним из блока теории, что этот алгоритм требует указания начальных точек. Я примерно прицелюсь в области середины леса и левее, там где застройка. Хочется попасть в тёмно-зелёный и серый цвета. В качестве начальных выберем точки с координатами [200, 50]
и [200,300]
. Оценим визуально их цвета - от этого сильно зависит успешность работы алгоритма:
[ I[200,50] I[200,300] ]

Устанавливаем координаты начальных точек, и получаем сегменты (результат сегментации) функцией seeded_region_growing
, также передав ей исходное цветное изображение. Средние значения цвета внутри сегмента можно узнать функцией segments_mean
:
seeds = [(CartesianIndex(200,50),1), (CartesianIndex(200,300),2)]
segments = seeded_region_growing(I, seeds);
sm = segment_mean(segments);
[ sm[1] sm[2] ]

Вот такие усреднённые цвета у нас получились для двух классов. Посмотрим, как отработал алгоритм:
simshow(map(i->segment_mean(segments,i), labels_map(segments)))

Бинарную матрицу мы получим из матрицы лейблов. Нас интересуют пиксели с лейблом 1
:
lmap = labels_map(segments);
BW_smart = lmap .== 1;

Ну и немного простой морфологии:
se = Kernel.gaussian(2) .> 0.004;
closeBW = closing(BW_smart,se);
MASK_smart = area_opening(.!closeBW; min_area=500) .> 0;

sv_smart = StackedView(CV[1,:,:] + (.!MASK_smart./3), CV[2,:,:] +
(.!MASK_smart./3), CV[3,:,:]);
view_smart = colorview(RGB, sv_smart)

SRG-алгоритм, хоть и является относительно "умным", не относится к техниками машинного обучения. Это классический алгоритм, в котором нет этапов обучения и автоматического выделения признаков непосредственно из данных. Впрочем, его можно объединять с техниками машинного обучения, например, для автоматического выбора начальных точек.
Заключение
Во второй части мы немного углубились в сегментацию на основе цветового расстояния, познакомились с ещё одним фильтром, позволяющим выделить текстуру, а также на примере алгоритма SRG посмотрели, как цвет и текстуру можно объединять в рамках комплексного алгоритма сегментации.
Весь код, как и в прошлый раз, доступен по ссылке. А больше примеров из области обработки изображений - в Сообществе.
В последующих публикациях мы двинемся в сторону машинного обучения. Оставайтесь с нами!
Комментарии (8)
slupoke
25.06.2025 17:13Сделать из этого пёстрого набора пикселей "ровную" маску нам вновь поможет морфология. В этот раз мы возьмём небольшой структурный элемент (7х7 пикселей) в форме ромба:
Почему именно метрика Манхэттена использовалась для таких данных?
MaratUss Автор
25.06.2025 17:13Гм. Форма структурного элемента здесь, думаю, никак не связана с расстоянием городских кварталов. Обычно формы структурных элементов просто подбираются исходя из направления и форм выделяемых объектов. В нашем случае на карте это не так принципиально, ромб был взят просто из головы. До этого в основном использовались кружки (диски).
slupoke
25.06.2025 17:13Форма структурного элемента здесь, думаю, никак не связана с расстоянием городских кварталов.
Ромб это как раз и есть шар в метрике L1 (расстояние городских кварталов). В данном случае получается ромб радиусом 3 (Расстояние из центра до границ структурного элемента по метрике Манхэттена)
Licemery
25.06.2025 17:13Обожаю эту тему с фильтрами. Помню, лет 10 назад столько аутировал в Ависинте с масками, даже делал детекцию глаз тоже на основе их формы и того факта, что они по горизонтали более неравномерные (белок+радужка), чем по вертикали.
Spaceoddity
Как только название статьи прочитал - первая мысль «дык это, просто смотрите значение каналов a и b в Lab, меняя диапазон - варируем "похожесть" цвета».
Skykharkov
Да, "расстояние" в RGB тут вообще никак не поможет. Однажды "скрещивал" Google Maps и OSM. Ну если OSM еще хоть как-то, ну например, определить вода или нет по этим координатам, просто из "слоя воды" то в Google такого API нет. Задачка была простая - из определенной точки получить расстояние до ближайшей воды. Как ни извращался всегда то в лес упрешься, то в штриховку какую-то. Так с гуглом с цветами и не получилось. А разница, кстати, там существенная. Между OSM и Google - легко разница в метров пятьдесят может быть.
MaratUss Автор
Расстояние здесь не в смысле расстояния между объектами на карте. Мы в этой части вообще никак не привязывались к единицам измерения типа метров. Цветовое расстояние в RGB, о котором идёт речь - это чистая математика. Евклидово расстояние в трёхмерном пространстве, где по осям координат откладываются градиенты цветов.
Skykharkov
Да, я про то-же. Криво сформулировал. :) Я про то что пытался определить "разность" между цветами точек на карте. Для разных цветов "расстояние" может быть одинаковым. Даже не "расстояние", это то понятно. А "разность". В RGB на это никак ориентироваться нельзя. особенно если нужно определить "похожесть" цветов.