Документы надо отдать наружу, персональные данные из них убрать: фамилии, телефоны, адреса, подписи. Всё в закрытом контуре, без интернета, на одной машине с одной видеокартой.
С фамилиями я разобрался за неделю. С подписями возился полтора месяца, и половина этого времени ушла на то, чтобы перепробовать и отбросить очевидные решения. Дальше рассказываю по замерам: что пробовал, что получилось в цифрах и к чему пришёл.
Все фамилии, организации и картинки выдуманы.
Развилка, из которой всё растёт
Документ бывает двух видов. У текстовой страницы есть текстовый слой: слово достаётся и удаляется вместе с символами. Скан это картинка, её распознаёт тессеракт, и «удалить» здесь значит закрасить чёрным то место, где слово было видно.
Ищется всё четырьмя способами сразу: словарь, регулярки, NER на natasha и гейт чернил для графики. С текстом это работает, но ни одной подписи не убирает: правила ищут по тексту, а подпись не текст.

Что видит детектор
Синие рамки это рукопись, оранжевая эмблема, зелёная печать. Уверенность 0,52–0,71: по обычным меркам это очень мало, а рабочий порог у меня и вовсе 0,05. Ниже объясню, почему это не опечатка.

Что получилось
Фамилии рядом с подписями закрыл не детектор, а правила со словарём. Одно имя во всём документе получает одну метку, иначе результат нечитаем.
Гейт чернил и чёрно-белый скан
Первым решением был поиск насыщенного цвета. Синий росчерк находится прекрасно. Потом пришёл документ, подписанный чёрной ручкой.
детектор на обесцвеченной странице |
найдено областей |
|---|---|
гейт чернил |
0 |
нейросеть |
7 |
Насыщенного цвета нет не только у чёрной ручки. Его нет ни у чего на чёрно-белом скане, поэтому на таком скане гейт чернил не находит вообще ничего.
До этого я пробовал эвристику «тёмное пятно, которое OCR не прочитал, красим». Один раз она стёрла весь левый столбец таблицы: он был набран мелко, тессеракт его не разобрал. Правило, которое я больше не нарушал: красим только опознанное.
Готовые детекторы: взял три, выбросил два
Отрицательный контроль: 32 документа, 1254 страницы, из них 721 скан. Находки по краям (титул, листы согласования и регистрации) полезны, находки в середине сплошного текста означают испорченную страницу.
детектор |
находки по краям |
находки в середине |
с/стр |
|---|---|---|---|
YOLOS |
183 |
18 (14,1%) |
1,40 |
yolov8s |
109 |
1 (0,8%) |
0,13 |
yolov8s + плитки |
134 |
3 (2,3%) |
1,01 |
YOLOS портит каждую седьмую страницу и вдесятеро медленнее: он обводит чертёж ротора и стрелки блок-схемы, так что его я выключил. Детектор печатей на документе без единой печати нашёл четыре с уверенностью 0,82–0,98. Порогом это не лечится: если поднять его до 0,9, отсекутся настоящие печати, а три ложные останутся. Его я тоже выключил.
Два бага с поворотом, которые все путают

Промах из-за поворота
Первый: /Rotate у страницы. Библиотека отдаёт картинку уже повёрнутой, а закраска ждёт координаты в неповёрнутом пространстве. Фамилия при этом найдена правильно, и координаты её рамки тоже правильные, просто они посчитаны для неповёрнутой страницы. В итоге прямоугольник ложится не на фамилию, а рядом: фамилия остаётся открытой, а закрашенным оказывается соседний абзац, в котором ничего личного не было. Исправляется это одной матрицей обратного поворота, но по результату догадаться о причине почти невозможно.
Второй: страницу положили в сканер боком. Тут /Rotate нет, страница ровная, текст идёт снизу вверх, и ориентацию определяешь сам через OSD тессеракта. Замерил: ориентацию он определяет правильно, а прочитать не может ничего ни в какой, от одного до восьми мусорных слов (Gy,,, PoP, BENGE). Форма заполнена от руки, а рукопись тессеракт не читает в принципе. Определение ориентации я оставил выключенным, и в августе, на прогоне по всему корпусу, это аукнулось. Об этом ниже.
Своя модель и две ошибки в замере
Настоящих страниц было мало, поэтому росчерк вырезается один раз и наклеивается на чистые в разных местах, масштабах и поворотах: разметка при этом известна точно.

Как размножали датасет
Страницы отбирал глазами, и тут была ловушка: автоматическая опись «где больше всего росчерков» ставила на первое место страницу с графиками, потому что эвристика считает росчерками кривые.
600 размноженных картинок плюс 40 настоящих, 12 отложенных настоящих страниц на проверку, четыре класса, база YOLO11s, GTX 1660 Ti.
класс |
первый заход |
после разметки по одной подписи |
|---|---|---|
эмблема, штамп |
0,995 |
0,995 |
рукопись |
0,388 → покрытие 63% |
88% |
чертёж |
0,249 → 33% |
89% |
закрашено листа |
30% |
26% |
Первый заход провалился не из-за размера выборки. Детектор чернил, которым я делал черновик разметки, слепляет соприкасающиеся подписи в одну рамку на всю колонку. Модель училась на аккуратных росчерках, а проверялась на слипшихся блобах.
Две ошибки в замере отняли у меня вечер. Отбор весов по mAP выбирает не те веса: mAP одинаково штрафует пропуск и лишнюю рамку, а у меня цены разные, пропущенная подпись это утечка за периметр, лишняя закраска просто больше чёрного. Отсюда порог 0,05. Сумма площадей рамок обманывает: рамки накладываются, и по сумме выходило, что модель закрашивает 179% листа. По объединению 26%.
Vision-модель: сначала везде, потом нигде
политика |
вызовов на документ |
время |
|---|---|---|
|
1123 |
~8 часов |
|
~45 |
~20 минут |
|
~7 |
3–5 минут |
Один документ дольше рабочего дня: в файле 221 вшитая картинка, в соседнем 555.
verify поменял роль модели. Раньше она искала данные в исходнике, то есть делала ту же работу, что и правила, только медленнее и с выдумками. Теперь документ обезличивается без неё, а модели показывают готовый файл: «что ты здесь ещё читаешь?» В проде vision выключена и совсем: на трудных случаях она полезна, но регулярно объявляет персональными данными то, что ими не является.
Самый частый вопрос, который мне задавали: «пусть модель просто скажет, где закрашивать». Проблема в координатах. Grounding у моделей уже есть, DeepSeek-OCR действительно отдаёт рамки, но это рамки блоков: шесть штук на страницу, медиана 374×40 из тысячи. Чтобы закрасить фамилию внутри строки, нужна рамка слова, а их сотни. Закрасить абзац вместо фамилии это не обезличивание, а порча документа.
Чистка картинки работает только целиком
Никаких моделей, только opencv: выпрямление перекоса, выравнивание освещения, шумодав, масштаб ×2, нерезкая маска. Прогнал не «до и после», а полную решётку.
вариант |
копия |
скан |
фото |
conf скан |
|---|---|---|---|---|
как есть |
103/103 |
63/103 |
0/103 |
65 |
+масштаб |
102 |
47 |
0 |
79 |
+свет |
103 |
70 |
4 |
66 |
ВСЁ |
96 |
92 |
8 |
82 |
ВСЁ − свет |
95 |
55 |
3 |
79 |
ВСЁ − шумодав |
96 |
57 |
7 |
81 |
Масштаб сам по себе вредит: 47 против 63 у базы, шум растёт вместе с картинкой. Смысл появляется только в порядке: сначала выровнять свет и убрать шум, потом увеличивать. А первая колонка показывает то, чего не ждёшь: на чистых страницах чистка вредит, 103 из 103 превращаются в 96, поэтому включать её можно только по низкой уверенности первого прохода. Какие именно семь страниц ломаются и почему, я так и не понял: глазами они неотличимы от уцелевших.
Но важнее процентов колонка conf. В маршруте стоит порог: уверенность ниже 70, и страница уходит на vision, а это 50 секунд. Чистка поднимает её с 65 до 82, дорогой маршрут не включается, страница читается за 4 секунды.
RealESRGAN не вытянул: 76/103 против 92/103 у бесплатной классики и 15,7 секунды на страницу против доли секунды. Связка «классика + ESRGAN» даёт ровно те же 76, что и голый ESRGAN, то есть он стирает всё, что дали свет и шумодав. Учили его на фотографиях: глазу красивее, OCR хуже.
492 настоящие страницы: кому нужна vision на самом деле
маршрут |
уходит на vision |
|---|---|
как сейчас, |
50 |
+ OSD и поворот |
23 |
+ OSD, поворот и фильтр отточия |
18 |
Обе страницы, не прошедшие порог, оказались ложной тревогой. Первая как раз и есть та августовская история с выключенным OSD: страница лежит повёрнутой на 90°, --psm 0 определяет это за 1,23 секунды, только в маршруте OSD не вызывается. Вторая идеально чистая, это «Содержание», и ниже порога она из-за отточия: «Основное назначение…» читается мусорной латиницей с уверенностью 0, а такие токены входят в среднее наравне со словами.
Тридцать две страницы из пятидесяти снимаются с дорогого маршрута, 42 минуты GPU превращаются в 18. А нужна vision восемнадцати страницам из 492, то есть 3,7%, и это не плохие сканы, а пустые бланки с вертикальными шапками таблиц. Фотографий телефоном и грязных сканов в корпусе нет вообще, поэтому перевес «86% против 80% на фото телефоном» здесь некуда приложить.
Двенадцать OCR-движков на одной странице
Две страницы акта: восемь чисел в таблицах численности и шестнадцать ФИО в списке работников. Метрика «одной ячейкой» отвечает на главный вопрос: попали ли фамилия и отчество в одну строку вывода или движок порвал ячейку. RTX 3050 на 6 ГБ, CUDA 12.4, llama.cpp b10618, 200 dpi.
движок |
ГБ |
чисел из 8 |
фамилий из 16 |
одной ячейкой |
сек стр.1/стр.2 |
|---|---|---|---|---|---|
Qwen3-VL-8B, наш инстанс |
5,8 |
8 |
16 |
16 |
332 |
Qianfan-OCR |
4,7 |
8 |
16 |
16 |
169/166 |
LightOnOCR-2 |
1,0 |
8 |
16 |
16 |
11/22 |
chandra-ocr-2 |
3,2 |
8 |
16 |
16 |
44/57 |
Nanonets-OCR2-3B |
2,6 |
8 |
16 |
15 |
38/47 |
PaddleOCR-VL 1.6 |
1,7 |
8 |
15 |
15 |
15/15 |
DeepSeek-OCR |
3,7 |
8 |
14 |
14 |
9/9 |
HunyuanOCR |
1,2 |
4 |
16 |
16 |
24/28 |
GLM-OCR |
1,3 |
0 |
11 |
7 |
12/18 |
dots.ocr, DeepSeek-OCR-2, Unlimited-OCR |
2,4–3,4 |
0 |
0 |
0 |
42–101 |
тессеракт 5.4 |
— |
7 |
16 |
0 |
~3 |
Ради последней строки эта таблица и составлялась. Тессеракт читает и числа, и фамилии, но ни одной целой ячейки. Четыре модели дают шестнадцать из шестнадцати.
Единый промпт для всех оказался нечестным сравнением: Nanonets с русским промптом даёт ноль чисел из восьми, с родным английским восемь из восьми. Тот же файл, та же модель. А PaddleOCR-VL я почти списал как нерабочую: она отдавала ноль знаков за семь секунд, потому что у неё шаблон чата в стиле ERNIE, а llama-mtmd-cli без флага --jinja вместо ошибки молча выходит с кодом 0. Движок, который «не работает», примерно в половине случаев работает не так, как его позвали.
Тессеракт обводит правильно. Он группирует неправильно
Заказчик спросил, нельзя ли взять PaddleOCR вместо тессеракта. Перед замером я просто отрисовал рамки обоих движков и посмотрел на них глазами.

Рамки тессеракта: по слову
Каждое слово найдено и обведено плотно. Полтора месяца я считал, что беда в геометрии. Геометрия в порядке, ломается сборка строк: поле line_num идёт по листу слева направо и склеивает ячейки соседних колонок.
1. | Николаев Николай M 14.03.1981 фрезеровщик 3 разряда Медицинские Николаевич противопоказания
Отчество уехало на следующую строку. Шаблон «Фамилия Имя Отчество» не совпадает ни с одной строкой такого вывода: вот откуда ноль срабатываний на двести семьдесят шесть человек. То есть движок не плохо прочитал, а прочитал хорошо и неправильно сложил строки.

Рамки детектора: по ячейке
Детектор PaddleOCR режет страницу по картинке, а не по эвристике «строка идёт слева направо». Ячейка Ф.И.О. приходит своей рамкой, «М» своей, дата своей. Колонки не склеиваются, потому что между ними на картинке пусто.
целых ФИО из 16 |
секунд на страницу |
|
|---|---|---|
тессеракт как есть |
0 |
3,1 |
тессеракт + детектор PP-OCR |
16 |
3,7 |
LightOnOCR-2 |
15–16 |
20 |
Берём у PaddleOCR только детектор. Читает по-прежнему тессеракт, ровно один раз, а детектор даёт карту ячеек, по которой раскладываются найденные слова. Шестнадцать из шестнадцати за шесть десятых секунды и 4,7 мегабайта весов против двадцати секунд и гигабайта. Детектор языконезависим, он ищет текст, а не читает его, и крутится на onnxruntime, который в образе уже стоял под YOLO.
А полная замена движка проверку не прошла:
движок |
чисел из 8 |
фамилий из 16 |
целых ФИО из 16 |
сек |
|---|---|---|---|---|
тессеракт как есть |
8 |
16 |
0 |
3,1 |
PaddleOCR целиком |
8 |
13 |
12 |
12,7 |
тессеракт + детектор PP-OCR |
8 |
16 |
16 |
3,7 |
Occular-OCR |
7 |
16 |
16 |
7,8 |
Три фамилии из шестнадцати PaddleOCR не прочитал вообще. Не с ошибкой, их нет в выдаче ни в каком виде. Для обезличивателя это худший вариант: раз нет слова, нет и рамки, закрашивать нечего, и фамилия уходит заказчику открытой.
Закраска, под которой лежит текст
Заказчик прислал готовый файл. Страница 2, столбец Ф.И.О., пятнадцать строк из шестнадцати закрашены чёрным, выглядит правильно.
строка |
что видно |
что в файле |
|---|---|---|
1 |
«Иванов Иван Иванович» открыто |
открыто |
2 |
чёрная закраска |
«Петров Пётр Петрович» цел |
3 |
чёрная закраска |
«Сидоров Сидор Сидорович» цел |
4–16 |
чёрная закраска |
пусто, пиксели стёрты |

Из-под одной закраски текст достаётся, из-под другой нет
Картинка нарисована не от руки: страница собрана скриптом, строки 2 и 3 на ней закрыты через draw_rect, строки 4–8 через apply_redactions, и справа стоит то, что после этого возвращает page.get_text() из тех же ячеек. Выглядят все семь одинаково.
Фамилии достаются оттуда тремя строками:
import fitz # PyMuPDF 1.24.10 page = fitz.open("акт_обезличенный.pdf")[1] print(page.get_text()) # «Петров Пётр Петрович» — на месте
Виноват проход, добивающий сканы: он закрывал найденное через draw_rect, положившись на то, что у страницы без текстового слоя закраска и есть удаление. Тело такой страницы это вложенная картинка, и вектор поверх неё пикселей не трогает.
Закраска это вид, а не удаление. Полтора месяца я проверял результат глазами, а глаза бесполезны ровно в том случае, ради которого всё затевалось. Теперь есть проверка, которая смотрит не на страницу, а под рамку. На старом серверном файле она нашла шесть утечек, после правки ноль из 3029 рамок акта, ноль из 456 «Положения», ноль из 449 «Перечня».

Белое пятно детектора поверх готовой чёрной полосы
Соседний баг того же рода. Красим двумя цветами: чёрным данные, белым печати и росчерки, иначе на месте подписи осталась бы чёрная клякса той же формы, по которой она и узнаётся. Белые проходы идут последними, и класс «рукопись» сработал на столбце Ф.И.О. целиком: пятно 141×199 пунктов легло поверх готовой чёрной полосы, строки 4–16 стали открытыми. Проверка «не накрывает ли рамка текст» это пропустила, потому что она не считает текстом слова, которые мы сами только что закрасили. Чем лучше отработала чёрная закраска, тем охотнее поверх неё ложится белое.
Шлюз, который переписывает документ
Жалоба звучала так: «стоит защита загрузок, она добавляет cleaned к нашим PDF, и всё становится кривым». Дело не в имени файла. Это CDR, обеззараживание содержимого: шлюз не проверяет документ, а пересобирает, и на кириллице подменяет шрифт.
страница 2 «Положения» |
оригинал |
после шлюза |
|---|---|---|
слов на странице |
54, средняя длина 6,6 |
264, средняя длина 1,2 |
|
|
|
поиск слова «Предисловие» |
1 |
0 |
всего текста в файле |
34 282 знака |
56 026 |
шрифт кириллицы |
TimesNewRomanPSMT |
ArialMT |
Arial шире Times, 556 против 500 на большинстве знаков, и слова наезжают друг на друга.
Теперь вернёмся к развилке из начала статьи. Вид документа определяется одной строкой: больше сотни знаков текста, значит текстовая страница. У пересобранного файла текста больше исходного. Ворота уверенно говорят «текстовый слой есть», сканный путь не включается, а поиск фамилии и NER не находят ничего, потому что в слое лежит И в а н о в. Документ выходит успешно обработанным, с открытыми фамилиями, без единой ошибки в логе. По имени отличить нельзя, проверено на десяти файлах: метка .cleaned стоит и на целых, и на испорченных. По содержимому можно с запасом: доля однобуквенных слов у испорченных 97–98%, у целых 22–24%.
Восстановить испорченный файл кодом нельзя: шлюз ставит между буквами настоящие пробелы, граница слова потеряна. Помогает выдавать документ растром, тогда подменять нечего.
«Положение» на выходе |
было |
стало |
|---|---|---|
текстовый слой |
34 282 знака |
0 |
встроенных шрифтов |
13 |
0 |
размер |
3,6 МБ |
5,5 МБ (150 dpi, jpeg 80) |
К двум видам документов добавился третий: файл с текстовым слоем, которому нельзя верить.
Ни одна проверка не ловит всё
проверка |
что видит |
чего не видит |
|---|---|---|
собственный контроль |
ФИО целиком |
одиночное имя без фамилии |
глазами |
всё на просмотренной странице |
всё на непросмотренной |
сплошная сверка с оригиналом |
каждое слово колонки |
колонки вне полосы |
под рамку |
текст под готовой закраской |
то, что не закрашено вовсе |
Собственный контроль показал ноль на шестом шаге патча из десяти: он ищет ФИО целиком, а на странице оставались открытые имена без фамилий, «Иван», «Пётр», «Анна». Четыре шага из десяти нашлись глазами, ещё один потому, что заказчик посмотрел на страницу, которую я не открывал. Сплошная сверка слово в слово с оригиналом на версии, объявленной мною чистой, нашла открытые фамилии на четырёх страницах подряд.
Собственные ошибки ловятся так же плохо: заливка колонки дошла до 21-й страницы, где таблицы уже нет, и выжгла пятнадцать обычных слов. Я перед этим проверил страницы 1, 2, 4 и 17 и на этом основании написал «лишнего не трогает». На последнюю не посмотрел.
Где я сейчас
было |
стало |
|
|---|---|---|
подписи на чёрно-белом скане |
не находились вовсе |
88% рукописи, 100% печатей и эмблем |
ложные срабатывания в середине документа |
14,1% страниц |
0,8% |
вызовов модели на документ |
1123 (~8 часов) |
0 в проде |
страница среднего скана |
~50 с через vision |
~4 с |
верных чисел на ней |
61% |
89% |
слов, найденных на плохом скане |
54% |
68% |
страниц корпуса на дорогом маршруте |
50 из 492 |
18 |
целых ФИО из ячейки таблицы |
0 из 16 |
16 из 16 |
фамилий, извлекаемых из-под закраски |
6 на документ |
0 из 3029 рамок |
Полтора месяца я искал модель, которая читает лучше. А надо было отрисовать рамки и посмотреть на них глазами.