diff -Nru trollimage-1.9.0/appveyor.yml trollimage-1.11.0/appveyor.yml --- trollimage-1.9.0/appveyor.yml 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/appveyor.yml 2019-10-24 18:12:03.000000000 +0000 @@ -18,11 +18,15 @@ PYTHON_ARCH: "64" NUMPY_VERSION: "stable" + - PYTHON: "C:\\Python37_64" + PYTHON_VERSION: "3.7" + 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" - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - "activate test" + - "conda activate test" build: false # Not a C# project, build stuff at the test step instead. diff -Nru trollimage-1.9.0/CHANGELOG.md trollimage-1.11.0/CHANGELOG.md --- trollimage-1.9.0/CHANGELOG.md 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/CHANGELOG.md 2019-10-24 18:12:03.000000000 +0000 @@ -1,3 +1,48 @@ +## Version 1.11.0 (2019/10/24) + +### Pull Requests Merged + +#### Bugs fixed + +* [PR 58](https://github.com/pytroll/trollimage/pull/58) - Make tags containing values to compute use store for saving + +#### Features added + +* [PR 60](https://github.com/pytroll/trollimage/pull/60) - Add tests on py 3.7 +* [PR 59](https://github.com/pytroll/trollimage/pull/59) - Add scale and offset inclusion utility when rio saving +* [PR 57](https://github.com/pytroll/trollimage/pull/57) - Add the `apply_pil` method + +In this release 4 pull requests were closed. + + +## Version 1.10.1 (2019/09/26) + +### Pull Requests Merged + +#### Bugs fixed + +* [PR 56](https://github.com/pytroll/trollimage/pull/56) - Fix WKT version used to convert CRS to GeoTIFF CRS + +In this release 1 pull request was closed. + + +## Version 1.10.0 (2019/09/20) + +### Pull Requests Merged + +#### Bugs fixed + +* [PR 53](https://github.com/pytroll/trollimage/pull/53) - Fix double format passing in saving functions + +#### Features added + +* [PR 55](https://github.com/pytroll/trollimage/pull/55) - Add enhancement-history to the image +* [PR 54](https://github.com/pytroll/trollimage/pull/54) - Add ability to use AreaDefinitions new "crs" property +* [PR 52](https://github.com/pytroll/trollimage/pull/52) - Add 'colors' and 'values' keyword arguments to Colormap + +In this release 4 pull requests were closed. + + ## Version 1.9.0 (2019/06/18) ### Pull Requests Merged diff -Nru trollimage-1.9.0/debian/changelog trollimage-1.11.0/debian/changelog --- trollimage-1.9.0/debian/changelog 2019-07-09 06:35:44.000000000 +0000 +++ trollimage-1.11.0/debian/changelog 2019-10-28 07:06:03.000000000 +0000 @@ -1,3 +1,38 @@ +trollimage (1.11.0-1) unstable; urgency=medium + + * New upstream release. + * Bump Standards-Version to 4.4.1, no changes. + * debian/patches: + - refresh all patches + + -- Antonio Valentino Mon, 28 Oct 2019 07:06:03 +0000 + +trollimage (1.10.1-1) unstable; urgency=medium + + * New upstream release. + + -- Antonio Valentino Mon, 30 Sep 2019 05:33:09 +0000 + +trollimage (1.10.0-1) unstable; urgency=medium + + * New upstream release. + * debian/patches: + - refresh all patches + * debian/control: + - explicit specification of Rules-Requires-Root + + -- Antonio Valentino Sun, 22 Sep 2019 07:13:42 +0000 + +trollimage (1.9.0-2) unstable; urgency=medium + + * Bump Standards-Version to 4.4.0, no changes. + * Use debhelper-compat instead of debian/compat. + * Set compat to 12. + * Set upstream metadata fields: Contact. + * Remove obsolete fields Name, Contact from debian/upstream/metadata. + + -- Antonio Valentino Mon, 02 Sep 2019 06:17:09 +0000 + trollimage (1.9.0-1) unstable; urgency=medium [ Bas Couwenberg ] diff -Nru trollimage-1.9.0/debian/compat trollimage-1.11.0/debian/compat --- trollimage-1.9.0/debian/compat 2018-12-25 08:36:04.000000000 +0000 +++ trollimage-1.11.0/debian/compat 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -11 diff -Nru trollimage-1.9.0/debian/control trollimage-1.11.0/debian/control --- trollimage-1.9.0/debian/control 2018-12-25 12:37:24.000000000 +0000 +++ trollimage-1.11.0/debian/control 2019-09-30 18:04:58.000000000 +0000 @@ -2,9 +2,10 @@ Maintainer: Debian GIS Project Uploaders: Antonio Valentino Section: python -Priority: optional Testsuite: autopkgtest-pkg-python -Build-Depends: debhelper (>= 11), +Rules-Requires-Root: no +Priority: optional +Build-Depends: debhelper-compat (= 12), dh-python, python3-all, python3-dask, @@ -14,7 +15,7 @@ python3-setuptools, python3-six, python3-xarray -Standards-Version: 4.3.0 +Standards-Version: 4.4.1 Vcs-Browser: https://salsa.debian.org/debian-gis-team/trollimage Vcs-Git: https://salsa.debian.org/debian-gis-team/trollimage.git Homepage: https://github.com/pytroll/trollimage diff -Nru trollimage-1.9.0/debian/patches/0001-No-display.patch trollimage-1.11.0/debian/patches/0001-No-display.patch --- trollimage-1.9.0/debian/patches/0001-No-display.patch 2019-07-09 06:35:44.000000000 +0000 +++ trollimage-1.11.0/debian/patches/0001-No-display.patch 2019-10-28 07:06:03.000000000 +0000 @@ -8,14 +8,14 @@ 1 file changed, 1 insertion(+) diff --git a/trollimage/tests/test_image.py b/trollimage/tests/test_image.py -index 4e7f34e..1b8cc74 100644 +index 83538e5..2b6cbe6 100644 --- a/trollimage/tests/test_image.py +++ b/trollimage/tests/test_image.py -@@ -1859,6 +1859,7 @@ class TestXRImage(unittest.TestCase): - def test_putalpha(self): +@@ -1916,6 +1916,7 @@ class TestXRImage(unittest.TestCase): + """Test putalpha.""" pass + @unittest.skip("no display") def test_show(self): - """Test that the show commands calls PIL.show""" + """Test that the show commands calls PIL.show.""" import xarray as xr diff -Nru trollimage-1.9.0/debian/upstream/metadata trollimage-1.11.0/debian/upstream/metadata --- trollimage-1.9.0/debian/upstream/metadata 2018-12-25 08:36:04.000000000 +0000 +++ trollimage-1.11.0/debian/upstream/metadata 2019-09-02 06:30:07.000000000 +0000 @@ -1,6 +1,4 @@ ---- Bug-Database: https://github.com/pytroll/trollimage/issues Bug-Submit: https://github.com/pytroll/trollimage/issues/new -Name: Trollimage Repository: https://github.com/pytroll/trollimage.git Repository-Browse: https://github.com/pytroll/trollimage diff -Nru trollimage-1.9.0/.pre-commit-config.yaml trollimage-1.11.0/.pre-commit-config.yaml --- trollimage-1.9.0/.pre-commit-config.yaml 1970-01-01 00:00:00.000000000 +0000 +++ trollimage-1.11.0/.pre-commit-config.yaml 2019-10-24 18:12:03.000000000 +0000 @@ -0,0 +1,8 @@ +exclude: '^$' +fail_fast: false +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v1.2.3 + hooks: + - id: flake8 + additional_dependencies: [flake8-docstrings, flake8-debugger, flake8-bugbear] diff -Nru trollimage-1.9.0/.travis.yml trollimage-1.11.0/.travis.yml --- trollimage-1.9.0/.travis.yml 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/.travis.yml 2019-10-24 18:12:03.000000000 +0000 @@ -1,7 +1,4 @@ language: python -python: -- '2.7' -- '3.6' env: global: # Set defaults to avoid repeating in most cases @@ -15,6 +12,17 @@ - CONDA_CHANNELS='conda-forge' - CONDA_CHANNEL_PRIORITY=True +matrix: + include: + - env: + - PYTHON_VERSION=2.7 + - NUMPY_VERSION=1.16 + os: linux + - env: PYTHON_VERSION=3.6 + os: linux + - env: PYTHON_VERSION=3.7 + os: linux + install: - git clone --depth 1 git://github.com/astropy/ci-helpers.git - source ci-helpers/travis/setup_conda.sh diff -Nru trollimage-1.9.0/trollimage/colormap.py trollimage-1.11.0/trollimage/colormap.py --- trollimage-1.9.0/trollimage/colormap.py 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/trollimage/colormap.py 2019-10-24 18:12:03.000000000 +0000 @@ -88,9 +88,13 @@ """ - def __init__(self, *tuples): - values = [a for (a, b) in tuples] - colors = [b for (a, b) in tuples] + def __init__(self, *tuples, **kwargs): + if 'colors' in kwargs and 'values' in kwargs: + values = kwargs['values'] + colors = kwargs['colors'] + else: + values = [a for (a, b) in tuples] + colors = [b for (a, b) in tuples] self.values = np.array(values) self.colors = np.array(colors) diff -Nru trollimage-1.9.0/trollimage/tests/test_image.py trollimage-1.11.0/trollimage/tests/test_image.py --- trollimage-1.9.0/trollimage/tests/test_image.py 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/trollimage/tests/test_image.py 2019-10-24 18:12:03.000000000 +0000 @@ -21,15 +21,17 @@ # You should have received a copy of the GNU General Public License # along with mpop. If not, see . -"""Module for testing the imageo.image module. -""" +"""Module for testing the image and xrimage modules.""" import os -import sys import random -import unittest +import sys import tempfile +import unittest +from collections import OrderedDict from tempfile import NamedTemporaryFile + import numpy as np + from trollimage import image try: @@ -58,18 +60,15 @@ class TestEmptyImage(unittest.TestCase): - """Class for testing the mpop.imageo.image module - """ + """Class for testing the mpop.imageo.image module.""" def setUp(self): - """Setup the test. - """ + """Set up the test case.""" self.img = image.Image() self.modes = ["L", "LA", "RGB", "RGBA", "YCbCr", "YCbCrA", "P", "PA"] def test_shape(self): - """Shape of an empty image. - """ + """Shape of an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -77,13 +76,11 @@ self.img.convert(oldmode) def test_is_empty(self): - """Test if an image is empty. - """ + """Test if an image is empty.""" self.assertEqual(self.img.is_empty(), True) def test_clip(self): - """Clip an empty image. - """ + """Clip an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -91,8 +88,7 @@ self.img.convert(oldmode) def test_convert(self): - """Convert an empty image. - """ + """Convert an empty image.""" for mode1 in self.modes: for mode2 in self.modes: self.img.convert(mode1) @@ -108,8 +104,7 @@ self.assertRaises(ValueError, self.img.convert, randstr) def test_stretch(self): - """Stretch an empty image - """ + """Stretch an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -136,8 +131,7 @@ self.img.convert(oldmode) def test_gamma(self): - """Gamma correction on an empty image. - """ + """Gamma correction on an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -158,8 +152,7 @@ self.img.convert(oldmode) def test_invert(self): - """Invert an empty image. - """ + """Invert an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -174,8 +167,7 @@ self.img.convert(oldmode) def test_pil_image(self): - """Return an empty PIL image. - """ + """Return an empty PIL image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -189,8 +181,7 @@ self.img.convert(oldmode) def test_putalpha(self): - """Add an alpha channel to en empty image - """ + """Add an alpha channel to en empty image.""" # Putting alpha channel to an empty image should not do anything except # change the mode if necessary. oldmode = self.img.mode @@ -212,8 +203,7 @@ self.img.convert(oldmode) def test_save(self): - """Save an empty image. - """ + """Save an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -222,8 +212,7 @@ self.img.convert(oldmode) def test_replace_luminance(self): - """Replace luminance in an empty image. - """ + """Replace luminance in an empty image.""" oldmode = self.img.mode for mode in self.modes: self.img.convert(mode) @@ -234,13 +223,11 @@ self.img.convert(oldmode) def test_resize(self): - """Resize an empty image. - """ + """Resize an empty image.""" self.assertRaises(ValueError, self.img.resize, (10, 10)) def test_merge(self): - """Merging of an empty image with another. - """ + """Merging of an empty image with another.""" newimg = image.Image() self.assertRaises(ValueError, self.img.merge, newimg) newimg = image.Image(np.array([[1, 2], [3, 4]])) @@ -250,20 +237,16 @@ class TestImageCreation(unittest.TestCase): - """Class for testing the mpop.imageo.image module - """ + """Class for testing the mpop.imageo.image module.""" def setUp(self): - """Setup the test. - """ + """Set up the test case.""" self.img = {} self.modes = ["L", "LA", "RGB", "RGBA", "YCbCr", "YCbCrA", "P", "PA"] self.modes_len = [1, 2, 3, 4, 3, 4, 1, 2] def test_creation(self): - """Creation of an image. - """ - + """Test creation of an image.""" self.assertRaises(TypeError, image.Image, channels=random.randint(1, 1000)) self.assertRaises(TypeError, image.Image, @@ -338,12 +321,10 @@ class TestRegularImage(unittest.TestCase): - """Class for testing the mpop.imageo.image module - """ + """Class for testing the mpop.imageo.image module.""" def setUp(self): - """Setup the test. - """ + """Set up the test case.""" one_channel = np.random.rand(random.randint(1, 10), random.randint(1, 10)) self.rand_img = image.Image(channels=[one_channel] * 3, @@ -370,8 +351,7 @@ os.chmod(self.tempdir, 0o444) def test_shape(self): - """Shape of an image. - """ + """Shape of an image.""" oldmode = self.img.mode for mode in self.modes: if mode == "P" or mode == "PA": @@ -381,13 +361,11 @@ self.img.convert(oldmode) def test_is_empty(self): - """Test if an image is empty. - """ + """Test if an image is empty.""" self.assertEqual(self.img.is_empty(), False) def test_clip(self): - """Clip an image. - """ + """Clip an image.""" oldmode = self.img.mode for mode in self.modes: if mode == "P" or mode == "PA": @@ -399,8 +377,7 @@ self.img.convert(oldmode) def test_convert(self): - """Convert an image. - """ + """Convert an image.""" i = 0 for mode1 in self.modes: j = 0 @@ -437,8 +414,7 @@ self.assertRaises(ValueError, self.img.convert, randstr) def test_stretch(self): - """Stretch an image. - """ + """Stretch an image.""" oldmode = self.img.mode for mode in "L": @@ -479,8 +455,7 @@ self.img.convert(oldmode) def test_gamma(self): - """Gamma correction on an image. - """ + """Gamma correction on an image.""" oldmode = self.img.mode for mode in self.modes: if mode == "P" or mode == "PA": @@ -519,8 +494,7 @@ self.img.convert(oldmode) def test_invert(self): - """Invert an image. - """ + """Invert an image.""" oldmode = self.img.mode for mode in self.modes: if mode == "P" or mode == "PA": @@ -543,11 +517,8 @@ self.img.convert(oldmode) def test_pil_image(self): - """Return an PIL image. - """ - + """Return an PIL image.""" # FIXME: Should test on palette images - oldmode = self.img.mode for mode in self.modes: if (mode == "YCbCr" or @@ -561,8 +532,7 @@ self.img.convert(oldmode) def test_putalpha(self): - """Add an alpha channel. - """ + """Add an alpha channel.""" # Putting alpha channel to an image should not do anything except # change the mode if necessary. oldmode = self.img.mode @@ -590,8 +560,7 @@ @unittest.skipIf(sys.platform.startswith('win'), "Read-only tmp dir not working under Windows") def test_save(self): - """Save an image. - """ + """Save an image.""" oldmode = self.img.mode for mode in self.modes: if (mode == "YCbCr" or @@ -614,8 +583,7 @@ @unittest.skipIf(sys.platform.startswith('win'), "Read-only tmp dir not working under Windows") def test_save_jpeg(self): - """Save a jpeg image. - """ + """Save a jpeg image.""" oldmode = self.img.mode self.img.convert('L') self.img.save("test.jpg") @@ -630,8 +598,7 @@ self.img.convert(oldmode) def test_replace_luminance(self): - """Replace luminance in an image. - """ + """Replace luminance in an image.""" oldmode = self.img.mode for mode in self.modes: if (mode == "P" or @@ -651,8 +618,7 @@ self.img.convert(oldmode) def test_resize(self): - """Resize an image. - """ + """Resize an image.""" self.img.resize((6, 6)) res = np.array([[0, 0, 0.5, 0.5, 0.5, 0.5], [0, 0, 0.5, 0.5, 0.5, 0.5], @@ -667,8 +633,7 @@ self.assertTrue(np.all(res == self.img.channels[0])) def test_merge(self): - """Merging of an image with another. - """ + """Merging of an image with another.""" newimg = image.Image() self.assertRaises(ValueError, self.img.merge, newimg) newimg = image.Image(np.array([[1, 2], [3, 4]])) @@ -686,17 +651,16 @@ EPSILON)) def tearDown(self): - """Clean up the mess. - """ + """Clean up the mess.""" os.chmod(self.tempdir, 0o777) os.rmdir(self.tempdir) class TestFlatImage(unittest.TestCase): - """Test a flat image, ie an image where min == max. - """ + """Test a flat image, ie an image where min == max.""" def setUp(self): + """Set up the test case.""" channel = np.ma.array([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], mask=[[1, 1, 1], [1, 1, 0]]) self.img = image.Image(channels=[channel] * 3, @@ -704,8 +668,7 @@ self.modes = ["L", "LA", "RGB", "RGBA", "YCbCr", "YCbCrA", "P", "PA"] def test_stretch(self): - """Stretch a flat image. - """ + """Stretch a flat image.""" self.img.stretch() self.assertTrue(self.img.channels[0].shape == (2, 3) and np.ma.count_masked(self.img.channels[0]) == 5) @@ -724,10 +687,10 @@ class TestNoDataImage(unittest.TestCase): - """Test an image filled with no data. - """ + """Test an image filled with no data.""" def setUp(self): + """Set up the test case.""" channel = np.ma.array([[0, 0.5, 0.5], [0.5, 0.25, 0.25]], mask=[[1, 1, 1], [1, 1, 1]]) self.img = image.Image(channels=[channel] * 3, @@ -735,8 +698,7 @@ self.modes = ["L", "LA", "RGB", "RGBA", "YCbCr", "YCbCrA", "P", "PA"] def test_stretch(self): - """Stretch a no data image. - """ + """Stretch a no data image.""" self.img.stretch() self.assertTrue(self.img.channels[0].shape == (2, 3)) self.img.stretch("crude") @@ -752,16 +714,16 @@ def random_string(length, choices="abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ"): - """Generates a random string with elements from *set* of the specified - *length*. - """ + """Generate a random string with elements from *set* of the specified *length*.""" return "".join([random.choice(choices) for dummy in range(length)]) class TestXRImage(unittest.TestCase): + """Test XRImage objects.""" 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']) @@ -793,9 +755,23 @@ img = xrimage.XRImage(data) self.assertEqual(img.mode, 'YCbCrA') + 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=[ + 'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']}) + with mock.patch.object(xrimage.XRImage, 'pil_save') as pil_save: + img = xrimage.XRImage(data) + + img.save(filename='bla.png', fformat='png', format='png') + self.assertNotIn('format', pil_save.call_args_list[0][1]) + @unittest.skipIf(sys.platform.startswith('win'), "'NamedTemporaryFile' not supported on Windows") def test_save(self): + """Test saving.""" import xarray as xr import dask.array as da from dask.delayed import Delayed @@ -985,7 +961,7 @@ delay = img.save(tmp.name, compute=False) self.assertIsInstance(delay, tuple) self.assertIsInstance(delay[0], da.Array) - self.assertIsInstance(delay[1], xrimage.RIOFile) + self.assertIsInstance(delay[1], xrimage.RIODataset) da.store(*delay) delay[1].close() @@ -1058,7 +1034,7 @@ delay = img.save(tmp.name, compute=False) self.assertIsInstance(delay, tuple) self.assertIsInstance(delay[0], da.Array) - self.assertIsInstance(delay[1], xrimage.RIOFile) + self.assertIsInstance(delay[1], xrimage.RIODataset) da.store(*delay) delay[1].close() @@ -1225,6 +1201,44 @@ with rio.open(tmp.name) as f: self.assertEqual(len(f.overviews(1)), 2) + @unittest.skipIf(sys.platform.startswith('win'), "'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=[ + '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)) + 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) + def test_gamma(self): """Test gamma correction.""" import xarray as xr @@ -1236,8 +1250,10 @@ 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}) img.gamma([2., 2., 2.]) + self.assertEqual(len(img.data.attrs['enhancement_history']), 2) self.assertTrue(np.allclose(img.data.values, arr)) def test_crude_stretch(self): @@ -1253,6 +1269,11 @@ red = img.data.sel(bands='R') green = img.data.sel(bands='G') blue = img.data.sel(bands='B') + enhs = img.data.attrs['enhancement_history'][0] + scale_expected = np.array([0.01388889, 0.01388889, 0.01388889]) + offset_expected = np.array([0., -0.01388889, -0.02777778]) + np.testing.assert_allclose(enhs['scale'].values, scale_expected) + np.testing.assert_allclose(enhs['offset'].values, offset_expected) np.testing.assert_allclose(red, arr[:, :, 0] / 72.) np.testing.assert_allclose(green, (arr[:, :, 1] - 1.) / (73. - 1.)) np.testing.assert_allclose(blue, (arr[:, :, 2] - 2.) / (74. - 2.)) @@ -1275,7 +1296,8 @@ img = xrimage.XRImage(data) 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)) data = xr.DataArray(arr.copy(), dims=['y', 'x', 'bands'], @@ -1299,6 +1321,9 @@ coords={'bands': ['R', 'G', 'B']}) img = xrimage.XRImage(data) img.stretch_linear() + enhs = img.data.attrs['enhancement_history'][0] + np.testing.assert_allclose(enhs['scale'].values, np.array([1.03815937, 1.03815937, 1.03815937])) + np.testing.assert_allclose(enhs['offset'].values, np.array([-0.00505051, -0.01907969, -0.03310887]), atol=1e-8) res = np.array([[[-0.005051, -0.005051, -0.005051], [0.037037, 0.037037, 0.037037], [0.079125, 0.079125, 0.079125], @@ -1328,6 +1353,7 @@ self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6)) def test_histogram_stretch(self): + """Test histogram stretching.""" import xarray as xr from trollimage import xrimage @@ -1336,6 +1362,8 @@ coords={'bands': ['R', 'G', 'B']}) img = xrimage.XRImage(data) img.stretch('histogram') + enhs = img.data.attrs['enhancement_history'][0] + self.assertDictEqual(enhs, {'hist_equalize': True}) res = np.array([[[0., 0., 0.], [0.04166667, 0.04166667, 0.04166667], [0.08333333, 0.08333333, 0.08333333], @@ -1369,6 +1397,7 @@ self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6)) def test_logarithmic_stretch(self): + """Test logarithmic strecthing.""" import xarray as xr from trollimage import xrimage @@ -1377,6 +1406,8 @@ coords={'bands': ['R', 'G', 'B']}) img = xrimage.XRImage(data) img.stretch(stretch='logarithmic') + enhs = img.data.attrs['enhancement_history'][0] + self.assertDictEqual(enhs, {'log_factor': 100.0}) res = np.array([[[0., 0., 0.], [0.35484693, 0.35484693, 0.35484693], [0.48307087, 0.48307087, 0.48307087], @@ -1410,7 +1441,7 @@ self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6)) def test_weber_fechner_stretch(self): - """S=2.3klog10I+C """ + """Test applying S=2.3klog10I+C to the data.""" import xarray as xr from trollimage import xrimage @@ -1419,6 +1450,8 @@ coords={'bands': ['R', 'G', 'B']}) 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)}) res = np.array([[[-np.inf, -6.73656795, -5.0037], [-3.99003723, -3.27083205, -2.71297317], [-2.25716928, -1.87179258, -1.5379641], @@ -1452,27 +1485,35 @@ self.assertTrue(np.allclose(img.data.values, res, atol=1.e-6)) def test_jpeg_save(self): + """Test saving to jpeg.""" pass def test_gtiff_save(self): + """Test saving to geotiff.""" pass def test_save_masked(self): + """Test saving masked data.""" pass def test_LA_save(self): + """Test LA saving.""" pass def test_L_save(self): + """Test L saving.""" pass def test_P_save(self): + """Test P saving.""" pass def test_PA_save(self): + """Test PA saving.""" pass def test_convert_modes(self): + """Test modes convertions.""" import dask import xarray as xr from trollimage import xrimage @@ -1612,6 +1653,18 @@ self.assertTrue(img2.mode == 'RGBA') self.assertTrue(len(img2.data.coords['bands']) == 4) + def test_final_mode(self): + """Test final_mode.""" + import xarray as xr + from trollimage import xrimage + + # numpy array image + 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') + def test_colorize(self): """Test colorize with an RGB colormap.""" import xarray as xr @@ -1782,7 +1835,7 @@ self.assertTupleEqual((2, 4), bw.colors.shape) def test_stack(self): - + """Test stack.""" import xarray as xr from trollimage import xrimage @@ -1810,9 +1863,11 @@ np.testing.assert_allclose(bkg.data, res.data, rtol=1e-05) def test_merge(self): + """Test merge.""" pass def test_blend(self): + """Test blend.""" import xarray as xr from trollimage import xrimage @@ -1854,13 +1909,15 @@ img1.blend(wrongimg) def test_replace_luminance(self): + """Test luminance replacement.""" pass def test_putalpha(self): + """Test putalpha.""" pass def test_show(self): - """Test that the show commands calls PIL.show""" + """Test that the show commands calls PIL.show.""" import xarray as xr from trollimage import xrimage @@ -1871,9 +1928,45 @@ img.show() s.assert_called_once() + 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. + data = xr.DataArray(np_data, dims=[ + 'y', 'x', 'bands'], coords={'bands': ['R', 'G', 'B']}) + + dummy_args = [(OrderedDict(), ), {}] + + def dummy_fun(pil_obj, *args, **kwargs): + dummy_args[0] = args + dummy_args[1] = kwargs + return pil_obj + + img = xrimage.XRImage(data) + pi = mock.MagicMock() + img.pil_image = pi + res = img.apply_pil(dummy_fun, 'RGB') + # check that the pil image generation is delayed + pi.assert_not_called() + # make it happen + res.data.data.compute() + pi.return_value.convert.assert_called_with('RGB') + + img = xrimage.XRImage(data) + pi = mock.MagicMock() + img.pil_image = pi + res = img.apply_pil(dummy_fun, 'RGB', + fun_args=('Hey', 'Jude'), + fun_kwargs={'chorus': "La lala lalalala"}) + self.assertEqual(dummy_args, [({}, ), {}]) + res.data.data.compute() + self.assertEqual(dummy_args, [(OrderedDict(), 'Hey', 'Jude'), {'chorus': "La lala lalalala"}]) + def suite(): - """The suite for test_image.""" + """Create the suite for test_image.""" loader = unittest.TestLoader() mysuite = unittest.TestSuite() mysuite.addTest(loader.loadTestsFromTestCase(TestEmptyImage)) diff -Nru trollimage-1.9.0/trollimage/version.py trollimage-1.11.0/trollimage/version.py --- trollimage-1.9.0/trollimage/version.py 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/trollimage/version.py 2019-10-24 18:12:03.000000000 +0000 @@ -23,9 +23,9 @@ # 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 = " (tag: v1.9.0)" - git_full = "63fa32f2d40bb65ebc39c4be1fb1baf8f163db98" - git_date = "2019-06-18 06:13:40 -0500" + git_refnames = " (HEAD -> master, tag: v1.11.0)" + git_full = "103b269144d33bbcf106c8afa20de95618d5a890" + git_date = "2019-10-24 20:12:03 +0200" keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} return keywords diff -Nru trollimage-1.9.0/trollimage/xrimage.py trollimage-1.11.0/trollimage/xrimage.py --- trollimage-1.9.0/trollimage/xrimage.py 2019-06-18 11:13:40.000000000 +0000 +++ trollimage-1.11.0/trollimage/xrimage.py 2019-10-24 18:12:03.000000000 +0000 @@ -21,24 +21,27 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""This module defines the XRImage class. It overlaps largely with the PIL -library, but has the advantage of using :class:`~xarray.DataArray` objects -backed by :class:`dask arrays ` as pixel arrays. This -allows for invalid values to be tracked, metadata to be assigned, and -stretching to be lazy evaluated. With the optional ``rasterio`` library -installed dask array chunks can be saved in parallel. +"""This module defines the XRImage class. + +It overlaps largely with the PIL library, but has the advantage of using +:class:`~xarray.DataArray` objects backed by :class:`dask arrays +` as pixel arrays. This allows for invalid values to +be tracked, metadata to be assigned, and stretching to be lazy +evaluated. With the optional ``rasterio`` library installed dask array +chunks can be saved in parallel. """ import logging import os +import threading import numpy as np from PIL import Image as PILImage import xarray as xr -import xarray.ufuncs as xu import dask import dask.array as da +from dask.delayed import delayed from trollimage.image import check_image_format @@ -69,45 +72,24 @@ self.mode = mode self.kwargs = kwargs self.rfile = None - self._closed = True - self.overviews = kwargs.pop('overviews', None) + self.lock = threading.Lock() - def __setitem__(self, key, item): - """Put the data chunk in the image.""" - if len(key) == 3: - indexes = list(range( - key[0].start + 1, - key[0].stop + 1, - key[0].step or 1 - )) - y = key[1] - x = key[2] - else: - indexes = 1 - y = key[0] - x = key[1] - chy_off = y.start - chy = y.stop - y.start - chx_off = x.start - chx = x.stop - x.start - - # band indexes - self.rfile.write(item, window=Window(chx_off, chy_off, chx, chy), - indexes=indexes) + @property + def closed(self): + """Check if the file is closed.""" + return self.rfile is None or self.rfile.closed def open(self, mode=None): + """Open the file.""" mode = mode or self.mode - if self._closed: + if self.closed: self.rfile = rasterio.open(self.path, mode, **self.kwargs) - self._closed = False def close(self): - if not self._closed: - if self.overviews: - logger.debug('Building overviews %s', str(self.overviews)) - self.rfile.build_overviews(self.overviews) - self.rfile.close() - self._closed = True + """Close the file.""" + with self.lock: + if not self.closed: + self.rfile.close() def __enter__(self): """Enter method.""" @@ -119,6 +101,7 @@ self.close() def __del__(self): + """Delete the instance.""" try: self.close() except (IOError, OSError): @@ -137,6 +120,82 @@ else: self.rfile.colorinterp = val + def write(self, *args, **kwargs): + """Write to the file.""" + with self.lock: + self.open('a') + return self.rfile.write(*args, **kwargs) + + def build_overviews(self, *args, **kwargs): + """Write overviews.""" + with self.lock: + self.open('a') + return self.rfile.build_overviews(*args, **kwargs) + + def update_tags(self, *args, **kwargs): + """Update tags.""" + with self.lock: + self.open('a') + return self.rfile.update_tags(*args, **kwargs) + + +class RIOTag: + """Rasterio wrapper to allow da.store on tag.""" + + def __init__(self, rfile, name): + """Init the rasterio tag.""" + self.rfile = rfile + self.name = name + + def __setitem__(self, key, item): + """Put the data in the tag.""" + kwargs = {self.name: item.item()} + self.rfile.update_tags(**kwargs) + + def close(self): + """Close the file.""" + return self.rfile.close() + + +class RIODataset: + """A wrapper for a rasterio dataset.""" + + def __init__(self, rfile, overviews=None): + """Init the rasterio dataset.""" + self.rfile = rfile + self.overviews = overviews + + def __setitem__(self, key, item): + """Put the data chunk in the image.""" + if len(key) == 3: + indexes = list(range( + key[0].start + 1, + key[0].stop + 1, + key[0].step or 1 + )) + y = key[1] + x = key[2] + else: + indexes = 1 + y = key[0] + x = key[1] + chy_off = y.start + chy = y.stop - y.start + chx_off = x.start + chx = x.stop - x.start + + # band indexes + self.rfile.write(item, window=Window(chx_off, chy_off, chx, chy), + indexes=indexes) + + def close(self): + """Close the file.""" + if self.overviews: + logger.debug('Building overviews %s', str(self.overviews)) + self.rfile.build_overviews(self.overviews) + + return self.rfile.close() + def color_interp(data): """Get the color interpretation for this image.""" @@ -165,11 +224,55 @@ return [colors[band] for band in data['bands'].values] +def combine_scales_offsets(*args): + """Combine ``(scale, offset)`` tuples in one, considering they are applied from left to right. + + For example, if we have our base data called ```orig_data`` and apply to it + ``(scale_1, offset_1)``, then ``(scale_2, offset_2)`` such that:: + + data_1 = orig_data * scale_1 + offset_1 + data_2 = data_1 * scale_2 + offset_2 + + this function will return the tuple ``(scale, offset)`` such that:: + + data_2 = orig_data * scale + offset + + given the arguments ``(scale_1, offset_1), (scale_2, offset_2)``. + + """ + cscale = 1 + coffset = 0 + for scale, offset in args: + cscale *= scale + coffset = coffset * scale + offset + return cscale, coffset + + +def invert_scale_offset(scale, offset): + """Invert scale and offset to allow reverse transformation. + + Ie, it will return ``rscale, roffset`` such that:: + + orig_data = rscale * data + roffset + + if:: + + data = scale * orig_data + offset + + """ + return 1 / scale, -offset / scale + + class XRImage(object): """Image class using an :class:`xarray.DataArray` as internal storage. - It can be saved to a variety of image formats, but if Rasterio is installed, - it can save to geotiff and jpeg2000 with geographical information. + It can be saved to a variety of image formats, but if Rasterio is + installed, it can save to geotiff and jpeg2000 with geographical + information. + + The enhancements functions are recording some parameters in the image's + data attribute called `enhancement_history`. + """ def __init__(self, data): @@ -252,7 +355,8 @@ saving with rasterio, used with keep_palette=True. Should be uint8. format_kwargs: Additional format options to pass to `rasterio` - or `PIL` saving methods. + or `PIL` saving methods. Any format argument passed + at this stage would be superseeded by `fformat`. Returns: Either `None` if `compute` is True or a `dask.Delayed` object or @@ -263,7 +367,8 @@ the caller. """ - fformat = fformat or os.path.splitext(filename)[1][1:4] + kwformat = format_kwargs.pop('format', None) + fformat = fformat or kwformat or os.path.splitext(filename)[1][1:] if fformat in ('tif', 'jp2') and rasterio: return self.rio_save(filename, fformat=fformat, fill_value=fill_value, compute=compute, @@ -275,16 +380,39 @@ def rio_save(self, filename, fformat=None, fill_value=None, dtype=np.uint8, compute=True, tags=None, - keep_palette=False, cmap=None, + keep_palette=False, cmap=None, overviews=None, + include_scale_offset_tags=False, **format_kwargs): """Save the image using rasterio. - Overviews can be added to the file using the `overviews` kwarg, eg:: + Args: + filename (string): The filename to save to. + fformat (string): The format to save to. If not specified (default), + it will be infered from the file extension. + fill_value (number): The value to fill the missing data with. + Default is ``None``, translating to trying to keep the data + transparent. + dtype (np.dtype): The type to save the data to. Defaults to + np.uint8. + compute (bool): Whether (default) or not to compute the lazy data. + tags (dict): Tags to include in the file. + keep_palette (bool): Whether or not (default) to keep the image in + P mode. + cmap (colormap): The colormap to use for the data. + overviews (list): The reduction factors of the overviews to include + in the image, eg:: + + img.rio_save('myfile.tif', overviews=[2, 4, 8, 16]) + + include_scale_offset_tags (bool): Whether or not (default) to + include a ``scale`` and an ``offset`` tag in the data that would + help retrieving original data values from pixel values. - img.rio_save('myfile.tif', overviews=[2, 4, 8, 16]) + Returns: + The delayed or computed result of the saving. """ - fformat = fformat or os.path.splitext(filename)[1][1:4] + fformat = fformat or os.path.splitext(filename)[1][1:] drivers = {'jpg': 'JPEG', 'png': 'PNG', 'tif': 'GTiff', @@ -297,7 +425,6 @@ data, mode = self.finalize(fill_value, dtype=dtype, keep_palette=keep_palette, cmap=cmap) data = data.transpose('bands', 'y', 'x') - data.attrs = self.data.attrs crs = None gcps = None @@ -318,7 +445,15 @@ photometric_map[mode.upper()]) try: - crs = rasterio.crs.CRS(data.attrs['area'].proj_dict) + area = data.attrs['area'] + if rasterio.__gdal_version__ >= '3': + wkt_version = 'WKT2_2018' + else: + wkt_version = 'WKT1_GDAL' + if hasattr(area, 'crs'): + crs = rasterio.crs.CRS.from_wkt(area.crs.to_wkt(version=wkt_version)) + else: + crs = rasterio.crs.CRS(data.attrs['area'].proj_dict) west, south, east, north = data.attrs['area'].area_extent height, width = data.sizes['y'], data.sizes['x'] transform = rasterio.transform.from_bounds(west, south, @@ -341,6 +476,10 @@ elif driver == 'JPEG' and 'A' in mode: raise ValueError('JPEG does not support alpha') + if include_scale_offset_tags: + scale, offset = self.get_scaling_from_history(data.attrs.get('enhancement_history', [])) + tags['scale'], tags['offset'] = invert_scale_offset(scale, offset) + # FIXME add metadata r_file = RIOFile(filename, 'w', driver=driver, width=data.sizes['x'], height=data.sizes['y'], @@ -354,7 +493,6 @@ r_file.open() if not keep_palette: r_file.colorinterp = color_interp(data) - r_file.rfile.update_tags(**tags) if keep_palette and cmap is not None: if data.dtype != 'uint8': @@ -366,24 +504,45 @@ 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 + + r_file.rfile.update_tags(**tags) + r_dataset = RIODataset(r_file, overviews) + + to_store = (data.data, r_dataset) + if da_tags: + to_store = list(zip(*([to_store] + da_tags))) + if compute: # write data to the file now - res = da.store(data.data, r_file) - r_file.close() + res = da.store(*to_store) + to_close = to_store[1] + if not isinstance(to_close, tuple): + to_close = [to_close] + for item in to_close: + item.close() return res # provide the data object and the opened file so the caller can # store them when they would like. Caller is responsible for # closing the file - return data.data, r_file + return to_store def pil_save(self, filename, fformat=None, fill_value=None, compute=True, **format_kwargs): """Save the image to the given *filename* using PIL. - For now, the compression level [0-9] is ignored, due to PIL's lack of - support. See also :meth:`save`. + For now, the compression level [0-9] is ignored, due to PIL's + lack of support. See also :meth:`save`. + """ - fformat = fformat or os.path.splitext(filename)[1][1:4] + fformat = fformat or os.path.splitext(filename)[1][1:] fformat = check_image_format(fformat) if fformat == 'png': @@ -396,12 +555,69 @@ return delay.compute() return delay + def get_scaling_from_history(self, history=None): + """Merge the scales and offsets from the history. + + If ``history`` isn't provided, the history of the current image will be + used. + """ + if history is None: + history = self.data.attrs.get('enhancement_history', []) + try: + scaling = [(item['scale'], item['offset']) for item in history] + except KeyError as err: + raise NotImplementedError('Can only get combine scaling from a list of scaling operations: %s' % str(err)) + return combine_scales_offsets(*scaling) + + @delayed(nout=1, pure=True) + def _delayed_apply_pil(self, fun, pil_args, pil_kwargs, fun_args, fun_kwargs, + image_metadata=None, output_mode=None): + if pil_args is None: + pil_args = tuple() + if pil_kwargs is None: + pil_kwargs = dict() + if fun_args is None: + fun_args = tuple() + if fun_kwargs is None: + fun_kwargs = dict() + if image_metadata is None: + image_metadata = dict() + new_img = fun(self.pil_image(*pil_args, **pil_kwargs), image_metadata, *fun_args, **fun_kwargs) + if output_mode is not None: + new_img = new_img.convert(output_mode) + return np.array(new_img) / self.data.dtype.type(255.0) + + def apply_pil(self, fun, output_mode, pil_args=None, pil_kwargs=None, fun_args=None, fun_kwargs=None): + """Apply a function `fun` on the pillow image corresponding to the instance of the XRImage. + + The function shall take a pil image as first argument, and is then passed fun_args and fun_kwargs. + In addition, the current images's metadata is passed as a keyword argument called `image_mda`. + It is expected to return the modified pil image. + This function returns a new XRImage instance with the modified image data. + + The pil_args and pil_kwargs are passed to the `pil_image` method of the XRImage instance. + + """ + new_array = self._delayed_apply_pil(fun, pil_args, pil_kwargs, fun_args, fun_kwargs, + self.data.attrs, output_mode) + bands = len(output_mode) + arr = da.from_delayed(new_array, dtype=self.data.dtype, + shape=(self.data.sizes['y'], self.data.sizes['x'], bands)) + + new_data = xr.DataArray(arr, dims=['y', 'x', 'bands'], + coords={'y': self.data.coords['y'], + 'x': self.data.coords['x'], + 'bands': list(output_mode)}, + attrs=self.data.attrs) + return XRImage(new_data) + def _pngmeta(self): """Return GeoImage.tags as a PNG metadata object. Inspired by: public domain, Nick Galbreath http://blog.modp.com/2007/08/python-pil-and-png-metadata-take-2.html + """ reserved = ('interlace', 'gamma', 'dpi', 'transparency', 'aspect') @@ -463,7 +679,9 @@ if np.issubdtype(data.dtype, np.integer): # xarray sometimes upcasts this calculation, so cast again null_mask = self._scale_to_dtype(null_mask, data.dtype).astype(data.dtype) + attrs = data.attrs.copy() data = xr.concat([data, null_mask], dim="bands") + data.attrs = attrs return data def _scale_to_dtype(self, data, dtype): @@ -475,6 +693,7 @@ be in the 0-1 range already. """ + attrs = data.attrs.copy() if np.issubdtype(dtype, np.integer): if np.issubdtype(data, np.integer): # preserve integer data type @@ -482,8 +701,12 @@ else: # scale float data (assumed to be 0 to 1) to full integer space dinfo = np.iinfo(dtype) - data = data.clip(0, 1) * (dinfo.max - dinfo.min) + dinfo.min + scale = dinfo.max - dinfo.min + offset = dinfo.min + data = data.clip(0, 1) * scale + offset + attrs.setdefault('enhancement_history', list()).append({'scale': scale, 'offset': offset}) data = data.round() + data.attrs = attrs return data def _check_modes(self, modes): @@ -531,8 +754,7 @@ return data def _l2rgb(self, mode): - """Convert from L (black and white) to RGB. - """ + """Convert from L (black and white) to RGB.""" self._check_modes(("L", "LA")) bands = ["L"] * 3 @@ -543,6 +765,7 @@ return data def convert(self, mode): + """Convert image to *mode*.""" if mode == self.mode: return self.__class__(self.data) @@ -588,22 +811,30 @@ return new_img def _finalize(self, fill_value=None, dtype=np.uint8, keep_palette=False, cmap=None): - """Wrapper around 'finalize' method for backwards compatibility.""" + """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 + def finalize(self, fill_value=None, dtype=np.uint8, keep_palette=False, cmap=None): """Finalize the image to be written to an output file. - This adds an alpha band or fills data with a fill_value (if specified). - It also scales float data to the output range of the data type (0-255 - for uint8, default). For integer input data this method assumes the - data is already scaled to the proper desired range. It will still fill - in invalid values and add an alpha band if needed. Integer input - data's fill value is determined by a special ``_FillValue`` attribute - in the ``DataArray`` ``.attrs`` dictionary. + This adds an alpha band or fills data with a fill_value (if + specified). It also scales float data to the output range of the + data type (0-255 for uint8, default). For integer input data + this method assumes the data is already scaled to the proper + desired range. It will still fill in invalid values and add an + alpha band if needed. Integer input data's fill value is + determined by a special ``_FillValue`` attribute in the + ``DataArray`` ``.attrs`` dictionary. """ if keep_palette and not self.mode.startswith('P'): @@ -622,31 +853,42 @@ "setting fill_value to 0") fill_value = 0 - final_data = self.data + final_data = self.data.copy() + try: + 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 - ifill = final_data.attrs.get('_FillValue') if np.issubdtype(final_data, np.integer) else None - 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).astype(dtype) - final_data = self._add_alpha(final_data, alpha=alpha) - 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) - # 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)) + with xr.set_options(keep_attrs=True): + ifill = final_data.attrs.get('_FillValue') if np.issubdtype(final_data, np.integer) else None + 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 = final_data.astype(dtype) + final_data.attrs = attrs - final_data = final_data.astype(dtype) - final_data.attrs = self.data.attrs return final_data, ''.join(final_data['bands'].values) def pil_image(self, fill_value=None, compute=True): @@ -674,19 +916,20 @@ dims=['bands'], coords={'bands': self.data['bands']}) - def gamma(self, gamma=1.0): + def gamma(self, gamma=None): """Apply gamma correction to the channels of the image. - If *gamma* is a - tuple, then it should have as many elements as the channels of the - image, and the gamma correction is applied elementwise. If *gamma* is a - number, the same gamma correction is applied on every channel, if there - are several channels in the image. The behaviour of :func:`gamma` is - undefined outside the normal [0,1] range of the channels. + If *gamma* is a tuple, then it should have as many elements as + the channels of the image, and the gamma correction is applied + elementwise. If *gamma* is a number, the same gamma correction + is applied on every channel, if there are several channels in + the image. The behaviour of :func:`gamma` is undefined outside + the normal [0,1] range of the channels. + """ if isinstance(gamma, (list, tuple)): gamma = self.xrify_tuples(gamma) - elif gamma == 1.0: + elif gamma is None or gamma == 1.0: return logger.debug("Applying gamma %s", str(gamma)) @@ -694,18 +937,21 @@ self.data = self.data.clip(min=0) self.data **= 1.0 / gamma self.data.attrs = attrs + self.data.attrs.setdefault('enhancement_history', []).append({'gamma': gamma}) def stretch(self, stretch="crude", **kwargs): """Apply stretching to the current image. - The value of *stretch* sets the type of stretching applied. The values - "histogram", "linear", "crude" (or "crude-stretch") perform respectively - histogram equalization, contrast stretching (with 5% cutoff on both - sides), and contrast stretching without cutoff. The value "logarithmic" - or "log" will do a logarithmic enhancement towards white. If a tuple or - a list of two values is given as input, then a contrast stretching is - performed with the values as cutoff. These values should be normalized - in the range [0.0,1.0]. + The value of *stretch* sets the type of stretching applied. The + values "histogram", "linear", "crude" (or "crude-stretch") + perform respectively histogram equalization, contrast stretching + (with 5% cutoff on both sides), and contrast stretching without + cutoff. The value "logarithmic" or "log" will do a logarithmic + enhancement towards white. If a tuple or a list of two values is + given as input, then a contrast stretching is performed with the + values as cutoff. These values should be normalized in the range + [0.0,1.0]. + """ logger.debug("Applying stretch %s with parameters %s", stretch, str(kwargs)) @@ -735,7 +981,7 @@ @staticmethod def _compute_quantile(data, dims, cutoffs): - """Helper method for stretch_linear. + """Compute quantile for stretch_linear. Dask delayed functions need to be non-internal functions (created inside a function) to be serializable on a multi-process scheduler. @@ -756,6 +1002,7 @@ """Stretch linearly the contrast of the current image. Use *cutoffs* for left and right trimming. + """ logger.debug("Perform a linear contrast stretch.") @@ -786,8 +1033,9 @@ def crude_stretch(self, min_stretch=None, max_stretch=None): """Perform simple linear stretching. - This is done without any cutoff on the current image and normalizes to - the [0,1] range. + This is done without any cutoff on the current image and + normalizes to the [0,1] range. + """ if min_stretch is None: non_band_dims = tuple(x for x in self.data.dims if x != 'bands') @@ -808,9 +1056,12 @@ else: scale_factor = 1.0 / delta attrs = self.data.attrs - self.data -= min_stretch + offset = -min_stretch * scale_factor self.data *= scale_factor + self.data += offset self.data.attrs = attrs + self.data.attrs.setdefault('enhancement_history', []).append({'scale': scale_factor, + 'offset': offset}) def stretch_hist_equalize(self, approximate=False): """Stretch the current image's colors through histogram equalization. @@ -858,6 +1109,7 @@ band_results.append(self.data.sel(bands='A')) self.data.data = da.stack(band_results, axis=self.data.dims.index('bands')) + self.data.attrs.setdefault('enhancement_history', []).append({'hist_equalize': True}) def stretch_logarithmic(self, factor=100.): """Move data into range [1:factor] through normalized logarithm.""" @@ -885,6 +1137,7 @@ band_results.append(self.data.sel(bands='A')) self.data.data = da.stack(band_results, axis=self.data.dims.index('bands')) + self.data.attrs.setdefault('enhancement_history', []).append({'log_factor': factor}) def stretch_weber_fechner(self, k, s0): """Stretch according to the Weber-Fechner law. @@ -892,10 +1145,12 @@ p = k.ln(S/S0) p is perception, S is the stimulus, S0 is the stimulus threshold (the highest unpercieved stimulus), and k is the factor. + """ attrs = self.data.attrs - self.data = k * xu.log(self.data / s0) + self.data = k * np.log(self.data / s0) self.data.attrs = attrs + self.data.attrs.setdefault('enhancement_history', []).append({'weber_fechner': (k, s0)}) def invert(self, invert=True): """Inverts all the channels of a image according to *invert*. @@ -905,6 +1160,7 @@ Note: 'Inverting' means that black becomes white, and vice-versa, not that the values are negated ! + """ logger.debug("Applying invert with parameters %s", str(invert)) if isinstance(invert, (tuple, list)): @@ -917,10 +1173,11 @@ attrs = self.data.attrs self.data = self.data * scale + offset self.data.attrs = attrs + self.data.attrs.setdefault('enhancement_history', []).append({'scale': scale, + 'offset': offset}) def stack(self, img): - """Stack the provided image on top of the current image. - """ + """Stack the provided image on top of the current image.""" # TODO: Conversions between different modes with notification # to the user, i.e. proper logging if self.mode != img.mode: @@ -929,8 +1186,10 @@ self.data = self.data.where(img.data.isnull(), img.data) def merge(self, img): - """Use the provided image as background for the current *img* image, - that is if the current image has missing data. + """Use the provided image as background for the current *img* image. + + That is if the current image has missing data. + """ raise NotImplementedError("This method has not be implemented for " "xarray support.") @@ -966,7 +1225,6 @@ Works only on "L" or "LA" images. """ - if self.mode not in ("L", "LA"): raise ValueError("Image should be grayscale to colorize") @@ -997,7 +1255,7 @@ @staticmethod def _palettize(data, colormap): - """Helper for dask-friendly palettize operation.""" + """Operate in a dask-friendly manner.""" # returns data and palette, only need data return colormap.palettize(data)[0] @@ -1009,7 +1267,6 @@ Works only on "L" or "LA" images. """ - if self.mode not in ("L", "LA"): raise ValueError("Image should be grayscale to colorize")