[Git][debian-gis-team/trollimage][upstream] New upstream version 1.18.0

Antonio Valentino (@antonio.valentino) gitlab at salsa.debian.org
Fri Feb 25 07:55:25 GMT 2022



Antonio Valentino pushed to branch upstream at Debian GIS Project / trollimage


Commits:
675756cc by Antonio Valentino at 2022-02-25T07:38:40+00:00
New upstream version 1.18.0
- - - - -


8 changed files:

- .github/workflows/ci.yaml
- − .travis.yml
- CHANGELOG.md
- trollimage/colormap.py
- trollimage/tests/test_colormap.py
- trollimage/tests/test_image.py
- trollimage/version.py
- trollimage/xrimage.py


Changes:

=====================================
.github/workflows/ci.yaml
=====================================
@@ -10,10 +10,10 @@ jobs:
       fail-fast: true
       matrix:
         os: ["windows-latest", "ubuntu-latest", "macos-latest"]
-        python-version: ["3.7", "3.8"]
+        python-version: ["3.8", "3.9", "3.10"]
         experimental: [false]
         include:
-          - python-version: "3.8"
+          - python-version: "3.9"
             os: "ubuntu-latest"
             experimental: true
 


=====================================
.travis.yml deleted
=====================================
@@ -1,63 +0,0 @@
-language: python
-env:
-    global:
-        # Set defaults to avoid repeating in most cases
-        - PYTHON_VERSION=$TRAVIS_PYTHON_VERSION
-        - NUMPY_VERSION=stable
-        - MAIN_CMD='python setup.py'
-        - CONDA_DEPENDENCIES='pillow gdal xarray dask coverage coveralls codecov rasterio pytest pytest-cov'
-        - SETUP_XVFB=False
-        - EVENT_TYPE='push pull_request'
-        - SETUP_CMD='test'
-        - CONDA_CHANNELS='conda-forge'
-        - CONDA_CHANNEL_PRIORITY=True
-        - UNSTABLE_DEPS=False
-
-matrix:
-  include:
-  - env: PYTHON_VERSION=3.7
-    os: linux
-  - env: PYTHON_VERSION=3.8
-    os: linux
-  - env:
-    - PYTHON_VERSION=3.8
-    - UNSTABLE_DEPS=True
-    os: linux
-  allow_failures:
-    - env:
-        - PYTHON_VERSION=3.8
-        - UNSTABLE_DEPS=True
-      os: linux
-
-install:
-  - git clone --depth 1 git://github.com/astropy/ci-helpers.git
-  - source ci-helpers/travis/setup_conda.sh
-  - if [ "$UNSTABLE_DEPS" == "True" ]; then
-    python -m pip install
-    -f https://7933911d6844c6c53a7d-47bd50c35cd79bd838daf386af554a83.ssl.cf2.rackcdn.com
-    --no-deps --pre --upgrade
-    numpy
-    pandas;
-    python -m pip install
-    --no-deps --upgrade
-    git+https://github.com/dask/dask
-    git+https://github.com/mapbox/rasterio
-    git+https://github.com/pydata/xarray;
-    fi
-  - pip install -e . --no-deps
-script:
-  - pytest --cov=trollimage trollimage/tests
-#after_success:
-#  - if [[ $PYTHON_VERSION == 3.8 ]]; then coveralls; fi
-#deploy:
-#  - provider: pypi
-#    user: dhoese
-#    password:
-#      secure: "Cpo7kPgjbyWKNRjnzWDggxo5dqG8KdtcrBxYWwBpkkhgly/ngN9EkjIdscrwmp8sCqfjTd0RGyR811k6dzU7kKJVbc6V309+4mG8O0w4IvfHCn+NaHymAMrHleRIqyxbo5kvrBZoX+eB7YWOUppF6ofeohbrNWWgMQv+/d+Mufs="
-#    distributions: sdist bdist_wheel
-#    skip_existing: true
-#    on:
-#      repo: pytroll/trollimage
-#      tags: true
-#notifications:
-#  slack: pytroll:96mNSYSI1dBjGyzVXkBT6qFt


=====================================
CHANGELOG.md
=====================================
@@ -1,3 +1,17 @@
+## Version 1.18.0 (2022/02/24)
+
+### Pull Requests Merged
+
+#### Features added
+
+* [PR 101](https://github.com/pytroll/trollimage/pull/101) - Add to_csv/from_csv to Colormap
+* [PR 100](https://github.com/pytroll/trollimage/pull/100) - Update Colormap.set_range to support flipped values
+* [PR 99](https://github.com/pytroll/trollimage/pull/99) - Add colorize and palettize to XRImage enhancement_history
+* [PR 98](https://github.com/pytroll/trollimage/pull/98) - Change tested Python versions to 3.8, 3.9 and 3.10
+
+In this release 4 pull requests were closed.
+
+
 ## Version 1.17.0 (2021/12/07)
 
 ### Issues Closed


=====================================
trollimage/colormap.py
=====================================
@@ -19,15 +19,28 @@
 
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
 """A simple colormap module."""
 
+import contextlib
+import os
+from io import StringIO
+from typing import Optional
 import warnings
+from numbers import Number
 
 import numpy as np
 from trollimage.colorspaces import rgb2hcl, hcl2rgb
 
 
+ at contextlib.contextmanager
+def _file_or_stringio(filename_or_none):
+    if filename_or_none is None:
+        yield StringIO()
+    else:
+        with open(filename_or_none, "w") as file_obj:
+            yield file_obj
+
+
 def colorize(arr, colors, values):
     """Colorize a monochromatic array *arr*, based *colors* given for *values*.
 
@@ -71,10 +84,16 @@ def _colorize(arr, colors, values):
 
 
 def _interpolate_rgb_colors(arr, colors, values):
+    interp_xp_coords = np.array(values)
     hcl_colors = _convert_rgb_list_to_hcl(colors)
+    interp_y_coords = np.array(hcl_colors)
+    if values[0] > values[-1]:
+        # monotonically decreasing
+        interp_xp_coords = interp_xp_coords[::-1]
+        interp_y_coords = interp_y_coords[::-1]
     channels = [np.interp(arr,
-                          np.array(values),
-                          np.array(hcl_colors)[:, i])
+                          interp_xp_coords,
+                          interp_y_coords[:, i])
                 for i in range(3)]
     channels = list(hcl2rgb(*channels))
     return channels
@@ -100,11 +119,7 @@ def _interpolate_alpha(arr, colors, values):
 
 def _mask_channels(channels, arr):
     """Mask the channels if arr is a masked array."""
-    try:
-        channels = [_mask_array(channel, arr) for channel in channels]
-    except AttributeError:
-        pass
-    return channels
+    return [_mask_array(channel, arr) for channel in channels]
 
 
 def _mask_array(new_array, arr):
@@ -145,10 +160,17 @@ def _palettize(arr, values):
 
 
 def _digitize_array(arr, values):
-    new_arr = np.digitize(arr.ravel(),
-                          np.concatenate((values,
-                                          [max(np.nanmax(arr),
-                                               values.max()) + 1])))
+    if values[0] <= values[-1]:
+        # monotonic increasing values
+        outside_range_bin = max(np.nanmax(arr), values.max()) + 1
+        right = False
+    else:
+        # monotonic decreasing values
+        outside_range_bin = min(np.nanmin(arr), values.min()) - 1
+        right = True
+    bins = np.concatenate((values, [outside_range_bin]))
+
+    new_arr = np.digitize(arr.ravel(), bins, right=right)
     new_arr -= 1
     new_arr = new_arr.clip(min=0, max=len(values) - 1)
     return new_arr
@@ -163,7 +185,7 @@ class Colormap(object):
             aren't provided.
         values: One dimensional array-like of control points where
             each corresponding color is applied. Must be the same number of
-            elements as colors and must be monotonically increasing.
+            elements as colors and must be monotonic.
         colors: Two dimensional array-like of RGB or RGBA colors where each
             color is applied to a specific control point. Must be the same
             number of colors as control points (values). Colors should be
@@ -256,15 +278,22 @@ class Colormap(object):
         """Append colormap together."""
         old, other = self._normalize_color_arrays(self, other)
         values = np.concatenate((old.values, other.values))
-        if not (np.diff(values) >= 0).all():
+        if not self._monotonic_one_direction(values):
             raise ValueError("Merged colormap 'values' are not monotonically "
-                             "increasing or equal.")
+                             "increasing, monotonically decreasing, or equal.")
         colors = np.concatenate((old.colors, other.colors))
         return Colormap(
             values=values,
             colors=colors,
         )
 
+    @staticmethod
+    def _monotonic_one_direction(values):
+        delta = np.diff(values)
+        all_increasing = (delta >= 0).all()
+        all_decreasing = (delta <= 0).all()
+        return all_increasing or all_decreasing
+
     @staticmethod
     def _normalize_color_arrays(cmap1, cmap2):
         num_bands1 = cmap1.colors.shape[-1]
@@ -293,9 +322,9 @@ class Colormap(object):
     def set_range(self, min_val, max_val, inplace=True):
         """Set the range of the colormap to [*min_val*, *max_val*].
 
-        If min is greater than max then the Colormap's colors are reversed
-        before values are updated to the new range. This is done because
-        Colormap ``values`` must always be monotonically increasing.
+        The Colormap's values will match the range specified even if "min_val"
+        is greater than "max_val". To flip the order of the colors, use
+        :meth:`reversed`.
 
         Args:
             min_val (float): New minimum value for the control points in
@@ -306,14 +335,9 @@ class Colormap(object):
                 If False, return a new Colormap instance.
 
         """
-        if min_val > max_val:
-            cmap = self.reverse(inplace=inplace)
-            max_val, min_val = min_val, max_val
-        else:
-            cmap = self
-
-        values = (((cmap.values * 1.0 - cmap.values.min()) /
-                   (cmap.values.max() - cmap.values.min()))
+        cmap = self
+        values = (((cmap.values * 1.0 - cmap.values[0]) /
+                   (cmap.values[-1] - cmap.values[0]))
                   * (max_val - min_val) + min_val)
         if not inplace:
             return Colormap(
@@ -330,6 +354,145 @@ class Colormap(object):
                    (self.colors.max() - self.colors.min())) * 255)
         return dict(zip(self.values, tuple(map(tuple, colors))))
 
+    def to_csv(
+            self,
+            filename: Optional[str] = None,
+            color_scale: Number = 255,
+    ) -> Optional[str]:
+        """Save Colormap to a comma-separated text file or string.
+
+        The CSV data will have 4 to 5 columns for each row where each
+        each row will contain the value (V), red (R), green (B), blue (B),
+        and if configured alpha (A).
+
+        The values will remain in whatever range is currently set on the
+        colormap. The colors of the colormap (assumed to be between 0 and 1)
+        will be multiplied by 255 to produce a traditional unsigned 8-bit
+        integer value.
+
+        Args:
+            filename: The filename of the CSV file to save to.
+                If not provided or None a string is returned with the contents.
+            color_scale: Scale colors by this factor before converting to a
+                CSV. Colors are stored in the Colormap in a 0 to 1 range..
+                Defaults to 255. If not equal to 1 values are converted to
+                integers too.
+
+        """
+        with _file_or_stringio(filename) as csv_file:
+            for value, color in zip(self.values, self.colors):
+                scaled_color = [x * color_scale for x in color]
+                if color_scale != 1.0:
+                    scaled_color = [int(x) for x in scaled_color]
+                csv_file.write(",".join(["{:0.6f}".format(value)] + [str(x) for x in scaled_color]) + "\n")
+        if isinstance(csv_file, StringIO):
+            return csv_file.getvalue()
+
+    @classmethod
+    def from_file(
+            cls,
+            filename_or_string: str,
+            colormap_mode: Optional[str] = None,
+            color_scale: Number = 255,
+    ):
+        """Create Colormap from a comma-separated or binary file of colormap data.
+
+        Args:
+            filename_or_string: Filename of a binary or CSV file or a
+                string version of the comma-separate data.
+            colormap_mode: Force the scheme of the colormap data (ex. RGBA).
+                See information below on other possible values and how they
+                are interpreted. By default this is determined based on the
+                number of columns in the data.
+            color_scale: The maximum possible color value in the colormap data
+                provided. For example, if the colors in the provided data were
+                8-bit unsigned integers this should be 255 (the default). This
+                value will be used to normalize the colors from 0 to 1.
+
+        Colormaps can be loaded from ``.npy``, ``.npz``, or comma-separated text
+        files. Numpy (npy/npz) files should be 2D arrays with rows for each color.
+        Comma-separated files should have a row for each color with each column
+        representing a single value/channel. A filename
+        ending with ``.npy`` or ``.npz`` is read as a numpy file with
+        :func:`numpy.load`. All other extensions are
+        read as a comma-separated file. For ``.npz`` files the data must be stored
+        as a positional list where the first element represents the colormap to
+        use. See :func:`numpy.savez` for more information. The filename should
+        be an absolute path for consistency.
+
+        The colormap is interpreted as 1 of 4 different "colormap modes":
+        ``RGB``, ``RGBA``, ``VRGB``, or ``VRGBA``. The
+        colormap mode can be forced with the ``colormap_mode`` keyword
+        argument. If it is not provided then a default will be chosen
+        based on the number of columns in the array (3: RGB, 4: VRGB, 5: VRGBA).
+
+        The "V" in the possible colormap modes represents the control value of
+        where that color should be applied. If "V" is not provided in the colormap
+        data it defaults to the row index in the colormap array (0, 1, 2, ...)
+        divided by the total number of colors to produce a number between 0 and 1.
+        See the "Set Range" section below for more information.
+        The remaining elements in the colormap array represent the Red (R),
+        Green (G), and Blue (B) color to be mapped to.
+
+        See the "Color Scale" section below for more information on the value
+        range of provided numbers.
+
+        **Color Scale**
+
+        By default colors are expected to be in a 0-255 range. This
+        can be overridden by specifying ``color_scale`` keyword argument..
+        A common alternative to 255 is ``1`` to specify floating
+        point numbers between 0 and 1. The resulting Colormap uses the normalized
+        color values (0-1).
+
+        """
+        if not os.path.isfile(filename_or_string):
+            filename_or_string = StringIO(filename_or_string)
+        values, colors = _get_values_colors_from_file(filename_or_string, colormap_mode, color_scale)
+        return cls(values=values, colors=colors)
+
+
+def _get_values_colors_from_file(filename, colormap_mode, color_scale):
+    data = _read_colormap_data_from_file(filename)
+    cols = data.shape[1]
+    default_modes = {
+        3: 'RGB',
+        4: 'VRGB',
+        5: 'VRGBA'
+    }
+    default_mode = default_modes.get(cols)
+    if colormap_mode is None:
+        colormap_mode = default_mode
+    if colormap_mode is None or len(colormap_mode) != cols:
+        raise ValueError(
+            "Unexpected colormap shape for mode '{}'".format(colormap_mode))
+    rows = data.shape[0]
+    if colormap_mode[0] == 'V':
+        colors = data[:, 1:]
+        if color_scale != 1:
+            colors = data[:, 1:] / float(color_scale)
+        values = data[:, 0]
+    else:
+        colors = data
+        if color_scale != 1:
+            colors = colors / float(color_scale)
+        values = np.arange(rows) / float(rows - 1)
+    return values, colors
+
+
+def _read_colormap_data_from_file(filename_or_file_obj):
+    if isinstance(filename_or_file_obj, str):
+        ext = os.path.splitext(filename_or_file_obj)[1]
+        if ext in (".npy", ".npz"):
+            file_content = np.load(filename_or_file_obj)
+            if ext == ".npz":
+                # .npz is a collection
+                # assume position list-like and get the first element
+                file_content = file_content["arr_0"]
+            return file_content
+    # CSV file or file-like object of CSV string data
+    return np.loadtxt(filename_or_file_obj, delimiter=",")
+
 
 # matlab jet "#00007F", "blue", "#007FFF", "cyan", "#7FFF7F", "yellow",
 # "#FF7F00", "red", "#7F0000"


=====================================
trollimage/tests/test_colormap.py
=====================================
@@ -21,9 +21,12 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 """Test colormap.py."""
 
+import os
+import contextlib
 import unittest
 from trollimage import colormap
 import numpy as np
+from tempfile import NamedTemporaryFile
 
 import pytest
 
@@ -38,95 +41,6 @@ class TestColormapClass(unittest.TestCase):
                                           (3, (1, 1, 1)),
                                           (4, (0, 0, 0)))
 
-    def test_colorize_no_interpolation(self):
-        """Test colorize."""
-        data = np.array([1, 2, 3, 4])
-
-        channels = self.colormap.colorize(data)
-        for i in range(3):
-            self.assertTrue(np.allclose(channels[i],
-                                        self.colormap.colors[:, i],
-                                        atol=0.001))
-
-    def test_colorize_with_interpolation(self):
-        """Test colorize."""
-        data = np.array([1.5, 2.5, 3.5, 4])
-
-        expected_channels = [np.array([0.22178232, 0.61069262, 0.50011605, 0.]),
-                             np.array([1.08365532, 0.94644083, 0.50000605, 0.]),
-                             np.array([0.49104964, 1.20509947, 0.49989589, 0.])]
-
-        channels = self.colormap.colorize(data)
-        for i in range(3):
-            self.assertTrue(np.allclose(channels[i],
-                                        expected_channels[i],
-                                        atol=0.001))
-
-    def test_colorize_dask_with_interpolation(self):
-        """Test colorize dask arrays."""
-        import dask.array as da
-        data = da.from_array(np.array([[1.5, 2.5, 3.5, 4],
-                                       [1.5, 2.5, 3.5, 4],
-                                       [1.5, 2.5, 3.5, 4]]), chunks=2)
-
-        expected_channels = [np.array([[0.22178232, 0.61069262, 0.50011605, 0.],
-                                       [0.22178232, 0.61069262, 0.50011605, 0.],
-                                       [0.22178232, 0.61069262, 0.50011605, 0.]]),
-                             np.array([[1.08365532, 0.94644083, 0.50000605, 0.],
-                                       [1.08365532, 0.94644083, 0.50000605, 0.],
-                                       [1.08365532, 0.94644083, 0.50000605, 0.]]),
-                             np.array([[0.49104964, 1.20509947, 0.49989589, 0.],
-                                       [0.49104964, 1.20509947, 0.49989589, 0.],
-                                       [0.49104964, 1.20509947, 0.49989589, 0.]])]
-
-        channels = self.colormap.colorize(data)
-        for i, expected_channel in enumerate(expected_channels):
-            current_channel = channels[i, :, :]
-            assert isinstance(current_channel, da.Array)
-            self.assertTrue(np.allclose(current_channel.compute(),
-                                        expected_channel,
-                                        atol=0.001))
-
-    def test_palettize(self):
-        """Test palettize."""
-        data = np.array([1, 2, 3, 4])
-
-        channels, colors = self.colormap.palettize(data)
-        self.assertTrue(np.allclose(colors, self.colormap.colors))
-        self.assertTrue(all(channels == [0, 1, 2, 3]))
-
-        cm_ = colormap.Colormap((0, (0.0, 0.0, 0.0)),
-                                (1, (1.0, 1.0, 1.0)),
-                                (2, (2, 2, 2)),
-                                (3, (3, 3, 3)))
-
-        data = np.arange(-1, 5)
-
-        channels, colors = cm_.palettize(data)
-        self.assertTrue(np.allclose(colors, cm_.colors))
-        self.assertTrue(all(channels == [0, 0, 1, 2, 3, 3]))
-
-        data = np.arange(-1.0, 5.0)
-        data[-1] = np.nan
-
-        channels, colors = cm_.palettize(data)
-        self.assertTrue(np.allclose(colors, cm_.colors))
-        self.assertTrue(all(channels == [0, 0, 1, 2, 3, 3]))
-
-    def test_palettize_dask(self):
-        """Test palettize on a dask array."""
-        import dask.array as da
-        data = da.from_array(np.array([[1, 2, 3, 4],
-                                       [1, 2, 3, 4],
-                                       [1, 2, 3, 4]]), chunks=2)
-        channels, colors = self.colormap.palettize(data)
-        assert isinstance(channels, da.Array)
-        self.assertTrue(np.allclose(colors, self.colormap.colors))
-        self.assertTrue(np.allclose(channels.compute(), [[0, 1, 2, 3],
-                                                         [0, 1, 2, 3],
-                                                         [0, 1, 2, 3]]))
-        assert channels.dtype == int
-
     def test_set_range(self):
         """Test set_range."""
         cm_ = colormap.Colormap((1, (1.0, 1.0, 0.0)),
@@ -146,8 +60,11 @@ class TestColormapClass(unittest.TestCase):
                                 (4, (0, 0, 0)))
 
         cm_.set_range(8, 0)
-        self.assertTrue(cm_.values[0] == 0)
-        self.assertTrue(cm_.values[-1] == 8)
+        assert cm_.values[0] == 8
+        assert cm_.values[-1] == 0
+        _assert_monotonic_values(cm_, increasing=False)
+        np.testing.assert_allclose(cm_.colors[0], (1.0, 1.0, 0.0))
+        np.testing.assert_allclose(cm_.colors[-1], (0.0, 0.0, 0.0))
 
     def test_reverse(self):
         """Test reverse."""
@@ -233,6 +150,28 @@ COLORS_RGBA1 = np.array([
 ])
 
 
+def _mono_inc_colormap():
+    """Create fake monotonically increasing colormap."""
+    values = [1, 2, 3, 4]
+    cmap = colormap.Colormap(values=values, colors=_four_rgb_colors())
+    return cmap
+
+
+def _mono_dec_colormap():
+    values = [4, 3, 2, 1]
+    cmap = colormap.Colormap(values=values, colors=_four_rgb_colors())
+    return cmap
+
+
+def _four_rgb_colors():
+    return [
+        (1.0, 1.0, 0.0),
+        (0.0, 1.0, 1.0),
+        (1, 1, 1),
+        (0, 0, 0),
+    ]
+
+
 class TestColormap:
     """Pytest tests for colormap objects."""
 
@@ -373,6 +312,21 @@ class TestColormap:
         # this should succeed
         _ = cmap1 + cmap2
 
+    def test_merge_monotonic_decreasing(self):
+        """Test that merged colormaps can be monotonically decreasing."""
+        cmap1 = colormap.Colormap(
+            colors=np.arange(5 * 3).reshape((5, 3)),
+            values=np.linspace(2, 1, 5),
+        )
+        cmap2 = colormap.Colormap(
+            colors=np.arange(5 * 3).reshape((5, 3)),
+            values=np.linspace(1, 0, 5),
+        )
+        _assert_monotonic_values(cmap1, increasing=False)
+        _assert_monotonic_values(cmap2, increasing=False)
+        # this should succeed
+        _ = cmap1 + cmap2
+
     @pytest.mark.parametrize('inplace', [False, True])
     def test_reverse(self, inplace):
         """Test colormap reverse."""
@@ -383,9 +337,9 @@ class TestColormap:
 
         cmap = colormap.Colormap(values=values, colors=colors)
         new_cmap = cmap.reverse(inplace)
-        self._assert_inplace_worked(cmap, new_cmap, inplace)
-        self._compare_reversed_colors(cmap, new_cmap, inplace, orig_colors)
-        self._assert_unchanged_values(cmap, new_cmap, inplace, orig_values)
+        _assert_inplace_worked(cmap, new_cmap, inplace)
+        _assert_reversed_colors(cmap, new_cmap, inplace, orig_colors)
+        _assert_unchanged_values(cmap, new_cmap, inplace, orig_values)
 
     @pytest.mark.parametrize(
         'new_range',
@@ -405,46 +359,316 @@ class TestColormap:
 
         cmap = colormap.Colormap(values=values, colors=colors)
         new_cmap = cmap.set_range(*new_range, inplace)
-        self._assert_inplace_worked(cmap, new_cmap, inplace)
-        self._assert_monotonic_values(cmap)
-        self._assert_monotonic_values(new_cmap)
-        self._assert_values_changed(cmap, new_cmap, inplace, orig_values)
-        if new_range[0] > new_range[1]:
-            self._compare_reversed_colors(cmap, new_cmap, inplace, orig_colors)
-
-    @staticmethod
-    def _assert_monotonic_values(cmap):
-        np.testing.assert_allclose(np.diff(cmap.values) > 0, True)
-
-    @staticmethod
-    def _assert_unchanged_values(cmap, new_cmap, inplace, orig_values):
-        if inplace:
-            np.testing.assert_allclose(cmap.values, orig_values)
-        else:
-            np.testing.assert_allclose(cmap.values, orig_values)
-            np.testing.assert_allclose(new_cmap.values, orig_values)
-
-    @staticmethod
-    def _compare_reversed_colors(cmap, new_cmap, inplace, orig_colors):
-        if inplace:
-            assert cmap is new_cmap
-            np.testing.assert_allclose(cmap.colors, orig_colors[::-1])
-        else:
-            assert cmap is not new_cmap
-            np.testing.assert_allclose(cmap.colors, orig_colors)
-            np.testing.assert_allclose(new_cmap.colors, orig_colors[::-1])
-
-    @staticmethod
-    def _assert_values_changed(cmap, new_cmap, inplace, orig_values):
-        assert not np.allclose(new_cmap.values, orig_values)
-        if not inplace:
-            np.testing.assert_allclose(cmap.values, orig_values)
-        else:
-            assert not np.allclose(cmap.values, orig_values)
+        flipped_range = new_range[0] > new_range[1]
+        _assert_inplace_worked(cmap, new_cmap, inplace)
+        _assert_monotonic_values(cmap, increasing=not inplace or not flipped_range)
+        _assert_monotonic_values(new_cmap, increasing=not flipped_range)
+        _assert_values_changed(cmap, new_cmap, inplace, orig_values)
+        _assert_unchanged_colors(cmap, new_cmap, orig_colors)
 
-    @staticmethod
-    def _assert_inplace_worked(cmap, new_cmap, inplace):
-        if not inplace:
-            assert new_cmap is not cmap
+    @pytest.mark.parametrize(
+        ("input_cmap_func", "expected_result"),
+        [
+            (_mono_inc_colormap, (0, 1, 2, 3)),
+            (_mono_dec_colormap, (3, 2, 1, 0)),
+        ]
+    )
+    def test_palettize_in_range(self, input_cmap_func, expected_result):
+        """Test palettize with values inside the set range."""
+        data = np.array([1, 2, 3, 4])
+        cm = input_cmap_func()
+        channels, colors = cm.palettize(data)
+        np.testing.assert_allclose(colors, cm.colors)
+        assert all(channels == expected_result)
+
+    def test_palettize_mono_inc_out_range(self):
+        """Test palettize with a value outside the colormap values."""
+        cm = colormap.Colormap(values=[0, 1, 2, 3],
+                               colors=_four_rgb_colors())
+        data = np.arange(-1, 5)
+        channels, colors = cm.palettize(data)
+        np.testing.assert_allclose(colors, cm.colors)
+        assert all(channels == [0, 0, 1, 2, 3, 3])
+
+    def test_palettize_mono_inc_nan(self):
+        """Test palettize with monotonic increasing values with a NaN."""
+        cm = colormap.Colormap(values=[0, 1, 2, 3],
+                               colors=_four_rgb_colors())
+        data = np.arange(-1.0, 5.0)
+        data[-1] = np.nan
+        channels, colors = cm.palettize(data)
+        np.testing.assert_allclose(colors, cm.colors)
+        assert all(channels == [0, 0, 1, 2, 3, 3])
+
+    def test_palettize_mono_inc_in_range_dask(self):
+        """Test palettize on a dask array."""
+        import dask.array as da
+        data = da.from_array(np.array([[1, 2, 3, 4],
+                                       [1, 2, 3, 4],
+                                       [1, 2, 3, 4]]), chunks=2)
+        cm = _mono_inc_colormap()
+        channels, colors = cm.palettize(data)
+        assert isinstance(channels, da.Array)
+        np.testing.assert_allclose(colors, cm.colors)
+        np.testing.assert_allclose(channels.compute(), [[0, 1, 2, 3],
+                                                        [0, 1, 2, 3],
+                                                        [0, 1, 2, 3]])
+        assert channels.dtype == int
+
+    @pytest.mark.parametrize(
+        ("input_cmap_func", "expected_result"),
+        [
+            (_mono_inc_colormap, _four_rgb_colors()),
+            (_mono_dec_colormap, _four_rgb_colors()[::-1]),
+        ]
+    )
+    def test_colorize_no_interpolation(self, input_cmap_func, expected_result):
+        """Test colorize."""
+        data = np.array([1, 2, 3, 4])
+        cm = input_cmap_func()
+        channels = cm.colorize(data)
+        output_colors = [channels[:, i] for i in range(data.size)]
+        for output_color, expected_color in zip(output_colors, expected_result):
+            np.testing.assert_allclose(output_color, expected_color, atol=0.001)
+
+    @pytest.mark.parametrize(
+        ("input_cmap_func", "expected_result"),
+        [
+            (_mono_inc_colormap,
+             np.array([
+                 [0.22178232, 1.08365532, 0.49104964],
+                 [0.61069262, 0.94644083, 1.20509947],
+                 [0.50011605, 0.50000605, 0.49989589],
+                 [0.0, 0.0, 0.0]])),
+            (_mono_dec_colormap,
+             np.array([
+                 [0.50011605, 0.50000605, 0.49989589],
+                 [0.61069262, 0.94644083, 1.20509947],
+                 [0.22178232, 1.08365532, 0.49104964],
+                 [1.0, 1.0, 0.0]])),
+        ]
+    )
+    def test_colorize_with_interpolation(self, input_cmap_func, expected_result):
+        """Test colorize where data values require interpolation between colors."""
+        data = np.array([1.5, 2.5, 3.5, 4])
+        cm = input_cmap_func()
+        channels = cm.colorize(data)
+        output_colors = [channels[:, i] for i in range(data.size)]
+        for output_color, expected_color in zip(output_colors, expected_result):
+            np.testing.assert_allclose(output_color, expected_color, atol=0.001)
+
+    def test_colorize_dask_with_interpolation(self):
+        """Test colorize dask arrays."""
+        import dask.array as da
+        data = da.from_array(np.array([[1.5, 2.5, 3.5, 4],
+                                       [1.5, 2.5, 3.5, 4],
+                                       [1.5, 2.5, 3.5, 4]]), chunks=2)
+
+        expected_channels = [np.array([[0.22178232, 0.61069262, 0.50011605, 0.],
+                                       [0.22178232, 0.61069262, 0.50011605, 0.],
+                                       [0.22178232, 0.61069262, 0.50011605, 0.]]),
+                             np.array([[1.08365532, 0.94644083, 0.50000605, 0.],
+                                       [1.08365532, 0.94644083, 0.50000605, 0.],
+                                       [1.08365532, 0.94644083, 0.50000605, 0.]]),
+                             np.array([[0.49104964, 1.20509947, 0.49989589, 0.],
+                                       [0.49104964, 1.20509947, 0.49989589, 0.],
+                                       [0.49104964, 1.20509947, 0.49989589, 0.]])]
+
+        cm = _mono_inc_colormap()
+        channels = cm.colorize(data)
+        for i, expected_channel in enumerate(expected_channels):
+            current_channel = channels[i, :, :]
+            assert isinstance(current_channel, da.Array)
+            np.testing.assert_allclose(current_channel.compute(),
+                                       expected_channel,
+                                       atol=0.001)
+
+
+ at contextlib.contextmanager
+def closed_named_temp_file(**kwargs):
+    """Named temporary file context manager that closes the file after creation.
+
+    This helps with Windows systems which can get upset with opening or
+    deleting a file that is already open.
+
+    """
+    try:
+        with NamedTemporaryFile(delete=False, **kwargs) as tmp_cmap:
+            yield tmp_cmap.name
+    finally:
+        os.remove(tmp_cmap.name)
+
+
+def _write_cmap_to_file(cmap_filename, cmap_data):
+    ext = os.path.splitext(cmap_filename)[1]
+    if ext in (".npy",):
+        np.save(cmap_filename, cmap_data)
+    elif ext in (".npz",):
+        np.savez(cmap_filename, cmap_data)
+    else:
+        np.savetxt(cmap_filename, cmap_data, delimiter=",")
+
+
+def _generate_cmap_test_data(color_scale, colormap_mode):
+    cmap_data = np.array([
+        [1, 0, 0],
+        [1, 1, 0],
+        [1, 1, 1],
+        [0, 0, 1],
+    ], dtype=np.float64)
+    if len(colormap_mode) != 3:
+        _cmap_data = cmap_data
+        cmap_data = np.empty((cmap_data.shape[0], len(colormap_mode)),
+                             dtype=np.float64)
+        if colormap_mode.startswith("V") or colormap_mode.endswith("A"):
+            cmap_data[:, 0] = np.array([128, 130, 132, 134]) / 255.0
+            cmap_data[:, -3:] = _cmap_data
+        if colormap_mode.startswith("V") and colormap_mode.endswith("A"):
+            cmap_data[:, 1] = np.array([128, 130, 132, 134]) / 255.0
+    if color_scale is None or color_scale == 255:
+        cmap_data = (cmap_data * 255).astype(np.uint8)
+    return cmap_data
+
+
+class TestFromFileCreation:
+    """Tests for loading Colormaps from files."""
+
+    @pytest.mark.parametrize("csv_filename", [None, "test.cmap"])
+    @pytest.mark.parametrize("new_range", [None, (25.0, 75.0)])
+    @pytest.mark.parametrize("color_scale", [1.0, 255, 65535])
+    def test_csv_roundtrip(self, tmp_path, csv_filename, new_range, color_scale):
+        """Test saving and loading a Colormap from a CSV file."""
+        orig_cmap = colormap.brbg
+        if new_range is not None:
+            orig_cmap = orig_cmap.set_range(*new_range, inplace=False)
+        if isinstance(csv_filename, str):
+            csv_filename = str(tmp_path / csv_filename)
+            res = orig_cmap.to_csv(csv_filename, color_scale=color_scale)
+            assert res is None
+            new_cmap = colormap.Colormap.from_file(csv_filename, color_scale=color_scale)
         else:
-            assert new_cmap is cmap
+            res = orig_cmap.to_csv(None, color_scale=color_scale)
+            assert isinstance(res, str)
+            new_cmap = colormap.Colormap.from_file(res, color_scale=color_scale)
+        np.testing.assert_allclose(orig_cmap.values, new_cmap.values)
+        np.testing.assert_allclose(orig_cmap.colors, new_cmap.colors)
+
+    @pytest.mark.parametrize("color_scale", [None, 1.0])
+    @pytest.mark.parametrize("colormap_mode", ["RGB", "VRGB", "VRGBA"])
+    @pytest.mark.parametrize("filename_suffix", [".npy", ".npz", ".csv"])
+    def test_cmap_from_file(self, color_scale, colormap_mode, filename_suffix):
+        """Test that colormaps can be loaded from a binary or CSV file."""
+        # create the colormap file on disk
+        with closed_named_temp_file(suffix=filename_suffix) as cmap_filename:
+            cmap_data = _generate_cmap_test_data(color_scale, colormap_mode)
+            _write_cmap_to_file(cmap_filename, cmap_data)
+
+            unset_first_value = 128.0 / 255.0 if colormap_mode.startswith("V") else 0.0
+            unset_last_value = 134.0 / 255.0 if colormap_mode.startswith("V") else 1.0
+            if (color_scale is None or color_scale == 255) and colormap_mode.startswith("V"):
+                unset_first_value *= 255
+                unset_last_value *= 255
+
+            first_color = [1.0, 0.0, 0.0]
+            if colormap_mode == "VRGBA":
+                first_color = [128.0 / 255.0] + first_color
+
+            kwargs1 = {}
+            if color_scale is not None:
+                kwargs1["color_scale"] = color_scale
+
+            cmap = colormap.Colormap.from_file(cmap_filename, **kwargs1)
+            assert cmap.colors.shape[0] == 4
+            np.testing.assert_equal(cmap.colors[0], first_color)
+            assert cmap.values.shape[0] == 4
+            assert cmap.values[0] == unset_first_value
+            assert cmap.values[-1] == unset_last_value
+
+    def test_cmap_vrgb_as_rgba(self):
+        """Test that data created as VRGB still reads as RGBA."""
+        with closed_named_temp_file(suffix=".npy") as cmap_filename:
+            cmap_data = _generate_cmap_test_data(None, "VRGB")
+            np.save(cmap_filename, cmap_data)
+            cmap = colormap.Colormap.from_file(cmap_filename, colormap_mode="RGBA")
+            assert cmap.colors.shape[0] == 4
+            assert cmap.colors.shape[1] == 4  # RGBA
+            np.testing.assert_equal(cmap.colors[0], [128 / 255., 1.0, 0, 0])
+            assert cmap.values.shape[0] == 4
+            assert cmap.values[0] == 0
+            assert cmap.values[-1] == 1.0
+
+    @pytest.mark.parametrize(
+        ("real_mode", "forced_mode"),
+        [
+            ("VRGBA", "RGBA"),
+            ("VRGBA", "VRGB"),
+            ("RGBA", "RGB"),
+        ]
+    )
+    @pytest.mark.parametrize("filename_suffix", [".npy", ".csv"])
+    def test_cmap_bad_mode(self, real_mode, forced_mode, filename_suffix):
+        """Test that reading colormaps with the wrong mode fails."""
+        with closed_named_temp_file(suffix=filename_suffix) as cmap_filename:
+            cmap_data = _generate_cmap_test_data(None, real_mode)
+            _write_cmap_to_file(cmap_filename, cmap_data)
+            # Force colormap_mode VRGBA to RGBA and we should see an exception
+            with pytest.raises(ValueError):
+                colormap.Colormap.from_file(cmap_filename, colormap_mode=forced_mode)
+
+    def test_cmap_from_file_bad_shape(self):
+        """Test that unknown array shape causes an error."""
+        with closed_named_temp_file(suffix='.npy') as cmap_filename:
+            np.save(cmap_filename, np.array([
+                [0],
+                [64],
+                [128],
+                [255],
+            ]))
+
+            with pytest.raises(ValueError):
+                colormap.Colormap.from_file(cmap_filename)
+
+
+def _assert_monotonic_values(cmap, increasing=True):
+    delta = np.diff(cmap.values)
+    np.testing.assert_allclose(delta > 0, increasing)
+
+
+def _assert_unchanged_values(cmap, new_cmap, inplace, orig_values):
+    if inplace:
+        assert cmap is new_cmap
+        np.testing.assert_allclose(cmap.values, orig_values)
+    else:
+        assert cmap is not new_cmap
+        np.testing.assert_allclose(cmap.values, orig_values)
+        np.testing.assert_allclose(new_cmap.values, orig_values)
+
+
+def _assert_unchanged_colors(cmap, new_cmap, orig_colors):
+    np.testing.assert_allclose(cmap.colors, orig_colors)
+    np.testing.assert_allclose(new_cmap.colors, orig_colors)
+
+
+def _assert_reversed_colors(cmap, new_cmap, inplace, orig_colors):
+    if inplace:
+        assert cmap is new_cmap
+        np.testing.assert_allclose(cmap.colors, orig_colors[::-1])
+    else:
+        assert cmap is not new_cmap
+        np.testing.assert_allclose(cmap.colors, orig_colors)
+        np.testing.assert_allclose(new_cmap.colors, orig_colors[::-1])
+
+
+def _assert_values_changed(cmap, new_cmap, inplace, orig_values):
+    assert not np.allclose(new_cmap.values, orig_values)
+    if not inplace:
+        np.testing.assert_allclose(cmap.values, orig_values)
+    else:
+        assert not np.allclose(cmap.values, orig_values)
+
+
+def _assert_inplace_worked(cmap, new_cmap, inplace):
+    if not inplace:
+        assert new_cmap is not cmap
+    else:
+        assert new_cmap is cmap


=====================================
trollimage/tests/test_image.py
=====================================
@@ -27,8 +27,13 @@ from collections import OrderedDict
 from tempfile import NamedTemporaryFile
 
 import numpy as np
+import xarray as xr
+import rasterio as rio
 import pytest
-from trollimage import image
+
+from trollimage import image, xrimage
+from trollimage.colormap import Colormap, brbg
+
 
 EPSILON = 0.0001
 
@@ -715,7 +720,6 @@ class TestXRImage:
 
     def test_init(self):
         """Test object initialization."""
-        import xarray as xr
         from trollimage import xrimage
         data = xr.DataArray([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], dims=['y', 'x'])
         img = xrimage.XRImage(data)
@@ -752,7 +756,6 @@ class TestXRImage:
         Xarray >0.15 makes data read-only after expand_dims.
 
         """
-        import xarray as xr
         from trollimage import xrimage
         data = xr.DataArray([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], dims=['y', 'x'])
         img = xrimage.XRImage(data)
@@ -763,7 +766,6 @@ class TestXRImage:
 
     def test_regression_double_format_save(self):
         """Test that double format information isn't passed to save."""
-        import xarray as xr
         from trollimage import xrimage
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3) / 74., dims=[
@@ -778,10 +780,8 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_rgb_save(self):
         """Test saving RGB/A data to simple image formats."""
-        import xarray as xr
         from dask.delayed import Delayed
         from trollimage import xrimage
-        import rasterio as rio
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3) / 74., dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
@@ -812,9 +812,7 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_single_band_jpeg(self):
         """Test saving single band to jpeg formats."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # Single band image
         data = np.arange(75).reshape(15, 5, 1) / 74.
@@ -841,9 +839,7 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_single_band_png(self):
         """Test saving single band images to simple image formats."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # Single band image
         data = np.arange(75).reshape(15, 5, 1) / 74.
@@ -888,7 +884,6 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_palettes(self):
         """Test saving paletted images to simple image formats."""
-        import xarray as xr
         from trollimage import xrimage
 
         # Single band image palettized
@@ -914,10 +909,8 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_geotiff_float(self):
         """Test saving geotiffs when input data is float."""
-        import xarray as xr
         import dask.array as da
         from trollimage import xrimage
-        import rasterio as rio
 
         # numpy array image - scale to 0 to 1 first
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3) / 75.,
@@ -1067,7 +1060,6 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_geotiff_datetime(self):
         """Test saving geotiffs when start_time is in the attributes."""
-        import xarray as xr
         import datetime as dt
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
@@ -1087,10 +1079,8 @@ class TestXRImage:
                         reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_geotiff_int(self):
         """Test saving geotiffs when input data is int."""
-        import xarray as xr
         import dask.array as da
         from trollimage import xrimage
-        import rasterio as rio
         from rasterio.control import GroundControlPoint
 
         # numpy array image
@@ -1280,10 +1270,8 @@ class TestXRImage:
         to.
 
         """
-        import xarray as xr
         import dask.array as da
         from trollimage import xrimage
-        import rasterio as rio
 
         # numpy array image
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
@@ -1307,9 +1295,7 @@ class TestXRImage:
     @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_jp2_int(self):
         """Test saving jp2000 when input data is int."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # numpy array image
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
@@ -1330,9 +1316,7 @@ class TestXRImage:
     @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_cloud_optimized_geotiff(self):
         """Test saving cloud optimized geotiffs."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # trigger COG driver to create 2 overview levels
         # COG driver is only available in GDAL 3.1 or later
@@ -1351,9 +1335,7 @@ class TestXRImage:
     @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_overviews(self):
         """Test saving geotiffs with overviews."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # numpy array image
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
@@ -1392,9 +1374,7 @@ class TestXRImage:
     @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_tags(self):
         """Test saving geotiffs with tags."""
-        import xarray as xr
         from trollimage import xrimage
-        import rasterio as rio
 
         # numpy array image
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
@@ -1410,7 +1390,6 @@ class TestXRImage:
 
     def test_gamma(self):
         """Test gamma correction."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3) / 75.
@@ -1427,7 +1406,6 @@ class TestXRImage:
 
     def test_crude_stretch(self):
         """Check crude stretching."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3)
@@ -1456,7 +1434,6 @@ class TestXRImage:
 
     def test_invert(self):
         """Check inversion of the image."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3) / 75.
@@ -1482,7 +1459,6 @@ class TestXRImage:
 
     def test_linear_stretch(self):
         """Test linear stretching with cutoffs."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3) / 74.
@@ -1523,7 +1499,6 @@ class TestXRImage:
 
     def test_histogram_stretch(self):
         """Test histogram stretching."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3) / 74.
@@ -1575,7 +1550,6 @@ class TestXRImage:
     @pytest.mark.parametrize("base", ["e", "10", "2"])
     def test_logarithmic_stretch(self, min_stretch, max_stretch, base):
         """Test logarithmic strecthing."""
-        import xarray as xr
         from trollimage import xrimage
         from .utils import assert_maximum_dask_computes
 
@@ -1624,7 +1598,6 @@ class TestXRImage:
 
     def test_weber_fechner_stretch(self):
         """Test applying S=2.3klog10I+C to the data."""
-        import xarray as xr
         from trollimage import xrimage
 
         arr = np.arange(75).reshape(5, 5, 3) / 74.
@@ -1697,7 +1670,6 @@ class TestXRImage:
     def test_convert_modes(self):
         """Test modes convertions."""
         import dask
-        import xarray as xr
         from trollimage import xrimage
         from trollimage.colormap import brbg, Colormap
 
@@ -1838,7 +1810,6 @@ class TestXRImage:
 
     def test_final_mode(self):
         """Test final_mode."""
-        import xarray as xr
         from trollimage import xrimage
 
         # numpy array image
@@ -1848,178 +1819,8 @@ class TestXRImage:
         assert img.final_mode(None) == 'RGBA'
         assert img.final_mode(0) == 'RGB'
 
-    def test_colorize(self):
-        """Test colorize with an RGB colormap."""
-        import xarray as xr
-        from trollimage import xrimage
-        from trollimage.colormap import brbg
-
-        arr = np.arange(75).reshape(5, 15) / 74.
-        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
-        img = xrimage.XRImage(data)
-        img.colorize(brbg)
-        values = img.data.compute()
-
-        expected = np.array([[
-            [3.29409498e-01, 3.59108764e-01, 3.88800969e-01,
-             4.18486092e-01, 4.48164112e-01, 4.77835010e-01,
-             5.07498765e-01, 5.37155355e-01, 5.65419479e-01,
-             5.92686124e-01, 6.19861622e-01, 6.46945403e-01,
-             6.73936907e-01, 7.00835579e-01, 7.27640871e-01],
-            [7.58680358e-01, 8.01695237e-01, 8.35686284e-01,
-             8.60598212e-01, 8.76625002e-01, 8.84194741e-01,
-             8.83948647e-01, 8.76714923e-01, 8.95016030e-01,
-             9.14039881e-01, 9.27287161e-01, 9.36546985e-01,
-             9.43656076e-01, 9.50421050e-01, 9.58544227e-01],
-            [9.86916929e-01, 1.02423117e+00, 1.03591220e+00,
-             1.02666645e+00, 1.00491333e+00, 9.80759775e-01,
-             9.63746819e-01, 9.60798629e-01, 9.47739946e-01,
-             9.27428067e-01, 9.01184523e-01, 8.71168132e-01,
-             8.40161241e-01, 8.11290344e-01, 7.87705814e-01],
-            [7.57749840e-01, 7.20020026e-01, 6.82329616e-01,
-             6.44678929e-01, 6.07068282e-01, 5.69497990e-01,
-             5.31968369e-01, 4.94025422e-01, 4.54275131e-01,
-             4.14517560e-01, 3.74757709e-01, 3.35000583e-01,
-             2.95251189e-01, 2.55514533e-01, 2.15795621e-01],
-            [1.85805611e-01, 1.58245609e-01, 1.30686714e-01,
-             1.03128926e-01, 7.55722460e-02, 4.80166757e-02,
-             2.04622160e-02, 3.79809920e-03, 3.46310306e-03,
-             3.10070529e-03, 2.68579661e-03, 2.19341216e-03,
-             1.59875239e-03, 8.77203803e-04, 4.35952940e-06]],
-
-            [[1.88249866e-01, 2.05728128e-01, 2.23209861e-01,
-              2.40695072e-01, 2.58183766e-01, 2.75675949e-01,
-              2.93171625e-01, 3.10670801e-01, 3.32877903e-01,
-              3.58244116e-01, 3.83638063e-01, 4.09059827e-01,
-              4.34509485e-01, 4.59987117e-01, 4.85492795e-01],
-             [5.04317660e-01, 4.97523483e-01, 4.92879482e-01,
-              4.90522941e-01, 4.90521579e-01, 4.92874471e-01,
-              4.97514769e-01, 5.04314130e-01, 5.48356836e-01,
-              6.02679755e-01, 6.57930117e-01, 7.13582394e-01,
-              7.69129132e-01, 8.24101035e-01, 8.78084923e-01],
-             [9.05957986e-01, 9.00459829e-01, 9.01710827e-01,
-              9.09304816e-01, 9.21567297e-01, 9.36002510e-01,
-              9.49878533e-01, 9.60836244e-01, 9.50521017e-01,
-              9.42321192e-01, 9.36098294e-01, 9.31447978e-01,
-              9.27737112e-01, 9.24164130e-01, 9.19837458e-01],
-             [9.08479555e-01, 8.93119640e-01, 8.77756168e-01,
-              8.62389039e-01, 8.47018155e-01, 8.31643415e-01,
-              8.16264720e-01, 7.98248733e-01, 7.69688456e-01,
-              7.41111049e-01, 7.12515170e-01, 6.83899486e-01,
-              6.55262669e-01, 6.26603399e-01, 5.97920364e-01],
-             [5.71406981e-01, 5.45439361e-01, 5.19471340e-01,
-              4.93502919e-01, 4.67534097e-01, 4.41564875e-01,
-              4.15595252e-01, 3.91172349e-01, 3.69029170e-01,
-              3.46833147e-01, 3.24591169e-01, 3.02310146e-01,
-              2.79997004e-01, 2.57658679e-01, 2.35302110e-01]],
-
-            [[1.96102817e-02, 2.23037080e-02, 2.49835320e-02,
-              2.76497605e-02, 3.03024001e-02, 3.29414575e-02,
-              3.55669395e-02, 3.81788529e-02, 5.03598778e-02,
-              6.89209657e-02, 8.74757090e-02, 1.06024973e-01,
-              1.24569626e-01, 1.43110536e-01, 1.61648577e-01],
-             [1.82340027e-01, 2.15315774e-01, 2.53562955e-01,
-              2.95884521e-01, 3.41038527e-01, 3.87773687e-01,
-              4.34864157e-01, 4.81142673e-01, 5.00410360e-01,
-              5.19991397e-01, 5.47394263e-01, 5.82556639e-01,
-              6.25097005e-01, 6.74344521e-01, 7.29379582e-01],
-             [7.75227971e-01, 8.13001048e-01, 8.59395545e-01,
-              9.04577146e-01, 9.40342288e-01, 9.61653621e-01,
-              9.67479211e-01, 9.60799542e-01, 9.63421077e-01,
-              9.66445062e-01, 9.67352042e-01, 9.63790783e-01,
-              9.53840372e-01, 9.36234978e-01, 9.10530024e-01],
-             [8.86771441e-01, 8.67903107e-01, 8.48953980e-01,
-              8.29924111e-01, 8.10813555e-01, 7.91622365e-01,
-              7.72350598e-01, 7.51439565e-01, 7.24376642e-01,
-              6.97504841e-01, 6.70822717e-01, 6.44328750e-01,
-              6.18021348e-01, 5.91898843e-01, 5.65959492e-01],
-             [5.40017537e-01, 5.14048293e-01, 4.88079755e-01,
-              4.62111921e-01, 4.36144791e-01, 4.10178361e-01,
-              3.84212632e-01, 3.58028450e-01, 3.31935148e-01,
-              3.06445966e-01, 2.81566598e-01, 2.57302099e-01,
-              2.33656886e-01, 2.10634733e-01, 1.88238767e-01]]])
-
-        np.testing.assert_allclose(values, expected)
-
-        # try it with an RGB
-        arr = np.arange(75).reshape(5, 15) / 74.
-        alpha = arr > 40.
-        data = xr.DataArray([arr.copy(), alpha],
-                            dims=['bands', 'y', 'x'],
-                            coords={'bands': ['L', 'A']})
-        img = xrimage.XRImage(data)
-        img.colorize(brbg)
-
-        values = img.data.values
-        expected = np.concatenate((expected,
-                                   alpha.reshape((1,) + alpha.shape)))
-        np.testing.assert_allclose(values, expected)
-
-    def test_colorize_rgba(self):
-        """Test colorize with an RGBA colormap."""
-        import xarray as xr
-        from trollimage import xrimage
-        from trollimage.colormap import Colormap
-
-        # RGBA colormap
-        bw = Colormap(
-            (0.0, (1.0, 1.0, 1.0, 1.0)),
-            (1.0, (0.0, 0.0, 0.0, 0.5)),
-        )
-
-        arr = np.arange(75).reshape(5, 15) / 74.
-        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
-        img = xrimage.XRImage(data)
-        img.colorize(bw)
-        values = img.data.compute()
-        assert (4, 5, 15) == values.shape
-        np.testing.assert_allclose(values[:, 0, 0], [1.0, 1.0, 1.0, 1.0], rtol=1e-03)
-        np.testing.assert_allclose(values[:, -1, -1], [0.0, 0.0, 0.0, 0.5])
-
-    def test_palettize(self):
-        """Test palettize with an RGB colormap."""
-        import xarray as xr
-        from trollimage import xrimage
-        from trollimage.colormap import brbg
-
-        arr = np.arange(75).reshape(5, 15) / 74.
-        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
-        img = xrimage.XRImage(data)
-        img.palettize(brbg)
-
-        values = img.data.values
-        expected = np.array([[
-            [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
-            [2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3],
-            [4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5],
-            [6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7],
-            [8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10]]])
-        np.testing.assert_allclose(values, expected)
-
-    def test_palettize_rgba(self):
-        """Test palettize with an RGBA colormap."""
-        import xarray as xr
-        from trollimage import xrimage
-        from trollimage.colormap import Colormap
-
-        # RGBA colormap
-        bw = Colormap(
-            (0.0, (1.0, 1.0, 1.0, 1.0)),
-            (1.0, (0.0, 0.0, 0.0, 0.5)),
-        )
-
-        arr = np.arange(75).reshape(5, 15) / 74.
-        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
-        img = xrimage.XRImage(data)
-        img.palettize(bw)
-
-        values = img.data.values
-        assert (1, 5, 15) == values.shape
-        assert (2, 4) == bw.colors.shape
-
     def test_stack(self):
         """Test stack."""
-        import xarray as xr
         from trollimage import xrimage
 
         # background image
@@ -2051,7 +1852,6 @@ class TestXRImage:
 
     def test_blend(self):
         """Test blend."""
-        import xarray as xr
         from trollimage import xrimage
 
         core1 = np.arange(75).reshape(5, 5, 3) / 75.0
@@ -2101,7 +1901,6 @@ class TestXRImage:
 
     def test_show(self):
         """Test that the show commands calls PIL.show."""
-        import xarray as xr
         from trollimage import xrimage
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3) / 75., dims=[
@@ -2113,7 +1912,6 @@ class TestXRImage:
 
     def test_apply_pil(self):
         """Test the apply_pil method."""
-        import xarray as xr
         from trollimage import xrimage
 
         np_data = np.arange(75).reshape(5, 5, 3) / 75.
@@ -2169,12 +1967,251 @@ class TestXRImage:
             pil_img.convert.assert_called_with('RGB')
 
 
+class TestXRImageColorize:
+    """Test the colorize method of the XRImage class."""
+
+    _expected = np.array([[
+        [3.29409498e-01, 3.59108764e-01, 3.88800969e-01,
+         4.18486092e-01, 4.48164112e-01, 4.77835010e-01,
+         5.07498765e-01, 5.37155355e-01, 5.65419479e-01,
+         5.92686124e-01, 6.19861622e-01, 6.46945403e-01,
+         6.73936907e-01, 7.00835579e-01, 7.27640871e-01],
+        [7.58680358e-01, 8.01695237e-01, 8.35686284e-01,
+         8.60598212e-01, 8.76625002e-01, 8.84194741e-01,
+         8.83948647e-01, 8.76714923e-01, 8.95016030e-01,
+         9.14039881e-01, 9.27287161e-01, 9.36546985e-01,
+         9.43656076e-01, 9.50421050e-01, 9.58544227e-01],
+        [9.86916929e-01, 1.02423117e+00, 1.03591220e+00,
+         1.02666645e+00, 1.00491333e+00, 9.80759775e-01,
+         9.63746819e-01, 9.60798629e-01, 9.47739946e-01,
+         9.27428067e-01, 9.01184523e-01, 8.71168132e-01,
+         8.40161241e-01, 8.11290344e-01, 7.87705814e-01],
+        [7.57749840e-01, 7.20020026e-01, 6.82329616e-01,
+         6.44678929e-01, 6.07068282e-01, 5.69497990e-01,
+         5.31968369e-01, 4.94025422e-01, 4.54275131e-01,
+         4.14517560e-01, 3.74757709e-01, 3.35000583e-01,
+         2.95251189e-01, 2.55514533e-01, 2.15795621e-01],
+        [1.85805611e-01, 1.58245609e-01, 1.30686714e-01,
+         1.03128926e-01, 7.55722460e-02, 4.80166757e-02,
+         2.04622160e-02, 3.79809920e-03, 3.46310306e-03,
+         3.10070529e-03, 2.68579661e-03, 2.19341216e-03,
+         1.59875239e-03, 8.77203803e-04, 4.35952940e-06]],
+
+        [[1.88249866e-01, 2.05728128e-01, 2.23209861e-01,
+          2.40695072e-01, 2.58183766e-01, 2.75675949e-01,
+          2.93171625e-01, 3.10670801e-01, 3.32877903e-01,
+          3.58244116e-01, 3.83638063e-01, 4.09059827e-01,
+          4.34509485e-01, 4.59987117e-01, 4.85492795e-01],
+         [5.04317660e-01, 4.97523483e-01, 4.92879482e-01,
+          4.90522941e-01, 4.90521579e-01, 4.92874471e-01,
+          4.97514769e-01, 5.04314130e-01, 5.48356836e-01,
+          6.02679755e-01, 6.57930117e-01, 7.13582394e-01,
+          7.69129132e-01, 8.24101035e-01, 8.78084923e-01],
+         [9.05957986e-01, 9.00459829e-01, 9.01710827e-01,
+          9.09304816e-01, 9.21567297e-01, 9.36002510e-01,
+          9.49878533e-01, 9.60836244e-01, 9.50521017e-01,
+          9.42321192e-01, 9.36098294e-01, 9.31447978e-01,
+          9.27737112e-01, 9.24164130e-01, 9.19837458e-01],
+         [9.08479555e-01, 8.93119640e-01, 8.77756168e-01,
+          8.62389039e-01, 8.47018155e-01, 8.31643415e-01,
+          8.16264720e-01, 7.98248733e-01, 7.69688456e-01,
+          7.41111049e-01, 7.12515170e-01, 6.83899486e-01,
+          6.55262669e-01, 6.26603399e-01, 5.97920364e-01],
+         [5.71406981e-01, 5.45439361e-01, 5.19471340e-01,
+          4.93502919e-01, 4.67534097e-01, 4.41564875e-01,
+          4.15595252e-01, 3.91172349e-01, 3.69029170e-01,
+          3.46833147e-01, 3.24591169e-01, 3.02310146e-01,
+          2.79997004e-01, 2.57658679e-01, 2.35302110e-01]],
+
+        [[1.96102817e-02, 2.23037080e-02, 2.49835320e-02,
+          2.76497605e-02, 3.03024001e-02, 3.29414575e-02,
+          3.55669395e-02, 3.81788529e-02, 5.03598778e-02,
+          6.89209657e-02, 8.74757090e-02, 1.06024973e-01,
+          1.24569626e-01, 1.43110536e-01, 1.61648577e-01],
+         [1.82340027e-01, 2.15315774e-01, 2.53562955e-01,
+          2.95884521e-01, 3.41038527e-01, 3.87773687e-01,
+          4.34864157e-01, 4.81142673e-01, 5.00410360e-01,
+          5.19991397e-01, 5.47394263e-01, 5.82556639e-01,
+          6.25097005e-01, 6.74344521e-01, 7.29379582e-01],
+         [7.75227971e-01, 8.13001048e-01, 8.59395545e-01,
+          9.04577146e-01, 9.40342288e-01, 9.61653621e-01,
+          9.67479211e-01, 9.60799542e-01, 9.63421077e-01,
+          9.66445062e-01, 9.67352042e-01, 9.63790783e-01,
+          9.53840372e-01, 9.36234978e-01, 9.10530024e-01],
+         [8.86771441e-01, 8.67903107e-01, 8.48953980e-01,
+          8.29924111e-01, 8.10813555e-01, 7.91622365e-01,
+          7.72350598e-01, 7.51439565e-01, 7.24376642e-01,
+          6.97504841e-01, 6.70822717e-01, 6.44328750e-01,
+          6.18021348e-01, 5.91898843e-01, 5.65959492e-01],
+         [5.40017537e-01, 5.14048293e-01, 4.88079755e-01,
+          4.62111921e-01, 4.36144791e-01, 4.10178361e-01,
+          3.84212632e-01, 3.58028450e-01, 3.31935148e-01,
+          3.06445966e-01, 2.81566598e-01, 2.57302099e-01,
+          2.33656886e-01, 2.10634733e-01, 1.88238767e-01]]])
+
+    @pytest.mark.parametrize("colormap_tag", [None, "colormap"])
+    def test_colorize_geotiff_tag(self, tmp_path, colormap_tag):
+        """Test that a colorized colormap can be saved to a geotiff tag."""
+        new_range = (0.0, 0.5)
+        arr = np.arange(75).reshape(5, 15) / 74.
+        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
+        new_brbg = brbg.set_range(*new_range, inplace=False)
+        img = xrimage.XRImage(data)
+        img.colorize(new_brbg)
+
+        dst = str(tmp_path / "test.tif")
+        img.save(dst, colormap_tag=colormap_tag)
+        with rio.open(dst, "r") as gtiff_file:
+            metadata = gtiff_file.tags()
+            if colormap_tag is None:
+                assert "colormap" not in metadata
+            else:
+                assert "colormap" in metadata
+                loaded_brbg = Colormap.from_file(metadata["colormap"])
+                np.testing.assert_allclose(new_brbg.values, loaded_brbg.values)
+                np.testing.assert_allclose(new_brbg.colors, loaded_brbg.colors)
+
+    @pytest.mark.parametrize(
+        ("new_range", "input_scale", "input_offset", "expected_scale", "expected_offset"),
+        [
+            ((0.0, 1.0), 1.0, 0.0, 1.0, 0.0),
+            ((0.0, 0.5), 1.0, 0.0, 2.0, 0.0),
+            ((2.0, 4.0), 2.0, 2.0, 0.5, -1.0),
+        ],
+    )
+    def test_colorize_l_rgb(self, new_range, input_scale, input_offset, expected_scale, expected_offset):
+        """Test colorize with an RGB colormap."""
+        arr = np.arange(75).reshape(5, 15) / 74. * input_scale + input_offset
+        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
+        new_brbg = brbg.set_range(*new_range, inplace=False)
+        img = xrimage.XRImage(data)
+        img.colorize(new_brbg)
+        values = img.data.compute()
+
+        if new_range[1] == 0.5:
+            expected2 = self._expected.copy().reshape((3, 75))
+            flat_expected = self._expected.reshape((3, 75))
+            expected2[:, :38] = flat_expected[:, ::2]
+            expected2[:, 38:] = flat_expected[:, -1:]
+            expected = expected2.reshape((3, 5, 15))
+        else:
+            expected = self._expected
+        np.testing.assert_allclose(values, expected)
+        assert "enhancement_history" in img.data.attrs
+        assert img.data.attrs["enhancement_history"][-1]["scale"] == expected_scale
+        assert img.data.attrs["enhancement_history"][-1]["offset"] == expected_offset
+        assert isinstance(img.data.attrs["enhancement_history"][-1]["colormap"], Colormap)
+
+    def test_colorize_la_rgb(self):
+        """Test colorizing an LA image with an RGB colormap."""
+        arr = np.arange(75).reshape(5, 15) / 74.
+        alpha = arr > 40.
+        data = xr.DataArray([arr.copy(), alpha],
+                            dims=['bands', 'y', 'x'],
+                            coords={'bands': ['L', 'A']})
+        img = xrimage.XRImage(data)
+        img.colorize(brbg)
+
+        values = img.data.values
+        expected = np.concatenate((self._expected,
+                                   alpha.reshape((1,) + alpha.shape)))
+        np.testing.assert_allclose(values, expected)
+        assert "enhancement_history" in img.data.attrs
+        assert img.data.attrs["enhancement_history"][-1]["scale"] == 1.0
+        assert img.data.attrs["enhancement_history"][-1]["offset"] == 0.0
+        assert isinstance(img.data.attrs["enhancement_history"][-1]["colormap"], Colormap)
+
+    def test_colorize_rgba(self):
+        """Test colorize with an RGBA colormap."""
+        from trollimage import xrimage
+        from trollimage.colormap import Colormap
+
+        # RGBA colormap
+        bw = Colormap(
+            (0.0, (1.0, 1.0, 1.0, 1.0)),
+            (1.0, (0.0, 0.0, 0.0, 0.5)),
+        )
+
+        arr = np.arange(75).reshape(5, 15) / 74.
+        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
+        img = xrimage.XRImage(data)
+        img.colorize(bw)
+        values = img.data.compute()
+        assert (4, 5, 15) == values.shape
+        np.testing.assert_allclose(values[:, 0, 0], [1.0, 1.0, 1.0, 1.0], rtol=1e-03)
+        np.testing.assert_allclose(values[:, -1, -1], [0.0, 0.0, 0.0, 0.5])
+        assert "enhancement_history" in img.data.attrs
+        assert img.data.attrs["enhancement_history"][-1]["scale"] == 1.0
+        assert img.data.attrs["enhancement_history"][-1]["offset"] == 0.0
+        assert isinstance(img.data.attrs["enhancement_history"][-1]["colormap"], Colormap)
+
+
+class TestXRImagePalettize:
+    """Test the XRImage palettize method."""
+
+    @pytest.mark.parametrize(
+        ("new_range", "input_scale", "input_offset"),
+        [
+            ((0.0, 1.0), 1.0, 0.0),
+            ((0.0, 0.5), 1.0, 0.0),
+            ((2.0, 4.0), 2.0, 2.0),
+        ],
+    )
+    def test_palettize(self, new_range, input_scale, input_offset):
+        """Test palettize with an RGB colormap."""
+        from trollimage import xrimage
+        from trollimage.colormap import brbg
+
+        arr = np.arange(75).reshape(5, 15) / 74. * input_scale + input_offset
+        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
+        img = xrimage.XRImage(data)
+        new_brbg = brbg.set_range(*new_range, inplace=False)
+        img.palettize(new_brbg)
+
+        values = img.data.values
+        expected = np.array([[
+            [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
+            [2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3],
+            [4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5],
+            [6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7],
+            [8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10]]])
+        if new_range[1] == 0.5:
+            flat_expected = expected.reshape((1, 75))
+            expected2 = flat_expected.copy()
+            expected2[:, :38] = flat_expected[:, ::2]
+            expected2[:, 38:] = flat_expected[:, -1:]
+            expected = expected2.reshape((1, 5, 15))
+        np.testing.assert_allclose(values, expected)
+        assert "enhancement_history" in img.data.attrs
+        assert img.data.attrs["enhancement_history"][-1]["scale"] == 0.1
+        assert img.data.attrs["enhancement_history"][-1]["offset"] == 0.0
+
+    def test_palettize_rgba(self):
+        """Test palettize with an RGBA colormap."""
+        from trollimage import xrimage
+        from trollimage.colormap import Colormap
+
+        # RGBA colormap
+        bw = Colormap(
+            (0.0, (1.0, 1.0, 1.0, 1.0)),
+            (1.0, (0.0, 0.0, 0.0, 0.5)),
+        )
+
+        arr = np.arange(75).reshape(5, 15) / 74.
+        data = xr.DataArray(arr.copy(), dims=['y', 'x'])
+        img = xrimage.XRImage(data)
+        img.palettize(bw)
+
+        values = img.data.values
+        assert (1, 5, 15) == values.shape
+        assert (2, 4) == bw.colors.shape
+
+
 class TestXRImageSaveScaleOffset(unittest.TestCase):
     """Test case for saving an image with scale and offset tags."""
 
     def setUp(self) -> None:
         """Set up the test case."""
-        import xarray as xr
         from trollimage import xrimage
         data = xr.DataArray(np.arange(25).reshape(5, 5, 1), dims=[
             'y', 'x', 'bands'], coords={'bands': ['L']})


=====================================
trollimage/version.py
=====================================
@@ -23,9 +23,9 @@ def get_keywords():
     # setup.py/versioneer.py will grep for the variable names, so they must
     # each be defined on a line of their own. _version.py will just call
     # get_keywords().
-    git_refnames = " (HEAD -> main, tag: v1.17.0)"
-    git_full = "cf4dd6eeb6a5151caf83ba5043aa4cd739666171"
-    git_date = "2021-12-07 14:59:27 -0600"
+    git_refnames = " (HEAD -> main, tag: v1.18.0)"
+    git_full = "b5e69f5a2055f3f5f4c9e23305cbdc91171ed7ec"
+    git_date = "2022-02-24 09:00:27 -0600"
     keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
     return keywords
 


=====================================
trollimage/xrimage.py
=====================================
@@ -459,6 +459,7 @@ class XRImage(object):
                  overviews_minsize=256, overviews_resampling=None,
                  include_scale_offset_tags=False,
                  scale_offset_tags=None,
+                 colormap_tag=None,
                  driver=None,
                  **format_kwargs):
         """Save the image using rasterio.
@@ -502,10 +503,16 @@ class XRImage(object):
                 provided. Common values include `nearest` (default),
                 `bilinear`, `average`, and many others. See the rasterio
                 documentation for more information.
-            scale_offset_tags (Tuple[str, str] or None)
+            scale_offset_tags (Tuple[str, str] or None):
                 If set to a ``(str, str)`` tuple, scale and offset will be
                 stored in GDALMetaData tags.  Those can then be used to
                 retrieve the original data values from pixel values.
+            colormap_tag (str or None):
+                If set and the image was colorized or palettized, a tag will
+                be added with this name with the value of a comma-separated
+                version of the Colormap that was used. See
+                :meth:`trollimage.colormap.Colormap.to_csv` for more
+                information.
 
         Returns:
             The delayed or computed result of the saving.
@@ -588,6 +595,8 @@ class XRImage(object):
         elif driver == 'JPEG' and 'A' in mode:
             raise ValueError('JPEG does not support alpha')
 
+        if colormap_tag and "colormap" in data.attrs.get('enhancement_history', [{}])[-2]:
+            tags[colormap_tag] = data.attrs['enhancement_history'][-2]['colormap'].to_csv()
         if scale_offset_tags:
             scale_label, offset_label = scale_offset_tags
             scale, offset = self.get_scaling_from_history(data.attrs.get('enhancement_history', []))
@@ -1500,6 +1509,15 @@ class XRImage(object):
         attrs = self.data.attrs
         dims = self.data.dims
         self.data = xr.DataArray(new_data, coords=coords, attrs=attrs, dims=dims)
+        cmap_min = colormap.values[0]
+        cmap_max = colormap.values[-1]
+        scale_factor = 1.0 / (cmap_max - cmap_min)
+        offset = -cmap_min * scale_factor
+        self.data.attrs.setdefault('enhancement_history', []).append({
+            'scale': scale_factor,
+            'offset': offset,
+            'colormap': colormap,
+        })
 
     def palettize(self, colormap):
         """Palettize the current image using ``colormap``.
@@ -1537,6 +1555,17 @@ class XRImage(object):
 
         self.data.data = new_data
         self.data.coords['bands'] = list(mode)
+        # the data values are now indexes into the palette array of colors
+        # so it can't be more than the maximum index (len - 1)
+        palette_num_values = len(self.palette) - 1
+        scale_factor = 1.0 / palette_num_values
+        offset = 0
+
+        self.data.attrs.setdefault('enhancement_history', []).append({
+            'scale': scale_factor,
+            'offset': offset,
+            'colormap': colormap,
+        })
 
     def blend(self, src):
         r"""Alpha blend *src* on top of the current image.



View it on GitLab: https://salsa.debian.org/debian-gis-team/trollimage/-/commit/675756ccdb4995dd8f58f138143d79b81db53f67

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/trollimage/-/commit/675756ccdb4995dd8f58f138143d79b81db53f67
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20220225/4abfb793/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list