Comparação de otimizadores de PNG no Node.js
Escolha OptiPNG ou PNGs sem paleta no Sharp quando os pixels decodificados precisarem permanecer inalterados. Experimente pngquant ou a saída com paleta do Sharp quando puder trocar um pouco de precisão de cores por menos bytes. Este guia executa as quatro configurações na mesma entrada e registra tamanho, tempo decorrido e diferenças entre pixels para que você possa fazer essa escolha com suas próprias imagens.
Visão geral das ferramentas
pngquant
pngquant converte imagens para uma paleta com até 256 cores, incluindo transparência. Seu intervalo de qualidade pode recusar uma conversão. Um PNG com paleta ainda usa compressão PNG sem perdas, mas a escolha das cores pode alterar os pixels de origem.
OptiPNG
OptiPNG busca configurações de compressão e
pode reduzir a profundidade de bits ou o tipo de cor sem alterar a imagem decodificada. Aqui usamos
-o7, que testa mais combinações de compressão do que os níveis inferiores.
A otimização pode deixar uma imagem já compacta inalterada.
Sharp
As opções de PNG do Sharp permitem gerar saídas sem paleta e
com paleta. compressionLevel controla o esforço de compressão sem quantização;
adaptiveFiltering habilita a filtragem de linhas. Definir quality ou effort habilita uma paleta, então omita ambos na comparação sem paleta.
Uma qualidade de 80 no Sharp e 65-80 no pngquant são configurações de
codificadores diferentes, não medidas equivalentes de qualidade.
Instalação
Use Bash, Node.js 24.15.0, Corepack com Yarn 4.12.0 e OptiPNG 7.9.1 no seu
PATH. Instale o OptiPNG a partir dos
downloads oficiais; este exemplo chama esse executável em vez
de um wrapper npm. Os comandos foram testados no Linux. Outras plataformas podem ter binários
nativos e tempos de execução diferentes.
Crie um projeto a partir de um diretório em que png-compare ainda não exista:
(
mkdir png-compare &&
cd png-compare &&
printf '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}\n' > package.json &&
touch yarn.lock &&
YARN_ENABLE_GLOBAL_CACHE=false YARN_ENABLE_SCRIPTS=true YARN_NODE_LINKER=node-modules \
corepack yarn add --exact sharp@0.35.3 pngquant-bin@9.0.0 pngjs@7.0.0
)
O subshell mantém seu diretório atual intacto, e && interrompe a
configuração se uma etapa falhar. O arquivo de lock vazio torna este um projeto Yarn separado, mesmo
dentro de outro workspace. As configurações de ambiente fornecem dependências locais e habilitam a
instalação nativa do pngquant. Mantenha o arquivo de lock resultante para uma resolução reproduzível
das dependências. Execute os comandos restantes a partir de png-compare.
Salve os programas abaixo com suas extensões .mts. O Node
executa arquivos .mts como módulos ES
sem um compilador ou um tsconfig.json herdado. pngjs
fornece o decodificador PNG separado usado nas verificações.
Comparar um PNG
Salve este código como compare.mts. Use PNGs estáticos RGB ou RGBA de 8 bits.
O script rejeita animações, entradas de 16 bits e blocos ICC, sRGB, gama, cromaticidade ou Exif.
A verificação compara as amostras armazenadas e o alfa; ela não comprova a aparência com gerenciamento
de cores nem a preservação de metadados. Mantenha seus originais.
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { PNG } from 'pngjs'
import pngquant from 'pngquant-bin'
import sharp from 'sharp'
const execFilePromise = promisify(execFile)
type Pixels = { width: number; height: number; data: Uint8Array }
function decodePng(bytes: Buffer): Pixels {
const image = PNG.sync.read(bytes, { checkCRC: true })
// Hidden RGB is not rendered, and decoders represent tRNS keys differently.
for (let index = 0; index < image.data.length; index += 4) {
if (image.data[index + 3] === 0) image.data.fill(0, index, index + 3)
}
return image
}
function checkInput(bytes: Buffer): void {
if (bytes[24] !== 8 || ![2, 6].includes(bytes[25])) {
throw new Error('Use an 8-bit RGB or RGBA PNG')
}
for (let offset = 8; offset + 12 <= bytes.length;) {
const length = bytes.readUInt32BE(offset)
const type = bytes.toString('ascii', offset + 4, offset + 8)
if (['acTL', 'iCCP', 'gAMA', 'cHRM', 'sRGB', 'eXIf'].includes(type)) {
throw new Error(`Unsupported input chunk: ${type}; use an unprofiled static PNG`)
}
offset += length + 12
}
}
function pixelMetrics(before: Pixels, after: Pixels) {
if (before.width !== after.width || before.height !== after.height) {
throw new Error('Output dimensions differ from the input')
}
let changedPixels = 0
let squaredError = 0
let maxAlphaError = 0
for (let index = 0; index < before.data.length; index += 4) {
const alphaBefore = before.data[index + 3] / 255
const alphaAfter = after.data[index + 3] / 255
maxAlphaError = Math.max(maxAlphaError, Math.abs(alphaBefore - alphaAfter) * 255)
if (before.data.subarray(index, index + 4).some((value, channel) =>
value !== after.data[index + channel])) changedPixels++
for (const background of [0, 255]) {
for (let channel = 0; channel < 3; channel++) {
const a = before.data[index + channel] * alphaBefore + background * (1 - alphaBefore)
const b = after.data[index + channel] * alphaAfter + background * (1 - alphaAfter)
squaredError += (a - b) ** 2
}
}
}
return {
changedPixels,
rmse: Number(Math.sqrt(squaredError / (before.width * before.height * 6)).toFixed(3)),
maxAlphaError: Math.round(maxAlphaError),
}
}
async function optimizeWithPngquant(input: string, output: string, quality: string) {
try {
await execFilePromise(pngquant, [
`--quality=${quality}`, '--strip', '--speed=3', '--output', output, '--', input,
])
return output
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 99) return null
throw new Error('pngquant failed', { cause: error })
}
}
async function optimizeWithOptipng(input: string, output: string) {
await execFilePromise('optipng', ['-o7', '-strip', 'all', '-out', output, '--', input])
return output
}
async function optimizeWithSharp(input: string, output: string, palette: boolean) {
await sharp(input)
.png(palette
? { compressionLevel: 9, adaptiveFiltering: true, palette: true, quality: 80, effort: 10 }
: { compressionLevel: 9, adaptiveFiltering: true, palette: false })
.toFile(output)
return output
}
async function main(): Promise<void> {
const [inputArgument, directoryArgument, quality = '65-80', ...extra] = process.argv.slice(2)
const range = /^(\d{1,3})-(\d{1,3})$/.exec(quality)
if (!inputArgument || !directoryArgument || extra.length || !range ||
Number(range[1]) > Number(range[2]) || Number(range[2]) > 100) {
throw new Error('Usage: node compare.mts INPUT.png NEW_DIRECTORY [MIN-MAX]')
}
const input = resolve(inputArgument)
const directory = resolve(directoryArgument)
const original = await readFile(input)
const before = decodePng(original)
checkInput(original)
const versions = {
node: process.version,
sharp: sharp.versions.sharp,
libvips: sharp.versions.vips,
pngquant: (await execFilePromise(pngquant, ['--version'])).stdout.trim(),
optipng: (await execFilePromise('optipng', ['-version'])).stdout.trim().split('\n')[0],
}
await mkdir(directory)
const jobs = [
{ name: 'pngquant', lossless: false, settings: `quality=${quality}, speed=3`, run:
(output: string) => optimizeWithPngquant(input, output, quality) },
{ name: 'optipng', lossless: true, settings: '-o7 -strip all', run:
(output: string) => optimizeWithOptipng(input, output) },
{ name: 'sharp-full', lossless: true, settings: 'compressionLevel=9, adaptiveFiltering=true, palette=false', run:
(output: string) => optimizeWithSharp(input, output, false) },
{ name: 'sharp-palette', lossless: false, settings: 'quality=80, effort=10, compressionLevel=9, adaptiveFiltering=true', run:
(output: string) => optimizeWithSharp(input, output, true) },
]
const results = []
for (const job of jobs) {
const start = performance.now()
const file = await job.run(join(directory, `${job.name}.png`))
const ms = Number((performance.now() - start).toFixed(1))
if (file === null) {
results.push({ name: job.name, status: 'refused', settings: job.settings, ms })
continue
}
const encoded = await readFile(file)
const metrics = pixelMetrics(before, decodePng(encoded))
if (job.lossless && metrics.changedPixels !== 0) {
throw new Error(`${job.name}: decoded pixels changed`)
}
results.push({ name: job.name, status: 'encoded', file, settings: job.settings,
bytes: encoded.length, ms, ...metrics })
}
const report = {
input: { file: input, bytes: original.length, width: before.width, height: before.height,
sha256: createHash('sha256').update(original).digest('hex') },
versions,
results,
}
const reportFile = join(directory, 'results.json')
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' })
console.table(results)
console.log(`Report: ${reportFile}`)
}
main().catch((error: unknown) => {
console.error(`Comparison failed for ${process.argv[2] ?? '(no input)'}:`,
error instanceof Error ? error.message : error)
process.exitCode = 1
})
Todos os resultados vão para um diretório recém-criado; um diretório existente é recusado sem
substituir seus arquivos. Uma entrada inválida ou um pré-requisito ausente causa uma falha antes da
criação do diretório. Uma falha posterior no codificador ou na verificação pode deixar PNGs parciais,
mas nenhum results.json concluído. Só consuma um relatório depois que o comando
terminar com sucesso.
O código de saída 99 do pngquant se torna uma linha refused sem arquivo
candidato. Ele significa que a conversão foi recusada na configuração de qualidade solicitada, não
que a decodificação do original falhou. Em particular, uma recusa não fornece uma pontuação medida
para a saída. Outros erros fazem a comparação falhar.
changedPixels conta os pixels RGBA diferentes após ignorar o RGB oculto sob alfa
zero. Ele deve ser zero nas linhas sem perdas. rmse mede o erro RGB após a
composição sobre fundos preto e branco, em uma escala de amostras de 0 a 255;
maxAlphaError informa a maior diferença de alfa. Essas verificações são úteis,
mas um erro médio baixo pode ocultar uma borda ruim ou faixas de cor. Inspecione as imagens com
paleta no tamanho de exibição pretendido, sobre ambos os fundos, antes de aceitá-las.
Comparação do tamanho dos arquivos
Salve este código como make-fixtures.mts. Ele cria três PNGs de 256 × 256: um degradê
suave, um desenho de linhas com quatro cores e faixas coloridas com pixels transparentes,
semitransparentes e opacos. A codificação de entrada usa um nível baixo de compressão para que haja
margem para recomprimir a imagem na comparação.
import { mkdir, writeFile } from 'node:fs/promises'
import { PNG } from 'pngjs'
async function main(): Promise<void> {
await mkdir('fixtures')
const size = 256
for (const name of ['gradient', 'line-art', 'alpha']) {
const data = Buffer.alloc(size * size * 4)
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const index = (y * size + x) * 4
const line = x % 32 < 2 || y % 32 < 2
const color = x < 128 ? [30, 110, 220] : [230, 70, 40]
const rgb = name === 'gradient' ? [x, y, Math.floor((x + y) / 2)]
: name === 'line-art' ? (line ? [0, 0, 0] : (y < 128 ? color : [255, 255, 255]))
: color
data.set(rgb, index)
data[index + 3] = name === 'alpha' ? (x < 64 ? 0 : x < 192 ? 128 : 255) : 255
}
}
const bytes = PNG.sync.write({ width: size, height: size, data }, { deflateLevel: 1 })
await writeFile(`fixtures/${name}.png`, bytes, { flag: 'wx' })
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
Depois de salvar os dois programas, execute:
node make-fixtures.mts &&
node compare.mts fixtures/gradient.png results-gradient &&
node compare.mts fixtures/line-art.png results-line-art &&
node compare.mts fixtures/alpha.png results-alpha
Cada comparação bem-sucedida imprime uma tabela e o caminho para seu
results.json, junto com as saídas PNG nomeadas. O gerador recusa um diretório
fixtures existente. Para testar outra imagem ou configuração, reutilize
compare.mts com um novo diretório de resultados. Por exemplo, este comando
solicita a configuração de qualidade mais rigorosa do pngquant sem alterar as outras configurações:
node compare.mts fixtures/gradient.png results-strict 100-100
Uma execução no Linux x86-64 em 3 de outubro de 2026 usou Node.js 24.15.0, pngquant-bin 9.0.0 fornecendo pngquant 3.0.3, OptiPNG 7.9.1, Sharp 0.35.3, libvips 8.18.3 e pngjs 7.0.0. O pngquant usou duas threads OpenMP. Para a entrada de degradê de 29.234 B, as configurações do programa produziram:
| Configuração | Bytes | Tempo (ms) | Pixels alterados | RMSE |
|---|---|---|---|---|
| pngquant | 18.681 | 32,1 | 65.317 | 6,081 |
| OptiPNG | 679 | 182,6 | 0 | 0 |
| Sharp sem paleta | 830 | 6,2 | 0 | 0 |
| Sharp com paleta | 36.104 | 241,3 | 65.314 | 5,164 |
Os valores regulares dos pixels desse degradê são bem comprimidos sem uma paleta. A quantização alterou as cores e produziu arquivos maiores do que as configurações sem perdas; a saída com paleta do Sharp também ficou maior que a entrada. Na imagem de teste com desenho de linhas, todas as configurações preservaram os pixels decodificados, com o OptiPNG produzindo 195 B e o pngquant, 198 B. As quatro também preservaram as cores renderizadas da imagem de teste de alfa e seus valores de alfa de 0, 128 e 255. Essas pequenas entradas sintéticas revelam comportamentos diferentes; elas não representam uma coleção de recursos de produção.
Os tempos abrangem uma chamada de codificador por linha, executada sequencialmente, incluindo a inicialização do subprocesso e a gravação do arquivo. Eles excluem a configuração e a verificação independente de pixels. São medições locais, não um benchmark de vazão; repita com recursos representativos e registre as versões e configurações no relatório.
Escolher a ferramenta certa
Para recursos que exigem pixels exatos, como diagramas, compare o OptiPNG com
sharp-full e rejeite qualquer changedPixels diferente de zero.
Uma paleta também pode codificar um pequeno conjunto de cores com exatidão, mas verifique os pixels
reais em vez de supor que toda conversão para paleta perde detalhes. Para degradês e bordas
translúcidas, compare a economia de bytes com as colunas de erro e sua inspeção visual.
Mantenha o original quando um candidato for maior, for recusado ou tiver qualidade visivelmente inferior. Use o Sharp quando o redimensionamento ou outro processamento já fizer parte do mesmo pipeline Node.js; use o OptiPNG quando quiser recomprimir PNGs existentes com uma verificação de preservação de pixels.
