HotPDF поставляет THPDFBuiltInOCREngine — ограниченный template-matching OCR-движок, полностью написанный на Object Pascal: он бинаризует отрендерированную страницу порогом Otsu, извлекает glyph как связанные компоненты и оценивает каждый glyph по покрытию оттенками серого относительно кэшированных шаблонов нескольких шрифтов, поэтому приложение Delphi может построить searchable text layer без внешней OCR-зависимости. В версии v2.731.0 движок пришлось собирать заново, и причина была не в matcher. Проблема была в пикселях
Старый движок проходил свои тесты. Он распознавал заглавные ASCII на синтетических bitmap, а в Win32 продолжал делать это месяцами. Затем тот же код запустили под Win64, и он перестал выдавать что-либо вообще: ни слов, ни диагностики кроме «found no high-contrast foreground», ни падения. Оказалось, что в пути чтения пикселей были две независимые ошибки, которые взаимно компенсировали друг друга, и их распутывание хорошо показывает, почему OCR-код молча не распознаёт данные вместо громкого сбоя
Почему старый OCR-движок работал только случайно?
Старый движок работал потому, что его template bitmap и целевые bitmap были перевёрнуты одинаково, поэтому вертикальная инверсия в reader пикселей оставалась незаметной для matcher. TBitmap.ScanLine возвращает строки в порядке, противоположном соглашению DIB с положительным biHeight, которое предполагает остальная imaging-подсистема. Отрендерите M вверх ногами, сравните его с таким же перевёрнутым шаблоном — и L1-разность будет идентична правильному сравнению. Каждый glyph совпадал. Правильным не было ничего
Именно эта симметрия делает класс ошибки дорогим. Одностороннее исправление ломает matching: поправьте чтение target и оставьте шаблоны как есть — распознавание превратится в шум; сначала поправьте шаблоны — получите тот же результат в обратном направлении. Постепенного пути ремонта нет. Поэтому при пересборке чтение полностью заменили на GetDIBits с явно объявленным BITMAPINFOHEADER, где положительный biHeight по контракту означает строки снизу вверх, а переворот выполняется один раз и намеренно при копировании в grayscale buffer
Вторая ошибка проявилась только в Win64. Переданный в GetDIBits HDC не должен быть собственным memory DC bitmap, поскольку bitmap уже выбран в него, а Windows документирует это как недопустимое использование. Передача Bitmap.Canvas.Handle терпелась процессом Win32 и стабильно падала в тестовом процессе Win64. Исправление — одноразовый screen DC из GetDC(0), освобождаемый в блоке finally и не связанный ни с одним bitmap
procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
Work: TBitmap;
Info: TBitmapInfo;
Buffer: TBytes;
DC: HDC;
P: PByte;
Stride, X, Y: Integer;
begin
Work := TBitmap.Create;
try
Work.Assign(Bitmap);
Work.PixelFormat := pf24bit;
Stride := ((Work.Width * 24 + 31) div 32) * 4;
SetLength(Buffer, Stride * Work.Height);
FillChar(Info, SizeOf(Info), 0);
Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
Info.bmiHeader.biWidth := Work.Width;
Info.bmiHeader.biHeight := Work.Height; // положительное значение => строки снизу вверх
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // никогда не Work.Canvas.Handle: Work уже выбран в него
if DC = 0 then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
try
if GetDIBits(DC, Work.Handle, 0, Work.Height,
@Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
finally
ReleaseDC(0, DC);
end;
SetLength(Gray, Work.Width * Work.Height);
for Y := 0 to Work.Height - 1 do
begin
P := @Buffer[(Work.Height - 1 - Y) * Stride]; // один намеренный переворот
for X := 0 to Work.Width - 1 do
Gray[Y * Work.Width + X] :=
(Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
Integer(P[X * 3 + 2]) * 77) shr 8;
end;
finally
Work.Free;
end;
end;
Бинаризация и связанные компоненты: от серых пикселей к прямоугольникам glyph
Сначала HotPDF бинаризует изображение методом Otsu и переходит к порогу по локальному окну только тогда, когда Otsu неприменим. Глобальный путь требует настоящей бимодальной гистограммы: движок вычисляет максимум межклассовой дисперсии и дополнительно требует, чтобы диапазон серого охватывал минимум 64 уровня, прежде чем довериться результату. Выбеленный скан, страница с градиентным фоном или bitmap, почти полностью состоящий из чернил, эту проверку не проходят. Затем fallback сравнивает каждый пиксель со средним 31 на 31 окна с поправкой 6 уровней серого; среднее вычисляется текущими суммами столбцов, поэтому скользящее окно остаётся линейным по числу пикселей
Извлечение glyph — это маркировка 8-связных компонент по полученной маске с явным стеком вместо рекурсии, потому что полноэкранная маска легко переполняет стек потока Delphi при глубоком flood fill. Во время маркировки действуют два фильтра: компоненты меньше 9 пикселей отбрасываются как точечный шум, а компонент, охватывающий более трёх пятых и ширины, и высоты изображения, отбрасывается как рамка или линия, а не glyph. Второй проход объединяет вертикально расположенные прямоугольники, чьё горизонтальное перекрытие составляет минимум четверть более узкого прямоугольника, и именно так точка над i или j снова соединяется со стержнем. Всё это работает с raster, а raster приходит от того же renderer, который описан в статье о рендеринге загруженной страницы PDF в bitmap в Delphi, и это важно по практической причине: качество OCR ограничено качеством рендера, а стандартные 300 DPI для text layer — осознанный компромисс, а не максимум
Что делает заглавную I и строчную l неразличимыми?
В Arial заглавная I и строчная l растеризуются в идентичные по пикселям полосы, поэтому ни один признак формы не может их разделить, и регистр должен прийти откуда-то ещё. Ответ движка — кластеризация высот на уровне строки. Прямоугольники glyph группируются в текстовые строки по вертикальному перекрытию, для каждой строки вычисляются cap height и модальная baseline, а высоты внутри строки разделяются на короткий и высокий кластер. Полоса в коротком кластере — это l, та же полоса в высоком кластере — I
Очевидная реализация такого разделения — фиксированный порог отношения, и она не работает. Отношение x-height к cap-height в Arial примерно равно 0.72, то есть попадает ровно на значения 0.70 и 0.75, к которым все обращаются в первую очередь. Сдвиньте константу на сотую долю в любую сторону — и весь корпус поменяет регистр. Поэтому HotPDF выполняет одномерное разбиение k=2 с минимизацией дисперсии: сортирует кандидатные высоты, пробует каждую точку разреза и оставляет ту, для которой сумма квадратов отклонений внутри кластеров минимальна. Порог становится свойством страницы, а не константой в исходном коде
// ClusterHeights отсортирован по возрастанию; найти разбиение k=2 с минимальной дисперсией
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
SumA := 0;
for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
SumB := 0;
for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
MeanA := SumA / I;
MeanB := SumB / (ClusterCount - I);
Variance := 0;
for J := 0 to I - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
for J := I to ClusterCount - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
if Variance < BestVariance then
begin
BestVariance := Variance;
BestSplit := I;
end;
end;
// Только отношение средних двух кластеров определяет короткий диапазон
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // настоящий диапазон x-height: формы нижнего регистра
else
SmallGroup := ggTall; // один диапазон высоты: всё имеет высоту заглавных
Line.LowercaseContext := (SmallGroup = ggSmall);
Строки с одним диапазоном высоты вообще не несут внутреннего свидетельства. Заголовок целиком в верхнем регистре и подпись целиком в нижнем выглядят одинаково изолированно. Для таких случаев HotPDF сравнивает медианную высоту строки с медианой x-height страницы, взятой из строк, где разбиение состоялось: отношение не выше 1.10 помечает строку как контекст нижнего регистра, отношение не ниже 1.18 — как контекст верхнего регистра, а всё между ними остаётся без ограничений. Затем matching добавляет небольшой бонус предпочтения регистра 0.03 кандидату, согласующемуся с этим контекстом, что разрешает ничьи, но никогда не перекрывает явное различие формы
Почему сетка шаблона 12x18 путала c и o?
Сетку шаблона расширили с 12 на 18 ячеек до 16 на 24, потому что при меньшем разрешении разница grayscale coverage между c и o падала ниже 0.007, то есть далеко внутри порога неоднозначности движка. Каждый прямоугольник glyph передискретизируется в сетку как значения покрытия от 0 до 255, а не как бинарная маска, поэтому ячейка, заполненная чернилами на треть, получает примерно 85, а не округляется до чёрного или белого. При 12 на 18 открытая сторона c занимает чуть больше одного столбца ячеек, и усреднение с antialiasing смывает зазор. При 16 на 24 зазор сохраняется после resampling, и большинство легко путаемых пар снова удаляется на безопасное расстояние
Оценка — нормированное L1-расстояние между двумя coverage-сетками плюс штраф 0.30 от разности логарифмов aspect ratio и 0.16 от разности плотности чернил, с жёстким prefilter, пропускающим любой шаблон, чьё aspect ratio отличается больше чем в 2.6 раза. Шаблоны растеризуются один раз на процесс из пяти системных шрифтов (Arial, Times New Roman, Courier New, Tahoma и Segoe UI) по алфавиту из 62 символов, кэшируются под critical section и используются каждым последующим вызовом
Последняя константа — самая интересная. Когда результат символа, занявшего второе место, отличается от победителя не более чем на 0.018, HotPDF ограничивает confidence glyph значением 0.5, то есть ниже порога принятия 0.55, и glyph просто не выдаётся. Это намеренное fail-closed-решение, а не артефакт настройки: ограниченный движок, который угадывает, создаёт searchable layer, чей текст не совпадает с изображением, а неверное слово в text layer хуже пропущенного, потому что человек, проверяющий скан, его не заметит
Разделение слов без фиксированного порога пробела
HotPDF выводит порог word-space для каждой строки из распределения межсимвольных зазоров, а не из фиксированного множителя средней ширины glyph. Классическая эвристика «зазор шире 0.75 среднего advance — это пробел» ломается, как только строка смешивает цифры с узкими буквами, потому что средний advance перестаёт что-либо реально описывать. Вместо этого движок сортирует зазоры строки и ищет самый большой скачок между соседними отсортированными значениями — границу между внутрисловным и межсловным кластерами, если такой кластер вообще есть. Три предохранителя не дают сработать на шуме: скачок должен составлять минимум 0.22 средней ширины glyph, первый зазор выше разреза — минимум 0.32 от неё, а последний зазор ниже разреза не должен превышать 0.65 от неё. Если любой предохранитель не сработал, порог остаётся MaxInt, и вся строка становится одним словом. Последнее условие не даёт одной необычно широкой kerning-паре разделить слово надвое, а это гораздо вреднее, чем слить два слова, поскольку объединённый токен всё ещё содержит правильные символы в правильном порядке для substring search
Запись невидимого text layer поверх отсканированного изображения
ApplyLoadedOCRTextLayer превращает распознанные слова в searchable layer, рисуя их в text rendering mode 3 — режиме «не заполнять и не обводить», определённом в ISO 32000-1 §9.3.6, — поверх изображения, из которого они были получены. Content stream открывается с BT, за которым следует 3 Tr, а каждое слово размещается text matrix, построенной по сообщённой baseline, его cap height, пересчитанной из пикселей через DPI запроса, и горизонтальному масштабу, растягивающему synthetic glyph run до измеренной ширины слова. В результате текст можно копировать и искать, но он ничего не рисует
Существует engine-free overload, который создаёт встроенный recognizer за вас; именно его стоит использовать большинству вызывающих встроенного пути. Распознавание, проверка Unicode, учёт бюджета и построение content завершаются до открытия copy-on-write transaction, поэтому отмена, превышение бюджета или ошибка движка оставляют граф объектов и номер версии нетронутыми. Слова фильтруются дважды: движок отбрасывает всё ниже собственной границы confidence 0.55 на glyph, затем THPDFOCRTextLayerOptions.MinimumConfidence (по умолчанию 0.5) отбрасывает целые слова ниже порога вызывающего кода
var
Doc: THotPDF;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile('scan.pdf') < 1 then
Exit;
Options := THPDFOCRTextLayerOptions.Default; // DPI 300, MinimumConfidence 0.5
Options.SkipPagesWithText := True; // не трогать страницы, изначально содержащие цифровой текст
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload без engine: HotPDF предоставляет встроенный ограниченный recognizer
if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
begin
Writeln(Info.AcceptedWordCount, ' words accepted by ',
string(Info.EngineName));
Doc.SaveLoadedDocument('scan-searchable.pdf');
end
else
Writeln('No text layer written: ', string(Info.Diagnostic));
finally
Doc.Free;
end;
end;
Одно ограничение лучше обозначить прямо, а не обнаружить позже. Невидимый layer использует общий синтетический не встроенный шрифт Type0, которого достаточно для поиска и копирования в любом viewer, но недостаточно для требования ISO 19005 об embedding шрифта. Если результат должен быть PDF/A, вызывающий код обязан отдельно встроить соответствующий шрифт. Кроме того, OCR text layer несёт геометрию, а не структуру, поэтому порядок чтения выводится только из позиций glyph; если нужен логический порядок страницы, на которой уже есть настоящий текст, извлечение текста в порядке структуры по tag tree решает другую задачу другим инструментом
Где заканчивается встроенный движок
Встроенный движок намеренно узок, и понимание его границ как раз сохраняет его полезность. Он рассчитан на высококонтрастный машинопечатный ASCII шрифтами, близкими к пяти template faces, а за пределами этого возвращает отсутствие слова, а не догадку. Конкретные границы таковы:
- Изображения размером до 4096 на 4096 и 4 194 304 пикселей, с дедлайном распознавания 2000 ms и кооперативной отменой через
THPDFCancellationToken - Алфавит из 62 ASCII-букв и цифр; без пунктуации, акцентированных символов и CJK
- Только текст, выровненный по осям, с поворотом страницы, который уже нормализовал renderer; skewed scans не deskew-ятся
- Неоднозначные пары glyph остаются неразрешёнными, поэтому страница может вернуть неполные слова или диагностику «found no unambiguous ASCII words»
Если этот конверт слишком мал, используйте IHPDFOCREngine как seam. Реализуйте Recognize на собственном движке, передайте его в трёхаргументную перегрузку ApplyLoadedOCRTextLayer, и всё ниже по конвейеру — mapping координат, обработка поворота, проверка Unicode, бюджеты и atomic commit — останется прежним. Bitmap заимствуется на время синхронного вызова и не должен сохраняться. Чтобы подтвердить, что layer действительно записан, перезагрузите сохранённый файл и запустите обычный text path из статьи об извлечении текста из загруженного PDF в Delphi; если слова вернулись, layer реален
Встроенный template-matching OCR, невидимый text layer, page renderer, который их питает, и извлечение текста из загруженного документа, которое их проверяет, поставляются в одном нативном VCL-компоненте без внешнего OCR runtime и без DLL, которую нужно развёртывать рядом с приложением. Если вы создаёте захват документов, архивирование или поиск по отсканированным PDF в Delphi или C++Builder, PDF-компонент HotPDF для Delphi даёт весь конвейер в одной зависимости