From eb5fe994a2fa7c083c82b40f9df08b2c1eef2051 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Wed, 6 Nov 2024 17:30:33 +0200 Subject: [PATCH 01/12] add gamma factor parameter --- terracotta/handlers/rgb.py | 1 + terracotta/server/rgb.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/terracotta/handlers/rgb.py b/terracotta/handlers/rgb.py index 6b75661b..e0cfded1 100644 --- a/terracotta/handlers/rgb.py +++ b/terracotta/handlers/rgb.py @@ -23,6 +23,7 @@ def rgb( tile_xyz: Optional[Tuple[int, int, int]] = None, *, stretch_ranges: Optional[ListOfRanges] = None, + gamma_factor: Optional[float] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: """Return RGB image as PNG diff --git a/terracotta/server/rgb.py b/terracotta/server/rgb.py index fe9c7a1c..0a549c11 100644 --- a/terracotta/server/rgb.py +++ b/terracotta/server/rgb.py @@ -48,7 +48,7 @@ class Meta: example="[0,1]", missing=None, description=( - "Stretch range [min, max] to use for the gren band as JSON array. " + "Stretch range [min, max] to use for the green band as JSON array. " "Min and max may be numbers to use as absolute range, or strings " "of the format `p` with an integer between 0 and 100 " "to use percentiles of the image instead. " @@ -68,6 +68,7 @@ class Meta: "Null values indicate global minimum / maximum." ), ) + gamma_factor = fields.Float(missing=None, description="Gamma factor to perform gamma correction.") tile_size = fields.List( fields.Integer(), validate=validate.Length(equal=2), @@ -165,11 +166,13 @@ def _get_rgb_image( rgb_values = (options.pop("r"), options.pop("g"), options.pop("b")) stretch_ranges = tuple(options.pop(k) for k in ("r_range", "g_range", "b_range")) + gamma_factor = options.pop("gamma_factor") image = rgb( some_keys, rgb_values, stretch_ranges=stretch_ranges, + gamma_factor=gamma_factor, tile_xyz=tile_xyz, **options, ) From 501b7428c8e1b37eb9685721fdfc0b8d8aaaffa0 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Wed, 6 Nov 2024 17:38:25 +0200 Subject: [PATCH 02/12] gamma correction for both singleband and rgb --- terracotta/handlers/rgb.py | 11 ++++++++- terracotta/handlers/singleband.py | 14 +++++++++++- terracotta/image.py | 37 ++++++++++++++++++++++++++++++- terracotta/server/singleband.py | 6 +++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/terracotta/handlers/rgb.py b/terracotta/handlers/rgb.py index e0cfded1..4d3b6d8f 100644 --- a/terracotta/handlers/rgb.py +++ b/terracotta/handlers/rgb.py @@ -89,7 +89,8 @@ def get_band_future(band_key: str) -> Future: keys = (*some_keys, band_key) metadata = driver.get_metadata(keys) - band_stretch_range = list(metadata["range"]) + band_range = list(metadata["range"]) + band_stretch_range = band_range.copy() scale_min, scale_max = band_stretch_override percentiles = metadata.get("percentiles", []) @@ -105,6 +106,14 @@ def get_band_future(band_key: str) -> Future: ) band_data = band_data_future.result() + + if gamma_factor: + # gamma correction is monotonic and preserves percentiles + band_stretch_range_arr = np.array(band_stretch_range, dtype=band_data.dtype) + band_stretch_range = list(image.gamma_correction(band_stretch_range_arr, gamma_factor, band_range)) + # gamma correct band data + band_data = image.gamma_correction(band_data, gamma_factor, band_range) + out_arrays.append(image.to_uint8(band_data, *band_stretch_range)) out = np.ma.stack(out_arrays, axis=-1) diff --git a/terracotta/handlers/singleband.py b/terracotta/handlers/singleband.py index 15311fc8..59b0b2f2 100644 --- a/terracotta/handlers/singleband.py +++ b/terracotta/handlers/singleband.py @@ -8,6 +8,8 @@ import collections +import numpy as np + from terracotta import get_settings, get_driver, image, xyz from terracotta.profile import trace @@ -26,6 +28,7 @@ def singleband( *, colormap: Union[str, Mapping[Number, RGBA], None] = None, stretch_range: Optional[Tuple[NumberOrString, NumberOrString]] = None, + gamma_factor: Optional[float] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: """Return singleband image as PNG""" @@ -61,7 +64,8 @@ def singleband( out = image.label(tile_data, labels) else: # determine stretch range from metadata and arguments - stretch_range_ = list(metadata["range"]) + band_range = list(metadata["range"]) + stretch_range_ = band_range.copy() percentiles = metadata.get("percentiles", []) if stretch_min is not None: @@ -71,6 +75,14 @@ def singleband( stretch_range_[1] = image.get_stretch_scale(stretch_max, percentiles) cmap_or_palette = cast(Optional[str], colormap) + + if gamma_factor: + # gamma correction is monotonic and preserves percentiles + band_stretch_range_arr = np.array(stretch_range_, dtype=tile_data.dtype) + stretch_range_ = list(image.gamma_correction(band_stretch_range_arr, gamma_factor, band_range)) + # gamma correct band data + tile_data = image.gamma_correction(tile_data, gamma_factor, band_range) + out = image.to_uint8(tile_data, *stretch_range_) return image.array_to_png(out, colormap=cmap_or_palette) diff --git a/terracotta/image.py b/terracotta/image.py index 44c5b664..1502ffb8 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -3,7 +3,7 @@ Utilities to create and manipulate images. """ -from typing import List, Sequence, Tuple, TypeVar, Union +from typing import List, Sequence, Tuple, TypeVar, Union, Optional from typing.io import BinaryIO from io import BytesIO @@ -162,6 +162,41 @@ def to_uint8(data: Array, lower_bound: Number, upper_bound: Number) -> Array: return rescaled.astype(np.uint8) +def gamma(arr: Array, gamma_factor: float) -> Array: + """Applies gamma correction to the input `arr`""" + return arr ** (1.0 / gamma_factor) + + +def to_math_type(arr: Array, band_range: Optional[list]) -> Array: + """Convert an array from native integer dtype range to 0..1 scaling down linearly""" + if band_range: + return contrast_stretch(arr, band_range, (0, 1)) + elif np.issubdtype(arr.dtype, np.integer): + max_int = np.iinfo(arr.dtype).max + return arr.astype(np.float32) / max_int + else: + raise exceptions.InvalidArgumentsError("No band range given and array is not of integer type") + + +def scale_dtype(arr: Array, dtype: type) -> Array: + """Convert an array from 0..1 to dtype, scaling up linearly""" + max_int = np.iinfo(dtype).max + return (arr * max_int).astype(dtype) + + +def gamma_correction( + masked_data: Array, + gamma_factor: float, + band_range: list, + out_dtype: type = np.uint16, +) -> Array: + """Apply gamma correction to the input array and scale it to the output dtype.""" + arr = to_math_type(masked_data, band_range) + arr = gamma(arr, gamma_factor) + arr = scale_dtype(arr, out_dtype) + return arr + + def label(data: Array, labels: Sequence[Number]) -> Array: """Create a labelled uint8 version of data, with output values starting at 1. diff --git a/terracotta/server/singleband.py b/terracotta/server/singleband.py index 642527e7..4fd4eb52 100644 --- a/terracotta/server/singleband.py +++ b/terracotta/server/singleband.py @@ -49,6 +49,12 @@ class Meta: missing=None, ) + gamma_factor = fields.Float( + validate=validate.Range(min=0, min_inclusive=False), + missing=None, + description="Gamma factor to perform gamma correction." + ) + colormap = fields.String( description="Colormap to apply to image (see /colormap)", validate=validate.OneOf(("explicit", *AVAILABLE_CMAPS)), From 4bcf79ce5d72500c2ad5ad7e936bf436957db885 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Thu, 7 Nov 2024 14:06:13 +0200 Subject: [PATCH 03/12] gamma_factor invalid values --- terracotta/server/rgb.py | 6 +++++- tests/server/test_flask_api.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/terracotta/server/rgb.py b/terracotta/server/rgb.py index 0a549c11..74f12a38 100644 --- a/terracotta/server/rgb.py +++ b/terracotta/server/rgb.py @@ -68,7 +68,11 @@ class Meta: "Null values indicate global minimum / maximum." ), ) - gamma_factor = fields.Float(missing=None, description="Gamma factor to perform gamma correction.") + gamma_factor = fields.Float( + validate=validate.Range(min=0, min_inclusive=False), + missing=None, + description="Gamma factor to perform gamma correction." + ) tile_size = fields.List( fields.Integer(), validate=validate.Length(equal=2), diff --git a/tests/server/test_flask_api.py b/tests/server/test_flask_api.py index d4929e52..179f10e4 100644 --- a/tests/server/test_flask_api.py +++ b/tests/server/test_flask_api.py @@ -588,3 +588,25 @@ def test_get_rgb_invalid_stretch_percentile( f"r_range={stretch_range}&b_range={stretch_range}&g_range={stretch_range}" ) assert stretch_range_params[1] in str(exc.value) + + +@pytest.mark.parametrize( + "gamma_factor_params", + [ + ['-1', "Must be greater than 0"], + ['2,2', "Not a valid number"], + ['[1]', "Not a valid number"], + ['0', "Must be greater than 0"], + ], +) +def test_get_rgb_invalid_gamma_factor( + debug_client, use_testdb, raster_file_xyz, gamma_factor_params +): + x, y, z = raster_file_xyz + gamma_factor = gamma_factor_params[0] + with pytest.raises(marshmallow.ValidationError) as exc: + debug_client.get( + f"/rgb/val21/x/{z}/{x}/{y}.png?r=val22&g=val23&b=val24&" + f"gamma_factor={gamma_factor}" + ) + assert gamma_factor_params[1] in str(exc.value) From 5bb6cccad74215eed79e1c00084553919a4ffca2 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Thu, 7 Nov 2024 14:13:47 +0200 Subject: [PATCH 04/12] include singleband test --- tests/server/test_flask_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/server/test_flask_api.py b/tests/server/test_flask_api.py index 179f10e4..106504cf 100644 --- a/tests/server/test_flask_api.py +++ b/tests/server/test_flask_api.py @@ -599,7 +599,7 @@ def test_get_rgb_invalid_stretch_percentile( ['0', "Must be greater than 0"], ], ) -def test_get_rgb_invalid_gamma_factor( +def test_get_singleband_rgb_invalid_gamma_factor( debug_client, use_testdb, raster_file_xyz, gamma_factor_params ): x, y, z = raster_file_xyz @@ -610,3 +610,9 @@ def test_get_rgb_invalid_gamma_factor( f"gamma_factor={gamma_factor}" ) assert gamma_factor_params[1] in str(exc.value) + + with pytest.raises(marshmallow.ValidationError) as exc: + debug_client.get( + f"/singleband/val11/x/val21/{z}/{x}/{y}.png?gamma_factor={gamma_factor}" + ) + assert gamma_factor_params[1] in str(exc.value) From 21055389a62c64f5e991b6cb485751f3ed281a0d Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Fri, 8 Nov 2024 01:44:07 +0200 Subject: [PATCH 05/12] add valid rgb gamma correction test --- tests/handlers/test_rgb.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/handlers/test_rgb.py b/tests/handlers/test_rgb.py index 358c4f85..58763074 100644 --- a/tests/handlers/test_rgb.py +++ b/tests/handlers/test_rgb.py @@ -222,3 +222,45 @@ def test_rgb_preview(use_testdb): raw_img = rgb.rgb(["val21", "x"], ["val22", "val23", "val24"]) img_data = np.asarray(Image.open(raw_img)) assert img_data.shape == (*terracotta.get_settings().DEFAULT_TILE_SIZE, 3) + + +def test_rgb_gamma_correction(use_testdb, testdb, raster_file_xyz): + import terracotta + from terracotta.xyz import get_tile_data + from terracotta.handlers import rgb + from terracotta import image + + ds_keys = ["val21", "x", "val22"] + bands = ["val22", "val23", "val24"] + gamma_factor = 2 + + raw_img = rgb.rgb( + ds_keys[:2], + bands, + raster_file_xyz, + gamma_factor=gamma_factor, + ) + img_data = np.asarray(Image.open(raw_img))[..., 0] + + # get non-gamma corrected data to compare to + driver = terracotta.get_driver(testdb) + + with driver.connect(): + tile_data = get_tile_data( + driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape + ) + + tile_metadata = driver.get_metadata(ds_keys) + + # non-gamma corrected uint8 data + tile_uint8 = image.to_uint8(tile_data, *tile_metadata["range"]) + + # filter transparent values + valid_mask = ~tile_data.mask + assert np.all(img_data[~valid_mask] == 0) + + valid_img = img_data[valid_mask] + valid_data = tile_uint8.compressed() + + # gamma factor of 2 is sqrt(x) in [0, 1] + assert np.all(valid_img > valid_data) From 873844af2fc7e0084e08ec2476a0b21b8c4725a2 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Fri, 8 Nov 2024 11:07:20 +0200 Subject: [PATCH 06/12] test invalid gamma factor cases --- terracotta/image.py | 3 +++ tests/handlers/test_rgb.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/terracotta/image.py b/terracotta/image.py index 1502ffb8..a21707de 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -7,6 +7,7 @@ from typing.io import BinaryIO from io import BytesIO +import numbers import numpy as np from PIL import Image @@ -191,6 +192,8 @@ def gamma_correction( out_dtype: type = np.uint16, ) -> Array: """Apply gamma correction to the input array and scale it to the output dtype.""" + if not isinstance(gamma_factor, numbers.Number) or gamma_factor <= 0: + raise exceptions.InvalidArgumentsError("Invalid gamma factor") arr = to_math_type(masked_data, band_range) arr = gamma(arr, gamma_factor) arr = scale_dtype(arr, out_dtype) diff --git a/tests/handlers/test_rgb.py b/tests/handlers/test_rgb.py index 58763074..1a01eb6f 100644 --- a/tests/handlers/test_rgb.py +++ b/tests/handlers/test_rgb.py @@ -264,3 +264,29 @@ def test_rgb_gamma_correction(use_testdb, testdb, raster_file_xyz): # gamma factor of 2 is sqrt(x) in [0, 1] assert np.all(valid_img > valid_data) + + +@pytest.mark.parametrize( + "gamma_factor_params", + [ + ['-1', "Invalid gamma factor"], + ['2,2', "Invalid gamma factor"], + ['[1]', "Invalid gamma factor"], + ['0', "Invalid gamma factor"], + ], +) +def test_rgb_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_factor_params): + from terracotta.handlers import rgb + + ds_keys = ["val21", "x", "val22"] + bands = ["val22", "val23", "val24"] + + gamma_factor = gamma_factor_params[:2] + with pytest.raises(exceptions.InvalidArgumentsError) as err: + rgb.rgb( + ds_keys[:2], + bands, + raster_file_xyz, + gamma_factor=gamma_factor, + ) + assert gamma_factor[1] in str(err.value) From f80789da5ef937fb7b9530ac5b94076f079fa62f Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Fri, 8 Nov 2024 11:25:35 +0200 Subject: [PATCH 07/12] test singleband gamma correction --- tests/handlers/test_singleband.py | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/handlers/test_singleband.py b/tests/handlers/test_singleband.py index 7029737a..10e3249c 100644 --- a/tests/handlers/test_singleband.py +++ b/tests/handlers/test_singleband.py @@ -225,3 +225,67 @@ def test_rgb_invalid_percentiles(use_testdb, stretch_range_params): stretch_range=stretch_range, ) assert stretch_range_params[2] in str(err.value) + + +def test_singleband_gamma_correction(use_testdb, testdb, raster_file_xyz): + import terracotta + from terracotta.xyz import get_tile_data + from terracotta.handlers import singleband + from terracotta import image + + ds_keys = ["val21", "x", "val22"] + gamma_factor = 0.5 + + raw_img = singleband.singleband( + ds_keys, + tile_xyz=raster_file_xyz, + gamma_factor=gamma_factor, + ) + img_data = np.asarray(Image.open(raw_img)) + + # get unstretched data to compare to + driver = terracotta.get_driver(testdb) + + with driver.connect(): + tile_data = get_tile_data( + driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape + ) + + tile_metadata = driver.get_metadata(ds_keys) + + # non-gamma corrected uint8 data + tile_uint8 = image.to_uint8(tile_data, *tile_metadata["range"]) + + # filter transparent values + valid_mask = ~tile_data.mask + assert np.all(img_data[~valid_mask] == 0) + + valid_img = img_data[valid_mask] + valid_data = tile_uint8.compressed() + + # gamma factor of 0.5 is x^2 in [0, 1] + assert np.all(valid_img < valid_data) + + +@pytest.mark.parametrize( + "gamma_factor_params", + [ + ['-1', "Invalid gamma factor"], + ['2,2', "Invalid gamma factor"], + ['[1]', "Invalid gamma factor"], + ['0', "Invalid gamma factor"], + ], +) +def test_singleband_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_factor_params): + from terracotta.handlers import singleband + + ds_keys = ["val21", "x", "val22"] + + gamma_factor = gamma_factor_params[:2] + with pytest.raises(exceptions.InvalidArgumentsError) as err: + singleband.singleband( + ds_keys, + raster_file_xyz, + gamma_factor=gamma_factor, + ) + assert gamma_factor[1] in str(err.value) From ec58a7d47f928d46d9c423dd75f1ca3ea5c02324 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Fri, 8 Nov 2024 13:38:04 +0200 Subject: [PATCH 08/12] test both gamma correction and stretch --- tests/handlers/test_rgb.py | 49 +++++++++++++++++++++++++++++++ tests/handlers/test_singleband.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/tests/handlers/test_rgb.py b/tests/handlers/test_rgb.py index 1a01eb6f..6d924ad1 100644 --- a/tests/handlers/test_rgb.py +++ b/tests/handlers/test_rgb.py @@ -290,3 +290,52 @@ def test_rgb_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_factor_para gamma_factor=gamma_factor, ) assert gamma_factor[1] in str(err.value) + + +def test_rgb_stretch_gamma_correction(use_testdb, testdb, raster_file_xyz): + import terracotta + from terracotta.xyz import get_tile_data + from terracotta.handlers import rgb + + ds_keys = ["val21", "x", "val22"] + bands = ["val22", "val23", "val24"] + gamma_factor = 2 + pct_stretch_range = ["p2", "p98"] + + raw_img = rgb.rgb( + ds_keys[:2], + bands, + raster_file_xyz, + gamma_factor=gamma_factor, + stretch_ranges=[pct_stretch_range] * 3, + ) + img_data = np.asarray(Image.open(raw_img))[..., 0] + + # get unstretched data to compare to + driver = terracotta.get_driver(testdb) + + with driver.connect(): + tile_data = get_tile_data( + driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape + ) + band_metadata = driver.get_metadata(ds_keys) + + stretch_range = [ + band_metadata["percentiles"][1], + band_metadata["percentiles"][97], + ] + + # filter transparent values + valid_mask = ~tile_data.mask + assert np.all(img_data[~valid_mask] == 0) + + valid_img = img_data[valid_mask] + valid_data = tile_data.compressed() + + assert np.all(valid_img[valid_data < stretch_range[0]] == 1) + stretch_range_mask = (valid_data > stretch_range[0]) & ( + valid_data < stretch_range[1] + ) + assert np.all(valid_img[stretch_range_mask] >= 1) + assert np.all(valid_img[stretch_range_mask] <= 255) + assert np.all(valid_img[valid_data > stretch_range[1]] == 255) diff --git a/tests/handlers/test_singleband.py b/tests/handlers/test_singleband.py index 10e3249c..d3e37753 100644 --- a/tests/handlers/test_singleband.py +++ b/tests/handlers/test_singleband.py @@ -289,3 +289,51 @@ def test_singleband_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_fact gamma_factor=gamma_factor, ) assert gamma_factor[1] in str(err.value) + + +def test_singleband_stretch_gamma(use_testdb, testdb, raster_file_xyz): + import terracotta + from terracotta.xyz import get_tile_data + from terracotta.handlers import singleband + + ds_keys = ["val21", "x", "val22"] + gamma_factor = 2 + pct_stretch_range = ["p2", "p98"] + + raw_img = singleband.singleband( + ds_keys, + tile_xyz=raster_file_xyz, + gamma_factor=gamma_factor, + stretch_range=pct_stretch_range, + ) + img_data = np.asarray(Image.open(raw_img)) + + # get unstretched data to compare to + driver = terracotta.get_driver(testdb) + + with driver.connect(): + tile_data = get_tile_data( + driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape + ) + + band_metadata = driver.get_metadata(ds_keys) + + stretch_range = [ + band_metadata["percentiles"][1], + band_metadata["percentiles"][97], + ] + + # filter transparent values + valid_mask = ~tile_data.mask + assert np.all(img_data[~valid_mask] == 0) + + valid_img = img_data[valid_mask] + valid_data = tile_data.compressed() + + assert np.all(valid_img[valid_data < stretch_range[0]] == 1) + stretch_range_mask = (valid_data > stretch_range[0]) & ( + valid_data < stretch_range[1] + ) + assert np.all(valid_img[stretch_range_mask] >= 1) + assert np.all(valid_img[stretch_range_mask] <= 255) + assert np.all(valid_img[valid_data > stretch_range[1]] == 255) From 6efe415363ff5caa8da54cae4f63a4c74a4960b3 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Wed, 13 Nov 2024 01:55:37 +0200 Subject: [PATCH 09/12] use color-operations for gamma implementation and utils --- setup.py | 1 + terracotta/image.py | 33 ++++++++++----------------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/setup.py b/setup.py index 1f743d41..421257e5 100644 --- a/setup.py +++ b/setup.py @@ -64,6 +64,7 @@ "cachetools>=3.1.0", "click", "click-spinner", + "color-operations", "flask", "flask_cors", "marshmallow>=3.0.0", diff --git a/terracotta/image.py b/terracotta/image.py index a21707de..62788b14 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -11,6 +11,8 @@ import numpy as np from PIL import Image +from color_operations.operations import gamma +from color_operations.utils import to_math_type, scale_dtype from terracotta.profile import trace from terracotta import exceptions, get_settings @@ -163,28 +165,6 @@ def to_uint8(data: Array, lower_bound: Number, upper_bound: Number) -> Array: return rescaled.astype(np.uint8) -def gamma(arr: Array, gamma_factor: float) -> Array: - """Applies gamma correction to the input `arr`""" - return arr ** (1.0 / gamma_factor) - - -def to_math_type(arr: Array, band_range: Optional[list]) -> Array: - """Convert an array from native integer dtype range to 0..1 scaling down linearly""" - if band_range: - return contrast_stretch(arr, band_range, (0, 1)) - elif np.issubdtype(arr.dtype, np.integer): - max_int = np.iinfo(arr.dtype).max - return arr.astype(np.float32) / max_int - else: - raise exceptions.InvalidArgumentsError("No band range given and array is not of integer type") - - -def scale_dtype(arr: Array, dtype: type) -> Array: - """Convert an array from 0..1 to dtype, scaling up linearly""" - max_int = np.iinfo(dtype).max - return (arr * max_int).astype(dtype) - - def gamma_correction( masked_data: Array, gamma_factor: float, @@ -194,7 +174,14 @@ def gamma_correction( """Apply gamma correction to the input array and scale it to the output dtype.""" if not isinstance(gamma_factor, numbers.Number) or gamma_factor <= 0: raise exceptions.InvalidArgumentsError("Invalid gamma factor") - arr = to_math_type(masked_data, band_range) + + if band_range: + arr = contrast_stretch(masked_data, band_range, (0, 1)) + elif np.issubdtype(masked_data.dtype, np.integer): + arr = to_math_type(masked_data) + else: + raise exceptions.InvalidArgumentsError("No band range given and array is not of integer type") + arr = gamma(arr, gamma_factor) arr = scale_dtype(arr, out_dtype) return arr From 993ed667f9189f12cb2671dda2187a4898e6158f Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Thu, 12 Dec 2024 11:58:34 +0200 Subject: [PATCH 10/12] use color_operations for custom color transforms --- terracotta/handlers/rgb.py | 23 ++++++++++++++++------- terracotta/image.py | 16 ++++++++++++++++ terracotta/server/rgb.py | 4 ++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/terracotta/handlers/rgb.py b/terracotta/handlers/rgb.py index 4d3b6d8f..f5f2b46e 100644 --- a/terracotta/handlers/rgb.py +++ b/terracotta/handlers/rgb.py @@ -24,6 +24,7 @@ def rgb( *, stretch_ranges: Optional[ListOfRanges] = None, gamma_factor: Optional[float] = None, + color_transform: Optional[str] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: """Return RGB image as PNG @@ -81,6 +82,7 @@ def get_band_future(band_key: str) -> Future: futures = [get_band_future(key) for key in rgb_values] band_items = zip(rgb_values, stretch_ranges_, futures) + out_ranges = [] out_arrays = [] for i, (band_key, band_stretch_override, band_data_future) in enumerate( @@ -107,14 +109,21 @@ def get_band_future(band_key: str) -> Future: band_data = band_data_future.result() - if gamma_factor: - # gamma correction is monotonic and preserves percentiles - band_stretch_range_arr = np.array(band_stretch_range, dtype=band_data.dtype) - band_stretch_range = list(image.gamma_correction(band_stretch_range_arr, gamma_factor, band_range)) - # gamma correct band data - band_data = image.gamma_correction(band_data, gamma_factor, band_range) + out_ranges.append(band_stretch_range) + out_arrays.append(band_data) - out_arrays.append(image.to_uint8(band_data, *band_stretch_range)) + out = np.ma.stack(out_arrays, axis=0) + + if color_transform: + band_stretch_range_arr = [np.array(band_rng, dtype=band_data.dtype) for band_rng in out_ranges] + band_stretch_range_arr = np.ma.stack(band_stretch_range_arr, axis=0) + + band_stretch_range_arr = image.apply_color_transform(band_stretch_range_arr, color_transform) + band_data = image.apply_color_transform(out, color_transform) + + out_arrays = [] + for k in range(band_data.shape[0]): + out_arrays.append(image.to_uint8(band_data[k], *band_stretch_range_arr[k])) out = np.ma.stack(out_arrays, axis=-1) return image.array_to_png(out) diff --git a/terracotta/image.py b/terracotta/image.py index 62788b14..76ec45dc 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -11,6 +11,7 @@ import numpy as np from PIL import Image +from color_operations import parse_operations from color_operations.operations import gamma from color_operations.utils import to_math_type, scale_dtype @@ -187,6 +188,21 @@ def gamma_correction( return arr +def apply_color_transform( + masked_data: Array, + color_transform: str, + out_dtype: type = np.uint16, +) -> Array: + """Apply gamma correction to the input array and scale it to the output dtype.""" + arr = to_math_type(masked_data) + + for func in parse_operations(color_transform): + arr = func(arr) + + arr = scale_dtype(arr, out_dtype) + return arr + + def label(data: Array, labels: Sequence[Number]) -> Array: """Create a labelled uint8 version of data, with output values starting at 1. diff --git a/terracotta/server/rgb.py b/terracotta/server/rgb.py index 74f12a38..2627a290 100644 --- a/terracotta/server/rgb.py +++ b/terracotta/server/rgb.py @@ -73,6 +73,10 @@ class Meta: missing=None, description="Gamma factor to perform gamma correction." ) + color_transform = fields.String( + missing=None, + description="Gamma factor to perform gamma correction." + ) tile_size = fields.List( fields.Integer(), validate=validate.Length(equal=2), From 21f2db2549377ad80426ab1982f4af76a5cc8b02 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Wed, 1 Jan 2025 23:57:45 +0200 Subject: [PATCH 11/12] remove gamma correction references --- terracotta/handlers/rgb.py | 1 - terracotta/handlers/singleband.py | 10 --- terracotta/image.py | 28 +------ terracotta/server/rgb.py | 7 -- terracotta/server/singleband.py | 6 -- tests/handlers/test_rgb.py | 117 ------------------------------ tests/handlers/test_singleband.py | 112 ---------------------------- tests/server/test_flask_api.py | 28 ------- 8 files changed, 2 insertions(+), 307 deletions(-) diff --git a/terracotta/handlers/rgb.py b/terracotta/handlers/rgb.py index f5f2b46e..916ebf98 100644 --- a/terracotta/handlers/rgb.py +++ b/terracotta/handlers/rgb.py @@ -23,7 +23,6 @@ def rgb( tile_xyz: Optional[Tuple[int, int, int]] = None, *, stretch_ranges: Optional[ListOfRanges] = None, - gamma_factor: Optional[float] = None, color_transform: Optional[str] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: diff --git a/terracotta/handlers/singleband.py b/terracotta/handlers/singleband.py index 59b0b2f2..e85d5802 100644 --- a/terracotta/handlers/singleband.py +++ b/terracotta/handlers/singleband.py @@ -8,8 +8,6 @@ import collections -import numpy as np - from terracotta import get_settings, get_driver, image, xyz from terracotta.profile import trace @@ -28,7 +26,6 @@ def singleband( *, colormap: Union[str, Mapping[Number, RGBA], None] = None, stretch_range: Optional[Tuple[NumberOrString, NumberOrString]] = None, - gamma_factor: Optional[float] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: """Return singleband image as PNG""" @@ -76,13 +73,6 @@ def singleband( cmap_or_palette = cast(Optional[str], colormap) - if gamma_factor: - # gamma correction is monotonic and preserves percentiles - band_stretch_range_arr = np.array(stretch_range_, dtype=tile_data.dtype) - stretch_range_ = list(image.gamma_correction(band_stretch_range_arr, gamma_factor, band_range)) - # gamma correct band data - tile_data = image.gamma_correction(tile_data, gamma_factor, band_range) - out = image.to_uint8(tile_data, *stretch_range_) return image.array_to_png(out, colormap=cmap_or_palette) diff --git a/terracotta/image.py b/terracotta/image.py index 76ec45dc..6fc2736a 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -3,17 +3,15 @@ Utilities to create and manipulate images. """ -from typing import List, Sequence, Tuple, TypeVar, Union, Optional +from typing import List, Sequence, Tuple, TypeVar, Union from typing.io import BinaryIO from io import BytesIO -import numbers import numpy as np from PIL import Image from color_operations import parse_operations -from color_operations.operations import gamma -from color_operations.utils import to_math_type, scale_dtype +from color_operations.utils import to_math_type from terracotta.profile import trace from terracotta import exceptions, get_settings @@ -166,28 +164,6 @@ def to_uint8(data: Array, lower_bound: Number, upper_bound: Number) -> Array: return rescaled.astype(np.uint8) -def gamma_correction( - masked_data: Array, - gamma_factor: float, - band_range: list, - out_dtype: type = np.uint16, -) -> Array: - """Apply gamma correction to the input array and scale it to the output dtype.""" - if not isinstance(gamma_factor, numbers.Number) or gamma_factor <= 0: - raise exceptions.InvalidArgumentsError("Invalid gamma factor") - - if band_range: - arr = contrast_stretch(masked_data, band_range, (0, 1)) - elif np.issubdtype(masked_data.dtype, np.integer): - arr = to_math_type(masked_data) - else: - raise exceptions.InvalidArgumentsError("No band range given and array is not of integer type") - - arr = gamma(arr, gamma_factor) - arr = scale_dtype(arr, out_dtype) - return arr - - def apply_color_transform( masked_data: Array, color_transform: str, diff --git a/terracotta/server/rgb.py b/terracotta/server/rgb.py index 2627a290..835475c4 100644 --- a/terracotta/server/rgb.py +++ b/terracotta/server/rgb.py @@ -68,11 +68,6 @@ class Meta: "Null values indicate global minimum / maximum." ), ) - gamma_factor = fields.Float( - validate=validate.Range(min=0, min_inclusive=False), - missing=None, - description="Gamma factor to perform gamma correction." - ) color_transform = fields.String( missing=None, description="Gamma factor to perform gamma correction." @@ -174,13 +169,11 @@ def _get_rgb_image( rgb_values = (options.pop("r"), options.pop("g"), options.pop("b")) stretch_ranges = tuple(options.pop(k) for k in ("r_range", "g_range", "b_range")) - gamma_factor = options.pop("gamma_factor") image = rgb( some_keys, rgb_values, stretch_ranges=stretch_ranges, - gamma_factor=gamma_factor, tile_xyz=tile_xyz, **options, ) diff --git a/terracotta/server/singleband.py b/terracotta/server/singleband.py index 4fd4eb52..642527e7 100644 --- a/terracotta/server/singleband.py +++ b/terracotta/server/singleband.py @@ -49,12 +49,6 @@ class Meta: missing=None, ) - gamma_factor = fields.Float( - validate=validate.Range(min=0, min_inclusive=False), - missing=None, - description="Gamma factor to perform gamma correction." - ) - colormap = fields.String( description="Colormap to apply to image (see /colormap)", validate=validate.OneOf(("explicit", *AVAILABLE_CMAPS)), diff --git a/tests/handlers/test_rgb.py b/tests/handlers/test_rgb.py index 6d924ad1..358c4f85 100644 --- a/tests/handlers/test_rgb.py +++ b/tests/handlers/test_rgb.py @@ -222,120 +222,3 @@ def test_rgb_preview(use_testdb): raw_img = rgb.rgb(["val21", "x"], ["val22", "val23", "val24"]) img_data = np.asarray(Image.open(raw_img)) assert img_data.shape == (*terracotta.get_settings().DEFAULT_TILE_SIZE, 3) - - -def test_rgb_gamma_correction(use_testdb, testdb, raster_file_xyz): - import terracotta - from terracotta.xyz import get_tile_data - from terracotta.handlers import rgb - from terracotta import image - - ds_keys = ["val21", "x", "val22"] - bands = ["val22", "val23", "val24"] - gamma_factor = 2 - - raw_img = rgb.rgb( - ds_keys[:2], - bands, - raster_file_xyz, - gamma_factor=gamma_factor, - ) - img_data = np.asarray(Image.open(raw_img))[..., 0] - - # get non-gamma corrected data to compare to - driver = terracotta.get_driver(testdb) - - with driver.connect(): - tile_data = get_tile_data( - driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape - ) - - tile_metadata = driver.get_metadata(ds_keys) - - # non-gamma corrected uint8 data - tile_uint8 = image.to_uint8(tile_data, *tile_metadata["range"]) - - # filter transparent values - valid_mask = ~tile_data.mask - assert np.all(img_data[~valid_mask] == 0) - - valid_img = img_data[valid_mask] - valid_data = tile_uint8.compressed() - - # gamma factor of 2 is sqrt(x) in [0, 1] - assert np.all(valid_img > valid_data) - - -@pytest.mark.parametrize( - "gamma_factor_params", - [ - ['-1', "Invalid gamma factor"], - ['2,2', "Invalid gamma factor"], - ['[1]', "Invalid gamma factor"], - ['0', "Invalid gamma factor"], - ], -) -def test_rgb_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_factor_params): - from terracotta.handlers import rgb - - ds_keys = ["val21", "x", "val22"] - bands = ["val22", "val23", "val24"] - - gamma_factor = gamma_factor_params[:2] - with pytest.raises(exceptions.InvalidArgumentsError) as err: - rgb.rgb( - ds_keys[:2], - bands, - raster_file_xyz, - gamma_factor=gamma_factor, - ) - assert gamma_factor[1] in str(err.value) - - -def test_rgb_stretch_gamma_correction(use_testdb, testdb, raster_file_xyz): - import terracotta - from terracotta.xyz import get_tile_data - from terracotta.handlers import rgb - - ds_keys = ["val21", "x", "val22"] - bands = ["val22", "val23", "val24"] - gamma_factor = 2 - pct_stretch_range = ["p2", "p98"] - - raw_img = rgb.rgb( - ds_keys[:2], - bands, - raster_file_xyz, - gamma_factor=gamma_factor, - stretch_ranges=[pct_stretch_range] * 3, - ) - img_data = np.asarray(Image.open(raw_img))[..., 0] - - # get unstretched data to compare to - driver = terracotta.get_driver(testdb) - - with driver.connect(): - tile_data = get_tile_data( - driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape - ) - band_metadata = driver.get_metadata(ds_keys) - - stretch_range = [ - band_metadata["percentiles"][1], - band_metadata["percentiles"][97], - ] - - # filter transparent values - valid_mask = ~tile_data.mask - assert np.all(img_data[~valid_mask] == 0) - - valid_img = img_data[valid_mask] - valid_data = tile_data.compressed() - - assert np.all(valid_img[valid_data < stretch_range[0]] == 1) - stretch_range_mask = (valid_data > stretch_range[0]) & ( - valid_data < stretch_range[1] - ) - assert np.all(valid_img[stretch_range_mask] >= 1) - assert np.all(valid_img[stretch_range_mask] <= 255) - assert np.all(valid_img[valid_data > stretch_range[1]] == 255) diff --git a/tests/handlers/test_singleband.py b/tests/handlers/test_singleband.py index d3e37753..7029737a 100644 --- a/tests/handlers/test_singleband.py +++ b/tests/handlers/test_singleband.py @@ -225,115 +225,3 @@ def test_rgb_invalid_percentiles(use_testdb, stretch_range_params): stretch_range=stretch_range, ) assert stretch_range_params[2] in str(err.value) - - -def test_singleband_gamma_correction(use_testdb, testdb, raster_file_xyz): - import terracotta - from terracotta.xyz import get_tile_data - from terracotta.handlers import singleband - from terracotta import image - - ds_keys = ["val21", "x", "val22"] - gamma_factor = 0.5 - - raw_img = singleband.singleband( - ds_keys, - tile_xyz=raster_file_xyz, - gamma_factor=gamma_factor, - ) - img_data = np.asarray(Image.open(raw_img)) - - # get unstretched data to compare to - driver = terracotta.get_driver(testdb) - - with driver.connect(): - tile_data = get_tile_data( - driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape - ) - - tile_metadata = driver.get_metadata(ds_keys) - - # non-gamma corrected uint8 data - tile_uint8 = image.to_uint8(tile_data, *tile_metadata["range"]) - - # filter transparent values - valid_mask = ~tile_data.mask - assert np.all(img_data[~valid_mask] == 0) - - valid_img = img_data[valid_mask] - valid_data = tile_uint8.compressed() - - # gamma factor of 0.5 is x^2 in [0, 1] - assert np.all(valid_img < valid_data) - - -@pytest.mark.parametrize( - "gamma_factor_params", - [ - ['-1', "Invalid gamma factor"], - ['2,2', "Invalid gamma factor"], - ['[1]', "Invalid gamma factor"], - ['0', "Invalid gamma factor"], - ], -) -def test_singleband_invalid_gamma_factor(use_testdb, raster_file_xyz, gamma_factor_params): - from terracotta.handlers import singleband - - ds_keys = ["val21", "x", "val22"] - - gamma_factor = gamma_factor_params[:2] - with pytest.raises(exceptions.InvalidArgumentsError) as err: - singleband.singleband( - ds_keys, - raster_file_xyz, - gamma_factor=gamma_factor, - ) - assert gamma_factor[1] in str(err.value) - - -def test_singleband_stretch_gamma(use_testdb, testdb, raster_file_xyz): - import terracotta - from terracotta.xyz import get_tile_data - from terracotta.handlers import singleband - - ds_keys = ["val21", "x", "val22"] - gamma_factor = 2 - pct_stretch_range = ["p2", "p98"] - - raw_img = singleband.singleband( - ds_keys, - tile_xyz=raster_file_xyz, - gamma_factor=gamma_factor, - stretch_range=pct_stretch_range, - ) - img_data = np.asarray(Image.open(raw_img)) - - # get unstretched data to compare to - driver = terracotta.get_driver(testdb) - - with driver.connect(): - tile_data = get_tile_data( - driver, ds_keys, tile_xyz=raster_file_xyz, tile_size=img_data.shape - ) - - band_metadata = driver.get_metadata(ds_keys) - - stretch_range = [ - band_metadata["percentiles"][1], - band_metadata["percentiles"][97], - ] - - # filter transparent values - valid_mask = ~tile_data.mask - assert np.all(img_data[~valid_mask] == 0) - - valid_img = img_data[valid_mask] - valid_data = tile_data.compressed() - - assert np.all(valid_img[valid_data < stretch_range[0]] == 1) - stretch_range_mask = (valid_data > stretch_range[0]) & ( - valid_data < stretch_range[1] - ) - assert np.all(valid_img[stretch_range_mask] >= 1) - assert np.all(valid_img[stretch_range_mask] <= 255) - assert np.all(valid_img[valid_data > stretch_range[1]] == 255) diff --git a/tests/server/test_flask_api.py b/tests/server/test_flask_api.py index 106504cf..d4929e52 100644 --- a/tests/server/test_flask_api.py +++ b/tests/server/test_flask_api.py @@ -588,31 +588,3 @@ def test_get_rgb_invalid_stretch_percentile( f"r_range={stretch_range}&b_range={stretch_range}&g_range={stretch_range}" ) assert stretch_range_params[1] in str(exc.value) - - -@pytest.mark.parametrize( - "gamma_factor_params", - [ - ['-1', "Must be greater than 0"], - ['2,2', "Not a valid number"], - ['[1]', "Not a valid number"], - ['0', "Must be greater than 0"], - ], -) -def test_get_singleband_rgb_invalid_gamma_factor( - debug_client, use_testdb, raster_file_xyz, gamma_factor_params -): - x, y, z = raster_file_xyz - gamma_factor = gamma_factor_params[0] - with pytest.raises(marshmallow.ValidationError) as exc: - debug_client.get( - f"/rgb/val21/x/{z}/{x}/{y}.png?r=val22&g=val23&b=val24&" - f"gamma_factor={gamma_factor}" - ) - assert gamma_factor_params[1] in str(exc.value) - - with pytest.raises(marshmallow.ValidationError) as exc: - debug_client.get( - f"/singleband/val11/x/val21/{z}/{x}/{y}.png?gamma_factor={gamma_factor}" - ) - assert gamma_factor_params[1] in str(exc.value) From 4577289e7efd447e8536aba6ac9f1f9471534eb8 Mon Sep 17 00:00:00 2001 From: Biser Hong Date: Thu, 2 Jan 2025 12:25:12 +0200 Subject: [PATCH 12/12] color transform for singleband and rgb --- terracotta/handlers/rgb.py | 12 ++++++------ terracotta/handlers/singleband.py | 11 +++++++++++ terracotta/image.py | 12 +++++++++--- terracotta/server/fields.py | 18 ++++++++++++++++++ terracotta/server/rgb.py | 5 +++-- terracotta/server/singleband.py | 10 +++++++++- 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/terracotta/handlers/rgb.py b/terracotta/handlers/rgb.py index 916ebf98..44d70362 100644 --- a/terracotta/handlers/rgb.py +++ b/terracotta/handlers/rgb.py @@ -111,18 +111,18 @@ def get_band_future(band_key: str) -> Future: out_ranges.append(band_stretch_range) out_arrays.append(band_data) - out = np.ma.stack(out_arrays, axis=0) + band_data = np.ma.stack(out_arrays, axis=0) if color_transform: - band_stretch_range_arr = [np.array(band_rng, dtype=band_data.dtype) for band_rng in out_ranges] - band_stretch_range_arr = np.ma.stack(band_stretch_range_arr, axis=0) + out_ranges = [np.array(band_rng, dtype=band_data.dtype) for band_rng in out_ranges] + out_ranges = np.ma.stack(out_ranges, axis=0) - band_stretch_range_arr = image.apply_color_transform(band_stretch_range_arr, color_transform) - band_data = image.apply_color_transform(out, color_transform) + out_ranges = image.apply_color_transform(out_ranges, color_transform, band_range) + band_data = image.apply_color_transform(band_data, color_transform, band_range) out_arrays = [] for k in range(band_data.shape[0]): - out_arrays.append(image.to_uint8(band_data[k], *band_stretch_range_arr[k])) + out_arrays.append(image.to_uint8(band_data[k], *out_ranges[k])) out = np.ma.stack(out_arrays, axis=-1) return image.array_to_png(out) diff --git a/terracotta/handlers/singleband.py b/terracotta/handlers/singleband.py index e85d5802..58f58d3a 100644 --- a/terracotta/handlers/singleband.py +++ b/terracotta/handlers/singleband.py @@ -8,6 +8,8 @@ import collections +import numpy as np + from terracotta import get_settings, get_driver, image, xyz from terracotta.profile import trace @@ -26,6 +28,7 @@ def singleband( *, colormap: Union[str, Mapping[Number, RGBA], None] = None, stretch_range: Optional[Tuple[NumberOrString, NumberOrString]] = None, + color_transform: Optional[str] = None, tile_size: Optional[Tuple[int, int]] = None ) -> BinaryIO: """Return singleband image as PNG""" @@ -73,6 +76,14 @@ def singleband( cmap_or_palette = cast(Optional[str], colormap) + if color_transform: + stretch_range_ = np.array(stretch_range_, dtype=tile_data.dtype) + stretch_range_ = np.ma.stack(stretch_range_, axis=0) + + stretch_range_ = image.apply_color_transform(stretch_range_, color_transform, band_range) + tile_data = np.expand_dims(tile_data, axis=0) + tile_data = image.apply_color_transform(tile_data, color_transform, band_range)[0] + out = image.to_uint8(tile_data, *stretch_range_) return image.array_to_png(out, colormap=cmap_or_palette) diff --git a/terracotta/image.py b/terracotta/image.py index 6fc2736a..5d511ad9 100755 --- a/terracotta/image.py +++ b/terracotta/image.py @@ -167,15 +167,21 @@ def to_uint8(data: Array, lower_bound: Number, upper_bound: Number) -> Array: def apply_color_transform( masked_data: Array, color_transform: str, - out_dtype: type = np.uint16, + band_range: list, ) -> Array: """Apply gamma correction to the input array and scale it to the output dtype.""" - arr = to_math_type(masked_data) + + if band_range: + arr = contrast_stretch(masked_data, band_range, (0, 1)) + elif np.issubdtype(masked_data.dtype, np.integer): + arr = to_math_type(masked_data) + else: + raise exceptions.InvalidArgumentsError("No band range given and array is not of integer type") + for func in parse_operations(color_transform): arr = func(arr) - arr = scale_dtype(arr, out_dtype) return arr diff --git a/terracotta/server/fields.py b/terracotta/server/fields.py index 401610af..8f93eb6c 100644 --- a/terracotta/server/fields.py +++ b/terracotta/server/fields.py @@ -8,6 +8,8 @@ from typing import Any, Union +from color_operations import parse_operations + class StringOrNumber(fields.Field): """ @@ -45,3 +47,19 @@ def validate_stretch_range(data: Any) -> None: if isinstance(data, str): if not re.match("^p\\d+$", data): raise ValidationError("Percentile format is `p`") + + +def validate_color_transform(data: Any) -> None: + """ + Validate that the color transform is a string and can be parsed by `color_operations`. + """ + if not isinstance(data, str): + raise ValidationError("Color transform needs to be a string") + + if "saturation" in data: + raise ValidationError("Saturation is currently not supported") + + try: + parse_operations(data) + except (ValueError, KeyError): + raise ValidationError("Invalid color transform") diff --git a/terracotta/server/rgb.py b/terracotta/server/rgb.py index 835475c4..25103497 100644 --- a/terracotta/server/rgb.py +++ b/terracotta/server/rgb.py @@ -9,7 +9,7 @@ from marshmallow import Schema, fields, validate, pre_load, ValidationError, EXCLUDE from flask import request, send_file, Response -from terracotta.server.fields import StringOrNumber, validate_stretch_range +from terracotta.server.fields import StringOrNumber, validate_stretch_range, validate_color_transform from terracotta.server.flask_api import TILE_API @@ -69,8 +69,9 @@ class Meta: ), ) color_transform = fields.String( + validate=validate_color_transform, missing=None, - description="Gamma factor to perform gamma correction." + description="Color transform DSL string from color-operations.", ) tile_size = fields.List( fields.Integer(), diff --git a/terracotta/server/singleband.py b/terracotta/server/singleband.py index 642527e7..39db6227 100644 --- a/terracotta/server/singleband.py +++ b/terracotta/server/singleband.py @@ -17,7 +17,7 @@ ) from flask import request, send_file, Response -from terracotta.server.fields import StringOrNumber, validate_stretch_range +from terracotta.server.fields import StringOrNumber, validate_stretch_range, validate_color_transform from terracotta.server.flask_api import TILE_API from terracotta.cmaps import AVAILABLE_CMAPS @@ -65,6 +65,14 @@ class Meta: "hex strings.", ) + color_transform = fields.String( + validate=validate_color_transform, + missing=None, + example="gamma 1 1.5, sigmoidal 1 15 0.5", + description="Color transform DSL string from color-operations." + "All color operations for singleband should specify band 1.", + ) + tile_size = fields.List( fields.Integer(), validate=validate.Length(equal=2),