Статьи

Определение особенностей в изображениях с помощью кластерного анализа

Что я научился делать?

За последние пару недель я узнал о двух наиболее популярных алгоритмах кластеризации данных: K-Means Clustering и Density Based Clustering (слабо соответствует DBSCAN ). В этой статье я покажу свою собственную реализацию DBSCAN и то, как я использовал ее для разделения изображений на отдельные файлы, содержащие различные функции из исходного изображения. Вот быстрый пример, чтобы лучше объяснить: Мой алгоритм определил все пустое пространство в логотипе Cheezburger и разделил его на новый файл изображения. Это выглядит так:


Перед:
1image

После (серые области теперь прозрачны):
0image

Он также извлек текст:
3image
0image

Следует отметить, что я просто буду обсуждать наивную реализацию алгоритма.
Чтобы обработать больший лимит НАВСЕГДА Я также буду обсуждать, почему это так, и некоторые абстрактные мысли о том, как это можно улучшить.
 
Почему я учу это сейчас?

Основная цель этого упражнения — не научиться выполнять обработку изображений и не научиться писать лучший или самый быстрый алгоритм кластеризации.
Смысл этого эксперимента состоял в том, чтобы узнать достаточно о кластеризации, чтобы я мог написать свою собственную наивную реализацию. Одно дело сказать, что я что-то знаю, и совсем другое — знать достаточно, чтобы уметь это создавать.
 
Что такое кластеризация данных?

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

Они уже разделили данные на две группы для нас.
Это также отличный пример того, почему можно использовать алгоритм кластеризации данных на основе плотности, а не алгоритм k-средних. 

K-means лучше всего работает для определения круговых группировок, подобных синей группе выше.
K-means также очень прост в создании. Самым большим недостатком этого алгоритма является то, что красный кластер на приведенном выше графике будет разделен на несколько групп, потому что K-means не может объяснить длинную изогнутую форму. Алгоритм кластеризации на основе плотности, однако, может изящно обрабатывать оба, но становится немного сложнее, так как алгоритм по существу становится проблемой обхода графа, которая, учитывая большое количество точек, препятствует тому, чтобы более простые рекурсивные решения прохождения графа могли быть отслеживаемыми.
 
Как я применил все это к изображениям

Поскольку я узнал больше об анализе данных, я продолжаю повторять высказывание, которое я слышал ранее: «Изображения являются отличным источником данных для машинного обучения».
В этом случае я решил использовать изображения в качестве источника многомерных данных, используя каждый пиксель в качестве «ImagePoint» в 6-мерном пространстве. Красный, зеленый и синий были первыми тремя измерениями. Последние три были значением альфа-прозрачности, а также координатами X и Y пикселя на изображении.

Моя главная гипотеза состояла в том, что я должен видеть изображения, разделенные цветами, в некоторой степени основанные на их относительном визуальном расположении.
Приведенный выше логотип Cheezburger — прекрасный пример моих ожиданий.
 
Шаг за шагом через код

Перед тем, как мы углубимся в код, необходимо помнить о трех концепциях, которые являются ключевыми для того, как я реализовал DBSCAN:
  1. Наименьший кластер — минимальное количество точек, необходимое для рассмотрения группы точек кластера.
  2. Cluster Distance — Максимальное расстояние между двумя точками, чтобы они были «плотными», чтобы их можно было рассмотреть для включения в кластер.

Имена, использованные выше, не являются именами по умолчанию, а моими собственными из кода.
 
Шаг 1. Загрузите все пиксели в память

Для начала я загружаю все пиксели изображения в память и преобразовываю их в объекты ImagePoint, которые я обсуждал ранее.
Ради этого разговора давайте просто установим, что вызов функции выглядит следующим образом:
1
var
image_bounds
=
GetImagePointsFromFile
(
filename_of_image_to_load
,
image_points
);

Я возвращаю размеры изображения, чтобы после записи данных изображения каждого кластера в отдельные файлы я мог расположить изображения в тех же местах, в которых они находились бы в оригинале, чтобы понять, как это работает.
 
Шаг 2. Кластер!

Этот шаг не так тривиален, как предыдущий.
Сначала я напишу псевдо-код, а затем покажу вам исходный код.
  1. Возьмите каждую непосещенную точку изображения в пространстве исходного изображения
  2. Отметьте точку как посещенную, а затем найдите все соседние точки
  3. Соседние точки определяются путем вычисления расстояния между данной точкой и всеми остальными точками и сохранения только тех из них, которые находятся в пределах расстояния кластера.
  4. Тогда, если у нас будет больше соседних точек, чем размер самого маленького кластера, мы обнаружим, что у нас новый кластер!
  5. Создайте новый кластер и добавьте его в наш список кластеров, а затем добавьте указанную точку изображения в новый кластер.
  6. Теперь мы можем расширить наш недавно созданный кластер!
  7. Отсюда мы начинаем исследовать каждую точку изображения, с которой наша данная точка изображения плотно связана. В основном, пришло время проследить за сухарями до конца дороги.
  8. Теперь давайте перейдем ко всем соседним точкам, которые мы еще не посещали, и добавим их в наш список «связанных точек изображения, которые необходимо исследовать».
  9. Используя этот список, мы будем отслеживать точки, с которых мы начали, а также любые новые, которые необходимо изучить. Когда мы найдем точки, которые соответствуют нашим критериям, мы добавим их в наш текущий кластер.
  10. Мы также должны пометить эти точки как поставленные в очередь для посещения, чтобы они не добавлялись в наш список несколько раз и тратили время.
  11. Теперь, пока у нас еще есть соединенные точки изображения, которые нужно исследовать
  12. Возьмите один из них, и если он еще не посещен, давайте начнем наш визит!
  13. Начните визит, отметив точку изображения как посещенную
  14. Добавьте эту точку в наш кластер
  15. Найдите все соседние точки изображения для этой точки изображения, которая сама является соседней точкой изображения для точки изображения, которую мы получили на шаге 1! Сложно много?
  16. Добавьте все эти новейшие соседние точки изображения в наш список «связанных точек изображения, которые необходимо исследовать», ЕСЛИ:

    1. Они еще не посещались
    2. Они еще не стоят в очереди для посещения
    3. Их больше, чем наименьший размер кластера.
  17. Поскольку мы добавляем точки изображения в список «связанных точек изображения, которые необходимо исследовать», пометьте их как «Очередь для посещения».

Есть несколько ключевых упрощающих предположений, которые я делаю.
Во-первых, точку нужно посетить только один раз. Кроме того, если одна точка в нашем внешнем цикле идентифицирует новый кластер, этот кластер будет полностью встроен во внутренний цикл. Это означает, что нам никогда не нужно проверять, была ли точка уже добавлена ​​в кластер.

Вот определение класса ImagePoint:
 
public class ImagePoint
{
    public bool Visited;
    public int X;
    public int Y;
    public byte R;
    public byte G;
    public byte B;
    public byte A;

    public bool AlreadyFoundCandidates;
    public bool QueuedForVisit;

    public double DistanceTo(ImagePoint other_point)
    {
        return Math.Sqrt(
                Math.Pow(other_point.R - this.R, 2)
            + Math.Pow(other_point.G - this.G, 2)
            + Math.Pow(other_point.B - this.B, 2)
            + Math.Pow(other_point.A - this.A, 2)
            + Math.Pow(other_point.X - this.X, 2)
            + Math.Pow(other_point.Y - this.Y, 2));
    }
}

Особенно важна функция расстояния.
Если бы я хотел, то можно полностью изменить алгоритм, просто добавив или удалив факторы, которые я хочу включить в вычисление расстояния. Одна вещь, которую я мог бы удалить, например, это координаты X и Y, а затем я бы выбрал кластеры пикселей, которые имеют плавно переходящие цвета (вроде).
 

Во всяком случае, теперь фактический код, который использует это:
 
private static IEnumerable<DensityCluster> GetImageClusters(List<ImagePoint> original_image_space, double cluster_distance, int smallest_cluster)
{
    var clusters = new List<DensityCluster>();
    foreach (var image_point in original_image_space.Where(x => !x.Visited))
    {
        Console.WriteLine("Finding cluster for image point @ ({0}, {1})", image_point.X, image_point.Y);
        image_point.Visited = true;
        var neighboring_points = FindNeighboringPointsFor(original_image_space, image_point, cluster_distance);
        if (neighboring_points.Count >= smallest_cluster)
        {
            var cluster = new DensityCluster();
            clusters.Add(cluster);

            cluster.Add(image_point);

            Console.WriteLine("Added to cluster... searching around @ ({0}, {1})", image_point.X, image_point.Y);

            ExpandCluster(cluster, original_image_space, neighboring_points, cluster_distance, smallest_cluster);
        }
    }

    clusters = clusters.Where(x => x.Points.Count >= smallest_cluster).ToList();
    return clusters;
}

private static void ExpandCluster(DensityCluster cluster,
                                    IEnumerable<ImagePoint> original_image_space,
                                    IEnumerable<ImagePoint> neighboring_points,
                                    double cluster_distance,
                                    int smallest_cluster)
{
    foreach (var neighboring_point in neighboring_points)
        neighboring_point.QueuedForVisit = true;

    var connected_image_points_to_be_examined = new Stack<ImagePoint>(neighboring_points.Where(x => !x.Visited));
    while (connected_image_points_to_be_examined.Count > 0)
    {
        var connected_image_point_to_be_examined = connected_image_points_to_be_examined.Pop();
        if (!connected_image_point_to_be_examined.Visited)
        {
            connected_image_point_to_be_examined.Visited = true;
            cluster.Add(connected_image_point_to_be_examined);
            var distant_neighbor_image_points = FindNeighboringPointsFor(original_image_space,
                                                                            connected_image_point_to_be_examined,
                                                                            cluster_distance);
            if (distant_neighbor_image_points.Count >= smallest_cluster)
            {
                foreach (
                    var distant_neighbor_image_point in
                        distant_neighbor_image_points.Where(x => !x.Visited && !x.QueuedForVisit))
                {
                    distant_neighbor_image_point.QueuedForVisit = true;
                    connected_image_points_to_be_examined.Push(distant_neighbor_image_point);
                }
            }
        }
    }
}

public static IList<ImagePoint> FindNeighboringPointsFor(IEnumerable<ImagePoint> image_points, ImagePoint image_point, double cluster_distance)
{
    return image_points.Where(x => x.DistanceTo(image_point) <= cluster_distance).ToArray();
} 
Шаг 3. Создайте образ для каждого кластера.

Это так же просто, как перебрать все наши кластеры и записать каждый содержащийся в нем пиксель в растровое изображение на диске.
C # делает это довольно просто (одна из причин, по которой я этого не делал в Ruby). Это не значит, что я не смог бы сделать это в Ruby, просто не сразу было понятно, как это сделать.
 
public static void CreateComponentImages(IEnumerable<DensityCluster> clusters, int image_width, int image_height, string filepath_to_save_component_images_to)
{
    var counter = 0;
    foreach (var cluster in clusters)
    {
        Console.WriteLine("Saving cluster {0} to file", counter);
        using (var image = new Bitmap(image_width, image_height))
        {
            foreach (var image_point in cluster.Points)
            {
                image.SetPixel(image_point.X, image_point.Y,
                                Color.FromArgb(image_point.A, image_point.R, image_point.G, image_point.B));
            }

            image.Save(
                filepath_to_save_component_images_to + "_group_" + counter + ".bmp");
        }
        counter++;
    }
}
Что можно сделать лучше?

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

image_points.Where (x => x.DistanceTo (image_point) <= cluster_distance) .ToArray ()

Там, где находится узкое место (на самом деле в выражении Where). Если у кого-то есть какие-то конкретные советы о том, как я могу кэшировать эти данные, чтобы уменьшить время выполнения, я весь в ушах. (РЕДАКТИРОВАТЬ: очевидно, что использование KD-Tree значительно помогает при поиске ближайшего соседа, как этот.

Окончательные результаты!

В качестве финального теста я запустил алгоритм для этого мема:

2image

 

И вот несколько примеров изображений, на которые оно было разбито:

Кусочек круговой диаграммы

1image

 

Водяной знак мема:

Образ

Вот и все! Спасибо за чтение!