Бот для аркады. Часть №2: подключаем OpenCV

  • Tutorial

Введение

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

Для обработки изображений возьмем широкораспространенную библиотеку OpenCV. Она неродная (unmanaged) для .net, поэтому подключим ее через wrapper OpenCvSharp.

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


Затрагиваемые темы: выбор библиотеки для обработки изображений, выбор wrapper-а для работы с OpenCV, основные функции OpenCV, выделение движущихся объектов, цветовая модель HSV.



Почему OpenCV?

Для обработки изображений есть и другие хорошие библиотеки, на одной OpenCV свет клином не сошелся.

При программировании под .net также стоит обратить внимание на библиотеку Accord.Net (и ее более раннюю версию AForge.Net). Эти две библиотеки также бесплатные, но родные(managed) для .net-платформы, в отличии от OpenCV.

При разработке продукта на продажу, а не «for fun» — я скорее остановился бы на managed-библиотеках. Выбор хороших managed-библиотек не ухудшает производительность, но здорово упрощает развертывание, переносимость и последующее сопровождение. Но в стартапах и при разработке «for fun» важнее большое community, чем будущее упрощение сопровождения. И это возвращает нас к библиотеке OpenCV, у которой намного более широкая известность.

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

OpenCV целиком поддерживает данный тезис. По ней есть книжка: Learning OpenCV (честно говоря, еще не читал, но собираюсь исправить это в ближайщем времени), есть ее перевод на русский locv.ru (сейчас у меня не открывается), есть online-документация, есть куча вопросов с ответами на stackoverflow.

Всё это наем дает быстрый старт, обеспечивая подход «Пли! Готовсь! Целься» (когда изучение библиотеки идет по ходу работы) вместо классического «Готовсь! Целься! Пли!» (когда сначала значительное время уходит на предварительное ознакомление с устройством библиотеки).

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

Примеры решаемых вопросов:
— выделить R-компоненту из изображения — запрос в гугл: google: opencv get single channel, и первая же ссылка говорит, что это делается с помощью функции Split
— найти различия между изображениями: google: opencv difference images, и пример из первого же ответа говорит, что это делает функция absdiff. Если же при поиске использовать слово compare вместо difference, то гугл начнет показывать совсем другие страницы, и это даст более общие ответы с рекомендацией использовать сравнение гистограмм и т.д. Это показывает важность выбора ключевых слов при поиске ответа на свой вопрос.

Выбираем .net-wrapper для OpenCV

Библиотеку выбрали, осталось подружить ее с C#-ом. Эта задача уже решена до нас, и нам опять остается только сделать выбор между имеющимися вариантами. Распространенных wrapper-а два: Emgu Cv и OpenCvSharp. Emgu Cv более старая и более кондовая, OpenCvSharp более современная. Выбор остановился на OpenCvSharp, подкупили слова автора о том, что поддерживается IDisposable. Это означает, что автор не просто 1 в 1 перенес структуры и функции из C/C++ в C#, но и допилил их напильником, чтобы их было удобнее использовать в C#-стиле написания кода.

Подключаем OpenCvSharp к проекту
Подключение OpenCvSharp к проекту делается стандартным способом, без каких-то особых закавык. Есть небольшой tutorial от автора, также есть возможность подключения OpenCvSharp через nuget.

Основные базовые функции

OpenCV имеет множество функции для работы с изображениями. Остановимся только на основных базовых функциях, которые используются при решении задачи выделения объектов из изображения. OpenCV имеет два варианта использования: C-стиль и C++-стиль. Для упрощения кода будем использовать C++-стиль (вернее его аналог через OpenCvSharp).

Основных класса два: Mat и Cv2. Оба находятся в namespace-е OpenCvSharp.CPlusPlus. Mat — это само изображение, а Cv2 — это набор действий над изображениями.
Функции:
//загрузка изображения
var mat = new Mat("test.bmp");

//сохранение изображения
mat.ImWrite("out.bmp");

//преобразование в bitmap
var bmp = mat.ToBitmap();

//преобразование из bitmap (требуется подключение OpenCvSharp.Extensions)
var mat2 = new Mat(bmp.ToIplImage(), true); 

//показ изображения
using (new Window("изображение", mat))
{
   Cv2.WaitKey();
}

//преобразование цветового пространства (например, из цветного в градации серого)
Cv2.CvtColor(mat, dstMat, ColorConversion.RgbToGray);

//разделение изображения на отдельные цветовые каналы
Cv2.Split(mat, out mat_channels)

//сборка многоцветного изображения из отдельных каналов
Cv2.Merge(mat_channels, mat)

//разница между двумя изображениями
Cv2.Absdiff(mat1, mat2, dstMat);

//приведение точек, которые темнее/светлее определенного уровня(50) к черному(0) или белому цвету(255)
Cv2.Threshold(mat, dstMat, 50, 255, OpenCvSharp.ThresholdType.Binary);

//рисование примитивов
mat.Circle(x, y, radius, new Scalar(b, g, r));
mat.Line(x1, y1, x2, y2, new CvScalar(b, g, r));
mat.Rectangle(new Rect(x, y, width, height), new Scalar(b, g, r));
mat.Rectangle(new Rect(x, y, width, height), new Scalar(b, g, r), -1); //закрашенный прямоугольник
mat.PutText("test", new OpenCvSharp.CPlusPlus.Point(x, y), FontFace.HersheySimplex, 2, new Scalar(b, g, r))


Также в OpenCV есть специальные функции для выделения объектов(Structural Analysis and Shape Descriptors, Motion Analysis and Object Tracking, Feature Detection, Object Detection), но наскоком выжать из них полезный результат не получилось (надо, наверное, все-таки книжку почитать), поэтому оставим их на потом.

Выделение объектов

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


Выделение объектов сильно затрудняет их «полосатость». Вот, например, как реагирует функция Canny, выделяющая контуры объектов.


Использование отдельных цветовых компонент жизнь лучше не делает.



Выделение движущихся объектов

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

Формирование серии изображении
Для формирования серии изображений добавим небольшой код в нашего бота. Бот будет вести историю последних кадров, и по нажатию на пробел сбрасывать их на диск.
      var history = new List<Bitmap>();
      for (var tick = 0; ;tick++)
      {
          var bmp = GetScreenImage(gameScreenRect);
          history.Insert(0, bmp);
          const int maxHistoryLength = 10;
          if (history.Count > maxHistoryLength)
            history.RemoveRange(maxHistoryLength, history.Count - maxHistoryLength);

          if (Console.KeyAvailable)
          {
            var keyInfo = Console.ReadKey();
            if (keyInfo.Key == ConsoleKey.Spacebar)
            {
              for (var i = 0; i < history.Count; ++i)
                history[i].Save(string.Format("{0}.png", i));
            }
            [..]
          }
          [..]
      }


Запускаем, жмем и вуаля — у нас на руках есть два кадра.


Сравнение соседних
Вычитаем одно изображение из другого… и что называется «смешались в кучу кони, люди». Шары превратились во что-то «странное» (это хорошо видно, на полноразмерном фрагменте), но это ладно, главное что шары получилось отделить от фона.


Сравнение со специально подготовленным фоном
Сравнение соседних кадров хорошо подходит для «отдельно стоящих» движущихся объектов. Если же необходимо выделить плотно движущиеся объекты, то лучше работает сравнение со специально подготовленным статичным фоном.
Подготавливаем такой фон:

Сравниваем:


Много лучше, но всё портят тени. От теней я пытался избавиться множеством способов, но самый лучший эффект дала мысль, что тень, по сути, есть изменение яркости, а это уже меня навело на мысль о цветовой модели HSV.

HSV

Цветовая модель HSV, также как и RGB, состоит из трех каналов. Но в отличии от него (и того же CMYK) — это не просто смешение цветов.
— Первый канал, H(Hue) — цветовой тон. В первом приближении, это номер цвета из радуги.
— Второй канал, S(Saturation) — насыщенность. Чем меньше значение этого канала, тем цвет ближе к серому, чем больше — тем более цвет выражен. Цвета с высокой насыщенностью известны под разговорным названием — «кислотные».
— Третий канал, V(Value) — яркость. Это самый простый для понимания канал, чем больше освещенность, тем выше значение по данному каналу.
Картинка справа показывает взаимосвязь каналов и цветов между собой. По кругу идет радуга — это канал H. Треугольник для конкретного цвета (сейчас это красный) показывает изменение канала S — насыщенности (направление в верх-право) и изменение канала V — яркости (в верх-лево). Классически, значения канала H лежат в диапазоне 0-360, S — 0-100, V — 0-100. В OpenCV значения всех каналов приведены к диапазону 0-255 для того, чтобы по-максимуму использовать размерность одного байта.

Цветовая модель RGB близка к человеческому глазу, к тому как он устроен. Цветовая модель HSV близка к тому, как цвет воспринимается мозгом. Ниже я специально привел серию изображений того, что будет, если каждый канал поменять на плюс/минус 50 попугаев. На них видно, что даже после изменения каналов S и V на 100 единиц (а это половина диапазона) изображение воспринимается почти как тоже самое, а вот даже небольшое изменение канала H сильно меняет восприятие, делая изображение «наркоманским». Это связано с тем, что мозг за долгие годы эволюции научился отделять более стабильные данные от менее стабильных.

Что значит «стабильные»? Это та часть информации, которая меньше всего меняется от каких-то внешних условий. Возьмем реальный предмет, например, однотонный мячик. Он обладает каким-то собственным цветом, но восприятие этого цвета будет меняться в зависимости от внешних условий: освещенности, прозрачности воздуха, отсвета соседних объектов и т.д. Соответственно, если стоит задача выделять мячик из окружающего мира вне зависимости от внешних условий, то необходимо больше ориентироваться на ту часть информации, которая слабо меняется от внешних условий, и игнорировать ту часть, что меняется сильнее всего. Наименее стабильной является яркость (канал V): переместились в тень и яркость окружающего мира поменялась, небо затянуло тучами — и яркость опять поменялась. Насыщенность (канал S) также меняется в течении дня, точнее меняется восприятие цвета — чем меньше освещенность, тем больше дают вклад колбочки (черно-белое зрение) и меньше поступает информации от палочек (цветное зрение). Цветовой тон (канал H) меняется слабее всего и наиболее стабильно отражает цвет объекта.







Сравнение с фоном в hsv-пространстве
Повторяем вычитание из статичного фона, но теперь уже после преобразования в пространство hsv, и О! Чудо! В каналах H и S шарики четко отделены от теней, все тени почти целиком ушли в канал V. В H-канале даже пропадает «изрезанность» шариков, но, к сожалению, желтые шары начинают сливаются с фоном. В S-канале изрезанность остается, но зато все шары хорошо видны, и перевод в двухцветное изображение (с обрезанием «мусора» меньше значения 25) дает четкие круги и убирает всё лишнее.







Резюме
Поставленная на сегодня цель достигнута (шары отделены от фона и от своих теней), и со спокойной душой можно идти спать.

PS
Все приведенные изображения сформированы с помощью использования OpenCV (код под катом).
Скрытый текст
      var resizeK = 0.2;

      var dir = "Example/";

      var src = new Mat("0.bmp");
      var src_g = new Mat("0.bmp", LoadMode.GrayScale);

      var src_1 = new Mat("1.bmp");
      var src_1_g = new Mat("1.bmp", LoadMode.GrayScale);

      var background = new Mat("background.bmp");
      var background_g = new Mat("background.bmp", LoadMode.GrayScale);

      src.Resize(resizeK).ImWrite(dir + "0.png");
      src_g.Resize(resizeK).ImWrite(dir + "0 g.png");
      src_g.ThresholdStairs().Resize(resizeK).ImWrite(dir + "0 g th.png");

      var canny = new Mat();
      Cv2.Canny(src_g, canny, 50, 200);
      canny.Resize(0.5).ImWrite(dir + "0 canny.png");

      Mat[] src_channels;
      Cv2.Split(src, out src_channels);

      for (var i = 0; i < src_channels.Length; ++i)
      {
        var channels = Enumerable.Range(0, src_channels.Length).Select(j => new Mat(src_channels[0].Rows, src_channels[0].Cols, src_channels[0].Type())).ToArray();
        channels[i] = src_channels[i];
        var dst = new Mat();
        Cv2.Merge(channels, dst);
        dst.Resize(resizeK).ImWrite(dir + string.Format("0 ch{0}.png", i));
        src_channels[i].ThresholdStairs().Resize(resizeK).ImWrite(dir + string.Format("0 ch{0} th.png", i));
      }

      if (true)
      {
        src.Resize(0.4).ImWrite(dir + "0.png");
        src_1.Resize(0.4).ImWrite(dir + "1.png");
        background.Resize(0.4).ImWrite(dir + "bg.png");

        var dst_01 = new Mat();
        Cv2.Absdiff(src, src_1, dst_01);
        dst_01.Resize(resizeK).ImWrite(dir + "01.png");
        dst_01.Cut(new Rect(50, src.Height * 4 / 5 - 50, src.Width / 5, src.Height / 5)).ImWrite(dir + "01 part.png");
        dst_01.Cut(new Rect(50, src.Height * 4 / 5 - 50, src.Width / 5, src.Height / 5)).CvtColor(ColorConversion.RgbToGray).ImWrite(dir + "01 g.png");
        dst_01.CvtColor(ColorConversion.RgbToGray).ThresholdStairs().Resize(resizeK).ImWrite(dir + "01 g th.png");

        var dst_01_g = new Mat();
        Cv2.Absdiff(src_g, src_1_g, dst_01_g);
        dst_01_g.Cut(new Rect(50, src.Height * 4 / 5 - 50, src.Width / 5, src.Height / 5)).ImWrite(dir + "0g1g.png");
        dst_01_g.ThresholdStairs().Resize(resizeK).ImWrite(dir + "0g1g th.png");
      }

      if (true)
      {
        var dst_0b = new Mat();
        Cv2.Absdiff(src, background, dst_0b);
        dst_0b.Resize(0.6).ImWrite(dir + "0b.png");

        var dst_0b_g = new Mat();
        Cv2.Absdiff(src_g, background_g, dst_0b_g);
        dst_0b_g.Resize(0.3).ImWrite(dir + "0b g.png");
        dst_0b_g.ThresholdStairs().Resize(0.3).ImWrite(dir + "0b g th.png");
      }
      if (true)
      {
        var hsv_src = new Mat();
        Cv2.CvtColor(src, hsv_src, ColorConversion.RgbToHsv);


        var hsv_background = new Mat();
        Cv2.CvtColor(background, hsv_background, ColorConversion.RgbToHsv);

        var hsv_background_channels = hsv_background.Split();

        var hsv_src_channels = hsv_src.Split();

        if (true)
        {
          var all = new Mat(src.ToIplImage(), true);
          for (var i = 0; i < hsv_src_channels.Length; ++i)
          {
            hsv_src_channels[i].CvtColor(ColorConversion.GrayToRgb).CopyTo(all, new Rect(i * src.Width / 3, src.Height / 2, src.Width / 3, src.Height / 2));
          }
          src_g.CvtColor(ColorConversion.GrayToRgb).CopyTo(all, new Rect(src.Width / 2, 0, src.Width / 2, src.Height / 2));
          all.Resize(0.3).ImWrite(dir + "all.png");
        }

        foreach (var pair in new[] { "h", "s", "v" }.Select((channel, index) => new { channel, index }))
        {
          var diff = new Mat();
          Cv2.Absdiff(hsv_src_channels[pair.index], hsv_background_channels[pair.index], diff);
          diff.Resize(0.3).With_Title(pair.channel).ImWrite(dir + string.Format("0b {0}.png", pair.channel));
          diff.ThresholdStairs().Resize(0.3).ImWrite(dir + string.Format("0b {0} th.png", pair.channel));

          hsv_src_channels[pair.index].Resize(resizeK).With_Title(pair.channel).ImWrite(dir + string.Format("0 {0}.png", pair.channel));

          foreach (var d in new[] { -100, -50, 50, 100 })
          {
            var delta = new Mat(hsv_src_channels[pair.index].ToIplImage(), true);
            delta.Rectangle(new Rect(0, 0, delta.Width, delta.Height), new Scalar(Math.Abs(d)), -1);

            var new_channel = new Mat();
            if (d >= 0)
              Cv2.Add(hsv_src_channels[pair.index], delta, new_channel);
            else
              Cv2.Subtract(hsv_src_channels[pair.index], delta, new_channel);

            var new_hsv = new Mat();
            Cv2.Merge(hsv_src_channels.Select((channel, index) => index == pair.index ? new_channel : channel).ToArray(), new_hsv);

            var res = new Mat();
            Cv2.CvtColor(new_hsv, res, ColorConversion.HsvToRgb);
            res.Resize(resizeK).With_Title(string.Format("{0} {1:+#;-#}", pair.channel, d)).ImWrite(dir + string.Format("0 {0}{1}.png", pair.channel, d));
          }
        }

      }

  static class OpenCvHlp
  {
    public static Scalar ToScalar(this Color color)
    {
      return new Scalar(color.B, color.G, color.R);
    }
    public static void CopyTo(this Mat src, Mat dst, Rect rect)
    {
      var mask = new Mat(src.Rows, src.Cols, MatType.CV_8UC1);
      mask.Rectangle(rect, new Scalar(255), -1);
      src.CopyTo(dst, mask);
    }
    public static Mat Absdiff(this Mat src, Mat src2)
    {
      var dst = new Mat();
      Cv2.Absdiff(src, src2, dst);
      return dst;
    }
    public static Mat CvtColor(this Mat src, ColorConversion code)
    {
      var dst = new Mat();
      Cv2.CvtColor(src, dst, code);
      return dst;
    }
    public static Mat Threshold(this Mat src, double thresh, double maxval, ThresholdType type)
    {
      var dst = new Mat();
      Cv2.Threshold(src, dst, thresh, maxval, type);
      return dst;
    }

    public static Mat ThresholdStairs(this Mat src)
    {
      var dst = new Mat(src.Rows, src.Cols, src.Type());

      var partCount = 10;
      var partWidth = src.Width / partCount;

      for (var i = 0; i < partCount; ++i)
      {
        var th_mat = new Mat();
        Cv2.Threshold(src, th_mat, 255 / 10 * (i + 1), 255, ThresholdType.Binary);
        th_mat.Rectangle(new Rect(0, 0, partWidth * i, src.Height), new Scalar(0), -1);
        th_mat.Rectangle(new Rect(partWidth * (i + 1), 0, src.Width - partWidth * (i + 1), src.Height), new Scalar(0), -1);

        Cv2.Add(dst, th_mat, dst);
      }
      var color_dst = new Mat();
      Cv2.CvtColor(dst, color_dst, ColorConversion.GrayToRgb);
      for (var i = 0; i < partCount; ++i)
      {
        color_dst.Line(partWidth * i, 0, partWidth * i, src.Height, new CvScalar(50, 200, 50), thickness: 2);
      }
      return color_dst;
    }
    public static Mat With_Title(this Mat mat, string text)
    {
      var res = new Mat(mat.ToIplImage(), true);
      res.Rectangle(new Rect(res.Width / 2 - 10, 30, 20 + text.Length * 15, 25), new Scalar(0), -1);
      res.PutText(text, new OpenCvSharp.CPlusPlus.Point(res.Width / 2, 50), FontFace.HersheyComplex, 0.7, new Scalar(150, 200, 150));
      return res;
    }
    public static Mat Resize(this Mat src, double k)
    {
      var dst = new Mat();
      Cv2.Resize(src, dst, new OpenCvSharp.CPlusPlus.Size((int)(src.Width * k), (int)(src.Height * k)));
      return dst;
    }
    public static Mat Cut(this Mat src, Rect rect)
    {
      return new Mat(src, rect);
    }
    public static Mat[] Split(this Mat hsv_background)
    {
      Mat[] hsv_background_channels;
      Cv2.Split(hsv_background, out hsv_background_channels);
      return hsv_background_channels;
    }

  }



Бот для DirectX-аркады. Часть №1: устанавливаем контакт
Бот для аркады. Часть №2: подключаем OpenCV
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 11

    –4
    Прошу меня простить, но использование для этих целей Open CV подозрительно напоминает мне процесс забивания гвоздей микроскопом.
      0
      Что тогда стоит использовать, например, для преобразования в то же HSV-пространство?
        +5
        Да какая разница, если это интересно и дает возможность освоить основы компьютерного зрения на довольно простом примере?
          0
          Спасибо. На следующей итерации обязательно попробую
          0
          Поиск по шаблонам 2х2-5х5 работает отлично для таких задач. Конечно, GetPixel() тут не прокатит, но немного unmanaged кода и можно добиться почти мгновенного поиска.
            0
            Подход интересный, спасибо. Смущает, что предварительно необходимо подготовить все варианты отображения объектов (и шары, и лягушка — крутятся вокруг своей оси), а если это дело автоматизировать, то возвращаемся к исходной задаче — предварительному выделению объектов.
            Собираюсь попробовать этот метод в будущем, если будут проблемы с производительностью.
            0
            Для увеличения точности рекомендуется предварительно размывать (или как-нибудь ещё) изображение, чтобы избавится от мелких деталей, портящих результат.

            Фон можно не вычитать, а применять как маску: если пиксель сильно отличается от фона — выводить пиксель, если примерно равен фону — выводить 0.
              0
              Идею с маской понял, спасибо. Обязательно попробую.
              0
              Дополнительные пути
              — Можно анализировать уменьшенное изображение
              — Можно разбить изображение на квадраты, вычислить средний цвет пикселей. Cравнивать полученные значения на близость к нужным цветам. Если попадает в нужный диапазон — искомая область. Заполнить массив в соответствии с этими значениями. После чего можно выделить значения, которые будут центрами шаров.
                0
                Оба эти способа я как раз хотел попробовать в ближайшее время )

              Only users with full accounts can post comments. Log in, please.