Надежная локально-адаптивная мягкая бинаризация!Вот как я это называю.
Я уже делал подобные вещи раньше, для немного другой цели, так что это может не совсем соответствовать вашим потребностям, но надеюсь, что это поможет (также я написал этот код ночью для личногопользуйся так некрасиво).В некотором смысле, этот код был предназначен для решения более общего случая по сравнению с вашим, где мы можем иметь много структурированного шума на заднем плане (см. Демонстрацию ниже).
Что делает этот код? Учитывая фотографию листа бумаги, он отбелит его, чтобы его можно было идеально печатать.См. Примеры изображений ниже.
Тизер: так будут выглядеть ваши страницы после этого алгоритма (до и после).Обратите внимание, что даже аннотации к цветным маркерам пропали, поэтому я не знаю, подойдет ли это для вашего варианта использования, но код может быть полезен:
Чтобы получить идеально чистые результаты, вам может потребоваться немного поиграться с параметрами фильтрации, но, как вы можете видеть, даже с параметрами по умолчанию это работает довольно хорошо.
Шаг 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
.Хитрость заключается в том, чтобы выбрать его размер, достаточно большой, чтобы вы по-прежнему получали большой кусок текста и фона (то есть больше, чем любые имеющиеся у вас символы), но достаточно маленький, чтобы не страдать от каких-либо изменений условий освещения (то есть «больших, но все жеlocal "). - Внутри каждого блока мы выполняем локально-адаптивную бинаризацию: мы смотрим на медианное значение и выдвигаем гипотезу, что это фон (потому что мы выбрали
BLOCK_SIZE
достаточно большим, чтобы иметь большую его частьбыть фоном).Затем мы дополнительно определяем DELTA
- в основном просто порог «как далеко от медианы мы все равно будем рассматривать его как фон?».
Итак, функция process_image
выполняет свою работу.Кроме того, вы можете модифицировать функции preprocess
и postprocess
в соответствии с вашими потребностями (однако, как вы можете видеть из приведенного выше примера, алгоритм довольно робастный , то есть он довольно хорошо работает вне-бокса без изменения слишком большого количества параметров).
Код этой части предполагает, что передний план является более темным, чем фон (т. е. чернила на бумаге).Но вы можете легко изменить это, настроив функцию 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: «Мягкая» часть бинаризации
Наличиекапли, которые покрывают символы и немного больше, мы, наконец, можем сделать процедуру отбеливания.
Если мы более внимательно посмотрим на фотографии листов бумаги с текстом (особенно те, которые имеют рукописные надписи), преобразованиеОт «фона» (белая бумага) до «переднего плана» (тёмный цвет чернил) не резкий, но очень постепенный.Другие основанные на бинаризации ответы в этом разделе предлагают простой порог (даже если они локально адаптивны, это все еще порог), который работает хорошо для печатного текста, но даст не очень приятные результаты с рукописными записями.
Итак, мотивация этого раздела в том, что мы хотим сохранить этот эффект постепенной передачи от черного к белому, так же, как естественные фотографии листов бумаги с натуральными чернилами.Конечная цель для этого - сделать его пригодным для печати.
Основная идея проста: чем больше значение пикселя (после определения порога выше) отличается от локального значения min, тем более вероятно, что онопринадлежит фону.Мы можем выразить это с помощью семейства функций Sigmoid , перемасштабированных до диапазона локального блока (чтобы эта функция адаптивно масштабировалась по изображению).
# 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.