PNG-Optimierer in Node.js vergleichen
Wählen Sie OptiPNG oder die Vollfarb-PNG-Ausgabe von Sharp, wenn decodierte Pixel unverändert bleiben müssen. Testen Sie pngquant oder die Palettenausgabe von Sharp, wenn Sie für weniger Bytes etwas Farbgenauigkeit aufgeben können. Diese Anleitung führt alle vier Konfigurationen mit derselben Eingabe aus und erfasst Größe, Laufzeit und Pixelunterschiede. So können Sie anhand Ihrer eigenen Bilder entscheiden.
Werkzeuge im Überblick
pngquant
pngquant konvertiert Bilder in eine Palette mit bis zu 256 Farben, einschließlich Transparenz. Der Qualitätsbereich kann dazu führen, dass eine Konvertierung abgelehnt wird. Ein Paletten-PNG nutzt weiterhin verlustfreie PNG-Kompression, doch die Farbauswahl kann die Quellpixel verändern.
OptiPNG
OptiPNG durchsucht Kompressionseinstellungen und kann Bittiefe oder
Farbtyp reduzieren, ohne das decodierte Bild zu verändern. Hier verwenden wir
-o7, das mehr Kompressionskombinationen als niedrigere Stufen ausprobiert.
Bei der Optimierung kann ein bereits kompaktes Bild unverändert bleiben.
Sharp
PNG-Optionen von Sharp unterstützen Vollfarb- und Palettenausgabe.
compressionLevel steuert den Kompressionsaufwand ohne Quantisierung;
adaptiveFiltering aktiviert die Zeilenfilterung. Wenn Sie
quality oder effort setzen, wird eine Palette aktiviert.
Lassen Sie daher beide für den Vollfarbvergleich weg. Ein Qualitätswert von 80 in Sharp und
65-80 in pngquant sind Einstellungen für unterschiedliche Encoder, keine
gleichen gemessenen Qualitätswerte.
Installation
Verwenden Sie Bash, Node.js 24.15.0, Corepack mit Yarn 4.12.0 und OptiPNG 7.9.1 in Ihrem
PATH. Installieren Sie OptiPNG aus den
offiziellen Downloads; dieses Beispiel ruft die ausführbare Datei
statt eines npm-Wrappers auf. Die Befehle wurden unter Linux getestet. Auf anderen Plattformen können
sich native Binärdateien und Laufzeiten unterscheiden.
Erstellen Sie ein neues Projekt aus einem Verzeichnis, in dem png-compare noch
nicht existiert:
(
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
)
Die Subshell lässt Ihr aktuelles Verzeichnis unverändert, und && stoppt
die Einrichtung nach einem fehlgeschlagenen Schritt. Die leere Lockdatei macht daraus ein separates
Yarn-Projekt, auch innerhalb eines anderen Workspaces. Die Umgebungseinstellungen sorgen für lokale
Abhängigkeiten und ermöglichen die native Installation von pngquant. Bewahren Sie die erzeugte
Lockdatei für eine reproduzierbare Auflösung der Abhängigkeiten auf. Führen Sie die übrigen Befehle
in png-compare aus.
Speichern Sie die folgenden Programme mit der Erweiterung .mts. Node
führt Dateien mit der Erweiterung .mts als ES-Module aus,
ohne Compiler oder geerbte tsconfig.json. pngjs liefert den
separaten PNG-Decoder für die Prüfungen.
Ein PNG vergleichen
Speichern Sie dies als compare.mts. Verwenden Sie statische 8-Bit-PNGs in RGB oder
RGBA. Das Skript lehnt Animationen, 16-Bit-Eingaben sowie ICC-, sRGB-, Gamma-, Chromatizitäts- oder
Exif-Chunks ab. Die Prüfung vergleicht gespeicherte Abtastwerte und Alpha; sie weist weder ein
farbverwaltetes Erscheinungsbild noch den Erhalt von Metadaten nach. Bewahren Sie Ihre Originale auf.
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
})
Jedes Ergebnis wird in einem neu erstellten Verzeichnis abgelegt. Ein bestehendes Verzeichnis wird
abgelehnt, ohne seine Dateien zu ersetzen. Ungültige Eingaben oder fehlende Voraussetzungen führen
vor der Verzeichniserstellung zum Fehlschlag. Ein späterer Encoder- oder Prüfungsfehler kann
unvollständige PNGs hinterlassen, aber keine fertige results.json. Verwenden Sie
einen Bericht erst, nachdem der Befehl erfolgreich beendet wurde.
Der Exit-Code 99 von pngquant wird als Zeile mit refused ohne Kandidatendatei
erfasst. Er bedeutet, dass die Konvertierung mit der angeforderten Qualitätseinstellung abgelehnt
wurde, nicht, dass das Original nicht decodiert werden konnte. Insbesondere liefert eine Ablehnung
keinen gemessenen Qualitätswert für die Ausgabe. Andere Fehler lassen den Vergleich fehlschlagen.
changedPixels zählt abweichende RGBA-Pixel, wobei RGB-Werte unter einem Alpha-Wert
von null ignoriert werden. Für die verlustfreien Zeilen muss der Wert null sein.
rmse misst den RGB-Fehler nach dem Compositing vor schwarzem und weißem
Hintergrund auf einer Abtastwertskala von 0–255; maxAlphaError gibt die größte
Alpha-Differenz an. Diese Prüfungen sind nützlich, doch ein geringer durchschnittlicher Fehler kann
eine unsaubere Kante oder Farbabstufungen verdecken. Prüfen Sie Palettenbilder in der vorgesehenen
Anzeigegröße vor beiden Hintergründen, bevor Sie sie akzeptieren.
Dateigrößen vergleichen
Speichern Sie dies als make-fixtures.mts. Es erstellt drei PNGs mit 256 × 256:
einen sanften Farbverlauf, eine vierfarbige Strichgrafik und farbige Streifen mit transparenten,
halbtransparenten und deckenden Pixeln. Das Encoding der Eingaben nutzt eine niedrige
Kompressionsstufe, damit der Vergleich Spielraum für eine erneute Kompression bietet.
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
})
Nachdem Sie beide Programme gespeichert haben, führen Sie Folgendes aus:
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
Jeder erfolgreiche Vergleich gibt eine Tabelle und den Pfad zu seiner
results.json aus, zusammen mit den benannten PNG-Ausgaben. Der Generator lehnt ein
bereits vorhandenes Verzeichnis fixtures ab. Um ein anderes Bild oder eine
andere Einstellung zu testen, verwenden Sie compare.mts erneut mit einem neuen
Ergebnisverzeichnis. Dieses Beispiel fordert etwa die strengste Qualitätseinstellung von pngquant
an und lässt die übrigen Konfigurationen unverändert:
node compare.mts fixtures/gradient.png results-strict 100-100
Ein Durchlauf unter Linux x86-64 am 3. Oktober 2026 verwendete Node.js 24.15.0, pngquant-bin 9.0.0 mit pngquant 3.0.3, OptiPNG 7.9.1, Sharp 0.35.3, libvips 8.18.3 und pngjs 7.0.0. pngquant nutzte zwei OpenMP-Threads. Für die Farbverlaufseingabe mit 29.234 B lieferten die Einstellungen im Programm:
| Konfiguration | Bytes | Zeit (ms) | Geänderte Pixel | RMSE |
|---|---|---|---|---|
| pngquant | 18.681 | 32,1 | 65.317 | 6,081 |
| OptiPNG | 679 | 182,6 | 0 | 0 |
| Sharp Vollfarbe | 830 | 6,2 | 0 | 0 |
| Sharp Palette | 36.104 | 241,3 | 65.314 | 5,164 |
Die regelmäßigen Pixelwerte dieses Farbverlaufs lassen sich ohne Palette gut komprimieren. Die Quantisierung veränderte Farben und erzeugte größere Dateien als die verlustfreien Konfigurationen; die Palettenausgabe von Sharp war auch größer als die Eingabe. Bei der Strichgrafik-Fixture blieben die decodierten Pixel in jeder Konfiguration erhalten, wobei OptiPNG 195 B und pngquant 198 B erzeugte. Alle vier bewahrten auch die gerenderten Farben der Alpha-Fixture und deren Alpha-Werte 0, 128 und 255. Diese kleinen synthetischen Eingaben zeigen unterschiedliche Verhaltensweisen auf; sie repräsentieren keine Sammlung von Produktionsdateien.
Die Zeiten umfassen einen seriellen Encoder-Aufruf pro Zeile, einschließlich des Starts des Unterprozesses und des Schreibens der Datei. Die Einrichtung und die unabhängige Pixelprüfung sind nicht enthalten. Es handelt sich um lokale Messungen, nicht um einen Durchsatz-Benchmark. Wiederholen Sie den Test mit repräsentativen Dateien und erfassen Sie Versionen und Einstellungen im Bericht.
Das richtige Werkzeug wählen
Vergleichen Sie bei pixelgenauen Dateien wie Diagrammen OptiPNG mit
sharp-full und lehnen Sie jeden Wert von changedPixels ab, der
nicht null ist. Auch eine Palette kann eine kleine Farbmenge exakt codieren. Prüfen Sie jedoch die
tatsächlichen Pixel, statt anzunehmen, dass jede Palettenkonvertierung Details verliert. Vergleichen
Sie bei Farbverläufen und halbtransparenten Kanten die Einsparungen mit den Fehlerspalten und Ihrer
visuellen Prüfung.
Behalten Sie das Original, wenn ein Kandidat größer ist, abgelehnt wird oder sichtbar schlechter aussieht. Verwenden Sie Sharp, wenn Größenänderung oder andere Verarbeitung bereits zur selben Node.js-Pipeline gehören. Verwenden Sie OptiPNG, wenn Sie vorhandene PNGs erneut komprimieren und dabei den Erhalt der Pixel prüfen möchten.
