Skip to content

Commit

Permalink
Classify named colors in documentation based on hue, lightness, and s…
Browse files Browse the repository at this point in the history
…aturation (pyvista#6955)

* Add color classifications to docs

* Rename variable

* Update table ranges, add title, use HLS values

* Update color hue ranges

* Fix formatting

* Add test

* Remove comments

* Remove int_hls

* Update docstring

* Apply suggestions from code review

Co-authored-by: Tetsuo Koyama <tkoyama010@gmail.com>

---------

Co-authored-by: Tetsuo Koyama <tkoyama010@gmail.com>
  • Loading branch information
user27182 and tkoyama010 authored Dec 13, 2024
1 parent d4b8740 commit c250fd1
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 21 deletions.
24 changes: 22 additions & 2 deletions doc/source/api/utilities/color_table.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ for additional information about the ``CSS`` and ``TABLEAU`` colors.

.. include:: /api/utilities/color_table/color_table.rst

.. dropdown:: Colors Sorted by Hue, Saturation, and Value
.. dropdown:: Colors Sorted by Hue, Lightness, and Saturation (HLS)

.. include:: /api/utilities/color_table/color_table_sorted.rst
.. include:: /api/utilities/color_table/color_table_BLACK.rst

.. include:: /api/utilities/color_table/color_table_GRAY.rst

.. include:: /api/utilities/color_table/color_table_WHITE.rst

.. include:: /api/utilities/color_table/color_table_RED.rst

.. include:: /api/utilities/color_table/color_table_ORANGE.rst

.. include:: /api/utilities/color_table/color_table_YELLOW.rst

.. include:: /api/utilities/color_table/color_table_GREEN.rst

.. include:: /api/utilities/color_table/color_table_CYAN.rst

.. include:: /api/utilities/color_table/color_table_BLUE.rst

.. include:: /api/utilities/color_table/color_table_VIOLET.rst

.. include:: /api/utilities/color_table/color_table_MAGENTA.rst
195 changes: 176 additions & 19 deletions doc/source/make_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
import colorsys
from collections.abc import Sequence
from dataclasses import dataclass
import sys

Expand Down Expand Up @@ -52,7 +52,7 @@ def __str__(self) -> str:
from types import FunctionType
from types import ModuleType

from pyvista.plotting.colors import ColorLike
from pyvista.plotting.colors import Color

# Paths to directories in which resulting rst files and images are stored.
CHARTS_TABLE_DIR = 'api/plotting/charts'
Expand Down Expand Up @@ -385,9 +385,10 @@ class ColorTable(DocTable):
"""Class to generate colors table."""

path = f'{COLORS_TABLE_DIR}/color_table.rst'
title = ''
header = _aligned_dedent(
"""
|.. list-table::
|.. list-table:: {}
| :widths: 8 48 18 26
| :header-rows: 1
| :stub-columns: 1
Expand All @@ -412,23 +413,24 @@ class ColorTable(DocTable):
@classmethod
def fetch_data(cls):
# Fetch table data from ``hexcolors`` dictionary.
return ColorTable._table_data_from_color_sequence(_sorted_color_names())
return ColorTable._table_data_from_color_sequence(ALL_COLORS)

@staticmethod
def _table_data_from_color_sequence(colors: Iterable[ColorLike]):
colors_obj: list[pv.Color] = [pv.Color(c) for c in colors]
def _table_data_from_color_sequence(colors: Sequence[Color]):
assert len(colors) > 0, 'No colors were provided.'
colors_dict: dict[str | None, dict[str, Any]] = {
c.name: {'name': c.name, 'hex': c.hex_rgb, 'synonyms': []} for c in colors_obj
c.name: {'name': c.name, 'hex': c.hex_rgb, 'synonyms': []} for c in colors
}
assert all(name is not None for name in colors_dict.keys()), 'Colors must be named.'
# Add synonyms defined in ``color_synonyms`` dictionary.
for s, name in pv.colors.color_synonyms.items():
colors_dict[name]['synonyms'].append(s)
if name in colors_dict:
colors_dict[name]['synonyms'].append(s)
return colors_dict.values()

@classmethod
def get_header(cls, data):
return cls.header
return cls.header.format(cls.title)

@classmethod
def get_row(cls, i, row_data):
Expand Down Expand Up @@ -456,23 +458,168 @@ def _sorted_color_names():
return sorted(color_names, key=lambda name: name.replace('tab:', ''))


def _sort_colors():
colors_rgb = [pv.Color(c).float_rgb for c in pv.hexcolors.values()]
# Sort colors by hue, saturation, and value (HSV)
return sorted(colors_rgb, key=lambda c: tuple(colorsys.rgb_to_hsv(*pv.Color(c).float_rgb)))
def _sort_colors_by_hls(colors: Sequence[Color]):
return sorted(colors, key=lambda c: c._float_hls)


ALL_COLORS: tuple[Color] = tuple(pv.Color(c) for c in pv.hexcolors.keys())

# Saturation constants
GRAYS_SATURATION_THRESHOLD = 0.15

# Lightness constants
LOWER_LIGHTNESS_THRESHOLD = 0.1
UPPER_LIGHTNESS_THRESHOLD = 0.9

# Hue constants in range [0, 1]
_360 = 360.0
RED_UPPER_BOUND = 10 / _360
ORANGE_UPPER_BOUND = 39 / _360
YELLOW_UPPER_BOUND = 61 / _360
GREEN_UPPER_BOUND = 157 / _360
CYAN_UPPER_BOUND = 187 / _360
BLUE_UPPER_BOUND = 248 / _360
VIOLET_UPPER_BOUND = 290 / _360
MAGENTA_UPPER_BOUND = 348 / _360


class ColorClassification(StrEnum):
WHITE = auto()
BLACK = auto()
GRAY = auto()
RED = auto()
YELLOW = auto()
ORANGE = auto()
GREEN = auto()
CYAN = auto()
BLUE = auto()
VIOLET = auto()
MAGENTA = auto()


def classify_color(color: Color) -> ColorClassification:
"""Classify color based on its Hue, Lightness, and Saturation (HLS)."""
hue, lightness, saturation = color._float_hls

# Classify by lightness
if lightness > UPPER_LIGHTNESS_THRESHOLD:
return ColorClassification.WHITE
elif lightness < LOWER_LIGHTNESS_THRESHOLD:
return ColorClassification.BLACK

# Classify by saturation
elif saturation < GRAYS_SATURATION_THRESHOLD:
return ColorClassification.GRAY

# Classify by hue
elif hue >= MAGENTA_UPPER_BOUND or hue < RED_UPPER_BOUND:
return ColorClassification.RED
elif RED_UPPER_BOUND <= hue < ORANGE_UPPER_BOUND:
return ColorClassification.ORANGE
elif ORANGE_UPPER_BOUND <= hue < YELLOW_UPPER_BOUND:
return ColorClassification.YELLOW
elif YELLOW_UPPER_BOUND <= hue < GREEN_UPPER_BOUND:
return ColorClassification.GREEN
elif GREEN_UPPER_BOUND <= hue < CYAN_UPPER_BOUND:
return ColorClassification.CYAN
elif CYAN_UPPER_BOUND <= hue < BLUE_UPPER_BOUND:
return ColorClassification.BLUE
elif BLUE_UPPER_BOUND <= hue < VIOLET_UPPER_BOUND:
return ColorClassification.VIOLET
elif VIOLET_UPPER_BOUND <= hue < MAGENTA_UPPER_BOUND:
return ColorClassification.MAGENTA
else:
raise RuntimeError(
f'Color with Hue {hue}, Lightness {lightness}, and Saturation {saturation}, was not categorized. \n'
f'Double-check classifier logic.'
)


SORTED_COLORS_AS_RGB_FLOAT = _sort_colors()
class ColorClassificationTable(ColorTable):
"""Class to generate sorted colors table."""

classification: ColorClassification

class SortedColorTable(ColorTable):
"""Class to generate sorted colors table."""
@property
@final
def path(cls):
return f'{COLORS_TABLE_DIR}/color_table_{cls.classification.name}.rst'

path = f'{COLORS_TABLE_DIR}/color_table_sorted.rst'
@classmethod
def get_header(cls, data):
return cls.header.format('**' + cls.classification.name.upper() + 'S**')

@classmethod
def fetch_data(cls):
return cls._table_data_from_color_sequence(SORTED_COLORS_AS_RGB_FLOAT)
colors = [color for color in ALL_COLORS if classify_color(color) == cls.classification]
colors = _sort_colors_by_hls(colors)
return cls._table_data_from_color_sequence(colors)


class ColorTableWHITE(ColorClassificationTable):
"""Class to generate WHITE colors table."""

classification = ColorClassification.WHITE


class ColorTableBLACK(ColorClassificationTable):
"""Class to generate BLACK colors table."""

classification = ColorClassification.BLACK


class ColorTableGRAY(ColorClassificationTable):
"""Class to generate GRAY colors table."""

classification = ColorClassification.GRAY


class ColorTableRED(ColorClassificationTable):
"""Class to generate RED colors table."""

classification = ColorClassification.RED


class ColorTableORANGE(ColorClassificationTable):
"""Class to generate ORANGE colors table."""

classification = ColorClassification.ORANGE


class ColorTableYELLOW(ColorClassificationTable):
"""Class to generate YELLOW colors table."""

classification = ColorClassification.YELLOW


class ColorTableGREEN(ColorClassificationTable):
"""Class to generate GREEN colors table."""

classification = ColorClassification.GREEN


class ColorTableCYAN(ColorClassificationTable):
"""Class to generate CYAN colors table."""

classification = ColorClassification.CYAN


class ColorTableBLUE(ColorClassificationTable):
"""Class to generate BLUE colors table."""

classification = ColorClassification.BLUE


class ColorTableVIOLET(ColorClassificationTable):
"""Class to generate VIOLET colors table."""

classification = ColorClassification.VIOLET


class ColorTableMAGENTA(ColorClassificationTable):
"""Class to generate MAGENTA colors table."""

classification = ColorClassification.MAGENTA


def _get_doc(func: Callable[[], Any]) -> str | None:
Expand Down Expand Up @@ -2119,7 +2266,17 @@ def make_all_tables(): # noqa: D103
MarkerStyleTable.generate()
ColorSchemeTable.generate()
ColorTable.generate()
SortedColorTable.generate()
ColorTableGRAY.generate()
ColorTableWHITE.generate()
ColorTableBLACK.generate()
ColorTableRED.generate()
ColorTableORANGE.generate()
ColorTableYELLOW.generate()
ColorTableGREEN.generate()
ColorTableCYAN.generate()
ColorTableBLUE.generate()
ColorTableVIOLET.generate()
ColorTableMAGENTA.generate()

# Make dataset gallery carousels
os.makedirs(DATASET_GALLERY_DIR, exist_ok=True)
Expand Down
6 changes: 6 additions & 0 deletions pyvista/plotting/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# of methods defined in this module.
from __future__ import annotations

from colorsys import rgb_to_hls
import inspect

from cycler import Cycler
Expand Down Expand Up @@ -844,6 +845,11 @@ def float_rgb(self) -> tuple[float, float, float]: # numpydoc ignore=RT01
"""
return self.float_rgba[:3]

@property
def _float_hls(self) -> tuple[float, float, float]:
"""Get the color as Hue, Lightness, Saturation (HLS) in range [0.0, 1.0]."""
return rgb_to_hls(*self.float_rgb)

@property
def hex_rgba(self) -> str: # numpydoc ignore=RT01
"""Get the color value as an RGBA hexadecimal value.
Expand Down
8 changes: 8 additions & 0 deletions tests/plotting/test_colors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import colorsys
import itertools

import matplotlib as mpl
Expand Down Expand Up @@ -120,6 +121,13 @@ def test_color():
c[4] # Invalid integer index


def test_color_hls():
lime = pv.Color('lime')
actual_hls = lime._float_hls
expected_hls = colorsys.rgb_to_hls(*lime.float_rgb)
assert actual_hls == expected_hls


def test_color_opacity():
color = pv.Color(opacity=0.5)
assert color.opacity == 128
Expand Down

0 comments on commit c250fd1

Please sign in to comment.