Skip to content

Inspect intermediate images with .v and vipsdisp

Copy page

A dark final image can originate in decoding, alpha handling, compositing, or encoding. Saving a selected stage in native VIPS format (.v) lets you inspect its pixels and properties and narrow the search. This example deliberately premultiplies an overlay before a compositor that expects straight alpha, then uses four checkpoints to locate the first unexpected change.

The input is the project-owned 720 × 480 transparent SVG. Both paths use background #f0e6f2, explicitly composite in srgb, keep the same position, and perform no resize.

Translucent purple rings and coloured dots darkened by repeated premultiplication
Wrong: premultiply, then composite as straight alpha720 × 480, 43,713 bytes, alpha, 4 bands

Generated with pyvips 3.2.0 / libvips 8.17.3.

Purple rings and coloured dots at their intended brightness after removing the extra premultiply
Fixed: remove the extra premultiply720 × 480, 49,524 bytes, alpha, 4 bands

Generated with pyvips 3.2.0 / libvips 8.17.3.

Each PNG is read back from its matching .v, explicitly processed with rint().cast("uchar"), and saved: float values are rounded and converted to unsigned 8-bit integers. These are display copies. Use the .v files below for precise float inspection. The composites retain four bands, but their alpha values are all 255; having an alpha band does not mean an image still contains transparent pixels.

Exporting an ordinary image may convert pixel types, quantise values, or drop unsupported fields. Here, premultiply() produces float pixels with fractional values. Converting an 8-bit PNG back to .v cannot recover those fractions.

This example checks every pixel before and after the .v round trip. It also checks dimensions, bands, band format, interpretation, coding, resolution, offsets, and an explicitly added checkpoint-stage string. The float tolerance is 1e-5. These checks do not establish a round-trip guarantee for every possible metadata type.

Install Python, pyvips, and libvips; the CLI comparison also needs the vips command. Download the SVG above as input.svg and save the complete source below as main.py. The pyvips tab is this example’s reference. The CLI tab uses the same Python driver to invoke vips operations and apply the same assertions. Its intermediate steps are written to disk, so its execution differs from the lazy Python pipeline.

Run command

python3 main.py input.svg output/

Complete source

"""Save and verify native VIPS checkpoints; optionally build them with the CLI."""

import argparse
import json
import math
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

import pyvips


STAGES = ("source", "premultiplied", "wrong", "correct")
POINTS = ((238, 126), (502, 356), (360, 240), (0, 0))
BACKGROUND = (240, 230, 242)
TOLERANCE = 1e-5


def build_stage(input_path, stage):
    # Each checkpoint gets a fresh input, never a second consumer of a stream.
    source = pyvips.Image.new_from_file(str(input_path), access="random")
    if (source.width, source.height, source.bands) != (720, 480, 4):
        raise ValueError("expected the supplied 720x480 RGBA SVG")
    if stage == "source":
        return source
    if stage == "premultiplied":
        return source.premultiply()
    background = pyvips.Image.black(source.width, source.height).new_from_image(
        list(BACKGROUND)
    ).cast("float").copy(interpretation="srgb")
    # A float cast on the correct path keeps quantisation out of this comparison.
    overlay = source.premultiply() if stage == "wrong" else source.cast("float")
    return background.composite2(
        overlay, "over", compositing_space="srgb", premultiplied=False
    )


def build_cli_stage(input_path, stage, destination):
    """CLI operations materialise each intermediate; Python only drives them."""
    def vips(*args):
        subprocess.run(["vips", *map(str, args)], check=True, capture_output=True, text=True)

    with tempfile.TemporaryDirectory(prefix="vips-cli-checkpoint-") as directory:
        work = Path(directory)
        if stage == "source":
            vips("copy", input_path, destination)
        elif stage == "premultiplied":
            vips("premultiply", input_path, destination)
        else:
            vips("black", work / "black.v", 720, 480, "--bands", 3)
            vips("linear", work / "black.v", work / "background.v", "0 0 0", "240 230 242")
            vips("copy", work / "background.v", work / "srgb.v", "--interpretation", "srgb")
            if stage == "wrong":
                vips("premultiply", input_path, work / "overlay.v")
            else:
                vips("cast", input_path, work / "overlay.v", "float")
            # premultiplied defaults to false in the CLI; do not pass its true flag.
            vips("composite2", work / "srgb.v", work / "overlay.v", destination,
                 "over", "--compositing-space", "srgb")


def properties(image):
    return {name: image.get(name) for name in (
        "width", "height", "bands", "format", "coding", "interpretation",
        "xres", "yres", "xoffset", "yoffset",
    )}


def samples(image):
    return [{"x": x, "y": y, "values": [round(v, 6) for v in image.getpoint(x, y)]}
            for x, y in POINTS]


def require(condition, message):
    if not condition:
        raise ValueError(message)


def verify_roundtrip(original, restored, stage):
    before, after = properties(original), properties(restored)
    for name, value in before.items():
        matches = abs(value - after[name]) <= TOLERANCE if name in ("xres", "yres") else value == after[name]
        require(matches, f"{stage}: changed {name}: {value} -> {after[name]}")
    difference = (original.cast("double") - restored.cast("double")).abs().max()
    require(math.isfinite(difference) and difference <= TOLERANCE,
            f"{stage}: pixel roundtrip error {difference}")
    require(restored.get("checkpoint-stage") == stage, f"{stage}: missing stage metadata")


def verify_case(images):
    source, premultiplied = images["source"], images["premultiplied"]
    for x, y in POINTS:
        before, after = source.getpoint(x, y), premultiplied.getpoint(x, y)
        require(before[3] == after[3], "premultiply changed alpha")
        for channel in range(3):
            require(abs(after[channel] - before[channel] * before[3] / 255) <= TOLERANCE,
                    "premultiply did not scale RGB by alpha")
        wrong, correct = images["wrong"].getpoint(x, y), images["correct"].getpoint(x, y)
        require(wrong[3] == correct[3] == 255, "expected an opaque composite")
        if (x, y) == (0, 0):
            require(wrong[:3] == correct[:3] == list(BACKGROUND), "transparent control changed")
        else:
            alpha = before[3] / 255
            for channel in range(3):
                expected = before[channel] * alpha + BACKGROUND[channel] * (1 - alpha)
                require(abs(correct[channel] - expected) < 1e-4, "correct over pixel is wrong")
                require(correct[channel] - wrong[channel] > 10, "expected a visibly darker wrong pixel")


def view_checkpoint(image, viewer="vipsdisp"):
    executable = shutil.which(viewer)
    if executable is None:
        raise ValueError(f"viewer not installed: {viewer}")
    # A private directory avoids fixed /tmp names and filename races.
    with tempfile.TemporaryDirectory(prefix="vips-view-") as directory:
        filename = Path(directory) / "checkpoint.v"
        image.write_to_file(str(filename))
        subprocess.run([executable, str(filename)], check=True)
        # An existing viewer instance may accept the file and return immediately.
        input("Close this image in the viewer, then press Enter to remove the temporary file: ")


def check_saved(input_path, output_dir):
    """Validate persisted evidence too, including native files without byte baselines."""
    report = json.loads((output_dir / "checkpoints.json").read_text(encoding="utf-8"))
    images = {}
    require([entry["stage"] for entry in report["checkpoints"]] == list(STAGES),
            "unexpected checkpoint report stages")
    for stage, entry in zip(STAGES, report["checkpoints"]):
        original = build_stage(input_path, stage)
        restored = pyvips.Image.new_from_file(str(output_dir / f"{stage}.v"), access="random")
        verify_roundtrip(original, restored, stage)
        require(entry["path"] == f"{stage}.v" and entry["properties"] == properties(restored)
                and entry["samples"] == samples(restored) and entry["roundtrip"] == "passed"
                and entry["metadata"] == {"checkpoint-stage": stage}, f"{stage}: stale report")
        images[stage] = restored
    verify_case(images)
    for stage in ("wrong", "correct"):
        preview = pyvips.Image.new_from_file(str(output_dir / f"{stage}.png"), access="random")
        require((preview.cast("double") - images[stage].rint().cast("uchar")).abs().max() == 0,
                f"{stage}: stale display copy")
    return images


def generate(input_path, output_dir, use_cli=False):
    require(input_path.is_file(), "input file does not exist")
    output_dir.mkdir(parents=True, exist_ok=True)
    images, checkpoints = {}, []
    for stage in STAGES:
        original = build_stage(input_path, stage).copy()
        original.set_type(pyvips.GValue.gstr_type, "checkpoint-stage", stage)
        path = output_dir / f"{stage}.v"
        if use_cli:
            # The CLI snapshot is independently compared against the intended stage.
            # Add the same diagnostic string through a separate, lossless VIPS write.
            with tempfile.TemporaryDirectory(prefix="vips-stage-") as directory:
                candidate = Path(directory) / "stage.v"
                build_cli_stage(input_path, stage, candidate)
                cli_image = pyvips.Image.new_from_file(str(candidate), access="random").copy()
                cli_image.set_type(pyvips.GValue.gstr_type, "checkpoint-stage", stage)
                cli_image.write_to_file(str(path))
        else:
            original.write_to_file(str(path))
        restored = pyvips.Image.new_from_file(str(path), access="random")
        verify_roundtrip(original, restored, stage)
        images[stage] = restored
        checkpoints.append({"stage": stage, "path": path.name,
                            "properties": properties(restored), "samples": samples(restored),
                            "roundtrip": "passed", "metadata": {"checkpoint-stage": stage}})

    verify_case(images)
    outputs = []
    for stage in ("wrong", "correct"):
        # The PNG is a rounded 8-bit display copy, not the float checkpoint.
        preview = images[stage].rint().cast("uchar")
        path = output_dir / f"{stage}.png"
        preview.write_to_file(str(path), compression=6)
        decoded = pyvips.Image.new_from_file(str(path), access="random")
        require((decoded.cast("double") - preview.cast("double")).abs().max() == 0,
                f"{stage}: PNG differs from explicit display conversion")
        outputs.append({"id": stage, "path": path.name, "format": "png",
                        "width": decoded.width, "height": decoded.height, "bands": decoded.bands,
                        "hasAlpha": decoded.hasalpha(), "size": path.stat().st_size,
                        "parameters": {"compositing_space": "srgb", "premultiplied": False,
                                       "extraPremultiply": stage == "wrong", "preview": "rint + uchar"}})

    generator = {"pyvips": pyvips.__version__,
                 "libvips": ".".join(str(pyvips.version(i)) for i in range(3))}
    if use_cli:
        generator = {"vipsCli": generator["libvips"], "libvips": generator["libvips"],
                     "verificationPyvips": pyvips.__version__}
    for name, data in (
        ("result.json", {"recipeId": "pipeline-checkpoints", "generator": generator,
                         "input": input_path.name, "outputs": outputs}),
        ("checkpoints.json", {"generator": generator, "tolerance": TOLERANCE,
                              "checkpoints": checkpoints, "diagnosis": "extra-premultiply",
                              "scope": "image state only; not pipeline execution or performance"}),
    ):
        (output_dir / name).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
    return images


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("input", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--cli", action="store_true", help="build snapshots with vips CLI operations")
    parser.add_argument("--check", action="store_true", help="verify saved evidence without writing files")
    parser.add_argument("--view", choices=STAGES, help="interactively inspect a private temporary copy")
    args = parser.parse_args()
    try:
        if args.check:
            images = check_saved(args.input.resolve(), args.output.resolve())
        else:
            images = generate(args.input.resolve(), args.output.resolve(), args.cli)
        if args.view:
            view_checkpoint(images[args.view])
    except (pyvips.Error, OSError, ValueError, subprocess.SubprocessError, EOFError) as error:
        print(f"pipeline-checkpoints: {error}", file=sys.stderr)
        if isinstance(error, subprocess.CalledProcessError) and error.stderr:
            print(error.stderr, file=sys.stderr)
        return 1
    print("pipeline-checkpoints: four native snapshots, two previews, all assertions passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Every checkpoint starts with a freshly loaded input, rather than adding consumers to one sequential pipeline. Both composites use float calculations to keep premature quantisation out of the comparison. Assertion or file I/O failures produce a non-zero exit code.

Download the .v files below or open those produced by your local run. Follow the vipsdisp installation instructions, then run:

终端窗口
vipsdisp output/premultiplied.v

Open View → Properties (Alt+Return) to check the bands, pixel type, and metadata, then enable View → Info bar. Move the cursor to the listed (x, y) positions and read the coordinates and RGBA values in the bottom bar. Coordinates start at (0, 0) in the upper-left corner. Brightness controls and transparency displays help with viewing; use the recorded values and properties for diagnosis.

Properties read back from the saved .v files
StageSizeBands / format / interpretationNative file
Source overlay720 × 4804 / uchar / srgbsource.v
After manual premultiply720 × 4804 / float / srgbpremultiplied.v
Wrong composite720 × 4804 / float / srgbwrong.v
Correct composite720 × 4804 / float / srgbcorrect.v
Pixel (238, 126) · RGBA
StageMeasured values (up to three decimals)
Source overlay237, 118, 168, 174
After manual premultiply161.718, 80.518, 114.635, 174
Wrong composite186.584, 128, 155.092, 255
Correct composite237.953, 153.576, 191.506, 255
Pixel (502, 356) · RGBA
StageMeasured values (up to three decimals)
Source overlay133, 213, 113, 130
After manual premultiply67.804, 108.588, 57.608, 130
Wrong composite152.214, 168.104, 147.996, 255
Correct composite185.451, 221.333, 176.235, 255
Pixel (360, 240) · RGBA
StageMeasured values (up to three decimals)
Source overlay214, 229, 243, 219
After manual premultiply183.788, 196.671, 208.694, 219
Wrong composite191.724, 201.376, 213.396, 255
Correct composite217.671, 229.141, 242.859, 255
Pixel (0, 0) · RGBA
StageMeasured values (up to three decimals)
Source overlay0, 0, 0, 0
After manual premultiply0, 0, 0, 0
Wrong composite240, 230, 242, 255
Correct composite240, 230, 242, 255

Download all checked properties, samples to six decimals, and verification results (JSON)

You can inspect the same files without a GUI:

终端窗口
vipsheader --all output/premultiplied.v
vips getpoint output/premultiplied.v 238 126
vips getpoint output/wrong.v 238 126
vips getpoint output/correct.v 238 126

Follow source → premultiplied → wrong. The manual premultiply leaves alpha unchanged but scales RGB by Alpha / 255. The following composite2(..., premultiplied=False) still treats the overlay as straight alpha and premultiplies it internally once more.

For this 8-bit source, write normalised alpha as a = Alpha / 255. With the explicit sRGB compositing space and a fully opaque background, each correct colour channel is C × a + B × (1 − a); the wrong path gives C × a² + B × (1 − a). This is an explanation of this test, not a general recommendation for linear-light compositing.

Remove the unnecessary premultiply() before compositing. The correct path casts to float and composites directly, preserving the expected alpha convention. The fully transparent control at (0, 0) must produce the background in both paths. The other three sampled positions must have visibly darker RGB in the wrong path. The program checks those relationships and verifies the PNG against the explicit rounded conversion.

For another failure, start with two checkpoints immediately before and after the suspected operation. Compare the relevant properties and the same pixel coordinates, then narrow the interval. A result that looks plausible still needs verification.

The main command keeps files in your chosen output directory so you can inspect them repeatedly. For an additional temporary viewing copy, run:

终端窗口
python3 main.py input.svg output/ --view premultiplied

Viewing mode creates a private directory with TemporaryDirectory and launches the viewer with an argument array. It uses neither a fixed /tmp name nor shell interpolation. A viewer may hand the request to an existing instance and return immediately, so the program also waits for confirmation: close this image in the viewer before pressing Enter to delete its temporary file. Launch failures, EOF during confirmation, and exceptional exit clean up that private directory.

Image breakpoints that save, reload, and continue

Section titled “Image breakpoints that save, reload, and continue”

The maintainer suggested extending checkpoints into image breakpoints: save the current image, open the viewer, and return an image reloaded from the .v file. The caller must reassign it, for example image = image_breakpoint(image, "before vertical flip"). Subsequent operations read the materialised file, so they do not follow the old image back into an exhausted sequential input.

This also allows multiple breakpoints in one pipeline: sequential JPEG load → breakpoint 1 (save and reload) → vertical flip → breakpoint 2 (save and reload) → PNG output. A vertical flip deliberately requires reading its source from bottom to top. The .v file supports direct access and can satisfy that read order.

Download the project-owned 1938 × 1292 JPEG as input.jpg. Save the complete source below as breakpoints.py and run:

终端窗口
python3 breakpoints.py input.jpg flipped.png

Each breakpoint filename includes a sequence number and stage name to identify where execution has paused. After inspection, close the current image in the viewer, then press Enter in the terminal to continue. Repeated stage names still produce distinct files. For a headless check, run:

终端窗口
python3 breakpoints.py input.jpg flipped.png --no-viewer

--no-viewer skips only the viewer and confirmation. It still saves and reloads both .v files, then compares every output pixel against an independently decoded random-access control.

Complete source: named breakpoints and a sequential JPEG case
breakpoints.py
"""Inspect and reload named image breakpoints before continuing a pipeline."""
import argparse
from contextlib import contextmanager
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import pyvips
@contextmanager
def image_breakpoints(viewer="vipsdisp"):
"""Keep native files alive until all downstream evaluation has finished."""
executable = shutil.which(viewer) if viewer is not None else None
if viewer is not None and executable is None:
raise ValueError(f"viewer not installed: {viewer}")
with tempfile.TemporaryDirectory(prefix="vips-breakpoints-") as directory:
active = True
sequence = 0
def image_breakpoint(image, name="breakpoint"):
nonlocal sequence
if not active:
raise ValueError("breakpoint session has already ended")
sequence += 1
label = re.sub(r"[^A-Za-z0-9_-]+", "-", name).strip("-")[:64]
filename = Path(directory) / f"{sequence:02d}-{label or 'breakpoint'}.v"
# The directory is private; each call has a new filename, even for
# repeated labels. Never overwrite a file backing an earlier image.
image.write_to_file(str(filename))
if executable is not None:
subprocess.run([executable, str(filename)], check=True)
# vipsdisp may forward this file to an existing instance.
input(f"Inspect {name!r}, close this image in the viewer, "
"then press Enter to continue: ")
# Reassign the caller's image to this new source. Continuing from
# the old image would consume the original sequential stream again.
return pyvips.Image.new_from_file(str(filename), access="random")
try:
yield image_breakpoint
finally:
active = False
def run(input_path, output_path, viewer="vipsdisp"):
if input_path.resolve() == output_path.resolve():
raise ValueError("output must not overwrite the input")
with image_breakpoints(viewer) as image_breakpoint:
image = pyvips.Image.jpegload(str(input_path), access="sequential")
image = image_breakpoint(image, "before vertical flip")
image = image.flip("vertical")
image = image_breakpoint(image, "after vertical flip")
# Complete the sink while both lazy file-backed sources still exist.
image.pngsave(str(output_path))
# Independent decoding, rather than rereading the consumed sequential input.
reference = pyvips.Image.jpegload(str(input_path), access="random").flip("vertical")
actual = pyvips.Image.new_from_file(str(output_path), access="random")
fields = ("width", "height", "bands", "format", "interpretation")
if any(actual.get(field) != reference.get(field) for field in fields):
raise ValueError("output properties differ from the random-access control")
difference = (actual.cast("double") - reference.cast("double")).abs().max()
if difference != 0:
raise ValueError(f"output pixels differ from the control: {difference}")
return {
"pyvips": pyvips.__version__,
"libvips": ".".join(str(pyvips.version(i)) for i in range(3)),
"breakpoints": ["before vertical flip", "after vertical flip"],
"properties": {field: actual.get(field) for field in fields},
"maxPixelDifference": difference,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="JPEG input")
parser.add_argument("output", type=Path, help="PNG output")
parser.add_argument("--no-viewer", action="store_true",
help="run the same materialisation and reloads without a GUI")
args = parser.parse_args()
try:
report = run(args.input, args.output, None if args.no_viewer else "vipsdisp")
except (pyvips.Error, OSError, ValueError, subprocess.SubprocessError, EOFError) as error:
print(f"Breakpoint failed: {error}", file=sys.stderr)
return 1
print(json.dumps(report, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

The JPEG above was exercised on 2026-10-26 with pyvips 3.2.0 / libvips 8.17.3:

Path Observed result
Sequential load → save a copy only → flip the old image Reproduced out of order read
Sequential load → two save-and-reload breakpoints → output Succeeded; 1938 × 1292, 3 bands, uchar, srgb
Breakpoint output versus an independent random-access flip Maximum pixel difference across the whole image: 0

These are headless execution checks; the actual viewer UI review is recorded in the next section. Breakpoints add uncompressed disk storage and I/O and replace upstream dependencies in the execution graph. They help inspect multiple stages, but do not establish the streaming behaviour, peak memory, or performance of the original pipeline. They also cannot fix a read-order error that occurs before the first breakpoint.

These outputs were generated on 2026-10-24 with pyvips 3.2.0, vips CLI 8.17.3, and libvips 8.17.3. Automated checks cover full-image checkpoint pixel round trips, the listed fields, the stage string, four sample positions, and the display conversion. CLI checkpoints are also checked individually against the intended stages. This does not change the cross-binding pixel comparison status of other recipes.

The viewer workflow was checked with Flathub’s vipsdisp 4.2.0: all four files opened, Properties showed the expected fields, and the float readouts at (238, 126) in the Info bar agreed with this evidence. Record the viewer environment separately from the generation environment; its version does not replace the libvips version used to produce these files.

govips v2.20.0 and sharp 0.35.5 both support alpha compositing, but their public save APIs do not expose the native .v snapshot required here. PNG or raw buffers can serve other debugging tasks; they do not replace the complete field set checked by this example. This page does not depend on an unreleased govips API.

Continue with access patterns and materialisation, alpha fundamentals, compositing, and out-of-order diagnosis.