diff --git a/PyReconstruct/modules/gui/dialog/all_options.py b/PyReconstruct/modules/gui/dialog/all_options.py index c8a2caca..3f879250 100644 --- a/PyReconstruct/modules/gui/dialog/all_options.py +++ b/PyReconstruct/modules/gui/dialog/all_options.py @@ -15,6 +15,7 @@ ) from .quick_dialog import QuickDialog from .backup import BackupDialog +from .autoseg_palette import AutosegColorsWidget from PyReconstruct.modules.datatypes import Series from PyReconstruct.modules.constants import is_frozen @@ -340,17 +341,9 @@ def setOption(response): self.series.setOption("find_zoom", response[0]) self.addOptionWidget("find_zoom", structure, setOption) - # autoseg import colors - structure = [ - ["Color seed (same seed gives the same colors):", - ("int", self.series.getOption("autoseg_color_seed", use_defaults))], - ["Records which color arrangement autoseg import uses. To try new"], - ["colors, use the \"Shuffle colors\" button on the zarr import overlay;"], - ["it updates this seed. Set a specific number to reproduce a past run."], - ] - def setOption(response): - self.series.setOption("autoseg_color_seed", response[0]) - self.addOptionWidget("autoseg_colors", structure, setOption) + # autoseg import colors (seed + editable palette) + autoseg_widget = AutosegColorsWidget(self, self.series, use_defaults) + self.addOptionWidget("autoseg_colors", autoseg_widget) # user structure = [ diff --git a/PyReconstruct/modules/gui/dialog/autoseg_palette.py b/PyReconstruct/modules/gui/dialog/autoseg_palette.py new file mode 100644 index 00000000..80442184 --- /dev/null +++ b/PyReconstruct/modules/gui/dialog/autoseg_palette.py @@ -0,0 +1,254 @@ +"""Editor for the autoseg-import color palette (Series > Options > View). + +Autoseg import colors each imported trace from a curated whitelist, mapped +deterministically from the label id (see modules/backend/autoseg/palette.py). +The whitelist ships CVD-safe by default, but issue #96 asks that the user be +able to adjust it. This widget backs the ``autoseg_color_palette`` option: it +shows the palette as clickable color swatches, lets the user add/remove/recolor +them, and offers a one-click reset to the shipped color-blind-safe default. + +It is added to the options dialog as a plain composite widget (the same pattern +as BackupDialog) because a dynamic, growable swatch grid does not fit the static +quick_dialog structure tuples. Like every options widget it exposes +``accept(close=False)`` (validate) and ``set()`` (commit), which +AllOptionsDialog calls on OK. + +Note on determinism: import indexes the palette by ``hash(id) % len(palette)``, +so changing the *number* of colors reshuffles every id -> color assignment (not +just the colors that changed). That is inherent to length-based indexing and is +the same knob the color seed offers; it only affects future imports and the live +preview, never traces whose colors were already baked in at a past import. +""" + +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QColorDialog, + QApplication, +) +from PySide6.QtGui import QColor, QPixmap, QIcon, QPainter, QPalette +from PySide6.QtCore import QSize, Qt + +from .helper import resizeLineEdit +from PyReconstruct.modules.backend.autoseg.palette import DEFAULT_AUTOSEG_PALETTE +from PyReconstruct.modules.gui.utils import notify + +# A palette of one color makes every label id the same color and leaves the seed +# and "Shuffle colors" button with nothing to reshuffle (next_shuffle_seed no-ops +# below two colors). Two is the smallest palette for which the whole color +# machinery is still meaningful, so it is the enforced floor. Distinctness beyond +# that (avoiding near-duplicate or hard-to-see colors) is left to the user, per +# the maintainer's intent. +MIN_PALETTE_COLORS = 2 + +_SWATCH_SIZE = QSize(56, 28) + + +def normalize_palette(colors): + """Return the value to persist for ``autoseg_color_palette``. + + Store ``[]`` (meaning "use the shipped default") when the edited list matches + DEFAULT_AUTOSEG_PALETTE exactly, so a user who never customizes keeps + tracking the curated default even if it changes in a future release. + Otherwise store the explicit list of ``[R, G, B]`` integer lists -- the + format ``palette_color`` and the preview already consume. + + Params: + colors (list): list of (R, G, B) sequences + Returns: + (list): [] when equal to the default, else a list of [R, G, B] lists + """ + as_tuples = [tuple(int(v) for v in c) for c in colors] + if as_tuples == [tuple(c) for c in DEFAULT_AUTOSEG_PALETTE]: + return [] + return [[int(v) for v in c] for c in as_tuples] + + +def _swatch_icon(rgb): + """Build a filled color-swatch icon for a palette entry.""" + pixmap = QPixmap(_SWATCH_SIZE) + pixmap.fill(QColor(int(rgb[0]), int(rgb[1]), int(rgb[2]))) + return QIcon(pixmap) + + +class AutosegColorsWidget(QWidget): + + def __init__(self, parent, series, use_defaults=False): + """Create the autoseg import-colors editor. + + Params: + parent (QWidget): the parent widget + series (Series): the series whose options are edited + use_defaults (bool): show the shipped defaults instead of the + stored values (Reset Defaults in the options dialog) + """ + super().__init__(parent) + self.series = series + + stored = series.getOption("autoseg_color_palette", use_defaults) or [] + # An empty option means "use the built-in default"; show that default so + # the user edits a concrete starting palette rather than a blank list. + source = stored if stored else DEFAULT_AUTOSEG_PALETTE + self.colors = [[int(v) for v in c] for c in source] + + seed = series.getOption("autoseg_color_seed", use_defaults) or 0 + self._seed = int(seed) + + vlayout = QVBoxLayout() + + header = QLabel("Autoseg import colors", self) + f = header.font() + f.setBold(True) + header.setFont(f) + vlayout.addWidget(header) + + desc = QLabel( + "Imported autoseg traces are colored from this palette, chosen\n" + "deterministically per label id. The default palette is color-blind\n" + "safe and readable on grayscale images.", + self, + ) + vlayout.addWidget(desc) + + # seed row + seed_row = QHBoxLayout() + seed_row.addWidget(QLabel("Color seed (same seed gives the same colors):", self)) + self.seed_edit = QLineEdit(str(self._seed), self) + resizeLineEdit(self.seed_edit, "000000") + seed_row.addWidget(self.seed_edit) + seed_row.addStretch() + vlayout.addLayout(seed_row) + + seed_hint = QLabel( + 'The "Shuffle colors" button on the zarr import overlay updates this ' + "seed;\nset a specific number to reproduce a past run.", + self, + ) + vlayout.addWidget(seed_hint) + + # swatch grid: click (or Edit) a swatch to recolor it + self.list = QListWidget(self) + self.list.setViewMode(QListWidget.IconMode) + self.list.setIconSize(_SWATCH_SIZE) + self.list.setResizeMode(QListWidget.Adjust) + self.list.setMovement(QListWidget.Static) + self.list.setSpacing(4) + self.list.setSelectionMode(QListWidget.SingleSelection) + self.list.setMaximumHeight(150) + self.list.itemDoubleClicked.connect(self._edit_selected) + self.list.currentRowChanged.connect(lambda _: self._update_buttons()) + vlayout.addWidget(self.list) + + # controls + btn_row = QHBoxLayout() + add_btn = QPushButton("Add", self) + add_btn.clicked.connect(self._add_color) + self.edit_btn = QPushButton("Edit", self) + self.edit_btn.clicked.connect(self._edit_selected) + self.remove_btn = QPushButton("Remove", self) + self.remove_btn.clicked.connect(self._remove_selected) + reset_btn = QPushButton("Reset to default", self) + reset_btn.clicked.connect(self._reset_default) + btn_row.addWidget(add_btn) + btn_row.addWidget(self.edit_btn) + btn_row.addWidget(self.remove_btn) + btn_row.addStretch() + btn_row.addWidget(reset_btn) + vlayout.addLayout(btn_row) + + self.setLayout(vlayout) + self._rebuild() + + # --- palette editing ----------------------------------------------------- + + def _rebuild(self): + """Repopulate the swatch grid from self.colors.""" + self.list.clear() + for rgb in self.colors: + item = QListWidgetItem(self.list) + item.setIcon(_swatch_icon(rgb)) + item.setToolTip("rgb({}, {}, {})".format(*rgb)) + self._update_buttons() + + def _update_buttons(self): + has_sel = self.list.currentRow() >= 0 + self.edit_btn.setEnabled(has_sel) + # keep the palette at or above the floor + self.remove_btn.setEnabled(has_sel and len(self.colors) > MIN_PALETTE_COLORS) + + def _pick_color(self, initial): + """Open QColorDialog; return an [R, G, B] list or None if cancelled.""" + color = QColorDialog.getColor(QColor(*initial), self) + if color.isValid(): + return [color.red(), color.green(), color.blue()] + return None + + def _add_color(self): + rgb = self._pick_color((255, 255, 255)) + if rgb is None: + return + self.colors.append(rgb) + self._rebuild() + self.list.setCurrentRow(len(self.colors) - 1) + + def _edit_selected(self, *args): + row = self.list.currentRow() + if row < 0: + return + rgb = self._pick_color(self.colors[row]) + if rgb is None: + return + self.colors[row] = rgb + self._rebuild() + self.list.setCurrentRow(row) + + def _remove_selected(self): + row = self.list.currentRow() + if row < 0: + return + if len(self.colors) <= MIN_PALETTE_COLORS: + notify(f"The palette must keep at least {MIN_PALETTE_COLORS} colors.") + return + del self.colors[row] + self._rebuild() + + def _reset_default(self): + self.colors = [[int(v) for v in c] for c in DEFAULT_AUTOSEG_PALETTE] + self._rebuild() + + # --- options-dialog protocol (accept -> set) ----------------------------- + + def accept(self, close=True): + """Validate the inputs. Called by AllOptionsDialog before set().""" + text = self.seed_edit.text().strip() + if not text: + self._seed = 0 + else: + try: + self._seed = int(text) + except ValueError: + notify("Please enter a whole number for the color seed.") + return False + if len(self.colors) < MIN_PALETTE_COLORS: + notify(f"The palette must keep at least {MIN_PALETTE_COLORS} colors.") + return False + return True + + def set(self): + """Commit the seed and palette to the series options.""" + self.series.setOption("autoseg_color_seed", self._seed) + self.series.setOption("autoseg_color_palette", normalize_palette(self.colors)) + + # --- cosmetic border, matching the sibling OptionWidgets ----------------- + + def paintEvent(self, event): + super().paintEvent(event) + painter = QPainter(self) + painter.setPen(QApplication.palette().color(QPalette.WindowText)) + painter.drawRect(self.rect().adjusted(0, 0, -1, -1)) diff --git a/tests/test_autoseg_palette_editor.py b/tests/test_autoseg_palette_editor.py new file mode 100644 index 00000000..db293224 --- /dev/null +++ b/tests/test_autoseg_palette_editor.py @@ -0,0 +1,244 @@ +"""Tests for the autoseg import-colors editor (Series > Options > View). + +The editor backs the ``autoseg_color_palette`` / ``autoseg_color_seed`` options. +These tests exercise its save path (accept -> set), reset-to-default, the +minimum-color floor, and that what it persists is exactly what the import +preview and shuffle consume -- without opening real Qt dialogs. +""" +import types + +import pytest + +from PyReconstruct.modules.backend.autoseg.palette import ( + DEFAULT_AUTOSEG_PALETTE, + palette_color, +) +from PyReconstruct.modules.gui.dialog import autoseg_palette as ape +from PyReconstruct.modules.gui.dialog.autoseg_palette import ( + AutosegColorsWidget, + MIN_PALETTE_COLORS, + normalize_palette, +) + + +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + return QApplication.instance() or QApplication(["test"]) + + +class _SeriesStub: + """Minimal series exposing just the option get/set the editor uses.""" + + def __init__(self, palette=None, seed=0): + self.store = { + "autoseg_color_palette": [] if palette is None else palette, + "autoseg_color_seed": seed, + } + self.writes = [] + + def getOption(self, name, use_defaults=False): + if use_defaults: + return {"autoseg_color_palette": [], "autoseg_color_seed": 0}[name] + return self.store[name] + + def setOption(self, name, value): + self.store[name] = value + self.writes.append((name, value)) + + +def _widget(qapp, series, use_defaults=False): + return AutosegColorsWidget(None, series, use_defaults) + + +# --- normalize_palette (pure) ---------------------------------------------- + + +def test_normalize_default_returns_empty(): + # an unchanged default palette persists as [] so it keeps tracking the + # shipped default instead of freezing today's colors + assert normalize_palette([list(c) for c in DEFAULT_AUTOSEG_PALETTE]) == [] + assert normalize_palette([tuple(c) for c in DEFAULT_AUTOSEG_PALETTE]) == [] + + +def test_normalize_custom_returns_list_of_lists(): + custom = [(1, 2, 3), (4, 5, 6)] + assert normalize_palette(custom) == [[1, 2, 3], [4, 5, 6]] + + +def test_normalize_near_default_still_persists_explicitly(): + near = [list(c) for c in DEFAULT_AUTOSEG_PALETTE] + near[0] = [0, 0, 0] # one channel changed -> no longer the default + out = normalize_palette(near) + assert out != [] + assert out[0] == [0, 0, 0] + + +# --- construction: what the editor shows ----------------------------------- + + +def test_empty_option_shows_default_palette(qapp): + w = _widget(qapp, _SeriesStub(palette=[])) + assert w.colors == [list(c) for c in DEFAULT_AUTOSEG_PALETTE] + assert w.list.count() == len(DEFAULT_AUTOSEG_PALETTE) + + +def test_stored_custom_palette_is_shown(qapp): + custom = [[10, 20, 30], [40, 50, 60], [70, 80, 90]] + w = _widget(qapp, _SeriesStub(palette=custom, seed=7)) + assert w.colors == custom + assert w.list.count() == 3 + assert w.seed_edit.text() == "7" + + +# --- save path (accept -> set) --------------------------------------------- + + +def test_set_round_trips_custom_palette_and_seed(qapp): + series = _SeriesStub(palette=[], seed=0) + w = _widget(qapp, series) + w.colors = [[10, 20, 30], [40, 50, 60]] + w.seed_edit.setText("12345") + assert w.accept(close=False) is True + w.set() + assert series.store["autoseg_color_palette"] == [[10, 20, 30], [40, 50, 60]] + assert series.store["autoseg_color_seed"] == 12345 + + +def test_set_normalizes_unchanged_default_to_empty(qapp): + # opening on the default palette and saving without edits keeps the option [] + series = _SeriesStub(palette=[], seed=3) + w = _widget(qapp, series) + assert w.accept(close=False) is True + w.set() + assert series.store["autoseg_color_palette"] == [] + assert series.store["autoseg_color_seed"] == 3 + + +def test_saved_palette_is_consumable_by_preview_and_import(qapp): + """What the editor writes must be exactly what palette_color reads -- the + same option the live preview, shuffle and import all consume.""" + series = _SeriesStub(palette=[], seed=0) + w = _widget(qapp, series) + custom = [[10, 20, 30], [40, 50, 60], [200, 100, 0]] + w.colors = [list(c) for c in custom] + assert w.accept(close=False) is True + w.set() + saved = series.store["autoseg_color_palette"] + seen = {palette_color(i, saved, series.store["autoseg_color_seed"]) + for i in range(1, 500)} + assert seen # non-empty + assert all(list(c) in custom for c in seen) + + +# --- reset to default ------------------------------------------------------- + + +def test_reset_to_default_restores_cvd_palette(qapp): + series = _SeriesStub(palette=[[1, 1, 1], [2, 2, 2]], seed=0) + w = _widget(qapp, series) + assert w.colors == [[1, 1, 1], [2, 2, 2]] + w._reset_default() + assert w.colors == [list(c) for c in DEFAULT_AUTOSEG_PALETTE] + w.set() + # reset + save collapses back to the "use built-in default" sentinel + assert series.store["autoseg_color_palette"] == [] + + +# --- minimum-color floor ---------------------------------------------------- + + +def test_remove_refuses_below_minimum(qapp, monkeypatch): + notes = [] + monkeypatch.setattr(ape, "notify", lambda msg: notes.append(msg)) + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + w.list.setCurrentRow(0) + w._remove_selected() + assert len(w.colors) == MIN_PALETTE_COLORS # unchanged + assert notes # user was told why + + +def test_remove_button_disabled_at_minimum(qapp): + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + w.list.setCurrentRow(0) + assert not w.remove_btn.isEnabled() + + +def test_remove_allowed_above_minimum(qapp): + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6], [7, 8, 9]])) + w.list.setCurrentRow(1) + assert w.remove_btn.isEnabled() + w._remove_selected() + assert w.colors == [[1, 2, 3], [7, 8, 9]] + + +def test_accept_rejects_below_minimum(qapp, monkeypatch): + notes = [] + monkeypatch.setattr(ape, "notify", lambda msg: notes.append(msg)) + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + w.colors = [[1, 2, 3]] # forced below floor + assert w.accept(close=False) is False + assert notes + + +# --- seed validation -------------------------------------------------------- + + +def test_accept_treats_empty_seed_as_zero(qapp): + w = _widget(qapp, _SeriesStub(palette=[], seed=9)) + w.seed_edit.setText("") + assert w.accept(close=False) is True + assert w._seed == 0 + + +def test_accept_rejects_non_integer_seed(qapp, monkeypatch): + notes = [] + monkeypatch.setattr(ape, "notify", lambda msg: notes.append(msg)) + w = _widget(qapp, _SeriesStub(palette=[], seed=0)) + w.seed_edit.setText("not-a-number") + assert w.accept(close=False) is False + assert notes + + +# --- add / edit via the color dialog (stubbed) ------------------------------ + + +def _stub_color(monkeypatch, rgb): + from PySide6.QtGui import QColor + + class _C(QColor): + def isValid(self): + return rgb is not None + + def fake_getColor(initial=None, parent=None, *a, **k): + return _C(*rgb) if rgb is not None else _C() + + monkeypatch.setattr(ape.QColorDialog, "getColor", staticmethod(fake_getColor)) + + +def test_add_color_appends_picked_color(qapp, monkeypatch): + _stub_color(monkeypatch, (11, 22, 33)) + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + w._add_color() + assert w.colors[-1] == [11, 22, 33] + assert w.list.count() == 3 + + +def test_edit_color_replaces_selected(qapp, monkeypatch): + _stub_color(monkeypatch, (99, 88, 77)) + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + w.list.setCurrentRow(1) + w._edit_selected() + assert w.colors == [[1, 2, 3], [99, 88, 77]] + + +def test_cancelled_color_dialog_leaves_palette_unchanged(qapp, monkeypatch): + _stub_color(monkeypatch, None) # invalid -> user cancelled + w = _widget(qapp, _SeriesStub(palette=[[1, 2, 3], [4, 5, 6]])) + before = [list(c) for c in w.colors] + w._add_color() + assert w.colors == before + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"]))