Skip to content

Commit 09081c9

Browse files
authored
DAS-2276: retain 3 and 4 band information in browse images (nasa#39)
1 parent 2f47607 commit 09081c9

File tree

6 files changed

+285
-123
lines changed

6 files changed

+285
-123
lines changed

CHANGELOG.md

+14-12
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ 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-
## [unreleased] - 2024-12-10
7+
## [v2.1.0] - 2024-12-13
88

99
### Changed
1010

11+
* Input GeoTIFF RGB[A] images are **no longer palettized** when converted to a PNG. The new resulting output browse images are now 3 or 4 band PNG retaining the color information of the input image.[#39](https://github.com/nasa/harmony-browse-image-generator/pull/39)
1112
* Changed pre-commit configuration to remove `black-jupyter` dependency [#38](https://github.com/nasa/harmony-browse-image-generator/pull/38)
1213
* Updates service image's python to 3.12 [#38](https://github.com/nasa/harmony-browse-image-generator/pull/38)
1314
* Simplifies test scripts to run with pytest and pytest plugins [#38](https://github.com/nasa/harmony-browse-image-generator/pull/38)
@@ -90,14 +91,15 @@ outlined by the NASA open-source guidelines.
9091
For more information on internal releases prior to NASA open-source approval,
9192
see legacy-CHANGELOG.md.
9293

93-
[unreleased]:https://github.com/nasa/harmony-browse-image-generator/compare/2.0.2..HEAD
94-
[v2.0.2]:https://github.com/nasa/harmony-browse-image-generator/compare/2.0.1..2.0.2
95-
[v2.0.1]:https://github.com/nasa/harmony-browse-image-generator/compare/2.0.0..2.0.1
96-
[v2.0.0]:https://github.com/nasa/harmony-browse-image-generator/compare/1.2.2..2.0.0
97-
[v1.2.2]: https://github.com/nasa/harmony-browse-image-generator/compare/1.2.1..1.2.2
98-
[v1.2.1]: https://github.com/nasa/harmony-browse-image-generator/compare/1.2.0..1.2.1
99-
[v1.2.0]: https://github.com/nasa/harmony-browse-image-generator/compare/1.1.0..1.2.0
100-
[v1.1.0]: https://github.com/nasa/harmony-browse-image-generator/compare/1.0.2..1.1.0
101-
[v1.0.2]: https://github.com/nasa/harmony-browse-image-generator/compare/1.0.1..1.0.2
102-
[v1.0.1]: https://github.com/nasa/harmony-browse-image-generator/compare/1.0.0..1.0.1
103-
[v1.0.0]: https://github.com/nasa/harmony-browse-image-generator/compare/0.0.11-legacy..1.0.0
94+
[unreleased]: https://github.com/nasa/harmony-browse-image-generator/
95+
[v2.1.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.1.0
96+
[v2.0.2]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.0.2
97+
[v2.0.1]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.0.1
98+
[v2.0.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.0.0
99+
[v1.2.2]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.2.2
100+
[v1.2.1]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.2.1
101+
[v1.2.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.2.0
102+
[v1.1.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.1.0
103+
[v1.0.2]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.0.2
104+
[v1.0.1]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.0.1
105+
[v1.0.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/1.0.0

docker/service_version.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.0.2
1+
2.1.0

hybig/browse.py

+81-28
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from harmony_service_lib.message import Source as HarmonySource
1414
from matplotlib.cm import ScalarMappable
1515
from matplotlib.colors import Normalize
16-
from numpy import ndarray
16+
from numpy import ndarray, uint8
1717
from osgeo_utils.auxiliary.color_palette import ColorPalette
1818
from PIL import Image
1919
from rasterio.io import DatasetReader
@@ -181,7 +181,9 @@ def create_browse_imagery(
181181
f'incorrect number of bands for image: {rio_in_array.rio.count}'
182182
)
183183

184-
raster, color_map = prepare_raster_for_writing(raster, output_driver)
184+
raster, color_map = standardize_raster_for_writing(
185+
raster, output_driver, rio_in_array.rio.count
186+
)
185187

186188
grid_parameters = get_target_grid_parameters(message, rio_in_array)
187189
grid_parameter_list, tile_locators = create_tiled_output_parameters(
@@ -217,12 +219,14 @@ def create_browse_imagery(
217219
return processed_files
218220

219221

220-
def convert_mulitband_to_raster(data_array: DataArray) -> ndarray:
222+
def convert_mulitband_to_raster(data_array: DataArray) -> ndarray[uint8]:
221223
"""Convert multiband to a raster image.
222224
223-
Reads the three or four bands from the file, then normalizes them to the range
224-
0 to 255. This assumes the input image is already in RGB or RGBA format and
225-
just ensures that the output is 8bit.
225+
Return a 4-band raster, where the alpha layer is presumed to be the missing
226+
data mask.
227+
228+
Convert 3-band data into a 4-band raster by generating an alpha layer from
229+
any missing data in the RGB bands.
226230
227231
"""
228232
if data_array.rio.count not in [3, 4]:
@@ -233,26 +237,49 @@ def convert_mulitband_to_raster(data_array: DataArray) -> ndarray:
233237

234238
bands = data_array.to_numpy()
235239

236-
# Create an alpha layer where input NaN values are transparent.
240+
if data_array.rio.count == 4:
241+
return convert_to_uint8(bands, original_dtype(data_array))
242+
243+
# Input NaNs in any of the RGB bands are made transparent.
237244
nan_mask = np.isnan(bands).any(axis=0)
238245
nan_alpha = np.where(nan_mask, TRANSPARENT, OPAQUE)
239246

240-
# grab any existing alpha layer
241-
bands, image_alpha = remove_alpha(bands)
247+
raster = convert_to_uint8(bands, original_dtype(data_array))
242248

243-
norm = Normalize(vmin=np.nanmin(bands), vmax=np.nanmax(bands))
244-
raster = np.nan_to_num(np.around(norm(bands) * 255.0), copy=False, nan=0.0).astype(
245-
'uint8'
246-
)
249+
return np.concatenate((raster, nan_alpha[None, ...]), axis=0)
250+
251+
252+
def convert_to_uint8(bands: ndarray, dtype: str | None) -> ndarray[uint8]:
253+
"""Convert Banded data with NaNs (missing) into a uint8 data cube.
254+
255+
Nearly all of the time this will simply pass through the data coercing it
256+
back into unsigned ints and setting the missing values to 0 that will be
257+
masked as transparent in the output png.
247258
248-
if image_alpha is not None:
249-
# merge missing alpha with the image alpha band prefering transparency
250-
# to opaqueness.
251-
alpha = np.minimum(nan_alpha, image_alpha).astype(np.uint8)
259+
There is a some small non-zero chance that the input RGB image was 16-bit
260+
and if any of the values exceed 255, we must normalize all of input data to
261+
the range 0-255.
262+
263+
"""
264+
265+
if dtype != 'uint8' and np.nanmax(bands) > 255:
266+
norm = Normalize(vmin=np.nanmin(bands), vmax=np.nanmax(bands))
267+
scaled = np.around(norm(bands) * 255.0)
268+
raster = scaled.filled(0).astype('uint8')
252269
else:
253-
alpha = nan_alpha
270+
raster = np.nan_to_num(bands).astype('uint8')
271+
272+
return raster
254273

255-
return np.concatenate((raster, alpha[None, ...]), axis=0)
274+
275+
def original_dtype(data_array: DataArray) -> str | None:
276+
"""Return the original input data's type.
277+
278+
rastero_open retains the input dtype in the encoding dictionary and is used
279+
to understand what kind of casts are safe.
280+
281+
"""
282+
return data_array.encoding.get('dtype') or data_array.encoding.get('rasterio_dtype')
256283

257284

258285
def convert_singleband_to_raster(
@@ -330,16 +357,38 @@ def image_driver(mime: str) -> str:
330357
return 'PNG'
331358

332359

333-
def prepare_raster_for_writing(
334-
raster: ndarray, driver: str
360+
def standardize_raster_for_writing(
361+
raster: ndarray,
362+
driver: str,
363+
band_count: int,
335364
) -> tuple[ndarray, dict | None]:
336-
"""Remove alpha layer if writing a jpeg."""
337-
if driver == 'JPEG':
338-
if raster.shape[0] == 4:
339-
raster = raster[0:3, :, :]
340-
return raster, None
365+
"""Standardize raster data for writing to browse image.
341366
342-
return palettize_raster(raster)
367+
Args:
368+
raster: Input raster data array
369+
driver: Output image format ('JPEG' or 'PNG')
370+
band_count: Number of bands in original input data
371+
372+
The function handles two special cases:
373+
- JPEG output with 4-band data -> Drop alpha channel and return 3-band RGB
374+
- PNG output with single-band data -> Convert to paletted format
375+
376+
Returns:
377+
tuple: (prepared_raster, color_map) where:
378+
- prepared_raster is the processed ndarray
379+
- color_map is either None or a dict mapping palette indices to RGBA values
380+
381+
382+
"""
383+
if driver == 'JPEG' and raster.shape[0] == 4:
384+
return raster[0:3, :, :], None
385+
386+
if driver == 'PNG' and band_count == 1:
387+
# Only palettize single band input data that has been converted to an
388+
# RGBA raster.
389+
return palettize_raster(raster)
390+
391+
return raster, None
343392

344393

345394
def palettize_raster(raster: ndarray) -> tuple[ndarray, dict]:
@@ -476,9 +525,13 @@ def write_georaster_as_browse(
476525
477526
"""
478527
n_bands = raster.shape[0]
479-
dst_nodata = NODATA_IDX
528+
480529
if color_map is not None:
530+
dst_nodata = NODATA_IDX
481531
color_map[dst_nodata] = NODATA_RGBA
532+
else:
533+
# for banded data set the each band's destination nodata to zero (TRANSPARENT).
534+
dst_nodata = TRANSPARENT
482535

483536
creation_options = {
484537
**grid_parameters,

hybig/sizes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def find_closest_resolution(
422422
423423
"""
424424
best_info = None
425-
smallest_diff = np.Infinity
425+
smallest_diff = np.inf
426426
for res in resolutions:
427427
for info in resolution_info:
428428
resolution_diff = np.abs(res - info.pixel_size)

tests/test_service/test_adapter.py

+19-10
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from harmony_service.exceptions import HyBIGServiceError
2020
from hybig.browse import (
2121
convert_mulitband_to_raster,
22-
prepare_raster_for_writing,
22+
standardize_raster_for_writing,
2323
)
2424
from tests.utilities import Granule, create_stac
2525

@@ -270,10 +270,14 @@ def move_tif(*args, **kwargs):
270270
mock_reproject.call_args_list, expected_reproject_calls
271271
):
272272
np.testing.assert_array_equal(
273-
actual_call.kwargs['source'], expected_call.kwargs['source']
273+
actual_call.kwargs['source'],
274+
expected_call.kwargs['source'],
275+
strict=True,
274276
)
275277
np.testing.assert_array_equal(
276-
actual_call.kwargs['destination'], expected_call.kwargs['destination']
278+
actual_call.kwargs['destination'],
279+
expected_call.kwargs['destination'],
280+
strict=True,
277281
)
278282
self.assertEqual(
279283
actual_call.kwargs['src_transform'],
@@ -452,11 +456,11 @@ def move_tif(*args, **kwargs):
452456
'transform': expected_transform,
453457
'driver': 'PNG',
454458
'dtype': 'uint8',
455-
'dst_nodata': 255,
459+
'dst_nodata': 0,
456460
'count': 3,
457461
}
458462
raster = convert_mulitband_to_raster(rio_data_array)
459-
raster, color_map = prepare_raster_for_writing(raster, 'PNG')
463+
raster, color_map = standardize_raster_for_writing(raster, 'PNG', 3)
460464

461465
dest = np.full(
462466
(expected_params['height'], expected_params['width']),
@@ -466,26 +470,31 @@ def move_tif(*args, **kwargs):
466470

467471
expected_reproject_calls = [
468472
call(
469-
source=raster[0, :, :],
473+
source=raster[band, :, :],
470474
destination=dest,
471475
src_transform=rio_data_array.rio.transform(),
472476
src_crs=rio_data_array.rio.crs,
473477
dst_transform=expected_params['transform'],
474478
dst_crs=expected_params['crs'],
475-
dst_nodata=255,
479+
dst_nodata=expected_params['dst_nodata'],
476480
resampling=Resampling.nearest,
477481
)
482+
for band in range(4)
478483
]
479484

480-
self.assertEqual(mock_reproject.call_count, 1)
485+
self.assertEqual(mock_reproject.call_count, 4)
481486
for actual_call, expected_call in zip(
482487
mock_reproject.call_args_list, expected_reproject_calls
483488
):
484489
np.testing.assert_array_equal(
485-
actual_call.kwargs['source'], expected_call.kwargs['source']
490+
actual_call.kwargs['source'],
491+
expected_call.kwargs['source'],
492+
strict=True,
486493
)
487494
np.testing.assert_array_equal(
488-
actual_call.kwargs['destination'], expected_call.kwargs['destination']
495+
actual_call.kwargs['destination'],
496+
expected_call.kwargs['destination'],
497+
strict=True,
489498
)
490499
self.assertEqual(
491500
actual_call.kwargs['src_transform'],

0 commit comments

Comments
 (0)