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)
|
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:
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
Reference in New Issue
Block a user