Хочу поделиться способом, основанном на создании маски прозрачности с помощью оператора Собеля и некоторых других преобразований. Основная идея совершенно не нова, но применение некоторых дополнительных техник в правильном порядке позволило улучшить результаты, о чем и будет эта заметка.
Реализация стала возможной благодаря OpenCV и C# обертке OpenCVSharp.
Общая схема
Основная задача — сформировать альфа канал на основе входного изображения, оставив таким образом на нем только интересующий нас объект.
- Edge detection: Создаем основу для будущей маски, подействовав оператором вычисления градиента на исходное изображение.
- Заливка: выполняем заливку внешней области черным цветом.
- Очистка от шумов: убираем незалившиеся островки пикселей, сглаживаем границы.
- Финальный этап: Выполняем бинаризацию маски, немного размываем и получаем выходной альфа канал.
Рассмотрим каждый пункт подробно на примере моей мышки с КДПВ. Полный код фильтра можно найти в репозитории.
Предварительная подготовка
Под спойлером приведен базовый класс фильтра, определяющий его интерфейс, от него будем наследоваться. Введен для удобства, особых пояснений не требует, сделан по образу и подобию BaseFilter из Accord .NET, другой весьма достойной .NET библиотеки для обработки изображений и не только.
Отмечу только, что используемый здесь Mat — это универсальная сущность OpenCV, представляющая матрицу с элементами определенного типа (MatType) и с определенным количеством каналов. Например, матрица с элементами типа CV_8UС3 подходит для хранения изображений в формате RGB (BGR) по одному байту на цвет. А CV_32FC1 — для хранения одноканального изображения с float значениями.
/// <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:
- Обесцветим изображение (смысла в вычислении градиента для всех трех каналов практически нет — результат в итоге будет очень мало отличаться).
- Рассчитаем вертикальную и горизонтальную составляющие.
- Вычислим итоговый результат с помощью функции 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 (та самая подпорка).
Посмотрим на результат:
Картинка несколько «посерела» из-за добавленной константы в конце. Причина столь странного действия станет понятна на следующем шаге.
Заливка
Собственно, зальем максимально простым способом — от угла изображения. FloodFillRelativeSeedPoint — просто константа, определяющая относительный отступ от угла, а FloodFillTolerance — «жадность» заливки:
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);
}
Вот и финальный результат
Конечно, способ неидеальный. Самые ощутимые проблемы:
- Если попытаться удалить фон у бублика или аналогичного объекта, то внутренняя область вырезана не будет (т.к. заливка не пройдет внутрь).
- Тени. Частично побеждаются чувствительностью, частично удаляются вместе с шумом, но, зачастую, так или иначе попадают в финальный результат. Остается либо жить с ними, либо дополнительно реализовывать поиск и удаление теней.
Тем не менее, для многих изображений результат оказывается приемлемым, может быть кому-нибудь этот способ пригодится (исходники).
Комментарии (20)
opendannyy
22.04.2018 23:09-1это все игрушки…
sofcom
23.04.2018 11:43как сделать не игрушки, опиши
opendannyy
23.04.2018 12:19Делать ничего не надо, только скилл, ибо это элемент творчества.
+Автоматизация нужна не в этой области. Я уже тут говорил кому-то, что творчество оптимизировать — потерять человечность. Но такие статьи все появляются и появляются. Бред…
Даже если предположить что эти инструменты хотят претендовать на качество… просто вырезать изображение не достаточно, но для некоторых это почему-то секрет… Ну а если это не профессиональный инструмент — значит это игрушка.Azoh
23.04.2018 13:37Эпоха, когда все делали серьезные профессионалы профессиональными инструментами прошла (это считая, что она когда-то была).
К тому же, ценность мифической "человечности" — вопрос философский, а возможность быстро и без особых познаний решить вопрос простой обработки изображения (или нескольких сотен/тысяч) имеет вполне практическую ценность. Качество бывает не всегда важно, а творческая составляющая может отсутствовать в принципе.
opendannyy
23.04.2018 13:50Я как человек творчества, очень надеюсь что все мы будем ценить в будущем не только практичность.
И не спорю — все что вы сказали, безусловно, право и логично.lorc
23.04.2018 16:43Я так понимаю, что автор решает задачу наподобие заполнения каталога магазина.
Грубо говоря, специально обученный человек (не фотограф же, фотография — процесс творческий) вкладывает в лайткуб товары и нажимает на спуск фотоаппарата. Получаются сотни фотографий разных товаров. Потому он запускает этот фильтр и получает изображения которые можно добавить в каталог магазина. Чисто механический процесс.
Конечно же, элитный магазин может нанять фотографа и художника, чтобы они творчески отсняли товары и отретушировали снимки. Но эту будет стоить совсем других денег, которые в конечном итоге должен будет заплатить покупатель. Да и захочет ли творческий человек удалять фон на сотнях фотографий компьютерных мышек?ArXen42
23.04.2018 17:02Да, в моем случае смысл именно в быстрой автоматической съемке объектов. Творчество в каком-то виде может разве что иметь место в плане подгонки освещения и прочей подготовке к фотографии. В удалении фона уж точно творчества минимум, поэтому пытаюсь вот таким образом этот этап автоматизировать, хотя бы самую скучную и монотонную его часть.
kvazimoda24
23.04.2018 10:56Вроде, не по теме, но всегда интересовал вопрос. При съёмке на одноцветном фоне как удаляют цветные блики? Т.е. если мы будем снимать белый кубик на зелёном фоне, то неминуемо получим зелень на некоторых гранях кубика. Как с этим бороться? Или, наверное, ещё сложнее вариант с прозрачными и полупрозрачными объектами.
ArXen42
23.04.2018 12:04Меня он тоже интересовал, пока этот фильтр делал. Частично удалось это решить проходом эрозии по маске — это удаляет "гало" вокруг объекта, но так можно случайно обрезать часть контура.
Как вариант — применить morphological gradient, разницу между дилитацией и эрозией (или повторно применить оператор Собеля к маске). Тогда получим контур объекта вроде такого
imageserafims
23.04.2018 11:23А что будет, если объект имеет «дырку» — например, ножницы лежат на фоне, в их кольцах удалится фон?
ArXen42
23.04.2018 11:50Нет, не удалится. Это, конечно, проблема. Как ее решить без участия пользователя (задающего точку начала заливки вместо угла изображения) — не придумал. Так как фильтр простой и не имеет представления о высокоуровневых понятиях вроде "объект" или "фон", то автоматически отличить дырку в ножницах от просто однородной поверхности объекта довольно затруднительно.
Можно попробовать сделать дополнительный анализ исходного изображения на предмет монотонных белых областей и сделать заливку и в них, но есть вероятность ошибочных срабатываний.
VioletGiraffe
23.04.2018 11:27+1Подробнее можно узнать в этом вопросе (ценность принятого ответа на который, возможно, превышает ценность всего данного поста).
Ссылка ведёт не туда, куда задумано.
Kwent
23.04.2018 12:19В том же OpenCV (не знаю как в его C# версии) есть отличная штука для отделения объекта от фона — GrabCut
salisbury-espinosa
23.04.2018 15:52не рабочее это, в смысле работает только с помощью ручной разметки
вот из доков:
те надо помогать отделять фон.
AgentFire
Четко, мб стоит выложить в paint.net плагины?
ArXen42
Хм, не думал об этом. Плагины там могут тянуть зависимости? В данном случае придется вытянуть OpenCVSharp, который к тому же за собой тянет dll от самого OpenCV. Поизучаю вопрос, спасибо.
AgentFire
зависимости, если не тянутся сами по себе, можно попробовать впихнуть через костуру.