HERE Technologies and Wherobots announce partnership Learn More

Detecting roads with RasterFlow

This notebook will guide you through detecting roads from aerial imagery, using Wherobots RasterFlow and the ChesapeakeRSC model. You will gain a hands-on understanding of how to run models like ChesapeakeRSC on your selected area of interest, vectorize the model outputs, and work with those vectors in WherobotsDB.

ChesapeakeRSC

The ChesapeakeRSC1 model is an open source segmentation model that can detect roads from high resolution imagery, even when those roads may be occluded by tree cover.

This model predicts 2 classes:

  • background
  • road

It was trained on high resolution imagery from the National Agriculture Imagery Program (NAIP) and labeled land cover data for the state of Maryland in the United States.

Preview: model inputs and outputs

The interactive map linked below shows the input imagery and model outputs for this notebook’s example AOI. Toggle layers in the sidebar to compare the inputs, the raster model output, and the vectorized geometries side-by-side.

Layers:

  • ChesapeakeRSC input mosaic: RGB high-resolution aerial imagery the model runs on
  • ChesapeakeRSC model outputs: single-band raster output from the model (road class)
  • ChesapeakeRSC vector geometries: vectorized road polygons derived from the raster output
  • ChesapeakeRSC PM Tiles: same vectors delivered as PMTiles for fast rendering at scale

View the interactive map here.

Selecting an Area of Interest (AOI)

We will choose an Area of Interest (AOI) for our analysis. ChesapeakeRSC was trained on select geographies in the Northeastern United States.

We will try it out in Montgomery County, Maryland.

import os

import shapely
import wkls
from sedona.spark import *

config = SedonaContext.builder().getOrCreate()
sedona = SedonaContext.create(config)

# Generate a WKT geometry for Montgomery County, Maryland using Well-Known Locations (https://github.com/wherobots/wkls)
aoi = wkls.us.md.montgomerycounty.wkt()

min_lon, min_lat, max_lon, max_lat = shapely.from_wkt(aoi).bounds
print(f"Bounds: [{min_lon:.4f}, {min_lat:.4f}, {max_lon:.4f}, {max_lat:.4f}]")

Selecting a time range and verifying NAIP coverage

NAIP is flown state by state on a multi-year cycle at mixed resolutions, and the ChesapeakeRSC recipe sources 1m imagery. If you change the AOI or the time range, the 1m imagery the model needs may not exist. The USDA’s NAIP coverage map (PDF, 2002-2025) shows what is available where.

from datetime import datetime

import pyspark.sql.functions as f

# Date range for imagery to be used by the model
start_date = datetime(2017, 1, 1)
end_date = datetime(2018, 1, 1)

# The ChesapeakeRSC recipe runs on 1m NAIP imagery. The index holds one row per NAIP scene, with its
# footprint, resolution (res) and acquisition time.
MODEL_RES = 1.0
naip_index = (
    sedona.read.format("geoparquet")
    .load("s3://wherobots-examples/rasterflow/indexes/naip_index.parquet")
    .select("geometry", "res", "year", "time")
)
# `time` is stored with nanosecond precision, which Spark reads as a BIGINT of nanoseconds since the epoch
if dict(naip_index.dtypes)["time"] == "bigint":
    naip_index = naip_index.withColumn("time", f.timestamp_micros((f.col("time") / 1000).cast("long")))

# The AOI as a one-row DataFrame, so the checks below can join against it
aoi_df = sedona.createDataFrame([(aoi,)], ["wkt"]).select(ST_GeomFromWKT("wkt").alias("aoi"))

# Scenes at the model's resolution touching the AOI (the index and wkls are both EPSG:4326)
nearby = (
    naip_index.filter(f.col("res") == MODEL_RES)
    .join(aoi_df, ST_Intersects("geometry", "aoi"))
    .select("geometry", "year", "time")
    .cache()
)

# Narrowed to the requested date range
covering = nearby.filter(f.col("time").between(start_date, end_date))

# The recipe needs the AOI fully inside the union of the matching scene footprints
footprint = covering.agg(ST_Union_Aggr("geometry").alias("footprint"))
coverage = aoi_df.crossJoin(footprint).select(
    ST_Contains("footprint", "aoi").alias("covered"),
    # Share of the AOI under the footprint, as geodesic area on the WGS84 spheroid
    (1.0 - ST_AreaSpheroid(ST_Difference("aoi", "footprint")) / ST_AreaSpheroid("aoi")).alias("fraction"),
).first()

if not coverage["covered"]:
    fraction = max(0.0, coverage["fraction"] or 0.0)
    # Years that would work, applying the same full-coverage test as the gate above
    by_year = nearby.groupBy("year").agg(ST_Union_Aggr("geometry").alias("footprint"))
    available = [
        row.year
        for row in by_year.crossJoin(aoi_df).filter(ST_Contains("footprint", "aoi")).orderBy("year").collect()
    ]
    raise ValueError(
        f"{MODEL_RES:g}m NAIP covers {fraction:.1%} of this AOI between "
        f"{start_date:%Y-%m-%d} and {end_date:%Y-%m-%d}; the recipe needs the AOI fully covered. "
        + (
            f"Years with complete {MODEL_RES:g}m coverage over this AOI: {available}."
            if available
            else f"No year has complete {MODEL_RES:g}m NAIP coverage over this AOI."
        )
    )

years = sorted(row.year for row in covering.select("year").distinct().collect())
print(f"NAIP coverage is available. Found {covering.count()} NAIP scenes at {MODEL_RES:g}m covering the AOI")
print(f"Acquisition years: {years}")

Initializing the RasterFlow client

from rasterflow_remote import RasterflowClient

from rasterflow_remote.data_models import (
    ModelRecipes, 
    VectorizeMethodEnum
)

rf_client = RasterflowClient()

Running a model

RasterFlow has pre-defined workflows to simplify orchestration of the processing steps for model inference.These steps include:

  • Ingesting imagery for the specified Area of Interest (AOI)
  • Generating a seamless image from multiple image tiles (a mosaic)
  • Running inference with the selected model

The output is a Zarr store of the model outputs.

Note: This step will take approximately 30 minutes to complete.

model_output_index = rf_client.predict_mosaic_recipe(
    # Area of Interest as WKT (EPSG:4326)
    aoi = aoi,

    # Date range for imagery to be used by the model (set in the coverage check above)
    start = start_date,
    end = end_date,

    # Coordinate Reference System EPSG code for the output mosaic   
    target_crs = 3857,

    # The model recipe to be used for inference (ChesapeakeRSC in this case)
    model_recipe = ModelRecipes.CHESAPEAKE_RSC
)

# Inspect the mosaic index (one row per output mosaic location)
model_output_index.mosaic_index_df.show(truncate=60)
# This example AOI has one geometry, so we select the first (only) mosaic location.
model_output_store = model_output_index.first_row_mosaic

model_output_store

(Optional) Build an optimized Zarr for visualization

RasterFlow writes its outputs as Zarr stores at native resolution. To explore them interactively on cloud.wherobots.com/map, you can build an optimized multiscale Zarr. build_zarr_multiscales adds downsampled overview levels (image pyramids) to the store so the map can stream coarse tiles when zoomed out and full-resolution pixels when zoomed in.

This step is optional and can take a few minutes for large outputs, so the code below is commented out by default — uncomment it to run it.

# optimized_store = rf_client.build_zarr_multiscales(source_store=model_output_store)
# optimized_store

Visualizing outputs

You can visualize the Zarr, GeoParquet, and other geospatial outputs using cloud.wherobots.com/map.

Vectorize the raster model outputs

The output for the ChesapeakeRSC model is a raster with two classes: background and road.

We can run a seperate flow to convert the roads into vector geometries, based on the confidence threshold.Converting these results to geometries allows us to more easily post process the results or join the resuilts with other vector data.

import xarray as xr
import s3fs
import zarr
# Determine the classes that are predicted by the model
fs = s3fs.S3FileSystem(profile="default", asynchronous=True)
zstore = zarr.storage.FsspecStore(fs, path=model_output_store[5:])
ds = xr.open_zarr(zstore)
model_features = ds['band'].data.tolist()

# Remove the 'background' class for vectorization
vector_features = [f for f in model_features if f != 'background']  
# Note: this should take about 5 minutes to complete
vectorized_results = rf_client.vectorize_mosaic(
        mosaic = model_output_store,
        features = vector_features,
        threshold = 0.5,
        vectorize_method = VectorizeMethodEnum.SEMANTIC_SEGMENTATION_RASTERIO,
        vectorize_config={"stats": True, "medial_skeletonize": False}
    )

print(vectorized_results)

Save the vectorized results to the catalog

We can store these vectorized outputs in the catalog by using WherobotsDB to persist the GeoParquet results.

sedona.sql("CREATE DATABASE IF NOT EXISTS examples_temp.chesapeake_db")

df = sedona.read.format("geoparquet").load(vectorized_results.uri)
df = df.withColumnRenamed("label", "layer")
df.writeTo("examples_temp.chesapeake_db.chesapeake_vectorized").createOrReplace()

Visualize the vectorized results

To visualize the vectorized results, we will choose a small subset of the outputs that intersect with a bounding box around the center of the AOI.We will also filter out results with a score lower than 0.7.

from sedona.spark.sql.st_constructors import ST_MakeEnvelope
from sedona.spark.sql.st_predicates import ST_Intersects
import pyspark.sql.functions as f

# Compute a small bounding box around the center of the AOI (bounds from the AOI cell above)
center_lat = (min_lat + max_lat) / 2
center_lon = (min_lon + max_lon) / 2
delta = 0.01

min_lon = center_lon - delta
max_lon = center_lon + delta
min_lat = center_lat - delta
max_lat = center_lat + delta

df = df.filter(
    ST_Intersects(f.col("geometry"), ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat))
).filter("score_mean > 0.7")
from wherobots_gl import Map

# Wherobots-GL Map is URL-based, so write the filtered subset to GeoParquet first.
vectorized_subset_path = os.getenv("USER_S3_PATH") + "chesapeake_vectorized_subset.parquet"
df.write.format("geoparquet").mode("overwrite").save(vectorized_subset_path)

Map(
    layers=[{"type": "geoparquet", "source": vectorized_subset_path, "name": "Vectorized results"}],
    view={"zoom": 12, "lat": center_lat, "lng": center_lon},
    basemap="dark",
)

Generate PM Tiles for visualization

To improve visualization performance of a large number of geometries, we can use Wherobots built-in high performance PM tile generator.

from wherobots import vtiles

full_tiles_path = os.getenv("USER_S3_PATH") + "tiles.pmtiles"
vtiles.generate_pmtiles(df, full_tiles_path)
vtiles.show_pmtiles(full_tiles_path)
full_tiles_path

References

  1. Robinson, C., Corley, I., Ortiz, A., Dodhia, R., Lavista Ferres, J. M., & Najafirad, P. (2024). Seeing the roads through the trees: A benchmark for modeling spatial dependencies with aerial imagery. arXiv preprint arXiv:2401.06762. https://arxiv.org/abs/2401.06762