# Geometric Transformations Applied to GeoDataFrames During Plotting in Prettymaps

> Explore geometric transformations like translate, scale, and rotate applied to GeoDataFrames in prettymaps. Learn how the transform_gdfs function enhances map plotting with Matplotlib.

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

---

**Prettymaps applies translate, scale, and rotate transformations to GeoDataFrames through the `transform_gdfs` function in [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py) before rendering maps with Matplotlib.**

Before any map elements appear on screen, the **prettymaps** library aggressively manipulates spatial data. The `plot()` API automatically projects, aggregates, affinely transforms, and re-projects **GeoDataFrames** (GDFs) fetched from OpenStreetMap. This pipeline ensures that users can shift, stretch, and rotate entire city layouts with simple numeric parameters.

## The `transform_gdfs` Pipeline in prettymaps/draw.py

All geometric transformations happen inside `transform_gdfs`, located at lines 75‑87 of [[`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py)](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py). The function follows a strict six-step workflow:

1. **Project to planar CRS** — convert from latitude/longitude to local meter-based coordinates
2. **Aggregate geometries** — pack every layer into nested `GeometryCollection` objects
3. **Apply affine operations** — translate, scale, and rotate the unified collection
4. **De-aggregate** — split transformed geometries back to their original layers
5. **Re-project to geographic CRS** — return to `EPSG:4326` for downstream compatibility

This design guarantees that **all layers move together** as a rigid body, preventing misalignment between streets, buildings, and water features.

### Why Projection Precedes Transformation

The library relies on `osmnx.projection.project_gdf` to switch each GDF to a planar coordinate reference system before any affine work:

```python
gdfs = {
    name: ox.projection.project_gdf(gdf) if len(gdf) > 0 else gdf
    for name, gdf in gdfs.items()
}

```

Planar coordinates (meters) are required because **shapely.affinity** operations assume Euclidean space. Latitude/longitude degrees vary in physical size depending on location, which would distort scaling and rotation.

## Core Affine Operations: translate, scale, rotate

Once aggregated into a single `GeometryCollection`, the geometries pass through three **shapely.affinity** functions in sequence:

| Operation | Shapely Call | Purpose |
|-----------|--------------|---------|
| **Translate** | `shapely.affinity.translate(collection, x, y)` | Move east/west (`x`) and north/south (`y`) in meters |
| **Scale** | `shapely.affinity.scale(collection, scale_x, scale_y)` | Stretch horizontally (`scale_x`) and vertically (`scale_y`) |
| **Rotate** | `shapely.affinity.rotate(collection, rotation)` | Pivot around the origin by `rotation` degrees counter-clockwise |

The rotation angle uses **degrees**, not radians, matching user expectations and Matplotlib conventions.

### Aggregation and De-aggregation Mechanics

To apply uniform transforms across layers, prettymaps nests collections:

```python
collection = GeometryCollection([
    GeometryCollection(list(gdf.geometry))
    for gdf in gdfs.values()
])

```

After transformation, geometries are reassigned using their original order:

```python
gdfs[layer].geometry = list(collection.geoms[i].geoms)

```

This preserves layer names and GeoDataFrame structure while permitting mathematical operations that treat the entire map as one geometric object.

## User-Facing API: Parameters in `plot()`

The high-level `plot()` function exposes transformations directly. According to the docstring and implementation in [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py), the signature includes:

```python
def plot(...,
         x: float = 0,
         y: float = 0,
         scale_x: float = 1,
         scale_y: float = 1,
         rotation: float = 0,
         ...):

```

These parameters feed directly into `transform_gdfs`:

```python
gdfs = transform_gdfs(gdfs, x, y, scale_x, scale_y, rotation, logging=logging)

```

Users never need to import `transform_gdfs` directly unless building custom workflows.

### Translation Example: Shifting a City Boundary

```python
import prettymaps as pm

# Shift Porto Alegre 500m east, 200m north

pm.plot(
    query="Porto Alegre, Brazil",
    x=500,
    y=200,
    scale_x=1,
    scale_y=1,
    rotation=0,
    layers={"streets": {}},
    style={"streets": {"ec": "#222", "fc": "#ddd"}},
)

```

The `x=500, y=200` arguments move the entire rendered map without changing the underlying OSM query.

### Rotation Example: Diamond-Oriented Park View

```python
pm.plot(
    query="Central Park, New York, USA",
    rotation=45,  # degrees counter-clockwise

    layers={"waterway": {}},
    style={"waterway": {"fc": "#aaddff"}},
)

```

Setting `rotation=45` pivots the dataset around the planar origin, producing an angled perspective of the park's reservoirs and paths.

## Direct Access to `transform_gdfs` for Advanced Pipelines

For workflows requiring inspection or intermediate processing, import `transform_gdfs` alongside `get_gdfs` from [`fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py):

```python
from prettymaps.draw import transform_gdfs, get_gdfs

# Fetch raw OSM layers

raw_gdfs = get_gdfs(
    query="Amsterdam, Netherlands",
    layers={"streets": {}, "waterway": {}},
    radius=None,
    dilate=None,
    rotation=0,
    logging=False,
)

# Apply custom affine transforms

transformed = transform_gdfs(
    raw_gdfs,
    x=-300,           # shift west

    y=150,            # shift north

    scale_x=0.8,      # compress width

    scale_y=0.8,      # compress height

    rotation=30,      # tilt

)

# Access transformed GeoDataFrame directly

streets = transformed["streets"]
print(streets.total_bounds)  # check new extents

```

This pattern separates **data acquisition** from **spatial manipulation**, enabling metric computation or multi-stage filtering before最终渲染.

## CRS Handling: The Re-projection Step

After transformation, `transform_gdfs` forces every layer back to `EPSG:4326`:

```python
gdfs[layer] = ox.projection.project_gdf(gdfs[layer], to_crs="EPSG:4326")

```

This step ensures compatibility with:
- Hill-shade generation (operates on geographic coordinates)
- Background layer creation in `draw_background`
- External data fusion requiring latitude/longitude

The round-trip projection—geographic → planar → geographic—adds minimal overhead because OSMnx caches CRS transformations internally.

## Source Code Architecture

Understanding these files clarifies how geometric transformations fit into the broader pipeline:

| File | Transformation Role |
|------|---------------------|
| [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py) | Contains `transform_gdfs` and orchestrates the full draw sequence |
| [`prettymaps/fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py) | Provides `get_gdfs` for raw OSM retrieval; outputs feed into transformation |
| [`prettymaps/utils.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/utils.py) | Supplies execution-time logging via `log_execution_time` decorator |
| [`prettymaps/__init__.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/__init__.py) | Exposes public `plot` API that triggers the transformation chain |

## Summary

- **Geometric transformations in prettymaps** happen automatically through `transform_gdfs` in [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py)
- The pipeline **projects, aggregates, transforms, de-aggregates, and re-projects** GeoDataFrames as a unified workflow
- Users control **translate (x, y), scale (scale_x, scale_y), and rotate** via simple parameters in `plot()`
- **Planar CRS** is mandatory for accurate affine math; the library handles this transparently
- **Direct `transform_gdfs` access** enables preprocessing for advanced cartographic workflows

## Frequently Asked Questions

### What coordinate units does prettymaps use for translation?

Prettymaps expects **meters** for the `x` and `y` translation parameters. These values apply after the library projects GeoDataFrames to a planar CRS, where one unit equals one meter locally. Passing geographic degrees would produce massive, unintended shifts.

### Can I apply different transformations to individual layers?

No—the `transform_gdfs` implementation deliberately treats all layers as a rigid body. To transform layers independently, you must extract GDFs manually with `get_gdfs`, modify geometries separately, and pass them to lower-level drawing functions rather than `plot()`.

### Why does prettymaps re-project to EPSG:4326 after transformation?

Downstream operations including hill-shade calculation and background generation in [`draw.py`](https://github.com/marceloprates/prettymaps/blob/main/draw.py) assume latitude/longitude coordinates. The re-projection step maintains compatibility with these routines while keeping the internal transformation math in reliable planar units.

### Does rotation affect the bounding box used for fetching OSM data?

No—rotation happens **after** data fetching. The `rotation` parameter in `plot()` passes through to `transform_gdfs`, which runs on already-retrieved geometries. If you need a rotated query boundary for large rotations, you must manually expand the search radius or use `dilate`.