Сегментация без нейросетей
Введение
В этой статье мы рассмотрим конкретную задачу, связанную с проектом по созданию автоматизированной системы анализа аэроснимков полей, на которых выращивается подсолнечник. Главная цель проекта — оценка эффективности процесса посева.
Давайте разберем решение задачи сегментации и инстанс сегментации подсолнечника с помощью классических алгоритмов!
Задача сегментации не всегда требует использования нейронных сетей, поскольку классические методы обработки изображений также могут приводить к хорошим результатам. Однако для достижения высокого качества нужно учитывать следующий ряд условий:
- объект должен быть полностью виден на изображении;
- объект должен иметь монотонный цвет или уникальный цвет;
- объект должен иметь уникальную форму.
Сегментация изображений с помощью цветового пространства HSV
Начнём с задачи семантической сегментации, для решения которой нам нужно получить бинарную маску объектов на поле. Для начала давайте посмотрим на исходную картинку, представленную на рисунке 1. Мы видим, что поле и растения хорошо различимы по цвету, а значит можно попробовать их сегментировать по этому признаку. В таком случае цветовое пространство RGB будет не лучшим выбором для решения этой задачи, так как оно представляет цвет через три других (в отличие от HSV).
HSV — это цилиндрическое цветовое пространство, представляющее цвета на основе их оттенка, насыщенности и значения.
- Оттенок (Hue) — сам цвет;
- Насыщенность (Saturation) — изменение цветности от нейтрального (белого) к насыщенному;
- Значение (Value) — изменение яркости от черного цвета к насыщенному.
Преимущество использования цветового пространства HSV при сегментации объектов по цвету — наша возможность легко изолировать объекты определенного цвета независимо от их яркости и насыщенности. Например, для сегментации объектов зеленого цвета мы можем использовать диапазон значений оттенка, соответствующий этому цвету, вне зависимости от того, насколько ярким или насыщенным является зеленый цвет на изображении.
На рисунке 3 зеленый цвет представлен в диапазоне примерно от [40:80].
Попробуем воспользоваться цветовым пространством HSV и подобрать нужные значения для нашего изображения. Определить их диапазоны мы сможем по графикам распределений цветов в HSV пространстве.
В OpenCV канал Hue в цветовом пространстве HSV представлен в диапазоне от [0:179], а не от [0:359] — это специфика библиотеки.
Важно отметить:
На графике ниже мы можем увидеть два облака точек, которые на первый взгляд кажутся отдельными. Однако мы уже знаем, что в цветовом пространстве HSV компонент Hue представляет собой окружность (см. Рисунок 2). Следовательно, у нас есть только одно облако точек, разорванное на проекции.
Построение 3D графика и диаграммы HV для HSV пространства
import cv2
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colors
from mpl_toolkits.mplot3d import Axes3D
from typing import List, Tuple
img_path = 'data/img.JPG'
img = plt.imread(img_path)
# convert image to HSV color space
hsv_img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
# split the HSV channels
h, s, v = cv2.split(hsv_img)
# create the 3D scatter plot
fig = plt.figure(figsize=(16, 8))
ax1 = fig.add_subplot(1, 2, 1, projection="3d")
ax2 = fig.add_subplot(1, 2, 2)
# plot the image pixels in 3D
ax1.scatter(h.flatten(), s.flatten(), v.flatten(), facecolors=pixel_colors, marker=".", s=20)
ax1.set_xlabel("Hue")
ax1.set_ylabel("Saturation")
ax1.set_zlabel("Value")
ax1.set_title("Uniform")
# plot the hue and saturation components
ax2.scatter(h.flatten(), s.flatten(), s=20, c=pixel_colors, alpha=0.5)
ax2.set_xlabel("Hue")
ax2.set_ylabel("Saturation")
ax2.set_title("Hue vs Saturation")
ax2.invert_yaxis()
# save the plot as PNG image
plt.savefig("scatterplot.png", dpi=300)
# show the plot
plt.show()
У нас есть зеленая трава и фиолетовая земля. Нам необходимо правильно выбрать границы для отделения зеленой травы от фона.
Ориентируемся на диаграмму рассеяния Hue-Saturation и получаем следующий диапазон:
# convert image to HSV color space
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
#H - 0:30, S - 0:150, V - 0:255
lower1 = np.array([0, 0, 0])
upper1 = np.array([30, 150, 255])
mask1 = cv2.inRange(hsv, lower1, upper1) #первое облако точек
#H - 126:179, S - 0:50, V - 0:255
lower2 = np.array([126, 0, 0])
upper2 = np.array([179, 50, 255])
mask2 = cv2.inRange(hsv, lower2, upper2)#второе облако точек
Для подбора значений опираемся на график распределений (см. Рисунок 4) и результаты получаемых ниже масок.
Теперь объединим наши результаты:
mask = mask1 + mask2
plt.imshow(mask)
Итак, мы получили бинарную маску. Однако на ней присутствует шум, который мы хотим убрать. Для улучшения полученной маски рассмотрим некоторые методы постобработки.
Постобработка
В качестве постобработки можно использовать, например, методы морфологической обработки (такие как dilation и erosion), чтобы удалить нежелательные фрагменты и улучшить контуры объектов. Однако в нашем случае попробуем применить алгоритм поиска компонентов со статистикой.
Функция cv2.connectedComponentsWithStats — алгоритм поиска связанных компонентов на бинарном изображении. В процессе поиска каждому пикселю присваивается метка (label) с уникальным целым числом, представляющим связанный компонент, к которому этот пиксель принадлежит. После завершения поиска мы получаем целый набор разных статистик для каждой связанной компоненты: площадь, ограничивающие прямоугольники (bbox), центроиды и т.д.
def find_objects(binary_map: np.ndarray) -> np.ndarray:
"""Remove small connected components from a binary image."""
n_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary_map, 8, cv2.CV_32S)
# get areas of all components except the background (first label)
areas = stats[1:, cv2.CC_STAT_AREA]
# сreate a new binary image with only the components whose area is >= 100
result = np.zeros(labels.shape, np.uint8)
for i, area in enumerate(areas):
if area >= 100:
result[labels == i+1] = 255
return result
plt.imshow(find_objects(mask))
Так гораздо лучше! Задача сегментации растений решена. Теперь перейдем к инстанс сегментации.
Инстанс сегментация растений
Для решения задачи инстанс сегментации необходимо получить не только бинарную маску, выделяющую области объектов на изображении, но и уникальный идентификатор для каждого объекта. Таким образом, каждый объект на изображении должен быть выделен и идентифицирован. Это позволяет точно определить положение, границы и количество объектов.
Небольшая ремарка:
Для получения контуров в коде ниже можно было также воспользоваться cv2.connectedComponentsWithStats, но для простоты имплементации мы обратились к функции cv2.findContours.
Теперь мы найдем и нарисуем контуры на изображении.
Код
def get_contours(mask: np.ndarray) -> Tuple[List[np.ndarray], np.ndarray]:
"""Find contours with hierarchy."""
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
return contours, hierarchy
def get_random_color() -> Tuple[float, float, float]:
"""Generate a random RGB color tuple."""
return tuple(map(lambda x: x * 255, np.random.rand(3, )))
def draw_contours(mask: np.ndarray, contours: List[np.ndarray], hierarchy: np.ndarray) -> np.ndarray:
"""Draw all contours in an image, with different colors."""
result_img = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
k = -1
blobs = []
for i, cnt in enumerate(contours):
color = get_random_color()
# hier[Next, Previous, First_Child, Parent]
if hierarchy[0, i, 3] == -1:
k += 1
cv2.drawContours(result_img, [cnt], -1, color, 1)
blobs.append(cnt)
return result_img, blobs
clean_mask = find_objects(mask)
contours, hierarchy = get_contours(clean_mask)
result_img = draw_contours(clean_mask, contours, hierarchy)
Следующим шагом будет поиск и разбиение слипшихся растений (блобов) на отдельные инстансы. В результате мы сможем получить маску и центр для каждого растения.
Чтобы найти слипшиеся кусты, нам надо придумать алгоритм для оценки количества растений в каждом объекте. Будем считать, что наши подсолнечники имеют одинаковый размер. Поэтому количество растений в блобе оценим как:
\( n\_plants = \frac{blob\_area}{median\_plant\_area} \)
Заметим, что брать медиану корректнее, чем среднее, потому что внутри одного блоба может быть несколько растений. В таком случае если мы будем брать среднее, то получим явно завышенное значение для оценки размера одного растения.
Воспользуемся статистикой и некоторыми функциями OpenCV для получения всех слипшихся растений и примерного их количества в блобе.
Код получения слипшихся растений и их количества
def get_contours(mask: np.ndarray) -> Tuple[List[np.ndarray], np.ndarray]:
"""Find contours with hierarchy."""
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
return contours, hierarchy
def get_random_color() -> Tuple[float, float, float]:
"""Generate a random RGB color tuple."""
return tuple(map(lambda x: x * 255, np.random.rand(3, )))
def draw_contours(mask: np.ndarray, contours: List[np.ndarray], hierarchy: np.ndarray) -> np.ndarray:
"""Draw all contours in an image, with different colors."""
result_img = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
k = -1
blobs = []
for i, cnt in enumerate(contours):
color = get_random_color()
# hier[Next, Previous, First_Child, Parent]
if hierarchy[0, i, 3] == -1:
k += 1
cv2.drawContours(result_img, [cnt], -1, color, 1)
blobs.append(cnt)
return result_img, blobs
clean_mask = find_objects(mask)
contours, hierarchy = get_contours(clean_mask)
result_img = draw_contours(clean_mask, contours, hierarchy)
Отлично, мы оценили количество растений в каждом блобе, теперь надо придумать как их разделить. Тут мы вспоминаем специфику задачи: растения подсолнечника сеют на определенном расстоянии друг от друга и они имеют особую форму. Это в большинстве случаев позволяет точно разделить их друг от друга.
Визуализируем распределение суммы значений пикселей для каждого столбца изображения. На графике ниже можно наблюдать два четко выраженных пика.
Код
def get_contours(mask: np.ndarray) -> Tuple[List[np.ndarray], np.ndarray]:
"""Find contours with hierarchy."""
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
return contours, hierarchy
def get_random_color() -> Tuple[float, float, float]:
"""Generate a random RGB color tuple."""
return tuple(map(lambda x: x * 255, np.random.rand(3, )))
def draw_contours(mask: np.ndarray, contours: List[np.ndarray], hierarchy: np.ndarray) -> np.ndarray:
"""Draw all contours in an image, with different colors."""
result_img = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
k = -1
blobs = []
for i, cnt in enumerate(contours):
color = get_random_color()
# hier[Next, Previous, First_Child, Parent]
if hierarchy[0, i, 3] == -1:
k += 1
cv2.drawContours(result_img, [cnt], -1, color, 1)
blobs.append(cnt)
return result_img, blobs
clean_mask = find_objects(mask)
contours, hierarchy = get_contours(clean_mask)
result_img = draw_contours(clean_mask, contours, hierarchy)
Затем кластеризируем объекты на изображении.
Код
from sklearn.mixture import GaussianMixture
model = GaussianMixture(n_components=2, covariance_type='full')
model.fit(np.argwhere(grey == 255))
color = model.fit_predict(np.argwhere(grey == 255))
coord = np.argwhere(grey == 255)
plt.scatter(coord[:, 1], coord[:, 0], c = color)
plt.gca().invert_yaxis()
Итак, мы убедились на практике в том, что сегментация объектов без применения нейронных сетей является довольно эффективным подходом, особенно если эти объекты имеют различные цвета или оттенки.
Однако у такого подхода есть некоторые недостатки:
- подбор параметров;
- сложность выделения объектов с похожими цветами;
- отсутствие возможности учитывать текстуру и форму;
- проигрыш нейронным сетям по качеству сегментации объектов;
- сложный подбор оптимальных параметров в связи с постоянным изменением цвета в данных (оно связано с колебаниями освещения).
Несмотря на это, есть различные возможности использования рассмотренного в статье метода:
- возможность убрать фон с фото;
- трекинг объекта на видео;
- создание своего фото-редактора;
- фильтрация изображений;
- поиск изображений на основе цветовой схожести. Например, предположим, у вас есть база данных изображений цветов, и вы хотите найти изображения, подобные данному запросу. Вы можете вычислить цветовую гистограмму HSV для каждого изображения и сравнить их, используя метрику расстояния для поиска наиболее похожих изображений.
Ссылки
Для более подробного ознакомления с данной темой вы можете обратиться к следующим источникам:
https://docs.opencv.org/3.4/de/d25/imgproc_color_conversions.html
https://opencv-tutorial.readthedocs.io/en/latest/color/color.html