Technischer Artikel

Integrierte Template-Matching-OCR in Delphi mit HotPDF

HotPDF enthält THPDFBuiltInOCREngine, eine begrenzte Template-Matching-OCR, vollständig in Object Pascal geschrieben: Sie binarisiert eine gerenderte Seite mit Otsu-Schwellwertbildung, extrahiert Glyphs als zusammenhängende Komponenten und bewertet jedes Glyph anhand seiner Graustufenabdeckung gegen zwischengespeicherte Templates mehrerer Fonts. So kann eine Delphi-Anwendung ohne externe OCR-Abhängigkeit eine durchsuchbare Textebene erzeugen. Die Engine musste in v2.731.0 von Grund auf neu gebaut werden, und der Grund war nicht der Matcher. Es waren die Pixel

Die alte Engine bestand ihre Tests. Sie erkannte großgeschriebenes ASCII auf synthetischen Bitmaps, und unter Win32 tat sie das monatelang weiter. Dann lief derselbe Code unter Win64 und erzeugte überhaupt nichts: keine Wörter, keine Diagnose außer "found no high-contrast foreground", keinen Absturz. Der Fehler erwies sich als zwei unabhängige Fehler im Pixel-Lesepfad, die sich gegenseitig aufgehoben hatten. Sie auseinanderzunehmen, zeigt gut, warum OCR-Code eher still als laut scheitert

Warum funktionierte die alte OCR nur zufällig?

Die alte Engine funktionierte, weil ihre Template-Bitmaps und Ziel-Bitmaps auf dieselbe Weise gespiegelt wurden, sodass eine vertikale Invertierung im Pixel-Leser für den Matcher unsichtbar blieb. TBitmap.ScanLine liefert Zeilen in der umgekehrten Reihenfolge gegenüber der positiven biHeight-DIB-Konvention, von der der übrige Bildpfad ausgeht. Rendert man ein M auf dem Kopf und vergleicht es mit einem ebenfalls auf dem Kopf stehenden Template, ist die L1-Differenz identisch mit dem korrekten Vergleich. Jedes Glyph wurde erkannt. Nichts daran war richtig

Diese Symmetrie macht die Fehlerklasse so teuer. Jede einseitige Korrektur zerstört das Matching: Korrigiert man das Lesen des Ziels und lässt die Templates unverändert, bricht die Erkennung zu Rauschen zusammen; korrigiert man zuerst die Templates, entsteht derselbe Zusammenbruch aus der anderen Richtung. Es gibt keinen schrittweisen Reparaturweg. Der Neuaufbau ersetzte daher das gesamte Lesen durch GetDIBits gegen einen ausdrücklich deklarierten BITMAPINFOHEADER. Dort bedeutet ein positives biHeight vertraglich Bottom-up-Zeilen statt einer VCL-Konvention, und beim Kopieren in den Graustufenpuffer wird genau einmal bewusst gespiegelt

Der zweite Fehler wurde erst unter Win64 sichtbar. Der an GetDIBits übergebene HDC darf nicht der eigene Memory-DC der Bitmap sein, weil die Bitmap bereits darin selektiert ist und Windows das als ungültig dokumentiert. Bitmap.Canvas.Handle wurde vom Win32-Prozess toleriert und scheiterte im Win64-Testprozess zuverlässig. Die Korrektur ist ein Wegwerf-Screen-DC aus GetDC(0), der in einem finally-Block freigegeben wird und nichts mit einer Bitmap zu tun hat

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;   // positiv => Bottom-up-Zeilen
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // niemals Work.Canvas.Handle: Work ist dort selektiert
    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];   // eine bewusste Spiegelung
      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;

Binarisierung und zusammenhängende Komponenten: von Graupixeln zu Glyphboxen

HotPDF binarisiert zuerst mit Otsus Methode und greift nur dann auf einen Schwellwert aus einem lokalen Fenster zurück, wenn Otsu nicht anwendbar ist. Der globale Weg verlangt ein wirklich bimodales Histogramm: Die Engine berechnet das Maximum der Zwischenklassenvarianz und verlangt zusätzlich, dass der Graubereich mindestens 64 Stufen umfasst, bevor sie dem Ergebnis vertraut. Ein ausgewaschener Scan, eine Seite mit Farbverlauf im Hintergrund oder eine Bitmap, die fast vollständig aus Tinte besteht, scheitern an dieser Prüfung. Der Fallback vergleicht dann jedes Pixel mit dem Mittelwert eines 31-mal-31-Fensters und einem Bias von 6 Graustufen. Laufende Spaltensummen halten das gleitende Fenster linear in der Pixelanzahl

Die Glyph-Extraktion ist eine 8-verbundene Komponentenbeschriftung über der entstandenen Maske, mit einem expliziten Stack statt Rekursion, weil eine ganzseitige Maske den Stack eines Delphi-Threads bei einem tiefen Flood Fill problemlos sprengen kann. Zwei Filter laufen bereits während der Beschriftung: Komponenten mit weniger als 9 Pixeln werden als Sprenkelrauschen verworfen, und jede Komponente, die sich über mehr als drei Fünftel sowohl der Bildbreite als auch der Bildhöhe erstreckt, wird als Rahmen oder Linie und nicht als Glyph verworfen. Ein zweiter Durchlauf verbindet vertikal gestapelte Boxen, deren horizontale Überlappung mindestens ein Viertel der schmaleren Box beträgt. So werden der Punkt von i oder j und der Stamm wieder zusammengeführt. Alles arbeitet auf einem Raster, und das Raster stammt aus demselben Renderer wie bei der Darstellung einer geladenen PDF-Seite als Bitmap in Delphi. Das ist praktisch relevant: Die OCR-Qualität kann nie besser als die Renderqualität sein, und die Standard-DPI von 300 für die Textebene ist ein bewusster Kompromiss und kein Maximum

Was macht großes I und kleines l unentscheidbar?

In Arial rasterisieren großes I und kleines l zu pixelidentischen Balken. Kein Formmerkmal kann sie trennen, daher muss die Groß-/Kleinschreibung vollständig aus einer anderen Quelle kommen. Die Engine verwendet dafür eine Höhenclusterung auf Zeilenebene. Glyphboxen werden anhand vertikaler Überlappung zu Textzeilen gruppiert, jede Zeile wird auf ihre Versalhöhe und ihre modale Grundlinie untersucht, und die Höhen innerhalb einer Zeile werden in ein kurzes und ein hohes Cluster geteilt. Ein Balken im kurzen Cluster ist ein l, derselbe Balken im hohen Cluster ein I

Die naheliegende Implementierung dieser Trennung ist ein fester Verhältnis-Schwellwert, und sie funktioniert nicht. Das Verhältnis von x-Höhe zu Versalhöhe in Arial liegt ungefähr bei 0,72 und damit genau bei den Werten 0,70 und 0,75, zu denen man zuerst greift. Verschiebt man die Konstante um ein Hundertstel in die eine oder andere Richtung, kippt ein gesamtes Korpus in der Groß-/Kleinschreibung. HotPDF verwendet stattdessen eine eindimensionale varianzminimierende Trennung mit k=2: Kandidatenhöhen sortieren, jeden Trennpunkt ausprobieren und den Schnitt behalten, dessen Summe der quadrierten Abweichungen innerhalb der Cluster am kleinsten ist. Der Schwellwert wird so zu einer Eigenschaft der Seite und nicht zu einer Konstante im Quelltext

// ClusterHeights ist aufsteigend sortiert; die k=2-Trennung mit der kleinsten Varianz finden
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;
// Nur das Verhältnis der beiden Clustermittelwerte entscheidet, welches Band kurz ist
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // ein echtes x-Höhenband: Kleinbuchstabenformen
else
  SmallGroup := ggTall;          // ein Höhenband: alles Versalhöhe
Line.LowercaseContext := (SmallGroup = ggSmall);

Zeilen mit nur einem Höhenband enthalten keinerlei interne Evidenz. Eine Überschrift nur aus Großbuchstaben und eine Bildunterschrift nur aus Kleinbuchstaben sehen isoliert gleich aus. Für solche Zeilen vergleicht HotPDF die mittlere Zeilenhöhe mit der seitensweiten mittleren x-Höhe aus Zeilen, die tatsächlich getrennt wurden: Ein Verhältnis bis einschließlich 1,10 markiert den Kontext als kleingeschrieben, ein Verhältnis ab 1,18 als Versalkontext, und dazwischen bleibt er unbestimmt. Das Matching gibt dann dem Kandidaten, der zu diesem Kontext passt, einen kleinen Bonus von 0,03. Er löst Gleichstände, überschreibt aber niemals einen klaren Formunterschied

Warum brachte ein Template-Raster von 12×18 c und o durcheinander?

Das Template-Raster wurde von 12×18 auf 16×24 Zellen erweitert, weil der Grauabdeckungsabstand zwischen c und o bei der kleineren Auflösung unter 0,007 fiel und damit deutlich innerhalb der Mehrdeutigkeitsschwelle der Engine lag. Jede Glyphbox wird als Abdeckungswerte von 0 bis 255 und nicht als binäre Schablone in das Raster resampelt. Eine Zelle mit einem Drittel Tinte liest sich so ungefähr als 85, statt auf Schwarz oder Weiß gerundet zu werden. Bei 12×18 umfasst die offene Seite eines c kaum mehr als eine Zellenspalte, und der antialiaste Mittelwert wäscht die Lücke weg. Bei 16×24 bleibt sie nach dem Resampling erhalten, und die meisten leicht verwechselbaren Paare kommen wieder auf einen sicheren Abstand

Die Bewertung ist die normalisierte L1-Distanz zwischen den beiden Abdeckungsrastern plus einer Strafe von 0,30 mal der logarithmischen Differenz der Seitenverhältnisse und 0,16 mal der Differenz der Tintendichte. Ein harter Vorfilter überspringt jedes Template, dessen Seitenverhältnis sich um mehr als den Faktor 2,6 unterscheidet. Templates werden einmal pro Prozess aus fünf Systemfonts (Arial, Times New Roman, Courier New, Tahoma und Segoe UI) über ein Alphabet von 62 Zeichen gerastert, hinter einer Critical Section gecacht und von jedem folgenden Aufruf wiederverwendet

Die letzte Konstante ist die interessante. Wenn die Bewertung des zweitbesten Zeichens höchstens 0,018 vom Gewinner entfernt ist, begrenzt HotPDF das Vertrauen in das Glyph auf 0,5. Das liegt unter der Akzeptanzschwelle von 0,55, daher wird das Glyph einfach nicht ausgegeben. Das ist eine bewusste Fail-closed-Grenze und kein Tuning-Artefakt: Eine begrenzte Engine, die rät, erzeugt eine durchsuchbare Ebene, deren Text nicht zum Bild passt. Ein falsches Wort in einer Textebene ist schlimmer als ein fehlendes, weil es für die Person, die den Scan prüft, unsichtbar bleibt

Wörter ohne festen Abstandsschwellwert trennen

HotPDF leitet den Schwellwert für Wortabstände je Zeile aus der Verteilung der Glyphabstände ab und nicht aus einem festen Vielfachen der durchschnittlichen Glyphbreite. Die klassische Heuristik "Ein Abstand größer als 0,75 des mittleren Vorschubs ist ein Leerzeichen" bricht, sobald eine Zeile Ziffern mit schmalen Buchstaben mischt, weil der mittlere Vorschub dann nichts Reales mehr beschreibt. Die Engine sortiert stattdessen die Abstände der Zeile und sucht den größten Sprung zwischen aufeinanderfolgenden sortierten Werten. Wenn es eine Grenze gibt, liegt dort die Trennung zwischen dem Cluster innerhalb eines Worts und dem Cluster zwischen Wörtern. Drei Schutzbedingungen verhindern das Auslösen durch Rauschen: Der Sprung muss mindestens 0,22 der durchschnittlichen Glyphbreite betragen, der erste Abstand oberhalb der Trennung mindestens 0,32 davon, und der letzte Abstand unterhalb der Trennung darf 0,65 davon nicht überschreiten. Scheitert eine Bedingung, bleibt der Schwellwert bei MaxInt und die ganze Zeile wird zu einem Wort. Die letzte Bedingung verhindert, dass ein ungewöhnlich breites Kerningpaar ein Wort teilt. Das ist ein deutlich schädlicherer Fehler als zwei Wörter zu verbinden, denn ein verbundenes Token enthält für eine Teilstringsuche weiterhin die richtigen Zeichen in der richtigen Reihenfolge

Die unsichtbare Textebene über dem Scan schreiben

ApplyLoadedOCRTextLayer macht aus erkannten Wörtern eine durchsuchbare Ebene, indem es sie im Text-Rendering-Modus 3 zeichnet, dem in ISO 32000-1 §9.3.6 definierten Modus weder für Füllung noch für Kontur, und sie über dem gescannten Ursprungsbild positioniert. Der Content-Stream beginnt mit BT, gefolgt von 3 Tr, und jedes Wort wird mit einer Textmatrix platziert, die aus seiner gemeldeten Grundlinie, seiner aus Pixeln bei der angeforderten DPI umgerechneten Versalhöhe und einer horizontalen Skalierung für die gemessene Wortbreite gebildet wird. Das Ergebnis lässt sich kopieren und durchsuchen, wird aber nicht gezeichnet

Es gibt eine overloadfreie Engine-Variante, die den integrierten Recognizer selbst instanziiert; die meisten Aufrufer des integrierten Wegs sollten sie verwenden. Erkennung, Unicode-Validierung, Budgetabrechnung und Inhaltskonstruktion sind abgeschlossen, bevor die Copy-on-Write-Transaktion beginnt. Eine Abbruchanforderung, ein überschrittenes Budget oder ein Enginefehler lässt Objektgraph und Versionsnummer daher unverändert. Wörter werden zweimal gefiltert: Die Engine verwirft alles unter ihrer eigenen Glyph-Vertrauensschwelle von 0,55, anschließend verwirft THPDFOCRTextLayerOptions.MinimumConfidence (Standard 0,5) ganze Wörter unterhalb der Vorgabe des Aufrufers

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;             // Seiten mit vorhandenem Text auslassen
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // overloadfreie Engine-Variante: HotPDF liefert den integrierten begrenzten 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;

Eine Grenze sollte man klar aussprechen, statt sie später zu entdecken. Die unsichtbare Ebene verwendet einen gemeinsamen synthetischen, nicht eingebetteten Type0-Font. Das reicht für Suche und Kopieren in jedem Viewer, erfüllt aber nicht die Font-Einbettungsanforderung von ISO 19005. Muss die Ausgabe PDF/A sein, muss der Aufrufer separat einen konformen Font einbetten. Außerdem trägt eine OCR-Textebene Geometrie und keine Struktur; die Lesereihenfolge stammt allein aus Glyphpositionen. Wenn Sie die logische Reihenfolge einer Seite mit echtem Text benötigen, ist strukturgeführte Textextraktion über den Tag-Baum ein anderes Werkzeug für ein anderes Problem

Wo die integrierte Engine endet

Die integrierte Engine ist bewusst schmal ausgelegt, und wer ihre Grenzen kennt, hält sie nützlich. Sie zielt auf kontrastreiche, maschinell gedruckte ASCII-Zeichen aus Fonts, die ihren fünf Template-Schriften nahekommen. Alles außerhalb dieses Rahmens liefert kein Wort statt eines geratenen. Die konkreten Grenzen sind:

  • Bilder bis 4096×4096 und 4.194.304 Pixel mit einer Erkennungsfrist von 2000 ms und kooperativem Abbruch über THPDFCancellationToken
  • Ein Alphabet aus 62 ASCII-Buchstaben und Ziffern; keine Satzzeichen, keine Akzentzeichen, kein CJK
  • Nur achsenparalleler Text in der Seitenrotation, die der Renderer bereits normalisiert hat; schiefe Scans werden nicht entzerrt
  • Mehrdeutige Glyphpaare bleiben ungelöst, daher kann eine Seite unvollständige Wörter oder die Diagnose "found no unambiguous ASCII words" liefern

Wenn dieser Rahmen zu klein ist, ist IHPDFOCREngine die Schnittstelle. Implementieren Sie Recognize für Ihre eigene Engine, übergeben Sie sie an die Drei-Argumente-Überladung von ApplyLoadedOCRTextLayer, und alles danach – Koordinatenzuordnung, Rotationsbehandlung, Unicode-Validierung, Budgets und der atomare Commit – bleibt gleich. Die Bitmap wird für die Dauer des synchronen Aufrufs geliehen und darf nicht behalten werden. Um zu bestätigen, dass die Ebene korrekt gelandet ist, laden Sie die gespeicherte Datei neu und nutzen Sie den gewöhnlichen Textpfad aus der Extraktion von Text aus einem geladenen PDF in Delphi. Wenn die Wörter zurückkommen, ist die Ebene echt

Die integrierte Template-Matching-OCR, die unsichtbare Textebene, der sie zuführende Seitenrenderer und die geladene Dokumenttextextraktion zu ihrer Prüfung werden gemeinsam in derselben nativen VCL-Komponente ausgeliefert, ohne externe OCR-Laufzeit und ohne DLL neben Ihrer Anwendung. Wenn Sie Dokumentenerfassung, Archivierung oder Suche über gescannte PDFs in Delphi oder C++Builder bauen, liefert Ihnen die HotPDF Delphi PDF component die gesamte Pipeline als eine Abhängigkeit