Бухгалтери витрачають до 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, інакше доопрацьовуємо безкоштовно. Зв'яжіться для розрахунку пілотного проекту. Отримайте консультацію з вашого завдання — ми запропонуємо оптимальний стек і терміни.







