技術記事

DelphiのPDFiumPasでPDF画像を適応型リサンプリング

圧縮機能を出荷した翌週、2つの苦情が届きます。スキャンした契約書の文字が階段状にギザギザになり、表紙の透明ロゴが薄いハローの中に座っている、というものです。PDFiumPasはこの両方に1箇所で答えます。TPdf.OptimizeImagesは各画像を縮小する前に実測し、リサンプリングカーネルを選び、アルファを意識した形で色を蓄積します

それは昔からそうだったわけではありません。v3.100.0より前、同じメソッドはすべての非二値画像を固定のニアレストネイバーの手順でダウンサンプルしていました。これはまさに両方の苦情を生むアルゴリズムです。出力ピクセルごとにソースピクセルを1つ点サンプリングし、完全に透明なピクセルの下にあるRGBを、読者が見ることがあるかのように扱います。v3.100.0の書き直しは、その単一の経路を5つのカーネル、実測に基づく選択規則、明示的な作業メモリ予算に置き換えました

なぜダウンサンプルするとスキャンした文字がギザギザに見えるのか

点サンプリングは問いを間違えているからです。300 DPIのスキャンを150 DPIへ向け直すとき、各出力ピクセルは2×2のソースピクセルのブロックに対応し、ニアレストネイバーはその4つのうち1つを残して残りを捨てます。どれが生き残るかは丸め次第なので、ソースで滑らかにアンチエイリアスされていたストロークの縁がピクセルごとのコイントスになります。結果はグリフの縁に沿う典型的なエイリアスの階段で、捨てられたサンプルがたまたまパターンを運んでいたハーフトーン領域ではモアレが加わります。これが画面上よりPDFで深刻なのは、ダメージが恒久だからです。画像XObjectはサンプルデータとともに/Width/Height/BitsPerComponentを運び(ISO 32000-1 §8.9.5)、リサンプリングはファイル内部でこの3つすべてを書き換えます。ビューアでのまずいズームは描き直せるフレームであり、PDFiumPasはそれ用の別の機構をレンダリングキャッシュとズーム性能に持っています。まずいダウンサンプルは、顧客に渡す新しい文書です

PDFiumPasでDelphi向けPDFのニアレストネイバーダウンサンプルがスキャン文字を台無しにする理由。各出力ピクセルは4つのソースピクセルのうち1つを残して残りを捨て、エイリアス付きのグリフ縁とモアレを生む。5つのリサンプリングカーネルがこれを置き換える
点サンプリングは出力ピクセルごとにソースピクセルを1つ残して他の3つを捨てます。だからPDFiumPasは今や1つではなく5つのカーネルを提供します

PDFiumPasがディテールを測りカーネルを選ぶ方法

PDFiumPasは文書単位ではなく画像単位で決めます。カーネルを選ぶ前に、有界なサンプリンググリッドから正規化された輝度ディテールスコアを計算します。水平と垂直の刻みは(Width + 63) div 64(Height + 63) div 64なので、12000ピクセルのスキャンも300ピクセルのサムネイルも、ほぼ同じ64×64の走査で済みます。各サンプル位置で、右の隣と下の隣への絶対差を最大3チャネルにわたって合計し、サンプル数×255で割ります。スコアは0から1に収まり、平坦なビジネスグラフィックは0近くに、濃い写真の質感は高く登ります

選択ラダーは固定の順序で走ります。ResampleFilterpirfAdaptive以外なら、そのフィルタがそのまま使われます。そうでなければ、1ビットの内容はpirfBilevelContentClasspiccLineArtならpirfBox。倍率4以上でもpirfBoxです。その縮小率では面積平均が最も安く、かつ最も正しい答えだからです。piccPhoto、ディテールスコア0.08以上、またはPreferredQuality0.9以上は、3ローブカーネルのpirfLanczosへ。倍率2以上または品質0.7以上は、半径2のpirfBicubicへ。残りはすべてpirfBilinearです。TPdfImageOptimizeOptions.DefaultPreferredQualityを0.85に設定するので、デフォルト実行がバイリニアにフォールバックするのは、縮小が緩く内容が平坦なときだけです

DelphiでPDFiumPasがリサンプリングカーネルを選ぶ方法。有界な64×64の走査が正規化ディテールスコアを生み、その下の固定ラダーが各画像を二値、ボックス、Lanczos、バイキュービック、バイリニアの各フィルタへ振り分ける
ディテールスコアは12000ピクセルのスキャンでもサムネイルでも同じコストで、その下のラダーは最初に一致した条件で止まります
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // デフォルト: TargetDpi 150、MinDpiRatio 1.5、PreserveBilevel True、
    // MinDimension 8、pirfAdaptive、piccAuto、品質 0.85、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;

画像が触れられるのは、水平と垂直の配置DPIの大きい方をTargetDpiで割った値がMinDpiRatioに達するときだけです。このガードがあるのは、150 DPIターゲットに向けた160 DPIの写真が、品質の1世代を犠牲にする6%の利得のために再エンコードされないようにするためです。いずれかの軸でMinDimension未満の画像は、デフォルトで8、アイコンや罫線としてスキップされます

なぜ透明ロゴは白いフチを拾うのか

完全に透明なピクセルの下の色は任意であり、単純な重み付き平均はそれに投票を許すからです。デザインツールからロゴを書き出すと、不可視の余白はしばしば白か黒か、キャンバスが何であったかの色です。アルファチャネルはそれを隠し、カーネルフットプリント上の素直な合計はそれを可視の縁へすぐ混ぜ戻します。PDFiumPasはBGRAサンプルをプレマルチプライ(premultiplied)形式で蓄積し、プレマルチプライを宛先ピクセルでのみ解くことでこれを避けます

具体的には、寄与する各サンプルはchannel * alpha * weightを色アキュムレータへ、alpha * weightをアルファアキュムレータへ、weightを重み合計へ加えます。宛先の色は重み合計ではなくアルファアキュムレータで割られます。ここが効く段です。重み合計で割ると色は不可視ピクセルへ引かれ、蓄積アルファで割ると可視サンプルが実際に一致していた色が再構成されます。宛先のアルファは別の量で、255 * AlphaSum / WeightSumです。アルファなし形式は従来どおり重み合計で割り、FPDFBitmap_BGRx宛先のパディングバイトは定数255として書かれ、すべてのチャネルは保存前に0から255へクランプされます。このアルファは通常、画像辞書のソフトマスクエントリ(ISO 32000-1 §11.4)由来で、PDFiumがすでにリサンプラが受け取るBGRAバッファへ合成済みです

DelphiでPDFiumPasが透明PDF画像から白いハローを取り除く方法。サンプルはプレマルチプライ形式で蓄積され、宛先の色は重み合計ではなく蓄積アルファで割られるため、不可視ピクセルは投票できない
プレマルチプライ済みの色を蓄積アルファで割れば可視サンプルが一致していたものが再構成され、重み合計で割れば縁は不可視ピクセルへ引かれます
// 寄与するソースサンプルごとの内側蓄積ループの形
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;

// ... そして宛先ピクセルで、アルファ合計に対してアンプレマルチプライ
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;

1ビットラインアートをグレー地帯から守る

二値スキャンに連続カーネルを適用するとグレーが生まれ、グレーこそファックス風画像に許されないものです。そこでPDFiumPasはデフォルトで1ビット画像に手を付けません。TPdfImageOptimizeOptions.DefaultではPreserveBilevelTrueで、そうした画像は無傷のままSkippedCountに入ります。これをFalseにすると、平滑化カーネルの代わりにpirfBilevel経路が引き継ぎます。これは各宛先ピクセルを覆う正確なソース矩形を歩き、BGRメモリ順の0.114、0.587、0.299の重みで輝度を平均し、結果を127.5で閾値処理して平坦な0か255にします。中間値は書けないので、縁はシャープなまま薄いストロークの周りにグレーのハローは形成されません。BGRAソースのアルファチャネルは通常どおり平均され、BGRx宛先は定数255を受け取ります。小さい文書ではなく元のピクセルが必要なら、PDF文書からの画像抽出が別の経路です

画像が作業メモリ予算を超えたら何が起きるか

画像は元のまま残り、カウントされます。MaxWorkingBytesはデフォルトで64 MiBで、2回強制されます。宛先ビットマップを作る前に、幅×高さ×ピクセルあたりバイト数が予算を超えればPDFiumPasは画像を拒否します。FPDFBitmap_CreateExが成功した後も、実際のストライド×高さで再チェックします。行パディングが、素朴な積ではクリアしていた限界を押し上げることがあるからです。いずれの拒否でも宛先は破棄され、何も返しません。この劣化の意味を明確にしてください。予算超過の画像はより低い品質でリサンプルされるのでも、タイルに分割されるのでもありません。元の画像は文書に残り、BudgetExceededCountSkippedCountが両方増え、したがって文書が部分的にしか最適化されていなくても実行は成功を報告し得ます。これは意図的なフェイルセーフ動作ですが、レポートが読み飛ばせない読み物であることを意味します。別の失敗モードもあります。CMYK、JPX、JBIG2、マスク付きソースのような、PDFiumがビットマップをそもそも作れない画像は、代わりにFailedCountを増やし、同様に無傷で残ります

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // 二値の面積投票を使う
  Options.ContentClass := piccPhoto;             // 写真セットにはLanczosを強制
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // 大きなスキャンのための余裕
  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;

ファイルを出荷する前にレポートを読む

TPdfImageOptimizeReportは単なるログではなく診断のために作られています。OptimizedCountSkippedCountFailedCountに加え、カーネルごとに1つのカウンタを露出し、BoxFilterCountBilinearFilterCountBicubicFilterCountLanczosFilterCountBilevelFilterCountが、適応規則がコーパスについて実際に何を結論したかを教えます。すべてボックスの結果は縮小が急だったか内容がラインアートと分類されたことを意味し、ラインアートだと思っていた文書ですべてLanczosなら、ContentClassを明示的に設定すべき合図です。AverageDetailScorePreferredQualityを調整するときに0.08のLanczos閾値と比較すべき数で、PeakWorkingBytesは実行がMaxWorkingBytesのどれだけを実際に必要としたかを示します。無効なオプションは静かにではなく大音量で失敗します。正でないTargetDpi、1未満のMinDpiRatio、0から1の範囲外のPreferredQuality、正でないMaxWorkingBytesは、ページに触れる前にEPdfErrorを投げます。そしてOptimizeImagesが編集するのはメモリ内の文書だけです。各変更ページはFPDFPage_GenerateContentでコミットされ、その後もSaveAsは自分で呼びます。何が変わったか目で確認するには、PDFページのJPEG画像への変換のとおりに前後の文書をビットマップへレンダリングし、フルズームで比較します

適応リサンプリングは、動いているときには見えず、動いていないときにはサポートチケットを生む種類の機能です。だからこそ測定、アルファ処理、メモリ予算は3つの別々の改善としてではなく、一緒に着地しなければなりませんでした。Delphi、C++Builder、Lazarus製品向けに評価しているなら、完全なAPIサーフェスとライセンス詳細はPDFiumPas Delphi PDFium Componentのページにあります