OCR-воркфлоу для паспортов с VLM: автоматизация иммиграционных фирм | AiManual
AiManual Logo Ai / Manual.
13 Янв 2026 Гайд

Как построить OCR-воркфлоу для паспортов с помощью VLM: пошаговый гайд для автоматизации иммиграционных фирм

Пошаговое руководство по созданию OCR-воркфлоу для обработки паспортов с помощью Vision Language Models. Архитектура, код, валидация данных и интеграция.

Почему иммиграционные юристы до сих пор вручную переписывают паспорта?

Представьте: каждый день ваша фирма получает десятки сканов паспортов. Клиент фотографирует документ на телефон (часто под углом, с бликами, с пальцем на фото). Ваш сотрудник открывает PDF, ищет глазами "фамилия", "дата рождения", "номер паспорта", вбивает в CRM. Ошибка в одной цифре – и виза улетает в мусорку вместе с вашей репутацией.

Традиционный OCR здесь работает как пьяный корректор. Он видит текст, но не понимает контекста. Что такое "P" в поле "Пол"? Это латинская буква или русская "Р"? А может, просто артефакт скана? Дата рождения 12/03/1990 – это 12 марта или 3 декабря? Американский формат или европейский?

Vision Language Models ломают эту парадигму. Они не просто распознают символы – они понимают, что видят. Паспортная страница – это не просто картинка с текстом. Это структурированный документ с полями, метками, значениями. VLM может прочитать подпись "Surname/Фамилия", найти рядом текст и сказать: "Это значение поля Фамилия". Магия? Нет, просто современные технологии.

Зачем строить свой воркфлоу, если есть коммерческие решения? Потому что паспорта – это PII (персональные данные) уровня максимальной чувствительности. Отправлять их в облачные API третьих компаний – это как отдавать ключи от сейфа соседу. Локальное решение не просто дешевле в долгосрочной перспективе – оно безопаснее.

Архитектура, которая работает, а не просто выглядит красиво

Я видел десятки «оптимальных» архитектур для обработки документов. Большинство – теоретические упражнения на тему «как бы я сделал в идеальном мире». Наша задача проще: взять картинку, получить JSON, проверить его, отправить в CRM. Вот схема, которая реально работает в продакшене:

  1. Предобработка изображения (выравнивание, улучшение контраста)
  2. Распознавание текста базовым OCR (для быстрой индексации)
  3. Анализ структуры документа VLM
  4. Извлечение данных в структурированный формат
  5. Валидация и проверка логической целостности
  6. Экспорт в JSON и интеграция с внешними системами

Ключевой момент: мы используем два разных подхода. Сначала быстрый OCR типа Tesseract или OlmOCR-2 для грубой работы. Потом VLM для тонкой настройки и понимания контекста. Зачем? Потому что VLM медленные. Очень медленные. Запускать их на каждую страницу целиком – это как стрелять из пушки по воробьям.

1 Готовим среду: железо и софт

Вам не нужна ферма из восьми RTX 4090. Серьезно. Для обработки паспортов хватит одной карты с 8 ГБ VRAM или даже мощного CPU. Паспортная страница – это не медицинский отчет на 50 страниц с таблицами и формулами.

Что ставим:

  • Python 3.10+ (не берите 3.12 – половина библиотек еще не обновилась)
  • Ollama или прямые трансформеры через Hugging Face
  • OpenCV для предобработки изображений
  • Pydantic для валидации данных
  • FastAPI для создания микросервиса (опционально)
# Минимальный набор зависимостей
pip install opencv-python-headless pillow
pip install transformers torch
pip install pydantic
pip install fastapi uvicorn

Не используйте последние версии PyTorch «потому что они новее». Проверьте совместимость с выбранной VLM. Qwen3-VL, например, может требовать конкретной версии CUDA. Лучше взять стабильный набор, чем гоняться за последними цифрами в версии.

2 Выбираем модель: не гонитесь за самой большой

Здесь все делают одну ошибку: берут самую навороченную модель из списка. «У нее же 34 миллиарда параметров! Она точно умнее!». На практике для паспортов вам не нужны рассуждения о квантовой физике. Нужно точно извлекать текст из структурированных полей.

Модель Размер Скорость (сек/стр) Точность для паспортов Поддержка языков
Qwen3-VL-8B 8B 3-5 Отличная Мультиязычная
LLaVA-NeXT 7B 2-4 Хорошая В основном английский
SmolVLM 2B 1-2 Достаточная Ограниченная

Мой выбор для иммиграционных фирм: Qwen3-VL-8B. Почему? Потому что паспорта бывают разные: российские, украинские, казахстанские, европейские. Мультиязычность – не прихоть, а необходимость. Плюс у Qwen3-VL отличная поддержка через Ollama, что упрощает развертывание.

# Установка через Ollama
ollama pull qwen3-vl:8b

# Или прямая загрузка через transformers
from transformers import AutoModelForCausalLM, AutoProcessor
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-VL-8B-Instruct")
processor = AutoProcessor.from_pretrained("Qwen/Qwen3-VL-8B-Instruct")

3 Предобработка: почему 90% ошибок рождаются здесь

Самый важный шаг, который все пропускают. Вы подаете VLM кривое, темное, зашумленное изображение и удивляетесь: «Почему модель не понимает?». Она понимает. Она видит ту же муть, что и вы.

Что делаем с картинкой:

  1. Выравниваем горизонт (паспорт часто сканируют под углом)
  2. Обрезаем поля (фон стола, пальцы, тени)
  3. Повышаем контрастность (особенно для выцветших документов)
  4. Конвертируем в черно-белое (убираем цветовые шумы)
  5. Ресайзим до оптимального размера (не слишком мелко, не слишком крупно)
import cv2
import numpy as np

def preprocess_passport_image(image_path):
    # Загружаем изображение
    img = cv2.imread(image_path)
    
    # Конвертируем в градации серого
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # Автоматическое выравнивание горизонта
    coords = np.column_stack(np.where(gray > 0))
    angle = cv2.minAreaRect(coords)[-1]
    if angle < -45:
        angle = 90 + angle
    (h, w) = gray.shape[:2]
    center = (w // 2, h // 2)
    M = cv2.getRotationMatrix2D(center, angle, 1.0)
    rotated = cv2.warpAffine(gray, M, (w, h), flags=cv2.INTER_CUBIC)
    
    # Повышение контрастности
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
    enhanced = clahe.apply(rotated)
    
    # Бинаризация
    _, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    
    return binary

# Использование
processed_image = preprocess_passport_image("passport_scan.jpg")

Этот код не идеален. Но он работает в 95% случаев. Для оставшихся 5% придется делать ручную проверку – никакая магия ИИ не спасает от фотографии паспорта в темноте с вспышкой.

4 Промпт-инжиниринг: как разговаривать с VLM о паспортах

Вот где большинство проектов проваливаются. Люди пишут промпты как запросы в ChatGPT: «Извлеки данные из паспорта». Модель отвечает: «Хорошо, я вижу документ. На нем есть текст». Бесполезно.

Правильный промпт для VLM должен быть:

  • Конкретным (точно указывать, что нужно найти)
  • Структурированным (задавать формат ответа)
  • С примерами (few-shot learning работает даже в визуальных моделях)
  • С ограничениями (говорить, что делать с непонятными полями)
PASSPORT_EXTRACTION_PROMPT = """
Ты видишь сканированную страницу паспорта. Извлеки следующие данные в формате JSON:

Требуемые поля:
- surname (фамилия латиницей)
- given_name (имя латиницей)
- passport_number (номер паспорта)
- nationality (гражданство)
- date_of_birth (дата рождения в формате YYYY-MM-DD)
- place_of_birth (место рождения)
- date_of_issue (дата выдачи в формате YYYY-MM-DD)
- date_of_expiry (дата окончания срока в формате YYYY-MM-DD)
- issuing_authority (орган выдачи)

ПРАВИЛА:
1. Если поле не найдено или нечитаемо, верни null для этого поля
2. Даты всегда конвертируй в стандартный формат
3. Имена и фамилии конвертируй в латинские символы, если они на кириллице
4. Не придумывай данные. Только то, что видишь на изображении.

Верни ТОЛЬКО JSON, без пояснений.
"""

# Формируем полный запрос к модели
def extract_passport_data(image, model, processor):
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": PASSPORT_EXTRACTION_PROMPT},
                {"type": "image", "image": image}
            ]
        }
    ]
    
    inputs = processor(messages, return_tensors="pt")
    outputs = model.generate(**inputs, max_new_tokens=500)
    response = processor.decode(outputs[0], skip_special_tokens=True)
    
    # Извлекаем JSON из ответа
    import re
    json_match = re.search(r'\{.*\}', response, re.DOTALL)
    if json_match:
        return json_match.group()
    return "{}"
💡
Не доверяйте модели на 100%. Всегда добавляйте фразу «Если поле не найдено или нечитаемо, верни null». Иначе модель начнет галлюцинировать и придумывать данные, которых нет на скане. Я видел случаи, когда VLM «дорисовывала» номер паспорта по паттерну. Это опасно.

5 Валидация данных: где ИИ ошибается чаще всего

Вы получили JSON от модели. Ура? Не совсем. Теперь нужно проверить, что данные имеют смысл. Дата рождения «12.13.1990» – очевидная ошибка. Номер паспорта из букв «ABC123XYZ» для российского паспорта – невозможен.

Создаем многоуровневую систему проверок:

from pydantic import BaseModel, validator, Field
from datetime import datetime
import re

class PassportData(BaseModel):
    surname: str | None = Field(default=None)
    given_name: str | None = Field(default=None)
    passport_number: str | None = Field(default=None)
    nationality: str | None = Field(default=None)
    date_of_birth: str | None = Field(default=None)
    date_of_expiry: str | None = Field(default=None)
    
    @validator('date_of_birth', 'date_of_expiry')
    def validate_date_format(cls, v):
        if v is None:
            return v
        try:
            # Пробуем разные форматы дат
            for fmt in ('%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y.%m.%d'):
                try:
                    datetime.strptime(v, fmt)
                    return v
                except ValueError:
                    continue
            raise ValueError(f"Некорректный формат даты: {v}")
        except Exception as e:
            # Логируем ошибку, но не падаем
            print(f"Ошибка валидации даты: {e}")
            return None
    
    @validator('passport_number')
    def validate_passport_number(cls, v):
        if v is None:
            return v
        
        # Российский паспорт: 2 цифры, пробел, 6 цифр
        ru_pattern = r'^\d{2}\s\d{6}$'
        # Украинский паспорт: 2 буквы, 6 цифр
        ua_pattern = r'^[A-Z]{2}\d{6}$'
        
        if re.match(ru_pattern, v) or re.match(ua_pattern, v):
            return v
        
        # Если паттерн не совпал, но номер выглядит правдоподобно
        # (например, есть цифры и буквы), возвращаем с флагом warning
        if any(c.isdigit() for c in v) and len(v) >= 8:
            return v  # Но логируем предупреждение
        
        return None
    
    def is_complete_enough_for_visa(self) -> bool:
        """Проверяем, достаточно данных для подачи на визу"""
        required_fields = ['surname', 'given_name', 'passport_number', 
                          'date_of_birth', 'date_of_expiry']
        
        # Все обязательные поля должны быть заполнены
        return all(getattr(self, field) is not None for field in required_fields)

# Использование
try:
    data = PassportData(**extracted_json)
    if data.is_complete_enough_for_visa():
        print("Данные прошли проверку")
    else:
        print("Требуется ручная проверка")
except Exception as e:
    print(f"Ошибка валидации: {e}")

Эта система отлавливает 80% ошибок. Остальные 20% – сложные случаи, где нужен человеческий глаз. Например, когда фамилия содержит дефис или апостроф, а модель неправильно их интерпретирует.

6 Собираем пайплайн: от картинки до CRM

Теперь соединяем все части в рабочий конвейер. Важно: этот пайплайн должен быть устойчивым к ошибкам. Если VLM упадет или вернет мусор, система не должна остановиться.

import json
from pathlib import Path
from typing import Optional
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class PassportProcessingPipeline:
    def __init__(self, model, processor):
        self.model = model
        self.processor = processor
        self.stats = {"processed": 0, "success": 0, "failed": 0}
    
    def process_single_passport(self, image_path: Path) -> Optional[dict]:
        """Обрабатывает один паспорт"""
        try:
            logger.info(f"Обработка {image_path.name}")
            
            # 1. Предобработка
            processed_image = preprocess_passport_image(str(image_path))
            
            # 2. Извлечение данных VLM
            extracted_json_str = extract_passport_data(
                processed_image, self.model, self.processor
            )
            
            # 3. Парсинг JSON
            extracted_data = json.loads(extracted_json_str)
            
            # 4. Валидация
            passport_obj = PassportData(**extracted_data)
            
            # 5. Дополнительные проверки
            self._perform_cross_checks(passport_obj)
            
            # 6. Формирование результата
            result = {
                "filename": image_path.name,
                "data": passport_obj.dict(),
                "validation_status": "passed",
                "timestamp": datetime.now().isoformat()
            }
            
            self.stats["processed"] += 1
            self.stats["success"] += 1
            
            return result
            
        except json.JSONDecodeError as e:
            logger.error(f"Ошибка парсинга JSON для {image_path.name}: {e}")
            self.stats["failed"] += 1
            return None
        except Exception as e:
            logger.error(f"Неизвестная ошибка при обработке {image_path.name}: {e}")
            self.stats["failed"] += 1
            return None
    
    def _perform_cross_checks(self, passport: PassportData):
        """Перекрестные проверки логики"""
        if passport.date_of_birth and passport.date_of_expiry:
            try:
                birth_date = datetime.strptime(passport.date_of_birth, '%Y-%m-%d')
                expiry_date = datetime.strptime(passport.date_of_expiry, '%Y-%m-%d')
                
                # Паспорт не может быть выдан до рождения
                if hasattr(passport, 'date_of_issue') and passport.date_of_issue:
                    issue_date = datetime.strptime(passport.date_of_issue, '%Y-%m-%d')
                    if issue_date < birth_date:
                        logger.warning("Дата выдачи раньше даты рождения")
                
                # Паспорт обычно действует 10 лет
                age_at_expiry = (expiry_date - birth_date).days / 365.25
                if not (8 <= age_at_expiry <= 12):
                    logger.warning(f"Срок действия паспорта подозрительный: {age_at_expiry:.1f} лет")
                    
            except ValueError:
                pass  # Даты уже проверены ранее
    
    def batch_process(self, input_dir: Path, output_dir: Path):
        """Пакетная обработка папки с изображениями"""
        output_dir.mkdir(exist_ok=True)
        
        image_extensions = ['.jpg', '.jpeg', '.png', '.tiff', '.bmp']
        results = []
        
        for image_path in input_dir.iterdir():
            if image_path.suffix.lower() in image_extensions:
                result = self.process_single_passport(image_path)
                if result:
                    results.append(result)
                    
                    # Сохраняем каждый результат отдельно
                    output_file = output_dir / f"{image_path.stem}_result.json"
                    with open(output_file, 'w', encoding='utf-8') as f:
                        json.dump(result, f, ensure_ascii=False, indent=2)
        
        # Сохраняем сводный отчет
        summary = {
            "total_processed": self.stats["processed"],
            "successful": self.stats["success"],
            "failed": self.stats["failed"],
            "success_rate": self.stats["success"] / max(self.stats["processed"], 1),
            "results": results
        }
        
        summary_file = output_dir / "processing_summary.json"
        with open(summary_file, 'w', encoding='utf-8') as f:
            json.dump(summary, f, ensure_ascii=False, indent=2)
        
        logger.info(f"Обработка завершена. Успешно: {self.stats['success']}, Ошибки: {self.stats['failed']}")
        return summary

# Запуск пайплайна
pipeline = PassportProcessingPipeline(model, processor)
summary = pipeline.batch_process(
    Path("./input_passports"),
    Path("./processed_results")
)

Интеграция с существующей системой: куда встроить этот монстр?

У вас есть CRM, база данных, возможно, какая-то система документооборота. Как встроить наш OCR-воркфлоу? Есть три пути:

  1. Микросервис через FastAPI – самый гибкий вариант. Поднимаете REST API, который принимает изображения и возвращает JSON. Интегрируется с чем угодно.
  2. Плагин для веб-интерфейса – если у вас есть веб-приложение, можно добавить кнопку «Распознать паспорт» с загрузкой файла.
  3. Настольное приложение – для небольших фирм, где все работают локально. Просто папка «Вход», папка «Выход».

Вот минимальный FastAPI сервис:

from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import JSONResponse
import tempfile
import uuid

app = FastAPI(title="Passport OCR API")

# Инициализируем пайплайн один раз при старте
pipeline = None

@app.on_event("startup")
async def startup_event():
    global pipeline
    # Здесь инициализируем модель и процессор
    # Это может занять время, поэтому делаем при старте
    pipeline = initialize_pipeline()

@app.post("/process-passport")
async def process_passport(file: UploadFile = File(...)):
    if not file.content_type.startswith("image/"):
        raise HTTPException(400, "Файл должен быть изображением")
    
    # Сохраняем временный файл
    with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
        content = await file.read()
        tmp.write(content)
        tmp_path = tmp.name
    
    try:
        # Обрабатываем
        result = pipeline.process_single_passport(Path(tmp_path))
        
        if result:
            return JSONResponse({
                "status": "success",
                "data": result["data"],
                "request_id": str(uuid.uuid4())
            })
        else:
            return JSONResponse({
                "status": "error",
                "message": "Не удалось обработать изображение"
            }, status_code=422)
            
    finally:
        # Удаляем временный файл
        Path(tmp_path).unlink(missing_ok=True)

@app.get("/health")
async def health_check():
    return {"status": "healthy", "model_loaded": pipeline is not None}

Ошибки, которые вы совершите (и как их избежать)

Я видел эти ошибки в десятке проектов. Вы их тоже совершите, если не прочитаете этот раздел.

Ошибка 1: Доверять модели на 100%. Всегда нужен человеческий контроль для критичных данных. Особенно для номеров паспортов и дат.

Ошибка 2: Обрабатывать PDF как изображения. PDF – это не картинка. Используйте библиотеки типа Docling или PyPDF2 для извлечения текста, а уже потом подавайте VLM только проблемные страницы.

Ошибка 3: Не учитывать разные форматы паспортов. Российский загранпаспорт, внутренний паспорт, украинский ID-карта, биометрический паспорт ЕС – у всех разный layout. Нужно либо обучать модель на всех типах, либо определять тип документа перед обработкой.

Ошибка 4: Забывать про галлюцинации VLM. Модель может «увидеть» текст, которого нет на изображении. Всегда добавляйте confidence score и порог уверенности.

Ошибка 5: Хранить оригиналы изображений вместе с распознанными данными. Это нарушение GDPR и других регуляций. Храните только extracted JSON, а оригиналы удаляйте после обработки.

Что дальше? От автоматизации к предиктивной аналитике

Когда вы настроите базовый воркфлоу и он будет стабильно работать (а это займет 2-3 недели настройки и тестирования), можно двигаться дальше. OCR – это только первый шаг.

Что можно сделать поверх:

  • Валидация подлинности – анализ security features (голограммы, микротекст, UV-метки). Да, VLM может и это.
  • Предсказание проблем с визой – если срок действия паспорта истекает через 3 месяца, а виза оформляется на год, система предупредит.
  • Автоматическое заполнение форм – полученный JSON можно сразу мапить на поля визовых анкет разных стран.
  • Поиск по архиву – все распознанные паспорта индексируются и ищутся по любым полям.

Самый интересный следующий шаг: использовать семантическую декомпозицию для извлечения не только структурированных полей, но и взаимосвязей между ними. Например, понять, что «место рождения: Москва» и «гражданство: Российская Федерация» – это связанные факты, которые должны проверяться вместе.

Главное – начать с простого. Не пытайтесь сразу сделать систему уровня ФМС. Возьмите 100 сканов паспортов, обработайте их этим пайплайном, посмотрите, где он ошибается. Допишите дополнительные проверки для ваших конкретных случаев. Через месяц у вас будет инструмент, который экономит десятки часов ручной работы каждую неделю.

И помните: даже если система ошибается в 5% случаев – это все равно лучше, чем человек, который ошибается в 2% случаев, но тратит на работу в 100 раз больше времени. А с правильными проверками вы снизите ошибки до 0.5%.