Бухгалтеры тратят до 30% рабочего времени на ручной ввод данных из кассовых чеков и квитанций. При этом 3–5% записей содержат ошибки: пропущенная позиция, неверная сумма, потерянный ИНН. Чеки — самый сложный вид документов для OCR: термобумага со временем выгорает, качество печати низкое, форматы у каждого ритейлера свои, структура чека нелинейная (позиции, скидки, итоги могут идти в любом порядке). На датасете SROIE LayoutLMv3 даёт F1 0.974 — но это чистые, сканированные документы. На мобильных фотографиях реального качества — 0.87–0.91. Мы разработали pipeline, который поднимает F1 до 0.95+ на реальных данных за счёт агрессивной предобработки и гибридного парсинга (OCR + регулярок + LLM-верификации). Наш опыт включает 40+ проектов по извлечению данных из чеков, накладных и счетов.
Почему предобработка — основной источник ошибок?
Мобильная фотография чека страдает от: перспективного искажения, смазанности, тени от пальцев, пересветов. Без исправления PaddleOCR ошибается на 15–25% символов. После нашего pipeline CER снижается в 2–3 раза: с 4–5% до 1.5% на хороших фото, с 18% до 6% на плохих.
import cv2 import numpy as np from PIL import Image def preprocess_receipt_photo( image: np.ndarray, target_width: int = 768 # ширина нормализованного чека ) -> np.ndarray: """ Шаги: шумоподавление → выравнивание яркости → deskew → binarization """ # 1. Шумоподавление denoised = cv2.fastNlMeansDenoisingColored( image, h=10, hColor=10, templateWindowSize=7, searchWindowSize=21 ) # 2. CLAHE для выравнивания освещённости lab = cv2.cvtColor(denoised, cv2.COLOR_BGR2LAB) clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) enhanced = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) # 3. Автоматическое определение контура чека и deskew gray = cv2.cvtColor(enhanced, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150) contours, _ = cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if contours: # Находим самый большой контур — предположительно чек largest = max(contours, key=cv2.contourArea) hull = cv2.convexHull(largest) if cv2.contourArea(hull) > 0.1 * image.shape[0] * image.shape[1]: rect = cv2.minAreaRect(hull) angle = rect[2] if abs(angle) > 5: M = cv2.getRotationMatrix2D( (image.shape[1]//2, image.shape[0]//2), angle, 1 ) enhanced = cv2.warpAffine( enhanced, M, (image.shape[1], image.shape[0]) ) # 4. Адаптивная бинаризация для термопечати gray = cv2.cvtColor(enhanced, cv2.COLOR_BGR2GRAY) binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize=11, C=2 ) # 5. Нормализация ширины h, w = binary.shape scale = target_width / w resized = cv2.resize( binary, (target_width, int(h * scale)), interpolation=cv2.INTER_LANCZOS4 ) return resized Детали настройки окружения
Рекомендуем использовать Docker с CUDA 12.1 и драйвером NVIDIA >=525. Модель PaddleOCR v4 запускаем через ONNX Runtime для CPU, для GPU — через TensorRT.Как парсить структуру чека?
После OCR нужно разобрать структуру: строки товаров, скидки, итоги, кассир, ИНН.
import re from dataclasses import dataclass, field from typing import Optional @dataclass class ReceiptLineItem: name: str quantity: float unit_price: float total_price: float discount: float = 0.0 @dataclass class ParsedReceipt: store_name: Optional[str] inn: Optional[str] # ИНН продавца datetime_str: Optional[str] items: list[ReceiptLineItem] = field(default_factory=list) subtotal: Optional[float] = None tax_amount: Optional[float] = None total: Optional[float] = None payment_method: Optional[str] = None fiscal_sign: Optional[str] = None class ReceiptParser: # Паттерны для российских чеков (ФФД 1.05/1.1) PATTERNS = { 'inn': r'ИНН\s*[:№]?\s*(\d{10,12})', 'total': r'(?:ИТОГО|ИТОГО К ОПЛАТЕ|ИТОГ)\s*[:=]?\s*([\d\s,\.]+)', 'tax': r'(?:НДС|В т\.ч\. НДС)\s+\d+%\s*[:=]?\s*([\d\s,\.]+)', 'fiscal_sign': r'ФП\s*[::]?\s*(\d+)', 'line_item': r'^(.+?)\s+([\d,\.]+)\s*[xх×]\s*([\d,\.]+)\s*=?\s*([\d,\.]+)', 'datetime': r'(\d{2}[\.\/]\d{2}[\.\/]\d{4})\s+(\d{2}:\d{2}(?::\d{2})?)', } def parse(self, ocr_text: str) -> ParsedReceipt: lines = ocr_text.split('\n') receipt = ParsedReceipt( store_name=lines[0].strip() if lines else None, inn=self._extract(ocr_text, 'inn'), datetime_str=self._extract_datetime(ocr_text), total=self._parse_amount(self._extract(ocr_text, 'total')), tax_amount=self._parse_amount(self._extract(ocr_text, 'tax')), fiscal_sign=self._extract(ocr_text, 'fiscal_sign') ) for line in lines: item = self._parse_line_item(line) if item: receipt.items.append(item) return receipt def _extract(self, text: str, key: str) -> Optional[str]: m = re.search(self.PATTERNS[key], text, re.IGNORECASE) return m.group(1).strip() if m else None def _extract_datetime(self, text: str) -> Optional[str]: m = re.search(self.PATTERNS['datetime'], text) if m: return f'{m.group(1)} {m.group(2)}' return None def _parse_amount(self, text: Optional[str]) -> Optional[float]: if not text: return None cleaned = re.sub(r'\s', '', text).replace(',', '.') try: return float(cleaned) except ValueError: return None def _parse_line_item(self, line: str) -> Optional[ReceiptLineItem]: m = re.match(self.PATTERNS['line_item'], line.strip()) if not m: return None try: return ReceiptLineItem( name=m.group(1).strip(), quantity=float(m.group(2).replace(',', '.')), unit_price=float(m.group(3).replace(',', '.')), total_price=float(m.group(4).replace(',', '.')) ) except (ValueError, AttributeError): return None Классический подход — регулярки — работает для фиксированных форматов, но на 100+ сетях магазинов количество паттернов взрывается. Мы комбинируем regex-парсер с LLM-валидацией (GPT-4o или кастомная модель). Если регулярка не находит поле, запускается few-shot промпт для извлечения. Это делает парсер универсальным, но требует настройки на конкретный домен.
Сравнение подходов к извлечению полей
| Подход | F1 (ровные поля) | Скорость (мс на чек) | Стоимость (отн.) |
|---|---|---|---|
| Regex-only | 0.82 | 5 | Бесплатно |
| Regex + LLM validation | 0.94 | 300 | Средняя |
| End-to-end LLM (GPT-4o) | 0.97 | 3000 | Высокая |
Гибридный подход даёт оптимальный баланс: точность, близкую к LLM, при стоимости в 10 раз ниже.
Сравнение OCR-движков
| Движок | CER (хорошее фото) | CER (плохое фото) | Скорость | Относительная стоимость |
|---|---|---|---|---|
| Tesseract 5 (без препроц.) | 4.2% | 18.7% | 200ms | Бесплатно |
| Tesseract 5 + preprocessing | 1.8% | 8.3% | 350ms | Бесплатно |
| PaddleOCR v4 | 0.9% | 4.1% | 280ms | Бесплатно |
| Azure Read API | 0.6% | 2.8% | 1.2s | Средняя |
| GPT-4V | 0.4% | 1.9% | 3–5s | Высокая |
PaddleOCR v4 в 4.5 раза точнее Tesseract на плохих фото и при этом полностью бесплатен.
Как мы это делаем: стек и процесс
Стек: PaddleOCR v4 для детекции текста, кастомная модель на LayoutLMv3 для извлечения полей, PyTorch + ONNX Runtime для инференса на CPU. Для сложных случаев — GPT-4o как fallback. Векторизация эмбеддингов позиций через Hugging Face.
Процесс работы:
- Анализ выборки чеков заказчика (минимум 200 образцов).
- Разметка полей: дата, сумма, ИНН, позиции.
- Тренировка/дообучение модели на размеченных данных.
- Интеграция через REST API или библиотеку на Python.
- Пилот на реальных данных — оценка точности (F1) и скорости (p99 latency).
- Передача документации, обучение операторов, поддержка 1 месяц.
Сроки: от 2 недель для одной сети, от 6 недель для универсального решения.
Что входит в работу?
- Предобученная модель на ваших данных
- REST API с документацией (swagger)
- Обучение сотрудников (2 часа)
- Поддержка в течение 1 месяца
- Гарантия точности >95% на целевых форматах
Почему стоит заказать у нас?
5+ лет опыта в компьютерном зрении, 40+ внедрений OCR в финансах, логистике, ритейле. Сертифицированные партнёры OpenAI и Microsoft. Мы гарантируем, что F1 на ваших чеках будет не ниже 0.93, иначе дорабатываем бесплатно. Свяжитесь для расчёта пилотного проекта. Получите консультацию по вашей задаче — мы предложим оптимальный стек и сроки.







