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_gdfis the central projection engine, called automatically throughoutprettymaps/fetch.pyandprettymaps/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.pylines 100-108, 109-128, 159-165, 266-271;draw.pylines 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →