osm-rasterizer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build-and-publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # required for PyPI Trusted Publisher (OIDC)
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv build
17
+ - uses: pypa/gh-action-pypi-publish@release/v1
18
+ with:
19
+ packages-dir: dist/
@@ -0,0 +1,16 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .env
12
+ CLAUDE.md
13
+
14
+ cache/
15
+ *.tif*
16
+ *.png
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrés Camilo Zúñiga-González
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: osm-rasterizer
3
+ Version: 0.1.0
4
+ Summary: Rasterize OpenStreetMap vector features into GeoTIFF rasters
5
+ Project-URL: Homepage, https://github.com/ancazugo/osm-rasterizer
6
+ Project-URL: Repository, https://github.com/ancazugo/osm-rasterizer
7
+ Author-email: Andrés Camilo Zúñiga-González <ancazugo@hotmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: geospatial,gis,openstreetmap,osm,rasterization
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: GIS
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: geopandas>=1.0
19
+ Requires-Dist: numpy>=2.0
20
+ Requires-Dist: osmnx>=2.0
21
+ Requires-Dist: pyproj>=3.4
22
+ Requires-Dist: rasterio>=1.4
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: scipy>=1.17.0
25
+ Requires-Dist: shapely>=2.0
26
+ Requires-Dist: typer>=0.15
27
+ Description-Content-Type: text/markdown
28
+
29
+ # osm-rasterizer
30
+
31
+ Convert OpenStreetMap vector features into GeoTIFF rasters. Define feature classes using OSM tags, specify a bounding box and resolution, and get a multi-band or single-layer categorical raster as output.
32
+
33
+ ## Installation
34
+
35
+ Requires Python 3.12+ and [`uv`](https://docs.astral.sh/uv/).
36
+
37
+ ```bash
38
+ git clone https://github.com/your-org/osm-rasterizer
39
+ cd osm-rasterizer
40
+ uv sync
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ uv run main.py \
47
+ --bbox "minx,miny,maxx,maxy" \
48
+ --feature 'name:{"osm_key": "value"}' \
49
+ --output output.tif \
50
+ --resolution 10
51
+ ```
52
+
53
+ Or using the installed script entry point:
54
+
55
+ ```bash
56
+ osm-rasterizer --bbox ... --feature ... --output ...
57
+ ```
58
+
59
+ ### Options
60
+
61
+ | Option | Short | Default | Description |
62
+ |---|---|---|---|
63
+ | `--bbox` | `-b` | required | Bounding box as `minx,miny,maxx,maxy` in WGS84 (EPSG:4326) |
64
+ | `--feature` | `-f` | required | OSM feature spec (repeatable, see below) |
65
+ | `--output` | `-o` | required | Output GeoTIFF path |
66
+ | `--resolution` | `-r` | `10.0` | Pixel size in metres |
67
+ | `--single-layer` | | `False` | Merge all features into one categorical band |
68
+ | `--fill-nodata` | | `False` | Fill empty pixels from nearest labelled neighbour |
69
+ | `--fill-nodata-distance` | | unlimited | Max fill distance in pixels (prevents border flooding) |
70
+ | `--crs` | | auto | Output CRS, e.g. `EPSG:32630`. Auto-detected as best-fit UTM if omitted |
71
+
72
+ ### Feature spec format
73
+
74
+ Each `--feature` argument is either a bare JSON tag dict or a named spec:
75
+
76
+ ```
77
+ '{"key": value}' # unnamed — name inferred from tags
78
+ 'name:{"key": value}' # named band/category
79
+ ```
80
+
81
+ Tag values follow the [osmnx convention](https://osmnx.readthedocs.io/):
82
+
83
+ ```
84
+ '{"building": true}' # any feature with a "building" tag
85
+ '{"highway": "residential"}' # exact value match
86
+ '{"highway": ["primary", "secondary"]}' # any of these values
87
+ ```
88
+
89
+ ### Output modes
90
+
91
+ **Multi-band** (default): one `uint8` band per feature, values 0 (absent) or 1 (present).
92
+
93
+ **Single-layer** (`--single-layer`): one `uint8` band with 1-based category indices (0 = no data). Features listed **later** take priority when areas overlap. Order your features from least to most important.
94
+
95
+ Band names are stored in the GeoTIFF metadata under the `BAND_NAMES` tag. In single-layer mode, category names are stored under `CATEGORIES`.
96
+
97
+ ## Example: Cambridge land cover
98
+
99
+ ```bash
100
+ uv run main.py \
101
+ --bbox "-0.24786388455006128, 52.242894345312415, 0.10397291341351336, 52.34506356709806" \
102
+ --feature 'bare_ground:{"natural": ["bare_rock", "sand", "scree"], "landuse": ["quarry", "brownfield"]}' \
103
+ --feature 'cropland:{"landuse": ["farmland", "orchard", "allotments", "greenhouse_horticulture"]}' \
104
+ --feature 'grassland:{"natural": "grassland", "landuse": ["grass", "meadow", "village_green"], "leisure": "park"}' \
105
+ --feature 'forest:{"landuse": "forest", "natural": "wood"}' \
106
+ --feature 'wetland:{"natural": "wetland"}' \
107
+ --feature 'infrastructure:{"building": true, "landuse": ["industrial", "commercial", "retail", "residential", "construction", "railway"]}' \
108
+ --feature 'road:{"highway": ["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "service", "track", "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link"]}' \
109
+ --feature 'water:{"natural": "water", "waterway": ["river", "canal", "stream", "drain", "ditch"]}' \
110
+ --output cambridge_landcover.tif \
111
+ --resolution 10 \
112
+ --single-layer \
113
+ --fill-nodata \
114
+ --fill-nodata-distance 50
115
+ ```
116
+
117
+ This produces a 10 m resolution single-layer categorical raster with 8 land cover classes, with small gaps filled by propagating the nearest label up to 50 pixels away.
118
+
119
+ ## Python API
120
+
121
+ ```python
122
+ from osm_rasterizer.rasterize import rasterize
123
+
124
+ result = rasterize(
125
+ bbox=(-0.15, 51.48, -0.08, 51.52), # central London
126
+ features=[
127
+ ("building", {"building": True}),
128
+ ("water", {"natural": "water"}),
129
+ ("park", {"leisure": "park"}),
130
+ ],
131
+ resolution=10.0,
132
+ single_layer=True,
133
+ fill_nodata=True,
134
+ fill_nodata_distance=30,
135
+ )
136
+
137
+ # result.array — numpy array, shape (1, H, W) in single-layer mode
138
+ # result.crs — rasterio CRS
139
+ # result.transform — affine transform
140
+ # result.categories — ["building", "water", "park"]
141
+
142
+ # Or write directly to a file:
143
+ rasterize(
144
+ bbox=(-0.15, 51.48, -0.08, 51.52),
145
+ features=[("building", {"building": True})],
146
+ output_path="buildings.tif",
147
+ )
148
+ ```
149
+
150
+ ## How it works
151
+
152
+ 1. **Fetch** — OSM features are downloaded via the Overpass API (using [osmnx](https://osmnx.readthedocs.io/)) and clipped to the exact bounding box.
153
+ 2. **Project** — The bbox and geometries are reprojected to the best-fit UTM CRS (or a user-specified CRS).
154
+ 3. **Rasterize** — Each feature class is burned into a `uint8` grid using [rasterio](https://rasterio.readthedocs.io/).
155
+ 4. **Merge / fill** — Bands are optionally merged into a single categorical layer, and empty pixels optionally filled using a Euclidean distance transform (scipy).
156
+ 5. **Write** — Output is a cloud-optimised, LZW-compressed, tiled GeoTIFF.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ # Run tests (unit tests only, no network)
162
+ uv run pytest
163
+
164
+ # Run including integration tests (requires Overpass network access)
165
+ uv run pytest -m integration
166
+ ```
@@ -0,0 +1,138 @@
1
+ # osm-rasterizer
2
+
3
+ Convert OpenStreetMap vector features into GeoTIFF rasters. Define feature classes using OSM tags, specify a bounding box and resolution, and get a multi-band or single-layer categorical raster as output.
4
+
5
+ ## Installation
6
+
7
+ Requires Python 3.12+ and [`uv`](https://docs.astral.sh/uv/).
8
+
9
+ ```bash
10
+ git clone https://github.com/your-org/osm-rasterizer
11
+ cd osm-rasterizer
12
+ uv sync
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ uv run main.py \
19
+ --bbox "minx,miny,maxx,maxy" \
20
+ --feature 'name:{"osm_key": "value"}' \
21
+ --output output.tif \
22
+ --resolution 10
23
+ ```
24
+
25
+ Or using the installed script entry point:
26
+
27
+ ```bash
28
+ osm-rasterizer --bbox ... --feature ... --output ...
29
+ ```
30
+
31
+ ### Options
32
+
33
+ | Option | Short | Default | Description |
34
+ |---|---|---|---|
35
+ | `--bbox` | `-b` | required | Bounding box as `minx,miny,maxx,maxy` in WGS84 (EPSG:4326) |
36
+ | `--feature` | `-f` | required | OSM feature spec (repeatable, see below) |
37
+ | `--output` | `-o` | required | Output GeoTIFF path |
38
+ | `--resolution` | `-r` | `10.0` | Pixel size in metres |
39
+ | `--single-layer` | | `False` | Merge all features into one categorical band |
40
+ | `--fill-nodata` | | `False` | Fill empty pixels from nearest labelled neighbour |
41
+ | `--fill-nodata-distance` | | unlimited | Max fill distance in pixels (prevents border flooding) |
42
+ | `--crs` | | auto | Output CRS, e.g. `EPSG:32630`. Auto-detected as best-fit UTM if omitted |
43
+
44
+ ### Feature spec format
45
+
46
+ Each `--feature` argument is either a bare JSON tag dict or a named spec:
47
+
48
+ ```
49
+ '{"key": value}' # unnamed — name inferred from tags
50
+ 'name:{"key": value}' # named band/category
51
+ ```
52
+
53
+ Tag values follow the [osmnx convention](https://osmnx.readthedocs.io/):
54
+
55
+ ```
56
+ '{"building": true}' # any feature with a "building" tag
57
+ '{"highway": "residential"}' # exact value match
58
+ '{"highway": ["primary", "secondary"]}' # any of these values
59
+ ```
60
+
61
+ ### Output modes
62
+
63
+ **Multi-band** (default): one `uint8` band per feature, values 0 (absent) or 1 (present).
64
+
65
+ **Single-layer** (`--single-layer`): one `uint8` band with 1-based category indices (0 = no data). Features listed **later** take priority when areas overlap. Order your features from least to most important.
66
+
67
+ Band names are stored in the GeoTIFF metadata under the `BAND_NAMES` tag. In single-layer mode, category names are stored under `CATEGORIES`.
68
+
69
+ ## Example: Cambridge land cover
70
+
71
+ ```bash
72
+ uv run main.py \
73
+ --bbox "-0.24786388455006128, 52.242894345312415, 0.10397291341351336, 52.34506356709806" \
74
+ --feature 'bare_ground:{"natural": ["bare_rock", "sand", "scree"], "landuse": ["quarry", "brownfield"]}' \
75
+ --feature 'cropland:{"landuse": ["farmland", "orchard", "allotments", "greenhouse_horticulture"]}' \
76
+ --feature 'grassland:{"natural": "grassland", "landuse": ["grass", "meadow", "village_green"], "leisure": "park"}' \
77
+ --feature 'forest:{"landuse": "forest", "natural": "wood"}' \
78
+ --feature 'wetland:{"natural": "wetland"}' \
79
+ --feature 'infrastructure:{"building": true, "landuse": ["industrial", "commercial", "retail", "residential", "construction", "railway"]}' \
80
+ --feature 'road:{"highway": ["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "service", "track", "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link"]}' \
81
+ --feature 'water:{"natural": "water", "waterway": ["river", "canal", "stream", "drain", "ditch"]}' \
82
+ --output cambridge_landcover.tif \
83
+ --resolution 10 \
84
+ --single-layer \
85
+ --fill-nodata \
86
+ --fill-nodata-distance 50
87
+ ```
88
+
89
+ This produces a 10 m resolution single-layer categorical raster with 8 land cover classes, with small gaps filled by propagating the nearest label up to 50 pixels away.
90
+
91
+ ## Python API
92
+
93
+ ```python
94
+ from osm_rasterizer.rasterize import rasterize
95
+
96
+ result = rasterize(
97
+ bbox=(-0.15, 51.48, -0.08, 51.52), # central London
98
+ features=[
99
+ ("building", {"building": True}),
100
+ ("water", {"natural": "water"}),
101
+ ("park", {"leisure": "park"}),
102
+ ],
103
+ resolution=10.0,
104
+ single_layer=True,
105
+ fill_nodata=True,
106
+ fill_nodata_distance=30,
107
+ )
108
+
109
+ # result.array — numpy array, shape (1, H, W) in single-layer mode
110
+ # result.crs — rasterio CRS
111
+ # result.transform — affine transform
112
+ # result.categories — ["building", "water", "park"]
113
+
114
+ # Or write directly to a file:
115
+ rasterize(
116
+ bbox=(-0.15, 51.48, -0.08, 51.52),
117
+ features=[("building", {"building": True})],
118
+ output_path="buildings.tif",
119
+ )
120
+ ```
121
+
122
+ ## How it works
123
+
124
+ 1. **Fetch** — OSM features are downloaded via the Overpass API (using [osmnx](https://osmnx.readthedocs.io/)) and clipped to the exact bounding box.
125
+ 2. **Project** — The bbox and geometries are reprojected to the best-fit UTM CRS (or a user-specified CRS).
126
+ 3. **Rasterize** — Each feature class is burned into a `uint8` grid using [rasterio](https://rasterio.readthedocs.io/).
127
+ 4. **Merge / fill** — Bands are optionally merged into a single categorical layer, and empty pixels optionally filled using a Euclidean distance transform (scipy).
128
+ 5. **Write** — Output is a cloud-optimised, LZW-compressed, tiled GeoTIFF.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ # Run tests (unit tests only, no network)
134
+ uv run pytest
135
+
136
+ # Run including integration tests (requires Overpass network access)
137
+ uv run pytest -m integration
138
+ ```
@@ -0,0 +1,4 @@
1
+ from osm_rasterizer.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,7 @@
1
+ """osm-rasterizer: Rasterize OpenStreetMap features into GeoTIFF rasters."""
2
+
3
+ from .crs import get_utm_crs
4
+ from .fetch import fetch_features
5
+ from .rasterize import RasterizeResult, rasterize
6
+
7
+ __all__ = ["rasterize", "RasterizeResult", "fetch_features", "get_utm_crs"]
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from .rasterize import rasterize
10
+
11
+ app = typer.Typer(help="Rasterize OpenStreetMap features into GeoTIFF rasters.")
12
+ console = Console()
13
+
14
+
15
+ def _parse_feature(s: str) -> tuple[str, dict] | dict:
16
+ """Parse a feature string into a (name, tags) tuple or bare tags dict.
17
+
18
+ Accepted formats:
19
+ - ``'{"building": true}'`` → bare dict
20
+ - ``'name:{"building": true}'`` → named tuple
21
+ """
22
+ brace_idx = s.find("{")
23
+ if brace_idx < 0:
24
+ raise typer.BadParameter(f"Feature string must contain a JSON object: {s!r}")
25
+
26
+ prefix = s[:brace_idx]
27
+ json_part = s[brace_idx:]
28
+
29
+ try:
30
+ tags = json.loads(json_part)
31
+ except json.JSONDecodeError as exc:
32
+ raise typer.BadParameter(f"Invalid JSON in feature spec {s!r}: {exc}") from exc
33
+
34
+ if not isinstance(tags, dict):
35
+ raise typer.BadParameter(f"Feature JSON must be an object, got: {type(tags).__name__}")
36
+
37
+ name = prefix.rstrip(":").strip() if prefix.strip().rstrip(":") else None
38
+
39
+ if name:
40
+ return (name, tags)
41
+ return tags
42
+
43
+
44
+ @app.command()
45
+ def main(
46
+ bbox: Annotated[str, typer.Option("--bbox", "-b", help="Bounding box as 'minx,miny,maxx,maxy' in WGS84.")],
47
+ feature: Annotated[list[str], typer.Option("--feature", "-f", help="OSM feature spec. Format: '{\"key\": val}' or 'name:{\"key\": val}'. Repeatable.")],
48
+ output: Annotated[str, typer.Option("--output", "-o", help="Output GeoTIFF file path.")],
49
+ resolution: Annotated[float, typer.Option("--resolution", "-r", help="Pixel resolution in metres.")] = 10.0,
50
+ single_layer: Annotated[bool, typer.Option("--single-layer", help="Merge all features into a single band.")] = False,
51
+ fill_nodata: Annotated[bool, typer.Option("--fill-nodata", help="Fill empty pixels with the consensus of neighbouring pixels.")] = False,
52
+ fill_nodata_distance: Annotated[Optional[float], typer.Option("--fill-nodata-distance", help="Max distance in pixels to fill from a labelled pixel. Prevents border flooding. Default: unlimited.")] = None,
53
+ crs: Annotated[Optional[str], typer.Option("--crs", help="Output CRS, e.g. 'EPSG:32632'. Auto-detected if omitted.")] = None,
54
+ date: Annotated[Optional[str], typer.Option("--date", help="Point-in-time ISO 8601 date, e.g. '2020-01-01'. Queries OSM as it existed at that date.")] = None,
55
+ ) -> None:
56
+ """Rasterize OSM features for a bounding box into a GeoTIFF."""
57
+ # Parse bbox
58
+ try:
59
+ parts = [float(v.strip()) for v in bbox.split(",")]
60
+ if len(parts) != 4:
61
+ raise ValueError
62
+ bbox_tuple: tuple[float, float, float, float] = (parts[0], parts[1], parts[2], parts[3])
63
+ except ValueError:
64
+ raise typer.BadParameter(
65
+ f"bbox must be 'minx,miny,maxx,maxy' (4 comma-separated floats), got: {bbox!r}",
66
+ param_hint="--bbox",
67
+ )
68
+
69
+ # Parse features
70
+ parsed = [_parse_feature(f) for f in feature]
71
+
72
+ console.print(f"[bold]Rasterizing {len(parsed)} feature(s)[/bold] → [cyan]{output}[/cyan]")
73
+ date_info = f", date: {date}" if date else ""
74
+ console.print(f" bbox: {bbox_tuple}, resolution: {resolution}m, single_layer: {single_layer}, fill_nodata: {fill_nodata}, fill_nodata_distance: {fill_nodata_distance}{date_info}")
75
+
76
+ rasterize(
77
+ bbox=bbox_tuple,
78
+ features=parsed,
79
+ resolution=resolution,
80
+ single_layer=single_layer,
81
+ fill_nodata=fill_nodata,
82
+ fill_nodata_distance=fill_nodata_distance,
83
+ output_path=output,
84
+ crs=crs,
85
+ date=date,
86
+ )
87
+
88
+ console.print(f"[green]Done.[/green] Output written to [cyan]{output}[/cyan]")
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import pyproj
4
+ from pyproj import CRS
5
+ from pyproj.aoi import AreaOfInterest
6
+ from pyproj.database import query_utm_crs_info
7
+
8
+
9
+ def get_utm_crs(bbox: tuple[float, float, float, float]) -> CRS:
10
+ """Auto-detect best UTM CRS for a WGS84 bbox.
11
+
12
+ Parameters
13
+ ----------
14
+ bbox:
15
+ ``(minx, miny, maxx, maxy)`` in WGS84 (EPSG:4326).
16
+
17
+ Returns
18
+ -------
19
+ pyproj.CRS
20
+ The best-fit UTM CRS for the given bounding box.
21
+ """
22
+ minx, miny, maxx, maxy = bbox
23
+ aoi = AreaOfInterest(
24
+ west_lon_degree=minx,
25
+ south_lat_degree=miny,
26
+ east_lon_degree=maxx,
27
+ north_lat_degree=maxy,
28
+ )
29
+ results = query_utm_crs_info(datum_name="WGS 84", area_of_interest=aoi)
30
+ if not results:
31
+ raise ValueError(f"No UTM CRS found for bbox {bbox}")
32
+ best = results[0]
33
+ return CRS.from_authority(best.auth_name, best.code)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import geopandas as gpd
4
+ import osmnx as ox
5
+ from shapely.geometry import box
6
+
7
+ try:
8
+ from osmnx._errors import InsufficientResponseError
9
+ except ImportError:
10
+ # osmnx < 2.0 fallback name
11
+ from osmnx.errors import InsufficientResponseError # type: ignore[no-redef]
12
+
13
+
14
+ def fetch_features(
15
+ bbox: tuple[float, float, float, float],
16
+ tags: dict,
17
+ date: str | None = None,
18
+ ) -> gpd.GeoDataFrame:
19
+ """Fetch OSM features for a WGS84 bounding box.
20
+
21
+ Parameters
22
+ ----------
23
+ bbox:
24
+ ``(minx, miny, maxx, maxy)`` in WGS84 degrees.
25
+ tags:
26
+ OSM tag dict in osmnx convention, e.g. ``{"building": True}``.
27
+ date:
28
+ Optional ISO 8601 date string (e.g. ``"2020-01-01"`` or
29
+ ``"2020-01-01T00:00:00Z"``) to query OSM data as it existed at
30
+ that point in time. Uses the Overpass API ``[date:"..."]`` filter.
31
+
32
+ Returns
33
+ -------
34
+ geopandas.GeoDataFrame
35
+ Features clipped to the exact bbox polygon. Returns an empty
36
+ GeoDataFrame (with a geometry column) if no features are found.
37
+ """
38
+ minx, miny, maxx, maxy = bbox
39
+
40
+ original_settings = ox.settings.overpass_settings
41
+ if date:
42
+ dt = date if "T" in date else f"{date}T00:00:00Z"
43
+ ox.settings.overpass_settings = f'[out:json][timeout:180][date:"{dt}"]'
44
+
45
+ # osmnx 2.x uses (west, south, east, north) = (minx, miny, maxx, maxy),
46
+ # which matches our convention directly. osmnx 1.x used (north, south, east, west).
47
+ try:
48
+ gdf = ox.features_from_bbox(bbox=(minx, miny, maxx, maxy), tags=tags)
49
+ except InsufficientResponseError:
50
+ return gpd.GeoDataFrame(geometry=[], crs="EPSG:4326")
51
+ finally:
52
+ ox.settings.overpass_settings = original_settings
53
+
54
+ if gdf.empty:
55
+ return gpd.GeoDataFrame(geometry=[], crs="EPSG:4326")
56
+
57
+ # Clip to exact bbox to remove any features partially outside
58
+ clip_poly = box(minx, miny, maxx, maxy)
59
+ return gdf.clip(clip_poly)