Надежная локально-адаптивная мягкая бинаризация! Так я это называю.
Раньше я делал подобные вещи для немного другой цели, так что это может не идеально соответствовать вашим потребностям, но надеюсь, что это поможет (также я написал этот код ночью для личного использования, поэтому он уродлив). В некотором смысле этот код был предназначен для решения более общего случая по сравнению с вашим, когда мы можем иметь много структурированного шума на фоне (см. Демонстрацию ниже).
Что делает этот код? На фотографии листа бумаги он становится белее, чтобы его можно было безупречно распечатать. См. Примеры изображений ниже.
Тизер: так ваши страницы будут выглядеть после этого алгоритма (до и после). Обратите внимание, что даже аннотации цветных маркеров исчезли, поэтому я не знаю, подойдет ли это вашему варианту использования, но код может быть полезен:
Чтобы получить идеально чистые результаты, вам, возможно, придется немного поиграть с параметрами фильтрации, но, как видите, даже с параметрами по умолчанию она работает достаточно хорошо.
Шаг 0: Вырежьте изображения так, чтобы они соответствовали странице
Предположим, вы каким-то образом выполнили этот шаг (похоже, что в приведенных вами примерах). Если вам нужен ручной инструмент для создания аннотаций и исправлений, просто напишите мне в личку! ^^ Результаты этого шага приведены ниже (примеры, которые я использую здесь, возможно, сложнее, чем тот, который вы предоставили, хотя он может не совсем соответствовать вашему случаю):
Отсюда сразу видны следующие проблемы:
- Неравномерное освещение. Это означает, что все простые методы бинаризации не работают. Я перепробовал множество решений, доступных в
OpenCV
, а также их комбинации, ни одно из них не сработало!
- Сильный фоновый шум. В моем случае мне нужно было удалить сетку бумаги, а также чернила с другой стороны бумаги, которая видна через тонкий лист.
Шаг 1: Гамма-коррекция
Смысл этого шага в том, чтобы сбалансировать контраст всего изображения (поскольку ваше изображение может быть немного переэкспонировано / недоэкспонировано в зависимости от условий освещения).
Сначала это может показаться ненужным шагом, но его важность нельзя недооценивать: в некотором смысле он нормализует изображения к аналогичному распределению экспозиций, так что вы можете выбрать значимые гиперпараметры позже (например, параметр DELTA
в следующий раздел, параметры фильтрации шума, параметры морфологических материалов и т. д.)
# Somehow I found the value of `gamma=1.2` to be the best in my case
def adjust_gamma(image, gamma=1.2):
# build a lookup table mapping the pixel values [0, 255] to
# their adjusted gamma values
invGamma = 1.0 / gamma
table = np.array([((i / 255.0) ** invGamma) * 255
for i in np.arange(0, 256)]).astype("uint8")
# apply gamma correction using the lookup table
return cv2.LUT(image, table)
Вот результаты настройки гаммы:
Как видите, теперь он немного более ... "сбалансирован". Без этого шага все параметры, которые вы выберете вручную на последующих шагах, станут менее надежными!
Шаг 2. Адаптивная бинаризация для обнаружения текстовых блобов
На этом этапе мы адаптивно преобразуем текстовые капли в двоичную форму. Позже я добавлю больше комментариев, но основная идея заключается в следующем:
- Разделяем изображение на блоки размером
BLOCK_SIZE
. Хитрость заключается в том, чтобы выбрать его размер достаточно большим, чтобы вы по-прежнему получали большой кусок текста и фона (то есть больше, чем любые символы, которые у вас есть), но достаточно маленький, чтобы не страдать от каких-либо изменений условий освещения (например, «большой, но все же местный").
- Внутри каждого блока мы выполняем локально адаптивную бинаризацию: мы смотрим на медианное значение и предполагаем, что это фон (потому что мы выбрали
BLOCK_SIZE
достаточно большим, чтобы большая часть его была фоном). Затем мы дополнительно определяем DELTA
, по сути, просто порог «как далеко от медианы мы будем рассматривать его как фон?».
Итак, функция process_image
выполняет свою работу. Кроме того, вы можете изменить функции preprocess
и postprocess
в соответствии с вашими потребностями (однако, как видно из приведенного выше примера, алгоритм довольно надежен, т.е. box без особого изменения параметров).
Код этой части предполагает, что передний план темнее фона (т.е. чернила на бумаге). Но вы можете легко изменить это, настроив функцию preprocess
: вместо 255 - image
верните только image
.
# These are probably the only important parameters in the
# whole pipeline (steps 0 through 3).
BLOCK_SIZE = 40
DELTA = 25
# Do the necessary noise cleaning and other stuffs.
# I just do a simple blurring here but you can optionally
# add more stuffs.
def preprocess(image):
image = cv2.medianBlur(image, 3)
return 255 - image
# Again, this step is fully optional and you can even keep
# the body empty. I just did some opening. The algorithm is
# pretty robust, so this stuff won't affect much.
def postprocess(image):
kernel = np.ones((3,3), np.uint8)
image = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel)
return image
# Just a helper function that generates box coordinates
def get_block_index(image_shape, yx, block_size):
y = np.arange(max(0, yx[0]-block_size), min(image_shape[0], yx[0]+block_size))
x = np.arange(max(0, yx[1]-block_size), min(image_shape[1], yx[1]+block_size))
return np.meshgrid(y, x)
# Here is where the trick begins. We perform binarization from the
# median value locally (the img_in is actually a slice of the image).
# Here, following assumptions are held:
# 1. The majority of pixels in the slice is background
# 2. The median value of the intensity histogram probably
# belongs to the background. We allow a soft margin DELTA
# to account for any irregularities.
# 3. We need to keep everything other than the background.
#
# We also do simple morphological operations here. It was just
# something that I empirically found to be "useful", but I assume
# this is pretty robust across different datasets.
def adaptive_median_threshold(img_in):
med = np.median(img_in)
img_out = np.zeros_like(img_in)
img_out[img_in - med < DELTA] = 255
kernel = np.ones((3,3),np.uint8)
img_out = 255 - cv2.dilate(255 - img_out,kernel,iterations = 2)
return img_out
# This function just divides the image into local regions (blocks),
# and perform the `adaptive_mean_threshold(...)` function to each
# of the regions.
def block_image_process(image, block_size):
out_image = np.zeros_like(image)
for row in range(0, image.shape[0], block_size):
for col in range(0, image.shape[1], block_size):
idx = (row, col)
block_idx = get_block_index(image.shape, idx, block_size)
out_image[block_idx] = adaptive_median_threshold(image[block_idx])
return out_image
# This function invokes the whole pipeline of Step 2.
def process_image(img):
image_in = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
image_in = preprocess(image_in)
image_out = block_image_process(image_in, BLOCK_SIZE)
image_out = postprocess(image_out)
return image_out
В результате получаются такие красивые капли, которые точно следуют за чернильным следом:
Шаг 3. "Мягкая" часть бинаризации
Имея капли, которые покрывают символы, и еще немного, мы, наконец, можем провести процедуру отбеливания.
Если присмотреться к фотографиям листов бумаги с текстом (особенно на тех, на которых написано от руки), переход от «фона» (белая бумага) к «переднему плану» (чернила темного цвета) не резкий, а очень постепенный. . Другие ответы на основе бинаризации в этом разделе предлагают простое пороговое значение (даже если они локально адаптируются, это все еще пороговое значение), которое нормально работает для печатного текста, но дает не очень хорошие результаты при написании рук.
Итак, цель этого раздела состоит в том, что мы хотим сохранить эффект постепенного перехода от черного к белому, как естественные фотографии листов бумаги с натуральными чернилами. Конечная цель - сделать его пригодным для печати.
Основная идея проста: чем больше значение пикселя (после порогового значения выше) отличается от локального минимального значения, тем более вероятно, что оно принадлежит фону. Мы можем выразить это с помощью семейства функций сигмоидальных функций, масштабированных до диапазона локального блока ( так что эта функция адаптивно масштабируется по изображению).
# This is the function used for composing
def sigmoid(x, orig, rad):
k = np.exp((x - orig) * 5 / rad)
return k / (k + 1.)
# Here, we combine the local blocks. A bit lengthy, so please
# follow the local comments.
def combine_block(img_in, mask):
# First, we pre-fill the masked region of img_out to white
# (i.e. background). The mask is retrieved from previous section.
img_out = np.zeros_like(img_in)
img_out[mask == 255] = 255
fimg_in = img_in.astype(np.float32)
# Then, we store the foreground (letters written with ink)
# in the `idx` array. If there are none (i.e. just background),
# we move on to the next block.
idx = np.where(mask == 0)
if idx[0].shape[0] == 0:
img_out[idx] = img_in[idx]
return img_out
# We find the intensity range of our pixels in this local part
# and clip the image block to that range, locally.
lo = fimg_in[idx].min()
hi = fimg_in[idx].max()
v = fimg_in[idx] - lo
r = hi - lo
# Now we use good old OTSU binarization to get a rough estimation
# of foreground and background regions.
img_in_idx = img_in[idx]
ret3,th3 = cv2.threshold(img_in[idx],0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
# Then we normalize the stuffs and apply sigmoid to gradually
# combine the stuffs.
bound_value = np.min(img_in_idx[th3[:, 0] == 255])
bound_value = (bound_value - lo) / (r + 1e-5)
f = (v / (r + 1e-5))
f = sigmoid(f, bound_value + 0.05, 0.2)
# Finally, we re-normalize the result to the range [0..255]
img_out[idx] = (255. * f).astype(np.uint8)
return img_out
# We do the combination routine on local blocks, so that the scaling
# parameters of Sigmoid function can be adjusted to local setting
def combine_block_image_process(image, mask, block_size):
out_image = np.zeros_like(image)
for row in range(0, image.shape[0], block_size):
for col in range(0, image.shape[1], block_size):
idx = (row, col)
block_idx = get_block_index(image.shape, idx, block_size)
out_image[block_idx] = combine_block(
image[block_idx], mask[block_idx])
return out_image
# Postprocessing (should be robust even without it, but I recommend
# you to play around a bit and find what works best for your data.
# I just left it blank.
def combine_postprocess(image):
return image
# The main function of this section. Executes the whole pipeline.
def combine_process(img, mask):
image_in = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
image_out = combine_block_image_process(image_in, mask, 20)
image_out = combine_postprocess(image_out)
return image_out
Некоторые материалы комментируются, поскольку они не являются обязательными. Функция combine_process
берет маску из предыдущего шага и выполняет весь конвейер композиции. Вы можете попробовать поиграть с ними для ваших конкретных данных (изображений). Результаты отличные:
Возможно, в этом ответе я добавлю больше комментариев и пояснений к коду. Загрузим все это (вместе с кодом обрезки и деформации) на Github.
person
FalconUA
schedule
18.07.2019