Fixing Map Colours That Change Between Screen and Print
On this page
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
for ramp in ["viridis", "YlGnBu", "tab10"]:
print(ramp)
cmap = matplotlib.colormaps[ramp]
colours = cmap.colors[:7] if ramp == "tab10" else cmap(np.linspace(0.12, 0.94, 7))
print_safe_check(np.asarray(colours)[:, :3])
viridis
lightness range 25.0 โ 86.6
min adjacent ฮE 27.1 ok
min lightness step 9.9 ok
YlGnBu
lightness range 19.8 โ 95.5
min adjacent ฮE 14.6 ok
min lightness step 7.2 ok
tab10
lightness range 42.4 โ 66.9
min adjacent ฮE 52.1 ok
min lightness step 4.6 relies on hue โ risky in print
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.
Find what changed, then choose printable colours
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
Round-tripped through a real press profile in Example 4, pure sRGB blue moves ฮE 90.8 on coated paper, a pure green 55.4 and a pure red 21.4, while the site's own #1a3a6b indigo moves 1.6 โ and 20.0 on uncoated paper, where every one of them moves further. Pulling saturation back 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 = matplotlib.colormaps["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 in Example 4 on the trimmed ramps (12โ94% of the way along), with the smallest adjacent ฮE on screen, then through a coated (FOGRA39) and an uncoated (FOGRA29) press profile: viridis goes 27.1 โ 23.1 โ 9.7 at seven classes and 19.4 โ 17.0 โ 5.5 at nine; YlGnBu goes 14.6 โ 12.5 โ 8.0 and 10.2 โ 9.0 โ 7.5. The paper decides as much as the ramp: uncoated stock lifts the darkest class from L* 27 to 39, and nine viridis classes that are comfortably separate on coated paper merge on uncoated.
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.
Proofing a palette against two press profiles
The data used on this page
Two real press profiles โ FOGRA39 (ISO coated) and FOGRA29 (ISO uncoated) โ taken from Debian's colord-data package, and the colour helpers the examples share. Example 2 proofs a real choropleth: population change 2020โ25 in Great Britain's local authority districts, from the ONS boundaries and NOMIS estimates.
import io
import os
import tarfile
from pathlib import Path
import matplotlib
import numpy as np
import requests
from PIL import Image, ImageCms
matplotlib.use("Agg")
def ar_members(blob):
"""The members of a .deb (an ar archive), without needing dpkg."""
pos = 8
while pos < len(blob):
name, size = blob[pos:pos + 16].decode().strip(), int(blob[pos + 48:pos + 58])
yield name, blob[pos + 60:pos + 60 + size]
pos += 60 + size + size % 2
PROFILES = {"coated": "FOGRA39L_coated.icc", "uncoated": "FOGRA29L_uncoated.icc"}
if not all(Path(p).exists() for p in PROFILES.values()):
deb = requests.get("http://deb.debian.org/debian/pool/main/c/colord/colord-data_1.4.7-3_all.deb", timeout=120)
deb.raise_for_status()
for name, data in ar_members(deb.content):
if name.startswith("data.tar"):
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
for member in archive.getmembers():
if Path(member.name).name in PROFILES.values():
Path(Path(member.name).name).write_bytes(archive.extractfile(member).read())
def srgb_to_linear(c):
c = np.asarray(c, dtype=float)
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def linear_to_srgb(c):
return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.clip(c, 0, None) ** (1 / 2.4) - 0.055)
M = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]])
WHITE = np.array([0.95047, 1.0, 1.08883])
def to_lab(rgb):
"""sRGB (0โ1) to CIE L*a*b* under D65."""
xyz = srgb_to_linear(np.atleast_2d(np.asarray(rgb, dtype=float))[:, :3]) @ M.T / WHITE
d = 6 / 29
f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4 / 29)
return np.stack([116 * f[:, 1] - 16, 500 * (f[:, 0] - f[:, 1]), 200 * (f[:, 1] - f[:, 2])], axis=1)
def lab_to_rgb(lab):
lab = np.atleast_2d(lab)
fy = (lab[:, 0] + 16) / 116
f = np.stack([fy + lab[:, 1] / 500, fy, fy - lab[:, 2] / 200], axis=1)
d = 6 / 29
xyz = np.where(f > d, f ** 3, 3 * d * d * (f - 4 / 29)) * WHITE
return linear_to_srgb(xyz @ np.linalg.inv(M).T)
print({paper: f"{Path(p).stat().st_size:,} bytes" for paper, p in PROFILES.items()})
{'coated': '121,908 bytes', 'uncoated': '121,308 bytes'}
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
for ramp in ["viridis", "YlGnBu"]:
for n in (7, 9):
print_audit(ramp, n)
viridis, 7 classes
lightness 25.0 โ 86.6
min lightness step 9.9 carried by lightness
min adjacent ฮE 27.1 ok
lightest vs page ฮE 91.1 visible
2 class(es) highly saturated โ likely to shift in CMYK
viridis, 9 classes
lightness 25.0 โ 86.6
min lightness step 7.3 carried by lightness
min adjacent ฮE 19.4 ok
lightest vs page ฮE 91.1 visible
3 class(es) highly saturated โ likely to shift in CMYK
YlGnBu, 7 classes
lightness 19.8 โ 95.5
min lightness step 7.2 carried by lightness
min adjacent ฮE 14.6 ok
lightest vs page ฮE 35.4 visible
YlGnBu, 9 classes
lightness 19.8 โ 95.5
min lightness step 5.2 carried by lightness
min adjacent ฮE 10.2 ok
lightest vs page ฮE 35.4 visible
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
A real map to proof: Great Britain's population change 2020โ25, in seven YlGnBu classes and in seven classes of tab10 hues of similar lightness.
import geopandas as gpd
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
NOMIS = ("https://www.nomisweb.co.uk/api/v01/dataset/NM_2002_1.data.csv?geography=TYPE424&gender=0"
"&c_age=200&measures=20100&select=date_name,geography_code,obs_value&date=")
if not os.path.exists("districts.gpkg"):
reply = requests.get(ONS, timeout=120, params={"where": "1=1", "outFields": "LAD24CD", "outSR": 27700, "f": "geojson"})
reply.raise_for_status()
population = (pd.concat([pd.read_csv(NOMIS + year) for year in ("2020", "2025")])
.pivot(index="GEOGRAPHY_CODE", columns="DATE_NAME", values="OBS_VALUE"))
change = ((population[2025] / population[2020] - 1) * 100).rename("change")
(gpd.read_file(reply.text).merge(change, left_on="LAD24CD", right_index=True)
.dropna(subset=["change"]).to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
hue_only = ListedColormap(np.asarray(matplotlib.colormaps["tab10"].colors)[[0, 1, 2, 3, 4, 6, 9]])
for label, cmap in [("YlGnBu", ListedColormap(matplotlib.colormaps["YlGnBu"](np.linspace(0.12, 0.94, 7)))),
("tab10 hues", hue_only)]:
fig, ax = plt.subplots(figsize=(5, 6))
districts.plot(column="change", scheme="quantiles", k=7, cmap=cmap, ax=ax, edgecolor="none")
ax.set_axis_off()
print(label)
greyscale_proof(fig, f"proof_{label.split()[0]}.png", dpi=80)
plt.close(fig)
YlGnBu
wrote proof_YlGnBu.png
lightness spread (std): 46.0 classes separate
tab10 hues
wrote proof_tab10.png
lightness spread (std): 51.8 classes separate
Both pass, and the hue-only map scores higher. The standard deviation of the whole image is dominated by the white margin against the land, so it says the picture has contrast, not that the classes do. Look at the proofs โ or measure the classes themselves, as grey levels in class order:
def grey_steps(cmap, n=7):
grey = np.asarray(Image.fromarray((np.asarray([cmap(i / (n - 1))[:3] for i in range(n)]) * 255)
.astype("uint8").reshape(1, n, 3)).convert("L"), dtype=int).ravel()
return grey, np.abs(np.diff(grey))
for label, cmap in [("YlGnBu", ListedColormap(matplotlib.colormaps["YlGnBu"](np.linspace(0.12, 0.94, 7)))),
("tab10 hues", hue_only)]:
grey, steps = grey_steps(cmap)
print(f"{label:10} grey levels {grey.tolist()} smallest step {steps.min()}, "
f"ordered: {bool((np.diff(grey) < 0).all() or (np.diff(grey) > 0).all())}")
YlGnBu grey levels [237, 215, 175, 141, 105, 73, 42] smallest step 22, ordered: True
tab10 hues grey levels [100, 152, 112, 91, 126, 160, 142] smallest step 18, ordered: False
That is the test the greyscale copy is really performing. The ramp's classes step down in grey, in order. The hue-only palette's greys jump about, so in a greyscale copy the order is gone, and classes far apart in the data print as near-identical greys: the second and sixth at 152 and 160, the first and fourth at 100 and 91.
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
for ramp in ["YlGnBu", "viridis"]:
print(ramp)
compare_saturation(ramp)
YlGnBu
lightness unchanged: max shift 0.00 L*
chroma reduced by 15%
viridis
lightness unchanged: max shift 0.00 L*
chroma reduced by 15%
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.
Example 4 โ simulating the press with a real ICC profile
The checks above are predictions. LittleCMS, through Pillow, can do what a RIP does: convert each colour to the press's CMYK with a real profile and a rendering intent, then back to sRGB, so what comes back is the colour the press can actually make:
def via_press(colours, paper, intent=ImageCms.Intent.RELATIVE_COLORIMETRIC):
srgb, press = ImageCms.createProfile("sRGB"), ImageCms.getOpenProfile(PROFILES[paper])
image = Image.fromarray((np.clip(colours, 0, 1) * 255).round().astype("uint8").reshape(1, -1, 3))
cmyk = ImageCms.applyTransform(image, ImageCms.buildTransform(srgb, press, "RGB", "CMYK", intent))
back = ImageCms.applyTransform(cmyk, ImageCms.buildTransform(press, srgb, "CMYK", "RGB", intent))
return np.asarray(back, dtype=float).reshape(-1, 3) / 255
swatches = {"pure blue": "#0000ff", "pure green": "#00ff00", "pure red": "#ff0000", "red #ef4444": "#ef4444",
"sky #0ea5e9": "#0ea5e9", "indigo #1a3a6b": "#1a3a6b", "mid grey": "#808080"}
rgb = np.array([matplotlib.colors.to_rgb(h) for h in swatches.values()])
shift = {paper: np.linalg.norm(to_lab(rgb) - to_lab(via_press(rgb, paper)), axis=1) for paper in PROFILES}
print(f"{'colour':16} {'coated ฮE':>10} {'uncoated ฮE':>12}")
for i, name in enumerate(swatches):
print(f"{name:16} {shift['coated'][i]:10.1f} {shift['uncoated'][i]:12.1f}")
print(f"\n{'ramp':8} {'classes':>7} {'screen':>7} {'coated':>7} {'uncoated':>9} darkest L* coated/uncoated")
for ramp in ["viridis", "YlGnBu", "Blues"]:
for n in (7, 9):
colours = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
seen = {"screen": colours, **{paper: via_press(colours, paper) for paper in PROFILES}}
gap = {k: np.linalg.norm(np.diff(to_lab(v), axis=0), axis=1).min() for k, v in seen.items()}
dark = [to_lab(seen[p])[:, 0].min() for p in PROFILES]
print(f"{ramp:8} {n:>7} {gap['screen']:7.1f} {gap['coated']:7.1f} {gap['uncoated']:9.1f} "
f"{dark[0]:.1f} / {dark[1]:.1f}")
colour coated ฮE uncoated ฮE
pure blue 90.8 105.2
pure green 55.4 75.9
pure red 21.4 43.2
red #ef4444 2.5 12.7
sky #0ea5e9 6.8 9.1
indigo #1a3a6b 1.6 20.0
mid grey 0.0 0.7
ramp classes screen coated uncoated darkest L* coated/uncoated
viridis 7 27.1 23.1 9.7 27.3 / 38.9
viridis 9 19.4 17.0 5.5 27.3 / 38.9
YlGnBu 7 14.6 12.5 8.0 25.4 / 38.4
YlGnBu 9 10.2 9.0 7.5 25.4 / 38.4
Blues 7 8.6 9.6 9.4 30.3 / 40.7
Blues 9 6.3 7.0 5.9 30.3 / 40.7
Two things the earlier checks could not show. The saturated primaries move enormously โ pure blue by ฮE 90.8 even on coated paper โ while low-chroma colours like the indigo barely move on coated paper, which is the gamut argument in numbers. And the paper matters as much as the ramp: uncoated stock cannot print a dark colour, so every ramp's darkest class lifts from about L* 25โ30 to 38โ41 and the classes squeeze together. Nine viridis classes keep ฮE 17.0 on coated paper and fall to 5.5 on uncoated. These are two standard profiles, not your press; the method is what transfers โ ask the printer for their profile and run the same function.
Gamuts that overlap but do not nest
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
A screen lights every pixel, including the lightest tint, 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 nine classes starts at a minimum adjacent ฮE of 10.2 on screen โ barely above the threshold at which two classes are reliably distinguishable โ and ends at 9.0 on coated paper and 7.5 on uncoated. Dropping to five or six classes restores the margin far more reliably than swapping the ramp.
Paper stock, colour management and dark fills
- 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.