Labelling Explained: Why Automatic Map Labels Collide

On this page

Problem statement

Adding labels to a map is a two-line change and a permanent problem. The first attempt looks like this:

for point, name in zip(cities.geometry, cities["NAME"]):
    ax.text(point.x, point.y, name, fontsize=7)

and it produces a map where names overlap each other, sit on top of the symbols they belong to, run off the edge of the figure, and appear in an order determined by the row order of the file.

The scale of the problem is easy to under-estimate. In a Europe-sized window containing 752 candidate places, labelling the largest ones produced (Example 4):

labels drawn    overlapping pairs
       50               9
      100              80
      200             248

Overlaps grow faster than labels, because each new label can collide with every label already placed. And the ceiling is lower than it looks: a 7 pt "Wolverhampton" occupies 78 × 10 pixels, and the plotting area of an 8 × 6 inch map of Europe is 512 × 462 pixels, so 292 labels of that size would tile the map completely (Example 2).

Labelling is therefore not a rendering task. It is a selection problem with a placement problem inside it.

Quick answer

Decide what to label before deciding where to put it, then place greedily and drop what does not fit:

def place_labels(ax, features, name_col, rank_col, max_labels=60, fontsize=7):
    """Rank, then place, then drop. In that order."""
    ranked = features.nlargest(max_labels, rank_col)
    fig = ax.get_figure()
    fig.canvas.draw()
    renderer = fig.canvas.get_renderer()

    placed, kept = [], 0
    offsets = [(4, 4, "left", "bottom"), (-4, 4, "right", "bottom"),
               (4, -4, "left", "top"),  (-4, -4, "right", "top")]

    for point, name in zip(ranked.geometry, ranked[name_col]):
        text = ax.annotate(name, (point.x, point.y), xytext=(0, 0), fontsize=fontsize,
                           textcoords="offset points")
        for dx, dy, ha, va in offsets:
            text.set(position=(point.x, point.y), ha=ha, va=va)
            text.xyann = (dx, dy)
            fig.canvas.draw()
            box = text.get_window_extent(renderer=renderer)
            rect = (box.x0, box.y0, box.x1, box.y1)
            if not any(overlaps(rect, other) for other in placed):
                placed.append(rect)
                kept += 1
                break
        else:
            text.set_visible(False)          # nowhere to put it
    print(f"placed {kept} of {len(ranked)} labels ({100 * kept / len(ranked):.0f}%)")


def overlaps(a, b):
    return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]

Measured with four candidate positions per label, this kept 96% of 50 labels, 85% of 100 and 71% of 200 (Example 4).

Overlapping label pairs growing from 9 to 248 as the label count rises. 752 candidate places in a Europe window50 labels9 pairs100 labels80 pairs200 labels248 pairs0260Collisions grow faster than labels, because each new one can hit every existing one spatialworkflow.io
Beyond a couple of hundred labels no placement algorithm rescues the map.

Rank, offer positions, measure in device space

1. Rank the candidates before you draw anything

Automatic labelling fails first at selection, not at placement. The file order is not an importance order, so the labels that survive a collision test are arbitrary unless you rank first.

Useful ranking keys, roughly in order of usefulness: population or magnitude, the variable the map is about, area of the polygon, distance from other labelled features. Rank descending and place in that order, so that when space runs out, the labels dropped are the least important ones.

2. Give each label several candidate positions

A single fixed offset means one collision equals one lost label. Four positions — the diagonal quadrants around the anchor — recover most of them, at a cost of a few extra bounding-box tests.

The order of the candidates encodes a cartographic convention: upper-right first, then upper-left, then lower-right, then lower-left. Readers expect labels above and to the right of their point.

3. Measure the bounding boxes in device space

Text size does not scale with the data, so collision detection has to happen in pixels, not in map units. That means rendering the figure once, getting a renderer, and asking each text object for its window_extent.

The consequence is that labels must be placed after the axes limits are final. Changing the extent afterwards moves every label and invalidates every collision test.

4. Keep labels off the features they name

A label on top of its own symbol is unreadable, and a label on top of a different feature is worse — it reads as belonging to that one. Two defences:

  • Offset from the anchor by at least the marker radius plus a couple of points.
  • A halo behind the text, so a label crossing a boundary or a coastline stays legible:
import matplotlib.patheffects as pe
text.set_path_effects([pe.withStroke(linewidth=2.2, foreground="white")])

The halo is not decoration. Without it, the only way to keep text legible over a busy map is to label almost nothing.

5. Report what was dropped

A labelling routine that silently discards a third of its input is a data-quality problem hiding as a rendering detail. Print the kept and dropped counts, and — during development — the names of the dropped labels.

That is how you discover that the three places the map is actually about were all dropped because they are close together.

6. Accept a lower label count than you wanted

The measurements at the top are the argument: at 200 labels there were 248 overlapping pairs before decluttering, and 29% of labels had to be dropped afterwards.

A map with 50 well-placed labels reads better than one with 150 fighting each other, and the selection is a cartographic decision worth making deliberately rather than leaving to a collision loop.

Labels kept and dropped by greedy placement with four candidate positions each. four candidate positions, greedy placementcandidateskeptdropped50482100851520014258placement runs in ranked order, so the dropped labels are the least important onesAsking for four times as many labels bought three times as many spatialworkflow.io
Dropping beats moving on a map: a moved label can end up nearer a different feature.

A ranked labeller for Europe's cities

The data used on this page

Natural Earth's 1:10m populated places as cities, ranked by POP_MAX, and its 1:10m countries as a grey backdrop, in a Europe window of 11° W to 30° E, 35 to 60° N. Every figure is 8 × 6 inches at 100 dpi, which, with the map's geographic aspect kept, gives a plotting area of 512 × 462 pixels; new_map() draws the backdrop and the points so that each experiment starts from the same finished map.

import os

import geopandas as gpd
import matplotlib
import matplotlib.pyplot as plt
import requests

matplotlib.use("Agg")
NE = "https://naciscdn.org/naturalearth/10m/cultural/"
for name in ("ne_10m_populated_places.zip", "ne_10m_admin_0_countries.zip"):
    if not os.path.exists(name):
        open(name, "wb").write(requests.get(NE + name, timeout=300).content)
W, S, E, N = -11, 35, 30, 60
cities = gpd.read_file("ne_10m_populated_places.zip").cx[W:E, S:N][["NAME", "POP_MAX", "geometry"]]
countries = gpd.read_file("ne_10m_admin_0_countries.zip").cx[W:E, S:N][["NAME", "geometry"]]


def new_map():
    fig, ax = plt.subplots(figsize=(8, 6), dpi=100)
    countries.plot(ax=ax, color="#f1f5f9", edgecolor="#cbd5e1", linewidth=0.4)
    cities.plot(ax=ax, markersize=2, color="#1a3a6b")
    ax.set_xlim(W, E)
    ax.set_ylim(S, N)
    ax.set_axis_off()
    return fig, ax


print(f"{len(cities):,} candidate places in the window")
752 candidate places in the window

Example 1 — a labeller with ranking, halos and a report

import matplotlib.patheffects as pe


def label_features(ax, gdf, name_col, rank_col=None, max_labels=60,
                   fontsize=7, halo=2.2, marker_pad=4.0, report=True):
    fig = ax.get_figure()
    fig.canvas.draw()
    renderer = fig.canvas.get_renderer()

    candidates = (gdf.nlargest(max_labels, rank_col) if rank_col
                  else gdf.head(max_labels))
    offsets = [(marker_pad, marker_pad, "left", "bottom"),
               (-marker_pad, marker_pad, "right", "bottom"),
               (marker_pad, -marker_pad, "left", "top"),
               (-marker_pad, -marker_pad, "right", "top")]

    placed, dropped = [], []
    axis_box = ax.get_window_extent(renderer=renderer)

    for _, row in candidates.iterrows():
        anchor = row.geometry.representative_point()
        text = ax.annotate(str(row[name_col]), (anchor.x, anchor.y), xytext=(0, 0),
                           textcoords="offset points", fontsize=fontsize,
                           color="#1e293b", zorder=10,
                           path_effects=[pe.withStroke(linewidth=halo,
                                                       foreground="white")])
        for dx, dy, ha, va in offsets:
            text.set(ha=ha, va=va)
            text.xyann = (dx, dy)
            fig.canvas.draw()
            box = text.get_window_extent(renderer=renderer)
            rect = (box.x0, box.y0, box.x1, box.y1)
            inside = (rect[0] >= axis_box.x0 and rect[2] <= axis_box.x1
                      and rect[1] >= axis_box.y0 and rect[3] <= axis_box.y1)
            if inside and not any(overlaps(rect, other) for other in placed):
                placed.append(rect)
                break
        else:
            text.remove()
            dropped.append(str(row[name_col]))

    if report:
        print(f"labels: {len(placed)} placed, {len(dropped)} dropped "
              f"({100 * len(placed) / max(1, len(candidates)):.0f}% kept)")
        if dropped:
            print("  dropped:", ", ".join(dropped[:10]),
                  f"… (+{len(dropped) - 10})" if len(dropped) > 10 else "")
    return placed, dropped


fig, ax = new_map()
placed, dropped = label_features(ax, cities, "NAME", "POP_MAX", max_labels=60)
plt.close(fig)
labels: 54 placed, 6 dropped (90% kept)
  dropped: Geneva, Düsseldorf, Amsterdam, Rotterdam, Cologne, Antwerpen 

The inside test matters as much as the collision test. A label placed at the edge of the axes is clipped by the figure, and a clipped label is worse than a missing one because it looks like a rendering bug.

Example 2 — measuring how crowded a map is before labelling it

def label_capacity(ax, sample_text="Wolverhampton", fontsize=7):
    """How many labels of this size would fill the plotting area?"""
    fig = ax.get_figure()
    fig.canvas.draw()
    renderer = fig.canvas.get_renderer()

    probe = ax.text(0.5, 0.5, sample_text, fontsize=fontsize,
                    transform=ax.transAxes)
    fig.canvas.draw()
    box = probe.get_window_extent(renderer=renderer)
    probe.remove()

    axis_box = ax.get_window_extent(renderer=renderer)
    per_label = box.width * box.height
    capacity = int((axis_box.width * axis_box.height) / per_label)

    print(f"one {fontsize} pt label: {box.width:.0f} × {box.height:.0f} px")
    print(f"plotting area:          {axis_box.width:.0f} × {axis_box.height:.0f} px")
    print(f"labels that would tile it completely: {capacity}")
    print(f"a realistic ceiling is about {capacity // 6} with white space")
    return capacity


fig, ax = new_map()
capacity = label_capacity(ax)
plt.close(fig)
one 7 pt label: 78 × 10 px
plotting area:          512 × 462 px
labels that would tile it completely: 292
a realistic ceiling is about 48 with white space

Example 3 — labelling polygons rather than points

def label_polygons(ax, gdf, name_col, min_area_fraction=0.004, fontsize=7):
    """Only label a polygon big enough to hold its own name."""
    area = gdf.geometry.to_crs(6933).area                # equal-area, not square degrees
    total_area = area.sum()
    labelled = 0

    for i, row in gdf.iterrows():
        if area[i] / total_area < min_area_fraction:
            continue                                   # too small to hold text
        point = row.geometry.representative_point()     # always inside, unlike centroid
        ax.annotate(str(row[name_col]), (point.x, point.y), ha="center", va="center",
                    fontsize=fontsize, color="#1e293b",
                    path_effects=[pe.withStroke(linewidth=2.0, foreground="white")])
        labelled += 1

    print(f"labelled {labelled} of {len(gdf)} polygons; "
          f"{len(gdf) - labelled} were too small — use a leader line or a key")
    return labelled


import matplotlib.patheffects as pe

fig, ax = new_map()
labelled = label_polygons(ax, countries, "NAME")
plt.close(fig)
labelled 19 of 53 polygons; 34 were too small — use a leader line or a key

representative_point() rather than centroid is the important line. A centroid can fall outside a crescent-shaped or multipart polygon, putting the label in the sea or in the neighbouring country.

Example 4 — the numbers at the top of this page

import itertools


def naive_pairs(n):
    """The two-line first attempt: every name at its point, no placement at all."""
    fig, ax = new_map()
    boxes = []
    for point, name in zip(cities.nlargest(n, "POP_MAX").geometry, cities.nlargest(n, "POP_MAX").NAME):
        t = ax.text(point.x, point.y, name, fontsize=7)
        fig.canvas.draw()
        b = t.get_window_extent(renderer=fig.canvas.get_renderer())
        boxes.append((b.x0, b.y0, b.x1, b.y1))
    plt.close(fig)
    return sum(overlaps(a, b) for a, b in itertools.combinations(boxes, 2))


print(f"{len(cities)} candidate places")
print("labels drawn    overlapping pairs")
for n in (50, 100, 200):
    print(f"{n:9}       {naive_pairs(n):9}")
for n in (50, 100, 200):
    fig, ax = new_map()
    place_labels(ax, cities, "NAME", "POP_MAX", max_labels=n)
    plt.close(fig)
752 candidate places
labels drawn    overlapping pairs
       50               9
      100              80
      200             248
placed 48 of 50 labels (96%)
placed 85 of 100 labels (85%)
placed 142 of 200 labels (71%)

Why collisions grow faster than labels

Why overlaps grow faster than labels

Each new label can collide with every label already on the map, so in the worst case the number of possible collisions grows as the square of the label count. The measured sequence — 9 pairs at 50 labels, 80 at 100, 248 at 200 — is close to that shape, because the underlying places are clustered rather than uniform.

The practical consequence is that doubling the labels much more than doubles the work of placing them, and beyond a few hundred no placement algorithm rescues the map. Selection has to do most of the job.

Why four candidate positions is a good trade

One position is brittle; eight is slow and produces labels in odd places that break the reader's expectation of where a name sits relative to its point.

Four diagonal positions recovered 92%, 85% and 74% of labels at 50, 100 and 200 candidates in the measured runs. That is most of the available gain, at four bounding-box computations per label, and it keeps every label in a conventional position.

Why device space is unavoidable

A label's size in map units depends on the zoom, the figure size and the DPI. Two labels that do not collide in a 10-inch figure collide in a 4-inch one at the same data extent.

Collision detection therefore has to happen after layout, in pixels. This is also why label placement cannot be cached between figure sizes, and why a map series with a shared style still needs labels placed per panel.

Why halos change what is possible

Without a halo, a label is only legible over a plain background, so the labeller has to avoid coastlines, boundaries and other labels' neighbourhoods — which on a busy map means avoiding almost everywhere.

A 2 pt white stroke behind the glyphs decouples legibility from what is underneath. It is the single change that most increases how many labels a map can carry, and it costs one line.

Four candidate label positions around an anchor, tried in a conventional order. try four positions, in this ordercandidate positions1234upper-right firstpolygon anchorscentroid — outsideinsiderepresentative_point, never centroidA centroid can fall outside a crescent or multipart polygon, putting the label in the wrong feature spatialworkflow.io
Readers expect names above and to the right, which is why that position is tried first.

Timing, polygons, leaders and long names

  • Place labels last, after the axes limits are final. Changing the extent invalidates every collision test.
  • representative_point() for polygons, never centroid — a centroid can fall outside its own shape.
  • Clipped labels look like bugs. Test containment in the axes box as well as collisions.
  • Leader lines let a label sit outside a small polygon; they cost more code and rescue crowded areas.
  • Long names dominate the budget. "Newcastle upon Tyne" is three times the width of "York".
  • Do not scale font size with importance unless the map is about the size — it reads as a second data variable.
  • Labels on a raster background need a halo more than labels on a plain fill, not less.
  • Interactive maps can defer labels to zoom; a static map spends its whole budget at once.

FAQ

Why do my map labels overlap?

Because a plain text loop draws every label at a fixed offset with no knowledge of the others. Labelling 200 places in a Europe-sized window produced 248 overlapping pairs.

How many labels can a map carry?

Fewer than most people expect. A 7 pt "Wolverhampton" is 78 × 10 pixels and an 8 × 6 inch map of Europe has a plotting area of 512 × 462 — 292 would tile it completely, so a realistic ceiling with white space is around 50.

How do I stop labels colliding?

Rank the candidates, try four candidate positions per label, test bounding boxes in device space, and drop what does not fit. That kept 92% of 50 labels and 74% of 200 in measurement.

Should I use the centroid to place a polygon label?

No — use representative_point(). A centroid can fall outside a crescent-shaped or multipart polygon, which puts the label in the wrong feature.

What is a halo and do I need one?

A stroke of background colour behind the glyphs. It decouples legibility from what is underneath and is the single change that most increases how many labels a map can carry.

Why do labels move when I change the figure size?

Because collision detection happens in pixels. The same data extent at a different figure size produces different overlaps, so labels have to be placed per figure.