2025, Dec 20 15:02
Очистка кромок бинаризованных сканов нот и книг в OpenCV
Пошаговый метод очистки кромок бинаризованных сканов нот в OpenCV: внешняя маска, итеративная обрезка по «тёмным» сторонам, сохранение нотации и текста.
Сканированные страницы нот и книг нередко уже приходят бинаризованными, но по краям остаётся беспорядок: полосы, точки и полуразорванные метки, прижимающиеся к рамке. Простая заливка из угла после добавления сплошной окантовки помогает, однако остаточные артефакты и изредка чрезмерная обрезка всё же случаются. Ниже — практический способ сделать очистку кромок детерминированной и устойчивой в OpenCV, при этом сохранить музыкальную нотацию и текст.
Обзор задачи
Исходный конвейер прост: загрузить страницу в градациях серого, добавить чёрную рамку, выполнить заливку из верхнего левого угла белым, затем обрезать по минимальному ограничивающему прямоугольнику чёрных пикселей. Это удаляет чёрные области, связанные с границами, но упрямый шум по краям может остаться, а в некоторых случаях съедается содержимое возле угла.
Минимальный пример, демонстрирующий проблему
Ниже приведён фрагмент, который показывает этот базовый подход. Он неявно порогует, работая с уже бинаризованным сканом, добавляет отступы, заливает внешнюю область белым, затем вычисляет ограничивающий прямоугольник оставшегося чёрного содержимого. На некоторых страницах всё равно остаются точки и кольцеобразные следы вдоль кромок; на других — теряется мелкое содержимое у углов.
import os
import cv2
import numpy as np
base_dir = os.path.abspath(os.path.dirname(__file__))
page_names = ['1.webp', '2.webp', '3.webp', '4.webp']
for page_name in page_names:
src_path = os.path.join(base_dir, page_name)
dst_path = os.path.join(base_dir, page_name + '-clean.webp')
page = cv2.imread(src_path, cv2.IMREAD_GRAYSCALE)
page = cv2.copyMakeBorder(page, 50, 50, 50, 50, cv2.BORDER_CONSTANT)
cv2.floodFill(page, None, (0, 0), 255)
ys, xs = np.nonzero(page == 0)
page = page[np.min(ys):np.max(ys), np.min(xs):np.max(xs)]
cv2.imwrite(dst_path, page)
Что именно идёт не так и почему
Артефакты, примыкающие к границе, могут пережить обработку, потому что единая «обрезка со всех сторон» по текущему силуэту слишком груба. Если чёрный мусор присутствует сразу на нескольких сторонах, ограничивающий прямоугольник захватывает их одновременно и сохраняет ненужные поля. И наоборот, когда тонкие элементы или бледные штрихи касаются кромки, заливка может превратить этот участок в «фон», и последующая обрезка по прямоугольнику удалит реальное содержимое. В результате поведение непоследовательно: в одних изображениях остаётся пограничный мусор, в других теряются значимые штрихи.
Более надёжный подход с OpenCV
Надёжная стратегия — явно смоделировать внешнюю область, а затем итеративно убирать по одному пикселю с той стороны рамки, где кромка самая «тёмная», пока все четыре стороны не очистятся. По сути, схема проста. При необходимости конвертируем в три канала, добавляем чёрную рамку толщиной 1 пиксель для замыкания, выполняем заливку из верхнего левого угла маркерным цветом (красным), с помощью inRange выделяем этот красный внешний контур, инвертируем, чтобы получить маску, где внутреннее содержимое белое, а внешнее — чёрное, затем многократно измеряем однопиксельные полосы по всем четырём сторонам. На каждом шаге убираем ровно один пиксель с той стороны, у которой граничная полоса темнее остальных. Пересчитываем и повторяем, пока все стороны не станут белыми. Наконец, обрезаем исходное изображение, используя найденные координаты. Это напоминает поведение в стиле ImageMagick, но целиком остаётся в Python/OpenCV.
import cv2
import numpy as np
# img = cv2.imread('page1.webp')
# img = cv2.imread('page2.webp')
src = cv2.imread('page3.webp')
h, w, ch = src.shape
if ch != 3:
src = cv2.merge([src, src, src])
padded = cv2.copyMakeBorder(src, 1, 1, 1, 1, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
ff = padded.copy()
red_color = (0, 0, 255)
lo_tol = (0, 0, 0)
hi_tol = (0, 0, 0)
cv2.floodFill(ff, None, (0, 0), red_color, lo_tol, hi_tol, flags=8)
lower = (0, 0, 255)
upper = (0, 0, 255)
edge_mask = cv2.inRange(ff, lower, upper)
edge_mask = 255 - edge_mask
print(edge_mask[0:1, 0:1])
t = 0
l = 0
b = h
r = w
step = 0
mt = np.mean(edge_mask[t:t+1, l:r])
ml = np.mean(edge_mask[t:b, l:l+1])
mb = np.mean(edge_mask[b-1:b, l:r])
mr = np.mean(edge_mask[t:b, r-1:r])
mmin = min(mt, ml, mb, mr)
print("mean_top=", mt, " mean_left=", ml, " mean_bottom=", mb, " mean_right=", mr, " mean_minimum=", mmin)
t_state = "stop" if (mt == 255) else "go"
l_state = "stop" if (ml == 255) else "go"
b_state = "stop" if (mb == 255) else "go"
r_state = "stop" if (mr == 255) else "go"
print(t_state, l_state, b_state, r_state)
while t_state == "go" or l_state == "go" or r_state == "go" or b_state == "go":
if t_state == "go":
if mt != 255:
if mt == mmin:
t += 1
mt = np.mean(edge_mask[t:t+1, l:r])
ml = np.mean(edge_mask[t:b, l:l+1])
mb = np.mean(edge_mask[b-1:b, l:r])
mr = np.mean(edge_mask[t:b, r-1:r])
mmin = min(mt, ml, mr, mb)
step += 1
print("increment=", step, "top_count=", t, " top_mean=", mt)
continue
else:
t_state = "stop"
print("top stop")
if l_state == "go":
if ml != 255:
if ml == mmin:
l += 1
mt = np.mean(edge_mask[t:t+1, l:r])
ml = np.mean(edge_mask[t:b, l:l+1])
mb = np.mean(edge_mask[b-1:b, l:r])
mr = np.mean(edge_mask[t:b, r-1:r])
mmin = min(mt, ml, mr, mb)
step += 1
print("increment=", step, "left_count=", l, " left_mean=", ml)
continue
else:
l_state = "stop"
print("left stop")
if b_state == "go":
if mb != 255:
if mb == mmin:
b -= 1
mt = np.mean(edge_mask[t:t+1, l:r])
ml = np.mean(edge_mask[t:b, l:l+1])
mb = np.mean(edge_mask[b-1:b, l:r])
mr = np.mean(edge_mask[t:b, r-1:r])
mmin = min(mt, ml, mr, mb)
step += 1
print("increment=", step, "bottom_count=", b, " bottom_mean=", mb)
continue
else:
b_state = "stop"
print("bottom stop")
if r_state == "go":
if mr != 255:
if mr == mmin:
r -= 1
mt = np.mean(edge_mask[t:t+1, l:r])
ml = np.mean(edge_mask[t:b, l:l+1])
mb = np.mean(edge_mask[b-1:b, l:r])
mr = np.mean(edge_mask[t:b, r-1:r])
mmin = min(mt, ml, mr, mb)
step += 1
print("increment=", step, "right_count=", r, " right_mean=", mr)
continue
else:
r_state = "stop"
print("right stop")
cropped = src[t:b, l:r]
print("top: ", t)
print("bottom: ", b)
print("left: ", l)
print("right: ", r)
print("height:", cropped.shape[0])
print("width:", cropped.shape[1])
# cv2.imwrite('page1_cropped.png', cropped)
# cv2.imwrite('page2_cropped.png', cropped)
cv2.imwrite('page3_cropped.png', cropped)
cv2.imshow("mask", edge_mask)
cv2.imshow("cropped", cropped)
cv2.waitKey(0)
cv2.destroyAllWindows()
Как этот подход закрывает крайние случаи
Ключ в том, чтобы явно отделить внешнее, а затем решать — пиксель за пикселем — с какой стороны сбривать границу, опираясь на измеренную «тёмность». Залитая красным область помечает «снаружи». После преобразования в двоичную маску и инверсии граничные полосы оцениваются численно. Постепенно убирая по одному пикселю с самой тёмной стороны и переоценивая, обрезка подстраивается под неравномерный мусор вокруг страницы. Итерации заканчиваются только тогда, когда все четыре стороны белые на маске — значит, внутреннее содержимое изолировано.
Почему это важно
В массовой обработке детерминированные правила обеспечивают стабильный результат. При миллионах страниц даже небольшие недо- или переобрезки накапливаются. Решение по каждой стороне, основанное на маске и измерении, сокращает и остаточные точки, и случайную потерю содержимого на краях, оставаясь полностью автоматическим и воспроизводимым на разных страницах. Есть и другие варианты, например, эвристики по подсчёту пикселей в строках/столбцах или семантическая сегментация, но описанный подход сугубо алгоритмический и работает прямо с бинаризованными сканами на стандартных примитивах OpenCV.
Практические итоги
Если ваши сканы уже порогованы и единичная связка «заливка + ограничивающий прямоугольник» даёт смешанные результаты, переходите на внешнюю маску и итерации. Добавьте минимальную чёрную рамку, выполните заливку из угла маркерным цветом, получите двоичную маску внешнего, инвертируйте её до внутреннего, затем по шагу обрезайте с самой тёмной стороны, пока границы не станут чистыми. Эта небольшая замена превращает обрезку «за раз» в управляемый, основанный на данных процесс, который лучше сохраняет нотацию и текст и при этом удаляет упрямые артефакты по краям.