diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index c2e4ba36..8ce8c609 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -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: diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index 1ba9068d..5add3ba5 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -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): diff --git a/tests/unit_tests/test_color_validation.py b/tests/unit_tests/test_color_validation.py index dd652553..6eb2c0df 100644 --- a/tests/unit_tests/test_color_validation.py +++ b/tests/unit_tests/test_color_validation.py @@ -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) diff --git a/tests/unit_tests/test_waveform1d.py b/tests/unit_tests/test_waveform1d.py index adaba4da..95697a99 100644 --- a/tests/unit_tests/test_waveform1d.py +++ b/tests/unit_tests/test_waveform1d.py @@ -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):