diff --git a/CHANGES.md b/CHANGES.md index a318bc4b..1211b75b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -67,6 +67,14 @@ >> ("b1", "array") ``` +# 7.2.2 (2024-11-18) + +* Catch and expand error message when GDAL cannot encode data using specified image driver (https://github.com/cogeotiff/rio-tiler/pull/767) + +# 7.2.1 (2024-11-14) + +* add official support for floating point values in ColorMap +* cast data to `uint8` datatype when applying linear colormap # 7.2.0 (2024-11-05) diff --git a/pyproject.toml b/pyproject.toml index 78190e24..9156f9d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,7 +158,7 @@ ignore = [ max-complexity = 14 [tool.bumpversion] -current_version = "7.2.0" +current_version = "7.2.2" search = "{current_version}" replace = "{new_version}" regex = false diff --git a/rio_tiler/__init__.py b/rio_tiler/__init__.py index fd9b20da..15c75a73 100644 --- a/rio_tiler/__init__.py +++ b/rio_tiler/__init__.py @@ -1,6 +1,6 @@ """rio-tiler.""" -__version__ = "7.2.0" +__version__ = "7.2.2" from . import ( # noqa colormap, diff --git a/rio_tiler/colormap.py b/rio_tiler/colormap.py index 1f754e36..ab493496 100644 --- a/rio_tiler/colormap.py +++ b/rio_tiler/colormap.py @@ -4,6 +4,7 @@ import os import pathlib import re +import warnings from typing import Dict, List, Sequence, Tuple, Union import attr @@ -18,6 +19,7 @@ from rio_tiler.types import ( ColorMapType, DataMaskType, + DiscreteColorMapType, GDALColorMapType, IntervalColorMapType, ) @@ -116,28 +118,38 @@ def apply_cmap(data: numpy.ndarray, colormap: ColorMapType) -> DataMaskType: # rio_tiler.colormap.make_lut, because we don't want to create a `lookup table` # with more than 256 entries (256 x 4) array. In this case we use `apply_discrete_cmap` # which can work with arbitrary colormap dict. - if len(colormap) != 256 or max(colormap) >= 256 or min(colormap) < 0: + if ( + len(colormap) != 256 + or max(colormap) >= 256 + or min(colormap) < 0 + or any(isinstance(k, float) for k in colormap) + ): return apply_discrete_cmap(data, colormap) - lookup_table = make_lut(colormap) + # For now we assume ColorMap are in uint8 + if data.dtype != numpy.uint8: + warnings.warn( + f"Input array is of type {data.dtype} and `will be converted to Int in order to apply the ColorMap.", + UserWarning, + ) + data = data.astype(numpy.uint8) + + lookup_table = make_lut(colormap) # type: ignore data = lookup_table[data[0], :] data = numpy.transpose(data, [2, 0, 1]) - # If the colormap has values between 0-255 - # we cast the output array to Uint8. - if data.min() >= 0 and data.max() <= 255: - data = data.astype("uint8") - return data[:-1], data[-1] -def apply_discrete_cmap(data: numpy.ndarray, colormap: GDALColorMapType) -> DataMaskType: +def apply_discrete_cmap( + data: numpy.ndarray, colormap: Union[GDALColorMapType, DiscreteColorMapType] +) -> DataMaskType: """Apply discrete colormap. Args: data (numpy.ndarray): 1D image array to translate to RGB. - colormap (GDALColorMapType): Discrete ColorMap dictionary. + colormap (GDALColorMapType or DiscreteColorMapType): Discrete ColorMap dictionary. Returns: tuple: Data (numpy.ndarray) and Alpha band (numpy.ndarray). diff --git a/rio_tiler/types.py b/rio_tiler/types.py index b7fa47f6..dc580752 100644 --- a/rio_tiler/types.py +++ b/rio_tiler/types.py @@ -18,11 +18,15 @@ # ColorMap Dict: {1: (0, 0, 0, 255), ...} GDALColorMapType = Dict[int, ColorTuple] +# Discrete Colormap, like GDALColorMapType but accept Float: {0.1: (0, 0, 0, 255), ...} +DiscreteColorMapType = Dict[NumType, ColorTuple] + # Intervals ColorMap: [((0, 1), (0, 0, 0, 0)), ...] IntervalColorMapType = Sequence[Tuple[IntervalTuple, ColorTuple]] ColorMapType = Union[ GDALColorMapType, + DiscreteColorMapType, IntervalColorMapType, ] diff --git a/rio_tiler/utils.py b/rio_tiler/utils.py index 2c6b0abf..99843535 100644 --- a/rio_tiler/utils.py +++ b/rio_tiler/utils.py @@ -33,7 +33,7 @@ from rio_tiler.colormap import apply_cmap from rio_tiler.constants import WEB_MERCATOR_CRS, WGS84_CRS -from rio_tiler.errors import RioTilerError +from rio_tiler.errors import InvalidFormat, RioTilerError from rio_tiler.types import BBox, ColorMapType, IntervalTuple, RIOResampling @@ -588,24 +588,30 @@ def render( } output_profile.update(creation_options) - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - category=NotGeoreferencedWarning, - module="rasterio", - ) - with MemoryFile() as memfile: - with memfile.open(**output_profile) as dst: - dst.write(data, indexes=list(range(1, count + 1))) + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=NotGeoreferencedWarning, + module="rasterio", + ) + with MemoryFile() as memfile: + with memfile.open(**output_profile) as dst: + dst.write(data, indexes=list(range(1, count + 1))) + + # Use Mask as an alpha band + if mask is not None: + if ColorInterp.alpha not in dst.colorinterp: + dst.colorinterp = *dst.colorinterp[:-1], ColorInterp.alpha - # Use Mask as an alpha band - if mask is not None: - if ColorInterp.alpha not in dst.colorinterp: - dst.colorinterp = *dst.colorinterp[:-1], ColorInterp.alpha + dst.write(mask.astype(data.dtype), indexes=count + 1) - dst.write(mask.astype(data.dtype), indexes=count + 1) + return memfile.read() - return memfile.read() + except Exception as e: + raise InvalidFormat( + f"Could not encode array of shape ({count},{height},{width}) and of datatype `{data.dtype}` using {img_format} driver" + ) from e def mapzen_elevation_rgb(data: numpy.ndarray) -> numpy.ndarray: diff --git a/tests/fixtures/cog_int16.tif b/tests/fixtures/cog_int16.tif new file mode 100644 index 00000000..f62d03bf Binary files /dev/null and b/tests/fixtures/cog_int16.tif differ diff --git a/tests/test_cmap.py b/tests/test_cmap.py index 7d1c42c6..6a5d2397 100644 --- a/tests/test_cmap.py +++ b/tests/test_cmap.py @@ -301,6 +301,8 @@ def test_parse_color_bad(): def test_discrete_float(): """test for titiler issue 738.""" + + # make sure we apply discrete colormap when we have less than 256 cmap entries cm = { 0: (0, 255, 255, 255), 1: (83, 151, 145, 255), @@ -325,3 +327,20 @@ def test_discrete_float(): dd, mm = colormap.apply_discrete_cmap(data.copy(), cm) assert d.dtype == numpy.uint8 assert m.dtype == numpy.uint8 + numpy.testing.assert_array_equal(d, dd) + numpy.testing.assert_array_equal(m, mm) + + # make we allow float keys in discrete colormap + cm = { + 0.5: (0, 255, 255, 255), + 1.5: (83, 151, 145, 255), + 2.5: (87, 194, 23, 255), + } + + data = numpy.random.choice([0.5, 2.5], 256 * 256).reshape(1, 256, 256) + d, m = colormap.apply_cmap(data.copy(), cm) + dd, mm = colormap.apply_discrete_cmap(data.copy(), cm) + assert d.dtype == numpy.uint8 + assert m.dtype == numpy.uint8 + numpy.testing.assert_array_equal(d, dd) + numpy.testing.assert_array_equal(m, mm) diff --git a/tests/test_io_rasterio.py b/tests/test_io_rasterio.py index 7a8427f6..23b18e8e 100644 --- a/tests/test_io_rasterio.py +++ b/tests/test_io_rasterio.py @@ -17,6 +17,7 @@ from rasterio.vrt import WarpedVRT from rasterio.warp import transform_bounds +from rio_tiler.colormap import cmap from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS from rio_tiler.errors import ( ExpressionMixingWarning, @@ -1120,3 +1121,27 @@ def test_inverted_latitude(): with pytest.warns(UserWarning): with Reader(COG_INVERTED) as src: _ = src.tile(0, 0, 0) + + +def test_int16_colormap(): + """Should raise a warning about invalid data type for applying colormap. + + ref: https://github.com/developmentseed/titiler/issues/1023 + """ + data = os.path.join(PREFIX, "cog_int16.tif") + color_map = cmap.get("viridis") + + with Reader(data) as src: + info = src.info() + assert info.dtype == "int16" + assert info.nodata_type == "Nodata" + assert info.nodata_value == -32768 + + img = src.preview() + assert img.mask.any() + + with pytest.warns(UserWarning): + im = img.apply_colormap(color_map) + + # make sure we keep the nodata part masked + assert not im.mask.all() diff --git a/tests/test_models.py b/tests/test_models.py index 6268aed4..988b300a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,7 +10,11 @@ from rasterio.errors import NotGeoreferencedWarning from rasterio.io import MemoryFile -from rio_tiler.errors import InvalidDatatypeWarning, InvalidPointDataError +from rio_tiler.errors import ( + InvalidDatatypeWarning, + InvalidFormat, + InvalidPointDataError, +) from rio_tiler.models import ImageData, PointData @@ -456,3 +460,9 @@ def test_imagedata_coverage(): coverage = im.get_coverage_array(poly, cover_scale=1000) assert numpy.round(numpy.unique(coverage), decimals=3).tolist() == [0, 0.125, 0.25] + + +def test_image_encoding_error(): + """Test ImageData error when using bad data array shape.""" + with pytest.raises(InvalidFormat): + ImageData(numpy.zeros((5, 256, 256), dtype="uint8")).render(img_format="PNG")