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 color vision deficiency, gradient, and palette display tools #20

Merged
merged 13 commits into from
Jun 13, 2024
3 changes: 2 additions & 1 deletion arcadia_pycolor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from arcadia_pycolor import colors, gradients, mpl, palettes, plot, style_defaults
from arcadia_pycolor import colors, cvd, gradients, mpl, palettes, plot, style_defaults

from .colors import *
from .gradient import *
from .hexcode import *
from .palette import *

__all__ = [
"cvd",
"gradients",
"mpl",
"palettes",
Expand Down
147 changes: 147 additions & 0 deletions arcadia_pycolor/cvd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from typing import Union

import matplotlib as mpl
import numpy as np
from colorspacious import cspace_convert

from arcadia_pycolor.gradient import Gradient
from arcadia_pycolor.hexcode import HexCode
from arcadia_pycolor.palette import Palette
from arcadia_pycolor.plot import plot_gradient_lightness

CVD_TYPES = {"d": "deuteranomaly", "p": "protanomaly", "t": "tritanomaly"}


def _make_cvd_dict(cvd_type: str, severity: int = 100) -> dict:
"""
Makes a dictionary for colorspacious to simulate color vision deficiency.

Args:
cvd_type (str): 'd' for deuteranomaly, 'p' for protanomaly, and 't' for tritanomaly.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""

if cvd_type not in CVD_TYPES:
raise ValueError(
"Choose 'd' for deuteranomaly, 'p' for protanomaly, and 't' for tritanomaly."
)

clipped_severity = np.clip(severity, 0, 100)

cvd_space = {"name": "sRGB1+CVD", "cvd_type": CVD_TYPES[cvd_type], "severity": clipped_severity}

return cvd_space


def simulate_color(
colors: Union[HexCode, list[HexCode]], cvd_type: str = "d", severity: int = 100
) -> Union[HexCode, list[HexCode]]:
"""
Simulates color vision deficiency for a single HexCode or list of HexCodes.

Args:
colors (HexCode or list[HexCode]): colors to simulate color vision deficiency on.
cvd_type (str): 'd' for deuteranomaly, 'p' for protanomaly, and 't' for tritanomaly.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_space = _make_cvd_dict(cvd_type=cvd_type, severity=severity)

if not isinstance(colors, list):
processed_colors = [colors]
else:
processed_colors = colors

returned_colors = []
for color in processed_colors:
rgb_color = color.to_rgb()
cvd_color_name = f"{color.name}_{cvd_type}"
cvd_rgb_color = np.clip(cspace_convert(rgb_color, cvd_space, "sRGB1") / 255, 0, 1)
cvd_hexcode = HexCode(name=cvd_color_name, hex_code=mpl.colors.to_hex(cvd_rgb_color))
returned_colors.append(cvd_hexcode)

if len(returned_colors) == 1:
return returned_colors[0]
else:
return returned_colors


def display_all_color(color: HexCode, severity: int = 100) -> None:
"""
Display all color vision deficiency types for a single HexCode.

Args:
color (HexCode): color to simulate color vision deficiency on.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_colors = [color] + [simulate_color(color, cvd_type, severity) for cvd_type in CVD_TYPES]
for cvd_color in cvd_colors:
mezarque marked this conversation as resolved.
Show resolved Hide resolved
print(cvd_color.swatch())


def simulate_palette(palette: Palette, cvd_type: str = "d", severity: int = 100) -> Palette:
"""
Simulates color vision deficiency on a Palette.

Args:
palette (Palette): Palette object on which to simulate color vision deficiency.
cvd_type (str): 'd' for deuteranomaly, 'p' for protanomaly, and 't' for tritanomaly.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_hex_colors = simulate_color(palette.colors, cvd_type=cvd_type, severity=severity)
cvd_palette = Palette(f"{palette.name}_{cvd_type}", cvd_hex_colors)

return cvd_palette


def display_all_palette(palette: Palette, severity: int = 100) -> None:
"""
Display all color vision deficiency types for a Palette.

Args:
palette (Palette): Palette object on which to simulate color vision deficiency.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_palettes = [palette] + [
simulate_palette(palette, cvd_type, severity) for cvd_type in CVD_TYPES
]
for palette in cvd_palettes:
print(palette.name)
print(palette.swatch())


def simulate_gradient(gradient: Gradient, cvd_type="d", severity: int = 100) -> Gradient:
"""
Simulates color vision deficiency on a Gradient.

Args:
gradient (Gradient): the Gradient object to display
cvd_type (str): 'd' for deuteranomaly, 'p' for protanomaly, and 't' for tritanomaly.
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_hex_colors = simulate_color(gradient.colors, cvd_type=cvd_type, severity=severity)
cvd_gradient = Gradient(f"{gradient.name}_{cvd_type}", cvd_hex_colors, gradient.values)

return cvd_gradient


def display_all_gradient(gradient: Gradient, severity: int = 100) -> None:
"""
Display all color vision deficiency types for a Gradient.

Args:
gradient (Gradient): the Gradient object to display
severity (int): severity of the color vision deficiency, from 0 to 100.
"""
cvd_gradients = [gradient] + [
simulate_gradient(gradient, cvd_type, severity) for cvd_type in CVD_TYPES
]
for grad in cvd_gradients:
print(grad.name)
print(grad.swatch())


def display_all_gradient_lightness(gradient: Gradient, severity: int = 100, **kwargs):
plot_gradient_lightness(
[gradient] + [simulate_gradient(gradient, cvd_type, severity) for cvd_type in CVD_TYPES],
**kwargs,
)
65 changes: 64 additions & 1 deletion arcadia_pycolor/gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
from arcadia_pycolor.display import colorize
from arcadia_pycolor.hexcode import HexCode
from arcadia_pycolor.palette import Palette
from arcadia_pycolor.utils import distribute_values
from arcadia_pycolor.utils import (
distribute_values,
interpolate_x_values,
is_monotonic,
rescale_and_concatenate_values,
)


class Gradient(Palette):
Expand Down Expand Up @@ -58,6 +63,64 @@ def swatch(self, steps=21):

return "".join(swatches)

def reverse(self):
return Gradient(
name=f"{self.name}_r",
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe reverse instead of r

Copy link
Member Author

Choose a reason for hiding this comment

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

Here, I'm trying to conform with matplotlib's syntax for designating reverse gradients. So, I think we should keep this in place.

colors=self.colors[::-1],
values=[1 - value for value in self.values[::-1]],
)

def resample_as_palette(self, steps=5):
"""
Resamples the gradient, returning a Palette with the specified number of steps.
"""
gradient = self.to_mpl_cmap()
values = distribute_values(steps)
colors = [
HexCode(name=f"{self.name}_{i}", hex_code=mcolors.to_hex(gradient(value)))
for i, value in enumerate(values)
]

return Palette(
name=f"{self.name}_resampled_{steps}",
colors=colors,
)

def interpolate_lightness(self):
"""
Interpolates the gradient to new values based on lightness.
"""

if len(self.colors) < 3:
raise ValueError("Interpolation requires at least three colors.")
if not is_monotonic(self.values):
raise ValueError("Lightness must be monotonically increasing or decreasing.")

lightness_values = [color.to_cam02ucs()[0] for color in self.colors]
new_values = interpolate_x_values(lightness_values)

return Gradient(
name=f"{self.name}_interpolated",
colors=self.colors,
values=new_values,
)

def __add__(self, other: "Gradient"):
new_colors = []
new_values = []

# If the first gradient ends with the same color as the start of the second gradient,
# drop the repeated color.
offset = 1 if self.colors[-1] == other.colors[0] else 0
new_colors = self.colors + other.colors[offset:]
new_values = rescale_and_concatenate_values(self.values, other.values[offset:])

return Gradient(
name=f"{self.name}_{other.name}",
colors=new_colors,
values=new_values,
)

def __repr__(self):
longest_name_length = self._get_longest_name_length()

Expand Down
2 changes: 2 additions & 0 deletions arcadia_pycolor/gradients.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,5 @@
[colors.depths, colors.seaweed, colors.paper, colors.tangerine, colors.umber, colors.soil],
[0.0, 0.21, 0.5, 0.6, 0.81, 1.0],
)

_all_gradients = [obj for obj in globals().values() if isinstance(obj, Gradient)]
13 changes: 13 additions & 0 deletions arcadia_pycolor/hexcode.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import re

import matplotlib.colors as mcolors
from colorspacious import cspace_converter

from arcadia_pycolor.display import colorize

Expand Down Expand Up @@ -37,6 +38,18 @@ def to_rgb(self):
"""Returns a tuple of RGB values for the color."""
return [int(c * 255) for c in mcolors.to_rgb(self.hex_code)]

def to_cam02ucs(self):
mezarque marked this conversation as resolved.
Show resolved Hide resolved
"""Returns a tuple of CAM02-UCS values for the color, where
the first value is the lightness (J) and the second and third values
are the chromaticity coordinates (a: redness-to-greenness, b: blueness-to-yellowness)."""
# Convert RGB255 to RGB1
rgb = [i / 255 for i in self.to_rgb()]

# Convert RGB1 to CAM02-UCS
cam02ucs = cspace_converter("sRGB1", "CAM02-UCS")(rgb)

return cam02ucs

def swatch(self, width: int = 2, min_name_width: int = None):
"""
Returns a color swatch with the specified width and color name.
Expand Down
9 changes: 7 additions & 2 deletions arcadia_pycolor/mpl.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import arcadia_pycolor.colors as colors
import arcadia_pycolor.gradients
import arcadia_pycolor.palettes
from arcadia_pycolor.gradient import Gradient
from arcadia_pycolor.palette import Palette
from arcadia_pycolor.style_defaults import (
ARCADIA_RC_PARAMS,
Expand Down Expand Up @@ -328,8 +329,12 @@ def load_colormaps():
)
for object in cmaps:
if isinstance(object, Palette):
if (gradient_name := f"apc:{object.name}") not in colormaps:
plt.register_cmap(name=gradient_name, cmap=object.to_mpl_cmap())
if (colormap_name := f"apc:{object.name}") not in colormaps:
plt.register_cmap(name=colormap_name, cmap=object.to_mpl_cmap())
# Register the reversed version of the gradient as well.
if isinstance(object, Gradient):
if (colormap_name := f"apc:{object.name}_r") not in colormaps:
plt.register_cmap(name=colormap_name, cmap=object.reverse().to_mpl_cmap())


def load_styles():
Expand Down
15 changes: 14 additions & 1 deletion arcadia_pycolor/palette.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import matplotlib.colors as mcolors

from arcadia_pycolor.display import colorize
from arcadia_pycolor.hexcode import HexCode


Expand All @@ -24,11 +25,23 @@ def from_dict(cls, name: str, colors: dict[str, str]):
hex_codes = [HexCode(name, hex_code) for name, hex_code in colors.items()]
return cls(name, hex_codes)

def swatch(self):
swatches = [colorize(" ", bg_color=color) for color in self.colors]

return "".join(swatches)

def reverse(self):
return Palette(
name=f"{self.name}_r",
Copy link
Contributor

Choose a reason for hiding this comment

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

same here

colors=self.colors[::-1],
)

def __repr__(self):
longest_name_length = self._get_longest_name_length()

return "\n".join(
[color.swatch(min_name_width=longest_name_length) for color in self.colors]
[self.swatch()]
+ [color.swatch(min_name_width=longest_name_length) for color in self.colors]
)

def __add__(self, other: "Palette"):
Expand Down
2 changes: 2 additions & 0 deletions arcadia_pycolor/palettes.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,5 @@
core + neutral + accent + light_accent + accent_expanded + light_accent_expanded + other + named
)
all.name = "All"

_all_palettes = [obj for obj in globals().values() if isinstance(obj, Palette)]
14 changes: 14 additions & 0 deletions arcadia_pycolor/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from colorspacious import cspace_converter

from arcadia_pycolor.gradient import Gradient
from arcadia_pycolor.gradients import _all_gradients
from arcadia_pycolor.palettes import _all_palettes


def plot_gradient_lightness(
Expand Down Expand Up @@ -107,3 +109,15 @@ def plot_gradient_lightness(
return fig

plt.show()


def display_all_gradients():
for gradient in _all_gradients:
print(gradient.name)
print(gradient.swatch())


def display_all_palettes():
for palette in _all_palettes:
print(palette.name)
print(palette.swatch())
2 changes: 1 addition & 1 deletion arcadia_pycolor/tests/test_palette.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_palette_from_dict():


def test_palette_repr():
expected_swatch = "\x1b[48;2;255;255;255m \x1b[0m\x1b[38;2;255;255;255m white #FFFFFF\x1b[0m\n\x1b[48;2;0;0;0m \x1b[0m\x1b[38;2;0;0;0m black #000000\x1b[0m" # noqa E501
expected_swatch = "\x1b[48;2;255;255;255m \x1b[0m\x1b[48;2;0;0;0m \x1b[0m\n\x1b[48;2;255;255;255m \x1b[0m\x1b[38;2;255;255;255m white #FFFFFF\x1b[0m\n\x1b[48;2;0;0;0m \x1b[0m\x1b[38;2;0;0;0m black #000000\x1b[0m" # noqa E501
assert (
Palette(
"my_palette",
Expand Down
Loading
Loading