Why Do the Colours Flicker in My Map Animation?

On this page

Problem statement

You render a choropleth for each week, play the frames back, and counties blink between colours even though their values barely moved. Nothing in the data is flickering. The colour key is. When each frame is classified on its own, with its own quantiles or its own minimum and maximum, the same value gets a different colour in different frames. The reader cannot see the key change. They only see the colours change, and they read that as the data changing.

This page reproduces the problem on a year of weekly US Drought Monitor statistics for Texas's 254 counties, measures how many colour changes are false, and fixes it.

Quick answer

Decide the class breaks once, for all frames, and reuse them for every frame:

BREAKS = [10, 25, 50, 75]                                # % of county area in drought, fixed for every week
classes = wide.apply(lambda col: pd.Series(np.digitize(col, BREAKS, right=True), index=col.index))
print(f"{wide.shape[1]} weeks x {wide.shape[0]} counties classified with one set of breaks: {BREAKS}")
54 weeks x 254 counties classified with one set of breaks: [10, 25, 50, 75]
Six maps of Texas counties for 22 October, 29 October and 5 November 2024: in the top row, classified by each week's quintiles, the state changes from mid-orange to pale and back; in the bottom row, with fixed breaks, the same weeks look almost identical, with deep brown drought across the east and west and pale counties in the centre and south. Six maps of Texas counties for 22 October, 29 October and 5 November 2024: in the top row, classified by each week's quintiles, the state changes from mid-orange to pale and back; in the bottom row, with fixed breaks, the same weeks look almost identical, with deep brown drought across the east and west and pale counties in the centre and south.
The same three weeks of drought, classified two ways. With weekly quintiles 168 counties change colour each week; with fixed breaks at 10, 25, 50 and 75% of county area, 42 and then 37 do. Colours run from under 10% (palest) to over 75% (darkest).

See the breaks move, count false changes, fix the breaks

1. See the breaks move

Classify each week by its own quintiles, the default in many tools, and print the breaks for a few weeks:

def quintiles(col):
    return mapclassify.Quantiles(col.to_numpy(), k=5)

for week in wide.columns[[0, 20, 40]]:
    q = quintiles(wide[week])
    print(f"{week:%d %b %Y}: {len(q.bins)} classes, upper bounds {np.round(q.bins, 1).tolist()}")
26 Dec 2023: 3 classes, upper bounds [0.0, 46.3, 100.0]
14 May 2024: 3 classes, upper bounds [0.0, 18.0, 100.0]
01 Oct 2024: 4 classes, upper bounds [0.0, 16.2, 90.8, 100.0]

The breaks move from week to week, and there are fewer than five classes. Most counties sit at exactly 0% or 100%, and quantiles cannot split tied values, so the classes collapse.

2. Count the false changes

Take every county-week whose drought share changed by less than one percentage point since the previous week. Its colour should not change. Count how often it does:

per_week = {w: pd.Series(quintiles(wide[w]).yb, index=wide.index) for w in wide.columns}
still = flips_q = flips_f = 0
for a, b in zip(wide.columns[:-1], wide.columns[1:]):
    s = (wide[b] - wide[a]).abs() < 1
    still += s.sum()
    flips_q += (per_week[a][s] != per_week[b][s]).sum()
    flips_f += (classes[a][s] != classes[b][s]).sum()
print(f"{still:,} county-weeks changed by < 1 point; colour changed anyway: "
      f"{flips_q:,} ({flips_q / still:.1%}) with weekly quintiles, {flips_f} ({flips_f / still:.2%}) with fixed breaks")
11,717 county-weeks changed by < 1 point; colour changed anyway: 1,253 (10.7%) with weekly quintiles, 2 (0.02%) with fixed breaks
Share of Texas county-weeks that changed colour although their drought share moved by less than one point, by classification. colour changes with no change in the dataweekly quintiles1,253 of 11,717 (10.7%)fixed breaks2 of 11,717 (0.02%)06%12%US Drought Monitor county statistics, Texas, 54 weeks spatialworkflow.io
With per-week quintiles, one county in ten changes colour in a week when its value did not move.

3. Choose breaks that hold for every frame

Fixed breaks can come from meaning (10%, 25%, 50%, 75% of a county in drought) or from the data, computed once on all weeks pooled. Check that the pooled values use all the classes:

pooled = wide.to_numpy().ravel()
counts = np.bincount(np.digitize(pooled, BREAKS, right=True), minlength=len(BREAKS) + 1)
labels = ["≤10%", "10–25%", "25–50%", "50–75%", ">75%"]
print(", ".join(f"{l}: {c / counts.sum():.0%}" for l, c in zip(labels, counts)))
≤10%: 64%, 10–25%: 4%, 25–50%: 5%, 50–75%: 4%, >75%: 24%

Per-frame and fixed classifications side by side

The data used on this page

County statistics from the US Drought Monitor data services: the percentage of each Texas county's area in moderate drought or worse (D1+) for every weekly map from 26 December 2023 to 31 December 2024; county outlines from the US Census Bureau cartographic boundary files, 2023.

import io
import warnings
from pathlib import Path

import geopandas as gpd
import mapclassify
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests

warnings.filterwarnings("ignore", message="Not enough unique values")   # mapclassify warns when quantiles collapse
Path("data").mkdir(exist_ok=True)
CSV = Path("data/tx_county_2024.csv")
if not CSV.exists():
    CSV.write_text(requests.get("https://usdmdataservices.unl.edu/api/CountyStatistics/GetDroughtSeverityStatisticsByAreaPercent",
                                params={"aoi": "TX", "startdate": "1/1/2024", "enddate": "12/31/2024", "statisticsType": 1},
                                headers={"Accept": "text/csv"}, timeout=120).text)
if not Path("data/cb_2023_us_county_20m.zip").exists():
    Path("data/cb_2023_us_county_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip", timeout=300).content)
stats = pd.read_csv(CSV)
stats["week"] = pd.to_datetime(stats.MapDate.astype(str))
wide = stats.pivot(index="FIPS", columns="week", values="D1").sort_index(axis=1)      # D1 = % of area in D1 or worse
counties = gpd.read_file("data/cb_2023_us_county_20m.zip").query("STATEFP == '48'").astype({"GEOID": int}).set_index("GEOID").to_crs(3083)
print(f"{wide.shape[0]} counties x {wide.shape[1]} weeks; {np.mean(wide.to_numpy() == 0):.0%} of values are exactly 0, "
      f"{np.mean(wide.to_numpy() == 100):.0%} exactly 100; {len(counties)} county outlines")
254 counties x 54 weeks; 59% of values are exactly 0, 18% exactly 100; 254 county outlines

Example 1: the two classifications side by side

cmap = matplotlib.colormaps["YlOrBr"].resampled(5)
weeks = wide.columns[[43, 44, 45]]                                   # late October to early November 2024
fig, axes = plt.subplots(2, 3, figsize=(10, 6.5))
for col, w in enumerate(weeks):
    g = counties.join(pd.DataFrame({"quintile": per_week[w], "fixed": classes[w]}))
    g.plot(ax=axes[0, col], column="quintile", cmap=cmap, vmin=0, vmax=4, edgecolor="white", linewidth=0.2)
    g.plot(ax=axes[1, col], column="fixed", cmap=cmap, vmin=0, vmax=4, edgecolor="white", linewidth=0.2)
    axes[0, col].set_title(f"{w:%d %b}: weekly quintiles")
    axes[1, col].set_title(f"{w:%d %b}: fixed breaks")
for ax in axes.flat:
    ax.set_axis_off()
fig.savefig("data/flicker_compare.png", dpi=120)
pairs = list(zip(weeks[:-1], weeks[1:]))
print("counties changing colour week to week: quintiles", [int((per_week[a] != per_week[b]).sum()) for a, b in pairs],
      "fixed", [int((classes[a] != classes[b]).sum()) for a, b in pairs])
counties changing colour week to week: quintiles [168, 168] fixed [42, 37]

A colour scale is a key

A colour scale is a key that maps values to colours. Animate a map whose key changes every frame and the reader has no way to know. The legend, if there is one, changes too fast to read, so the eye takes every colour change for a change in the data. Quantile classes make this worse in an animation: they try to give each colour a fifth of the counties in that frame, so a county's colour depends on every other county's value that week. Between 22 and 29 October 2024, 168 of Texas's 254 counties changed colour under weekly quintiles; with fixed breaks, 42 did, because their values crossed a break.

Weekly quintile class breaks for three weeks of Texas drought statistics. the key moves every weekweekclassesupper bounds (% in D1+)26 Dec 202330 · 46.3 · 10014 May 202430 · 18.0 · 1001 Oct 202440 · 16.2 · 90.8 · 100Asked for 5 quintiles; 59% of values are exactly 0 and 18% exactly 100 spatialworkflow.io
The key changes every week, and quantiles collapse when most counties are at exactly 0% or 100%.

The same mistake appears in rasters. imshow without vmin and vmax stretches each frame to its own minimum and maximum. xarray's .plot(robust=True) picks the 2nd and 98th percentiles of the array it is given, so calling it once per frame rescales every frame. A GIF with a palette chosen per frame shifts colours in smooth gradients. The fix is always the same: decide the key once, for all frames, before drawing any of them.

Pooled quantiles, outliers and diverging data

Where per-frame colour scaling hides in map animation code, and the fix. where the key changes per frameCodeFixmapclassify.Quantiles per framebreaks once, from all framesimshow(a) with no vmin/vmaxone vmin, vmax for every frameda.plot(robust=True) per framepooled percentiles, computed onceGIF palette per frameone shared paletteThe key must be decided before the first frame is drawn spatialworkflow.io
Each one decides the colour key inside the frame loop. Move it out.
  • Pooled quantiles are a fair compromise when there are no meaningful breaks: compute them once on all frames' values together.
  • Outliers: pooled min and max can be set by one odd value; use pooled percentiles and clip.
  • Diverging data (anomalies): fix a symmetric range around zero for all frames.
  • Legends: draw one legend for the whole animation; it must never change between frames.
  • Ties: quantiles on data with many identical values (0%, 100%) collapse to fewer classes; mapclassify warns, so do not silence the warning without reading it.

FAQ

Why do the colours change between frames of my animation?

Each frame is classified or scaled on its own. Use the same class breaks, or the same vmin/vmax, for every frame.

Should I use quantile classes in an animated choropleth?

Not per frame. If you want quantiles, compute them once on the values of all frames pooled together.

Does matplotlib autoscale colours by default?

Yes. imshow and pcolormesh without vmin/vmax or a norm scale each array to its own minimum and maximum.

How do I choose fixed class breaks?

From meaning when the variable has natural thresholds (percent of area, regulatory limits), otherwise from percentiles of all frames pooled.