HotPDF levererar THPDFBuiltInOCREngine, en begränsad mallmatchande OCR-motor skriven helt i Object Pascal: den binariserar en renderad sida med Otsu-tröskling, extraherar glyfer som sammanhängande komponenter och poängsätter varje glyf efter gråskalstäckning mot cachade mallar i flera typsnitt, så att en Delphi-applikation kan bygga ett sökbart textlager utan något externt OCR-beroende. Motorn behövde byggas om från grunden i v2.731.0, och orsaken var inte matcharen. Det var pixlarna
Den gamla motorn klarade sina tester. Den kände igen versal ASCII på syntetiska bitmappar, och i Win32 fortsatte den att göra det i månader. Sedan kördes samma kod under Win64 och producerade ingenting alls: inga ord, ingen annan diagnostik än "found no high-contrast foreground", ingen krasch. Felet visade sig vara två oberoende misstag i vägen för pixelavläsning som hade tagit ut varandra, och att reda ut dem är en bra illustration av varför OCR-kod misslyckas tyst i stället för högljutt
Varför fungerade den gamla OCR-motorn bara av en slump?
Den gamla motorn fungerade eftersom dess mallbitmappar och målbitmappar var vända åt samma håll, så en vertikal spegling i pixelläsaren var osynlig för matcharen. TBitmap.ScanLine lämnar tillbaka rader i motsatt ordning mot den positiva biHeight-konvention för DIB som resten av bildvägen antar. Rendera ett M upp och ner, jämför det med en mall som också är upp och ner, och L1-skillnaden är identisk med den korrekta jämförelsen. Varje glyf matchade. Ingenting var rätt
Den symmetrin är exakt det som gör denna typ av fel dyr. Varje ensidig fix bryter matchningen: rätta målläsningen och låt mallarna vara, så kollapsar igenkänningen till brus; rätta mallarna först och du får samma kollaps åt andra hållet. Det finns ingen inkrementell reparationsväg. Ombyggnaden ersatte därför hela läsningen med GetDIBits mot en uttryckligen deklarerad BITMAPINFOHEADER, där en positiv biHeight enligt kontraktet betyder bottom-up-rader i stället för VCL-konvention, och vänder en gång, avsiktligt, när pixlarna kopieras till gråskalebufferten
Det andra misstaget är det som bara Win64 avslöjade. Den HDC som skickas till GetDIBits får inte vara bitmapens egen minnes-DC, eftersom bitmapen redan är vald i den och Windows dokumenterar det som ogiltigt. Att skicka Bitmap.Canvas.Handle tolererades av Win32-processen och misslyckades konsekvent i Win64-testprocessen. Lösningen är en tillfällig skärm-DC från GetDC(0), som frigörs i ett finally-block och inte har någon koppling till någon 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; // positivt => bottom-up-rader
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // aldrig Work.Canvas.Handle: Work är vald där
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]; // en avsiktlig vändning
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;
Binarisering och sammanhängande komponenter: från grå pixlar till glyfrutor
HotPDF binariserar först med Otsus metod och faller tillbaka till en tröskel från ett lokalt fönster endast när Otsu inte är tillämplig. Den globala vägen kräver ett verkligt bimodalt histogram: motorn beräknar maximum för variansen mellan klasserna och kräver dessutom att gråintervallet spänner över minst 64 nivåer innan resultatet godtas. En urtvättad skanning, en sida med gradientbakgrund eller en bitmap som nästan helt består av bläck misslyckas med det testet. Reservvägen jämför sedan varje pixel med medelvärdet i ett fönster på 31 gånger 31 med en bias på 6 grånivåer, beräknad med löpande kolumnsummor så att det skjutande fönstret förblir linjärt i antalet pixlar
Glyfextrahering är etikettering av 8-anslutna komponenter över den resulterande masken, med en uttrycklig stack i stället för rekursion, eftersom en mask för en hel sida utan problem kan spränga en Delphi-trådstack i en djup flood fill. Två filter körs vid etiketteringen: komponenter mindre än 9 pixlar tas bort som fläckbrus, och varje komponent som spänner över mer än tre femtedelar av både bildens bredd och höjd tas bort som en ram eller linje i stället för en glyf. Ett andra pass slår ihop vertikalt staplade rutor vars horisontella överlappning är minst en fjärdedel av den smalare rutan, vilket återförenar punkten i ett i eller ett j med stammen. Allt detta arbetar på ett raster, och rastret kommer från samma renderare som beskrivs i rendering av en inläst PDF-sida till en bitmap i Delphi, vilket spelar roll av en praktisk anledning: OCR-kvaliteten begränsas uppåt av renderingskvaliteten, och standard-DPI:t 300 för textlagret är en avsiktlig kompromiss, inte ett maximum
Vad gör versalt I och gement l omöjliga att skilja åt?
I Arial rasteriseras versalt I och gement l till pixelformer som är identiska, så ingen formfunktion kan skilja dem åt och informationen om skiftläge måste komma någon annanstans ifrån. Motorns svar är höjdklustring på radnivå. Glyfrutor grupperas i textrader efter vertikal överlappning, varje rad analyseras för sin versalhöjd och sitt modala baslinjeläge, och höjderna inom en rad delas i ett kort och ett långt kluster. En stapel i det korta klustret är ett l; samma stapel i det långa klustret är ett I
Den uppenbara implementationen av delningen är en fast kvottröskel, och den fungerar inte. Arials förhållande mellan x-höjd och versalhöjd är ungefär 0,72, vilket hamnar mitt på de värden 0,70 och 0,75 som alla provar först. Flytta konstanten en hundradel åt något håll och en hel korpus byter skiftläge. HotPDF gör i stället en endimensionell variansminimerande k=2-delning: sortera kandidathöjderna, prova varje brytpunkt och behåll den brytning vars summa av kvadrerade avvikelser inom klustren är minst. Tröskeln blir en egenskap hos sidan i stället för en konstant i källan
// ClusterHeights är sorterad stigande; hitta k=2-delningen med minst varians
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;
// endast kvoten mellan de två klustermedelvärdena avgör det korta bandet
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // ett verkligt x-höjdsband: gemena former
else
SmallGroup := ggTall; // ett höjdband: allt har versalhöjd
Line.LowercaseContext := (SmallGroup = ggSmall);
Rader med ett enda höjdband har ingen intern evidens alls. En rubrik med bara versaler och en bildtext med bara gemener ser likadana ut isolerat. För sådana rader jämför HotPDF radens medianhöjd med medianen för x-höjden på sidnivå, hämtad från rader som faktiskt delades: en kvot på högst 1,10 markerar gement sammanhang, en kvot på minst 1,18 markerar versalsammanhang och allt däremellan förblir obundet. Matchningen lägger sedan till en liten skiftlägespreferens på 0,03 för kandidaten som stämmer med sammanhanget, vilket knuffar oavgjorda resultat utan att någonsin åsidosätta en tydlig formskillnad
Varför förväxlade ett mallrutnät på 12x18 c och o?
Mallrutnätet breddades från 12 gånger 18 celler till 16 gånger 24 eftersom gråskalstäckningens marginal mellan c och o vid den lägre upplösningen föll under 0,007, långt inne i motorns tvetydighetströskel. Varje glyfruta samplas om till rutnätet som täckningsvärden från 0 till 255 i stället för som en binär stencil, så en cell som till en tredjedel består av bläck läses som ungefär 85 i stället för att avrundas till svart eller vitt. Vid 12 gånger 18 spänner den öppna sidan av ett c knappt över mer än en cellkolumn och det antialiasade medlet tvättar bort gapet. Vid 16 gånger 24 överlever gapet omsamplingen, och de flesta lättförväxlade par hamnar åter på säkert avstånd
Poängsättningen är det normaliserade L1-avståndet mellan de två täckningsrutnäten, plus en straffavgift på 0,30 gånger log-skillnaden i aspektförhållande och 0,16 gånger skillnaden i bläcktäthet, med ett hårt förfilter som hoppar över varje mall vars aspektförhållande skiljer sig med mer än en faktor 2,6. Mallarna rasteriseras en gång per process från fem systemtypsnitt (Arial, Times New Roman, Courier New, Tahoma och Segoe UI) över ett alfabet med 62 tecken, cachas bakom ett kritiskt avsnitt och återanvänds av varje senare anrop
Den sista konstanten är den intressanta. När tvåan i rankingen får en poäng inom 0,018 från vinnaren klampar HotPDF glyfens konfidens till 0,5, vilket ligger under acceptansgränsen 0,55, så glyfen skrivs helt enkelt inte ut. Det är ett avsiktligt fail-closed-snitt, inte en trimningsartefakt: en begränsad motor som gissar producerar ett sökbart lager vars text inte stämmer med bilden, och ett felaktigt ord i ett textlager är värre än ett saknat eftersom det är osynligt för personen som granskar skanningen
Dela ord utan en fast avståndströskel
HotPDF härleder ordmellanrummets tröskel per rad från fördelningen av mellan-glyf-avstånd i stället för från en fast multipel av den genomsnittliga glyfbredden. Den klassiska heuristiken, "ett mellanrum bredare än 0,75 av det genomsnittliga steget är ett mellanslag", bryter så snart en rad blandar siffror med smala bokstäver, eftersom det genomsnittliga steget slutar beskriva något verkligt. Motorn sorterar i stället avstånden för raden och letar efter det största hoppet mellan två på varandra följande sorterade värden, vilket är gränsen mellan klustret inom ord och klustret mellan ord om ett sådant finns. Tre skydd hindrar brus från att utlösa det: hoppet måste vara minst 0,22 av den genomsnittliga glyfbredden, det första avståndet ovanför delningen måste vara minst 0,32 av den och det sista avståndet under delningen får inte överstiga 0,65 av den. Om något skydd fallerar förblir tröskeln MaxInt och hela raden blir ett enda ord. Det sista skyddet hindrar ett enda ovanligt brett kerningpar från att dela ett ord i två, vilket är ett mycket skadligare fel än att slå ihop två ord, eftersom ett sammanslaget token fortfarande innehåller rätt tecken i rätt ordning för en delsträngssökning
Skriv det osynliga textlagret över den skannade bilden
ApplyLoadedOCRTextLayer omvandlar igenkända ord till ett sökbart lager genom att rita dem i textåtergivningsläge 3, läget utan fyllning och linje som definieras i ISO 32000-1 §9.3.6, placerat över den skannade bilden de kom från. Innehållsströmmen börjar med BT följt av 3 Tr, och varje ord placeras med en textmatris byggd från den rapporterade baslinjen, dess versalhöjd omräknad från pixlar vid den begärda DPI:n och en horisontell skala som sträcker det syntetiska glyftåget till den uppmätta ordvidden. Resultatet kopieras och söks som text men målar ingenting
Det finns en motorfri överlagring som instansierar den inbyggda igenkännaren åt dig, och den bör de flesta anropare av den inbyggda vägen använda. Igenkänning, Unicode-validering, budgeträkning och innehållskonstruktion blir klara innan copy-on-write-transaktionen öppnas, så en avbrytning, ett budgetöverskridande eller ett motorfel lämnar objektgrafen och versionsnumret orörda. Ord filtreras två gånger: motorn tar bort allt under sin egen konfidensgräns på 0,55 per glyf, och sedan tar THPDFOCRTextLayerOptions.MinimumConfidence (standard 0,5) bort hela ord under anroparens gräns
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; // lämna digitalt födda sidor orörda
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// motorfri överlagring: HotPDF tillhandahåller den inbyggda begränsade igenkännaren
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;
En begränsning bör sägas tydligt i stället för att upptäckas senare. Det osynliga lagret använder ett delat syntetiskt oinbäddat Type0-typsnitt, vilket räcker för sökning och kopiering i alla visare men inte uppfyller kravet på typsnitts inbäddning i ISO 19005. Om resultatet måste vara PDF/A måste anroparen bädda in ett kompatibelt typsnitt separat. Och ett OCR-textlager bär geometri, inte struktur, så läsordningen kommer enbart från glyfpositionerna; om du behöver logisk ordning från en sida som redan har riktig text är strukturordnad textextrahering som styrs av taggträdet ett annat verktyg för ett annat problem
Var den inbyggda motorn slutar
Den inbyggda motorn är avsiktligt smal, och att känna till dess kanter är det som håller den användbar. Den riktar sig mot högkontrast-ASCII från maskintryckta typsnitt som ligger nära dess fem mallansikten, och allt utanför det ger inga ord i stället för en gissning. De konkreta gränserna är:
- Bilder upp till 4096 gånger 4096 och 4 194 304 pixlar, med en igenkänningsfrist på 2000 ms och samverkande avbrytning genom
THPDFCancellationToken - Ett alfabet med 62 ASCII-bokstäver och siffror; ingen interpunktion, inga accenttecken, ingen CJK
- Endast axeljusterad text, vid den sidrotation som renderaren redan normaliserat; skeva skanningar rätas inte upp
- Tvetydiga glyfpar förblir olösta, så en sida kan returnera partiella ord eller diagnosen "found no unambiguous ASCII words"
När det höljet är för litet är IHPDFOCREngine skarven. Implementera Recognize mot din egen motor, skicka den till överlagringen av ApplyLoadedOCRTextLayer med tre argument och allt nedströms (koordinatmappning, rotationshantering, Unicode-validering, budgetar och det atomiska åtagandet) förblir detsamma. Bitmapen lånas under det synkrona anropet och får inte behållas. För att bekräfta att lagret landade rätt laddar du om den sparade filen och kör den vanliga textvägen som beskrivs i extrahering av text från en inläst PDF i Delphi; om orden kommer tillbaka är lagret verkligt
Den inbyggda mallmatchande OCR:en, det osynliga textlagret, sidrenderaren som matar dem och extraheringen av text från det inlästa dokumentet som verifierar dem levereras alla i samma inbyggda VCL-komponent, utan extern OCR-runtime och utan någon DLL att distribuera bredvid applikationen. Om du bygger dokumentinfångning, arkivering eller sökning över skannade PDF-filer i Delphi eller C++Builder ger HotPDF Delphi PDF-komponent dig hela pipelinen i ett beroende