Skip to content

Commit 19ae635

Browse files
authored
HyBIG 2.8.0: Improved reprojection support and bug fixes (#67)
* GITC-9048: Fix latitude max < latitude min error when transforming bounds, fix polar projection, fix adapter * GITC-9047/9048/9148: Updated unit tests, removed bad read window code, updated changelog * Update dependency versions
1 parent 1ed62d6 commit 19ae635

7 files changed

Lines changed: 38 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ HyBIG follows semantic versioning. All notable changes to this project will be
44
documented in this file. The format is based on [Keep a
55
Changelog](http://keepachangelog.com/en/1.0.0/).
66

7+
## [2.8.0] - 2026-07-17
8+
9+
* [GITC-9047](https://bugs.earthdata.nasa.gov/browse/GITC-9047): Updated adapter code to now log a message when a request produces no data, but not throw a service error. Previously, jobs with no output would produce this error: `WorkItem failed: nasa/harmony-browse-image-generator:2.7.0: list index out of range`
10+
* [GITC-9048](https://bugs.earthdata.nasa.gov/browse/GITC-9048): Fixed a bug where some granules fail to process due to an inverted latitude coordinate system.
11+
* [GITC-9148](https://bugs.earthdata.nasa.gov/browse/GITC-9148): Removed an "optimization" when reading from the source data that caused an unintended bug when reprojecting some types of data from EPSG:4326 to EPSG:3413. The consequence of removing this code is that requests for downsampled browse images will use more memory, but this is not a common mode for HyBIG.
12+
* Update harmony-service-lib to 3.0 and pillow to 12.3.0
13+
14+
715
## [2.7.0] - 2026-05-11
816

917
### Changed

docker/service_version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.7.0
1+
2.8.0

harmony_service/adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,13 @@ def process_item(self, item: Item, source: HarmonySource) -> Item:
121121
# image_file_list is a list of tuples (image, world, auxiliary)
122122
# we need to stage them each individually, and then add their final
123123
# locations to a list before creating the stac item.
124-
item_assets = []
124+
item_assets: list[tuple[str, str, str]] = []
125+
126+
if not image_file_list:
127+
# If output is empty, log an error message but don't throw an
128+
# Exception, just return an empty stac item
129+
self.logger.error('No output assets produced from HyBIG call.')
130+
return self.create_output_stac_item(item, item_assets)
125131

126132
for (
127133
browse_image_name,

hybig/browse.py

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import numpy as np
99
import rasterio
10-
from affine import Affine, dumpsw
10+
from affine import dumpsw
1111
from harmony_service_lib.message import Message as HarmonyMessage
1212
from harmony_service_lib.message import Source as HarmonySource
1313
from matplotlib.colors import BoundaryNorm, Normalize
@@ -228,22 +228,10 @@ def process_tile(
228228
if src_window is None:
229229
return False
230230

231-
# Compute downsampled read dimensions: read at output resolution rather than
232-
# full source resolution, avoiding loading the entire source into memory when
233-
# the output is much smaller than the source (e.g. height=1280, width=2560).
234-
src_pixel_x = abs(src_ds.transform.a)
235-
src_pixel_y = abs(src_ds.transform.e)
236-
dst_pixel_x = abs(grid_params['transform'].a)
237-
dst_pixel_y = abs(grid_params['transform'].e)
231+
read_width = int(src_window.width)
232+
read_height = int(src_window.height)
238233

239-
win_height = int(src_window.height)
240-
win_width = int(src_window.width)
241-
242-
# Cap at full window size so we never upsample during the read.
243-
read_width = min(win_width, max(1, round(win_width * src_pixel_x / dst_pixel_x)))
244-
read_height = min(win_height, max(1, round(win_height * src_pixel_y / dst_pixel_y)))
245-
246-
# Explicitly load a subset of ds at the target resolution
234+
# Explicitly load a subset of the dataset needed for the browse image tile
247235
tile_source = read_window_with_mask_and_scale(
248236
src_ds, src_window, out_shape=(band_count, read_height, read_width)
249237
)
@@ -255,11 +243,7 @@ def process_tile(
255243
return False
256244

257245
src_crs = src_ds.crs
258-
# Adjust the window transform to reflect the downsampled pixel size.
259-
window_transform = src_ds.window_transform(src_window)
260-
src_transform = window_transform * Affine.scale(
261-
win_width / read_width, win_height / read_height
262-
)
246+
src_transform = src_ds.window_transform(src_window)
263247

264248
dst_nodata = TRANSPARENT
265249

hybig/sizes.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,18 @@ def choose_scale_extent(
151151
}
152152
)
153153
else:
154-
left, bottom, right, top = transform_bounds(src_ds.crs, dst_crs, *src_ds.bounds)
154+
# Normalize the source bounds before transforming. Some inputs have a
155+
# flipped (south-up) geotransform with a positive Y resolution, which
156+
# makes src_ds.bounds report top < bottom. transform_bounds requires a
157+
# properly ordered box (min/max), otherwise PROJ raises
158+
# "latitude max < latitude min."
159+
src_left = min(src_ds.bounds.left, src_ds.bounds.right)
160+
src_right = max(src_ds.bounds.left, src_ds.bounds.right)
161+
src_bottom = min(src_ds.bounds.bottom, src_ds.bounds.top)
162+
src_top = max(src_ds.bounds.bottom, src_ds.bounds.top)
163+
left, bottom, right, top = transform_bounds(
164+
src_ds.crs, dst_crs, src_left, src_bottom, src_right, src_top
165+
)
155166

156167
# Correct for antimeridian crossing.
157168
if left > right:

pip_requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
harmony-service-lib~=2.11.0
1+
harmony-service-lib~=3.0.0
22
matplotlib==3.9.0
33
numpy==1.26.4
4-
pillow==12.2.0
4+
pillow~=12.3.0
55
pyproj==3.6.1
66
pystac~=1.0.1
77
rasterio==1.3.10

tests/unit/test_browse.py

Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from numpy.testing import assert_array_equal, assert_equal
1414
from osgeo_utils.auxiliary.color_palette import ColorPalette
1515
from rasterio import Affine
16+
from rasterio.coords import BoundingBox
1617
from rasterio.crs import CRS
1718
from rasterio.io import DatasetReader, DatasetWriter
1819
from rasterio.warp import Resampling
@@ -161,7 +162,7 @@ def test_create_browse_imagery_with_mocks(self, rasterio_open_mock, reproject_mo
161162
ds.crs = CRS.from_string('EPSG:4326')
162163
ds.count = 1
163164
ds.colormap = Mock(side_effect=ValueError)
164-
ds.bounds = (-180.0, -90.0, 180.0, 90.0)
165+
ds.bounds = BoundingBox(-180.0, -90.0, 180.0, 90.0)
165166
ds.window_transform = Mock(return_value=file_transform)
166167
ds.nodatavals = (255,)
167168
ds.scales = (1,)
@@ -313,7 +314,7 @@ def test_create_browse_imagery_excludes_all_nan_tiles(self, rasterio_open_mock):
313314
ds.crs = CRS.from_string('EPSG:4326')
314315
ds.count = 1
315316
ds.colormap = Mock(side_effect=ValueError)
316-
ds.bounds = (-180.0, -90.0, 180.0, 90.0)
317+
ds.bounds = BoundingBox(-180.0, -90.0, 180.0, 90.0)
317318
ds.window_transform = Mock(return_value=file_transform)
318319
ds.nodatavals = (None,)
319320
ds.scales = (1,)
@@ -368,74 +369,6 @@ def test_read_window_with_mask_and_scale_without_out_shape(self):
368369
ds.read.assert_called_once_with([1], window=window)
369370
self.assertEqual(result.shape, (1, 4, 4))
370371

371-
@patch('hybig.browse.reproject')
372-
@patch('rasterio.open')
373-
def test_process_tile_downsamples_read_for_coarser_output(
374-
self, rasterio_open_mock, reproject_mock
375-
):
376-
"""Test process_tile reads at output resolution when output is coarser
377-
than source.
378-
379-
This is the memory fix: a 36000x18000 source with a 1280x2560 output
380-
should not load the full-resolution source into memory.
381-
"""
382-
# Source: 1° pixels, 10x10
383-
src_affine = Affine(1.0, 0.0, -5.0, 0.0, -1.0, 5.0)
384-
ds = Mock(spec=DatasetReader)
385-
ds.crs = CRS.from_string('EPSG:4326')
386-
ds.transform = src_affine
387-
ds.shape = (10, 10)
388-
ds.count = 1
389-
ds.nodatavals = (None,)
390-
ds.scales = (1,)
391-
ds.offsets = (0,)
392-
# Return data at the downsampled (2x2) size
393-
ds.read.return_value = np.array([[[0, 100], [200, 255]]], dtype='float64')
394-
ds.window_transform.return_value = src_affine
395-
396-
dest_write_mock = Mock(spec=DatasetWriter)
397-
rasterio_open_mock.return_value.__enter__.return_value = dest_write_mock
398-
399-
# Output: 5° pixels, 2x2 — coarser than source
400-
out_affine = Affine(5.0, 0.0, -5.0, 0.0, -5.0, 5.0)
401-
grid_params = GridParams(
402-
{
403-
'height': 2,
404-
'width': 2,
405-
'crs': CRS.from_string('EPSG:4326'),
406-
'transform': out_affine,
407-
}
408-
)
409-
410-
result = process_tile(
411-
ds,
412-
grid_params,
413-
None,
414-
'PNG',
415-
self.tmp_dir / 'output.png',
416-
self.tmp_dir / 'output.pgw',
417-
self.logger,
418-
)
419-
420-
self.assertTrue(result)
421-
422-
# Source window covers the full 10x10 source (with buffer, clamped).
423-
# read_width = min(10, round(10 * 1.0 / 5.0)) = 2
424-
# read_height = min(10, round(10 * 1.0 / 5.0)) = 2
425-
ds.read.assert_called_once()
426-
read_kwargs = ds.read.call_args.kwargs
427-
self.assertIn('out_shape', read_kwargs)
428-
_bands, read_height, read_width = read_kwargs['out_shape']
429-
self.assertEqual(read_width, 2)
430-
self.assertEqual(read_height, 2)
431-
432-
# src_transform passed to reproject must have 5° pixel size (scaled from 1°)
433-
self.assertEqual(reproject_mock.call_count, 1)
434-
actual_src_transform = reproject_mock.call_args.kwargs['src_transform']
435-
self.assertAlmostEqual(actual_src_transform.a, 5.0)
436-
self.assertAlmostEqual(abs(actual_src_transform.e), 5.0)
437-
self.assertAlmostEqual(actual_src_transform.c, -5.0) # origin unchanged
438-
439372
@patch('hybig.browse.reproject')
440373
@patch('rasterio.open')
441374
def test_process_tile_full_res_when_resolutions_match(

0 commit comments

Comments
 (0)