Using Postprocessing Callback Functions to Modify GeoDataFrames Before Rendering in Prettymaps

Prettymaps lets you inject custom Python functions to modify GeoDataFrames after data fetching and transformation but before visual rendering, enabling dynamic layer manipulation without hacking the library internals.

The open-source Python library Prettymaps generates artistic maps by fetching OpenStreetMap data through a streamlined pipeline. A powerful but underdocumented feature is the postprocessing callback hook that lets you intercept and transform the complete set of GeoDataFrames immediately before they hit the canvas. This article demonstrates how to leverage this hook using actual source code patterns from the repository.

How the Postprocessing Hook Fits Into the Pipeline

In prettymaps/draw.py, the plot() function orchestrates four sequential stages. Understanding their order is critical—your callback executes at a specific point with well-defined inputs.

The pipeline sequence in lines 28-33 of draw.py:


# 1. Fetch raw OSM data

gdfs = get_gdfs(query=query, radius=radius, layers=layers, ...)

# 2. Apply geometric transformations

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

# 3. EXECUTE USER CALLBACK

gdfs = postprocessing(gdfs)           # ← your function here

# 4. Render to figure

fig, ax = draw_layers(...)

Your postprocessing function receives a Dict[str, geopandas.GeoDataFrame] containing every fetched layer (streets, building, waterway, perimeter, and any custom layers). It must return the same dictionary structure—modified or unmodified—which the rendering engine consumes directly.

Postprocessing Callback Signature and Requirements

The callback interface is intentionally minimal. From plot()'s function signature (lines 106-178 in draw.py):

def plot(
    ...,
    postprocessing: Callable[[Dict[str, gpd.GeoDataFrame]], Dict[str, gpd.GeoDataFrame]] = lambda x: x,
    ...
):

Key constraints:

  • Input: Dictionary mapping layer names to geopandas.GeoDataFrame objects
  • Output: Dictionary with the same structure (may add/remove/modify entries)
  • Default behavior: Identity function lambda x: x passes data through unchanged
  • Coordinate reference system: All GDFs share the same CRS established during fetching

The callback runs after translation, scaling, and rotation—meaning you work with the final geometric coordinates that appear in the rendered output.

Practical Postprocessing Callback Examples

Adding a Synthetic Custom Layer

Inject geometries that don't exist in OpenStreetMap, such as highlighting zones or overlay shapes:

import prettymaps
import geopandas as gpd
from shapely.geometry import Polygon

def add_zone_overlay(gdfs):
    """Add a rectangular highlight zone centered on the map."""
    # CRS already matches other layers after transformation

    square = Polygon([(-400, -400), (-400, 400), (400, 400), (400, -400)])
    gdfs["highlight"] = gpd.GeoDataFrame(
        geometry=[square], 
        crs=gdfs["perimeter"].crs
    )
    return gdfs

plot = prettymaps.plot(
    query="Porto, Portugal",
    postprocessing=add_zone_overlay,
    style={"highlight": {"fc": "#FF6B6B", "alpha": 0.3, "ec": "none"}},
    show=False
)

The new "highlight" layer becomes available for standard styling in the style parameter.

Filtering Geometries by Area Threshold

Remove visually insignificant features to reduce clutter and improve render performance:

def filter_small_buildings(gdfs, min_sqm=250):
    """Drop building footprints below area threshold."""
    if "building" not in gdfs:
        return gdfs
    
    buildings = gdfs["building"]
    # Area calculated in projected units (typically meters for Web Mercator)

    large_enough = buildings[buildings.geometry.area > min_sqm]
    gdfs["building"] = large_enough
    
    print(f"Filtered buildings: {len(buildings)} → {len(large_enough)}")
    return gdfs

prettymaps.plot(
    query="Barcelona, Spain",
    postprocessing=lambda g: filter_small_buildings(g, min_sqm=500),
    show=False
)

Generating a Heatmap from Density Data

Create entirely synthetic layers derived from spatial analysis of existing data:

import numpy as np
from shapely.geometry import Point

def add_density_heatmap(gdfs, point_count=1000):
    """Generate random point distribution within map perimeter."""
    perimeter = gdfs["perimeter"]
    bounds = perimeter.total_bounds  # (minx, miny, maxx, maxy)

    
    # Generate random points constrained to bounding box

    rng = np.random.default_rng(seed=42)
    points = [
        Point(
            rng.uniform(bounds[0], bounds[2]),
            rng.uniform(bounds[1], bounds[3])
        )
        for _ in range(point_count)
    ]
    
    gdfs["density"] = gpd.GeoDataFrame(
        geometry=points,
        crs=perimeter.crs
    )
    return gdfs

prettymaps.plot(
    query="Tokyo, Japan",
    postprocessing=functools.partial(add_density_heatmap, point_count=2000),
    style={"density": {"cmap": "plasma", "s": 2, "alpha": 0.4}},
    show=False
)

Advanced Pattern: Conditional Layer Modification

Chain multiple transformations using a dispatcher pattern:

def chained_postprocessing(gdfs):
    """Apply multiple modifications in sequence."""
    # Remove empty geometries

    for name, gdf in list(gdfs.items()):
        gdfs[name] = gdf[~gdf.geometry.is_empty].copy()
    
    # Buffer water features for visual prominence

    if "waterway" in gdfs:
        gdfs["waterway"] = gdfs["waterway"].buffer(5).copy()
    
    # Add derived statistics as attributes for styling

    if "building" in gdfs:
        gdfs["building"] = gdfs["building"].assign(
            area_sqm=lambda df: df.geometry.area,
            centroid_dist=lambda df: df.geometry.centroid.distance(
                gdfs["perimeter"].unary_union.centroid
            )
        )
    
    return gdfs

# Usage

prettymaps.plot(
    query="Amsterdam",
    postprocessing=chained_postprocessing,
    show=False
)

Where Postprocessing Executes in the Source Code

The exact execution point in prettymaps/draw.py (confirmed in main branch):

Line Range Operation Description
28 get_gdfs() Fetches OSM layers via Overpass API
30 transform_gdfs() Applies x/y translation, scale factors, rotation
31 postprocessing(gdfs) User callback executes here
34-40 draw_layers() Renders all GDFs to matplotlib axes

This placement guarantees your modifications affect the final visual output without requiring re-fetching data or re-applying transformations.

Testing Your Postprocessing Callbacks

The repository's test suite validates this feature in tests/test.py (lines 22-37):

def test_plot_postprocessing():
    """Verify postprocessing callback receives and returns GDF dict."""
    mock_postproc = MagicMock(return_value={"mock": "result"})
    
    result = plot(
        query="dummy",
        postprocessing=mock_postproc,
        show=False
    )
    
    assert mock_postproc.called
    # Callback receives dict of GeoDataFrames

    call_arg = mock_postproc.call_args[0][0]
    assert isinstance(call_arg, dict)

Model your callbacks to match this interface: accept one dictionary argument, return one dictionary.

Summary

  • Hook location: Line 31 of prettymaps/draw.py, executed after geometric transforms, before rendering
  • Function signature: Callable[[Dict[str, GeoDataFrame]], Dict[str, GeoDataFrame]]
  • Common use cases: Adding synthetic layers, filtering by geometry properties, computing derived attributes, merging external datasets
  • Return requirement: Must return the complete GDF dictionary; original is discarded if new dict returned
  • Styling integration: New or modified layers work with standard Prettymaps style configuration

Frequently Asked Questions

Can I modify the CRS of GeoDataFrames in postprocessing?

No—Prettymaps expects all layers to share a consistent coordinate reference system established during fetching in get_gdfs(). Changing CRS in postprocessing will cause alignment errors during rendering. Transform geometries instead, or preprocess your external data to match the fetched CRS (typically EPSG:4326 or a projected local CRS).

What happens if my postprocessing callback raises an exception?

The exception propagates uncaught, halting the pipeline before any rendering occurs. Since fetching and transformation complete before your callback runs, you'll pay the network/API cost without output. Wrap risky operations in try/except blocks and return the original gdfs unmodified on failure.

Can I access the matplotlib Axes or Figure inside postprocessing?

No—the callback receives only the GeoDataFrames dictionary. The fig and ax objects are created in subsequent draw_layers() and draw_background() calls. For figure-level customization, use plot()'s preset parameter or modify the returned Plot object after the call completes.

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 →