Skip to content

roi_spec

tit.opt.roi_spec

Pure-Python ROI-spec resolution for flex-search targets.

Extracted from the former PyQt RoiPickerWidget (as of the v3 audit): the parts of that widget that turn a selection (an atlas display name + region names, a volume atlas path + integer labels, or explicit sphere centers/radii) into one of :class:tit.opt.config.FlexConfig's nested ROI dataclasses (SphericalROI, AtlasROI, SubcorticalROI), plus the LUT/label name resolution the widget uses to show human-readable region names.

Nothing here imports PyQt. Every function takes plain values (subject id, project dir, atlas/region names, coordinates) instead of reading Qt widget state, so it is reusable from :mod:tit.server.routes.plan, notebooks, and future non-Qt UIs alike.

Public API

get_roi_spec Build a SphericalROI / AtlasROI / SubcorticalROI instance from plain parameters. resolve_cortical_region_index_map (hemisphere, region_name) -> annot label index for one atlas. resolve_atlas_name_for_subject Subject-prefixed atlas base name ("{subject_id}_{atlas_display}"). resolve_volume_atlas_path Absolute path to a volumetric atlas file from its display name/space. resolve_volume_label_names label_id -> region name for a volumetric atlas, via its sidecar LUT (or, absent one, the bundled FreeSurfer colour table).

See Also

tit.atlas.mesh.MeshAtlasManager : Cortical (.annot) atlas discovery. tit.atlas.voxel.VoxelAtlasManager : Volumetric atlas discovery. tit.opt.config.FlexConfig : Owner of the three nested ROI dataclasses this module builds.

parse_region_names

parse_region_names(text: str) -> list[tuple[str, str]]

Parse a comma-separated list of hemisphere-prefixed region names.

Parameters

text : str E.g. "lh.precentral, rh.superiorfrontal".

Returns

list of (str, str) (hemisphere, region_name) tuples, hemisphere lowercased.

Raises

ValueError If any non-empty token is not "<lh|rh>.<name>".

Source code in tit/opt/roi_spec.py
def parse_region_names(text: str) -> list[tuple[str, str]]:
    """Parse a comma-separated list of hemisphere-prefixed region names.

    Parameters
    ----------
    text : str
        E.g. ``"lh.precentral, rh.superiorfrontal"``.

    Returns
    -------
    list of (str, str)
        ``(hemisphere, region_name)`` tuples, hemisphere lowercased.

    Raises
    ------
    ValueError
        If any non-empty token is not ``"<lh|rh>.<name>"``.
    """
    tokens: list[tuple[str, str]] = []
    for part in text.split(","):
        part = part.strip()
        if not part:
            continue
        if "." not in part:
            raise ValueError(
                f"Region {part!r} must be hemisphere-prefixed, e.g. 'lh.precentral'"
            )
        hemi, name = part.split(".", 1)
        hemi = hemi.strip().lower()
        if hemi not in ("lh", "rh"):
            raise ValueError(f"Region {part!r} hemisphere must be 'lh' or 'rh'")
        tokens.append((hemi, name.strip()))
    return tokens

resolve_atlas_name_for_subject

resolve_atlas_name_for_subject(atlas_display: str, subject_id: str) -> str

Convert an atlas display name to the subject-specific atlas base name.

The display name is already the atlas type (e.g. "DK40", "HCP_MMP1") because :meth:MeshAtlasManager.find_all_atlases strips the subject prefix; this just prepends the target subject id, mirroring RoiPickerWidget._resolve_atlas_name_for_subject.

Source code in tit/opt/roi_spec.py
def resolve_atlas_name_for_subject(atlas_display: str, subject_id: str) -> str:
    """Convert an atlas display name to the subject-specific atlas base name.

    The display name is already the atlas type (e.g. ``"DK40"``,
    ``"HCP_MMP1"``) because :meth:`MeshAtlasManager.find_all_atlases` strips
    the subject prefix; this just prepends the target subject id, mirroring
    ``RoiPickerWidget._resolve_atlas_name_for_subject``.
    """
    return f"{subject_id}_{atlas_display}"

resolve_cortical_region_index_map

resolve_cortical_region_index_map(seg_dir: str, atlas_display: str) -> dict[tuple[str, str], int]

Map (hemisphere, region_name) -> annot label index for one atlas.

Parameters

seg_dir : str m2m_{subject}/segmentation/ directory. atlas_display : str Atlas display name as returned by :meth:MeshAtlasManager.find_all_atlases (e.g. "DK40").

Returns

dict Empty when the atlas's .annot files cannot be read (e.g. no segmentation for this subject yet).

Source code in tit/opt/roi_spec.py
def resolve_cortical_region_index_map(
    seg_dir: str, atlas_display: str
) -> dict[tuple[str, str], int]:
    """Map ``(hemisphere, region_name) -> annot label index`` for one atlas.

    Parameters
    ----------
    seg_dir : str
        ``m2m_{subject}/segmentation/`` directory.
    atlas_display : str
        Atlas display name as returned by
        :meth:`MeshAtlasManager.find_all_atlases` (e.g. ``"DK40"``).

    Returns
    -------
    dict
        Empty when the atlas's ``.annot`` files cannot be read (e.g. no
        segmentation for this subject yet).
    """
    mgr = MeshAtlasManager(seg_dir)
    mapping: dict[tuple[str, str], int] = {}
    for hemi in ("lh", "rh"):
        annot_file = mgr.find_all_atlases(hemi).get(atlas_display)
        if not annot_file:
            continue
        try:
            regions = mgr.list_annot_regions(annot_file)
        except (OSError, ValueError):
            continue
        for idx, name in regions:
            mapping[(hemi, name)] = idx
    return mapping

build_cortical_atlas_roi

build_cortical_atlas_roi(*, subject_id: str, seg_dir: str, atlas_display: str, regions: list[tuple[str, str]], roi_cls: type = FlexConfig) -> Any

Build an AtlasROI from hemisphere-prefixed cortical region names.

Parameters

subject_id : str Subject identifier (no sub- prefix). seg_dir : str m2m_{subject}/segmentation/ directory. atlas_display : str Atlas display name (e.g. "DK40"). regions : list of (str, str) (hemisphere, region_name) tuples, as returned by :func:parse_region_names. roi_cls : type, optional The config class owning the AtlasROI dataclass to build. Defaults to :class:~tit.opt.config.FlexConfig.

Returns

roi_cls.AtlasROI A union across every resolved region (parallel atlas_path / hemisphere / label lists), scalar-collapsed for a single region.

Raises

ValueError If none of regions resolve to a known annotation label.

Source code in tit/opt/roi_spec.py
def build_cortical_atlas_roi(
    *,
    subject_id: str,
    seg_dir: str,
    atlas_display: str,
    regions: list[tuple[str, str]],
    roi_cls: type = FlexConfig,
) -> Any:
    """Build an ``AtlasROI`` from hemisphere-prefixed cortical region names.

    Parameters
    ----------
    subject_id : str
        Subject identifier (no ``sub-`` prefix).
    seg_dir : str
        ``m2m_{subject}/segmentation/`` directory.
    atlas_display : str
        Atlas display name (e.g. ``"DK40"``).
    regions : list of (str, str)
        ``(hemisphere, region_name)`` tuples, as returned by
        :func:`parse_region_names`.
    roi_cls : type, optional
        The config class owning the ``AtlasROI`` dataclass to build.
        Defaults to :class:`~tit.opt.config.FlexConfig`.

    Returns
    -------
    roi_cls.AtlasROI
        A union across every resolved region (parallel ``atlas_path`` /
        ``hemisphere`` / ``label`` lists), scalar-collapsed for a single
        region.

    Raises
    ------
    ValueError
        If none of *regions* resolve to a known annotation label.
    """
    atlas_name = resolve_atlas_name_for_subject(atlas_display, subject_id)
    name_map = resolve_cortical_region_index_map(seg_dir, atlas_display)

    atlas_paths, hemispheres, labels = [], [], []
    for hemi, name in regions:
        index = name_map.get((hemi, name))
        if index is None:
            continue
        labels.append(index)
        hemispheres.append(hemi)
        atlas_paths.append(os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot"))

    if not labels:
        raise ValueError(
            f"Could not resolve any cortical region name(s) for atlas "
            f"{atlas_name!r}; the annotation files could not be read."
        )

    return roi_cls.AtlasROI(
        atlas_path=_collapse(atlas_paths),
        label=_collapse(labels),
        hemisphere=_collapse(hemispheres),
    )

resolve_volume_atlas_path

resolve_volume_atlas_path(*, subject_id: str, seg_dir: str, freesurfer_mri_dir: str = '', atlas_filename: str, atlas_space: str, fastsurfer_mri_dir: str = '') -> str

Resolve a volumetric atlas display name/space to an absolute path.

Mirrors RoiPickerWidget._selected_volume_atlas_path for the cases that do not already carry a full path (e.g. from :meth:tit.atlas.voxel.VoxelAtlasManager.list_atlases, which returns (display_name, path) pairs directly -- pass path straight through in that case, this helper is only needed for a bare filename).

Parameters

subject_id : str Subject identifier. seg_dir : str m2m_{subject}/segmentation/ directory (for labeling.nii.gz). freesurfer_mri_dir : str, optional Legacy FreeSurfer mri/ directory (recon-all volume atlases). atlas_filename : str Bare atlas filename (e.g. "aparc.DKTatlas+aseg.deep.mgz"). atlas_space : str "subject" or "mni". fastsurfer_mri_dir : str, optional FastSurfer mri/ directory. Searched before freesurfer_mri_dir, matching :meth:tit.atlas.voxel.VoxelAtlasManager.list_atlases.

Source code in tit/opt/roi_spec.py
def resolve_volume_atlas_path(
    *,
    subject_id: str,
    seg_dir: str,
    freesurfer_mri_dir: str = "",
    atlas_filename: str,
    atlas_space: str,
    fastsurfer_mri_dir: str = "",
) -> str:
    """Resolve a volumetric atlas display name/space to an absolute path.

    Mirrors ``RoiPickerWidget._selected_volume_atlas_path`` for the cases
    that do not already carry a full path (e.g. from
    :meth:`tit.atlas.voxel.VoxelAtlasManager.list_atlases`, which returns
    ``(display_name, path)`` pairs directly -- pass ``path`` straight
    through in that case, this helper is only needed for a bare filename).

    Parameters
    ----------
    subject_id : str
        Subject identifier.
    seg_dir : str
        ``m2m_{subject}/segmentation/`` directory (for ``labeling.nii.gz``).
    freesurfer_mri_dir : str, optional
        Legacy FreeSurfer ``mri/`` directory (``recon-all`` volume atlases).
    atlas_filename : str
        Bare atlas filename (e.g. ``"aparc.DKTatlas+aseg.deep.mgz"``).
    atlas_space : str
        ``"subject"`` or ``"mni"``.
    fastsurfer_mri_dir : str, optional
        FastSurfer ``mri/`` directory. Searched before *freesurfer_mri_dir*,
        matching :meth:`tit.atlas.voxel.VoxelAtlasManager.list_atlases`.
    """
    if str(atlas_space).lower() == "mni":
        from tit.atlas.manifest import check_shipped

        check_shipped(atlas_filename)
        return os.path.join(_mni_atlas_dir(), atlas_filename)
    if atlas_filename == "labeling.nii.gz":
        return os.path.join(seg_dir, atlas_filename)
    if fastsurfer_mri_dir:
        candidate = os.path.join(fastsurfer_mri_dir, atlas_filename)
        if os.path.isfile(candidate) or not freesurfer_mri_dir:
            return candidate
    return os.path.join(freesurfer_mri_dir, atlas_filename)

build_subcortical_roi

build_subcortical_roi(*, atlas_path: str, labels: list[int], tissues: str = 'GM', atlas_space: str = 'subject', roi_cls: type = FlexConfig) -> Any

Build a SubcorticalROI from a resolved atlas path and labels.

Parameters

atlas_path : str Absolute path to the volumetric atlas/mask file. labels : list of int Integer label(s) within the atlas. tissues : str, optional "GM", "WM", or "both". atlas_space : str, optional "subject" or "mni". roi_cls : type, optional The config class owning the SubcorticalROI dataclass to build.

Source code in tit/opt/roi_spec.py
def build_subcortical_roi(
    *,
    atlas_path: str,
    labels: list[int],
    tissues: str = "GM",
    atlas_space: str = "subject",
    roi_cls: type = FlexConfig,
) -> Any:
    """Build a ``SubcorticalROI`` from a resolved atlas path and labels.

    Parameters
    ----------
    atlas_path : str
        Absolute path to the volumetric atlas/mask file.
    labels : list of int
        Integer label(s) within the atlas.
    tissues : str, optional
        ``"GM"``, ``"WM"``, or ``"both"``.
    atlas_space : str, optional
        ``"subject"`` or ``"mni"``.
    roi_cls : type, optional
        The config class owning the ``SubcorticalROI`` dataclass to build.
    """
    return roi_cls.SubcorticalROI(
        atlas_path=atlas_path,
        label=_collapse(list(labels)),
        tissues=tissues,
        atlas_space=atlas_space,
    )

build_spherical_roi

build_spherical_roi(*, centers: list[tuple[float, float, float]], radii: list[float], use_mni: bool = False, volumetric: bool = False, tissues: str = 'GM', roi_cls: type = FlexConfig) -> Any

Build a SphericalROI from one or more sphere centers/radii.

Source code in tit/opt/roi_spec.py
def build_spherical_roi(
    *,
    centers: list[tuple[float, float, float]],
    radii: list[float],
    use_mni: bool = False,
    volumetric: bool = False,
    tissues: str = "GM",
    roi_cls: type = FlexConfig,
) -> Any:
    """Build a ``SphericalROI`` from one or more sphere centers/radii."""
    return roi_cls.SphericalROI(
        x=_collapse([c[0] for c in centers]),
        y=_collapse([c[1] for c in centers]),
        z=_collapse([c[2] for c in centers]),
        radius=_collapse(list(radii)),
        use_mni=use_mni,
        volumetric=volumetric,
        tissues=tissues if volumetric else "GM",
    )

get_roi_spec

get_roi_spec(roi_type: str, *, subject_id: str, seg_dir: str, freesurfer_mri_dir: str = '', fastsurfer_mri_dir: str = '', roi_cls: type = FlexConfig, centers: list[tuple[float, float, float]] | None = None, radii: list[float] | None = None, use_mni: bool = False, volumetric: bool = False, sphere_tissues: str = 'GM', atlas_display: str | None = None, regions: list[tuple[str, str]] | None = None, volume_atlas_path: str | None = None, volume_atlas_filename: str | None = None, volume_atlas_space: str = 'subject', subcortical_labels: list[int] | None = None, subcortical_tissues: str = 'GM') -> Any

Build the ROI dataclass matching roi_type from plain parameters.

The single entry point mirroring RoiPickerWidget.get_roi_spec, split into three dispatchable builders (:func:build_spherical_roi, :func:build_cortical_atlas_roi, :func:build_subcortical_roi) so callers that already know their ROI type can call the specific builder directly instead.

Parameters

roi_type : str One of "spherical", "atlas"/"cortical", "subcortical". subject_id, seg_dir, freesurfer_mri_dir, fastsurfer_mri_dir : str Subject context (the three directory arguments are unused for the "spherical" type). roi_cls : type, optional The config class owning the ROI dataclasses to build. Defaults to :class:~tit.opt.config.FlexConfig.

Returns

roi_cls.SphericalROI or roi_cls.AtlasROI or roi_cls.SubcorticalROI

Raises

ValueError If roi_type is unknown, or a required parameter for that type is missing (mirrors the nested dataclasses' own __post_init__ errors where possible).

Source code in tit/opt/roi_spec.py
def get_roi_spec(
    roi_type: str,
    *,
    subject_id: str,
    seg_dir: str,
    freesurfer_mri_dir: str = "",
    fastsurfer_mri_dir: str = "",
    roi_cls: type = FlexConfig,
    # spherical
    centers: list[tuple[float, float, float]] | None = None,
    radii: list[float] | None = None,
    use_mni: bool = False,
    volumetric: bool = False,
    sphere_tissues: str = "GM",
    # cortical
    atlas_display: str | None = None,
    regions: list[tuple[str, str]] | None = None,
    # subcortical
    volume_atlas_path: str | None = None,
    volume_atlas_filename: str | None = None,
    volume_atlas_space: str = "subject",
    subcortical_labels: list[int] | None = None,
    subcortical_tissues: str = "GM",
) -> Any:
    """Build the ROI dataclass matching *roi_type* from plain parameters.

    The single entry point mirroring ``RoiPickerWidget.get_roi_spec``, split
    into three dispatchable builders (:func:`build_spherical_roi`,
    :func:`build_cortical_atlas_roi`, :func:`build_subcortical_roi`) so
    callers that already know their ROI type can call the specific builder
    directly instead.

    Parameters
    ----------
    roi_type : str
        One of ``"spherical"``, ``"atlas"``/``"cortical"``, ``"subcortical"``.
    subject_id, seg_dir, freesurfer_mri_dir, fastsurfer_mri_dir : str
        Subject context (the three directory arguments are unused for the
        ``"spherical"`` type).
    roi_cls : type, optional
        The config class owning the ROI dataclasses to build. Defaults to
        :class:`~tit.opt.config.FlexConfig`.

    Returns
    -------
    roi_cls.SphericalROI or roi_cls.AtlasROI or roi_cls.SubcorticalROI

    Raises
    ------
    ValueError
        If *roi_type* is unknown, or a required parameter for that type is
        missing (mirrors the nested dataclasses' own ``__post_init__``
        errors where possible).
    """
    kind = str(roi_type).strip().lower()
    if kind == "spherical":
        if not centers or not radii:
            raise ValueError("spherical ROI requires centers and radii")
        return build_spherical_roi(
            centers=centers,
            radii=radii,
            use_mni=use_mni,
            volumetric=volumetric,
            tissues=sphere_tissues,
            roi_cls=roi_cls,
        )
    if kind in ("atlas", "cortical"):
        if not atlas_display or not regions:
            raise ValueError("cortical ROI requires atlas_display and regions")
        return build_cortical_atlas_roi(
            subject_id=subject_id,
            seg_dir=seg_dir,
            atlas_display=atlas_display,
            regions=regions,
            roi_cls=roi_cls,
        )
    if kind == "subcortical":
        if not subcortical_labels:
            raise ValueError("subcortical ROI requires subcortical_labels")
        path = volume_atlas_path
        if path is None:
            if not volume_atlas_filename:
                raise ValueError(
                    "subcortical ROI requires volume_atlas_path or "
                    "volume_atlas_filename"
                )
            path = resolve_volume_atlas_path(
                subject_id=subject_id,
                seg_dir=seg_dir,
                freesurfer_mri_dir=freesurfer_mri_dir,
                fastsurfer_mri_dir=fastsurfer_mri_dir,
                atlas_filename=volume_atlas_filename,
                atlas_space=volume_atlas_space,
            )
        return build_subcortical_roi(
            atlas_path=path,
            labels=subcortical_labels,
            tissues=subcortical_tissues,
            atlas_space=volume_atlas_space,
            roi_cls=roi_cls,
        )
    raise ValueError(
        f"Unknown roi_type {roi_type!r}; expected 'spherical', 'atlas', or "
        "'subcortical'"
    )

resolve_volume_label_names

resolve_volume_label_names(atlas_path: str, atlas_space: str = 'subject') -> dict[int, str]

Resolve label_id -> region name for a volumetric atlas.

Reads a sidecar LUT when one exists next to atlas_path; otherwise falls back to the unique nonzero integer labels actually present in the volume (via nibabel), named from the bundled standard FreeSurfer colour table. Mirrors RoiPickerWidget._subcortical_id_name_map / _resolve_volume_label_entries.

Parameters

atlas_path : str Absolute path to the atlas volume (.mgz/.nii/.nii.gz). atlas_space : str, optional "subject" or "mni" -- only affects sidecar-LUT discovery.

Returns

dict Empty when the atlas cannot be read (e.g. nibabel not installed, or no subject data yet) rather than raising -- callers fall back to showing the bare integer label.

Source code in tit/opt/roi_spec.py
def resolve_volume_label_names(
    atlas_path: str, atlas_space: str = "subject"
) -> dict[int, str]:
    """Resolve ``label_id -> region name`` for a volumetric atlas.

    Reads a sidecar LUT when one exists next to *atlas_path*; otherwise
    falls back to the unique nonzero integer labels actually present in the
    volume (via nibabel), named from the bundled standard FreeSurfer colour
    table. Mirrors ``RoiPickerWidget._subcortical_id_name_map`` /
    ``_resolve_volume_label_entries``.

    Parameters
    ----------
    atlas_path : str
        Absolute path to the atlas volume (``.mgz``/``.nii``/``.nii.gz``).
    atlas_space : str, optional
        ``"subject"`` or ``"mni"`` -- only affects sidecar-LUT discovery.

    Returns
    -------
    dict
        Empty when the atlas cannot be read (e.g. nibabel not installed, or
        no subject data yet) rather than raising -- callers fall back to
        showing the bare integer label.
    """
    lut_file = _find_volume_lut(atlas_path, atlas_space)
    if lut_file is not None:
        names: dict[int, str] = {}
        try:
            with open(lut_file, "r") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    parsed = _parse_lut_line(line)
                    if parsed is None:
                        continue
                    label_id, label_name, _ = parsed
                    names[int(label_id)] = label_name
            return names
        except OSError:
            return {}

    try:
        import numpy as np
        import nibabel as nib
    except ImportError:
        return {}

    lut = _load_freesurfer_lut()
    if not lut and not _is_custom_mask(atlas_path):
        return {}

    try:
        img = nib.load(atlas_path)
        present = np.unique(np.asarray(img.dataobj))
    except (OSError, ValueError):
        return {}

    keep_unknown = _is_custom_mask(atlas_path)
    names = {}
    for value in present:
        label_id = int(value)
        if label_id == 0:
            continue
        info = lut.get(label_id)
        if info is None:
            if not keep_unknown:
                continue
            names[label_id] = f"Label {label_id}"
            continue
        names[label_id] = info[0]
    return names

region_name

region_name(atlas_path: str, label: Any, atlas_space: str = 'subject') -> str

One atlas region's display name, never raising.

An .annot label is named from that file's colortable (lh.insula), a volume label from the atlas's colour table (:func:resolve_volume_label_names; a shipped MNI atlas's from the table its manifest entry names). An atlas recorded inside the container but read on the host is looked up in this machine's resources. lh label 3 / label 10 when unreadable.

Source code in tit/opt/roi_spec.py
def region_name(atlas_path: str, label: Any, atlas_space: str = "subject") -> str:
    """One atlas region's display name, never raising.

    An ``.annot`` label is named from that file's colortable (``lh.insula``), a volume label from
    the atlas's colour table (:func:`resolve_volume_label_names`; a shipped MNI atlas's from the
    table its manifest entry names). An atlas recorded inside the container but read on the host
    is looked up in this machine's resources. ``lh label 3`` / ``label 10`` when unreadable.
    """
    path = str(atlas_path)
    if path.endswith(".annot"):
        hemi = Path(path).name.partition(".")[0]
        try:
            from nibabel.freesurfer import read_annot

            region = read_annot(path)[2][int(label)]
            return f"{hemi}.{region.decode() if isinstance(region, bytes) else region}"
        except (OSError, ValueError, IndexError, TypeError):
            return f"{hemi} label {label}"
    if label is None:
        return f"all of {_strip_nifti_suffix(Path(path).name)}"
    if not os.path.isfile(path):
        from tit.paths import resolve_resource_path

        path = resolve_resource_path("atlas", os.path.basename(path))
    try:
        key = int(label)
    except (TypeError, ValueError):
        return f"label {label}"
    return resolve_volume_label_names(path, atlas_space).get(key, f"label {label}")

config_target

config_target(config: dict) -> str | None

What a flex-, ex- or mex-search config targets, by name; None when it names none.

A FlexConfig.roi object (_type SubcorticalROI / AtlasROI: its regions via :func:region_name; SphericalROI: each centre and radius), or an ex/mex config's ROI CSV names (roi_names when given, else roi_name) and roi_atlas regions. Names repeat once, in order, joined by ", ".

Source code in tit/opt/roi_spec.py
def config_target(config: dict) -> str | None:
    """What a flex-, ex- or mex-search config targets, by name; ``None`` when it names none.

    A ``FlexConfig.roi`` object (``_type`` ``SubcorticalROI`` / ``AtlasROI``: its regions via
    :func:`region_name`; ``SphericalROI``: each centre and radius), or an ex/mex config's ROI CSV
    names (``roi_names`` when given, else ``roi_name``) and ``roi_atlas`` regions. Names repeat
    once, in order, joined by ", ".
    """
    names: list[str] = []
    roi = config.get("roi")
    if isinstance(roi, dict) and roi.get("_type") == "SphericalROI":
        radii = _as_list(roi.get("radius", 10.0))
        mni = " MNI" if roi.get("use_mni") else ""
        centres = zip(*(_as_list(roi.get(axis)) for axis in "xyz"))
        for i, (x, y, z) in enumerate(centres):
            r = radii[i] if i < len(radii) else radii[0]
            names.append(
                f"sphere at ({_mm(x)}, {_mm(y)}, {_mm(z)}) mm{mni}, radius {_mm(r)} mm"
            )
    elif isinstance(roi, dict) and roi.get("_type") in ("AtlasROI", "SubcorticalROI"):
        labels = _as_list(roi.get("label"))
        paths = _as_list(roi.get("atlas_path"))
        paths = paths * len(labels) if len(paths) == 1 else paths
        space = roi.get("atlas_space", "subject")
        names += [region_name(p, lab, space) for p, lab in zip(paths, labels)]
    csvs = config.get("roi_names")
    if csvs is None:
        csvs = [config["roi_name"]] if config.get("roi_name") else []
    names += [str(name).removesuffix(".csv") for name in _as_list(csvs)]
    for atlas in config.get("roi_atlas") or []:
        if isinstance(atlas, dict):
            names.append(
                region_name(
                    atlas.get("atlas_path", ""),
                    atlas.get("label"),
                    atlas.get("atlas_space", "subject"),
                )
            )
    return ", ".join(dict.fromkeys(names)) or None