Otimização eficiente de PNG em Python com Oxipng
Use pyoxipng para comprimir um PNG na memória e salve o resultado apenas se o destino ainda
não existir. Este passo a passo oferece um script para arquivos individuais e pequenos lotes, com
tamanhos medidos, códigos de saída diferentes de zero em caso de falha e uma verificação separada de
que os pixels decodificados permanecem inalterados.
OptiPNG versus Oxipng
OptiPNG e
Oxipng são otimizadores de PNG distintos. O pacote Python usado aqui,
pyoxipng, encapsula a biblioteca Rust do Oxipng e é importado como
oxipng. Você não precisa de um executável do OptiPNG nem de um subprocesso para chamá-lo.
Decidir o que preservar
A otimização sem perdas pode alterar a compressão, a paleta ou o tipo de cor de um PNG, preservando os pixels decodificados. Ela não promete uma redução percentual específica. Um arquivo que já é compacto pode nem diminuir de tamanho.
O exemplo mantém os arquivos de origem intactos, recusa destinos existentes e não solicita a remoção
de metadados. Ele também mantém inalteradas as cores de pixels transparentes com optimize_alpha=False.
Use-o para assets PNG estáticos locais em diretórios que você controla. A verificação de pixels
abaixo cobre entradas RGB e RGBA de 8 bits; ela não valida animação, precisão de 16 bits nem
aparência com gerenciamento de cores.
Pré-requisitos
Os comandos abaixo usam Bash e Python 3.12 com venv e pip. Eles foram testados no
Linux com Python 3.12.13 e pyoxipng 9.1.1. Comece em um diretório onde png-demo ainda não
existe:
mkdir png-demo &&
cd png-demo &&
python3.12 -m venv .venv &&
.venv/bin/python -m pip install --only-binary=:all: pyoxipng==9.1.1 Pillow==12.3.0
A cadeia && interrompe a configuração se uma etapa falhar, inclusive quando o diretório
png-demo já existe. A opção --only-binary exige um wheel compatível em vez de iniciar uma
compilação a partir do código-fonte em Rust.
O PyPI lista os wheels disponíveis. O Pillow é usado
apenas para a verificação de pixels, não para a otimização. Execute os demais comandos a partir de
png-demo.
Otimizar um único PNG
Salve o seguinte como optimize_png.py. Ele passa os bytes PNG codificados para
optimize_from_memory,
portanto não reconstrói a imagem a partir de um array de pixels do Pillow ou do NumPy.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
def optimize_bytes(data: bytes, level: int) -> bytes:
candidate = oxipng.optimize_from_memory(
data,
level=level,
strip=oxipng.StripChunks.none(),
optimize_alpha=False,
)
return candidate if len(candidate) < len(data) else data
def optimize_file(source: Path, destination: Path, level: int) -> None:
start = perf_counter()
original = source.read_bytes()
optimized = optimize_bytes(original, level)
# Exclusive creation fails even for a dangling destination symlink.
output = destination.open("xb")
try:
with output:
output.write(optimized)
except OSError:
# Only remove the file this invocation successfully created.
destination.unlink()
raise
saved = len(original) - len(optimized)
print(
f"{source.name}: {len(original)} -> {len(optimized)} bytes; "
f"saved {saved} bytes; {perf_counter() - start:.3f} s"
)
def main() -> int:
parser = argparse.ArgumentParser(description="Optimize PNGs without overwriting outputs")
parser.add_argument("source", type=Path, help="PNG file or directory")
parser.add_argument("destination", type=Path, help="New PNG file or output directory")
parser.add_argument("--level", type=int, choices=range(7), default=2)
args = parser.parse_args()
try:
source = args.source.resolve(strict=True)
if source.is_dir():
if source == args.destination.resolve():
raise ValueError("Source and output directories must differ")
files = sorted(
path for path in source.iterdir()
if path.suffix.lower() == ".png" and not path.is_symlink() and path.is_file()
)
if not files:
raise ValueError("No PNG files found")
args.destination.mkdir(exist_ok=True)
pairs = [(path, args.destination / path.name) for path in files]
else:
pairs = [(source, args.destination)]
except (OSError, ValueError) as error:
parser.exit(1, f"Error: {error}\n")
failed = False
for input_path, output_path in pairs:
try:
optimize_file(input_path, output_path, args.level)
except (OSError, oxipng.PngError) as error:
print(f"Failed {input_path.name}: {type(error).__name__}: {error}", file=sys.stderr)
failed = True
return int(failed)
if __name__ == "__main__":
sys.exit(main())
Coloque um PNG chamado input.png ao lado do script e execute:
.venv/bin/python optimize_png.py input.png output.png
Em caso de sucesso, ele exibe as contagens de bytes de entrada e saída, os bytes economizados e os
segundos decorridos, incluindo a E/S de arquivo. Um resultado de zero bytes economizados é válido: o
script grava uma cópia do original quando o candidato não é menor. Executar o mesmo comando
novamente informa FileExistsError, encerra com status 1 e mantém output.png intacto. Usar a
própria entrada como destino também falha, sem alterá-la.
O modo "xb" do Python toma a decisão de não
sobrescrever no momento em que cria o arquivo. Uma entrada corrompida ou ausente falha antes desse
ponto. Se a gravação ou o fechamento do arquivo recém-criado gerar OSError, o script tenta
remover essa saída parcial. Este é um script local, não um mecanismo de publicação atômica: um
encerramento forçado pode deixar um arquivo parcial, e outro processo não deve consumir o destino
antes que a execução termine com sucesso.
Otimizar um diretório
Coloque seus PNGs em images_source e use o mesmo script:
.venv/bin/python optimize_png.py images_source images_optimized --level 2
Ele cria images_optimized se necessário e processa apenas os arquivos regulares no nível imediato do
diretório terminados em .png, sem diferenciar maiúsculas de minúsculas. Ele ignora
subdiretórios e entradas de link simbólico. Espaços e caracteres % literais nos nomes de
arquivo são suportados; para um caminho de linha de comando que comece com -, use um
prefixo ./ ou coloque -- antes dos argumentos posicionais.
Cada saída já existente conta como falha para aquele arquivo. Os demais arquivos continuam sendo processados, as saídas bem-sucedidas permanecem e o lote encerra com status 1 se algum arquivo falhar. Um diretório de origem ausente, um inventário de PNGs vazio ou diretórios de origem e saída idênticos falham antes do processamento. Para executar o lote inteiro novamente, escolha um novo diretório de saída.
Este loop processa uma imagem por vez para limitar quantas imagens decodificadas ficam na memória ao mesmo tempo. O próprio Oxipng oferece suporte a multithreading; não está comprovado que adicionar um pool de threads em Python acelere essa carga de trabalho. Os bytes de entrada, os bytes de saída e a memória de trabalho do otimizador ainda precisam caber na memória para cada imagem.
Como escolher o nível de otimização adequado
O binding aceita níveis de 0 a 6, sendo 2 o padrão. Eles são predefinições de busca, não configurações de qualidade nem garantia de economia. Comece com 2 e compare os resultados com seus próprios assets.
| Nível | Use para |
|---|---|
| 0 | Uma referência rápida |
| 2 | Builds rotineiros de assets |
| 4 | Uma comparação com mais esforço de busca |
| 6 | Uma comparação quando o tempo de execução importa menos |
Medir sem criar arquivos de benchmark
Salve isto como benchmark_png.py ao lado de optimize_png.py e execute
.venv/bin/python benchmark_png.py input.png. Ele lê um arquivo, testa quatro níveis e escreve
apenas no terminal. Não há nome de arquivo de saída temporário que possa colidir com seus arquivos.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
from optimize_png import optimize_bytes
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
args = parser.parse_args()
try:
data = args.source.read_bytes()
for level in (0, 2, 4, 6):
start = perf_counter()
result = optimize_bytes(data, level)
print(f"level={level}: {len(data)} -> {len(result)} bytes; {perf_counter() - start:.3f} s")
except (OSError, oxipng.PngError) as error:
sys.exit(f"Benchmark failed: {error}")
Cada nível parte dos bytes originais. A medição de tempo inclui a otimização, mas exclui a leitura inicial do arquivo. Repita a medição com arquivos representativos; uma única medição inclui efeitos de aquecimento e de carga do sistema. Uma predefinição mais alta não produz necessariamente um resultado menor para todas as imagens.
Preservar ou remover metadados
StripChunks.none() solicita que nenhum metadado seja
removido. Isso é diferente de preservar cada chunk byte a byte: o Oxipng pode
remover chunks invalidados por uma mudança de tipo de cor ou de profundidade de bits.
O script também cria um novo arquivo no sistema de arquivos, então não copia os timestamps nem as
permissões do arquivo de origem.
StripChunks.safe() remove metadados considerados desnecessários para a renderização, incluindo
comentários de texto. StripChunks.all() também pode remover perfis de cor e chunks de animação. Não use
nenhum dos dois como substituto quando sua tarefa exigir a retenção dessas informações. Nem um
arquivo menor nem um buffer de pixels igual comprovam que os metadados foram mantidos. Inspecione os
campos específicos de que sua aplicação precisa.
Comparar pixels decodificados
Salve isto como check_pixels.py e execute .venv/bin/python check_pixels.py input.png output.png.
Para PNGs de origem estáticos RGB/RGBA de 8 bits, ele compara as dimensões e os bytes RGBA
decodificados, incluindo os valores de cor sob pixels totalmente transparentes. Um arquivo otimizado
pode usar uma paleta, por isso as duas imagens são decodificadas em um modo comum antes da
comparação.
import argparse
import sys
from PIL import Image
parser = argparse.ArgumentParser()
parser.add_argument("source")
parser.add_argument("output")
args = parser.parse_args()
try:
with Image.open(args.source) as before, Image.open(args.output) as after:
if before.format != "PNG" or after.format != "PNG":
raise ValueError("Both files must be PNGs")
if before.n_frames != 1 or after.n_frames != 1:
raise ValueError("This check covers static PNGs only")
if before.mode not in ("RGB", "RGBA"):
raise ValueError("Use an 8-bit RGB or RGBA source for this check")
if before.size != after.size or before.convert("RGBA").tobytes() != after.convert("RGBA").tobytes():
raise ValueError("Decoded pixels differ")
print("Decoded RGBA pixels match")
except (OSError, ValueError) as error:
sys.exit(f"Pixel check failed: {error}")
tobytes() do Pillow
retorna dados de pixels decodificados. Esta comparação não aplica perfis ICC nem compara texto,
EXIF, resolução ou outros metadados. Forneça origens de 8 bits, conforme indicado: o modo RGB do
Pillow, por si só, não estabelece a profundidade de bits do PNG original.
Solução de problemas
| Erro | Ação |
|---|---|
ModuleNotFoundError | Use o .venv/bin/python do projeto e repita a etapa de instalação nele. |
FileExistsError | Escolha um novo destino; inspecione uma saída antiga antes de removê-la você mesmo. |
FileNotFoundError | Verifique a origem e o diretório pai da saída. |
PngError | Verifique se a entrada é um PNG completo e válido. |
PermissionError | Escolha uma origem legível e um diretório de saída com permissão de escrita. |
Um lote com falhas pode deixar saídas bem-sucedidas ao lado das falhas. Leia o stderr para ver os nomes dos arquivos afetados; o status de saída descreve o lote, não indica uma reversão. Se o uso de memória for alto demais, mantenha o loop sequencial e teste imagens menores antes de aumentar o nível de otimização.
Alternativa gerenciada
Para um fluxo de trabalho de upload gerenciado pela Transloadit, consulte a documentação de /image/optimize e escolha a política de metadados dela separadamente das configurações do script local.
