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 map_values to Gradient #43

Merged
merged 6 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
44 changes: 44 additions & 0 deletions arcadia_pycolor/gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from arcadia_pycolor.hexcode import HexCode
from arcadia_pycolor.palette import ColorSequence, Palette
from arcadia_pycolor.utils import (
NumericSequence,
distribute_values,
interpolate_x_values,
is_monotonic,
Expand Down Expand Up @@ -94,6 +95,49 @@ def resample_as_palette(self, steps: int = 5) -> Palette:
colors=colors,
)

def map_values(
keithchev marked this conversation as resolved.
Show resolved Hide resolved
self,
values: NumericSequence,
min_value: Union[float, None] = None,
max_value: Union[float, None] = None,
) -> list[HexCode]:
"""Map a sequence of values to their corresponding colors from a gradient

Args:
min_value:
Determines which value corresponds to the first color in the spectrum.
Any values below this minimum are assigned to the first color. If not
provided, min(values) is chosen.
max_value:
Determines which value corresponds to the last color in the spectrum.
Any values greater than this maximum are assigned to the last color. If
not provided, max(values) is chosen.

Returns:
list[HexCode]: A list of hex codes.
"""

if not len(values):
return []

if min_value is None:
min_value = min(values)

if max_value is None:
max_value = max(values)

if min_value >= max_value:
raise ValueError(
f"max_value ({max_value}) must be greater than min_value ({min_value})."
)

cmap = self.to_mpl_cmap()

normalized_values = [(value - min_value) / (max_value - min_value) for value in values]
clamped_values = [max(0.0, min(1.0, value)) for value in normalized_values]
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we should warn the user when values outside of the min/max range are provided, rather than clamping those values silently here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can only speak to my personal usage with colormaps, but if I'm touching the min and max values, it's usually because I'm interested in the middle of the color range, but far-reaching extrema values are decreasing contrast.

Like if your data is spread like this (each x a datapoint, each | the min/max value):

x                           x x    x x  x x x x x x x x                              x
|                                                                                    |
red                                  yellow                                      green

The bulk of the data is washed out. In comparison, by setting min/max inside the value range:

x                           x x    x x  x x x x x x x x                              x
                          |                             |
red                       red          yellow       green                        green

You get more contrast in the region you care about.

All this is to say that I personally would not want a warning that I'm doing this, because it would be very intentional.

Copy link
Member

Choose a reason for hiding this comment

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

I understand this use case a bit better, thanks! I do wonder if it would be good to use the matplotlib set_extremes functionality when windowing the data in this way, so that colors over and under the gradient are distinctly marked as out of range. Probably not relevant to your structure viewer use case, but in a heatmap or other context, it would be important to indicate so as to avoid misleading readers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah good idea. By name, I thought that set_extremes would allow us to avoid normalizing the data altogether, by setting the extrema for the value range. But looking into the implementation, I now see it allows you to control the colors of those outside 0 to 1.

This could be useful if we wanted more fancy features and could be accomplished by propagating those args to the map_values call signature.

Either way, it's noteworthy that the default behavior for over and under is to clamp the color to the lowest/highest color. Here's a black to white cmap:

(Pdb) mcolors.to_hex(cmap(-1.0))
'#000000'
(Pdb) mcolors.to_hex(cmap(2.0))
'#ffffff'

That's what we we were manually doing. As a step towards implementing over/under/bad, the clamping op has been removed (0c1c8af).


return [HexCode(f"{value}", mcolors.to_hex(cmap(value))) for value in clamped_values]

def interpolate_lightness(self) -> "Gradient":
"""
Interpolates the gradient to new values based on lightness.
Expand Down
54 changes: 54 additions & 0 deletions arcadia_pycolor/tests/test_gradients.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pytest

from arcadia_pycolor import Gradient, HexCode
from arcadia_pycolor.colors import black, white

from .test_hexcode import INVALID_HEXCODES

Expand Down Expand Up @@ -116,3 +117,56 @@ def test_gradient_swatch_steps():
)
def test_gradient_swatch_repr(name, colors, swatch):
assert Gradient(name, colors).__repr__() == swatch


@pytest.fixture
def black_to_white_gradient() -> Gradient:
return Gradient("_", [black, white], [0.0, 1.0])


@pytest.mark.parametrize(
"values, expected_colors",
[
([0, 1], ["#000000", "#ffffff"]),
([0, 0.5, 1], ["#000000", "#808080", "#ffffff"]),
([1, 2, 3, 4, 5], ["#000000", "#404040", "#808080", "#c0c0c0", "#ffffff"]),
([-1, 0, 1], ["#000000", "#808080", "#ffffff"]),
([-3, -2, -1], ["#000000", "#808080", "#ffffff"]),
([], []),
],
)
def test_map_values_basic_cases(
keithchev marked this conversation as resolved.
Show resolved Hide resolved
black_to_white_gradient: Gradient,
values: list[float],
expected_colors: list[str],
):
assert black_to_white_gradient.map_values(values) == expected_colors


@pytest.mark.parametrize(
"values, min_value, max_value, expected_colors",
[
([0, 0.5, 1], 0, 1, ["#000000", "#808080", "#ffffff"]),
([0, 0.5, 1], 0.25, 0.75, ["#000000", "#808080", "#ffffff"]),
([-1, 0.5, 2], 0, 1, ["#000000", "#808080", "#ffffff"]),
([0, 10], 0, 20, ["#000000", "#808080"]),
],
)
def test_map_values_custom_ranges(
black_to_white_gradient: Gradient,
values: list[float],
min_value: float,
max_value: float,
expected_colors: list[str],
):
assert black_to_white_gradient.map_values(values, min_value, max_value) == expected_colors


def test_map_values_invalid_cases(black_to_white_gradient: Gradient):
# You can't pass min larger than max
with pytest.raises(ValueError, match="must be greater than"):
black_to_white_gradient.map_values([0, 1], min_value=1, max_value=0)

# Or min equal to max
with pytest.raises(ValueError, match="must be greater than"):
black_to_white_gradient.map_values([0, 1], min_value=1, max_value=1)
Loading