Fixing Map Colours That Change Between Screen and Print
Problem statement
The choropleth is a clean sequence of blues on screen. Printed, the two lightest classes are the same colour, the darkest has gone muddy, and the highlight red has turned orange.
Nothing in the file changed. What changed is the device: a screen emits light in RGB with a wide gamut, and a printer absorbs light using CMYK inks with a narrower one. Colours outside the printer's gamut are clipped to the nearest reproducible colour, and that clipping is not uniform โ saturated colours move furthest, which is why the highlight shifts and the ramp compresses.
There are three separate problems here and they need different responses:
- gamut โ the colour cannot be printed at all and is being substituted
- separation โ two classes that differ by enough on screen do not after clipping
- calibration โ the screen is not showing what the file contains
Quick answer
Design for the narrower device, and check separation in lightness rather than in hue:
def print_safe_check(colours, min_dE=10.0):
"""Two tests that survive the trip to CMYK: lightness spread and
adjacent separation. Both are computed in CIE L*a*b*."""
lab = to_lab(colours)
lightness = lab[:, 0]
adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)
lightness_steps = np.abs(np.diff(lightness))
print(f"lightness range {lightness.min():5.1f} โ {lightness.max():5.1f}")
print(f"min adjacent ฮE {adjacent.min():5.1f}"
f" {'ok' if adjacent.min() >= min_dE else 'classes will merge'}")
print(f"min lightness step {lightness_steps.min():5.1f}"
f" {'ok' if lightness_steps.min() >= 5 else 'relies on hue โ risky in print'}")
return adjacent.min() >= min_dE and lightness_steps.min() >= 5
A ramp whose classes are separated by lightness survives printing, greyscale copying and colour vision deficiency. One separated by hue alone survives none of them reliably.
Step-by-step solution
1. Establish what actually changed
Three checks in order:
- Print a greyscale copy. If the classes are indistinguishable there, they were separated by hue alone and the printer's gamut is not the root cause.
- Compare a printed swatch chart with the screen. Which colours moved? Saturated ones moving most points at gamut clipping.
- Check the screen. An uncalibrated monitor showing a wide-gamut file makes everything look more saturated than it is.
2. Prefer ramps with monotonic lightness
This is the fix that addresses most of the symptoms at once. Measured over 32 samples: viridis runs from L* 14.9 to 90.9 with no reversals and a largest step of 2.94; YlGnBu runs 13.4 to 99.1; jet reverses direction three times with steps up to 9.51.
A monotonic ramp keeps its ordering when the hues are clipped, because the ordering is carried by lightness โ which CMYK reproduces well even where it cannot match the hue.
3. Avoid the colours printers cannot reach
The usual offenders, in a standard CMYK gamut:
- saturated blues and violets โ they shift towards purple and lose depth
- bright greens โ noticeably duller
- bright oranges and reds at full saturation โ the highlight-red problem
- very light tints below about 5% โ they may not print at all, or print as blank
Pulling saturation back by 10โ20% before exporting costs little on screen and removes most of the shift.
4. Keep the lightest class distinguishable from the page
A ramp's lightest class is frequently near-white, and on paper the difference between a 3% tint and the page is invisible. Trim the ramp so the lightest class is a visible tint:
colours = plt.get_cmap("YlGnBu")(np.linspace(0.12, 0.94, n))
Trimming the top of the ramp also stops the darkest class merging with the boundary lines and the type.
5. Increase the lightness step rather than the number of classes
If classes merge in print, the immediate reaction is usually to change the ramp. The more reliable fix is fewer classes with bigger lightness steps between them.
Measured on real ramps at two class counts: viridis gives a minimum adjacent ฮE of 23.5 at seven classes and 16.7 at nine; YlGnBu gives 10.3 and 8.9. Nine YlGnBu classes have two steps that were already marginal on screen โ printing is what finally merges them.
6. Prove it before you commit
The reliable test is a physical proof: print the map, on the paper it will be printed on, from the machine that will print it. Everything else is a prediction.
Where that is not possible, a greyscale print is the next best thing โ it catches every failure that depends on hue, which is most of them.
Code examples
Example 1 โ auditing a palette for print
import numpy as np
import matplotlib
import matplotlib.colors as mc
def print_audit(ramp, n=7, page_white=(1, 1, 1)):
colours = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
lab = to_lab(colours)
lightness = lab[:, 0]
print(f"{ramp}, {n} classes")
print(f" lightness {lightness.min():5.1f} โ {lightness.max():5.1f}")
steps = np.abs(np.diff(lightness))
print(f" min lightness step {steps.min():5.1f}"
f" {'carried by lightness' if steps.min() >= 5 else 'relies on hue'}")
adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)
print(f" min adjacent ฮE {adjacent.min():5.1f}"
f" {'ok' if adjacent.min() >= 10 else 'will merge in print'}")
to_page = np.linalg.norm(lab[0] - to_lab([page_white])[0])
print(f" lightest vs page ฮE {to_page:5.1f}"
f" {'visible' if to_page >= 8 else 'may vanish on paper'}")
saturation = np.linalg.norm(lab[:, 1:], axis=1)
hot = int((saturation > 60).sum())
if hot:
print(f" {hot} class(es) highly saturated โ likely to shift in CMYK")
return colours
Example 2 โ a greyscale proof, in code
import io
import numpy as np
from PIL import Image
def greyscale_proof(fig, path="proof_grey.png", dpi=200):
"""The cheapest print test there is: does the map work without hue?"""
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
buf.seek(0)
grey = Image.open(buf).convert("L")
grey.save(path)
arr = np.asarray(grey, dtype=float)
print(f"wrote {path}")
print(f"lightness spread (std): {arr.std():.1f} "
f"{'classes separate' if arr.std() > 25 else 'flat โ classes merge'}")
return grey
Example 3 โ desaturating a palette to keep it inside a printable gamut
def pull_back_saturation(colours, factor=0.85):
"""Reduce chroma without moving hue or lightness.
A crude but effective way to keep colours inside a CMYK gamut: scale the
a* and b* channels in L*a*b*, which leaves the lightness ordering intact.
"""
lab = to_lab(colours).copy()
lab[:, 1:] *= factor
return np.clip(lab_to_rgb(lab), 0, 1)
def compare_saturation(ramp, n=7):
original = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
pulled = pull_back_saturation(original)
lab_a, lab_b = to_lab(original), to_lab(pulled)
print(f"lightness unchanged: max shift {np.abs(lab_a[:, 0] - lab_b[:, 0]).max():.2f} L*")
print(f"chroma reduced by {100 * (1 - 0.85):.0f}%")
return original, pulled
Scaling chroma in Lab* keeps the lightness ordering exactly, which is the property that has to survive. Doing the same in RGB moves lightness as well and can break the ordering the ramp depends on.
Explanation
Why gamut clipping is not uniform
A screen's RGB gamut and a printer's CMYK gamut overlap but do not nest. Colours inside both reproduce faithfully; colours outside the printer's are mapped to the nearest reproducible colour, and "nearest" depends on the rendering intent.
Saturated colours are furthest outside, so they move most. That is why a highlight red shifts noticeably while a mid-grey does not, and why a ramp's darkest, most saturated end compresses while its middle survives.
Why lightness is the channel that survives
CMYK reproduces lightness well โ that is what the black plate is for โ and hue and saturation less well. A ramp that encodes its ordering in lightness therefore keeps its ordering through the conversion, even where the specific hues shift.
The same property makes such a ramp work in greyscale, in photocopies, on a projector and for colour-blind readers. Choosing a monotonic-lightness ramp is one decision that satisfies five separate constraints.
Why the lightest class disappears on paper
Screens show a light tint against a black-when-off background, so a 5% tint is clearly visible. Paper is already white, and a 5% tint of ink on white paper is close to nothing โ some presses will not lay it down at all.
Trimming the ramp so the lightest class is around 12% into it keeps a visible tint. It costs a little of the ramp's range and removes a failure mode that only appears after printing.
Why fewer classes beats a different ramp
Print compresses the differences between colours. A ramp whose adjacent classes were already close on screen has no margin left.
The measurements make the trade concrete: YlGnBu at seven classes has a minimum adjacent ฮE of 10.3, and at nine it is 8.9 โ already below the threshold at which two classes are reliably distinguishable, before any device conversion. Dropping to five or six classes restores the margin far more reliably than swapping the ramp.
Edge cases or notes
- Ask which press and which paper. Coated and uncoated stock have visibly different gamuts.
- Matplotlib has no colour management. It writes RGB; conversion happens downstream.
- Rendering intent matters โ perceptual compresses the whole gamut, relative colorimetric clips only what is outside.
- Screens vary. An uncalibrated monitor is a prediction, not a measurement.
- Very dark colours can fill in on absorbent paper: a 95% black area may print as solid.
- Transparency is often flattened in print workflows, which changes the composited colour.
- A greyscale print catches most failures and costs nothing.
- Keep the tested palette in a module with its measured lightness steps recorded.
Internal links
- Colour on maps explained: sequential, diverging and qualitative โ the lightness measurements
- How to choose and test a colour ramp in Python โ the ramp test suite
- How to check a map for colour-blind readers โ the same lightness argument
- Accessible maps explained: contrast, text and alternatives โ contrast against the page
- How to export a map at print quality โ the export settings around this
- Fixing choropleth colours that look wrong โ when the classification is the cause
- Choropleth classification explained โ fewer classes, bigger steps
- How to build a reusable map style module โ where a print-tested palette lives
FAQ
Why do my map colours look different when printed?
The printer's CMYK gamut is narrower than the screen's RGB gamut, so colours outside it are clipped to the nearest printable colour. Saturated colours move most.
Which colours are most likely to shift?
Saturated blues and violets, bright greens, and full-saturation oranges and reds. Pulling chroma back 10โ20% removes most of the shift.
Why did two classes merge in print?
They were too close to begin with. YlGnBu at nine classes has a minimum adjacent ฮE of 8.9 โ below the reliable threshold before any device conversion. Use fewer classes.
Why has the lightest class vanished on paper?
A very light tint is invisible against white paper and some presses will not print it. Trim the ramp so the lightest class starts around 12% in.
How do I test without a proof print?
Print in greyscale. It catches every failure that depends on hue, which is most of them, and it takes a minute.
Does matplotlib support CMYK?
No. It writes RGB and the conversion happens in the print workflow. Design for the narrower gamut instead of trying to control the conversion.