Artigo Técnico

OCR com matching de templates integrado no Delphi com HotPDF

O HotPDF inclui THPDFBuiltInOCREngine, um engine de OCR por matching de templates, limitado e escrito inteiramente em Object Pascal: ele binariza uma página renderizada com thresholding de Otsu, extrai glifos como componentes conexos e pontua cada glifo pela cobertura em tons de cinza contra templates de várias fontes mantidos em cache, para que uma aplicação Delphi possa criar uma camada de texto pesquisável sem dependência de OCR externo. O engine precisou ser reconstruído do zero na v2.731.0, e o motivo não era o matcher. Eram os pixels

O engine antigo passava nos testes. Ele reconhecia ASCII maiúsculo em bitmaps sintéticos e, no Win32, continuou fazendo isso por meses. Então o mesmo código foi executado no Win64 e não produziu absolutamente nada: nenhuma palavra, nenhum diagnóstico além de "found no high-contrast foreground", nenhum crash. O bug acabou sendo formado por dois erros independentes no caminho de leitura de pixels que estavam se anulando, e desfazê-los é uma boa ilustração de por que código de OCR falha silenciosamente em vez de falhar de forma ruidosa

Por que o engine de OCR antigo só funcionava por acidente?

O engine antigo funcionava porque seus bitmaps de template e seus bitmaps alvo eram invertidos da mesma forma, então uma inversão vertical no leitor de pixels ficava invisível para o matcher. TBitmap.ScanLine devolve as linhas na ordem oposta à convenção de DIB com biHeight positivo que o restante do caminho de imagem pressupõe. Renderize um M de cabeça para baixo, compare-o com um template também invertido e a diferença L1 será idêntica à comparação correta. Todos os glifos davam match. Nada estava certo

Essa simetria é exatamente o que torna essa classe de bug cara. Qualquer correção unilateral quebra o matching: corrija a leitura do alvo e deixe os templates como estão, e o reconhecimento vira ruído; corrija primeiro os templates e você terá o mesmo colapso na outra direção. Não existe um caminho de reparo incremental. Por isso a reconstrução substituiu a leitura inteira por GetDIBits contra um BITMAPINFOHEADER declarado explicitamente, em que um biHeight positivo significa linhas bottom-up por contrato, não por convenção da VCL, e faz uma única inversão deliberada ao copiar para o buffer em tons de cinza

O segundo erro só apareceu no Win64. O HDC passado a GetDIBits não pode ser o próprio memory DC do bitmap, porque o bitmap já está selecionado nele e o Windows documenta isso como inválido. Passar Bitmap.Canvas.Handle era tolerado pelo processo Win32 e falhava de forma consistente no processo de teste Win64. A correção é um screen DC descartável obtido com GetDC(0) e liberado em um bloco finally, que não tem relação com bitmap algum

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 => linhas bottom-up
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // nunca Work.Canvas.Handle: Work esta selecionado ali
    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];   // uma unica inversao deliberada
      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;

Binarização e componentes conexos: dos pixels cinza às caixas de glifo

O HotPDF primeiro binariza com o método de Otsu e só recorre a um threshold de janela local quando Otsu não é aplicável. O caminho global exige um histograma realmente bimodal: o engine calcula o máximo da variância entre classes e também exige que o intervalo de cinza cubra pelo menos 64 níveis antes de confiar no resultado. Um scan desbotado, uma página com fundo em gradiente ou um bitmap quase todo preenchido por tinta falham nesse teste. O fallback então compara cada pixel com a média de uma janela de 31 por 31, com um viés de 6 níveis de cinza, calculada com somas correntes por coluna para que a janela deslizante permaneça linear na quantidade de pixels

A extração de glifos é uma rotulagem de componentes 8-conexa sobre a máscara resultante, com uma pilha explícita em vez de recursão, porque uma máscara de página inteira pode facilmente estourar a stack de uma thread Delphi durante um flood fill profundo. Dois filtros rodam no momento da rotulagem: componentes menores que 9 pixels são descartados como ruído pontual, e qualquer componente que ocupe mais de três quintos da largura e da altura da imagem é descartado como moldura ou linha, não como glifo. Uma segunda passagem mescla caixas empilhadas verticalmente cuja sobreposição horizontal seja de pelo menos um quarto da caixa mais estreita, reunindo o ponto de um i ou j ao seu haste. Tudo isso opera sobre um raster, e o raster vem do mesmo renderer descrito em renderizar uma página PDF carregada para bitmap no Delphi, o que importa por um motivo prático: a qualidade do OCR é limitada pela qualidade da renderização, e o DPI padrão de 300 da camada de texto é uma troca deliberada, não um máximo

O que torna I maiúsculo e l minúsculo indecidíveis?

Na Arial, o I maiúsculo e o l minúsculo rasterizam como barras idênticas em pixels, então nenhum recurso de forma consegue separá-los e o caso precisa vir de outro lugar. A resposta do engine é um clustering de altura no nível da linha. As caixas de glifo são agrupadas em linhas de texto por sobreposição vertical, cada linha é analisada quanto à sua altura de maiúscula e à linha de base modal, e as alturas dentro de uma linha são divididas em um cluster curto e um alto. Uma barra no cluster curto é um l; a mesma barra no cluster alto é um I

A implementação óbvia dessa divisão é um threshold de proporção fixo, e ele não funciona. A proporção entre x-height e cap-height da Arial é cerca de 0,72, exatamente entre os valores 0,70 e 0,75 que todos tentam primeiro. Mova a constante um centésimo para qualquer lado e um corpus inteiro troca de caixa. Em vez disso, o HotPDF faz uma divisão unidimensional k=2 que minimiza a variância: ordena as alturas candidatas, testa cada ponto de corte e mantém o corte cuja soma das diferenças quadráticas dentro dos clusters seja menor. O threshold vira uma propriedade da página, não uma constante no source

// ClusterHeights esta ordenado em ordem crescente; encontre a divisao k=2 com menor variancia
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;
// somente a proporcao entre as medias dos clusters decide qual e a faixa curta
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // uma faixa real de x-height: formas minusculas
else
  SmallGroup := ggTall;          // uma faixa de altura: tudo tem altura de maiuscula
Line.LowercaseContext := (SmallGroup = ggSmall);

Linhas com uma única faixa de altura não carregam nenhuma evidência interna. Um título todo em maiúsculas e uma legenda toda em minúsculas parecem iguais isoladamente. Para essas linhas, o HotPDF compara a altura mediana da linha com a x-height mediana da página, obtida das linhas que foram divididas: uma proporção de até 1,10 marca contexto minúsculo, uma proporção de pelo menos 1,18 marca contexto de maiúsculas e qualquer valor intermediário permanece sem restrição. O matching então aplica um pequeno bônus de preferência de caixa de 0,03 ao candidato que concorda com o contexto, ajustando empates sem jamais ignorar uma diferença clara de forma

Por que uma grade de templates 12x18 confundia c e o?

A grade de templates foi ampliada de 12 por 18 células para 16 por 24 porque, na resolução menor, a margem de cobertura em tons de cinza entre c e o caiu abaixo de 0,007, bem dentro do threshold de ambiguidade do engine. Cada caixa de glifo é reamostrada na grade como valores de cobertura de 0 a 255, e não como uma máscara binária, então uma célula com um terço de tinta vale aproximadamente 85 em vez de ser arredondada para preto ou branco. Em 12 por 18, o lado aberto de um c ocupa pouco mais de uma coluna de células e a média antialiasing apaga a abertura. Em 16 por 24, o espaço sobrevive à reamostragem, e a maioria dos pares fáceis de confundir volta a uma distância segura

A pontuação é a distância L1 normalizada entre as duas grades de cobertura, mais uma penalidade de 0,30 vezes a diferença logarítmica da proporção de aspecto e 0,16 vezes a diferença de densidade de tinta, com um prefilter rígido que ignora qualquer template cuja proporção de aspecto difira por mais de um fator 2,6. Os templates são rasterizados uma vez por processo a partir de cinco fontes do sistema (Arial, Times New Roman, Courier New, Tahoma e Segoe UI) em um alfabeto de 62 caracteres, mantidos em cache atrás de uma seção crítica e reutilizados por toda chamada posterior

A última constante é a interessante. Quando o caractere em segundo lugar pontua dentro de 0,018 do vencedor, o HotPDF limita a confiança do glifo a 0,5, abaixo do gate de aceitação de 0,55, e o glifo simplesmente não é emitido. É um corte deliberado fail-closed, não um artefato de tuning: um engine limitado que chuta produz uma camada pesquisável cujo texto não corresponde à imagem, e uma palavra errada na camada é pior do que uma ausente porque fica invisível para a pessoa que revisa o scan

Separando palavras sem um threshold fixo de espaço

O HotPDF deriva o threshold de espaço entre palavras por linha a partir da distribuição das lacunas entre glifos, em vez de usar um múltiplo fixo da largura média do glifo. A heurística clássica, "uma lacuna maior que 0,75 do avanço médio é um espaço", quebra assim que uma linha mistura dígitos com letras estreitas, porque o avanço médio deixa de descrever algo real. O engine ordena as lacunas da linha e procura o maior salto entre valores consecutivos ordenados, que é a fronteira entre o cluster intrapalavra e o cluster entre palavras quando um deles existe. Três guardas evitam que isso dispare por ruído: o salto precisa ser de pelo menos 0,22 da largura média do glifo, a primeira lacuna acima do corte precisa ser de pelo menos 0,32 dela e a última lacuna abaixo do corte não pode exceder 0,65 dela. Se qualquer guarda falhar, o threshold permanece em MaxInt e a linha inteira vira uma única palavra. Essa última guarda impede que um único par de kerning excepcionalmente largo divida uma palavra em duas, um erro muito mais danoso que mesclar duas palavras, pois um token mesclado ainda contém os caracteres corretos na ordem correta para uma busca de substring

Escrevendo a camada invisível de texto sobre a imagem digitalizada

ApplyLoadedOCRTextLayer transforma palavras reconhecidas em uma camada pesquisável ao desenhá-las no modo de renderização de texto 3, o modo que não preenche nem contorna definido na ISO 32000-1 §9.3.6, posicionada sobre a imagem digitalizada de onde vieram. O content stream começa com BT seguido de 3 Tr, e cada palavra é posicionada com uma matriz de texto construída a partir da linha de base informada, da cap-height convertida de pixels no DPI solicitado e de uma escala horizontal que estica o run de glifos sintético até a largura medida da palavra. O resultado pode ser copiado e pesquisado, mas não pinta nada

Existe uma sobrecarga sem engine que instancia o recognizer integrado para você, e é essa que a maioria dos chamadores do caminho integrado deve usar. Reconhecimento, validação Unicode, contabilidade de orçamento e construção do conteúdo terminam antes de a transação copy-on-write abrir, então um cancelamento, estouro de orçamento ou falha do engine deixa o object graph e o número da versão intactos. As palavras são filtradas duas vezes: o engine descarta qualquer coisa abaixo do seu próprio gate de confiança por glifo de 0,55, e depois THPDFOCRTextLayerOptions.MinimumConfidence (padrão 0,5) descarta palavras inteiras abaixo do limite do chamador

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;             // deixe paginas born-digital intactas
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // sobrecarga sem engine: o HotPDF fornece o recognizer integrado limitado
    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;

Há um limite que merece ser declarado claramente, em vez de ser descoberto depois. A camada invisível usa uma fonte Type0 sintética compartilhada e não incorporada, suficiente para pesquisa e cópia em qualquer viewer, mas que não satisfaz a exigência de incorporação de fonte da ISO 19005. Se a saída precisar ser PDF/A, o chamador terá de incorporar separadamente uma fonte compatível. E uma camada de texto OCR carrega geometria, não estrutura, portanto a ordem de leitura vem apenas das posições dos glifos; se você precisa de ordem lógica em uma página que já tem texto real, a extração de texto em ordem estrutural orientada pela tag tree é uma ferramenta diferente para um problema diferente

Onde o engine integrado termina

O engine integrado é deliberadamente restrito, e conhecer seus limites é o que o mantém útil. Ele mira ASCII impresso por máquina e de alto contraste, em fontes próximas às cinco faces de template, e tudo fora disso retorna nenhuma palavra em vez de um chute. Os limites concretos são:

  • Imagens de até 4096 por 4096 e 4.194.304 pixels, com prazo de reconhecimento de 2000 ms e cancelamento cooperativo por meio de THPDFCancellationToken
  • Um alfabeto de 62 caracteres com letras ASCII e dígitos; sem pontuação, caracteres acentuados ou CJK
  • Texto apenas com eixos alinhados, na rotação da página que o renderer já normalizou; scans inclinados não são deskewed
  • Pares de glifos ambíguos permanecem sem resolução, então uma página pode retornar palavras parciais ou o diagnóstico "found no unambiguous ASCII words"

Quando esse envelope é pequeno demais, IHPDFOCREngine é a costura. Implemente Recognize contra seu próprio engine, passe-o à sobrecarga de três argumentos de ApplyLoadedOCRTextLayer e tudo o que vem depois — mapeamento de coordenadas, tratamento de rotação, validação Unicode, orçamentos e commit atômico — permanece igual. O bitmap é emprestado durante a chamada síncrona e não pode ser retido. Para confirmar que a camada foi aplicada corretamente, recarregue o arquivo salvo e execute o caminho de texto comum descrito em extrair texto de um PDF carregado no Delphi; se as palavras voltarem, a camada é real

O OCR integrado por matching de templates, a camada invisível de texto, o renderer de páginas que os alimenta e a extração de texto do documento carregado que os verifica são distribuídos no mesmo componente VCL nativo, sem runtime de OCR externo e sem DLL para distribuir ao lado da aplicação. Se você está criando captura documental, arquivamento ou busca sobre PDFs digitalizados em Delphi ou C++Builder, o componente PDF Delphi HotPDF oferece o pipeline completo em uma única dependência