Artigo Técnico

Reamostragem adaptativa de imagens PDF com PDFiumPas

Duas queixas chegam na semana seguinte ao lançamento de uma funcionalidade de compressão: o contrato digitalizado ficou com glifos em degraus e de contornos difusos, e o logótipo transparente da capa passou a viver dentro de um halo pálido. O PDFiumPas responde a ambas num só sítio. O TPdf.OptimizeImages mede cada imagem antes de a reduzir, escolhe de seguida um kernel de reamostragem e acumula a cor numa forma consciente do alfa

Nem sempre foi assim. Antes da v3.100.0, o mesmo método reduzia todas as imagens não bilevel com um passo fixo de vizinho mais próximo, exatamente o algoritmo que produz as duas queixas: faz amostragem pontual de um pixel de origem por pixel de saída, e trata o RGB que está por baixo de um pixel totalmente transparente como se algum leitor o visse algum dia. A reescrita na v3.100.0 substitui esse caminho único por cinco kernels, uma regra de seleção baseada em medições e um orçamento explícito de memória de trabalho

Porque é que a redução de resolução deixa o texto digitalizado serrilhado?

Porque a amostragem pontual responde à pergunta errada. Quando os 300 DPI de uma digitalização são reajustados para 150 DPI, cada pixel de destino representa um bloco dois por dois de pixels de origem, e o vizinho mais próximo guarda um dos quatro e descarta o resto. Qual sobrevive depende do arredondamento, pelo que um contorno de traço suavizado na origem se torna numa moeda ao ar por pixel. O resultado é a clássica escada com aliasing ao longo dos contornos dos glifos, mais moiré nas regiões de retícula em que as amostras descartadas por acaso transportavam o padrão. Isto importa mais num PDF do que no ecrã porque o dano é permanente. Um XObject de imagem transporta os seus dados de amostra ao lado de /Width, /Height e /BitsPerComponent (ISO 32000-1 §8.9.5), e a reamostragem reescreve os três dentro do ficheiro. Um zoom mau num visualizador é uma moldura que se pode redesenhar, e o PDFiumPas tem maquinaria separada para isso na cache de renderização e desempenho de zoom. Uma redução mal feita é um documento novo que entrega ao cliente

Porque o downsampling por vizinho mais próximo arruína o texto digitalizado no PDFiumPas para Delphi: cada pixel de saída guarda um de quatro pixels de origem e descarta o resto, produzindo contornos de glifos com aliasing e moiré, o que os cinco kernels de reamostragem substituem
A amostragem pontual guarda um pixel de origem por pixel de saída e deita os outros três fora, e é por isso que o PDFiumPas agora oferece cinco kernels em vez de um

Como o PDFiumPas mede o detalhe e escolhe um kernel

O PDFiumPas decide por imagem, não por documento. Antes de escolher um kernel calcula uma pontuação de detalhe de luminância normalizada a partir de uma grelha de amostragem limitada: os passos horizontal e vertical são (Width + 63) div 64 e (Height + 63) div 64, pelo que uma digitalização de 12000 pixels e uma miniatura de 300 pixels custam aproximadamente a mesma varredura de 64 por 64. Em cada posição amostrada soma a diferença absoluta para o vizinho à direita e o vizinho abaixo, em até três canais, e depois divide pela contagem de amostras vezes 255. A pontuação fica entre 0 e 1, onde gráficos empresariais planos ficam perto de zero e texturas fotográficas densas sobem

A escada de seleção corre depois numa ordem fixa. Se ResampleFilter for outra coisa qualquer além de pirfAdaptive, esse filtro é usado tal como está. Caso contrário: conteúdo de 1 bit leva pirfBilevel; um ContentClass de piccLineArt leva pirfBox; um fator de escala de 4 ou mais também leva pirfBox, porque nessa redução uma média de área é ao mesmo tempo a resposta mais barata e a mais correta; piccPhoto, uma pontuação de detalhe de 0.08 ou superior, ou um PreferredQuality de 0.9 ou superior levam pirfLanczos com o seu kernel de três lóbulos; uma escala de 2 ou mais ou uma qualidade de 0.7 ou superior levam pirfBicubic a raio 2; tudo o que sobra leva pirfBilinear. Como TPdfImageOptimizeOptions.Default define PreferredQuality como 0.85, uma execução predefinida nunca recai no bilinear, a menos que a redução seja suave e o conteúdo plano

Como o PDFiumPas escolhe um kernel de reamostragem em Delphi: uma varredura limitada de sessenta e quatro por sessenta e quatro produz uma pontuação de detalhe normalizada, depois uma escada fixa de condições encaminha cada imagem para o filtro bilevel, box, Lanczos, bicubic ou bilinear
A pontuação de detalhe custa o mesmo numa digitalização de 12000 pixels que numa miniatura, e a escada por baixo dela para na primeira condição que corresponde
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // Predefinições: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, qualidade 0.85, orçamento 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;

Uma imagem só é tocada quando o maior dos DPI de colocação horizontal e vertical, dividido por TargetDpi, alcança MinDpiRatio. Essa salvaguarda existe para que uma foto de 160 DPI destinada a um alvo de 150 DPI não seja recodificada por um ganho de seis por cento que custa uma geração de qualidade. Imagens abaixo de MinDimension em qualquer um dos eixos, 8 por predefinição, são ignoradas como ícones ou filetes

Porque é que logótipos transparentes apanham uma franja branca?

Porque a cor por baixo de um pixel totalmente transparente é arbitrária, e uma média ponderada simples deixa-a votar. Exporte um logótipo de uma ferramenta de desenho e a margem invisível é frequentemente branca, ou preta, ou o que quer que fosse a área de desenho; o canal alfa esconde-a, e uma soma direta sobre a pegada do kernel mistura-a de volta para a borda visível em pouco tempo. O PDFiumPas evita isto acumulando amostras BGRA em forma premultiplicada e desfazendo a premultiplicação apenas no pixel de destino

Concretamente, cada amostra contribuinte adiciona channel * alpha * weight ao acumulador de cor, alpha * weight a um acumulador de alfa, e weight à soma de pesos. A cor de destino é depois dividida pelo acumulador de alfa em vez de pela soma de pesos, e é esse o passo que importa: dividir pela soma de pesos arrastaria a cor na direção dos pixels invisíveis, enquanto dividir pelo alfa acumulado reconstrói a cor em que as amostras visíveis realmente convergiram. O alfa de destino é uma grandeza separada, 255 * AlphaSum / WeightSum. Os formatos sem alfa dividem pela soma de pesos como de costume, o byte de preenchimento de um destino FPDFBitmap_BGRx é escrito como 255 constante, e cada canal é limitado a 0 a 255 antes de ser armazenado. Esse alfa normalmente provém de uma entrada de soft mask no dicionário da imagem (ISO 32000-1 §11.4), que o PDFium já compôs no buffer BGRA que o reamostrador recebe

Como o PDFiumPas remove o halo branco de imagens PDF transparentes em Delphi: as amostras são acumuladas em forma premultiplicada, e a cor de destino é dividida pelo alfa acumulado em vez da soma de pesos, para que os pixels invisíveis não possam votar
Dividir a cor premultiplicada pelo alfa acumulado reconstrói aquilo em que as amostras visíveis convergiram, enquanto dividir pela soma de pesos arrasta a borda na direção dos pixels invisíveis
// Forma do ciclo interno de acumulação, por amostra de origem contribuinte
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;

// ... e no pixel de destino, desfazer a premultiplicação contra a soma de alfa
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;

Manter o line art de 1 bit fora da zona cinzenta

Qualquer kernel contínuo aplicado a uma digitalização bilevel produz cinzento, e cinzento é precisamente aquilo que uma imagem ao estilo de fax não pode conter. O PDFiumPas por isso deixa as imagens de 1 bit em paz por predefinição: PreserveBilevel é True em TPdfImageOptimizeOptions.Default, e essas imagens caem em SkippedCount intocadas. Defina-o como False e o caminho pirfBilevel assume o lugar de um kernel de suavização. Ele percorre o retângulo de origem exato que cobre cada pixel de destino, faz a média da luminância com os pesos 0.114, 0.587 e 0.299 pela ordem de memória BGR, e aplica um limiar de 127.5 ao resultado, tornando-o num 0 ou 255 planos. Nada intermédio pode ser escrito, pelo que os contornos permanecem nítidos e nenhum halo cinzento se forma à volta dos traços finos; o canal alfa de uma origem BGRA é sujeito à média normalmente, e um destino BGRx recebe o 255 constante. Se precisa dos pixels subjacentes e não de um documento mais pequeno, extrair imagens de documentos PDF é o caminho separado

O que acontece quando uma imagem excede o orçamento de memória de trabalho?

É deixada exatamente como estava, e é contada. MaxWorkingBytes vale 64 MiB por predefinição e é imposto duas vezes. Antes de o bitmap de destino ser criado, o PDFiumPas rejeita a imagem se largura vezes altura vezes bytes por pixel exceder o orçamento. Depois de FPDFBitmap_CreateEx ter sucesso verifica outra vez usando o stride real vezes a altura, porque o preenchimento de linha pode empurrar uma alocação para lá de um limite que o produto ingénuo respeitava. Qualquer das rejeições destrói o destino e não devolve nada. Seja claro quanto à degradação que isto implica: uma imagem acima do orçamento não é reamostrada a uma qualidade inferior, e não é dividida em mosaicos. A original fica no documento, BudgetExceededCount e SkippedCount ambos aumentam, e uma execução pode por isso comunicar sucesso enquanto o documento está apenas parcialmente otimizado. Esse é um comportamento fail-safe deliberado, mas significa que o relatório não é uma leitura opcional. Um modo de falha distinto também existe: imagens cujo bitmap o PDFium não consegue produzir de todo, como origens CMYK, JPX, JBIG2 ou mascaradas, aumentam antes FailedCount e são igualmente deixadas intocadas

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // usar a votação de área bilevel
  Options.ContentClass := piccPhoto;             // forçar Lanczos para conjuntos de fotos
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // folga para digitalizações grandes
  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;

Ler o relatório antes de enviar o ficheiro

O TPdfImageOptimizeReport foi construído para ser diagnosticado, não apenas registado. Ao lado de OptimizedCount, SkippedCount e FailedCount expõe um contador por kernel, pelo que BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount e BilevelFilterCount lhe dizem o que a regra adaptativa realmente concluiu sobre o seu corpus. Um resultado todo box significa que as reduções foram íngremes ou o conteúdo foi classificado como line art; um resultado todo Lanczos num documento que acreditava ser line art é um sinal de que ContentClass devia ser definido explicitamente. O AverageDetailScore é o número a comparar com o limiar Lanczos de 0.08 ao afinar o PreferredQuality, e o PeakWorkingBytes mostra quanta parte do MaxWorkingBytes a execução realmente precisou. Opções inválidas falham de forma ruidosa em vez de silenciosa: um TargetDpi não positivo, um MinDpiRatio abaixo de 1, um PreferredQuality fora de 0 a 1, ou um MaxWorkingBytes não positivo lançam EPdfError antes de qualquer página ser tocada. E o OptimizeImages edita apenas o documento em memória; cada página modificada é submetida com FPDFPage_GenerateContent, depois do qual o SaveAs ainda é da sua responsabilidade. Para ver a olho nu o que mudou, renderize os documentos antes e depois para bitmaps como descrito em converter páginas PDF em imagens JPEG e compare-os a zoom total

A reamostragem adaptativa é uma daquelas funcionalidades que são invisíveis quando funcionam e geram tickets de suporte quando não funcionam, e é por isso que a medição, o tratamento do alfa e o orçamento de memória tiveram de chegar juntos em vez de três refinamentos separados. Se está a avaliar isto para um produto Delphi, C++Builder ou Lazarus, a superfície completa da API e os detalhes de licenciamento estão na página do componente PDFium PDFiumPas para Delphi