Article technique

OCR par gabarits intégré dans Delphi avec HotPDF

HotPDF fournit THPDFBuiltInOCREngine, un moteur OCR borné par appariement de gabarits, entièrement écrit en Object Pascal : il binarise une page rendue avec le seuillage d’Otsu, extrait les glyphes comme composants connexes et évalue chaque glyphe selon sa couverture en niveaux de gris face à des gabarits multi-polices mis en cache, afin qu’une application Delphi puisse construire une couche de texte interrogeable sans dépendance OCR externe. Le moteur a dû être reconstruit de zéro dans la version 2.731.0, et la raison n’était pas l’appariement. C’étaient les pixels

L’ancien moteur passait ses tests. Il reconnaissait l’ASCII majuscule sur des bitmaps synthétiques et, sous Win32, continuait à le faire depuis des mois. Puis le même code a été exécuté sous Win64 et n’a plus rien produit : aucun mot, aucun diagnostic au-delà de « found no high-contrast foreground », aucun crash. Le bug s’est révélé être constitué de deux erreurs indépendantes dans le chemin de lecture des pixels, qui s’annulaient l’une l’autre, et les démêler illustre bien pourquoi le code OCR échoue silencieusement plutôt que bruyamment

Pourquoi l’ancien moteur OCR ne fonctionnait-il que par accident ?

L’ancien moteur fonctionnait parce que ses bitmaps de gabarits et ses bitmaps cibles étaient inversés de la même façon ; une inversion verticale dans le lecteur de pixels était donc invisible pour l’appariement. TBitmap.ScanLine renvoie les lignes dans l’ordre opposé à la convention DIB à biHeight positif supposée par le reste du chemin d’imagerie. Rendez un M à l’envers, comparez-le à un gabarit lui aussi à l’envers et la différence L1 est identique à celle d’une comparaison correcte. Chaque glyphe correspondait. Rien n’était juste

C’est précisément cette symétrie qui rend cette catégorie de bug coûteuse. Toute correction d’un seul côté casse l’appariement : corrigez la lecture de la cible en laissant les gabarits tels quels, et la reconnaissance s’effondre en bruit ; corrigez d’abord les gabarits et vous obtenez le même effondrement dans l’autre sens. Il n’existe pas de chemin de réparation incrémental. La reconstruction a donc remplacé toute la lecture par GetDIBits contre un BITMAPINFOHEADER déclaré explicitement, où un biHeight positif signifie par contrat des lignes bottom-up plutôt que par convention VCL, puis inverse une seule fois, délibérément, lors de la copie dans le tampon en niveaux de gris

La deuxième erreur est celle que seul Win64 a révélée. Le HDC passé à GetDIBits ne doit pas être le DC mémoire propre au bitmap, puisque le bitmap y est déjà sélectionné et que Windows documente ce cas comme invalide. Passer Bitmap.Canvas.Handle était toléré par le processus Win32 et échouait systématiquement dans le processus de test Win64. Le correctif consiste à utiliser un DC écran jetable obtenu par GetDC(0), libéré dans un bloc finally, qui n’a aucun lien avec 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;   // positif => lignes bottom-up
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // jamais Work.Canvas.Handle : Work y est sélectionné
    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];   // une seule inversion délibérée
      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;

Binarisation et composants connexes : des pixels gris aux boîtes de glyphes

HotPDF commence par binariser avec la méthode d’Otsu et ne revient à un seuillage par fenêtre locale que lorsqu’Otsu n’est pas applicable. Le chemin global exige un histogramme réellement bimodal : le moteur calcule le maximum de variance interclasses et exige en plus que la plage de gris couvre au moins 64 niveaux avant de faire confiance au résultat. Un scan délavé, une page à fond dégradé ou un bitmap presque entièrement couvert d’encre échoue à ce test. Le repli compare alors chaque pixel à la moyenne d’une fenêtre de 31 par 31 avec un biais de 6 niveaux de gris, calculée grâce à des sommes cumulées par colonne afin que la fenêtre glissante reste linéaire en fonction du nombre de pixels

L’extraction des glyphes est un étiquetage de composants connexes à 8 connexions sur le masque obtenu, avec une pile explicite plutôt que la récursion, car un masque couvrant toute la page peut sans difficulté faire déborder la pile d’un thread Delphi lors d’un flood fill profond. Deux filtres s’exécutent pendant l’étiquetage : les composants de moins de 9 pixels sont éliminés comme bruit ponctuel, et tout composant couvrant plus des trois cinquièmes de la largeur et de la hauteur de l’image est éliminé comme cadre ou trait plutôt que comme glyphe. Une deuxième passe fusionne les boîtes empilées verticalement dont le chevauchement horizontal atteint au moins un quart de la boîte la plus étroite, ce qui réunit le point d’un i ou d’un j avec sa hampe. Tout cela opère sur un raster, et le raster provient du même renderer que celui décrit dans le rendu d’une page PDF chargée vers un bitmap dans Delphi, ce qui compte pour une raison pratique : la qualité de l’OCR ne peut pas dépasser celle du rendu, et les 300 DPI par défaut de la couche de texte sont un compromis délibéré, pas un maximum

Qu’est-ce qui rend I majuscule et l minuscule indécidables ?

Dans Arial, le I majuscule et le l minuscule se rastérisent en barres identiques au pixel près ; aucun trait de forme ne peut donc les séparer, et la casse doit venir d’un tout autre endroit. Le moteur répond par un regroupement des hauteurs au niveau de la ligne. Les boîtes de glyphes sont regroupées en lignes de texte selon leur chevauchement vertical, chaque ligne est analysée pour sa hauteur de capitale et sa ligne de base modale, puis les hauteurs de la ligne sont réparties entre un groupe court et un groupe haut. Une barre du groupe court est un l ; la même barre dans le groupe haut est un I

L’implémentation évidente de cette séparation est un seuil de rapport fixe, et elle ne fonctionne pas. Le rapport entre la hauteur d’x et la hauteur de capitale d’Arial vaut environ 0,72, ce qui tombe exactement sur les valeurs 0,70 et 0,75 vers lesquelles tout le monde se tourne d’abord. Déplacez la constante d’un centième dans un sens ou dans l’autre et tout un corpus change de casse. HotPDF effectue plutôt une séparation unidimensionnelle k=2 qui minimise la variance : trier les hauteurs candidates, essayer chaque point de coupure et conserver celui dont la somme des écarts quadratiques intra-groupes est la plus faible. Le seuil devient une propriété de la page plutôt qu’une constante dans le source

// ClusterHeights est trié par ordre croissant ; trouver la séparation k=2 de variance minimale
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;
// seul le rapport entre les moyennes des deux groupes décide de la bande courte
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // une vraie bande de hauteur d’x : formes minuscules
else
  SmallGroup := ggTall;          // une seule bande de hauteur : tout est en capitales
Line.LowercaseContext := (SmallGroup = ggSmall);

Les lignes qui ne présentent qu’une seule bande de hauteur ne fournissent aucune information interne. Un titre tout en capitales et une légende tout en minuscules se ressemblent lorsqu’ils sont isolés. Dans ce cas, HotPDF compare la hauteur médiane de la ligne à la hauteur d’x médiane de la page, calculée à partir des lignes qui se sont séparées : un rapport inférieur ou égal à 1,10 marque la ligne comme contexte minuscule, un rapport supérieur ou égal à 1,18 la marque comme contexte capitales, et tout ce qui se trouve entre les deux reste sans contrainte. L’appariement applique ensuite un petit bonus de préférence de casse de 0,03 au candidat qui correspond à ce contexte, ce qui départage les égalités sans jamais remplacer une différence de forme nette

Pourquoi une grille de gabarit 12x18 confondait-elle c et o ?

La grille de gabarit est passée de 12 par 18 cellules à 16 par 24, car à la résolution inférieure la marge de couverture en niveaux de gris entre c et o tombait sous 0,007, largement dans le seuil d’ambiguïté du moteur. Chaque boîte de glyphe est rééchantillonnée dans la grille comme des valeurs de couverture allant de 0 à 255 plutôt que comme un masque binaire ; une cellule remplie à un tiers d’encre vaut donc environ 85 au lieu d’être arrondie au noir ou au blanc. En 12 par 18, le côté ouvert d’un c couvre à peine plus d’une colonne de cellules et la moyenne antialiasée efface l’écart. En 16 par 24, l’écart survit au rééchantillonnage et la plupart des paires facilement confondues retrouvent une distance sûre

Le score est la distance L1 normalisée entre les deux grilles de couverture, à laquelle s’ajoutent une pénalité égale à 0,30 fois la différence logarithmique de rapport d’aspect et 0,16 fois la différence de densité d’encre, avec un préfiltre strict qui ignore tout gabarit dont le rapport d’aspect diffère de plus d’un facteur 2,6. Les gabarits sont rastérisés une fois par processus à partir de cinq polices système (Arial, Times New Roman, Courier New, Tahoma et Segoe UI) sur un alphabet de 62 caractères, mis en cache derrière une section critique et réutilisés par chaque appel ultérieur

La dernière constante est la plus intéressante. Lorsque le score du caractère arrivé en deuxième position se trouve à moins de 0,018 du gagnant, HotPDF plafonne la confiance du glyphe à 0,5, sous le seuil d’acceptation de 0,55 ; le glyphe n’est donc tout simplement pas émis. C’est une coupure fail-closed délibérée, pas un artefact de réglage : un moteur borné qui devine produit une couche interrogeable dont le texte ne correspond pas à l’image, et un mot erroné dans une couche de texte est pire qu’un mot manquant parce qu’il reste invisible pour la personne qui examine le scan

Séparer les mots sans seuil d’espace fixe

HotPDF déduit le seuil d’espace entre les mots pour chaque ligne à partir de la distribution des écarts entre glyphes, plutôt que d’un multiple fixe de la largeur moyenne d’un glyphe. L’heuristique classique — « un écart supérieur à 0,75 de l’avance moyenne est un espace » — se brise dès qu’une ligne mélange des chiffres et des lettres étroites, car l’avance moyenne ne décrit plus rien de réel. Le moteur trie plutôt les écarts de la ligne et cherche le plus grand saut entre deux valeurs triées consécutives, qui constitue la frontière entre le groupe intra-mot et le groupe inter-mots lorsqu’il existe. Trois garde-fous empêchent ce mécanisme de réagir au bruit : le saut doit atteindre au moins 0,22 de la largeur moyenne d’un glyphe, le premier écart au-dessus de la séparation doit atteindre au moins 0,32 de cette largeur et le dernier écart sous la séparation ne doit pas dépasser 0,65 de celle-ci. Si un garde-fou échoue, le seuil reste à MaxInt et toute la ligne devient un seul mot. C’est ce dernier garde-fou qui empêche une paire de crénage exceptionnellement large de couper un mot en deux, erreur bien plus dommageable que la fusion de deux mots, puisqu’un token fusionné contient toujours les bons caractères dans le bon ordre pour une recherche de sous-chaîne

Écrire la couche de texte invisible sur l’image numérisée

ApplyLoadedOCRTextLayer transforme les mots reconnus en couche interrogeable en les dessinant avec le mode de rendu de texte 3, le mode ni remplissage ni contour défini dans l’ISO 32000-1 §9.3.6, au-dessus de l’image numérisée dont ils proviennent. Le flux de contenu s’ouvre par BT suivi de 3 Tr, et chaque mot est placé avec une matrice de texte construite à partir de sa ligne de base signalée, de sa hauteur de capitale convertie depuis les pixels au DPI demandé et d’une échelle horizontale qui étire le groupe de glyphes synthétique jusqu’à la largeur mesurée du mot. Le résultat se copie et se recherche comme du texte, mais ne peint rien

Une surcharge sans moteur instancie le recognizer intégré pour vous ; c’est celle que devraient utiliser la plupart des appelants du chemin intégré. La reconnaissance, la validation Unicode, le comptage du budget et la construction du contenu s’achèvent avant l’ouverture de la transaction copy-on-write ; une annulation, un dépassement de budget ou un échec du moteur laisse donc le graphe d’objets et le numéro de version inchangés. Les mots sont filtrés deux fois : le moteur élimine ce qui se trouve sous son propre seuil de confiance par glyphe de 0,55, puis THPDFOCRTextLayerOptions.MinimumConfidence (0,5 par défaut) élimine les mots entiers sous le seuil de l’appelant

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;             // laisser intactes les pages nées numériques
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // surcharge sans moteur : HotPDF fournit le recognizer intégré et borné
    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;

Une limite mérite d’être énoncée clairement plutôt que découverte plus tard. La couche invisible utilise une police Type0 synthétique partagée et non incorporée, suffisante pour la recherche et la copie dans tous les viewers mais non conforme à l’exigence d’incorporation des polices de l’ISO 19005. Si la sortie doit être au format PDF/A, l’appelant doit incorporer séparément une police conforme. En outre, une couche de texte OCR porte une géométrie, pas une structure, si bien que l’ordre de lecture vient seulement des positions des glyphes ; si vous avez besoin de l’ordre logique d’une page qui possède déjà du vrai texte, l’extraction du texte selon l’ordre structurel pilotée par l’arbre de balises est un autre outil pour un autre problème

Où s’arrête le moteur intégré

Le moteur intégré est volontairement étroit, et connaître ses limites est ce qui le rend utile. Il vise l’ASCII imprimé mécaniquement et à fort contraste, dans des polices proches de ses cinq faces de gabarit ; tout le reste renvoie l’absence de mot plutôt qu’une supposition. Les limites concrètes sont les suivantes :

  • Images jusqu’à 4096 par 4096 et 4 194 304 pixels, avec une échéance de reconnaissance de 2000 ms et une annulation coopérative via THPDFCancellationToken
  • Un alphabet de 62 lettres et chiffres ASCII ; aucune ponctuation, aucun caractère accentué, aucun CJK
  • Texte uniquement aligné sur les axes, à la rotation de page déjà normalisée par le renderer ; les scans inclinés ne sont pas redressés
  • Les paires de glyphes ambiguës restent non résolues, de sorte qu’une page peut renvoyer des mots partiels ou le diagnostic « found no unambiguous ASCII words »

Lorsque cette enveloppe est trop petite, IHPDFOCREngine constitue le point d’extension. Implémentez Recognize avec votre propre moteur, transmettez-le à la surcharge à trois arguments de ApplyLoadedOCRTextLayer, et tout ce qui suit (mappage des coordonnées, gestion de la rotation, validation Unicode, budgets, commit atomique) reste identique. Le bitmap est emprunté pendant la durée de l’appel synchrone et ne doit pas être conservé. Pour confirmer que la couche a été correctement déposée, rechargez le fichier enregistré et exécutez le chemin de texte ordinaire décrit dans l’extraction de texte d’un PDF chargé dans Delphi ; si les mots reviennent, la couche est réelle

L’OCR intégré par appariement de gabarits, la couche de texte invisible, le renderer de page qui les alimente et l’extraction de texte du document chargé qui les vérifie sont tous livrés dans le même composant VCL natif, sans runtime OCR externe ni DLL à déployer avec votre application. Si vous construisez dans Delphi ou C++Builder une capture documentaire, un archivage ou une recherche sur des PDF numérisés, le composant PDF HotPDF pour Delphi vous fournit tout le pipeline dans une seule dépendance