Почему один 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
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.
Альтернатива: может, есть готовое решение?
Есть. 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 примерах. Вручную проверьте, что:
- PaddleOCR не пропускает текст (особенно мелкий или цветной)
- Qwen правильно классифицирует PII-типы (иногда "ул. Ленина, 15" он определяет как адрес, а иногда - как "other")
- Маскировка не задевает важные не-PII данные (подписи, печати, логотипы)
И помните: даже замаскированный документ может содержать метаданные (EXIF, автор, дата создания). Используйте exiftool для их очистки перед отправкой документа куда-либо.
Этот пайплайн - не идеальное решение. Это компромисс между точностью промышленных систем и приватностью локальной обработки. Но в 2026 году, когда каждый второй сервис хочет загрузить ваши документы "для анализа", такой компромисс - лучше, чем ничего.