Article technique

Rééchantillonnage adaptatif d'images PDF en Delphi

Deux plaintes arrivent la semaine où une fonctionnalité de compression sort : le contrat scanné présente désormais des lettres en escalier et duveteuses, et le logo transparent de la page de couverture se trouve dans un halo pâle. PDFiumPas répond aux deux au même endroit. TPdf.OptimizeImages mesure chaque image avant de la réduire, puis choisit un noyau de rééchantillonnage et accumule la couleur sous forme consciente de l’alpha

Cela n’a pas toujours été vrai. Avant la v3.100.0 la même méthode sous-échantillonnait chaque image non binaire avec une étape fixe au plus proche voisin, qui est exactement l’algorithme produisant les deux plaintes : il échantillonne par point un pixel source par pixel de sortie, et il traite le RGB situé sous un pixel entièrement transparent comme si un lecteur allait le voir. La réécriture de la v3.100.0 remplace ce chemin unique par cinq noyaux, une règle de sélection mesurée et un budget explicite de mémoire de travail

Pourquoi le sous-échantillonnage rend-il le texte scanné irrégulier ?

Parce que l’échantillonnage par point répond à la mauvaise question. Quand un scan de 300 DPI est re-ciblé à 150 DPI, chaque pixel de destination représente un bloc deux par deux de pixels source, et le plus proche voisin en garde un des quatre et jette le reste. Celui qui survit dépend de l’arrondi, si bien qu’un bord de trait lissé en anticrénelage dans la source devient un pile ou face par pixel. Le résultat est l’escalier crénelé classique le long des bords de glyphes, plus du moirage sur les zones de demi-teinte où les échantillons jetés portaient par hasard le motif. Cela importe plus dans un PDF qu’à l’écran parce que le dégât est permanent. Un XObject image porte ses données d’échantillon à côté de /Width, /Height et /BitsPerComponent (ISO 32000-1 §8.9.5), et le rééchantillonnage réécrit les trois à l’intérieur du fichier. Un mauvais zoom dans un visionneur est une image que vous pouvez redessiner, et PDFiumPas a une machinerie séparée pour cela dans cache de rendu et performances de zoom. Un mauvais sous-échantillonnage est un nouveau document que vous remettez au client

Pourquoi le sous-échantillonnage au plus proche voisin ruine le texte scanné dans PDFiumPas pour Delphi : chaque pixel de sortie garde un des quatre pixels source et jette le reste, produisant des bords de glyphes crénelés et du moirage, ce que les cinq noyaux de rééchantillonnage remplacent
L’échantillonnage par point garde un pixel source par pixel de sortie et jette les trois autres, voilà pourquoi PDFiumPas offre désormais cinq noyaux au lieu d’un

Comment PDFiumPas mesure le détail et choisit un noyau

PDFiumPas décide par image, pas par document. Avant de choisir un noyau il calcule un score de détail de luminance normalisé depuis une grille d’échantillonnage bornée : les pas horizontal et vertical sont (Width + 63) div 64 et (Height + 63) div 64, si bien qu’un scan de 12000 pixels et une vignette de 300 pixels coûtent tous deux à peu près le même balayage de 64 par 64. À chaque position échantillonnée il somme la différence absolue vers le voisin de droite et le voisin du dessous, sur jusqu’à trois canaux, puis divise par le nombre d’échantillons fois 255. Le score atterrit entre 0 et 1, où les graphiques d’affaires plats se tiennent près de zéro et la texture photographique dense grimpe

L’échelle de sélection tourne ensuite dans un ordre fixe. Si ResampleFilter est autre chose que pirfAdaptive, ce filtre est utilisé verbatim. Sinon : le contenu 1-bit prend pirfBilevel ; un ContentClass de piccLineArt prend pirfBox ; un facteur d’échelle de 4 ou plus prend pirfBox aussi, car à cette réduction une moyenne de surface est à la fois la réponse la moins chère et la plus correcte ; piccPhoto, un score de détail de 0.08 ou plus, ou un PreferredQuality de 0.9 ou plus prend pirfLanczos avec son noyau à trois lobes ; une échelle de 2 ou plus ou une qualité de 0.7 ou plus prend pirfBicubic au rayon 2 ; tout ce qui reste prend pirfBilinear. Comme TPdfImageOptimizeOptions.Default règle PreferredQuality à 0.85, une exécution par défaut ne retombe jamais sur le bilinéaire sauf si la réduction est légère et le contenu plat

Comment PDFiumPas choisit un noyau de rééchantillonnage en Delphi : un balayage borné de soixante-quatre par soixante-quatre produit un score de détail normalisé, puis une échelle fixe de conditions aiguille chaque image vers le filtre bilevel, box, Lanczos, bicubique ou bilinéaire
Le score de détail coûte la même chose sur un scan de 12000 pixels que sur une vignette, et l’échelle au-dessous s’arrête à la première condition qui correspond
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // Defaults: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, quality 0.85, 64 MiB budget.
    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;

Une image n’est touchée que lorsque le plus grand de ses DPI de placement horizontal et vertical, divisé par TargetDpi, atteint MinDpiRatio. Ce garde-fou existe pour qu’une photo de 160 DPI visant une cible de 150 DPI ne soit pas ré-encodée pour un gain de six pour cent qui coûte une génération de qualité. Les images au-dessous de MinDimension sur l’un des axes, 8 par défaut, sont sautées comme icônes ou filets

Pourquoi les logos transparents prennent-ils une frange blanche ?

Parce que la couleur sous un pixel entièrement transparent est arbitraire, et qu’une moyenne pondérée ordinaire la laisse voter. Exportez un logo depuis un outil de design et la marge invisible est fréquemment blanche, ou noire, ou ce que le canevas était ; le canal alpha la cache, et une somme droite sur l’emprise du noyau la mélange aussitôt dans le bord visible. PDFiumPas évite cela en accumulant les échantillons BGRA sous forme prémultipliée et en défaisant la prémultiplication seulement au pixel de destination

Concrètement, chaque échantillon contributeur ajoute channel * alpha * weight à l’accumulateur de couleur, alpha * weight à un accumulateur d’alpha, et weight à la somme des poids. La couleur de destination est ensuite divisée par l’accumulateur d’alpha plutôt que par la somme des poids, et c’est l’étape qui compte : diviser par la somme des poids tirerait la couleur vers les pixels invisibles, tandis que diviser par l’alpha accumulé reconstruit la couleur sur laquelle les échantillons visibles se sont réellement accordés. L’alpha de destination est une quantité séparée, 255 * AlphaSum / WeightSum. Les formats sans alpha divisent par la somme des poids comme d’habitude, l’octet de remplissage d’une destination FPDFBitmap_BGRx est écrit comme un 255 constant, et chaque canal est borné entre 0 et 255 avant d’être stocké. Cet alpha provient normalement d’une entrée de masque doux dans le dictionnaire d’image (ISO 32000-1 §11.4), que PDFium a déjà composée dans le tampon BGRA que le rééchantillonneur reçoit

Comment PDFiumPas retire le halo blanc des images PDF transparentes en Delphi : les échantillons sont accumulés sous forme prémultipliée, et la couleur de destination est divisée par l’alpha accumulé au lieu de la somme des poids pour que les pixels invisibles ne puissent pas voter
Diviser la couleur prémultipliée par l’alpha accumulé reconstruit sur quoi les échantillons visibles se sont accordés, tandis que diviser par la somme des poids tire le bord vers les pixels invisibles
// Shape of the inner accumulation loop, per contributing source sample
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;

// ... and at the destination pixel, unpremultiply against the alpha sum
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;

Maintenir le line art 1-bit hors de la zone grise

Tout noyau continu appliqué à un scan binaire produit du gris, et le gris est précisément ce qu’une image de type fax n’a pas le droit de contenir. PDFiumPas laisse donc les images 1-bit tranquilles par défaut : PreserveBilevel est True dans TPdfImageOptimizeOptions.Default, et de telles images atterrissent dans SkippedCount sans être touchées. Réglez-le à False et le chemin pirfBilevel prend le relais au lieu d’un noyau de lissage. Il parcourt le rectangle source exact couvrant chaque pixel de destination, moyenne la luminance avec les poids 0.114, 0.587 et 0.299 dans l’ordre mémoire BGR, et seuille le résultat à 127.5 en un plat 0 ou 255. Rien d’intermédiaire ne peut être écrit, si bien que les bords restent nets et qu’aucun halo gris ne se forme autour des traits fins ; le canal alpha d’une source BGRA est moyenné normalement, et une destination BGRx reçoit le 255 constant. Si vous avez besoin des pixels sous-jacents plutôt que d’un document plus petit, extraire des images de documents PDF est le chemin séparé

Que se passe-t-il quand une image dépasse le budget de mémoire de travail ?

Elle est laissée exactement comme elle était, et elle est comptée. MaxWorkingBytes vaut 64 MiB par défaut et est appliqué deux fois. Avant que le bitmap de destination ne soit créé, PDFiumPas rejette l’image si largeur fois hauteur fois octets par pixel dépasse le budget. Après que FPDFBitmap_CreateEx a réussi il vérifie encore avec le stride réel fois la hauteur, parce que le remplissage de ligne peut pousser une allocation au-delà d’une limite que le produit naïf laissait passer. L’un ou l’autre rejet détruit la destination et ne renvoie rien. Soyez clair sur la dégradation que cela implique : une image hors budget n’est pas rééchantillonnée à une qualité inférieure, et elle n’est pas découpée en tuiles. L’originale reste dans le document, BudgetExceededCount et SkippedCount augmentent tous deux, et une exécution peut donc rapporter succès alors qu’un document n’est qu’en partie optimisé. C’est un comportement fail-safe délibéré, mais cela signifie que le rapport n’est pas une lecture optionnelle. Un mode d’échec distinct existe aussi : les images dont le bitmap ne peut pas être produit du tout par PDFium, telles que CMYK, JPX, JBIG2 ou des sources masquées, augmentent FailedCount au lieu, et sont de même laissées intactes

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // use the bilevel area vote
  Options.ContentClass := piccPhoto;             // force Lanczos for photo sets
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // headroom for large scans
  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;

Lire le rapport avant de livrer le fichier

TPdfImageOptimizeReport est construit pour être diagnostiqué, pas simplement journalisé. À côté de OptimizedCount, SkippedCount et FailedCount il expose un compteur par noyau, si bien que BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount et BilevelFilterCount vous disent ce que la règle adaptative a réellement conclu sur votre corpus. Un résultat tout box signifie que les réductions étaient fortes ou que le contenu a été classé line art ; un résultat tout Lanczos sur un document que vous croyiez être du line art signale que ContentClass devrait être réglé explicitement. AverageDetailScore est le nombre à comparer au seuil Lanczos de 0.08 quand vous ajustez PreferredQuality, et PeakWorkingBytes montre combien de MaxWorkingBytes l’exécution a réellement besoin. Les options invalides échouent bruyamment plutôt qu’en silence : un TargetDpi non positif, un MinDpiRatio au-dessous de 1, un PreferredQuality hors de 0 à 1, ou un MaxWorkingBytes non positif lève EPdfError avant que toute page soit touchée. Et OptimizeImages ne modifie que le document en mémoire ; chaque page modifiée est validée avec FPDFPage_GenerateContent, après quoi vous appelez encore SaveAs vous-même. Pour voir à l’œil ce qui a changé, rendez les documents avant et après en bitmaps comme décrit dans convertir des pages PDF en images JPEG et comparez-les au zoom complet

Le rééchantillonnage adaptatif est une de ces fonctionnalités invisibles quand elle marche et génératrices de tickets de support quand elle ne marche pas, voilà pourquoi la mesure, la gestion d’alpha et le budget mémoire ont dû atterrir ensemble plutôt que comme trois raffinements séparés. Si vous évaluez ceci pour un produit Delphi, C++Builder ou Lazarus, la surface API complète et les détails de licence sont sur la page PDFiumPas Delphi PDFium component