Skip to content

Commit ad094bf

Browse files
authored
Merge pull request #50 from nasa/GITC-7208
GITC-7208: Fixing Colormap Rasterization issues
2 parents a68a7b6 + 7ad99be commit ad094bf

File tree

9 files changed

+176
-365
lines changed

9 files changed

+176
-365
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ 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+
## [v2.4.0] - 2025-04-28
8+
9+
### Changed
10+
11+
* Fix rasterization issues with palettized granules. Source images now retain their palette in a scaled form, rather than reinterpreting the palette. [[#50](https://github.com/nasa/harmony-browse-image-generator/pull/50)]
12+
* Minor bugfixes and type formatting improvements.
713

814
## [v2.3.0] - 2025-02-26
915

@@ -112,6 +118,7 @@ For more information on internal releases prior to NASA open-source approval,
112118
see legacy-CHANGELOG.md.
113119

114120
[unreleased]: https://github.com/nasa/harmony-browse-image-generator/
121+
[v2.4.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.4.0
115122
[v2.3.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.3.0
116123
[v2.2.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.2.0
117124
[v2.1.0]: https://github.com/nasa/harmony-browse-image-generator/releases/tag/2.1.0

docker/service.Dockerfile

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,30 @@
1515
# and updates the entrypoint of the new service container
1616
#
1717
###############################################################################
18-
FROM python:3.12
18+
FROM python:3.12-slim
1919

2020
WORKDIR "/home"
2121

22-
RUN apt-get update
23-
RUN apt-get install -y libgdal-dev
22+
RUN apt-get update && apt-get install -y \
23+
build-essential \
24+
libgdal-dev \
25+
gdal-bin \
26+
&& apt-get clean \
27+
&& rm -rf /var/lib/apt/lists/*
28+
29+
RUN export GDAL_VERSION=$(gdal-config --version) && \
30+
echo "GDAL version: $GDAL_VERSION"
31+
32+
ENV CPLUS_INCLUDE_PATH=/usr/include/gdal
33+
ENV C_INCLUDE_PATH=/usr/include/gdal
34+
35+
RUN pip install GDAL==$(gdal-config --version)
2436

2537
# Install Pip dependencies
26-
COPY pip_requirements*.txt /home/
38+
COPY pip_requirements.txt /home/
2739

2840
RUN pip install --no-input --no-cache-dir \
29-
-r pip_requirements.txt \
30-
-r pip_requirements_skip_snyk.txt
41+
-r pip_requirements.txt
3142

3243
# Copy service code.
3344
COPY ./harmony_service harmony_service

docker/service_version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.3.0
1+
2.4.0

hybig/browse.py

Lines changed: 46 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@
1111
from affine import dumpsw
1212
from harmony_service_lib.message import Message as HarmonyMessage
1313
from harmony_service_lib.message import Source as HarmonySource
14-
from matplotlib.cm import ScalarMappable
1514
from matplotlib.colors import Normalize
1615
from numpy import ndarray, uint8
1716
from osgeo_utils.auxiliary.color_palette import ColorPalette
1817
from PIL import Image
1918
from rasterio.io import DatasetReader
20-
from rasterio.plot import reshape_as_image, reshape_as_raster
2119
from rasterio.warp import Resampling, reproject
2220
from rioxarray import open_rasterio
2321
from xarray import DataArray
@@ -28,10 +26,12 @@
2826
NODATA_RGBA,
2927
OPAQUE,
3028
TRANSPARENT,
29+
ColorMap,
3130
all_black_color_map,
31+
colormap_from_colors,
3232
get_color_palette,
33+
greyscale_colormap,
3334
palette_from_remote_colortable,
34-
remove_alpha,
3535
)
3636
from hybig.exceptions import HyBIGError
3737
from hybig.sizes import (
@@ -171,18 +171,19 @@ def create_browse_imagery(
171171
color_palette = get_color_palette(
172172
in_dataset, source, item_color_palette
173173
)
174-
raster = convert_singleband_to_raster(rio_in_array, color_palette)
174+
raster, color_map = convert_singleband_to_raster(
175+
rio_in_array, color_palette
176+
)
175177
elif rio_in_array.rio.count in (3, 4):
176178
raster = convert_mulitband_to_raster(rio_in_array)
179+
color_map = None
180+
if output_driver == 'JPEG':
181+
raster = raster[0:3, :, :]
177182
else:
178183
raise HyBIGError(
179184
f'incorrect number of bands for image: {rio_in_array.rio.count}'
180185
)
181186

182-
raster, color_map = standardize_raster_for_writing(
183-
raster, output_driver, rio_in_array.rio.count
184-
)
185-
186187
grid_parameters = get_target_grid_parameters(message, rio_in_array)
187188
grid_parameter_list, tile_locators = create_tiled_output_parameters(
188189
grid_parameters
@@ -283,69 +284,65 @@ def original_dtype(data_array: DataArray) -> str | None:
283284
def convert_singleband_to_raster(
284285
data_array: DataArray,
285286
color_palette: ColorPalette | None = None,
286-
) -> ndarray:
287-
"""Convert input dataset to a 4 band raster image.
287+
) -> tuple[ndarray, ColorMap]:
288+
"""Convert input dataset to a 1-band palettized image with colormap.
288289
289-
Use a palette if provided otherwise return a greyscale image.
290+
Uses a palette if provided otherwise returns a greyscale image.
290291
"""
291292
if color_palette is None:
292-
return convert_gray_1band_to_raster(data_array)
293-
return convert_paletted_1band_to_raster(data_array, color_palette)
293+
return scale_grey_1band(data_array)
294+
return scale_paletted_1band(data_array, color_palette)
294295

295296

296-
def convert_gray_1band_to_raster(data_array: DataArray) -> ndarray:
297-
"""Convert a 1-band raster without a color association."""
297+
def scale_grey_1band(data_array: DataArray) -> tuple[ndarray, ColorMap]:
298+
"""Normalize input array and return scaled data with greyscale ColorMap."""
298299
band = data_array[0, :, :]
299-
cmap = matplotlib.colormaps['Greys_r']
300-
cmap.set_bad(NODATA_RGBA)
301300
norm = Normalize(vmin=np.nanmin(band), vmax=np.nanmax(band))
302-
scalar_map = ScalarMappable(cmap=cmap, norm=norm)
303301

304-
rgba_image = np.zeros((*band.shape, 4), dtype='uint8')
305-
for row_no in range(band.shape[0]):
306-
rgba_image_slice = scalar_map.to_rgba(band[row_no, :], bytes=True)
307-
rgba_image[row_no, :, :] = rgba_image_slice
302+
# Scale input data from 0 to 254
303+
normalized_data = norm(band) * 254.0
308304

309-
return reshape_as_raster(rgba_image)
305+
# Set any missing to missing
306+
normalized_data[np.isnan(normalized_data)] = NODATA_IDX
310307

308+
grey_colormap = greyscale_colormap()
309+
raster_data = np.expand_dims(np.round(normalized_data).data, 0)
310+
return np.array(raster_data, dtype='uint8'), grey_colormap
311311

312-
def convert_paletted_1band_to_raster(
312+
313+
def scale_paletted_1band(
313314
data_array: DataArray, palette: ColorPalette
314-
) -> ndarray:
315-
"""Convert a 1 band image with palette into a rgba raster image."""
315+
) -> tuple[ndarray, ColorMap]:
316+
"""Scale a 1-band image with palette into modified image and associated color_map.
317+
318+
Use the palette's levels and values, transform the input data_array into
319+
the correct levels indexed from 0-255 return the scaled array along side of
320+
a colormap corresponding to the new levels.
321+
"""
316322
band = data_array[0, :, :]
317323
levels = list(palette.pal.keys())
318324
colors = [
319325
palette.color_to_color_entry(value, with_alpha=True)
320326
for value in palette.pal.values()
321327
]
322-
scaled_colors = [
323-
(r / 255.0, g / 255.0, b / 255.0, a / 255.0) for r, g, b, a in colors
324-
]
325-
326-
cmap, norm = matplotlib.colors.from_levels_and_colors(
327-
levels, scaled_colors, extend='max'
328-
)
328+
norm = matplotlib.colors.BoundaryNorm(levels, len(levels) - 1)
329329

330330
# handle palette no data value
331+
nodata_color = (0, 0, 0, 0)
331332
if palette.ndv is not None:
332-
nodata_colors = palette.color_to_color_entry(palette.ndv, with_alpha=True)
333-
cmap.set_bad(
334-
(
335-
nodata_colors[0] / 255.0,
336-
nodata_colors[1] / 255.0,
337-
nodata_colors[2] / 255.0,
338-
nodata_colors[3] / 255.0,
339-
)
340-
)
333+
nodata_color = palette.color_to_color_entry(palette.ndv, with_alpha=True)
341334

342-
scalar_map = matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap)
343-
rgba_image = np.zeros((*band.shape, 4), dtype='uint8')
344-
for row_no in range(band.shape[0]):
345-
rgba_image[row_no, :, :] = scalar_map.to_rgba(
346-
np.ma.masked_invalid(band[row_no, :]), bytes=True
347-
)
348-
return reshape_as_raster(rgba_image)
335+
colors = [*colors, nodata_color]
336+
337+
scaled_band = norm(band)
338+
339+
# Set underflow and nan values to nodata
340+
scaled_band[scaled_band == -1] = len(colors) - 1
341+
scaled_band[np.isnan(band)] = len(colors) - 1
342+
343+
color_map = colormap_from_colors(colors)
344+
raster_data = np.expand_dims(scaled_band.data, 0)
345+
return np.array(raster_data, dtype='uint8'), color_map
349346

350347

351348
def image_driver(mime: str) -> str:
@@ -355,81 +352,6 @@ def image_driver(mime: str) -> str:
355352
return 'PNG'
356353

357354

358-
def standardize_raster_for_writing(
359-
raster: ndarray,
360-
driver: str,
361-
band_count: int,
362-
) -> tuple[ndarray, dict | None]:
363-
"""Standardize raster data for writing to browse image.
364-
365-
Args:
366-
raster: Input raster data array
367-
driver: Output image format ('JPEG' or 'PNG')
368-
band_count: Number of bands in original input data
369-
370-
The function handles two special cases:
371-
- JPEG output with 4-band data -> Drop alpha channel and return 3-band RGB
372-
- PNG output with single-band data -> Convert to paletted format
373-
374-
Returns:
375-
tuple: (prepared_raster, color_map) where:
376-
- prepared_raster is the processed ndarray
377-
- color_map is either None or a dict mapping palette indices to RGBA values
378-
379-
380-
"""
381-
if driver == 'JPEG' and raster.shape[0] == 4:
382-
return raster[0:3, :, :], None
383-
384-
if driver == 'PNG' and band_count == 1:
385-
# Only palettize single band input data that has been converted to an
386-
# RGBA raster.
387-
return palettize_raster(raster)
388-
389-
return raster, None
390-
391-
392-
def palettize_raster(raster: ndarray) -> tuple[ndarray, dict]:
393-
"""Convert an RGB or RGBA image into a 1band image and palette.
394-
395-
Converts a 3 or 4 band np raster into a PIL image.
396-
Quantizes the image into a 1band raster with palette
397-
398-
Transparency is handled by first removing the Alpha layer and creating
399-
quantized raster from just the RGB layers. Next the Alpha layer values are
400-
treated as either transparent or opaque and any transparent values are
401-
written to the final raster as 254 and add the mapped RGBA value to the
402-
color palette.
403-
"""
404-
# reserves index 255 for transparent and off grid fill values
405-
# 0 to 254
406-
max_colors = 255
407-
rgb_raster, alpha = remove_alpha(raster)
408-
409-
multiband_image = Image.fromarray(reshape_as_image(rgb_raster))
410-
quantized_image = multiband_image.quantize(colors=max_colors)
411-
412-
color_map = get_color_map_from_image(quantized_image)
413-
414-
quantized_array, color_map = add_alpha(alpha, np.array(quantized_image), color_map)
415-
416-
one_band_raster = np.expand_dims(quantized_array, 0)
417-
return one_band_raster, color_map
418-
419-
420-
def add_alpha(
421-
alpha: ndarray | None, quantized_array: ndarray, color_map: dict
422-
) -> tuple[ndarray, dict]:
423-
"""If the input data had alpha values, manually set the quantized_image
424-
index to the transparent index in those places.
425-
"""
426-
if alpha is not None and np.any(alpha != OPAQUE):
427-
# Set any alpha to the transparent index value
428-
quantized_array = np.where(alpha != OPAQUE, NODATA_IDX, quantized_array)
429-
color_map[NODATA_IDX] = NODATA_RGBA
430-
return quantized_array, color_map
431-
432-
433355
def get_color_map_from_image(image: Image) -> dict:
434356
"""Get a writable color map
435357

hybig/color_utility.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import numpy as np
99
import requests
1010
from harmony_service_lib.message import Source as HarmonySource
11+
from numpy import uint8
1112
from osgeo_utils.auxiliary.color_palette import ColorPalette
1213
from pystac import Item
1314
from rasterio.io import DatasetReader
@@ -17,16 +18,18 @@
1718
HyBIGNoColorInformation,
1819
)
1920

21+
ColorMap = dict[uint8, tuple[uint8, uint8, uint8, uint8]]
22+
2023
# Constants for output PNG images
2124
# Applied to transparent pixels where alpha < 255
22-
TRANSPARENT = np.uint8(0)
23-
OPAQUE = np.uint8(255)
25+
TRANSPARENT = uint8(0)
26+
OPAQUE = uint8(255)
2427
# Applied to off grid areas during reprojection
2528
NODATA_RGBA = (0, 0, 0, 0)
2629
NODATA_IDX = 255
2730

2831

29-
def remove_alpha(raster: np.ndarray) -> tuple[np.ndarray, np.ndarray, None]:
32+
def remove_alpha(raster: np.ndarray) -> tuple[np.ndarray, np.ndarray | None]:
3033
"""Remove alpha layer when it exists."""
3134
if raster.shape[0] == 4:
3235
return raster[0:3, :, :], raster[3, :, :]
@@ -87,7 +90,16 @@ def get_color_palette(
8790
return get_remote_palette_from_source(source)
8891
except HyBIGNoColorInformation:
8992
try:
90-
return convert_colormap_to_palette(dataset.colormap(1))
93+
ds_cmap = dataset.colormap(1)
94+
# very defensive since this function is not documented in rasterio
95+
ndv_tuple: tuple[float, ...] = dataset.get_nodatavals()
96+
if ndv_tuple is not None and len(ndv_tuple) > 0:
97+
# this service only supports one ndv, so just use the first one
98+
# (usually the only one)
99+
ds_cmap['nv'] = ds_cmap[ndv_tuple[0]]
100+
# then remove the value associated with the ndv key
101+
ds_cmap.pop(ndv_tuple[0])
102+
return convert_colormap_to_palette(ds_cmap)
91103
except ValueError:
92104
return None
93105

@@ -120,11 +132,28 @@ def get_remote_palette_from_source(source: HarmonySource) -> dict:
120132
raise HyBIGNoColorInformation('No color in source') from exc
121133

122134

123-
def all_black_color_map():
135+
def all_black_color_map() -> ColorMap:
124136
"""Return a full length rgba color map with all black values."""
125137
return {idx: (0, 0, 0, 255) for idx in range(256)}
126138

127139

140+
def colormap_from_colors(
141+
colors: list[tuple[uint8, uint8, uint8, uint8]],
142+
) -> ColorMap:
143+
color_map = {}
144+
for idx, rgba in enumerate(colors):
145+
color_map[idx] = rgba
146+
return color_map
147+
148+
149+
def greyscale_colormap() -> ColorMap:
150+
color_map = {}
151+
for idx in range(255):
152+
color_map[idx] = (idx, idx, idx, 255)
153+
color_map[NODATA_IDX] = NODATA_RGBA
154+
return color_map
155+
156+
128157
def convert_colormap_to_palette(colormap: dict) -> ColorPalette:
129158
"""Convert a GeoTIFF palette to GDAL ColorPalette.
130159

pip_requirements_skip_snyk.txt

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)