Назад
210

Сегментация без нейросетей

210

Введение

В этой статье мы рассмотрим конкретную задачу, связанную с проектом по созданию автоматизированной системы анализа аэроснимков полей, на которых выращивается подсолнечник. Главная цель проекта — оценка эффективности процесса посева.

Давайте разберем решение задачи сегментации и инстанс сегментации подсолнечника с помощью классических алгоритмов!

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

  • объект должен быть полностью виден на изображении;
  • объект должен иметь монотонный цвет или уникальный цвет;
  • объект должен иметь уникальную форму.
Рис.1. Исходное изображение

Сегментация изображений с помощью цветового пространства HSV

Начнём с задачи семантической сегментации, для решения которой нам нужно получить бинарную маску объектов на поле. Для начала давайте посмотрим на исходную картинку, представленную на рисунке 1. Мы видим, что поле и растения хорошо различимы по цвету, а значит можно попробовать их сегментировать по этому признаку. В таком случае цветовое пространство RGB будет не лучшим выбором для решения этой задачи, так как оно представляет цвет через три других (в отличие от HSV).

HSV — это цилиндрическое цветовое пространство, представляющее цвета на основе их оттенка, насыщенности и значения.

  • Оттенок (Hue) — сам цвет;
  • Насыщенность (Saturation) — изменение цветности от нейтрального (белого) к насыщенному;
  • Значение (Value) — изменение яркости от черного цвета к насыщенному.
Рис.2. Представление цветового пространства HSV. Источник: Wikipedia

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

На рисунке 3 зеленый цвет представлен в диапазоне примерно от [40:80].

Рис.3. Диапазон цветового пространства HSV по компаненту Hue [0:180)

Попробуем воспользоваться цветовым пространством 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()
Рис. 4. Слева: распределение цветов в HSV. Справа: диаграмма рассеяния Hue-Saturation

У нас есть зеленая трава и фиолетовая земля. Нам необходимо правильно выбрать границы для отделения зеленой травы от фона.

Ориентируемся на диаграмму рассеяния 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) и результаты получаемых ниже масок.

Рис. 5. Слева: маска 1 по левому облаку точек. Справа: маска 2 по правому облаку точек

Теперь объединим наши результаты:

mask = mask1 + mask2
plt.imshow(mask)
Рис. 6. Итоговая маска с помощью цветового пространства HSV

Итак, мы получили бинарную маску. Однако на ней присутствует шум, который мы хотим убрать. Для улучшения полученной маски рассмотрим некоторые методы постобработки.

Постобработка

В качестве постобработки можно использовать, например, методы морфологической обработки (такие как 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))
Рис. 7. Бинарная маска после удаления с нее шума

Так гораздо лучше! Задача сегментации растений решена. Теперь перейдем к инстанс сегментации.

Инстанс сегментация растений

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

Небольшая ремарка:

Для получения контуров в коде ниже можно было также воспользоваться 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)
Рис. 8. Отображение полученных контуров на маске

Следующим шагом будет поиск и разбиение слипшихся растений (блобов) на отдельные инстансы. В результате мы сможем получить маску и центр для каждого растения.

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

\( 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)
Рис. 9. Найденные слипшиеся объекты и их примерное количество

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

Визуализируем распределение суммы значений пикселей для каждого столбца изображения. На графике ниже можно наблюдать два четко выраженных пика.

Код


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)
Рис. 10.

Затем кластеризируем объекты на изображении.

Код
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()
Рис. 11. Разделенные на инстансы растения подсолнечника
Рис.12. Пример результата инстанс сегментации который можно получить данным способом

Итак, мы убедились на практике в том, что сегментация объектов без применения нейронных сетей является довольно эффективным подходом, особенно если эти объекты имеют различные цвета или оттенки.

Однако у такого подхода есть некоторые недостатки:

  • подбор параметров;
  • сложность выделения объектов с похожими цветами;
  • отсутствие возможности учитывать текстуру и форму;
  • проигрыш нейронным сетям по качеству сегментации объектов;
  • сложный подбор оптимальных параметров в связи с постоянным изменением цвета в данных (оно связано с колебаниями освещения).

Несмотря на это, есть различные возможности использования рассмотренного в статье метода:

  • возможность убрать фон с фото;
  • трекинг объекта на видео;
  • создание своего фото-редактора;
  • фильтрация изображений;
  • поиск изображений на основе цветовой схожести. Например, предположим, у вас есть база данных изображений цветов, и вы хотите найти изображения, подобные данному запросу. Вы можете вычислить цветовую гистограмму HSV для каждого изображения и сравнить их, используя метрику расстояния для поиска наиболее похожих изображений.

Ссылки

Для более подробного ознакомления с данной темой вы можете обратиться к следующим источникам:

https://docs.opencv.org/3.4/de/d25/imgproc_color_conversions.html

https://opencv-tutorial.readthedocs.io/en/latest/color/color.html

https://scikit-learn.org/stable/modules/mixture.html#gmm

Телеграм-канал

DeepSchool

Короткие посты по теории ML/DL, полезные
библиотеки и фреймворки, вопросы с собеседований
и советы, которые помогут в работе

Открыть Телеграм

Увидели ошибку?

Напишите нам в Telegram!