# How Prettymaps Handles Sea and Coastline Detection and Filtering: A Complete Technical Guide

> Learn how Prettymaps detects and filters sea and coastlines using SRTM elevation data and OpenStreetMap geometries. Get a complete technical guide.

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

---

**Prettymaps detects sea and coastlines by combining SRTM elevation data (pixels below sea level) with OpenStreetMap coastline geometries, then filters candidates by checking for road network intersections and minimum area thresholds.**

The `prettymaps` library automates the creation of publication-ready maps from OpenStreetMap data. Its **sea and coastline detection** pipeline is particularly sophisticated, using a hybrid approach that merges raster elevation analysis with vector geometry processing to distinguish true ocean from inland water bodies. This article examines the complete implementation as found in the `marceloprates/prettymaps` repository.

---

## The Six-Step Sea Detection Pipeline

Prettymaps builds the sea layer through a deterministic pipeline defined in [`prettymaps/fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py). Each step addresses a specific challenge in separating coastal waters from lakes, rivers, and other inland features.

### Step 1: Elevation Data Acquisition

The process begins with **SRTM (Shuttle Radar Topography Mission)** data. The `obtain_elevation()` function at line 22 of [`fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py) takes the map's area polygon and fetches corresponding elevation tiles using the `elevation` library.

```python

# From prettymaps/fetch.py, line 22

def obtain_elevation(polygon, crs="EPSG:4326"):
    """
    Download SRTM elevation data for the given polygon area.
    Returns a reprojected raster array.
    """
    # elevation library handles tile download and merging

    # Output is reprojected to match OSM-derived CRS

```

This raster provides the first signal: any pixel below zero elevation is potentially sea.

### Step 2: Sea Mask Creation from Elevation

At line 61, `get_sea_mask()` converts the elevation raster into a boolean mask where `elevation < 0`, then extracts contours using **scikit-image's `find_contours`** function.

```python

# From prettymaps/fetch.py, line 61

def get_sea_mask(elevation_raster, threshold=0):
    """
    Create boolean mask from elevation data.
    Negative elevations → sea candidates.
    """
    sea_mask = elevation_raster < threshold
    # find_contours returns line segments at the zero-elevation boundary

    contours = find_contours(sea_mask, 0.5)
    # Convert pixel coordinates back to geographic polygons

    return contours_to_polygons(contours, elevation_raster.transform)

```

This elevation-based approach catches open ocean reliably but may misclassify low-lying inland areas (e.g., Death Valley, Caspian Sea shorelines).

### Step 3: OSM Coastline Download

Simultaneously, `fetch_osm_data()` at line 140 queries OpenStreetMap for features tagged `natural=coastline` or `waterway=coastline` using **osmnx**:

```python

# From prettymaps/fetch.py, line 140

def fetch_osm_data(perimeter, tags=None):
    """
    Download OSM features within perimeter.
    Coastline is extracted from natural=coastline tags.
    """
    coastline = ox.features_from_polygon(
        perimeter,
        tags={"natural": "coastline"}
    )
    # Returns GeoDataFrame with coastline LineString geometries

```

The coastline provides the critical boundary between land and sea.

### Step 4: Sea Candidate Generation

The geometric core of the algorithm occurs around line 425. The bounding box polygon is **differenced** with a micro-buffered coastline to generate candidate sea geometries:

```python

# From prettymaps/fetch.py, line 425

sea_candidates = bbox.difference(coastline.buffer(1e-9)).geoms

```

This operation creates polygons representing all areas **outside** the coastline—but this includes both ocean and large inland water bodies that OSM may not have tagged precisely.

### Step 5: Intelligent Candidate Filtering

The `filter_candidate` function (lines 430-450) applies two decisive filters:

| Filter | Purpose | Implementation |
|--------|---------|----------------|
| **Road network intersection** | Eliminate inland lakes by checking if candidate touches drivable roads | `if candidate.intersects(roads_union): return False` |
| **Minimum area threshold** | Remove tiny islands and artifacts | `if candidate.area < sea_area_threshold: return False` |

The road intersection test is particularly clever: true ocean areas rarely contain drivable road networks, while inland cities—even coastal ones—have roads throughout their landmass. This heuristic correctly excludes features like the Caspian Sea when mapping nearby cities.

### Step 6: Final Sea Layer Assembly

Surviving candidates are merged via `unary_union` and wrapped in a `GeoDataFrame` (lines 460-660):

```python

# Final assembly in prettymaps/fetch.py

sea = unary_union([c for c in candidates if filter_candidate(c)])
gdf = GeoDataFrame(geometry=[sea], crs=perimeter.crs)

```

The coastline itself remains unmodified—it serves purely as a spatial mask.

---

## Practical Configuration

### Including and Styling the Sea Layer

Request `"sea"` in the layers list and customize its appearance via the `style` parameter:

```python
import prettymaps as pm

perimeter = pm.get_boundary("Lisbon, Portugal", radius=5000, circle=True)

fig, ax = pm.plot(
    perimeter,
    layers=["sea", "water", "roads", "buildings"],
    style={
        "sea": {"color": "#6ab7ff", "alpha": 0.9},
        "water": {"color": "#9bd9ff"},
        "coastline": {"color": "#2c3e50", "linewidth": 1.5}
    },
)

```

### Tuning the Area Threshold

Control minimum sea polygon size to eliminate small artifacts or distant islands:

```python
fig, ax = pm.plot(
    perimeter,
    layers=["sea"],
    sea_area_threshold=0.5,  # km² — increase to filter small islands

)

```

The `sea_area_threshold` parameter flows directly to `filter_candidate()` in [`fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py).

### Disabling Sea Detection for Inland Maps

For landlocked locations, omit `"sea"` from layers to skip elevation processing entirely:

```python
fig, ax = pm.plot(
    perimeter,
    layers=["water", "roads", "buildings"],  # No sea layer

)

```

This significantly reduces execution time by avoiding SRTM downloads and raster processing.

---

## Key Source Files and Functions

| File | Key Functions | Responsibility |
|------|-------------|--------------|
| [`prettymaps/fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py) | `obtain_elevation()` (L22), `get_sea_mask()` (L61), `fetch_osm_data()` (L140), candidate filtering (L425-450) | Complete sea/coastline detection pipeline |
| [`prettymaps/draw.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/draw.py) | Layer rendering | Matplotlib-based visualization of processed sea polygons |
| [`prettymaps/__init__.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/__init__.py) | `plot()`, `get_boundary()` | Public API entry points |
| [`prettymaps/utils.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/utils.py) | `log_execution_time` | Performance instrumentation |

---

## Summary

- **Hybrid detection**: Prettymaps combines **SRTM elevation rasters** (sea level = 0m) with **OSM coastline vectors** for robust sea identification
- **Critical filtering**: Road network intersection tests distinguish true ocean from large inland water bodies
- **Configurable thresholds**: The `sea_area_threshold` parameter controls minimum sea polygon size
- **Performance option**: Omitting `"sea"` from layers skips all elevation processing for faster inland maps
- **Implementation location**: All core logic resides in [`prettymaps/fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/prettymaps/fetch.py), specifically lines 22-660

---

## Frequently Asked Questions

### How does prettymaps distinguish between ocean and large lakes?

Prettymaps uses a **road network intersection heuristic**. After generating sea candidates by differencing the coastline from the bounding box, it checks whether each candidate polygon intersects with drivable roads. True ocean areas rarely contain road networks, while inland cities—even those on large lakes—have roads throughout. Candidates touching roads are rejected as inland water.

### What data source provides the elevation for sea detection?

The library uses **SRTM (Shuttle Radar Topography Mission)** data accessed through the `elevation` Python library. These 1-arc-second resolution rasters provide global coverage of terrain heights, with values below zero indicating areas potentially below sea level. The data is automatically downloaded and reprojected to match the map's coordinate reference system.

### Can I adjust how aggressively prettymaps filters sea areas?

Yes. Pass the **`sea_area_threshold`** parameter to `pm.plot()` (in square kilometers). Higher values eliminate smaller islands and coastal features. The default behavior retains most sea polygons, but setting `sea_area_threshold=0.5` or higher focuses visualization on substantial water bodies only. This parameter directly controls the area check in `filter_candidate()` at lines 430-450 of [`fetch.py`](https://github.com/marceloprates/prettymaps/blob/main/fetch.py).

### Why might sea detection fail for some coastal locations?

Sea detection can produce artifacts in three scenarios: (1) **low-lying coastal areas** where elevation data shows near-zero values, (2) **regions with incomplete OSM coastline data**, and (3) **cities built over water** (e.g., Venice, Amsterdam) where road networks extend into areas the algorithm would classify as sea. The filtering heuristics handle most common cases, but manual layer exclusion remains available.