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.

Two panels listing colours that shift in CMYK and colours that survive.
Saturated colours are furthest outside the printable gamut, so they move furthest.

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.

Five constraints all satisfied by a ramp with monotonic lightness.
A ramp separated by hue alone satisfies none of them reliably.

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.

Grid of minimum adjacent colour distance for two ramps at seven and nine classes.
Dropping to five or six classes restores the margin more reliably than changing 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.

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.