HotPDF dodává THPDFBuiltInOCREngine, omezený OCR engine s template matchingem napsaný celý v Object Pascalu: vyrenderovanou stránku binarizuje Otsuovou metodou, extrahuje glyfy jako connected components a každý glyph ohodnotí podle pokrytí odstíny šedi proti cacheovaným šablonám z více fontů, takže aplikace v Delphi může vytvořit prohledávatelnou textovou vrstvu bez externí závislosti na OCR. Engine bylo nutné ve v2.731.0 postavit od nuly a důvodem nebyl matcher. Byly jím pixely
Starý engine prošel své testy. Na syntetických bitmapách rozpoznával velká ASCII písmena a na Win32 to celé měsíce fungovalo. Pak se stejný kód spustil pod Win64 a nevrátil vůbec nic: žádná slova, žádná diagnostika kromě „found no high-contrast foreground“, žádný crash. Ukázalo se, že v cestě čtení pixelů byly dvě nezávislé chyby, které se navzájem rušily, a jejich rozpletení dobře ukazuje, proč OCR kód selhává potichu místo hlasitého pádu
Proč starý OCR engine fungoval jen náhodou
Starý engine fungoval, protože bitmapy šablon i cílové bitmapy byly převrácené stejným způsobem, takže vertikální inverze ve čtečce pixelů byla pro matcher neviditelná. TBitmap.ScanLine vrací řádky v opačném pořadí než konvence DIB s kladným biHeight, kterou předpokládá zbytek obrazové cesty. Vyrenderujte M vzhůru nohama, porovnejte ho se stejně převrácenou šablonou a L1 rozdíl bude stejný jako při správném porovnání. Každý glyph se shodoval. Nic nebylo správně
Právě tato symetrie dělá tento druh chyby drahým. Jakákoli jednostranná oprava matching rozbije: opravte čtení cíle a šablony nechte být a rozpoznávání se zhroutí v šum; nejdříve opravte šablony a zhroutí se znovu opačným směrem. Neexistuje postupná oprava. Rebuild proto nahradil celé čtení pomocí GetDIBits proti explicitně deklarovanému BITMAPINFOHEADER, kde kladné biHeight podle smlouvy znamená řádky bottom-up, nikoli jen podle konvence VCL, a při kopírování do grayscale bufferu provede jeden záměrný flip
Druhá chyba se projevila až ve Win64. HDC předaný do GetDIBits nesmí být vlastní memory DC bitmapy, protože bitmapa už je do něj vybraná a Windows to dokumentuje jako neplatné. Předání Bitmap.Canvas.Handle proces Win32 toleroval, ale v testovacím procesu Win64 spolehlivě selhávalo. Opravou je dočasný screen DC z GetDC(0), uvolněný v bloku finally, který není spojený s žádnou bitmapou
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; // kladné => řádky bottom-up
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // nikdy Work.Canvas.Handle: Work je v něm vybrán
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]; // jeden záměrný flip
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;
Binarizace a connected components: od šedých pixelů k rámečkům glyfů
HotPDF nejdříve binarizuje Otsuovou metodou a k lokálnímu prahu v okně se vrací jen tehdy, když Otsu nelze použít. Globální cesta vyžaduje skutečný bimodální histogram: engine vypočte maximum variance mezi třídami a navíc vyžaduje, aby rozsah šedi pokrýval alespoň 64 úrovní, než výsledku uvěří. Vybledlý sken, stránka s gradientním pozadím nebo bitmapa téměř celá pokrytá inkoustem tímto testem neprojdou. Fallback pak porovnává každý pixel s průměrem okna 31 krát 31 s biasem 6 úrovní šedi, který se počítá průběžnými součty sloupců, takže posuvné okno zůstává lineární podle počtu pixelů
Extrakce glyfů je 8-connected component labeling nad výslednou maskou s explicitním stackem místo rekurze, protože maska celé stránky snadno vyčerpá zásobník vlákna Delphi při hlubokém flood fillu. Při labelingu běží dva filtry: components menší než 9 pixelů se zahodí jako tečkový šum a každá component, která zabírá více než tři pětiny šířky i výšky obrazu, se zahodí jako rámeček nebo čára, nikoli jako glyph. Druhý průchod spojí vertikálně naskládané boxy, jejichž horizontální překryv je alespoň čtvrtina užšího boxu, čímž znovu spojí tečku i nebo j s jejich kmenem. Všechno pracuje s rasterem a raster pochází ze stejného rendereru popsaného v renderování načtené PDF stránky do bitmapy v Delphi, což má praktický důvod: kvalita OCR je shora omezena kvalitou renderu a výchozí DPI textové vrstvy 300 je záměrný kompromis, nikoli maximum
Co činí velké I a malé l nerozhodnutelnými
V písmu Arial se velké I a malé l rasterizují do pixelově shodných sloupců, takže žádný tvarový rys je nedokáže oddělit a informace o velikosti musí přijít úplně odjinud. Odpovědí enginu je shlukování výšek na úrovni řádku. Boxy glyfů se seskupí do textových řádků podle vertikálního překryvu, každý řádek se analyzuje podle výšky verzálek a modální baseline a výšky uvnitř řádku se rozdělí do krátkého a vysokého clusteru. Sloupec v krátkém clusteru je l; stejný sloupec ve vysokém clusteru je I
Očividná implementace tohoto rozdělení je pevný prahový poměr a nefunguje. Poměr x-height k cap-height v Arialu je přibližně 0,72, takže přesně leží na hodnotách 0,70 a 0,75, po kterých každý sáhne jako po prvních. Posuňte konstantu kterýmkoli směrem o setinu a celý korpus změní velikost písmen. HotPDF místo toho provádí jednorozměrné dělení k=2 s minimalizací variance: kandidátní výšky seřadí, vyzkouší každý bod dělení a ponechá řez s nejmenším součtem čtverců odchylek uvnitř clusterů. Prah se tak stane vlastností stránky, nikoli konstantou ve zdroji
// ClusterHeights je vzestupně seřazené; najdi dělení k=2 s nejmenší variancí
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;
// o krátkém pásmu rozhoduje jen poměr průměrů obou clusterů
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // skutečné pásmo x-height: tvary malých písmen
else
SmallGroup := ggTall; // jedno výškové pásmo: vše má výšku verzálek
Line.LowercaseContext := (SmallGroup = ggSmall);
Řádky s jediným výškovým pásmem nenesou žádný vnitřní důkaz. Nadpis psaný jen verzálkami a popisek psaný jen malými písmeny vypadají izolovaně stejně. U nich HotPDF porovná medián výšky řádku s mediánem x-height stránky získaným z řádků, které se rozdělily: poměr nejvýše 1,10 označí kontext řádku jako lowercase, poměr nejméně 1,18 jako cap a cokoli mezi tím nechá bez omezení. Matching pak přidá malý bonus preference velikosti 0,03 kandidátovi, který odpovídá tomuto kontextu, což vyřeší remízy, ale nikdy nepřebije jasný rozdíl tvaru
Proč mřížka šablon 12x18 mátla c a o
Mřížka šablon se rozšířila z 12 krát 18 buněk na 16 krát 24, protože při menším rozlišení klesl rozdíl grayscale coverage mezi c a o pod 0,007, tedy hluboko do prahu nejednoznačnosti enginu. Každý box glyfu se do mřížky převzorkuje jako hodnoty pokrytí od 0 do 255 místo binární masky, takže buňka s třetinovým inkoustem se čte přibližně jako 85, nikoli jako černá nebo bílá. U 12 krát 18 zabírá otevřená strana c sotva více než jeden sloupec buněk a antialiasovaný průměr mezeru rozmaže. U 16 krát 24 mezera převzorkování přežije a většina snadno zaměnitelných dvojic se vrátí do bezpečné vzdálenosti
Skóre je normalizovaná L1 vzdálenost mezi dvěma coverage mřížkami plus penalizace 0,30 násobku logaritmického rozdílu poměru stran a 0,16 násobku rozdílu hustoty inkoustu, s tvrdým předfiltrem, který přeskočí každou šablonu s poměrem stran lišícím se více než faktorem 2,6. Šablony se jednou za proces rasterizují z pěti systémových fontů (Arial, Times New Roman, Courier New, Tahoma a Segoe UI) přes abecedu 62 znaků, cacheují se za kritickou sekcí a znovu používají při každém dalším volání
Poslední konstanta je zajímavá. Když skóre druhého kandidáta leží do 0,018 od vítěze, HotPDF confidence glyfu ořízne na 0,5, což je pod akceptační bránou 0,55, takže glyph se jednoduše nevypíše. Je to záměrná fail-closed hranice, nikoli artefakt ladění: omezený engine, který hádá, vytváří prohledávatelnou vrstvu s textem neodpovídajícím obrazu a chybné slovo v textové vrstvě je horší než chybějící, protože člověk kontrolující sken ho neuvidí
Dělení slov bez pevného prahu mezery
HotPDF odvozuje práh mezery pro každou řádku z rozdělení mezer mezi glyfy místo pevného násobku průměrné šířky glyfu. Klasická heuristika „mezera širší než 0,75 průměrného advance je mezera“ se rozbije, jakmile řádek smíchá číslice s úzkými písmeny, protože průměrný advance přestane popisovat něco skutečného. Engine místo toho mezery v řádku seřadí a hledá největší skok mezi po sobě jdoucími seřazenými hodnotami, což je hranice mezi clusterem uvnitř slova a clusterem mezi slovy, pokud vůbec existuje. Tři pojistky brání tomu, aby se spustil nad šumem: skok musí mít alespoň 0,22 průměrné šířky glyfu, první mezera nad řezem alespoň 0,32 této hodnoty a poslední mezera pod řezem nesmí překročit 0,65. Když některá pojistka selže, práh zůstane MaxInt a celý řádek se stane jediným slovem. Poslední pojistka brání tomu, aby jediná neobvykle široká kerningová dvojice rozřízla slovo, což je mnohem škodlivější než spojení dvou slov, protože spojený token stále obsahuje správné znaky ve správném pořadí pro substring search
Zápis neviditelné textové vrstvy přes naskenovaný obraz
ApplyLoadedOCRTextLayer převede rozpoznaná slova na prohledávatelnou vrstvu kreslením v text rendering mode 3, tedy režimu neither-fill-nor-stroke definovaném v ISO 32000-1 §9.3.6, a umístí je nad naskenovaný obraz, ze kterého pocházejí. Content stream se otevře přes BT následované 3 Tr a každé slovo se umístí textovou maticí sestavenou z nahlášené baseline, cap height převedené z pixelů podle DPI požadavku a horizontálního měřítka, které roztáhne syntetický glyph run na naměřenou šířku slova. Výsledkem je text, který se kopíruje a vyhledává, ale nic nekreslí
Existuje overload bez enginu, který vestavěný recognizer vytvoří za vás a který by měli používat zejména volající vestavěné cesty. Rozpoznání, validace Unicode, účtování budgetu i tvorba contentu se dokončí před otevřením copy-on-write transakce, takže cancel, překročení budgetu nebo selhání enginu ponechá graf objektů i číslo verze nedotčené. Slova se filtrují dvakrát: engine zahodí vše pod svou vlastní bránou confidence 0,55 na glyph a potom THPDFOCRTextLayerOptions.MinimumConfidence (výchozí 0,5) zahodí celá slova pod prahem volajícího
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; // digitální stránky nechej být
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload bez enginu: HotPDF dodá vestavěný omezený 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;
Jedno omezení je lepší říct přímo než ho objevit později. Neviditelná vrstva používá sdílený syntetický neembedded Type0 font, což stačí pro vyhledávání a kopírování v každém vieweru, ale nesplňuje požadavek na vložení fontu z ISO 19005. Pokud výstup musí být PDF/A, volající musí samostatně vložit vyhovující font. OCR textová vrstva také nese geometrii, nikoli strukturu, takže pořadí čtení vychází jen z pozic glyfů; pokud potřebujete logické pořadí ze stránky, která už skutečný text má, extrakce textu podle struktury řízená stromem tagů je jiný nástroj pro jiný problém
Kde vestavěný engine končí
Vestavěný engine je záměrně úzký a znalost jeho hranic ho udržuje užitečným. Míří na strojově tištěné ASCII s vysokým kontrastem z fontů blízkých jeho pěti šablonovým řezům a vše mimo tento rámec vrací jako žádné slovo, nikoli jako odhad. Konkrétní hranice jsou:
- Obrazy do 4096 krát 4096 a 4 194 304 pixelů, s limitem rozpoznání 2000 ms a kooperativním zrušením přes
THPDFCancellationToken - Abeceda 62 ASCII písmen a číslic; žádná interpunkce, diakritická písmena ani CJK
- Text pouze zarovnaný s osami při rotaci stránky, kterou renderer už normalizoval; zkosené skeny se nedeskewují
- Nejednoznačné dvojice glyfů zůstávají nevyřešené, takže stránka může vrátit neúplná slova nebo diagnostiku „found no unambiguous ASCII words“
Když je tento obal příliš malý, rozhraním je IHPDFOCREngine. Implementujte Recognize proti vlastnímu enginu, předejte ho tříargumentovému overloadu ApplyLoadedOCRTextLayer a vše downstream (mapování souřadnic, práce s rotací, validace Unicode, budgety i atomický commit) zůstane stejné. Bitmapa se půjčuje na dobu synchronního volání a nesmí se uchovávat. Správné vložení vrstvy ověříte znovunačtením uloženého souboru a běžnou textovou cestou popsanou v extrakci textu z načteného PDF v Delphi; pokud se slova vrátí, vrstva je skutečná
Vestavěné OCR s template matchingem, neviditelná textová vrstva, renderer stránky, který je napájí, i extrakce textu načteného dokumentu, která je ověřuje, se dodávají ve stejné nativní VCL komponentě bez externího OCR runtime a bez DLL k nasazení vedle aplikace. Pokud v Delphi nebo C++Builderu stavíte zpracování dokumentů, archivaci nebo vyhledávání v naskenovaných PDF, HotPDF Delphi PDF component vám dá celou pipeline v jedné závislosti