Skip to content

Commit 82e966b

Browse files
authored
Merge pull request #56 from nasa/GITC-8325
GITC-8325: Properly handle data values outside of colormap range
2 parents 281201e + f944395 commit 82e966b

File tree

4 files changed

+238
-22
lines changed

4 files changed

+238
-22
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ Changelog](http://keepachangelog.com/en/1.0.0/).
1111
* GitHub release notes for HyBIG will now include the commit history for that
1212
release.
1313

14-
## [v2.4.2] - Unreleased
14+
## [v2.5.0] - Unreleased
15+
16+
### Changed
17+
18+
* Correctly handle clipping behavior for values outside the colormap range
19+
20+
## [v2.4.2] - 2025-10-28
1521

1622
### Changed
1723

docker/service_version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.4.2
1+
2.5.0

hybig/browse.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def scale_grey_1band(data_array: DataArray) -> tuple[ndarray, ColorMap]:
312312
normalized_data = norm(band) * 254.0
313313

314314
# Set any missing to missing
315-
normalized_data[np.isnan(normalized_data)] = NODATA_IDX
315+
normalized_data[np.isnan(band)] = NODATA_IDX
316316

317317
grey_colormap = greyscale_colormap()
318318
raster_data = np.expand_dims(np.round(normalized_data).data, 0)
@@ -342,7 +342,7 @@ def scale_grey_1band_to_rgb(data_array: DataArray) -> tuple[ndarray, None]:
342342
normalized_data = norm(band) * 254.0
343343

344344
# Set any missing to 0 (black), no transparency
345-
normalized_data[np.isnan(normalized_data)] = 0
345+
normalized_data[np.isnan(band)] = 0
346346

347347
grey_data = np.round(normalized_data).astype('uint8')
348348
rgb_data = np.stack([grey_data, grey_data, grey_data], axis=0)
@@ -367,8 +367,17 @@ def scale_paletted_1band_to_rgb(
367367
if palette.ndv is not None:
368368
nodata_color = palette.color_to_color_entry(palette.ndv, with_alpha=True)
369369

370+
# Store NaN mask before normalization
371+
nan_mask = np.isnan(band)
372+
373+
# Replace NaN with first level to avoid issues during normalization
374+
band_clean = np.where(nan_mask, levels[0], band)
375+
370376
# Apply normalization to get palette indices
371-
indexed_band = norm(band)
377+
indexed_band = norm(band_clean)
378+
379+
# Clip indices to valid range [0, len(colors)-1]
380+
indexed_band = np.clip(indexed_band, 0, len(colors) - 1)
372381

373382
# Create RGB output array
374383
height, width = band.shape
@@ -381,8 +390,7 @@ def scale_paletted_1band_to_rgb(
381390
rgb_array[1, mask] = color[1] # Green
382391
rgb_array[2, mask] = color[2] # Blue
383392

384-
# Handle NaN/nodata values
385-
nan_mask = np.isnan(band)
393+
# Handle NaN/nodata values (overwrite any color assignment)
386394
if nan_mask.any():
387395
rgb_array[0, nan_mask] = nodata_color[0]
388396
rgb_array[1, nan_mask] = nodata_color[1]
@@ -399,6 +407,10 @@ def scale_paletted_1band(
399407
Use the palette's levels and values, transform the input data_array into
400408
the correct levels indexed from 0-255 return the scaled array along side of
401409
a colormap corresponding to the new levels.
410+
411+
Values below the minimum palette level are clipped to the lowest color.
412+
Values above the maximum palette level are clipped to the highest color.
413+
Only NaN values are mapped to the nodata index.
402414
"""
403415
global DST_NODATA
404416
band = data_array[0, :, :]
@@ -413,31 +425,38 @@ def scale_paletted_1band(
413425
nodata_color = (0, 0, 0, 0)
414426
if palette.ndv is not None:
415427
nodata_color = palette.color_to_color_entry(palette.ndv, with_alpha=True)
416-
color_list = list(palette.pal.values())
417-
try:
418-
DST_NODATA = color_list.index(palette.ndv)
419-
# ndv is included in the list of colors, so no need to add nodata_color
420-
# to the list
421-
except ValueError:
422-
# ndv is not an index in the color palette, therefore it should be
423-
# index 0, which is the default for a ColorPalette when using
424-
# palette.get_all_keys()
428+
# Check if nodata color already exists in palette
429+
if palette.ndv in palette.pal.values():
430+
DST_NODATA = list(palette.pal.values()).index(palette.ndv)
431+
# Don't add nodata_color; it's already in colors
432+
else:
433+
# Nodata not in palette, add it at the beginning
425434
DST_NODATA = 0
426435
colors = [nodata_color, *colors]
427436
else:
428437
# if there is no ndv, add one to the end of the colormap
438+
DST_NODATA = len(colors)
429439
colors = [*colors, nodata_color]
430-
DST_NODATA = len(colors) - 1
431440

432-
scaled_band = norm(band)
441+
nan_mask = np.isnan(band)
442+
band_clean = np.where(nan_mask, levels[0], band)
443+
scaled_band = norm(band_clean)
444+
433445
if DST_NODATA == 0:
434446
# boundary norm indexes [0, levels) by default, so if the NODATA index is 0,
435447
# all the palette indices need to be incremented by 1.
436-
scaled_band += 1
448+
scaled_band = scaled_band + 1
449+
450+
# Clip to valid palette range (excluding nodata index)
451+
if DST_NODATA == 0:
452+
# Palette occupies indices 1 to len(colors)-1
453+
scaled_band = np.clip(scaled_band, 1, len(colors) - 1)
454+
else:
455+
# Palette occupies indices 0 to DST_NODATA-1
456+
scaled_band = np.clip(scaled_band, 0, DST_NODATA - 1)
437457

438-
# Set underflow and nan values to nodata index
439-
scaled_band[scaled_band == -1] = DST_NODATA
440-
scaled_band[np.isnan(band)] = DST_NODATA
458+
# Only set NaN values to nodata index
459+
scaled_band[nan_mask] = DST_NODATA
441460

442461
color_map = colormap_from_colors(colors)
443462
raster_data = np.expand_dims(scaled_band.data, 0)

tests/unit/test_browse.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,197 @@ def test_palette_from_remote_colortable(self, mock_get):
776776
),
777777
)
778778

779+
def test_scale_paletted_1band_clips_underflow_values(self):
780+
"""Test that values below the palette min are clipped to lowest color."""
781+
from hybig.browse import scale_paletted_1band
782+
783+
# Create test data with values below the palette minimum
784+
# Palette covers 100-400, but data includes -50 and 50
785+
data_with_underflow = np.array(
786+
[
787+
[-50, 50, 100, 200],
788+
[100, 200, 300, 400],
789+
[50, 100, 200, 300],
790+
[-100, 0, 150, 250],
791+
]
792+
).astype('float64')
793+
ds = DataArray(data_with_underflow).expand_dims('band')
794+
795+
# Expected: underflow values (-50, 50, 0, -100) should map to index 0
796+
# which is the lowest color (red at value 100)
797+
expected_raster = np.array(
798+
[
799+
[
800+
[0, 0, 0, 1], # -50, 50 -> 0 (red), 100->0, 200->1
801+
[0, 1, 2, 3], # 100->0, 200->1, 300->2, 400->3
802+
[0, 0, 1, 2], # 50, 100 -> 0, 200->1, 300->2
803+
[0, 0, 0, 1], # -100, 0 -> 0, 150->0, 250->1
804+
],
805+
],
806+
dtype='uint8',
807+
)
808+
809+
image_palette = convert_colormap_to_palette(self.colormap)
810+
actual_raster, _ = scale_paletted_1band(ds, image_palette)
811+
assert_array_equal(expected_raster, actual_raster, strict=True)
812+
813+
def test_scale_paletted_1band_clips_overflow_values(self):
814+
"""Test that values above the palette max are clipped to highest color."""
815+
from hybig.browse import scale_paletted_1band
816+
817+
# Create test data with values above the palette maximum
818+
# Palette covers 100-400, but data includes 500 and 1000
819+
data_with_overflow = np.array(
820+
[
821+
[100, 200, 300, 400],
822+
[400, 500, 600, 1000],
823+
[200, 300, 400, 500],
824+
[300, 350, 400, 800],
825+
]
826+
).astype('float64')
827+
ds = DataArray(data_with_overflow).expand_dims('band')
828+
829+
# Expected: overflow values (500, 600, 1000, 800) should map to index 3
830+
# which is the highest color (blue at value 400)
831+
expected_raster = np.array(
832+
[
833+
[
834+
[0, 1, 2, 3], # 100->0, 200->1, 300->2, 400->3
835+
[3, 3, 3, 3], # 400->3, 500->3, 600->3, 1000->3
836+
[1, 2, 3, 3], # 200->1, 300->2, 400->3, 500->3
837+
[2, 2, 3, 3], # 300->2, 350->2, 400->3, 800->3
838+
],
839+
],
840+
dtype='uint8',
841+
)
842+
843+
image_palette = convert_colormap_to_palette(self.colormap)
844+
actual_raster, _ = scale_paletted_1band(ds, image_palette)
845+
assert_array_equal(expected_raster, actual_raster, strict=True)
846+
847+
def test_scale_paletted_1band_with_nan_and_clipping(self):
848+
"""Test that NaN values map to nodata while clipping still works."""
849+
from hybig.browse import scale_paletted_1band
850+
851+
# Create test data with NaN, underflow, and overflow values
852+
data_mixed = np.array(
853+
[
854+
[np.nan, -50, 100, 500],
855+
[50, 200, 300, 1000],
856+
[100, np.nan, 400, 600],
857+
[-100, 250, np.nan, 800],
858+
]
859+
).astype('float64')
860+
ds = DataArray(data_mixed).expand_dims('band')
861+
862+
# Expected: NaN -> 4 (nodata), underflow -> 0, overflow -> 3
863+
expected_raster = np.array(
864+
[
865+
[
866+
[4, 0, 0, 3], # NaN->4, -50->0, 100->0, 500->3
867+
[0, 1, 2, 3], # 50->0, 200->1, 300->2, 1000->3
868+
[0, 4, 3, 3], # 100->0, NaN->4, 400->3, 600->3
869+
[0, 1, 4, 3], # -100->0, 250->1, NaN->4, 800->3
870+
],
871+
],
872+
dtype='uint8',
873+
)
874+
875+
image_palette = convert_colormap_to_palette(self.colormap)
876+
actual_raster, actual_palette = scale_paletted_1band(ds, image_palette)
877+
assert_array_equal(expected_raster, actual_raster, strict=True)
878+
879+
# Verify nodata color is transparent
880+
expected_nodata_color = (0, 0, 0, 0)
881+
self.assertEqual(actual_palette[np.uint8(4)], expected_nodata_color)
882+
883+
def test_scale_paletted_1band_to_rgb_clips_underflow_values(self):
884+
"""Test RGB output clips values below palette min to lowest color."""
885+
from hybig.browse import scale_paletted_1band_to_rgb
886+
887+
# Create test data with values below the palette minimum
888+
data_with_underflow = np.array(
889+
[
890+
[-50, 50, 100, 200],
891+
[100, 200, 300, 400],
892+
]
893+
).astype('float64')
894+
ds = DataArray(data_with_underflow).expand_dims('band')
895+
896+
image_palette = convert_colormap_to_palette(self.colormap)
897+
actual_rgb, _ = scale_paletted_1band_to_rgb(ds, image_palette)
898+
899+
# Values -50 and 50 should get red color (255, 0, 0)
900+
# which is the lowest color in the palette
901+
self.assertEqual(actual_rgb[0, 0, 0], 255) # Red channel for -50
902+
self.assertEqual(actual_rgb[1, 0, 0], 0) # Green channel for -50
903+
self.assertEqual(actual_rgb[2, 0, 0], 0) # Blue channel for -50
904+
905+
self.assertEqual(actual_rgb[0, 0, 1], 255) # Red channel for 50
906+
self.assertEqual(actual_rgb[1, 0, 1], 0) # Green channel for 50
907+
self.assertEqual(actual_rgb[2, 0, 1], 0) # Blue channel for 50
908+
909+
def test_scale_paletted_1band_to_rgb_clips_overflow_values(self):
910+
"""Test RGB output clips values above palette max to highest color."""
911+
from hybig.browse import scale_paletted_1band_to_rgb
912+
913+
# Create test data with values above the palette maximum
914+
data_with_overflow = np.array(
915+
[
916+
[400, 500, 600, 1000],
917+
[300, 400, 800, 1500],
918+
]
919+
).astype('float64')
920+
ds = DataArray(data_with_overflow).expand_dims('band')
921+
922+
image_palette = convert_colormap_to_palette(self.colormap)
923+
actual_rgb, _ = scale_paletted_1band_to_rgb(ds, image_palette)
924+
925+
# Values 500, 600, 1000, 800, 1500 should get blue color (0, 0, 255)
926+
# which is the highest color in the palette
927+
for col in [1, 2, 3]: # columns 1, 2, 3 in row 0
928+
self.assertEqual(actual_rgb[0, 0, col], 0) # Red channel
929+
self.assertEqual(actual_rgb[1, 0, col], 0) # Green channel
930+
self.assertEqual(actual_rgb[2, 0, col], 255) # Blue channel
931+
932+
for col in [2, 3]: # columns 2, 3 in row 1
933+
self.assertEqual(actual_rgb[0, 1, col], 0) # Red channel
934+
self.assertEqual(actual_rgb[1, 1, col], 0) # Green channel
935+
self.assertEqual(actual_rgb[2, 1, col], 255) # Blue channel
936+
937+
def test_scale_paletted_1band_to_rgb_with_nan_and_clipping(self):
938+
"""Test RGB output with NaN mapped to nodata and clipping working."""
939+
from hybig.browse import scale_paletted_1band_to_rgb
940+
941+
# Create test data with NaN, underflow, and overflow values
942+
data_mixed = np.array(
943+
[
944+
[np.nan, -50, 100, 500],
945+
[50, 200, np.nan, 1000],
946+
]
947+
).astype('float64')
948+
ds = DataArray(data_mixed).expand_dims('band')
949+
950+
image_palette = convert_colormap_to_palette(self.colormap)
951+
actual_rgb, _ = scale_paletted_1band_to_rgb(ds, image_palette)
952+
953+
# NaN should map to nodata color (0, 0, 0)
954+
self.assertEqual(actual_rgb[0, 0, 0], 0) # Red for NaN at (0,0)
955+
self.assertEqual(actual_rgb[1, 0, 0], 0) # Green for NaN at (0,0)
956+
self.assertEqual(actual_rgb[2, 0, 0], 0) # Blue for NaN at (0,0)
957+
958+
self.assertEqual(actual_rgb[0, 1, 2], 0) # Red for NaN at (1,2)
959+
self.assertEqual(actual_rgb[1, 1, 2], 0) # Green for NaN at (1,2)
960+
self.assertEqual(actual_rgb[2, 1, 2], 0) # Blue for NaN at (1,2)
961+
962+
# -50 and 50 should clip to red (255, 0, 0)
963+
self.assertEqual(actual_rgb[0, 0, 1], 255) # Red for -50
964+
self.assertEqual(actual_rgb[0, 1, 0], 255) # Red for 50
965+
966+
# 500 and 1000 should clip to blue (0, 0, 255)
967+
self.assertEqual(actual_rgb[2, 0, 3], 255) # Blue for 500
968+
self.assertEqual(actual_rgb[2, 1, 3], 255) # Blue for 1000
969+
779970

780971
class TestCreateBrowse(TestCase):
781972
"""A class testing the create_browse function call.

0 commit comments

Comments
 (0)