Skip to content

Commit c65bdc7

Browse files
authored
Merge pull request #3 from DPIRD-DMA/perf-easy-wins
Switch vector data to Overture Maps by default
2 parents bc6c809 + 04385c0 commit c65bdc7

17 files changed

Lines changed: 2204 additions & 130 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ OWM_cache/*
2727
data/S2A_56HLH_20231011_0_L2A_RGBNIR.tif
2828
examples/water_vectors_cache/gdfs/*.parquet
2929
examples/water_vectors_cache/*.db
30+
*.tif.aux.xml

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# Changelog
22

3+
## [0.6.0] - Aug 11, 2026
4+
5+
### Changed
6+
- **Vector data now comes from [Overture Maps](https://overturemaps.org) by default** instead of the Overpass API. Overpass is a live query service that routinely rate-limits or times out on dense urban bounding boxes; Overture serves static monthly GeoParquet releases from cloud storage. On the Sydney example scene, Overpass did not return within 10 minutes, while Overture returned water, road and building vectors in 7–13 s each. Pass `vector_source="osm"` to restore the previous Overpass behaviour.
7+
- Vector cache database bumped to `geodataframes_v2.db`, adding `source` and `ocean` columns so entries from different vector sources cannot be served for one another. An existing v1 cache is ignored rather than migrated, since its rows carry no record of which source produced them.
8+
9+
### Added
10+
- `vector_source` parameter on `make_water_mask` and `make_water_mask_debug`, accepting `"overture"` (default) or `"osm"`.
11+
- `include_ocean` parameter (default True). Overture's `ocean` water features cover everything seaward of the OSM coastline — signal the previous OSM tag set had no equivalent for, since it did not query `natural=coastline`. Set to False where coastline/tide offsets cause false positives.
12+
- `overturemaps>=1.0.0` dependency. `osmnx` is retained for the `vector_source="osm"` path.
13+
- The spaCy exclusion now covers 3.8.15 as well as 3.8.14, and is scoped to Python 3.14 with an environment marker. Both releases publish neither a cp314 wheel nor an sdist, so there is nothing installable on 3.14; 3.8.13 ships both. Other interpreters are no longer held back by the exclusion, and a later release that restores cp314 artifacts will be picked up without another change here.
14+
- The declared `pyarrow` floor is raised from 10.0.0 to 15.0.2. `overturemaps` requires that version, so 10.0.0 was never actually installable alongside it; the old floor only misdescribed what the package supports.
15+
16+
### Fixed
17+
- Overture fetches now retry transient failures (3 attempts, 2s then 4s backoff). Overture is served from S3, where a throttled range read or dropped connection is common and the client does not retry on its own; previously a single blip cost the scene all of its vector targets. A persistent failure now raises rather than returning an empty frame, so a network problem fails the build instead of being read as "no water here".
18+
- A bounding box that genuinely intersects no Overture files (open ocean, Antarctica) is no longer treated as a fetch error. `record_batch_reader` returns `None` for both the empty and the failed case, so the STAC file coverage is checked to tell them apart; if that check is unavailable the `None` is treated as an error, which is the conservative reading.
19+
- Argument validation in `build_targets` moved outside its catch-all `except`, so an invalid `vector_source` raises instead of degrading to a full run with no vector targets and one line in the log.
20+
- A target-building thread that dies without reporting no longer hangs the run. `build_targets` returns its result through a queue, and the reader blocked forever if the thread raised before it could put anything there; the wait now surfaces a `RuntimeError` instead.
21+
- A failed vector build is logged with its traceback (`logging.exception`) rather than just the exception message.
22+
- **A scene whose vector targets fail to build is now skipped instead of exported without them.** Previously the run continued and wrote a mask derived from NDWI and the model alone — not obviously wrong on inspection, and its presence on disk made a later run with `overwrite=False` skip the scene, so one transient outage silently became a permanent result. The scene is now logged at ERROR and left unwritten, and is omitted from the returned output paths; the rest of the batch continues, and a re-run reprocesses it. `build_targets` signals this with a new `TargetBuildError` (put on its queue when threaded, since a raise from a thread would be lost), which is distinct from the `None` it returns when there was nothing to build.
23+
- A skipped scene no longer leaves its other target thread running. Positive and negative targets are built in two threads; propagating the first failure without joining the second left it fetching on into the next scene, so a widespread outage piled orphaned threads up across a batch. Both are now joined before either failure propagates.
24+
- Overpass rate-limiting is no longer invisible. osmnx pauses for its advertised slot time and retries 429/504 after 55s, but reports that through its own logger, which writes nowhere by default — an overloaded Overpass looked like a multi-minute hang. Those messages now route to the standard `logging` module (without turning on osmnx's log files).
25+
- An Overpass response that returns 200 with a body that will not parse as JSON is no longer treated as "no features in this area". osmnx raises `InsufficientResponseError` for both that and a genuinely empty query; the parse failure is now told apart by its chained `JSONDecodeError` and propagates, so a fetch failure cannot be written to the vector cache as an empty result.
26+
- Landforms filed under Overture's water theme are no longer rasterized as positive water targets. Overture's `WaterClass` enum includes `cape` (a headland), `blowhole` (a coastal rock formation) and `shoal` (a routinely exposed sandbank), all carried under `subtype="physical"`. Capes and blowholes are usually points, which `combine_vector_targets` already drops, but shoals do appear as polygons — a Cape Cod bounding box returns two. These would have marked land as water. `natural=cape`/`natural=shoal` were never in `OSM_water_tags`, so dropping them also keeps the two sources aligned. Genuine water in the same subtype (`bay`, `strait`, `sound`) is retained.
27+
- Rasterio dataset handles no longer leak, one or more per scene. The two target threads each read from a dataset opened by the integration layer, and neither that layer nor `export_to_disk` ever closed what it opened, so a long batch could exhaust the process's file descriptors. The handles are now closed in a `finally`, after both threads are joined — closing a dataset a thread is still reading from would take that thread down with it — so they are released whether the scene succeeds, is skipped, or fails during inference.
28+
- An osmnx too old for the Overpass path is now reported as the `ImportError` that says so. The version check ran inside the per-scene target thread, where the raise was lost and the caller saw only "the vector target thread exited without a result", with the real message going to stderr through the threading excepthook rather than the logging module. `make_water_mask`/`make_water_mask_debug` now check up front, before any scene is opened, alongside the existing `vector_source` validation.
29+
30+
### Notes
31+
- Road targets use Overture `segment` features with `subtype="road"`, matching what `highway=*` returned from OSM; rail and waterway segments are excluded.
32+
- Overture building footprints include machine-learning-derived data beyond OSM, so negative building targets have broader coverage than before.
33+
- The Overture path is covered by live network tests marked `e2e`, which assert the fetched schema, subtype vocabulary and land-class filtering against real releases. Run them with `pytest -m e2e`.
34+
335
## [0.5.0] - Jun 3, 2026
436

537
### Changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ water_mask_path = make_water_mask(
8686

8787
## Usage tips
8888

89-
- OWM requires an active internet connection to function properly, as it needs to download OpenStreetMap (OSM) data.
89+
- OWM requires an active internet connection to function properly, as it needs to download vector data.
90+
- Vector data comes from [Overture Maps](https://overturemaps.org) by default. If you would rather query OpenStreetMap live through the Overpass API, set `vector_source="osm"`. Overture serves static monthly GeoParquet releases from cloud storage, so it avoids the rate limits and timeouts Overpass returns on large or dense bounding boxes. The underlying data is largely the same — Overture's water and road layers are derived from OSM — though its building footprints add machine-learning-derived data beyond OSM. Note that Overture files a few landforms (`cape`, `blowhole`, `shoal`) under its water theme; OWM filters these out so they are not treated as water.
91+
- If a scene's vector data cannot be fetched, that scene is skipped rather than processed without it — a mask built without its vector targets looks plausible but is quietly worse. Overture fetches retry transient failures first (3 attempts, 2s then 4s apart). A skipped scene is logged at ERROR, is left out of the returned list of output paths, and has no file written, so re-running the same call reprocesses it while the rest of the batch is untouched.
9092
- Hardware acceleration is strongly recommended:
9193
- NVIDIA GPU
9294
- Apple Silicon Mac
@@ -142,9 +144,13 @@ This matters because OWM optimises its detection thresholds both **locally** (pe
142144

143145
- `use_cache`: Whether to cache vector data processing results. Defaults to True
144146

145-
- `use_osm_building`: Whether to use OpenStreetMap building data to reduce false positives. Defaults to True
147+
- `use_osm_building`: Whether to use building data to reduce false positives. Defaults to True
146148

147-
- `use_osm_roads`: Whether to use OpenStreetMap road data to reduce false positives. Defaults to True
149+
- `use_osm_roads`: Whether to use road data to reduce false positives. Defaults to True
150+
151+
- `vector_source`: Where water, road and building vectors come from — `"overture"` (Overture Maps GeoParquet) or `"osm"` (OpenStreetMap via the Overpass API). Defaults to "overture"
152+
153+
- `include_ocean`: Whether Overture ocean polygons count as positive water targets. These cover everything seaward of the OSM coastline, which the OSM tag set does not provide. Set to False if coastline/tide offsets cause false positives on your scenes. Only applies when `vector_source="overture"`. Defaults to True
148154

149155
- `cache_dir`: Directory for storing cached vector data. Defaults to "OWM_cache" in current directory
150156

omniwatermask/overture_source.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Fetch vector features from Overture Maps.
2+
3+
Overture is an alternative to the Overpass API that ``osmnx`` queries. Overpass
4+
is a live query service that routinely rate-limits or times out on dense urban
5+
bounding boxes; Overture serves static, monthly-released GeoParquet from cloud
6+
storage, so a fetch is a bounded range-read rather than a server-side query.
7+
8+
The underlying data is largely the same: Overture's ``base`` and
9+
``transportation`` themes are derived from OpenStreetMap. The ``buildings``
10+
theme adds machine-learning-derived footprints on top of OSM.
11+
"""
12+
13+
import logging
14+
import time
15+
from typing import Any, Optional
16+
17+
import geopandas as gpd
18+
from overturemaps import core
19+
20+
# Overture type names for each kind of feature OWM targets, chosen to match the
21+
# OSM tag sets in target_builders: OSM_water_tags -> water, OSM_roads_tags
22+
# (highway=*) -> road segments, OSM_buildings_tags -> buildings.
23+
OVERTURE_TYPES: dict[str, str] = {
24+
"water": "water",
25+
"roads": "segment",
26+
"buildings": "building",
27+
}
28+
29+
# The transportation theme also carries "rail" and "water" segments. Only
30+
# "road" is kept so negative targets match what highway=* returned from OSM.
31+
OVERTURE_SUBTYPES: dict[str, frozenset[str]] = {
32+
"roads": frozenset({"road"}),
33+
}
34+
35+
# Everything seaward of the OSM coastline is a single "ocean" water feature.
36+
# OSM_water_tags has no coastline equivalent, so this is signal OWM did not
37+
# previously receive - but it is pinned to the coastline vector regardless of
38+
# tide, hence the opt-out in build_targets.
39+
OCEAN_SUBTYPE = "ocean"
40+
41+
# Overture files a few landforms under the water theme's "physical" subtype:
42+
# a cape is a headland, a blowhole is a coastal rock formation, and a shoal is
43+
# a sandbank that is routinely exposed. Rasterizing them as positive water
44+
# targets marks land as water. Capes and blowholes are usually points (which
45+
# combine_vector_targets drops anyway), but shoals do appear as polygons.
46+
# OSM_water_tags never selected these - it queried natural=water/strait, not
47+
# natural=cape/shoal - so dropping them also keeps the two sources aligned.
48+
OVERTURE_LAND_CLASSES = frozenset({"cape", "blowhole", "shoal"})
49+
50+
# Only these are needed downstream: combine_vector_targets uses geometry alone,
51+
# and the subtype/class pair is kept for filtering, cache inspection and
52+
# debugging.
53+
# Dropping the rest keeps the concat in combine_vector_targets and the parquet
54+
# cache free of Overture's deeply nested attribute columns.
55+
KEEP_COLUMNS = ["subtype", "class", "geometry"]
56+
57+
# Overture is served from S3, so failures are transient far more often than not:
58+
# a throttled range read, a dropped connection, a timeout on a dense bbox. The
59+
# client itself does not retry, so a single blip would otherwise cost the scene
60+
# all of its vector targets. Delays are 2s, 4s - short enough not to stall a
61+
# batch run, long enough to clear a throttle.
62+
MAX_ATTEMPTS = 3
63+
BACKOFF_SECONDS = 2.0
64+
65+
66+
def get_overture_features(
67+
gdf_bounds_4326: gpd.GeoDataFrame,
68+
kind: str,
69+
include_ocean: bool = True,
70+
) -> gpd.GeoDataFrame:
71+
"""Download Overture features of ``kind`` within a bounding box.
72+
73+
``kind`` is one of ``water``, ``roads`` or ``buildings``. ``include_ocean``
74+
only applies to ``water``.
75+
"""
76+
if kind not in OVERTURE_TYPES:
77+
raise ValueError(
78+
f"Unknown Overture feature kind: {kind!r}. "
79+
f"Expected one of {sorted(OVERTURE_TYPES)}."
80+
)
81+
overture_type = OVERTURE_TYPES[kind]
82+
bounds = gdf_bounds_4326.total_bounds
83+
bbox = (
84+
float(bounds[0]),
85+
float(bounds[1]),
86+
float(bounds[2]),
87+
float(bounds[3]),
88+
)
89+
90+
features = _fetch_with_retry(overture_type=overture_type, bbox=bbox, kind=kind)
91+
92+
if features.empty:
93+
logging.info(f"No {kind} features found within bbox: {bbox}")
94+
return gpd.GeoDataFrame()
95+
96+
features = _filter_features(features, kind=kind, include_ocean=include_ocean)
97+
if features.empty:
98+
logging.info(f"No {kind} features left after filtering")
99+
return gpd.GeoDataFrame()
100+
101+
features = features[[c for c in KEEP_COLUMNS if c in features.columns]]
102+
features = features.set_crs("EPSG:4326", allow_override=True)
103+
features = gpd.clip(features, gdf_bounds_4326)
104+
return gpd.GeoDataFrame(features)
105+
106+
107+
def _fetch_once(
108+
overture_type: str,
109+
bbox: tuple[float, float, float, float],
110+
) -> Optional[gpd.GeoDataFrame]:
111+
"""Run a single fetch attempt, or return None if Overture gave no reader.
112+
113+
``record_batch_reader`` streams lazily, so a failed range read surfaces out
114+
of ``from_arrow`` rather than out of the reader call. Both live here so the
115+
retry above covers the whole request.
116+
"""
117+
# stac=True lets the client resolve the release and target only the
118+
# relevant files via the STAC catalogue. Without it the whole dataset
119+
# listing is opened, which measured ~5x slower per request.
120+
reader = core.record_batch_reader(overture_type, bbox=bbox, stac=True)
121+
if reader is None:
122+
return None
123+
return gpd.GeoDataFrame.from_arrow(reader)
124+
125+
126+
def _bbox_has_no_files(
127+
overture_type: str,
128+
bbox: tuple[float, float, float, float],
129+
) -> bool:
130+
"""Report whether STAC finds no files at all intersecting ``bbox``.
131+
132+
``record_batch_reader`` returns None both when the bbox is genuinely empty
133+
(STAC matched zero files, e.g. open ocean or Antarctica) and when opening
134+
the dataset failed. Only the second is an error, and the two are
135+
indistinguishable from the None alone. ``_prepare_query`` is what returns
136+
None for the empty case, so ask it directly. It is private, so an
137+
overturemaps release that drops or renames it makes this report False and
138+
the caller treats the None as an error - the conservative reading.
139+
"""
140+
prepare_query = getattr(core, "_prepare_query", None)
141+
if prepare_query is None:
142+
return False
143+
try:
144+
return prepare_query(overture_type, bbox=bbox, stac=True) is None
145+
except Exception as e:
146+
logging.debug(f"Could not check STAC file coverage for {overture_type}: {e}")
147+
return False
148+
149+
150+
def _fetch_with_retry(
151+
overture_type: str,
152+
bbox: tuple[float, float, float, float],
153+
kind: str,
154+
) -> gpd.GeoDataFrame:
155+
"""Fetch ``overture_type`` over ``bbox``, retrying transient failures.
156+
157+
Returns an empty frame when the bbox genuinely holds no features. Raises
158+
once the attempts are exhausted, so a persistent failure fails the build
159+
rather than passing an empty frame off as "no water here".
160+
"""
161+
last_error: Optional[Exception] = None
162+
163+
for attempt in range(1, MAX_ATTEMPTS + 1):
164+
try:
165+
features = _fetch_once(overture_type, bbox)
166+
except Exception as e:
167+
last_error = e
168+
else:
169+
if features is not None:
170+
return features
171+
if _bbox_has_no_files(overture_type, bbox):
172+
logging.info(f"Overture has no {kind} files intersecting bbox: {bbox}")
173+
return gpd.GeoDataFrame()
174+
last_error = RuntimeError(
175+
f"Overture returned no reader for type {overture_type!r} "
176+
f"within bbox: {bbox}"
177+
)
178+
179+
if attempt < MAX_ATTEMPTS:
180+
delay = BACKOFF_SECONDS * 2 ** (attempt - 1)
181+
logging.warning(
182+
f"Overture {kind} fetch failed (attempt {attempt}/{MAX_ATTEMPTS}): "
183+
f"{last_error}. Retrying in {delay:.0f}s"
184+
)
185+
time.sleep(delay)
186+
187+
raise RuntimeError(
188+
f"Overture {kind} fetch failed after {MAX_ATTEMPTS} attempts for bbox: "
189+
f"{bbox}. This usually indicates a network or cloud storage error."
190+
) from last_error
191+
192+
193+
def _filter_features(
194+
features: gpd.GeoDataFrame,
195+
kind: str,
196+
include_ocean: bool,
197+
) -> gpd.GeoDataFrame:
198+
"""Restrict features to those matching the equivalent OSM tag sets."""
199+
if kind == "water" and "class" in features.columns:
200+
features = features[~features["class"].isin(OVERTURE_LAND_CLASSES)]
201+
202+
if "subtype" not in features.columns:
203+
return gpd.GeoDataFrame(features)
204+
205+
keep: Any = OVERTURE_SUBTYPES.get(kind)
206+
if keep is not None:
207+
features = features[features["subtype"].isin(keep)]
208+
209+
if kind == "water" and not include_ocean:
210+
features = features[features["subtype"] != OCEAN_SUBTYPE]
211+
212+
return gpd.GeoDataFrame(features)

omniwatermask/raster_helpers.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,18 @@ def export_to_disk(
5454
embedded inside the GeoTIFF (``GDAL_TIFF_INTERNAL_MASK``) rather than written
5555
as a separate ``.tif.msk`` sidecar, so it travels with the file.
5656
"""
57-
src = rio.open(source_path)
58-
profile = {
59-
"dtype": array.dtype,
60-
"count": array.shape[0],
61-
"compress": "lzw",
62-
"nodata": None,
63-
"driver": "GTiff",
64-
"height": array.shape[1],
65-
"width": array.shape[2],
66-
"transform": src.transform,
67-
"crs": src.crs,
68-
}
57+
with rio.open(source_path) as src:
58+
profile = {
59+
"dtype": array.dtype,
60+
"count": array.shape[0],
61+
"compress": "lzw",
62+
"nodata": None,
63+
"driver": "GTiff",
64+
"height": array.shape[1],
65+
"width": array.shape[2],
66+
"transform": src.transform,
67+
"crs": src.crs,
68+
}
6969

7070
with rio.Env(GDAL_TIFF_INTERNAL_MASK=True):
7171
with rio.open(export_path, "w", **profile) as dst:

0 commit comments

Comments
 (0)