Bài viết kỹ thuật

Lệch text layer OCR của HotPDF: ánh xạ qua CropBox

Text layer OCR, biên barcode và khối che mặt (redaction) bị lệch trên các trang PDF đã crop khi pixel bitmap được ánh xạ ngược qua MediaBox thay vì box mà renderer thực sự đã rasterize: CropBox được cắt theo MediaBox (ISO 32000-1 §14.11.2). HotPDF sửa điều này cho ApplyLoadedOCRTextLayer ở v2.770.153, và cho DecodeLoadedPageBarcodes cùng DetectLoadedRedactionFindings ở v2.770.154

Báo cáo lỗi thường tới theo kiểu này. Một kho hợp đồng scan chạy qua OCR, kết quả tìm được, và phần highlight trúng một số điều khoản lại sáng lên nửa inch bên dưới và bên trái con số in. Đa số tệp trong lô đều ổn. Những tệp hỏng đều đến từ một trạm scan duy nhất có ghi /CropBox để cắt mép bàn kính. Chính chi tiết ấy tách bức ảnh mà OCR engine nhìn thấy khỏi khung mà text layer được đặt vào, và cùng sự lệch đó kéo theo biên barcode và, nghiêm trọng hơn, các khối che mặt

Vì sao text layer OCR trôi ra xa các chữ đã scan?

Text layer trôi vì hai nửa của pipeline bất đồng về hình chữ nhật mà bitmap phủ. Ở v2.766.64, HotPDF đổi rendering, export SVG, viewer và in ấn để tôn trọng CropBox: một trang được hiển thị qua CropBox của nó cắt theo MediaBox, đúng điều ISO 32000-1 §14.11.2 quy định, và GetLoadedPageVisibleBox được thêm vào để trả về box nhìn thấy được ấy. Các tính năng nhận diện vẫn dựng phép biến đổi device-to-page của mình từ GetLoadedPageBox(PageIndex, pbMediaBox, ...). Raster giờ phủ box nhìn thấy được, phép biến đổi vẫn giả định MediaBox, và mọi vị trí được nhận diện quay về lệch đúng khoảng cách giữa hai cái

Cửa sổ bị ảnh hưởng vì thế rất rõ ràng. ApplyLoadedOCRTextLayer đặt sai chỗ văn bản từ v2.766.64 tới v2.770.152. DecodeLoadedPageBarcodes cả trang và phần phát hiện mặt trong DetectLoadedRedactionFindings sai lâu hơn một bản build, tới hết v2.770.153. Trước v2.766.64, renderer vẽ trọn MediaBox, nên phép ánh xạ và raster khớp nhau, đổi lấy việc nhận diện cả nội dung mà viewer chẳng bao giờ hiển thị. Các bản sửa đổi ba thứ cùng lúc cho từng tính năng: phép biến đổi, ước lượng ngân sách pixel, và page box trao cho một engine tùy chỉnh trong record yêu cầu

Vài trường hợp chưa bao giờ bị ảnh hưởng:

  • Các trang không có /CropBox, hay có CropBox trùng MediaBox, ánh xạ như nhau trước và sau bản sửa
  • DecodeLoadedPageBarcodes với HasRegion được đặt render đúng region bạn truyền và ánh xạ qua chính region ấy, nên việc decode theo region tường minh luôn đúng; phép kiểm region nằm trong trang vẫn dùng MediaBox
  • Các phát hiện redaction theo mẫu (email, số thẻ và tương tự) đến từ trích văn bản trong user space, không từ raster, nên chỉ các phát hiện từ face detection bị xê dịch

Ba hệ tọa độ, và API HotPDF nào dùng hệ nào

Code HotPDF chạm tới phần nhận diện phải đối phó với ba hệ, và phần lớn bug ánh xạ đến từ việc trộn lẫn hai trong số đó

  • Pixel bitmap: gốc trên-trái, Y hướng xuống, đơn vị là pixel theo DPI của yêu cầu. THPDFOCRWord.Left, Top, Right và Bottom nằm trong hệ này, cùng các điểm baseline tùy chọn, kết quả mà một IHPDFBarcodeDecoder tùy chỉnh trả về, và các box từ một IHPDFFaceDetector tùy chỉnh
  • PDF user space của một trang đã nạp: gốc dưới-trái, Y hướng lên, đơn vị là point, với Bottom < Top. GetLoadedPageBox và GetLoadedPageVisibleBox trả về Left, Bottom, Right, Top trong hệ này, và các trường PageLeft, PageBottom, PageRight, PageTop của THPDFOCRRequest, các biên trong THPDFDecodedBarcode cùng các hình chữ nhật trong THPDFRedactionFinding cũng vậy
  • Tọa độ vẽ trang HotPDF: API bạn dùng để dựng các trang mới (xuất văn bản, hình khối, barcode, link, trường biểu mẫu) làm việc với gốc trên-trái và Y hướng xuống. Hệ đó thuộc về phần sinh tài liệu và chẳng liên quan gì tới các API tài liệu đã nạp ở trên, nên đừng bao giờ nạp thẳng một hình chữ nhật user space của trang đã nạp vào đó

Record từ OCR được cố ý dựa trên pixel: engine báo lại những gì nó nhìn thấy trong ảnh, còn ApplyLoadedOCRTextLayer nắm phần chuyển đổi. Sự phân công đó chỉ hoạt động khi phép chuyển đổi dùng đúng box, và đó là điều v2.770.153 khôi phục lại

Các hệ tọa độ nhận diện của HotPDF: pixel bitmap với gốc trên-trái mà các box THPDFOCRWord và decoder tùy chỉnh dùng, PDF user space với gốc dưới-trái mà GetLoadedPageBox và GetLoadedPageVisibleBox trả về, và API vẽ trang gốc trên-trái, thứ không bao giờ được nhận thẳng một hình chữ nhật của trang đã nạp
Engine báo pixel vì đó là thứ chúng nhìn thấy, HotPDF lo phần ánh xạ, còn việc trộn hai hệ chính là cách các layer và khối che mặt trôi đi

Phép biến đổi device-to-page đứng sau OCR, barcode và nhận diện mặt

HotPDF ánh xạ pixel bitmap sang trang bằng một ma trận affine duy nhất dựng từ năm input: góc xoay, tỉ lệ DPI / 72, chiều cao bitmap, và Left, Bottom, Right, Top của box đã render. OCR, decode barcode và nhận diện mặt dùng chung một thủ tục cho việc này, nên một input box sai đã làm hỏng cả ba theo cùng một kiểu. Với trang không xoay, ma trận page-to-device [A B C D E F] là:

  • A = Scale và D = -Scale, với Scale = DPI / 72; giá trị D âm lật user space (Y hướng lên) thành bitmap space (Y hướng xuống)
  • B = C = 0, vì một trang không xoay chẳng có shear hay trao đổi trục nào
  • E = -Left * Scale, đưa cạnh trái của box về cột pixel 0
  • F = BitmapHeight + Bottom * Scale, ánh xạ cạnh dưới của box tới y = BitmapHeight — cạnh dưới của bitmap — để cạnh trên đáp vào hàng 0

Pixel quay ngược về trang qua ma trận nghịch đảo của phép trên. Request.PageRotation mang /Rotate của trang đã chuẩn hóa về 0, 90, 180 hay 270 (mọi giá trị không phải bội của 90 được coi là 0), và renderer xoay trang theo chiều kim đồng hồ như ISO 32000-1 §7.7.3.3 yêu cầu. Khi có xoay, các trục trao chỗ cho nhau và một cặp cạnh box khác bị ghim vào gốc bitmap. Viết ra thành các công thức nghịch đảo, với S = DPI / 72, x và y theo pixel và H là chiều cao bitmap:

/RotateTrang XTrang YCác cạnh box mà phép ánh xạ dựa vào
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

Cột cuối giải thích vì sao bug nhìn như ngẫu nhiên trên môi trường thật. Một CropBox chỉ cắt phần đỉnh trang thì không đụng tới Left và Bottom, nên các trang đứng thẳng ra hoàn hảo và chỉ các trang mang /Rotate 270 mới lệch. Xoay còn tráo cả kích thước bitmap: ở 90 và 270, bitmap rộng (Top - Bottom) * S pixel và cao (Right - Left) * S pixel

Bảng ánh xạ ngược theo góc xoay trang của HotPDF: tại /Rotate 0 và 90, phép biến đổi ghim cạnh Left và Bottom của box đã render, tại 180 ghim Right và Bottom, tại 270 ghim Right và Top, vì thế một trang đã crop lệch theo một hướng khác nhau cho mỗi hướng xoay trong một tài liệu trộn lẫn
Cùng một vết cắt nửa inch trông như ba bug khác nhau một khi các trang mang các giá trị /Rotate khác nhau, vì mỗi hướng xoay ghim một cặp cạnh box khác nhau

Chuyện gì sai với MediaBox [0 0 612 792] và CropBox [36 36 576 756]?

Với một vết cắt nửa inch mỗi phía, text layer của một trang không xoay đáp lệch đúng 36 point sang trái và 36 point xuống dưới so với các chữ đã scan nếu MediaBox được dùng. Lấy một trang US Letter có CropBox cắt 36 point (0.5 inch) mỗi cạnh. Box nhìn thấy được là 540 nhân 720 point, nên ở độ phân giải OCR mặc định 300 DPI, tỉ lệ là 300 / 72 ≈ 4.1667 và bitmap là 2250 nhân 3000 pixel

Giả sử engine báo một từ với box pixel Left 450, Top 600, Right 900, Bottom 660 và không có baseline. HotPDF khi đó đặt baseline cách cạnh dưới 20 phần trăm chiều cao của từ, tại hàng pixel 648, và ánh xạ điểm bắt đầu (450, 648):

  • Qua box nhìn thấy được: x = 36 + 450 / 4.1667 = 144.0 và y = 36 + (3000 - 648) / 4.1667 = 600.48, đúng chỗ chữ được in
  • Qua MediaBox: x = 0 + 108.0 = 108.0 và y = 0 + 564.48 = 564.48, một dịch chuyển đều (-36, -36) point
Giải phẫu sự trôi CropBox của HotPDF trên một trang US Letter với MediaBox 0 0 612 792 và CropBox 36 36 576 756: renderer rasterize box nhìn thấy được ở 300 DPI, nên ánh xạ pixel từ 450 qua GetLoadedPageVisibleBox cho ra 144.0 và 600.48 trong khi phép biến đổi qua MediaBox đáp tại 108.0 và 564.48
Raster phủ CropBox, nên bất kỳ phép biến đổi nào dựng từ MediaBox đều dịch mọi từ được nhận diện đúng bằng mép cắt

Xoay cùng trang đó thì hướng của lỗi đổi, vì những cạnh khác bị dính vào. Ở /Rotate 180, số hạng X dùng Right, và 612 thay vì 576 đẩy layer sang phải 36 point trong khi Bottom vẫn kéo nó xuống 36 point. Ở /Rotate 270, cả Right lẫn Top đều quá lớn, nên layer dịch 36 point sang phải và 36 point lên trên. Một tài liệu với các hướng trộn lẫn có thể hiện sự trôi theo ba hướng — một dấu vân tay đáng tin cho bug này. Code tự viết suy tỉ lệ từ box, kiểu Bitmap.Width / (Right - Left), còn kéo giãn mọi tọa độ thêm 612 / 540, khoảng 13 phần trăm, chồng lên phần lệch

Tài liệu PDF nào của bạn bị ảnh hưởng?

Một tài liệu PDF bị phơi nhiễm khi ít nhất một trang có box nhìn thấy được khác MediaBox của nó, và HotPDF có thể báo cho bạn điều đó trong vài dòng. So GetLoadedPageBox với pbMediaBox với GetLoadedPageVisibleBox cho từng trang, và in kèm GetLoadedPageRotation để đoán trước hướng trôi từ bảng ở trên. THPDFPageBoundary còn có pbCropBox, pbBleedBox, pbTrimBox và pbArtBox, nhưng GetLoadedPageBox(pbCropBox) lùi về MediaBox khi không có crop box nào và không cắt, nên box nhìn thấy được mới là thứ đúng để so

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;
      // mảng lưu có thể liệt kê các góc theo thứ tự bất kỳ
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // đã chuẩn hóa và cắt theo 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;

Hai chi tiết của GetLoadedPageVisibleBox quan trọng với các script kiểu này. Hàm không đụng tới các tham số out của nó khi thất bại, nên đặt sẵn một cỡ trang mặc định trước lời gọi là một mẫu an toàn. Và khi một CropBox dị dạng chẳng giao với MediaBox chút nào, hàm trả về MediaBox thay vì một hình chữ nhật rỗng. Nếu báo cáo liệt kê các trang và bản build bạn triển khai cũ hơn v2.770.153 cho OCR, hay v2.770.154 cho barcode và mặt, hãy chạy lại nhận diện trên các trang ấy sau khi nâng cấp. Một OCR layer được ghi bởi bản build bị ảnh hưởng vẫn nằm trong tệp đã lưu, và option mặc định SkipPagesWithText sẽ bỏ qua những trang đó ở lượt hai trừ khi bạn tắt nó đi hay xóa lớp cũ trước

Một IHPDFOCREngine tùy chỉnh nên ánh xạ pixel ngược về PDF space thế nào?

Một IHPDFOCREngine tùy chỉnh nên trả về các word box theo pixel bitmap và để HotPDF lo ánh xạ; chỉ chuyển sang user space cho các quyết định của riêng bạn, và khi đó dùng box trong yêu cầu, đừng bao giờ dùng MediaBox. Kể từ v2.770.153, các trường PageLeft, PageBottom, PageRight và PageTop của yêu cầu mô tả box nhìn thấy được đã render, nên chúng khớp Request.Bitmap chính xác. Helper dưới đây là nghịch đảo của phép biến đổi của thư viện, gồm cả việc dùng chiều cao bitmap thực cho các trang đứng thẳng, nên nó khớp HotPDF tới từng pixel

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

// Pixel bitmap (gốc trên-trái, Y hướng xuống) sang PDF user space
// (gốc dưới-trái, Y hướng lên), qua box mà bitmap được render từ đó
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;

Một lý do thực tế khiến bạn cần user space bên trong engine là quy tắc theo vùng: những tờ hóa đơn mà phần letterhead bạn không bao giờ muốn tìm kiếm được, hay một vùng con dấu làm recognizer bối rối. Engine dưới đây, viết bằng TInterfacedObject để đếm tham chiếu lo vòng đời, lọc các từ theo vị trí tâm của chúng rơi trên trang, rồi trả lại những từ sống sót nguyên trạng theo tọa độ pixel. RunRecognizer đứng thay cho lời gọi recognizer của bạn

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // recognizer của bạn, box pixel
  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];  // vẫn là pixel: HotPDF tự ánh xạ chúng
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Trao engine cho ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) như với bất kỳ engine nào khác. Thư viện kiểm chứng những gì quay về trước khi tin: một từ bị bỏ và đếm vào Info.DroppedWordCount khi box của nó lọt ra ngoài bitmap, khi Right <= Left hay Bottom <= Top, hay khi Confidence ngoài 0..1 hoặc dưới MinimumConfidence. Trả về nhiều hơn MaxWordsPerPage từ, hay đẩy tổng cộng dồn vượt MaxTotalWords, sẽ làm cả lời gọi thất bại với một lỗi ngân sách, nên hãy tôn trọng Request.MaxWords trong engine. Đừng chuyển các word box sang user space trước khi trả về; HotPDF sẽ coi các giá trị point đó là pixel và layer sẽ sụp về gốc bitmap

Ánh xạ đầu ra detector tự viết của bạn

Cùng helper ấy phục vụ một pipeline tự dựng trên RenderLoadedPageToBitmap, thứ render box nhìn thấy được và áp /Rotate y như các tính năng nhận diện. Đọc box bằng GetLoadedPageVisibleBox, chuẩn hóa góc xoay cùng kiểu HotPDF làm, và ánh xạ hai góc đối diện của mỗi box pixel. Trục Y lật, và ở 90 cùng 270 độ, các trục tráo chỗ, nên các góc đã ánh xạ ra theo thứ tự không cố định; hãy lấy min và max của các điểm đã ánh xạ, cũng chính là cách HotPDF dựng biên barcode

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);  // code của bạn, box pixel
      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;

Hành vi xoay được bàn sâu hơn trong làm phẳng góc xoay trang mà không phá các page box, còn pipeline decode barcode tiêu thụ cùng phép biến đổi đó nằm trong decode mã QR xoay từ các trang PDF. Nếu engine của bạn bọc một recognizer ngoài, adapter OCR Tesseract cho PDF có thể tìm kiếm cho thấy phía cô lập tiến trình và hủy của cùng interface đó

Tra nhanh: ánh xạ tọa độ an toàn với CropBox

  • Renderer rasterize box nhìn thấy được, tức CropBox cắt theo MediaBox (ISO 32000-1 §14.11.2); mọi phép ánh xạ pixel-sang-trang phải dùng box đó, đọc bằng GetLoadedPageVisibleBox
  • HotPDF v2.770.153 sửa ApplyLoadedOCRTextLayer; v2.770.154 sửa DecodeLoadedPageBarcodes cả trang và các phát hiện mặt từ DetectLoadedRedactionFindings; các bản build từ v2.766.64 tới trước các phiên bản đó đều bị ảnh hưởng
  • Các box THPDFOCRWord là pixel bitmap với gốc trên-trái; GetLoadedPageBox và GetLoadedPageVisibleBox trả về PDF user space với gốc dưới-trái và Bottom < Top
  • Tỉ lệ là DPI / 72; hãy suy nó từ DPI, đừng bao giờ từ một page box chia vào bề rộng bitmap
  • /Rotate quyết định những cạnh nào quan trọng: Left và Bottom ở 0 và 90, Right và Bottom ở 180, Right và Top ở 270
  • Trả các từ OCR theo pixel và để HotPDF ánh xạ; chỉ chuyển đổi cho logic lọc của riêng bạn
  • Chạy lại OCR trên các trang đã crop từng đi qua một bản build bị ảnh hưởng, và nhớ rằng SkipPagesWithText bỏ qua các trang đang mang lớp cũ

Các tính năng nhận diện, các truy vấn page box và rendering tài liệu đã nạp dùng ở đây đều đi kèm HotPDF component cho Delphi và C++Builder; giấy phép, bản dùng thử và danh sách tính năng đầy đủ nằm trên trang HotPDF Delphi PDF component