Хабрахабр

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

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

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

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

Общая схема

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

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

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

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

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

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

OpenCvFilter

/// <summary>
/// Base class for custom OpenCV filters. More convenient than plain static methods.
/// </summary>
public abstract class OpenCvFilter
/// <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. К сожалению, этот простой код подойдет не всегда. Правая — результат для исходной фотографии 5184x3456.

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

Но использовать ее все равно не получится по следующим причинам: Функция 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 для их применения. Код несколько усложнился и без небольших подпорок не обошлось. Для того, чтобы результаты между разными размерами имели минимум отличий, уже нормализованные ядра больших размеров дополнительно умножаются на 2 (та самая подпорка). Для изображений большего размера выбираются большие размеры ядра (GetDerivKernels поддерживает размеры вплоть до 31).

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

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

Примечание

Например, в 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);
}

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть