From 8dae8e31ae02ee7c7111fa26c765c0df38240c9c Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 19 Jul 2026 14:59:18 -0500 Subject: [PATCH] feat(autoseg): reapply palette colors to existing objects Objects imported before the autoseg color features baked their colors in at import time, so they never pick up the current colorblind-safe default (or a custom) palette. Add "Reapply autoseg colors..." to the object-list / object context menu to push the CURRENT palette + seed back onto selected objects. - palette.py: AUTOSEG_TRACE_PREFIX (single source of truth for the "autoseg_" import naming), label_id_from_name (recovers the label id from an unmodified import name), and palette_color_for_name (reproduces the exact import color for a parseable name; falls back to a stable, PYTHONHASHSEED- independent zlib.crc32 hash of the name otherwise). conversions.py now builds import names from the shared prefix so the two stay in sync. - series.reapplyAutosegColors: resolves the palette/seed and per-object color once, then rewrites colors through the normal per-section editTraceAttributes path over only the sections the objects appear on -- one undoable operation, correct list/field refresh (reuses the #69 stale-render funnel), no per-object full-series scans. - GUI action goes through the existing object_function wrapper, so locked objects are blocked exactly as any other bulk attribute edit, and a confirm dialog warns before existing colors are discarded (undo restores them). Selection is the contract: it composes with the regex/group/invert filters, so "select all autoseg_* then reapply" works without guessing a name pattern. Tests: id-parse + hash-fallback determinism, custom palette/seed respected, recolor matches a fresh import, distinct ids stay distinct, single undo restores every prior color across sections, zero-selection no-op. Co-Authored-By: Claude Fable 5 --- .../modules/backend/autoseg/conversions.py | 8 +- .../modules/backend/autoseg/palette.py | 60 ++++ PyReconstruct/modules/datatypes/series.py | 65 +++- .../modules/gui/main/context_menu_list.py | 5 + .../modules/gui/main/field_widget_3_object.py | 29 +- tests/test_autoseg_reapply_colors.py | 298 ++++++++++++++++++ 6 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 tests/test_autoseg_reapply_colors.py diff --git a/PyReconstruct/modules/backend/autoseg/conversions.py b/PyReconstruct/modules/backend/autoseg/conversions.py index 47979765..0145a422 100644 --- a/PyReconstruct/modules/backend/autoseg/conversions.py +++ b/PyReconstruct/modules/backend/autoseg/conversions.py @@ -13,7 +13,7 @@ from PyReconstruct.modules.backend.threading import ThreadPoolProgBar from PyReconstruct.modules.calc import reducePoints -from .palette import DEFAULT_AUTOSEG_PALETTE, palette_color +from .palette import AUTOSEG_TRACE_PREFIX, DEFAULT_AUTOSEG_PALETTE, palette_color dt = None @@ -761,7 +761,7 @@ def importSection(data_zg, group, snum, series, ids=None): ## Add exteriors as traces for ext in exteriors: - trace_name = f"autoseg_{id}" + trace_name = f"{AUTOSEG_TRACE_PREFIX}{id}" trace_color = palette_color(id, palette, color_seed) trace = Trace(name=trace_name, color=trace_color) @@ -771,8 +771,8 @@ def importSection(data_zg, group, snum, series, ids=None): section.addTrace(trace) ## Add trace to group - series.object_groups.add(f"seg_{dt}", f"autoseg_{id}") - series.object_groups.add(f"seg_{group}", f"autoseg_{id}") + series.object_groups.add(f"seg_{dt}", f"{AUTOSEG_TRACE_PREFIX}{id}") + series.object_groups.add(f"seg_{group}", f"{AUTOSEG_TRACE_PREFIX}{id}") section.save() diff --git a/PyReconstruct/modules/backend/autoseg/palette.py b/PyReconstruct/modules/backend/autoseg/palette.py index 9e00bb07..e9019e29 100644 --- a/PyReconstruct/modules/backend/autoseg/palette.py +++ b/PyReconstruct/modules/backend/autoseg/palette.py @@ -70,6 +70,66 @@ ] +# Prefix autoseg import gives every trace/object it creates. The object name is +# this prefix followed by the (decimal) segmentation label id, e.g. "autoseg_42". +# Kept here as the single source of truth so both the import (which builds the +# name) and the recolor (which recovers the id from the name) stay in sync. +AUTOSEG_TRACE_PREFIX = "autoseg_" + + +def label_id_from_name(name: str): + """Recover the segmentation label id baked into an autoseg object name. + + Import names each object ``f"{AUTOSEG_TRACE_PREFIX}{id}"`` (e.g. + "autoseg_42"), so the original label id is the trailing decimal integer. + Returns that int when the name matches exactly, else None (the caller then + falls back to a stable name hash). + + Params: + name (str): the object name + Returns: + (int | None): the label id, or None when the name is not an + unmodified autoseg import name + """ + if not isinstance(name, str) or not name.startswith(AUTOSEG_TRACE_PREFIX): + return None + suffix = name[len(AUTOSEG_TRACE_PREFIX):] + # Only a bare run of digits is a genuine label id. This deliberately + # rejects renamed/derived names ("autoseg_42_dendrite", "autoseg_") so they + # take the stable-hash fallback instead of silently colliding on int(42). + if not suffix.isdigit(): + return None + return int(suffix) + + +def palette_color_for_name(name: str, palette=None, seed: int = 0) -> tuple: + """Return the palette color an object *named* ``name`` should get. + + Used to (re)apply the current palette to already-imported objects. When the + name still encodes its autoseg label id (see ``label_id_from_name``) the + result is byte-identical to what import would have assigned for that id -- + so re-running with the same palette/seed is a no-op. When the name does not + parse (renamed, or never an autoseg object), the color is derived from a + stable hash of the name string: ``zlib.crc32`` is used deliberately because + it is deterministic across processes and Python runs (unlike the builtin + ``hash``, which is salted by PYTHONHASHSEED), so the same name always yields + the same color everywhere. + + Params: + name (str): the object name + palette: list of (R, G, B) entries; falls back to the shipped + default when None or empty + seed (int): re-roll seed (same meaning as palette_color) + Returns: + (tuple): an (R, G, B) integer triple from the palette + """ + label_id = label_id_from_name(name) + if label_id is None: + import zlib + label_id = zlib.crc32(str(name).encode("utf-8")) + return palette_color(label_id, palette, seed) + + def _mix(value: int, seed: int) -> int: """Deterministically scramble an integer (splitmix64/Murmur3 finalizer). diff --git a/PyReconstruct/modules/datatypes/series.py b/PyReconstruct/modules/datatypes/series.py index fef0c501..3696b9b3 100644 --- a/PyReconstruct/modules/datatypes/series.py +++ b/PyReconstruct/modules/datatypes/series.py @@ -1788,7 +1788,70 @@ def removeAllTraceTags(self, obj_names : list, series_states=None, log_event=Tru self.addLog(name, None, "Remove all trace tags") self.modified = True - + + def reapplyAutosegColors(self, obj_names : list, series_states=None, log_event=True): + """Recolor objects using the CURRENT autoseg palette and seed. + + Lets a user reapply today's palette (colorblind-safe default or a custom + one) to objects imported before the palette existed -- their old colors + were baked in at import time and never update on their own. + + Each object's color is resolved from its name: an unmodified autoseg + name ("autoseg_") recovers its label id and gets exactly the color a + fresh import would assign; any other name falls back to a stable hash of + the name (see ``palette_color_for_name``). The color is written through + the same per-section ``editTraceAttributes`` path every other bulk + attribute edit uses, so it is one undoable operation and the field/lists + refresh correctly. + + Params: + obj_names (list): the names of objects to recolor + series_states (dict): optional dict for GUI undo states + log_event (bool): True if the event should be logged + """ + from PyReconstruct.modules.backend.autoseg.palette import ( + DEFAULT_AUTOSEG_PALETTE, + palette_color_for_name, + ) + + ## Resolve the palette + seed once (empty override -> curated default), + ## then the per-object color once -- these do not vary by section. + palette = self.getOption("autoseg_color_palette") or DEFAULT_AUTOSEG_PALETTE + color_seed = self.getOption("autoseg_color_seed") or 0 + color_map = { + name: palette_color_for_name(name, palette, color_seed) + for name in obj_names + } + + ## Touch only the sections the selected objects appear on. + for snum, section in self.enumerateSections( + message="Reapplying autoseg colors...", + series_states=series_states, + section_numbers=self.getObjectSections(obj_names) + ): + modified = False + for obj_name in obj_names: + if obj_name in section.contours: + traces = section.contours[obj_name].getTraces() + if traces: + section.editTraceAttributes( + traces, + name=None, + color=color_map[obj_name], + tags=None, + mode=None, + log_event=False + ) + modified = True + if modified: + section.save() + + if log_event: + for name in obj_names: + self.addLog(name, None, "Reapply autoseg colors") + + self.modified = True + def hideObjects(self, obj_names : list, hide=True, series_states=None, log_event=True): """Hide all traces of a set of objects throughout the series. diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 3a8ba741..aa83f9af 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -114,6 +114,11 @@ def get_context_menu_list_obj(self): None, ("setobjalignment_act", "Edit alignment...", "", self.editAlignment), None, + # Reapply the current autoseg palette (colorblind-safe default + # or a custom one) to objects imported before the palette + # existed, whose old colors were baked in at import time. + ("reapplyautosegcolors_act", "Reapply autoseg colors...", "", self.reapplyAutosegColors), + None, # Lock/Unlock lives here as its single home (it is a stored # object attribute); do NOT re-add it to another submenu. ("lockobj_act", "Lock", "", self.lockObjects), diff --git a/PyReconstruct/modules/gui/main/field_widget_3_object.py b/PyReconstruct/modules/gui/main/field_widget_3_object.py index c53e3cfe..786fb31e 100644 --- a/PyReconstruct/modules/gui/main/field_widget_3_object.py +++ b/PyReconstruct/modules/gui/main/field_widget_3_object.py @@ -17,7 +17,8 @@ TextWidget, ) from PyReconstruct.modules.gui.utils import ( - notify + notify, + notifyConfirm, ) from PyReconstruct.modules.gui.table import ( HistoryTableWidget, @@ -166,6 +167,32 @@ def editAttributes(self, obj_names : list): return True + @object_function(update_objects=True, reload_field=True) + def reapplyAutosegColors(self, obj_names : list): + """Recolor selected objects with the current autoseg palette + seed. + + Confirms first because it discards the objects' existing colors. Locked + objects are blocked by the object_function wrapper (update_objects=True), + exactly as any other bulk attribute edit; a single series undo restores + every prior color. + """ + n = len(obj_names) + s = "s" if n != 1 else "" + confirmed = notifyConfirm( + f"Recolor {n} selected object{s} using the current autoseg palette " + "and seed?\n\n" + "This replaces the objects' existing colors. You can undo it.", + yn=True, + ) + if not confirmed: + return False + + self.series.reapplyAutosegColors( + obj_names, + series_states=self.series_states, + ) + return True + @object_function(update_objects=True, reload_field=True) def smoothObject(self, obj_names: list): """Smooth object traces.""" diff --git a/tests/test_autoseg_reapply_colors.py b/tests/test_autoseg_reapply_colors.py new file mode 100644 index 00000000..31b30c7a --- /dev/null +++ b/tests/test_autoseg_reapply_colors.py @@ -0,0 +1,298 @@ +"""Tests for reapplying the autoseg palette to already-imported objects. + +Series imported before the autoseg color features baked their colors in at +import time. ``Series.reapplyAutosegColors`` lets a user push the CURRENT +palette (colorblind-safe default or a custom one) back onto selected objects. + +Two layers are covered: + +* the pure name -> color recovery (``label_id_from_name`` / + ``palette_color_for_name``): an unmodified autoseg name reproduces the exact + import color; anything else takes a stable, deterministic hash fallback; +* the bulk ``Series.reapplyAutosegColors`` path end-to-end on the real + ``shapes1.jser`` fixture: colors are rewritten through the normal + attribute-edit machinery, honor a custom palette, and a single series undo + restores every prior color across every section. +""" +import os +import shutil + +import pytest + +from PyReconstruct.modules.backend.autoseg.palette import ( + AUTOSEG_TRACE_PREFIX, + DEFAULT_AUTOSEG_PALETTE, + label_id_from_name, + palette_color, + palette_color_for_name, +) + +FIXTURE = os.path.join( + os.path.dirname(__file__), "..", "PyReconstruct", "assets", + "checker", "files", "shapes1.jser", +) + + +# --------------------------------------------------------------------------- # +# name -> label id recovery +# --------------------------------------------------------------------------- # + +def test_prefix_matches_import_naming(): + # guards the shared constant against drifting from the "autoseg_" scheme + assert AUTOSEG_TRACE_PREFIX == "autoseg_" + assert f"{AUTOSEG_TRACE_PREFIX}42" == "autoseg_42" + + +def test_label_id_parses_bare_autoseg_names(): + assert label_id_from_name("autoseg_0") == 0 + assert label_id_from_name("autoseg_1") == 1 + assert label_id_from_name("autoseg_42") == 42 + assert label_id_from_name("autoseg_1000") == 1000 + + +def test_label_id_rejects_non_autoseg_or_modified_names(): + # renamed / derived / non-autoseg names must NOT parse -> hash fallback + for name in ( + "autoseg_42_dendrite", # suffix added after import + "autoseg_", # no id + "autoseg_1a", # not all digits + "autoseg_-5", # sign is not a bare digit run + "mito_3", # different object entirely + "dendrite", + "", + ): + assert label_id_from_name(name) is None, name + + +def test_label_id_handles_non_string(): + assert label_id_from_name(None) is None + assert label_id_from_name(42) is None + + +# --------------------------------------------------------------------------- # +# name -> color mapping +# --------------------------------------------------------------------------- # + +def test_autoseg_name_reproduces_import_color_exactly(): + """An unmodified autoseg name recolors to EXACTLY what import assigned.""" + for label_id in range(0, 2000): + name = f"{AUTOSEG_TRACE_PREFIX}{label_id}" + assert palette_color_for_name(name) == palette_color(label_id) + + +def test_fallback_is_deterministic_and_from_palette(): + """Names that don't parse get a stable color drawn from the palette.""" + whitelist = set(DEFAULT_AUTOSEG_PALETTE) + for name in ("dendrite", "mito_3", "autoseg_42_dendrite", "spine 7", ""): + first = palette_color_for_name(name) + assert first == palette_color_for_name(name) # deterministic + assert first in whitelist # from palette + + +def test_fallback_does_not_depend_on_pythonhashseed(): + """crc32 fallback (not builtin hash) -> stable across processes/runs. + + Pin one concrete value so a switch to a salted/unstable hash is caught. + """ + import zlib + name = "dendrite" + expected = palette_color(zlib.crc32(name.encode("utf-8"))) + assert palette_color_for_name(name) == expected + + +def test_custom_palette_and_seed_are_respected(): + custom = [(10, 20, 30), (40, 50, 60), (70, 80, 90)] + for name in ("autoseg_7", "autoseg_8", "dendrite", "mito_3"): + c = palette_color_for_name(name, palette=custom, seed=3) + assert c in {tuple(x) for x in custom} + # matches the underlying palette_color contract for the recovered id + assert c == palette_color_for_name(name, palette=custom, seed=3) + # a different seed can reassign at least one name (sanity on seed plumbing) + assert any( + palette_color_for_name(f"autoseg_{i}", palette=custom, seed=0) + != palette_color_for_name(f"autoseg_{i}", palette=custom, seed=1) + for i in range(50) + ) + + +# --------------------------------------------------------------------------- # +# end-to-end on the real series fixture +# --------------------------------------------------------------------------- # + +def _load_series(tmp_path): + if not os.path.exists(FIXTURE): + pytest.skip("fixture shapes1.jser not found") + fp = str(tmp_path / "shapes1.jser") + shutil.copyfile(FIXTURE, fp) + + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication(["test"]) + from PyReconstruct.modules.datatypes.series import Series + from PyReconstruct.modules.datatypes.series_data import SeriesData + from PyReconstruct.modules.backend.progress import NullProgressReporter + + series = Series.openJser(fp) + sd = SeriesData(series) + sd.refresh() + series.data = sd + series.setProgressReporter(NullProgressReporter) + return series + + +def _force_options(series, palette=None, seed=0): + """Shadow getOption so tests never read/write the machine QSettings store. + + Returns colors for the two autoseg options and delegates everything else to + the real implementation. + """ + real = series.getOption + + def fake(option_name, get_default=False): + if option_name == "autoseg_color_palette": + return [] if palette is None else palette + if option_name == "autoseg_color_seed": + return seed + return real(option_name, get_default) + + series.getOption = fake + + +def _snapshot_colors(series, obj_names): + """Map (snum, obj, trace_index) -> color tuple for the given objects.""" + snap = {} + for snum, section in series.enumerateSections(show_progress=False): + for obj in obj_names: + if obj in section.contours: + for i, trace in enumerate(section.contours[obj].getTraces()): + snap[(snum, obj, i)] = tuple(trace.color) + return snap + + +def _some_objects(series, n=3): + names = sorted(series.data["objects"].keys()) + assert names, "fixture had no objects" + return names[:n] + + +def test_reapply_sets_expected_palette_colors(tmp_path): + series = _load_series(tmp_path) + _force_options(series) # default palette, seed 0 + objs = _some_objects(series) + + # bake a bogus uniform color first so any change is visible + series.editObjectAttributes(objs, color=(1, 1, 1), log_event=False) + + series.reapplyAutosegColors(objs, log_event=False) + + for snum, section in series.enumerateSections(show_progress=False): + for obj in objs: + if obj in section.contours: + expected = palette_color_for_name(obj) + for trace in section.contours[obj].getTraces(): + assert tuple(trace.color) == expected + series.close() + + +def test_reapply_id_parse_path_matches_fresh_import(tmp_path): + """Rename an object to a bare autoseg name; recolor must equal import.""" + series = _load_series(tmp_path) + _force_options(series) + obj = _some_objects(series, 1)[0] + + series.editObjectAttributes([obj], name="autoseg_5", log_event=False) + series.reapplyAutosegColors(["autoseg_5"], log_event=False) + + expected = palette_color(5) # exactly what a fresh import would assign + checked = 0 + for snum, section in series.enumerateSections(show_progress=False): + if "autoseg_5" in section.contours: + for trace in section.contours["autoseg_5"].getTraces(): + assert tuple(trace.color) == expected + checked += 1 + assert checked, "renamed object appeared on no section" + series.close() + + +def test_reapply_assigns_distinct_colors_to_distinct_ids(tmp_path): + """Two objects whose ids map to different palette entries stay distinct.""" + series = _load_series(tmp_path) + _force_options(series) + two = _some_objects(series, 2) + if len(two) < 2: + pytest.skip("fixture has fewer than two objects") + + # ids 1 and 2 map to different default-palette entries (pinned elsewhere) + assert palette_color(1) != palette_color(2) + series.editObjectAttributes([two[0]], name="autoseg_1", log_event=False) + series.editObjectAttributes([two[1]], name="autoseg_2", log_event=False) + series.reapplyAutosegColors(["autoseg_1", "autoseg_2"], log_event=False) + + colors = {} + for snum, section in series.enumerateSections(show_progress=False): + for name in ("autoseg_1", "autoseg_2"): + if name in section.contours: + for trace in section.contours[name].getTraces(): + colors[name] = tuple(trace.color) + assert colors.get("autoseg_1") == palette_color(1) + assert colors.get("autoseg_2") == palette_color(2) + assert colors["autoseg_1"] != colors["autoseg_2"] + series.close() + + +def test_reapply_respects_custom_palette(tmp_path): + series = _load_series(tmp_path) + custom = [(11, 22, 33), (44, 55, 66)] + _force_options(series, palette=custom, seed=0) + objs = _some_objects(series) + + series.reapplyAutosegColors(objs, log_event=False) + + custom_set = {tuple(c) for c in custom} + for snum, section in series.enumerateSections(show_progress=False): + for obj in objs: + if obj in section.contours: + expected = palette_color_for_name(obj, palette=custom, seed=0) + for trace in section.contours[obj].getTraces(): + assert tuple(trace.color) == expected + assert tuple(trace.color) in custom_set + series.close() + + +def test_reapply_is_a_single_undoable_operation(tmp_path): + """One series undo restores every prior color on every section.""" + from PyReconstruct.modules.backend.func.state_manager import SeriesStates + + series = _load_series(tmp_path) + _force_options(series) + objs = _some_objects(series) + + before = _snapshot_colors(series, objs) + assert before, "no traces to recolor in fixture" + + series_states = SeriesStates(series) + series.reapplyAutosegColors(objs, series_states=series_states, log_event=False) + + after = _snapshot_colors(series, objs) + assert after.keys() == before.keys() + assert any(after[k] != before[k] for k in before), \ + "recolor changed nothing -- undo test would be vacuous" + + can_undo = series_states.canUndo()[0] + assert can_undo, "recolor must leave an undoable series state" + series_states.undoState() + + restored = _snapshot_colors(series, objs) + assert restored == before, "a single undo must restore every prior color" + series.close() + + +def test_reapply_empty_selection_is_noop(tmp_path): + series = _load_series(tmp_path) + _force_options(series) + all_objs = _some_objects(series, 5) + before = _snapshot_colors(series, all_objs) + + series.reapplyAutosegColors([], log_event=False) # must not raise + + assert _snapshot_colors(series, all_objs) == before + series.close()