Проблема: наклон полей страницы при сканировании — документ уложен под углом, горизонтали текста не параллельны краям кадра.
K-Means
Геометрический (geom)
Линейная алгебра (linalg)
Нейросеть (AngleNet)
Изображение поворачивается в диапазоне углов; на каждом шаге строкам назначаются кластеры (текст / фон) методом K-Means. Угол, при котором в «белом» кластере максимальное число строк, считается углом коррекции.
def doit(img):
original = img.copy()
stat = []
gray = original.convert('L')
for angle in np.arange(-7, 7, 0.1):
img_rot = gray.rotate(angle, expand=False, fillcolor='white')
h = np.array(img_rot).sum(1)
km = KMeans(n_clusters=2, n_init="auto").fit(h.reshape(-1, 1))
lb = km.labels_
white = 1 if np.mean(h[np.argwhere(lb == 1)]) > np.mean(h[np.argwhere(lb == 0)]) else 0
stat.append((angle, np.bincount(lb)[white]))
stat = np.array(stat)
rotate_angle = stat[np.argmax(stat[:, 1]), 0]
return original.rotate(rotate_angle, expand=False, fillcolor='white')
Бинаризация методом Оцу → поиск ненулевых координат → минимальный описывающий прямоугольник (minAreaRect). Угол наклона извлекается из параметров прямоугольника.
def __detect_angle_geom(self, page):
gray = cv.cvtColor(page, cv.COLOR_BGR2GRAY)
blurred = cv.GaussianBlur(gray, (5, 5), 0)
_, thresh = cv.threshold(blurred, 0, 255,
cv.THRESH_BINARY_INV | cv.THRESH_OTSU)
kernel = cv.getStructuringElement(cv.MORPH_RECT, (4, 4))
thresh_filtered = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel)
nonZeroCoordinates = cv.findNonZero(thresh_filtered)
if nonZeroCoordinates is None: return None
box = cv.minAreaRect(nonZeroCoordinates)
if box[1][0] * box[1][1] > 0.90 * page.shape[0] * page.shape[1]:
return 0
angle = box[2]
width, height = box[1]
angle = 90 - angle if width < height else -angle
if angle > 45: angle -= 90 elif angle < -45: angle += 90 return -angle
Детектор границ Canny → PCA по координатам граничных пикселей. Главный собственный вектор ковариационной матрицы даёт направление основного текстового содержимого.
def __detect_angle_linalg(self, page):
gray = cv.cvtColor(page, cv.COLOR_BGR2GRAY)
edges = cv.Canny(cv.GaussianBlur(gray, (5, 5), 0), 50, 150)
y, x = np.where(edges > 0)
if len(x) < 2: return None
points = np.stack((x, y), axis=1)
cov_matrix = np.cov(points, rowvar=False)
eig_vals, eig_vecs = np.linalg.eig(cov_matrix)
main_vec = eig_vecs[:, np.argmax(eig_vals)]
angle = np.degrees(np.arctan2(main_vec[1], main_vec[0]))
if angle > 45: angle -= 90 elif angle < -45: angle += 90 return angle
Лёгкая свёрточная сеть AngleNet на синтетических данных. Напрямую предсказывает нормализованный угол поворота. Обучение на синтетических страницах с известным углом в диапазоне ±15°.
class AngleNet(nn.Module):
def __init__(self, in_channels=1, base=16):
super().__init__()
self.inc = DoubleConv(in_channels, base, drop=False)
self.down1 = Down(base, base * 2, drop=False)
self.down2 = Down(base * 2, base * 4, drop=True)
self.head = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(base * 4, 64),
nn.ReLU(inplace=True),
nn.Dropout(0.1),
nn.Linear(64, 1),
)
def forward(self, x):
x = self.inc(x)
x = self.down1(x)
x = self.down2(x)
return self.head(x)
criterion = nn.SmoothL1Loss()
pred_angle = model(img_tensor) * CONFIG["max_rotation"]
Проблема: при сканировании книг или журналов два листа попадают в один кадр как единый разворот — страницы необходимо разделить.
simple
pixel
var
kmeans
нейросеть (SpineNet)
Разделение ровно посередине изображения или по явно переданным координатам линии разреза. Быстрый метод для случаев, когда геометрия разворота известна заранее.
def _simple_split(self, page, p1=None, p2=None):
h, w = page.shape[:2]
if None in (p1, p2):
left = page[:, :w // 2]
right = page[:, w // 2:]
else:
mid_x = int((p1[0] + p2[0]) / 2)
left = page[:, :mid_x]
right = page[:, mid_x:]
cv.imwrite('left_part.png', left)
cv.imwrite('right_part.png', right)
Вертикальная проекция суммы тёмных пикселей: в центральной зоне разворота текст отсутствует, создавая характерный минимум. Нахождение этого минимума определяет линию разреза.
def __pixel_values_split(self, page):
gray = cv.cvtColor(page, cv.COLOR_BGR2GRAY)
h, w = gray.shape
_, binary = cv.threshold(gray, 200, 255, cv.THRESH_BINARY_INV)
proj = np.sum(binary[int(h*0.1):int(h*0.9), :], axis=0)
smoothed = uniform_filter1d(proj.astype(float),
size=int(w * 0.02))
center_region = smoothed[int(w*0.3):int(w*0.7)]
above = np.where(center_region >= center_region.max() * 0.85)[0]
best_x = int(w*0.3) + (above[0] + above[-1]) // 2 return (float(best_x), 0.0), (float(best_x), float(h))
Локальная дисперсия яркости: области с текстом имеют высокую дисперсию, поля и желоб разворота — низкую. Минимум суммарной дисперсии по столбцам указывает на линию разреза.
def __var_split(self, page):
h, w = page.shape[:2]
scale = min(1.0, 250.0 / max(h, w))
small = cv.resize(page, (int(w*scale), int(h*scale)))
gray = cv.cvtColor(small, cv.COLOR_BGR2GRAY).astype(np.float32)
mu = cv.blur(gray, (7, 7))
var = cv.blur(gray**2, (7, 7)) - mu**2
text_mask = (var > np.percentile(var, 65)).astype(np.uint8) * 255
proj = uniform_filter1d(np.sum(text_mask, axis=0).astype(float), ...)
split_x = int((cx0 + np.argmin(center)) / scale)
return (float(split_x), 0.0), (float(split_x), float(h))
K-Means кластеризует пиксели в два класса (текст/фон). По дисперсии текстового кластера в центральной зоне определяется желоб разворота. Изображение масштабируется до 250 пкс без потери структурной информации — для ускорения расчётов.
def __kmeans_split(self, page):
h, w = page.shape[:2]
scale = 250 / max(h, w)
small = cv.resize(page, (int(w*scale), int(h*scale))) if scale < 1 else page.copy()
gray = cv.cvtColor(small, cv.COLOR_BGR2GRAY)
km = KMeans(n_clusters=2, n_init='auto')
labels = km.fit_predict(gray.reshape(-1, 1)).reshape(gray.shape)
text_cluster = np.argmin(km.cluster_centers_)
mask = cv.resize((labels == text_cluster).astype(np.uint8) * 255,
(w, h), interpolation=cv.INTER_NEAREST)
col_variance = uniform_filter1d(np.var(mask, axis=0), size=int(w*0.02))
split_x = left + (text_cols[0] + text_cols[-1]) // 2 return (float(split_x), 0.0), (float(split_x), float(h))
Для решения задачи детекции и точного позиционирования линии книжного разворота был разработан пайплайн, включающий синтетический датасет, двухголовую нейронную сеть и специализированную функцию потерь.
Архитектура модели, реализованная в классе SpineNet, построена по принципу условного выполнения и состоит из общего свёрточного энкодера и двух независимых «голов». Энкодер извлекает глобальные признаки изображения, после чего они передаются на классификатор и регрессор. Классификационная голова определяет факт наличия разворота, выдавая единственный логит. Регрессионная голова, применяющая сигмоиду на выходе, предсказывает те самые две нормализованные координаты линии разворота. Важной особенностью реализации является то, что регрессионная часть вычисляется только в том случае, если классификатор уверен в наличии разворота. Если разворот не обнаружен, модель сразу возвращает нулевые координаты, что позволяет избежать вычислительных затрат и паразитных предсказаний на фоне.
Обучение модели оптимизируется с помощью составной функции потерь SpineLoss, которая агрегирует ошибки классификации и регрессии с заданными весами. Для блока классификации используется бинарная кросс-энтропия с логитами, а для блока регрессии применяется SmoothL1Loss, менее чувствительный к грубым выбросам в координатах. Особенность этой функции потерь заключается в маскировании: ошибка регрессии вычисляется исключительно для тех изображений, на которых разворот присутствует.
import torch
import torch.nn as nn
class ConvBlock(nn.Module):
def __init__(self, in_ch, out_ch):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x):
return self.block(x)
class SpineNet(nn.Module):
def __init__(self, base: int = 32):
super().__init__()
self.encoder = nn.Sequential(
ConvBlock(1, base),
nn.MaxPool2d(2),
ConvBlock(base, base * 2),
nn.MaxPool2d(2),
ConvBlock(base * 2, base * 4),
nn.MaxPool2d(2),
ConvBlock(base * 4, base * 8),
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
)
feat = base * 8
self.cls_head = nn.Sequential(
nn.Linear(feat, 64),
nn.ReLU(inplace=True),
nn.Dropout(0.1),
nn.Linear(64, 1),
)
self.reg_head = nn.Sequential(
nn.Linear(feat, 128),
nn.ReLU(inplace=True),
nn.Dropout(0.1),
nn.Linear(128, 2),
nn.Sigmoid(),
)
def forward(self, x):
feat = self.encoder(x)
cls = self.cls_head(feat)
mask = (cls > 0).squeeze(-1)
reg = torch.zeros(cls.size(0), 2, device=cls.device, dtype=cls.dtype)
if mask.any():
reg[mask] = self.reg_head(feat[mask])
return torch.cat([cls, reg], dim=1)
Проблема: на скане проступает текст с оборотной стороны листа (просвечивание), затрудняющий чтение основного содержимого.
Жёсткая бинаризация по порогу 170: пиксели разделяются строго на чёрный (текст) и белый (фон). Полутона, которыми проявляется просвечивающий текст, уходят в белый класс и исчезают из результата.
def doit(img):
img = img.convert('L')
img_array = np.array(img)
binary_array = np.where(img_array <= 170, 0, 255).astype(np.uint8)
img = Image.fromarray(binary_array).convert('RGB')
return img
Проблема: широкие пустые поля занимают значительную часть кадра, уменьшая видимую площадь содержательной части скана.
Горизонтальная и вертикальная проекции бинаризованного изображения: строки/столбцы с достаточным количеством тёмных пикселей считаются содержательными. Изображение обрезается по крайним содержательным строкам/столбцам с небольшим отступом.
def doit(img):
binary = (np.array(img.convert('L')) <= 128)
h = binary.sum(1)
v = binary.sum(0)
rows = np.where(h > 0.1 * h.max())[0]
cols = np.where(v > 0.1 * v.max())[0]
l, r = cols[0] - 10, cols[-1] + 10
t, b = rows[0] - 10, rows[-1] + 10 return img.crop((l, t, r, b))
Проблема: разрушение растровой структуры символов при низком разрешении сканирования или компрессии — «битые» шрифты, потеря читаемости.
Для восстановления качества шрифтов применяется нейросетевой подход на основе свёрточной архитектуры BalancedTextSR с обучением на парах «низкое разрешение → высокое разрешение».
План реализации:
- Датасет: формирование пар изображений из директорий
bad_tiles_all и good_tiles_all с приведением к градациям серого и бинаризацией. - Модель: свёрточная сеть
BalancedTextSR с энкодером и декодером на основе пропускающих соединений. Количество параметров — 152,833. Выходные логиты преобразуются через сигмоиду. - Функция потерь: взвешенная бинарная кросс-энтропия с логитами, где положительный класс (текст) получает больший вес для компенсации дисбаланса.
- Оптимизация: AdamW с начальной скоростью обучения 1e-3 и динамическим снижением при затухании улучшений.
- Метрики: оценка качества по PSNR и SSIM на бинаризованных предсказаниях.
- Обучение: разделение на обучающую (80%) и валидационную (20%) выборки с сохранением лучшей модели.
◉ Данный инструмент находится в активной стадии разработки и тестирования.
Проблема: чёрные пятна, линии и другие артефакты сканирования, не пересекающиеся с текстом, — грязь на стекле сканера, повреждения носителя, пометки карандашом. Их необходимо удалить, не затронув содержательные символы.
Алгоритм работает в два этапа: сначала находит кандидатов на текстовые символы по геометрическим признакам (get_text_candidates), затем маскирует всё, что находится вне текстовых зон (text_mask), заменяя фоном — тем самым «стирая» артефакты, изолированные от текста.
Шаг 1 — поиск кандидатов на текст
def get_text_candidates(binary, min_area, max_area,
min_aspect=0.1, max_aspect=7.0,
solidity_thresh=0.25, fill_ratio_thresh=0.1):
contours, _ = cv2.findContours(binary,
cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area or area > max_area:
continue
x, y, rw, rh = cv2.boundingRect(cnt)
bbox_area = rw * rh
if bbox_area == 0: continue
aspect = rw / rh if rh > 0 else 0 if aspect < min_aspect or aspect > max_aspect: continue if aspect > 10.0 or aspect < 0.1: continue
hull = cv2.convexHull(cnt)
hull_area = cv2.contourArea(hull)
solidity = area / hull_area if hull_area > 0 else 0 if solidity < solidity_thresh: continue
fill_ratio = area / bbox_area
if fill_ratio < fill_ratio_thresh: continue
candidates.append({
'contour': cnt,
'bbox': (x, y, rw, rh),
'center': (x + rw//2, y + rh//2),
'area': area,
'aspect': aspect,
'solidity': solidity,
})
return candidates
Контуры отфильтровываются по пяти критериям: площадь (min_area / max_area), соотношение сторон (0.1–7.0 — характерно для букв), solidity — заполненность выпуклой оболочки (≥ 0.25 исключает нитевидные линии), fill_ratio — заполненность bbox (≥ 0.1 исключает рамки без содержимого). Оставшиеся контуры — вероятные символы.
Шаг 2 — маскирование и удаление артефактов
text_mask = cv2.bitwise_and(binary_clean, text_zone_mask)
if margin > 0:
kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (margin*2+1, margin*2+1))
text_mask = cv2.dilate(text_mask, kernel,
iterations=dilate_iterations)
result = np.array(img_rgb)
result[text_mask == 0] = (255, 255, 255)
return Image.fromarray(result)
Пересечение binary_clean и text_zone_mask оставляет тёмные пиксели только внутри текстовых зон. Морфологическое расширение (dilation) эллиптическим ядром добавляет «буфер» вокруг символов — чтобы не срезать штрихи на краях. Всё, что осталось за пределами маски, закрашивается белым: пятна и линии исчезают, текст остаётся нетронутым.