Inspect intermediate images with .v and vipsdisp
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.
Compare the failure and the fix
Section titled “Compare the failure and the fix”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.

Generated with pyvips 3.2.0 / libvips 8.17.3.

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.
Why save native .v state?
Section titled “Why save native .v state?”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.
Run the complete example
Section titled “Run the complete example”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())Run command
python3 main.py --cli 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())Reason govips v2.20.0 exposes Alpha composition, but has no public native .v checkpoint saver. Exporting PNG and converting it to .v cannot recover the original float image state.
Reason sharp 0.35.5 exposes Alpha composition, but its public output API does not expose native .v snapshots. A PNG or raw buffer is not an equivalent checkpoint of all the fields verified here.
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.
Inspect the four stages in a viewer
Section titled “Inspect the four stages in a viewer”Download the .v files below or open those produced by your local run. Follow the vipsdisp installation instructions, then run:
vipsdisp output/premultiplied.vOpen 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.
| Stage | Size | Bands / format / interpretation | Native file |
|---|---|---|---|
| Source overlay | 720 × 480 | 4 / uchar / srgb | source.v |
| After manual premultiply | 720 × 480 | 4 / float / srgb | premultiplied.v |
| Wrong composite | 720 × 480 | 4 / float / srgb | wrong.v |
| Correct composite | 720 × 480 | 4 / float / srgb | correct.v |
| Stage | Measured values (up to three decimals) |
|---|---|
| Source overlay | 237, 118, 168, 174 |
| After manual premultiply | 161.718, 80.518, 114.635, 174 |
| Wrong composite | 186.584, 128, 155.092, 255 |
| Correct composite | 237.953, 153.576, 191.506, 255 |
| Stage | Measured values (up to three decimals) |
|---|---|
| Source overlay | 133, 213, 113, 130 |
| After manual premultiply | 67.804, 108.588, 57.608, 130 |
| Wrong composite | 152.214, 168.104, 147.996, 255 |
| Correct composite | 185.451, 221.333, 176.235, 255 |
| Stage | Measured values (up to three decimals) |
|---|---|
| Source overlay | 214, 229, 243, 219 |
| After manual premultiply | 183.788, 196.671, 208.694, 219 |
| Wrong composite | 191.724, 201.376, 213.396, 255 |
| Correct composite | 217.671, 229.141, 242.859, 255 |
| Stage | Measured values (up to three decimals) |
|---|---|
| Source overlay | 0, 0, 0, 0 |
| After manual premultiply | 0, 0, 0, 0 |
| Wrong composite | 240, 230, 242, 255 |
| Correct composite | 240, 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.vvips getpoint output/premultiplied.v 238 126vips getpoint output/wrong.v 238 126vips getpoint output/correct.v 238 126Locate the extra premultiply
Section titled “Locate the extra premultiply”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.
Temporary files and sequential inputs
Section titled “Temporary files and sequential inputs”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 premultipliedViewing 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.pngEach 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
"""Inspect and reload named image breakpoints before continuing a pipeline."""
import argparsefrom contextlib import contextmanagerimport jsonfrom pathlib import Pathimport reimport shutilimport subprocessimport sysimport tempfile
import pyvips
@contextmanagerdef 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.
Versions and verification boundary
Section titled “Versions and verification boundary”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.