mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(colors): evenly spaced color generation + new golden ratio calculation
This commit is contained in:
@ -107,9 +107,98 @@ class Colors:
|
||||
angles.append(angle)
|
||||
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
|
||||
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:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
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":
|
||||
colors.append(QColor.fromRgbF(*color).name())
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
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":
|
||||
colors.append(QColor.fromRgbF(*color))
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
ii += 1
|
||||
return colors
|
||||
return color_list
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
|
@ -170,12 +170,17 @@ def test_ring_bar(rpc_server_dock):
|
||||
|
||||
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_values = [ring._config_dict["value"] for ring in bar.rings]
|
||||
assert bar_config["num_bars"] == 5
|
||||
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):
|
||||
|
@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
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, 128) == "#FF573380"
|
||||
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)
|
||||
|
@ -152,12 +152,30 @@ def test_getting_curve(qtbot, mocked_client):
|
||||
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
|
||||
w1 = bec_figure.plot()
|
||||
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",
|
||||
gui_id="test_curve",
|
||||
parent_id=w1.gui_id,
|
||||
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_color=None,
|
||||
symbol_size=7,
|
||||
@ -171,14 +189,39 @@ def test_getting_curve(qtbot, mocked_client):
|
||||
),
|
||||
)
|
||||
|
||||
assert w1.curves[0].config == c1_expected_config
|
||||
assert w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config
|
||||
assert w1.get_curve(0).config == c1_expected_config
|
||||
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 w1.get_curve("bpm4i-bpm4i").config == c1_expected_config
|
||||
assert c1.get_config(False) == c1_expected_config
|
||||
assert c1.get_config() == c1_expected_config.model_dump()
|
||||
assert (
|
||||
w1.curves[0].config == c1_expected_config_dark
|
||||
or w1.curves[0].config == c1_expected_config_light
|
||||
)
|
||||
assert (
|
||||
w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_dark
|
||||
or w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_light
|
||||
)
|
||||
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):
|
||||
|
Reference in New Issue
Block a user