Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ Unreleased Changes
* Fixed a bug where ``convolve_2d`` was not handling ``nan`` NoData
values correctly.
https://github.com/natcap/pygeoprocessing/issues/473
* Fixed a bug where ``align_and_resize_raster_stack`` would pad rasters that
were smaller than the target extent and didn't have a defined NoData value
with 0s. The function will now automatically assign an appropriate NoData
value to any input raster without one defined.
https://github.com/natcap/pygeoprocessing/issues/476

2.4.10 (2026-01-13)
-------------------
Expand Down
44 changes: 42 additions & 2 deletions src/pygeoprocessing/geoprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,44 @@ def align_and_resize_raster_stack(
n_pixels * align_pixel_size[index] +
align_bounding_box[index])

# Set a nodata value if the raster does not have one defined. This
# ensures that a raster that is smaller than the target extent will
# not get filled with 0s that could be confused with real data.
# We do this by creating a temporary VRT with an explicit nodata value
# for rasters that are missing one.
fixed_base_raster_path_list = list(base_raster_path_list)
temp_working_dir_nodata = None
for index, (raster_info, base_path) in enumerate(
zip(raster_info_list, base_raster_path_list)):
nodata_list = raster_info['nodata']
if None in nodata_list:
if temp_working_dir_nodata is None:
temp_working_dir_nodata = tempfile.mkdtemp(dir=working_dir,
prefix='align-nodata-')
raster = gdal.OpenEx(base_path, gdal.OF_RASTER)
chosen_nodata = choose_nodata(raster_info['numpy_type'])

if set(nodata_list) != {None}:
LOGGER.warning(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can tell, gdal does not create geotiffs that have different NoData values (even if you try to explicitly set different NoData values for different bands, it won't work), so I think this would be a very rare situation and therefore not super important or easy to handle gracefully (i.e., by setting NoData only for the bands that lack a defined NoData value, since gdal doesn't support this for tiffs). I also decided not to raise an error as a raster that has the pygeoprocessing-approved NoData value for its datatype (e.g., 32767 for an int16 raster) would be fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think gdal handles different nodata values for different bands. See the "UNIFIED_SRC_NODATA" option from gdalwarpoptions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately that doesn't seem to work! Not sure why, because I can't seem to find documentation saying this doesn't work for geotiffs, but I do get this warning Warning 1: multi_diffND.tif, band 2: Setting nodata to 255 on band 2, but band 1 has nodata at 9999. The TIFFTAG_GDAL_NODATA only support one value per dataset. This value of 255 will be used for all bands on re-opening

"Input raster %s has some bands with a defined nodata "
"value and some bands without. Setting %s as the nodata "
"value for all bands in a temporary VRT before aligning "
"and resizing.", base_path, chosen_nodata)
else:
LOGGER.warning(
"Input raster %s has no defined nodata value. Setting "
"%s as nodata in a temporary VRT before aligning and "
"resizing.", base_path, chosen_nodata)

vrt_path = os.path.join(temp_working_dir_nodata,
f'input_with_nodata_{index}.vrt')
vrt = gdal.Translate(vrt_path, raster, format='VRT',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if the creation of the VRT is necessary or if we could take advantage of gdal_warp_options and the dstnodata (?) argument. Like the resample list, could we keep a list of destination nodata values and pass those along through the gdal_warp_options?

I'm not sure it's a better approach than the VRT one other than it reduces a call to gdal.Translate.

Copy link
Copy Markdown
Contributor Author

@claire-simpson claire-simpson Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my comment above, I've tried using the UNIFIED_SRC_NODATA and the srcNodata and dstNodata warp options, and have tried to explicitly set no data for each band via band1.SetNoDataValue(255); band2.SetNoDataValue(9999), but both of these result in an output tif that has the same nodata value for each band. Using a VRT was the only way I could create a file with different nodata values for each band.

noData=chosen_nodata)
vrt = None
raster = None

fixed_base_raster_path_list[index] = vrt_path

if mask_options:
# Create a warped VRT.
# This allows us to cheaply figure out the dimensions, projection, etc.
Expand All @@ -1110,7 +1148,7 @@ def align_and_resize_raster_stack(
temp_working_dir = tempfile.mkdtemp(dir=working_dir, prefix='mask-')
warped_vrt = os.path.join(temp_working_dir, 'warped.vrt')
warp_raster(
base_raster_path=base_raster_path_list[0],
base_raster_path=fixed_base_raster_path_list[0],
target_pixel_size=target_pixel_size,
target_raster_path=warped_vrt,
resample_method='near',
Expand Down Expand Up @@ -1142,7 +1180,7 @@ def align_and_resize_raster_stack(
else None))

for index, (base_path, target_path, resample_method) in enumerate(zip(
base_raster_path_list, target_raster_path_list,
fixed_base_raster_path_list, target_raster_path_list,
resample_method_list)):
warp_raster(
base_path, target_pixel_size, target_path, resample_method,
Expand All @@ -1160,6 +1198,8 @@ def align_and_resize_raster_stack(

LOGGER.info("aligned all %d rasters.", n_rasters)

if temp_working_dir_nodata:
shutil.rmtree(temp_working_dir_nodata, ignore_errors=True)
if mask_options:
shutil.rmtree(temp_working_dir, ignore_errors=True)

Expand Down
72 changes: 72 additions & 0 deletions tests/test_geoprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2585,6 +2585,78 @@ def test_align_and_resize_raster_stack_bb(self):
numpy.testing.assert_array_equal(
numpy.ones((4, 4), pixel_a_matrix.dtype), target_array)

def test_align_and_resize_raster_stack_no_set_nodata_multiband(self):
"""Test align_and_resize sets nodata if not defined for input."""
arr = numpy.arange(16).reshape(4, 4)

srs = osr.SpatialReference()
srs.ImportFromEPSG(26910)
input_raster_path = os.path.join(self.workspace_dir, 'test.tif')
pygeoprocessing.numpy_array_to_raster(
arr.astype(numpy.int16), target_nodata=None,
pixel_size=(10, -10), origin=(0, 0),
projection_wkt=srs.ExportToWkt(),
target_path=input_raster_path)

output_raster_path = os.path.join(self.workspace_dir, 'test_align.tif')
pygeoprocessing.align_and_resize_raster_stack(
[input_raster_path],
[output_raster_path],
['near'],
(10, -10),
[-10, -20, 20, 0])
# this target bounding box extends beyond extent of input raster
# (input raster's bbox is [0, -40, 40, 0])

output_array = pygeoprocessing.raster_to_numpy_array(
output_raster_path)
expected_output_array = numpy.array([[32767, 0, 1], [32767, 4, 5]])
numpy.testing.assert_allclose(output_array, expected_output_array)

def test_align_and_resize_raster_stack_mixed_nodata_warns(self):
"""Test warning if 1 band in multiband raster has no defined nodata.

Test that a warning is raised if a multiband raster with one band
that has a defined nodata value and one band that does not have a
defined nodata value is passed to align_and_resize_raster_stack.
"""

raster_path = os.path.join(self.workspace_dir, 'multi.tif')
driver = gdal.GetDriverByName('GTiff')
raster = driver.Create(raster_path, 4, 4, 2, gdal.GDT_Int16)
srs = osr.SpatialReference()
srs.ImportFromEPSG(26910)
raster.SetProjection(srs.ExportToWkt())
raster.SetGeoTransform((0, 10, 0, 0, 0, -10))
arr = numpy.arange(16).reshape(4, 4)
band1 = raster.GetRasterBand(1)
band2 = raster.GetRasterBand(2)
band1.WriteArray(arr)
band2.WriteArray(arr)
raster = None

vrt_path = os.path.join(self.workspace_dir, 'multi.vrt')
gdal.BuildVRT(vrt_path, [raster_path])
vrt = gdal.Open(vrt_path, gdal.GA_Update)
vrt.GetRasterBand(2).SetNoDataValue(-9999)
# leave band 1 without nodata
vrt = None

output_raster_path = os.path.join(self.workspace_dir, 'out.tif')
with self.assertLogs(level='WARNING') as cm:
pygeoprocessing.align_and_resize_raster_stack(
[vrt_path],
[output_raster_path],
['near'],
(10, -10),
[-10, -20, 20, 0]
)

self.assertTrue(
any("some bands with a defined nodata value and some bands without"
in msg for msg in cm.output),
f"Expected warning not found in logs: {cm.output}")

def test_raster_calculator(self):
"""PGP.geoprocessing: raster_calculator identity test."""
pixel_matrix = numpy.ones((5, 5), numpy.int16)
Expand Down
Loading