Artigo Técnico

Desvio da camada de texto OCR no HotPDF: mapear pelo CropBox

Camadas de texto OCR, limites de códigos de barras e caixas de redação de rostos derivam em páginas PDF recortadas quando os pixels do bitmap são mapeados de volta através do MediaBox em vez da caixa que o renderer realmente rasterizou: o CropBox recortado ao MediaBox (ISO 32000-1 §14.11.2). O HotPDF corrigiu isto para o ApplyLoadedOCRTextLayer na v2.770.153, e para o DecodeLoadedPageBarcodes e o DetectLoadedRedactionFindings na v2.770.154

O relatório de bug que normalmente chega tem este aspecto. Um arquivo de contratos digitalizados passa por OCR, o output é pesquisável, e o hit de pesquisa de um número de cláusula fica realçado meia polegada abaixo e à esquerda do número impresso. A maioria dos ficheiros do lote está bem. Os partidos vieram todos de uma estação de digitalização que escreve um /CropBox para aparar a margem do vidro. Esse único detalhe separa a imagem que o engine OCR viu da frame em que a camada de texto foi colocada, e o mesmo desajuste move limites de códigos de barras e, mais grave, caixas de redação de rostos

Porque é que a camada de texto OCR se afasta das palavras digitalizadas?

A camada de texto deriva porque duas metades do pipeline discordavam sobre que retângulo o bitmap cobre. Na v2.766.64, o HotPDF mudou a renderização, a exportação SVG, o viewer e a impressão para honrar o CropBox: uma página é mostrada através do seu CropBox recortado ao seu MediaBox, que é o que a ISO 32000-1 §14.11.2 prescreve, e o GetLoadedPageVisibleBox foi acrescentado para devolver essa caixa visível. As funcionalidades de reconhecimento continuaram a construir a sua transformação de dispositivo para página a partir do GetLoadedPageBox(PageIndex, pbMediaBox, ...). O raster agora cobria a caixa visível, a transformação ainda assumia o MediaBox, e cada posição reconhecida voltava deslocada pela distância entre as duas

A janela afetada é por isso precisa. O ApplyLoadedOCRTextLayer deslocou texto da v2.766.64 até à v2.770.152. O DecodeLoadedPageBarcodes de página inteira e a deteção de rostos dentro do DetectLoadedRedactionFindings ficaram errados mais uma build, até à v2.770.153. Antes da v2.766.64 o renderer desenhava o MediaBox inteiro, por isso mapeamento e raster concordavam, ao custo de reconhecer conteúdo que os viewers nunca mostram. As correções mudaram três coisas em conjunto para cada funcionalidade: a transformação, a estimativa do orçamento de pixels, e a caixa de página entregue a um engine personalizado no registo de pedido

Vários casos nunca foram afetados:

  • Páginas sem /CropBox, ou cujo CropBox iguala o MediaBox, mapeiam-se de forma idêntica antes e depois da correção
  • O DecodeLoadedPageBarcodes com HasRegion definido renderiza exatamente a região que passa e mapeia através dessa mesma região, por isso a descodificação por região explícita esteve correta o tempo todo; a verificação de que a região está dentro da página ainda usa o MediaBox
  • Descobertas de redação por padrões (emails, números de cartão e afins) vêm da extração de texto em user space, não de um raster, por isso só as descobertas de deteção de rostos se moveram

Três frames de coordenadas, e que APIs do HotPDF usam cada uma

O código HotPDF que toca no reconhecimento lida com três frames, e a maioria dos bugs de mapeamento vem de misturar duas delas

  • Pixels de bitmap: origem no topo esquerdo, Y cresce para baixo, as unidades são pixels no DPI do pedido. O THPDFOCRWord.Left, Top, Right e Bottom estão nesta frame, tal como os pontos de baseline opcionais, os resultados que um IHPDFBarcodeDecoder personalizado devolve, e as caixas de um IHPDFFaceDetector personalizado
  • User space PDF de uma página carregada: origem no fundo esquerdo, Y cresce para cima, as unidades são pontos, com Bottom < Top. O GetLoadedPageBox e o GetLoadedPageVisibleBox devolvem Left, Bottom, Right, Top nesta frame, e também os campos PageLeft, PageBottom, PageRight e PageTop do THPDFOCRRequest, os limites no THPDFDecodedBarcode e os retângulos no THPDFRedactionFinding
  • Coordenadas de desenho de página do HotPDF: a API que usa para construir páginas novas (output de texto, formas, códigos de barras, links, campos de formulário) trabalha com origem no topo esquerdo e Y a crescer para baixo. Essa frame pertence à geração de documentos e não tem nada a ver com as APIs de documentos carregados acima, por isso nunca alimente um retângulo user space de página carregada nela inalterado

O registo de palavras OCR é deliberadamente baseado em pixels: um engine reporta o que viu na imagem, e o ApplyLoadedOCRTextLayer é dono da conversão. Essa divisão só funciona quando a conversão usa a caixa certa, que é o que a v2.770.153 restaurou

Frames de coordenadas de reconhecimento do HotPDF: pixels de bitmap com origem no topo esquerdo usados pelas caixas THPDFOCRWord e descodificadores personalizados, user space PDF com origem no fundo esquerdo devolvido por GetLoadedPageBox e GetLoadedPageVisibleBox, e a API de desenho de página de topo esquerdo, que nunca deve receber um retângulo de página carregada inalterado
os engines reportam pixels porque foi isso que viram, o HotPDF mapeia-os, e misturar as duas frames é como camadas e caixas de redação derivam

A transformação de dispositivo para página atrás de OCR, códigos de barras e rostos

O HotPDF mapeia pixels de bitmap para a página com uma única matriz afim construída a partir de cinco inputs: a rotação, a escala DPI / 72, a altura do bitmap, e o Left, Bottom, Right e Top da caixa renderizada. OCR, descodificação de códigos de barras e deteção de rostos partilham todos uma única rotina para isto, razão pela qual uma caixa errada partiu os três da mesma maneira. Para uma página não rodada a matriz página-para-dispositivo [A B C D E F] é:

  • A = Scale e D = -Scale, em que Scale = DPI / 72; o D negativo vira o user space (Y para cima) em espaço de bitmap (Y para baixo)
  • B = C = 0, porque uma página não rodada não tem shear nem troca entre eixos
  • E = -Left * Scale, que move a margem esquerda da caixa para a coluna de pixel 0
  • F = BitmapHeight + Bottom * Scale, que mapeia a margem inferior da caixa para y = BitmapHeight, a margem inferior do bitmap, por isso a margem superior cai na linha 0

Os pixels voltam à página pelo inverso dessa matriz. O Request.PageRotation transporta o /Rotate da página normalizado a 0, 90, 180 ou 270 (qualquer valor que não seja múltiplo de 90 é tratado como 0), e o renderer roda a página no sentido dos ponteiros como a ISO 32000-1 §7.7.3.3 exige. Sob rotação os eixos trocam e um par diferente de margens da caixa é pregado à origem do bitmap. Escrito como fórmulas inversas, com S = DPI / 72, x e y em pixels e H a altura do bitmap:

/RotatePágina XPágina YMargens da caixa de que o mapeamento depende
0Left + x / SBottom + (H - y) / SLeft, Bottom
90Left + y / SBottom + x / SLeft, Bottom
180Right - x / SBottom + y / SRight, Bottom
270Right - y / STop - x / SRight, Top

A última coluna explica porque é que o bug parecia aleatório em produção. Um CropBox que apare apenas o topo da página deixa Left e Bottom intactos, por isso páginas verticais saíam perfeitas e só páginas com /Rotate 270 derivavam. A rotação também troca as dimensões do bitmap: a 90 e 270 o bitmap tem (Top - Bottom) * S pixels de largura e (Right - Left) * S pixels de altura

Tabela de mapeamento inverso do HotPDF para rotação de página: a /Rotate 0 e 90 a transformação prega as margens Left e Bottom da caixa renderizada, a 180 prega Right e Bottom, a 270 Right e Top, razão pela qual uma página recortada deriva numa direção diferente para cada orientação num documento misto
o mesmo recorte de meia polegada parece três bugs diferentes quando as páginas trazem valores /Rotate diferentes, porque cada orientação prega um par diferente de margens da caixa

O que corre mal com MediaBox [0 0 612 792] e CropBox [36 36 576 756]?

Com um recorte de meia polegada em cada lado, a camada de texto de uma página não rodada cai exatamente 36 pontos à esquerda e 36 pontos abaixo das palavras digitalizadas quando o MediaBox é usado. Tome uma página US Letter cujo CropBox apare 36 pontos (0,5 polegada) de cada margem. A caixa visível tem 540 por 720 pontos, por isso à resolução OCR por omissão de 300 DPI a escala é 300 / 72 ≈ 4,1667 e o bitmap é 2250 por 3000 pixels

Suponha que o engine reporta uma palavra com caixa em pixels Left 450, Top 600, Right 900, Bottom 660 e sem baseline. O HotPDF põe então a baseline a 20 por cento da altura da palavra acima da margem inferior, na linha de pixel 648, e mapeia o ponto inicial (450, 648):

  • Através da caixa visível: x = 36 + 450 / 4,1667 = 144,0 e y = 36 + (3000 - 648) / 4,1667 = 600,48, que é onde a palavra está impressa
  • Através do MediaBox: x = 0 + 108,0 = 108,0 e y = 0 + 564,48 = 564,48, um deslocamento uniforme de (-36, -36) pontos
Anatomia do desvio de CropBox do HotPDF numa página US Letter com MediaBox 0 0 612 792 e CropBox 36 36 576 756: o renderer rasteriza a caixa visível a 300 DPI, por isso mapear o pixel 450 da palavra através do GetLoadedPageVisibleBox dá 144,0 e 600,48 enquanto a transformação do MediaBox cai em 108,0 e 564,48
o raster cobre o CropBox, por isso qualquer transformação construída a partir do MediaBox desloca cada palavra reconhecida exatamente pela margem do recorte

Rode a mesma página e a direção do erro muda, porque margens diferentes estão envolvidas. A /Rotate 180 o termo X usa Right, e 612 em vez de 576 empurra a camada 36 pontos para a direita enquanto Bottom ainda a puxa 36 pontos para baixo. A /Rotate 270 tanto Right como Top são demasiado grandes, por isso a camada move-se 36 pontos para a direita e 36 pontos para cima. Um documento com orientações mistas pode mostrar a deriva em três direções, uma impressão digital fiável deste bug. Código escrito à mão que derive a escala a partir da caixa, como Bitmap.Width / (Right - Left), também estica cada coordenada por 612 / 540, cerca de 13 por cento, por cima do deslocamento

Que documentos PDF seus são afetados?

Um documento PDF está exposto quando pelo menos uma página tem uma caixa visível que difere do seu MediaBox, e o HotPDF consegue dizer-lhe isso em poucas linhas. Compare o GetLoadedPageBox com pbMediaBox contra o GetLoadedPageVisibleBox para cada página, e imprima o GetLoadedPageRotation ao lado para poder prever a direção da deriva a partir da tabela acima. O THPDFPageBoundary também oferece pbCropBox, pbBleedBox, pbTrimBox e pbArtBox, mas o GetLoadedPageBox(pbCropBox) recua para o MediaBox quando não existe crop box e não recorta, por isso a caixa visível é a coisa certa com que comparar

uses
  System.SysUtils, HPDFDoc;

procedure ReportCroppedPages(const FileName: string);
var
  Pdf: THotPDF;
  I: Integer;
  ML, MB, MR, MT, VL, VB, VR, VT, Tmp: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) < 1 then
      raise Exception.Create('Cannot load ' + FileName);
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      if not Pdf.GetLoadedPageBox(I, pbMediaBox, ML, MB, MR, MT) then
        Continue;
      // o array guardado pode listar os seus cantos por qualquer ordem
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // já normalizada e recortada ao MediaBox
      if not Pdf.GetLoadedPageVisibleBox(I, VL, VB, VR, VT) then
        Continue;
      if (Abs(VL - ML) > 0.01) or (Abs(VB - MB) > 0.01) or
         (Abs(VR - MR) > 0.01) or (Abs(VT - MT) > 0.01) then
        Writeln(Format('Page %d  MediaBox [%g %g %g %g]  visible [%g %g %g %g]  /Rotate %d',
          [I + 1, ML, MB, MR, MT, VL, VB, VR, VT,
           Pdf.GetLoadedPageRotation(I)]));
    end;
  finally
    Pdf.Free;
  end;
end;

Dois detalhes do GetLoadedPageVisibleBox interessam para scripts como este. A função deixa os seus parâmetros out intocados quando falha, por isso pré-definir um tamanho de página por omissão antes da chamada é um padrão seguro. E quando um CropBox malformado não intersecta o MediaBox de todo, a função devolve o MediaBox em vez de um retângulo vazio. Se o relatório listar páginas e a build que tem em produção for anterior à v2.770.153 para OCR, ou à v2.770.154 para códigos de barras e rostos, corra de novo o reconhecimento nessas páginas depois do upgrade. Uma camada OCR consolidada por uma build afetada fica no ficheiro gravado, e a opção por omissão SkipPagesWithText vai saltar essas páginas numa segunda passagem a menos que a desligue ou remova primeiro a camada antiga

Como deve um IHPDFOCREngine personalizado mapear pixels de volta ao espaço PDF?

Um IHPDFOCREngine personalizado deve devolver caixas de palavras em pixels de bitmap e deixar o HotPDF fazer o mapeamento; converta para user space só para as suas próprias decisões, e nesse caso use a caixa do pedido, nunca o MediaBox. Desde a v2.770.153 os PageLeft, PageBottom, PageRight e PageTop do pedido descrevem a caixa visível renderizada, por isso correspondem exatamente ao Request.Bitmap. O helper abaixo é o inverso da transformação da biblioteca, incluindo o seu uso da altura real do bitmap para páginas verticais, por isso concorda com o HotPDF ao pixel

uses
  System.SysUtils, System.Math, Vcl.Graphics, HPDFDoc;

// Pixel de bitmap (origem no topo esquerdo, Y para baixo) para user space PDF
// (origem no fundo esquerdo, Y para cima), pela caixa de que o bitmap foi renderizado
procedure HotPixelToPage(Rotation, DPI, BitmapHeight: Integer;
  Left, Bottom, Right, Top: Single; X, Y: Double;
  out PageX, PageY: Double);
var
  S: Double;
begin
  S := DPI / 72.0;
  case Rotation of
    90:  begin PageX := Left + Y / S;  PageY := Bottom + X / S; end;
    180: begin PageX := Right - X / S; PageY := Bottom + Y / S; end;
    270: begin PageX := Right - Y / S; PageY := Top - X / S; end;
  else
    PageX := Left + X / S;
    PageY := Bottom + (BitmapHeight - Y) / S;
  end;
end;

Uma razão realista para precisar de user space dentro de um engine é uma regra de zona: faturas cujo membro letterhead nunca quer pesquisável, ou uma área de selo que confunde o reconhecedor. O engine abaixo, escrito com TInterfacedObject para a contagem de referências tratar do seu tempo de vida, filtra palavras por onde os seus centros caem na página, e devolve depois as sobreviventes intocadas em coordenadas de pixels. O RunRecognizer faz de chamada ao seu próprio reconhecedor

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // o seu reconhecedor, caixas em pixels
  public
    constructor Create(SkipLeft, SkipBottom, SkipRight, SkipTop: Single);
    function GetName: AnsiString;
    function Recognize(const Request: THPDFOCRRequest;
      out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
  end;

function TZoneFilterOCREngine.Recognize(const Request: THPDFOCRRequest;
  out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
var
  Raw: THPDFOCRWords;
  I, Count: Integer;
  CX, CY: Double;
begin
  Diagnostic := '';
  SetLength(Words, 0);
  if not RunRecognizer(Request.Bitmap, Request.MaxWords, Raw) then
  begin
    Diagnostic := 'recognizer failed';
    Exit(False);
  end;
  SetLength(Words, Length(Raw));
  Count := 0;
  for I := 0 to High(Raw) do
  begin
    HotPixelToPage(Request.PageRotation, Request.DPI,
      Request.Bitmap.Height, Request.PageLeft, Request.PageBottom,
      Request.PageRight, Request.PageTop,
      (Raw[I].Left + Raw[I].Right) / 2, (Raw[I].Top + Raw[I].Bottom) / 2,
      CX, CY);
    if (CX >= FSkipLeft) and (CX <= FSkipRight) and
       (CY >= FSkipBottom) and (CY <= FSkipTop) then
      Continue;
    Words[Count] := Raw[I];  // ainda pixels: o HotPDF mapeia-os ele próprio
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Entregue o engine ao ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) como a qualquer outro engine. A biblioteca valida o que volta antes de confiar nele: uma palavra é descartada e contada no Info.DroppedWordCount quando a sua caixa sai do bitmap, quando Right <= Left ou Bottom <= Top, ou quando Confidence está fora de 0..1 ou abaixo do MinimumConfidence. Devolver mais palavras do que MaxWordsPerPage, ou empurrar o total corrente além do MaxTotalWords, falha a chamada inteira com um erro de orçamento, por isso honre o Request.MaxWords no engine. Não converta caixas de palavras para user space antes de as devolver; o HotPDF trataria os valores de pontos como pixels e a camada colapsaria para a origem do bitmap

Mapear o output do seu próprio detetor

O mesmo helper serve um pipeline caseiro construído sobre o RenderLoadedPageToBitmap, que renderiza a caixa visível e aplica o /Rotate tal como as funcionalidades de reconhecimento. Leia a caixa com o GetLoadedPageVisibleBox, normalize a rotação da mesma maneira que o HotPDF, e mapeie dois cantos opostos de cada caixa em pixels. O eixo Y vira e, a 90 e 270 graus, os eixos trocam, por isso os cantos mapeados saem sem ordem fixa; tome o mínimo e o máximo dos pontos mapeados, que é também como o HotPDF constrói os limites de códigos de barras

const
  DPI = 200;
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  VL, VB, VR, VT: Single;
  Rotation: Integer;
  PxL, PxT, PxR, PxB, X1, Y1, X2, Y2: Double;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-ids.pdf');
    if not Pdf.GetLoadedPageVisibleBox(0, VL, VB, VR, VT) then Exit;
    Rotation := Pdf.GetLoadedPageRotation(0) mod 360;
    if Rotation < 0 then Inc(Rotation, 360);
    if (Rotation <> 90) and (Rotation <> 180) and (Rotation <> 270) then
      Rotation := 0;
    Bmp := Pdf.RenderLoadedPageToBitmap(0, DPI);
    if Bmp = nil then Exit;
    try
      MyDetector(Bmp, PxL, PxT, PxR, PxB);  // o seu código, caixa em pixels
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxL, PxT, X1, Y1);
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxR, PxB, X2, Y2);
      Writeln(Format('User-space box [%.2f %.2f %.2f %.2f]',
        [Min(X1, X2), Min(Y1, Y2), Max(X1, X2), Max(Y1, Y2)]));
    finally
      Bmp.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

O comportamento de rotação está coberto com mais profundidade em achatar a rotação de página sem partir as caixas de página, e o pipeline de descodificação de códigos de barras que consome a mesma transformação em descodificar códigos QR rodados de páginas PDF. Se o seu engine embrulha um reconhecedor externo, o adaptador Tesseract OCR para PDF pesquisável mostra o lado de isolamento de processo e cancelamento da mesma interface

Referência rápida: mapeamento de coordenadas seguro quanto ao CropBox

  • O renderer rasteriza a caixa visível, o CropBox recortado ao MediaBox (ISO 32000-1 §14.11.2); todo o mapeamento de pixel para página tem de usar essa caixa, lida com o GetLoadedPageVisibleBox
  • O HotPDF v2.770.153 corrigiu o ApplyLoadedOCRTextLayer; a v2.770.154 corrigiu o DecodeLoadedPageBarcodes de página inteira e as descobertas de rostos do DetectLoadedRedactionFindings; builds da v2.766.64 até essas versões são afetadas
  • As caixas THPDFOCRWord são pixels de bitmap com origem no topo esquerdo; o GetLoadedPageBox e o GetLoadedPageVisibleBox devolvem user space PDF com origem no fundo esquerdo e Bottom < Top
  • A escala é DPI / 72; derive-a do DPI, nunca de uma caixa de página dividida pela largura do bitmap
  • O /Rotate decide que margens interessam: Left e Bottom a 0 e 90, Right e Bottom a 180, Right e Top a 270
  • Devolva palavras OCR em pixels e deixe o HotPDF mapeá-las; converta só para a sua própria lógica de filtragem
  • Corra de novo o OCR em páginas recortadas processadas por uma build afetada, e lembre-se de que o SkipPagesWithText salta páginas que já trazem a camada antiga

As funcionalidades de reconhecimento, as consultas de caixas de página e a renderização de documentos carregados usadas aqui vêm todas no componente HotPDF para Delphi e C++Builder; licenciamento, downloads de trial e a lista completa de funcionalidades estão na página do componente PDF Delphi HotPDF