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

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

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

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

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

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

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

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

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

    • применяется к Y (после преобразования RGB => YUV)
    • или применяется к 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. Метод "Contrast strechting" из scikit-image по выравниванию гистограммы:

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

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


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

Ответ 1

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

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

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

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

cyTGa.jpg k9AU9.png

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


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

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

VPvcH.jpg jVh9O.png

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

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

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

x0KZ8.jpg xd5Up.png

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


Шаг 2: Адаптивная бинаризация для обнаружения текстовых BLOB-объектов

На этом шаге мы будем адаптивно преобразовывать двоичные объекты в двоичные объекты. Я добавлю больше комментариев позже, но идея в основном заключается в следующем:

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

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

pqmx2.png P54HY.png


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

GW25W.png l4Hj9.png

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

Ответ 2

Я думаю, что способ сделать это 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')

Ответ 3

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)

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

Ответ 4

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

bBX7c.png

Чтобы вычислить 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% с другими изображениями

xOrL1.jpgTHfxY.png

aJQuc.jpgfVTK9.png

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

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

Ответ 5

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

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