Настройка локального OCR с Qwen 3 VL и PaddleOCR для скрытия PII в документах | AiManual
AiManual Logo Ai / Manual.
19 Фев 2026 Гайд

Локальный OCR с PII-маскировкой: зачем Qwen 3 VL не справляется в одиночку и как собрать гибридный пайплайн

Пошаговый гайд по созданию гибридного OCR-пайплайна с Qwen 3 VL 8B Instruct и PaddleOCR для обнаружения и маскировки персональных данных в документах локально.

Почему один Qwen 3 VL не спасет ваши документы от утечки PII

Вы скачали Qwen 3 VL 8B Instruct, запустили на своем MacBook Pro, загрузили сканы договоров и... получили текст. Отлично. Но где в этом тексте номера паспортов? Где телефоны клиентов? Где адреса, которые нельзя показывать даже коллегам из другого отдела?

Вот в чем проблема: современные Vision Language Models (VLM) вроде Qwen 3 VL - это гении контекста. Они понимают, что на картинке кот, а не собака. Они могут описать схему из учебника. Но когда дело доходит до точного, пиксельного определения где именно на документе находится чувствительная информация - они плывут. Буквально.

Qwen 3 VL 8B Instruct (последняя версия на февраль 2026) возвращает текст. Только текст. Не координаты bounding box'ов, не уверенность в распознавании каждого символа. Вы получаете красивый JSON с распознанными строками, но не знаете, какая строка - это номер кредитки, а какая - просто дата подписания.

Именно поэтому в гибридных IDP+VLM системах используют два движка: один для точного позиционирования текста, другой - для семантического понимания. Сегодня соберем именно такой пайплайн, но с фокусом на приватность.

Архитектура: что делает каждый компонент и почему их два

Представьте, что вам нужно не просто прочитать документ, а закрасить черным маркером все персональные данные. Сделать это вслепую нельзя - нужно сначала найти, что закрашивать.

Компонент Задача Почему именно он
PaddleOCR (последняя версия 2026) Точное обнаружение текстовых блоков с координатами (bounding boxes) Выдает координаты с точностью до пикселя. Знает, где каждая буква.
Qwen 3 VL 8B Instruct Семантический анализ: что в этих блоках - PII или нет Понимает контекст. Отличает "Иванов И.И." (PII) от "Иванов и партнеры" (не PII).
Скрипт-компоновщик Наложение масок на оригинальное изображение Берет координаты от PaddleOCR и решение от Qwen, рисует черные прямоугольники.

Звучит логично, но есть нюанс: PaddleOCR находит ВЕСЬ текст. Даже тот, который вам не нужен. Qwen должен пройтись по каждому блоку и сказать: "это имя - маскируй", "это просто текст статьи - оставь". Это десятки, иногда сотни запросов к модели. На ноутбуке.

1 Готовим окружение: что ставить, а что можно пропустить

Не повторяйте мою ошибку: не пытайтесь поставить PaddleOCR через pip install paddleocr. В 2026 году этот способ сломан для Apple Silicon. Вместо этого - используйте Docker или conda.

# Создаем изолированное окружение
conda create -n ocr-pii python=3.10 -y
conda activate ocr-pii

# PaddleOCR через conda (работает на M3/M4)
conda install paddlepaddle -c paddle -y
pip install "paddleocr>=2.7.0"

# Для Qwen 3 VL
pip install transformers torch torchvision torchaudio
pip install accelerate  # обязательно для 8GB RAM
pip install Pillow opencv-python
💡
Если у вас 8 ГБ памяти, как в базовом MacBook, прочитайте мой гайд про выжимание OCR из MacBook. Там те же принципы: 4-битная квантизация, акселерация через bitsandbytes, отключение ненужных слоев.

2 Первый этап: PaddleOCR вытаскивает все текстовые блоки

Здесь важно не просто получить текст, а получить его с привязкой к координатам. И сохранить оригинальное изображение в памяти - оно понадобится для маскировки.

from paddleocr import PaddleOCR
import cv2
import json

# Инициализируем с русским и английским языками
ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False)  # en работает и с русским

def extract_text_boxes(image_path):
    """Возвращает список текстовых блоков с координатами и текстом"""
    image = cv2.imread(image_path)
    result = ocr.ocr(image, cls=True)
    
    boxes = []
    if result is not None:
        for line in result[0]:
            box = line[0]  # координаты [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
            text = line[1][0]  # распознанный текст
            confidence = line[1][1]  # уверенность
            
            # Преобразуем в формат (x_min, y_min, x_max, y_max)
            xs = [point[0] for point in box]
            ys = [point[1] for point in box]
            x_min, x_max = min(xs), max(xs)
            y_min, y_max = min(ys), max(ys)
            
            boxes.append({
                'text': text,
                'bbox': [int(x_min), int(y_min), int(x_max), int(y_max)],
                'confidence': float(confidence)
            })
    
    return image, boxes

# Пример использования
img, text_boxes = extract_text_boxes('договор_сканированный.jpg')
print(f"Найдено {len(text_boxes)} текстовых блоков")

Обратите внимание на параметр show_log=False. Без него PaddleOCR завалит ваш терминал дебажными сообщениями. А еще - он умеет определять ориентацию текста (use_angle_cls=True), что критично для сканов, которые могли отсканировать криво.

3 Второй этап: Qwen 3 VL решает, что маскировать

Теперь у нас есть массив text_boxes. Каждый элемент - текст и его координаты. Нужно для каждого текста спросить у Qwen: "Содержит ли этот текст PII? Если да - какой тип?"

Но спрашивать по одному - слишком медленно. Группируем.

from transformers import Qwen2VLForConditionalGeneration, AutoTokenizer
import torch
from PIL import Image
import base64
from io import BytesIO

# Загружаем модель с 4-битной квантизацией (для 8GB RAM)
model_id = "Qwen/Qwen3-VL-8B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
model = Qwen2VLForConditionalGeneration.from_pretrained(
    model_id,
    torch_dtype=torch.float16,
    device_map="auto",
    load_in_4bit=True,  # Критично для маломощных систем
    trust_remote_code=True
)

def contains_pii(text):
    """Определяет, содержит ли текст PII и какой тип"""
    prompt = f"""Ты - анализатор конфиденциальных данных. Определи, содержит ли следующий текст персональные данные (PII).
Текст: {text}

Ответь строго в формате JSON:
{{
  "contains_pii": true/false,
  "pii_type": "none/name/phone/email/passport/address/credit_card/other",
  "reason": "краткое объяснение"
}}

Если содержит несколько типов PII, укажи основной."""
    
    # Qwen 3 VL ожидает мультимодальный ввод, но у нас только текст
    # Создаем пустое изображение 1x1 пиксель
    dummy_img = Image.new('RGB', (1, 1), color='white')
    
    messages = [
        {"role": "user", "content": [
            {"type": "image", "image": dummy_img},
            {"type": "text", "text": prompt}
        ]}
    ]
    
    text_prompt = tokenizer.apply_chat_template(
        messages, 
        tokenize=False, 
        add_generation_prompt=True
    )
    
    inputs = tokenizer(text_prompt, return_tensors="pt").to(model.device)
    
    with torch.no_grad():
        generated_ids = model.generate(
            **inputs,
            max_new_tokens=150,
            do_sample=False  # Для консистентности
        )
    
    response = tokenizer.decode(generated_ids[0], skip_special_tokens=True)
    
    # Парсим JSON из ответа
    try:
        # Ищем JSON в ответе
        import re
        json_match = re.search(r'\{.*\}', response, re.DOTALL)
        if json_match:
            import json as json_lib
            result = json_lib.loads(json_match.group())
            return result
        else:
            return {"contains_pii": False, "pii_type": "none", "reason": "Не удалось распарсить ответ"}
    except:
        return {"contains_pii": False, "pii_type": "none", "reason": "Ошибка парсинга"}

# Пример батчевой обработки
def analyze_boxes_for_pii(text_boxes, batch_size=5):
    """Анализирует текстовые блоки на наличие PII"""
    results = []
    
    for i in range(0, len(text_boxes), batch_size):
        batch = text_boxes[i:i+batch_size]
        for box in batch:
            analysis = contains_pii(box['text'])
            box['pii_analysis'] = analysis
            results.append(box)
        
        print(f"Обработано {min(i+batch_size, len(text_boxes))}/{len(text_boxes)} блоков")
    
    return results

Вот где собака зарыта: Qwen 3 VL ожидает и изображение, и текст. Но нам не нужно анализировать изображение - только текст. Поэтому мы создаем dummy-изображение 1x1 пиксель. Это хак, но он работает. В будущих версиях, возможно, появится текстовый-режим.

Почему do_sample=False? Потому что нам нужна детерминированность. Сегодня текст "Иванов Иван Иванович" - это PII, завтра - тоже PII. Нельзя позволить модели "сомневаться" в таких вещах.

4 Третий этап: маскировка и сохранение

Теперь у нас есть все данные: оригинальное изображение, координаты каждого текстового блока, и решение - маскировать его или нет. Осталось нарисовать черные прямоугольники.

def mask_pii_on_image(image, analyzed_boxes):
    """Накладывает черные маски на PII-данные"""
    masked_image = image.copy()
    
    for box in analyzed_boxes:
        if box.get('pii_analysis', {}).get('contains_pii', False):
            x_min, y_min, x_max, y_max = box['bbox']
            
            # Добавляем небольшой отступ вокруг текста
            padding = 3
            x_min = max(0, x_min - padding)
            y_min = max(0, y_min - padding)
            x_max = min(image.shape[1], x_max + padding)
            y_max = min(image.shape[0], y_max + padding)
            
            # Закрашиваем черным прямоугольником
            cv2.rectangle(masked_image, 
                         (x_min, y_min), 
                         (x_max, y_max), 
                         (0, 0, 0),  # Черный цвет
                         -1)  # Заполненный прямоугольник
    
    return masked_image

# Полный пайплайн
def process_document_for_pii(input_path, output_path):
    print("1. Извлекаем текст с координатами...")
    image, text_boxes = extract_text_boxes(input_path)
    
    print(f"2. Анализируем {len(text_boxes)} блоков на PII...")
    analyzed_boxes = analyze_boxes_for_pii(text_boxes)
    
    print("3. Применяем маски...")
    masked_image = mask_pii_on_image(image, analyzed_boxes)
    
    print(f"4. Сохраняем результат в {output_path}...")
    cv2.imwrite(output_path, masked_image)
    
    # Также сохраняем лог анализа
    log_path = output_path.replace('.jpg', '_analysis.json')
    with open(log_path, 'w', encoding='utf-8') as f:
        import json
        json.dump(analyzed_boxes, f, ensure_ascii=False, indent=2)
    
    print("Готово!")
    return masked_image, analyzed_boxes

# Запуск
masked_img, analysis = process_document_for_pii(
    'договор_оригинал.jpg',
    'договор_замаскированный.jpg'
)

Обратите внимание на padding = 3. Без отступа черный прямоугольник может попасть точно по границам текста, оставив видимыми края букв. Особенно это заметно на сканах низкого качества.

Где этот пайплайн ломается (и как чинить)

Я тестировал эту систему на реальных документах: договорах аренды, медицинских картах, резюме. Вот что не работает с первого раза:

  • Рукописный текст. PaddleOCR его видит плохо. Qwen - тоже. Решение: если у вас много рукописных данных, посмотрите мой тест про арабский OCR - там те же проблемы, но с другим алфавитом.
  • Таблицы. Текст в таблицах PaddleOCR разбивает на отдельные ячейки, но теряет структурную связь. Qwen получает "Иванов", "Иван", "Иванович" в трех разных запросах и не понимает, что это ФИО.
  • Скорость. На MacBook M3 Pro обработка страницы А4 занимает 45-60 секунд. Больше всего времени ест Qwen.
💡
Для ускорения можно кэшировать ответы Qwen. Создайте простую SQLite базу, где ключ - текст, значение - результат анализа PII. Большинство документов содержат повторяющиеся фразы ("Договор №", "Сторона 1", "Подпись"). Зачем анализировать их каждый раз?

Альтернатива: может, есть готовое решение?

Есть. Microsoft Presidio, Amazon Comprehend, Google DLP. Но они: 1) облачные, 2) дорогие, 3) не всегда понимают русский контекст. Российский аналог - SberVision (партнерская ссылка) - хорош, но тоже cloud-based.

Локальные альтернативы: spaCy с NER-моделями для русского. Но они работают только с текстом, не с изображениями. Вам все равно нужен OCR.

Именно поэтому гибридный подход - единственный вариант для полностью оффлайн обработки конфиденциальных документов. Особенно если вы работаете в юриспруденции, медицине или финансовом секторе, где данные физически не могут уходить в облако.

Что делать, если нужно не маскировать, а извлекать PII для базы данных?

Инвертируйте логику. Вместо того чтобы закрашивать черным прямоугольником, извлекайте текст из блоков, где contains_pii == true, и сохраняйте в структурированном виде.

def extract_pii_to_structured(analyzed_boxes):
    """Извлекает PII в структурированный формат"""
    pii_data = {
        'names': [],
        'phones': [],
        'emails': [],
        'passports': [],
        'addresses': [],
        'other': []
    }
    
    for box in analyzed_boxes:
        analysis = box.get('pii_analysis', {})
        if analysis.get('contains_pii', False):
            pii_type = analysis.get('pii_type', 'other')
            text = box['text']
            
            if pii_type == 'name' and text not in pii_data['names']:
                pii_data['names'].append(text)
            elif pii_type == 'phone':
                pii_data['phones'].append(text)
            # ... и так далее для других типов
    
    return pii_data

Теперь у вас есть JSON с извлеченными персональными данными, который можно импортировать в CRM или систему учета. Главное - храните этот JSON в зашифрованном виде. И не забывайте про GDPR/152-ФЗ.

Финальный совет: не доверяйте слепо ни PaddleOCR, ни Qwen

Перед тем как запускать пайплайн на тысячах документов, сделайте валидацию на 50-100 примерах. Вручную проверьте, что:

  1. PaddleOCR не пропускает текст (особенно мелкий или цветной)
  2. Qwen правильно классифицирует PII-типы (иногда "ул. Ленина, 15" он определяет как адрес, а иногда - как "other")
  3. Маскировка не задевает важные не-PII данные (подписи, печати, логотипы)

И помните: даже замаскированный документ может содержать метаданные (EXIF, автор, дата создания). Используйте exiftool для их очистки перед отправкой документа куда-либо.

Этот пайплайн - не идеальное решение. Это компромисс между точностью промышленных систем и приватностью локальной обработки. Но в 2026 году, когда каждый второй сервис хочет загрузить ваши документы "для анализа", такой компромисс - лучше, чем ничего.