Простой фильтр для автоматического удаления фона с изображений

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

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



    Реализация стала возможной благодаря OpenCV и C# обертке OpenCVSharp.

    Общая схема


    Основная задача — сформировать альфа канал на основе входного изображения, оставив таким образом на нем только интересующий нас объект.

    1. Edge detection: Создаем основу для будущей маски, подействовав оператором вычисления градиента на исходное изображение.
    2. Заливка: выполняем заливку внешней области черным цветом.
    3. Очистка от шумов: убираем незалившиеся островки пикселей, сглаживаем границы.
    4. Финальный этап: Выполняем бинаризацию маски, немного размываем и получаем выходной альфа канал.

    Рассмотрим каждый пункт подробно на примере моей мышки с КДПВ. Полный код фильтра можно найти в репозитории.

    Предварительная подготовка


    Под спойлером приведен базовый класс фильтра, определяющий его интерфейс, от него будем наследоваться. Введен для удобства, особых пояснений не требует, сделан по образу и подобию BaseFilter из Accord .NET, другой весьма достойной .NET библиотеки для обработки изображений и не только.

    Отмечу только, что используемый здесь Mat — это универсальная сущность OpenCV, представляющая матрицу с элементами определенного типа (MatType) и с определенным количеством каналов. Например, матрица с элементами типа CV_8UС3 подходит для хранения изображений в формате RGB (BGR) по одному байту на цвет. А CV_32FC1 — для хранения одноканального изображения с float значениями.

    OpenCvFilter
    /// <summary>
    ///     Base class for custom OpenCV filters. More convenient than plain static methods.
    /// </summary>
    public abstract class OpenCvFilter
    {
        static OpenCvFilter()
        {
            Cv2.SetUseOptimized(true);
        }
    
        /// <summary>
        ///     Supported depth types of input array.
        /// </summary>
        public abstract IEnumerable<MatType> SupportedMatTypes { get; }
    
        /// <summary>
        ///     Applies filter to <see cref="src" /> and returns result.
        /// </summary>
        /// <param name="src">Source array.</param>
        /// <returns>Result of processing filter.</returns>
        public Mat Apply(Mat src)
        {
            var dst = new Mat();
            ApplyInPlace(src, dst);
    
            return dst;
        }
    
        /// <summary>
        ///     Applies filter to <see cref="src" /> and writes to <see cref="dst" />.
        /// </summary>
        /// <param name="src">Source array.</param>
        /// <param name="dst">Output array.</param>
        /// <exception cref="ArgumentException">Provided image does not meet the requirements.</exception>
        public void ApplyInPlace(Mat src, Mat dst)
        {
            if (!SupportedMatTypes.Contains(src.Type()))
                throw new ArgumentException("Depth type of provided Mat is not supported");
    
            ProcessFilter(src, dst);
        }
    
        /// <summary>
        ///     Actual filter.
        /// </summary>
        /// <param name="src">Source array.</param>
        /// <param name="dst">Output array.</param>
        protected abstract void ProcessFilter(Mat src, Mat dst);
    }
    


    Edge detection


    Основополагающий этап работы фильтра. В самом базовом варианте может быть реализован так:

    Как в туториалах
    /// <summary>
    ///     Performs edges detection. Result will be used as base for transparency mask.
    /// </summary>
    private Mat GetGradient(Mat src)
    {
        using (var preparedSrc = new Mat())
        {
            Cv2.CvtColor(src, preparedSrc, ColorConversionCodes.BGR2GRAY);
            preparedSrc.ConvertTo(preparedSrc, MatType.CV_32F, 1.0 / 255); // From 0..255 bytes to 0..1 floats
            
            using (var gradX = preparedSrc.Sobel(ddepth: MatType.CV_32F, xorder: 0, yorder: 1, ksize: 3, scale: 1 / 4.0))
            using (var gradY = preparedSrc.Sobel(ddepth: MatType.CV_32F, xorder: 1, yorder: 0, ksize: 3, scale: 1 / 4.0))
            {
                var result = new Mat();
                Cv2.Magnitude(gradX, gradY, result);
                
                return result;
            }
        }
    }
    


    Это типовой пример использования функции Sobel:

    1. Обесцветим изображение (смысла в вычислении градиента для всех трех каналов практически нет — результат в итоге будет очень мало отличаться).
    2. Рассчитаем вертикальную и горизонтальную составляющие.
    3. Вычислим итоговый результат с помощью функции Magnitude.

    Тут стоит обратить внимание на следующее:

    • Функции Sobel передан размер ядра (ksize) 3. Ядро такого размера используется чаще всего.
    • Также передан множитель нормализации 1/4. Нормализация требуется для получения чистой картинки с оптимальной яркостью и минимальной зашумленностью. Подробнее можно узнать в этом вопросе (ценность принятого ответа на который, возможно, превышает ценность всего данного поста).

    К сожалению, этот простой код подойдет не всегда. Проблема в том, что оператор Собеля resolution-dependent. Левая половина изображения снизу — это результат для изображения размером 1280x853. Правая — результат для исходной фотографии 5184x3456.



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

    Функция Sobel может принимать и другие размеры ядра. Но использовать ее все равно не получится по следующим причинам:

    • Ядра произвольных размеров внутри генерируются целочисленными и требуют нормализации, иначе диапазон полученных значений будет отличаться от 0..1 и работать с ними дальше будет затруднительно, изображение будет очень сильно зашумлено и пересвечено после применения magnitude.
    • Какие конкретно ядра были выбраны разработчиками OpenCV для размеров больше 5 — незадокументировано. Можно найти обсуждения ядер большего размера, но не все из них совпадают с тем, что используется в OpenCV.
    • Внутренние функции в deriv.cpp имеют булевый параметр normalize, но функия cv::sobel вызывает их с параметром false.

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

    Что получилось
    private Mat GetGradient(Mat src)
    {
        using (var preparedSrc = new Mat())
        {
            Cv2.CvtColor(src, preparedSrc, ColorConversionCodes.BGR2GRAY);
            preparedSrc.ConvertTo(preparedSrc, MatType.CV_32F, 1.0 / 255);
            
            // Calculate Sobel derivative with kernel size depending on image resolution
            Mat Derivative(Int32 dx, Int32 dy)
            {
                Int32 resolution = preparedSrc.Width * preparedSrc.Height;
                
                // Larger image --> larger kernel
                Int32 kernelSize =
                    resolution < 1280 * 1280 ? 3 :
                    resolution < 2000 * 2000 ? 5 :
                    resolution < 3000 * 3000 ? 9 :
                                               15;
                
                // Compensate lack of contrast on large images
                Single kernelFactor = kernelSize == 3 ? 1 : 2;
                using (var kernelRows = new Mat())
                using (var kernelColumns = new Mat())
                {
                    // Get normalized Sobel kernel of desired size
                    Cv2.GetDerivKernels(kernelRows, kernelColumns,
                        dx, dy, kernelSize,
                        normalize: true
                    );
                    
                    using (var multipliedKernelRows = kernelRows * kernelFactor)
                    using (var multipliedKernelColumns = kernelColumns * kernelFactor)
                    {
                        return preparedSrc.SepFilter2D(
                            MatType.CV_32FC1,
                            multipliedKernelRows,
                            multipliedKernelColumns
                        );
                    }
                }
            }
            
            using (var gradX = Derivative(1, 0))
            using (var gradY = Derivative(0, 1))
            {
                var result = new Mat();
                Cv2.Magnitude(gradX, gradY, result);
                
                //Add small constant so the flood fill will perform correctly
                result += 0.15f;
                return result;
            }
        }
    }
    


    Код несколько усложнился и без небольших подпорок не обошлось. Вместо использования Sobel, объявлена локальная функция Derivative, использующая GetDerivKernels для получения нормализованных ядер и SepFilter2D для их применения. Для изображений большего размера выбираются большие размеры ядра (GetDerivKernels поддерживает размеры вплоть до 31). Для того, чтобы результаты между разными размерами имели минимум отличий, уже нормализованные ядра больших размеров дополнительно умножаются на 2 (та самая подпорка).

    Посмотрим на результат:



    Картинка несколько «посерела» из-за добавленной константы в конце. Причина столь странного действия станет понятна на следующем шаге.

    Примечание
    Кроме оператора Собеля есть и другие, дающие чуть лучший результат. Например, в OpenCV из коробки доступен Scharr. Но только для Sobel есть встроенный генератор ядер произвольного размера, поэтому использовал его.

    Заливка


    Собственно, зальем максимально простым способом — от угла изображения. FloodFillRelativeSeedPoint — просто константа, определяющая относительный отступ от угла, а FloodFillTolerance — «жадность» заливки:

    FloodFill
    protected override void ProcessFilter(Mat src, Mat dst)
    {
        using (Mat alphaMask = GetGradient(src))
        {
            Cv2.FloodFill( // Flood fill outer space
                image: alphaMask,
                seedPoint: new Point(
                    (Int32) (FloodFillRelativeSeedPoint * src.Width),
                    (Int32) (FloodFillRelativeSeedPoint * src.Height)),
                newVal: new Scalar(0),
                rect: out Rect _,
                loDiff: new Scalar(FloodFillTolerance),
                upDiff: new Scalar(FloodFillTolerance),
                flags: FloodFillFlags.FixedRange | FloodFillFlags.Link4);
    
            ...
        }
    }
    


    И получим:



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



    Видно, что черный цвет «затек» через небольшой просвет туда, куда не стоило. Разумеется, можно попробовать понизить FloodFillTolerance (здесь 0.04), но в таком случае появляется больше ненужных нам кусков фона и шумов. И вот здесь пригодится еще один очень полезный вид операций над изображениями: морфологические преобразования. В документации есть отличный пример их действия, поэтому не буду повторяться. Добавим один проход дилатации перед заливкой, чтобы закрыть возможные бреши в контурах:

    Код
    protected override void ProcessFilter(Mat src, Mat dst)
    {
        using (Mat alphaMask = GetGradient(src))
        {
            // Performs morphology operation on alpha mask with resolution-dependent element size
            void PerformMorphologyEx(MorphTypes operation, Int32 iterations)
            {
                Double elementSize = Math.Sqrt(alphaMask.Width * alphaMask.Height) / 300;
                if (elementSize < 3)
                    elementSize = 3;
    
                if (elementSize > 20)
                    elementSize = 20;
                
                using (var se = Cv2.GetStructuringElement(
                    MorphShapes.Ellipse, new Size(elementSize, elementSize)))
                {
                    Cv2.MorphologyEx(alphaMask, alphaMask, operation, se, null, iterations);
                }
            }
            
            PerformMorphologyEx(MorphTypes.Dilate, 1); // Close small gaps in edges
            
            Cv2.FloodFill(...);
        }
    
        ...
    }
    


    Стало лучше:



    Локальная функция PerformMorphologyEx просто применяет заданную морфологическую операцию к изображению. При этом выбирается структурный элемент эллипсоидной формы (можно взять прямоугольный, но в таком случае появятся резкие прямые углы) с размером, зависимым от разрешения (для того, чтобы результаты оставались консистентными на разных размерах изображений). Формулу выбора размера можно еще покрутить, она была выбрана «на глаз».

    Очистка от шумов


    Здесь у нас идеальный полигон для применения morphological opening — за один-два прохода отлично удалятся все эти островки серых пикселей и даже остатки многих теней. Добавим такие три строчки после заливки:

    PerformMorphologyEx(MorphTypes.Erode, 1); // Compensate initial dilate
    PerformMorphologyEx(MorphTypes.Open,  2); // Remove not filled small spots (noise)
    PerformMorphologyEx(MorphTypes.Erode, 1); // Final erode to remove white fringes/halo around objects
    

    Сначала делаем эрозию для компенсации дилатации с предыдущего шага, после чего две итерации эрозии и дилатации (морфологического сужения и расширения соответственно). Пока получаем следующее:



    Третья строчка (проход эрозией) нужна для того, чтобы в конце избежать появления в результате

    такой обводки


    Финальный этап


    По большому счету маска уже готова. Добавим в конец фильтра:

    Следующий код
    Cv2.Threshold(
        src: alphaMask,
        dst: alphaMask,
        thresh: 0,
        maxval: 255,
        type: ThresholdTypes.Binary); // Everything non-filled becomes white
    
    alphaMask.ConvertTo(alphaMask, MatType.CV_8UC1, 255);
    
    if (MaskBlurFactor > 0)
        Cv2.GaussianBlur(alphaMask, alphaMask, new Size(MaskBlurFactor, MaskBlurFactor), MaskBlurFactor);
    
    AddAlphaChannel(src, dst, alphaMask);
    

    AddAlphaChannel просто добавляет альфа канал к входному изображению и записывает результат в выходное:

    /// <summary>
    ///     Adds transparency channel to source image and writes to output image.
    /// </summary>
    private static void AddAlphaChannel(Mat src, Mat dst, Mat alpha)
    {
        var bgr  = Cv2.Split(src);
        var bgra = new[] {bgr[0], bgr[1], bgr[2], alpha};
        Cv2.Merge(bgra, dst);
    }
    


    Вот и финальный результат



    Конечно, способ неидеальный. Самые ощутимые проблемы:

    • Если попытаться удалить фон у бублика или аналогичного объекта, то внутренняя область вырезана не будет (т.к. заливка не пройдет внутрь).
    • Тени. Частично побеждаются чувствительностью, частично удаляются вместе с шумом, но, зачастую, так или иначе попадают в финальный результат. Остается либо жить с ними, либо дополнительно реализовывать поиск и удаление теней.

    Тем не менее, для многих изображений результат оказывается приемлемым, может быть кому-нибудь этот способ пригодится (исходники). Моей целью было удаление фона с фотографий объектов, снятых с использованием таких поворотных столов.
    Поделиться публикацией
    Комментарии 22
      +1

      Четко, мб стоит выложить в paint.net плагины?

        +1

        Хм, не думал об этом. Плагины там могут тянуть зависимости? В данном случае придется вытянуть OpenCVSharp, который к тому же за собой тянет dll от самого OpenCV. Поизучаю вопрос, спасибо.

          0

          зависимости, если не тянутся сами по себе, можно попробовать впихнуть через костуру.

        0
        Было бы не плохо, ссылку на демку и исходники.
          0

          Ссылка на репозиторий сразу после ката. Можно просто собрать проект и запустить, рядом с image.jpg создастся out.png.

          –1
          это все игрушки…
            0
            как сделать не игрушки, опиши
              –3
              Делать ничего не надо, только скилл, ибо это элемент творчества.
              +
              Автоматизация нужна не в этой области. Я уже тут говорил кому-то, что творчество оптимизировать — потерять человечность. Но такие статьи все появляются и появляются. Бред…
              Даже если предположить что эти инструменты хотят претендовать на качество… просто вырезать изображение не достаточно, но для некоторых это почему-то секрет… Ну а если это не профессиональный инструмент — значит это игрушка.

              Правда промежуточные результаты интересны, даже интереснее финального
                0

                Эпоха, когда все делали серьезные профессионалы профессиональными инструментами прошла (это считая, что она когда-то была).


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

                  –1
                  Я как человек творчества, очень надеюсь что все мы будем ценить в будущем не только практичность.
                  И не спорю — все что вы сказали, безусловно, право и логично.
                    0
                    Я так понимаю, что автор решает задачу наподобие заполнения каталога магазина.

                    Грубо говоря, специально обученный человек (не фотограф же, фотография — процесс творческий) вкладывает в лайткуб товары и нажимает на спуск фотоаппарата. Получаются сотни фотографий разных товаров. Потому он запускает этот фильтр и получает изображения которые можно добавить в каталог магазина. Чисто механический процесс.

                    Конечно же, элитный магазин может нанять фотографа и художника, чтобы они творчески отсняли товары и отретушировали снимки. Но эту будет стоить совсем других денег, которые в конечном итоге должен будет заплатить покупатель. Да и захочет ли творческий человек удалять фон на сотнях фотографий компьютерных мышек?
                      0

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

            0
            Вроде, не по теме, но всегда интересовал вопрос. При съёмке на одноцветном фоне как удаляют цветные блики? Т.е. если мы будем снимать белый кубик на зелёном фоне, то неминуемо получим зелень на некоторых гранях кубика. Как с этим бороться? Или, наверное, ещё сложнее вариант с прозрачными и полупрозрачными объектами.
              0

              Меня он тоже интересовал, пока этот фильтр делал. Частично удалось это решить проходом эрозии по маске — это удаляет "гало" вокруг объекта, но так можно случайно обрезать часть контура.


              Как вариант — применить morphological gradient, разницу между дилитацией и эрозией (или повторно применить оператор Собеля к маске). Тогда получим контур объекта вроде такого


              image

              И попиксельно корректировать цвет изображения в этих точках. Я пытался так сделать, но не придумал нормального способа определять относительную позицию пикселя внутри контура (ближе к внутреннему краю, центру или внешнему?), чтобы корректировать их по-разному.

              0
              А что будет, если объект имеет «дырку» — например, ножницы лежат на фоне, в их кольцах удалится фон?
                0

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


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

                +1
                Подробнее можно узнать в этом вопросе (ценность принятого ответа на который, возможно, превышает ценность всего данного поста).

                Ссылка ведёт не туда, куда задумано.
                +2
                В том же OpenCV (не знаю как в его C# версии) есть отличная штука для отделения объекта от фона — GrabCut
                  0
                  не рабочее это, в смысле работает только с помощью ручной разметки
                  вот из доков:
                  image

                  те надо помогать отделять фон.
                    +1
                    Прошу прощения за поздний ответ, вполне рабочее, там есть вариант инициализации прямоугольником (ничто не мешает в качестве прямоугольника задать 0 0 w h), для простых фонов особенно хорошо справляется
                      0
                      те хромакей — legacy?

                      ru.wikipedia.org/wiki/%D0%A5%D1%80%D0%BE%D0%BC%D0%B0%D0%BA%D0%B5%D0%B9
                      С помощью хромакея можно «удалить» фон, и «подложить» изображение или видео через видеоредактор. Также в повседневной жизни хромакеем называют сам экран, на фоне которого снимают

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое