osm-rasterizer 0.1.0__py3-none-any.whl

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,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"]
osm_rasterizer/cli.py ADDED
@@ -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]")
osm_rasterizer/crs.py ADDED
@@ -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)
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import warnings
5
+ from math import ceil
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ import numpy as np
10
+ import rasterio
11
+ from affine import Affine
12
+ from pyproj import Transformer
13
+ from rasterio.features import rasterize as rio_rasterize
14
+
15
+ from .crs import get_utm_crs
16
+ from .fetch import fetch_features
17
+
18
+ OsmTags = dict[str, Union[str, bool, list[str]]]
19
+ FeatureSpec = Union[OsmTags, tuple[str, OsmTags]]
20
+
21
+
22
+ @dataclasses.dataclass
23
+ class RasterizeResult:
24
+ """Result of a rasterization operation.
25
+
26
+ When ``single_layer=False`` (default): ``array`` shape is ``(n_bands, H, W)``,
27
+ values are 0/1 per-feature presence masks.
28
+
29
+ When ``single_layer=True``: ``array`` shape is ``(1, H, W)``, values are
30
+ 1-based category indices into ``categories`` (0 = no data). The priority
31
+ order is determined by the feature list order passed to ``rasterize()``:
32
+ the **last** feature has the highest priority and wins conflicts.
33
+ """
34
+
35
+ array: np.ndarray # shape: (n_bands, height, width), dtype uint8
36
+ transform: Affine
37
+ crs: rasterio.CRS
38
+ band_names: list[str]
39
+ nodata: int = 0
40
+ categories: list[str] | None = None # populated in single_layer mode
41
+
42
+
43
+ def _fill_nodata_consensus(
44
+ arr: np.ndarray,
45
+ max_distance: float | None = None,
46
+ ) -> np.ndarray:
47
+ """Fill zero pixels with the value of their nearest non-zero neighbour.
48
+
49
+ Uses a Euclidean distance transform to propagate labels outward from all
50
+ labelled pixels simultaneously in a single O(n) pass.
51
+
52
+ Parameters
53
+ ----------
54
+ arr:
55
+ 2-D ``uint8`` array.
56
+ max_distance:
57
+ Maximum distance in pixels within which a zero pixel will be filled.
58
+ Zero pixels farther than this from any labelled pixel are left as 0,
59
+ preventing border areas outside OSM coverage from being flooded.
60
+ ``None`` (default) fills all zero pixels regardless of distance.
61
+ """
62
+ from scipy.ndimage import distance_transform_edt
63
+
64
+ zero_mask = arr == 0
65
+ if not zero_mask.any() or arr.max() == 0:
66
+ return arr.copy()
67
+
68
+ distances, nearest = distance_transform_edt(zero_mask, return_indices=True)
69
+
70
+ if max_distance is not None:
71
+ fill_mask = zero_mask & (distances <= max_distance)
72
+ else:
73
+ fill_mask = zero_mask
74
+
75
+ result = arr.copy()
76
+ result[fill_mask] = arr[nearest[0][fill_mask], nearest[1][fill_mask]]
77
+ return result
78
+
79
+
80
+ def _auto_name(tags: dict, index: int) -> str:
81
+ """Generate a band name from an OSM tag dict."""
82
+ if not tags:
83
+ return f"feature_{index}"
84
+ key = next(iter(tags))
85
+ val = tags[key]
86
+ if isinstance(val, bool) or val is True:
87
+ return key
88
+ if isinstance(val, str):
89
+ return f"{key}_{val}"
90
+ return key
91
+
92
+
93
+ def _normalize_features(
94
+ features: list,
95
+ ) -> list[tuple[str, dict]]:
96
+ """Normalize feature specs to ``[(name, tags), ...]``."""
97
+ if not features:
98
+ raise ValueError("features list must not be empty")
99
+
100
+ # Determine whether all are named tuples or all are bare dicts
101
+ are_tuples = [isinstance(f, tuple) for f in features]
102
+ if any(are_tuples) and not all(are_tuples):
103
+ raise TypeError(
104
+ "features must be either all bare dicts or all (name, dict) tuples, "
105
+ "not a mix of both"
106
+ )
107
+
108
+ normalized: list[tuple[str, dict]] = []
109
+ for i, f in enumerate(features):
110
+ if isinstance(f, tuple):
111
+ name, tags = f
112
+ normalized.append((str(name), tags))
113
+ else:
114
+ normalized.append((_auto_name(f, i), f))
115
+ return normalized
116
+
117
+
118
+ def rasterize(
119
+ bbox: tuple[float, float, float, float],
120
+ features: list,
121
+ resolution: float = 10.0,
122
+ single_layer: bool = False,
123
+ fill_nodata: bool = False,
124
+ fill_nodata_distance: Union[float, None] = None,
125
+ output_path: Union[str, Path, None] = None,
126
+ transform: Union[Affine, None] = None,
127
+ crs: Union[rasterio.CRS, str, None] = None,
128
+ date: Union[str, None] = None,
129
+ ) -> Union[RasterizeResult, None]:
130
+ """Rasterize OSM features into a GeoTIFF or return a RasterizeResult.
131
+
132
+ Parameters
133
+ ----------
134
+ bbox:
135
+ ``(minx, miny, maxx, maxy)`` in WGS84 degrees.
136
+ features:
137
+ List of OSM tag dicts or ``(name, tags)`` tuples.
138
+ resolution:
139
+ Pixel size in metres (ignored when *transform* is supplied).
140
+ single_layer:
141
+ If True, merge all features into a single categorical band. Pixel
142
+ values are 1-based indices into the feature list (0 = no data).
143
+ Conflicts are resolved by priority: features listed **later** win.
144
+ Reorder your feature list from least to most important category.
145
+ fill_nodata:
146
+ If True, replace empty (zero) pixels with the value of their nearest
147
+ non-zero neighbour. Applied per-band in multi-band mode and on the
148
+ merged array in single-layer mode.
149
+ fill_nodata_distance:
150
+ Maximum distance in pixels within which an empty pixel will be
151
+ filled. Pixels farther than this from any labelled pixel are left
152
+ as 0, preventing border areas outside OSM coverage from being
153
+ flooded with a nearby label. Ignored when *fill_nodata* is False.
154
+ ``None`` (default) fills all empty pixels regardless of distance.
155
+ output_path:
156
+ Write a GeoTIFF here; return None instead of RasterizeResult.
157
+ transform:
158
+ Explicit affine transform (overrides *resolution*).
159
+ crs:
160
+ Output CRS; auto-detected from *bbox* if None.
161
+ date:
162
+ Optional ISO 8601 date string (e.g. ``"2020-01-01"``) to query OSM
163
+ data as it existed at that point in time.
164
+
165
+ Returns
166
+ -------
167
+ RasterizeResult or None
168
+ None when *output_path* is given; RasterizeResult otherwise.
169
+ """
170
+ # 1. Validate
171
+ minx, miny, maxx, maxy = bbox
172
+ if minx >= maxx or miny >= maxy:
173
+ raise ValueError(
174
+ f"Invalid bbox {bbox}: minx must be < maxx and miny must be < maxy"
175
+ )
176
+ if not features:
177
+ raise ValueError("features list must not be empty")
178
+
179
+ # 2. Normalize features
180
+ named_features = _normalize_features(features)
181
+
182
+ # 3. Determine output CRS
183
+ if crs is None:
184
+ out_crs_pyproj = get_utm_crs(bbox)
185
+ out_crs = rasterio.CRS.from_wkt(out_crs_pyproj.to_wkt())
186
+ elif isinstance(crs, str):
187
+ out_crs = rasterio.CRS.from_string(crs)
188
+ else:
189
+ out_crs = crs
190
+
191
+ # 4. Reproject bbox corners to UTM
192
+ transformer = Transformer.from_crs("EPSG:4326", out_crs.to_wkt(), always_xy=True)
193
+ corners_x, corners_y = transformer.transform(
194
+ [minx, maxx, minx, maxx],
195
+ [miny, miny, maxy, maxy],
196
+ )
197
+ utm_minx = min(corners_x)
198
+ utm_maxx = max(corners_x)
199
+ utm_miny = min(corners_y)
200
+ utm_maxy = max(corners_y)
201
+
202
+ # 5. Compute affine transform and grid size
203
+ if transform is None:
204
+ out_transform = Affine.translation(utm_minx, utm_maxy) * Affine.scale(resolution, -resolution)
205
+ width = ceil((utm_maxx - utm_minx) / resolution)
206
+ height = ceil((utm_maxy - utm_miny) / resolution)
207
+ else:
208
+ out_transform = transform
209
+ px_width = abs(transform.a)
210
+ px_height = abs(transform.e)
211
+ width = ceil((utm_maxx - utm_minx) / px_width)
212
+ height = ceil((utm_maxy - utm_miny) / px_height)
213
+
214
+ # 6. Per-feature rasterization
215
+ bands: list[np.ndarray] = []
216
+ band_names: list[str] = []
217
+
218
+ for name, tags in named_features:
219
+ gdf = fetch_features(bbox, tags, date=date)
220
+
221
+ if gdf.empty:
222
+ warnings.warn(
223
+ f"No features found for tag spec {tags!r} (band '{name}'); "
224
+ "writing zero band.",
225
+ stacklevel=2,
226
+ )
227
+ bands.append(np.zeros((height, width), dtype=np.uint8))
228
+ band_names.append(name)
229
+ continue
230
+
231
+ gdf_utm = gdf.to_crs(out_crs.to_wkt())
232
+
233
+ shapes = (
234
+ (geom, 1)
235
+ for geom in gdf_utm.geometry
236
+ if geom is not None and not geom.is_empty
237
+ )
238
+ burned = rio_rasterize(
239
+ shapes=shapes,
240
+ out_shape=(height, width),
241
+ transform=out_transform,
242
+ fill=0,
243
+ dtype=np.uint8,
244
+ )
245
+ bands.append(burned)
246
+ band_names.append(name)
247
+
248
+ # 7. Handle single_layer
249
+ if single_layer:
250
+ # Priority-based merge: later features overwrite earlier ones.
251
+ # Pixel value = 1-based category index; 0 = no data.
252
+ merged = np.zeros((height, width), dtype=np.uint8)
253
+ for i, band in enumerate(bands):
254
+ merged = np.where(band > 0, np.uint8(i + 1), merged)
255
+ if fill_nodata:
256
+ merged = _fill_nodata_consensus(merged, max_distance=fill_nodata_distance)
257
+ array = merged[np.newaxis, ...]
258
+ final_band_names = ["landcover"]
259
+ categories = band_names
260
+ else:
261
+ if fill_nodata:
262
+ bands = [_fill_nodata_consensus(b, max_distance=fill_nodata_distance) for b in bands]
263
+ array = np.stack(bands, axis=0)
264
+ final_band_names = band_names
265
+ categories = None
266
+
267
+ result = RasterizeResult(
268
+ array=array,
269
+ transform=out_transform,
270
+ crs=out_crs,
271
+ band_names=final_band_names,
272
+ categories=categories,
273
+ )
274
+
275
+ # 8. Write or return
276
+ if output_path is None:
277
+ return result
278
+
279
+ output_path = Path(output_path)
280
+ n_bands, h, w = array.shape
281
+ with rasterio.open(
282
+ output_path,
283
+ "w",
284
+ driver="GTiff",
285
+ height=h,
286
+ width=w,
287
+ count=n_bands,
288
+ dtype=np.uint8,
289
+ crs=out_crs,
290
+ transform=out_transform,
291
+ nodata=0,
292
+ compress="lzw",
293
+ tiled=True,
294
+ blockxsize=256,
295
+ blockysize=256,
296
+ ) as dst:
297
+ dst.write(array)
298
+ dst.update_tags(BAND_NAMES=",".join(final_band_names))
299
+ if categories is not None:
300
+ # single_layer mode: record the category→value mapping.
301
+ # Value 1 = categories[0], value 2 = categories[1], etc.
302
+ dst.update_tags(CATEGORIES=",".join(categories))
303
+ for i, band_name in enumerate(final_band_names, start=1):
304
+ dst.update_tags(i, name=band_name)
305
+
306
+ return None
@@ -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,10 @@
1
+ osm_rasterizer/__init__.py,sha256=PpRoFemZsYoK1P7BYU0nZz-50eHwS3d_7G3hweODavQ,268
2
+ osm_rasterizer/cli.py,sha256=bM2LiKrZn5pabVKIYCD9eX3LEA1OgEm6gtNEqXH6ThU,3738
3
+ osm_rasterizer/crs.py,sha256=fqvKhwpAMRgHATWSBU_leZuwV43y4kqwI2zmH1tjrhY,905
4
+ osm_rasterizer/fetch.py,sha256=kiTbOrBpuHQsMQplzFLhe9wFR651GBfWQ_ia9qgntj0,1980
5
+ osm_rasterizer/rasterize.py,sha256=aOn6BPnix3jbvQmXJxfDTob7FDTF_RjPC_FJCtJWxRo,10189
6
+ osm_rasterizer-0.1.0.dist-info/METADATA,sha256=ipZxv3R2gFr2ZqsqK9uG90GAdJ0__K9IOuyDbOrjitc,6382
7
+ osm_rasterizer-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ osm_rasterizer-0.1.0.dist-info/entry_points.txt,sha256=v7lg7OnlVT28vb3oXjNhrA_0g-wg2ONTv86zvIFTCug,58
9
+ osm_rasterizer-0.1.0.dist-info/licenses/LICENSE,sha256=CBsU8d_8UH2X3m6Xl0dzVU1HZ7-2_UoEQ0BNCiA5pUg,1090
10
+ osm_rasterizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ osm-rasterizer = osm_rasterizer.cli:app
@@ -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.