Автоматическая настройка контрастности и яркости цветной фотографии листа бумаги с помощью OpenCV - PullRequest
31 голосов
/ 05 июля 2019

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

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

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

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

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

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

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

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

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

    как предложено этим ответом ( Выравнивание гистограммы не работает на цветном изображении - OpenCV ) или это один ( OpenCV Python equizeHist цветнойimage ):

    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. Метод "Contrast strechting" из scikit-imageучебник по выравнивание гистограммы :

    изображение масштабируется, чтобы включить все интенсивности, которые попадают во 2-й и 98-й процентили

    немного лучше, но все еще далек от желаемого результата (см. изображение в верхней части этого вопроса).


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

Ответы [ 5 ]

9 голосов
/ 19 июля 2019

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

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

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

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

image image

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


Шаг 0: Обрежьте изображения, чтобы они точно соответствовали странице

Давайте предположим, что вы каким-то образом проделали этот шаг (похоже, что в приведенных вами примерах).Если вам нужен ручной инструмент для аннотирования и перемотки, просто пишите мне в личку!^^ Ниже приведены результаты этого шага (примеры, которые я здесь использую, возможно, сложнее, чем тот, который вы предоставили, хотя он может не совсем соответствовать вашему случаю):

image image

Отсюда мы сразу видим следующие проблемы:

  • Условие освещения не четное. Это означает, что все простые бинаризацииметоды не будут работать.Я перепробовал множество решений, доступных в 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)

Вот результаты регулировки гаммы:

image image

Вы можете видеть, что это немного более ... "сбалансировано" сейчас.Без этого шага все параметры, которые вы выберете вручную на последующих шагах, станут менее надежными!


Шаг 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

В результате получаются такие замечательные пузырьки, точно следящие за чернильным следом:

image image


Шаг 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 берет маску из предыдущего шага и выполняет весь конвейер композиции.Вы можете попробовать поиграть с ними для ваших конкретных данных (изображений).Результаты аккуратные:

image image

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

8 голосов
/ 06 июля 2019

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

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

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

Введите:

enter image description here

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


enter image description here

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


enter image description here

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


enter image description here

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


enter image description here

Сложение:

Вот код 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')
8 голосов
/ 05 июля 2019

enter image description here enter image description here

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

enter image description here

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%), и вырезать правую и левую стороны гистограммы.Это дает нам наш минимальный и максимальный диапазоны.Вот визуализация гистограммы до (синий) и после отсечения (оранжевый).

image

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

α = 255 / (maximum_gray - minimum_gray)

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

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

, что после решения результатов в этом

β = -minimum_gray * α

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

alpha 3.75

beta -311.25

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

image image

image image

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

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()

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

enter image description here

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

enter image description here enter image description here

enter image description here enter image description here

7 голосов
/ 17 июля 2019

enter image description here

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

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)

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

5 голосов
/ 19 июля 2019

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

Текст изображения в градациях серого локально получает пороговое значение для получения черно-белого изображения.Вы можете выбрать свою любимую технику из этого сравнения или этого опроса .Я выбрал технику 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) 

enter image description here

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

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)

enter image description here

...