How to Automate Scale Bar Generation in Python

Automate scale bar generation in Python by enforcing a metric CRS, locking the figure DPI before axes initialization, and adding a ScaleBar artist from matplotlib-scalebar — the library reads the axes transform directly, so every export produces a bar that is geometrically correct without any manual adjustment.

Core Algorithm and Workflow

The three failure modes that break automated scale bars share a single root cause: a mismatch between what the rendering engine thinks a data unit means and what the ScaleBar library expects. The algorithm below eliminates that mismatch deterministically.

Scale bar generation pipeline Five sequential stages: Load GeoDataFrame, Reproject to metric CRS, Lock DPI and initialize figure, Add ScaleBar artist, then savefig at locked DPI. Arrows connect each stage left to right. Load GeoDataFrame Reproject to metric CRS Lock DPI + init figure Add ScaleBar artist savefig at locked DPI EPSG:3857 or UTM same value: subplots + savefig dx=1, units="m"

The four-step workflow:

  1. Load and validate the CRS. Read the source file with geopandas. Check gdf.crs.is_geographic before any rendering step. Geographic CRS units are decimal degrees; matplotlib-scalebar interprets dx as metres per data unit, so a degree-based CRS produces a bar that is either astronomically long or rounds to zero.
  2. Reproject to a metric CRS. For web maps or any output that does not require print-accurate distance, EPSG:3857 (Web Mercator) is the fastest default. As detailed in Scale Mapping for Web and Print, for regional or national print work a local UTM zone is mandatory — Web Mercator distorts distances by 15 % or more beyond ±60° latitude.
  3. Lock DPI at figure creation. Pass the target DPI to plt.subplots(dpi=N). Matplotlib’s axes transform matrix is computed at draw time from the figure’s internal DPI, and matplotlib-scalebar reads that matrix. If savefig(dpi=M) uses a different value than subplots(dpi=N), the rendered bar will be proportionally wrong by a factor of M/N.
  4. Add the ScaleBar artist and export. Set dx=1 to declare that one data unit equals one metre (true for all metric CRS), then call ax.add_artist(scalebar) before fig.savefig(). Close the figure immediately after saving to prevent memory accumulation in batch loops.

Production-Ready Python Implementation

import matplotlib
matplotlib.use("Agg")  # Must be set before importing pyplot in headless environments

import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib_scalebar.scalebar import ScaleBar
import warnings

warnings.filterwarnings("ignore", category=UserWarning)


def generate_map_with_scale_bar(
    shapefile_path: str,
    output_path: str,
    dpi: int = 300,
    target_crs: int = 3857,
    scale_location: str = "lower right",
) -> None:
    """
    Render a map with a geometrically accurate scale bar and export it
    as a raster image. Suitable for headless / CI environments.

    Parameters
    ----------
    shapefile_path : str
        Path to the input vector dataset (any OGR-supported format).
    output_path : str
        Destination file path for the exported image (PNG, TIFF, etc.).
    dpi : int
        Target resolution. Must be identical for subplots() and savefig().
        Use 300 for print; 150 for screen-optimised web thumbnails.
    target_crs : int
        EPSG code for the export CRS. Use a local UTM zone for print accuracy.
        EPSG:3857 is a safe default for web map contexts only.
    scale_location : str
        matplotlib-scalebar location string: "lower right", "lower left", etc.
    """
    # Step 1 — Load and conditionally reproject
    gdf = gpd.read_file(shapefile_path)
    if gdf.crs is None:
        raise ValueError(
            f"Input dataset has no CRS: {shapefile_path}. "
            "Assign a CRS with gdf.set_crs() before rendering."
        )
    if gdf.crs.is_geographic or gdf.crs.to_epsg() != target_crs:
        gdf = gdf.to_crs(epsg=target_crs)

    # Step 2 — Initialize figure with the locked DPI
    # Changing DPI after this point desynchronises the axes transform matrix.
    fig, ax = plt.subplots(figsize=(10, 8), dpi=dpi)
    gdf.plot(ax=ax, edgecolor="black", facecolor="lightgray", linewidth=0.5)
    ax.axis("off")

    # Step 3 — Build the scale bar
    # dx=1 declares that 1 data unit == 1 metre (valid for all metric CRS).
    # length_fraction=0.2 targets ~20 % of the visible axis width.
    scalebar = ScaleBar(
        dx=1,
        units="m",
        length_fraction=0.2,
        location=scale_location,
        frameon=True,
        color="black",
        box_alpha=0.85,
        scale_loc="bottom",
        label_loc="top",
        font_properties={"size": 10},
        sep=4,           # gap in points between bar and label
        border_pad=0.5,  # padding around the scale bar frame
    )
    ax.add_artist(scalebar)

    # Step 4 — Export at the same DPI used for figure initialisation
    fig.savefig(output_path, dpi=dpi, bbox_inches="tight", pad_inches=0.15)
    plt.close(fig)  # release figure memory — mandatory in batch loops


def batch_export_maps(
    input_paths: list[str],
    output_dir: str,
    dpi: int = 300,
    target_crs: int = 32632,  # UTM zone 32N — replace with your region
) -> None:
    """
    Run generate_map_with_scale_bar() over a list of vector files.
    Demonstrates the memory-safe pattern for large batches.
    """
    import os
    for path in input_paths:
        stem = os.path.splitext(os.path.basename(path))[0]
        out = os.path.join(output_dir, f"{stem}_scale.png")
        try:
            generate_map_with_scale_bar(path, out, dpi=dpi, target_crs=target_crs)
            print(f"Exported: {out}")
        except Exception as exc:
            # Log and continue — do not let one bad file abort the batch
            print(f"FAILED {path}: {exc}")


if __name__ == "__main__":
    generate_map_with_scale_bar(
        shapefile_path="regions.gpkg",
        output_path="regions_map.png",
        dpi=300,
        target_crs=32632,
    )

Required packages (pin these in requirements.txt or pyproject.toml):

  • geopandas>=0.14.0
  • matplotlib>=3.8.0
  • matplotlib-scalebar>=0.8.1

Performance Tuning and Cartographic Best Practices

  • Choose CRS by extent, not convenience. EPSG:3857 is convenient but it exaggerates area and distance at mid-to-high latitudes. Use Projection Selection Algorithms to route each dataset to the most appropriate metric CRS automatically, based on its bounding-box centroid and extent.

  • Reserve fixed margins instead of relying on bbox_inches="tight". The tight option recalculates the bounding box after every savefig() call, which can shift the scale bar’s pixel position between exports and makes canvas dimensions non-deterministic. For reproducible layouts, set explicit figure margins with fig.subplots_adjust() and omit bbox_inches.

  • Tune length_fraction per output size. A bar set to 20 % of the axis width looks proportional on an A4 sheet but becomes illegible at thumbnail resolution. For web exports below 600 px wide, increase length_fraction to 0.30–0.35 and font_properties={"size": 8} to keep the label readable.

  • Use fixed_value and fixed_units for publication-standardised bars. When editorial style requires a bar that always reads “10 km” regardless of zoom level, pass fixed_value=10000, fixed_units="m" to ScaleBar. This prevents the library from auto-selecting a “nice” interval that varies between exports, which is important when producing a plate series where bars must be visually comparable.

  • Close figures synchronously in batch loops. plt.close(fig) frees the C-level memory held by the Agg renderer. Without it, each iteration retains ~10–40 MB depending on figure size and DPI; a 500-map batch can exhaust a 16 GB machine. The batch_export_maps() function above demonstrates the correct placement immediately after savefig().

Integration and Next Steps

This script is a self-contained rendering unit that plugs directly into a broader cartographic pipeline. The natural upstream dependency is Scale Mapping for Web and Print, which covers representative fraction calculation and scale-dependent symbology — once you know the RF of a map, the length_fraction and fixed_value parameters for the scale bar follow directly.

For print-ready output, pair this script with the DPI and resolution management patterns documented in Print-Ready Export and Batch Generation Workflows. That workflow covers CMYK conversion, bleed margins, and multi-page PDF assembly — contexts where the bbox_inches="tight" warning above becomes critical.

In a CI environment, inject this function into a Makefile or GitHub Actions step that runs after any GeoPackage update. A shell invocation is straightforward:

# ci_render.py — called from GitHub Actions or a cron job
import glob
from pathlib import Path

from render import batch_export_maps

sources = sorted(glob.glob("data/regions/*.gpkg"))
batch_export_maps(sources, output_dir="dist/maps/", dpi=300, target_crs=32632)

To add visual hierarchy to the exported map — graduated stroke weights, fill opacity ramps, or feature-class ordering — extend the gdf.plot() call with a classification scheme before inserting the scale bar. The scale bar artist is added to the axes after plotting, so it always renders on top of all feature layers regardless of draw order.


Frequently Asked Questions

Why does matplotlib-scalebar show “0 m” even though my data loads correctly?

The library interprets dx as metres per data unit. If gdf.crs.is_geographic is True, data units are decimal degrees (~111,000 m each). Because dx=1 declares one unit = one metre, the calculated bar length is 1/111,000th of what it should be and rounds to zero. Reproject to a metric CRS before calling gdf.plot().

Why does the bar length change between savefig() calls in the same script?

fig.savefig(dpi=M) overrides the figure’s internal DPI when M != N (where N was passed to plt.subplots(dpi=N)). The ScaleBar calculates its pixel span from the axes transform, which Matplotlib recomputes using the final DPI during the save. Always pass the same integer to both.

Is EPSG:3857 accurate enough for a print-quality scale bar?

Web Mercator introduces roughly 0.5 % distance error at 25° latitude and exceeds 15 % beyond 60°. For city-scale or neighbourhood-scale maps the error is cartographically negligible. For regional or national print work, route each dataset to its local UTM zone. The Projection Selection Algorithms cluster covers automated CRS routing based on bounding-box centroids.