Coordinate Projection and Reprojection with osmnx in prettymaps: A Complete Technical Guide

prettymaps uses osmnx.projection.project_gdf to automatically convert WGS 84 (EPSG:4326) coordinates to a local metric CRS before buffering, scaling, or hill-shade operations, then reprojects back to geographic coordinates for display.

The prettymaps library by Marcelo Prates builds its entire geometry workflow on top of osmnx's projection utilities. Since OpenStreetMap data arrives in EPSG:4326 (latitude/longitude degrees), the library must transform coordinates into a projected coordinate system (meters) for accurate metric operations. Understanding this pipeline is essential for customizing radii, dilations, and raster overlays without distortion.

How prettymaps Handles Coordinate Projection

Stage 1: Initial Projection for Perimeter Building

In prettymaps/fetch.py, the get_boundary function creates a single-point GeoDataFrame in EPSG:4326 and immediately projects it to a local metric CRS:


# fetch.py lines 100-108

gdf = gpd.GeoDataFrame(geometry=[Point(lon, lat)], crs="EPSG:4326")
gdf = ox.projection.project_gdf(gdf)  # Projects to appropriate UTM zone

ox.projection.project_gdf automatically selects a suitable UTM zone based on the geometry's centroid, or falls back to a global Equidistant Cylindrical projection. This ensures linear units are in meters for all subsequent operations.

Stage 2: Buffering and Shape Creation

After projection, the library applies geometric buffers using native GeoSeries.buffer. The projected CRS guarantees that a radius=500 parameter means 500 meters, not 500 degrees:


# fetch.py lines 109-128

circle = unary_union(gdf.geometry).buffer(radius)  # radius in meters

# Or for square perimeters, manual construction in projected space

Without projection, buffering 500 "units" in EPSG:4326 would create a massive, latitude-dependent distortion—particularly severe near the poles.

Stage 3: Non-Uniform Scaling

For custom aspect ratios, prettymaps applies shapely.affinity.scale while still in the projected CRS:


# fetch.py lines 159-165

from shapely.affinity import scale
perimeter = scale(perimeter, xfact=scale[0], yfact=scale[1])

Scaling in meters preserves the intended proportions. Scaling in degrees would stretch shapes differently depending on latitude.

Stage 4: Dilation with Reprojection Cycle

When users request perimeter dilation (expanding/contracting the boundary), prettymaps performs a full reprojection cycle:


# fetch.py lines 266-271

perimeter = ox.projection.project_gdf(perimeter)  # To meters

perimeter = perimeter.buffer(dilate)              # Metric dilation

perimeter = perimeter.to_crs("EPSG:4326")         # Back to WGS 84 for OSM queries

This cycle ensures accurate metric dilation while returning to geographic coordinates for downstream OpenStreetMap queries.

Elevation and Raster Alignment

Hill-shade Projection Handling

The obtain_elevation function in fetch.py (lines 149-155) downloads SRTM elevation tiles and reprojects them to match the perimeter's projected CRS:


# fetch.py lines 149-155

import rioxarray as rxr

elevation = rxr.open_rasterio(url)
target_crs = ox.projection.project_gdf(gdf).crs  # Get metric CRS

elevation = elevation.rio.reproject(target_crs)   # Align raster to vector

This alignment prevents sub-pixel misalignment artifacts between hill-shade relief and vector layers like streets or buildings.

Drawing and Display Projection

Pre-Conversion Projection in draw.py

The gdf_to_shapely utility in prettymaps/draw.py (lines 82-86) checks for CRS presence and projects before Shapely conversion:


# draw.py lines 82-86

def gdf_to_shapely(gdf):
    if gdf.crs is not None:
        gdf = ox.projection.project_gdf(gdf)  # Project to meters

    # ... convert to shapely objects for buffering/scaling

This guarantees that any downstream buffer or scale operations work in consistent meter units.

Background Bounds and Keypoint Placement

For calculating matplotlib extents and positioning labels, prettymaps projects to obtain meter-based bounds:


# draw.py lines 606-610  (background box)

projected = ox.projection.project_gdf(perimeter)
xmin, ymin, xmax, ymax = projected.total_bounds

# Scale and use directly for matplotlib extents

# draw.py lines 333-338  (keypoint placement)

projected = ox.projection.project_gdf(gdf)

# Position labels using meter coordinates

The final display renders in the original EPSG:4326, with all metric transformations invisible to the end user.

Practical Code Examples

Automatic Projection with Place Names

import prettymaps as pm

# 500-meter radius buffer automatically projects, buffers, reprojects

pm.plot(
    query="Porto Alegre, Brazil",
    radius=500,
    layers={"streets": {}},
    style={"streets": {"fc": "#222", "ec": "#444"}},
)

Behind the scenes: fetch.get_perimeter → ox.projection.project_gdf → buffer → to_crs(4326).

Bypassing Automatic Projection

import geopandas as gpd
import shapely.geometry
import prettymaps as pm

# Pre-projected polygon in UTM zone 23S (Southern Brazil)

utm_crs = "EPSG:32723"
polygon = gpd.GeoSeries(
    [shapely.geometry.box(500000, 7000000, 501000, 7001000)],
    crs=utm_crs
)

# Passed directly—no extra projection occurs

pm.plot(
    query=polygon,
    layers={"waterway": {}},
    style={"waterway": {"fc": "#6dbcd2"}},
)

Hill-shade with Correct Raster Projection

pm.plot(
    query="Heerhugowaard, Netherlands",
    radius=800,
    layers={"hillshade": {}, "streets": {}},
    style={"hillshade": {"alpha": 0.6}},
)

The SRTM elevation data automatically reprojects to match the perimeter's metric CRS before hill-shade generation.

Core Source Files and Functions

File Key Function Role
prettymaps/fetch.py get_boundary, get_perimeter, obtain_elevation Initial projection, buffering, dilation cycles, raster alignment
prettymaps/draw.py gdf_to_shapely, background/keypoint utilities Pre-drawing projection, matplotlib extent calculation
osmnx (dependency) ox.projection.project_gdf Automatic UTM zone selection and CRS transformation

All projection logic in prettymaps ultimately delegates to ox.projection.project_gdf, which handles the complex work of selecting appropriate projected coordinate systems based on geometry location.

Why Metric Projection Matters

  • Metric accuracy: Buffering 100 meters in degrees would produce ~0.001° near the equator but ~0.002° near 60° latitude—unacceptable for precise cartography
  • Performance: Local UTM projections minimize distortion and maintain numerical stability for geometric operations
  • Raster interoperability: Alignment between vector layers and SRTM/DTM elevation rasters requires shared projected CRS

Summary

  • ox.projection.project_gdf is the central projection engine, called automatically throughout prettymaps/fetch.py and prettymaps/draw.py
  • All metric operations (buffer, scale, dilate) occur in projected CRS; results return to EPSG:4326 for OSM queries and display
  • Elevation data reprojects to match the perimeter's projected CRS, ensuring hill-shade alignment with vector layers
  • Pre-projected GeoDataFrames bypass automatic projection, allowing custom CRS workflows
  • Source file locations: fetch.py lines 100-108, 109-128, 159-165, 266-271; draw.py lines 82-86, 333-338, 606-610

Frequently Asked Questions

How does prettymaps choose which projected CRS to use?

prettymaps delegates this decision entirely to osmnx.projection.project_gdf, which selects the appropriate UTM zone based on the geometry's centroid. If no suitable UTM zone exists (e.g., spanning zones or polar regions), it falls back to a global Equidistant Cylindrical projection. This happens automatically without user intervention.

Can I force prettymaps to use a specific CRS like a national grid?

Yes—by passing a pre-projected GeoDataFrame as your query parameter. When fetch.get_perimeter detects an existing CRS, it skips the initial project_gdf call and preserves your custom projection. All subsequent operations (buffer, scale) then use your specified CRS.

Why does dilation require two projection operations?

Dilation needs meters for accurate buffering but must return to EPSG:4326 for OpenStreetMap queries. The cycle project_gdf → buffer → to_crs(4326) in fetch.py lines 266-271 ensures metric accuracy while maintaining compatibility with OSM's geographic coordinate requirements.

Does hill-shade projection affect visual quality?

Absolutely. obtain_elevation reprojects SRTM rasters to match the perimeter's projected CRS before hill-shade calculation. Without this alignment, elevation gradients would misalign with geographic features, producing visible seams or shifted relief shading on the final map.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →