Comparación de optimizadores PNG en Node.js
Elige OptiPNG o un PNG a todo color de Sharp cuando los píxeles decodificados deban permanecer inalterados. Prueba pngquant o la salida con paleta de Sharp cuando puedas sacrificar algo de precisión del color para reducir los bytes. Esta guía ejecuta las cuatro configuraciones con la misma entrada y registra el tamaño, el tiempo transcurrido y las diferencias entre píxeles para que puedas decidir con tus propias imágenes.
Descripción general de las herramientas
Pngquant
pngquant convierte imágenes a una paleta de hasta 256 colores, incluida la transparencia. Su rango de calidad puede hacer que se rechace una conversión. Un PNG con paleta sigue usando compresión PNG sin pérdida, pero la selección de sus colores puede cambiar los píxeles originales.
Optipng
OptiPNG busca ajustes de compresión y puede reducir la profundidad de
bits o el tipo de color sin cambiar la imagen decodificada. Aquí usamos -o7,
que prueba más combinaciones de compresión que los niveles inferiores. La optimización puede dejar
sin cambios una imagen que ya sea compacta.
Sharp
Las opciones de PNG de Sharp admiten salidas a todo color y con paleta.
compressionLevel controla el esfuerzo de compresión sin cuantización;
adaptiveFiltering habilita el filtrado de filas. Establecer
quality o effort habilita una paleta, así que omite ambos
para la comparación a todo color. Una calidad de 80 en Sharp y 65-80 en
pngquant son ajustes de codificadores distintos, no una misma calidad medida.
Instalación
Usa Bash, Node.js 24.15.0, Corepack con Yarn 4.12.0 y OptiPNG 7.9.1 en tu
PATH. Instala OptiPNG desde sus
descargas oficiales; este ejemplo llama a ese ejecutable en lugar de
usar un paquete envoltorio de npm. Los comandos se probaron en Linux. Otras plataformas pueden tener
binarios nativos y tiempos diferentes.
Crea un proyecto nuevo desde un directorio donde aún no exista png-compare:
(
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
)
El subshell deja intacto tu directorio actual, y && detiene la preparación
si falla un paso. El archivo de bloqueo vacío hace que este sea un proyecto Yarn independiente,
incluso dentro de otro espacio de trabajo. Los ajustes del entorno le proporcionan dependencias
locales y habilitan la instalación nativa de pngquant. Conserva el archivo de bloqueo resultante
para que la resolución de dependencias sea reproducible. Ejecuta los comandos restantes desde
png-compare.
Guarda los programas siguientes con sus extensiones .mts. Node
ejecuta los archivos .mts como módulos ES
sin un compilador ni un tsconfig.json heredado. pngjs
proporciona el decodificador PNG independiente que se usa para las comprobaciones.
Compara un PNG
Guarda este código como compare.mts. Usa imágenes PNG estáticas RGB o RGBA de
8 bits. El script rechaza animaciones, entradas de 16 bits y bloques ICC, sRGB, gamma, de cromaticidad
o Exif. La comprobación compara las muestras almacenadas y el canal alfa; no verifica la apariencia
con gestión del color ni la conservación de los metadatos. Conserva tus originales.
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
})
Cada resultado se guarda en un directorio recién creado; si el directorio ya existe, se rechaza sin
reemplazar sus archivos. Una entrada no válida o la falta de un requisito previo provoca un fallo
antes de crear el directorio. Un fallo posterior del codificador o de la verificación puede dejar
archivos PNG parciales, pero no un results.json completo. Usa un informe solo después
de que el comando finalice correctamente.
El código de salida 99 de pngquant se convierte en una fila refused sin archivo
candidato. Significa que se rechazó la conversión con el ajuste de calidad solicitado, no que fallara
la decodificación del original. En particular, un rechazo no proporciona una puntuación medida para
la salida. Otros errores hacen que falle la comparación.
changedPixels cuenta los píxeles RGBA diferentes tras ignorar los valores RGB ocultos
bajo un alfa de cero. Debe ser cero en las filas sin pérdida. rmse mide el
error RGB tras componer la imagen sobre negro y blanco, en una escala de muestras de 0–255;
maxAlphaError indica la mayor diferencia de alfa. Estas comprobaciones son útiles,
pero un error promedio bajo puede ocultar un borde defectuoso o bandas de color. Inspecciona las
imágenes con paleta al tamaño de visualización previsto y sobre ambos fondos antes de aceptarlas.
Comparación del tamaño de los archivos
Guarda este código como make-fixtures.mts. Crea tres imágenes PNG de 256 × 256: un
degradado suave, un dibujo de líneas de cuatro colores y franjas de colores con píxeles transparentes,
semitransparentes y opacos. La codificación de entrada usa un nivel de compresión bajo para que haya
margen de recompresión en la comparación.
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
})
Después de guardar ambos programas, ejecuta:
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 comparación que finaliza correctamente imprime una tabla y la ruta a su
results.json, junto con las salidas PNG con sus respectivos nombres. El generador
rechaza el directorio fixtures si ya existe. Para probar otra imagen u otro
ajuste, reutiliza compare.mts con un directorio de resultados nuevo. Por ejemplo,
esto solicita el ajuste de calidad más estricto de pngquant sin cambiar las demás configuraciones:
node compare.mts fixtures/gradient.png results-strict 100-100
Una ejecución en Linux x86-64 el 3 de octubre de 2026 usó Node.js 24.15.0, pngquant-bin 9.0.0 con pngquant 3.0.3, OptiPNG 7.9.1, Sharp 0.35.3, libvips 8.18.3 y pngjs 7.0.0. pngquant usó dos hilos OpenMP. Para la entrada de degradado de 29.234 B, los ajustes del programa produjeron:
| Configuración | Bytes | Tiempo (ms) | Píxeles modificados | RMSE |
|---|---|---|---|---|
| pngquant | 18.681 | 32,1 | 65.317 | 6,081 |
| OptiPNG | 679 | 182,6 | 0 | 0 |
| Sharp a todo color | 830 | 6,2 | 0 | 0 |
| Sharp con paleta | 36.104 | 241,3 | 65.314 | 5,164 |
Los valores regulares de los píxeles de este degradado se comprimen bien sin una paleta. La cuantización cambió los colores y produjo archivos más grandes que las configuraciones sin pérdida; la salida con paleta de Sharp también fue más grande que la entrada. En la imagen de prueba con el dibujo de líneas, todas las configuraciones conservaron los píxeles decodificados: OptiPNG produjo 195 B y pngquant, 198 B. Las cuatro también conservaron los colores renderizados de la imagen de prueba con alfa y sus valores alfa de 0, 128 y 255. Estas pequeñas entradas sintéticas revelan comportamientos diferentes; no representan una colección de activos de producción.
Los tiempos abarcan una llamada al codificador por fila, ejecutadas de forma secuencial, e incluyen el inicio del subproceso y la escritura de archivos. Excluyen la preparación y la comprobación independiente de píxeles. Son mediciones locales, no una prueba de capacidad de procesamiento; repite la prueba con activos representativos y registra las versiones y los ajustes en el informe.
Cómo elegir la herramienta adecuada
Para activos que requieran conservar los píxeles exactos, como los diagramas, compara OptiPNG con
sharp-full y rechaza cualquier valor de changedPixels distinto de
cero. Una paleta también puede codificar un conjunto pequeño de colores con exactitud, pero comprueba
los píxeles reales en lugar de asumir que toda conversión a paleta pierde detalle. Para degradados y
bordes translúcidos, compara el ahorro con las columnas de error y tu inspección visual.
Conserva el original cuando un candidato sea más grande, se rechace o se vea peor. Usa Sharp cuando el cambio de tamaño u otro procesamiento ya forme parte del mismo pipeline de Node.js; usa OptiPNG cuando quieras recomprimir imágenes PNG existentes con una comprobación de conservación de píxeles.
