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

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

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

qyaugjh84pei6weajkflho6sdag.png

Реализация стала возможной благодаря 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
/// 
///     Base class for custom OpenCV filters. More convenient than plain static methods.
/// 
public abstract class OpenCvFilter
{
    static OpenCvFilter()
    {
        Cv2.SetUseOptimized(true);
    }

    /// 
    ///     Supported depth types of input array.
    /// 
    public abstract IEnumerable SupportedMatTypes { get; }

    /// 
    ///     Applies filter to  and returns result.
    /// 
    /// Source array.
    /// Result of processing filter.
    public Mat Apply(Mat src)
    {
        var dst = new Mat();
        ApplyInPlace(src, dst);

        return dst;
    }

    /// 
    ///     Applies filter to  and writes to .
    /// 
    /// Source array.
    /// Output array.
    /// Provided image does not meet the requirements.
    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);
    }

    /// 
    ///     Actual filter.
    /// 
    /// Source array.
    /// Output array.
    protected abstract void ProcessFilter(Mat src, Mat dst);
}

Edge detection


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

Как в туториалах
/// 
///     Performs edges detection. Result will be used as base for transparency mask.
/// 
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. Ядро такого размера используется чаще всего.
  • Также передан множитель нормализации ¼. Нормализация требуется для получения чистой картинки с оптимальной яркостью и минимальной зашумленностью. Подробнее можно узнать в этом вопросе (ценность принятого ответа на который, возможно, превышает ценность всего данного поста).

К сожалению, этот простой код подойдет не всегда. Проблема в том, что оператор Собеля resolution-dependent. Левая половина изображения снизу — это результат для изображения размером 1280×853. Правая — результат для исходной фотографии 5184×3456.
uezfqmu6hbfrpwszkbomrb2bkhw.png
Линии краев объектов стали значительно менее выраженными, так как, при том же размере ядра, пиксельные расстояния между одними и теми же точками изображения стали в несколько раз больше. Для менее удачных фотографий (объект хуже отделим от фона) важные детали могут и вовсе пропасть.

Функция 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 (та самая подпорка).

Посмотрим на результат:
4u2eupryihsb0igx5r-ms7stz2i.png

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

Примечание
Кроме оператора Собеля есть и другие, дающие чуть лучший результат. Например, в 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);

        ...
    }
}


И получим:
bfo7ze7yub1jzwkt_-x44lkpi0m.png

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

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

    ...
}

Стало лучше:
duixtr-wowrfnms8-7yg6lnurku.png

Локальная функция 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

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

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

такой обводки
akfi2lgnpn2zb9ia7wbftytfdnc.png

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


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

Следующий код
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 просто добавляет альфа канал к входному изображению и записывает результат в выходное:

/// 
///     Adds transparency channel to source image and writes to output image.
/// 
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);
}

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

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

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

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

© Habrahabr.ru