Labelling Explained: Why Automatic Map Labels Collide

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 783 candidate places, labelling the largest ones produced:

labels drawn    overlapping pairs
     50                 9
    100                67
    200               204

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 label occupies about 78 ร— 10 pixels, and the plotting area of a standard figure is 620 ร— 462 pixels, so 354 labels of that size would tile the map completely.

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), 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 92% of 50 labels, 85% of 100 and 74% of 200.

Bar chart of overlapping label pairs at 50, 100 and 200 labels.
Beyond a couple of hundred labels no placement algorithm rescues the map.

Step-by-step solution

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 204 overlapping pairs before decluttering, and 26% 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.

Bar chart of the percentage of labels kept after decluttering at three label counts.
Dropping beats moving on a map: a moved label can end up nearer a different feature.

Code examples

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),
                           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

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
one 7 pt label: 78 ร— 10 px
plotting area:          620 ร— 462 px
labels that would tile it completely: 354
a realistic ceiling is about 59 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."""
    total_area = gdf.geometry.area.sum()
    labelled = 0

    for _, row in gdf.iterrows():
        if row.geometry.area / 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

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.

Explanation

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, 67 at 100, 204 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.

Two panels: four candidate label positions, and a centroid falling outside a crescent polygon.
Readers expect names above and to the right, which is why that position is tried first.

Edge cases or notes

  • 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 204 overlapping pairs.

How many labels can a map carry?

Fewer than most people expect. A 7 pt label is about 78 ร— 10 pixels and a standard plotting area is 620 ร— 462 โ€” 354 would tile it completely, so a realistic ceiling with white space is around 50 to 60.

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.