Skip to content

Advanced Usage

This guide covers the advanced pixopt APIs. It assumes you are already comfortable with optimize_image and the basic CLI. Each section explains a feature, lists the key parameters and result objects, and provides runnable examples.


Watermarking

pixopt can overlay text or a logo onto an image with configurable position, opacity, padding, and scaling. Use WatermarkPosition to place the mark in one of the corners or in the center.

Text watermarks

from pixopt import add_text_watermark, WatermarkPosition

result = add_text_watermark(
    "photo.jpg",
    "photo_watermarked.jpg",
    "© 2025 My Company",
    position=WatermarkPosition.BOTTOM_RIGHT,
    opacity=0.5,
    padding=20,
    font_size=48,
    font_path="fonts/Arial.ttf",  # optional; when omitted, arial.ttf or a default font is used
    color=(255, 255, 255),
)
print(result.output_path, result.width, result.height)
Parameter Default Description
position BOTTOM_RIGHT One of TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, CENTER
opacity 0.5 Opacity from 0.0 (invisible) to 1.0 (opaque)
padding 20 Pixel distance from the edge or center offset
font_size 36 Text size in pixels
font_path None Path to a .ttf or .otf font file
color (255, 255, 255) Text color as an (R, G, B) tuple

Image watermarks

from pixopt import add_image_watermark, WatermarkPosition

result = add_image_watermark(
    "photo.jpg",
    "photo_branded.jpg",
    "logo.png",
    position=WatermarkPosition.BOTTOM_LEFT,
    opacity=0.6,
    padding=30,
    scale=0.15,  # watermark width is 15% of the base image width
)
print(result.output_path, result.width, result.height)

Tip

Use a PNG with an alpha channel for image watermarks. The watermark is composited with alpha, so a transparent logo blends cleanly onto the base image.

Warning

add_text_watermark rejects text longer than 1000 characters, and both watermark functions enforce the package-wide maximum image dimension of 30,000 pixels.


Sprite Sheets and Contact Sheets

Combine many images into a single file for game assets, CSS sprites, or thumbnail indexes.

Sprite sheets

from pathlib import Path
from pixopt import create_sprite, SpriteLayout

frames = sorted(Path("frames").glob("*.png"))
result = create_sprite(
    frames,
    "sprite_sheet.png",
    cell_width=128,
    cell_height=128,
    columns=4,
    layout=SpriteLayout.GRID,
    padding=2,
    background=(255, 255, 255),
    fmt="PNG",
)
print(f"Sprite: {result.width}x{result.height}, {result.rows}x{result.columns}")
for slot in result.slots:
    print(slot.index, slot.x, slot.y, slot.width, slot.height)

Contact sheets

from pixopt import create_contact_sheet

photos = ["img_01.jpg", "img_02.jpg", "img_03.jpg"]
result = create_contact_sheet(
    photos,
    "contact_sheet.jpg",
    cell_width=200,
    cell_height=200,
    columns=3,
    padding=10,
    label_height=20,
    fmt="JPEG",
)

create_contact_sheet adds the filename under each thumbnail and draws a thin border around every cell.

Layout Description
GRID Square-ish grid with auto-calculated row and column counts
HORIZONTAL One row, every image side-by-side
VERTICAL One column, every image stacked

Note

create_sprite defaults cell_width and cell_height to the largest source image size, so each image fits without being enlarged unless you request it. Both functions are limited to 200 source images and 1 billion source pixels.


Asset Bundles

generate_asset_bundle produces a complete set of web assets from a single source image: hero, thumbnail, og:image, favicon, srcset variants, LQIP data URI, blurhash, dominant color, and color palette.

from pixopt import generate_asset_bundle, BundleOptions

options = BundleOptions(
    hero_width=1920,
    thumbnail_width=300,
    og_width=1200,
    og_height=630,
    favicon_sizes=[16, 32, 48, 64, 128, 256],
    srcset_widths=[320, 640, 960, 1280, 1920],
    quality=85,
    lqip_size=32,
    lqip_quality=20,
    palette_n=6,
)

bundle = generate_asset_bundle("hero.jpg", "assets/", options=options)

print(bundle.hero)
print(bundle.thumbnail)
print(bundle.og_image)
print(bundle.favicon)
print(bundle.srcset_images)
print(bundle.lqip_data_uri)
print(bundle.blurhash)
print(bundle.dominant_color)
print(bundle.palette)
BundleOptions field Default Description
hero_width 1920 Hero image width
thumbnail_width 300 Thumbnail width
og_width / og_height 1200 / 630 Open Graph image size, cropped with fit="cover"
favicon_sizes [16, 32, 48, 64, 128, 256] ICO resolutions to generate
srcset_widths [320, 640, 960, 1280, 1920] Responsive width variants
palette_n 6 Number of dominant colors to extract (1-32)
quality 85 Quality for generated images
lqip_size / lqip_quality 32 / 20 Low-quality image placeholder size and quality
generate_* flags True Toggle each output type individually

PDF Import and Export

Convert PDF pages to images and images to PDF. PDF support is optional and requires PyMuPDF.

pip install pixopt[pdf]

Import PDF to images

from pixopt import pdf_to_images

result = pdf_to_images(
    "document.pdf",
    "pdf_pages/",
    dpi=150,
    fmt="PNG",
    prefix="doc",
)
print(result.total_pages)
for page in result.pages:
    print(page.page_number, page.width, page.height, page.output_path)

Export images to PDF

from pixopt import images_to_pdf

result = images_to_pdf(
    ["page_1.png", "page_2.png", "page_3.png"],
    "output.pdf",
    title="My Document",
)
print(result.output, result.page_count)

Warning

pdf_to_images returns a PdfImportResult with success=False if PyMuPDF is not installed, the PDF exceeds 50 MB, or it has more than 100 pages. Page images are named {prefix}_{page:04d}.{fmt}.


Next-Generation Formats (JXL / WebP 2)

pixopt can detect and convert to next-generation formats when Pillow or a compatible plugin supports them.

from pixopt import (
    detect_format_support,
    is_format_supported,
    convert_to_nextgen,
    NextGenFormat,
)

support = detect_format_support()
for info in support.formats:
    print(info.format, info.supported, info.note)

if is_format_supported(NextGenFormat.JXL):
    result = convert_to_nextgen(
        "photo.jpg",
        "photo.jxl",
        fmt=NextGenFormat.JXL,
        quality=85,
        fallback=True,
        fallback_format="WEBP",
    )
    print(result.output, result.supported, result.fallback, result.savings_percent)
NextGenFormat Description
JXL JPEG XL. Requires pillow-jxl-plugin or native Pillow support.
WEBP2 WebP 2. Currently rarely supported by Pillow.

convert_to_nextgen returns a ConversionResult. If the target format is not supported and fallback=True, it writes a fallback_format file and adjusts the output path extension.

Note

Next-gen support is intentionally graceful. detect_format_support actually tries to encode a small test image, so the result reflects the real capabilities of the installed environment.


Duplicate Detection

Find duplicate or near-duplicate images using perceptual hashing. The scan supports phash, dhash, and ahash.

from pixopt import scan_duplicates, compute_hash, hamming_distance

report = scan_duplicates(
    "./gallery",
    algorithm="phash",
    threshold=5,
    recursive=True,
    hash_size=8,
)
print(report.total_files, report.total_duplicates)
for group in report.duplicate_groups:
    print(group.hash_hex, group.count, group.files)

Compare two images directly:

h1 = compute_hash("image_a.jpg", algorithm="phash", hash_size=8)
h2 = compute_hash("image_b.jpg", algorithm="phash", hash_size=8)
distance = hamming_distance(h1.hash_hex, h2.hash_hex)
print(f"Hamming distance: {distance}")
Parameter Description
algorithm phash, dhash, or ahash
threshold Maximum Hamming distance to consider images duplicates. 0 means exact matches only.
hash_size Side length in bits; 8 produces a 64-bit hash

Tip

Use threshold=0 for fast exact-duplicate grouping. Use a small positive threshold such as 5 for near-duplicate detection.


Directory Scanning and Inventory

scan_directory inventories a folder and returns structured metadata plus aggregate statistics.

from pixopt import scan_directory

report = scan_directory(
    "./images",
    recursive=True,
    extensions=[".jpg", ".png", ".webp"],
)

print(report.total_files)
print(report.valid_images)
print(report.errors)
print(report.human_total_size)
print(report.formats)

for entry in report.entries:
    print(entry.file_path, entry.format, entry.width, entry.height, entry.human_file_size)

ScanEntry contains file_path, file_size, width, height, format, mode, has_alpha, is_animated, frame_count, and error (if the file could not be read). ScanReport adds totals, a format histogram, and the largest and smallest files.

Note

The scan stops at 10,000 entries (MAX_SCAN_ENTRIES) to prevent runaway processing. Use extensions to limit the file types and recursive=True to descend into subdirectories.


Format Benchmarking

benchmark_formats encodes an image in several formats and quality levels, then recommends the smallest variant.

from pixopt import benchmark_formats

result = benchmark_formats(
    "photo.jpg",
    qualities=[50, 60, 70, 80, 90],
    formats=["JPEG", "WEBP", "AVIF", "PNG"],
)

for variant in result.variants:
    print(variant.label, variant.size_bytes, f"{variant.savings_percent}%")

print("Recommended:", result.recommended_format, result.recommended_quality)
print("Savings:", result.recommended_savings)
Result field Description
source_size Original file size in bytes
variants List of BenchmarkVariant results
recommended_format Smallest successful format
recommended_quality Quality setting for the recommendation, or None for PNG
recommended_size / recommended_savings Size and savings of the recommendation

Note

PNG is lossless and is benchmarked once with no quality parameter. AVIF is automatically skipped if the environment cannot encode it.


Color Palette Extraction

Extract dominant colors from an image for design systems or theme generation.

from pixopt import extract_palette

palette = extract_palette("photo.jpg", n=6)
print(palette.width, palette.height)
for swatch in palette.colors:
    print(swatch.hex, swatch.rgb, f"{swatch.percent}%")

extract_palette uses Pillow's median-cut quantization. It returns a PaletteResult with a list of ColorSwatch objects sorted by dominance. n must be between 1 and 32.


Base64 / Bytes Workflows

The io_bytes module lets you optimize images that live only in memory. This is useful for web backends, APIs, and the pixopt MCP server.

Optimize raw bytes

from pixopt import optimize_bytes
from pixopt.models import OutputFormat

with open("photo.jpg", "rb") as f:
    data = f.read()

result = optimize_bytes(
    data,
    max_width=1200,
    quality=80,
    output_format=OutputFormat.WEBP,
)
print(result.format, result.width, result.height)
print(result.original_size, result.optimized_size, result.savings_percent)

# Write the optimized bytes back to disk
with open("photo_optimized.webp", "wb") as f:
    f.write(result.data)

Optimize a base64 string

from pixopt import optimize_base64

b64 = "..."  # raw base64, without the data URI prefix
result = optimize_base64(
    b64,
    max_width=800,
    quality=75,
    output_format=OutputFormat.WEBP,
)
print(result.base64[:80], result.optimized_size)

Convert between PIL, bytes, and base64

from PIL import Image
from pixopt import (
    bytes_to_image,
    image_to_bytes,
    image_to_base64,
    base64_to_image,
)

img = Image.open("photo.png")
encoded = image_to_base64(img, fmt="WEBP", quality=85)
img.close()

decoded = base64_to_image(encoded)
print(decoded.size)
decoded.close()

Warning

bytes_to_image and base64_to_image return a loaded PIL.Image.Image. The caller is responsible for calling .close(). Input bytes are capped at 100 MB; base64 strings are capped at 1 MB.


Async API

All CPU-bound work is dispatched to a background thread with asyncio.to_thread, so the event loop stays responsive. The main entry points are async_optimize_image, async_batch_optimize, and async_optimize_base64.

import asyncio
from pixopt import (
    async_optimize_image,
    async_batch_optimize,
    async_optimize_base64,
)
from pixopt.models import OutputFormat


async def main():
    single = await async_optimize_image(
        "photo.jpg",
        "photo.webp",
        max_width=1200,
        quality=80,
        output_format=OutputFormat.WEBP,
    )
    print(single.savings_percent)

    report = await async_batch_optimize(
        ["a.jpg", "b.jpg", "c.png"],
        "optimized/",
        max_width=1200,
        quality=80,
        output_format=OutputFormat.WEBP,
        max_concurrency=4,
    )
    print(report.total_files, report.succeeded, report.failed)
    print(report.total_savings_percent)

    b64_result = await async_optimize_base64(b64_input, max_width=800)
    print(b64_result.optimized_size)


asyncio.run(main())

Progress callbacks

async_batch_optimize accepts an on_progress callback that receives a ProgressInfo object:

from pixopt import async_batch_optimize
from pixopt.progress import ProgressInfo


def on_progress(info: ProgressInfo) -> None:
    print(f"{info.current}/{info.total} {info.current_file.name}")


await async_batch_optimize(
    ["a.jpg", "b.jpg"],
    "out/",
    max_width=1200,
    on_progress=on_progress,
)

Tip

Adjust max_concurrency based on your workload. The default is 4; raising it helps for many small files, while lowering it reduces memory pressure on large images.


Perceptual Hashing

Compute pHash, dHash, or aHash for an image and compare hashes with Hamming distance.

from pixopt import compute_hash, hamming_distance, phash, dhash, ahash

# Direct algorithms
print(ahash("photo.jpg", hash_size=8))
print(dhash("photo.jpg", hash_size=8))
print(phash("photo.jpg", hash_size=8, highfreq_factor=4))

# Generic wrapper
h1 = compute_hash("photo.jpg", algorithm="phash", hash_size=8)
h2 = compute_hash("photo_copy.jpg", algorithm="phash", hash_size=8)

print(h1.hash_hex, h2.hash_hex)
print(hamming_distance(h1.hash_hex, h2.hash_hex))
Algorithm Best for
ahash Fast, brightness-tolerant comparison
dhash Detecting small changes and crops
phash Robust to resizing and compression

compute_hash returns a HashResult with file_path, algorithm, hash_hex, and hash_size.


Quality Metrics and compare_images

Compare an original image against an optimized or modified version with SSIM, PSNR, and MSE.

from pixopt import compare_images, compute_ssim, compute_psnr
import numpy as np
from PIL import Image

# High-level comparison
metrics = compare_images("original.jpg", "optimized.webp")
print(f"SSIM: {metrics.ssim}")
print(f"PSNR: {metrics.psnr}")
print(f"MSE: {metrics.mse}")
print(f"Verdict: {metrics.verdict}")

For lower-level control, work with numpy arrays directly:

arr1 = np.asarray(Image.open("original.jpg").convert("RGB"), dtype=np.float32)
arr2 = np.asarray(Image.open("optimized.webp").convert("RGB"), dtype=np.float32)

ssim = compute_ssim(arr1, arr2, win_size=7, data_range=255.0)
mse = np.mean((arr1 - arr2) ** 2)
psnr = compute_psnr(mse)
print(ssim, psnr)
QualityMetrics field Description
ssim Structural similarity in [-1, 1]; 1.0 is identical
psnr Peak signal-to-noise ratio, or None when MSE is 0
mse Mean squared error
verdict excellent, good, fair, or poor based on SSIM

Warning

compare_images requires both images to have the same dimensions. SSIM computation is limited to 4 megapixels (MAX_SSIM_PIXELS).


Presets

Presets bundle common optimization settings. Built-in presets include web, social, thumbnail, e-commerce, and print.

from pixopt import (
    BUILTIN_PRESETS,
    get_preset_names,
    resolve_preset,
    apply_preset,
    load_custom_presets,
)

print(get_preset_names())
print(BUILTIN_PRESETS["web"])

# Resolve to a flat dict
web = resolve_preset("web")
print(web)

# Merge with overrides
merged = apply_preset("web", quality=90, max_width=1200)
print(merged)

# Load custom presets from JSON
custom = load_custom_presets("my_presets.json")
combined = apply_preset("my-preset", custom_presets=custom)

Example my_presets.json:

{
  "my-preset": {
    "quality": 92,
    "strip": false,
    "fit": "contain",
    "max-width": 1600,
    "background-color": "#ffffff"
  }
}

Note

Preset keys are CLI-style dashed names such as max-width, aspect-ratio, and background-color. If you want to pass a preset directly to optimize_image, convert the keys to the snake_case names that the Python API uses, or pass the values explicitly.


The Pipeline Fluent API

Pipeline lets you chain image operations and run them in a single call. Each method returns self, so calls can be chained.

from pixopt import Pipeline, OutputFormat, WatermarkPosition

pipeline = (
    Pipeline()
    .open("photo.jpg")
    .auto_orient()
    .resize(max_width=1200, fit="cover")
    .watermark_text(
        "© 2025",
        position=WatermarkPosition.BOTTOM_RIGHT,
        opacity=0.5,
        font_size=48,
    )
    .optimize(quality=85, output_format=OutputFormat.WEBP)
    .save("output/photo.webp")
)

result = pipeline.run()
print(result.output_path)
print(result.width, result.height, result.format, result.size_bytes)
print(result.steps_executed)

# Inspect the queued steps without running them
print(pipeline.steps)

Image watermark in a pipeline

pipeline = (
    Pipeline()
    .open("photo.jpg")
    .resize(max_width=1200)
    .watermark_image(
        "logo.png",
        position=WatermarkPosition.BOTTOM_LEFT,
        opacity=0.6,
        scale=0.12,
    )
    .optimize(quality=85, output_format=OutputFormat.WEBP)
    .save("output/photo.webp")
)
result = pipeline.run()
Method Purpose
.open(path) Set the source image
.auto_orient() Apply EXIF orientation
.resize(...) Resize with fit, anchor, aspect_ratio, and background_color
.convert(mode) Convert color mode, e.g. "RGB", "RGBA", "L"
.watermark_text(...) Add a text watermark
.watermark_image(...) Add an image watermark
.optimize(...) Configure quality, format, progressive, optimize, lossless, strip
.save(path) Set the output path
.run() Execute the pipeline and return a PipelineResult

Tip

The pipeline automatically creates intermediate temporary files for watermark operations, so you can chain resize and watermark steps without manual file management.