Skip to content

Commit 220e14d

Browse files
wrignj08claude
andcommitted
Stream the debug export instead of stacking it
The debug path built every layer into float32 and stacked them before writing. Most of those layers are natively bool or uint8, a quarter the width, and np.stack copies - so 14 layers on a full Sentinel-2 tile meant 6.3 GB of promoted layers plus a 6.3 GB stacked copy live at once, to produce bands that are serialised one at a time anyway. make_composite_output now hands the layers over as it received them, and export_to_disk promotes each one as it writes it and releases it immediately, so only a single float32 band exists at a time. None is passed through rather than replaced with zeros up front, so an absent layer costs nothing until it is written. Writing band by band needs the GeoTIFF laid out to match: with the default PIXEL interleave, LZW has to decode and re-encode every strip once per band. The streamed path sets BAND interleave and tiles. Measured end to end on a 6000x6000 scene with all 14 debug layers, peak RSS 6.22 GB -> 2.22 GB (-64%). The single-band mask still goes through as a stacked array and is unaffected. Output verified bitwise identical against main across all 14 bands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c4e8351 commit 220e14d

5 files changed

Lines changed: 199 additions & 35 deletions

File tree

omniwatermask/raster_helpers.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import geopandas as gpd
55
import numpy as np
66
import rasterio as rio
7+
import torch
78
from numpy.typing import NDArray
89
from rasterio import features
910
from rasterio.transform import from_bounds
@@ -40,41 +41,91 @@ def resample_input(
4041

4142

4243
def export_to_disk(
43-
array: NDArray[Any],
44+
array: Union[NDArray[Any], list[Optional["torch.Tensor"]]],
4445
export_path: Path,
4546
source_path: Path,
4647
layer_names: list[str],
4748
nodata_mask: Optional[NDArray[Any]] = None,
4849
) -> None:
4950
"""Export the array to disk as a GeoTIFF.
5051
52+
``array`` is either a stacked numpy array - the single-band water mask - or
53+
a list of debug layers, which are written one band at a time. The list form
54+
exists to keep peak memory down: the debug output is 14 layers, and holding
55+
them all as float32 alongside a stacked copy costs 12.6 GB on a full
56+
Sentinel-2 tile, to write bands that are serialised individually anyway.
57+
Each layer is promoted as it is written and released immediately after, so
58+
only one float32 band is live at a time.
59+
5160
If ``nodata_mask`` is provided (1 = valid, 0 = no data) it is written as a
5261
GDAL dataset mask via ``dst.write_mask`` rather than as a regular band, so
5362
GIS software (e.g. QGIS) treats no-data pixels as transparent. The mask is
5463
embedded inside the GeoTIFF (``GDAL_TIFF_INTERNAL_MASK``) rather than written
5564
as a separate ``.tif.msk`` sidecar, so it travels with the file.
5665
"""
66+
layers: Optional[list[Optional["torch.Tensor"]]] = (
67+
array if isinstance(array, list) else None
68+
)
69+
if layers is not None:
70+
shape = _first_layer_shape(layers)
71+
count, height, width = len(layers), shape[-2], shape[-1]
72+
dtype: Any = "float32"
73+
else:
74+
assert not isinstance(array, list)
75+
count, height, width = array.shape[0], array.shape[1], array.shape[2]
76+
dtype = array.dtype
77+
5778
with rio.open(source_path) as src:
5879
profile = {
59-
"dtype": array.dtype,
60-
"count": array.shape[0],
80+
"dtype": dtype,
81+
"count": count,
6182
"compress": "lzw",
6283
"nodata": None,
6384
"driver": "GTiff",
64-
"height": array.shape[1],
65-
"width": array.shape[2],
85+
"height": height,
86+
"width": width,
6687
"transform": src.transform,
6788
"crs": src.crs,
6889
}
90+
if layers is not None:
91+
# Writing band by band only lays out and compresses well with BAND
92+
# interleave and tiles; under the default PIXEL interleave LZW has to
93+
# decode and re-encode every strip once per band.
94+
profile.update(interleave="band", tiled=True, blockxsize=512, blockysize=512)
6995

7096
with rio.Env(GDAL_TIFF_INTERNAL_MASK=True):
7197
with rio.open(export_path, "w", **profile) as dst:
72-
dst.write(array)
98+
if layers is not None:
99+
for index in range(count):
100+
band = _layer_as_float32(layers[index], height, width)
101+
# Drop the caller's reference as well as ours, so a layer
102+
# nothing else holds is freed before the next is promoted.
103+
layers[index] = None
104+
dst.write(band, index + 1)
105+
del band
106+
else:
107+
dst.write(array)
73108
dst.descriptions = layer_names
74109
if nodata_mask is not None:
75110
dst.write_mask((nodata_mask * 255).astype("uint8"))
76111

77112

113+
def _first_layer_shape(layers: list[Optional["torch.Tensor"]]) -> tuple[int, ...]:
114+
for layer in layers:
115+
if layer is not None:
116+
return tuple(layer.shape)
117+
raise ValueError("export_to_disk was given no non-None layers")
118+
119+
120+
def _layer_as_float32(
121+
layer: Optional["torch.Tensor"], height: int, width: int
122+
) -> NDArray[Any]:
123+
"""Promote one debug layer to the float32 band the GeoTIFF stores."""
124+
if layer is None:
125+
return np.zeros((height, width), dtype=np.float32)
126+
return layer.float().numpy(force=True).astype(np.float32, copy=False)
127+
128+
78129
def rasterize_vector(
79130
gdf: gpd.GeoDataFrame, reference_profile: dict[str, Any], all_touched: bool = False
80131
) -> NDArray[Any]:

omniwatermask/water_inf_helpers.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from .target_builders import build_targets
1616

1717

18+
# What export_to_disk accepts: a stacked array for the single-band mask, or the
19+
# debug layers left as tensors so they can be promoted one at a time.
20+
ExportPayload = Union[NDArray[Any], list[Optional[torch.Tensor]]]
21+
22+
1823
def get_masked_iou(
1924
source: torch.Tensor,
2025
target: torch.Tensor,
@@ -287,25 +292,25 @@ def get_NDWI(
287292

288293
def make_composite_output(
289294
input_dict: dict[str, Optional[torch.Tensor]],
290-
) -> tuple[NDArray[Any], list[str]]:
291-
output_layers = []
292-
layer_names = []
293-
# Get the shape of the first non-None layer
294-
shape = None
295-
for value in input_dict.values():
296-
if value is not None:
297-
shape = value.shape
298-
break
299-
if shape is None:
295+
) -> tuple[list[Optional[torch.Tensor]], list[str]]:
296+
"""Collect the debug layers, leaving them in whatever form they arrived in.
297+
298+
Layers are handed on as tensors rather than promoted to float32 and stacked.
299+
Most of them are natively bool or uint8 - a quarter the width - and stacking
300+
doubles whatever the promoted set costs, because np.stack copies. On a full
301+
Sentinel-2 tile that was 6.3 GB of float32 layers plus a 6.3 GB stacked copy
302+
live at once, to write bands that are then serialised one at a time anyway.
303+
304+
``export_to_disk`` promotes each layer as it writes it, so only one float32
305+
band exists at a time. ``None`` is passed through rather than replaced with
306+
zeros here, so an absent layer costs nothing until it is written.
307+
"""
308+
if all(value is None for value in input_dict.values()):
300309
raise ValueError("make_composite_output requires at least one non-None layer")
301310
for key, value in input_dict.items():
302-
# if value is None, use a zero tensor to avoid missing layers
303311
if value is None:
304-
logging.info(f"Layer {key} is None, setting to zero tensor")
305-
value = torch.zeros(shape, dtype=torch.float32)
306-
output_layers.append(value.float().numpy(force=True).astype(np.float32))
307-
layer_names.append(key)
308-
return np.stack(output_layers), layer_names
312+
logging.info(f"Layer {key} is None, will be written as zeros")
313+
return list(input_dict.values()), list(input_dict.keys())
309314

310315

311316
def _fuse_any(layers: list[torch.Tensor]) -> torch.Tensor:
@@ -413,7 +418,7 @@ def integrate_water_detection_methods(
413418
mosaic_device: Union[str, torch.device] = "cpu",
414419
no_data_value: int = 0,
415420
optimise_model: bool = True,
416-
) -> tuple[NDArray[Any], list[str], Optional[NDArray[Any]]]:
421+
) -> tuple[ExportPayload, list[str], Optional[NDArray[Any]]]:
417422
"""Combine the NDWI, model predictions and vector targets.
418423
419424
Returns the stacked output array, the per-band layer names, and an optional
@@ -633,6 +638,7 @@ def integrate_water_detection_methods(
633638

634639
if debug_output:
635640
logging.info("Exporting debug layers")
641+
final_output: ExportPayload
636642
final_output, layer_names = make_composite_output(
637643
{
638644
"Water predictions": combined_water_tensor,
@@ -653,8 +659,8 @@ def integrate_water_detection_methods(
653659
)
654660
nodata_mask_np = None
655661
else:
656-
final_output = combined_water_tensor.numpy(force=True).astype(np.uint8)
657-
final_output = np.expand_dims(final_output, axis=0)
662+
mask_band = combined_water_tensor.numpy(force=True).astype(np.uint8)
663+
final_output = np.expand_dims(mask_band, axis=0)
658664
layer_names = ["Water predictions"]
659665
# validity mask: 1 where data is valid, 0 where no data. Written as a
660666
# GDAL dataset mask on export so QGIS treats nodata as transparent

tests/test_orchestration.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ def test_debug_output_has_diagnostic_layers(
127127
use_model=True,
128128
debug_output=True,
129129
)
130-
assert result.ndim == 3
130+
# debug output is handed on as a list of layers, promoted per band at
131+
# export time rather than stacked here
132+
assert isinstance(result, list)
133+
assert len(result) == len(layer_names)
131134
assert len(layer_names) > 2
132135
assert "Water predictions" in layer_names
133136
assert "NDWI binary" in layer_names

tests/test_raster_helpers.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import pytest
12
import numpy as np
3+
import torch
24
import geopandas as gpd
35
import rasterio as rio
46
from shapely.geometry import box
@@ -129,3 +131,90 @@ def test_full_coverage_polygon(self, sample_rasterio_src):
129131
gdf = gpd.GeoDataFrame(geometry=[full_poly], crs=sample_rasterio_src.crs)
130132
result = rasterize_vector(gdf, profile)
131133
assert result.sum() == 100 * 100
134+
135+
136+
class TestExportToDiskStreaming:
137+
"""The debug path hands over a list of layers instead of a stacked array."""
138+
139+
def test_writes_each_layer_as_its_own_band(self, sample_geotiff, tmp_dir):
140+
layers = [
141+
torch.full((100, 100), 3, dtype=torch.uint8),
142+
torch.ones((100, 100), dtype=torch.bool),
143+
]
144+
export_path = tmp_dir / "streamed.tif"
145+
export_to_disk(
146+
array=layers,
147+
export_path=export_path,
148+
source_path=sample_geotiff,
149+
layer_names=["counts", "flags"],
150+
)
151+
with rio.open(export_path) as src:
152+
assert src.count == 2
153+
assert src.dtypes == ("float32", "float32")
154+
assert src.descriptions == ("counts", "flags")
155+
assert np.all(src.read(1) == 3.0)
156+
assert np.all(src.read(2) == 1.0)
157+
158+
def test_releases_each_layer_as_it_is_written(self, sample_geotiff, tmp_dir):
159+
"""The memory contract: a written layer must not still be referenced.
160+
161+
Holding all 14 debug layers as float32 plus a stacked copy is what cost
162+
12.6 GB on a full Sentinel-2 tile. Streaming only helps if each source
163+
is actually dropped, which callers rely on.
164+
"""
165+
layers = [torch.ones((50, 50)), torch.zeros((50, 50))]
166+
export_to_disk(
167+
array=layers,
168+
export_path=tmp_dir / "released.tif",
169+
source_path=sample_geotiff,
170+
layer_names=["a", "b"],
171+
)
172+
assert layers == [None, None]
173+
174+
def test_none_layer_is_written_as_zeros(self, sample_geotiff, tmp_dir):
175+
layers = [torch.ones((100, 100)), None]
176+
export_path = tmp_dir / "with_none.tif"
177+
export_to_disk(
178+
array=layers,
179+
export_path=export_path,
180+
source_path=sample_geotiff,
181+
layer_names=["present", "missing"],
182+
)
183+
with rio.open(export_path) as src:
184+
assert np.all(src.read(2) == 0.0)
185+
186+
def test_uses_band_interleave_and_tiles(self, sample_geotiff, tmp_dir):
187+
"""Band-at-a-time writes only compress well with BAND interleave."""
188+
export_path = tmp_dir / "layout.tif"
189+
export_to_disk(
190+
array=[torch.ones((100, 100)), torch.zeros((100, 100))],
191+
export_path=export_path,
192+
source_path=sample_geotiff,
193+
layer_names=["a", "b"],
194+
)
195+
with rio.open(export_path) as src:
196+
assert src.profile["tiled"] is True
197+
assert src.interleaving.name.lower() == "band"
198+
199+
def test_all_none_is_rejected(self, sample_geotiff, tmp_dir):
200+
with pytest.raises(ValueError):
201+
export_to_disk(
202+
array=[None, None],
203+
export_path=tmp_dir / "empty.tif",
204+
source_path=sample_geotiff,
205+
layer_names=["a", "b"],
206+
)
207+
208+
def test_stacked_array_path_is_unchanged(self, sample_geotiff, tmp_dir):
209+
"""The single-band mask still goes through as a numpy array."""
210+
array = np.ones((1, 100, 100), dtype=np.uint8)
211+
export_path = tmp_dir / "mask.tif"
212+
export_to_disk(
213+
array=array,
214+
export_path=export_path,
215+
source_path=sample_geotiff,
216+
layer_names=["Water predictions"],
217+
)
218+
with rio.open(export_path) as src:
219+
assert src.count == 1
220+
assert src.dtypes == ("uint8",)

tests/test_water_inf_helpers.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,29 +154,44 @@ def test_water_has_positive_ndwi(self):
154154

155155

156156
class TestMakeCompositeOutput:
157-
def test_stacks_layers(self):
157+
def test_returns_layers_and_names_in_order(self):
158158
layers = {
159159
"layer1": torch.ones(10, 10),
160160
"layer2": torch.zeros(10, 10),
161161
}
162162
output, names = make_composite_output(layers)
163-
assert output.shape == (2, 10, 10)
164163
assert names == ["layer1", "layer2"]
164+
assert len(output) == 2
165+
assert all(t.shape == (10, 10) for t in output)
165166

166-
def test_handles_none_values(self):
167+
def test_passes_none_through_rather_than_materialising_zeros(self):
168+
"""An absent layer should cost nothing until export writes it."""
167169
layers = {
168170
"present": torch.ones(10, 10),
169171
"missing": None,
170172
}
171173
output, names = make_composite_output(layers)
172-
assert output.shape == (2, 10, 10)
173-
# The None layer should be zeros
174-
assert np.all(output[1] == 0)
174+
assert names == ["present", "missing"]
175+
assert output[1] is None
175176

176-
def test_output_dtype_is_float32(self):
177-
layers = {"a": torch.ones(5, 5, dtype=torch.int32)}
177+
def test_leaves_layers_in_their_own_dtype(self):
178+
"""Promotion to float32 happens per band at export, not here.
179+
180+
Most debug layers are natively bool or uint8; promoting them all up
181+
front is what used to cost 6.3 GB on a full tile, doubled again by the
182+
stacked copy.
183+
"""
184+
layers = {
185+
"a": torch.ones(5, 5, dtype=torch.int32),
186+
"b": torch.ones(5, 5).bool(),
187+
}
178188
output, _ = make_composite_output(layers)
179-
assert output.dtype == np.float32
189+
assert output[0].dtype == torch.int32
190+
assert output[1].dtype == torch.bool
191+
192+
def test_rejects_an_all_none_dict(self):
193+
with pytest.raises(ValueError):
194+
make_composite_output({"a": None, "b": None})
180195

181196

182197
class TestCollectTarget:

0 commit comments

Comments
 (0)