Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
003470c
flex: union of same-type ROIs (Phase 1 backend)
idossha Jul 8, 2026
a08f2b0
flex GUI: multi-region ROI picker (union) — Phase 2
idossha Jul 8, 2026
2960c09
ex-search: union of selected spherical ROIs (Phase 3)
idossha Jul 8, 2026
81f1326
Validate non-ROI picker in flex run_optimization
idossha Jul 8, 2026
cbc767b
flex/ex: review polish — union-operator consistency, display dedup, s…
idossha Jul 8, 2026
179db26
Add reusable AtlasRegionFinderDialog and HelpIcon GUI components
idossha Jul 8, 2026
95f9b2d
Rework ROI picker: click-to-load region finders + unified spheres table
idossha Jul 8, 2026
f4fbd15
Reuse shared region-finder and help-icon components in analyzer and e…
idossha Jul 8, 2026
6b73196
GUI: defer atlas region load to Add Selected; make HelpIcon click-to-…
idossha Jul 8, 2026
3385451
flex GUI: cortical ROI by region name; reorder ROI pages + dynamic he…
idossha Jul 8, 2026
012ddc4
roi_picker/help_icon: review polish — clear error on unresolvable cor…
idossha Jul 8, 2026
6745872
Resolve subcortical region names for subject FreeSurfer atlases via b…
idossha Jul 8, 2026
4a0c9c3
Add RegionChipsWidget reusable chips component
idossha Jul 8, 2026
6c523a2
roi_picker: use RegionChipsWidget as source of truth for cortical/sub…
idossha Jul 8, 2026
39ee88d
roi_picker: harden subcortical atlas-read fallback (ImageFileError); …
idossha Jul 8, 2026
abe16df
region finder: drop RGB colour swatch — show label number and name only
idossha Jul 8, 2026
7cdf537
Analyzer: replace region-name text field with shared RegionChipsWidget
idossha Jul 8, 2026
7583fec
analyzer: drop dead region-string fallbacks; alphabetize finder list
idossha Jul 8, 2026
c5e591b
GUI: full-width analyzer region chips row; column-order-agnostic LUT …
idossha Jul 8, 2026
1cd43d6
roi_picker: ignore mri_segstats sidecar for FS subject atlases — reso…
idossha Jul 9, 2026
f87aaea
Reorganize analyzer Analysis Configuration into adaptive target groups
idossha Jul 9, 2026
ad2828a
Fix analyzer Tissue selector hidden in Voxel+Spherical mode
idossha Jul 9, 2026
3afe3c5
Accept ids-or-names in AtlasRegionFinderDialog preselection
idossha Jul 9, 2026
7da3b1d
Enforce single-atlas ROI selection in roi_picker
idossha Jul 9, 2026
309ee92
Analyzer: mirror optimizer region-selection UX
idossha Jul 9, 2026
b5099a7
region select: only clear chips on an actual atlas/space change (not …
idossha Jul 9, 2026
b44bf96
analyzer: keep independent Mesh/Voxel region selections (restore on S…
idossha Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,397 changes: 1,397 additions & 0 deletions resources/atlas/FreeSurferColorLUT.txt

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions tests/test_opt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
166 changes: 166 additions & 0 deletions tests/test_opt_ex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Loading
Loading