Как стать автором
Обновить

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

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

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



Реализация стала возможной благодаря 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
Комментарии 22
Комментарии Комментарии 22

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн