O HotPDF inclui THPDFBuiltInOCREngine, um engine OCR limitado por correspondência de modelos escrito inteiramente em Object Pascal: binariza uma página renderizada com thresholding de Otsu, extrai glifos como componentes ligados e atribui uma pontuação a cada glifo pela cobertura em escala de cinzentos contra templates multi-fonte em cache, para que uma aplicação Delphi possa construir uma camada de texto pesquisável sem dependências OCR externas. O engine teve de ser reconstruído de raiz na v2.731.0, e a razão não foi o matcher. Foram os pixels
O engine antigo passava os testes. Reconhecia ASCII maiúsculo em bitmaps sintéticos e, em Win32, continuou a fazê-lo durante meses. Depois, o mesmo código foi executado em Win64 e não produziu absolutamente nada: sem palavras, sem diagnóstico além de "found no high-contrast foreground", sem crash. O erro acabou por ser constituído por dois enganos independentes no percurso de leitura de pixels que se tinham cancelado mutuamente, e desfazê-los é uma boa ilustração de por que razão o código OCR falha silenciosamente em vez de falhar de forma ruidosa
Porque é que o engine OCR antigo só funcionava por acaso?
O engine antigo funcionava porque os seus bitmaps de templates e os bitmaps alvo eram invertidos da mesma forma, pelo que uma inversão vertical no leitor de pixels ficava invisível para o matcher. TBitmap.ScanLine devolve linhas na ordem oposta à convenção DIB de biHeight positivo que o resto do percurso de imagem assume. Renderize um M de cabeça para baixo, compare-o com um template que também está invertido e a diferença L1 é idêntica à comparação correta. Todos os glifos correspondiam. Nada estava correto
É precisamente essa simetria que torna esta classe de erro dispendiosa. Qualquer correção de um só lado quebra a correspondência: corrija a leitura do alvo e deixe os templates como estão e o reconhecimento transforma-se em ruído; corrija primeiro os templates e obtém o mesmo colapso na direção oposta. Não há um percurso de reparação incremental. A reconstrução substituiu, portanto, toda a leitura por GetDIBits contra um BITMAPINFOHEADER declarado explicitamente, onde um biHeight positivo significa, por contrato, linhas bottom-up e não por convenção da VCL, e inverte uma vez, deliberadamente, ao copiar para o buffer em escala de cinzentos
O segundo engano foi o que só apareceu em 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 consistentemente no processo de testes Win64. A correção é um screen DC descartável obtido de GetDC(0), libertado num bloco finally, que não tem qualquer relação com um 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 => linhas bottom-up
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // nunca Work.Canvas.Handle: Work está selecionado aí
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 inversão 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 ligados: de pixels cinzentos a caixas de glifos
O HotPDF começa por binarizar com o método de Otsu e só recorre a um threshold de janela local quando Otsu não é aplicável. O percurso global exige um histograma realmente bimodal: o engine calcula o máximo da variância entre classes e exige adicionalmente que o intervalo de cinzentos cubra pelo menos 64 níveis antes de confiar no resultado. Um scan deslavado, uma página com fundo em gradiente ou um bitmap quase todo preenchido com tinta falham esse teste. O fallback compara então cada pixel com a média de uma janela de 31 por 31 com um bias de 6 níveis de cinzento, calculada com somas correntes de colunas para que a janela deslizante permaneça linear no número de pixels
A extração de glifos é uma identificação de componentes com 8 conexões sobre a máscara resultante, usando uma stack explícita em vez de recursão, porque uma máscara de página inteira pode facilmente rebentar a stack de uma thread Delphi num flood fill profundo. Dois filtros são aplicados no momento da identificação: componentes com menos de 9 pixels são descartados como ruído pontual e qualquer componente que cubra mais de três quintos da largura e da altura da imagem é descartado como moldura ou linha, não como glifo. Uma segunda passagem funde caixas empilhadas verticalmente cuja sobreposição horizontal seja de pelo menos um quarto da caixa mais estreita, reunindo o ponto de um i ou de um j com o seu traço. Tudo isto opera sobre um raster, e o raster vem do mesmo renderer descrito em renderizar uma página PDF carregada para um bitmap em Delphi, o que importa por uma razão prática: a qualidade do OCR tem como limite superior a qualidade da renderização, e os 300 DPI predefinidos da camada de texto são um compromisso deliberado, não um máximo
O que torna I maiúsculo e l minúsculo indecidíveis?
Em Arial, o I maiúsculo e o l minúsculo rasterizam como barras pixel a pixel idênticas, pelo que nenhuma característica da forma os pode separar e o caso tem de vir de um local completamente diferente. A resposta do engine é o agrupamento de alturas ao nível da linha. As caixas de glifos são agrupadas em linhas de texto por sobreposição vertical, cada linha é analisada quanto à sua altura de maiúsculas e à sua baseline modal, e as alturas dentro de uma linha são divididas num grupo curto e num grupo alto. Uma barra que esteja no grupo curto é um l; a mesma barra no grupo alto é um I
A implementação óbvia dessa divisão é um threshold de razão fixo, e não funciona. A razão entre a altura-x e a altura de maiúsculas do Arial é cerca de 0,72, precisamente em cima dos valores 0,70 e 0,75 a que todos recorrem primeiro. Mova a constante um centésimo em qualquer direção e um corpus inteiro muda de maiúsculas para minúsculas. Em vez disso, o HotPDF faz uma divisão unidimensional k=2 que minimiza a variância: ordena as alturas candidatas, experimenta cada ponto de corte e conserva o corte cuja soma dos desvios quadráticos dentro dos clusters seja menor. O threshold passa a ser uma propriedade da página, não uma constante no código
// ClusterHeights está ordenado por ordem crescente; encontre a divisão k=2 com menor variância
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;
// apenas a razão entre as médias dos dois clusters decide qual grupo curto usar
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // uma verdadeira faixa de altura-x: formas minúsculas
else
SmallGroup := ggTall; // uma faixa de altura: tudo tem altura de maiúscula
Line.LowercaseContext := (SmallGroup = ggSmall);
As linhas com uma única faixa de alturas não contêm qualquer evidência interna. Um título só com maiúsculas e uma legenda só com minúsculas parecem iguais isoladamente. Para essas linhas, o HotPDF compara a altura mediana da linha com a altura-x mediana ao nível da página, obtida a partir das linhas que foram divididas: uma razão igual ou inferior a 1,10 marca a linha como contexto de minúsculas, uma razão igual ou superior a 1,18 marca-a como contexto de maiúsculas e qualquer valor intermédio permanece sem restrição. A correspondência aplica então um pequeno bónus de preferência de caso de 0,03 ao candidato que concorda com esse contexto, aproximando empates sem nunca sobrepor-se a uma diferença clara de forma
Porque é que uma grelha de templates 12x18 confundia c e o?
A grelha de templates foi aumentada de células 12 por 18 para 16 por 24 porque, na resolução mais pequena, a margem de cobertura em escala de cinzentos entre c e o caía abaixo de 0,007, bem dentro do threshold de ambiguidade do engine. Cada caixa de glifo é reamostrada para a grelha como valores de cobertura de 0 a 255 e não como uma máscara binária, pelo que 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, a abertura sobrevive à reamostragem e a maioria dos pares facilmente confundíveis volta a uma distância segura
A pontuação é a distância L1 normalizada entre as duas grelhas de cobertura, mais uma penalização de 0,30 vezes a diferença logarítmica da razão de aspeto e 0,16 vezes a diferença de densidade de tinta, com um prefilter rígido que ignora qualquer template cuja razão de aspeto difira 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) num alfabeto de 62 carateres, colocados em cache atrás de uma secção crítica e reutilizados por todas as chamadas seguintes
A última constante é a interessante. Quando o caráter em segundo lugar obtém uma pontuação dentro de 0,018 do vencedor, o HotPDF limita a confiança do glifo a 0,5, abaixo da barreira de aceitação de 0,55, pelo que o glifo simplesmente não é emitido. É uma decisão deliberada de falhar fechando, não um artefacto de afinação: um engine limitado que adivinha produz uma camada pesquisável cujo texto não corresponde à imagem, e uma palavra errada numa camada de texto é pior do que uma palavra em falta porque fica invisível para a pessoa que revê o scan
Separar palavras sem um threshold fixo de espaços
O HotPDF deriva o threshold de espaço entre palavras por linha a partir da distribuição dos intervalos entre glifos, em vez de usar um múltiplo fixo da largura média dos glifos. A heurística clássica, "um intervalo superior a 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 os intervalos da linha e procura o maior salto entre valores consecutivos ordenados, que é a fronteira entre o cluster intrapalavra e o cluster interpalavras, se existir. Três guardas impedem que isto seja acionado por ruído: o salto tem de ser pelo menos 0,22 da largura média do glifo, o primeiro intervalo acima da divisão tem de ser pelo menos 0,32 dessa largura e o último intervalo abaixo da divisão não pode exceder 0,65 dessa largura. Se qualquer guarda falhar, o threshold permanece em MaxInt e a linha inteira passa a ser uma única palavra. É esta última guarda que impede um único par de kerning invulgarmente largo de dividir uma palavra em duas, um erro muito mais danoso do que fundir duas palavras, já que um token fundido ainda contém os carateres corretos na ordem correta para uma pesquisa por substring
Escrever a camada de texto invisível sobre a imagem digitalizada
ApplyLoadedOCRTextLayer transforma palavras reconhecidas numa camada pesquisável desenhando-as no modo de rendering de texto 3, o modo nem-preenchimento-nem-traço definido na ISO 32000-1 §9.3.6, posicionadas sobre a imagem digitalizada de onde vieram. O content stream começa com BT seguido de 3 Tr, e cada palavra é colocada com uma matriz de texto construída a partir da baseline comunicada, da sua altura de maiúsculas convertida de pixels à DPI solicitada e de uma escala horizontal que estica o glyph run sintético até à largura medida da palavra. O resultado copia-se e pesquisa-se como texto, mas não pinta nada
Existe uma overload sem engine que instancia o recognizer integrado por si, e é essa que a maioria dos chamadores do percurso integrado deve usar. O reconhecimento, a validação Unicode, a contabilidade de orçamento e a construção do conteúdo terminam todos antes de abrir a transação copy-on-write, pelo que um cancelamento, um excesso de orçamento ou uma falha do engine deixam o grafo de objetos e o número da versão intactos. As palavras são filtradas duas vezes: o engine descarta tudo o que esteja abaixo da sua própria barreira de confiança por glifo de 0,55, e depois THPDFOCRTextLayerOptions.MinimumConfidence (predefinido 0,5) descarta as 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; // deixar as páginas born-digital intactas
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload sem engine: o HotPDF fornece o recognizer limitado integrado
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 convém declarar claramente em vez de o descobrir mais tarde. A camada invisível usa uma fonte Type0 sintética partilhada e não incorporada, suficiente para pesquisar e copiar em todos os visualizadores, mas que não cumpre o requisito de incorporação de fontes da ISO 19005. Se a saída tiver de ser PDF/A, o chamador tem de incorporar separadamente uma fonte conforme. E uma camada de texto OCR transporta geometria, não estrutura, pelo que a ordem de leitura vem apenas das posições dos glifos; se precisar de ordem lógica numa página que já tenha texto real, a extração de texto por ordem estrutural orientada pela árvore de tags é uma ferramenta diferente para um problema diferente
Onde termina o engine integrado
O engine integrado é deliberadamente limitado, e conhecer os seus limites é o que o mantém útil. Destina-se a ASCII impresso por máquina e de alto contraste, com fontes próximas das suas cinco faces de template, e tudo o que fica fora disso devolve zero palavras em vez de um palpite. Os limites concretos são:
- Imagens até 4096 por 4096 e 4 194 304 pixels, com um prazo de reconhecimento de 2000 ms e cancelamento cooperativo através de
THPDFCancellationToken - Um alfabeto de 62 carateres de letras ASCII e dígitos; sem pontuação, sem carateres acentuados, sem CJK
- Apenas texto alinhado nos eixos, na rotação de página que o renderer já normalizou; scans inclinados não são deskewed
- Pares de glifos ambíguos permanecem por resolver, pelo que uma página pode devolver palavras parciais ou o diagnóstico "found no unambiguous ASCII words"
Quando esse envelope é demasiado pequeno, IHPDFOCREngine é a costura de extensão. Implemente Recognize contra o seu próprio engine, entregue-o à overload de três argumentos de ApplyLoadedOCRTextLayer e tudo o que vem depois (mapeamento de coordenadas, tratamento de rotação, validação Unicode, orçamentos, o commit atómico) permanece igual. O bitmap é emprestado durante a chamada síncrona e não pode ser retido. Para confirmar que a camada ficou corretamente aplicada, volte a carregar o ficheiro guardado e execute o percurso de texto normal descrito em extrair texto de um PDF carregado em Delphi; se as palavras voltarem, a camada é real
O OCR integrado por correspondência de modelos, a camada de texto invisível, o renderer de páginas que os alimenta e a extração de texto do documento carregado que os verifica são todos distribuídos no mesmo componente VCL nativo, sem runtime OCR externo e sem DLL para distribuir ao lado da sua aplicação. Se está a criar captura de documentos, arquivo ou pesquisa sobre PDFs digitalizados em Delphi ou C++Builder, o componente PDF HotPDF para Delphi oferece-lhe todo o pipeline numa única dependência