Isochrone Maps with Python

3 minute read

Published:

Isochrone maps show areas reachable within a given travel time from a starting point. This project combines OSRM travel time calculations with satellite-derived elevation data to produce maps that show both accessibility and topography.

Isochrone map showing car travel times from Offenbach

Process overview

  1. Load and process a Digital Elevation Model (DEM)
  2. Calculate travel times via OSRM
  3. Color-code administrative areas by travel time
  4. Overlay hillshading, city labels, and street networks

DEM processing

The elevation data goes through several steps: CRS transformation, masking to the area of interest, and hillshading.

Original DEM

def _process_dem(self):
    if self.kwargs.use_elevation:
        bounds = self.get_shp_bounds()
        elevation_file = self.get_elevation_dem(bounds)
    else:
        elevation_file = self.kwargs.dem_file

    with rio.open(elevation_file) as src:
        working_dem = transform_dem(src, self.config["dem"]["dest_crs"])
        if self.kwargs.reduce_size != 1.0:
            working_dem = resize_dem(working_dem, self.kwargs.reduce_size)
        working_dem = mask_dem(working_dem, self.admin_shp["geometry"])
        transformed_shaded = hillshade(working_dem.read(1), azimuth=315, angle_altitude=45)
        return recreate_dataset_reader(transformed_shaded, working_dem.meta, 1)

Transformed DEM

CRS transformation is straightforward with rasterio:

def transform_dem(dem_dataset, target_crs):
    if dem_dataset.crs == target_crs:
        return dem_dataset
    transform, width, height = calculate_default_transform(
        dem_dataset.crs, target_crs,
        dem_dataset.width, dem_dataset.height,
        *dem_dataset.bounds
    )
    kwargs = dem_dataset.meta.copy()
    kwargs.update({'crs': target_crs, 'transform': transform, 'width': width, 'height': height})
    return recreate_dataset_reader(dem_dataset.read(), kwargs)

Masked DEM

Masking clips the raster to the administrative boundary:

def mask_dem(dem_dataset, geometries):
    out_image, out_transform = rio_mask(dem_dataset, geometries, crop=True, all_touched=True)
    out_meta = dem_dataset.meta.copy()
    out_meta.update({
        "height": out_image.shape[1], "width": out_image.shape[2], "transform": out_transform
    })
    return recreate_dataset_reader(out_image, out_meta)

Hillshaded DEM

Hillshading uses gradient-based slope and aspect calculation:

def hillshade(array, azimuth=315, angle_altitude=45):
    x, y = np.gradient(array)
    slope = np.pi/2 - np.arctan(np.sqrt(x*x + y*y))
    aspect = np.arctan2(-x, y)
    azimuthrad = azimuth * np.pi / 180
    altituderad = angle_altitude * np.pi / 180
    return (np.sin(altituderad) * np.sin(slope) +
            np.cos(altituderad) * np.cos(slope) *
            np.cos((azimuthrad - np.pi/2) - aspect)) * 255

Travel time calculation

OSRM (Open Source Routing Machine) computes driving times from a central point to the centroid of every administrative area. Coordinates are converted from UTM to WGS84 before querying:

Travel Times

class OSRM:
    def calculate_travel_time(self, gdf):
        gdf = self._init_gdf_fields(gdf)
        for _, row in tqdm(gdf.iterrows(), total=len(gdf)):
            utm_x, utm_y = row["geometry"].centroid.coords[0]
            lat, lon = utm.to_latlon(utm_x, utm_y, 32, 'U')
            result = self._get_distance_duration(lat, lon)
            if result:
                distance, duration = result
                gdf = self._update_gdf(gdf, row["DEBKG_ID"], lat, lon, distance, duration)
        return gdf

The areas are then dissolved by travel time bucket for a cleaner visualization:

Dissolved Travel Times

Combining DEM and travel times

Each administrative area is colored by travel time using a gold-to-blue gradient, then multiplied with the hillshade raster:

Combined visualization

def combine_shape_dem(self, admin_gdf, dem):
    unique_colors = len(admin_gdf["traveltime_h"].unique())
    cmap = LinearSegmentedColormap.from_list(
        "Custom", [(255/255, 215/255, 0/255), (0/255, 87/255, 183/255)], N=unique_colors
    )
    base_image = np.full((dem.shape[0], dem.shape[1], 3), np.array([0, 0, 0]))
    
    for _, area in tqdm(admin_gdf.iterrows(), total=len(admin_gdf)):
        out_image, _ = rio_mask(dem, [area["geometry"]], crop=False, all_touched=False)
        color_index, ccode = self._get_colorcode_val(
            area["traveltime_h"], area["traveltime_h30"], unique_colors
        )
        rgba = cmap(ccode)
        rgb = np.array(rgba[:3])
        base_color = np.full((dem.shape[0], dem.shape[1], 3), rgb)
        image_data = np.expand_dims(out_image[0, :, :], axis=2)
        base_image = base_image + np.rint(base_color * image_data).astype("int")
    return base_image

Final map

The final render adds city labels, a street network overlay, and a legend:

Final isochrone map

def plot_map(colored_image, street_graph_edges, city_label_gdf, eu_gdf, config, legend_used_colors):
    fig, ax = plt.subplots(figsize=config["map"]["size"])
    ax.imshow(colored_image)
    if street_graph_edges is not None:
        street_graph_edges.plot(ax=ax, linewidth=0.5, alpha=0.5)
    for idx, row in city_label_gdf.iterrows():
        ax.annotate(row['city'], (row.geometry.x, row.geometry.y), fontsize=config["map"]["fontsize"])
    plot_legend(ax, legend_used_colors)
    plt.savefig(config["map"]["output_file"], dpi=300, bbox_inches='tight')

The coordinate handling juggles WGS84 (EPSG:4326) for GPS coordinates and UTM Zone 32N (EPSG:25832) for local measurements, with rasterio and GeoPandas handling the conversions.