Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,14 @@ Custom colormaps can be provided using the `colormap` parameter. When using a cu

The colormap is a JSON-encoded dictionary with:
- **Keys**: String integers from "0" to "255" (not data values)
- **Values**: Hex color codes in the format `#RRGGBB`
- **Values**: Hex color codes in the format `#RRGGBB` or `#RRGGBBAA` (with optional alpha channel)

> [!IMPORTANT]
> Custom colormaps must include both "0" and "255" as keys. These colormaps must have keys that are "0" and "255", not data values. The data value is rescaled by `colorscalerange` to 0→1; the colormap is rescaled from 0→255 to 0→1 and then applied to the scaled 0→1 data.

> [!TIP]
> Custom colormaps support RGBA colors with alpha transparency (e.g., `#FF000080` for 50% transparent red). The alpha channel allows for creating semi-transparent color gradients or making specific values fully transparent.

### Dimension selection with methods

`xpublish-tiles` supports flexible dimension selection using a DSL that allows you to specify selection methods. This is particularly useful for temporal and vertical coordinates where you may want to select the nearest value, or use forward/backward fill.
Expand Down
2 changes: 2 additions & 0 deletions src/xpublish_tiles/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ def create_colormap_from_dict(colormap_dict: dict[str, str]) -> mcolors.Colormap
# this is a matplotlib requirement
raise ValueError("Provided colormap keys must contain 0 and 255.")

# Note: matplotlib's LinearSegmentedColormap.from_list() supports both
# #RRGGBB and #RRGGBBAA hex color formats
return mcolors.LinearSegmentedColormap.from_list(
"custom", list(zip(positions, colors, strict=True)), N=256
)
50 changes: 50 additions & 0 deletions src/xpublish_tiles/testing/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,54 @@ def assert_render_matches_snapshot(
)


def assert_has_transparent_pixels(result: io.BytesIO | bytes) -> None:
"""Assert that the image has at least some transparent pixels (alpha == 0).

Args:
result: BytesIO buffer or bytes containing PNG image data

Raises:
AssertionError: If no transparent pixels are found or image is not RGBA
"""
if isinstance(result, io.BytesIO):
result.seek(0)
img = Image.open(result)
else:
img = Image.open(io.BytesIO(result))

assert img.mode == "RGBA", f"Expected RGBA mode, got {img.mode}"

img_array = np.array(img)
alpha_channel = img_array[:, :, 3]

transparent_pixels = np.sum(alpha_channel == 0)
assert transparent_pixels > 0, "Expected some fully transparent pixels but found none"


def assert_has_opaque_pixels(result: io.BytesIO | bytes) -> None:
"""Assert that the image has at least some opaque pixels (alpha == 255).

Args:
result: BytesIO buffer or bytes containing PNG image data

Raises:
AssertionError: If no opaque pixels are found or image is not RGBA
"""
if isinstance(result, io.BytesIO):
result.seek(0)
img = Image.open(result)
else:
img = Image.open(io.BytesIO(result))

assert img.mode == "RGBA", f"Expected RGBA mode, got {img.mode}"

img_array = np.array(img)
alpha_channel = img_array[:, :, 3]

opaque_pixels = np.sum(alpha_channel == 255)
assert opaque_pixels > 0, "Expected some opaque pixels but found none"


def visualize_tile(result: io.BytesIO, tile: Tile) -> None:
"""Visualize a rendered tile with matplotlib showing RGB and alpha channels.

Expand Down Expand Up @@ -664,6 +712,8 @@ def visualize_tile(result: io.BytesIO, tile: Tile) -> None:

# Export the fixture name for easier importing
__all__ = [
"assert_has_opaque_pixels",
"assert_has_transparent_pixels",
"assert_render_matches_snapshot",
"compare_image_buffers",
"compare_image_buffers_with_debug",
Expand Down
8 changes: 4 additions & 4 deletions src/xpublish_tiles/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def validate_colormap(v: str | dict | None) -> dict[str, str] | None:
v: Colormap input - can be None, a JSON string, or a dict

Returns:
Parsed colormap dict with string keys (0-255) and hex color values (#RRGGBB) or None
Parsed colormap dict with string keys (0-255) and hex color values (#RRGGBB or #RRGGBBAA) or None

Raises:
ValueError: If colormap format is invalid
Expand Down Expand Up @@ -184,11 +184,11 @@ def validate_colormap(v: str | dict | None) -> dict[str, str] | None:
f"colormap values must be strings, got {type(value).__name__} for key {key}"
)

# Validation for hex colors only (#RRGGBB)
# Validation for hex colors only (#RRGGBB or #RRGGBBAA)
value = value.strip()
if not (value.startswith("#") and len(value) == 7):
if not (value.startswith("#") and len(value) in (7, 9)):
raise ValueError(
f"colormap value '{value}' for key {key} must be a hex color (#RRGGBB)"
f"colormap value '{value}' for key {key} must be a hex color (#RRGGBB or #RRGGBBAA)"
)

validated_colormap[str_key] = value
Expand Down
2 changes: 1 addition & 1 deletion src/xpublish_tiles/xpublish/tiles/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@ class TileQuery(BaseModel):
Field(
default=None,
json_schema_extra={
"description": "Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB). When provided, overrides any colormap from the style parameter.",
"description": "Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB or #RRGGBBAA). When provided, overrides any colormap from the style parameter.",
},
),
]
Expand Down
4 changes: 2 additions & 2 deletions src/xpublish_tiles/xpublish/wms/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class WMSGetMapQuery(WMSBaseQuery):
)
colormap: dict[str, str] | None = Field(
None,
description="Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB). When provided, overrides any colormap from the styles parameter.",
description="Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB or #RRGGBBAA). When provided, overrides any colormap from the styles parameter.",
)
format: ImageFormat = Field(
ImageFormat.PNG,
Expand Down Expand Up @@ -209,7 +209,7 @@ class WMSGetLegendGraphicQuery(WMSBaseQuery):
)
colormap: dict[str, str] | None = Field(
None,
description="Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB). When provided, overrides any colormap from the styles parameter.",
description="Custom colormap as JSON-encoded dictionary with numeric keys (0-255) and hex color values (#RRGGBB or #RRGGBBAA). When provided, overrides any colormap from the styles parameter.",
)
styles: tuple[str, str] = Field(
("raster", "default"),
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
PARA,
)
from xpublish_tiles.testing.lib import (
assert_has_opaque_pixels,
assert_has_transparent_pixels,
assert_render_matches_snapshot,
compare_image_buffers,
compare_image_buffers_with_debug,
Expand Down Expand Up @@ -269,6 +271,47 @@ async def test_categorical_data_with_custom_colormap(png_snapshot, pytestconfig)
)


@pytest.mark.asyncio
async def test_categorical_data_with_rgba_colormap(png_snapshot, pytestconfig):
"""Test categorical data with RGBA colormap including transparency."""
ds = PARA.create().squeeze("time")

# PARA has 10 categories (flag_values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
# Create a custom RGBA colormap with category 0 fully transparent
rgba_colormap = {
"0": "#ff000000", # red, fully transparent
"1": "#00ff00ff", # green, fully opaque
"2": "#0000ffff", # blue, fully opaque
"3": "#ffff00ff", # yellow, fully opaque
"4": "#ff00ffff", # magenta, fully opaque
"5": "#00ffffff", # cyan, fully opaque
"6": "#800000ff", # maroon, fully opaque
"7": "#008000ff", # dark green, fully opaque
"8": "#000080ff", # navy, fully opaque
"9": "#808080ff", # gray, fully opaque
}

# Use para_belem_z6 tile which is fully within PARA domain (Belém capital area)
tile, tms = PARA_TILES[6].values
query_params = create_query_params(
tile, tms, style="raster", variant="custom", colormap=rgba_colormap
)
result = await pipeline(ds, query_params)

# Verify that the result has transparent pixels (category 0 is fully transparent)
assert_has_transparent_pixels(result)
assert_has_opaque_pixels(result)

if pytestconfig.getoption("--visualize"):
result.seek(0)
visualize_tile(result, tile)

result.seek(0)
assert_render_matches_snapshot(
result, png_snapshot, tile=tile, tms=tms, dataset_bbox=ds.attrs["bbox"]
)


@pytest.mark.asyncio
@pytest.mark.parametrize("tile,tms", GLOBAL_NANS.tiles)
async def test_global_nans_data(tile, tms, png_snapshot, pytestconfig):
Expand Down
38 changes: 36 additions & 2 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,14 @@ def test_invalid_value_non_string(self):
def test_invalid_color_format(self):
with pytest.raises(
ValueError,
match="colormap value 'invalid' for key 0 must be a hex color \\(#RRGGBB\\)",
match="colormap value 'invalid' for key 0 must be a hex color \\(#RRGGBB or #RRGGBBAA\\)",
):
validate_colormap({"0": "invalid"})

def test_invalid_named_colors(self):
with pytest.raises(
ValueError,
match="colormap value 'white' for key 0 must be a hex color \\(#RRGGBB\\)",
match="colormap value 'white' for key 0 must be a hex color \\(#RRGGBB or #RRGGBBAA\\)",
):
validate_colormap({"0": "white", "255": "black"})

Expand All @@ -316,6 +316,40 @@ def test_non_dict_json_input(self):
with pytest.raises(ValueError, match="colormap must be a dictionary"):
validate_colormap(["not", "a", "dict"]) # type: ignore # this doesn't validate with the type checker

def test_valid_rgba_colors(self):
colormap = {"0": "#FFFFFFFF", "255": "#000000FF"}
result = validate_colormap(colormap)
assert result == {"0": "#FFFFFFFF", "255": "#000000FF"}

def test_mixed_rgb_rgba_colors(self):
colormap = {"0": "#FF0000", "128": "#00FF0080", "255": "#0000FFFF"}
result = validate_colormap(colormap)
assert result == {"0": "#FF0000", "128": "#00FF0080", "255": "#0000FFFF"}

def test_rgba_with_full_opacity(self):
colormap = {"0": "#FF0000FF", "255": "#0000FFFF"}
result = validate_colormap(colormap)
assert result == {"0": "#FF0000FF", "255": "#0000FFFF"}

def test_rgba_with_transparency(self):
colormap = {"0": "#FF000080", "128": "#00FF0040", "255": "#0000FF00"}
result = validate_colormap(colormap)
assert result == {"0": "#FF000080", "128": "#00FF0040", "255": "#0000FF00"}

def test_invalid_rgba_format_8_chars(self):
with pytest.raises(
ValueError,
match="colormap value '#FF00001' for key 0 must be a hex color \\(#RRGGBB or #RRGGBBAA\\)",
):
validate_colormap({"0": "#FF00001", "255": "#000000"})

def test_invalid_rgba_format_10_chars(self):
with pytest.raises(
ValueError,
match="colormap value '#FF0000FFFF' for key 0 must be a hex color \\(#RRGGBB or #RRGGBBAA\\)",
):
validate_colormap({"0": "#FF0000FFFF", "255": "#000000"})


class TestCategoricalColormap:
def test_create_listed_colormap_valid(self):
Expand Down