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

> Master coordinate projection and reprojection with osmnx and prettymaps. Learn how to effortlessly transform and display geospatial data for your projects.

- Repository: [Marcelo de Oliveira Rosa Prates/prettymaps](https://github.com/marceloprates/prettymaps)
- Tags: how-to-guide
- Published: 2026-08-20

---

**`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`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py), the `get_boundary` function creates a single-point `GeoDataFrame` in EPSG:4326 and immediately projects it to a local metric CRS:

```python

# 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:

```python

# 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:

```python

# 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**:

```python

# 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`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py) (lines 149-155) downloads SRTM elevation tiles and reprojects them to match the perimeter's projected CRS:

```python

# 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`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py) (lines 82-86) checks for CRS presence and projects before Shapely conversion:

```python

# 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:

```python

# 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

```python
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

```python
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

```python
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`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py) | `get_boundary`, `get_perimeter`, `obtain_elevation` | Initial projection, buffering, dilation cycles, raster alignment |
| [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/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`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py) and [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/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`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py) lines 100-108, 109-128, 159-165, 266-271; [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/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`](https://github.com/marceloprates/prettymaps/blob/main/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.