Comparaison d’optimiseurs PNG dans Node.js
Choisissez OptiPNG ou un PNG Sharp en vraies couleurs lorsque les pixels décodés doivent rester inchangés. Essayez pngquant ou la sortie avec palette de Sharp si vous pouvez sacrifier un peu de précision des couleurs pour réduire le nombre d’octets. Ce guide exécute les quatre configurations sur la même entrée et relève la taille, le temps écoulé et les différences entre pixels pour vous permettre de faire ce choix avec vos propres images.
Présentation des outils
pngquant
pngquant convertit les images en une palette contenant jusqu’à 256 couleurs, avec prise en charge de la transparence. Sa plage de qualité peut entraîner le refus d’une conversion. Un PNG avec palette utilise toujours une compression PNG sans perte, mais le choix de ses couleurs peut modifier les pixels d’origine.
OptiPNG
OptiPNG explore les réglages de compression et peut réduire la
profondeur en bits ou simplifier le type de couleur sans modifier l’image décodée. Nous utilisons ici
-o7, qui essaie davantage de combinaisons de compression que les niveaux
inférieurs. L’optimisation peut laisser inchangée une image déjà compacte.
Sharp
Les options PNG de Sharp permettent une sortie en vraies couleurs
ou avec palette. compressionLevel contrôle l’effort de compression sans quantification ;
adaptiveFiltering active le filtrage des lignes. Définir quality
ou effort active une palette : omettez donc les deux pour la comparaison en
vraies couleurs. Une qualité de 80 dans Sharp et le réglage 65-80 de pngquant
sont des paramètres destinés à des encodeurs différents, et non des qualités mesurées équivalentes.
Installation
Utilisez Bash, Node.js 24.15.0 et Corepack avec Yarn 4.12.0, ainsi qu’OptiPNG 7.9.1 dans votre
PATH. Installez OptiPNG à partir des
téléchargements officiels ; cet exemple appelle cet exécutable
plutôt qu’une surcouche npm. Les commandes ont été testées sous Linux. Les binaires natifs et les
temps d’exécution peuvent différer sur d’autres plateformes.
Créez un nouveau projet depuis un répertoire où png-compare n’existe pas déjà :
(
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
)
Le sous-shell laisse votre répertoire courant intact, et && arrête la
configuration si une étape échoue. Le fichier de verrouillage vide en fait un projet Yarn distinct,
même au sein d’un autre espace de travail. Les paramètres d’environnement lui donnent des
dépendances locales et activent l’installation native de pngquant. Conservez le fichier de
verrouillage obtenu pour une résolution reproductible des dépendances. Exécutez les commandes
restantes depuis png-compare.
Enregistrez les programmes ci-dessous avec leurs extensions .mts. Node
exécute les fichiers .mts en tant que modules ES
sans compilateur ni tsconfig.json hérité. pngjs fournit le
décodeur PNG indépendant utilisé pour les vérifications.
Comparer un PNG
Enregistrez ce programme sous compare.mts. Utilisez des PNG statiques RGB ou RGBA à
8 bits. Le script rejette les animations, les entrées à 16 bits et les blocs ICC, sRGB, gamma,
chromaticité ou Exif. La vérification compare les échantillons stockés et l’alpha ; elle ne permet de
conclure ni sur le rendu avec gestion des couleurs ni sur la préservation des métadonnées. Conservez
vos originaux.
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
})
Tous les résultats sont placés dans un répertoire nouvellement créé ; un répertoire existant est
refusé sans remplacement de ses fichiers. Une entrée invalide ou un prérequis manquant provoque un
échec avant la création du répertoire. Un échec ultérieur de l’encodeur ou de la vérification peut
laisser des PNG partiels, mais aucun results.json complet. N’utilisez un rapport
qu’après la fin réussie de la commande.
Le code de sortie 99 de pngquant devient une ligne refused, sans fichier
candidat. Il signifie que la conversion a été refusée au niveau de qualité demandé, et non que le
décodage de l’original a échoué. En particulier, un refus ne fournit aucun score mesuré pour la
sortie. Les autres erreurs font échouer la comparaison.
changedPixels compte les pixels RGBA différents après avoir ignoré les composantes
RGB masquées par un alpha nul. Sa valeur doit être nulle pour les lignes sans perte.
rmse mesure l’erreur RGB après composition sur un fond noir et sur un fond
blanc, sur une échelle de 0–255 pour les échantillons ; maxAlphaError indique le plus
grand écart d’alpha. Ces vérifications sont utiles, mais une faible erreur moyenne peut masquer un
contour dégradé ou des effets de bandes. Inspectez les images avec palette à leur taille d’affichage
prévue, sur les deux fonds, avant de les accepter.
Comparaison des tailles de fichier
Enregistrez ce programme sous make-fixtures.mts. Il crée trois PNG de 256 × 256 : un
dégradé lisse, un dessin au trait en quatre couleurs et des bandes colorées avec des pixels
transparents, semi-transparents et opaques. L’encodage des entrées utilise un faible niveau de
compression pour laisser une marge de recompression lors de la comparaison.
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
})
Après avoir enregistré les deux programmes, exécutez :
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
Chaque comparaison réussie affiche un tableau et le chemin de son results.json,
qui accompagne les fichiers PNG de sortie nommés. Le générateur refuse un répertoire
fixtures existant. Pour essayer une autre image ou un autre réglage, réutilisez
compare.mts avec un nouveau répertoire de résultats. Par exemple, cette commande
demande le réglage de qualité le plus strict de pngquant sans modifier les autres configurations :
node compare.mts fixtures/gradient.png results-strict 100-100
Une exécution sous Linux x86-64 le 3 octobre 2026 utilisait Node.js 24.15.0, pngquant-bin 9.0.0 fournissant pngquant 3.0.3, OptiPNG 7.9.1, Sharp 0.35.3, libvips 8.18.3 et pngjs 7.0.0. pngquant utilisait deux fils d’exécution OpenMP. Pour l’image de dégradé d’entrée de 29 234 B, les réglages du programme ont produit les résultats suivants :
| Configuration | Octets | Temps (ms) | Pixels modifiés | RMSE |
|---|---|---|---|---|
| pngquant | 18 681 | 32,1 | 65 317 | 6,081 |
| OptiPNG | 679 | 182,6 | 0 | 0 |
| Sharp en vraies couleurs | 830 | 6,2 | 0 | 0 |
| Sharp avec palette | 36 104 | 241,3 | 65 314 | 5,164 |
Les valeurs régulières des pixels de ce dégradé se compressent bien sans palette. La quantification a modifié les couleurs et produit des fichiers plus volumineux que les configurations sans perte ; le fichier Sharp avec palette était aussi plus volumineux que l’entrée. Sur l’image de test du dessin au trait, chaque configuration a préservé les pixels décodés, avec un résultat de 195 B pour OptiPNG et de 198 B pour pngquant. Les quatre configurations ont aussi préservé les couleurs rendues de l’image de test de l’alpha ainsi que ses valeurs alpha de 0, 128 et 255. Ces petites images d’entrée synthétiques révèlent des comportements différents ; elles ne sont pas représentatives d’une collection de ressources de production.
Les temps couvrent un appel d’encodeur par ligne, les appels étant exécutés en série, et incluent le démarrage du sous-processus et l’écriture des fichiers. Ils excluent la préparation et la vérification indépendante des pixels. Ce sont des mesures locales, pas un test de performance du débit ; répétez les mesures avec des ressources représentatives et consignez les versions et les réglages dans le rapport.
Choisir le bon outil
Pour les ressources qui exigent une fidélité exacte des pixels, comme les diagrammes, comparez
OptiPNG à sharp-full et rejetez tout résultat dont la valeur
changedPixels est non nulle. Une palette peut elle aussi encoder exactement un petit
ensemble de couleurs, mais vérifiez les pixels réels au lieu de supposer que chaque conversion avec
palette perd des détails. Pour les dégradés et les contours translucides, mettez les gains de taille
en regard des colonnes d’erreur et de votre inspection visuelle.
Conservez l’original lorsque le fichier candidat est plus volumineux, que la conversion est refusée ou que le résultat est visiblement moins bon. Utilisez Sharp lorsque le redimensionnement ou d’autres traitements font déjà partie de la même chaîne de traitement Node.js ; utilisez OptiPNG lorsque vous souhaitez recompresser des PNG existants avec une vérification de la préservation des pixels.
