Skip to content

Update luminance calculation to use WCAG 2.0 standard coefficients #278

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

Merged
merged 3 commits into from
Mar 4, 2025
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/link-check-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"aliveStatusCodes": [200, 403]
}
2 changes: 2 additions & 0 deletions .github/workflows/link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ jobs:

- name: Run markdown link check
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
config-file: .github/workflows/link-check-config.json
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ default_install_hook_types: [pre-commit, commit-msg]

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6
rev: v0.9.9
hooks:
- id: ruff
args: [--fix]
Expand Down Expand Up @@ -65,7 +65,7 @@ repos:
exclude: ^(site/src/figs/.+\.svelte|data/wbm/20.+\..+|site/src/(routes|figs).+\.(yaml|json)|changelog.md)$

- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.20.0
rev: v9.21.0
hooks:
- id: eslint
types: [file]
Expand All @@ -80,12 +80,12 @@ repos:
- "@stylistic/eslint-plugin"

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.393
rev: v1.1.396
hooks:
- id: pyright

- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.31.1
rev: 0.31.2
hooks:
- id: check-jsonschema
files: ^pymatviz/keys\.yml$
Expand Down
8 changes: 7 additions & 1 deletion assets/scripts/fetch_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@
import shutil
import sys
from datetime import datetime, timedelta, timezone
from typing import NotRequired, TypedDict

import yaml
from serpapi import GoogleSearch

from pymatviz import ROOT


if os.getenv("CI"):
raise SystemExit("Skip scraping Google Scholar in CI")

# NotRequired can't be imported below Python 3.11
from typing import NotRequired, TypedDict


class ScholarPaper(TypedDict):
"""Type for a paper fetched from Google Scholar."""

Expand Down
9 changes: 5 additions & 4 deletions assets/scripts/structure_viz/structure_3d_plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@


# %% 3d example
supercells = {
key: struct.make_supercell(2, in_place=False)
for key, struct in df_phonons[Key.structure].head(6).items()
}
fig = pmv.structure_3d_plotly(
{
key: struct.make_supercell(2, in_place=False)
for key, struct in df_phonons[Key.structure].head(6).to_dict().items()
},
supercells,
elem_colors=ElemColorScheme.jmol,
# show_unit_cell={"edge": dict(color="white", width=1.5)},
hover_text=SiteCoords.cartesian_fractional,
Expand Down
4 changes: 2 additions & 2 deletions pymatviz/structure_viz/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from pymatviz.colors import ELEM_COLORS_ALLOY, ELEM_COLORS_JMOL, ELEM_COLORS_VESTA
from pymatviz.enums import ElemColorScheme, Key, SiteCoords
from pymatviz.utils import df_ptable, pick_bw_for_contrast
from pymatviz.utils import df_ptable, pick_max_contrast_color


if TYPE_CHECKING:
Expand Down Expand Up @@ -319,7 +319,7 @@ def draw_site(
text=txt,
textposition="middle center",
textfont=dict(
color=pick_bw_for_contrast(color, text_color_threshold=0.5),
color=pick_max_contrast_color(color),
size=np.clip(atom_size * site_radius * (0.8 if is_image else 1), 10, 18),
),
hoverinfo="text" if hover_text else None,
Expand Down
2 changes: 1 addition & 1 deletion pymatviz/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ExperimentalWarning(Warning):
get_fig_xy_range,
get_font_color,
luminance,
pick_bw_for_contrast,
pick_max_contrast_color,
pretty_label,
validate_fig,
)
Expand Down
57 changes: 29 additions & 28 deletions pymatviz/utils/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,17 @@
- get_font_color: Get the font color used in a Matplotlib or Plotly figure.
- get_fig_xy_range: Get the x and y range of a plotly or matplotlib figure.
- luminance: Compute the luminance of a color.
- pick_bw_for_contrast: Choose black or white text color for contrast.
- pick_max_contrast_color: Choose black or white text color for contrast.
- pretty_label: Map metric keys to their pretty labels.
- validate_fig: Decorator to validate the type of fig keyword argument.
"""

from __future__ import annotations

from functools import wraps
from typing import TYPE_CHECKING, Literal, cast

import matplotlib as mpl


if TYPE_CHECKING:
from typing import Literal

from typing import TYPE_CHECKING

import matplotlib.colors
import matplotlib.pyplot as plt
import pandas as pd
import plotly.graph_objects as go
Expand All @@ -36,6 +30,7 @@
VALID_FIG_NAMES,
AxOrFig,
Backend,
ColorType,
P,
R,
)
Expand Down Expand Up @@ -204,39 +199,45 @@ def get_font_color(fig: AxOrFig) -> str:
raise TypeError(f"Input must be {VALID_FIG_NAMES}, got {type(fig)=}")


def luminance(color: str | tuple[float, float, float]) -> float:
"""Compute the luminance of a color as in https://stackoverflow.com/a/596243.
def luminance(color: ColorType) -> float:
"""Compute the relative luminance of a color using the WCAG 2.0 formula.

Args:
color (tuple[float, float, float]): RGB color tuple with values in [0, 1].
color (ColorType): RGB color tuple with values in [0, 1] or a color string
that can be converted to RGB.

Returns:
float: Luminance of the color.
float: Relative luminance of the color in range [0, 1].
"""
# raises ValueError if color invalid
red, green, blue = mpl.colors.to_rgb(color)
return 0.299 * red + 0.587 * green + 0.114 * blue
r, g, b = matplotlib.colors.to_rgb(color)

# Calculate relative luminance using WCAG 2.0 coefficients
return 0.2126 * r + 0.7152 * g + 0.0722 * b


def pick_bw_for_contrast(
color: tuple[float, float, float] | str,
text_color_threshold: float = 0.7,
) -> Literal["black", "white"]:
"""Choose black or white text color for a given background color based on luminance.
def pick_max_contrast_color(
bg_color: ColorType,
luminance_threshold: float = 0.3, # Threshold for light/dark color distinction
colors: tuple[ColorType, ColorType] = ("white", "black"),
) -> ColorType:
"""Choose dark or light text color for a given background color based on WCAG 2.0.

Args:
color (tuple[float, float, float] | str): RGB color tuple with values in [0, 1].
text_color_threshold (float, optional): Luminance threshold for choosing
black or white text color. Defaults to 0.7.
bg_color (ColorType): Background color.
luminance_threshold (float, optional): Luminance threshold for choosing text
color. Defaults to 0.5 to distinguish between light and dark colors.
colors (tuple[ColorType, ColorType], optional): One light and one dark text
color to choose from in that order. Defaults to ("white", "black").

Returns:
"black" | "white": depending on the luminance of the background color.
ColorType: The color that provides better contrast, usually "black" or "white".
"""
if isinstance(color, str):
color = mpl.colors.to_rgb(color)
# Calculate luminance of the background color
bg_luminance = luminance(bg_color)

light_bg = luminance(cast(tuple[float, float, float], color)) > text_color_threshold
return "black" if light_bg else "white"
# Use black text on light colors (luminance > threshold)
return colors[1] if bg_luminance > luminance_threshold else colors[0]


def pretty_label(key: str, backend: Backend) -> str:
Expand Down
14 changes: 7 additions & 7 deletions tests/files/.pytest-split-durations
Original file line number Diff line number Diff line change
Expand Up @@ -1347,13 +1347,13 @@
"tests/test_utils.py::test_patch_dict_with_mutable_value": 0.00010375003330409527,
"tests/test_utils.py::test_patch_dict_with_new_key": 0.00011454100604169071,
"tests/test_utils.py::test_patch_dict_with_none_value": 0.00011349900159984827,
"tests/test_utils.py::test_pick_bw_for_contrast[color0-0.7-black]": 0.00016512599540874362,
"tests/test_utils.py::test_pick_bw_for_contrast[color1-0.7-white]": 0.0001293760142289102,
"tests/test_utils.py::test_pick_bw_for_contrast[color2-0.7-white]": 0.0001595000212546438,
"tests/test_utils.py::test_pick_bw_for_contrast[color3-0-black]": 0.00014279200695455074,
"tests/test_utils.py::test_pick_bw_for_contrast[color4-0.7-white]": 0.00013204198330640793,
"tests/test_utils.py::test_pick_bw_for_contrast[color5-0.7-white]": 0.00014637500862590969,
"tests/test_utils.py::test_pick_bw_for_contrast[color6-0.4-white]": 0.00015074998373165727,
"tests/test_utils.py::test_pick_max_contrast_color[color0-0.7-black]": 0.00016512599540874362,
"tests/test_utils.py::test_pick_max_contrast_color[color1-0.7-white]": 0.0001293760142289102,
"tests/test_utils.py::test_pick_max_contrast_color[color2-0.7-white]": 0.0001595000212546438,
"tests/test_utils.py::test_pick_max_contrast_color[color3-0-black]": 0.00014279200695455074,
"tests/test_utils.py::test_pick_max_contrast_color[color4-0.7-white]": 0.00013204198330640793,
"tests/test_utils.py::test_pick_max_contrast_color[color5-0.7-white]": 0.00014637500862590969,
"tests/test_utils.py::test_pick_max_contrast_color[color6-0.4-white]": 0.00015074998373165727,
"tests/test_utils.py::test_pretty_label": 0.00015891698421910405,
"tests/test_utils.py::test_si_fmt": 0.0001337480207439512,
"tests/test_utils.py::test_si_fmt_int": 0.0001231660135090351,
Expand Down
11 changes: 4 additions & 7 deletions tests/rdf/test_rdf_plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,7 @@ def test_element_pair_rdfs_conflicting_bins_and_bin_size(

@pytest.mark.parametrize(
("param", "values"),
[
("cutoff", (5, 10, 15, -1.5, -2)),
("bin_size", (0.05, 0.1, 0.2)),
],
[("cutoff", (5, 10, 15, -1.5, -2)), ("bin_size", (0.05, 0.1, 0.2))],
)
def test_element_pair_rdfs_cutoff_and_bin_size(
structures: list[Structure], param: str, values: tuple[float, ...]
Expand Down Expand Up @@ -199,12 +196,12 @@ def test_element_pair_rdfs_custom_colors_and_styles(


def test_element_pair_rdfs_reference_line(structures: list[Structure]) -> None:
ref_line_kwargs = {"line_color": "red", "line_width": 2}
ref_line_kwargs = {"line_color": "teal", "line_width": 2}
fig = element_pair_rdfs(structures, reference_line=ref_line_kwargs)
n_subplots = len(fig._grid_ref) * len(fig._grid_ref[0])
assert (
sum(
shape.type == "line" and shape.line.color == "red"
shape.type == "line" and shape.line.color == "teal"
for shape in fig.layout.shapes
)
== n_subplots
Expand Down Expand Up @@ -242,7 +239,7 @@ def test_full_rdf_basic(structures: list[Structure]) -> None:
for struct in structures:
fig = full_rdf(struct)
assert isinstance(fig, go.Figure)
assert fig.layout.xaxis.title.text == "r (Å)"
assert fig.layout.xaxis.title.text == "r [Å]"
assert fig.layout.yaxis.title.text == "g(r)"
assert len(fig.data) == 1
assert fig.data[0].name == ""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_scatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ def test_density_scatter_plotly(
stats_annotations = [
ann
for ann in fig.layout.annotations
if any(metric in ann.text for metric in ["MAE", "RMSE", "R<sup>2</sup>"])
if any(metric in ann.text for metric in ("MAE", "RMSE", "R<sup>2</sup>"))
]
assert len(stats_annotations) == 1, "Stats annotation not found"
assert all(
metric in stats_annotations[0].text for metric in ["MAE", "R<sup>2</sup>"]
metric in stats_annotations[0].text for metric in ("MAE", "R<sup>2</sup>")
), f"{stats_annotations[0].text=}"
if isinstance(stats, dict):
if "prefix" in stats:
Expand Down
28 changes: 14 additions & 14 deletions tests/utils/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,36 @@
((0, 0, 0), 0), # Black
((1, 1, 1), 1), # White
((0.5, 0.5, 0.5), 0.5), # Gray
((1, 0, 0), 0.299), # Red
((0, 1, 0), 0.587), # Green
((0, 0, 1, 0.3), 0.114), # Blue with alpha (should be ignored)
("#FF0000", 0.299), # Red
("#00FF00", 0.587), # Green
("#0000FF", 0.114), # Blue
("red", 0.299),
("green", 0.294650),
("blue", 0.114),
((1, 0, 0), 0.2126), # Red
((0, 1, 0), 0.7152), # Green
((0, 0, 1, 0.3), 0.0722), # Blue with alpha (should be ignored)
("#FF0000", 0.2126), # Red
("#00FF00", 0.7152), # Green
("#0000FF", 0.0722), # Blue
("red", 0.2126),
("green", 0.35900235),
("blue", 0.0722),
],
)
def test_luminance(color: tuple[float, float, float], expected: float) -> None:
assert pmv.utils.luminance(color) == pytest.approx(expected, 0.001), f"{color=}"


@pytest.mark.parametrize(
("color", "text_color_threshold", "expected"),
("color", "luminance_threshold", "expected"),
[
((1.0, 1.0, 1.0), 0.7, "black"), # White
((0, 0, 0), 0.7, "white"), # Black
((0.5, 0.5, 0.5), 0.7, "white"), # Gray
((0.5, 0.5, 0.5), 0, "black"), # Gray with low threshold
((1, 0, 0, 0.3), 0.7, "white"), # Red with alpha (should be ignored)
((0, 1, 0), 0.7, "white"), # Green
((0, 1, 0), 0.7, "black"), # Green
((0, 0, 1.0), 0.4, "white"), # Blue with low threshold
],
)
def test_pick_bw_for_contrast(
def test_pick_max_contrast_color(
color: tuple[float, float, float],
text_color_threshold: float,
luminance_threshold: float,
expected: Literal["black", "white"],
) -> None:
assert pmv.utils.pick_bw_for_contrast(color, text_color_threshold) == expected
assert pmv.utils.pick_max_contrast_color(color, luminance_threshold) == expected
Loading