Geometric Transformations Applied to GeoDataFrames During Plotting in Prettymaps

Prettymaps applies translate, scale, and rotate transformations to GeoDataFrames through the transform_gdfs function in 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). 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:

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:

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

After transformation, geometries are reassigned using their original order:

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, the signature includes:

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:

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

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

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:

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:

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 Contains transform_gdfs and orchestrates the full draw sequence
prettymaps/fetch.py Provides get_gdfs for raw OSM retrieval; outputs feed into transformation
prettymaps/utils.py Supplies execution-time logging via log_execution_time decorator
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
  • 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 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.

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 →