HotPDF include THPDFBuiltInOCREngine, un motore OCR a template matching delimitato e scritto interamente in Object Pascal: binarizza una pagina renderizzata con la sogliatura di Otsu, estrae i glifi come connected component e valuta ogni glifo in base alla copertura in scala di grigi rispetto a template multi-font memorizzati nella cache, così un'applicazione Delphi può costruire un text layer ricercabile senza dipendenze OCR esterne. Il motore ha dovuto essere ricostruito da zero nella v2.731.0, e il motivo non era il matcher. Erano i pixel
Il vecchio motore superava i test. Riconosceva l'ASCII maiuscolo su bitmap sintetici, e su Win32 continuava a farlo da mesi. Poi lo stesso codice è stato eseguito su Win64 e non ha prodotto assolutamente nulla: nessuna parola, nessuna diagnosi oltre a "found no high-contrast foreground", nessun crash. Il bug si è rivelato composto da due errori indipendenti nel percorso di lettura dei pixel che si annullavano a vicenda, e separarli è un buon esempio di come il codice OCR fallisca silenziosamente invece che rumorosamente
Perché il vecchio motore OCR funzionava solo per caso?
Il vecchio motore funzionava perché i bitmap dei template e quelli target venivano capovolti nello stesso modo, quindi un'inversione verticale nel lettore dei pixel era invisibile al matcher. TBitmap.ScanLine restituisce le righe nell'ordine opposto rispetto alla convenzione DIB con biHeight positivo che il resto del percorso immagini presume. Renderizzate una M al contrario, confrontatela con un template anch'esso al contrario e la differenza L1 è identica a quella del confronto corretto. Ogni glifo veniva riconosciuto. Nulla era corretto
È proprio questa simmetria a rendere costosa la classe di bug. Qualsiasi correzione applicata a un solo lato rompe il matching: correggete la lettura del target e lasciate i template intatti, e il riconoscimento collassa nel rumore; correggete prima i template e ottenete lo stesso collasso dall'altra direzione. Non esiste un percorso di riparazione incrementale. La ricostruzione ha quindi sostituito l'intera lettura con GetDIBits su un BITMAPINFOHEADER dichiarato esplicitamente, dove un biHeight positivo significa per contratto righe bottom-up invece che per convenzione VCL, e capovolge una sola volta, deliberatamente, durante la copia nel buffer in scala di grigi
Il secondo errore è quello emerso solo su Win64. L'HDC passato a GetDIBits non deve essere il memory DC del bitmap, perché il bitmap è già selezionato al suo interno e Windows lo documenta come non valido. Passare Bitmap.Canvas.Handle veniva tollerato dal processo Win32 e falliva costantemente nel processo di test Win64. La correzione è un screen DC temporaneo ottenuto da GetDC(0), rilasciato in un blocco finally, che non ha alcun legame con un 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; // positivo => righe bottom-up
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // mai Work.Canvas.Handle: Work è selezionato lì
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]; // un solo capovolgimento deliberato
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;
Binarizzazione e connected component: dai pixel grigi ai riquadri dei glifi
HotPDF binarizza prima con il metodo di Otsu e ricorre a una soglia su finestra locale solo quando Otsu non è applicabile. Il percorso globale richiede un istogramma realmente bimodale: il motore calcola il massimo della varianza tra classi e richiede inoltre che l'intervallo dei grigi copra almeno 64 livelli prima di fidarsi del risultato. Una scansione slavata, una pagina con sfondo a gradiente o un bitmap quasi interamente pieno d'inchiostro falliscono questo test. Il fallback confronta quindi ogni pixel con la media di una finestra 31 per 31 con un bias di 6 livelli di grigio, calcolata con somme di colonna progressive così la finestra scorre in tempo lineare rispetto al numero di pixel
L'estrazione dei glifi è un connected-component labeling 8-connected sulla maschera risultante, con uno stack esplicito invece della ricorsione, perché una maschera a pagina intera può facilmente esaurire lo stack di un thread Delphi durante un flood fill profondo. Due filtri operano già durante il labeling: i componenti più piccoli di 9 pixel vengono scartati come rumore puntiforme, e qualsiasi componente che copra più di tre quinti sia della larghezza sia dell'altezza dell'immagine viene scartato come cornice o linea anziché glifo. Un secondo passaggio unisce i riquadri impilati verticalmente quando la loro sovrapposizione orizzontale è almeno un quarto del riquadro più stretto, ricongiungendo il punto di una i o di una j al proprio gambo. Tutto opera su un raster, e il raster proviene dallo stesso renderer descritto in renderizzare una pagina PDF caricata in bitmap in Delphi, cosa importante per un motivo pratico: la qualità OCR è limitata superiormente dalla qualità del rendering, e i 300 DPI del text layer predefinito sono un compromesso deliberato, non un massimo
Cosa rende indecidibili la I maiuscola e la l minuscola?
In Arial, la I maiuscola e la l minuscola rasterizzano in barre identiche pixel per pixel, quindi nessuna caratteristica della forma può separarle e il case deve provenire da tutt'altra fonte. La risposta del motore è il clustering dell'altezza a livello di riga. I riquadri dei glifi vengono raggruppati in text line tramite sovrapposizione verticale, ogni riga viene analizzata per la propria cap height e la baseline modale, e le altezze dentro una riga vengono divise in un cluster corto e uno alto. Una barra nel cluster corto è una l; la stessa barra nel cluster alto è una I
L'implementazione ovvia di questa divisione è una soglia a rapporto fisso, e non funziona. Il rapporto tra x-height e cap-height di Arial è circa 0,72, esattamente tra i valori 0,70 e 0,75 a cui tutti ricorrono per primi. Spostate la costante di un centesimo in una delle due direzioni e un intero corpus cambia case. HotPDF esegue invece una divisione unidimensionale k=2 che minimizza la varianza: ordina le altezze candidate, prova ogni punto di separazione e conserva quello con la somma dei quadrati degli scarti intra-cluster più piccola. La soglia diventa una proprietà della pagina invece che una costante nel sorgente
// ClusterHeights è ordinato in senso crescente; trova la divisione k=2 con varianza minima
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;
// solo il rapporto tra le medie dei due cluster decide quale sia la fascia corta
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // una vera fascia x-height: forme minuscole
else
SmallGroup := ggTall; // una sola fascia d'altezza: tutto è cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
Le righe con una sola fascia d'altezza non contengono alcuna evidenza interna. Un titolo tutto maiuscolo e una didascalia tutta minuscola appaiono uguali in isolamento. Per queste righe HotPDF confronta l'altezza mediana della riga con la x-height mediana a livello di pagina, ricavata dalle righe che hanno prodotto una divisione: un rapporto pari o inferiore a 1,10 marca il contesto come minuscolo, un rapporto pari o superiore a 1,18 come maiuscolo, mentre tutto ciò che sta nel mezzo resta non vincolato. Il matching applica poi un piccolo bonus di preferenza del case, 0,03, verso il candidato coerente con quel contesto, così da risolvere i pareggi senza mai sovrascrivere una differenza di forma evidente
Perché una griglia di template 12x18 confondeva c e o?
La griglia dei template è stata ampliata da 12 per 18 celle a 16 per 24 perché alla risoluzione più piccola il margine di copertura in scala di grigi tra c e o scendeva sotto 0,007, ben dentro la soglia di ambiguità del motore. Ogni riquadro del glifo viene ricampionato nella griglia come valore di copertura da 0 a 255 invece che come maschera binaria, quindi una cella piena per un terzo vale circa 85 invece di essere arrotondata a nero o bianco. A 12 per 18 il lato aperto di una c copre poco più di una colonna di celle e la media antialiasing cancella il vuoto. A 16 per 24 il vuoto sopravvive al ricampionamento e la maggior parte delle coppie facilmente confondibili torna a una distanza sicura
Il punteggio è la distanza L1 normalizzata tra le due griglie di copertura, più una penalità pari a 0,30 volte la differenza del rapporto d'aspetto logaritmico e a 0,16 volte la differenza di densità dell'inchiostro, con un pre-filtro rigido che salta ogni template il cui rapporto d'aspetto differisce di più di un fattore 2,6. I template vengono rasterizzati una volta per processo da cinque system font (Arial, Times New Roman, Courier New, Tahoma e Segoe UI) su un alfabeto di 62 caratteri, memorizzati dietro una critical section e riutilizzati da ogni chiamata successiva
L'ultima costante è quella interessante. Quando il punteggio del carattere arrivato secondo è entro 0,018 dal vincitore, HotPDF limita la confidence del glifo a 0,5, sotto la soglia di accettazione 0,55, quindi il glifo non viene semplicemente emesso. È un taglio fail-closed deliberato, non un dettaglio di tuning: un motore delimitato che indovina produce un text layer ricercabile il cui testo non corrisponde all'immagine, e una parola errata nel text layer è peggiore di una mancante perché è invisibile alla persona che esamina la scansione
Dividere le parole senza una soglia fissa per gli spazi
HotPDF ricava la soglia dello spazio tra parole per ogni riga dalla distribuzione degli spazi tra glifi invece che da un multiplo fisso della larghezza media del glifo. L'euristica classica, "uno spazio più largo di 0,75 dell'avanzamento medio", si rompe appena una riga mescola cifre e lettere strette, perché l'avanzamento medio smette di descrivere qualcosa di reale. Il motore ordina invece gli spazi della riga e cerca il salto più grande tra valori consecutivi, che è il confine tra il cluster intra-parola e quello inter-parola quando esiste. Tre guardie impediscono che si attivi sul rumore: il salto deve essere almeno 0,22 della larghezza media del glifo, il primo spazio sopra la divisione deve essere almeno 0,32 di quella larghezza e l'ultimo spazio sotto la divisione non deve superare 0,65. Se una guardia fallisce, la soglia resta MaxInt e l'intera riga diventa una sola parola. Quest'ultima guardia evita che una sola coppia di kern insolitamente larga spezzi una parola, un errore molto più dannoso dell'unione di due parole, perché un token unito contiene ancora i caratteri corretti nell'ordine corretto per una ricerca di sottostringa
Scrivere il text layer invisibile sopra l'immagine scansionata
ApplyLoadedOCRTextLayer trasforma le parole riconosciute in un layer ricercabile disegnandole nella modalità di rendering del testo 3, la modalità né riempita né tracciata definita in ISO 32000-1 §9.3.6, posizionandole sopra l'immagine scansionata da cui provengono. Il content stream si apre con BT seguito da 3 Tr, e ogni parola viene posizionata con una text matrix costruita dalla baseline riportata, dalla cap height convertita da pixel alla DPI richiesta e da una scala orizzontale che allunga il synthetic glyph run fino alla larghezza misurata della parola. Il risultato si copia e si cerca come testo, ma non dipinge nulla
Esiste un overload senza engine che istanzia per voi il recognizer integrato, ed è quello che dovrebbero usare la maggior parte dei caller del percorso built-in. Riconoscimento, validazione Unicode, conteggio del budget e costruzione del contenuto terminano prima dell'apertura della transazione copy-on-write, quindi una cancellazione, un superamento del budget o un errore del motore lasciano invariati object graph e numero di versione. Le parole vengono filtrate due volte: il motore scarta tutto ciò che è sotto la propria soglia di confidence per glifo, 0,55, poi THPDFOCRTextLayerOptions.MinimumConfidence, default 0,5, scarta le parole intere sotto la soglia del caller
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; // lascia intatte le pagine native digitali
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload senza engine: HotPDF fornisce il recognizer integrato delimitato
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;
Un limite va dichiarato chiaramente invece di scoprirlo più tardi. Il layer invisibile usa un synthetic Type0 font condiviso non embedded, sufficiente per ricerca e copia in ogni viewer ma non conforme al requisito di embedding dei font di ISO 19005. Se l'output deve essere PDF/A, il caller deve incorporare separatamente un font conforme. Inoltre un OCR text layer trasporta geometria, non struttura, quindi l'ordine di lettura deriva solo dalle posizioni dei glifi; se serve l'ordine logico di una pagina che contiene già testo reale, l'estrazione del testo in ordine strutturale guidata dall'albero dei tag è uno strumento diverso per un problema diverso
Dove si ferma il motore integrato
Il motore integrato è deliberatamente ristretto, e conoscere i suoi confini è ciò che lo mantiene utile. Punta all'ASCII stampato a macchina ad alto contrasto con font vicini alle sue cinque facce template, e tutto ciò che esce da questo perimetro restituisce zero parole invece di indovinare. I confini concreti sono:
- Immagini fino a 4096 per 4096 e 4.194.304 pixel, con una deadline di riconoscimento di 2000 ms e cancellazione cooperativa tramite
THPDFCancellationToken - Un alfabeto di 62 caratteri composto da lettere ASCII e cifre; niente punteggiatura, caratteri accentati o CJK
- Solo testo allineato agli assi, con la rotazione di pagina già normalizzata dal renderer; le scansioni inclinate non vengono deskewed
- Le coppie di glifi ambigue restano irrisolte, quindi una pagina può restituire parole parziali o la diagnosi "found no unambiguous ASCII words"
Quando questo involucro è troppo piccolo, il punto di estensione è IHPDFOCREngine. Implementate Recognize sul vostro engine, passatelo all'overload a tre argomenti di ApplyLoadedOCRTextLayer e tutto ciò che viene dopo (mapping delle coordinate, gestione della rotazione, validazione Unicode, budget, commit atomico) resta uguale. Il bitmap viene preso in prestito per la durata della chiamata sincrona e non deve essere conservato. Per confermare che il layer sia stato applicato correttamente, ricaricate il file salvato ed eseguite il normale percorso di testo descritto in estrarre testo da un PDF caricato in Delphi; se le parole tornano, il layer è reale
Il template-matching OCR integrato, il text layer invisibile, il page renderer che li alimenta e l'estrazione del testo dal documento caricato che li verifica sono tutti inclusi nello stesso componente VCL nativo, senza runtime OCR esterno e senza DLL da distribuire accanto all'applicazione. Se state costruendo acquisizione documentale, archiviazione o ricerca su PDF scansionati in Delphi o C++Builder, il componente PDF Delphi HotPDF vi offre l'intera pipeline in un'unica dipendenza