From 003470c18cde8df1dba76280223dd8ac7dc16940 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 10:29:26 -0500 Subject: [PATCH 01/27] flex: union of same-type ROIs (Phase 1 backend) Widen AtlasROI (atlas_path/hemisphere/label), SubcorticalROI (atlas_path/ label), and SphericalROI (x/y/z/radius) to scalar|list, validated (non-empty, equal length) in __post_init__ without mutating scalar inputs. Normalize to parallel lists at point-of-use so single-region configs stay byte-identical and existing saved configs/tests still load. utils.configure_roi now builds N-region union masks: per-entry mask_path/ mask_space/mask_value (enabling cross-hemisphere and cross-atlas cortical unions) with operator sequence ['intersection']+['union']*(N-1) on SimNIBS' all-True base; multi-sphere uses nested roi_sphere_center + operators while a single sphere keeps the flat form. Focality 'everything-else' complement now sets ['difference']*N to satisfy SimNIBS' equal-length check. generate_label and builder.generate_report render label/center unions '+'-joined, never as a list repr. Verified against real SimNIBS 4.6: a 2-hemi cortical union passes apply_mask's equal-length check. Adds union + back-compat + validation tests. --- tests/test_opt_builder.py | 39 +++++ tests/test_opt_flex_utils.py | 291 ++++++++++++++++++++++++++++++++++- tit/opt/config.py | 141 +++++++++++++---- tit/opt/flex/builder.py | 69 ++++++--- tit/opt/flex/utils.py | 201 ++++++++++++++++-------- 5 files changed, 631 insertions(+), 110 deletions(-) diff --git a/tests/test_opt_builder.py b/tests/test_opt_builder.py index 8aaca4c2..7aa188f6 100644 --- a/tests/test_opt_builder.py +++ b/tests/test_opt_builder.py @@ -485,3 +485,42 @@ def test_rh_hemisphere(self): from tit.opt.flex.builder import _atlas_name_from_path as atlas_name_from_path assert atlas_name_from_path("/path/to/rh.Destrieux.annot", "rh") == "Destrieux" + + +# --------------------------------------------------------------------------- +# Report field helpers for combined (union) ROIs +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestReportUnionFields: + def test_join_single_returns_scalar(self): + from tit.opt.flex.builder import _join + + # Byte-identical single-region reports: a bare scalar, not a list. + assert _join(1001) == 1001 + assert _join([1001]) == 1001 + + def test_join_multi_returns_plus_joined_string(self): + from tit.opt.flex.builder import _join + + assert _join([17, 53]) == "17+53" + assert "[" not in _join([17, 53]) + + def test_sphere_report_fields_single_flat(self): + from tit.opt.flex.builder import _sphere_report_fields + + coords, radius = _sphere_report_fields( + SphericalROI(x=10, y=20, z=30, radius=15) + ) + assert coords == [10, 20, 30] + assert radius == 15 + + def test_sphere_report_fields_multi_nested(self): + from tit.opt.flex.builder import _sphere_report_fields + + coords, radius = _sphere_report_fields( + SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8) + ) + assert coords == [[10, 20, 30], [-10, -20, -30]] + assert radius == [8, 8] diff --git a/tests/test_opt_flex_utils.py b/tests/test_opt_flex_utils.py index 2a6f3c72..9d48a863 100644 --- a/tests/test_opt_flex_utils.py +++ b/tests/test_opt_flex_utils.py @@ -244,8 +244,13 @@ def test_volumetric_sphere_with_mni(self): config = _make_config( roi=SphericalROI( - x=-24, y=-4, z=-20, radius=8, - use_mni=True, volumetric=True, tissues="WM", + x=-24, + y=-4, + z=-20, + radius=8, + use_mni=True, + volumetric=True, + tissues="WM", ), ) configure_roi(opt, config) @@ -620,3 +625,285 @@ def test_spherical_float_coords(self): label = generate_label(config) assert "10.5" in label assert "-20.5" in label + + +# --------------------------------------------------------------------------- +# Union of same-type ROIs (multi-region targets) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConfigureAtlasUnion: + def test_two_label_same_hemisphere(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=AtlasROI( + atlas_path="/path/to/lh.aparc.annot", + label=[1001, 1002], + hemisphere="lh", + ), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [1001, 1002] + # atlas_path broadcast to match the label list + assert roi_mock.mask_path == [ + "/path/to/lh.aparc.annot", + "/path/to/lh.aparc.annot", + ] + assert roi_mock.mask_space == ["subject_lh", "subject_lh"] + # First fold intersects the all-True base, the rest union on. + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_cross_hemisphere_union(self): + """Two entries with differing mask_path AND mask_space (lh + rh).""" + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=AtlasROI( + atlas_path=["/path/to/lh.aparc.annot", "/path/to/rh.aparc.annot"], + label=[17, 53], + hemisphere=["lh", "rh"], + ), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [17, 53] + assert roi_mock.mask_path == [ + "/path/to/lh.aparc.annot", + "/path/to/rh.aparc.annot", + ] + assert roi_mock.mask_space == ["subject_lh", "subject_rh"] + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_union_focality_everything_else_complement_length(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=AtlasROI( + atlas_path="/path/to/lh.aparc.annot", + label=[1001, 1002], + hemisphere="lh", + ), + ) + configure_roi(opt, config) + + # Complement operator length must equal the ROI mask length (N=2). + assert non_roi_mock.mask_operator == ["difference", "difference"] + assert len(non_roi_mock.mask_operator) == len(roi_mock.mask_value) + assert non_roi_mock.weight == -1 + + +@pytest.mark.unit +class TestConfigureSubcorticalUnion: + def test_two_label_shared_atlas(self, tmp_path): + """Both hippocampi (17, 53) from one aseg atlas.""" + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SubcorticalROI(atlas_path=str(atlas_file), label=[17, 53]), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [17, 53] + assert roi_mock.mask_path == [str(atlas_file), str(atlas_file)] + assert roi_mock.mask_space == ["subject", "subject"] + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_union_focality_complement_length(self, tmp_path): + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=SubcorticalROI(atlas_path=str(atlas_file), label=[17, 53]), + ) + configure_roi(opt, config) + + assert non_roi_mock.mask_operator == ["difference", "difference"] + assert len(non_roi_mock.mask_operator) == len(roi_mock.mask_value) + + def test_union_missing_one_atlas_raises(self, tmp_path): + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + opt.add_roi.return_value = MagicMock() + + config = _make_config( + roi=SubcorticalROI( + atlas_path=[str(atlas_file), "/nonexistent.nii.gz"], + label=[17, 53], + ), + ) + with pytest.raises(FileNotFoundError): + configure_roi(opt, config) + + +@pytest.mark.unit +class TestConfigureSphericalUnion: + def test_two_sphere_union(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=[8, 12]), + ) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_center == [[10, 20, 30], [-10, -20, -30]] + assert roi_mock.roi_sphere_radius == [8, 12] + assert roi_mock.roi_sphere_center_space == ["subject", "subject"] + assert roi_mock.roi_sphere_operator == ["intersection", "union"] + + def test_two_sphere_shared_radius_broadcast(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=9), + ) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_radius == [9, 9] + + def test_single_sphere_back_compat_flat(self): + """Scalar sphere keeps the flat (non-nested) form, no operator set.""" + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config(roi=SphericalROI(x=10, y=20, z=30, radius=15)) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_center == [10, 20, 30] + assert roi_mock.roi_sphere_radius == 15 + + def test_union_focality_complement_length(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8), + ) + configure_roi(opt, config) + + assert non_roi_mock.roi_sphere_operator == ["difference", "difference"] + assert non_roi_mock.roi_sphere_center == [[10, 20, 30], [-10, -20, -30]] + assert non_roi_mock.weight == -1 + + +@pytest.mark.unit +class TestUnionLabelRendering: + def test_generate_label_atlas_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=AtlasROI( + atlas_path=["/path/to/lh.aparc.annot", "/path/to/rh.aparc.annot"], + label=[17, 53], + hemisphere=["lh", "rh"], + ), + ) + label = generate_label(config) + assert "17+53" in label + assert "lh+rh" in label + assert "[" not in label and "]" not in label + + def test_generate_label_subcortical_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=SubcorticalROI(atlas_path="/path/to/aseg.nii.gz", label=[17, 53]), + ) + label = generate_label(config) + assert "17+53" in label + assert "[" not in label + + def test_generate_label_sphere_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8), + ) + label = generate_label(config) + assert label.count("sphere(") == 2 + assert "+" in label + + +@pytest.mark.unit +class TestUnionConfigValidation: + def test_atlas_hemisphere_length_mismatch_raises(self): + # length 3 hemisphere neither broadcasts (1) nor matches label count (2) + with pytest.raises(ValueError, match="hemisphere"): + AtlasROI( + atlas_path="/a.annot", label=[17, 53], hemisphere=["lh", "rh", "lh"] + ) + + def test_atlas_single_hemisphere_broadcasts(self): + # A single hemisphere is valid and broadcasts across all labels. + roi = AtlasROI(atlas_path="/a.annot", label=[17, 53], hemisphere="lh") + assert roi.label == [17, 53] + + def test_atlas_empty_label_raises(self): + with pytest.raises(ValueError, match="label"): + AtlasROI(atlas_path="/a.annot", label=[]) + + def test_sphere_unequal_coords_raise(self): + with pytest.raises(ValueError, match="equal length"): + SphericalROI(x=[1, 2], y=[3], z=[5, 6]) + + def test_sphere_bad_radius_length_raises(self): + with pytest.raises(ValueError, match="radius"): + SphericalROI(x=[1, 2], y=[3, 4], z=[5, 6], radius=[1, 2, 3]) + + def test_subcortical_atlas_length_mismatch_raises(self): + with pytest.raises(ValueError, match="atlas_path"): + SubcorticalROI(atlas_path=["/a.nii.gz", "/b.nii.gz"], label=[1, 2, 3]) diff --git a/tit/opt/config.py b/tit/opt/config.py index 99a0d88e..e6560662 100644 --- a/tit/opt/config.py +++ b/tit/opt/config.py @@ -24,6 +24,19 @@ from enum import StrEnum from typing import Literal + +def _as_list(value) -> list: + """Wrap a scalar in a single-element list; pass lists/tuples through. + + Strings are treated as scalars (``"lh"`` -> ``["lh"]``), never iterated + character-by-character. Used to normalise ROI fields that accept either a + single value (one region) or a list (a union of several regions). + """ + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + # ── Flex-search config ─────────────────────────────────────────────────────── @@ -187,16 +200,22 @@ class SphericalROI: tissue compartments are included (same semantics as :class:`SubcorticalROI.tissues`). + Each of *x*, *y*, *z*, *radius* accepts either a single value (one + sphere) or a list of values (a union of several spheres evaluated as + one combined target). The coordinate lists must be non-empty and of + equal length; *radius* may be a scalar (shared by every sphere) or a + list matching the number of centers. + Attributes ---------- - x : float - Center x-coordinate (mm). - y : float - Center y-coordinate (mm). - z : float - Center z-coordinate (mm). - radius : float - Sphere radius in mm. + x : float or list of float + Center x-coordinate(s) (mm). + y : float or list of float + Center y-coordinate(s) (mm). + z : float or list of float + Center z-coordinate(s) (mm). + radius : float or list of float + Sphere radius/radii in mm. A scalar is shared by all spheres. use_mni : bool If True, coordinates are in MNI space and SimNIBS will transform them to subject space during ROI setup. @@ -206,57 +225,125 @@ class SphericalROI: tissues : str Tissue compartments to include when *volumetric* is True. One of ``"GM"``, ``"WM"``, or ``"both"``. + + Raises + ------ + ValueError + If *x*/*y*/*z* are empty or unequal length, or *radius* is a list + whose length neither equals 1 nor the number of centers. """ - x: float - y: float - z: float - radius: float = 10.0 + x: float | list[float] + y: float | list[float] + z: float | list[float] + radius: float | list[float] = 10.0 use_mni: bool = False volumetric: bool = False tissues: str = "GM" # "GM", "WM", or "both" — only used when volumetric=True + def __post_init__(self): + xs, ys, zs = _as_list(self.x), _as_list(self.y), _as_list(self.z) + if not xs or not (len(xs) == len(ys) == len(zs)): + raise ValueError( + "SphericalROI x, y, z must be non-empty and of equal length" + ) + if len(_as_list(self.radius)) not in (1, len(xs)): + raise ValueError( + "SphericalROI radius must be a scalar or match the number of " + "centers" + ) + @dataclass class AtlasROI: """Cortical surface ROI from a FreeSurfer annotation atlas. + Each of *atlas_path*, *label*, *hemisphere* accepts either a single + value (one region) or a list (a union of several regions evaluated as + one combined target). Because ``.annot`` files are per-hemisphere, + carrying a per-region *hemisphere* (and matching *atlas_path*) allows a + target that spans **both** hemispheres, or even different atlases. + Scalars broadcast to the number of labels; lists must match its length. + Attributes ---------- - atlas_path : str - Path to the FreeSurfer ``.annot`` annotation file. - label : int - Integer label index within the annotation atlas. - hemisphere : str - Hemisphere to use (``"lh"`` or ``"rh"``). + atlas_path : str or list of str + Path(s) to the FreeSurfer ``.annot`` annotation file(s). + label : int or list of int + Integer label index/indices within the annotation atlas. + hemisphere : str or list of str + Hemisphere(s) to use (``"lh"`` or ``"rh"``), one per label. + + Raises + ------ + ValueError + If *label* is empty, or *atlas_path*/*hemisphere* is a list whose + length neither equals 1 nor the number of labels. """ - atlas_path: str - label: int - hemisphere: str = "lh" + atlas_path: str | list[str] + label: int | list[int] + hemisphere: str | list[str] = "lh" + + def __post_init__(self): + n = len(_as_list(self.label)) + if n == 0: + raise ValueError("AtlasROI label must be non-empty") + if len(_as_list(self.atlas_path)) not in (1, n): + raise ValueError( + "AtlasROI atlas_path must be a scalar or match the number " + "of labels" + ) + if len(_as_list(self.hemisphere)) not in (1, n): + raise ValueError( + "AtlasROI hemisphere must be a scalar or match the number " + "of labels" + ) @dataclass class SubcorticalROI: """Subcortical volume ROI from a volumetric atlas. + *label* accepts either a single value (one region) or a list (a union + of several regions -- e.g. both hippocampi from one ``aseg`` atlas -- + evaluated as one combined target). *atlas_path* may be a scalar + (shared by every label) or a list matching the number of labels; a + single shared *tissues* and *atlas_space* apply to the whole union. + Attributes ---------- - atlas_path : str - Path to the volumetric atlas NIfTI file. - label : int - Integer label index within the volumetric atlas. + atlas_path : str or list of str + Path(s) to the volumetric atlas NIfTI file(s). + label : int or list of int + Integer label index/indices within the volumetric atlas. tissues : str Tissue compartments to include. One of ``"GM"``, ``"WM"``, or ``"both"``. atlas_space : str Space of the atlas NIfTI. One of ``"subject"`` or ``"mni"``. MNI-space masks are transformed by SimNIBS during ROI setup. + + Raises + ------ + ValueError + If *label* is empty, or *atlas_path* is a list whose length + neither equals 1 nor the number of labels. """ - atlas_path: str - label: int + atlas_path: str | list[str] + label: int | list[int] tissues: str = "GM" # "GM", "WM", or "both" atlas_space: Literal["subject", "mni"] = "subject" + def __post_init__(self): + n = len(_as_list(self.label)) + if n == 0: + raise ValueError("SubcorticalROI label must be non-empty") + if len(_as_list(self.atlas_path)) not in (1, n): + raise ValueError( + "SubcorticalROI atlas_path must be a scalar or match the " + "number of labels" + ) + # ── Nested electrode config ─────────────────────────────────────── @dataclass diff --git a/tit/opt/flex/builder.py b/tit/opt/flex/builder.py index aabb856e..07b63daa 100644 --- a/tit/opt/flex/builder.py +++ b/tit/opt/flex/builder.py @@ -28,7 +28,7 @@ import numpy as np -from tit.opt.config import FlexConfig +from tit.opt.config import FlexConfig, _as_list from . import utils @@ -298,11 +298,12 @@ def generate_report( roi_data: dict = {} if isinstance(roi, FlexConfig.SphericalROI): + coordinates, radius = _sphere_report_fields(roi) roi_data = { "roi_name": "Target ROI", "roi_type": "spherical", - "coordinates": [roi.x, roi.y, roi.z], - "radius": roi.radius, + "coordinates": coordinates, + "radius": radius, "coordinate_space": "MNI" if roi.use_mni else "subject", } if ( @@ -311,22 +312,25 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.SphericalROI) ): nr = config.non_roi + nr_coordinates, nr_radius = _sphere_report_fields(nr) roi_data.update( { "non_roi_method": config.non_roi_method, - "non_roi_coordinates": [nr.x, nr.y, nr.z], - "non_roi_radius": nr.radius, + "non_roi_coordinates": nr_coordinates, + "non_roi_radius": nr_radius, "non_roi_coordinate_space": ("MNI" if nr.use_mni else "subject"), } ) elif isinstance(roi, FlexConfig.AtlasROI): - atlas_name = _atlas_name_from_path(roi.atlas_path, roi.hemisphere) + first_path = _as_list(roi.atlas_path)[0] + first_hemi = _as_list(roi.hemisphere)[0] + atlas_name = _atlas_name_from_path(first_path, first_hemi) roi_data = { "roi_name": "Target ROI", "roi_type": "atlas", - "hemisphere": roi.hemisphere, - "atlas": atlas_name or roi.atlas_path, - "atlas_label": roi.label, + "hemisphere": _join(roi.hemisphere), + "atlas": atlas_name or first_path, + "atlas_label": _join(roi.label), } if ( config.goal == "focality" @@ -334,23 +338,21 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.AtlasROI) ): nr = config.non_roi + nr_path = _as_list(nr.atlas_path)[0] roi_data.update( { - "non_roi_atlas": ( - os.path.basename(nr.atlas_path) if nr.atlas_path else None - ), - "non_roi_label": nr.label, + "non_roi_atlas": (os.path.basename(nr_path) if nr_path else None), + "non_roi_label": _join(nr.label), } ) elif isinstance(roi, FlexConfig.SubcorticalROI): + first_path = _as_list(roi.atlas_path)[0] roi_data = { "roi_name": "Target ROI", "roi_type": "subcortical", - "volume_atlas": ( - os.path.basename(roi.atlas_path) if roi.atlas_path else None - ), + "volume_atlas": (os.path.basename(first_path) if first_path else None), "volume_atlas_space": roi.atlas_space, - "volume_label": roi.label, + "volume_label": _join(roi.label), } if ( config.goal == "focality" @@ -358,13 +360,12 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.SubcorticalROI) ): nr = config.non_roi + nr_path = _as_list(nr.atlas_path)[0] roi_data.update( { - "non_roi_atlas": ( - os.path.basename(nr.atlas_path) if nr.atlas_path else None - ), + "non_roi_atlas": (os.path.basename(nr_path) if nr_path else None), "non_roi_atlas_space": nr.atlas_space, - "non_roi_label": nr.label, + "non_roi_label": _join(nr.label), } ) @@ -525,6 +526,32 @@ def _build_electrode_montage_base64(output_dir: Path) -> str | None: return base64.b64encode(buf.read()).decode("ascii") +def _join(values): + """Return the scalar when there is one region, else a ``+``-joined string. + + Keeps single-region report fields byte-identical (a bare int/str) while + rendering a union of labels/hemispheres as e.g. ``"17+53"`` rather than the + raw ``[17, 53]`` list repr. + """ + items = _as_list(values) + return items[0] if len(items) == 1 else "+".join(str(v) for v in items) + + +def _sphere_report_fields(roi_spec): + """Return ``(coordinates, radius)`` for the report from a (multi) sphere. + + Single sphere -> ``([x, y, z], r)`` (byte-identical with prior reports); + multiple spheres -> ``([[x, y, z], ...], [r, ...])``. + """ + xs, ys, zs = _as_list(roi_spec.x), _as_list(roi_spec.y), _as_list(roi_spec.z) + radii = _as_list(roi_spec.radius) + if len(radii) == 1 and len(xs) != 1: + radii = radii * len(xs) + if len(xs) == 1: + return [xs[0], ys[0], zs[0]], radii[0] + return [[x, y, z] for x, y, z in zip(xs, ys, zs)], radii + + def _atlas_name_from_path(path_value: str, hemisphere: str) -> str: """Extract a human-readable atlas name from an annotation file path.""" atlas_filename = os.path.basename(path_value) diff --git a/tit/opt/flex/utils.py b/tit/opt/flex/utils.py index 1440d439..dcdc916d 100644 --- a/tit/opt/flex/utils.py +++ b/tit/opt/flex/utils.py @@ -26,11 +26,40 @@ log = logging.getLogger(__name__) -from tit.opt.config import FlexConfig +from tit.opt.config import FlexConfig, _as_list _VOLUME_MASK_SPACES = {"subject", "mni"} +def _broadcast(value, n: int) -> list: + """Normalise *value* to a list of length *n*. + + A scalar (or single-element list) is repeated to length *n*; a list that + already has *n* elements is passed through. Used to align a shared ROI + field (e.g. one atlas path or radius) with a list of labels/centers. + """ + values = _as_list(value) + if len(values) == 1 and n != 1: + return values * n + return values + + +def _fmt_num(value): + """Render a coordinate/radius as an int when it has no fractional part.""" + return int(value) if value == int(value) else value + + +def _union_operators(n: int) -> list[str]: + """Operator sequence that unions *n* regions on SimNIBS' all-True base. + + SimNIBS seeds the working mask all-True and folds each region with its + operator, so the first fold must ``intersection`` (selecting region 1) and + the rest ``union`` on. An all-``union`` sequence would select the whole + brain. + """ + return ["intersection"] + ["union"] * (n - 1) + + def eeg_net_csv_path(eeg_positions_dir: str, eeg_net: str) -> Path: """Resolve an EEG net name or filename inside a subject EEG directory.""" filename = Path(eeg_net).name @@ -40,6 +69,7 @@ def eeg_net_csv_path(eeg_positions_dir: str, eeg_net: str) -> Path: filename = f"{filename}.csv" return Path(eeg_positions_dir) / filename + # --------------------------------------------------------------------------- # Output directory naming # --------------------------------------------------------------------------- @@ -113,26 +143,34 @@ def generate_label(config, pareto: bool = False) -> str: roi = config.roi if isinstance(roi, FlexConfig.SphericalROI): - x = int(roi.x) if roi.x == int(roi.x) else roi.x - y = int(roi.y) if roi.y == int(roi.y) else roi.y - z = int(roi.z) if roi.z == int(roi.z) else roi.z - r = int(roi.radius) if roi.radius == int(roi.radius) else roi.radius - roi_str = f"sphere({x},{y},{z})r{r}" + xs, ys, zs = _as_list(roi.x), _as_list(roi.y), _as_list(roi.z) + radii = _broadcast(roi.radius, len(xs)) + roi_str = "+".join( + f"sphere({_fmt_num(x)},{_fmt_num(y)},{_fmt_num(z)})r{_fmt_num(r)}" + for x, y, z, r in zip(xs, ys, zs, radii) + ) elif isinstance(roi, FlexConfig.AtlasROI): - hemi = roi.hemisphere + labels = _as_list(roi.label) + hemis = _broadcast(roi.hemisphere, len(labels)) + paths = _broadcast(roi.atlas_path, len(labels)) + hemi = "+".join(dict.fromkeys(hemis)) + first_path = paths[0] if paths else None atlas = ( - os.path.basename(roi.atlas_path).replace(".annot", "").split(".")[-1] - if roi.atlas_path + os.path.basename(first_path).replace(".annot", "").split(".")[-1] + if first_path else "atlas" ) - roi_str = f"{hemi}-{atlas}-{roi.label}" + roi_str = f"{hemi}-{atlas}-{'+'.join(str(v) for v in labels)}" elif isinstance(roi, FlexConfig.SubcorticalROI): - atlas = os.path.basename(roi.atlas_path) if roi.atlas_path else "volume" + labels = _as_list(roi.label) + paths = _broadcast(roi.atlas_path, len(labels)) + first_path = paths[0] if paths else None + atlas = os.path.basename(first_path) if first_path else "volume" for ext in (".nii.gz", ".nii", ".mgz"): if atlas.endswith(ext): atlas = atlas[: -len(ext)] break - roi_str = f"subcortical-{atlas}-{roi.label}" + roi_str = f"subcortical-{atlas}-{'+'.join(str(v) for v in labels)}" else: roi_str = "unknown" @@ -231,8 +269,40 @@ def _resolve_tissues(tissues_str: str) -> list: return [ElementTags.GM] -def _sphere_center(roi_spec: FlexConfig.SphericalROI) -> list[float]: - return [roi_spec.x, roi_spec.y, roi_spec.z] +def _apply_spheres(roi_obj, centers, radii, space, *, complement: bool) -> None: + """Write sphere geometry onto a SimNIBS ROI object. + + For a single sphere the scalar/flat form is used (``roi_sphere_center = + [x, y, z]``, ``roi_sphere_radius = r``, ``roi_sphere_center_space = space``) + so single-region behaviour is byte-identical to the pre-union code. For a + union of *N* spheres the list form is used with an explicit operator + sequence. When *complement* is True (focality "everything else") every + sphere is subtracted from the all-True base (``["difference"] * N``); + otherwise the union sequence is used. + """ + n = len(centers) + operators = ["difference"] * n if complement else _union_operators(n) + if n == 1: + roi_obj.roi_sphere_center_space = space + roi_obj.roi_sphere_center = centers[0] + roi_obj.roi_sphere_radius = radii[0] + # A single non-complement sphere leaves the operator unset (SimNIBS + # defaults to "intersection") to stay byte-identical with prior output. + if complement: + roi_obj.roi_sphere_operator = operators + else: + roi_obj.roi_sphere_center_space = [space] * n + roi_obj.roi_sphere_center = centers + roi_obj.roi_sphere_radius = list(radii) + roi_obj.roi_sphere_operator = operators + + +def _spheres_from_spec(roi_spec: FlexConfig.SphericalROI): + """Return ``(centers, radii, space)`` lists for a (possibly multi) sphere.""" + xs, ys, zs = _as_list(roi_spec.x), _as_list(roi_spec.y), _as_list(roi_spec.z) + radii = _broadcast(roi_spec.radius, len(xs)) + centers = [[x, y, z] for x, y, z in zip(xs, ys, zs)] + return centers, radii, _sphere_center_space(roi_spec) def _sphere_center_space(roi_spec: FlexConfig.SphericalROI) -> str: @@ -249,18 +319,16 @@ def _volume_mask_space(roi_spec: FlexConfig.SubcorticalROI) -> str: def _configure_spherical_roi(opt, config: FlexConfig) -> None: - """Set up a spherical ROI (surface or volumetric).""" + """Set up a spherical ROI (surface or volumetric), unioning N spheres.""" roi_spec: FlexConfig.SphericalROI = config.roi # type: ignore[assignment] - center = _sphere_center(roi_spec) - center_space = _sphere_center_space(roi_spec) - radius = roi_spec.radius + centers, radii, center_space = _spheres_from_spec(roi_spec) roi = opt.add_roi() if roi_spec.volumetric: log.info( - f"Using volumetric sphere (tissues={roi_spec.tissues}) " - f"at {center} ({center_space}) r={radius}" + f"Using volumetric sphere(s) (tissues={roi_spec.tissues}) " + f"at {centers} ({center_space}) r={radii}" ) roi.method = "volume" roi.tissues = _resolve_tissues(roi_spec.tissues) @@ -268,9 +336,7 @@ def _configure_spherical_roi(opt, config: FlexConfig) -> None: roi.method = "surface" roi.surface_type = "central" - roi.roi_sphere_center_space = center_space - roi.roi_sphere_center = center - roi.roi_sphere_radius = radius + _apply_spheres(roi, centers, radii, center_space, complement=False) # Add non-ROI if focality optimisation is requested if config.goal == "focality": @@ -283,31 +349,39 @@ def _configure_spherical_roi(opt, config: FlexConfig) -> None: non_roi.surface_type = "central" if config.non_roi_method == "everything_else": - non_roi.roi_sphere_center_space = center_space - non_roi.roi_sphere_center = center - non_roi.roi_sphere_radius = radius - non_roi.roi_sphere_operator = ["difference"] + _apply_spheres(non_roi, centers, radii, center_space, complement=True) non_roi.weight = -1 else: # Specific non-ROI from config.non_roi (unified ROISpec type) non_roi_spec: FlexConfig.SphericalROI = config.non_roi # type: ignore[assignment] - non_roi.roi_sphere_center = _sphere_center(non_roi_spec) - non_roi.roi_sphere_center_space = _sphere_center_space(non_roi_spec) - non_roi.roi_sphere_radius = non_roi_spec.radius + nr_centers, nr_radii, nr_space = _spheres_from_spec(non_roi_spec) + _apply_spheres(non_roi, nr_centers, nr_radii, nr_space, complement=False) non_roi.weight = -1 +def _atlas_mask_lists(roi_spec: FlexConfig.AtlasROI): + """Return parallel ``(mask_space, mask_path, mask_value)`` lists. + + Each label carries its own hemisphere (``subject_lh`` / ``subject_rh``) and + atlas path, so a target can span both hemispheres or several atlases. + """ + labels = _as_list(roi_spec.label) + n = len(labels) + hemis = _broadcast(roi_spec.hemisphere, n) + paths = _broadcast(roi_spec.atlas_path, n) + mask_space = [f"subject_{h}" for h in hemis] + return mask_space, list(paths), list(labels) + + def _configure_atlas_roi(opt, config: FlexConfig) -> None: - """Set up a cortical atlas-based ROI on the central surface.""" + """Set up a cortical atlas-based ROI (union of N labels) on the surface.""" roi_spec: FlexConfig.AtlasROI = config.roi # type: ignore[assignment] roi = opt.add_roi() roi.method = "surface" roi.surface_type = "central" - hemi = roi_spec.hemisphere - roi.mask_space = [f"subject_{hemi}"] - roi.mask_path = [roi_spec.atlas_path] - roi.mask_value = [roi_spec.label] + roi.mask_space, roi.mask_path, roi.mask_value = _atlas_mask_lists(roi_spec) + roi.mask_operator = _union_operators(len(roi.mask_value)) if config.goal == "focality": non_roi = opt.add_roi() @@ -318,13 +392,15 @@ def _configure_atlas_roi(opt, config: FlexConfig) -> None: non_roi.mask_space = roi.mask_space non_roi.mask_path = roi.mask_path non_roi.mask_value = roi.mask_value - non_roi.mask_operator = ["difference"] + # Length must match the ROI's mask lists or SimNIBS rejects it. + non_roi.mask_operator = ["difference"] * len(roi.mask_value) non_roi.weight = -1 else: non_roi_spec: FlexConfig.AtlasROI = config.non_roi # type: ignore[assignment] - non_roi.mask_space = roi.mask_space - non_roi.mask_path = [non_roi_spec.atlas_path] - non_roi.mask_value = [non_roi_spec.label] + non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( + _atlas_mask_lists(non_roi_spec) + ) + non_roi.mask_operator = _union_operators(len(non_roi.mask_value)) non_roi.weight = -1 @@ -334,24 +410,33 @@ def _resolve_roi_tissues(config: FlexConfig) -> list: return _resolve_tissues(roi_spec.tissues) -def _configure_subcortical_roi(opt, config: FlexConfig) -> None: - """Set up a subcortical volume-based ROI from a label atlas.""" - roi_spec: FlexConfig.SubcorticalROI = config.roi # type: ignore[assignment] +def _subcortical_mask_lists(roi_spec: FlexConfig.SubcorticalROI): + """Return parallel ``(mask_space, mask_path, mask_value)`` lists. - volume_atlas_path = roi_spec.atlas_path - label_val = roi_spec.label - mask_space = _volume_mask_space(roi_spec) + A single shared *atlas_space* applies to the whole union; *atlas_path* may + be shared (broadcast) or one path per label. Every atlas path is verified + to exist. + """ + labels = _as_list(roi_spec.label) + n = len(labels) + paths = _broadcast(roi_spec.atlas_path, n) + space = _volume_mask_space(roi_spec) + for path in paths: + if not path or not os.path.isfile(path): + raise FileNotFoundError(f"Volume atlas file not found: {path}") + return [space] * n, list(paths), list(labels) - if not volume_atlas_path or not os.path.isfile(volume_atlas_path): - raise FileNotFoundError(f"Volume atlas file not found: {volume_atlas_path}") + +def _configure_subcortical_roi(opt, config: FlexConfig) -> None: + """Set up a subcortical volume ROI (union of N labels) from a label atlas.""" + roi_spec: FlexConfig.SubcorticalROI = config.roi # type: ignore[assignment] tissues = _resolve_roi_tissues(config) roi = opt.add_roi() roi.method = "volume" - roi.mask_space = [mask_space] - roi.mask_path = [volume_atlas_path] - roi.mask_value = [label_val] + roi.mask_space, roi.mask_path, roi.mask_value = _subcortical_mask_lists(roi_spec) + roi.mask_operator = _union_operators(len(roi.mask_value)) roi.tissues = tissues if config.goal == "focality": @@ -362,19 +447,15 @@ def _configure_subcortical_roi(opt, config: FlexConfig) -> None: non_roi.mask_space = roi.mask_space non_roi.mask_path = roi.mask_path non_roi.mask_value = roi.mask_value - non_roi.mask_operator = ["difference"] + # Length must match the ROI's mask lists or SimNIBS rejects it. + non_roi.mask_operator = ["difference"] * len(roi.mask_value) non_roi.weight = -1 non_roi.tissues = tissues else: non_roi_spec: FlexConfig.SubcorticalROI = config.non_roi # type: ignore[assignment] - if not non_roi_spec.atlas_path or not os.path.isfile( - non_roi_spec.atlas_path - ): - raise FileNotFoundError( - f"Non-ROI volume atlas not found: {non_roi_spec.atlas_path}" - ) - non_roi.mask_space = [_volume_mask_space(non_roi_spec)] - non_roi.mask_path = [non_roi_spec.atlas_path] - non_roi.mask_value = [non_roi_spec.label] + non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( + _subcortical_mask_lists(non_roi_spec) + ) + non_roi.mask_operator = _union_operators(len(non_roi.mask_value)) non_roi.weight = -1 non_roi.tissues = tissues From a08f2b0a0d0a5f4acea919f0f5ba8afa55da29cc Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 10:38:59 -0500 Subject: [PATCH 02/27] =?UTF-8?q?flex=20GUI:=20multi-region=20ROI=20picker?= =?UTF-8?q?=20(union)=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROIPickerWidget now feeds the widened Phase-1 dataclasses so the flex target can be a union of same-type ROIs, while single-region entry stays byte-identical. Cortical page: - Region label QSpinBox -> comma-separated QLineEdit (_parse_labels helper). - hemi_combo gains a "Both (lh + rh)" option. - get_roi_spec expands (labels x hemispheres) into equal-length atlas_path/ hemisphere/label lists, building the per-entry lh/rh .annot path (Both x [17,53] -> label=[17,17,53,53], hemi=[lh,rh,lh,rh]). Subcortical page: - Region label QSpinBox -> comma-separated QLineEdit (list[int] labels). Spherical page: - Add an optional multi-sphere table (X/Y/Z/Radius rows) with Add/Remove; the existing spin boxes are sphere #1, so an empty table == prior single sphere. Plumbing: - _connect_signals: label inputs valueChanged -> textChanged; sphere add/remove wired to roi_changed (via lambdas so the button 'checked' bool isn't consumed as a coordinate arg). - New _collapse helper returns a scalar for a 1-element result, keeping single- region dataclasses/JSON byte-identical; lists only appear for real unions. - get_roi_params + validate handle the comma lists and multi-sphere rows; _validate_labels rejects empty/non-int/<1 labels; confirmation dialog string updated for unions. The same widget is reused as the focality non-ROI picker (self.nonroi_picker); its get_roi_spec path uses the same collapse logic, and the backend non-ROI branches route through _as_list/_broadcast, so "Specific Region" focality still round-trips for both single and multi inputs. Verified: py_compile both files; traced get_roi_spec expansion into the real Phase-1 dataclasses + utils (correct parallel lists, subject_lh/rh mask spaces, ['intersection','union'*] operators, scalar collapse for single). Backend suite green (117 passed in the config/utils/builder set; full suite 1897 passed with only the 3 documented pre-existing TestRunFlexSearch failures). GUI import tests skip (no PyQt5 in env). black clean. --- tit/gui/components/roi_picker.py | 251 +++++++++++++++++++++++++------ tit/gui/flex_search_tab.py | 19 ++- 2 files changed, 225 insertions(+), 45 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index a6de2274..701b9c50 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -222,6 +222,35 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: self.radius_input.setDecimals(2) layout.addRow(self.radius_label, self.radius_input) + # Additional spheres (optional) — the X/Y/Z/Radius above define the first + # sphere; each extra row unions another sphere into one combined target. + self.extra_spheres_table = QtWidgets.QTableWidget(0, 4) + self.extra_spheres_table.setHorizontalHeaderLabels(["X", "Y", "Z", "Radius"]) + self.extra_spheres_table.horizontalHeader().setStretchLastSection(True) + self.extra_spheres_table.verticalHeader().setVisible(False) + self.extra_spheres_table.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectRows + ) + self.extra_spheres_table.setMaximumHeight(140) + self.extra_spheres_table.setToolTip( + "Extra spheres to union with the first one. Leave empty for a single " + "sphere (unchanged behavior)." + ) + + sphere_btn_widget = QtWidgets.QWidget() + sphere_btn_layout = QtWidgets.QHBoxLayout(sphere_btn_widget) + sphere_btn_layout.setContentsMargins(0, 0, 0, 0) + self.add_sphere_btn = QtWidgets.QPushButton("Add Sphere") + self.add_sphere_btn.setToolTip("Union another sphere into this ROI.") + self.remove_sphere_btn = QtWidgets.QPushButton("Remove Selected") + self.remove_sphere_btn.setToolTip("Remove the selected extra sphere row(s).") + sphere_btn_layout.addWidget(self.add_sphere_btn) + sphere_btn_layout.addWidget(self.remove_sphere_btn) + sphere_btn_layout.addStretch() + + layout.addRow(QtWidgets.QLabel("Additional Spheres:"), self.extra_spheres_table) + layout.addRow("", sphere_btn_widget) + # Volumetric toggle + tissue type vol_widget = QtWidgets.QWidget() vol_layout = QtWidgets.QHBoxLayout(vol_widget) @@ -281,7 +310,11 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: atlas_layout.addWidget(self.hemi_label) self.hemi_combo = QtWidgets.QComboBox() - self.hemi_combo.addItems(["Left (lh)", "Right (rh)"]) + self.hemi_combo.addItems(["Left (lh)", "Right (rh)", "Both (lh + rh)"]) + self.hemi_combo.setToolTip( + "Choose one hemisphere, or 'Both' to union the same label(s) across " + "left and right hemispheres." + ) atlas_layout.addWidget(self.hemi_combo) self.refresh_atlases_btn = QtWidgets.QPushButton("Refresh") @@ -293,11 +326,18 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: atlas_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas:"), atlas_widget) - # Region label value - self.label_value_input = QtWidgets.QSpinBox() - self.label_value_input.setRange(1, 10000) - self.label_value_input.setValue(1) - layout.addRow(QtWidgets.QLabel("Region Label Value:"), self.label_value_input) + # Region label value(s) — one integer, or several comma-separated to + # union multiple regions into one combined target. + self.label_value_input = QtWidgets.QLineEdit() + self.label_value_input.setText("1") + self.label_value_input.setPlaceholderText("17 or 17,53") + self.label_value_input.setToolTip( + "Integer atlas label(s). Comma-separate several labels to union them " + "into one combined target, e.g. 17,53." + ) + layout.addRow( + QtWidgets.QLabel("Region Label Value(s):"), self.label_value_input + ) return page @@ -347,15 +387,20 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: volume_layout.addStretch() layout.addRow(QtWidgets.QLabel("Volume Atlas:"), volume_widget) - # Region label value - self.volume_label_input = QtWidgets.QSpinBox() - self.volume_label_input.setRange(1, 10000) - self.volume_label_input.setValue(10) + # Region label value(s) — one integer, or several comma-separated to + # union multiple regions (e.g. both hippocampi) into one combined target. + self.volume_label_input = QtWidgets.QLineEdit() + self.volume_label_input.setText("10") + self.volume_label_input.setPlaceholderText("10 or 17,53") self.volume_label_input.setToolTip( + "Integer atlas label(s). Comma-separate several labels to union them " + "into one combined target (e.g. 17,53 = both hippocampi). " "Common values: 10=Left-Thalamus, 49=Right-Thalamus, " "17=Left-Hippocampus, 53=Right-Hippocampus" ) - layout.addRow(QtWidgets.QLabel("Region Label Value:"), self.volume_label_input) + layout.addRow( + QtWidgets.QLabel("Region Label Value(s):"), self.volume_label_input + ) # Tissue type self.tissue_combo = QtWidgets.QComboBox() @@ -400,6 +445,13 @@ def _connect_signals(self): self.volume_subject_radio.toggled.connect(self._refresh_volume_atlases) self.volume_mni_radio.toggled.connect(self._refresh_volume_atlases) + # Multi-sphere add/remove (lambdas so the button's bool 'checked' arg is + # not passed as a coordinate/row argument). + self.add_sphere_btn.clicked.connect(lambda: self._add_sphere_row()) + self.remove_sphere_btn.clicked.connect( + lambda: self._remove_selected_sphere_rows() + ) + # Emit roi_changed on any value change self.x_input.valueChanged.connect(self.roi_changed) self.y_input.valueChanged.connect(self.roi_changed) @@ -409,11 +461,11 @@ def _connect_signals(self): self.sphere_tissue_combo.currentIndexChanged.connect(self.roi_changed) self.atlas_combo.currentIndexChanged.connect(self.roi_changed) self.hemi_combo.currentIndexChanged.connect(self.roi_changed) - self.label_value_input.valueChanged.connect(self.roi_changed) + self.label_value_input.textChanged.connect(self.roi_changed) self.volume_atlas_combo.currentIndexChanged.connect(self.roi_changed) self.volume_subject_radio.toggled.connect(self.roi_changed) self.volume_mni_radio.toggled.connect(self.roi_changed) - self.volume_label_input.valueChanged.connect(self.roi_changed) + self.volume_label_input.textChanged.connect(self.roi_changed) self.tissue_combo.currentIndexChanged.connect(self.roi_changed) self._mode_group.buttonClicked.connect(lambda: self.roi_changed.emit()) @@ -468,6 +520,87 @@ def _update_coordinate_space_labels(self): "Open subject's T1 scan in Freeview to find RAS coordinates" ) + # ------------------------------------------------------------------ + # Multi-sphere / multi-label helpers + # ------------------------------------------------------------------ + + def _add_sphere_row( + self, x: float = 0.0, y: float = 0.0, z: float = 0.0, r: float = 10.0 + ): + """Append a row of X/Y/Z/Radius spin boxes to the extra-spheres table.""" + table = self.extra_spheres_table + row = table.rowCount() + table.insertRow(row) + specs = [(-150, 150, x), (-150, 150, y), (-150, 150, z), (1, 50, r)] + for col, (lo, hi, val) in enumerate(specs): + spin = QtWidgets.QDoubleSpinBox() + spin.setRange(lo, hi) + spin.setDecimals(2) + spin.setValue(val) + spin.valueChanged.connect(self.roi_changed) + table.setCellWidget(row, col, spin) + self.roi_changed.emit() + + def _remove_selected_sphere_rows(self): + """Remove the currently selected extra-sphere rows (if any).""" + table = self.extra_spheres_table + rows = sorted({idx.row() for idx in table.selectedIndexes()}, reverse=True) + if not rows and table.rowCount(): + rows = [table.rowCount() - 1] + for row in rows: + table.removeRow(row) + self.roi_changed.emit() + + def _collect_spheres(self) -> tuple[list[list[float]], list[float]]: + """Return ``(centers, radii)`` for the first sphere plus any extra rows. + + The X/Y/Z/Radius spin boxes define the first (always present) sphere; + each row in the extra-spheres table adds another. A single sphere (no + extra rows) reproduces the pre-union behavior exactly. + """ + centers = [[self.x_input.value(), self.y_input.value(), self.z_input.value()]] + radii = [self.radius_input.value()] + table = self.extra_spheres_table + for row in range(table.rowCount()): + widgets = [table.cellWidget(row, col) for col in range(4)] + if any(w is None for w in widgets): + continue + xw, yw, zw, rw = widgets + centers.append([xw.value(), yw.value(), zw.value()]) + radii.append(rw.value()) + return centers, radii + + def _selected_hemis(self) -> list[str]: + """Return the hemispheres selected in the cortical page. + + ``["lh"]`` or ``["rh"]`` for a single hemisphere, or ``["lh", "rh"]`` + when the "Both" option is chosen. + """ + idx = self.hemi_combo.currentIndex() + if idx == 0: + return ["lh"] + if idx == 1: + return ["rh"] + return ["lh", "rh"] + + @staticmethod + def _parse_labels(text: str) -> list[int]: + """Parse comma-separated integer atlas labels (mirrors analyzer input). + + Raises: + ValueError: If any non-empty token is not an integer. + """ + return [int(part.strip()) for part in text.split(",") if part.strip()] + + @staticmethod + def _collapse(values: list): + """Collapse a single-element list to a scalar; pass longer lists through. + + Keeps single-region ROI dataclasses (and their serialized JSON) + byte-identical to the pre-union format, while a union yields a list. + """ + return values[0] if len(values) == 1 else values + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -511,14 +644,14 @@ def get_roi_params(self) -> dict: """ roi_type = self.get_roi_type() if roi_type == "spherical": + centers, radii = self._collect_spheres() d = { "method": "spherical", - "center": [ - self.x_input.value(), - self.y_input.value(), - self.z_input.value(), - ], - "radius": self.radius_input.value(), + "center": centers[0], + "radius": radii[0], + "centers": centers, + "radii": radii, + "num_spheres": len(centers), "volumetric": self.volumetric_checkbox.isChecked(), } if self.volumetric_checkbox.isChecked(): @@ -528,14 +661,15 @@ def get_roi_params(self) -> dict: return { "method": "atlas", "atlas": self.atlas_combo.currentText(), - "region": str(self.label_value_input.value()), + "region": self.label_value_input.text().strip(), + "hemisphere": "+".join(self._selected_hemis()), } else: # subcortical return { "method": "subcortical", "volume_atlas": self.volume_atlas_combo.currentText(), "volume_atlas_space": self._selected_volume_atlas_space(), - "volume_region": str(self.volume_label_input.value()), + "volume_region": self.volume_label_input.text().strip(), "tissues": self.tissue_combo.currentData(), } @@ -561,11 +695,12 @@ def get_roi_spec(self, subject_id: str, project_dir: str): roi_type = self.get_roi_type() if roi_type == "spherical": vol = self.volumetric_checkbox.isChecked() + centers, radii = self._collect_spheres() return FlexConfig.SphericalROI( - x=self.x_input.value(), - y=self.y_input.value(), - z=self.z_input.value(), - radius=self.radius_input.value(), + x=self._collapse([c[0] for c in centers]), + y=self._collapse([c[1] for c in centers]), + z=self._collapse([c[2] for c in centers]), + radius=self._collapse(radii), use_mni=self.is_mni_space(), volumetric=vol, tissues=self.sphere_tissue_combo.currentData() if vol else "GM", @@ -574,18 +709,31 @@ def get_roi_spec(self, subject_id: str, project_dir: str): atlas_name = self._resolve_atlas_name_for_subject( self.atlas_combo.currentText(), subject_id ) - hemi = "lh" if self.hemi_combo.currentIndex() == 0 else "rh" - atlas_path = os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot") + labels = self._parse_labels(self.label_value_input.text()) + hemis = self._selected_hemis() + # Expand (labels x hemispheres) into equal-length parallel lists so a + # target can union across labels and/or both hemispheres. Each entry + # carries its own lh/rh .annot path. Outer loop over labels keeps the + # ordering label=[17,17,53,53] / hemi=[lh,rh,lh,rh] for Both. + atlas_paths, hemispheres, out_labels = [], [], [] + for label in labels: + for hemi in hemis: + out_labels.append(label) + hemispheres.append(hemi) + atlas_paths.append( + os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot") + ) return FlexConfig.AtlasROI( - atlas_path=atlas_path, - label=int(self.label_value_input.value()), - hemisphere=hemi, + atlas_path=self._collapse(atlas_paths), + label=self._collapse(out_labels), + hemisphere=self._collapse(hemispheres), ) else: # subcortical volume_atlas_path = self._selected_volume_atlas_path(subject_id, seg_dir) + labels = self._parse_labels(self.volume_label_input.text()) return FlexConfig.SubcorticalROI( atlas_path=volume_atlas_path, - label=int(self.volume_label_input.value()), + label=self._collapse(labels), tissues=self.tissue_combo.currentData(), atlas_space=self._selected_volume_atlas_space(), ) @@ -617,20 +765,35 @@ def validate(self) -> str | None: """ roi_type = self.get_roi_type() if roi_type == "spherical": - if self.radius_input.value() <= 0: + _, radii = self._collect_spheres() + if any(r <= 0 for r in radii): return "ROI radius must be greater than zero." elif roi_type == "atlas": if not self.atlas_combo.currentText(): return "No cortical atlas selected. Use Refresh to discover atlases." - if self.label_value_input.value() < 1: - return "Region label value must be at least 1." + error = self._validate_labels(self.label_value_input.text()) + if error: + return error elif roi_type == "subcortical": if not self.volume_atlas_combo.currentText(): return ( "No volume atlas selected. Use Refresh to discover volume atlases." ) - if self.volume_label_input.value() < 1: - return "Region label value must be at least 1." + error = self._validate_labels(self.volume_label_input.text()) + if error: + return error + return None + + def _validate_labels(self, text: str) -> str | None: + """Validate a comma-separated region-label field. Returns an error or None.""" + try: + labels = self._parse_labels(text) + except ValueError: + return "Region label value(s) must be integers (comma-separated)." + if not labels: + return "Enter at least one region label value." + if any(label < 1 for label in labels): + return "Region label value(s) must be at least 1." return None # ------------------------------------------------------------------ @@ -737,9 +900,13 @@ def _resolve_atlas_name_for_subject( # ------------------------------------------------------------------ def _on_list_atlas_regions(self): - """Show regions for the currently selected cortical atlas.""" + """Show regions for the currently selected cortical atlas. + + With the "Both" hemisphere option the label sets are identical across + hemispheres, so the first selected hemisphere is listed. + """ atlas = self.atlas_combo.currentText() - hemi = "lh" if self.hemi_combo.currentIndex() == 0 else "rh" + hemi = self._selected_hemis()[0] self._show_atlas_regions_dialog(atlas, hemi) def _on_list_volume_regions(self): @@ -846,9 +1013,7 @@ def _show_volume_regions_dialog(self, volume_atlas: str): output += f"{label_id:<4} {label_name:<35} (no color)\n" else: r, g, b = rgb - output += ( - f"{label_id:<4} {label_name:<35} ({r},{g},{b})\n" - ) + output += f"{label_id:<4} {label_name:<35} ({r},{g},{b})\n" except OSError as e: QtWidgets.QMessageBox.warning( self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" @@ -919,7 +1084,9 @@ def _strip_nifti_suffix(filename: str) -> str: return os.path.splitext(filename)[0] @staticmethod - def _parse_lut_line(line: str) -> tuple[str, str, tuple[str, str, str] | None] | None: + def _parse_lut_line( + line: str, + ) -> tuple[str, str, tuple[str, str, str] | None] | None: parts = line.split() if len(parts) < 2: return None diff --git a/tit/gui/flex_search_tab.py b/tit/gui/flex_search_tab.py index 1a5d68f8..33df94a2 100644 --- a/tit/gui/flex_search_tab.py +++ b/tit/gui/flex_search_tab.py @@ -698,13 +698,26 @@ def run_optimization(self): # Show confirmation dialog roi_description = "" if roi_params["method"] == "spherical": - roi_description = f"Spherical ROI at ({roi_params['center'][0]}, {roi_params['center'][1]}, {roi_params['center'][2]}) with radius {roi_params['radius']}mm" + num_spheres = roi_params.get("num_spheres", 1) + if num_spheres > 1: + roi_description = ( + f"Spherical ROI: union of {num_spheres} spheres " + f"(first at ({roi_params['center'][0]}, " + f"{roi_params['center'][1]}, {roi_params['center'][2]}))" + ) + else: + roi_description = ( + f"Spherical ROI at ({roi_params['center'][0]}, " + f"{roi_params['center'][1]}, {roi_params['center'][2]}) " + f"with radius {roi_params['radius']}mm" + ) elif roi_params["method"] == "atlas": roi_description = ( - f"Cortical ROI: {roi_params['atlas']} region {roi_params['region']}" + f"Cortical ROI: {roi_params['atlas']} " + f"[{roi_params['hemisphere']}] region(s) {roi_params['region']}" ) else: - roi_description = f"Subcortical ROI: {roi_params['volume_atlas']} region {roi_params['volume_region']}" + roi_description = f"Subcortical ROI: {roi_params['volume_atlas']} region(s) {roi_params['volume_region']}" details = ( f"Subjects: {', '.join(selected_subjects)}\n" From 2960c09f6260c78f573f90bd758bacbacad266ac Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 10:52:02 -0500 Subject: [PATCH 03/27] ex-search: union of selected spherical ROIs (Phase 3) Add a 'Combine selected ROIs into one target' toggle to ex-search so multiple selected spherical ROIs can be searched as a single unioned target instead of separate runs. - ExConfig: add roi_names: list[str] | None; normalize .csv suffix in __post_init__ (scalar roi_name path unchanged). - ex.run_ex_search: resolve a list of roi_files (roi_names, falling back to [roi_name]) and hand it to the engine. - ExSearchEngine: accept str | list[str] roi_file; read one center per file into roi_centers and OR the per-center spherical masks into roi_indices (single center = N=1). roi_coords still mirrors the first center for back-compat; roi_radius is shared across centers. - ex_search_tab: combine_rois_cb checkbox (default OFF) beside the radius control; when ON with >=2 selected ROIs, run one pass over all files with output/run name via '+'.join(names). Default OFF keeps the separate-runs behavior byte-identical. Tests: config normalization, run_ex_search single vs union file resolution, and engine multi-center mask union. All ex tests pass; single-ROI path stays byte-identical. --- tests/test_opt_ex.py | 166 +++++++++++++++++++++++++++++++++++++++ tit/gui/ex_search_tab.py | 54 +++++++++++-- tit/opt/config.py | 14 +++- tit/opt/ex/engine.py | 49 +++++++++--- tit/opt/ex/ex.py | 7 +- 5 files changed, 270 insertions(+), 20 deletions(-) diff --git a/tests/test_opt_ex.py b/tests/test_opt_ex.py index dd256ec0..ab8a1a88 100644 --- a/tests/test_opt_ex.py +++ b/tests/test_opt_ex.py @@ -208,3 +208,169 @@ def test_result_fields( assert result.n_combinations == 3 assert result.config_json == "/results.json" assert result.results_csv == "/results.csv" + + +# --------------------------------------------------------------------------- +# Combined-ROI (union) support +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExConfigRoiNames: + def test_default_roi_names_is_none(self): + cfg = _make_ex_config() + assert cfg.roi_names is None + + def test_roi_names_csv_suffix_appended(self): + cfg = _make_ex_config(roi_names=["a", "b.csv", "c"]) + assert cfg.roi_names == ["a.csv", "b.csv", "c.csv"] + + def test_single_roi_name_unchanged(self): + # Scalar roi_name path stays byte-identical (1-element behavior). + cfg = _make_ex_config(roi_name="motor") + assert cfg.roi_name == "motor.csv" + assert cfg.roi_names is None + + +@pytest.mark.unit +class TestRunExSearchCombine: + def _run(self, mock_gpm, mock_engine_cls, mock_save, tmp_path, config): + pm = MagicMock() + pm.logs.return_value = str(tmp_path / "logs") + pm.ex_search_run.return_value = str(tmp_path / "output") + pm.rois.return_value = str(tmp_path / "rois") + mock_gpm.return_value = pm + + engine = MagicMock() + mock_engine_cls.return_value = engine + engine.run.return_value = {"m1": {}} + mock_save.return_value = {"config_json_path": "/j", "csv_path": "/c"} + + from tit.opt.ex.ex import run_ex_search + + run_ex_search(config) + return pm, mock_engine_cls + + @patch("tit.opt.ex.ex.process_and_save") + @patch("tit.opt.ex.ex.ExSearchEngine") + @patch("tit.opt.ex.ex.add_file_handler") + @patch("tit.opt.ex.ex.get_path_manager") + def test_single_roi_passes_one_file( + self, mock_gpm, mock_afh, mock_engine_cls, mock_save, tmp_path + ): + config = _make_ex_config() # roi_name="motor.csv", roi_names=None + pm, engine_cls = self._run( + mock_gpm, mock_engine_cls, mock_save, tmp_path, config + ) + + # Engine receives a single-element roi_files list (N=1 union case). + args = engine_cls.call_args[0] + roi_files = args[1] + assert roi_files == [os.path.join(str(tmp_path / "rois"), "motor.csv")] + assert args[2] == "motor.csv" # metric-key prefix unchanged + + @patch("tit.opt.ex.ex.process_and_save") + @patch("tit.opt.ex.ex.ExSearchEngine") + @patch("tit.opt.ex.ex.add_file_handler") + @patch("tit.opt.ex.ex.get_path_manager") + def test_combine_passes_union_files( + self, mock_gpm, mock_afh, mock_engine_cls, mock_save, tmp_path + ): + config = _make_ex_config( + roi_name="L_hippo+R_hippo", + roi_names=["L_hippo", "R_hippo"], + ) + pm, engine_cls = self._run( + mock_gpm, mock_engine_cls, mock_save, tmp_path, config + ) + + rois = str(tmp_path / "rois") + args = engine_cls.call_args[0] + roi_files = args[1] + assert roi_files == [ + os.path.join(rois, "L_hippo.csv"), + os.path.join(rois, "R_hippo.csv"), + ] + # Metric-key prefix matches config.roi_name (what process_and_save reads). + assert args[2] == "L_hippo+R_hippo.csv" + + +# --------------------------------------------------------------------------- +# ExSearchEngine multi-center union +# --------------------------------------------------------------------------- + + +def _make_engine(roi_file="/fake/roi.csv"): + from tit.opt.ex.engine import ExSearchEngine + + return ExSearchEngine( + leadfield_hdf="/fake/leadfield.hdf5", + roi_file=roi_file, + roi_name="Combined", + logger=MagicMock(), + ) + + +@pytest.mark.unit +class TestEngineUnion: + def test_loads_multiple_centers(self, tmp_path): + import csv + + f1 = tmp_path / "a.csv" + f2 = tmp_path / "b.csv" + with open(f1, "w", newline="") as f: + csv.writer(f).writerow([0.0, 0.0, 0.0]) + with open(f2, "w", newline="") as f: + csv.writer(f).writerow([10.0, 0.0, 0.0]) + + engine = _make_engine(roi_file=[str(f1), str(f2)]) + engine._load_roi_coordinates() + + assert engine.roi_centers == [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + # roi_coords mirrors the first center for back-compat. + assert engine.roi_coords == [0.0, 0.0, 0.0] + + def test_unions_masks_from_multiple_centers(self): + import numpy as np + + engine = _make_engine(roi_file=["/a.csv", "/b.csv"]) + engine.roi_centers = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + + centers = np.array( + [ + [0.0, 0.0, 0.0], # near center A + [1.0, 0.0, 0.0], # near center A + [10.0, 0.0, 0.0], # near center B + [11.0, 0.0, 0.0], # near center B + [50.0, 0.0, 0.0], # far from both + ] + ) + volumes = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) + mesh = MagicMock() + mesh.elements_baricenters.return_value = MagicMock(value=centers) + mesh.elements_volumes_and_areas.return_value = MagicMock(value=volumes) + engine.mesh = mesh + + engine._find_roi_elements(roi_radius=3.0) + + # Union of both spheres: indices 0,1 (A) and 2,3 (B); 4 excluded. + assert set(engine.roi_indices.tolist()) == {0, 1, 2, 3} + + def test_single_center_fallback_matches_prior_behavior(self): + import numpy as np + + # roi_centers stays None; single-center path uses roi_coords (N=1). + engine = _make_engine(roi_file="/a.csv") + engine.roi_coords = [0.0, 0.0, 0.0] + + centers = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [5.0, 0.0, 0.0]]) + volumes = np.array([1.0, 2.0, 3.0]) + mesh = MagicMock() + mesh.elements_baricenters.return_value = MagicMock(value=centers) + mesh.elements_volumes_and_areas.return_value = MagicMock(value=volumes) + engine.mesh = mesh + + engine._find_roi_elements(roi_radius=3.0) + + assert set(engine.roi_indices.tolist()) == {0, 1} + np.testing.assert_array_equal(engine.roi_volumes, [1.0, 2.0]) diff --git a/tit/gui/ex_search_tab.py b/tit/gui/ex_search_tab.py index b154c13d..e2236546 100644 --- a/tit/gui/ex_search_tab.py +++ b/tit/gui/ex_search_tab.py @@ -297,6 +297,8 @@ def __init__(self, parent=None): self.leadfield_generating = False self.leadfield_thread = None self.roi_processing_queue = [] + self._combine_rois = False + self._combined_roi_names = [] self._shared_log_file = None self.e1_plus = [] self.e1_minus = [] @@ -874,8 +876,8 @@ def setup_ui(self): # ============================================================ roi_container = QtWidgets.QGroupBox("ROI Selection") roi_container.setFixedHeight( - 190 - ) # Fixed height for balance (includes radius control) + 220 + ) # Fixed height for balance (includes radius + combine controls) roi_layout = QtWidgets.QVBoxLayout(roi_container) roi_layout.setContentsMargins(10, 10, 10, 10) roi_layout.setSpacing(8) @@ -920,6 +922,17 @@ def setup_ui(self): radius_layout.addStretch() roi_layout.addLayout(radius_layout) + # Combine selected ROIs into a single (unioned) target + self.combine_rois_cb = QtWidgets.QCheckBox( + "Combine selected ROIs into one target" + ) + self.combine_rois_cb.setToolTip( + "When checked, all selected ROIs are unioned into one target and " + "searched in a single run (output named by joining the ROI names " + "with '+'). When unchecked, each selected ROI is searched separately." + ) + roi_layout.addWidget(self.combine_rois_cb) + # Add ROI container to grid - Row 1, Column 0 main_grid_layout.addWidget(roi_container, 1, 0) @@ -1655,6 +1668,8 @@ def run_optimization(self): f"ROIs: {', '.join(roi_names)}\n" f"Total ROIs: {len(roi_names)}" ) + if self.combine_rois_cb.isChecked() and len(roi_names) >= 2: + details += f"\nCombine ROIs: union → {'+'.join(roi_names)}" if not ConfirmationDialog.confirm( self, @@ -1689,8 +1704,19 @@ def run_optimization(self): self.disable_controls() self.update_status(f"Running optimization for subject {subject_id}...") - # Initialize ROI processing queue and start pipeline - self.roi_processing_queue = selected_roi_names.copy() + # Decide whether to union the selected ROIs into a single target. + # Combine mode only engages with >= 2 ROIs; a single selection always + # takes the byte-identical separate-run path. + self._combine_rois = ( + self.combine_rois_cb.isChecked() and len(selected_roi_names) >= 2 + ) + if self._combine_rois: + self._combined_roi_names = selected_roi_names.copy() + combined_display = "+".join(n[:-4] for n in selected_roi_names) + self.roi_processing_queue = [f"{combined_display}.csv"] + else: + self._combined_roi_names = [] + self.roi_processing_queue = selected_roi_names.copy() self.current_roi_index = 0 self._exsearch_had_errors = False self.e1_plus = e1_plus @@ -1704,7 +1730,9 @@ def run_optimization(self): # Run the pipeline for the first ROI self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) - def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): + def _build_ex_config( + self, subject_id, roi_name, leadfield_hdf, eeg_net, roi_names=None + ): """Build an ExConfig dataclass from current UI widget values. Args: @@ -1712,6 +1740,9 @@ def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): roi_name: ROI name (with or without .csv extension). leadfield_hdf: Full path to the leadfield HDF5 file. eeg_net: Name of the selected EEG net. + roi_names: Optional list of ROI CSV filenames to union into a + single target (combined mode). ``None`` keeps single-ROI + behavior. Returns: ExConfig instance ready for serialization. @@ -1730,6 +1761,7 @@ def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): subject_id=subject_id, leadfield_hdf=leadfield_hdf, roi_name=roi_name, + roi_names=roi_names, electrodes=electrodes, total_current=self.total_current_spinbox.value(), current_step=self.current_step_spinbox.value(), @@ -1806,7 +1838,16 @@ def run_roi_pipeline(self, subject_id, project_dir, ex_search_dir, env): # Get ROI coordinates pm = self.pm roi_dir = pm.rois(subject_id) - roi_file = os.path.join(roi_dir, current_roi) + if self._combine_rois: + # Combined target: the queue holds one synthetic entry; read coords + # from the first real ROI for logging while the backend unions all + # selected ROI spheres (roi_names). + roi_names_for_config = list(self._combined_roi_names) + coord_source = self._combined_roi_names[0] + else: + roi_names_for_config = None + coord_source = current_roi + roi_file = os.path.join(roi_dir, coord_source) try: with open(roi_file, "r") as f: @@ -1831,6 +1872,7 @@ def run_roi_pipeline(self, subject_id, project_dir, ex_search_dir, env): roi_name, selected_hdf5_path, selected_net_name, + roi_names=roi_names_for_config, ) # Minimal env — only pass through what downstream steps still need diff --git a/tit/opt/config.py b/tit/opt/config.py index e6560662..ff4dddce 100644 --- a/tit/opt/config.py +++ b/tit/opt/config.py @@ -482,7 +482,14 @@ class ExConfig: Path to the precomputed leadfield HDF5 file. roi_name : str ROI CSV filename (e.g. ``"target.csv"``). The ``".csv"`` suffix - is appended automatically if missing. + is appended automatically if missing. Used as the metric-key + prefix and (with the net name) the output-directory label. + roi_names : list of str or None + Optional list of ROI CSV filenames to **union** into a single + target. When provided (combined mode), the spherical masks of + every listed ROI are OR-folded into one region. ``None`` + (default) keeps single-ROI behavior driven by *roi_name*. Each + entry gets the ``".csv"`` suffix appended if missing. electrodes : BucketElectrodes or PoolElectrodes Electrode specification, either a single shared pool (:class:`PoolElectrodes`) or separate per-channel buckets @@ -559,6 +566,7 @@ class PoolElectrodes: # ── ROI ──────────────────────────────────────────────────────────── roi_radius: float = 3.0 + roi_names: list[str] | None = None # ── Output naming (defaults to datetime stamp) ───────────────────── run_name: str | None = None @@ -571,6 +579,10 @@ def __post_init__(self): self.electrodes = ExConfig.BucketElectrodes(**self.electrodes) if not self.roi_name.endswith(".csv"): self.roi_name += ".csv" + if self.roi_names is not None: + self.roi_names = [ + n if n.endswith(".csv") else f"{n}.csv" for n in self.roi_names + ] # Validation if self.current_step <= 0: diff --git a/tit/opt/ex/engine.py b/tit/opt/ex/engine.py index 854ecba4..c5e907cd 100644 --- a/tit/opt/ex/engine.py +++ b/tit/opt/ex/engine.py @@ -29,7 +29,11 @@ class ExSearchEngine: """ def __init__( - self, leadfield_hdf: str, roi_file: str, roi_name: str, logger: logging.Logger + self, + leadfield_hdf: str, + roi_file: str | list[str], + roi_name: str, + logger: logging.Logger, ): self.leadfield_hdf = leadfield_hdf self.roi_file = roi_file @@ -40,6 +44,7 @@ def __init__( self.mesh = None self.idx_lf = None self.roi_coords = None + self.roi_centers = None self.roi_indices = None self.roi_volumes = None self.gm_indices = None @@ -61,24 +66,46 @@ def _load_leadfield(self) -> None: self.logger.info(f"Loaded in {time.time() - start:.1f}s") def _load_roi_coordinates(self) -> None: - """Read ROI center from a simple CSV (one row: x,y,z).""" - with open(self.roi_file) as f: + """Read ROI center(s) from one or more simple CSVs (one row: x,y,z). + + A single ``roi_file`` (str) yields one center; a list yields one + center per file (combined/union mode). ``roi_coords`` always + mirrors the first center for backward compatibility. + """ + files = self.roi_file if isinstance(self.roi_file, list) else [self.roi_file] + self.roi_centers = [self._read_center(f) for f in files] + self.roi_coords = self.roi_centers[0] + if len(self.roi_centers) == 1: + self.logger.info(f"ROI coords: {self.roi_coords}") + else: + self.logger.info( + f"ROI centers ({len(self.roi_centers)}): {self.roi_centers}" + ) + + def _read_center(self, path: str) -> list[float]: + """Return the first valid ``[x, y, z]`` triple from an ROI CSV.""" + with open(path) as f: for row in csv.reader(f): if not row: continue coords = [float(v.strip()) for v in row if v.strip()] if len(coords) >= 3: - self.roi_coords = coords[:3] - self.logger.info(f"ROI coords: {self.roi_coords}") - return - raise ValueError(f"No valid coordinates in {self.roi_file}") + return coords[:3] + raise ValueError(f"No valid coordinates in {path}") def _find_roi_elements(self, roi_radius: float) -> None: - """Find mesh elements whose barycenters fall within a sphere.""" + """Find mesh elements whose barycenters fall within any ROI sphere. + + For a combined target the per-center spherical masks are OR-folded + into one region (single-center is the N=1 case). + """ self.logger.info(f"Finding ROI elements (radius={roi_radius}mm)...") - centers = self.mesh.elements_baricenters().value - center = np.asarray(self.roi_coords, dtype=float) - mask = np.sum((centers - center) ** 2, axis=1) <= roi_radius**2 + baricenters = self.mesh.elements_baricenters().value + roi_centers = self.roi_centers if self.roi_centers else [self.roi_coords] + mask = np.zeros(baricenters.shape[0], dtype=bool) + for c in roi_centers: + center = np.asarray(c, dtype=float) + mask |= np.sum((baricenters - center) ** 2, axis=1) <= roi_radius**2 volumes = self.mesh.elements_volumes_and_areas().value if volumes.ndim > 1: diff --git a/tit/opt/ex/ex.py b/tit/opt/ex/ex.py index 724b4275..1657a773 100644 --- a/tit/opt/ex/ex.py +++ b/tit/opt/ex/ex.py @@ -46,7 +46,10 @@ def _run_ex_search_inner(config: ExConfig) -> ExResult: os.makedirs(output_dir, exist_ok=True) logger.info(f"Output: {output_dir}") - roi_file = os.path.join(pm.rois(config.subject_id), config.roi_name) + roi_names = config.roi_names or [config.roi_name] + roi_files = [os.path.join(pm.rois(config.subject_id), name) for name in roi_names] + if len(roi_files) > 1: + logger.info(f"Combining {len(roi_files)} ROIs into one target: {roi_names}") if isinstance(config.electrodes, ExConfig.PoolElectrodes): pool = config.electrodes.electrodes @@ -63,7 +66,7 @@ def _run_ex_search_inner(config: ExConfig) -> ExResult: pm.leadfields(config.subject_id), config.leadfield_hdf ) - engine = ExSearchEngine(leadfield_path, roi_file, config.roi_name, logger) + engine = ExSearchEngine(leadfield_path, roi_files, config.roi_name, logger) engine.initialize(roi_radius=config.roi_radius) ratios = generate_current_ratios( From 81f13262bee9197c02ae1e1ca1fe4df2ad6e3a66 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 11:16:09 -0500 Subject: [PATCH 04/27] Validate non-ROI picker in flex run_optimization The focality non-ROI picker (self.nonroi_picker) was never validated in run_optimization(). After the region-label input became a free-text QLineEdit, an empty/non-integer non-ROI label raised a raw ValueError from get_roi_spec() -> _parse_labels() -> AtlasROI/SubcorticalROI __post_init__ inside _build_flex_config, which has no try/except at its call sites. Add a matching nonroi_picker.validate() check (only when goal=focality and non_roi_method=specific) so the reused non-ROI picker surfaces the same friendly validation as the main ROI picker. --- tit/gui/flex_search_tab.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tit/gui/flex_search_tab.py b/tit/gui/flex_search_tab.py index 33df94a2..d89eb6e1 100644 --- a/tit/gui/flex_search_tab.py +++ b/tit/gui/flex_search_tab.py @@ -664,6 +664,16 @@ def run_optimization(self): QtWidgets.QMessageBox.warning(self, "Warning", error) return + # Validate non-ROI when focality targets a specific region + if ( + self.goal_combo.currentData() == "focality" + and self.nonroi_method_combo.currentData() == "specific" + ): + error = self.nonroi_picker.validate() + if error: + QtWidgets.QMessageBox.warning(self, "Warning", f"Non-ROI: {error}") + return + # Check coordinate space for spherical ROI with MNI space selected if ( self.roi_picker.get_roi_type() == "spherical" From cbc767befca60fb4172ba2c1e2d6881519a940a0 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 11:34:43 -0500 Subject: [PATCH 05/27] =?UTF-8?q?flex/ex:=20review=20polish=20=E2=80=94=20?= =?UTF-8?q?union-operator=20consistency,=20display=20dedup,=20safe=20combi?= =?UTF-8?q?ned-ROI=20coord=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the non-blocking review findings: - utils.py: single-region atlas/subcortical ROIs now leave mask_operator unset (SimNIBS defaults to 'intersection'), mirroring the sphere path, via new _apply_union_operator() — keeps single-region setup byte-identical. - utils.generate_label + builder._join: de-duplicate labels/hemispheres in display names so a cortical 'Both' target reads '17+53' / 'lh+rh' instead of '17+17+53+53' / 'lh+rh+lh+rh' (backend lists unchanged). - ex_search_tab: in combined mode the per-ROI coordinate read is display-only (the backend unions all roi_names); a failed read no longer aborts the whole union run and falsely reports success. --- tit/gui/ex_search_tab.py | 26 ++++++++++++++++++-------- tit/opt/flex/builder.py | 7 +++++-- tit/opt/flex/utils.py | 27 +++++++++++++++++++++------ 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/tit/gui/ex_search_tab.py b/tit/gui/ex_search_tab.py index e2236546..b0cb514d 100644 --- a/tit/gui/ex_search_tab.py +++ b/tit/gui/ex_search_tab.py @@ -1853,18 +1853,28 @@ def run_roi_pipeline(self, subject_id, project_dir, ex_search_dir, env): with open(roi_file, "r") as f: coordinates = f.readline().strip() x, y, z = [float(coord.strip()) for coord in coordinates.split(",")] - roi_name = current_roi.replace(".csv", "") # Remove .csv extension if self.debug_mode: self.update_output(f"[DEBUG] ROI file: {roi_file}", "debug") self.update_output(f"[DEBUG] Parsed ROI coords: {(x, y, z)}", "debug") except (OSError, ValueError) as e: - self.update_output( - f"Error reading ROI file {current_roi}: {str(e)}", "error" - ) - # Move to next ROI - self.current_roi_index += 1 - self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) - return + if self._combine_rois: + # In combined mode these coords are display-only (the backend + # unions all selected roi_names itself), so a bad display-source + # read must NOT abort the whole union run. + self.update_output( + f"Warning: could not read coords from {coord_source} " + f"for logging: {str(e)}", + "warning", + ) + x = y = z = 0.0 + else: + self.update_output( + f"Error reading ROI file {current_roi}: {str(e)}", "error" + ) + # Move to next ROI + self.current_roi_index += 1 + self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) + return # Build ExConfig dataclass from UI state ex_config = self._build_ex_config( diff --git a/tit/opt/flex/builder.py b/tit/opt/flex/builder.py index 07b63daa..2a8e723f 100644 --- a/tit/opt/flex/builder.py +++ b/tit/opt/flex/builder.py @@ -531,10 +531,13 @@ def _join(values): Keeps single-region report fields byte-identical (a bare int/str) while rendering a union of labels/hemispheres as e.g. ``"17+53"`` rather than the - raw ``[17, 53]`` list repr. + raw ``[17, 53]`` list repr. Duplicates from the cortical "Both" expansion + (labels/hemispheres repeated per hemi) are collapsed for display. """ items = _as_list(values) - return items[0] if len(items) == 1 else "+".join(str(v) for v in items) + if len(items) == 1: + return items[0] + return "+".join(dict.fromkeys(str(v) for v in items)) def _sphere_report_fields(roi_spec): diff --git a/tit/opt/flex/utils.py b/tit/opt/flex/utils.py index dcdc916d..263ec7e5 100644 --- a/tit/opt/flex/utils.py +++ b/tit/opt/flex/utils.py @@ -60,6 +60,19 @@ def _union_operators(n: int) -> list[str]: return ["intersection"] + ["union"] * (n - 1) +def _apply_union_operator(roi_obj) -> None: + """Set the union operator sequence on a SimNIBS mask ROI. + + Mirrors the sphere path (:func:`_apply_spheres`): a single region leaves the + operator unset (SimNIBS defaults to ``"intersection"``) so single-region + setup stays byte-identical with the pre-union code; a union of *N* regions + gets the explicit ``["intersection"] + ["union"] * (N-1)`` sequence. + """ + n = len(roi_obj.mask_value) + if n > 1: + roi_obj.mask_operator = _union_operators(n) + + def eeg_net_csv_path(eeg_positions_dir: str, eeg_net: str) -> Path: """Resolve an EEG net name or filename inside a subject EEG directory.""" filename = Path(eeg_net).name @@ -160,7 +173,7 @@ def generate_label(config, pareto: bool = False) -> str: if first_path else "atlas" ) - roi_str = f"{hemi}-{atlas}-{'+'.join(str(v) for v in labels)}" + roi_str = f"{hemi}-{atlas}-{'+'.join(dict.fromkeys(str(v) for v in labels))}" elif isinstance(roi, FlexConfig.SubcorticalROI): labels = _as_list(roi.label) paths = _broadcast(roi.atlas_path, len(labels)) @@ -170,7 +183,9 @@ def generate_label(config, pareto: bool = False) -> str: if atlas.endswith(ext): atlas = atlas[: -len(ext)] break - roi_str = f"subcortical-{atlas}-{'+'.join(str(v) for v in labels)}" + roi_str = ( + f"subcortical-{atlas}-{'+'.join(dict.fromkeys(str(v) for v in labels))}" + ) else: roi_str = "unknown" @@ -381,7 +396,7 @@ def _configure_atlas_roi(opt, config: FlexConfig) -> None: roi.method = "surface" roi.surface_type = "central" roi.mask_space, roi.mask_path, roi.mask_value = _atlas_mask_lists(roi_spec) - roi.mask_operator = _union_operators(len(roi.mask_value)) + _apply_union_operator(roi) if config.goal == "focality": non_roi = opt.add_roi() @@ -400,7 +415,7 @@ def _configure_atlas_roi(opt, config: FlexConfig) -> None: non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( _atlas_mask_lists(non_roi_spec) ) - non_roi.mask_operator = _union_operators(len(non_roi.mask_value)) + _apply_union_operator(non_roi) non_roi.weight = -1 @@ -436,7 +451,7 @@ def _configure_subcortical_roi(opt, config: FlexConfig) -> None: roi = opt.add_roi() roi.method = "volume" roi.mask_space, roi.mask_path, roi.mask_value = _subcortical_mask_lists(roi_spec) - roi.mask_operator = _union_operators(len(roi.mask_value)) + _apply_union_operator(roi) roi.tissues = tissues if config.goal == "focality": @@ -456,6 +471,6 @@ def _configure_subcortical_roi(opt, config: FlexConfig) -> None: non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( _subcortical_mask_lists(non_roi_spec) ) - non_roi.mask_operator = _union_operators(len(non_roi.mask_value)) + _apply_union_operator(non_roi) non_roi.weight = -1 non_roi.tissues = tissues From 179db266bf2fdef0a6c5908c924a2d7b519042cb Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 14:27:52 -0500 Subject: [PATCH 06/27] Add reusable AtlasRegionFinderDialog and HelpIcon GUI components Add an interactive, searchable, multi-select region picker (AtlasRegionFinderDialog) that generalizes the analyzer's inline picker and replaces flex's two read-only region dialogs. Stores (id, name) on each item via Qt.UserRole to avoid fragile string parsing, filters by id or name, and exposes selected_ids/selected_names/selected_values plus a merge_into_lineedit load-back helper. Add a tiny hover-only HelpIcon affordance and re-export both from tit.gui.components. --- tit/gui/components/__init__.py | 5 + tit/gui/components/atlas_region_finder.py | 218 ++++++++++++++++++++++ tit/gui/components/help_icon.py | 41 ++++ 3 files changed, 264 insertions(+) create mode 100644 tit/gui/components/atlas_region_finder.py create mode 100644 tit/gui/components/help_icon.py diff --git a/tit/gui/components/__init__.py b/tit/gui/components/__init__.py index 409a6e0f..d5753ee9 100644 --- a/tit/gui/components/__init__.py +++ b/tit/gui/components/__init__.py @@ -9,19 +9,24 @@ from .console import ConsoleWidget from .action_buttons import RunStopButtons +from .atlas_region_finder import AtlasRegionFinderDialog, merge_into_lineedit from .base_thread import detect_message_type_from_content from .electrode_config import ElectrodeConfigWidget +from .help_icon import HelpIcon from .qsi_config_dialogs import QSIPrepConfigDialog, QSIReconConfigDialog from .roi_picker import ROIPickerWidget from .solver_params import SolverParamsWidget __all__ = [ + "AtlasRegionFinderDialog", "ConsoleWidget", "ElectrodeConfigWidget", + "HelpIcon", "ROIPickerWidget", "RunStopButtons", "SolverParamsWidget", "detect_message_type_from_content", + "merge_into_lineedit", "QSIPrepConfigDialog", "QSIReconConfigDialog", "SubjectRow", diff --git a/tit/gui/components/atlas_region_finder.py b/tit/gui/components/atlas_region_finder.py new file mode 100644 index 00000000..65bfc2f3 --- /dev/null +++ b/tit/gui/components/atlas_region_finder.py @@ -0,0 +1,218 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Interactive, searchable, multi-select atlas region picker. + +This module provides :class:`AtlasRegionFinderDialog`, a reusable dialog that +lets the user search and select one or more atlas regions from a supplied list +of ``(id, name, rgb)`` entries. It replaces several hand-rolled dialogs across +the GUI (the flex tab's two read-only region listers and the analyzer tab's +inline picker) with a single, unambiguous, id-aware component. + +The dialog is *pure UI*: it performs no disk access. Callers are responsible +for loading atlas labels (e.g. via ``MeshAtlasManager`` / ``VoxelAtlasManager``) +and passing them in as ``entries``. + +A module-level helper, :func:`merge_into_lineedit`, writes selected values back +into an existing ``QLineEdit``, de-duplicating while preserving order. +""" + +from __future__ import annotations + +from typing import Iterable, List, Optional, Sequence, Tuple + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class AtlasRegionFinderDialog(QtWidgets.QDialog): + """Searchable, multi-select dialog for choosing atlas regions. + + Each entry is displayed as ``"{id}: {name}"`` with an optional RGB colour + swatch. The ``(id, name)`` pair is stored on every list item via + ``Qt.UserRole`` so selections are resolved unambiguously (no fragile string + parsing). A search field filters items by id *or* name substring + (case-insensitive). + + Args: + parent: Parent widget (``QWidget`` or ``None``). + title: Window title for the dialog. + entries: Sequence of ``(id, name, rgb)`` tuples where ``id`` is an int, + ``name`` a str, and ``rgb`` a ``(r, g, b)`` tuple or ``None``. + return_field: Which field :meth:`selected_values` returns -- ``"id"`` + or ``"name"``. Defaults to ``"id"``. + multi: If ``True`` (default) allow multi-selection + (``ExtendedSelection``); otherwise single selection. + preselected: Optional iterable of ids to pre-select on open. + """ + + def __init__( + self, + parent: Optional[QtWidgets.QWidget], + title: str, + entries: Sequence[Tuple[int, str, Optional[Tuple]]], + return_field: str = "id", + multi: bool = True, + preselected: Optional[Iterable[int]] = None, + ) -> None: + super().__init__(parent) + + if return_field not in ("id", "name"): + raise ValueError( + f"return_field must be 'id' or 'name', got {return_field!r}" + ) + self._return_field = return_field + + self.setWindowTitle(title) + self.setMinimumWidth(450) + self.setMinimumHeight(500) + + layout = QtWidgets.QVBoxLayout(self) + + # ── Search box ─────────────────────────────────────────────────── + search_layout = QtWidgets.QHBoxLayout() + search_layout.addWidget(QtWidgets.QLabel("Search:")) + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Filter by id or name...") + search_layout.addWidget(self.search_input) + layout.addLayout(search_layout) + + # ── Region list ────────────────────────────────────────────────── + self.list_widget = QtWidgets.QListWidget() + self.list_widget.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + if multi + else QtWidgets.QAbstractItemView.SingleSelection + ) + preselected_set = {int(i) for i in preselected} if preselected else set() + for entry in entries or []: + self._add_entry(entry, preselected_set) + layout.addWidget(self.list_widget) + + # ── Buttons ────────────────────────────────────────────────────── + btn_layout = QtWidgets.QHBoxLayout() + self.add_btn = QtWidgets.QPushButton("Add Selected") + self.cancel_btn = QtWidgets.QPushButton("Cancel") + btn_layout.addWidget(self.add_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + # ── Wiring ─────────────────────────────────────────────────────── + self.search_input.textChanged.connect(self._filter) + self.add_btn.clicked.connect(self._on_accept) + self.cancel_btn.clicked.connect(self.reject) + self.list_widget.itemDoubleClicked.connect(lambda _item: self._on_accept()) + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + def _add_entry( + self, + entry: Tuple[int, str, Optional[Tuple]], + preselected: set, + ) -> None: + """Create and append a list item for a single ``(id, name, rgb)`` entry.""" + region_id, name, rgb = entry + item = QtWidgets.QListWidgetItem(f"{region_id}: {name}") + item.setData(QtCore.Qt.UserRole, (int(region_id), str(name))) + swatch = self._make_swatch(rgb) + if swatch is not None: + item.setIcon(swatch) + self.list_widget.addItem(item) + if int(region_id) in preselected: + item.setSelected(True) + + @staticmethod + def _make_swatch(rgb: Optional[Tuple]) -> Optional[QtGui.QIcon]: + """Build a small colour-swatch icon from an ``(r, g, b)`` tuple. + + Returns ``None`` when ``rgb`` is missing or cannot be parsed. + """ + if not rgb: + return None + try: + r, g, b = (int(c) for c in rgb) + except (TypeError, ValueError): + return None + pixmap = QtGui.QPixmap(12, 12) + pixmap.fill(QtGui.QColor(r, g, b)) + return QtGui.QIcon(pixmap) + + # ------------------------------------------------------------------ + # Behaviour + # ------------------------------------------------------------------ + + def _filter(self, text: str) -> None: + """Hide items whose id and name do not contain ``text`` (case-insensitive).""" + query = text.strip().lower() + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + region_id, name = item.data(QtCore.Qt.UserRole) + haystack = f"{region_id} {name}".lower() + item.setHidden(query not in haystack) + + def _on_accept(self) -> None: + """Accept the dialog if at least one region is selected.""" + if not self.list_widget.selectedItems(): + return + self.accept() + + # ------------------------------------------------------------------ + # Accessors + # ------------------------------------------------------------------ + + def _selected_pairs(self) -> List[Tuple[int, str]]: + """Return selected ``(id, name)`` pairs in top-to-bottom list order.""" + pairs: List[Tuple[int, str]] = [] + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item.isSelected(): + pairs.append(item.data(QtCore.Qt.UserRole)) + return pairs + + def selected_ids(self) -> List[int]: + """Return the ids of the selected regions.""" + return [pair[0] for pair in self._selected_pairs()] + + def selected_names(self) -> List[str]: + """Return the names of the selected regions.""" + return [pair[1] for pair in self._selected_pairs()] + + def selected_values(self) -> List: + """Return selected ids or names according to ``return_field``.""" + if self._return_field == "name": + return self.selected_names() + return self.selected_ids() + + +def merge_into_lineedit( + line_edit: QtWidgets.QLineEdit, + values: Iterable, + *, + replace_default: Optional[object] = None, +) -> None: + """Append comma-separated ``values`` into ``line_edit``, de-duplicating. + + Existing entries are preserved in order; new values are appended only if + not already present. If the field currently holds *only* ``replace_default`` + (e.g. ``"1"`` or ``"10"``), that placeholder is replaced rather than + appended to. + + Args: + line_edit: The target ``QLineEdit`` to update in place. + values: Iterable of values (str/int) to merge in. + replace_default: Optional placeholder value; when the field contains + only this value it is cleared before appending. + """ + existing = line_edit.text().strip() + current = [v.strip() for v in existing.split(",") if v.strip()] + + if replace_default is not None and current == [str(replace_default)]: + current = [] + + for value in values: + token = str(value).strip() + if token and token not in current: + current.append(token) + + line_edit.setText(", ".join(current)) diff --git a/tit/gui/components/help_icon.py b/tit/gui/components/help_icon.py new file mode 100644 index 00000000..b3694c9f --- /dev/null +++ b/tit/gui/components/help_icon.py @@ -0,0 +1,41 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Reusable hover-only help affordance. + +This module provides :class:`HelpIcon`, a tiny non-interactive ``"?"`` label +that reveals contextual help on hover via a tooltip. It follows the repo's +``setToolTip`` convention for inline help and keeps a consistent circular +appearance across the GUI. +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5 import QtCore, QtWidgets + + +class HelpIcon(QtWidgets.QLabel): + """A small circular ``"?"`` icon that shows help text on hover. + + The icon is purely informational: it has no click behaviour and simply + displays ``help_text`` as a tooltip when hovered. + + Args: + help_text: The text shown as a tooltip on hover. + parent: Optional parent widget. + """ + + def __init__( + self, help_text: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__("?", parent) + self.setToolTip(help_text) + self.setCursor(QtCore.Qt.WhatsThisCursor) + self.setFixedSize(15, 15) + self.setAlignment(QtCore.Qt.AlignCenter) + self.setStyleSheet( + "QLabel { color:#666; border:1px solid #999; " + "border-radius:7px; font-weight:bold; font-size:10px; }" + ) From 95f9b2dcb5aae85224e602aad41d62df3f11ba24 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 14:34:31 -0500 Subject: [PATCH 07/27] Rework ROI picker: click-to-load region finders + unified spheres table - Cortical/subcortical 'List Regions' now open AtlasRegionFinderDialog (searchable, multi-select) and append selected label ids into the region-label QLineEdit via merge_into_lineedit, replacing the read-only text dumps. - Spherical page redesigned around a single full-width spheres table (one row per sphere) with stretch columns + a narrow per-row delete button; Add/Duplicate/Remove buttons manage rows. Single row still collapses to scalar x/y/z/radius so single-sphere JSON stays byte-identical. - Added set_spheres() to repopulate the table from a saved config. --- tit/gui/components/roi_picker.py | 370 ++++++++++++++++--------------- 1 file changed, 197 insertions(+), 173 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 701b9c50..c131b3a3 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -10,11 +10,14 @@ import subprocess from pathlib import Path -from PyQt5 import QtWidgets, QtCore, QtGui +from PyQt5 import QtWidgets, QtCore from tit.paths import get_path_manager from tit.atlas import MNI_ATLAS_DIR, MeshAtlasManager, VoxelAtlasManager -from tit.gui.style import FONT_HELP, FONT_SIZE_MONOSPACE +from tit.gui.components.atlas_region_finder import ( + AtlasRegionFinderDialog, + merge_into_lineedit, +) from tit.opt.config import FlexConfig @@ -150,10 +153,15 @@ def _setup_ui(self): main_layout.addWidget(self.stacked) def _build_spherical_page(self) -> QtWidgets.QWidget: - """Build the spherical ROI input page.""" + """Build the spherical ROI input page. + + All spheres live in a single full-width table (one row per sphere). A + single row reproduces the classic single-sphere behavior; extra rows + union into one combined target. + """ page = QtWidgets.QWidget() - layout = QtWidgets.QFormLayout(page) - layout.setVerticalSpacing(3) + layout = QtWidgets.QVBoxLayout(page) + layout.setSpacing(5) layout.setContentsMargins(0, 5, 0, 5) # Coordinate space toggle @@ -168,88 +176,72 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: space_layout.addWidget(self.space_subject_radio) space_layout.addWidget(self.space_mni_radio) space_layout.addStretch() - layout.addRow(space_widget) + layout.addWidget(space_widget) else: self.space_subject_radio = None self.space_mni_radio = None self.mni_info_label = None - # Coordinate inputs + Freeview button + # Header row: coordinate-space label + Freeview button + header_widget = QtWidgets.QWidget() + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) self.coords_label = QtWidgets.QLabel("ROI Center RAS Coordinates (mm):") - coords_widget = QtWidgets.QWidget() - coords_layout = QtWidgets.QHBoxLayout(coords_widget) - coords_layout.setContentsMargins(0, 0, 0, 0) - - coords_layout.addWidget(QtWidgets.QLabel("X:")) - self.x_input = QtWidgets.QDoubleSpinBox() - self.x_input.setRange(-150, 150) - self.x_input.setValue(0) - self.x_input.setDecimals(2) - coords_layout.addWidget(self.x_input) - - coords_layout.addWidget(QtWidgets.QLabel("Y:")) - self.y_input = QtWidgets.QDoubleSpinBox() - self.y_input.setRange(-150, 150) - self.y_input.setValue(0) - self.y_input.setDecimals(2) - coords_layout.addWidget(self.y_input) - - coords_layout.addWidget(QtWidgets.QLabel("Z:")) - self.z_input = QtWidgets.QDoubleSpinBox() - self.z_input.setRange(-150, 150) - self.z_input.setValue(0) - self.z_input.setDecimals(2) - coords_layout.addWidget(self.z_input) + header_layout.addWidget(self.coords_label) + header_layout.addStretch() if self._enable_freeview_button: self.view_t1_btn = QtWidgets.QPushButton("View T1 in Freeview") self.view_t1_btn.setToolTip( "Open subject's T1 scan in Freeview to find RAS coordinates" ) - coords_layout.addWidget(self.view_t1_btn) + header_layout.addWidget(self.view_t1_btn) else: self.view_t1_btn = None - coords_layout.addStretch() - layout.addRow(self.coords_label, coords_widget) - - # Radius - self.radius_label = QtWidgets.QLabel("ROI Radius (mm):") - self.radius_input = QtWidgets.QDoubleSpinBox() - self.radius_input.setRange(1, 50) - self.radius_input.setValue(10) - self.radius_input.setDecimals(2) - layout.addRow(self.radius_label, self.radius_input) - - # Additional spheres (optional) — the X/Y/Z/Radius above define the first - # sphere; each extra row unions another sphere into one combined target. - self.extra_spheres_table = QtWidgets.QTableWidget(0, 4) - self.extra_spheres_table.setHorizontalHeaderLabels(["X", "Y", "Z", "Radius"]) - self.extra_spheres_table.horizontalHeader().setStretchLastSection(True) - self.extra_spheres_table.verticalHeader().setVisible(False) - self.extra_spheres_table.setSelectionBehavior( - QtWidgets.QAbstractItemView.SelectRows + layout.addWidget(header_widget) + + # Unified spheres table — every sphere is a row. + self.spheres_table = QtWidgets.QTableWidget(0, 5) + self.spheres_table.setHorizontalHeaderLabels( + ["X (mm)", "Y (mm)", "Z (mm)", "Radius (mm)", ""] + ) + sph_header = self.spheres_table.horizontalHeader() + for col in range(4): + sph_header.setSectionResizeMode(col, QtWidgets.QHeaderView.Stretch) + sph_header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) + self.spheres_table.setColumnWidth(4, 40) + self.spheres_table.verticalHeader().setVisible(True) + self.spheres_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.spheres_table.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.spheres_table.setMinimumHeight(190) + self.spheres_table.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) - self.extra_spheres_table.setMaximumHeight(140) - self.extra_spheres_table.setToolTip( - "Extra spheres to union with the first one. Leave empty for a single " - "sphere (unchanged behavior)." + self.spheres_table.setToolTip( + "Each row is a sphere. Multiple rows union into one combined target; " + "a single row is a plain single-sphere ROI (unchanged behavior)." ) + layout.addWidget(self.spheres_table) + # Sphere management buttons sphere_btn_widget = QtWidgets.QWidget() sphere_btn_layout = QtWidgets.QHBoxLayout(sphere_btn_widget) sphere_btn_layout.setContentsMargins(0, 0, 0, 0) self.add_sphere_btn = QtWidgets.QPushButton("Add Sphere") self.add_sphere_btn.setToolTip("Union another sphere into this ROI.") + self.duplicate_sphere_btn = QtWidgets.QPushButton("Duplicate Selected") + self.duplicate_sphere_btn.setToolTip( + "Copy the selected sphere row(s) as new rows." + ) self.remove_sphere_btn = QtWidgets.QPushButton("Remove Selected") - self.remove_sphere_btn.setToolTip("Remove the selected extra sphere row(s).") + self.remove_sphere_btn.setToolTip("Remove the selected sphere row(s).") sphere_btn_layout.addWidget(self.add_sphere_btn) + sphere_btn_layout.addWidget(self.duplicate_sphere_btn) sphere_btn_layout.addWidget(self.remove_sphere_btn) sphere_btn_layout.addStretch() - - layout.addRow(QtWidgets.QLabel("Additional Spheres:"), self.extra_spheres_table) - layout.addRow("", sphere_btn_widget) + layout.addWidget(sphere_btn_widget) # Volumetric toggle + tissue type vol_widget = QtWidgets.QWidget() @@ -280,12 +272,15 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: vol_layout.addWidget(self.sphere_tissue_combo) vol_layout.addStretch() - layout.addRow(vol_widget) + layout.addWidget(vol_widget) # Wire checkbox to enable/disable tissue combo self.volumetric_checkbox.toggled.connect(self.sphere_tissue_combo.setEnabled) self.volumetric_checkbox.toggled.connect(self.sphere_tissue_label.setEnabled) + # Seed one default sphere so the table is never empty. + self._add_sphere_row() + return page def _build_cortical_page(self) -> QtWidgets.QWidget: @@ -445,18 +440,18 @@ def _connect_signals(self): self.volume_subject_radio.toggled.connect(self._refresh_volume_atlases) self.volume_mni_radio.toggled.connect(self._refresh_volume_atlases) - # Multi-sphere add/remove (lambdas so the button's bool 'checked' arg is - # not passed as a coordinate/row argument). + # Multi-sphere add/duplicate/remove (lambdas so the button's bool + # 'checked' arg is not passed as a coordinate/row argument). self.add_sphere_btn.clicked.connect(lambda: self._add_sphere_row()) + self.duplicate_sphere_btn.clicked.connect( + lambda: self._duplicate_selected_sphere_rows() + ) self.remove_sphere_btn.clicked.connect( lambda: self._remove_selected_sphere_rows() ) - # Emit roi_changed on any value change - self.x_input.valueChanged.connect(self.roi_changed) - self.y_input.valueChanged.connect(self.roi_changed) - self.z_input.valueChanged.connect(self.roi_changed) - self.radius_input.valueChanged.connect(self.roi_changed) + # Emit roi_changed on any value change (per-row sphere spin boxes wire + # themselves in _add_sphere_row). self.volumetric_checkbox.toggled.connect(self.roi_changed) self.sphere_tissue_combo.currentIndexChanged.connect(self.roi_changed) self.atlas_combo.currentIndexChanged.connect(self.roi_changed) @@ -487,7 +482,6 @@ def _on_mode_changed(self, button): def _update_coordinate_space_labels(self): """Update coordinate labels and tooltips based on space selection.""" if self.get_roi_type() != "spherical": - pass # mni_info_label removed return if self.is_mni_space(): @@ -497,10 +491,6 @@ def _update_coordinate_space_labels(self): "for each subject)" ) self.coords_label.setStyleSheet("color: #007ACC; font-weight: bold;") - pass # mni_info_label removed - self.x_input.setToolTip("X coordinate in MNI space") - self.y_input.setToolTip("Y coordinate in MNI space") - self.z_input.setToolTip("Z coordinate in MNI space") if self.view_t1_btn is not None: self.view_t1_btn.setText("View MNI Template") self.view_t1_btn.setToolTip( @@ -510,10 +500,6 @@ def _update_coordinate_space_labels(self): self.coords_label.setText("ROI Center RAS Coordinates (mm):") self.coords_label.setToolTip("Subject-specific RAS coordinates") self.coords_label.setStyleSheet("") - pass # mni_info_label removed - self.x_input.setToolTip("X coordinate in subject RAS space") - self.y_input.setToolTip("Y coordinate in subject RAS space") - self.z_input.setToolTip("Z coordinate in subject RAS space") if self.view_t1_btn is not None: self.view_t1_btn.setText("View T1 in Freeview") self.view_t1_btn.setToolTip( @@ -527,8 +513,8 @@ def _update_coordinate_space_labels(self): def _add_sphere_row( self, x: float = 0.0, y: float = 0.0, z: float = 0.0, r: float = 10.0 ): - """Append a row of X/Y/Z/Radius spin boxes to the extra-spheres table.""" - table = self.extra_spheres_table + """Append a sphere row (X/Y/Z/Radius spin boxes + delete button).""" + table = self.spheres_table row = table.rowCount() table.insertRow(row) specs = [(-150, 150, x), (-150, 150, y), (-150, 150, z), (1, 50, r)] @@ -539,37 +525,119 @@ def _add_sphere_row( spin.setValue(val) spin.valueChanged.connect(self.roi_changed) table.setCellWidget(row, col, spin) + + del_btn = QtWidgets.QPushButton("✕") + del_btn.setFixedWidth(32) + del_btn.setToolTip("Remove this sphere.") + del_btn.clicked.connect(lambda: self._remove_sphere_row_widget(del_btn)) + table.setCellWidget(row, 4, del_btn) + + self.roi_changed.emit() + + def _row_values(self, row: int) -> tuple[float, float, float, float] | None: + """Return ``(x, y, z, radius)`` for a table row, or ``None`` if empty.""" + table = self.spheres_table + widgets = [table.cellWidget(row, col) for col in range(4)] + if any(w is None for w in widgets): + return None + xw, yw, zw, rw = widgets + return xw.value(), yw.value(), zw.value(), rw.value() + + def _remove_sphere_at(self, row: int): + """Remove the sphere at ``row``, always keeping at least one row.""" + table = self.spheres_table + if table.rowCount() <= 1: + return + table.removeRow(row) self.roi_changed.emit() + def _remove_sphere_row_widget(self, button: QtWidgets.QPushButton): + """Remove the row whose trailing delete button is ``button``. + + Rows shift as siblings are removed, so the owning row is resolved by + widget identity rather than a captured index. + """ + table = self.spheres_table + for row in range(table.rowCount()): + if table.cellWidget(row, 4) is button: + self._remove_sphere_at(row) + return + def _remove_selected_sphere_rows(self): - """Remove the currently selected extra-sphere rows (if any).""" - table = self.extra_spheres_table + """Remove the selected sphere rows, always keeping at least one row.""" + table = self.spheres_table rows = sorted({idx.row() for idx in table.selectedIndexes()}, reverse=True) - if not rows and table.rowCount(): - rows = [table.rowCount() - 1] + if not rows: + return + changed = False for row in rows: + if table.rowCount() <= 1: + break table.removeRow(row) - self.roi_changed.emit() + changed = True + if changed: + self.roi_changed.emit() + + def _duplicate_selected_sphere_rows(self): + """Append a copy of each selected sphere row (last row if none selected).""" + table = self.spheres_table + rows = sorted({idx.row() for idx in table.selectedIndexes()}) + if not rows and table.rowCount(): + rows = [table.rowCount() - 1] + # Snapshot values first so appended rows are not re-copied. + values = [self._row_values(row) for row in rows] + for vals in values: + if vals is None: + continue + x, y, z, r = vals + self._add_sphere_row(x, y, z, r) def _collect_spheres(self) -> tuple[list[list[float]], list[float]]: - """Return ``(centers, radii)`` for the first sphere plus any extra rows. + """Return ``(centers, radii)`` for every sphere row in the table. - The X/Y/Z/Radius spin boxes define the first (always present) sphere; - each row in the extra-spheres table adds another. A single sphere (no - extra rows) reproduces the pre-union behavior exactly. + A single row reproduces the pre-union behavior exactly. If the table is + somehow empty, a single default sphere is returned so callers always see + at least one sphere. """ - centers = [[self.x_input.value(), self.y_input.value(), self.z_input.value()]] - radii = [self.radius_input.value()] - table = self.extra_spheres_table + centers: list[list[float]] = [] + radii: list[float] = [] + table = self.spheres_table for row in range(table.rowCount()): - widgets = [table.cellWidget(row, col) for col in range(4)] - if any(w is None for w in widgets): + vals = self._row_values(row) + if vals is None: continue - xw, yw, zw, rw = widgets - centers.append([xw.value(), yw.value(), zw.value()]) - radii.append(rw.value()) + x, y, z, r = vals + centers.append([x, y, z]) + radii.append(r) + if not centers: + centers = [[0.0, 0.0, 0.0]] + radii = [10.0] return centers, radii + def set_spheres( + self, + centers: list[list[float]], + radii: list[float], + ): + """Repopulate the spheres table from saved coordinates and radii. + + The inverse of :meth:`_collect_spheres`: clears existing rows and adds + one row per ``(center, radius)`` pair. When no spheres are supplied a + single default row is seeded so the table is never empty. + + Args: + centers: Sequence of ``[x, y, z]`` coordinate triples. + radii: Sequence of radii, one per center. + """ + table = self.spheres_table + table.setRowCount(0) + if not centers: + self._add_sphere_row() + return + for center, radius in zip(centers, radii): + x, y, z = center + self._add_sphere_row(float(x), float(y), float(z), float(radius)) + def _selected_hemis(self) -> list[str]: """Return the hemispheres selected in the cortical page. @@ -915,7 +983,10 @@ def _on_list_volume_regions(self): self._show_volume_regions_dialog(volume_atlas) def _show_atlas_regions_dialog(self, atlas_display: str, hemi: str): - """Show a dialog listing regions in the selected cortical atlas. + """Open the region finder for the selected cortical atlas. + + Selected region ids (the ``.annot`` label indices flex expects) are + appended to the region-label field. Args: atlas_display: Atlas display name from the combo box. @@ -935,46 +1006,27 @@ def _show_atlas_regions_dialog(self, atlas_display: str, hemi: str): try: mesh_mgr = MeshAtlasManager("") regions = mesh_mgr.list_annot_regions(annot_file) - output_lines = [] - for idx, name in regions: - output_lines.append(f"{idx:3d}: {name}") - output = "\n".join(output_lines) except (OSError, ValueError) as e: QtWidgets.QMessageBox.warning(self, "Error Listing Regions", str(e)) return - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle(f"Regions in {hemi}.{atlas_display}") - dlg.setMinimumWidth(600) - layout = QtWidgets.QVBoxLayout(dlg) - - search_box = QtWidgets.QLineEdit() - search_box.setPlaceholderText("Search regions...") - layout.addWidget(search_box) - - text = QtWidgets.QTextEdit() - text.setReadOnly(True) - text.setText(output) - layout.addWidget(text) - - def filter_regions(): - query = search_box.text().strip().lower() - if not query: - text.setText(output) - return - filtered = "\n".join( - line for line in output.splitlines() if query in line.lower() + entries = [(idx, name, None) for idx, name in regions] + dlg = AtlasRegionFinderDialog( + self, + f"Regions in {hemi}.{atlas_display}", + entries, + return_field="id", + multi=True, + ) + if dlg.exec_() == QtWidgets.QDialog.Accepted: + merge_into_lineedit( + self.label_value_input, dlg.selected_values(), replace_default="1" ) - text.setText(filtered) - - search_box.textChanged.connect(filter_regions) - btn = QtWidgets.QPushButton("Close") - btn.clicked.connect(dlg.accept) - layout.addWidget(btn) - dlg.exec_() def _show_volume_regions_dialog(self, volume_atlas: str): - """Show a dialog listing regions in the selected volume atlas. + """Open the region finder for the selected volume atlas. + + Selected region ids are appended to the volume region-label field. Args: volume_atlas: Volume atlas display name from the combo box. @@ -995,62 +1047,34 @@ def _show_volume_regions_dialog(self, volume_atlas: str): ) return + entries = [] try: - output = f"Subcortical Regions ({volume_atlas}):\n" - output += "=" * 50 + "\n" - output += f"{'ID':<4} {'Structure Name':<35} {'RGB'}\n" - output += "-" * 50 + "\n" - with open(lut_file, "r") as f: for line in f: line = line.strip() if line and not line.startswith("#"): - label = self._parse_lut_line(line) - if label is None: + parsed = self._parse_lut_line(line) + if parsed is None: continue - label_id, label_name, rgb = label - if rgb is None: - output += f"{label_id:<4} {label_name:<35} (no color)\n" - else: - r, g, b = rgb - output += f"{label_id:<4} {label_name:<35} ({r},{g},{b})\n" + label_id, label_name, rgb = parsed + entries.append((int(label_id), label_name, rgb)) except OSError as e: QtWidgets.QMessageBox.warning( self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" ) return - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle(f"Subcortical Regions - {volume_atlas}") - dlg.setMinimumWidth(700) - layout = QtWidgets.QVBoxLayout(dlg) - - search_box = QtWidgets.QLineEdit() - search_box.setPlaceholderText("Search regions (by ID or name)...") - layout.addWidget(search_box) - - text = QtWidgets.QTextEdit() - text.setReadOnly(True) - text.setFont(QtGui.QFont("Consolas", FONT_SIZE_MONOSPACE)) - text.setText(output) - layout.addWidget(text) - - def filter_regions(): - query = search_box.text().strip().lower() - if not query: - text.setText(output) - return - filtered_lines = [ - line for line in output.splitlines() if query in line.lower() - ] - text.setText("\n".join(filtered_lines)) - - search_box.textChanged.connect(filter_regions) - - btn = QtWidgets.QPushButton("Close") - btn.clicked.connect(dlg.accept) - layout.addWidget(btn) - dlg.exec_() + dlg = AtlasRegionFinderDialog( + self, + f"Subcortical Regions - {volume_atlas}", + entries, + return_field="id", + multi=True, + ) + if dlg.exec_() == QtWidgets.QDialog.Accepted: + merge_into_lineedit( + self.volume_label_input, dlg.selected_values(), replace_default="10" + ) def _find_volume_lut(self, atlas_path: str) -> Path | None: atlas = Path(atlas_path) From f4fbd1548b7ff03ab3d752796355f7f9acc2a859 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 14:38:19 -0500 Subject: [PATCH 08/27] Reuse shared region-finder and help-icon components in analyzer and ex tabs Analyzer tab: replace the inline QDialog/QListWidget region picker in show_available_regions with AtlasRegionFinderDialog(return_field="name"). Mesh regions feed as (index, name, None); voxel regions are parsed from their "Name (ID: N)" strings into (id, name, None) so the dialog returns clean names without the ' (ID:' split hack. Selected names are merged back into region_input via merge_into_lineedit, preserving the existing comma-join + dedupe behavior and the NAMES-only config contract. Ex-search tab: add a HelpIcon next to the 'Combine selected ROIs into one target' checkbox, sharing the hoisted tooltip string, wrapped in a QHBoxLayout. --- tit/gui/analyzer_tab.py | 82 ++++++++++++++-------------------------- tit/gui/ex_search_tab.py | 16 +++++--- 2 files changed, 40 insertions(+), 58 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 99808979..3ec9d2bd 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -33,6 +33,10 @@ ) from tit.gui.components.action_buttons import RunStopButtons from tit.gui.components.base_thread import BaseProcessThread +from tit.gui.components.atlas_region_finder import ( + AtlasRegionFinderDialog, + merge_into_lineedit, +) from tit.atlas.constants import BUILTIN_ATLASES from tit.paths import get_path_manager @@ -2224,60 +2228,32 @@ def show_available_regions(self): progress_dialog.setValue(100) progress_dialog.close() - # Show searchable region dialog - dialog = QtWidgets.QDialog(self) - dialog.setWindowTitle(f"Available Regions - {atlas_type_display}") - dialog.setMinimumWidth(400) - dialog.setMinimumHeight(500) - layout = QtWidgets.QVBoxLayout(dialog) - - search_input = QtWidgets.QLineEdit() - search_layout = QtWidgets.QHBoxLayout() - search_layout.addWidget(QtWidgets.QLabel("Search:")) - search_layout.addWidget(search_input) - layout.addLayout(search_layout) - - list_widget = QtWidgets.QListWidget() - list_widget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - list_widget.addItems(regions) - layout.addWidget(list_widget) - - def filter_regions(text): - for i in range(list_widget.count()): - list_widget.item(i).setHidden( - text.lower() not in list_widget.item(i).text().lower() - ) + # Build (id, name, rgb) entries for the shared region finder. Mesh + # regions are plain names (no numeric id), so a sequential index is + # used purely for display; voxel regions arrive as "Name (ID: N)" + # strings and are parsed into their id and name parts here so the + # dialog can return clean names without any string-splitting hack. + if self.space_mesh.isChecked(): + entries = [(idx, name, None) for idx, name in enumerate(regions)] + else: + entries = [] + for region in regions: + name_part, _, id_part = region.partition(" (ID:") + try: + region_id = int(id_part.rstrip(")").strip()) + except ValueError: + region_id = 0 + entries.append((region_id, name_part.strip(), None)) - def select_regions(): - selected = list_widget.selectedItems() - if not selected: - return - existing = self.region_input.text().strip() - current = ( - [r.strip() for r in existing.split(",") if r.strip()] - if existing - else [] - ) - for item in selected: - region_text = item.text().split(" (ID:")[0] - if region_text not in current: - current.append(region_text) - self.region_input.setText(", ".join(current)) - dialog.accept() - - search_input.textChanged.connect(filter_regions) - list_widget.itemDoubleClicked.connect(lambda: select_regions()) - - btn_layout = QtWidgets.QHBoxLayout() - copy_btn = QtWidgets.QPushButton("Add Selected") - close_btn = QtWidgets.QPushButton("Close") - copy_btn.clicked.connect(select_regions) - close_btn.clicked.connect(dialog.reject) - btn_layout.addWidget(copy_btn) - btn_layout.addWidget(close_btn) - layout.addLayout(btn_layout) - - dialog.exec_() + dialog = AtlasRegionFinderDialog( + self, + f"Available Regions - {atlas_type_display}", + entries, + return_field="name", + multi=True, + ) + if dialog.exec_() == QtWidgets.QDialog.Accepted: + merge_into_lineedit(self.region_input, dialog.selected_values()) def load_t1_in_freeview(self): """Load subject's T1 NIfTI file or MNI template in Freeview for coordinate selection.""" diff --git a/tit/gui/ex_search_tab.py b/tit/gui/ex_search_tab.py index b0cb514d..ef83a3b6 100644 --- a/tit/gui/ex_search_tab.py +++ b/tit/gui/ex_search_tab.py @@ -36,6 +36,7 @@ ) from tit.gui.components.action_buttons import RunStopButtons from tit.gui.components.base_thread import BaseProcessThread +from tit.gui.components.help_icon import HelpIcon from tit.paths import get_path_manager from tit import logger as logging_util from tit.opt.ex.engine import ExSearchEngine @@ -923,15 +924,20 @@ def setup_ui(self): roi_layout.addLayout(radius_layout) # Combine selected ROIs into a single (unioned) target - self.combine_rois_cb = QtWidgets.QCheckBox( - "Combine selected ROIs into one target" - ) - self.combine_rois_cb.setToolTip( + combine_tooltip = ( "When checked, all selected ROIs are unioned into one target and " "searched in a single run (output named by joining the ROI names " "with '+'). When unchecked, each selected ROI is searched separately." ) - roi_layout.addWidget(self.combine_rois_cb) + self.combine_rois_cb = QtWidgets.QCheckBox( + "Combine selected ROIs into one target" + ) + self.combine_rois_cb.setToolTip(combine_tooltip) + combine_layout = QtWidgets.QHBoxLayout() + combine_layout.addWidget(self.combine_rois_cb) + combine_layout.addWidget(HelpIcon(combine_tooltip)) + combine_layout.addStretch() + roi_layout.addLayout(combine_layout) # Add ROI container to grid - Row 1, Column 0 main_grid_layout.addWidget(roi_container, 1, 0) From 6b73196bd1d382723d8f2943c327d51b8e443b82 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 15:03:16 -0500 Subject: [PATCH 09/27] GUI: defer atlas region load to Add Selected; make HelpIcon click-to-popup - atlas_region_finder: remove double-click-to-accept; selecting a row now only updates the highlight, loading happens exclusively via the Add Selected button. - help_icon: convert HelpIcon from a hover-only QLabel to a clickable QToolButton that opens the help text in an information popup (with a configurable title). - ex_search_tab: use the clickable HelpIcon (title 'Combine ROIs') next to the Combine checkbox. --- tit/gui/components/atlas_region_finder.py | 4 +- tit/gui/components/help_icon.py | 46 +++++++++++++++-------- tit/gui/ex_search_tab.py | 2 +- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/tit/gui/components/atlas_region_finder.py b/tit/gui/components/atlas_region_finder.py index 65bfc2f3..436947cb 100644 --- a/tit/gui/components/atlas_region_finder.py +++ b/tit/gui/components/atlas_region_finder.py @@ -97,10 +97,12 @@ def __init__( layout.addLayout(btn_layout) # ── Wiring ─────────────────────────────────────────────────────── + # Clicking / double-clicking a row only updates the highlight (Qt + # selection); nothing is loaded and the dialog is not accepted until + # the user presses "Add Selected". self.search_input.textChanged.connect(self._filter) self.add_btn.clicked.connect(self._on_accept) self.cancel_btn.clicked.connect(self.reject) - self.list_widget.itemDoubleClicked.connect(lambda _item: self._on_accept()) # ------------------------------------------------------------------ # Construction helpers diff --git a/tit/gui/components/help_icon.py b/tit/gui/components/help_icon.py index b3694c9f..918b873d 100644 --- a/tit/gui/components/help_icon.py +++ b/tit/gui/components/help_icon.py @@ -1,12 +1,11 @@ #!/usr/bin/env simnibs_python # -*- coding: utf-8 -*- -"""Reusable hover-only help affordance. +"""Reusable click-to-popup help affordance. -This module provides :class:`HelpIcon`, a tiny non-interactive ``"?"`` label -that reveals contextual help on hover via a tooltip. It follows the repo's -``setToolTip`` convention for inline help and keeps a consistent circular -appearance across the GUI. +This module provides :class:`HelpIcon`, a tiny clickable ``"?"`` button that +reveals contextual help in a small popup when clicked. It keeps a consistent +circular appearance across the GUI and is intentionally small and reusable. """ from __future__ import annotations @@ -16,26 +15,41 @@ from PyQt5 import QtCore, QtWidgets -class HelpIcon(QtWidgets.QLabel): - """A small circular ``"?"`` icon that shows help text on hover. +class HelpIcon(QtWidgets.QToolButton): + """A small circular ``"?"`` button that shows help text on click. - The icon is purely informational: it has no click behaviour and simply - displays ``help_text`` as a tooltip when hovered. + The icon is an interactive affordance: clicking it opens a small + information popup containing ``help_text``. The same text is also set as a + tooltip so it remains discoverable on hover. Args: - help_text: The text shown as a tooltip on hover. + help_text: The text shown in the popup (and as a tooltip) on demand. + title: Window title for the popup dialog. Defaults to ``"Help"``. parent: Optional parent widget. """ def __init__( - self, help_text: str, parent: Optional[QtWidgets.QWidget] = None + self, + help_text: str, + title: str = "Help", + parent: Optional[QtWidgets.QWidget] = None, ) -> None: - super().__init__("?", parent) + super().__init__(parent) + self._help_text = help_text + self._title = title + self.setText("?") self.setToolTip(help_text) - self.setCursor(QtCore.Qt.WhatsThisCursor) + self.setCursor(QtCore.Qt.PointingHandCursor) self.setFixedSize(15, 15) - self.setAlignment(QtCore.Qt.AlignCenter) + self.setFocusPolicy(QtCore.Qt.NoFocus) self.setStyleSheet( - "QLabel { color:#666; border:1px solid #999; " - "border-radius:7px; font-weight:bold; font-size:10px; }" + "QToolButton { color:#666; border:1px solid #999; " + "border-radius:7px; font-weight:bold; font-size:10px; " + "padding:0px; }" + "QToolButton:hover { color:#333; border-color:#666; }" ) + self.clicked.connect(self._show_popup) + + def _show_popup(self) -> None: + """Show the help text in a small information popup near the button.""" + QtWidgets.QMessageBox.information(self, self._title, self._help_text) diff --git a/tit/gui/ex_search_tab.py b/tit/gui/ex_search_tab.py index ef83a3b6..68316dbc 100644 --- a/tit/gui/ex_search_tab.py +++ b/tit/gui/ex_search_tab.py @@ -935,7 +935,7 @@ def setup_ui(self): self.combine_rois_cb.setToolTip(combine_tooltip) combine_layout = QtWidgets.QHBoxLayout() combine_layout.addWidget(self.combine_rois_cb) - combine_layout.addWidget(HelpIcon(combine_tooltip)) + combine_layout.addWidget(HelpIcon(combine_tooltip, title="Combine ROIs")) combine_layout.addStretch() roi_layout.addLayout(combine_layout) From 3385451e0b2fd3368131b526ded15f16e95bcd1b Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 15:13:14 -0500 Subject: [PATCH 10/27] flex GUI: cortical ROI by region name; reorder ROI pages + dynamic height Cortical ROI picker now uses analyzer-style hemisphere-prefixed region NAMES (e.g. "lh.precentral, rh.superiorfrontal") instead of integer label ids, and the separate hemisphere dropdown is removed (the hemisphere is carried in each name). List Regions now lists both hemispheres as hemi-prefixed names via AtlasRegionFinderDialog (return_field="name"). get_roi_spec resolves each "hemi.name" token to its .annot label index using a {(hemi, name): index} map built from list_annot_regions; the serialized AtlasROI contract (parallel atlas_path/hemisphere/label lists) is unchanged. validate() parses names and warns on unknown regions. Subcortical stays integer-ID based. Reorder ROI Definition pages/radios to Cortical (default), Subcortical, Spherical, and make the stacked area size to the current page (collapse non-current pages' vertical size policy to Ignored on currentChanged) so the compact cortical/subcortical pages no longer inherit the tall spherical spheres-table height. Flex tab lets the ROI Definition group hug that dynamic height (Maximum vertical policy). --- tit/gui/components/roi_picker.py | 306 ++++++++++++++++++++----------- tit/gui/flex_search_tab.py | 20 +- 2 files changed, 210 insertions(+), 116 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index c131b3a3..11f1abdc 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -96,20 +96,12 @@ def _setup_ui(self): self._mode_group = QtWidgets.QButtonGroup(self) self._radios = [] - if self._enable_spherical: - self.radio_spherical = QtWidgets.QRadioButton( - "Spherical (coordinates and radius)" - ) - radio_layout.addWidget(self.radio_spherical) - self._mode_group.addButton(self.radio_spherical, 0) - self._radios.append(self.radio_spherical) - else: - self.radio_spherical = None - + # Display / stacked order: Cortical (default), Subcortical, Spherical. + # Button ids match the stacked-page indices below. if self._enable_atlas: self.radio_cortical = QtWidgets.QRadioButton("Cortical") radio_layout.addWidget(self.radio_cortical) - self._mode_group.addButton(self.radio_cortical, 1) + self._mode_group.addButton(self.radio_cortical, 0) self._radios.append(self.radio_cortical) else: self.radio_cortical = None @@ -117,14 +109,24 @@ def _setup_ui(self): if self._enable_subcortical: self.radio_subcortical = QtWidgets.QRadioButton("Subcortical") radio_layout.addWidget(self.radio_subcortical) - self._mode_group.addButton(self.radio_subcortical, 2) + self._mode_group.addButton(self.radio_subcortical, 1) self._radios.append(self.radio_subcortical) else: self.radio_subcortical = None + if self._enable_spherical: + self.radio_spherical = QtWidgets.QRadioButton( + "Spherical (coordinates and radius)" + ) + radio_layout.addWidget(self.radio_spherical) + self._mode_group.addButton(self.radio_spherical, 2) + self._radios.append(self.radio_spherical) + else: + self.radio_spherical = None + radio_layout.addStretch() - # Default: first enabled mode is checked + # Default: first enabled mode is checked (Cortical when available) if self._radios: self._radios[0].setChecked(True) @@ -133,18 +135,21 @@ def _setup_ui(self): # --- Stacked widget --- self.stacked = QtWidgets.QStackedWidget() - # Page 0 — Spherical - self._spherical_page = self._build_spherical_page() - self.stacked.addWidget(self._spherical_page) - - # Page 1 — Cortical (Atlas) + # Page 0 — Cortical (Atlas) self._cortical_page = self._build_cortical_page() self.stacked.addWidget(self._cortical_page) - # Page 2 — Subcortical (Volume) + # Page 1 — Subcortical (Volume) self._subcortical_page = self._build_subcortical_page() self.stacked.addWidget(self._subcortical_page) + # Page 2 — Spherical + self._spherical_page = self._build_spherical_page() + self.stacked.addWidget(self._spherical_page) + + # Keep the stack sized to the current page (see _resize_stack_to_current). + self.stacked.currentChanged.connect(self._resize_stack_to_current) + # Set initial page if self._radios: checked_id = self._mode_group.checkedId() @@ -152,6 +157,9 @@ def _setup_ui(self): main_layout.addWidget(self.stacked) + # Collapse non-current pages so the stack hugs the initial page height. + self._resize_stack_to_current() + def _build_spherical_page(self) -> QtWidgets.QWidget: """Build the spherical ROI input page. @@ -284,13 +292,18 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: return page def _build_cortical_page(self) -> QtWidgets.QWidget: - """Build the cortical (atlas annotation) ROI input page.""" + """Build the cortical (atlas annotation) ROI input page. + + The region field uses analyzer-style, hemisphere-prefixed region + *names* (e.g. ``lh.precentral, rh.superiorfrontal``); the hemisphere is + carried in each name, so there is no separate hemisphere selector. + """ page = QtWidgets.QWidget() layout = QtWidgets.QFormLayout(page) layout.setVerticalSpacing(3) layout.setContentsMargins(0, 5, 0, 5) - # Atlas combo + hemisphere + refresh + list regions + # Atlas combo + refresh + list regions atlas_widget = QtWidgets.QWidget() atlas_layout = QtWidgets.QHBoxLayout(atlas_widget) atlas_layout.setContentsMargins(0, 0, 0, 0) @@ -301,17 +314,6 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: ) atlas_layout.addWidget(self.atlas_combo) - self.hemi_label = QtWidgets.QLabel("Hemisphere:") - atlas_layout.addWidget(self.hemi_label) - - self.hemi_combo = QtWidgets.QComboBox() - self.hemi_combo.addItems(["Left (lh)", "Right (rh)", "Both (lh + rh)"]) - self.hemi_combo.setToolTip( - "Choose one hemisphere, or 'Both' to union the same label(s) across " - "left and right hemispheres." - ) - atlas_layout.addWidget(self.hemi_combo) - self.refresh_atlases_btn = QtWidgets.QPushButton("Refresh") atlas_layout.addWidget(self.refresh_atlases_btn) @@ -321,18 +323,17 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: atlas_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas:"), atlas_widget) - # Region label value(s) — one integer, or several comma-separated to - # union multiple regions into one combined target. + # Region name(s) — one hemisphere-prefixed name, or several + # comma-separated to union multiple regions into one combined target. self.label_value_input = QtWidgets.QLineEdit() - self.label_value_input.setText("1") - self.label_value_input.setPlaceholderText("17 or 17,53") + self.label_value_input.setPlaceholderText("lh.precentral, rh.superiorfrontal") self.label_value_input.setToolTip( - "Integer atlas label(s). Comma-separate several labels to union them " - "into one combined target, e.g. 17,53." - ) - layout.addRow( - QtWidgets.QLabel("Region Label Value(s):"), self.label_value_input + "Hemisphere-prefixed region name(s), e.g. lh.precentral. " + "Comma-separate several names to union them into one combined " + "target, e.g. lh.precentral, rh.superiorfrontal. Use 'List Regions' " + "to browse available names." ) + layout.addRow(QtWidgets.QLabel("Region Name(s):"), self.label_value_input) return page @@ -455,7 +456,6 @@ def _connect_signals(self): self.volumetric_checkbox.toggled.connect(self.roi_changed) self.sphere_tissue_combo.currentIndexChanged.connect(self.roi_changed) self.atlas_combo.currentIndexChanged.connect(self.roi_changed) - self.hemi_combo.currentIndexChanged.connect(self.roi_changed) self.label_value_input.textChanged.connect(self.roi_changed) self.volume_atlas_combo.currentIndexChanged.connect(self.roi_changed) self.volume_subject_radio.toggled.connect(self.roi_changed) @@ -475,10 +475,42 @@ def _on_mode_changed(self, button): # Show/hide Freeview button (only visible in spherical mode) if self.view_t1_btn is not None: - self.view_t1_btn.setVisible(checked_id == 0) + self.view_t1_btn.setVisible(self.get_roi_type() == "spherical") self._update_coordinate_space_labels() + def _resize_stack_to_current(self, index=None): + """Size the stacked widget to the current page instead of the tallest. + + A ``QStackedWidget`` normally reserves the height of its tallest page + (here the spherical spheres-table page), leaving large empty space on + the compact cortical/subcortical pages. Collapsing every non-current + page's vertical size policy to ``Ignored`` (and keeping the current + page ``Preferred``) makes the stack size to whichever page is showing. + The spherical table keeps its own minimum height, so it still gets + adequate room when active. + + Args: + index: Unused; accepted so this can be wired to ``currentChanged``. + """ + current = self.stacked.currentWidget() + for i in range(self.stacked.count()): + page = self.stacked.widget(i) + if page is None: + continue + policy = page.sizePolicy() + policy.setVerticalPolicy( + QtWidgets.QSizePolicy.Preferred + if page is current + else QtWidgets.QSizePolicy.Ignored + ) + page.setSizePolicy(policy) + if current is not None: + current.adjustSize() + self.stacked.updateGeometry() + self.stacked.adjustSize() + self.updateGeometry() + def _update_coordinate_space_labels(self): """Update coordinate labels and tooltips based on space selection.""" if self.get_roi_type() != "spherical": @@ -638,18 +670,54 @@ def set_spheres( x, y, z = center self._add_sphere_row(float(x), float(y), float(z), float(radius)) - def _selected_hemis(self) -> list[str]: - """Return the hemispheres selected in the cortical page. + @staticmethod + def _parse_region_names(text: str) -> list[tuple[str, str]]: + """Parse comma-separated hemisphere-prefixed cortical region names. + + Accepts analyzer-style tokens such as ``lh.precentral`` or + ``rh.superiorfrontal``. - ``["lh"]`` or ``["rh"]`` for a single hemisphere, or ``["lh", "rh"]`` - when the "Both" option is chosen. + Returns: + List of ``(hemisphere, region_name)`` tuples, e.g. + ``("lh", "precentral")``. + + Raises: + ValueError: If any non-empty token is not of the form + ``lh.`` or ``rh.``. """ - idx = self.hemi_combo.currentIndex() - if idx == 0: - return ["lh"] - if idx == 1: - return ["rh"] - return ["lh", "rh"] + tokens: list[tuple[str, str]] = [] + for part in text.split(","): + part = part.strip() + if not part: + continue + hemi, sep, name = part.partition(".") + if sep != "." or hemi not in ("lh", "rh") or not name: + raise ValueError(f"Invalid cortical region name: {part!r}") + tokens.append((hemi, name)) + return tokens + + def _cortical_name_index_map(self) -> dict[tuple[str, str], int]: + """Map ``(hemi, region_name) -> annot label index`` for the current atlas. + + Built from the cached lh/rh ``.annot`` files discovered for the current + subject. Region label indices are intrinsic to an atlas parcellation + (the ``.annot`` colour-table ordering), so the same indices apply to + every subject sharing that atlas. Returns an empty dict when the atlas + files cannot be read (e.g. no subject set yet). + """ + atlas_display = self.atlas_combo.currentText() + mapping: dict[tuple[str, str], int] = {} + for hemi in ("lh", "rh"): + annot_file = self.atlases.get((hemi, atlas_display)) + if not annot_file: + continue + try: + regions = MeshAtlasManager("").list_annot_regions(annot_file) + except (OSError, ValueError): + continue + for idx, name in regions: + mapping[(hemi, name)] = idx + return mapping @staticmethod def _parse_labels(text: str) -> list[int]: @@ -696,9 +764,7 @@ def get_roi_type(self) -> str: One of ``'spherical'``, ``'atlas'``, or ``'subcortical'``. """ checked_id = self._mode_group.checkedId() - return {0: "spherical", 1: "atlas", 2: "subcortical"}.get( - checked_id, "spherical" - ) + return {0: "atlas", 1: "subcortical", 2: "spherical"}.get(checked_id, "atlas") def get_roi_params(self) -> dict: """Return a dict describing the current ROI selection. @@ -730,7 +796,6 @@ def get_roi_params(self) -> dict: "method": "atlas", "atlas": self.atlas_combo.currentText(), "region": self.label_value_input.text().strip(), - "hemisphere": "+".join(self._selected_hemis()), } else: # subcortical return { @@ -777,20 +842,23 @@ def get_roi_spec(self, subject_id: str, project_dir: str): atlas_name = self._resolve_atlas_name_for_subject( self.atlas_combo.currentText(), subject_id ) - labels = self._parse_labels(self.label_value_input.text()) - hemis = self._selected_hemis() - # Expand (labels x hemispheres) into equal-length parallel lists so a - # target can union across labels and/or both hemispheres. Each entry - # carries its own lh/rh .annot path. Outer loop over labels keeps the - # ordering label=[17,17,53,53] / hemi=[lh,rh,lh,rh] for Both. + # Resolve each hemisphere-prefixed name to its annot label index. + # Region indices are intrinsic to the atlas parcellation, so the map + # built from the current subject's .annot applies to every subject. + name_map = self._cortical_name_index_map() + tokens = self._parse_region_names(self.label_value_input.text()) + # Build equal-length parallel lists so a target can union across + # regions and/or hemispheres; each entry carries its own lh/rh + # .annot path and label index. The serialized AtlasROI contract + # (parallel atlas_path/hemisphere/label lists) is unchanged. atlas_paths, hemispheres, out_labels = [], [], [] - for label in labels: - for hemi in hemis: - out_labels.append(label) - hemispheres.append(hemi) - atlas_paths.append( - os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot") - ) + for hemi, name in tokens: + index = name_map.get((hemi, name)) + if index is None: + continue + out_labels.append(index) + hemispheres.append(hemi) + atlas_paths.append(os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot")) return FlexConfig.AtlasROI( atlas_path=self._collapse(atlas_paths), label=self._collapse(out_labels), @@ -839,7 +907,7 @@ def validate(self) -> str | None: elif roi_type == "atlas": if not self.atlas_combo.currentText(): return "No cortical atlas selected. Use Refresh to discover atlases." - error = self._validate_labels(self.label_value_input.text()) + error = self._validate_region_names(self.label_value_input.text()) if error: return error elif roi_type == "subcortical": @@ -852,6 +920,29 @@ def validate(self) -> str | None: return error return None + def _validate_region_names(self, text: str) -> str | None: + """Validate the comma-separated cortical region-name field. + + Returns an error message string, or ``None`` when valid. Unknown names + are reported only when the atlas annotation files can actually be read; + otherwise name membership is left to the flex backend. + """ + try: + tokens = self._parse_region_names(text) + except ValueError: + return ( + "Cortical region(s) must be hemisphere-prefixed names, " + "e.g. lh.precentral, rh.superiorfrontal." + ) + if not tokens: + return "Enter at least one cortical region name." + name_map = self._cortical_name_index_map() + if name_map: + unknown = [f"{h}.{n}" for h, n in tokens if (h, n) not in name_map] + if unknown: + return "Unknown cortical region(s): " + ", ".join(unknown) + return None + def _validate_labels(self, text: str) -> str | None: """Validate a comma-separated region-label field. Returns an error or None.""" try: @@ -968,60 +1059,57 @@ def _resolve_atlas_name_for_subject( # ------------------------------------------------------------------ def _on_list_atlas_regions(self): - """Show regions for the currently selected cortical atlas. + """Show cortical regions from BOTH hemispheres as hemi-prefixed names. - With the "Both" hemisphere option the label sets are identical across - hemispheres, so the first selected hemisphere is listed. + Entries are built from ``list_annot_regions`` on the lh and rh + ``.annot`` files and displayed as ``lh.`` / ``rh.``. The + finder returns the selected names, which are merged into the region + field. """ - atlas = self.atlas_combo.currentText() - hemi = self._selected_hemis()[0] - self._show_atlas_regions_dialog(atlas, hemi) - - def _on_list_volume_regions(self): - """Show regions for the currently selected volume atlas.""" - volume_atlas = self.volume_atlas_combo.currentText() - self._show_volume_regions_dialog(volume_atlas) - - def _show_atlas_regions_dialog(self, atlas_display: str, hemi: str): - """Open the region finder for the selected cortical atlas. - - Selected region ids (the ``.annot`` label indices flex expects) are - appended to the region-label field. - - Args: - atlas_display: Atlas display name from the combo box. - hemi: Hemisphere string (``'lh'`` or ``'rh'``). - """ - atlas_key = (hemi, atlas_display) - if atlas_key not in self.atlases: + atlas_display = self.atlas_combo.currentText() + if not atlas_display: QtWidgets.QMessageBox.warning( self, - "Atlas File Not Found", - f"Could not find atlas file for {hemi}.{atlas_display}", + "No Atlas Selected", + "Select a cortical atlas first (use Refresh to discover atlases).", ) return - annot_file = self.atlases[atlas_key] + entries = [] + for hemi in ("lh", "rh"): + annot_file = self.atlases.get((hemi, atlas_display)) + if not annot_file: + continue + try: + regions = MeshAtlasManager("").list_annot_regions(annot_file) + except (OSError, ValueError) as e: + QtWidgets.QMessageBox.warning(self, "Error Listing Regions", str(e)) + return + for idx, name in regions: + entries.append((idx, f"{hemi}.{name}", None)) - try: - mesh_mgr = MeshAtlasManager("") - regions = mesh_mgr.list_annot_regions(annot_file) - except (OSError, ValueError) as e: - QtWidgets.QMessageBox.warning(self, "Error Listing Regions", str(e)) + if not entries: + QtWidgets.QMessageBox.warning( + self, + "Atlas File Not Found", + f"Could not find atlas files for {atlas_display}.", + ) return - entries = [(idx, name, None) for idx, name in regions] dlg = AtlasRegionFinderDialog( self, - f"Regions in {hemi}.{atlas_display}", + f"Cortical Regions - {atlas_display}", entries, - return_field="id", + return_field="name", multi=True, ) if dlg.exec_() == QtWidgets.QDialog.Accepted: - merge_into_lineedit( - self.label_value_input, dlg.selected_values(), replace_default="1" - ) + merge_into_lineedit(self.label_value_input, dlg.selected_values()) + + def _on_list_volume_regions(self): + """Show regions for the currently selected volume atlas.""" + volume_atlas = self.volume_atlas_combo.currentText() + self._show_volume_regions_dialog(volume_atlas) def _show_volume_regions_dialog(self, volume_atlas: str): """Open the region finder for the selected volume atlas. diff --git a/tit/gui/flex_search_tab.py b/tit/gui/flex_search_tab.py index d89eb6e1..7a289d7e 100644 --- a/tit/gui/flex_search_tab.py +++ b/tit/gui/flex_search_tab.py @@ -382,8 +382,14 @@ def setup_ui(self): scroll_layout.addLayout(top_row_layout) - # ROI Definition — use component widget + # ROI Definition — use component widget. The picker sizes itself to the + # active page (see ROIPickerWidget._resize_stack_to_current), so let the + # group hug that height (Maximum) instead of reserving dead space; the + # trailing scroll_layout stretch absorbs the freed vertical space. self.roi_method_group = QtWidgets.QGroupBox("ROI Definition") + self.roi_method_group.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum + ) roi_layout = QtWidgets.QVBoxLayout(self.roi_method_group) roi_layout.addWidget(self.roi_picker) scroll_layout.addWidget(self.roi_method_group) @@ -606,16 +612,16 @@ def on_subject_changed(self): def _sync_nonroi_mode(self): """Keep the nonroi_picker on the same page as the roi_picker.""" roi_type = self.roi_picker.get_roi_type() - page_map = {"spherical": 0, "atlas": 1, "subcortical": 2} + page_map = {"atlas": 0, "subcortical": 1, "spherical": 2} idx = page_map.get(roi_type, 0) self.nonroi_picker.stacked.setCurrentIndex(idx) # Also check the matching radio if it exists - if idx == 0 and self.nonroi_picker.radio_spherical: - self.nonroi_picker.radio_spherical.setChecked(True) - elif idx == 1 and self.nonroi_picker.radio_cortical: + if idx == 0 and self.nonroi_picker.radio_cortical: self.nonroi_picker.radio_cortical.setChecked(True) - elif idx == 2 and self.nonroi_picker.radio_subcortical: + elif idx == 1 and self.nonroi_picker.radio_subcortical: self.nonroi_picker.radio_subcortical.setChecked(True) + elif idx == 2 and self.nonroi_picker.radio_spherical: + self.nonroi_picker.radio_spherical.setChecked(True) # ------------------------------------------------------------------ # # Run optimization # @@ -724,7 +730,7 @@ def run_optimization(self): elif roi_params["method"] == "atlas": roi_description = ( f"Cortical ROI: {roi_params['atlas']} " - f"[{roi_params['hemisphere']}] region(s) {roi_params['region']}" + f"region(s) {roi_params['region']}" ) else: roi_description = f"Subcortical ROI: {roi_params['volume_atlas']} region(s) {roi_params['volume_region']}" From 012ddc46d7eeea438cf96dedfdeb5e99f03ce194 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 15:21:57 -0500 Subject: [PATCH 11/27] =?UTF-8?q?roi=5Fpicker/help=5Ficon:=20review=20poli?= =?UTF-8?q?sh=20=E2=80=94=20clear=20error=20on=20unresolvable=20cortical?= =?UTF-8?q?=20names;=20docstring=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tit/gui/components/help_icon.py | 2 +- tit/gui/components/roi_picker.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tit/gui/components/help_icon.py b/tit/gui/components/help_icon.py index 918b873d..45ab6aa2 100644 --- a/tit/gui/components/help_icon.py +++ b/tit/gui/components/help_icon.py @@ -51,5 +51,5 @@ def __init__( self.clicked.connect(self._show_popup) def _show_popup(self) -> None: - """Show the help text in a small information popup near the button.""" + """Show the help text in a small information popup.""" QtWidgets.QMessageBox.information(self, self._title, self._help_text) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 11f1abdc..197f4c54 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -859,6 +859,11 @@ def get_roi_spec(self, subject_id: str, project_dir: str): out_labels.append(index) hemispheres.append(hemi) atlas_paths.append(os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot")) + if not out_labels: + raise ValueError( + f"Could not resolve any cortical region name(s) for atlas " + f"'{atlas_name}'; the annotation files could not be read." + ) return FlexConfig.AtlasROI( atlas_path=self._collapse(atlas_paths), label=self._collapse(out_labels), From 6745872fdf80829b9d57e750bb4233b9dcd564a6 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 16:53:19 -0500 Subject: [PATCH 12/27] Resolve subcortical region names for subject FreeSurfer atlases via bundled LUT Subject-space FreeSurfer atlases (e.g. aparc.DKTatlas+aseg.mgz) failed the subcortical region finder with 'LUT File Not Found' because tit only looked for a nonexistent _labels.txt sidecar. Bundle the standard FreeSurferColorLUT.txt and, when no sidecar exists, read the atlas volume's unique nonzero labels with nibabel and map them to names/colours from the bundled table (labels present in the volume only). No FreeSurfer/mri_segstats required. MNI atlases and sidecar-bearing atlases keep their existing behavior. --- resources/atlas/FreeSurferColorLUT.txt | 1397 ++++++++++++++++++++++++ tit/gui/components/roi_picker.py | 144 ++- 2 files changed, 1525 insertions(+), 16 deletions(-) create mode 100644 resources/atlas/FreeSurferColorLUT.txt diff --git a/resources/atlas/FreeSurferColorLUT.txt b/resources/atlas/FreeSurferColorLUT.txt new file mode 100644 index 00000000..2a7bd23d --- /dev/null +++ b/resources/atlas/FreeSurferColorLUT.txt @@ -0,0 +1,1397 @@ +#$Id: FreeSurferColorLUT.txt,v 1.70.2.7 2012/08/27 17:20:08 nicks Exp $ + +#No. Label Name: R G B A + +0 Unknown 0 0 0 0 +1 Left-Cerebral-Exterior 70 130 180 0 +2 Left-Cerebral-White-Matter 245 245 245 0 +3 Left-Cerebral-Cortex 205 62 78 0 +4 Left-Lateral-Ventricle 120 18 134 0 +5 Left-Inf-Lat-Vent 196 58 250 0 +6 Left-Cerebellum-Exterior 0 148 0 0 +7 Left-Cerebellum-White-Matter 220 248 164 0 +8 Left-Cerebellum-Cortex 230 148 34 0 +9 Left-Thalamus 0 118 14 0 +10 Left-Thalamus-Proper 0 118 14 0 +11 Left-Caudate 122 186 220 0 +12 Left-Putamen 236 13 176 0 +13 Left-Pallidum 12 48 255 0 +14 3rd-Ventricle 204 182 142 0 +15 4th-Ventricle 42 204 164 0 +16 Brain-Stem 119 159 176 0 +17 Left-Hippocampus 220 216 20 0 +18 Left-Amygdala 103 255 255 0 +19 Left-Insula 80 196 98 0 +20 Left-Operculum 60 58 210 0 +21 Line-1 60 58 210 0 +22 Line-2 60 58 210 0 +23 Line-3 60 58 210 0 +24 CSF 60 60 60 0 +25 Left-Lesion 255 165 0 0 +26 Left-Accumbens-area 255 165 0 0 +27 Left-Substancia-Nigra 0 255 127 0 +28 Left-VentralDC 165 42 42 0 +29 Left-undetermined 135 206 235 0 +30 Left-vessel 160 32 240 0 +31 Left-choroid-plexus 0 200 200 0 +32 Left-F3orb 100 50 100 0 +33 Left-lOg 135 50 74 0 +34 Left-aOg 122 135 50 0 +35 Left-mOg 51 50 135 0 +36 Left-pOg 74 155 60 0 +37 Left-Stellate 120 62 43 0 +38 Left-Porg 74 155 60 0 +39 Left-Aorg 122 135 50 0 +40 Right-Cerebral-Exterior 70 130 180 0 +41 Right-Cerebral-White-Matter 0 225 0 0 +42 Right-Cerebral-Cortex 205 62 78 0 +43 Right-Lateral-Ventricle 120 18 134 0 +44 Right-Inf-Lat-Vent 196 58 250 0 +45 Right-Cerebellum-Exterior 0 148 0 0 +46 Right-Cerebellum-White-Matter 220 248 164 0 +47 Right-Cerebellum-Cortex 230 148 34 0 +48 Right-Thalamus 0 118 14 0 +49 Right-Thalamus-Proper 0 118 14 0 +50 Right-Caudate 122 186 220 0 +51 Right-Putamen 236 13 176 0 +52 Right-Pallidum 13 48 255 0 +53 Right-Hippocampus 220 216 20 0 +54 Right-Amygdala 103 255 255 0 +55 Right-Insula 80 196 98 0 +56 Right-Operculum 60 58 210 0 +57 Right-Lesion 255 165 0 0 +58 Right-Accumbens-area 255 165 0 0 +59 Right-Substancia-Nigra 0 255 127 0 +60 Right-VentralDC 165 42 42 0 +61 Right-undetermined 135 206 235 0 +62 Right-vessel 160 32 240 0 +63 Right-choroid-plexus 0 200 221 0 +64 Right-F3orb 100 50 100 0 +65 Right-lOg 135 50 74 0 +66 Right-aOg 122 135 50 0 +67 Right-mOg 51 50 135 0 +68 Right-pOg 74 155 60 0 +69 Right-Stellate 120 62 43 0 +70 Right-Porg 74 155 60 0 +71 Right-Aorg 122 135 50 0 +72 5th-Ventricle 120 190 150 0 +73 Left-Interior 122 135 50 0 +74 Right-Interior 122 135 50 0 +# 75/76 removed. duplicates of 4/43 +77 WM-hypointensities 200 70 255 0 +78 Left-WM-hypointensities 255 148 10 0 +79 Right-WM-hypointensities 255 148 10 0 +80 non-WM-hypointensities 164 108 226 0 +81 Left-non-WM-hypointensities 164 108 226 0 +82 Right-non-WM-hypointensities 164 108 226 0 +83 Left-F1 255 218 185 0 +84 Right-F1 255 218 185 0 +85 Optic-Chiasm 234 169 30 0 +192 Corpus_Callosum 250 255 50 0 + +86 Left_future_WMSA 200 120 255 0 +87 Right_future_WMSA 200 121 255 0 +88 future_WMSA 200 122 255 0 + + +96 Left-Amygdala-Anterior 205 10 125 0 +97 Right-Amygdala-Anterior 205 10 125 0 +98 Dura 160 32 240 0 + +100 Left-wm-intensity-abnormality 124 140 178 0 +101 Left-caudate-intensity-abnormality 125 140 178 0 +102 Left-putamen-intensity-abnormality 126 140 178 0 +103 Left-accumbens-intensity-abnormality 127 140 178 0 +104 Left-pallidum-intensity-abnormality 124 141 178 0 +105 Left-amygdala-intensity-abnormality 124 142 178 0 +106 Left-hippocampus-intensity-abnormality 124 143 178 0 +107 Left-thalamus-intensity-abnormality 124 144 178 0 +108 Left-VDC-intensity-abnormality 124 140 179 0 +109 Right-wm-intensity-abnormality 124 140 178 0 +110 Right-caudate-intensity-abnormality 125 140 178 0 +111 Right-putamen-intensity-abnormality 126 140 178 0 +112 Right-accumbens-intensity-abnormality 127 140 178 0 +113 Right-pallidum-intensity-abnormality 124 141 178 0 +114 Right-amygdala-intensity-abnormality 124 142 178 0 +115 Right-hippocampus-intensity-abnormality 124 143 178 0 +116 Right-thalamus-intensity-abnormality 124 144 178 0 +117 Right-VDC-intensity-abnormality 124 140 179 0 + +118 Epidermis 255 20 147 0 +119 Conn-Tissue 205 179 139 0 +120 SC-Fat-Muscle 238 238 209 0 +121 Cranium 200 200 200 0 +122 CSF-SA 74 255 74 0 +123 Muscle 238 0 0 0 +124 Ear 0 0 139 0 +125 Adipose 173 255 47 0 +126 Spinal-Cord 133 203 229 0 +127 Soft-Tissue 26 237 57 0 +128 Nerve 34 139 34 0 +129 Bone 30 144 255 0 +130 Air 147 19 173 0 +131 Orbital-Fat 238 59 59 0 +132 Tongue 221 39 200 0 +133 Nasal-Structures 238 174 238 0 +134 Globe 255 0 0 0 +135 Teeth 72 61 139 0 +136 Left-Caudate-Putamen 21 39 132 0 +137 Right-Caudate-Putamen 21 39 132 0 +138 Left-Claustrum 65 135 20 0 +139 Right-Claustrum 65 135 20 0 +140 Cornea 134 4 160 0 +142 Diploe 221 226 68 0 +143 Vitreous-Humor 255 255 254 0 +144 Lens 52 209 226 0 +145 Aqueous-Humor 239 160 223 0 +146 Outer-Table 70 130 180 0 +147 Inner-Table 70 130 181 0 +148 Periosteum 139 121 94 0 +149 Endosteum 224 224 224 0 +150 R-C-S 255 0 0 0 +151 Iris 205 205 0 0 +152 SC-Adipose-Muscle 238 238 209 0 +153 SC-Tissue 139 121 94 0 +154 Orbital-Adipose 238 59 59 0 + +155 Left-IntCapsule-Ant 238 59 59 0 +156 Right-IntCapsule-Ant 238 59 59 0 +157 Left-IntCapsule-Pos 62 10 205 0 +158 Right-IntCapsule-Pos 62 10 205 0 + +# These labels are for babies/children +159 Left-Cerebral-WM-unmyelinated 0 118 14 0 +160 Right-Cerebral-WM-unmyelinated 0 118 14 0 +161 Left-Cerebral-WM-myelinated 220 216 21 0 +162 Right-Cerebral-WM-myelinated 220 216 21 0 +163 Left-Subcortical-Gray-Matter 122 186 220 0 +164 Right-Subcortical-Gray-Matter 122 186 220 0 +165 Skull 255 165 0 0 +166 Posterior-fossa 14 48 255 0 +167 Scalp 166 42 42 0 +168 Hematoma 121 18 134 0 +169 Left-Basal-Ganglia 236 13 127 0 +176 Right-Basal-Ganglia 236 13 126 0 + +# Label names and colors for Brainstem constituents +# No. Label Name: R G B A +170 brainstem 119 159 176 0 +171 DCG 119 0 176 0 +172 Vermis 119 100 176 0 +173 Midbrain 119 200 176 0 +174 Pons 119 159 100 0 +175 Medulla 119 159 200 0 + +#176 Right-Basal-Ganglia found in babies/children section above + +180 Left-Cortical-Dysplasia 73 61 139 0 +181 Right-Cortical-Dysplasia 73 62 139 0 + +#192 Corpus_Callosum listed after #85 above +193 Left-hippocampal_fissure 0 196 255 0 +194 Left-CADG-head 255 164 164 0 +195 Left-subiculum 196 196 0 0 +196 Left-fimbria 0 100 255 0 +197 Right-hippocampal_fissure 128 196 164 0 +198 Right-CADG-head 0 126 75 0 +199 Right-subiculum 128 96 64 0 +200 Right-fimbria 0 50 128 0 +201 alveus 255 204 153 0 +202 perforant_pathway 255 128 128 0 +203 parasubiculum 255 255 0 0 +204 presubiculum 64 0 64 0 +205 subiculum 0 0 255 0 +206 CA1 255 0 0 0 +207 CA2 128 128 255 0 +208 CA3 0 128 0 0 +209 CA4 196 160 128 0 +210 GC-ML-DG 32 200 255 0 +211 HATA 128 255 128 0 +212 fimbria 204 153 204 0 +213 lateral_ventricle 121 17 136 0 +214 molecular_layer_HP 128 0 0 0 +215 hippocampal_fissure 128 32 255 0 +216 entorhinal_cortex 255 204 102 0 +217 molecular_layer_subiculum 128 128 128 0 +218 Amygdala 104 255 255 0 +219 Cerebral_White_Matter 0 226 0 0 +220 Cerebral_Cortex 205 63 78 0 +221 Inf_Lat_Vent 197 58 250 0 +222 Perirhinal 33 150 250 0 +223 Cerebral_White_Matter_Edge 226 0 0 0 +224 Background 100 100 100 0 +225 Ectorhinal 197 150 250 0 +226 HP_tail 170 170 255 0 + +250 Fornix 255 0 0 0 +251 CC_Posterior 0 0 64 0 +252 CC_Mid_Posterior 0 0 112 0 +253 CC_Central 0 0 160 0 +254 CC_Mid_Anterior 0 0 208 0 +255 CC_Anterior 0 0 255 0 + +# This is for keeping track of voxel changes +256 Voxel-Unchanged 0 0 0 0 + +# lymph node and vascular labels +331 Aorta 255 0 0 0 +332 Left-Common-IliacA 255 80 0 0 +333 Right-Common-IliacA 255 160 0 0 +334 Left-External-IliacA 255 255 0 0 +335 Right-External-IliacA 0 255 0 0 +336 Left-Internal-IliacA 255 0 160 0 +337 Right-Internal-IliacA 255 0 255 0 +338 Left-Lateral-SacralA 255 50 80 0 +339 Right-Lateral-SacralA 80 255 50 0 +340 Left-ObturatorA 160 255 50 0 +341 Right-ObturatorA 160 200 255 0 +342 Left-Internal-PudendalA 0 255 160 0 +343 Right-Internal-PudendalA 0 0 255 0 +344 Left-UmbilicalA 80 50 255 0 +345 Right-UmbilicalA 160 0 255 0 +346 Left-Inf-RectalA 255 210 0 0 +347 Right-Inf-RectalA 0 160 255 0 +348 Left-Common-IliacV 255 200 80 0 +349 Right-Common-IliacV 255 200 160 0 +350 Left-External-IliacV 255 80 200 0 +351 Right-External-IliacV 255 160 200 0 +352 Left-Internal-IliacV 30 255 80 0 +353 Right-Internal-IliacV 80 200 255 0 +354 Left-ObturatorV 80 255 200 0 +355 Right-ObturatorV 195 255 200 0 +356 Left-Internal-PudendalV 120 200 20 0 +357 Right-Internal-PudendalV 170 10 200 0 +358 Pos-Lymph 20 130 180 0 +359 Neg-Lymph 20 180 130 0 + +400 V1 206 62 78 0 +401 V2 121 18 134 0 +402 BA44 199 58 250 0 +403 BA45 1 148 0 0 +404 BA4a 221 248 164 0 +405 BA4p 231 148 34 0 +406 BA6 1 118 14 0 +407 BA2 120 118 14 0 +408 BA1_old 123 186 221 0 +409 BAun2 238 13 177 0 +410 BA1 123 186 220 0 +411 BA2b 138 13 206 0 +412 BA3a 238 130 176 0 +413 BA3b 218 230 76 0 +414 MT 38 213 176 0 +415 AIPS_AIP_l 1 225 176 0 +416 AIPS_AIP_r 1 225 176 0 +417 AIPS_VIP_l 200 2 100 0 +418 AIPS_VIP_r 200 2 100 0 +419 IPL_PFcm_l 5 200 90 0 +420 IPL_PFcm_r 5 200 90 0 +421 IPL_PF_l 100 5 200 0 +422 IPL_PFm_l 25 255 100 0 +423 IPL_PFm_r 25 255 100 0 +424 IPL_PFop_l 230 7 100 0 +425 IPL_PFop_r 230 7 100 0 +426 IPL_PF_r 100 5 200 0 +427 IPL_PFt_l 150 10 200 0 +428 IPL_PFt_r 150 10 200 0 +429 IPL_PGa_l 175 10 176 0 +430 IPL_PGa_r 175 10 176 0 +431 IPL_PGp_l 10 100 255 0 +432 IPL_PGp_r 10 100 255 0 +433 Visual_V3d_l 150 45 70 0 +434 Visual_V3d_r 150 45 70 0 +435 Visual_V4_l 45 200 15 0 +436 Visual_V4_r 45 200 15 0 +437 Visual_V5_b 227 45 100 0 +438 Visual_VP_l 227 45 100 0 +439 Visual_VP_r 227 45 100 0 + +# wm lesions +498 wmsa 143 188 143 0 +499 other_wmsa 255 248 220 0 + +# HiRes Hippocampus labeling +500 right_CA2_3 17 85 136 0 +501 right_alveus 119 187 102 0 +502 right_CA1 204 68 34 0 +503 right_fimbria 204 0 255 0 +504 right_presubiculum 221 187 17 0 +505 right_hippocampal_fissure 153 221 238 0 +506 right_CA4_DG 51 17 17 0 +507 right_subiculum 0 119 85 0 +508 right_fornix 20 100 200 0 + +550 left_CA2_3 17 85 137 0 +551 left_alveus 119 187 103 0 +552 left_CA1 204 68 35 0 +553 left_fimbria 204 0 254 0 +554 left_presubiculum 221 187 16 0 +555 left_hippocampal_fissure 153 221 239 0 +556 left_CA4_DG 51 17 18 0 +557 left_subiculum 0 119 86 0 +558 left_fornix 20 100 201 0 + +600 Tumor 254 254 254 0 + + +# Cerebellar parcellation labels from SUIT (matches labels in cma.h) +#No. Label Name: R G B A +601 Cbm_Left_I_IV 70 130 180 0 +602 Cbm_Right_I_IV 245 245 245 0 +603 Cbm_Left_V 205 62 78 0 +604 Cbm_Right_V 120 18 134 0 +605 Cbm_Left_VI 196 58 250 0 +606 Cbm_Vermis_VI 0 148 0 0 +607 Cbm_Right_VI 220 248 164 0 +608 Cbm_Left_CrusI 230 148 34 0 +609 Cbm_Vermis_CrusI 0 118 14 0 +610 Cbm_Right_CrusI 0 118 14 0 +611 Cbm_Left_CrusII 122 186 220 0 +612 Cbm_Vermis_CrusII 236 13 176 0 +613 Cbm_Right_CrusII 12 48 255 0 +614 Cbm_Left_VIIb 204 182 142 0 +615 Cbm_Vermis_VIIb 42 204 164 0 +616 Cbm_Right_VIIb 119 159 176 0 +617 Cbm_Left_VIIIa 220 216 20 0 +618 Cbm_Vermis_VIIIa 103 255 255 0 +619 Cbm_Right_VIIIa 80 196 98 0 +620 Cbm_Left_VIIIb 60 58 210 0 +621 Cbm_Vermis_VIIIb 60 58 210 0 +622 Cbm_Right_VIIIb 60 58 210 0 +623 Cbm_Left_IX 60 58 210 0 +624 Cbm_Vermis_IX 60 60 60 0 +625 Cbm_Right_IX 255 165 0 0 +626 Cbm_Left_X 255 165 0 0 +627 Cbm_Vermis_X 0 255 127 0 +628 Cbm_Right_X 165 42 42 0 + +# Cerebellar lobule parcellations +640 Cbm_Right_I_V_med 204 0 0 0 +641 Cbm_Right_I_V_mid 255 0 0 0 +642 Cbm_Right_VI_med 0 0 255 0 +643 Cbm_Right_VI_mid 30 144 255 0 +644 Cbm_Right_VI_lat 100 212 237 0 +645 Cbm_Right_CrusI_med 218 165 32 0 +646 Cbm_Right_CrusI_mid 255 215 0 0 +647 Cbm_Right_CrusI_lat 255 255 166 0 +648 Cbm_Right_CrusII_med 153 0 204 0 +649 Cbm_Right_CrusII_mid 153 141 209 0 +650 Cbm_Right_CrusII_lat 204 204 255 0 +651 Cbm_Right_7med 31 212 194 0 +652 Cbm_Right_7mid 3 255 237 0 +653 Cbm_Right_7lat 204 255 255 0 +654 Cbm_Right_8med 86 74 147 0 +655 Cbm_Right_8mid 114 114 190 0 +656 Cbm_Right_8lat 184 178 255 0 +657 Cbm_Right_PUNs 126 138 37 0 +658 Cbm_Right_TONs 189 197 117 0 +659 Cbm_Right_FLOs 240 230 140 0 +660 Cbm_Left_I_V_med 204 0 0 0 +661 Cbm_Left_I_V_mid 255 0 0 0 +662 Cbm_Left_VI_med 0 0 255 0 +663 Cbm_Left_VI_mid 30 144 255 0 +664 Cbm_Left_VI_lat 100 212 237 0 +665 Cbm_Left_CrusI_med 218 165 32 0 +666 Cbm_Left_CrusI_mid 255 215 0 0 +667 Cbm_Left_CrusI_lat 255 255 166 0 +668 Cbm_Left_CrusII_med 153 0 204 0 +669 Cbm_Left_CrusII_mid 153 141 209 0 +670 Cbm_Left_CrusII_lat 204 204 255 0 +671 Cbm_Left_7med 31 212 194 0 +672 Cbm_Left_7mid 3 255 237 0 +673 Cbm_Left_7lat 204 255 255 0 +674 Cbm_Left_8med 86 74 147 0 +675 Cbm_Left_8mid 114 114 190 0 +676 Cbm_Left_8lat 184 178 255 0 +677 Cbm_Left_PUNs 126 138 37 0 +678 Cbm_Left_TONs 189 197 117 0 +679 Cbm_Left_FLOs 240 230 140 0 + +701 CSF-FSL-FAST 120 18 134 0 +702 GrayMatter-FSL-FAST 205 62 78 0 +703 WhiteMatter-FSL-FAST 0 225 0 0 + +999 SUSPICIOUS 255 100 100 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg in which the aseg cortex label is replaced +# by the labels in the aparc. It also supports wm labels that will +# eventually be created by mri_aparc2aseg. Otherwise, the aseg labels +# do not change from above. The cortical labels are the same as in +# colortable_desikan_killiany.txt, except that left hemisphere has +# 1000 added to the index and the right has 2000 added. The label +# names are also prepended with ctx-lh or ctx-rh. The white matter +# labels are the same as in colortable_desikan_killiany.txt, except +# that left hemisphere has 3000 added to the index and the right has +# 4000 added. The label names are also prepended with wm-lh or wm-rh. +# Centrum semiovale is also labeled with 5001 (left) and 5002 (right). +# Even further below are the color tables for aparc.a2005s and aparc.a2009s. + +#No. Label Name: R G B A +1000 ctx-lh-unknown 25 5 25 0 +1001 ctx-lh-bankssts 25 100 40 0 +1002 ctx-lh-caudalanteriorcingulate 125 100 160 0 +1003 ctx-lh-caudalmiddlefrontal 100 25 0 0 +1004 ctx-lh-corpuscallosum 120 70 50 0 +1005 ctx-lh-cuneus 220 20 100 0 +1006 ctx-lh-entorhinal 220 20 10 0 +1007 ctx-lh-fusiform 180 220 140 0 +1008 ctx-lh-inferiorparietal 220 60 220 0 +1009 ctx-lh-inferiortemporal 180 40 120 0 +1010 ctx-lh-isthmuscingulate 140 20 140 0 +1011 ctx-lh-lateraloccipital 20 30 140 0 +1012 ctx-lh-lateralorbitofrontal 35 75 50 0 +1013 ctx-lh-lingual 225 140 140 0 +1014 ctx-lh-medialorbitofrontal 200 35 75 0 +1015 ctx-lh-middletemporal 160 100 50 0 +1016 ctx-lh-parahippocampal 20 220 60 0 +1017 ctx-lh-paracentral 60 220 60 0 +1018 ctx-lh-parsopercularis 220 180 140 0 +1019 ctx-lh-parsorbitalis 20 100 50 0 +1020 ctx-lh-parstriangularis 220 60 20 0 +1021 ctx-lh-pericalcarine 120 100 60 0 +1022 ctx-lh-postcentral 220 20 20 0 +1023 ctx-lh-posteriorcingulate 220 180 220 0 +1024 ctx-lh-precentral 60 20 220 0 +1025 ctx-lh-precuneus 160 140 180 0 +1026 ctx-lh-rostralanteriorcingulate 80 20 140 0 +1027 ctx-lh-rostralmiddlefrontal 75 50 125 0 +1028 ctx-lh-superiorfrontal 20 220 160 0 +1029 ctx-lh-superiorparietal 20 180 140 0 +1030 ctx-lh-superiortemporal 140 220 220 0 +1031 ctx-lh-supramarginal 80 160 20 0 +1032 ctx-lh-frontalpole 100 0 100 0 +1033 ctx-lh-temporalpole 70 70 70 0 +1034 ctx-lh-transversetemporal 150 150 200 0 +1035 ctx-lh-insula 255 192 32 0 + +2000 ctx-rh-unknown 25 5 25 0 +2001 ctx-rh-bankssts 25 100 40 0 +2002 ctx-rh-caudalanteriorcingulate 125 100 160 0 +2003 ctx-rh-caudalmiddlefrontal 100 25 0 0 +2004 ctx-rh-corpuscallosum 120 70 50 0 +2005 ctx-rh-cuneus 220 20 100 0 +2006 ctx-rh-entorhinal 220 20 10 0 +2007 ctx-rh-fusiform 180 220 140 0 +2008 ctx-rh-inferiorparietal 220 60 220 0 +2009 ctx-rh-inferiortemporal 180 40 120 0 +2010 ctx-rh-isthmuscingulate 140 20 140 0 +2011 ctx-rh-lateraloccipital 20 30 140 0 +2012 ctx-rh-lateralorbitofrontal 35 75 50 0 +2013 ctx-rh-lingual 225 140 140 0 +2014 ctx-rh-medialorbitofrontal 200 35 75 0 +2015 ctx-rh-middletemporal 160 100 50 0 +2016 ctx-rh-parahippocampal 20 220 60 0 +2017 ctx-rh-paracentral 60 220 60 0 +2018 ctx-rh-parsopercularis 220 180 140 0 +2019 ctx-rh-parsorbitalis 20 100 50 0 +2020 ctx-rh-parstriangularis 220 60 20 0 +2021 ctx-rh-pericalcarine 120 100 60 0 +2022 ctx-rh-postcentral 220 20 20 0 +2023 ctx-rh-posteriorcingulate 220 180 220 0 +2024 ctx-rh-precentral 60 20 220 0 +2025 ctx-rh-precuneus 160 140 180 0 +2026 ctx-rh-rostralanteriorcingulate 80 20 140 0 +2027 ctx-rh-rostralmiddlefrontal 75 50 125 0 +2028 ctx-rh-superiorfrontal 20 220 160 0 +2029 ctx-rh-superiorparietal 20 180 140 0 +2030 ctx-rh-superiortemporal 140 220 220 0 +2031 ctx-rh-supramarginal 80 160 20 0 +2032 ctx-rh-frontalpole 100 0 100 0 +2033 ctx-rh-temporalpole 70 70 70 0 +2034 ctx-rh-transversetemporal 150 150 200 0 +2035 ctx-rh-insula 255 192 32 0 + +3000 wm-lh-unknown 230 250 230 0 +3001 wm-lh-bankssts 230 155 215 0 +3002 wm-lh-caudalanteriorcingulate 130 155 95 0 +3003 wm-lh-caudalmiddlefrontal 155 230 255 0 +3004 wm-lh-corpuscallosum 135 185 205 0 +3005 wm-lh-cuneus 35 235 155 0 +3006 wm-lh-entorhinal 35 235 245 0 +3007 wm-lh-fusiform 75 35 115 0 +3008 wm-lh-inferiorparietal 35 195 35 0 +3009 wm-lh-inferiortemporal 75 215 135 0 +3010 wm-lh-isthmuscingulate 115 235 115 0 +3011 wm-lh-lateraloccipital 235 225 115 0 +3012 wm-lh-lateralorbitofrontal 220 180 205 0 +3013 wm-lh-lingual 30 115 115 0 +3014 wm-lh-medialorbitofrontal 55 220 180 0 +3015 wm-lh-middletemporal 95 155 205 0 +3016 wm-lh-parahippocampal 235 35 195 0 +3017 wm-lh-paracentral 195 35 195 0 +3018 wm-lh-parsopercularis 35 75 115 0 +3019 wm-lh-parsorbitalis 235 155 205 0 +3020 wm-lh-parstriangularis 35 195 235 0 +3021 wm-lh-pericalcarine 135 155 195 0 +3022 wm-lh-postcentral 35 235 235 0 +3023 wm-lh-posteriorcingulate 35 75 35 0 +3024 wm-lh-precentral 195 235 35 0 +3025 wm-lh-precuneus 95 115 75 0 +3026 wm-lh-rostralanteriorcingulate 175 235 115 0 +3027 wm-lh-rostralmiddlefrontal 180 205 130 0 +3028 wm-lh-superiorfrontal 235 35 95 0 +3029 wm-lh-superiorparietal 235 75 115 0 +3030 wm-lh-superiortemporal 115 35 35 0 +3031 wm-lh-supramarginal 175 95 235 0 +3032 wm-lh-frontalpole 155 255 155 0 +3033 wm-lh-temporalpole 185 185 185 0 +3034 wm-lh-transversetemporal 105 105 55 0 +3035 wm-lh-insula 254 191 31 0 + +4000 wm-rh-unknown 230 250 230 0 +4001 wm-rh-bankssts 230 155 215 0 +4002 wm-rh-caudalanteriorcingulate 130 155 95 0 +4003 wm-rh-caudalmiddlefrontal 155 230 255 0 +4004 wm-rh-corpuscallosum 135 185 205 0 +4005 wm-rh-cuneus 35 235 155 0 +4006 wm-rh-entorhinal 35 235 245 0 +4007 wm-rh-fusiform 75 35 115 0 +4008 wm-rh-inferiorparietal 35 195 35 0 +4009 wm-rh-inferiortemporal 75 215 135 0 +4010 wm-rh-isthmuscingulate 115 235 115 0 +4011 wm-rh-lateraloccipital 235 225 115 0 +4012 wm-rh-lateralorbitofrontal 220 180 205 0 +4013 wm-rh-lingual 30 115 115 0 +4014 wm-rh-medialorbitofrontal 55 220 180 0 +4015 wm-rh-middletemporal 95 155 205 0 +4016 wm-rh-parahippocampal 235 35 195 0 +4017 wm-rh-paracentral 195 35 195 0 +4018 wm-rh-parsopercularis 35 75 115 0 +4019 wm-rh-parsorbitalis 235 155 205 0 +4020 wm-rh-parstriangularis 35 195 235 0 +4021 wm-rh-pericalcarine 135 155 195 0 +4022 wm-rh-postcentral 35 235 235 0 +4023 wm-rh-posteriorcingulate 35 75 35 0 +4024 wm-rh-precentral 195 235 35 0 +4025 wm-rh-precuneus 95 115 75 0 +4026 wm-rh-rostralanteriorcingulate 175 235 115 0 +4027 wm-rh-rostralmiddlefrontal 180 205 130 0 +4028 wm-rh-superiorfrontal 235 35 95 0 +4029 wm-rh-superiorparietal 235 75 115 0 +4030 wm-rh-superiortemporal 115 35 35 0 +4031 wm-rh-supramarginal 175 95 235 0 +4032 wm-rh-frontalpole 155 255 155 0 +4033 wm-rh-temporalpole 185 185 185 0 +4034 wm-rh-transversetemporal 105 105 55 0 +4035 wm-rh-insula 254 191 31 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg (with --a2005s flag) in which the aseg +# cortex label is replaced by the labels in the aparc.a2005s. The +# cortical labels are the same as in Simple_surface_labels2005.txt, +# except that left hemisphere has 1100 added to the index and the +# right has 2100 added. The label names are also prepended with +# ctx-lh or ctx-rh. The aparc.a2009s labels are further below + +#No. Label Name: R G B A +1100 ctx-lh-Unknown 0 0 0 0 +1101 ctx-lh-Corpus_callosum 50 50 50 0 +1102 ctx-lh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +1103 ctx-lh-G_cingulate-Isthmus 60 25 25 0 +1104 ctx-lh-G_cingulate-Main_part 25 60 60 0 + +1200 ctx-lh-G_cingulate-caudal_ACC 25 60 61 0 +1201 ctx-lh-G_cingulate-rostral_ACC 25 90 60 0 +1202 ctx-lh-G_cingulate-posterior 25 120 60 0 + +1205 ctx-lh-S_cingulate-caudal_ACC 25 150 60 0 +1206 ctx-lh-S_cingulate-rostral_ACC 25 180 60 0 +1207 ctx-lh-S_cingulate-posterior 25 210 60 0 + +1210 ctx-lh-S_pericallosal-caudal 25 150 90 0 +1211 ctx-lh-S_pericallosal-rostral 25 180 90 0 +1212 ctx-lh-S_pericallosal-posterior 25 210 90 0 + +1105 ctx-lh-G_cuneus 180 20 20 0 +1106 ctx-lh-G_frontal_inf-Opercular_part 220 20 100 0 +1107 ctx-lh-G_frontal_inf-Orbital_part 140 60 60 0 +1108 ctx-lh-G_frontal_inf-Triangular_part 180 220 140 0 +1109 ctx-lh-G_frontal_middle 140 100 180 0 +1110 ctx-lh-G_frontal_superior 180 20 140 0 +1111 ctx-lh-G_frontomarginal 140 20 140 0 +1112 ctx-lh-G_insular_long 21 10 10 0 +1113 ctx-lh-G_insular_short 225 140 140 0 +1114 ctx-lh-G_and_S_occipital_inferior 23 60 180 0 +1115 ctx-lh-G_occipital_middle 180 60 180 0 +1116 ctx-lh-G_occipital_superior 20 220 60 0 +1117 ctx-lh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +1118 ctx-lh-G_occipit-temp_med-Lingual_part 220 180 140 0 +1119 ctx-lh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +1120 ctx-lh-G_orbital 220 60 20 0 +1121 ctx-lh-G_paracentral 60 100 60 0 +1122 ctx-lh-G_parietal_inferior-Angular_part 20 60 220 0 +1123 ctx-lh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +1124 ctx-lh-G_parietal_superior 220 180 220 0 +1125 ctx-lh-G_postcentral 20 180 140 0 +1126 ctx-lh-G_precentral 60 140 180 0 +1127 ctx-lh-G_precuneus 25 20 140 0 +1128 ctx-lh-G_rectus 20 60 100 0 +1129 ctx-lh-G_subcallosal 60 220 20 0 +1130 ctx-lh-G_subcentral 60 20 220 0 +1131 ctx-lh-G_temporal_inferior 220 220 100 0 +1132 ctx-lh-G_temporal_middle 180 60 60 0 +1133 ctx-lh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +1134 ctx-lh-G_temp_sup-Lateral_aspect 220 60 220 0 +1135 ctx-lh-G_temp_sup-Planum_polare 65 220 60 0 +1136 ctx-lh-G_temp_sup-Planum_tempolare 25 140 20 0 +1137 ctx-lh-G_and_S_transverse_frontopolar 13 0 250 0 +1138 ctx-lh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +1139 ctx-lh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +1140 ctx-lh-Lat_Fissure-post_sgt 61 60 100 0 +1141 ctx-lh-Medial_wall 25 25 25 0 +1142 ctx-lh-Pole_occipital 140 20 60 0 +1143 ctx-lh-Pole_temporal 220 180 20 0 +1144 ctx-lh-S_calcarine 63 180 180 0 +1145 ctx-lh-S_central 221 20 10 0 +1146 ctx-lh-S_central_insula 21 220 20 0 +1147 ctx-lh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +1148 ctx-lh-S_cingulate-Marginalis_part 221 20 100 0 +1149 ctx-lh-S_circular_insula_anterior 221 60 140 0 +1150 ctx-lh-S_circular_insula_inferior 221 20 220 0 +1151 ctx-lh-S_circular_insula_superior 61 220 220 0 +1152 ctx-lh-S_collateral_transverse_ant 100 200 200 0 +1153 ctx-lh-S_collateral_transverse_post 10 200 200 0 +1154 ctx-lh-S_frontal_inferior 221 220 20 0 +1155 ctx-lh-S_frontal_middle 141 20 100 0 +1156 ctx-lh-S_frontal_superior 61 220 100 0 +1157 ctx-lh-S_frontomarginal 21 220 60 0 +1158 ctx-lh-S_intermedius_primus-Jensen 141 60 20 0 +1159 ctx-lh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +1160 ctx-lh-S_occipital_anterior 61 20 180 0 +1161 ctx-lh-S_occipital_middle_and_Lunatus 101 60 220 0 +1162 ctx-lh-S_occipital_superior_and_transversalis 21 20 140 0 +1163 ctx-lh-S_occipito-temporal_lateral 221 140 20 0 +1164 ctx-lh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +1165 ctx-lh-S_orbital-H_shapped 101 20 20 0 +1166 ctx-lh-S_orbital_lateral 221 100 20 0 +1167 ctx-lh-S_orbital_medial-Or_olfactory 181 200 20 0 +1168 ctx-lh-S_paracentral 21 180 140 0 +1169 ctx-lh-S_parieto_occipital 101 100 180 0 +1170 ctx-lh-S_pericallosal 181 220 20 0 +1171 ctx-lh-S_postcentral 21 140 200 0 +1172 ctx-lh-S_precentral-Inferior-part 21 20 240 0 +1173 ctx-lh-S_precentral-Superior-part 21 20 200 0 +1174 ctx-lh-S_subcentral_ant 61 180 60 0 +1175 ctx-lh-S_subcentral_post 61 180 250 0 +1176 ctx-lh-S_suborbital 21 20 60 0 +1177 ctx-lh-S_subparietal 101 60 60 0 +1178 ctx-lh-S_supracingulate 21 220 220 0 +1179 ctx-lh-S_temporal_inferior 21 180 180 0 +1180 ctx-lh-S_temporal_superior 223 220 60 0 +1181 ctx-lh-S_temporal_transverse 221 60 60 0 + +2100 ctx-rh-Unknown 0 0 0 0 +2101 ctx-rh-Corpus_callosum 50 50 50 0 +2102 ctx-rh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +2103 ctx-rh-G_cingulate-Isthmus 60 25 25 0 +2104 ctx-rh-G_cingulate-Main_part 25 60 60 0 + +2105 ctx-rh-G_cuneus 180 20 20 0 +2106 ctx-rh-G_frontal_inf-Opercular_part 220 20 100 0 +2107 ctx-rh-G_frontal_inf-Orbital_part 140 60 60 0 +2108 ctx-rh-G_frontal_inf-Triangular_part 180 220 140 0 +2109 ctx-rh-G_frontal_middle 140 100 180 0 +2110 ctx-rh-G_frontal_superior 180 20 140 0 +2111 ctx-rh-G_frontomarginal 140 20 140 0 +2112 ctx-rh-G_insular_long 21 10 10 0 +2113 ctx-rh-G_insular_short 225 140 140 0 +2114 ctx-rh-G_and_S_occipital_inferior 23 60 180 0 +2115 ctx-rh-G_occipital_middle 180 60 180 0 +2116 ctx-rh-G_occipital_superior 20 220 60 0 +2117 ctx-rh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +2118 ctx-rh-G_occipit-temp_med-Lingual_part 220 180 140 0 +2119 ctx-rh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +2120 ctx-rh-G_orbital 220 60 20 0 +2121 ctx-rh-G_paracentral 60 100 60 0 +2122 ctx-rh-G_parietal_inferior-Angular_part 20 60 220 0 +2123 ctx-rh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +2124 ctx-rh-G_parietal_superior 220 180 220 0 +2125 ctx-rh-G_postcentral 20 180 140 0 +2126 ctx-rh-G_precentral 60 140 180 0 +2127 ctx-rh-G_precuneus 25 20 140 0 +2128 ctx-rh-G_rectus 20 60 100 0 +2129 ctx-rh-G_subcallosal 60 220 20 0 +2130 ctx-rh-G_subcentral 60 20 220 0 +2131 ctx-rh-G_temporal_inferior 220 220 100 0 +2132 ctx-rh-G_temporal_middle 180 60 60 0 +2133 ctx-rh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +2134 ctx-rh-G_temp_sup-Lateral_aspect 220 60 220 0 +2135 ctx-rh-G_temp_sup-Planum_polare 65 220 60 0 +2136 ctx-rh-G_temp_sup-Planum_tempolare 25 140 20 0 +2137 ctx-rh-G_and_S_transverse_frontopolar 13 0 250 0 +2138 ctx-rh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +2139 ctx-rh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +2140 ctx-rh-Lat_Fissure-post_sgt 61 60 100 0 +2141 ctx-rh-Medial_wall 25 25 25 0 +2142 ctx-rh-Pole_occipital 140 20 60 0 +2143 ctx-rh-Pole_temporal 220 180 20 0 +2144 ctx-rh-S_calcarine 63 180 180 0 +2145 ctx-rh-S_central 221 20 10 0 +2146 ctx-rh-S_central_insula 21 220 20 0 +2147 ctx-rh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +2148 ctx-rh-S_cingulate-Marginalis_part 221 20 100 0 +2149 ctx-rh-S_circular_insula_anterior 221 60 140 0 +2150 ctx-rh-S_circular_insula_inferior 221 20 220 0 +2151 ctx-rh-S_circular_insula_superior 61 220 220 0 +2152 ctx-rh-S_collateral_transverse_ant 100 200 200 0 +2153 ctx-rh-S_collateral_transverse_post 10 200 200 0 +2154 ctx-rh-S_frontal_inferior 221 220 20 0 +2155 ctx-rh-S_frontal_middle 141 20 100 0 +2156 ctx-rh-S_frontal_superior 61 220 100 0 +2157 ctx-rh-S_frontomarginal 21 220 60 0 +2158 ctx-rh-S_intermedius_primus-Jensen 141 60 20 0 +2159 ctx-rh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +2160 ctx-rh-S_occipital_anterior 61 20 180 0 +2161 ctx-rh-S_occipital_middle_and_Lunatus 101 60 220 0 +2162 ctx-rh-S_occipital_superior_and_transversalis 21 20 140 0 +2163 ctx-rh-S_occipito-temporal_lateral 221 140 20 0 +2164 ctx-rh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +2165 ctx-rh-S_orbital-H_shapped 101 20 20 0 +2166 ctx-rh-S_orbital_lateral 221 100 20 0 +2167 ctx-rh-S_orbital_medial-Or_olfactory 181 200 20 0 +2168 ctx-rh-S_paracentral 21 180 140 0 +2169 ctx-rh-S_parieto_occipital 101 100 180 0 +2170 ctx-rh-S_pericallosal 181 220 20 0 +2171 ctx-rh-S_postcentral 21 140 200 0 +2172 ctx-rh-S_precentral-Inferior-part 21 20 240 0 +2173 ctx-rh-S_precentral-Superior-part 21 20 200 0 +2174 ctx-rh-S_subcentral_ant 61 180 60 0 +2175 ctx-rh-S_subcentral_post 61 180 250 0 +2176 ctx-rh-S_suborbital 21 20 60 0 +2177 ctx-rh-S_subparietal 101 60 60 0 +2178 ctx-rh-S_supracingulate 21 220 220 0 +2179 ctx-rh-S_temporal_inferior 21 180 180 0 +2180 ctx-rh-S_temporal_superior 223 220 60 0 +2181 ctx-rh-S_temporal_transverse 221 60 60 0 + + +2200 ctx-rh-G_cingulate-caudal_ACC 25 60 61 0 +2201 ctx-rh-G_cingulate-rostral_ACC 25 90 60 0 +2202 ctx-rh-G_cingulate-posterior 25 120 60 0 + +2205 ctx-rh-S_cingulate-caudal_ACC 25 150 60 0 +2206 ctx-rh-S_cingulate-rostral_ACC 25 180 60 0 +2207 ctx-rh-S_cingulate-posterior 25 210 60 0 + +2210 ctx-rh-S_pericallosal-caudal 25 150 90 0 +2211 ctx-rh-S_pericallosal-rostral 25 180 90 0 +2212 ctx-rh-S_pericallosal-posterior 25 210 90 0 + +3100 wm-lh-Unknown 0 0 0 0 +3101 wm-lh-Corpus_callosum 50 50 50 0 +3102 wm-lh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +3103 wm-lh-G_cingulate-Isthmus 60 25 25 0 +3104 wm-lh-G_cingulate-Main_part 25 60 60 0 +3105 wm-lh-G_cuneus 180 20 20 0 +3106 wm-lh-G_frontal_inf-Opercular_part 220 20 100 0 +3107 wm-lh-G_frontal_inf-Orbital_part 140 60 60 0 +3108 wm-lh-G_frontal_inf-Triangular_part 180 220 140 0 +3109 wm-lh-G_frontal_middle 140 100 180 0 +3110 wm-lh-G_frontal_superior 180 20 140 0 +3111 wm-lh-G_frontomarginal 140 20 140 0 +3112 wm-lh-G_insular_long 21 10 10 0 +3113 wm-lh-G_insular_short 225 140 140 0 +3114 wm-lh-G_and_S_occipital_inferior 23 60 180 0 +3115 wm-lh-G_occipital_middle 180 60 180 0 +3116 wm-lh-G_occipital_superior 20 220 60 0 +3117 wm-lh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +3118 wm-lh-G_occipit-temp_med-Lingual_part 220 180 140 0 +3119 wm-lh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +3120 wm-lh-G_orbital 220 60 20 0 +3121 wm-lh-G_paracentral 60 100 60 0 +3122 wm-lh-G_parietal_inferior-Angular_part 20 60 220 0 +3123 wm-lh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +3124 wm-lh-G_parietal_superior 220 180 220 0 +3125 wm-lh-G_postcentral 20 180 140 0 +3126 wm-lh-G_precentral 60 140 180 0 +3127 wm-lh-G_precuneus 25 20 140 0 +3128 wm-lh-G_rectus 20 60 100 0 +3129 wm-lh-G_subcallosal 60 220 20 0 +3130 wm-lh-G_subcentral 60 20 220 0 +3131 wm-lh-G_temporal_inferior 220 220 100 0 +3132 wm-lh-G_temporal_middle 180 60 60 0 +3133 wm-lh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +3134 wm-lh-G_temp_sup-Lateral_aspect 220 60 220 0 +3135 wm-lh-G_temp_sup-Planum_polare 65 220 60 0 +3136 wm-lh-G_temp_sup-Planum_tempolare 25 140 20 0 +3137 wm-lh-G_and_S_transverse_frontopolar 13 0 250 0 +3138 wm-lh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +3139 wm-lh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +3140 wm-lh-Lat_Fissure-post_sgt 61 60 100 0 +3141 wm-lh-Medial_wall 25 25 25 0 +3142 wm-lh-Pole_occipital 140 20 60 0 +3143 wm-lh-Pole_temporal 220 180 20 0 +3144 wm-lh-S_calcarine 63 180 180 0 +3145 wm-lh-S_central 221 20 10 0 +3146 wm-lh-S_central_insula 21 220 20 0 +3147 wm-lh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +3148 wm-lh-S_cingulate-Marginalis_part 221 20 100 0 +3149 wm-lh-S_circular_insula_anterior 221 60 140 0 +3150 wm-lh-S_circular_insula_inferior 221 20 220 0 +3151 wm-lh-S_circular_insula_superior 61 220 220 0 +3152 wm-lh-S_collateral_transverse_ant 100 200 200 0 +3153 wm-lh-S_collateral_transverse_post 10 200 200 0 +3154 wm-lh-S_frontal_inferior 221 220 20 0 +3155 wm-lh-S_frontal_middle 141 20 100 0 +3156 wm-lh-S_frontal_superior 61 220 100 0 +3157 wm-lh-S_frontomarginal 21 220 60 0 +3158 wm-lh-S_intermedius_primus-Jensen 141 60 20 0 +3159 wm-lh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +3160 wm-lh-S_occipital_anterior 61 20 180 0 +3161 wm-lh-S_occipital_middle_and_Lunatus 101 60 220 0 +3162 wm-lh-S_occipital_superior_and_transversalis 21 20 140 0 +3163 wm-lh-S_occipito-temporal_lateral 221 140 20 0 +3164 wm-lh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +3165 wm-lh-S_orbital-H_shapped 101 20 20 0 +3166 wm-lh-S_orbital_lateral 221 100 20 0 +3167 wm-lh-S_orbital_medial-Or_olfactory 181 200 20 0 +3168 wm-lh-S_paracentral 21 180 140 0 +3169 wm-lh-S_parieto_occipital 101 100 180 0 +3170 wm-lh-S_pericallosal 181 220 20 0 +3171 wm-lh-S_postcentral 21 140 200 0 +3172 wm-lh-S_precentral-Inferior-part 21 20 240 0 +3173 wm-lh-S_precentral-Superior-part 21 20 200 0 +3174 wm-lh-S_subcentral_ant 61 180 60 0 +3175 wm-lh-S_subcentral_post 61 180 250 0 +3176 wm-lh-S_suborbital 21 20 60 0 +3177 wm-lh-S_subparietal 101 60 60 0 +3178 wm-lh-S_supracingulate 21 220 220 0 +3179 wm-lh-S_temporal_inferior 21 180 180 0 +3180 wm-lh-S_temporal_superior 223 220 60 0 +3181 wm-lh-S_temporal_transverse 221 60 60 0 + +4100 wm-rh-Unknown 0 0 0 0 +4101 wm-rh-Corpus_callosum 50 50 50 0 +4102 wm-rh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +4103 wm-rh-G_cingulate-Isthmus 60 25 25 0 +4104 wm-rh-G_cingulate-Main_part 25 60 60 0 +4105 wm-rh-G_cuneus 180 20 20 0 +4106 wm-rh-G_frontal_inf-Opercular_part 220 20 100 0 +4107 wm-rh-G_frontal_inf-Orbital_part 140 60 60 0 +4108 wm-rh-G_frontal_inf-Triangular_part 180 220 140 0 +4109 wm-rh-G_frontal_middle 140 100 180 0 +4110 wm-rh-G_frontal_superior 180 20 140 0 +4111 wm-rh-G_frontomarginal 140 20 140 0 +4112 wm-rh-G_insular_long 21 10 10 0 +4113 wm-rh-G_insular_short 225 140 140 0 +4114 wm-rh-G_and_S_occipital_inferior 23 60 180 0 +4115 wm-rh-G_occipital_middle 180 60 180 0 +4116 wm-rh-G_occipital_superior 20 220 60 0 +4117 wm-rh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +4118 wm-rh-G_occipit-temp_med-Lingual_part 220 180 140 0 +4119 wm-rh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +4120 wm-rh-G_orbital 220 60 20 0 +4121 wm-rh-G_paracentral 60 100 60 0 +4122 wm-rh-G_parietal_inferior-Angular_part 20 60 220 0 +4123 wm-rh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +4124 wm-rh-G_parietal_superior 220 180 220 0 +4125 wm-rh-G_postcentral 20 180 140 0 +4126 wm-rh-G_precentral 60 140 180 0 +4127 wm-rh-G_precuneus 25 20 140 0 +4128 wm-rh-G_rectus 20 60 100 0 +4129 wm-rh-G_subcallosal 60 220 20 0 +4130 wm-rh-G_subcentral 60 20 220 0 +4131 wm-rh-G_temporal_inferior 220 220 100 0 +4132 wm-rh-G_temporal_middle 180 60 60 0 +4133 wm-rh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +4134 wm-rh-G_temp_sup-Lateral_aspect 220 60 220 0 +4135 wm-rh-G_temp_sup-Planum_polare 65 220 60 0 +4136 wm-rh-G_temp_sup-Planum_tempolare 25 140 20 0 +4137 wm-rh-G_and_S_transverse_frontopolar 13 0 250 0 +4138 wm-rh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +4139 wm-rh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +4140 wm-rh-Lat_Fissure-post_sgt 61 60 100 0 +4141 wm-rh-Medial_wall 25 25 25 0 +4142 wm-rh-Pole_occipital 140 20 60 0 +4143 wm-rh-Pole_temporal 220 180 20 0 +4144 wm-rh-S_calcarine 63 180 180 0 +4145 wm-rh-S_central 221 20 10 0 +4146 wm-rh-S_central_insula 21 220 20 0 +4147 wm-rh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +4148 wm-rh-S_cingulate-Marginalis_part 221 20 100 0 +4149 wm-rh-S_circular_insula_anterior 221 60 140 0 +4150 wm-rh-S_circular_insula_inferior 221 20 220 0 +4151 wm-rh-S_circular_insula_superior 61 220 220 0 +4152 wm-rh-S_collateral_transverse_ant 100 200 200 0 +4153 wm-rh-S_collateral_transverse_post 10 200 200 0 +4154 wm-rh-S_frontal_inferior 221 220 20 0 +4155 wm-rh-S_frontal_middle 141 20 100 0 +4156 wm-rh-S_frontal_superior 61 220 100 0 +4157 wm-rh-S_frontomarginal 21 220 60 0 +4158 wm-rh-S_intermedius_primus-Jensen 141 60 20 0 +4159 wm-rh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +4160 wm-rh-S_occipital_anterior 61 20 180 0 +4161 wm-rh-S_occipital_middle_and_Lunatus 101 60 220 0 +4162 wm-rh-S_occipital_superior_and_transversalis 21 20 140 0 +4163 wm-rh-S_occipito-temporal_lateral 221 140 20 0 +4164 wm-rh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +4165 wm-rh-S_orbital-H_shapped 101 20 20 0 +4166 wm-rh-S_orbital_lateral 221 100 20 0 +4167 wm-rh-S_orbital_medial-Or_olfactory 181 200 20 0 +4168 wm-rh-S_paracentral 21 180 140 0 +4169 wm-rh-S_parieto_occipital 101 100 180 0 +4170 wm-rh-S_pericallosal 181 220 20 0 +4171 wm-rh-S_postcentral 21 140 200 0 +4172 wm-rh-S_precentral-Inferior-part 21 20 240 0 +4173 wm-rh-S_precentral-Superior-part 21 20 200 0 +4174 wm-rh-S_subcentral_ant 61 180 60 0 +4175 wm-rh-S_subcentral_post 61 180 250 0 +4176 wm-rh-S_suborbital 21 20 60 0 +4177 wm-rh-S_subparietal 101 60 60 0 +4178 wm-rh-S_supracingulate 21 220 220 0 +4179 wm-rh-S_temporal_inferior 21 180 180 0 +4180 wm-rh-S_temporal_superior 223 220 60 0 +4181 wm-rh-S_temporal_transverse 221 60 60 0 + +5001 Left-UnsegmentedWhiteMatter 20 30 40 0 +5002 Right-UnsegmentedWhiteMatter 20 30 40 0 + +# Below is the color table for white-matter pathways produced by dmri_paths + +#No. Label Name: R G B A +# +5100 fmajor 204 102 102 0 +5101 fminor 204 102 102 0 +# +5102 lh.atr 255 255 102 0 +5103 lh.cab 153 204 0 0 +5104 lh.ccg 0 153 153 0 +5105 lh.cst 204 153 255 0 +5106 lh.ilf 255 153 51 0 +5107 lh.slfp 204 204 204 0 +5108 lh.slft 153 255 255 0 +5109 lh.unc 102 153 255 0 +# +5110 rh.atr 255 255 102 0 +5111 rh.cab 153 204 0 0 +5112 rh.ccg 0 153 153 0 +5113 rh.cst 204 153 255 0 +5114 rh.ilf 255 153 51 0 +5115 rh.slfp 204 204 204 0 +5116 rh.slft 153 255 255 0 +5117 rh.unc 102 153 255 0 + +# These are the same tracula labels as above in human-readable form +5200 CC-ForcepsMajor 204 102 102 0 +5201 CC-ForcepsMinor 204 102 102 0 +5202 LAntThalRadiation 255 255 102 0 +5203 LCingulumAngBundle 153 204 0 0 +5204 LCingulumCingGyrus 0 153 153 0 +5205 LCorticospinalTract 204 153 255 0 +5206 LInfLongFas 255 153 51 0 +5207 LSupLongFasParietal 204 204 204 0 +5208 LSupLongFasTemporal 153 255 255 0 +5209 LUncinateFas 102 153 255 0 +5210 RAntThalRadiation 255 255 102 0 +5211 RCingulumAngBundle 153 204 0 0 +5212 RCingulumCingGyrus 0 153 153 0 +5213 RCorticospinalTract 204 153 255 0 +5214 RInfLongFas 255 153 51 0 +5215 RSupLongFasParietal 204 204 204 0 +5216 RSupLongFasTemporal 153 255 255 0 +5217 RUncinateFas 102 153 255 0 + +######################################## + +6000 CST-orig 0 255 0 0 +6001 CST-hammer 255 255 0 0 +6002 CST-CVS 0 255 255 0 +6003 CST-flirt 0 0 255 0 + +6010 Left-SLF1 236 16 231 0 +6020 Right-SLF1 237 18 232 0 + +6030 Left-SLF3 236 13 227 0 +6040 Right-SLF3 236 17 228 0 + +6050 Left-CST 1 255 1 0 +6060 Right-CST 2 255 1 0 + +6070 Left-SLF2 236 14 230 0 +6080 Right-SLF2 237 14 230 0 + +#No. Label Name: R G B A + +7001 Lateral-nucleus 72 132 181 0 +7002 Basolateral-nucleus 243 243 243 0 +7003 Basal-nucleus 207 63 79 0 +7004 Centromedial-nucleus 121 20 135 0 +7005 Central-nucleus 197 60 248 0 +7006 Medial-nucleus 2 149 2 0 +7007 Cortical-nucleus 221 249 166 0 +7008 Accessory-Basal-nucleus 232 146 35 0 +7009 Corticoamygdaloid-transitio 20 60 120 0 +7010 Anterior-amygdaloid-area-AAA 250 250 0 0 +7011 Fusion-amygdala-HP-FAH 122 187 222 0 +7012 Hippocampal-amygdala-transition-HATA 237 12 177 0 +7013 Endopiriform-nucleus 10 49 255 0 +7014 Lateral-nucleus-olfactory-tract 205 184 144 0 +7015 Paralaminar-nucleus 45 205 165 0 +7016 Intercalated-nucleus 117 160 175 0 +7017 Prepiriform-cortex 221 217 21 0 +7018 Periamygdaloid-cortex 20 60 120 0 +7019 Envelope-Amygdala 141 21 100 0 +7020 Extranuclear-Amydala 225 140 141 0 + +7100 Brainstem-inferior-colliculus 42 201 168 0 +7101 Brainstem-cochlear-nucleus 168 104 162 0 + +8001 Thalamus-Anterior 74 130 181 0 +8002 Thalamus-Ventral-anterior 242 241 240 0 +8003 Thalamus-Lateral-dorsal 206 65 78 0 +8004 Thalamus-Lateral-posterior 120 21 133 0 +8005 Thalamus-Ventral-lateral 195 61 246 0 +8006 Thalamus-Ventral-posterior-medial 3 147 6 0 +8007 Thalamus-Ventral-posterior-lateral 220 251 163 0 +8008 Thalamus-intralaminar 232 146 33 0 +8009 Thalamus-centromedian 4 114 14 0 +8010 Thalamus-mediodorsal 121 184 220 0 +8011 Thalamus-medial 235 11 175 0 +8012 Thalamus-pulvinar 12 46 250 0 +8013 Thalamus-lateral-geniculate 203 182 143 0 +8014 Thalamus-medial-geniculate 42 204 167 0 + +# +# Labels for thalamus parcellation using probabilistic tractography. See: +# Functional--Anatomical Validation and Individual Variation of Diffusion +# Tractography-based Segmentation of the Human Thalamus; Cerebral Cortex +# January 2005;15:31--39, doi:10.1093/cercor/bhh105, Advance Access +# publication July 6, 2004 +# + +#No. Label Name: R G B A +9000 ctx-lh-prefrontal 30 5 30 0 +9001 ctx-lh-primary-motor 30 100 45 0 +9002 ctx-lh-premotor 130 100 165 0 +9003 ctx-lh-temporal 105 25 5 0 +9004 ctx-lh-posterior-parietal 125 70 55 0 +9005 ctx-lh-prim-sec-somatosensory 225 20 105 0 +9006 ctx-lh-occipital 225 20 15 0 + +9500 ctx-rh-prefrontal 30 55 30 0 +9501 ctx-rh-primary-motor 30 150 45 0 +9502 ctx-rh-premotor 130 150 165 0 +9503 ctx-rh-temporal 105 75 5 0 +9504 ctx-rh-posterior-parietal 125 120 55 0 +9505 ctx-rh-prim-sec-somatosensory 225 70 105 0 +9506 ctx-rh-occipital 225 70 15 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg (with --a2009s flag) in which the aseg +# cortex label is replaced by the labels in the aparc.a2009s. The +# cortical labels are the same as in Simple_surface_labels2009.txt, +# except that left hemisphere has 11100 added to the index and the +# right has 12100 added. The label names are also prepended with +# ctx_lh_, ctx_rh_, wm_lh_ and wm_rh_ (note usage of _ instead of - +# to differentiate from a2005s labels). + +#No. Label Name: R G B A +11100 ctx_lh_Unknown 0 0 0 0 +11101 ctx_lh_G_and_S_frontomargin 23 220 60 0 +11102 ctx_lh_G_and_S_occipital_inf 23 60 180 0 +11103 ctx_lh_G_and_S_paracentral 63 100 60 0 +11104 ctx_lh_G_and_S_subcentral 63 20 220 0 +11105 ctx_lh_G_and_S_transv_frontopol 13 0 250 0 +11106 ctx_lh_G_and_S_cingul-Ant 26 60 0 0 +11107 ctx_lh_G_and_S_cingul-Mid-Ant 26 60 75 0 +11108 ctx_lh_G_and_S_cingul-Mid-Post 26 60 150 0 +11109 ctx_lh_G_cingul-Post-dorsal 25 60 250 0 +11110 ctx_lh_G_cingul-Post-ventral 60 25 25 0 +11111 ctx_lh_G_cuneus 180 20 20 0 +11112 ctx_lh_G_front_inf-Opercular 220 20 100 0 +11113 ctx_lh_G_front_inf-Orbital 140 60 60 0 +11114 ctx_lh_G_front_inf-Triangul 180 220 140 0 +11115 ctx_lh_G_front_middle 140 100 180 0 +11116 ctx_lh_G_front_sup 180 20 140 0 +11117 ctx_lh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +11118 ctx_lh_G_insular_short 225 140 140 0 +11119 ctx_lh_G_occipital_middle 180 60 180 0 +11120 ctx_lh_G_occipital_sup 20 220 60 0 +11121 ctx_lh_G_oc-temp_lat-fusifor 60 20 140 0 +11122 ctx_lh_G_oc-temp_med-Lingual 220 180 140 0 +11123 ctx_lh_G_oc-temp_med-Parahip 65 100 20 0 +11124 ctx_lh_G_orbital 220 60 20 0 +11125 ctx_lh_G_pariet_inf-Angular 20 60 220 0 +11126 ctx_lh_G_pariet_inf-Supramar 100 100 60 0 +11127 ctx_lh_G_parietal_sup 220 180 220 0 +11128 ctx_lh_G_postcentral 20 180 140 0 +11129 ctx_lh_G_precentral 60 140 180 0 +11130 ctx_lh_G_precuneus 25 20 140 0 +11131 ctx_lh_G_rectus 20 60 100 0 +11132 ctx_lh_G_subcallosal 60 220 20 0 +11133 ctx_lh_G_temp_sup-G_T_transv 60 60 220 0 +11134 ctx_lh_G_temp_sup-Lateral 220 60 220 0 +11135 ctx_lh_G_temp_sup-Plan_polar 65 220 60 0 +11136 ctx_lh_G_temp_sup-Plan_tempo 25 140 20 0 +11137 ctx_lh_G_temporal_inf 220 220 100 0 +11138 ctx_lh_G_temporal_middle 180 60 60 0 +11139 ctx_lh_Lat_Fis-ant-Horizont 61 20 220 0 +11140 ctx_lh_Lat_Fis-ant-Vertical 61 20 60 0 +11141 ctx_lh_Lat_Fis-post 61 60 100 0 +11142 ctx_lh_Medial_wall 25 25 25 0 +11143 ctx_lh_Pole_occipital 140 20 60 0 +11144 ctx_lh_Pole_temporal 220 180 20 0 +11145 ctx_lh_S_calcarine 63 180 180 0 +11146 ctx_lh_S_central 221 20 10 0 +11147 ctx_lh_S_cingul-Marginalis 221 20 100 0 +11148 ctx_lh_S_circular_insula_ant 221 60 140 0 +11149 ctx_lh_S_circular_insula_inf 221 20 220 0 +11150 ctx_lh_S_circular_insula_sup 61 220 220 0 +11151 ctx_lh_S_collat_transv_ant 100 200 200 0 +11152 ctx_lh_S_collat_transv_post 10 200 200 0 +11153 ctx_lh_S_front_inf 221 220 20 0 +11154 ctx_lh_S_front_middle 141 20 100 0 +11155 ctx_lh_S_front_sup 61 220 100 0 +11156 ctx_lh_S_interm_prim-Jensen 141 60 20 0 +11157 ctx_lh_S_intrapariet_and_P_trans 143 20 220 0 +11158 ctx_lh_S_oc_middle_and_Lunatus 101 60 220 0 +11159 ctx_lh_S_oc_sup_and_transversal 21 20 140 0 +11160 ctx_lh_S_occipital_ant 61 20 180 0 +11161 ctx_lh_S_oc-temp_lat 221 140 20 0 +11162 ctx_lh_S_oc-temp_med_and_Lingual 141 100 220 0 +11163 ctx_lh_S_orbital_lateral 221 100 20 0 +11164 ctx_lh_S_orbital_med-olfact 181 200 20 0 +11165 ctx_lh_S_orbital-H_Shaped 101 20 20 0 +11166 ctx_lh_S_parieto_occipital 101 100 180 0 +11167 ctx_lh_S_pericallosal 181 220 20 0 +11168 ctx_lh_S_postcentral 21 140 200 0 +11169 ctx_lh_S_precentral-inf-part 21 20 240 0 +11170 ctx_lh_S_precentral-sup-part 21 20 200 0 +11171 ctx_lh_S_suborbital 21 20 60 0 +11172 ctx_lh_S_subparietal 101 60 60 0 +11173 ctx_lh_S_temporal_inf 21 180 180 0 +11174 ctx_lh_S_temporal_sup 223 220 60 0 +11175 ctx_lh_S_temporal_transverse 221 60 60 0 + +12100 ctx_rh_Unknown 0 0 0 0 +12101 ctx_rh_G_and_S_frontomargin 23 220 60 0 +12102 ctx_rh_G_and_S_occipital_inf 23 60 180 0 +12103 ctx_rh_G_and_S_paracentral 63 100 60 0 +12104 ctx_rh_G_and_S_subcentral 63 20 220 0 +12105 ctx_rh_G_and_S_transv_frontopol 13 0 250 0 +12106 ctx_rh_G_and_S_cingul-Ant 26 60 0 0 +12107 ctx_rh_G_and_S_cingul-Mid-Ant 26 60 75 0 +12108 ctx_rh_G_and_S_cingul-Mid-Post 26 60 150 0 +12109 ctx_rh_G_cingul-Post-dorsal 25 60 250 0 +12110 ctx_rh_G_cingul-Post-ventral 60 25 25 0 +12111 ctx_rh_G_cuneus 180 20 20 0 +12112 ctx_rh_G_front_inf-Opercular 220 20 100 0 +12113 ctx_rh_G_front_inf-Orbital 140 60 60 0 +12114 ctx_rh_G_front_inf-Triangul 180 220 140 0 +12115 ctx_rh_G_front_middle 140 100 180 0 +12116 ctx_rh_G_front_sup 180 20 140 0 +12117 ctx_rh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +12118 ctx_rh_G_insular_short 225 140 140 0 +12119 ctx_rh_G_occipital_middle 180 60 180 0 +12120 ctx_rh_G_occipital_sup 20 220 60 0 +12121 ctx_rh_G_oc-temp_lat-fusifor 60 20 140 0 +12122 ctx_rh_G_oc-temp_med-Lingual 220 180 140 0 +12123 ctx_rh_G_oc-temp_med-Parahip 65 100 20 0 +12124 ctx_rh_G_orbital 220 60 20 0 +12125 ctx_rh_G_pariet_inf-Angular 20 60 220 0 +12126 ctx_rh_G_pariet_inf-Supramar 100 100 60 0 +12127 ctx_rh_G_parietal_sup 220 180 220 0 +12128 ctx_rh_G_postcentral 20 180 140 0 +12129 ctx_rh_G_precentral 60 140 180 0 +12130 ctx_rh_G_precuneus 25 20 140 0 +12131 ctx_rh_G_rectus 20 60 100 0 +12132 ctx_rh_G_subcallosal 60 220 20 0 +12133 ctx_rh_G_temp_sup-G_T_transv 60 60 220 0 +12134 ctx_rh_G_temp_sup-Lateral 220 60 220 0 +12135 ctx_rh_G_temp_sup-Plan_polar 65 220 60 0 +12136 ctx_rh_G_temp_sup-Plan_tempo 25 140 20 0 +12137 ctx_rh_G_temporal_inf 220 220 100 0 +12138 ctx_rh_G_temporal_middle 180 60 60 0 +12139 ctx_rh_Lat_Fis-ant-Horizont 61 20 220 0 +12140 ctx_rh_Lat_Fis-ant-Vertical 61 20 60 0 +12141 ctx_rh_Lat_Fis-post 61 60 100 0 +12142 ctx_rh_Medial_wall 25 25 25 0 +12143 ctx_rh_Pole_occipital 140 20 60 0 +12144 ctx_rh_Pole_temporal 220 180 20 0 +12145 ctx_rh_S_calcarine 63 180 180 0 +12146 ctx_rh_S_central 221 20 10 0 +12147 ctx_rh_S_cingul-Marginalis 221 20 100 0 +12148 ctx_rh_S_circular_insula_ant 221 60 140 0 +12149 ctx_rh_S_circular_insula_inf 221 20 220 0 +12150 ctx_rh_S_circular_insula_sup 61 220 220 0 +12151 ctx_rh_S_collat_transv_ant 100 200 200 0 +12152 ctx_rh_S_collat_transv_post 10 200 200 0 +12153 ctx_rh_S_front_inf 221 220 20 0 +12154 ctx_rh_S_front_middle 141 20 100 0 +12155 ctx_rh_S_front_sup 61 220 100 0 +12156 ctx_rh_S_interm_prim-Jensen 141 60 20 0 +12157 ctx_rh_S_intrapariet_and_P_trans 143 20 220 0 +12158 ctx_rh_S_oc_middle_and_Lunatus 101 60 220 0 +12159 ctx_rh_S_oc_sup_and_transversal 21 20 140 0 +12160 ctx_rh_S_occipital_ant 61 20 180 0 +12161 ctx_rh_S_oc-temp_lat 221 140 20 0 +12162 ctx_rh_S_oc-temp_med_and_Lingual 141 100 220 0 +12163 ctx_rh_S_orbital_lateral 221 100 20 0 +12164 ctx_rh_S_orbital_med-olfact 181 200 20 0 +12165 ctx_rh_S_orbital-H_Shaped 101 20 20 0 +12166 ctx_rh_S_parieto_occipital 101 100 180 0 +12167 ctx_rh_S_pericallosal 181 220 20 0 +12168 ctx_rh_S_postcentral 21 140 200 0 +12169 ctx_rh_S_precentral-inf-part 21 20 240 0 +12170 ctx_rh_S_precentral-sup-part 21 20 200 0 +12171 ctx_rh_S_suborbital 21 20 60 0 +12172 ctx_rh_S_subparietal 101 60 60 0 +12173 ctx_rh_S_temporal_inf 21 180 180 0 +12174 ctx_rh_S_temporal_sup 223 220 60 0 +12175 ctx_rh_S_temporal_transverse 221 60 60 0 + +#No. Label Name: R G B A +13100 wm_lh_Unknown 0 0 0 0 +13101 wm_lh_G_and_S_frontomargin 23 220 60 0 +13102 wm_lh_G_and_S_occipital_inf 23 60 180 0 +13103 wm_lh_G_and_S_paracentral 63 100 60 0 +13104 wm_lh_G_and_S_subcentral 63 20 220 0 +13105 wm_lh_G_and_S_transv_frontopol 13 0 250 0 +13106 wm_lh_G_and_S_cingul-Ant 26 60 0 0 +13107 wm_lh_G_and_S_cingul-Mid-Ant 26 60 75 0 +13108 wm_lh_G_and_S_cingul-Mid-Post 26 60 150 0 +13109 wm_lh_G_cingul-Post-dorsal 25 60 250 0 +13110 wm_lh_G_cingul-Post-ventral 60 25 25 0 +13111 wm_lh_G_cuneus 180 20 20 0 +13112 wm_lh_G_front_inf-Opercular 220 20 100 0 +13113 wm_lh_G_front_inf-Orbital 140 60 60 0 +13114 wm_lh_G_front_inf-Triangul 180 220 140 0 +13115 wm_lh_G_front_middle 140 100 180 0 +13116 wm_lh_G_front_sup 180 20 140 0 +13117 wm_lh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +13118 wm_lh_G_insular_short 225 140 140 0 +13119 wm_lh_G_occipital_middle 180 60 180 0 +13120 wm_lh_G_occipital_sup 20 220 60 0 +13121 wm_lh_G_oc-temp_lat-fusifor 60 20 140 0 +13122 wm_lh_G_oc-temp_med-Lingual 220 180 140 0 +13123 wm_lh_G_oc-temp_med-Parahip 65 100 20 0 +13124 wm_lh_G_orbital 220 60 20 0 +13125 wm_lh_G_pariet_inf-Angular 20 60 220 0 +13126 wm_lh_G_pariet_inf-Supramar 100 100 60 0 +13127 wm_lh_G_parietal_sup 220 180 220 0 +13128 wm_lh_G_postcentral 20 180 140 0 +13129 wm_lh_G_precentral 60 140 180 0 +13130 wm_lh_G_precuneus 25 20 140 0 +13131 wm_lh_G_rectus 20 60 100 0 +13132 wm_lh_G_subcallosal 60 220 20 0 +13133 wm_lh_G_temp_sup-G_T_transv 60 60 220 0 +13134 wm_lh_G_temp_sup-Lateral 220 60 220 0 +13135 wm_lh_G_temp_sup-Plan_polar 65 220 60 0 +13136 wm_lh_G_temp_sup-Plan_tempo 25 140 20 0 +13137 wm_lh_G_temporal_inf 220 220 100 0 +13138 wm_lh_G_temporal_middle 180 60 60 0 +13139 wm_lh_Lat_Fis-ant-Horizont 61 20 220 0 +13140 wm_lh_Lat_Fis-ant-Vertical 61 20 60 0 +13141 wm_lh_Lat_Fis-post 61 60 100 0 +13142 wm_lh_Medial_wall 25 25 25 0 +13143 wm_lh_Pole_occipital 140 20 60 0 +13144 wm_lh_Pole_temporal 220 180 20 0 +13145 wm_lh_S_calcarine 63 180 180 0 +13146 wm_lh_S_central 221 20 10 0 +13147 wm_lh_S_cingul-Marginalis 221 20 100 0 +13148 wm_lh_S_circular_insula_ant 221 60 140 0 +13149 wm_lh_S_circular_insula_inf 221 20 220 0 +13150 wm_lh_S_circular_insula_sup 61 220 220 0 +13151 wm_lh_S_collat_transv_ant 100 200 200 0 +13152 wm_lh_S_collat_transv_post 10 200 200 0 +13153 wm_lh_S_front_inf 221 220 20 0 +13154 wm_lh_S_front_middle 141 20 100 0 +13155 wm_lh_S_front_sup 61 220 100 0 +13156 wm_lh_S_interm_prim-Jensen 141 60 20 0 +13157 wm_lh_S_intrapariet_and_P_trans 143 20 220 0 +13158 wm_lh_S_oc_middle_and_Lunatus 101 60 220 0 +13159 wm_lh_S_oc_sup_and_transversal 21 20 140 0 +13160 wm_lh_S_occipital_ant 61 20 180 0 +13161 wm_lh_S_oc-temp_lat 221 140 20 0 +13162 wm_lh_S_oc-temp_med_and_Lingual 141 100 220 0 +13163 wm_lh_S_orbital_lateral 221 100 20 0 +13164 wm_lh_S_orbital_med-olfact 181 200 20 0 +13165 wm_lh_S_orbital-H_Shaped 101 20 20 0 +13166 wm_lh_S_parieto_occipital 101 100 180 0 +13167 wm_lh_S_pericallosal 181 220 20 0 +13168 wm_lh_S_postcentral 21 140 200 0 +13169 wm_lh_S_precentral-inf-part 21 20 240 0 +13170 wm_lh_S_precentral-sup-part 21 20 200 0 +13171 wm_lh_S_suborbital 21 20 60 0 +13172 wm_lh_S_subparietal 101 60 60 0 +13173 wm_lh_S_temporal_inf 21 180 180 0 +13174 wm_lh_S_temporal_sup 223 220 60 0 +13175 wm_lh_S_temporal_transverse 221 60 60 0 + +14100 wm_rh_Unknown 0 0 0 0 +14101 wm_rh_G_and_S_frontomargin 23 220 60 0 +14102 wm_rh_G_and_S_occipital_inf 23 60 180 0 +14103 wm_rh_G_and_S_paracentral 63 100 60 0 +14104 wm_rh_G_and_S_subcentral 63 20 220 0 +14105 wm_rh_G_and_S_transv_frontopol 13 0 250 0 +14106 wm_rh_G_and_S_cingul-Ant 26 60 0 0 +14107 wm_rh_G_and_S_cingul-Mid-Ant 26 60 75 0 +14108 wm_rh_G_and_S_cingul-Mid-Post 26 60 150 0 +14109 wm_rh_G_cingul-Post-dorsal 25 60 250 0 +14110 wm_rh_G_cingul-Post-ventral 60 25 25 0 +14111 wm_rh_G_cuneus 180 20 20 0 +14112 wm_rh_G_front_inf-Opercular 220 20 100 0 +14113 wm_rh_G_front_inf-Orbital 140 60 60 0 +14114 wm_rh_G_front_inf-Triangul 180 220 140 0 +14115 wm_rh_G_front_middle 140 100 180 0 +14116 wm_rh_G_front_sup 180 20 140 0 +14117 wm_rh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +14118 wm_rh_G_insular_short 225 140 140 0 +14119 wm_rh_G_occipital_middle 180 60 180 0 +14120 wm_rh_G_occipital_sup 20 220 60 0 +14121 wm_rh_G_oc-temp_lat-fusifor 60 20 140 0 +14122 wm_rh_G_oc-temp_med-Lingual 220 180 140 0 +14123 wm_rh_G_oc-temp_med-Parahip 65 100 20 0 +14124 wm_rh_G_orbital 220 60 20 0 +14125 wm_rh_G_pariet_inf-Angular 20 60 220 0 +14126 wm_rh_G_pariet_inf-Supramar 100 100 60 0 +14127 wm_rh_G_parietal_sup 220 180 220 0 +14128 wm_rh_G_postcentral 20 180 140 0 +14129 wm_rh_G_precentral 60 140 180 0 +14130 wm_rh_G_precuneus 25 20 140 0 +14131 wm_rh_G_rectus 20 60 100 0 +14132 wm_rh_G_subcallosal 60 220 20 0 +14133 wm_rh_G_temp_sup-G_T_transv 60 60 220 0 +14134 wm_rh_G_temp_sup-Lateral 220 60 220 0 +14135 wm_rh_G_temp_sup-Plan_polar 65 220 60 0 +14136 wm_rh_G_temp_sup-Plan_tempo 25 140 20 0 +14137 wm_rh_G_temporal_inf 220 220 100 0 +14138 wm_rh_G_temporal_middle 180 60 60 0 +14139 wm_rh_Lat_Fis-ant-Horizont 61 20 220 0 +14140 wm_rh_Lat_Fis-ant-Vertical 61 20 60 0 +14141 wm_rh_Lat_Fis-post 61 60 100 0 +14142 wm_rh_Medial_wall 25 25 25 0 +14143 wm_rh_Pole_occipital 140 20 60 0 +14144 wm_rh_Pole_temporal 220 180 20 0 +14145 wm_rh_S_calcarine 63 180 180 0 +14146 wm_rh_S_central 221 20 10 0 +14147 wm_rh_S_cingul-Marginalis 221 20 100 0 +14148 wm_rh_S_circular_insula_ant 221 60 140 0 +14149 wm_rh_S_circular_insula_inf 221 20 220 0 +14150 wm_rh_S_circular_insula_sup 61 220 220 0 +14151 wm_rh_S_collat_transv_ant 100 200 200 0 +14152 wm_rh_S_collat_transv_post 10 200 200 0 +14153 wm_rh_S_front_inf 221 220 20 0 +14154 wm_rh_S_front_middle 141 20 100 0 +14155 wm_rh_S_front_sup 61 220 100 0 +14156 wm_rh_S_interm_prim-Jensen 141 60 20 0 +14157 wm_rh_S_intrapariet_and_P_trans 143 20 220 0 +14158 wm_rh_S_oc_middle_and_Lunatus 101 60 220 0 +14159 wm_rh_S_oc_sup_and_transversal 21 20 140 0 +14160 wm_rh_S_occipital_ant 61 20 180 0 +14161 wm_rh_S_oc-temp_lat 221 140 20 0 +14162 wm_rh_S_oc-temp_med_and_Lingual 141 100 220 0 +14163 wm_rh_S_orbital_lateral 221 100 20 0 +14164 wm_rh_S_orbital_med-olfact 181 200 20 0 +14165 wm_rh_S_orbital-H_Shaped 101 20 20 0 +14166 wm_rh_S_parieto_occipital 101 100 180 0 +14167 wm_rh_S_pericallosal 181 220 20 0 +14168 wm_rh_S_postcentral 21 140 200 0 +14169 wm_rh_S_precentral-inf-part 21 20 240 0 +14170 wm_rh_S_precentral-sup-part 21 20 200 0 +14171 wm_rh_S_suborbital 21 20 60 0 +14172 wm_rh_S_subparietal 101 60 60 0 +14173 wm_rh_S_temporal_inf 21 180 180 0 +14174 wm_rh_S_temporal_sup 223 220 60 0 +14175 wm_rh_S_temporal_transverse 221 60 60 0 + diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 197f4c54..9d75227f 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -1131,29 +1131,54 @@ def _show_volume_regions_dialog(self, volume_atlas: str): return atlas_path = self.volume_atlas_combo.currentData() - lut_file = self._find_volume_lut(str(atlas_path)) if atlas_path else None - if lut_file is None: + if not atlas_path: QtWidgets.QMessageBox.warning( self, - "LUT File Not Found", - f"Could not find a label table for {volume_atlas}.", + "Atlas Not Found", + f"Could not resolve a file path for {volume_atlas}.", ) return + atlas_path = str(atlas_path) + lut_file = self._find_volume_lut(atlas_path) + entries = [] - try: - with open(lut_file, "r") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - parsed = self._parse_lut_line(line) - if parsed is None: - continue - label_id, label_name, rgb = parsed - entries.append((int(label_id), label_name, rgb)) - except OSError as e: + if lut_file is not None: + try: + with open(lut_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, rgb = parsed + entries.append((int(label_id), label_name, rgb)) + except OSError as e: + QtWidgets.QMessageBox.warning( + self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" + ) + return + else: + # No sidecar LUT (e.g. a subject-space FreeSurfer atlas such as + # aparc.DKTatlas+aseg.mgz). Resolve names from the atlas volume's own + # integer labels using the bundled standard FreeSurfer color table, + # so no FreeSurfer / mri_segstats invocation is required. + try: + entries = self._resolve_volume_label_entries(atlas_path) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Error Reading Atlas", + f"Could not read labels from {volume_atlas}: {str(e)}", + ) + return + + if not entries: QtWidgets.QMessageBox.warning( - self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" + self, + "No Regions Found", + f"Could not find any labeled regions for {volume_atlas}.", ) return @@ -1194,6 +1219,93 @@ def _find_volume_lut(self, atlas_path: str) -> Path | None: return candidate return None + def _resolve_volume_label_entries( + self, atlas_path: str + ) -> list[tuple[int, str, tuple[str, str, str] | None]]: + """Resolve region entries for a volume atlas that has no sidecar LUT. + + Reads the unique nonzero integer labels present in the atlas volume via + nibabel and maps them to names/colours using the bundled standard + FreeSurfer colour table (``resources/atlas/FreeSurferColorLUT.txt``). + Only labels actually present in the volume are returned, so subject-space + FreeSurfer atlases (e.g. ``aparc.DKTatlas+aseg.mgz``) can be browsed by + name without FreeSurfer / ``mri_segstats``. + + Args: + atlas_path: Absolute path to the atlas volume (``.mgz``/``.nii``/ + ``.nii.gz``). + + Returns: + List of ``(id, name, rgb)`` entries, sorted by id, for labels found + in both the volume and the bundled colour table. ``rgb`` is a tuple + of decimal string components, or ``None`` when unavailable. + """ + import numpy as np + import nibabel as nib + + lut = self._load_freesurfer_lut() + if not lut: + return [] + + img = nib.load(atlas_path) + # np.unique on the raw dataobj is fast and avoids loading a scaled float + # copy of the volume; label maps are stored as integers. + present = np.unique(np.asarray(img.dataobj)) + + entries: list[tuple[int, str, tuple[str, str, str] | None]] = [] + for value in present: + label_id = int(value) + if label_id == 0: + continue + info = lut.get(label_id) + if info is None: + continue + name, rgb = info + entries.append((label_id, name, rgb)) + return entries + + def _load_freesurfer_lut( + self, + ) -> dict[int, tuple[str, tuple[str, str, str] | None]]: + """Parse the bundled standard FreeSurfer colour table. + + Returns: + Mapping of ``label_id -> (name, rgb)``, where ``rgb`` is a tuple of + decimal string components (or ``None`` when the row has no colour). + Empty dict when the bundled table cannot be located or read. + """ + lut_path = self._freesurfer_lut_path() + if lut_path is None: + return {} + + lut: dict[int, tuple[str, tuple[str, str, str] | None]] = {} + try: + with open(lut_path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, rgb = parsed + lut[int(label_id)] = (label_name, rgb) + except OSError: + return {} + return lut + + def _freesurfer_lut_path(self) -> str | None: + """Return the path to the bundled standard FreeSurfer colour table. + + Reuses the same resources/atlas directory resolution as the MNI atlases + (container path first, then the repo-relative fallback). + + Returns: + Absolute path to ``FreeSurferColorLUT.txt``, or ``None`` if missing. + """ + candidate = os.path.join(self._mni_atlas_dir(), "FreeSurferColorLUT.txt") + return candidate if os.path.isfile(candidate) else None + @staticmethod def _strip_nifti_suffix(filename: str) -> str: if filename.endswith(".nii.gz"): From 4a0c9c388b60bf497fa64c2c4f6a0152e77e7a9b Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 16:56:13 -0500 Subject: [PATCH 13/27] Add RegionChipsWidget reusable chips component Add tit/gui/components/region_chips.py providing RegionChipsWidget, a compact chips/tags widget that shows selected regions as removable, wrapping pills. Includes a standard Qt FlowLayout helper for the left-to-right wrapping arrangement. De-dups by opaque key, exposes add_item/remove/clear/set_items/keys and a changed signal, and shows a muted placeholder when empty. Exported from components/__init__.py. --- tit/gui/components/__init__.py | 3 + tit/gui/components/region_chips.py | 359 +++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 tit/gui/components/region_chips.py diff --git a/tit/gui/components/__init__.py b/tit/gui/components/__init__.py index d5753ee9..c3662d9e 100644 --- a/tit/gui/components/__init__.py +++ b/tit/gui/components/__init__.py @@ -14,6 +14,7 @@ from .electrode_config import ElectrodeConfigWidget from .help_icon import HelpIcon from .qsi_config_dialogs import QSIPrepConfigDialog, QSIReconConfigDialog +from .region_chips import FlowLayout, RegionChipsWidget from .roi_picker import ROIPickerWidget from .solver_params import SolverParamsWidget @@ -21,7 +22,9 @@ "AtlasRegionFinderDialog", "ConsoleWidget", "ElectrodeConfigWidget", + "FlowLayout", "HelpIcon", + "RegionChipsWidget", "ROIPickerWidget", "RunStopButtons", "SolverParamsWidget", diff --git a/tit/gui/components/region_chips.py b/tit/gui/components/region_chips.py new file mode 100644 index 00000000..d718e609 --- /dev/null +++ b/tit/gui/components/region_chips.py @@ -0,0 +1,359 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Reusable "chips/tags" widget for compact, removable region selections. + +This module provides :class:`RegionChipsWidget`, a small widget that displays a +set of selected regions as rounded pills ("chips"). Each chip shows a display +string and a "✕" remove button, and the chips wrap onto new rows when the +host is narrow. When nothing is selected, a muted placeholder is shown instead. + +A minimal Qt :class:`FlowLayout` helper (the standard wrapping layout from the +Qt examples) is implemented here so the widget has no external layout +dependency. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from PyQt5 import QtCore, QtWidgets + +from tit.gui.style import ( + COLOR_ACCENT, + COLOR_ERROR, + COLOR_TEXT_MUTED, + FONT_HELP, + SP_XS, +) + + +# --------------------------------------------------------------------------- +# FlowLayout: a layout that lays items left-to-right and wraps to new rows. +# --------------------------------------------------------------------------- +class FlowLayout(QtWidgets.QLayout): + """A layout that arranges child items in rows, wrapping as needed. + + This is the standard Qt "flow layout" helper: items are placed + left-to-right and flow onto a new row whenever the current row runs out + of horizontal space. It supports ``heightForWidth`` so hosts can size it + correctly inside vertical layouts and scroll areas. + + Args: + parent: Optional parent widget. When given, the layout installs + itself on that widget. + margin: Uniform content margin in pixels. + spacing: Gap between items (both horizontal and vertical) in pixels. + """ + + def __init__( + self, + parent: Optional[QtWidgets.QWidget] = None, + margin: int = 0, + spacing: int = SP_XS, + ) -> None: + super().__init__(parent) + self._items: List[QtWidgets.QLayoutItem] = [] + self.setContentsMargins(margin, margin, margin, margin) + self.setSpacing(spacing) + + def addItem(self, item: QtWidgets.QLayoutItem) -> None: # noqa: N802 + self._items.append(item) + + def count(self) -> int: + return len(self._items) + + def itemAt(self, index: int) -> Optional[QtWidgets.QLayoutItem]: # noqa: N802 + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index: int) -> Optional[QtWidgets.QLayoutItem]: # noqa: N802 + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def expandingDirections(self) -> QtCore.Qt.Orientations: # noqa: N802 + return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) + + def hasHeightForWidth(self) -> bool: # noqa: N802 + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 + return self._do_layout(QtCore.QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect: QtCore.QRect) -> None: # noqa: N802 + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QtCore.QSize: # noqa: N802 + return self.minimumSize() + + def minimumSize(self) -> QtCore.QSize: # noqa: N802 + size = QtCore.QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + size += QtCore.QSize( + margins.left() + margins.right(), + margins.top() + margins.bottom(), + ) + return size + + def _do_layout(self, rect: QtCore.QRect, test_only: bool) -> int: + """Position items within ``rect`` and return the total height. + + Args: + rect: The rectangle to lay items out in. + test_only: When ``True``, only compute the required height without + moving any widgets. + + Returns: + The total height required for the laid-out content. + """ + margins = self.contentsMargins() + effective = rect.adjusted( + margins.left(), + margins.top(), + -margins.right(), + -margins.bottom(), + ) + x = effective.x() + y = effective.y() + line_height = 0 + spacing = self.spacing() + + for item in self._items: + hint = item.sizeHint() + next_x = x + hint.width() + spacing + if next_x - spacing > effective.right() and line_height > 0: + x = effective.x() + y = y + line_height + spacing + next_x = x + hint.width() + spacing + line_height = 0 + if not test_only: + item.setGeometry(QtCore.QRect(QtCore.QPoint(x, y), hint)) + x = next_x + line_height = max(line_height, hint.height()) + + return y + line_height - rect.y() + margins.bottom() + + +# --------------------------------------------------------------------------- +# Chip styling +# --------------------------------------------------------------------------- +_CHIP_STYLE = f""" +QFrame#regionChip {{ + background-color: #eef3f8; + border: 1px solid #ccd7e0; + border-radius: 9px; +}} +QFrame#regionChip:hover {{ + border-color: {COLOR_ACCENT}; +}} +""" + +_CHIP_LABEL_STYLE = ( + f"border: none; background: transparent; font-size: {FONT_HELP}; color: #333;" +) + +_CHIP_REMOVE_STYLE = f""" +QToolButton {{ + border: none; + background: transparent; + color: {COLOR_TEXT_MUTED}; + font-size: {FONT_HELP}; + font-weight: bold; + padding: 0px; +}} +QToolButton:hover {{ + color: {COLOR_ERROR}; +}} +""" + + +class _ChipWidget(QtWidgets.QFrame): + """A single rounded pill showing a display string and a remove button. + + Args: + key: Opaque identifier carried by the chip (emitted on removal). + display: Human-readable text shown on the chip. + parent: Optional parent widget. + """ + + removed = QtCore.pyqtSignal(str) + + def __init__( + self, + key: str, + display: str, + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__(parent) + self._key = key + self.setObjectName("regionChip") + self.setStyleSheet(_CHIP_STYLE) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(SP_XS + 2, 2, SP_XS, 2) + layout.setSpacing(SP_XS) + + label = QtWidgets.QLabel(display) + label.setStyleSheet(_CHIP_LABEL_STYLE) + label.setToolTip(display) + layout.addWidget(label) + + remove_btn = QtWidgets.QToolButton() + remove_btn.setText("✕") + remove_btn.setToolTip(f"Remove {display}") + remove_btn.setCursor(QtCore.Qt.PointingHandCursor) + remove_btn.setFocusPolicy(QtCore.Qt.NoFocus) + remove_btn.setFixedSize(14, 14) + remove_btn.setStyleSheet(_CHIP_REMOVE_STYLE) + remove_btn.clicked.connect(lambda: self.removed.emit(self._key)) + layout.addWidget(remove_btn) + + @property + def key(self) -> str: + """The opaque key this chip represents.""" + return self._key + + +class RegionChipsWidget(QtWidgets.QWidget): + """Compact, removable display of selected regions as wrapping chips. + + Each chip carries an opaque ``key`` plus its ``display`` text. Adding a + key that is already present is a no-op (de-duplication by key). Chips are + laid out left-to-right and wrap onto new rows via :class:`FlowLayout`. + When empty, a muted placeholder is shown instead. + + Signals: + changed: Emitted whenever the set of chips changes (add/remove/clear). + + Args: + parent: Optional parent widget. + placeholder: Text shown when no regions are selected. Falls back to a + sensible default when omitted. + """ + + changed = QtCore.pyqtSignal() + + _PLACEHOLDER_TEXT = "No regions selected — use Browse…" + + def __init__( + self, + parent: Optional[QtWidgets.QWidget] = None, + placeholder: Optional[str] = None, + ) -> None: + super().__init__(parent) + self._placeholder_text = placeholder or self._PLACEHOLDER_TEXT + # Insertion-ordered mapping of key -> display text. + self._items: "dict[str, str]" = {} + + self._flow = FlowLayout(self, margin=0, spacing=SP_XS) + + self._placeholder = QtWidgets.QLabel(self._placeholder_text) + self._placeholder.setStyleSheet( + f"color: {COLOR_TEXT_MUTED}; font-size: {FONT_HELP}; font-style: italic;" + ) + + # Keep at least ~1 row visible even when empty. + self.setMinimumHeight(24) + policy = self.sizePolicy() + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + + self._rebuild() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def add_item(self, key: str, display: str) -> None: + """Add a chip. Adding an existing key is a no-op. + + Args: + key: Opaque identifier for the chip. + display: Text shown on the chip. + """ + if key in self._items: + return + self._items[key] = display + self._rebuild() + self.changed.emit() + + def remove(self, key: str) -> None: + """Remove the chip with ``key`` if present. + + Args: + key: The key of the chip to remove. + """ + if key not in self._items: + return + del self._items[key] + self._rebuild() + self.changed.emit() + + def clear(self) -> None: + """Remove all chips. No-op (and no signal) when already empty.""" + if not self._items: + return + self._items.clear() + self._rebuild() + self.changed.emit() + + def set_items(self, items: List[Tuple[str, str]]) -> None: + """Replace all chips with ``items``. + + Later duplicates of the same key overwrite the earlier display text + while preserving the key's original position. + + Args: + items: Sequence of ``(key, display)`` tuples. + """ + new_items: "dict[str, str]" = {} + for key, display in items: + new_items[key] = display + self._items = new_items + self._rebuild() + self.changed.emit() + + def keys(self) -> List[str]: + """Return the chip keys in insertion order.""" + return list(self._items.keys()) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _clear_layout(self) -> None: + """Detach every item from the flow layout, deleting spent chips.""" + while self._flow.count(): + item = self._flow.takeAt(0) + widget = item.widget() if item is not None else None + if widget is None: + continue + widget.setParent(None) + if widget is not self._placeholder: + widget.deleteLater() + + def _rebuild(self) -> None: + """Rebuild the flow layout from the current item mapping.""" + self._clear_layout() + if not self._items: + self._flow.addWidget(self._placeholder) + self._placeholder.setVisible(True) + else: + for key, display in self._items.items(): + chip = _ChipWidget(key, display, self) + chip.removed.connect(self.remove) + self._flow.addWidget(chip) + self._flow.invalidate() + self.updateGeometry() + + # ------------------------------------------------------------------ + # Height-for-width plumbing so hosts size the widget correctly. + # ------------------------------------------------------------------ + def hasHeightForWidth(self) -> bool: # noqa: N802 + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 + return self._flow.heightForWidth(width) From 6c523a29eccf08bd8ac31e5111e402726f6c9dd3 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 17:02:05 -0500 Subject: [PATCH 14/27] roi_picker: use RegionChipsWidget as source of truth for cortical/subcortical ROIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the comma-separated QLineEdits on the cortical and subcortical ROI pages with RegionChipsWidget. Regions are now added only via the "List Regions" finder (no free-text typing) and shown as removable, de-duplicated chips. Cortical: the finder lists both hemispheres by name and adds a chip per selection with key "hemi.name" and display "hemi.name · ". get_roi_spec/validate/get_roi_params read the chip keys and resolve each to (hemisphere, index) via the atlas .annot map, preserving the AtlasROI parallel-list contract and single-region behavior. Subcortical: the finder uses Agent A's LUT/volume resolver to list present labels by name and adds a chip per selection with key "" and display " · ". get_roi_spec reads integer ids from the chip keys and builds SubcorticalROI as before. Add set_cortical_regions()/set_subcortical_regions() to restore chips from a loaded config (resolving display names/indices where possible, else showing the bare token) plus a _subcortical_id_name_map() helper. The picker is reused as the focality nonroi_picker, so both pages round-trip through chips. Remove the dead line-edit paths and the now-unused merge_into_lineedit import. --- tit/gui/components/roi_picker.py | 196 +++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 49 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 9d75227f..7af98c6c 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -14,10 +14,8 @@ from tit.paths import get_path_manager from tit.atlas import MNI_ATLAS_DIR, MeshAtlasManager, VoxelAtlasManager -from tit.gui.components.atlas_region_finder import ( - AtlasRegionFinderDialog, - merge_into_lineedit, -) +from tit.gui.components.atlas_region_finder import AtlasRegionFinderDialog +from tit.gui.components.region_chips import RegionChipsWidget from tit.opt.config import FlexConfig @@ -294,9 +292,13 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: def _build_cortical_page(self) -> QtWidgets.QWidget: """Build the cortical (atlas annotation) ROI input page. - The region field uses analyzer-style, hemisphere-prefixed region - *names* (e.g. ``lh.precentral, rh.superiorfrontal``); the hemisphere is - carried in each name, so there is no separate hemisphere selector. + Selected regions are shown as removable chips (:class:`RegionChipsWidget`) + rather than typed free-text. Each chip carries a hemisphere-prefixed + region *name* (e.g. ``lh.precentral``) as its key and displays both the + name and its ``.annot`` label index. Regions are added via the + "List Regions" finder (which lists both hemispheres by name); the + hemisphere is carried in each name, so there is no separate hemisphere + selector. """ page = QtWidgets.QWidget() layout = QtWidgets.QFormLayout(page) @@ -323,17 +325,19 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: atlas_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas:"), atlas_widget) - # Region name(s) — one hemisphere-prefixed name, or several - # comma-separated to union multiple regions into one combined target. - self.label_value_input = QtWidgets.QLineEdit() - self.label_value_input.setPlaceholderText("lh.precentral, rh.superiorfrontal") - self.label_value_input.setToolTip( - "Hemisphere-prefixed region name(s), e.g. lh.precentral. " - "Comma-separate several names to union them into one combined " - "target, e.g. lh.precentral, rh.superiorfrontal. Use 'List Regions' " - "to browse available names." + # Selected region(s) shown as removable chips. Regions are added via + # "List Regions" (both hemispheres, by name), de-duplicated by chip key + # ("hemi.name"). Multiple regions union into one combined target. + self.cortical_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" ) - layout.addRow(QtWidgets.QLabel("Region Name(s):"), self.label_value_input) + self.cortical_chips.setToolTip( + "Selected cortical regions. Use 'List Regions' to add hemisphere-" + "prefixed regions (e.g. lh.precentral); each chip shows the name and " + "its .annot label index. Multiple regions union into one combined " + "target; press ✕ on a chip to remove it." + ) + layout.addRow(QtWidgets.QLabel("Region(s):"), self.cortical_chips) return page @@ -383,20 +387,20 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: volume_layout.addStretch() layout.addRow(QtWidgets.QLabel("Volume Atlas:"), volume_widget) - # Region label value(s) — one integer, or several comma-separated to - # union multiple regions (e.g. both hippocampi) into one combined target. - self.volume_label_input = QtWidgets.QLineEdit() - self.volume_label_input.setText("10") - self.volume_label_input.setPlaceholderText("10 or 17,53") - self.volume_label_input.setToolTip( - "Integer atlas label(s). Comma-separate several labels to union them " - "into one combined target (e.g. 17,53 = both hippocampi). " - "Common values: 10=Left-Thalamus, 49=Right-Thalamus, " - "17=Left-Hippocampus, 53=Right-Hippocampus" + # Selected region(s) shown as removable chips. Regions are added via + # "List Regions" (present labels, by name), de-duplicated by chip key + # (the integer atlas label id). Multiple regions union into one target. + self.subcortical_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" ) - layout.addRow( - QtWidgets.QLabel("Region Label Value(s):"), self.volume_label_input + self.subcortical_chips.setToolTip( + "Selected subcortical regions. Use 'List Regions' to add atlas " + "labels by name (e.g. Left-Hippocampus); each chip shows the name " + "and its integer label id. Multiple regions union into one combined " + "target (e.g. 17,53 = both hippocampi); press ✕ on a chip to remove " + "it." ) + layout.addRow(QtWidgets.QLabel("Region(s):"), self.subcortical_chips) # Tissue type self.tissue_combo = QtWidgets.QComboBox() @@ -456,11 +460,11 @@ def _connect_signals(self): self.volumetric_checkbox.toggled.connect(self.roi_changed) self.sphere_tissue_combo.currentIndexChanged.connect(self.roi_changed) self.atlas_combo.currentIndexChanged.connect(self.roi_changed) - self.label_value_input.textChanged.connect(self.roi_changed) + self.cortical_chips.changed.connect(self.roi_changed) self.volume_atlas_combo.currentIndexChanged.connect(self.roi_changed) self.volume_subject_radio.toggled.connect(self.roi_changed) self.volume_mni_radio.toggled.connect(self.roi_changed) - self.volume_label_input.textChanged.connect(self.roi_changed) + self.subcortical_chips.changed.connect(self.roi_changed) self.tissue_combo.currentIndexChanged.connect(self.roi_changed) self._mode_group.buttonClicked.connect(lambda: self.roi_changed.emit()) @@ -670,6 +674,56 @@ def set_spheres( x, y, z = center self._add_sphere_row(float(x), float(y), float(z), float(radius)) + def set_cortical_regions(self, names: list[str]): + """Repopulate the cortical region chips from saved region names. + + The inverse of the cortical branch of :meth:`get_roi_params`: replaces + every chip with one per hemisphere-prefixed name. Where the current + atlas annotation resolves a name to its ``.annot`` label index the chip + shows both name and index; otherwise the name is shown alone. + + Args: + names: Sequence of ``"hemi.name"`` tokens (e.g. ``"lh.precentral"``). + """ + name_map = self._cortical_name_index_map() + items: list[tuple[str, str]] = [] + for token in names: + token = str(token).strip() + if not token: + continue + display = token + hemi, sep, name = token.partition(".") + if sep == ".": + index = name_map.get((hemi, name)) + if index is not None: + display = f"{token} · {index}" + items.append((token, display)) + self.cortical_chips.set_items(items) + + def set_subcortical_regions(self, ids: list): + """Repopulate the subcortical region chips from saved label ids. + + The inverse of the subcortical branch of :meth:`get_roi_params`: + replaces every chip with one per integer atlas label id. Where the + current volume atlas resolves an id to a region name the chip shows + both name and id; otherwise the id is shown alone. + + Args: + ids: Sequence of integer atlas label ids (ints or numeric strings). + """ + id_name = self._subcortical_id_name_map() + items: list[tuple[str, str]] = [] + for value in ids: + token = str(value).strip() + if not token: + continue + name = None + if token.lstrip("-").isdigit(): + name = id_name.get(int(token)) + display = f"{name} · {token}" if name else token + items.append((token, display)) + self.subcortical_chips.set_items(items) + @staticmethod def _parse_region_names(text: str) -> list[tuple[str, str]]: """Parse comma-separated hemisphere-prefixed cortical region names. @@ -719,6 +773,40 @@ def _cortical_name_index_map(self) -> dict[tuple[str, str], int]: mapping[(hemi, name)] = idx return mapping + def _subcortical_id_name_map(self) -> dict[int, str]: + """Map ``label_id -> region name`` for the currently selected volume atlas. + + Resolves names from a sidecar LUT when present, otherwise from the + atlas volume's own integer labels via the bundled FreeSurfer colour + table (Agent A's resolver, :meth:`_resolve_volume_label_entries`). + Returns an empty dict when the atlas cannot be read (e.g. no subject + set yet); callers fall back to showing the bare id. + """ + atlas_path = self.volume_atlas_combo.currentData() + if not atlas_path: + return {} + atlas_path = str(atlas_path) + mapping: dict[int, str] = {} + try: + lut_file = self._find_volume_lut(atlas_path) + if lut_file is not None: + with open(lut_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, _ = parsed + mapping[int(label_id)] = label_name + else: + for label_id, name, _ in self._resolve_volume_label_entries(atlas_path): + mapping[int(label_id)] = name + except (OSError, ValueError): + return {} + return mapping + @staticmethod def _parse_labels(text: str) -> list[int]: """Parse comma-separated integer atlas labels (mirrors analyzer input). @@ -795,14 +883,14 @@ def get_roi_params(self) -> dict: return { "method": "atlas", "atlas": self.atlas_combo.currentText(), - "region": self.label_value_input.text().strip(), + "region": ", ".join(self.cortical_chips.keys()), } else: # subcortical return { "method": "subcortical", "volume_atlas": self.volume_atlas_combo.currentText(), "volume_atlas_space": self._selected_volume_atlas_space(), - "volume_region": self.volume_label_input.text().strip(), + "volume_region": ", ".join(self.subcortical_chips.keys()), "tissues": self.tissue_combo.currentData(), } @@ -846,7 +934,7 @@ def get_roi_spec(self, subject_id: str, project_dir: str): # Region indices are intrinsic to the atlas parcellation, so the map # built from the current subject's .annot applies to every subject. name_map = self._cortical_name_index_map() - tokens = self._parse_region_names(self.label_value_input.text()) + tokens = self._parse_region_names(", ".join(self.cortical_chips.keys())) # Build equal-length parallel lists so a target can union across # regions and/or hemispheres; each entry carries its own lh/rh # .annot path and label index. The serialized AtlasROI contract @@ -871,7 +959,7 @@ def get_roi_spec(self, subject_id: str, project_dir: str): ) else: # subcortical volume_atlas_path = self._selected_volume_atlas_path(subject_id, seg_dir) - labels = self._parse_labels(self.volume_label_input.text()) + labels = self._parse_labels(", ".join(self.subcortical_chips.keys())) return FlexConfig.SubcorticalROI( atlas_path=volume_atlas_path, label=self._collapse(labels), @@ -912,7 +1000,7 @@ def validate(self) -> str | None: elif roi_type == "atlas": if not self.atlas_combo.currentText(): return "No cortical atlas selected. Use Refresh to discover atlases." - error = self._validate_region_names(self.label_value_input.text()) + error = self._validate_region_names(", ".join(self.cortical_chips.keys())) if error: return error elif roi_type == "subcortical": @@ -920,13 +1008,13 @@ def validate(self) -> str | None: return ( "No volume atlas selected. Use Refresh to discover volume atlases." ) - error = self._validate_labels(self.volume_label_input.text()) + error = self._validate_labels(", ".join(self.subcortical_chips.keys())) if error: return error return None def _validate_region_names(self, text: str) -> str | None: - """Validate the comma-separated cortical region-name field. + """Validate the selected cortical region chips (as a comma string). Returns an error message string, or ``None`` when valid. Unknown names are reported only when the atlas annotation files can actually be read; @@ -940,7 +1028,7 @@ def _validate_region_names(self, text: str) -> str | None: "e.g. lh.precentral, rh.superiorfrontal." ) if not tokens: - return "Enter at least one cortical region name." + return "Select at least one cortical region (use 'List Regions')." name_map = self._cortical_name_index_map() if name_map: unknown = [f"{h}.{n}" for h, n in tokens if (h, n) not in name_map] @@ -949,13 +1037,16 @@ def _validate_region_names(self, text: str) -> str | None: return None def _validate_labels(self, text: str) -> str | None: - """Validate a comma-separated region-label field. Returns an error or None.""" + """Validate the selected subcortical region chips (as a comma string). + + Returns an error message string, or ``None`` when valid. + """ try: labels = self._parse_labels(text) except ValueError: return "Region label value(s) must be integers (comma-separated)." if not labels: - return "Enter at least one region label value." + return "Select at least one subcortical region (use 'List Regions')." if any(label < 1 for label in labels): return "Region label value(s) must be at least 1." return None @@ -1067,9 +1158,9 @@ def _on_list_atlas_regions(self): """Show cortical regions from BOTH hemispheres as hemi-prefixed names. Entries are built from ``list_annot_regions`` on the lh and rh - ``.annot`` files and displayed as ``lh.`` / ``rh.``. The - finder returns the selected names, which are merged into the region - field. + ``.annot`` files and displayed as ``lh.`` / ``rh.``. Each + selected region is added as a chip whose key is the hemisphere-prefixed + name and whose display carries both the name and its ``.annot`` index. """ atlas_display = self.atlas_combo.currentText() if not atlas_display: @@ -1109,7 +1200,12 @@ def _on_list_atlas_regions(self): multi=True, ) if dlg.exec_() == QtWidgets.QDialog.Accepted: - merge_into_lineedit(self.label_value_input, dlg.selected_values()) + # selected_ids()/selected_names() are parallel: names are the + # "hemi.name" tokens, ids are the .annot label indices. + for index, name_token in zip(dlg.selected_ids(), dlg.selected_names()): + self.cortical_chips.add_item( + key=name_token, display=f"{name_token} · {index}" + ) def _on_list_volume_regions(self): """Show regions for the currently selected volume atlas.""" @@ -1119,7 +1215,8 @@ def _on_list_volume_regions(self): def _show_volume_regions_dialog(self, volume_atlas: str): """Open the region finder for the selected volume atlas. - Selected region ids are appended to the volume region-label field. + Each selected region is added as a chip whose key is the integer atlas + label id and whose display carries both the region name and the id. Args: volume_atlas: Volume atlas display name from the combo box. @@ -1190,9 +1287,10 @@ def _show_volume_regions_dialog(self, volume_atlas: str): multi=True, ) if dlg.exec_() == QtWidgets.QDialog.Accepted: - merge_into_lineedit( - self.volume_label_input, dlg.selected_values(), replace_default="10" - ) + for region_id, name in zip(dlg.selected_ids(), dlg.selected_names()): + self.subcortical_chips.add_item( + key=str(region_id), display=f"{name} · {region_id}" + ) def _find_volume_lut(self, atlas_path: str) -> Path | None: atlas = Path(atlas_path) From 39ee88de57ea009c616d3c81fd52cab5893fd642 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 17:09:30 -0500 Subject: [PATCH 15/27] roi_picker: harden subcortical atlas-read fallback (ImageFileError); comment nits --- tit/gui/components/roi_picker.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 7af98c6c..df44a27b 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -803,7 +803,9 @@ def _subcortical_id_name_map(self) -> dict[int, str]: else: for label_id, name, _ in self._resolve_volume_label_entries(atlas_path): mapping[int(label_id)] = name - except (OSError, ValueError): + except Exception: + # nibabel can raise ImageFileError (not an OSError) on an unreadable + # atlas; match the finder path and degrade to bare-id chips. return {} return mapping @@ -1283,7 +1285,7 @@ def _show_volume_regions_dialog(self, volume_atlas: str): self, f"Subcortical Regions - {volume_atlas}", entries, - return_field="id", + return_field="id", # unused: chips read selected_ids()/selected_names() directly multi=True, ) if dlg.exec_() == QtWidgets.QDialog.Accepted: @@ -1346,8 +1348,8 @@ def _resolve_volume_label_entries( return [] img = nib.load(atlas_path) - # np.unique on the raw dataobj is fast and avoids loading a scaled float - # copy of the volume; label maps are stored as integers. + # Label maps carry identity scaling, so np.asarray(dataobj) returns the + # native integer labels; int() below normalises regardless. present = np.unique(np.asarray(img.dataobj)) entries: list[tuple[int, str, tuple[str, str, str] | None]] = [] From abe16df80e9b5c802c15b497ae3f44f2f1b97a17 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 17:27:37 -0500 Subject: [PATCH 16/27] =?UTF-8?q?region=20finder:=20drop=20RGB=20colour=20?= =?UTF-8?q?swatch=20=E2=80=94=20show=20label=20number=20and=20name=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tit/gui/components/atlas_region_finder.py | 32 +++++------------------ 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/tit/gui/components/atlas_region_finder.py b/tit/gui/components/atlas_region_finder.py index 436947cb..a6b9267c 100644 --- a/tit/gui/components/atlas_region_finder.py +++ b/tit/gui/components/atlas_region_finder.py @@ -21,17 +21,16 @@ from typing import Iterable, List, Optional, Sequence, Tuple -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5 import QtCore, QtWidgets class AtlasRegionFinderDialog(QtWidgets.QDialog): """Searchable, multi-select dialog for choosing atlas regions. - Each entry is displayed as ``"{id}: {name}"`` with an optional RGB colour - swatch. The ``(id, name)`` pair is stored on every list item via - ``Qt.UserRole`` so selections are resolved unambiguously (no fragile string - parsing). A search field filters items by id *or* name substring - (case-insensitive). + Each entry is displayed as ``"{id}: {name}"`` (label number and name only). + The ``(id, name)`` pair is stored on every list item via ``Qt.UserRole`` so + selections are resolved unambiguously (no fragile string parsing). A search + field filters items by id *or* name substring (case-insensitive). Args: parent: Parent widget (``QWidget`` or ``None``). @@ -114,32 +113,13 @@ def _add_entry( preselected: set, ) -> None: """Create and append a list item for a single ``(id, name, rgb)`` entry.""" - region_id, name, rgb = entry + region_id, name, _rgb = entry item = QtWidgets.QListWidgetItem(f"{region_id}: {name}") item.setData(QtCore.Qt.UserRole, (int(region_id), str(name))) - swatch = self._make_swatch(rgb) - if swatch is not None: - item.setIcon(swatch) self.list_widget.addItem(item) if int(region_id) in preselected: item.setSelected(True) - @staticmethod - def _make_swatch(rgb: Optional[Tuple]) -> Optional[QtGui.QIcon]: - """Build a small colour-swatch icon from an ``(r, g, b)`` tuple. - - Returns ``None`` when ``rgb`` is missing or cannot be parsed. - """ - if not rgb: - return None - try: - r, g, b = (int(c) for c in rgb) - except (TypeError, ValueError): - return None - pixmap = QtGui.QPixmap(12, 12) - pixmap.fill(QtGui.QColor(r, g, b)) - return QtGui.QIcon(pixmap) - # ------------------------------------------------------------------ # Behaviour # ------------------------------------------------------------------ From 7cdf537bc093cef0313bd71922449a56c0505304 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 17:35:35 -0500 Subject: [PATCH 17/27] Analyzer: replace region-name text field with shared RegionChipsWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the analyzer tab the same region-chips UX as the optimizer's ROI picker: each selected region is a removable 'name · #' pill instead of a comma-separated QLineEdit. Behavior-preserving — _get_regions() now returns the chip keys (the same region names the analyzer config consumes via config[region]/[regions]). - List Regions now feeds the finder real atlas label ids: cortical uses MeshAtlasManager.list_annot_regions() per hemisphere ((index, lh/rh.name)); voxel parses the real 'Name (ID: N)' label id. Chips de-dup by region name. - Move every region_input reader (validation, detail strings, output name, enable/disable, disable_controls list) onto the chips widget / _get_regions. - Drop the QLineEdit maximum-width clamp so chips reflow via height-for-width. --- tit/gui/analyzer_tab.py | 129 +++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 60 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 3ec9d2bd..8e17498e 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -33,10 +33,8 @@ ) from tit.gui.components.action_buttons import RunStopButtons from tit.gui.components.base_thread import BaseProcessThread -from tit.gui.components.atlas_region_finder import ( - AtlasRegionFinderDialog, - merge_into_lineedit, -) +from tit.gui.components.atlas_region_finder import AtlasRegionFinderDialog +from tit.gui.components.region_chips import RegionChipsWidget from tit.atlas.constants import BUILTIN_ATLASES from tit.paths import get_path_manager @@ -650,14 +648,20 @@ def create_analysis_parameters_widget(self, container_widget): self.region_label.setSizePolicy( QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed ) - self.region_input = QtWidgets.QLineEdit() - self.region_input.setPlaceholderText( - "e.g., superiorfrontal or precentral, postcentral" + self.region_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" ) - self.region_input.setMinimumWidth(100) - self.region_input.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed + self.region_chips.setToolTip( + "Selected regions for cortical analysis. Use 'List Regions' to add " + "regions; each chip shows the region name and its atlas label index. " + "Multiple regions combine into one ROI; press ✕ on a chip to remove it." ) + self.region_chips.setMinimumWidth(100) + # Widen the chips horizontally while preserving the height-for-width + # plumbing the widget installs on itself (so chips wrap correctly). + _chips_policy = self.region_chips.sizePolicy() + _chips_policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding) + self.region_chips.setSizePolicy(_chips_policy) self.show_regions_btn = QtWidgets.QPushButton("List Regions") self.show_regions_btn.setToolTip("Show available regions in the selected atlas") @@ -668,7 +672,7 @@ def create_analysis_parameters_widget(self, container_widget): ) region_row.addWidget(self.region_label) - region_row.addWidget(self.region_input) + region_row.addWidget(self.region_chips) region_row.addSpacing(5) region_row.addWidget(self.show_regions_btn) region_row.addStretch() @@ -1025,7 +1029,7 @@ def update_atlas_dependent_controls( # Should be enabled for cortical analysis, but only if atlas controls are enabled region_enabled = cortical_controls_enabled self.region_label.setEnabled(region_enabled) - self.region_input.setEnabled(region_enabled) + self.region_chips.setEnabled(region_enabled) # Update show regions button # Should be enabled for cortical analysis, but only if we have valid atlases @@ -1273,7 +1277,7 @@ def update_group_atlas_options(self): self.atlas_warning_label.setVisible(False) # Disable all region/atlas controls self.region_label.setEnabled(False) - self.region_input.setEnabled(False) + self.region_chips.setEnabled(False) self.show_regions_btn.setEnabled(False) return # Skip the centralized update, as we've set everything explicitly @@ -1494,11 +1498,11 @@ def validate_analysis_parameters(self): # Shared parameters return False elif self.type_cortical.isChecked(): # Atlas selection for cortical is handled by validate_single/group_inputs - if not self.region_input.text().strip(): + if not self.region_chips.keys(): QtWidgets.QMessageBox.warning( self, "Warning", - "Please enter a region name for cortical analysis.", + "Please select a region for cortical analysis.", ) return False return True @@ -1875,7 +1879,7 @@ def get_single_analysis_details(self): else: details += f"- Voxel Atlas File: {self.atlas_combo.currentText()} (Path: {self.atlas_combo.currentData() or 'N/A'})\n" # Show path regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else self.region_input.text()}\n" + details += f"- Region(s): {', '.join(regions) if regions else '+'.join(self._get_regions())}\n" details += f"- Generate Visualizations: Yes" return details @@ -1918,7 +1922,7 @@ def get_group_analysis_details(self, subjects): else: details += f"- Voxel Atlas: Common atlas configuration\n" regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else self.region_input.text()} (for all)\n" + details += f"- Region(s): {', '.join(regions) if regions else '+'.join(self._get_regions())} (for all)\n" details += f"- Generate Visualizations: Yes" return details @@ -2065,7 +2069,7 @@ def _build_start_details(self, subject_id): region_str = ( "+".join(regions) if regions - else self.region_input.text().strip() or "region" + else "+".join(self._get_regions()) or "region" ) return f"Cortical: {atlas}.{region_str}" else: @@ -2111,7 +2115,7 @@ def disable_controls(self): self.atlas_name_combo, self.atlas_combo, self.show_regions_btn, - self.region_input, + self.region_chips, ] for widget in widgets_to_set_enabled: if hasattr(widget, "setEnabled"): @@ -2134,14 +2138,14 @@ def enable_controls(self): self.type_cortical, self.coords_radius_input, self.view_in_freeview_btn, - # atlas_name_combo, atlas_combo, show_regions_btn, region_input handled by update_atlas_visibility + # atlas_name_combo, atlas_combo, show_regions_btn, region_chips handled by update_atlas_visibility ] for widget in widgets_to_set_enabled: if hasattr(widget, "setEnabled"): widget.setEnabled(True) # Force enable these controls first, then let update_atlas_visibility handle proper state - self.region_input.setEnabled(True) + self.region_chips.setEnabled(True) self.region_label.setEnabled(True) self.atlas_name_combo.setEnabled(True) # Don't force enable atlas_combo - let update_atlas_visibility handle it properly @@ -2155,17 +2159,15 @@ def enable_controls(self): self.status_label.hide() - @staticmethod - def _list_annot_regions(seg_dir, atlas_name): - """Read region names from .annot files in the segmentation directory.""" - from tit.atlas import MeshAtlasManager - - return MeshAtlasManager(seg_dir).list_regions(atlas_name) - def _get_regions(self) -> list[str]: - """Parse comma-separated region names from the input field.""" - text = self.region_input.text().strip() - return [r.strip() for r in text.split(",") if r.strip()] + """Return the selected region names from the region chips. + + Each chip's key is the region name the analyzer config consumes (a + hemisphere-prefixed name for mesh atlases, a plain name for voxel + atlases), so this is a drop-in replacement for the former + comma-separated text field. + """ + return self.region_chips.keys() def show_available_regions(self): """Show a searchable dialog of available regions for the selected atlas.""" @@ -2179,7 +2181,7 @@ def show_available_regions(self): progress_dialog.setMinimumDuration(200) progress_dialog.setValue(0) - atlas_type_display, regions = "", [] + atlas_type_display, entries = "", [] subject_id = selected_subjects[0] if self.space_mesh.isChecked(): @@ -2198,7 +2200,21 @@ def show_available_regions(self): progress_dialog.setValue(20) QtWidgets.QApplication.processEvents() seg_dir = os.path.join(m2m_dir, "segmentation") - regions = self._list_annot_regions(seg_dir, atlas_name) + # Build (index, "hemi.name", None) entries straight from each + # hemisphere's .annot so chips display the region's real atlas + # label index instead of a fake enumerate position. The name token + # ("lh.precentral") is exactly what the analyzer config consumes. + from tit.atlas import MeshAtlasManager + + mesh_mgr = MeshAtlasManager(seg_dir) + for hemi in ("lh", "rh"): + annot_file = mesh_mgr.find_atlas_file(atlas_name, hemi) + if not annot_file: + continue + for idx, name in mesh_mgr.list_annot_regions(annot_file): + if name == "unknown": + continue + entries.append((idx, f"{hemi}.{name}", None)) progress_dialog.setValue(80) QtWidgets.QApplication.processEvents() @@ -2216,10 +2232,19 @@ def show_available_regions(self): from tit.atlas import VoxelAtlasManager - regions = VoxelAtlasManager().list_regions(atlas_path) + # VoxelAtlasManager yields "Name (ID: N)" strings; parse each into + # its real integer label id and clean name so chips show the id and + # the config receives the plain region name. + for region in VoxelAtlasManager().list_regions(atlas_path): + name_part, _, id_part = region.partition(" (ID:") + try: + region_id = int(id_part.rstrip(")").strip()) + except ValueError: + region_id = 0 + entries.append((region_id, name_part.strip(), None)) progress_dialog.setValue(90) - if not regions: + if not entries: QtWidgets.QMessageBox.information( self, "No Regions", f"No regions for: {atlas_type_display}" ) @@ -2228,23 +2253,6 @@ def show_available_regions(self): progress_dialog.setValue(100) progress_dialog.close() - # Build (id, name, rgb) entries for the shared region finder. Mesh - # regions are plain names (no numeric id), so a sequential index is - # used purely for display; voxel regions arrive as "Name (ID: N)" - # strings and are parsed into their id and name parts here so the - # dialog can return clean names without any string-splitting hack. - if self.space_mesh.isChecked(): - entries = [(idx, name, None) for idx, name in enumerate(regions)] - else: - entries = [] - for region in regions: - name_part, _, id_part = region.partition(" (ID:") - try: - region_id = int(id_part.rstrip(")").strip()) - except ValueError: - region_id = 0 - entries.append((region_id, name_part.strip(), None)) - dialog = AtlasRegionFinderDialog( self, f"Available Regions - {atlas_type_display}", @@ -2253,7 +2261,11 @@ def show_available_regions(self): multi=True, ) if dialog.exec_() == QtWidgets.QDialog.Accepted: - merge_into_lineedit(self.region_input, dialog.selected_values()) + # Add each selected region as a de-duplicated chip. The key is the + # region name the analyzer config expects (what _get_regions + # returned before); the display carries the real atlas label index. + for region_id, name in zip(dialog.selected_ids(), dialog.selected_names()): + self.region_chips.add_item(key=name, display=f"{name} · {region_id}") def load_t1_in_freeview(self): """Load subject's T1 NIfTI file or MNI template in Freeview for coordinate selection.""" @@ -2623,13 +2635,10 @@ def _update_input_widths(self): container_width = 400 # Default fallback # Calculate dynamic maximum widths as percentages of container width - # These percentages allow growth while preventing overflow - # Region input uses Preferred policy, so it will expand up to max - if hasattr(self, "region_input"): - # Allow expansion up to a reasonable max based on container size - max_region_width = max(200, min(300, int(container_width * 0.40))) - if self.region_input.maximumWidth() != max_region_width: - self.region_input.setMaximumWidth(max_region_width) + # These percentages allow growth while preventing overflow. + # The region selector is now a RegionChipsWidget that wraps its chips + # via height-for-width, so it needs no maximum-width clamp; leaving one + # would stop the chips from reflowing to fill the available row. # Coordinates+radius input uses Preferred policy if hasattr(self, "coords_radius_input"): From 7583fece87ea282a82ecf5dadcd223884cb72a24 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 18:17:19 -0500 Subject: [PATCH 18/27] analyzer: drop dead region-string fallbacks; alphabetize finder list --- tit/gui/analyzer_tab.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 8e17498e..ae723b63 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -1879,7 +1879,7 @@ def get_single_analysis_details(self): else: details += f"- Voxel Atlas File: {self.atlas_combo.currentText()} (Path: {self.atlas_combo.currentData() or 'N/A'})\n" # Show path regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else '+'.join(self._get_regions())}\n" + details += f"- Region(s): {', '.join(regions)}\n" details += f"- Generate Visualizations: Yes" return details @@ -1922,7 +1922,7 @@ def get_group_analysis_details(self, subjects): else: details += f"- Voxel Atlas: Common atlas configuration\n" regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else '+'.join(self._get_regions())} (for all)\n" + details += f"- Region(s): {', '.join(regions)} (for all)\n" details += f"- Generate Visualizations: Yes" return details @@ -2066,11 +2066,7 @@ def _build_start_details(self, subject_id): "." )[0] regions = self._get_regions() - region_str = ( - "+".join(regions) - if regions - else "+".join(self._get_regions()) or "region" - ) + region_str = "+".join(regions) or "region" return f"Cortical: {atlas}.{region_str}" else: parsed = self._parse_coords_radius() @@ -2215,6 +2211,7 @@ def show_available_regions(self): if name == "unknown": continue entries.append((idx, f"{hemi}.{name}", None)) + entries.sort(key=lambda e: e[1]) # alphabetical within the finder list progress_dialog.setValue(80) QtWidgets.QApplication.processEvents() From c5e591bdf08879564619eb84d799f233bf781a21 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 18:56:13 -0500 Subject: [PATCH 19/27] GUI: full-width analyzer region chips row; column-order-agnostic LUT name parsing --- tit/gui/analyzer_tab.py | 38 +++++++++++++++----------------- tit/gui/components/roi_picker.py | 22 +++++++++--------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index ae723b63..9b8438be 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -642,12 +642,27 @@ def create_analysis_parameters_widget(self, container_widget): # Region, Atlas, and Spherical parameters - organized into multiple rows # Row 1: Region input (comma-separated for combined ROI) + List Regions button - region_row = QtWidgets.QHBoxLayout() - region_row.setSpacing(10) + # Control row: "Region(s):" label + "List Regions" button. + region_header = QtWidgets.QHBoxLayout() + region_header.setSpacing(10) self.region_label = QtWidgets.QLabel("Region(s):") self.region_label.setSizePolicy( QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed ) + self.show_regions_btn = QtWidgets.QPushButton("List Regions") + self.show_regions_btn.setToolTip("Show available regions in the selected atlas") + self.show_regions_btn.clicked.connect(self.show_available_regions) + self.show_regions_btn.setEnabled(False) + self.show_regions_btn.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + region_header.addWidget(self.region_label) + region_header.addWidget(self.show_regions_btn) + region_header.addStretch() + analysis_params_layout.addLayout(region_header) + + # Selected-region chips get their OWN full-width row below the controls + # so they are clearly visible and wrap instead of being clipped. self.region_chips = RegionChipsWidget( placeholder="No regions selected — use List Regions…" ) @@ -656,27 +671,10 @@ def create_analysis_parameters_widget(self, container_widget): "regions; each chip shows the region name and its atlas label index. " "Multiple regions combine into one ROI; press ✕ on a chip to remove it." ) - self.region_chips.setMinimumWidth(100) - # Widen the chips horizontally while preserving the height-for-width - # plumbing the widget installs on itself (so chips wrap correctly). _chips_policy = self.region_chips.sizePolicy() _chips_policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding) self.region_chips.setSizePolicy(_chips_policy) - - self.show_regions_btn = QtWidgets.QPushButton("List Regions") - self.show_regions_btn.setToolTip("Show available regions in the selected atlas") - self.show_regions_btn.clicked.connect(self.show_available_regions) - self.show_regions_btn.setEnabled(False) - self.show_regions_btn.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - - region_row.addWidget(self.region_label) - region_row.addWidget(self.region_chips) - region_row.addSpacing(5) - region_row.addWidget(self.show_regions_btn) - region_row.addStretch() - analysis_params_layout.addLayout(region_row) + analysis_params_layout.addWidget(self.region_chips) # Row 2: Atlas selection (single widget that shows mesh or voxel based on space) atlas_row = QtWidgets.QHBoxLayout() diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index df44a27b..21ec4937 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -1424,17 +1424,17 @@ def _parse_lut_line( if not label_id.lstrip("-").isdigit(): return None - color_start = None - for i in range(2, len(parts) - 2): - if all(part.lstrip("-").isdigit() for part in parts[i : i + 3]): - color_start = i - break - - if color_start is None: - return label_id, " ".join(parts[1:]), None - - label_name = " ".join(parts[1:color_start]) - rgb = tuple(parts[color_start : color_start + 3]) + # The name is every non-integer token after the id; the RGB(A) columns + # are the integer tokens. This is column-order agnostic, so both a + # "ID Name R G B A" table and a "ID R G B Name" table yield a clean name + # (never "12 48 255 Left-Pallidum"). + rest = parts[1:] + name_tokens = [p for p in rest if not p.lstrip("-").isdigit()] + int_tokens = [p for p in rest if p.lstrip("-").isdigit()] + if not name_tokens: + return None + label_name = " ".join(name_tokens) + rgb = tuple(int_tokens[:3]) if len(int_tokens) >= 3 else None return label_id, label_name, rgb # ------------------------------------------------------------------ From 1cd43d6352acaa216d783c459b5ff011ccce9826 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:07:09 -0500 Subject: [PATCH 20/27] =?UTF-8?q?roi=5Fpicker:=20ignore=20mri=5Fsegstats?= =?UTF-8?q?=20sidecar=20for=20FS=20subject=20atlases=20=E2=80=94=20resolve?= =?UTF-8?q?=20subcortical=20names=20via=20bundled=20LUT=20(fixes=20'1:=202?= =?UTF-8?q?45555.0=20')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tit/gui/components/roi_picker.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 21ec4937..d8df3573 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -1300,10 +1300,13 @@ def _find_volume_lut(self, atlas_path: str) -> Path | None: if atlas.name == "labeling.nii.gz": lut_path = atlas.with_name("labeling_LUT.txt") return lut_path if lut_path.is_file() else None - labels_file = atlas.with_name( - f"{self._strip_nifti_suffix(atlas.name)}_labels.txt" - ) - return labels_file if labels_file.is_file() else None + # For FreeSurfer subject atlases (e.g. aparc.DKTatlas+aseg) a + # "_labels.txt" sidecar is an mri_segstats SUMMARY + # (Index SegId NVoxels Volume StructName) — NOT a colour LUT — so we + # do NOT parse it here (that would show "1: 245555.0 "). Return + # None so the caller resolves names from the volume's own labels via + # the bundled FreeSurferColorLUT (_resolve_volume_label_entries). + return None stem = self._strip_nifti_suffix(atlas.name) candidates = [ From f87aaea864edf356cbe84c3b3e69377724278038 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:17:24 -0500 Subject: [PATCH 21/27] Reorganize analyzer Analysis Configuration into adaptive target groups Group the Analysis Configuration box into Space, Type, and two adaptive target groups (Cortical target, Spherical target). Type moves up directly after Space and drives which group is shown: cortical widgets (atlas row + List Regions, full-width region chips, tissue) vs spherical widgets (coordinate space, coordinates). Visibility is toggled from update_atlas_visibility so it stays consolidated with the existing type/space handlers and initial setup call. No widget behavior, config building, or signal wiring changed. --- tit/gui/analyzer_tab.py | 143 +++++++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 61 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 9b8438be..49f33e65 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -601,19 +601,8 @@ def create_analysis_parameters_widget(self, container_widget): space_layout.addStretch() # Keep stretch to push radio buttons to left analysis_params_layout.addLayout(space_layout) - # Tissue type row - tissue_layout = QtWidgets.QHBoxLayout() - tissue_layout.setSpacing(10) - tissue_label = QtWidgets.QLabel("Tissue:") - tissue_label.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - tissue_layout.addWidget(tissue_label) - tissue_layout.addWidget(self.tissue_combo) - tissue_layout.addStretch() - analysis_params_layout.addLayout(tissue_layout) - - # Type selection row (separate from Space) - distribute evenly across width + # Type selection row - moved up so it sits right after Space and drives + # which target group (cortical vs spherical) is shown below. type_layout = QtWidgets.QHBoxLayout() type_layout.setSpacing(10) self.type_label = QtWidgets.QLabel("Type:") @@ -640,47 +629,18 @@ def create_analysis_parameters_widget(self, container_widget): # (Removed) Analysis mode selection row (surface vs volumetric). Analyzer is surface-only. - # Region, Atlas, and Spherical parameters - organized into multiple rows - # Row 1: Region input (comma-separated for combined ROI) + List Regions button - # Control row: "Region(s):" label + "List Regions" button. - region_header = QtWidgets.QHBoxLayout() - region_header.setSpacing(10) - self.region_label = QtWidgets.QLabel("Region(s):") - self.region_label.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - self.show_regions_btn = QtWidgets.QPushButton("List Regions") - self.show_regions_btn.setToolTip("Show available regions in the selected atlas") - self.show_regions_btn.clicked.connect(self.show_available_regions) - self.show_regions_btn.setEnabled(False) - self.show_regions_btn.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - region_header.addWidget(self.region_label) - region_header.addWidget(self.show_regions_btn) - region_header.addStretch() - analysis_params_layout.addLayout(region_header) - - # Selected-region chips get their OWN full-width row below the controls - # so they are clearly visible and wrap instead of being clipped. - self.region_chips = RegionChipsWidget( - placeholder="No regions selected — use List Regions…" - ) - self.region_chips.setToolTip( - "Selected regions for cortical analysis. Use 'List Regions' to add " - "regions; each chip shows the region name and its atlas label index. " - "Multiple regions combine into one ROI; press ✕ on a chip to remove it." - ) - _chips_policy = self.region_chips.sizePolicy() - _chips_policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding) - self.region_chips.setSizePolicy(_chips_policy) - analysis_params_layout.addWidget(self.region_chips) + # ===================== Cortical target group ===================== + self.cortical_group = QtWidgets.QGroupBox("Cortical target") + cortical_layout = QtWidgets.QVBoxLayout(self.cortical_group) + cortical_layout.setSpacing(8) - # Row 2: Atlas selection (single widget that shows mesh or voxel based on space) + # Atlas row: the atlas widget(s) (mesh or voxel depending on Space) plus + # the "List Regions" button. Both atlas widgets live here; only the one + # matching the current Space is shown/enabled (see update_atlas_visibility). atlas_row = QtWidgets.QHBoxLayout() atlas_row.setSpacing(10) - # Atlas widgets - always visible, just enabled/disabled + # Atlas widgets - always present, only the relevant one shown/enabled self.mesh_atlas_widget = QtWidgets.QWidget() mesh_atlas_layout = QtWidgets.QHBoxLayout(self.mesh_atlas_widget) mesh_atlas_layout.setContentsMargins(0, 0, 0, 0) @@ -719,16 +679,64 @@ def create_analysis_parameters_widget(self, container_widget): voxel_atlas_row_layout.addWidget(self.atlas_combo) voxel_atlas_vlayout.addWidget(voxel_atlas_row) - # Only add the appropriate atlas widget based on current space - # Both will be in the layout, but only one enabled/visible at a time + # "List Regions" button lives on the atlas row. + self.show_regions_btn = QtWidgets.QPushButton("List Regions") + self.show_regions_btn.setToolTip("Show available regions in the selected atlas") + self.show_regions_btn.clicked.connect(self.show_available_regions) + self.show_regions_btn.setEnabled(False) + self.show_regions_btn.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + + # Both atlas widgets in the layout, but only one enabled/visible at a time atlas_row.addWidget(self.mesh_atlas_widget) atlas_row.addWidget(self.voxel_atlas_widget) + atlas_row.addWidget(self.show_regions_btn) atlas_row.addStretch() - analysis_params_layout.addLayout(atlas_row) + cortical_layout.addLayout(atlas_row) + + # Region(s) label above the full-width chips row. + self.region_label = QtWidgets.QLabel("Region(s):") + self.region_label.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + cortical_layout.addWidget(self.region_label) + + # Selected-region chips get their OWN full-width row so they are clearly + # visible and wrap instead of being clipped. + self.region_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" + ) + self.region_chips.setToolTip( + "Selected regions for cortical analysis. Use 'List Regions' to add " + "regions; each chip shows the region name and its atlas label index. " + "Multiple regions combine into one ROI; press ✕ on a chip to remove it." + ) + _chips_policy = self.region_chips.sizePolicy() + _chips_policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding) + self.region_chips.setSizePolicy(_chips_policy) + cortical_layout.addWidget(self.region_chips) + + # Tissue type row (applies to voxel space; enable state handled elsewhere). + tissue_layout = QtWidgets.QHBoxLayout() + tissue_layout.setSpacing(10) + tissue_label = QtWidgets.QLabel("Tissue:") + tissue_label.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + tissue_layout.addWidget(tissue_label) + tissue_layout.addWidget(self.tissue_combo) + tissue_layout.addStretch() + cortical_layout.addLayout(tissue_layout) - # Spherical analysis parameters - split into separate rows + analysis_params_layout.addWidget(self.cortical_group) - # Row 2.5: Coordinate space selection (for spherical analysis) + # ===================== Spherical target group ===================== + self.spherical_group = QtWidgets.QGroupBox("Spherical target") + spherical_layout = QtWidgets.QVBoxLayout(self.spherical_group) + spherical_layout.setSpacing(8) + + # Coordinate space selection (Subject / MNI) for spherical analysis. coord_space_row = QtWidgets.QHBoxLayout() coord_space_row.setSpacing(10) self.coord_space_label = QtWidgets.QLabel("Coordinate Space:") @@ -739,9 +747,9 @@ def create_analysis_parameters_widget(self, container_widget): coord_space_row.addWidget(self.coord_space_subject) coord_space_row.addWidget(self.coord_space_mni) coord_space_row.addStretch() - analysis_params_layout.addLayout(coord_space_row) + spherical_layout.addLayout(coord_space_row) - # Row 3: Coordinates & Radius (single input: x,y,z,r) + # Coordinates & Radius (single input: x,y,z,r) coords_row = QtWidgets.QHBoxLayout() coords_row.setSpacing(10) self.coordinates_label = QtWidgets.QLabel("Coordinates (x,y,z,r):") @@ -768,12 +776,16 @@ def create_analysis_parameters_widget(self, container_widget): coords_row.addSpacing(10) coords_row.addWidget(self.view_in_freeview_btn) coords_row.addStretch() - analysis_params_layout.addLayout(coords_row) + spherical_layout.addLayout(coords_row) + + analysis_params_layout.addWidget(self.spherical_group) # Remove the stacked widget approach - coordinates/radius are always visible # Instead, we'll enable/disable them based on mode - # Original connections from setup_ui for space/type changes + # Original connections from setup_ui for space/type changes. + # update_atlas_visibility() also drives the adaptive cortical/spherical + # target-group visibility, so the type radios need no extra connection. self.space_mesh.toggled.connect(self.update_atlas_visibility) self.space_voxel.toggled.connect(self.update_atlas_visibility) self.type_spherical.toggled.connect(self.update_atlas_visibility) @@ -789,9 +801,10 @@ def create_analysis_parameters_widget(self, container_widget): self.coord_space_subject.toggled.connect(self._update_coordinate_space_labels) self.coord_space_mni.toggled.connect(self._update_coordinate_space_labels) - self.update_atlas_visibility() # Initial call + # Initial call also sets the adaptive target-group visibility. + self.update_atlas_visibility() self.update_cortical_button_text() # Initial call - analysis_params_container.setFixedHeight(250) + analysis_params_container.setMinimumHeight(250) right_layout.addWidget(analysis_params_container) # Compact Gmsh visualization - using space more efficiently @@ -1085,6 +1098,14 @@ def update_atlas_visibility(self): is_cortical = self.type_cortical.isChecked() is_spherical = self.type_spherical.isChecked() + # Adaptive target-group visibility: show only the group matching the + # selected analysis type. Guarded for early calls before the groups + # are created. + if hasattr(self, "cortical_group"): + self.cortical_group.setVisible(is_cortical) + if hasattr(self, "spherical_group"): + self.spherical_group.setVisible(is_spherical) + # Tissue selection only applies to voxel space self.tissue_combo.setEnabled(not is_mesh) if is_mesh: From ad2828a5b7efb5d1b57109f1d562803724ec04c3 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:20:55 -0500 Subject: [PATCH 22/27] Fix analyzer Tissue selector hidden in Voxel+Spherical mode Move the Tissue row out of cortical_group and into the always-visible analysis_params_layout (right after the Type row). Previously the row lived inside cortical_group, which update_atlas_visibility hides in spherical mode, making the Tissue control unreachable in Voxel+Spherical even though tissue is still serialized into the config and applied by the analyzer (field-file GM/WM selection and voxel tissue masking). Enable/disable is unchanged (tissue_combo.setEnabled(not is_mesh)), so it now shows for both Voxel+Cortical and Voxel+Spherical and stays disabled in Mesh mode. --- tit/gui/analyzer_tab.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 49f33e65..8add9251 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -627,6 +627,21 @@ def create_analysis_parameters_widget(self, container_widget): type_layout.addStretch() # Keep stretch to push radio buttons to left analysis_params_layout.addLayout(type_layout) + # Tissue type row (applies to voxel space; enable/disable handled in + # update_atlas_visibility). Kept always-visible here — NOT inside + # cortical_group — so it stays reachable in Voxel+Spherical mode, where it + # still affects output (field-file GM/WM selection and voxel tissue mask). + tissue_layout = QtWidgets.QHBoxLayout() + tissue_layout.setSpacing(10) + tissue_label = QtWidgets.QLabel("Tissue:") + tissue_label.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + tissue_layout.addWidget(tissue_label) + tissue_layout.addWidget(self.tissue_combo) + tissue_layout.addStretch() + analysis_params_layout.addLayout(tissue_layout) + # (Removed) Analysis mode selection row (surface vs volumetric). Analyzer is surface-only. # ===================== Cortical target group ===================== @@ -717,18 +732,6 @@ def create_analysis_parameters_widget(self, container_widget): self.region_chips.setSizePolicy(_chips_policy) cortical_layout.addWidget(self.region_chips) - # Tissue type row (applies to voxel space; enable state handled elsewhere). - tissue_layout = QtWidgets.QHBoxLayout() - tissue_layout.setSpacing(10) - tissue_label = QtWidgets.QLabel("Tissue:") - tissue_label.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - tissue_layout.addWidget(tissue_label) - tissue_layout.addWidget(self.tissue_combo) - tissue_layout.addStretch() - cortical_layout.addLayout(tissue_layout) - analysis_params_layout.addWidget(self.cortical_group) # ===================== Spherical target group ===================== From 3afe3c56428bfcfd8d98973c5c2858985b3c93cb Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:36:39 -0500 Subject: [PATCH 23/27] Accept ids-or-names in AtlasRegionFinderDialog preselection Coerce preselected to strings instead of ints and match items by str(region_id) OR name, so cortical callers keying chips by 'hemi.name' no longer crash and are correctly pre-highlighted. Int ids still match via str() coercion (back-compat). --- tit/gui/components/atlas_region_finder.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tit/gui/components/atlas_region_finder.py b/tit/gui/components/atlas_region_finder.py index a6b9267c..be21a472 100644 --- a/tit/gui/components/atlas_region_finder.py +++ b/tit/gui/components/atlas_region_finder.py @@ -41,7 +41,9 @@ class AtlasRegionFinderDialog(QtWidgets.QDialog): or ``"name"``. Defaults to ``"id"``. multi: If ``True`` (default) allow multi-selection (``ExtendedSelection``); otherwise single selection. - preselected: Optional iterable of ids to pre-select on open. + preselected: Optional iterable of ids-or-names to pre-select on open. + Values are compared as strings, so both region ids (int or str) + and region names match; callers may pass chip keys directly. """ def __init__( @@ -51,7 +53,7 @@ def __init__( entries: Sequence[Tuple[int, str, Optional[Tuple]]], return_field: str = "id", multi: bool = True, - preselected: Optional[Iterable[int]] = None, + preselected: Optional[Iterable] = None, ) -> None: super().__init__(parent) @@ -82,7 +84,7 @@ def __init__( if multi else QtWidgets.QAbstractItemView.SingleSelection ) - preselected_set = {int(i) for i in preselected} if preselected else set() + preselected_set = {str(x) for x in preselected} if preselected else set() for entry in entries or []: self._add_entry(entry, preselected_set) layout.addWidget(self.list_widget) @@ -117,7 +119,7 @@ def _add_entry( item = QtWidgets.QListWidgetItem(f"{region_id}: {name}") item.setData(QtCore.Qt.UserRole, (int(region_id), str(name))) self.list_widget.addItem(item) - if int(region_id) in preselected: + if str(region_id) in preselected or name in preselected: item.setSelected(True) # ------------------------------------------------------------------ From 7da3b1d51faa39853711cd71c67de585f786da3a Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:42:10 -0500 Subject: [PATCH 24/27] Enforce single-atlas ROI selection in roi_picker - Clear cortical/subcortical chips when the user switches atlas or volume atlas space (user-only activated/buttonClicked signals, so programmatic refresh does not wipe a restored selection) - Add a Clear button next to List Regions on both the cortical and subcortical pages - Pre-highlight already-selected regions when reopening the region finder - Reorder the subcortical page: Atlas Space, Tissue, Volume Atlas, Region(s) --- tit/gui/components/roi_picker.py | 83 +++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index d8df3573..43a6c2b1 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -322,6 +322,10 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: self.list_regions_btn = QtWidgets.QPushButton("List Regions") atlas_layout.addWidget(self.list_regions_btn) + self.clear_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_regions_btn.setToolTip("Remove all selected cortical regions.") + atlas_layout.addWidget(self.clear_regions_btn) + atlas_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas:"), atlas_widget) @@ -348,7 +352,7 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: layout.setVerticalSpacing(3) layout.setContentsMargins(0, 5, 0, 5) - # Volume atlas combo + refresh + list regions + # Atlas space (subject / MNI) toggle — kept at the top. source_widget = QtWidgets.QWidget() source_layout = QtWidgets.QHBoxLayout(source_widget) source_layout.setContentsMargins(0, 0, 0, 0) @@ -368,6 +372,20 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: source_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas Space:"), source_widget) + # Tissue type — placed above the volume atlas selector. + self.tissue_combo = QtWidgets.QComboBox() + self.tissue_combo.addItem("Gray Matter (GM)", "GM") + self.tissue_combo.addItem("White Matter (WM)", "WM") + self.tissue_combo.addItem("GM + WM (both)", "both") + self.tissue_combo.setToolTip( + "Tissue compartment(s) to include when evaluating the volume ROI. " + "GM is appropriate for most subcortical targets (e.g. thalamus, " + "hippocampus). WM or Both can be used when the target overlaps " + "white-matter tracts." + ) + layout.addRow(QtWidgets.QLabel("Tissue Type:"), self.tissue_combo) + + # Volume atlas combo + refresh + list regions + clear. volume_widget = QtWidgets.QWidget() volume_layout = QtWidgets.QHBoxLayout(volume_widget) volume_layout.setContentsMargins(0, 0, 0, 0) @@ -384,12 +402,19 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: self.list_volume_regions_btn = QtWidgets.QPushButton("List Regions") volume_layout.addWidget(self.list_volume_regions_btn) + self.clear_volume_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_volume_regions_btn.setToolTip( + "Remove all selected subcortical regions." + ) + volume_layout.addWidget(self.clear_volume_regions_btn) + volume_layout.addStretch() layout.addRow(QtWidgets.QLabel("Volume Atlas:"), volume_widget) - # Selected region(s) shown as removable chips. Regions are added via - # "List Regions" (present labels, by name), de-duplicated by chip key - # (the integer atlas label id). Multiple regions union into one target. + # Selected region(s) shown as removable chips, below the volume atlas. + # Regions are added via "List Regions" (present labels, by name), + # de-duplicated by chip key (the integer atlas label id). Multiple + # regions union into one target. self.subcortical_chips = RegionChipsWidget( placeholder="No regions selected — use List Regions…" ) @@ -402,19 +427,6 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: ) layout.addRow(QtWidgets.QLabel("Region(s):"), self.subcortical_chips) - # Tissue type - self.tissue_combo = QtWidgets.QComboBox() - self.tissue_combo.addItem("Gray Matter (GM)", "GM") - self.tissue_combo.addItem("White Matter (WM)", "WM") - self.tissue_combo.addItem("GM + WM (both)", "both") - self.tissue_combo.setToolTip( - "Tissue compartment(s) to include when evaluating the volume ROI. " - "GM is appropriate for most subcortical targets (e.g. thalamus, " - "hippocampus). WM or Both can be used when the target overlaps " - "white-matter tracts." - ) - layout.addRow(QtWidgets.QLabel("Tissue Type:"), self.tissue_combo) - return page # ------------------------------------------------------------------ @@ -445,6 +457,20 @@ def _connect_signals(self): self.volume_subject_radio.toggled.connect(self._refresh_volume_atlases) self.volume_mni_radio.toggled.connect(self._refresh_volume_atlases) + # Clear buttons drop the whole current selection. + self.clear_regions_btn.clicked.connect(self.cortical_chips.clear) + self.clear_volume_regions_btn.clicked.connect(self.subcortical_chips.clear) + + # No cross-atlas mixing: regions are atlas-specific, so switching the + # cortical atlas, the volume atlas, or the volume atlas space starts a + # fresh selection. These are wired to *user-only* signals + # (QComboBox.activated / QButtonGroup.buttonClicked) rather than + # currentIndexChanged / toggled, so programmatic repopulation during a + # refresh does not spuriously wipe a restored selection. + self.atlas_combo.activated.connect(self._on_cortical_atlas_changed) + self.volume_atlas_combo.activated.connect(self._on_volume_atlas_changed) + self._volume_space_group.buttonClicked.connect(self._on_volume_atlas_changed) + # Multi-sphere add/duplicate/remove (lambdas so the button's bool # 'checked' arg is not passed as a coordinate/row argument). self.add_sphere_btn.clicked.connect(lambda: self._add_sphere_row()) @@ -468,6 +494,27 @@ def _connect_signals(self): self.tissue_combo.currentIndexChanged.connect(self.roi_changed) self._mode_group.buttonClicked.connect(lambda: self.roi_changed.emit()) + def _on_cortical_atlas_changed(self, *_): + """Clear the cortical region chips when the user picks a new atlas. + + Region names/indices are specific to a parcellation, so a chip added + under one atlas is meaningless under another; switching atlases must + start fresh. Wired to ``activated`` (user-only), so a programmatic + repopulation during refresh does not clear a restored selection. + """ + self.cortical_chips.clear() + + def _on_volume_atlas_changed(self, *_): + """Clear the subcortical region chips when the user picks a new volume + atlas or switches the atlas space (subject/MNI). + + Label ids are specific to a given atlas volume, so mixing regions across + atlases/spaces is invalid; changing either starts fresh. Wired to + user-only signals (``activated`` / ``buttonClicked``), so programmatic + repopulation during refresh does not clear a restored selection. + """ + self.subcortical_chips.clear() + # ------------------------------------------------------------------ # Mode switching # ------------------------------------------------------------------ @@ -1200,6 +1247,7 @@ def _on_list_atlas_regions(self): entries, return_field="name", multi=True, + preselected=list(self.cortical_chips.keys()), ) if dlg.exec_() == QtWidgets.QDialog.Accepted: # selected_ids()/selected_names() are parallel: names are the @@ -1287,6 +1335,7 @@ def _show_volume_regions_dialog(self, volume_atlas: str): entries, return_field="id", # unused: chips read selected_ids()/selected_names() directly multi=True, + preselected=list(self.subcortical_chips.keys()), ) if dlg.exec_() == QtWidgets.QDialog.Accepted: for region_id, name in zip(dlg.selected_ids(), dlg.selected_names()): From 309ee92be36c178ce84c2c787196e49e69052c00 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:47:30 -0500 Subject: [PATCH 25/27] Analyzer: mirror optimizer region-selection UX - Add a 'Clear' button next to 'List Regions' that drops all region chips. - Pre-highlight already-selected regions when opening the region finder (pass preselected chip keys to AtlasRegionFinderDialog). - Reset region chips on user-initiated Space switch or atlas change, since mesh/voxel regions and different atlases are not interchangeable. Uses space_mesh.toggled (post-setup) and the combos' activated signal so programmatic repopulation never clears the selection. --- tit/gui/analyzer_tab.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 8add9251..3004502d 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -703,10 +703,20 @@ def create_analysis_parameters_widget(self, container_widget): QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed ) + # "Clear" button drops every selected region chip in one click, mirroring + # the optimizer's region-selection UX. It is wired to region_chips.clear + # below, once the chips widget exists. + self.clear_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_regions_btn.setToolTip("Clear all selected regions") + self.clear_regions_btn.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + # Both atlas widgets in the layout, but only one enabled/visible at a time atlas_row.addWidget(self.mesh_atlas_widget) atlas_row.addWidget(self.voxel_atlas_widget) atlas_row.addWidget(self.show_regions_btn) + atlas_row.addWidget(self.clear_regions_btn) atlas_row.addStretch() cortical_layout.addLayout(atlas_row) @@ -732,6 +742,9 @@ def create_analysis_parameters_widget(self, container_widget): self.region_chips.setSizePolicy(_chips_policy) cortical_layout.addWidget(self.region_chips) + # Now that region_chips exists, wire the "Clear" button created above. + self.clear_regions_btn.clicked.connect(self.region_chips.clear) + analysis_params_layout.addWidget(self.cortical_group) # ===================== Spherical target group ===================== @@ -798,6 +811,17 @@ def create_analysis_parameters_widget(self, container_widget): self.space_mesh.toggled.connect(self.update_cortical_button_text) self.space_voxel.toggled.connect(self.update_cortical_button_text) + # Reset the region selection whenever the analysis context changes: mesh + # and voxel regions (and different atlases) are not interchangeable, so a + # user-initiated Space switch or atlas change invalidates the current + # chips. These fire only on genuine user interaction — space_mesh.toggled + # emits once per real Space change (and is connected after the initial + # setChecked, so setup does not trigger it), while the combos' activated + # signal never fires on programmatic repopulation (clear/setCurrentIndex). + self.space_mesh.toggled.connect(self._reset_region_selection) + self.atlas_name_combo.activated.connect(self._reset_region_selection) + self.atlas_combo.activated.connect(self._reset_region_selection) + # (Removed) Analysis mode defaults (surface vs volumetric). Analyzer is surface-only. # Connect coordinate space radio buttons @@ -2177,6 +2201,18 @@ def enable_controls(self): self.status_label.hide() + def _reset_region_selection(self, *_args) -> None: + """Clear selected region chips when the analysis context changes. + + Connected to ``space_mesh.toggled`` and each atlas combo's ``activated`` + signal. Mesh vs voxel regions (and regions across different atlases) are + not interchangeable, so any user-initiated Space switch or atlas change + must reset the selection. ``*_args`` absorbs the ``bool``/``int`` payload + those signals emit. ``clear()`` is a no-op (and emits nothing) when no + regions are selected, so this is safe to fire on every such change. + """ + self.region_chips.clear() + def _get_regions(self) -> list[str]: """Return the selected region names from the region chips. @@ -2278,6 +2314,7 @@ def show_available_regions(self): entries, return_field="name", multi=True, + preselected=list(self.region_chips.keys()), ) if dialog.exec_() == QtWidgets.QDialog.Accepted: # Add each selected region as a de-duplicated chip. The key is the From b5099a718c52e9e0ffed4ccd9f00204baf56d1bc Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 19:57:15 -0500 Subject: [PATCH 26/27] region select: only clear chips on an actual atlas/space change (not re-selecting the same value) --- tit/gui/analyzer_tab.py | 11 +++++++++++ tit/gui/components/roi_picker.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 3004502d..cde94621 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -2210,7 +2210,18 @@ def _reset_region_selection(self, *_args) -> None: must reset the selection. ``*_args`` absorbs the ``bool``/``int`` payload those signals emit. ``clear()`` is a no-op (and emits nothing) when no regions are selected, so this is safe to fire on every such change. + + Guarded by the current (space, mesh-atlas, voxel-atlas) context so + re-selecting the same atlas/space does not needlessly wipe the chips. """ + key = ( + self.space_mesh.isChecked(), + self.atlas_name_combo.currentText(), + self.atlas_combo.currentText(), + ) + if key == getattr(self, "_last_region_context", None): + return + self._last_region_context = key self.region_chips.clear() def _get_regions(self) -> list[str]: diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index 43a6c2b1..dd5b8103 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -73,6 +73,11 @@ def __init__( self._subject_id: str | None = None self._project_dir: str | None = None + # Atlas/space the current chips belong to, so re-selecting the SAME + # value in a combo does not needlessly wipe the selection. + self._last_cortical_atlas = None + self._last_volume_key = None + self._setup_ui() self._connect_signals() @@ -502,6 +507,10 @@ def _on_cortical_atlas_changed(self, *_): start fresh. Wired to ``activated`` (user-only), so a programmatic repopulation during refresh does not clear a restored selection. """ + atlas = self.atlas_combo.currentText() + if atlas == self._last_cortical_atlas: + return + self._last_cortical_atlas = atlas self.cortical_chips.clear() def _on_volume_atlas_changed(self, *_): @@ -513,6 +522,11 @@ def _on_volume_atlas_changed(self, *_): user-only signals (``activated`` / ``buttonClicked``), so programmatic repopulation during refresh does not clear a restored selection. """ + space = "mni" if self.volume_mni_radio.isChecked() else "subject" + key = (space, self.volume_atlas_combo.currentText()) + if key == self._last_volume_key: + return + self._last_volume_key = key self.subcortical_chips.clear() # ------------------------------------------------------------------ From b44bf96592044f0c5d7613be6e92e74640794283 Mon Sep 17 00:00:00 2001 From: idossha Date: Wed, 8 Jul 2026 21:18:14 -0500 Subject: [PATCH 27/27] analyzer: keep independent Mesh/Voxel region selections (restore on Space switch instead of clearing) --- tit/gui/analyzer_tab.py | 64 ++++++++++++++++++++---------- tit/gui/components/region_chips.py | 8 ++++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index cde94621..91b87bc9 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -115,6 +115,14 @@ def __init__(self, parent=None): self._last_plain_output_line = None self._summary_printed = set() + # Per-Space region selection: Mesh and Voxel keep independent chip sets + # (like the optimizer's separate cortical/subcortical chips), so + # switching Space does not lose the other Space's selection. + self._region_store = {"mesh": [], "voxel": []} + self._current_region_space = "mesh" # default Space (space_mesh checked) + self._last_mesh_atlas = None + self._last_voxel_atlas = None + self.setup_ui() def showEvent(self, event): @@ -818,9 +826,9 @@ def create_analysis_parameters_widget(self, container_widget): # emits once per real Space change (and is connected after the initial # setChecked, so setup does not trigger it), while the combos' activated # signal never fires on programmatic repopulation (clear/setCurrentIndex). - self.space_mesh.toggled.connect(self._reset_region_selection) - self.atlas_name_combo.activated.connect(self._reset_region_selection) - self.atlas_combo.activated.connect(self._reset_region_selection) + self.space_mesh.toggled.connect(self._swap_region_space) + self.atlas_name_combo.activated.connect(self._on_analyzer_atlas_changed) + self.atlas_combo.activated.connect(self._on_analyzer_atlas_changed) # (Removed) Analysis mode defaults (surface vs volumetric). Analyzer is surface-only. @@ -2201,27 +2209,43 @@ def enable_controls(self): self.status_label.hide() - def _reset_region_selection(self, *_args) -> None: - """Clear selected region chips when the analysis context changes. + def _current_space_name(self) -> str: + return "mesh" if self.space_mesh.isChecked() else "voxel" - Connected to ``space_mesh.toggled`` and each atlas combo's ``activated`` - signal. Mesh vs voxel regions (and regions across different atlases) are - not interchangeable, so any user-initiated Space switch or atlas change - must reset the selection. ``*_args`` absorbs the ``bool``/``int`` payload - those signals emit. ``clear()`` is a no-op (and emits nothing) when no - regions are selected, so this is safe to fire on every such change. + def _swap_region_space(self, *_args) -> None: + """Preserve each Space's own region selection across a Space switch. - Guarded by the current (space, mesh-atlas, voxel-atlas) context so - re-selecting the same atlas/space does not needlessly wipe the chips. + Connected to ``space_mesh.toggled``. On a genuine Space change, the + current chips are snapshotted under the Space being left and the chips + for the Space being entered are restored (empty if never used), so Mesh + and Voxel keep independent selections. ``*_args`` absorbs the ``bool`` + payload; the connection is made after the initial ``setChecked`` so + setup does not trigger it. """ - key = ( - self.space_mesh.isChecked(), - self.atlas_name_combo.currentText(), - self.atlas_combo.currentText(), - ) - if key == getattr(self, "_last_region_context", None): + new_space = self._current_space_name() + if new_space == self._current_region_space: + return + self._region_store[self._current_region_space] = self.region_chips.items() + self.region_chips.set_items(self._region_store.get(new_space, [])) + self._current_region_space = new_space + + def _on_analyzer_atlas_changed(self, *_args) -> None: + """Reset the current Space's selection when its atlas actually changes. + + Regions are atlas-specific, so switching the atlas within a Space clears + that Space's chips and stored snapshot (single-atlas rule). Wired to the + combos' ``activated`` (user-only) signal and guarded so re-selecting the + same atlas — or a programmatic repopulation — does not wipe the chips. + """ + space = self._current_space_name() + if space == "mesh": + atlas, attr = self.atlas_name_combo.currentText(), "_last_mesh_atlas" + else: + atlas, attr = self.atlas_combo.currentText(), "_last_voxel_atlas" + if atlas == getattr(self, attr): return - self._last_region_context = key + setattr(self, attr, atlas) + self._region_store[space] = [] self.region_chips.clear() def _get_regions(self) -> list[str]: diff --git a/tit/gui/components/region_chips.py b/tit/gui/components/region_chips.py index d718e609..789ec4ce 100644 --- a/tit/gui/components/region_chips.py +++ b/tit/gui/components/region_chips.py @@ -321,6 +321,14 @@ def keys(self) -> List[str]: """Return the chip keys in insertion order.""" return list(self._items.keys()) + def items(self) -> List[Tuple[str, str]]: + """Return the chips as ``(key, display)`` tuples in insertion order. + + Suitable for snapshotting the current selection and restoring it later + via :meth:`set_items`. + """ + return list(self._items.items()) + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------