Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rgb_range percentile support #322

Merged
merged 16 commits into from
Jan 7, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions terracotta/handlers/rgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from terracotta.profile import trace

Number = TypeVar("Number", int, float)
ListOfRanges = Sequence[Optional[Tuple[Optional[Number], Optional[Number]]]]
NumberOrString = TypeVar("NumberOrString", int, float, str)
ListOfRanges = Sequence[Optional[Tuple[Optional[NumberOrString], Optional[NumberOrString]]]]


@trace("rgb_handler")
Expand Down Expand Up @@ -90,10 +91,10 @@
scale_min, scale_max = band_stretch_override

if scale_min is not None:
band_stretch_range[0] = scale_min
band_stretch_range[0] = get_scale(scale_min, metadata)

if scale_max is not None:
band_stretch_range[1] = scale_max
band_stretch_range[1] = get_scale(scale_max, metadata)

if band_stretch_range[1] < band_stretch_range[0]:
raise exceptions.InvalidArgumentsError(
Expand All @@ -105,3 +106,20 @@

out = np.ma.stack(out_arrays, axis=-1)
return image.array_to_png(out)


def get_scale(scale: NumberOrString, metadata) -> Number:
if isinstance(scale, (int, float)):
return scale
if isinstance(scale, str):
# can be a percentile
if scale.startswith("p"):
# TODO check if percentile is in range
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
percentile = int(scale[1:]) - 1
return metadata["percentiles"][percentile]

# can be a number
return float(scale)
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
raise exceptions.InvalidArgumentsError(

Check warning on line 123 in terracotta/handlers/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/handlers/rgb.py#L123

Added line #L123 was not covered by tests
"Invalid scale value: %s" % scale
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
)
52 changes: 46 additions & 6 deletions terracotta/server/rgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from typing import Optional, Any, Mapping, Dict, Tuple
import json
import re

from marshmallow import Schema, fields, validate, pre_load, ValidationError, EXCLUDE
from flask import request, send_file, Response
Expand All @@ -21,6 +22,36 @@
tile_x = fields.Int(required=True, description="x coordinate")


def validate_range(data):
if isinstance(data, str) and data.startswith("p"):
if not re.match("^p\d+$", data):
raise ValidationError("Percentile format is `p<digits>`")

Check warning on line 28 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L28

Added line #L28 was not covered by tests
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
else:
try:
float(data)
except ValueError:
raise ValidationError("Must be a number")

Check warning on line 33 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L32-L33

Added lines #L32 - L33 were not covered by tests
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved



class StringOrNumber(fields.Field):
def _serialize(self, value, attr, obj, **kwargs):
if isinstance(value, (str, bytes)):
return fields.String()._serialize(value, attr, obj, **kwargs)

Check warning on line 40 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L40

Added line #L40 was not covered by tests
elif isinstance(value, (int, float)):
return fields.Float()._serialize(value, attr, obj, **kwargs)

Check warning on line 42 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L42

Added line #L42 was not covered by tests
else:
raise ValidationError("Must be a string or a number")

Check warning on line 44 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L44

Added line #L44 was not covered by tests

def _deserialize(self, value, attr, data, **kwargs):
if isinstance(value, (str, bytes)):
return fields.String()._deserialize(value, attr, data, **kwargs)
elif isinstance(value, (int, float)):
return fields.Float()._deserialize(value, attr, data, **kwargs)
else:
raise ValidationError("Must be a string or a number")

Check warning on line 52 in terracotta/server/rgb.py

View check run for this annotation

Codecov / codecov/patch

terracotta/server/rgb.py#L52

Added line #L52 was not covered by tests


class RGBOptionSchema(Schema):
class Meta:
unknown = EXCLUDE
Expand All @@ -29,25 +60,34 @@
g = fields.String(required=True, description="Key value for green band")
b = fields.String(required=True, description="Key value for blue band")
r_range = fields.List(
fields.Number(allow_none=True),
StringOrNumber(allow_none=True, validate=validate_range),
validate=validate.Length(equal=2),
example="[0,1]",
missing=None,
description="Stretch range [min, max] to use for red band as JSON array",
description=(
"Stretch range [min, max] to use for red band as JSON array, "
"prefix with `p` for percentile"
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
),
)
g_range = fields.List(
fields.Number(allow_none=True),
StringOrNumber(allow_none=True, validate=validate_range),
validate=validate.Length(equal=2),
example="[0,1]",
missing=None,
description="Stretch range [min, max] to use for green band as JSON array",
description=(
"Stretch range [min, max] to use for red band as JSON array, "
"prefix with `p` for percentile"
),
)
b_range = fields.List(
fields.Number(allow_none=True),
StringOrNumber(allow_none=True, validate=validate_range),
validate=validate.Length(equal=2),
example="[0,1]",
missing=None,
description="Stretch range [min, max] to use for blue band as JSON array",
description=(
"Stretch range [min, max] to use for red band as JSON array, "
"prefix with `p` for percentile"
),
)
tile_size = fields.List(
fields.Integer(),
Expand Down
50 changes: 49 additions & 1 deletion tests/handlers/test_rgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ def test_rgb_lowzoom(use_testdb, raster_file, raster_file_xyz_lowzoom):


@pytest.mark.parametrize(
"stretch_range", [[0, 20000], [10000, 20000], [-50000, 50000], [100, 100]]
"stretch_range", [
[0, 20000], [10000, 20000], [-50000, 50000], [100, 100],
["0", "20000"], ["10000", "20000"], ["-50000", "50000"], ["100", "100"],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do?

]
)
def test_rgb_stretch(stretch_range, use_testdb, testdb, raster_file_xyz):
import terracotta
Expand Down Expand Up @@ -106,6 +109,7 @@ def test_rgb_stretch(stretch_range, use_testdb, testdb, raster_file_xyz):
valid_img = img_data[valid_mask]
valid_data = tile_data.compressed()

stretch_range = [float(stretch_range[0]), float(stretch_range[1])]
atanas-balevsky marked this conversation as resolved.
Show resolved Hide resolved
assert np.all(valid_img[valid_data < stretch_range[0]] == 1)
stretch_range_mask = (valid_data > stretch_range[0]) & (
valid_data < stretch_range[1]
Expand All @@ -131,6 +135,50 @@ def test_rgb_invalid_stretch(use_testdb, raster_file_xyz):
)


def test_rgb_percentile_stretch(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"]
pct_stretch_range = ["p2", "p98"]

raw_img = rgb.rgb(
ds_keys[:2],
bands,
raster_file_xyz,
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)


def test_rgb_preview(use_testdb):
import terracotta
from terracotta.handlers import rgb
Expand Down
2 changes: 2 additions & 0 deletions tests/server/test_flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,8 @@ def test_get_rgb_stretch(client, use_testdb, raster_file_xyz):

for stretch_range in (
"[0,10000]",
"[\"1.0e%2B01\",\"1.0e%2B04\"]",
"[\"p2\",\"p98\"]",
"[0,null]",
"[null, 10000]",
"[null,null]",
Expand Down
Loading