Repository: napari/napari

Issue:
Define `ColorEncoding` with tests
This should be similar to `StringEncoding` except that it encodes colors from features instead of strings.

This will be used to implement style coloring in the points, shapes, vectors, tracks, and maybe all layer types. It will also be used to encode text element colors.

As with `StringEncoding`, this can be private initially, and then exposed when used.


Discussion:

Resolving patch:
diff --git a/napari/layers/utils/_color_encoding.py b/napari/layers/utils/_color_encoding.py
new file mode 100644
--- /dev/null
+++ b/napari/layers/utils/_color_encoding.py
@@ -0,0 +1,258 @@
+from typing import Any, Literal, Optional, Tuple, Union
+
+import numpy as np
+from pydantic import Field, parse_obj_as, validator
+from typing_extensions import Protocol, runtime_checkable
+
+from ...utils import Colormap
+from ...utils.colormaps import ValidColormapArg, ensure_colormap
+from ...utils.colormaps.categorical_colormap import CategoricalColormap
+from ...utils.colormaps.standardize_color import transform_color
+from ...utils.translations import trans
+from .color_transformations import ColorType
+from .style_encoding import (
+    StyleEncoding,
+    _ConstantStyleEncoding,
+    _DerivedStyleEncoding,
+    _ManualStyleEncoding,
+)
+
+
+class ColorValue(np.ndarray):
+    """A 4x1 array that represents one RGBA color value."""
+
+    @classmethod
+    def __get_validators__(cls):
+        yield cls.validate_type
+
+    @classmethod
+    def validate_type(cls, val):
+        return transform_color(val)[0]
+
+
+class ColorArray(np.ndarray):
+    """An Nx4 array where each row of N represents one RGBA color value."""
+
+    @classmethod
+    def __get_validators__(cls):
+        yield cls.validate_type
+
+    @classmethod
+    def validate_type(cls, val):
+        return (
+            np.empty((0, 4), np.float32)
+            if len(val) == 0
+            else transform_color(val)
+        )
+
+
+@runtime_checkable
+class ColorEncoding(StyleEncoding[ColorValue, ColorArray], Protocol):
+    """Encodes colors from features."""
+
+
+"""The default color to use, which may also be used a safe fallback color."""
+DEFAULT_COLOR = ColorValue.validate_type('cyan')
+
+
+class ConstantColorEncoding(_ConstantStyleEncoding[ColorValue, ColorArray]):
+    """Encodes color values from a single constant color.
+
+    Attributes
+    ----------
+    constant : ColorValue
+        The constant color RGBA value.
+    """
+
+    encoding_type: Literal['ConstantColorEncoding'] = 'ConstantColorEncoding'
+    constant: ColorValue
+
+
+class ManualColorEncoding(_ManualStyleEncoding[ColorValue, ColorArray]):
+    """Encodes color values manually in an array attribute.
+
+    Attributes
+    ----------
+    array : ColorArray
+        The array of color values. Can be written to directly to make
+        persistent updates.
+    default : ColorValue
+        The default color value.
+    """
+
+    encoding_type: Literal['ManualColorEncoding'] = 'ManualColorEncoding'
+    array: ColorArray
+    default: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR)
+
+
+class DirectColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]):
+    """Encodes color values directly from a feature column.
+
+    Attributes
+    ----------
+    feature : str
+        The name of the feature that contains the desired color values.
+    fallback : ColorArray
+        The safe constant fallback color to use if the feature column
+        does not contain valid color values.
+    """
+
+    encoding_type: Literal['DirectColorEncoding'] = 'DirectColorEncoding'
+    feature: str
+    fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR)
+
+    def __call__(self, features: Any) -> ColorArray:
+        # A column-like may be a series or have an object dtype (e.g. color names),
+        # neither of which transform_color handles, so convert to a list.
+        return ColorArray.validate_type(list(features[self.feature]))
+
+
+class NominalColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]):
+    """Encodes color values from a nominal feature whose values are mapped to colors.
+
+    Attributes
+    ----------
+    feature : str
+        The name of the feature that contains the nominal values to be mapped to colors.
+    colormap : CategoricalColormap
+        Maps the feature values to colors.
+    fallback : ColorValue
+        The safe constant fallback color to use if mapping the feature values to
+        colors fails.
+    """
+
+    encoding_type: Literal['NominalColorEncoding'] = 'NominalColorEncoding'
+    feature: str
+    colormap: CategoricalColormap
+    fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR)
+
+    def __call__(self, features: Any) -> ColorArray:
+        # map is not expecting some column-likes (e.g. pandas.Series), so ensure
+        # this is a numpy array first.
+        values = np.asarray(features[self.feature])
+        return self.colormap.map(values)
+
+
+class QuantitativeColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]):
+    """Encodes color values from a quantitative feature whose values are mapped to colors.
+
+    Attributes
+    ----------
+    feature : str
+        The name of the feature that contains the nominal values to be mapped to colors.
+    colormap : Colormap
+        Maps feature values to colors.
+    contrast_limits : Optional[Tuple[float, float]]
+        The (min, max) feature values that should respectively map to the first and last
+        colors in the colormap. If None, then this will attempt to calculate these values
+        from the feature values each time this generates color values. If that attempt
+        fails, these are effectively (0, 1).
+    fallback : ColorValue
+        The safe constant fallback color to use if mapping the feature values to
+        colors fails.
+    """
+
+    encoding_type: Literal[
+        'QuantitativeColorEncoding'
+    ] = 'QuantitativeColorEncoding'
+    feature: str
+    colormap: Colormap
+    contrast_limits: Optional[Tuple[float, float]] = None
+    fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR)
+
+    def __call__(self, features: Any) -> ColorArray:
+        values = features[self.feature]
+        contrast_limits = self.contrast_limits or _calculate_contrast_limits(
+            values
+        )
+        if contrast_limits is not None:
+            values = np.interp(values, contrast_limits, (0, 1))
+        return self.colormap.map(values)
+
+    @validator('colormap', pre=True, always=True)
+    def _check_colormap(cls, colormap: ValidColormapArg) -> Colormap:
+        return ensure_colormap(colormap)
+
+    @validator('contrast_limits', pre=True, always=True)
+    def _check_contrast_limits(
+        cls, contrast_limits
+    ) -> Optional[Tuple[float, float]]:
+        if (contrast_limits is not None) and (
+            contrast_limits[0] >= contrast_limits[1]
+        ):
+            raise ValueError(
+                'contrast_limits must be a strictly increasing pair of values'
+            )
+        return contrast_limits
+
+
+"""The types of arguments supported when setting a ColorEncoding field."""
+ColorEncodingArgument = Union[ColorEncoding, dict, str, ColorType, None]
+
+
+def validate_color_encoding(value: ColorEncodingArgument) -> ColorEncoding:
+    """Validates and coerces a value to a ColorEncoding.
+
+    Parameters
+    ----------
+    value : ColorEncodingArgument
+        The value to validate and coerce.
+        If this is already a ColorEncoding, it is returned as is.
+        If this is a dict, then it should represent one of the built-in color encodings.
+        If this a string, then a DirectColorEncoding is returned.
+        If this a single color, a ConstantColorEncoding is returned.
+        If this is a sequence of colors, a ManualColorEncoding is returned.
+
+    Returns
+    -------
+    ColorEncoding
+
+    Raises
+    ------
+    TypeError
+        If the value is not a supported type.
+    ValidationError
+        If the value cannot be parsed into a ColorEncoding.
+    """
+    if value is None:
+        return ConstantColorEncoding(constant=DEFAULT_COLOR)
+    if isinstance(value, ColorEncoding):
+        return value
+    if isinstance(value, dict):
+        return parse_obj_as(
+            Union[
+                ConstantColorEncoding,
+                ManualColorEncoding,
+                DirectColorEncoding,
+                NominalColorEncoding,
+                QuantitativeColorEncoding,
+            ],
+            value,
+        )
+    if isinstance(value, str):
+        return DirectColorEncoding(feature=value, fallback=DEFAULT_COLOR)
+    try:
+        color_array = ColorArray.validate_type(value)
+    except (ValueError, AttributeError, KeyError):
+        raise TypeError(
+            trans._(
+                'value should be a ColorEncoding, a dict, a string, a color, a sequence of colors, or None',
+                deferred=True,
+            )
+        )
+    if color_array.shape[0] == 1:
+        return ConstantColorEncoding(constant=value)
+    return ManualColorEncoding(array=color_array, default=DEFAULT_COLOR)
+
+
+def _calculate_contrast_limits(
+    values: np.ndarray,
+) -> Optional[Tuple[float, float]]:
+    contrast_limits = None
+    if values.size > 0:
+        min_value = np.min(values)
+        max_value = np.max(values)
+        # Use < instead of != to handle nans.
+        if min_value < max_value:
+            contrast_limits = (min_value, max_value)
+    return contrast_limits
diff --git a/napari/layers/utils/style_encoding.py b/napari/layers/utils/style_encoding.py
--- a/napari/layers/utils/style_encoding.py
+++ b/napari/layers/utils/style_encoding.py
@@ -19,7 +19,7 @@
 
 @runtime_checkable
 class StyleEncoding(Protocol[StyleValue, StyleArray]):
-    """Encodes style values, like colors and strings, from layer features.
+    """Encodes generic style values, like colors and strings, from layer features.
 
     The public API of any StyleEncoding is just __call__, such that it can
     be called to generate style values from layer features. That call should
@@ -100,9 +100,35 @@ def _json_encode(self) -> dict:
         """
 
 
-class _ConstantStyleEncoding(EventedModel, Generic[StyleValue, StyleArray]):
+class _StyleEncodingModel(EventedModel):
+    class Config:
+        # Forbid extra initialization parameters instead of ignoring
+        # them by default. This is useful when parsing style encodings
+        # from dicts, as different types of encodings may have the same
+        # field names.
+        # https://pydantic-docs.helpmanual.io/usage/model_config/#options
+        extra = 'forbid'
+
+
+# The following classes provide generic implementations of common ways
+# to encode style values, like constant, manual, and derived encodings.
+# They inherit Python's built-in `Generic` type, so that an encoding with
+# a specific output type can inherit the generic type annotations from
+# this class along with the functionality it provides. For example,
+# `ConstantStringEncoding.__call__` returns an `Array[str, ()]` whereas
+# `ConstantColorEncoding.__call__` returns an `Array[float, (4,)]`.
+# For more information on `Generic`, see the official docs.
+# https://docs.python.org/3/library/typing.html#generics
+
+
+class _ConstantStyleEncoding(
+    _StyleEncodingModel, Generic[StyleValue, StyleArray]
+):
     """Encodes a constant style value.
 
+    This encoding is generic so that it can be used to implement style
+    encodings with different value types like Array[]
+
     Attributes
     ----------
     constant : StyleValue
@@ -134,7 +160,9 @@ def _json_encode(self) -> dict:
         return self.dict()
 
 
-class _ManualStyleEncoding(EventedModel, Generic[StyleValue, StyleArray]):
+class _ManualStyleEncoding(
+    _StyleEncodingModel, Generic[StyleValue, StyleArray]
+):
     """Encodes style values manually.
 
     The style values are encoded manually in the array attribute, so that
@@ -181,7 +209,7 @@ def _json_encode(self) -> dict:
 
 
 class _DerivedStyleEncoding(
-    EventedModel, Generic[StyleValue, StyleArray], ABC
+    _StyleEncodingModel, Generic[StyleValue, StyleArray], ABC
 ):
     """Encodes style values by deriving them from feature values.
 


Test patch:
diff --git a/napari/layers/utils/_tests/test_color_encoding.py b/napari/layers/utils/_tests/test_color_encoding.py
new file mode 100644
--- /dev/null
+++ b/napari/layers/utils/_tests/test_color_encoding.py
@@ -0,0 +1,266 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+from napari.layers.utils._color_encoding import (
+    ColorArray,
+    ConstantColorEncoding,
+    DirectColorEncoding,
+    ManualColorEncoding,
+    NominalColorEncoding,
+    QuantitativeColorEncoding,
+    validate_color_encoding,
+)
+
+
+def make_features_with_no_columns(*, num_rows) -> pd.DataFrame:
+    return pd.DataFrame({}, index=range(num_rows))
+
+
+@pytest.fixture
+def features() -> pd.DataFrame:
+    return pd.DataFrame(
+        {
+            'class': ['a', 'b', 'c'],
+            'confidence': [0.5, 1, 0],
+            'custom_colors': ['red', 'green', 'cyan'],
+        }
+    )
+
+
+def test_constant_call_with_no_rows():
+    features = make_features_with_no_columns(num_rows=0)
+    encoding = ConstantColorEncoding(constant='red')
+
+    values = encoding(features)
+
+    assert_colors_equal(values, 'red')
+
+
+def test_constant_call_with_some_rows():
+    features = make_features_with_no_columns(num_rows=3)
+    encoding = ConstantColorEncoding(constant='red')
+
+    values = encoding(features)
+
+    assert_colors_equal(values, 'red')
+
+
+def test_manual_call_with_no_rows():
+    features = make_features_with_no_columns(num_rows=0)
+    array = ['red', 'green', 'cyan']
+    default = 'yellow'
+    encoding = ManualColorEncoding(array=array, default=default)
+
+    values = encoding(features)
+
+    assert_colors_equal(values, [])
+
+
+def test_manual_call_with_fewer_rows():
+    features = make_features_with_no_columns(num_rows=2)
+    array = ['red', 'green', 'cyan']
+    default = 'yellow'
+    encoding = ManualColorEncoding(array=array, default=default)
+
+    values = encoding(features)
+
+    assert_colors_equal(values, ['red', 'green'])
+
+
+def test_manual_call_with_same_rows():
+    features = make_features_with_no_columns(num_rows=3)
+    array = ['red', 'green', 'cyan']
+    default = 'yellow'
+    encoding = ManualColorEncoding(array=array, default=default)
+
+    values = encoding(features)
+
+    assert_colors_equal(values, ['red', 'green', 'cyan'])
+
+
+def test_manual_with_more_rows():
+    features = make_features_with_no_columns(num_rows=4)
+    array = ['red', 'green', 'cyan']
+    default = 'yellow'
+    encoding = ManualColorEncoding(array=array, default=default)
+
+    values = encoding(features)
+
+    assert_colors_equal(values, ['red', 'green', 'cyan', 'yellow'])
+
+
+def test_direct(features):
+    encoding = DirectColorEncoding(feature='custom_colors')
+    values = encoding(features)
+    assert_colors_equal(values, list(features['custom_colors']))
+
+
+def test_direct_with_missing_feature(features):
+    encoding = DirectColorEncoding(feature='not_class')
+    with pytest.raises(KeyError):
+        encoding(features)
+
+
+def test_nominal_with_dict_colormap(features):
+    colormap = {'a': 'red', 'b': 'yellow', 'c': 'green'}
+    encoding = NominalColorEncoding(
+        feature='class',
+        colormap=colormap,
+    )
+
+    values = encoding(features)
+
+    assert_colors_equal(values, ['red', 'yellow', 'green'])
+
+
+def test_nominal_with_dict_cycle(features):
+    colormap = ['red', 'yellow', 'green']
+    encoding = NominalColorEncoding(
+        feature='class',
+        colormap=colormap,
+    )
+
+    values = encoding(features)
+
+    assert_colors_equal(values, ['red', 'yellow', 'green'])
+
+
+def test_nominal_with_missing_feature(features):
+    colormap = {'a': 'red', 'b': 'yellow', 'c': 'green'}
+    encoding = NominalColorEncoding(feature='not_class', colormap=colormap)
+    with pytest.raises(KeyError):
+        encoding(features)
+
+
+def test_quantitative_with_colormap_name(features):
+    colormap = 'gray'
+    encoding = QuantitativeColorEncoding(
+        feature='confidence', colormap=colormap
+    )
+
+    values = encoding(features)
+
+    assert_colors_equal(values, [[c] * 3 for c in features['confidence']])
+
+
+def test_quantitative_with_colormap_values(features):
+    colormap = ['black', 'red']
+    encoding = QuantitativeColorEncoding(
+        feature='confidence', colormap=colormap
+    )
+    values = encoding(features)
+    assert_colors_equal(values, [[c, 0, 0] for c in features['confidence']])
+
+
+def test_quantitative_with_contrast_limits(features):
+    colormap = 'gray'
+    encoding = QuantitativeColorEncoding(
+        feature='confidence',
+        colormap=colormap,
+        contrast_limits=(0, 2),
+    )
+
+    values = encoding(features)
+
+    assert encoding.contrast_limits == (0, 2)
+    assert_colors_equal(values, [[c / 2] * 3 for c in features['confidence']])
+
+
+def test_quantitative_with_missing_feature(features):
+    colormap = 'gray'
+    encoding = QuantitativeColorEncoding(
+        feature='not_confidence', colormap=colormap
+    )
+
+    with pytest.raises(KeyError):
+        encoding(features)
+
+
+def test_validate_from_string():
+    argument = 'class'
+    expected = DirectColorEncoding(feature=argument)
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_sequence():
+    argument = ['red', 'green', 'cyan']
+    expected = ManualColorEncoding(array=argument)
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_constant_dict():
+    constant = 'yellow'
+    argument = {'constant': constant}
+    expected = ConstantColorEncoding(constant=constant)
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_manual_dict():
+    array = ['red', 'green', 'cyan']
+    default = 'yellow'
+    argument = {'array': array, 'default': default}
+    expected = ManualColorEncoding(array=array, default=default)
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_direct_dict():
+    feature = 'class'
+    argument = {'feature': feature}
+    expected = DirectColorEncoding(feature=feature)
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_nominal_dict():
+    feature = 'class'
+    colormap = ['red', 'green', 'cyan']
+    argument = {'feature': feature, 'colormap': colormap}
+    expected = NominalColorEncoding(
+        feature=feature,
+        colormap=colormap,
+    )
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def test_validate_from_quantitative_dict(features):
+    feature = 'confidence'
+    colormap = 'gray'
+    contrast_limits = (0, 2)
+    argument = {
+        'feature': feature,
+        'colormap': colormap,
+        'contrast_limits': contrast_limits,
+    }
+    expected = QuantitativeColorEncoding(
+        feature=feature,
+        colormap=colormap,
+        contrast_limits=contrast_limits,
+    )
+
+    actual = validate_color_encoding(argument)
+
+    assert actual == expected
+
+
+def assert_colors_equal(actual, expected):
+    actual_array = ColorArray.validate_type(actual)
+    expected_array = ColorArray.validate_type(expected)
+    np.testing.assert_array_equal(actual_array, expected_array)