Skip to content

Commit 7e4f542

Browse files
wrignj08claude
andcommitted
Buffer road lines in the raster's own CRS
combine_vector_targets reprojected everything to EPSG:3857, buffered the line features there, then reprojected back to the raster CRS. The detour existed to get a CRS whose units are metres - but the raster's own CRS normally already is one, since Sentinel-2 is UTM. Reprojecting straight to it does in one pass what took two. Measured on a 60 km Perth box carrying 918,399 Overture features (256,906 roads and 661,493 buildings), the function's peak drops from +2.67 GB to +1.26 GB; the two reprojections were 2.1 GB of the 2.7 GB it spent. A full tile covers ~3.4x the area. It also buffers by the distance actually asked for. EPSG:3857's scale factor is 1/cos(latitude), so buffer(distance=5) there laid down about 4.24 m on the ground at Perth's latitude, and a different amount at every other one - the same code gave a Kununurra scene and a Hobart scene different real-world road widths. Roads are now buffered a true 5 m, which widens them by ~18% and sets 1.7% more negative-target pixels (7,470,943 -> 7,598,563 on the box measured). A geographic raster CRS cannot buffer in metres, so it keeps the detour through EPSG:3857 and is reprojected back as before. make_valid is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 220e14d commit 7e4f542

2 files changed

Lines changed: 83 additions & 3 deletions

File tree

omniwatermask/target_builders.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,20 @@ def combine_vector_targets(
156156
if all_targets.empty:
157157
return None
158158

159-
# re-project in 3857 to buffer and then to raster crs
160-
all_targets = gpd.GeoDataFrame(all_targets).to_crs("EPSG:3857")
159+
# Buffering needs a projected CRS so the distance is in metres, and the
160+
# raster's own CRS normally is one - Sentinel-2 is UTM. Reprojecting
161+
# straight to it does in one pass what used to take two, a round trip out
162+
# to EPSG:3857 and back, which cost 2.1 GB of the 2.7 GB this function
163+
# spent on a Perth tile's 918k features.
164+
#
165+
# It also buffers by the distance actually asked for. EPSG:3857's scale
166+
# factor is 1/cos(latitude), so buffering 5 m there lays down ~4.2 m on the
167+
# ground at Perth, and a different amount at every other latitude. Only a
168+
# geographic raster CRS still needs the detour.
169+
raster_crs = CRS.from_user_input(raster_src.crs)
170+
buffer_crs = raster_crs if raster_crs.is_projected else CRS.from_epsg(3857)
171+
172+
all_targets = gpd.GeoDataFrame(all_targets).to_crs(buffer_crs)
161173
if all_targets is None:
162174
return None
163175
# remove points
@@ -174,7 +186,8 @@ def combine_vector_targets(
174186

175187
all_targets["geometry"] = all_targets.geometry.make_valid()
176188

177-
all_targets = gpd.GeoDataFrame(all_targets).to_crs(raster_src.crs)
189+
if buffer_crs != raster_crs:
190+
all_targets = gpd.GeoDataFrame(all_targets).to_crs(raster_crs)
178191

179192
return gpd.GeoDataFrame(all_targets)
180193

tests/test_target_builders.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from rasterio.transform import from_bounds
2+
import rasterio as rio
3+
import numpy as np
14
import logging
25
from queue import Queue
36
from unittest.mock import patch
@@ -582,3 +585,67 @@ def test_records_source_and_ocean_in_cache_db(
582585
assert len(df) == 1
583586
assert df["source"].iloc[0] == "overture"
584587
assert bool(df["ocean"].iloc[0]) is True
588+
589+
590+
class TestCombineVectorTargetsBufferCrs:
591+
"""Lines are buffered in a projected CRS so the distance means metres."""
592+
593+
@staticmethod
594+
def _raster(tmp_path, crs, transform, name):
595+
path = tmp_path / name
596+
with rio.open(
597+
path,
598+
"w",
599+
driver="GTiff",
600+
height=100,
601+
width=100,
602+
count=1,
603+
dtype="uint8",
604+
crs=crs,
605+
transform=transform,
606+
) as dst:
607+
dst.write(np.zeros((1, 100, 100), "uint8"))
608+
return rio.open(path)
609+
610+
def test_projected_raster_buffers_by_a_true_five_metres(self, tmp_path):
611+
"""EPSG:3857 would lay down ~4.2 m here, its scale factor at -32 lat.
612+
613+
Buffering in the raster's own UTM avoids both that error and the
614+
reprojection round trip it used to require.
615+
"""
616+
src = self._raster(
617+
tmp_path,
618+
"EPSG:32750",
619+
from_bounds(390000, 6460000, 391000, 6461000, 100, 100),
620+
"utm.tif",
621+
)
622+
line = gpd.GeoDataFrame(
623+
geometry=[LineString([(390200, 6460500), (390800, 6460500)])],
624+
crs="EPSG:32750",
625+
).to_crs("EPSG:4326")
626+
627+
out = combine_vector_targets([line], src)
628+
assert out.crs == src.crs
629+
minx, miny, maxx, maxy = out.total_bounds
630+
# a 5 m buffer either side of a horizontal line: 10 m tall
631+
assert (maxy - miny) == pytest.approx(10.0, abs=0.2)
632+
633+
def test_geographic_raster_still_buffers_in_metres(self, tmp_path):
634+
"""A degrees-based raster CRS cannot buffer in metres, so it detours."""
635+
src = self._raster(
636+
tmp_path,
637+
"EPSG:4326",
638+
from_bounds(115.80, -32.00, 115.90, -31.90, 100, 100),
639+
"geo.tif",
640+
)
641+
line = gpd.GeoDataFrame(
642+
geometry=[LineString([(115.82, -31.95), (115.88, -31.95)])],
643+
crs="EPSG:4326",
644+
)
645+
646+
out = combine_vector_targets([line], src)
647+
assert out.crs == src.crs
648+
assert (out.geometry.type == "Polygon").all()
649+
# a few metres expressed in degrees of latitude is small but non-zero
650+
minx, miny, maxx, maxy = out.total_bounds
651+
assert 0 < (maxy - miny) < 0.01

0 commit comments

Comments
 (0)