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.
- osm_rasterizer-0.1.0/.github/workflows/publish.yml +19 -0
- osm_rasterizer-0.1.0/.gitignore +16 -0
- osm_rasterizer-0.1.0/.python-version +1 -0
- osm_rasterizer-0.1.0/LICENSE +21 -0
- osm_rasterizer-0.1.0/PKG-INFO +166 -0
- osm_rasterizer-0.1.0/README.md +138 -0
- osm_rasterizer-0.1.0/main.py +4 -0
- osm_rasterizer-0.1.0/osm_rasterizer/__init__.py +7 -0
- osm_rasterizer-0.1.0/osm_rasterizer/cli.py +88 -0
- osm_rasterizer-0.1.0/osm_rasterizer/crs.py +33 -0
- osm_rasterizer-0.1.0/osm_rasterizer/fetch.py +59 -0
- osm_rasterizer-0.1.0/osm_rasterizer/rasterize.py +306 -0
- osm_rasterizer-0.1.0/pyproject.toml +48 -0
- osm_rasterizer-0.1.0/tests/__init__.py +0 -0
- osm_rasterizer-0.1.0/tests/conftest.py +35 -0
- osm_rasterizer-0.1.0/tests/test_cli.py +134 -0
- osm_rasterizer-0.1.0/tests/test_crs.py +42 -0
- osm_rasterizer-0.1.0/tests/test_fetch.py +163 -0
- osm_rasterizer-0.1.0/tests/test_rasterize.py +314 -0
- osm_rasterizer-0.1.0/uv.lock +764 -0
|
@@ -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 @@
|
|
|
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,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)
|