Разработка AI для оцифровки бумажных архивов
Оцифровка архивов — задача от массовой обработки стандартных документов до сложной реставрации исторических рукописей. AI автоматизирует цепочку: сканирование → предобработка изображений → OCR → структурирование → индексация. Типичный объём промышленного архива: сотни тысяч страниц, которые вручную обрабатываются годами. AI-pipeline сокращает время в 10–50 раз при сопоставимом качестве.
Предобработка отсканированных документов
import cv2
import numpy as np
from PIL import Image, ImageEnhance
class DocumentPreprocessor:
"""
Предобработка влияет на качество OCR кардинально:
правильная бинаризация может поднять accuracy с 70% до 95%.
"""
def preprocess_scanned_page(self, image: np.ndarray,
dpi: int = 300) -> np.ndarray:
"""
Полный pipeline предобработки страницы.
DPI 300 минимум для OCR, 400+ для мелкого текста.
"""
# 1. Выравнивание наклона (deskew)
deskewed = self._deskew(image)
# 2. Удаление теней и неравномерного освещения
shadowless = self._remove_shadows(deskewed)
# 3. Адаптивная бинаризация (Sauvola)
binary = self._binarize_sauvola(shadowless)
# 4. Удаление шума и артефактов (пятна, пыль)
cleaned = self._remove_noise(binary)
# 5. Нормализация размера
if dpi != 300:
scale = 300 / dpi
h, w = cleaned.shape[:2]
cleaned = cv2.resize(cleaned, (int(w*scale), int(h*scale)),
interpolation=cv2.INTER_AREA)
return cleaned
def _deskew(self, image: np.ndarray) -> np.ndarray:
"""Коррекция угла наклона через Hough Transform"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
lines = cv2.HoughLines(edges, 1, np.pi/180, threshold=200)
if lines is None:
return image
angles = []
for line in lines[:20]: # берём только самые явные линии
rho, theta = line[0]
angle = np.degrees(theta) - 90
if abs(angle) < 45:
angles.append(angle)
if not angles:
return image
median_angle = np.median(angles)
if abs(median_angle) < 0.5: # незначительный наклон — пропускаем
return image
h, w = image.shape[:2]
M = cv2.getRotationMatrix2D((w/2, h/2), median_angle, 1.0)
return cv2.warpAffine(image, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
def _remove_shadows(self, image: np.ndarray) -> np.ndarray:
"""Устранение теней через CLAHE + морфология"""
if len(image.shape) == 3:
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
else:
l = image
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
l_enhanced = clahe.apply(l)
# Вычитание градиента освещённости
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 50))
bg = cv2.morphologyEx(l_enhanced, cv2.MORPH_DILATE, kernel)
normalized = cv2.divide(l_enhanced, bg, scale=255)
if len(image.shape) == 3:
return cv2.cvtColor(cv2.merge([normalized, a, b]), cv2.COLOR_LAB2BGR)
return normalized
def _binarize_sauvola(self, image: np.ndarray,
window_size: int = 25,
k: float = 0.2) -> np.ndarray:
"""
Sauvola бинаризация: лучше Otsu для неравномерного фона.
window_size=25 — оптимум для большинства текстовых документов.
"""
from skimage.filters import threshold_sauvola
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image
thresh = threshold_sauvola(gray, window_size=window_size, k=k)
binary = (gray > thresh).astype(np.uint8) * 255
return binary
def _remove_noise(self, binary: np.ndarray,
min_blob_area: int = 20) -> np.ndarray:
"""Удаление мелких артефактов (пыль, царапины)"""
# Находим связные компоненты
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
binary, connectivity=8
)
# Удаляем компоненты меньше min_blob_area пикселей
clean = np.zeros_like(binary)
for i in range(1, num_labels):
if stats[i, cv2.CC_STAT_AREA] >= min_blob_area:
clean[labels == i] = 255
return clean
Пакетная обработка с очередью
import asyncio
from pathlib import Path
from paddleocr import PaddleOCR
import json
import sqlite3
class ArchiveDigitizationPipeline:
def __init__(self, db_path: str, output_dir: str,
lang: str = 'ru'):
self.ocr = PaddleOCR(use_angle_cls=True, lang=lang,
use_gpu=True, show_log=False)
self.preprocessor = DocumentPreprocessor()
self.db = sqlite3.connect(db_path)
self.output_dir = Path(output_dir)
self._init_db()
def _init_db(self):
self.db.execute('''
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
file_path TEXT UNIQUE,
status TEXT DEFAULT 'pending',
ocr_text TEXT,
metadata TEXT,
processed_at TIMESTAMP
)
''')
self.db.commit()
def process_batch(self, image_paths: list[str],
n_workers: int = 4) -> dict:
"""Параллельная обработка пакета документов"""
from concurrent.futures import ProcessPoolExecutor
from tqdm import tqdm
results = {'success': 0, 'failed': 0, 'errors': []}
with ProcessPoolExecutor(max_workers=n_workers) as executor:
futures = {
executor.submit(self._process_single, p): p
for p in image_paths
}
for future in tqdm(futures, total=len(futures),
desc='Digitizing archive'):
path = futures[future]
try:
result = future.result(timeout=120)
self._save_to_db(path, result)
results['success'] += 1
except Exception as e:
results['failed'] += 1
results['errors'].append(str(e))
return results
def _process_single(self, image_path: str) -> dict:
import cv2
image = cv2.imread(image_path)
preprocessed = self.preprocessor.preprocess_scanned_page(image)
ocr_result = self.ocr.ocr(preprocessed, cls=True)
if not ocr_result or not ocr_result[0]:
return {'text': '', 'lines': [], 'confidence': 0}
lines = []
confidences = []
for line in ocr_result[0]:
bbox, (text, conf) = line
lines.append({'text': text, 'confidence': conf, 'bbox': bbox})
confidences.append(conf)
full_text = '\n'.join(l['text'] for l in lines)
mean_confidence = float(np.mean(confidences)) if confidences else 0
return {
'text': full_text,
'lines': lines,
'confidence': mean_confidence,
'low_quality': mean_confidence < 0.7
}
Полнотекстовая индексация результатов
import elasticsearch
class ArchiveSearchIndex:
def __init__(self, es_url: str, index_name: str = 'archive'):
self.es = elasticsearch.Elasticsearch([es_url])
self.index = index_name
self._create_index()
def _create_index(self):
mapping = {
'mappings': {
'properties': {
'file_path': {'type': 'keyword'},
'text': {
'type': 'text',
'analyzer': 'russian'
},
'confidence': {'type': 'float'},
'metadata': {'type': 'object'},
'processed_at': {'type': 'date'}
}
}
}
if not self.es.indices.exists(index=self.index):
self.es.indices.create(index=self.index, body=mapping)
def index_document(self, doc_id: str, text: str,
file_path: str, metadata: dict = None):
self.es.index(index=self.index, id=doc_id, body={
'text': text, 'file_path': file_path,
'metadata': metadata or {}
})
def search(self, query: str, size: int = 10) -> list:
result = self.es.search(index=self.index, body={
'query': {'match': {'text': query}},
'highlight': {'fields': {'text': {}}},
'size': size
})
return result['hits']['hits']
| Метрика |
Типографский текст |
Рукопись |
Исторические документы |
| OCR CER (хорошее качество) |
0.5–1.5% |
3–8% |
5–20% |
| OCR CER (плохое качество) |
2–5% |
8–20% |
15–40% |
| После AI постобработки |
0.3–1% |
2–6% |
3–15% |
| Объём архива |
Срок |
| 10,000–50,000 страниц (стандартные документы) |
4–8 недель |
| 100,000+ страниц с pipeline и индексацией |
10–18 недель |
| Исторический архив с рукописями и реставрацией |
20–36 недель |