Технічна стаття

Китайський і багатомовний OCR RapidOCR у Delphi з HotPDF

HotPDF виконує китайський і багатомовний OCR у Delphi через свій нативний адаптер RapidOCR DLL: THPDFRapidOCRDLLOptions.ForLanguage мапить мовний тег на кшталт 'zh-CN', 'zh-TW', 'ru' чи 'ar' на підібрану модель розпізнавання та словник символів, а THotPDF.ApplyLoadedOCRTextLayer перетворює розпізнані рядки на невидимий, пошуковий Unicode-текстовий шар на відсканованих сторінках PDF

Запустити демо на латиниці — це легка частина. Цікаві відмови починаються, коли ви перемикаєтеся на традиційну китайську чи російську і вихід перетворюється на впевнену, добре сформовану нісенітницю, або коли кожен рядок тихо втрачає свій останній символ, чи коли арабська сторінка повертається з текстовими боксами в неправильному порядку. Жодна з цих ситуацій сама собою не піднімає виключення. Мовні пресети, додані в HotPDF v2.775.0, існують здебільшого щоб закрити ті прогалини, а чотири пастки нижче варто розуміти, навіть якщо ви ніколи не торкаєтеся нативного коду, бо кожна пояснює симптом, за яким інакше можна ганяти цілий день

Як ForLanguage обирає модель і словник?

THPDFRapidOCRDLLOptions.ForLanguage розрішує тег до одного з дев'яти профілів і повертає опції, що вказують на <profile>/recognition.onnx і <profile>/dictionary.txt нижче вашої папки моделей, зберігаючи спільний детектор, необов'язковий класифікатор кута та усталення потоків, пікселів і таймауту з THPDFRapidOCRDLLOptions.Default. Метод знижує регістр тега, перетворює підкреслення на дефіси і підстрибує навколишні пробіли, тож 'zh_TW', 'ZH-tw' і ' zh-tw ' приземляються на той самий профіль. Аліаси — явний список, а не збіг за префіксом: 'zh-Hant-TW' приймається, бо він перелічений, тоді як довільний регіональний варіант, якого немає в списку, піднімає EArgumentException до завантаження хоч якоїсь моделі

Розрішення профілю ForLanguage у HotPDF для THPDFRapidOCRDLLOptions: теги на кшталт zh_TW, ZH-tw і zh-TW нормалізуються і зіставляються з дев'ятьма переліченими профілями, кожен із яких закріплює модель розпізнавання і словник, що завжди встановлюються разом, тоді як неперелічений тег піднімає EArgumentException до завантаження якоїсь моделі
один тег обирає одну закріплену пару модель-словник; детектор, класифікатор і бюджети лишаються спільними, а невідомий тег падає швидко замість завантаження чого завгодно
ПрофільМовиПриклади тегівЗакріплена модель
chСпрощена китайська та англійськаzh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtТрадиційна китайськаzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enАнглійськаen, en-US, en-GB, engPP-OCRv4
latinФранцузька, німецька, іспанська, португальська, італійська, нідерландська, турецькаfr, de, es-419, pt-BR, trPP-OCRv3
japanЯпонськаja, ja-JP, jpnPP-OCRv4
koreanКорейськаko, ko-KR, korPP-OCRv4
cyrillicРосійська, українська, болгарська, білоруськаru, ru-RU, uk, bgPP-OCRv3
arabicАрабська, перська, урдуar, ar-SA, fa, urPP-OCRv4
devanagariГінді, маратхі, непальськаhi, mr, nePP-OCRv4

Сам адаптер ніколи нічого не завантажує з мережі. Файли ви постачаєте один раз комплектним хелпером, скажімо tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (чи -Language All для всіх дев'яти профілів), і хелпер кладе спільні детектор і класифікатор у кореневі імена файлів, яких очікує Default. Після того відсканований документ спрощеною китайською стає пошуковим кількома рядками. Рушійна обв'язка — це той самий шов IHPDFOCREngine, описаний у статті про RapidOCR DLL у процесі та її межу ABI, тож ця стаття лишається зосередженою на мовах

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt, спільні детектор і класифікатор
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    // порожній список сторінок означає всі; сторінки з наявним текстом пропускаються
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

Дві деталі в тому виводі заслуговують на нотатку. Нативний конвеєр повертає один результат на виявлений рядок тексту, а не на слово, тож AcceptedWordCount тут лічить рядки, а MinimumConfidence порівнюється із середньою впевненістю символів усього рядка: рядок, що в середньому дає 0.45, скидається як єдине ціле. UniqueScalarCount звітує, скільки окремих Unicode-скалярів текстовому шару довелося замапити у свій шрифт і таблицю ToUnicode, — корисна санітарна перевірка, що CJK-текст справді прибув, а не жменька латинських фолбеків. Тримайте інтерфейс рушія живим між документами, бо ініціалізація моделей відбувається у фабриці і є дорогим кроком

Чому заміна лише моделі розпізнавання породжує сміття?

CTC-модель розпізнавання ніколи не виводить символи, лише індекси класів, і словник — це єдине, що перетворює індекс 1 204 на гліф. Замініть ch/recognition.onnx на cyrillic/recognition.onnx, але тримайте китайський словник, — і модель радісно виводитиме валідні кириличні індекси, які старий словник перекладає у випадкові ханьські символи. Результат виглядає як текст, проходить UTF-8-валідацію і є пошуковим рівно для нічого. Саме тому ForLanguage завжди ставить RecognitionModel і CharacterDictionary разом, і саме тому рукописні опції ніколи не мають змінювати одне без іншого

Очевидна перевірка безпеки — порівняння розміру словника з шириною виходу моделі — необхідна, але недостатня. Два словники можуть мати однакову кількість записів в іншому порядку, і off-by-one у порядку зсуває кожен символ на один code point. Тому HotPDF перевіряє у два стадії, коли фабрика ініціалізує модель. Перше: кількість класів виходу мусить дорівнювати записам словника плюс два. Друге: коли ONNX-файл вбудовує метасписок character, кожен запис словника порівнюється з ним по порядку, і розбіжність валить ініціалізацію з EInvalidOperation та нативною діагностикою замість породження правдоподібного сміття пізніше

«Плюс два» походить із компонування класів. Клас 0 — це CTC blank, класи 1 по N — це рядки словника в порядку файлу, а фінальний клас — пробіл. Деякі словники ще й несуть власний запис пробілу, і той рядок мусить лишатися точно як є. Саме тут добронамірний Trim чинить справжню шкоду: він перетворює запис з одного пробілу на порожній рядок і зсуває чи ламає таблицю. Єдина безпечна нормалізація — прибрати кінцевий повертий кар'єр, тож словник, збережений з кінцями рядків CRLF, завантажується правильно, тоді як BOM у UTF-8, порожній рядок чи запис із табом відхиляються. Ескіз нижче показує компонування на Pascal; це пояснювальний код, а не HotPDF API

Компонування таблиці класів CTC у HotPDF для словників RapidOCR: клас 0 — blank, класи 1 по N — рядки словника в порядку файлу з будь-яким самотнім записом пробілу, що зберігається, а фінальний клас — пробіл, що дає N плюс 2 класи виходу, які фабрика перевіряє проти моделі, включно з метаданими
словник — це єдине, що перетворює індекси класів у символи, тож його розмір, порядок і запис пробілу перевіряються до розпізнавання хоч однієї сторінки
// Лише ілюстрація: таблиця класів, яку очікує CTC-розпізнавач
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // переклад рядка в кінці файлу
  SetLength(Result, Last + 3);
  Result[0] := '';                               // клас 0: CTC blank
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF: скидаємо лише CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // ніколи не Trim: ' ' — це клас
  end;
  Result[Last + 2] := ' ';                       // фінальний клас: пробіл
  // Length(Result) мусить дорівнювати кількості класів виходу моделі
end;

Що насправді робить жадібне CTC-декодування?

Жадібне CTC-декодування обирає клас із найвищим рахунком на кожному часовому кроці, стискає підрядні повтори в один символ і скидає клас blank; blank — це те, що дозволяє справді подвоєним літерам виживати. Модель розпізнавання дивиться на рядок тексту як на послідовність вузьких вертикальних зрізів, і для кожного зрізу, чи часового кроку, вона виводить імовірність для кожного класу. Рядок, що містить AA中, міг би породити argmax-послідовність A A blank A 中 space. Стискання перших двох кроків A дає одну A, blank відділяє її від наступної A, а результат — AA中 із кінцевим пробілом недоторканим. Без правила blank book і bok були б нерозрізненні

Розбір GreedyCTCDecode у HotPDF: шість часових кроків голосують argmax-класи A, A, blank, A, ханьський символ і пробіл, підрядні повтори стискаються, blank скидає охоронця повторів, тож справді подвоєна літера виживає, а три граничні баги тихо гублять пробіли слів, останній символ чи подвоєні символи
декодер — це зо дюжини рядків, і кожна межа важить: включайте останній клас, включайте останній крок і дозволяйте лише blank відділяти повтори

Оскільки декодер — це зо дюжини рядків, межі легко зіпсувати, а відмови тихі. Якщо внутрішній argmax-цикл зупиниться на один клас раніше, клас пробілу ніколи не переможе, і кожен рядок повернеться без пробілів слів, що руйнує пошук фраз на англійських і латинських сторінках. Якщо зовнішній цикл зупиниться на один часовий крок раніше, останній символ кожного рядка зникає, що для короткого рядка може бути третиною тексту. А якщо охоронець повторів не скидається blank-ом, подвоєні символи, як-от ll, чи китайські редуплікації, як-от 谢谢, стискаються в один. Декодер HotPDF включає останній клас і останній часовий крок, тримає розділені blank-ом повтори і додатково відхиляє рахунки, що не скінченні чи виходять з 0 до 1, і будь-яку кількість класів, що не збігається зі словником. Ось та сама логіка як Pascal-ілюстрація

// Лише ілюстрація: жадібне CTC-декодування з правильними межами.
// Scores тримає Steps * Classes імовірностей, один рядок на часовий крок
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // клас 0 — це CTC blank
  for Step := 0 to Steps - 1 do             // включаємо останній часовий крок
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // включаємо останній клас (пробіл)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // blank скидає охоронця повторів
  end;
end;

Жадібне декодування — не найточніша наявна CTC-стратегія; beam search із мовною моделлю може полагодити деякі неоднозначні зрізи. Для надрукованих документів при 300 DPI жадібний результат — зазвичай усе, що модель має запропонувати, а декодер — не те місце, де варто компенсувати слабкості моделі. Латинська модель PP-OCRv3, скажімо, може прочитати ñ як n навіть на чистому вводі. HotPDF не замазує того постобробковими замінами символів, бо таблиця підстановок, що полагоджує іспанську, ламає щось інше, а неправильний символ у пошуковому шарі гірший за чесний промах

Як HotPDF упорядковує рядки тексту, включно з арабською справа ліворуч?

HotPDF сортує виявлені текстові бокси згори вниз, групує бокси в ряд, коли вони перетинаються вертикально щонайменше на половину висоти меншого бокса, і впорядковує кожен ряд зліва праворуч, чи справа ліворуч, коли увімкнено RightToLeft; символи всередині кожного розпізнаного рядка ніколи не обертаються. Групування важить тому, що детектор часто розколює один візуальний рядок на кілька боксів, скажімо мітку та значення, розділені широким проміжком, і чисте сортування за верхньою координатою переплело б їх із сусіднім рядком щоразу, коли їхні верхи різняться на піксель чи два

Арабський пресет ставить RightToLeft := True, що каже DLL упорядковувати бокси в кожному ряду за їхнім правим краєм, від правого поля всередину. Це вся дія. Текст, який модель повертає для рядка, уже в Unicode-логічному порядку — тому, в якому арабський читач читає і набирає, — і це також порядок, якого очікують екстракція та пошук тексту PDF. Механічне обертання рядка, щоб той «виглядав правильно» в дебагері, зламало б пошук, копіювання, вставляння та скрінрідери. Двонаправлений показ і формування гліфів — робота переглядача

Один рушій обслуговує один мовний профіль. Автоматичної детекції писемності немає, тож документ, що змішує писемності, потребує по одному рушію на профіль, застосованому до сторінок, які ним користуються. Оскільки ApplyLoadedOCRTextLayer бере явний список сторінок і комітить кожен виклик як власну транзакцію все-або-нічого, це прямо

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // піднімає EArgumentException для невідомого тега, до завантаження якоїсь моделі
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // місце для сторінок A3 при 300 DPI
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // профіль chinese_cht
  Arabic := CreateRapidEngine('ar-SA');   // профіль arabic, RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

Рядок MaxPixels там недаремно. Опції DLL усталюються до 16 777 216 пікселів на запит, що комфортно покриває A4 та US Letter при 300 DPI, але сторінка A3 при 300 DPI — це приблизно 3508 на 4961 пікселів, зо 17,4 мільйона, і запит відхиляється як поза бюджетом. Піднімайте MaxPixels (стеля — 67 108 864) чи знижуйте THPDFOCRTextLayerOptions.DPI для великих форматів. Упорядкування справа ліворуч користується необов'язковим експортом HPDFRapidOCRSetReadingDirection версії ABI 1; адаптер вимагає його лише коли встановлено RightToLeft, тож старіший DLL досі обслуговує мови зліва праворуч і падає при створенні рушія з EArgumentException, що називає відсутній експорт для арабської

Чому новіші OCR-моделі падають при завантаженні?

RapidOCR DLL від HotPDF лінкує статичний ONNX Runtime 1.14, який не може читати моделі, збережені з ONNX IR версією 10, а новіші експорти, як-от моделі PP-OCRv5, можуть вимагати новішого runtime; така модель падає при створенні рушія з нативною діагностикою. Саме те обмеження — причина, чому мовні пакети закріплені за конкретними парами розпізнавача і словника PP-OCRv3 та PP-OCRv4 замість «найсвіжішого», і чому таблиця вище змішує два покоління: кожна закріплена пара — це пара, що завантажується і верифікується під тим runtime

Інсталятор застосовує парування. Кожен файл у його маніфесті несе хеш SHA256, наявний файл з іншим хешем зупиняє встановлення замість перезаписування, і кожне завантаження приземлюється під тимчасовим ім'ям і пересувається на місце лише після збігу хешу. Те захищає від тихої версії проблеми словника: хтось кидає новіший recognition.onnx у папку профілю вручну, кількість класів випадково збігається, і нічого не падає, доки клієнт не звітує, що пошук не знаходить слів, які він plainly бачить. У виконанні адаптер лишається офлайн і ніколи не тягне відсутню модель. Розпізнавач ще й валідує форму моделі при завантаженні, приймаючи NCHW-ввід з фіксованою висотою 32 чи 48 пікселів чи динамічною висотою, яку він ганяє при 48

Якщо вам потрібна писемність, яку жоден із дев'яти профілів не покриває, ви досі можете націлити RecognitionModel і CharacterDictionary на власні файли. Ті самі перевірки застосовуються — у тому й сенс: невідповідна пара падає при ініціалізації, а не в архіві вашого клієнта. Для сторінок, де жоден профіль RapidOCR не пасує, адаптер Tesseract для пошукового PDF встромляється в той самий виклик ApplyLoadedOCRTextLayer, а для машинодрукованих ASCII-форм вбудований рушій OCR на template matching узагалі не потребує моделей

Шпаргалка: чекліст багатомовного RapidOCR

  • Створюйте опції через THPDFRapidOCRDLLOptions.ForLanguage і трактуйте EArgumentException як непідтримуваний тег, а не fault виконання
  • Змінюйте RecognitionModel і CharacterDictionary разом, ніколи не поодинці; рівні кількості класів не доводять рівного порядку символів
  • Тримайте словники як UTF-8 без BOM, ніколи не підстрибуйте записи і очікуйте від моделі N + 2 класів: blank, N записів, пробіл
  • Власний CTC-декодер мусять покривати останній клас і останній часовий крок і тримати повтори, розділені blank-ом
  • Користуйтеся одним рушієм на мовний профіль і передавайте явні списки сторінок для документів зі змішаними писемностями
  • RightToLeft змінює лише порядок боксів; розпізнаний текст лишається в Unicode-логічному порядку
  • Встановлюйте моделі через Install-RapidOCRModels.ps1, щоб SHA256-закріплення тримали парування моделі та словника; ставте UseAngleClassifier := False, якщо встановлювали з -SkipClassifier
  • Піднімайте MaxPixels понад усталені 16 777 216 перед прогоном сторінок A3 чи більших при 300 DPI

Мовні пресети RapidOCR, нативний DLL-адаптер і конвеєр OCR-текстового шару — частина HotPDF Delphi PDF Component для Delphi, C++Builder і Windows FPC/Lazarus, починаючи з v2.775.0 для багатомовних профілів