Автоматическая регулировка контрастности и яркости цветной фотографии листа бумаги с OpenCV

При фотографировании листа бумаги (например, камерой телефона) я получаю следующий результат (левое изображение) (загрузка jpg здесь). Желаемый результат (обработанный вручную с помощью программного обеспечения для редактирования изображений) находится справа:

Я хотел бы обработать исходное изображение с помощью openCV, чтобы автоматически получить лучшую яркость / контраст (чтобы фон был более белым).

Предположение: изображение имеет портретный формат A4 (нам не нужно искажать перспективу в этом разделе), а лист бумаги белый с возможным текстом / изображениями черного или цветного цвета.

Что я пробовал до сих пор:

  1. Различные методы адаптивного определения порога, такие как Gaussian, OTSU (см. Документ OpenCV Порог изображения). Обычно хорошо работает с ОТСУ:

    ret, gray = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY)
    

    но он работает только для изображений в градациях серого, а не непосредственно для цветных изображений. Кроме того, вывод является двоичным (белый или черный), что мне не нужно: я предпочитаю сохранять цветное недвоичное изображение в качестве вывода.

  2. Выравнивание гистограммы

    • applied on Y (after RGB => YUV transform)
    • или применяется к V (после преобразования RGB => HSV),

    как предлагается в этом ответе (Не работает выравнивание гистограммы для цветного изображения - OpenCV) или этот один (OpenCV Python equalizeHist цветное изображение):

    img3 = cv2.imread(f)
    img_transf = cv2.cvtColor(img3, cv2.COLOR_BGR2YUV)
    img_transf[:,:,0] = cv2.equalizeHist(img_transf[:,:,0])
    img4 = cv2.cvtColor(img_transf, cv2.COLOR_YUV2BGR)
    cv2.imwrite('test.jpg', img4)
    

    или с HSV:

    img_transf = cv2.cvtColor(img3, cv2.COLOR_BGR2HSV)
    img_transf[:,:,2] = cv2.equalizeHist(img_transf[:,:,2])
    img4 = cv2.cvtColor(img_transf, cv2.COLOR_HSV2BGR)
    

    К сожалению, результат очень плохой, так как он создает ужасные микроконтрасты локально (?):

    Я также попробовал вместо этого YCbCr, и он был похож.

  3. Я также попробовал CLAHE (адаптивное выравнивание гистограммы с ограничением контраста) с различными tileGridSize с 1 по 1000:

    img3 = cv2.imread(f)
    img_transf = cv2.cvtColor(img3, cv2.COLOR_BGR2HSV)
    clahe = cv2.createCLAHE(tileGridSize=(100,100))
    img_transf[:,:,2] = clahe.apply(img_transf[:,:,2])
    img4 = cv2.cvtColor(img_transf, cv2.COLOR_HSV2BGR)
    cv2.imwrite('test.jpg', img4)
    

    но результат тоже был ужасен.

  4. Выполнение этого метода CLAHE с цветовым пространством LAB, как предлагается в вопросе Как сделать применить CLAHE к цветным изображениям RGB:

    import cv2, numpy as np
    bgr = cv2.imread('_example.jpg')
    lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
    lab_planes = cv2.split(lab)
    clahe = cv2.createCLAHE(clipLimit=2.0,tileGridSize=(100,100))
    lab_planes[0] = clahe.apply(lab_planes[0])
    lab = cv2.merge(lab_planes)
    bgr = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
    cv2.imwrite('_example111.jpg', bgr)
    

    тоже дал плохой результат. Выходное изображение:

  5. Выполнение адаптивного порогового определения или выравнивания гистограммы отдельно для каждого канала (R, G, B) не является вариантом, поскольку это может нарушить цветовой баланс, как объяснялось в здесь.

  6. # P20 #
    # P21 #
    # P22 #

TL; DR: как получить автоматическую оптимизацию яркости / контрастности цветной фотографии листа бумаги с помощью OpenCV / Python? Какой вид пороговой обработки / выравнивания гистограммы / другой метод можно использовать?


person Basj    schedule 05.07.2019    source источник
comment
как насчет комбинации порогового значения и повторного масштабирования, я имею в виду, используя также пороговое значение, но для 8 (или 16) уровней (а не 2 в качестве двоичного порога), а затем повторно масштабировать его до 256 уровней яркости? поскольку это цветное изображение, вы можете попробовать использовать каждый цветовой канал.   -  person Tiendung    schedule 05.07.2019
comment
Спасибо за идею @Tiendung. Как найти лучшие 8 или 16 уровней автоматически (без необходимости вручную устанавливать параметр для каждого изображения), аналогично OTSU? Разве это не более-менее похоже на выравнивание гистограммы? Не могли бы вы опубликовать образец кода Python, чтобы мы могли попробовать ваше предложение?   -  person Basj    schedule 05.07.2019
comment
Похоже, ваши проблемы вызывают артефакты сжатия JPEG. Разве у вас нет сканов лучшего качества?   -  person Cris Luengo    schedule 15.07.2019
comment
@CrisLuengo Нет, это не связано с артефактами сжатия JPEG (согласно моим тестам).   -  person Basj    schedule 16.07.2019
comment
@Basj Ознакомьтесь со сценарием, которым я поделился. Результат автоматического метода кажется лучше, чем изображение, настроенное вручную, которым вы поделились.   -  person fireant    schedule 17.07.2019
comment
Я как-то недоволен вашими результатами использования CLAHE. Вы пытались эффективно настроить параметры?   -  person Rick M.    schedule 18.07.2019
comment
@RickM. Да, я пробовал CLAHE с разными значениями параметров (например, tileGridSize от 1 до 1000). Если вы думаете, что можете превратить это в успешный метод, не могли бы вы опубликовать свой код CLAHE?   -  person Basj    schedule 18.07.2019
comment
Результаты ответов на основе бинаризации здесь великолепны! Я многому научился из этого. Однако ознакомьтесь с шагом 3 моего ответа, здесь я предлагаю мягкий метод композиции, в то время как в других ответах здесь используется простое добавление.   -  person FalconUA    schedule 21.07.2019


Ответы (5)


введите описание изображения здесь

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

from skimage.filters import threshold_yen
from skimage.exposure import rescale_intensity
from skimage.io import imread, imsave

img = imread('mY7ep.jpg')

yen_threshold = threshold_yen(img)
bright = rescale_intensity(img, (0, yen_threshold), (0, 255))

imsave('out.jpg', bright)

Я здесь использую метод Йена, можете узнать больше об этом методе на эту страницу.

person fireant    schedule 17.07.2019
comment
Интересно, спасибо, что поделились! Будет ли этот метод работать, когда условия освещения сильно различаются по изображению? - person FalconUA; 19.07.2019

введите здесь описание изображения  введите описание изображения здесь

Яркость и контраст можно регулировать с помощью альфа (α) и бета (β) соответственно. Выражение можно записать как

введите описание изображения здесь

OpenCV уже реализует это как cv2.convertScaleAbs(), поэтому мы можем просто использовать эта функция с определяемыми пользователем значениями alpha и beta.

import cv2
import numpy as np
from matplotlib import pyplot as plt

image = cv2.imread('1.jpg')

alpha = 1.95 # Contrast control (1.0-3.0)
beta = 0 # Brightness control (0-100)

manual_result = cv2.convertScaleAbs(image, alpha=alpha, beta=beta)

cv2.imshow('original', image)
cv2.imshow('manual_result', manual_result)
cv2.waitKey()

Но вопрос был

Как получить автоматическую оптимизацию яркости / контрастности цветной фотографии?

По сути, вопрос в том, как автоматически вычислять alpha и beta. Для этого мы можем посмотреть гистограмму изображения. Автоматическая оптимизация яркости и контрастности вычисляет альфа и бета так, чтобы выходной диапазон был [0...255]. Мы вычисляем кумулятивное распределение, чтобы определить, где частота цвета меньше некоторого порогового значения (скажем, 1%), и вырезаем правую и левую части гистограммы. Это дает нам минимальный и максимальный диапазон. Вот визуализация гистограммы до (синий) и после обрезки (оранжевый). Обратите внимание, как более «интересные» участки изображения становятся более заметными после обрезки.

Чтобы вычислить alpha, мы берем минимальный и максимальный диапазон градаций серого после обрезки и делим его из желаемого диапазона вывода 255.

α = 255 / (maximum_gray - minimum_gray)

Чтобы вычислить бета, мы подставляем его в формулу, где g(i, j)=0 и f(i, j)=minimum_gray

g(i,j) = α * f(i,j) + β

что после решения приводит к этому

β = -minimum_gray * α

Для вашего изображения получаем вот это

Альфа: 3,75

Бета: -311,25

Возможно, вам придется отрегулировать значение порога отсечения для уточнения результатов. Вот несколько примеров результатов с использованием порога в 1% с другими изображениями.

Автоматический код яркости и контрастности

import cv2
import numpy as np
from matplotlib import pyplot as plt

# Automatic brightness and contrast optimization with optional histogram clipping
def automatic_brightness_and_contrast(image, clip_hist_percent=1):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Calculate grayscale histogram
    hist = cv2.calcHist([gray],[0],None,[256],[0,256])
    hist_size = len(hist)

    # Calculate cumulative distribution from the histogram
    accumulator = []
    accumulator.append(float(hist[0]))
    for index in range(1, hist_size):
        accumulator.append(accumulator[index -1] + float(hist[index]))

    # Locate points to clip
    maximum = accumulator[-1]
    clip_hist_percent *= (maximum/100.0)
    clip_hist_percent /= 2.0

    # Locate left cut
    minimum_gray = 0
    while accumulator[minimum_gray] < clip_hist_percent:
        minimum_gray += 1

    # Locate right cut
    maximum_gray = hist_size -1
    while accumulator[maximum_gray] >= (maximum - clip_hist_percent):
        maximum_gray -= 1

    # Calculate alpha and beta values
    alpha = 255 / (maximum_gray - minimum_gray)
    beta = -minimum_gray * alpha

    '''
    # Calculate new histogram with desired range and show histogram 
    new_hist = cv2.calcHist([gray],[0],None,[256],[minimum_gray,maximum_gray])
    plt.plot(hist)
    plt.plot(new_hist)
    plt.xlim([0,256])
    plt.show()
    '''

    auto_result = cv2.convertScaleAbs(image, alpha=alpha, beta=beta)
    return (auto_result, alpha, beta)

image = cv2.imread('1.jpg')
auto_result, alpha, beta = automatic_brightness_and_contrast(image)
print('alpha', alpha)
print('beta', beta)
cv2.imshow('auto_result', auto_result)
cv2.waitKey()

Изображение результата с этим кодом:

введите описание изображения здесь

Результаты с другими изображениями с использованием порога в 1%

введите описание изображения здесь  введите описание изображения здесь

введите здесь описание изображения  введите описание изображения здесь

Альтернативный вариант - добавить к изображению смещение и усиление с использованием арифметики насыщенности вместо использования OpenCV cv2.convertScaleAbs. Встроенный метод не принимает абсолютное значение, что привело бы к бессмысленным результатам (например, пиксель в 44 с альфа = 3 и бета = -210 становится 78 с OpenCV, тогда как на самом деле он должен стать 0).

import cv2
import numpy as np
# from matplotlib import pyplot as plt

def convertScale(img, alpha, beta):
    """Add bias and gain to an image with saturation arithmetics. Unlike
    cv2.convertScaleAbs, it does not take an absolute value, which would lead to
    nonsensical results (e.g., a pixel at 44 with alpha = 3 and beta = -210
    becomes 78 with OpenCV, when in fact it should become 0).
    """

    new_img = img * alpha + beta
    new_img[new_img < 0] = 0
    new_img[new_img > 255] = 255
    return new_img.astype(np.uint8)

# Automatic brightness and contrast optimization with optional histogram clipping
def automatic_brightness_and_contrast(image, clip_hist_percent=25):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Calculate grayscale histogram
    hist = cv2.calcHist([gray],[0],None,[256],[0,256])
    hist_size = len(hist)

    # Calculate cumulative distribution from the histogram
    accumulator = []
    accumulator.append(float(hist[0]))
    for index in range(1, hist_size):
        accumulator.append(accumulator[index -1] + float(hist[index]))

    # Locate points to clip
    maximum = accumulator[-1]
    clip_hist_percent *= (maximum/100.0)
    clip_hist_percent /= 2.0

    # Locate left cut
    minimum_gray = 0
    while accumulator[minimum_gray] < clip_hist_percent:
        minimum_gray += 1

    # Locate right cut
    maximum_gray = hist_size -1
    while accumulator[maximum_gray] >= (maximum - clip_hist_percent):
        maximum_gray -= 1

    # Calculate alpha and beta values
    alpha = 255 / (maximum_gray - minimum_gray)
    beta = -minimum_gray * alpha

    '''
    # Calculate new histogram with desired range and show histogram 
    new_hist = cv2.calcHist([gray],[0],None,[256],[minimum_gray,maximum_gray])
    plt.plot(hist)
    plt.plot(new_hist)
    plt.xlim([0,256])
    plt.show()
    '''

    auto_result = convertScale(image, alpha=alpha, beta=beta)
    return (auto_result, alpha, beta)

image = cv2.imread('1.jpg')
auto_result, alpha, beta = automatic_brightness_and_contrast(image)
print('alpha', alpha)
print('beta', beta)
cv2.imshow('auto_result', auto_result)
cv2.imwrite('auto_result.png', auto_result)
cv2.imshow('image', image)
cv2.waitKey()
person nathancy    schedule 05.07.2019
comment
Спасибо за ваш ответ (уже полезный, пожалуйста, сохраните его). Вопрос в том, как найти альфа / бета автоматически (мне нужна обработка без ручной настройки параметров), чтобы иметь хорошую оптимизацию изображения (что-то довольно стандартное: мы хотели бы, чтобы фон был почти белым а не серый, текст или изображения должны быть хорошо контрастными и т. д.). У вас есть идея, чтобы алгоритм находил хорошие значения альфа-бета для любого сфотографированного листа бумаги? - person Basj; 06.07.2019
comment
Один из возможных подходов - автоматизировать поиск значений альфа и бета с помощью гистограммы изображения. Проверить обновленный код - person nathancy; 06.07.2019
comment
Спасибо за обновленный ответ! Это немного улучшает результат, но, например, на моем образце изображения фон все еще темный (я отредактировал ваш ответ, чтобы добавить изображение результата при использовании вашего кода с моим образцом изображения, это полезно для дальнейшего использования). - person Basj; 15.07.2019
comment
Текущий метод отсечения гистограммы удаляет наиболее выпадающие участки и обычно работает для увеличения контрастности / яркости, но, поскольку вы пытаетесь получить полностью белое фоновое изображение, было бы довольно сложно определить автоматическое альфа / бета. Обычно используется среднее значение, но для получения полностью белого фона вам понадобится какой-либо индикатор, чтобы отклонить значения от среднего. Может быть, добавление константы сработает. В любом случае, это интересная проблема. Удачи! - person nathancy; 16.07.2019
comment
@nathancy, спасибо, что показал мне это, но почему вы используете min_gray, max_gray для цветного изображения? использовать оттенки серого, чтобы найти эти значения, или сделать RGB отдельно и получить 3 разных альфы и бета? - person mLstudent33; 18.10.2019
comment
@ mLstudent33, да, мы используем оттенки серого, чтобы найти эти значения. Мы конвертируем изображение в оттенки серого (1-канальный) со значениями пикселей в диапазоне от [0...255]. Отсюда мы находим гистограмму и отсекаем ее, чтобы улучшить и получить самые сильные значения (посмотрите на график). По сути, мы используем оттенки серого, чтобы найти альфа и бета, а затем применяем cv2.convertScaleAbs к изображению RGB / BGR с этими значениями альфа / бета. В итоге мы получаем одно значение альфа и бета, рассчитанное на основе изображения в градациях серого. Одна из причин, по которой мы сначала конвертируем в оттенки серого, заключается в том, что этот метод работает как с 1-канальными, так и с 3-канальными входными изображениями. - person nathancy; 18.10.2019
comment
Это не интуитивно понятно, но я попробую. Спасибо @nathancy - person mLstudent33; 18.10.2019
comment
@ mLstudent33, идея состоит в том, что путем преобразования в оттенки серого мы получаем фиксированный нормализованный диапазон, в котором мы можем использовать порог для вырезания неинтересных частей изображения, что оставляет нам самые высокие значения распределения. Визуально это улучшает изображение, удаляя скучные участки. Я думаю, что лучше всего посмотреть на графики гистограммы до и после, чтобы понять, что происходит. - person nathancy; 18.10.2019
comment
@nathancy, я видел сдвиг распределения в сторону более высоких значений пикселей, но я не уверен, найдены ли интересные части таким образом. Насчет градиентов и идей от резьбы по шву, т.е. энергетические карты? Например, черный фон с крохотным пятнышком белого цвета. - person mLstudent33; 18.10.2019
comment
@ mLstudent33, отличный вопрос. Никогда не пробовал на энергетических картах. Я считаю, что он улучшается на основе относительного порога всех пикселей в изображении, поэтому я предполагаю, что он все еще должен работать, но эффект не будет таким выраженным. - person nathancy; 18.10.2019
comment
@nathancy, какова математическая концепция расчета альфа и бета? есть ли статья или что-нибудь, что вы можете использовать, чтобы лучше понять этот шаг? - person hux0; 19.04.2020

Надежная локально-адаптивная мягкая бинаризация! Так я это называю.

Раньше я делал подобные вещи для немного другой цели, так что это может не идеально соответствовать вашим потребностям, но надеюсь, что это поможет (также я написал этот код ночью для личного использования, поэтому он уродлив). В некотором смысле этот код был предназначен для решения более общего случая по сравнению с вашим, когда мы можем иметь много структурированного шума на фоне (см. Демонстрацию ниже).

Что делает этот код? На фотографии листа бумаги он становится белее, чтобы его можно было безупречно распечатать. См. Примеры изображений ниже.

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

Чтобы получить идеально чистые результаты, вам, возможно, придется немного поиграть с параметрами фильтрации, но, как видите, даже с параметрами по умолчанию она работает достаточно хорошо.


Шаг 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
comment
Ваша процедура комбинирования проста, но очень умна. Устраняет множество неудобств при обработке изображений естественного текста. - person remote32; 23.07.2019
comment
Спасибо, что поделились этим замечательным методом! Однако это бинаризация, поэтому на выходе не будут сохраняться градиенты цветов (пример: допустим, на отсканированном листе бумаги есть фотография!), Поэтому это не совсем то, что требуется в этой теме. Но снова это интересно само по себе, так что спасибо, что поделились! - person Basj; 23.08.2019
comment
Или, может быть, @FalconUA у вас есть модифицированная версия вашего алгоритма, которая все еще сохраняет цвета (но просто найдите лучший баланс яркости / контрастности, подробнее см. В моем вопросе)? - person Basj; 23.08.2019
comment
накладывать обработанные и оригинальные изображения друг на друга и восстанавливать цвета, где пиксель черный - person Ashot Matevosyan; 20.03.2020
comment
Есть ли у вас способ обрезать изображения, чтобы они соответствовали странице, как вы описали в шаге 0? - person Wajd Meskini; 20.07.2020
comment
где вы использовали adjust_gamma - person RCvaram; 10.09.2020

Я думаю, что способ сделать это: 1) Извлечь канал цветности (насыщенности) из цветового пространства HCL. (HCL работает лучше, чем HSL или HSV). Только цвета должны иметь ненулевую насыщенность, поэтому яркие, а серые оттенки будут темными. 2) Пороговое значение, полученное в результате использования порогового значения otsu для использования в качестве маски. 3) Преобразуйте ввод в оттенки серого и примените пороговое значение локальной области (т.е. адаптивное). 4) поместите маску в альфа-канал оригинала, а затем объедините результат с пороговым значением локальной области с оригиналом, чтобы сохранить цветную область из оригинала, а везде использовать результат с пороговым значением локальной области.

Извините, я не очень хорошо знаком с OpeCV, но вот шаги с использованием ImageMagick.

Обратите внимание, что каналы нумеруются, начиная с 0. (H = 0 или красный, C = 1 или зеленый, L = 2 или синий)

Вход:

введите описание изображения здесь

magick image.jpg -colorspace HCL -channel 1 -separate +channel tmp1.png


 введите здесь описание изображения

magick tmp1.png -auto-threshold otsu tmp2.png


 введите здесь описание изображения

magick image.jpg -colorspace gray -negate -lat 20x20+10% -negate tmp3.png


 введите здесь описание изображения

magick tmp3.png \( image.jpg tmp2.png -alpha off -compose copy_opacity -composite \) -compose over -composite result.png


 введите здесь описание изображения

ДОБАВЛЕНИЕ:

Вот код Python Wand, который дает тот же результат. Для этого требуются Imagemagick 7 и Wand 0.5.5.

#!/bin/python3.7

from wand.image import Image
from wand.display import display
from wand.version import QUANTUM_RANGE

with Image(filename='text.jpg') as img:
    with img.clone() as copied:
        with img.clone() as hcl:
            hcl.transform_colorspace('hcl')
            with hcl.channel_images['green'] as mask:
                mask.auto_threshold(method='otsu')
                copied.composite(mask, left=0, top=0, operator='copy_alpha')
                img.transform_colorspace('gray')
                img.negate()
                img.adaptive_threshold(width=20, height=20, offset=0.1*QUANTUM_RANGE)
                img.negate()
                img.composite(copied, left=0, top=0, operator='over')
                img.save(filename='text_process.jpg')
person fmw42    schedule 06.07.2019
comment
Вау, это довольно изящное решение. Мне жаль, что я не знал об этих методах раньше, поэтому мне не нужно самому реализовывать подобные вещи из стандартного OpenCV. - person FalconUA; 19.07.2019
comment
Это также можно сделать в Python Wand, поскольку он основан на Imagemagick. - person fmw42; 19.07.2019
comment
Я добавил код Python Wand, чтобы ответить в ДОПОЛНЕНИИ - person fmw42; 20.07.2019

Сначала мы разделяем текст и цветовую маркировку. Это можно сделать в цветовом пространстве с каналом насыщенности цвета. Вместо этого я использовал очень простой метод, вдохновленный этой статьей: рацион of min (R, G, B) / max (R, G, B) будет около 1 для (светло) серых областей и ‹---------------- 1 для цветных областей. Для темно-серых областей мы получаем любое значение от 0 до 1, но это не имеет значения: либо эти области попадают в цветовую маску, а затем добавляются как есть, либо они не включаются в маску и вносятся в вывод из бинаризованной текст. Для черного мы используем тот факт, что 0/0 становится 0 при преобразовании в uint8.

Текст изображения в градациях серого получает локальное пороговое значение для создания черно-белого изображения. Вы можете выбрать понравившуюся технику из это сравнение или тот опрос . Я выбрал технику NICK, которая хорошо справляется с низким контрастом и является довольно надежной, т.е. выбор параметра k между примерно -0,3 и -0,1 хорошо работает для очень широкого диапазона условий, что хорошо для автоматической обработки. Для предоставленного образца документа выбранный метод не играет большой роли, поскольку он относительно равномерно освещен, но для того, чтобы справиться с неравномерно освещенными изображениями, это должен быть локальный метод определения порога.

На последнем этапе цветные области добавляются обратно к бинаризованному текстовому изображению.

Таким образом, это решение очень похоже на решение @ fmw42 (вся идея ему принадлежит), за исключением различных методов определения цвета и бинаризации.

image = cv2.imread('mY7ep.jpg')

# make mask and inverted mask for colored areas
b,g,r = cv2.split(cv2.blur(image,(5,5)))
np.seterr(divide='ignore', invalid='ignore') # 0/0 --> 0
m = (np.fmin(np.fmin(b, g), r) / np.fmax(np.fmax(b, g), r)) * 255
_,mask_inv = cv2.threshold(np.uint8(m), 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
mask = cv2.bitwise_not(mask_inv)

# local thresholding of grayscale image
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
text = cv2.ximgproc.niBlackThreshold(gray, 255, cv2.THRESH_BINARY, 41, -0.1, binarizationMethod=cv2.ximgproc.BINARIZATION_NICK)

# create background (text) and foreground (color markings)
bg = cv2.bitwise_and(text, text, mask = mask_inv)
fg = cv2.bitwise_and(image, image, mask = mask)

out = cv2.add(cv2.cvtColor(bg, cv2.COLOR_GRAY2BGR), fg) 

введите описание изображения здесь

Если вам не нужны цветные отметки, вы можете просто преобразовать изображение в градациях серого в двоичную форму:

image = cv2.imread('mY7ep.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
text = cv2.ximgproc.niBlackThreshold(gray, 255, cv2.THRESH_BINARY, at_bs, -0.3, binarizationMethod=cv2.ximgproc.BINARIZATION_NICK)

введите описание изображения здесь

person Stef    schedule 19.07.2019