Debugging Colour Shift Between Screen and Print Exports
Sample the same pixel from both files and compute a perceptual distance before doing anything else, because “the print looks warmer” is consistent with four different faults — an untagged export, a gamut clip, a wrong rendering intent, and a viewer applying its own transform — and each has a different fix.
Core Diagnosis: Symptom to Root Cause to Fix
The four causes are distinguishable by which colours moved.
Everything moved, including neutrals. The file is untagged, or is tagged with a profile that does not describe how it was rendered. Every consumer then applies its own assumption, which is why the same file looks different in two applications. Fix by embedding the correct profile at export.
Only saturated colours moved; neutrals are fine. A gamut clip. The destination space cannot represent those colours and the conversion mapped them to its boundary. Fix in the palette, not the conversion — the discussion under Color Profile and CMYK Conversion covers which map colours typically fall outside a coated CMYK gamut.
Everything moved slightly, including colours that were well inside the destination gamut. A perceptual rendering intent, which compresses the whole gamut to make room for the out-of-range colours. Relative colorimetric would have reproduced the in-gamut colours exactly.
Nothing moved in the file; only the display differs. A viewer that is not colour-managed. The file is fine.
Production-Ready Python Implementation
from PIL import Image, ImageCms
import io
import numpy as np
def describe_export(path: str) -> dict:
"""Report the colour contract of an exported raster."""
img = Image.open(path)
raw = img.info.get("icc_profile")
if raw is None:
return {"tagged": False,
"note": "no embedded profile — every consumer will guess"}
profile = ImageCms.ImageCmsProfile(io.BytesIO(raw))
return {"tagged": True,
"profile": ImageCms.getProfileDescription(profile).strip(),
"space": ImageCms.getProfileInfo(profile)[:40].strip(),
"mode": img.mode}
def compare_pixels(a_path: str, b_path: str, samples: list) -> list:
"""Perceptual distance between the same sample points in two exports."""
a, b = Image.open(a_path).convert("LAB"), Image.open(b_path).convert("LAB")
if a.size != b.size:
raise ValueError("exports differ in size; compare like with like")
out = []
for x, y in samples:
pa = np.array(a.getpixel((x, y)), dtype="float32")
pb = np.array(b.getpixel((x, y)), dtype="float32")
# Lab is roughly perceptually uniform: Euclidean distance is a usable ΔE.
out.append({"xy": (x, y), "delta_e": round(float(np.linalg.norm(pa - pb)), 2),
"neutral": bool(abs(pa[1] - 128) < 4 and abs(pa[2] - 128) < 4)})
return out
compare_pixels reports whether each sample was neutral, which is what turns a list of distances into a diagnosis: neutrals moving alongside saturated colours points at the profile, neutrals holding still points at the gamut.
Performance Tuning and Cartographic Best Practices
- Tag every export, always. It costs one argument and removes an entire class of ambiguity. An untagged file is not “sRGB by convention” — it is undefined, and different consumers resolve it differently.
- Soft-proof in the pipeline. Convert to the destination profile and back, then report the largest perceptual distance across the palette. Anything above a few units is a colour the press cannot reproduce, and the palette should change while it is still editable.
- Check the gamut before conversion. Afterwards the original chroma is gone and two classes that were distinct may share a value.
- Use relative colorimetric for maps. Perceptual is the common default in image software and the wrong choice for a map with a printed legend, because it moves colours that had exact equivalents.
- Sample the same pixel, not the same feature. A feature can move by a pixel between exports for reasons unrelated to colour, and comparing across that offset measures the wrong thing.
Integration and Next Steps
The soft-proof check belongs beside the contrast validation in the export pipeline, and its output feeds the palette work described in Color Theory for GIS — a class that fails the round trip should be adjusted in the palette generator rather than corrected per export. Where the destination is a specific press, obtain its profile rather than using a generic coated one; the difference between them is larger than most of the shifts being debugged.
Frequently Asked Questions
How do I tell a gamut clip from a profile problem?
By whether neutrals moved. A gamut clip affects only colours the destination cannot represent, so greys and pale fills reproduce correctly while the saturated hydrology cyan flattens. A profile problem reinterprets the whole encoding, so neutrals shift along with everything else. Sample one grey and one saturated fill from the same export and compare both — if only the saturated sample moved, the fix belongs in the palette rather than in the conversion settings.
Why does the same file look different in two applications?
Because one is colour-managed and the other is not. A managed application reads the embedded profile and converts to the display profile; a naive one sends the stored numbers straight to the screen. If the file is tagged, the managed view is the correct one and the naive view is showing raw numbers. If the file is untagged, neither view is authoritative — and that ambiguity is itself the bug, fixed by embedding a profile at export.
Is a visual comparison ever good enough?
For noticing that something changed, yes. For determining what changed, no. Surround colour, ambient lighting and display calibration each move a visual judgement by more than the shifts typically under investigation. Sample the same pixel from both files, convert to a perceptually uniform space, and compute a distance. A number distinguishes “the conversion moved this class by 6 units” from “this looks warmer to me in this room”.
Should I soft-proof before every print export?
For anything going to press, yes — and it should be automated rather than a manual step. Converting to the destination profile and back, then reporting the largest perceptual distance across the palette, costs two conversions and catches every colour the press cannot reproduce while the palette is still editable. Doing it manually means it happens on the first export of a project and not on the twentieth, which is where the palette will have quietly acquired a new class.
Related
- Color Theory for GIS — where a failing class should be corrected.
- Converting RGB Basemaps to CMYK with LittleCMS — the conversion this page debugs.
Back to Color Theory for GIS