Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 198 additions & 11 deletions nellie_napari/nellie_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@
QDoubleSpinBox,
QComboBox,
QScrollArea,
QButtonGroup,
QRadioButton,
)

from nellie.feature_extraction.hierarchical import HierarchyConfig
from nellie.im_info.verifier import (
CSVS_ONLY_PRESET,
DROPPABLE_KEYS,
KEEP_EVERYTHING_PRESET,
MASKS_AND_CSVS_PRESET,
)
from nellie.segmentation.filtering import FrangiConfig
from nellie.segmentation.labelling import LabelConfig
from nellie.segmentation.mocap_marking import MarkersConfig
Expand All @@ -25,13 +33,197 @@
from nellie.tracking.voxel_reassignment import VoxelReassignerConfig


# Per-key presentation metadata for the IntermediateRetentionGroup widget.
# Stage groupings, friendly labels, and tooltips. Order within each stage
# controls UI display order. See [[wiki/decisions/0014-intermediates-policy-frozenset]].
_RETENTION_STAGE_GROUPS: list[tuple[str, list[tuple[str, str, str]]]] = [
('Filter', [
('im_preprocessed', 'Frangi vesselness',
'Generated by: Filter (Frangi vesselness). Required to re-run Label.'),
]),
('Label', [
('im_instance_label', 'Instance segmentation labels',
'Generated by: Label. Required by Network, Markers, Hu, Voxel reassignment, Hierarchy.'),
]),
('Network', [
('im_skel', 'Skeleton',
'Generated by: Network. Required by Markers and Hierarchy.'),
('im_skel_relabelled', 'Branch-relabelled skeleton',
'Generated by: Network. Required by Hierarchy.'),
('im_pixel_class', 'Pixel classification',
'Generated by: Network. Required by Hierarchy.'),
]),
('Markers', [
('im_marker', 'Motion-anchor markers',
'Generated by: Markers. Required by Hu tracking.'),
('im_distance', 'Distance transform',
'Generated by: Markers. Required by Hierarchy.'),
('im_border', 'Border map',
'Generated by: Markers (internal).'),
]),
('Tracking', [
('flow_vector_array', 'Flow vectors',
'Generated by: Hu tracking. Required by Voxel reassignment and Hierarchy.'),
('voxel_matches', 'Voxel matches',
'Generated by: Hu tracking. Required by Voxel reassignment.'),
]),
('Voxel reassignment', [
('im_branch_label_reassigned', 'Tracked branch labels',
'Generated by: Voxel reassignment. Required by Hierarchy.'),
('im_obj_label_reassigned', 'Tracked object labels',
'Generated by: Voxel reassignment. Required by Hierarchy.'),
]),
('Hierarchy', [
('adjacency_maps', 'Node-edge adjacency maps',
'Generated by: Hierarchy (only when skip_nodes=False). '
'Read by the napari analyzer post-pipeline.'),
]),
('Pipeline input', [
('im_path', 'Canonical OME-TIFF (regenerable from source)',
'Generated by: ImInfo on first load. Read by every pipeline stage. '
'Regenerable from the original microscopy file (expensive).'),
]),
]


_PRESET_NAME_TO_DROP_SET: dict[str, frozenset[str]] = {
'Keep everything': KEEP_EVERYTHING_PRESET,
'Masks + CSVs only': MASKS_AND_CSVS_PRESET,
'CSVs only': CSVS_ONLY_PRESET,
}
_PRESET_DROP_SET_TO_NAME: dict[frozenset[str], str] = {
v: k for k, v in _PRESET_NAME_TO_DROP_SET.items()
}
_CUSTOM_PRESET_NAME = 'Custom'


class IntermediateRetentionGroup(QGroupBox):
"""Per-output intermediate retention controls.

Composition: a preset radio row at top + stage-grouped checkboxes
(one per key in :data:`DROPPABLE_KEYS`). Checkbox semantics:
**checked = drop**, unchecked = keep. Selecting a named preset
(Keep everything / Masks + CSVs only / CSVs only) sets the
checkboxes; toggling any checkbox flips the preset radio to
"Custom" automatically.

The "Custom" radio is set programmatically and is not user-clickable;
it is purely informational ("your selection diverges from any named
preset").

Read the current drop set via :meth:`drop_keys`; set it via
:meth:`set_drop_keys`.
"""

def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__("Intermediate file retention", parent)

self._key_checkboxes: dict[str, QCheckBox] = {}
self._preset_buttons: dict[str, QRadioButton] = {}
self._suppress_signals = False

outer = QVBoxLayout()

# Preset radio row.
preset_row = QHBoxLayout()
preset_row.addWidget(QLabel("Preset:"))
self._preset_button_group = QButtonGroup(self)
for name in (*_PRESET_NAME_TO_DROP_SET.keys(), _CUSTOM_PRESET_NAME):
btn = QRadioButton(name)
self._preset_button_group.addButton(btn)
self._preset_buttons[name] = btn
preset_row.addWidget(btn)
# Default selection.
self._preset_buttons['Keep everything'].setChecked(True)
# "Custom" is informational; user selects it indirectly via toggling
# any individual checkbox. Disable direct clicks but keep it visible.
self._preset_buttons[_CUSTOM_PRESET_NAME].setEnabled(False)
# Wire user-clickable presets to apply the matching drop set.
for name in _PRESET_NAME_TO_DROP_SET:
self._preset_buttons[name].toggled.connect(
lambda checked, n=name: self._on_preset_clicked(n, checked)
)
outer.addLayout(preset_row)

# Per-stage checkbox subgroups.
for stage_name, entries in _RETENTION_STAGE_GROUPS:
stage_box = QGroupBox(stage_name)
stage_layout = QVBoxLayout()
for key, friendly, tooltip in entries:
cb = QCheckBox(f"{friendly} (`{key}`)")
cb.setToolTip(tooltip)
cb.toggled.connect(self._on_checkbox_toggled)
self._key_checkboxes[key] = cb
stage_layout.addWidget(cb)
stage_box.setLayout(stage_layout)
outer.addWidget(stage_box)

self.setLayout(outer)

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def drop_keys(self) -> frozenset[str]:
"""Return the current drop set (keys whose checkbox is checked)."""
return frozenset(
key for key, cb in self._key_checkboxes.items() if cb.isChecked()
)

def set_drop_keys(self, drop_keys: frozenset[str]) -> None:
"""Apply a drop set to the checkboxes; sync the preset radio.

Validates that ``drop_keys`` is a subset of ``DROPPABLE_KEYS``.
Selects the matching named preset radio (or "Custom" if no
named preset matches).
"""
unknown = set(drop_keys) - DROPPABLE_KEYS
assert not unknown, f"set_drop_keys: unknown keys {sorted(unknown)}"
self._suppress_signals = True
try:
for key, cb in self._key_checkboxes.items():
cb.setChecked(key in drop_keys)
self._sync_preset_radio_to_drop_set(drop_keys)
finally:
self._suppress_signals = False

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def _on_preset_clicked(self, name: str, checked: bool) -> None:
if not checked or self._suppress_signals:
return
target = _PRESET_NAME_TO_DROP_SET[name]
self._suppress_signals = True
try:
for key, cb in self._key_checkboxes.items():
cb.setChecked(key in target)
finally:
self._suppress_signals = False

def _on_checkbox_toggled(self, _checked: bool) -> None:
if self._suppress_signals:
return
self._sync_preset_radio_to_drop_set(self.drop_keys())

def _sync_preset_radio_to_drop_set(self, drop_keys: frozenset[str]) -> None:
name = _PRESET_DROP_SET_TO_NAME.get(drop_keys, _CUSTOM_PRESET_NAME)
# Temporarily allow Custom to be programmatically selected.
was_enabled = self._preset_buttons[_CUSTOM_PRESET_NAME].isEnabled()
self._preset_buttons[_CUSTOM_PRESET_NAME].setEnabled(True)
# exclusive=True (default) ensures only one is checked.
self._preset_buttons[name].setChecked(True)
self._preset_buttons[_CUSTOM_PRESET_NAME].setEnabled(was_enabled)


@dataclass
class SettingsConfig:
"""
Structured representation of the Settings UI state.
"""
remove_edges: bool
remove_intermediates: bool
cleanup_drop_keys: frozenset[str]
voxel_reassign: bool
analyze_node_level: bool
track_all_frames: bool
Expand Down Expand Up @@ -140,13 +332,7 @@ def __init__(self, napari_viewer: "napari.viewer.Viewer", nellie, parent=None):
"weird image edge artifacts."
)

self.remove_intermediates_checkbox = QCheckBox("Remove intermediate files")
self.remove_intermediates_checkbox.setChecked(False)
self.remove_intermediates_checkbox.setEnabled(True)
self.remove_intermediates_checkbox.setToolTip(
"Remove intermediate files after processing. This means only CSV files "
"will be saved and intermediate data cannot be recovered."
)
self.intermediate_retention_group = IntermediateRetentionGroup()

self.voxel_reassign = QCheckBox("Auto-run voxel reassignment")
self.voxel_reassign.setChecked(True)
Expand Down Expand Up @@ -435,15 +621,16 @@ def set_ui(self):
processor_layout = QVBoxLayout()

subprocessor_layout1 = QHBoxLayout()
subprocessor_layout1.addWidget(self.remove_intermediates_checkbox)
subprocessor_layout1.addWidget(self.remove_edges_checkbox)
subprocessor_layout1.addStretch(1)

subprocessor_layout2 = QHBoxLayout()
subprocessor_layout2.addWidget(self.analyze_node_level)
subprocessor_layout2.addWidget(self.voxel_reassign)

processor_layout.addLayout(subprocessor_layout1)
processor_layout.addLayout(subprocessor_layout2)
processor_layout.addWidget(self.intermediate_retention_group)
processor_group.setLayout(processor_layout)

# Tracking settings group
Expand Down Expand Up @@ -642,7 +829,7 @@ def to_config(self) -> SettingsConfig:
"""
return SettingsConfig(
remove_edges=self.remove_edges_checkbox.isChecked(),
remove_intermediates=self.remove_intermediates_checkbox.isChecked(),
cleanup_drop_keys=self.intermediate_retention_group.drop_keys(),
voxel_reassign=self.voxel_reassign.isChecked(),
analyze_node_level=self.analyze_node_level.isChecked(),
track_all_frames=self.track_all_frames.isChecked(),
Expand Down Expand Up @@ -743,7 +930,7 @@ def apply_config(self, config: SettingsConfig):
Configuration to apply to the UI.
"""
self.remove_edges_checkbox.setChecked(config.remove_edges)
self.remove_intermediates_checkbox.setChecked(config.remove_intermediates)
self.intermediate_retention_group.set_drop_keys(config.cleanup_drop_keys)
self.voxel_reassign.setChecked(config.voxel_reassign)
self.analyze_node_level.setChecked(config.analyze_node_level)
self.track_all_frames.setChecked(config.track_all_frames)
Expand Down
Loading
Loading