Debugging Missed Collisions with Rotated Map Labels

Rotated labels overlap on the rendered map while your shapely STRtree reports no collision because you tested the wrong geometry: the tree compared loose axis-aligned minimum bounding rectangles (MBRs) instead of the tight oriented footprint of the rotated text. The fix is to build an oriented bounding box with shapely.affinity.rotate (and translate), pad it by the halo radius, and run intersects() on that true rotated polygon rather than on its envelope.

Core Algorithm and Workflow

A rotated label’s MBR is the smallest axis-aligned rectangle that contains the tilted glyph box. For a 45-degree street name it can be nearly twice the area of the text it wraps. That gap between the loose MBR and the tight oriented box is exactly where missed collisions hide. Two labels can each sit inside the other’s MBR gap — so an MBR-only test either over-reports (flags a collision that is not visible) or, more damagingly, under-reports when your candidate boxes were never rotated at all and the MBRs happen to miss while the real footprints touch.

The diagram below shows one rotated label, its loose axis-aligned MBR, and its tight oriented bounding box. The shaded corners are the dead space that an MBR test wrongly treats as occupied, and the neighbouring label slips into that space undetected.

Loose MBR versus tight oriented bounding box for a rotated label A tilted label rectangle drawn at roughly thirty degrees, wrapped by a dashed axis-aligned minimum bounding rectangle. The triangular corners between the two are shaded to mark dead space, and a second neighbouring label sits inside one corner gap where an MBR-only test fails to detect the true overlap. axis-aligned MBR (loose) Boulevard Haussmann oriented bbox (tight, tested) Rue Laffitte missed by MBR test

The corrected workflow has three deterministic steps that replace an MBR-only comparison:

  1. Build the oriented box. Construct an axis-aligned rectangle sized from the label’s width and height in CRS units, positioned at the anchor, then rotate it about that anchor by the label’s bearing with shapely.affinity.rotate. The result is a four-corner Polygon that traces the rendered glyph box.
  2. Pad by the halo. Buffer the oriented polygon by the halo (text-buffer) radius so glows are part of the footprint. Skipping this is the single most common cause of labels that “touch” while the resolver claims a clean placement.
  3. Test the true polygon. Insert the oriented, halo-padded polygons into the STRtree and query with predicate="intersects". The predicate runs the exact intersects() test against the stored rotated geometry, so the envelope only prefilters candidates — it never decides the result.

This is the rotated-geometry extension of the spatial-index approach in the Label Collision Avoidance Algorithms overview, and it slots into the same Programmatic Map Styling and Label Automation pipeline.

Production-Ready Python Implementation

The script below builds oriented bounding boxes for rotated labels, pads them by the halo radius, and detects true overlaps. It uses shapely>=2.0 (for the immutable STRtree and the exact-predicate query) and geopandas>=0.14. Angles are in degrees, measured counter-clockwise, matching shapely.affinity.rotate’s default (use_radians=False), and every length is in the projected CRS units of the input.

import math

import geopandas as gpd
from shapely.affinity import rotate, translate
from shapely.geometry import Polygon
from shapely.strtree import STRtree


def oriented_label_box(anchor, width, height, angle_deg, halo_units=0.0):
    """
    Build the oriented (rotated) bounding polygon for a single label.

    Parameters
    ----------
    anchor     : (x, y) label origin in projected CRS units.
    width      : Label width in CRS units (glyph run length).
    height     : Label cap height in CRS units.
    angle_deg  : Label bearing in degrees, CCW — the SAME frame Shapely uses.
    halo_units : Halo/buffer radius in CRS units, padded on every side.

    Returns
    -------
    shapely.geometry.Polygon — the tight rotated footprint, halo-padded.
    """
    cx, cy = anchor
    half_w, half_h = width / 2.0, height / 2.0

    # 1. Axis-aligned box centred on the origin.
    unrotated = Polygon([
        (-half_w, -half_h), (half_w, -half_h),
        (half_w,  half_h), (-half_w,  half_h),
    ])

    # 2. Rotate about (0, 0) — the anchor — then translate into place.
    #    Rotating BEFORE translating keeps the pivot at the label origin;
    #    rotating a pre-translated box would swing it around the map origin.
    rotated = rotate(unrotated, angle_deg, origin=(0, 0), use_radians=False)
    placed = translate(rotated, xoff=cx, yoff=cy)

    # 3. Pad by the halo so text glows count toward the footprint.
    #    A tiny join style keeps the corners square rather than rounded.
    if halo_units > 0.0:
        placed = placed.buffer(halo_units, join_style=2)  # 2 = mitre
    return placed


def find_missed_collisions(gdf, width_col="w", height_col="h",
                           angle_col="angle", halo_units=0.0):
    """
    Return the index pairs whose oriented footprints truly intersect.

    Pass a GeoDataFrame of point anchors in a PROJECTED CRS with per-row
    width/height/angle columns.  The result is the set of overlapping label
    pairs that an axis-aligned MBR test would miss.
    """
    if gdf.crs is None or gdf.crs.is_geographic:
        raise ValueError(
            "Project to a metric CRS first (e.g. gdf.to_crs('EPSG:3857')); "
            "rotating a box in degrees of latitude/longitude is meaningless."
        )

    boxes = [
        oriented_label_box(
            (geom.x, geom.y),
            row[width_col], row[height_col], row[angle_col], halo_units,
        )
        for geom, (_, row) in zip(gdf.geometry, gdf.iterrows())
    ]

    tree = STRtree(boxes)
    collisions = set()
    for i, poly in enumerate(boxes):
        # predicate='intersects' runs the EXACT test against each candidate's
        # true geometry, not its envelope — this is what fixes the miss.
        for j in tree.query(poly, predicate="intersects"):
            if i < j:  # de-duplicate the symmetric pair
                collisions.add((i, j))
    return collisions


# ---------------------------------------------------------------------------
# Example: two labels that a loose MBR test wrongly reports as clear
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    from shapely.geometry import Point

    gdf = gpd.GeoDataFrame(
        {
            "name":  ["Boulevard Haussmann", "Rue Laffitte"],
            "w":     [180.0, 55.0],   # CRS units (metres in EPSG:3857)
            "h":     [14.0, 14.0],
            "angle": [31.0, 0.0],     # degrees CCW
        },
        geometry=[Point(0, 0), Point(-60, 70)],
        crs="EPSG:3857",
    )

    # Halo of 2 map units nudges near-touching glyphs into a true overlap.
    hits = find_missed_collisions(gdf, halo_units=2.0)
    for i, j in hits:
        print(f"overlap: {gdf.at[i, 'name']!r} <> {gdf.at[j, 'name']!r}")

Because the resolver stores the rotated Polygon rather than a box, the same objects can feed an incremental placement loop: accept a candidate only when tree.query(candidate, predicate="intersects") returns empty, then rebuild the tree — exactly the commit pattern used in Solving Label Overlap in Dense Urban Maps with Python.

Failure Modes, Root Causes, and Fixes

  • MBR under-reports (the classic miss). Root cause: you built candidates with shapely.geometry.box and never rotated them, so the tree compares two axis-aligned rectangles whose overlap does not track the tilted glyphs; a diagonal label’s real footprint fits inside the corner gap. Fix: build the oriented polygon with rotate(...) and confirm every hit with intersects() on that polygon, not its .envelope.

  • MBR over-reports (phantom collisions). Root cause: you index the rotated polygon but treat any STRtree.query candidate as a collision without the predicate. query(geom) with no predicate returns everything whose envelope overlaps, which for rotated boxes is a superset. Fix: always pass predicate="intersects" so the tree runs the exact test and discards the envelope-only near-misses.

  • Forgotten halo padding. Root cause: the renderer draws a text halo (glow/buffer) that enlarges the visible footprint, but the collision geometry is the bare glyph box, so haloed labels touch while the resolver reports clearance. Fix: buffer the oriented polygon by the halo radius in CRS units before indexing, as oriented_label_box(..., halo_units=r) does. Halo and type sizing are covered under Typography Rules for Maps.

  • Degrees-versus-metres unit mismatch. Root cause: the box dimensions are in one space (screen pixels or metres) while the anchor or rotation is in another (degrees of latitude/longitude), so the oriented box is the wrong size or spins about the wrong pivot. Fix: project to a metric CRS first — the code raises on a geographic CRS — and keep width, height, anchor, and angle in one consistent frame.

  • Radians fed to a degree API. Root cause: passing a bearing computed with math.atan2 (radians) straight into rotate, whose use_radians defaults to False. A 0.54-radian bearing becomes a 0.54-degree rotation and the box stays almost axis-aligned. Fix: convert with math.degrees(...) or pass use_radians=True explicitly, and never mix the two in one pipeline.

Integration and Next Steps

Once the oriented footprints are correct, the choice of spatial index governs throughput at scale: for tens of thousands of rotated labels the STRtree bulk-load cost and query selectivity matter, which is the trade-off examined in STRtree vs R-tree for Label Collision Detection. Feed the resolved, non-colliding polygons downstream as a GeoJSON layer with placement already solved, so a Mapbox GL or MapLibre style can set text-allow-overlap: true without reintroducing overlaps. To debug visually, drop the oriented polygons onto the map with gpd.GeoSeries(boxes).plot(facecolor="none", edgecolor="red") and confirm each red outline hugs its glyphs — a loose, upright rectangle around a tilted label is the tell-tale that an envelope leaked back into the test path.


Back to Label Collision Avoidance Algorithms