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

Antonio Valentino gitlab at salsa.debian.org
Sun Mar 21 12:12:49 GMT 2021



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


Commits:
337c6a61 by Antonio Valentino at 2021-03-21T11:37:45+00:00
New upstream version 1.15.0
- - - - -


11 changed files:

- + .github/workflows/ci.yaml
- + .github/workflows/deploy-sdist.yaml
- .travis.yml
- CHANGELOG.md
- README.rst
- − appveyor.yml
- + continuous_integration/environment.yaml
- doc/colormap.rst
- trollimage/tests/test_image.py
- trollimage/version.py
- trollimage/xrimage.py


Changes:

=====================================
.github/workflows/ci.yaml
=====================================
@@ -0,0 +1,74 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ${{ matrix.os }}
+    continue-on-error: ${{ matrix.experimental }}
+    strategy:
+      fail-fast: true
+      matrix:
+        os: ["windows-latest", "ubuntu-latest", "macos-latest"]
+        python-version: ["3.7", "3.8"]
+        experimental: [false]
+        include:
+          - python-version: "3.8"
+            os: "ubuntu-latest"
+            experimental: true
+
+    env:
+      PYTHON_VERSION: ${{ matrix.python-version }}
+      OS: ${{ matrix.os }}
+      UNSTABLE: ${{ matrix.experimental }}
+      ACTIONS_ALLOW_UNSECURE_COMMANDS: true
+
+    steps:
+      - name: Checkout source
+        uses: actions/checkout at v2
+
+      - name: Setup Conda Environment
+        uses: conda-incubator/setup-miniconda at v2
+        with:
+          miniconda-version: "latest"
+          python-version: ${{ matrix.python-version }}
+          mamba-version: "*"
+          channels: conda-forge
+          channel-priority: strict
+          environment-file: continuous_integration/environment.yaml
+          activate-environment: test-environment
+
+      - name: Install unstable dependencies
+        if: matrix.experimental == true
+        shell: bash -l {0}
+        run: |
+          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/dask/distributed \
+          git+https://github.com/Unidata/cftime \
+          git+https://github.com/mapbox/rasterio \
+          git+https://github.com/pydata/bottleneck \
+          git+https://github.com/pydata/xarray;
+
+      - name: Install trollimage
+        shell: bash -l {0}
+        run: |
+          pip install --no-deps -e .
+
+      - name: Run unit tests
+        shell: bash -l {0}
+        run: |
+          pytest --cov=trollimage trollimage/tests --cov-report=xml
+
+      - name: Upload unittest coverage to Codecov
+        uses: codecov/codecov-action at v1
+        with:
+          flags: unittests
+          file: ./coverage.xml
+          env_vars: OS,PYTHON_VERSION,UNSTABLE


=====================================
.github/workflows/deploy-sdist.yaml
=====================================
@@ -0,0 +1,25 @@
+name: Deploy sdist
+
+on:
+  release:
+    types:
+      - published
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout source
+        uses: actions/checkout at v2
+
+      - name: Create sdist
+        shell: bash -l {0}
+        run: python setup.py sdist
+
+      - name: Publish package to PyPI
+        if: github.event.action == 'published'
+        uses: pypa/gh-action-pypi-publish at v1.4.1
+        with:
+          user: __token__
+          password: ${{ secrets.pypi_password }}
\ No newline at end of file


=====================================
.travis.yml
=====================================
@@ -47,17 +47,17 @@ install:
   - 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
+#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,27 @@
+## Version 1.15.0 (2021/03/12)
+
+### Issues Closed
+
+* [Issue 74](https://github.com/pytroll/trollimage/issues/74) - MNT: Stop using ci-helpers in appveyor.yml
+
+In this release 1 issue was closed.
+
+### Pull Requests Merged
+
+#### Bugs fixed
+
+* [PR 78](https://github.com/pytroll/trollimage/pull/78) - Fix list stretch tags
+* [PR 77](https://github.com/pytroll/trollimage/pull/77) - Remove defaults channel from ci conda environment and strict priority
+
+#### Features added
+
+* [PR 76](https://github.com/pytroll/trollimage/pull/76) - Add GitHub Actions for CI tests and sdist deployment
+* [PR 75](https://github.com/pytroll/trollimage/pull/75) - Change XRImage.save to keep fill_value separate from valid data
+* [PR 73](https://github.com/pytroll/trollimage/pull/73) - Refactor finalize method in XRImage class
+
+In this release 5 pull requests were closed.
+
+
 ## Version 1.14.0 (2020/09/18)
 
 


=====================================
README.rst
=====================================
@@ -5,13 +5,13 @@ Trollimage
     :target: https://pypi.python.org/pypi/trollimage/
     :alt: Version
 
-.. image:: https://travis-ci.org/pytroll/trollimage.svg?branch=master
-    :target: https://travis-ci.org/pytroll/trollimage
-    :alt: Travis CI
+.. image:: https://anaconda.org/conda-forge/trollimage/badges/version.svg
+   :target: https://anaconda.org/conda-forge/trollimage/
+   :alt: Conda-forge
 
-.. image:: https://ci.appveyor.com/api/projects/status/9ux7hgi8rry971fn/branch/master?svg=true
-    :target: https://ci.appveyor.com/project/pytroll/trollimage
-    :alt: Appveyor
+.. image:: https://github.com/pytroll/trollimage/workflows/CI/badge.svg?branch=master
+    :target: https://github.com/pytroll/trollimage/actions?query=workflow%3A%22CI%22
+    :alt: GitHub Actions
 
 .. image:: https://coveralls.io/repos/pytroll/trollimage/badge.png?branch=master
     :target: https://coveralls.io/r/pytroll/trollimage?branch=master


=====================================
appveyor.yml deleted
=====================================
@@ -1,43 +0,0 @@
-environment:
-  global:
-    PYTHON: "C:\\conda"
-    MINICONDA_VERSION: "latest"
-    CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\ci-helpers\\appveyor\\windows_sdk.cmd"
-    CONDA_DEPENDENCIES: "pillow gdal xarray dask coverage coveralls codecov rasterio pytest pytest-cov"
-    CONDA_CHANNELS: "conda-forge"
-    CONDA_CHANNEL_PRIORITY: "True"
-
-  matrix:
-    - PYTHON: "C:\\Python37_64"
-      PYTHON_VERSION: "3.7"
-      PYTHON_ARCH: "64"
-      NUMPY_VERSION: "stable"
-
-    - PYTHON: "C:\\Python38_64"
-      PYTHON_VERSION: "3.8"
-      PYTHON_ARCH: "64"
-      NUMPY_VERSION: "stable"
-
-install:
-    - "git clone --depth 1 git://github.com/astropy/ci-helpers.git"
-    - "powershell ci-helpers/appveyor/install-miniconda.ps1"
-    - "conda activate test"
-    - "pip install -e ."
-
-build: false  # Not a C# project, build stuff at the test step instead.
-
-test_script:
-  - "%CMD_IN_ENV% pytest --cov=trollimage trollimage/tests"
-
-after_test:
-  # If tests are successful, create a whl package for the project.
-  - "%CMD_IN_ENV% python setup.py bdist_wheel bdist_wininst"
-  - ps: "ls dist"
-
-artifacts:
-  # Archive the generated wheel package in the ci.appveyor.com build report.
-  - path: dist\*
-
-#on_success:
-#  - TODO: upload the content of dist/*.whl to a public wheelhouse
-#


=====================================
continuous_integration/environment.yaml
=====================================
@@ -0,0 +1,20 @@
+name: test-environment
+channels:
+  - conda-forge
+dependencies:
+  - xarray
+  - dask
+  - distributed
+  - toolz
+  - Cython
+  - sphinx
+  - pillow
+  - coveralls
+  - coverage
+  - codecov
+  - rasterio
+  - libtiff
+  - pytest
+  - pytest-cov
+  - fsspec
+  - pip


=====================================
doc/colormap.rst
=====================================
@@ -11,10 +11,10 @@ A simple example of applying a colormap on data::
     from trollimage.image import Image
 
     img = Image(data, mode="L")
-    
+
     rdbu.set_range(-90 + 273.15, 30 + 273.15)
     img.colorize(rdbu)
-    
+
     img.show()
 
 .. image:: _static/hayan_simple.png
@@ -29,7 +29,7 @@ A more complex example, with a colormap build from greyscale on one end, and spe
     from trollimage.image import Image
 
     img = Image(data, mode="L")
-    
+
     greys.set_range(-40 + 273.15, 30 + 273.15)
     spectral.set_range(-90 + 273.15, -40.00001 + 273.15)
     my_cm = spectral + greys
@@ -46,10 +46,10 @@ Now applying a palette to the data, with sharp edges::
     from trollimage.image import Image
 
     img = Image(data, mode="L")
-    
+
     set3.set_range(-90 + 273.15, 30 + 273.15)
     img.palettize(set3)
-    
+
     img.show()
 
 .. image:: _static/phayan.png
@@ -274,9 +274,10 @@ pastel2
 Rainbow Colormap
 ~~~~~~~~~~~~~~~~
 
-Don't use this one ! See here_ why
+Don't use this one ! See here_ and there_ why
 
-.. _here: http://data3.mprog.nl/course/15%20Readings/40%20Reading%204/Borland_Rainbow_Color_Map.pdf
+.. _here: https://www.nature.com/articles/s41467-020-19160-7
+.. _there: https://doi.org/10.1109/MCG.2007.323435
 
 rainbow
 


=====================================
trollimage/tests/test_image.py
=====================================
@@ -32,6 +32,7 @@ from collections import OrderedDict
 from tempfile import NamedTemporaryFile
 
 import numpy as np
+import pytest
 from trollimage import image
 
 EPSILON = 0.0001
@@ -316,7 +317,7 @@ class TestImageCreation(unittest.TestCase):
 
 
 class TestRegularImage(unittest.TestCase):
-    """Class for testing the mpop.imageo.image module."""
+    """Class for testing the image module."""
 
     def setUp(self):
         """Set up the test case."""
@@ -714,7 +715,7 @@ def random_string(length,
                     for dummy in range(length)])
 
 
-class TestXRImage(unittest.TestCase):
+class TestXRImage:
     """Test XRImage objects."""
 
     def test_init(self):
@@ -723,32 +724,32 @@ class TestXRImage(unittest.TestCase):
         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)
-        self.assertEqual(img.mode, 'L')
+        assert img.mode == 'L'
 
         data = xr.DataArray([[0, 0.5, 0.5], [0.5, 0.25, 0.25]])
         img = xrimage.XRImage(data)
-        self.assertEqual(img.mode, 'L')
-        self.assertTupleEqual(img.data.dims, ('bands', 'y', 'x'))
+        assert img.mode == 'L'
+        assert img.data.dims == ('bands', 'y', 'x')
 
         data = xr.DataArray([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], dims=['x', 'y_2'])
         img = xrimage.XRImage(data)
-        self.assertEqual(img.mode, 'L')
-        self.assertTupleEqual(img.data.dims, ('bands', 'x', 'y'))
+        assert img.mode == 'L'
+        assert img.data.dims == ('bands', 'x', 'y')
 
         data = xr.DataArray([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], dims=['x_2', 'y'])
         img = xrimage.XRImage(data)
-        self.assertEqual(img.mode, 'L')
-        self.assertTupleEqual(img.data.dims, ('bands', 'x', 'y'))
+        assert img.mode == 'L'
+        assert img.data.dims == ('bands', 'x', 'y')
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertEqual(img.mode, 'RGB')
+        assert img.mode == 'RGB'
 
         data = xr.DataArray(np.arange(100).reshape(5, 5, 4), dims=[
             'y', 'x', 'bands'], coords={'bands': ['Y', 'Cb', 'Cr', 'A']})
         img = xrimage.XRImage(data)
-        self.assertEqual(img.mode, 'YCbCrA')
+        assert img.mode == 'YCbCrA'
 
     def test_init_writability(self):
         """Test data is writable after init.
@@ -757,11 +758,10 @@ class TestXRImage(unittest.TestCase):
 
         """
         import xarray as xr
-        import numpy as np
         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)
-        self.assertEqual(img.mode, 'L')
+        assert img.mode == 'L'
         n_arr = np.asarray(img.data)
         # if this succeeds then its writable
         n_arr[n_arr == 0.5] = 1
@@ -777,80 +777,146 @@ class TestXRImage(unittest.TestCase):
             img = xrimage.XRImage(data)
 
             img.save(filename='bla.png', fformat='png', format='png')
-            self.assertNotIn('format', pil_save.call_args_list[0][1])
+            assert 'format' not in pil_save.call_args_list[0][1]
 
-    @unittest.skipIf(sys.platform.startswith('win'),
-                     "'NamedTemporaryFile' not supported on Windows")
-    def test_save(self):
-        """Test saving."""
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        reason="'NamedTemporaryFile' not supported on Windows")
+    def test_rgb_save(self):
+        """Test saving RGB/A data to simple image formats."""
         import xarray as xr
-        import dask.array as da
         from dask.delayed import Delayed
         from trollimage import xrimage
-        from trollimage.colormap import brbg, 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)),
-        )
+        import rasterio as rio
 
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3) / 74., dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
         with NamedTemporaryFile(suffix='.png') as tmp:
             img.save(tmp.name)
+            with rio.open(tmp.name) as f:
+                file_data = f.read()
+            assert file_data.shape == (4, 5, 5)  # alpha band added
+            exp = (np.arange(75.).reshape(5, 5, 3) / 74. * 255).round()
+            np.testing.assert_allclose(file_data[0], exp[:, :, 0])
+            np.testing.assert_allclose(file_data[1], exp[:, :, 1])
+            np.testing.assert_allclose(file_data[2], exp[:, :, 2])
+            np.testing.assert_allclose(file_data[3], 255)  # completely opaque
+
+        data = data.where(data > (10 / 74.0))
+        img = xrimage.XRImage(data)
+        with NamedTemporaryFile(suffix='.png') as tmp:
+            img.save(tmp.name)
+
+        # dask delayed save
+        with NamedTemporaryFile(suffix='.png') as tmp:
+            delay = img.save(tmp.name, compute=False)
+            assert isinstance(delay, Delayed)
+            delay.compute()
+
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        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 = xr.DataArray(np.arange(75).reshape(15, 5, 1) / 74., dims=[
+        data = np.arange(75).reshape(15, 5, 1) / 74.
+        data[-1, -1, 0] = np.nan
+        data = xr.DataArray(data, dims=[
             'y', 'x', 'bands'], coords={'bands': ['L']})
         # Single band image to JPEG
         img = xrimage.XRImage(data)
         with NamedTemporaryFile(suffix='.jpg') as tmp:
             img.save(tmp.name, fill_value=0)
+            with rio.open(tmp.name) as f:
+                file_data = f.read()
+            assert file_data.shape == (1, 15, 5)
+            # can't check data accuracy because jpeg compression will
+            # change the values
+
         # Jpeg fails without fill value (no alpha handling)
         with NamedTemporaryFile(suffix='.jpg') as tmp:
             # make sure fill_value is mentioned in the error message
-            self.assertRaisesRegex(OSError, "fill_value", img.save, tmp.name)
-        # As PNG that support alpha channel
-        img = xrimage.XRImage(data)
-        with NamedTemporaryFile(suffix='.png') as tmp:
-            img.save(tmp.name)
+            with pytest.raises(OSError, match=r".*fill_value.*"):
+                img.save(tmp.name)
 
-        # Single band image palettized
-        data = xr.DataArray(np.arange(75).reshape(15, 5, 1) / 74., dims=[
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        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.
+        data[-1, -1, 0] = np.nan
+        data = xr.DataArray(data, dims=[
             'y', 'x', 'bands'], coords={'bands': ['L']})
         # Single band image to JPEG
         img = xrimage.XRImage(data)
-        img.palettize(brbg)
+
+        # Single band image to PNG - min fill (check fill value scaling)
         with NamedTemporaryFile(suffix='.png') as tmp:
-            img.save(tmp.name)
-        # RGBA colormap
-        img = xrimage.XRImage(data)
-        img.palettize(bw)
+            img.save(tmp.name, fill_value=0)
+            with rio.open(tmp.name) as f:
+                file_data = f.read()
+            assert file_data.shape == (1, 15, 5)
+            exp = (np.arange(75.).reshape(1, 15, 5) / 74. * 254 + 1).round()
+            exp[0, -1, -1] = 0
+            np.testing.assert_allclose(file_data, exp)
+
+        # Single band image to PNG - max fill (check fill value scaling)
         with NamedTemporaryFile(suffix='.png') as tmp:
-            img.save(tmp.name)
+            img.save(tmp.name, fill_value=255)
+            with rio.open(tmp.name) as f:
+                file_data = f.read()
+            assert file_data.shape == (1, 15, 5)
+            exp = (np.arange(75.).reshape(1, 15, 5) / 74. * 254).round()
+            exp[0, -1, -1] = 255
+            np.testing.assert_allclose(file_data, exp)
 
-        data = xr.DataArray(da.from_array(np.arange(75).reshape(5, 5, 3) / 74.,
-                                          chunks=5),
-                            dims=['y', 'x', 'bands'],
-                            coords={'bands': ['R', 'G', 'B']})
-        img = xrimage.XRImage(data)
+        # As PNG that support alpha channel
         with NamedTemporaryFile(suffix='.png') as tmp:
             img.save(tmp.name)
+            with rio.open(tmp.name) as f:
+                file_data = f.read()
+            assert file_data.shape == (2, 15, 5)
+            # bad value should be transparent in alpha channel
+            assert file_data[1, -1, -1] == 0
+            # all other pixels should be opaque
+            assert file_data[1, 0, 0] == 255
+
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        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
 
-        data = data.where(data > (10 / 74.0))
+        # Single band image palettized
+        from trollimage.colormap import brbg, Colormap
+        data = xr.DataArray(np.arange(75).reshape(15, 5, 1) / 74., dims=[
+            'y', 'x', 'bands'], coords={'bands': ['L']})
         img = xrimage.XRImage(data)
+        img.palettize(brbg)
         with NamedTemporaryFile(suffix='.png') as tmp:
             img.save(tmp.name)
+        img = xrimage.XRImage(data)
+        # RGBA colormap
+        bw = Colormap(
+            (0.0, (1.0, 1.0, 1.0, 1.0)),
+            (1.0, (0.0, 0.0, 0.0, 0.5)),
+        )
 
-        # dask delayed save
+        img.palettize(bw)
         with NamedTemporaryFile(suffix='.png') as tmp:
-            delay = img.save(tmp.name, compute=False)
-            self.assertIsInstance(delay, Delayed)
-            delay.compute()
+            img.save(tmp.name)
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_geotiff_float(self):
         """Test saving geotiffs when input data is float."""
         import xarray as xr
@@ -867,7 +933,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = (np.arange(75.).reshape(5, 5, 3) / 75. * 255).round()
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
@@ -883,7 +949,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = (np.arange(75.).reshape(5, 5, 3) / 75. * 255).round()
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
@@ -897,7 +963,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp[exp <= 10. / 75.] = 0  # numpy converts NaNs to 0s
             exp = (exp * 255).round()
@@ -913,7 +979,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name, fill_value=128)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp2 = (exp * 255).round()
             exp2[exp <= 10. / 75.] = 128
@@ -926,7 +992,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name, dtype=np.float32)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             # fill value is forced to 0
             exp[exp <= 10. / 75.] = 0
@@ -939,7 +1005,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name, dtype=np.float32, fill_value=np.nan)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp[exp <= 10. / 75.] = np.nan
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
@@ -951,7 +1017,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name, dtype=np.float32, fill_value=128)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp[exp <= 10. / 75.] = 128
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
@@ -963,7 +1029,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name, dtype=np.int16, fill_value=-128)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp2 = (exp * (2 ** 16 - 1) - (2 ** 15)).round()
             exp2[exp <= 10. / 75.] = -128.
@@ -974,9 +1040,9 @@ class TestXRImage(unittest.TestCase):
         # dask delayed save
         with NamedTemporaryFile(suffix='.tif') as tmp:
             delay = img.save(tmp.name, compute=False)
-            self.assertIsInstance(delay, tuple)
-            self.assertIsInstance(delay[0], da.Array)
-            self.assertIsInstance(delay[1], xrimage.RIODataset)
+            assert isinstance(delay, tuple)
+            assert isinstance(delay[0], da.Array)
+            assert isinstance(delay[1], xrimage.RIODataset)
             da.store(*delay)
             delay[1].close()
 
@@ -991,7 +1057,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band already existed
+            assert file_data.shape == (4, 5, 5)  # alpha band already existed
             exp = np.arange(75.).reshape(5, 5, 3) / 75.
             exp[exp <= 10. / 75.] = 0  # numpy converts NaNs to 0s
             exp = (exp * 255.).round()
@@ -1002,7 +1068,8 @@ class TestXRImage(unittest.TestCase):
             np.testing.assert_allclose(file_data[3][not_null], 255)  # completely opaque
             np.testing.assert_allclose(file_data[3][~not_null], 0)  # completely transparent
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        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
@@ -1021,7 +1088,8 @@ class TestXRImage(unittest.TestCase):
         tags = _get_tags_after_writing_to_geotiff(data)
         assert "TIFFTAG_DATETIME" in tags
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @pytest.mark.skipif(sys.platform.startswith('win'),
+                        reason="'NamedTemporaryFile' not supported on Windows")
     def test_save_geotiff_int(self):
         """Test saving geotiffs when input data is int."""
         import xarray as xr
@@ -1034,12 +1102,12 @@ class TestXRImage(unittest.TestCase):
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = np.arange(75).reshape(5, 5, 3)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
@@ -1050,7 +1118,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = np.arange(75).reshape(5, 5, 3)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
@@ -1061,13 +1129,13 @@ class TestXRImage(unittest.TestCase):
                             dims=['y', 'x', 'bands'],
                             coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         # Regular default save
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = np.arange(75).reshape(5, 5, 3)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
@@ -1077,9 +1145,9 @@ class TestXRImage(unittest.TestCase):
         # dask delayed save
         with NamedTemporaryFile(suffix='.tif') as tmp:
             delay = img.save(tmp.name, compute=False)
-            self.assertIsInstance(delay, tuple)
-            self.assertIsInstance(delay[0], da.Array)
-            self.assertIsInstance(delay[1], xrimage.RIODataset)
+            assert isinstance(delay, tuple)
+            assert isinstance(delay[0], da.Array)
+            assert isinstance(delay[1], xrimage.RIODataset)
             da.store(*delay)
             delay[1].close()
 
@@ -1113,12 +1181,12 @@ class TestXRImage(unittest.TestCase):
             with rio.open(tmp.name) as f:
                 fgcps, fcrs = f.gcps
             for ref, val in zip(gcps, fgcps):
-                self.assertEqual(ref.col, val.col)
-                self.assertEqual(ref.row, val.row)
-                self.assertEqual(ref.x, val.x)
-                self.assertEqual(ref.y, val.y)
-                self.assertEqual(ref.z, val.z)
-            self.assertEqual(crs, fcrs)
+                assert ref.col == val.col
+                assert ref.row == val.row
+                assert ref.x == val.x
+                assert ref.y == val.y
+                assert ref.z == val.z
+            assert crs == fcrs
 
         # with rasterio colormap provided
         exp_cmap = {i: (i, 255 - i, i, 255) for i in range(256)}
@@ -1131,10 +1199,10 @@ class TestXRImage(unittest.TestCase):
             with rio.open(tmp.name) as f:
                 file_data = f.read()
                 cmap = f.colormap(1)
-            self.assertEqual(file_data.shape, (1, 9, 9))  # no alpha band
+            assert file_data.shape == (1, 9, 9)  # no alpha band
             exp = np.arange(81).reshape(9, 9, 1)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
-            self.assertEqual(cmap, exp_cmap)
+            assert cmap == exp_cmap
 
         # with trollimage colormap provided
         from trollimage.colormap import Colormap
@@ -1150,10 +1218,10 @@ class TestXRImage(unittest.TestCase):
             with rio.open(tmp.name) as f:
                 file_data = f.read()
                 cmap = f.colormap(1)
-            self.assertEqual(file_data.shape, (1, 9, 9))  # no alpha band
+            assert file_data.shape == (1, 9, 9)  # no alpha band
             exp = np.arange(81).reshape(9, 9, 1)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
-            self.assertEqual(cmap, exp_cmap)
+            assert cmap == exp_cmap
 
         # with bad colormap provided
         bad_cmap = [[i, [i, i, i]] for i in range(256)]
@@ -1162,10 +1230,11 @@ class TestXRImage(unittest.TestCase):
                             coords={'bands': ['P']})
         img = xrimage.XRImage(data)
         with NamedTemporaryFile(suffix='.tif') as tmp:
-            self.assertRaises(ValueError, img.save, tmp.name,
-                              keep_palette=True, cmap=bad_cmap)
-            self.assertRaises(ValueError, img.save, tmp.name,
-                              keep_palette=True, cmap=t_cmap, dtype='uint16')
+            with pytest.raises(ValueError):
+                img.save(tmp.name, keep_palette=True, cmap=bad_cmap)
+            with pytest.raises(ValueError):
+                img.save(tmp.name, keep_palette=True, cmap=t_cmap,
+                         dtype='uint16')
 
         # with input fill value
         data = np.arange(75).reshape(5, 5, 3)
@@ -1177,12 +1246,12 @@ class TestXRImage(unittest.TestCase):
                             attrs={'_FillValue': 5},
                             coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name, fill_value=128)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (3, 5, 5))  # no alpha band
+            assert file_data.shape == (3, 5, 5)  # no alpha band
             exp = np.arange(75).reshape(5, 5, 3)
             exp[0, 1, :] = 128
             exp[0, 1, 1] = 128
@@ -1195,7 +1264,7 @@ class TestXRImage(unittest.TestCase):
             img.save(tmp.name)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # no alpha band
+            assert file_data.shape == (4, 5, 5)  # no alpha band
             exp = np.arange(75).reshape(5, 5, 3)
             exp[0, 1, :] = 5
             exp[0, 1, 1] = 5
@@ -1206,7 +1275,7 @@ class TestXRImage(unittest.TestCase):
             np.testing.assert_allclose(file_data[2], exp[:, :, 2])
             np.testing.assert_allclose(file_data[3], exp_alpha)
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @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
@@ -1217,19 +1286,19 @@ class TestXRImage(unittest.TestCase):
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.jp2') as tmp:
             img.save(tmp.name, quality=100, reversible=True)
             with rio.open(tmp.name) as f:
                 file_data = f.read()
-            self.assertEqual(file_data.shape, (4, 5, 5))  # alpha band added
+            assert file_data.shape == (4, 5, 5)  # alpha band added
             exp = np.arange(75).reshape(5, 5, 3)
             np.testing.assert_allclose(file_data[0], exp[:, :, 0])
             np.testing.assert_allclose(file_data[1], exp[:, :, 1])
             np.testing.assert_allclose(file_data[2], exp[:, :, 2])
             np.testing.assert_allclose(file_data[3], 255)
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @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
@@ -1240,37 +1309,37 @@ class TestXRImage(unittest.TestCase):
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name, overviews=[2, 4])
             with rio.open(tmp.name) as f:
-                self.assertEqual(len(f.overviews(1)), 2)
+                assert len(f.overviews(1)) == 2
 
         # auto-levels
         data = np.zeros(25*25*3, dtype=np.uint8).reshape(25, 25, 3)
         data = xr.DataArray(data, dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name, overviews=[], overviews_minsize=2)
             with rio.open(tmp.name) as f:
-                self.assertEqual(len(f.overviews(1)), 4)
+                assert len(f.overviews(1)) == 4
 
         # auto-levels and resampling
         data = np.zeros(25*25*3, dtype=np.uint8).reshape(25, 25, 3)
         data = xr.DataArray(data, dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name, overviews=[], overviews_minsize=2,
                      overviews_resampling='average')
             with rio.open(tmp.name) as f:
                 # no way to check resampling method from the file
-                self.assertEqual(len(f.overviews(1)), 4)
+                assert len(f.overviews(1)) == 4
 
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
+    @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
@@ -1282,31 +1351,12 @@ class TestXRImage(unittest.TestCase):
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
         tags = {'avg': img.data.mean(), 'current_song': 'disco inferno'}
-        self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+        assert np.issubdtype(img.data.dtype, np.integer)
         with NamedTemporaryFile(suffix='.tif') as tmp:
             img.save(tmp.name, tags=tags)
             tags['avg'] = '37.0'
             with rio.open(tmp.name) as f:
-                self.assertEqual(f.tags(), tags)
-
-    @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows")
-    def test_save_scale_offset(self):
-        """Test saving geotiffs with tags."""
-        import xarray as xr
-        from trollimage import xrimage
-        import rasterio as rio
-
-        data = xr.DataArray(np.arange(25).reshape(5, 5, 1), dims=[
-            'y', 'x', 'bands'], coords={'bands': ['L']})
-        img = xrimage.XRImage(data)
-        img.stretch()
-        with NamedTemporaryFile(suffix='.tif') as tmp:
-            img.save(tmp.name, include_scale_offset_tags=True)
-            tags = {'scale': 24.0 / 255, 'offset': 0}
-            with rio.open(tmp.name) as f:
-                ftags = f.tags()
-                for key, val in tags.items():
-                    self.assertAlmostEqual(float(ftags[key]), val)
+                assert f.tags() == tags
 
     def test_gamma(self):
         """Test gamma correction."""
@@ -1318,12 +1368,12 @@ class TestXRImage(unittest.TestCase):
                             coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
         img.gamma(.5)
-        self.assertTrue(np.allclose(img.data.values, arr ** 2))
-        self.assertDictEqual(img.data.attrs['enhancement_history'][0], {'gamma': 0.5})
+        assert np.allclose(img.data.values, arr ** 2)
+        assert img.data.attrs['enhancement_history'][0] == {'gamma': 0.5}
 
         img.gamma([2., 2., 2.])
-        self.assertEqual(len(img.data.attrs['enhancement_history']), 2)
-        self.assertTrue(np.allclose(img.data.values, arr))
+        assert len(img.data.attrs['enhancement_history']) == 2
+        assert np.allclose(img.data.values, arr)
 
     def test_crude_stretch(self):
         """Check crude stretching."""
@@ -1366,8 +1416,8 @@ class TestXRImage(unittest.TestCase):
 
         img.invert(True)
         enhs = img.data.attrs['enhancement_history'][0]
-        self.assertDictEqual(enhs, {'scale': -1, 'offset': 1})
-        self.assertTrue(np.allclose(img.data.values, 1 - arr))
+        assert enhs == {'scale': -1, 'offset': 1}
+        assert np.allclose(img.data.values, 1 - arr)
 
         data = xr.DataArray(arr.copy(), dims=['y', 'x', 'bands'],
                             coords={'bands': ['R', 'G', 'B']})
@@ -1378,7 +1428,7 @@ class TestXRImage(unittest.TestCase):
                               coords={'bands': ['R', 'G', 'B']})
         scale = xr.DataArray(np.array([-1, 1, -1]), dims=['bands'],
                              coords={'bands': ['R', 'G', 'B']})
-        self.assertTrue(np.allclose(img.data.values, (data * scale + offset).values))
+        np.testing.assert_allclose(img.data.values, (data * scale + offset).values)
 
     def test_linear_stretch(self):
         """Test linear stretching with cutoffs."""
@@ -1419,7 +1469,7 @@ class TestXRImage(unittest.TestCase):
                          [0.962963, 0.962963, 0.962963],
                          [1.005051, 1.005051, 1.005051]]])
 
-        self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6))
+        np.testing.assert_allclose(img.data.values, res, atol=1.e-6)
 
     def test_histogram_stretch(self):
         """Test histogram stretching."""
@@ -1432,7 +1482,7 @@ class TestXRImage(unittest.TestCase):
         img = xrimage.XRImage(data)
         img.stretch('histogram')
         enhs = img.data.attrs['enhancement_history'][0]
-        self.assertDictEqual(enhs, {'hist_equalize': True})
+        assert enhs == {'hist_equalize': True}
         res = np.array([[[0., 0., 0.],
                          [0.04166667, 0.04166667, 0.04166667],
                          [0.08333333, 0.08333333, 0.08333333],
@@ -1463,7 +1513,7 @@ class TestXRImage(unittest.TestCase):
                          [0.95833333, 0.95833333, 0.95833333],
                          [0.99951172, 0.99951172, 0.99951172]]])
 
-        self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6))
+        np.testing.assert_allclose(img.data.values, res, atol=1.e-6)
 
     def test_logarithmic_stretch(self):
         """Test logarithmic strecthing."""
@@ -1476,7 +1526,7 @@ class TestXRImage(unittest.TestCase):
         img = xrimage.XRImage(data)
         img.stretch(stretch='logarithmic')
         enhs = img.data.attrs['enhancement_history'][0]
-        self.assertDictEqual(enhs, {'log_factor': 100.0})
+        assert enhs == {'log_factor': 100.0}
         res = np.array([[[0., 0., 0.],
                          [0.35484693, 0.35484693, 0.35484693],
                          [0.48307087, 0.48307087, 0.48307087],
@@ -1507,7 +1557,7 @@ class TestXRImage(unittest.TestCase):
                          [0.99085269, 0.99085269, 0.99085269],
                          [1., 1., 1.]]])
 
-        self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6))
+        np.testing.assert_allclose(img.data.values, res, atol=1.e-6)
 
     def test_weber_fechner_stretch(self):
         """Test applying S=2.3klog10I+C to the data."""
@@ -1520,7 +1570,7 @@ class TestXRImage(unittest.TestCase):
         img = xrimage.XRImage(data)
         img.stretch_weber_fechner(2.5, 0.2)
         enhs = img.data.attrs['enhancement_history'][0]
-        self.assertDictEqual(enhs, {'weber_fechner': (2.5, 0.2)})
+        assert enhs == {'weber_fechner': (2.5, 0.2)}
         res = np.array([[[-np.inf, -6.73656795, -5.0037],
                          [-3.99003723, -3.27083205, -2.71297317],
                          [-2.25716928, -1.87179258, -1.5379641],
@@ -1551,7 +1601,7 @@ class TestXRImage(unittest.TestCase):
                          [3.84869831, 3.88467015, 3.92013174],
                          [3.95509735, 3.98958065, 4.02359478]]])
 
-        self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6))
+        np.testing.assert_allclose(img.data.values, res, atol=1.e-6)
 
     def test_jpeg_save(self):
         """Test saving to jpeg."""
@@ -1606,19 +1656,19 @@ class TestXRImage(unittest.TestCase):
 
         img = xrimage.XRImage(dataset1)
         new_img = img.convert(img.mode)
-        self.assertIsNotNone(new_img)
+        assert new_img is not None
         # make sure it is a copy
-        self.assertIsNot(new_img, img)
-        self.assertIsNot(new_img.data, img.data)
+        assert new_img is not img
+        assert new_img.data is not img.data
 
         # L -> LA (int)
         with dask.config.set(scheduler=CustomScheduler(max_computes=1)):
             img = xrimage.XRImage((dataset1 * 150).astype(np.uint8))
             img.data.attrs['_FillValue'] = 0  # set fill value
             img = img.convert('LA')
-            self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
-            self.assertTrue(img.mode == 'LA')
-            self.assertTrue(len(img.data.coords['bands']) == 2)
+            assert np.issubdtype(img.data.dtype, np.integer)
+            assert img.mode == 'LA'
+            assert len(img.data.coords['bands']) == 2
             # make sure the alpha band is all opaque except the first pixel
             alpha = img.data.sel(bands='A').values.ravel()
             np.testing.assert_allclose(alpha[0], 0)
@@ -1628,22 +1678,22 @@ class TestXRImage(unittest.TestCase):
         with dask.config.set(scheduler=CustomScheduler(max_computes=1)):
             img = xrimage.XRImage(dataset1)
             img = img.convert('LA')
-            self.assertTrue(img.mode == 'LA')
-            self.assertTrue(len(img.data.coords['bands']) == 2)
+            assert img.mode == 'LA'
+            assert len(img.data.coords['bands']) == 2
             # make sure the alpha band is all opaque
             np.testing.assert_allclose(img.data.sel(bands='A'), 1.)
 
         # LA -> L (float)
         with dask.config.set(scheduler=CustomScheduler(max_computes=0)):
             img = img.convert('L')
-            self.assertTrue(img.mode == 'L')
-            self.assertTrue(len(img.data.coords['bands']) == 1)
+            assert img.mode == 'L'
+            assert len(img.data.coords['bands']) == 1
 
         # L -> RGB (float)
         with dask.config.set(scheduler=CustomScheduler(max_computes=1)):
             img = img.convert('RGB')
-            self.assertTrue(img.mode == 'RGB')
-            self.assertTrue(len(img.data.coords['bands']) == 3)
+            assert img.mode == 'RGB'
+            assert len(img.data.coords['bands']) == 3
             data = img.data.compute()
             np.testing.assert_allclose(data.sel(bands=['R']), arr1)
             np.testing.assert_allclose(data.sel(bands=['G']), arr1)
@@ -1652,9 +1702,9 @@ class TestXRImage(unittest.TestCase):
         # RGB -> RGBA (float)
         with dask.config.set(scheduler=CustomScheduler(max_computes=1)):
             img = img.convert('RGBA')
-            self.assertTrue(img.mode == 'RGBA')
-            self.assertTrue(len(img.data.coords['bands']) == 4)
-            self.assertTrue(np.issubdtype(img.data.dtype, np.floating))
+            assert img.mode == 'RGBA'
+            assert len(img.data.coords['bands']) == 4
+            assert np.issubdtype(img.data.dtype, np.floating)
             data = img.data.compute()
             np.testing.assert_allclose(data.sel(bands=['R']), arr1)
             np.testing.assert_allclose(data.sel(bands=['G']), arr1)
@@ -1666,11 +1716,11 @@ class TestXRImage(unittest.TestCase):
         with dask.config.set(scheduler=CustomScheduler(max_computes=1)):
             img = xrimage.XRImage((dataset1 * 150).astype(np.uint8))
             img = img.convert('RGB')  # L -> RGB
-            self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+            assert np.issubdtype(img.data.dtype, np.integer)
             img = img.convert('RGBA')
-            self.assertTrue(img.mode == 'RGBA')
-            self.assertTrue(len(img.data.coords['bands']) == 4)
-            self.assertTrue(np.issubdtype(img.data.dtype, np.integer))
+            assert img.mode == 'RGBA'
+            assert len(img.data.coords['bands']) == 4
+            assert np.issubdtype(img.data.dtype, np.integer)
             data = img.data.compute()
             np.testing.assert_allclose(data.sel(bands=['R']), (arr1 * 150).astype(np.uint8))
             np.testing.assert_allclose(data.sel(bands=['G']), (arr1 * 150).astype(np.uint8))
@@ -1682,8 +1732,8 @@ class TestXRImage(unittest.TestCase):
         with dask.config.set(scheduler=CustomScheduler(max_computes=0)):
             img = xrimage.XRImage(dataset2)
             img = img.convert('RGBA')
-            self.assertTrue(img.mode == 'RGBA')
-            self.assertTrue(len(img.data.coords['bands']) == 4)
+            assert img.mode == 'RGBA'
+            assert len(img.data.coords['bands']) == 4
 
         # L -> palettize -> RGBA (float)
         with dask.config.set(scheduler=CustomScheduler(max_computes=0)):
@@ -1692,20 +1742,21 @@ class TestXRImage(unittest.TestCase):
             pal = img.palette
 
             img2 = img.convert('RGBA')
-            self.assertTrue(np.issubdtype(img2.data.dtype, np.floating))
-            self.assertTrue(img2.mode == 'RGBA')
-            self.assertTrue(len(img2.data.coords['bands']) == 4)
+            assert np.issubdtype(img2.data.dtype, np.floating)
+            assert img2.mode == 'RGBA'
+            assert len(img2.data.coords['bands']) == 4
 
         # PA -> RGB (float)
         img = xrimage.XRImage(dataset3)
         img.palette = pal
         with dask.config.set(scheduler=CustomScheduler(max_computes=0)):
             img = img.convert('RGB')
-            self.assertTrue(np.issubdtype(img.data.dtype, np.floating))
-            self.assertTrue(img.mode == 'RGB')
-            self.assertTrue(len(img.data.coords['bands']) == 3)
+            assert np.issubdtype(img.data.dtype, np.floating)
+            assert img.mode == 'RGB'
+            assert len(img.data.coords['bands']) == 3
 
-        self.assertRaises(ValueError, img.convert, 'A')
+        with pytest.raises(ValueError):
+            img.convert('A')
 
         # L -> palettize -> RGBA (float) with RGBA colormap
         with dask.config.set(scheduler=CustomScheduler(max_computes=0)):
@@ -1713,14 +1764,14 @@ class TestXRImage(unittest.TestCase):
             img.palettize(bw)
 
             img2 = img.convert('RGBA')
-            self.assertTrue(np.issubdtype(img2.data.dtype, np.floating))
-            self.assertTrue(img2.mode == 'RGBA')
-            self.assertTrue(len(img2.data.coords['bands']) == 4)
+            assert np.issubdtype(img2.data.dtype, np.floating)
+            assert img2.mode == 'RGBA'
+            assert len(img2.data.coords['bands']) == 4
             # convert to RGB, use RGBA from colormap regardless
             img2 = img.convert('RGB')
-            self.assertTrue(np.issubdtype(img2.data.dtype, np.floating))
-            self.assertTrue(img2.mode == 'RGBA')
-            self.assertTrue(len(img2.data.coords['bands']) == 4)
+            assert np.issubdtype(img2.data.dtype, np.floating)
+            assert img2.mode == 'RGBA'
+            assert len(img2.data.coords['bands']) == 4
 
     def test_final_mode(self):
         """Test final_mode."""
@@ -1731,8 +1782,8 @@ class TestXRImage(unittest.TestCase):
         data = xr.DataArray(np.arange(75).reshape(5, 5, 3), dims=[
             'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']})
         img = xrimage.XRImage(data)
-        self.assertEqual(img.final_mode(None), 'RGBA')
-        self.assertEqual(img.final_mode(0), 'RGB')
+        assert img.final_mode(None) == 'RGBA'
+        assert img.final_mode(0) == 'RGB'
 
     def test_colorize(self):
         """Test colorize with an RGB colormap."""
@@ -1858,7 +1909,7 @@ class TestXRImage(unittest.TestCase):
         img = xrimage.XRImage(data)
         img.colorize(bw)
         values = img.data.compute()
-        self.assertTupleEqual((4, 5, 15), values.shape)
+        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])
 
@@ -1900,8 +1951,8 @@ class TestXRImage(unittest.TestCase):
         img.palettize(bw)
 
         values = img.data.values
-        self.assertTupleEqual((1, 5, 15), values.shape)
-        self.assertTupleEqual((2, 4), bw.colors.shape)
+        assert (1, 5, 15) == values.shape
+        assert (2, 4) == bw.colors.shape
 
     def test_stack(self):
         """Test stack."""
@@ -1969,12 +2020,12 @@ class TestXRImage(unittest.TestCase):
                  [0.5020408,    0.52,       0.5476586,  0.5846154,  0.63027024],
                  [0.683871,     0.7445614,  0.81142855, 0.8835443,  0.96]]))
 
-        with self.assertRaises(TypeError):
+        with pytest.raises(TypeError):
             img1.blend("Salekhard")
 
         wrongimg = xrimage.XRImage(
                 xr.DataArray(np.zeros((0, 0)), dims=("y", "x")))
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             img1.blend(wrongimg)
 
     def test_replace_luminance(self):
@@ -2031,9 +2082,9 @@ class TestXRImage(unittest.TestCase):
             res = img.apply_pil(dummy_fun, 'RGB',
                                 fun_args=('Hey', 'Jude'),
                                 fun_kwargs={'chorus': "La lala lalalala"})
-            self.assertEqual(dummy_args, [({}, ), {}])
+            assert dummy_args == [({}, ), {}]
             res.data.data.compute()
-            self.assertEqual(dummy_args, [(OrderedDict(), 'Hey', 'Jude'), {'chorus': "La lala lalalala"}])
+            assert dummy_args == [(OrderedDict(), 'Hey', 'Jude'), {'chorus': "La lala lalalala"}]
 
         # Test HACK for _burn_overlay
         dummy_args = [(OrderedDict(), ), {}]
@@ -2055,6 +2106,44 @@ class TestXRImage(unittest.TestCase):
             pil_img.convert.assert_called_with('RGB')
 
 
+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']})
+        self.img = xrimage.XRImage(data)
+
+    @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
+    def test_save_scale_offset(self):
+        """Test saving geotiffs with tags."""
+        expected_tags = {'scale': 24.0 / 255, 'offset': 0}
+
+        self.img.stretch()
+        self._save_and_check_tags(expected_tags)
+
+    def _save_and_check_tags(self, expected_tags):
+        with NamedTemporaryFile(suffix='.tif') as tmp:
+            self.img.save(tmp.name, include_scale_offset_tags=True)
+
+            import rasterio as rio
+            with rio.open(tmp.name) as f:
+                ftags = f.tags()
+                for key, val in expected_tags.items():
+                    np.testing.assert_almost_equal(float(ftags[key]), val)
+
+    @pytest.mark.skipif(sys.platform.startswith('win'), reason="'NamedTemporaryFile' not supported on Windows")
+    def test_save_scale_offset_from_lists(self):
+        """Test saving geotiffs with tags that come from lists."""
+        expected_tags = {'scale': 23.0 / 255, 'offset': 1}
+
+        self.img.crude_stretch([1], [24])
+        self._save_and_check_tags(expected_tags)
+
+
 def _get_tags_after_writing_to_geotiff(data):
     from trollimage import xrimage
     import rasterio as rio


=====================================
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 -> master, tag: v1.14.0)"
-    git_full = "301c1a9dc5721bda9547d0acec112902a5800484"
-    git_date = "2020-09-18 11:34:04 +0200"
+    git_refnames = " (HEAD -> master, tag: v1.15.0)"
+    git_full = "8f23f7413b9baf58b0d237e62b9eef73cf409a73"
+    git_date = "2021-03-12 13:57:27 +0100"
     keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
     return keywords
 


=====================================
trollimage/xrimage.py
=====================================
@@ -35,6 +35,7 @@ chunks can be saved in parallel.
 import logging
 import os
 import threading
+import warnings
 from contextlib import suppress
 
 import dask
@@ -414,13 +415,13 @@ class XRImage(object):
         kwformat = format_kwargs.pop('format', None)
         fformat = fformat or kwformat or os.path.splitext(filename)[1][1:]
         if fformat in ('tif', 'tiff', 'jp2') and rasterio:
+
             return self.rio_save(filename, fformat=fformat,
                                  fill_value=fill_value, compute=compute,
                                  keep_palette=keep_palette, cmap=cmap,
                                  **format_kwargs)
-        else:
-            return self.pil_save(filename, fformat, fill_value,
-                                 compute=compute, **format_kwargs)
+        return self.pil_save(filename, fformat, fill_value,
+                             compute=compute, **format_kwargs)
 
     def rio_save(self, filename, fformat=None, fill_value=None,
                  dtype=np.uint8, compute=True, tags=None,
@@ -483,7 +484,7 @@ class XRImage(object):
             tags = {}
 
         data, mode = self.finalize(fill_value, dtype=dtype,
-                                   keep_palette=keep_palette, cmap=cmap)
+                                   keep_palette=keep_palette)
         data = data.transpose('bands', 'y', 'x')
 
         crs = None
@@ -564,14 +565,7 @@ class XRImage(object):
             except AttributeError:
                 raise ValueError("Colormap is not formatted correctly")
 
-        da_tags = []
-        for key, val in list(tags.items()):
-            try:
-                if isinstance(val.data, da.Array):
-                    da_tags.append((val.data, RIOTag(r_file, key)))
-                    tags.pop(key)
-            except AttributeError:
-                continue
+        tags, da_tags = self._split_regular_vs_lazy_tags(tags, r_file)
 
         r_file.rfile.update_tags(**tags)
         r_dataset = RIODataset(r_file, overviews,
@@ -596,6 +590,21 @@ class XRImage(object):
         # closing the file
         return to_store
 
+    @staticmethod
+    def _split_regular_vs_lazy_tags(tags, r_file):
+        """Split tags into regular vs lazy (dask) tags."""
+        da_tags = []
+        for key, val in list(tags.items()):
+            try:
+                if isinstance(val.data, da.Array):
+                    da_tags.append((val.data, RIOTag(r_file, key)))
+                    tags.pop(key)
+                else:
+                    tags[key] = val.item()
+            except AttributeError:
+                continue
+        return tags, da_tags
+
     def pil_save(self, filename, fformat=None, fill_value=None,
                  compute=True, **format_kwargs):
         """Save the image to the given *filename* using PIL.
@@ -746,7 +755,9 @@ class XRImage(object):
         null_mask = null_mask.any(dim='bands')
         null_mask = null_mask.expand_dims('bands')
         null_mask['bands'] = ['A']
-        # match data dtype
+        # changes to null_mask attrs should not effect the original attrs
+        # XRImage never uses them either
+        null_mask.attrs = {}
         return null_mask
 
     def _add_alpha(self, data, alpha=None):
@@ -773,7 +784,27 @@ class XRImage(object):
         data.attrs = attrs
         return data
 
-    def _scale_to_dtype(self, data, dtype):
+    def _get_dtype_scale_offset(self, dtype, fill_value):
+        dinfo = np.iinfo(dtype)
+        scale = dinfo.max - dinfo.min
+        offset = dinfo.min
+        if fill_value is not None:
+            if fill_value == dinfo.min:
+                # leave the lowest value for fill value only
+                offset = offset + 1
+                scale = scale - 1
+            elif fill_value == dinfo.max:
+                # leave the top value for fill value only
+                scale = scale - 1
+            else:
+                warnings.warn(
+                    "Specified fill value will overlap with valid "
+                    "data. To avoid this warning specify a fill_value "
+                    "that is the minimum or maximum for the data type "
+                    "being saved to.")
+        return scale, offset
+
+    def _scale_to_dtype(self, data, dtype, fill_value=None):
         """Scale provided data to dtype range assuming a 0-1 range.
 
         Float input data is assumed to be normalized to a 0 to 1 range.
@@ -789,9 +820,8 @@ class XRImage(object):
                 data = data.clip(np.iinfo(dtype).min, np.iinfo(dtype).max)
             else:
                 # scale float data (assumed to be 0 to 1) to full integer space
-                dinfo = np.iinfo(dtype)
-                scale = dinfo.max - dinfo.min
-                offset = dinfo.min
+                # leave room for fill value if needed
+                scale, offset = self._get_dtype_scale_offset(dtype, fill_value)
                 data = data.clip(0, 1) * scale + offset
                 attrs.setdefault('enhancement_history', list()).append({'scale': scale, 'offset': offset})
             data = data.round()
@@ -899,21 +929,54 @@ class XRImage(object):
             new_img.palette = self.palette
         return new_img
 
-    def _finalize(self, fill_value=None, dtype=np.uint8, keep_palette=False, cmap=None):
-        """Wrap around 'finalize' method for backwards compatibility."""
-        import warnings
-        warnings.warn("'_finalize' is deprecated, use 'finalize' instead.",
-                      DeprecationWarning)
-        return self.finalize(fill_value, dtype, keep_palette, cmap)
-
     def final_mode(self, fill_value=None):
         """Get the mode of the finalized image when provided this fill_value."""
         if fill_value is None and not self.mode.endswith('A'):
             return self.mode + 'A'
-        else:
-            return self.mode
+        return self.mode
+
+    def _add_alpha_and_scale(self, data, ifill, dtype):
+        alpha = self._create_alpha(data, fill_value=ifill)
+        data = self._scale_to_dtype(data, dtype)
+        data = data.astype(dtype)
+        data = self._add_alpha(data, alpha=alpha)
+        return data
+
+    def _replace_fill_value(self, data, ifill, fill_value, dtype):
+        # Add fill_value after all other calculations have been done to
+        # make sure it is not scaled for the data type
+        if ifill is not None and fill_value is not None:
+            # cast fill value to output type so we don't change data type
+            fill_value = dtype(fill_value)
+            # integer fields have special fill values
+            data = data.where(data != ifill, dtype(fill_value))
+        elif fill_value is not None:
+            data = data.fillna(dtype(fill_value))
 
-    def finalize(self, fill_value=None, dtype=np.uint8, keep_palette=False, cmap=None):
+        return data
+
+    def _get_input_fill_value(self, data):
+        # if the data are integers then this fill value will be used to check for invalid values
+        if np.issubdtype(data, np.integer):
+            return data.attrs.get('_FillValue')
+        return None
+
+    def _scale_and_replace_fill_value(self, data, input_fill_value, fill_value, dtype):
+        # scale float data to the proper dtype
+        # this method doesn't cast yet so that we can keep track of NULL values
+        data = self._scale_to_dtype(data, dtype, fill_value)
+        data = self._replace_fill_value(data, input_fill_value, fill_value, dtype)
+        return data
+
+    def _scale_alpha_or_fill_data(self, data, fill_value, dtype):
+        input_fill_value = self._get_input_fill_value(data)
+        needs_alpha = fill_value is None and not self.mode.endswith('A')
+        if needs_alpha:
+            # We don't have a fill value or an alpha, let's add an alpha
+            return self._add_alpha_and_scale(data, input_fill_value, dtype)
+        return self._scale_and_replace_fill_value(data, input_fill_value, fill_value, dtype)
+
+    def finalize(self, fill_value=None, dtype=np.uint8, keep_palette=False):
         """Finalize the image to be written to an output file.
 
         This adds an alpha band or fills data with a fill_value (if
@@ -925,17 +988,44 @@ class XRImage(object):
         determined by a special ``_FillValue`` attribute in the
         ``DataArray`` ``.attrs`` dictionary.
 
+        Args:
+            fill_value (int or float or None): Output value to use to
+                represent invalid or missing pixels. By default this is
+                `None` meaning an Alpha channel will be used to represent
+                the invalid values; transparent for invalid, opaque
+                otherwise. Some output formats do not support alpha channels
+                so a ``fill_value`` must be provided. This is determined by
+                the underlying library doing the writing (pillow or rasterio).
+                If specified, it should be the minimum or maximum of the
+                ``dtype`` (ex. 0 or 255 for uint8). Floating point image data
+                is then scaled to fit the remainder of the data type space.
+                Integer image data will **not** be scaled. For example, a
+                ``dtype`` of ``numpy.uint8`` and a ``fill_value`` of 0 will
+                result in floating-point data being scaled linearly from 1 to 255.
+            dtype (numpy.dtype): Output data type to convert the current image
+                data to. Default is unsigned 8-bit integer
+                (:class:`numpy.uint8`).
+            keep_palette (bool): Whether to convert a paletted image to RGB/A
+                or not. If ``False`` (default) then ``P`` mode images will be
+                converted to ``RGB`` and ``PA`` will be converted to ``RGBA``.
+                If ``True``, images with mode ``P`` or ``PA`` are kept as is
+                and will not be scaled in order for their index values into a
+                palette to be maintained. This flag should always be ``False``
+                for non-paletted images.
+
         """
         if keep_palette and not self.mode.startswith('P'):
             keep_palette = False
 
         if not keep_palette:
+            finalize_kwargs = dict(
+                fill_value=fill_value, dtype=dtype,
+                keep_palette=keep_palette,
+            )
             if self.mode == "P":
-                return self.convert("RGB").finalize(fill_value=fill_value, dtype=dtype,
-                                                    keep_palette=keep_palette, cmap=cmap)
+                return self.convert("RGB").finalize(**finalize_kwargs)
             if self.mode == "PA":
-                return self.convert("RGBA").finalize(fill_value=fill_value, dtype=dtype,
-                                                     keep_palette=keep_palette, cmap=cmap)
+                return self.convert("RGBA").finalize(**finalize_kwargs)
 
         if np.issubdtype(dtype, np.floating) and fill_value is None:
             logger.warning("Image with floats cannot be transparent, so "
@@ -947,34 +1037,10 @@ class XRImage(object):
             final_data.attrs['enhancement_history'] = list(self.data.attrs['enhancement_history'])
         except KeyError:
             pass
-        attrs = final_data.attrs
-        # if the data are integers then this fill value will be used to check for invalid values
         with xr.set_options(keep_attrs=True):
-            ifill = final_data.attrs.get('_FillValue') if np.issubdtype(final_data, np.integer) else None
+            attrs = final_data.attrs
             if not keep_palette:
-                if fill_value is None and not self.mode.endswith('A'):
-                    # We don't have a fill value or an alpha, let's add an alpha
-                    alpha = self._create_alpha(final_data, fill_value=ifill)
-                    final_data = self._scale_to_dtype(final_data, dtype)
-                    attrs = final_data.attrs
-                    final_data = final_data.astype(dtype)
-                    final_data = self._add_alpha(final_data, alpha=alpha)
-                    final_data.attrs = attrs
-                else:
-                    # scale float data to the proper dtype
-                    # this method doesn't cast yet so that we can keep track of NULL values
-                    final_data = self._scale_to_dtype(final_data, dtype)
-                    attrs = final_data.attrs
-                    # Add fill_value after all other calculations have been done to
-                    # make sure it is not scaled for the data type
-                    if ifill is not None and fill_value is not None:
-                        # cast fill value to output type so we don't change data type
-                        fill_value = dtype(fill_value)
-                        # integer fields have special fill values
-                        final_data = final_data.where(final_data != ifill, dtype(fill_value))
-                    elif fill_value is not None:
-                        final_data = final_data.fillna(dtype(fill_value))
-
+                final_data = self._scale_alpha_or_fill_data(final_data, fill_value, dtype)
             final_data = final_data.astype(dtype)
             final_data.attrs = attrs
 
@@ -1394,10 +1460,10 @@ class XRImage(object):
             raise ValueError(
                     "Expected self.mode='RGBA', got {md!s}".format(
                         md=self.mode))
-        elif not isinstance(src, XRImage):
+        if not isinstance(src, XRImage):
             raise TypeError("Expected XRImage, got {tp!s}".format(
                 tp=type(src)))
-        elif src.mode != "RGBA":
+        if src.mode != "RGBA":
             raise ValueError("Expected src.mode='RGBA', got {sm!s}".format(
                 sm=src.mode))
 



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

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/trollimage/-/commit/337c6a61ae088b7888276126ea992a429cf6a6e4
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/20210321/645a7b64/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list