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.
The four-step workflow:
- Load and validate the CRS. Read the source file with
geopandas. Checkgdf.crs.is_geographicbefore any rendering step. Geographic CRS units are decimal degrees;matplotlib-scalebarinterpretsdxas metres per data unit, so a degree-based CRS produces a bar that is either astronomically long or rounds to zero. - 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. - 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, andmatplotlib-scalebarreads that matrix. Ifsavefig(dpi=M)uses a different value thansubplots(dpi=N), the rendered bar will be proportionally wrong by a factor of M/N. - Add the
ScaleBarartist and export. Setdx=1to declare that one data unit equals one metre (true for all metric CRS), then callax.add_artist(scalebar)beforefig.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.0matplotlib>=3.8.0matplotlib-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". Thetightoption recalculates the bounding box after everysavefig()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 withfig.subplots_adjust()and omitbbox_inches. -
Tune
length_fractionper 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, increaselength_fractionto 0.30–0.35 andfont_properties={"size": 8}to keep the label readable. -
Use
fixed_valueandfixed_unitsfor publication-standardised bars. When editorial style requires a bar that always reads “10 km” regardless of zoom level, passfixed_value=10000, fixed_units="m"toScaleBar. 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. Thebatch_export_maps()function above demonstrates the correct placement immediately aftersavefig().
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.
Related
- Scale Mapping for Web and Print — the parent cluster: representative fraction calculation, scale-dependent symbology, and web-vs-print rendering differences.
- Projection Selection Algorithms — automate CRS selection based on dataset extent; a prerequisite for reliable scale bars over large or polar regions.
- Choosing the Right Projection for Choropleth Maps Programmatically — applies equal-area CRS routing to thematic maps; the same
to_crs()pattern applies here. - Print-Ready Export and Batch Generation Workflows — DPI management, high-resolution vector export, and multi-page PDF assembly that this scale bar pipeline feeds into.