Artykuł techniczny

Adaptacyjny resampling obrazów PDF w Delphi z PDFiumPas

Dwa zgłoszenia przychodzą w tygodniu po wydaniu funkcji kompresji: zeskanowana umowa ma teraz schodkowe, mesiste kształty liter, a przezroczyste logo na stronie tytułowej siedzi w bladym halo. PDFiumPas odpowiada na oba w jednym miejscu. TPdf.OptimizeImages mierzy każdy obraz, zanim go pomniejszy, potem dobiera jądro resamplingu i akumuluje kolor w formie świadomej alfy

Nie zawsze tak było. Przed v3.100.0 ta sama metoda pomniejszała każdy obraz niebilevelowy stałym krokiem najbliższego sąsiada, czyli dokładnie algorytmem, który rodzi oba zgłoszenia: próbkowała punktowo jeden piksel źródła na piksel wyjścia i traktowała RGB leżący pod w pełni przezroczystym pikselem tak, jakby czytelnik miał go kiedykolwiek zobaczyć. Przepisanie w v3.100.0 zastępuje tę pojedynczą ścieżkę pięcioma jądrami, zmierzoną regułą wyboru i jawnym budżetem pamięci roboczej

Dlaczego pomniejszanie czyni zeskanowany tekst postrzępionym?

Bo próbkowanie punktowe odpowiada na złe pytanie. Gdy skan 300 DPI jest przeliczany na 150 DPI, każdy piksel docelowy stoi za blokiem dwóch na dwa piksele źródła, a najbliższy sąsiad zatrzymuje jeden z czterech i wyrzuca resztę. Który przeżyje, zależy od zaokrąglenia, więc krawędź kresek gładko wygładzona w źródle staje się rzutem monetą na piksel. Wynik to klasyczne aliasowe schodki wzdłuż krawędzi glifów, plus moiné na obszarach rastra, gdzie odrzucone próbki akurat niosły wzór. W PDF ma to większe znaczenie niż na ekranie, bo szkoda jest trwała. XObject obrazu niesie swoje dane próbek razem z /Width, /Height i /BitsPerComponent (ISO 32000-1 §8.9.5), a resampling przepisuje wszystkie trzy wewnątrz pliku. Zły zoom w przeglądarce to klatka, którą można przerysować, a PDFiumPas ma na to osobną maszynerię w cache renderowania i wydajności zoomu. Złe pomniejszenie to nowy dokument, który wręczasz klientowi

Dlaczego pomniejszanie najbliższym sąsiadem ruinuje zeskanowany tekst w PDFiumPas dla Delphi: każdy piksel wyjścia zatrzymuje jeden z czterech pikseli źródła i wyrzuca resztę, dając aliasowe krawędzie glifów i moiné, co zastępują pięć jąder resamplingu
Próbkowanie punktowe zatrzymuje jeden piksel źródła na piksel wyjścia i wyrzuca pozostałe trzy, dlatego PDFiumPas oferuje teraz pięć jąder zamiast jednego

Jak PDFiumPas mierzy szczegóły i dobiera jądro

PDFiumPas decyduje per obraz, nie per dokument. Zanim wybierze jądro, liczy znormalizowany wynik szczegółów luminancji z ograniczonej siatki próbkowania: kroki poziome i pionowe to (Width + 63) div 64 i (Height + 63) div 64, więc skan o 12000 pikseli i miniatura o 300 pikseli kosztują w przybliżeniu ten sam przebieg 64 na 64. W każdej próbkowanej pozycji sumuje różnicę bezwzględną do sąsiada po prawej i sąsiada poniżej, na maksymalnie trzech kanałach, potem dzieli przez liczbę próbek razy 255. Wynik ląduje w zakresie od 0 do 1, gdzie płaska grafika biznesowa siedzi przy zerze, a gęsta tekstura fotograficzna pnie się w górę

Drabinka wyboru biegnie potem w stałej kolejności. Jeśli ResampleFilter ma wartość inną niż pirfAdaptive, ten filtr jest używany dosłownie. W przeciwnym razie: treść 1-bitowa bierze pirfBilevel; ContentClass równy piccLineArt bierze pirfBox; współczynnik skali 4 lub więcej również bierze pirfBox, bo przy takiej redukcji średnia obszarowa jest zarazem najtańszą i najpoprawniejszą odpowiedzią; piccPhoto, wynik szczegółów 0.08 lub wyższy albo PreferredQuality 0.9 lub wyższy bierze pirfLanczos z jego trójpłatowym jądrem; skala 2 lub więcej albo jakość 0.7 lub wyższa bierze pirfBicubic o promieniu 2; wszystko, co zostaje, bierze pirfBilinear. Ponieważ TPdfImageOptimizeOptions.Default ustawia PreferredQuality na 0.85, domyślne uruchomienie nigdy nie cofa się do bilinear, chyba że redukcja jest łagodna, a treść płaska

Jak PDFiumPas wybiera jądro resamplingu w Delphi: ograniczony przebieg sześćdziesiąt cztery na sześćdziesiąt cztery daje znormalizowany wynik szczegółów, po czym stała drabinka warunków kieruje każdy obraz do filtra bilevel, box, Lanczos, bicubic albo bilinear
Wynik szczegółów kosztuje tyle samo na skanie o 12000 pikseli co na miniaturze, a drabinka pod nim zatrzymuje się na pierwszym pasującym warunku
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // Domyślne: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, jakość 0.85, budżet 64 MiB.
    Options := TPdfImageOptimizeOptions.Default;
    Options.TargetDpi := 150;
    Options.MinDpiRatio := 1.5;
    Options.ContentClass := piccAuto;
    Options.PreferredQuality := 0.85;
    if Pdf.OptimizeImages(Options, Report) and (Report.OptimizedCount > 0) then
      Pdf.SaveAs(OutputFile);
  finally
    Pdf.Free;
  end;
end;

Obraz jest dotykany tylko wtedy, gdy większy z jego DPI rozmieszczenia poziomego i pionowego, podzielony przez TargetDpi, osiąga MinDpiRatio. Ta straż istnieje po to, by zdjęcie 160 DPI celujące w cel 150 DPI nie było kodowane ponownie dla sześciu procent zysku, który kosztuje generację jakości. Obrazy poniżej MinDimension na dowolnej osi, domyślnie 8, są pomijane jako ikony albo linie

Dlaczego przezroczyste logo nabywa białej obwódki?

Bo kolor pod w pełni przezroczystym pikselem jest dowolny, a zwykła średnia ważona pozwala mu głosować. Wyeksportuj logo z narzędzia projektowego, a niewidoczny margines jest często biały, czarny albo taki, jaki było płótno; kanał alfa go ukrywa, a proste sumowanie po stopie jądra natychmiast wmiesza go z powrotem w widoczną krawędź. PDFiumPas unika tego, akumulując próbki BGRA w postaci premultiplied i cofając premultiplied dopiero na pikselu docelowym

Konkretnie, każda wnosząca próbka dodaje channel * alpha * weight do akumulatora koloru, alpha * weight do akumulatora alfy, a weight do sumy wag. Kolor docelowy jest potem dzielony przez akumulator alfy, a nie przez sumę wag, i to jest krok, który się liczy: dzielenie przez sumę wag ciągnęłoby kolor w stronę niewidocznych pikseli, podczas gdy dzielenie przez zakumulowaną alfę odtwarza kolor, na którym widoczne próbki faktycznie się zgodziły. Alfa docelowa to osobna wielkość, 255 * AlphaSum / WeightSum. Formaty bez alfy dzielą przez sumę wag jak zwykle, bajt wypełnienia celu FPDFBitmap_BGRx jest zapisywany jako stałe 255, a każdy kanał jest zaciskany do 0–255 przed zapisem. Ta alfa pochodzi normalnie z wpisu miękkiej maski w słowniku obrazu (ISO 32000-1 §11.4), który PDFium już skomponował do bufora BGRA, który resampler dostaje

Jak PDFiumPas usuwa białe halo z przezroczystych obrazów PDF w Delphi: próbki są akumulowane w postaci premultiplied, a kolor docelowy jest dzielony przez zakumulowaną alfę zamiast sumy wag, więc niewidoczne piksele nie mogą głosować
Dzielenie koloru premultiplied przez zakumulowaną alfę odtwarza to, na czym zgodziły się widoczne próbki, podczas gdy dzielenie przez sumę wag ciągnie krawędź w stronę niewidocznych pikseli
// Kształt wewnętrznej pętli akumulacji, na każdą wnoszącą próbkę źródła
if SrcFormat = FPDFBitmap_BGRA then
  Alpha := PByte(PAnsiChar(Pixel) + 3)^ / 255
else
  Alpha := 1;
for Channel := 0 to Min(BytesPerPixel, 3) - 1 do
  Accumulated[Channel] := Accumulated[Channel] +
    PByte(PAnsiChar(Pixel) + Channel)^ * Alpha * Weight;
AlphaSum := AlphaSum + Alpha * Weight;
WeightSum := WeightSum + Weight;

// ... a na pikselu docelowym, unpremultiply względem sumy alfy
if SrcFormat = FPDFBitmap_BGRA then
begin
  if Abs(AlphaSum) > 1E-12 then
    ValueSum := Accumulated[Channel] / AlphaSum
  else
    ValueSum := 0;
end
else
  ValueSum := Accumulated[Channel] / WeightSum;

Trzymanie grafiki liniowej 1-bitowej poza strefą szarości

Każde jądro ciągłe zastosowane do skanu bilevelowego produkuje szarość, a szarość jest dokładnie tym, czego obraz w stylu faksu nie może zawierać. PDFiumPas zostawia więc obrazy 1-bitowe w spokoju domyślnie: PreserveBilevel ma wartość True w TPdfImageOptimizeOptions.Default, a takie obrazy lądują w SkippedCount nietknięte. Ustaw False, a ścieżka pirfBilevel przejmuje stery zamiast jądra wygładzającego. Przechodzi dokładny prostokąt źródła pokrywający każdy piksel docelowy, uśrednia luminancję z wagami 0.114, 0.587 i 0.299 w kolejności pamięci BGR i zaciina wynik progiem 127.5 na płaskie 0 albo 255. Nic pośredniego nie da się zapisać, więc krawędzie zostają ostre i żadne szare halo nie powstaje wokół cienkich kresek; kanał alfa źródła BGRA jest uśredniany normalnie, a cel BGRx dostaje stałe 255. Jeśli potrzebujesz pikseli leżących pod spodem, a nie mniejszego dokumentu, ekstrakcja obrazów z dokumentów PDF to osobna ścieżka

Co się dzieje, gdy obraz przekracza budżet pamięci roboczej?

Pozostaje dokładnie taki, jaki był, i jest liczony. MaxWorkingBytes domyślnie wynosi 64 MiB i jest egzekwowany dwa razy. Zanim bitmapa docelowa powstanie, PDFiumPas odrzuca obraz, jeśli szerokość razy wysokość razy bajty na piksel przekraczają budżet. Po udanym FPDFBitmap_CreateEx sprawdza jeszcze raz, używając realnego stride razy wysokość, bo wypełnienie wierszy może pchnąć alokację ponad limit, który naiwny iloczyn zdawał się mieścić. Każde z tych odrzuceń niszczy cel i nie zwraca nic. Miej jasność co do degradacji, którą to implikuje: obraz ponad budżet nie jest resamplowany w niższej jakości i nie jest dzielony na kafle. Oryginał zostaje w dokumencie, BudgetExceededCount i SkippedCount oba rosną, więc uruchomienie może raportować sukces, podczas gdy dokument jest zoptymalizowany tylko częściowo. To celowe zachowanie fail-safe, ale oznacza, że raport nie jest lekturą opcjonalną. Osobny tryb awarii też istnieje: obrazy, których bitmapy PDFium nie potrafi wyprodukować wcale, jak CMYK, JPX, JBIG2 albo źródła z maską, zwiększają zamiast tego FailedCount i równie dobrze pozostają nietknięte

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // użyj głosu obszarowego bilevel
  Options.ContentClass := piccPhoto;             // wymuś Lanczos dla zestawów zdjęć
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // zapas na duże skany
  Pdf := TPdf.Create(nil);
  try
    for I := Low(Files) to High(Files) do
    begin
      Pdf.FileName := Files[I];
      if not Pdf.OptimizeImages(Options, Report) then
      begin
        WriteLn('optimize failed: ', Report.ErrorMessage);
        Continue;
      end;
      if Report.BudgetExceededCount > 0 then
        WriteLn(Files[I], ': ', Report.BudgetExceededCount,
          ' image(s) over budget and kept at full size');
      if Report.FailedCount > 0 then
        WriteLn(Files[I], ': ', Report.FailedCount,
          ' image(s) could not be decoded to a bitmap');
      if Report.OptimizedCount > 0 then
        Pdf.SaveAs(ChangeFileExt(Files[I], '.opt.pdf'));
    end;
  finally
    Pdf.Free;
  end;
end;

Czytanie raportu, zanim wyślesz plik

TPdfImageOptimizeReport jest zbudowany do diagnozowania, nie tylko do logowania. Obok OptimizedCount, SkippedCount i FailedCount wystawia jeden licznik na jądro, więc BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount i BilevelFilterCount mówią ci, co reguła adaptacyjna faktycznie wywnioskowała o twoim korpusie. Wynik w całości boxowy znaczy, że redukcje były strome albo treść została sklasyfikowana jako line art; wynik w całości Lanczosowy na dokumencie uznanym za line art to znak, że ContentClass powinien być ustawiony jawnie. AverageDetailScore to liczba do porównania z progiem 0.08 dla Lanczosa przy strojeniu PreferredQuality, a PeakWorkingBytes pokazuje, ile z MaxWorkingBytes uruchomienie naprawdę potrzebowało. Niepoprawne opcje zawodzą głośno, nie po cichu: nie dodatnie TargetDpi, MinDpiRatio poniżej 1, PreferredQuality poza zakresem 0–1 albo nie dodatnie MaxWorkingBytes rzuca EPdfError, zanim jakakolwiek strona zostanie dotknięta. A OptimizeImages edytuje tylko dokument w pamięci; każda zmodyfikowana strona jest zatwierdzana przez FPDFPage_GenerateContent, po czym i tak sam wywołujesz SaveAs. Żeby na oko zobaczyć, co się zmieniło, wyrenderuj dokumenty przed i po do bitmap tak, jak opisano w konwersji stron PDF na obrazy JPEG, i porównaj je przy pełnym zoomie

Adaptacyjny resampling to jedna z tych funkcji, które są niewidzialne, gdy działają, i generują zgłoszenia do wsparcia, gdy nie działają, dlatego pomiar, obsługa alfy i budżet pamięci musiały wylądować razem, a nie jako trzy osobne udoskonalenia. Jeśli oceniasz to do produktu w Delphi, C++Builder albo Lazarus, pełna powierzchnia API i szczegóły licencjonowania są na stronie komponentu PDFiumPas Delphi PDFium