0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 11:41:49 +02:00

feat(colors): evenly spaced color generation + new golden ratio calculation

This commit is contained in:
2024-10-25 12:22:55 +02:00
committed by wyzula_j
parent 5d4b86e1c6
commit 40c9fea35f
4 changed files with 208 additions and 40 deletions

View File

@ -107,9 +107,98 @@ class Colors:
angles.append(angle) angles.append(angle)
return angles return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Args:
theme(str): The theme to be applied.
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
tuple: Tuple of min_pos and max_pos.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if offset < 0 or offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
if theme is None:
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme.theme
if theme == "light":
min_pos = 0.0
max_pos = 1 - offset
else:
min_pos = 0.0 + offset
max_pos = 1.0
return min_pos, max_pos
@staticmethod
def evenly_spaced_colors(
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["light", "dark"] | None = None,
) -> list:
"""
Extract `num` colors from the specified colormap, evenly spaced along its range,
and return them in the specified format.
Args:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
if num == 1:
positions = np.array([(min_pos + max_pos) / 2])
else:
positions = np.linspace(min_pos, max_pos, num)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod @staticmethod
def golden_angle_color( def golden_angle_color(
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor" colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["dark", "light"] | None = None,
) -> list: ) -> list:
""" """
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format. Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
@ -118,45 +207,39 @@ class Colors:
colormap (str): Name of the colormap. colormap (str): Name of the colormap.
num (int): Number of requested colors. num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor'). format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns: Returns:
list: List of colors in the specified format. list: List of colors in the specified format.
Raises: Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap. ValueError: If theme_offset is not between 0 and 1.
""" """
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.getColors(mode="float")
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
ii = 0
while len(colors) < num:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme.theme == "light":
background = 255
else:
background = 0
if np.abs(np.mean(color[:3] * 255) - background) < 50:
ii += 1
continue
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions within the acceptable range
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX": if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name()) color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB": elif format.upper() == "RGB":
colors.append(tuple((np.array(color) * 255).astype(int))) color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR": elif format.upper() == "QCOLOR":
colors.append(QColor.fromRgbF(*color)) color_list.append(QColor.fromRgbF(*color))
else: else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
ii += 1 return color_list
return colors
@staticmethod @staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple: def hex_to_rgba(hex_color: str, alpha=255) -> tuple:

View File

@ -170,12 +170,17 @@ def test_ring_bar(rpc_server_dock):
bar_config = bar._config_dict bar_config = bar._config_dict
expected_colors = [list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB")] expected_colors_light = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
]
expected_colors_dark = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
]
bar_colors = [ring._config_dict["color"] for ring in bar.rings] bar_colors = [ring._config_dict["color"] for ring in bar.rings]
bar_values = [ring._config_dict["value"] for ring in bar.rings] bar_values = [ring._config_dict["value"] for ring in bar.rings]
assert bar_config["num_bars"] == 5 assert bar_config["num_bars"] == 5
assert bar_values == [10, 20, 30, 40, 50] assert bar_values == [10, 20, 30, 40, 50]
assert bar_colors == expected_colors assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock): def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock):

View File

@ -1,5 +1,6 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from qtpy.QtGui import QColor
from bec_widgets.utils import Colors from bec_widgets.utils import Colors
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import CurveConfig from bec_widgets.widgets.figure.plots.waveform.waveform_curve import CurveConfig
@ -73,3 +74,39 @@ def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF" assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF"
assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380" assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380"
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF" assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
@pytest.mark.parametrize("num", [10, 100, 400])
def test_evenly_spaced_colors(num):
colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor")
colors_hex = Colors.evenly_spaced_colors(colormap="magma", num=num, format="HEX")
colors_rgb = Colors.evenly_spaced_colors(colormap="magma", num=num, format="RGB")
assert len(colors_qcolor) == num
assert len(colors_hex) == num
assert len(colors_rgb) == num
assert all(isinstance(color, QColor) for color in colors_qcolor)
assert all(isinstance(color, str) for color in colors_hex)
assert all(isinstance(color, tuple) for color in colors_rgb)
assert all(color.isValid() for color in colors_qcolor)
assert all(color.startswith("#") for color in colors_hex)
@pytest.mark.parametrize("num", [10, 100, 400])
def test_golder_angle_colors(num):
colors_qcolor = Colors.golden_angle_color(colormap="magma", num=num, format="QColor")
colors_hex = Colors.golden_angle_color(colormap="magma", num=num, format="HEX")
colors_rgb = Colors.golden_angle_color(colormap="magma", num=num, format="RGB")
assert len(colors_qcolor) == num
assert len(colors_hex) == num
assert len(colors_rgb) == num
assert all(isinstance(color, QColor) for color in colors_qcolor)
assert all(isinstance(color, str) for color in colors_hex)
assert all(isinstance(color, tuple) for color in colors_rgb)
assert all(color.isValid() for color in colors_qcolor)
assert all(color.startswith("#") for color in colors_hex)

View File

@ -152,12 +152,30 @@ def test_getting_curve(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client) bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot() w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve") c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
c1_expected_config = CurveConfig( c1_expected_config_dark = CurveConfig(
widget_class="BECCurve", widget_class="BECCurve",
gui_id="test_curve", gui_id="test_curve",
parent_id=w1.gui_id, parent_id=w1.gui_id,
label="bpm4i-bpm4i", label="bpm4i-bpm4i",
color="#b73779", color="#3b0f70",
symbol="o",
symbol_color=None,
symbol_size=7,
pen_width=4,
pen_style="solid",
source="scan_segment",
signals=Signal(
source="scan_segment",
x=SignalData(name="samx", entry="samx", unit=None, modifier=None),
y=SignalData(name="bpm4i", entry="bpm4i", unit=None, modifier=None),
),
)
c1_expected_config_light = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id=w1.gui_id,
label="bpm4i-bpm4i",
color="#000004",
symbol="o", symbol="o",
symbol_color=None, symbol_color=None,
symbol_size=7, symbol_size=7,
@ -171,14 +189,39 @@ def test_getting_curve(qtbot, mocked_client):
), ),
) )
assert w1.curves[0].config == c1_expected_config assert (
assert w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config w1.curves[0].config == c1_expected_config_dark
assert w1.get_curve(0).config == c1_expected_config or w1.curves[0].config == c1_expected_config_light
assert w1.get_curve_config("bpm4i-bpm4i", dict_output=True) == c1_expected_config.model_dump() )
assert w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config assert (
assert w1.get_curve("bpm4i-bpm4i").config == c1_expected_config w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_dark
assert c1.get_config(False) == c1_expected_config or w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_light
assert c1.get_config() == c1_expected_config.model_dump() )
assert (
w1.get_curve(0).config == c1_expected_config_dark
or w1.get_curve(0).config == c1_expected_config_light
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=True) == c1_expected_config_dark.model_dump()
or w1.get_curve_config("bpm4i-bpm4i", dict_output=True)
== c1_expected_config_light.model_dump()
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_dark
or w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_light
)
assert (
w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_dark
or w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_light
)
assert (
c1.get_config(False) == c1_expected_config_dark
or c1.get_config(False) == c1_expected_config_light
)
assert (
c1.get_config() == c1_expected_config_dark.model_dump()
or c1.get_config() == c1_expected_config_light.model_dump()
)
def test_getting_curve_errors(qtbot, mocked_client): def test_getting_curve_errors(qtbot, mocked_client):