Remove the detached islands charm's labeling.nii.gz leaves in a region.
Why this exists
m2m_<id>/segmentation/labeling.nii.gz is an atlas warped into the subject and
intersected with tissue labels, so a region is not guaranteed to be one connected
body. Measured on the packaged Ernie head model (2026-09-17, 26-connectivity,
scipy.ndimage.label):
=========================== ========== ====== ===== ========= ==========
region components total minor minor (%) farthest
=========================== ========== ====== ===== ========= ==========
Left-Putamen 10 6109 77 1.26 38.2 mm
Right-Thalamus-Proper 13 7346 70 0.95 44.3 mm
Right-Putamen 3 5727 3 0.05 13.2 mm
Left-Hippocampus 2 4298 2 0.05 19.4 mm
=========================== ========== ====== ===== ========= ==========
Left-Putamen's main body is 6032 voxels; the rest is one 67-voxel blob 36.9 mm
away plus seven specks of one or two voxels 22–38 mm away. That is exactly the
"second blob inferior-anterior plus grey debris" a user reported seeing in the
scene pane, and the left/right asymmetry (10 components against 3 for the same
structure in the same subject) is what says it is a segmentation artefact rather
than anatomy or a defect in our surface extraction.
So the islands are upstream data, and they were never only cosmetic: 77 voxels
sitting 37 mm outside the putamen are averaged into the ROI field and into a
focality denominator exactly like the other 6032. This module is therefore
applied to the mask used for search and analysis and to the display surface,
so the pane shows what will be optimised.
Threshold
A component is kept when it has at least max(5% of the largest component, 50
voxels) voxels. The 5% term is what separates a genuine bilateral or bipartite
structure from debris; the 50-voxel floor stops a tiny region (Optic-Chiasm is 90
voxels in Ernie) from being reduced to a single component by the ratio alone. On
the table above it removes the 67-voxel blob and every speck from Left-Putamen
and keeps every region whose components are real.
Escape hatch
TIT_ROI_KEEP_ISLANDS=1 disables the cleanup everywhere, for a subject whose
segmentation genuinely is multi-component.
cleanup_enabled
cleanup_enabled() -> bool
False when TIT_ROI_KEEP_ISLANDS asks for the raw segmentation.
Source code in tit/atlas/islands.py
| def cleanup_enabled() -> bool:
"""False when ``TIT_ROI_KEEP_ISLANDS`` asks for the raw segmentation."""
return os.environ.get("TIT_ROI_KEEP_ISLANDS", "").strip().lower() not in (
"1",
"true",
"yes",
"on",
)
|
keep_main_components
keep_main_components(mask, *, what: str = 'region')
Return mask without its detached islands, plus the number of voxels dropped.
mask is a boolean 3-D array. Connectivity is 26-neighbour (the most
generous, so nothing is split on a diagonal touch). Returns
(cleaned_mask, removed_voxels, removed_components); an empty mask, a
single-component mask, or a disabled cleanup returns the input unchanged with
(mask, 0, 0). The largest component is always kept, even when it is
below the absolute floor, so this can never empty a region. what only
names the region in the log line.
Source code in tit/atlas/islands.py
| def keep_main_components(mask, *, what: str = "region"):
"""Return *mask* without its detached islands, plus the number of voxels dropped.
*mask* is a boolean 3-D array. Connectivity is 26-neighbour (the most
generous, so nothing is split on a diagonal touch). Returns
``(cleaned_mask, removed_voxels, removed_components)``; an empty mask, a
single-component mask, or a disabled cleanup returns the input unchanged with
``(mask, 0, 0)``. The largest component is *always* kept, even when it is
below the absolute floor, so this can never empty a region. *what* only
names the region in the log line.
"""
import numpy as np
mask = np.asarray(mask, dtype=bool)
if not cleanup_enabled() or not mask.any():
return mask, 0, 0
from scipy import ndimage
labelled, count = ndimage.label(
mask, structure=ndimage.generate_binary_structure(3, 3)
)
if count < 2:
return mask, 0, 0
sizes = np.bincount(labelled.ravel())
sizes[0] = 0
largest = int(sizes.max())
threshold = max(MIN_VOXELS, largest * MIN_FRACTION_OF_LARGEST)
keep = sizes >= threshold
# The largest component always survives, whatever the floor says. Measured
# 2026-09-17 while building the MNI guide: a thalamic atlas's mammillothalamic
# tract was 43 voxels in two pieces, so a flat 50-voxel floor deleted the
# *whole region* and the surface build died on an empty mask. A cleanup that can
# empty an ROI is worse than the islands it removes.
keep[int(np.argmax(sizes))] = True
cleaned = keep[labelled]
removed = int(mask.sum() - cleaned.sum())
dropped = int(count - keep[1:].sum())
if removed:
logger.info(
"%s: removed %d detached voxel(s) in %d island(s) from the "
"segmentation; the largest component has %d voxels "
"(set TIT_ROI_KEEP_ISLANDS=1 to keep them)",
what,
removed,
dropped,
largest,
)
return cleaned, removed, dropped
|
cleaned_label_mask
cleaned_label_mask(atlas_path: str, label: int, output_dir: str) -> str | None
Write a binary mask of label with its islands removed; None if there are none.
None is the common answer and means "use the atlas and the label directly":
the optimizers pass (atlas_path, label) straight to SimNIBS, and there is no
reason to materialise a file, or to change what they do, for a region that is
already one connected body. When there are islands the caller uses the
returned path with mask value 1 instead.
The file is named from the atlas's path, size and modification time plus the
label, so a second search on the same subject reuses it rather than rewriting it.
Source code in tit/atlas/islands.py
| def cleaned_label_mask(atlas_path: str, label: int, output_dir: str) -> str | None:
"""Write a binary mask of *label* with its islands removed; ``None`` if there are none.
``None`` is the common answer and means "use the atlas and the label directly":
the optimizers pass ``(atlas_path, label)`` straight to SimNIBS, and there is no
reason to materialise a file, or to change what they do, for a region that is
already one connected body. When there *are* islands the caller uses the
returned path with mask value ``1`` instead.
The file is named from the atlas's path, size and modification time plus the
label, so a second search on the same subject reuses it rather than rewriting it.
"""
import hashlib
from pathlib import Path
import nibabel as nib
import numpy as np
if not cleanup_enabled():
return None
source = Path(atlas_path)
stat = source.stat()
key = hashlib.sha256(
f"{source.resolve()}|{stat.st_size}|{int(stat.st_mtime)}|{label}|"
f"{MIN_FRACTION_OF_LARGEST}|{MIN_VOXELS}".encode()
).hexdigest()[:16]
destination = Path(output_dir) / f"roi-{source.stem.split('.')[0]}-{label}-{key}.nii"
if destination.is_file():
return str(destination)
image = nib.load(str(source))
mask = np.asanyarray(image.dataobj) == label
if not mask.any():
return None
cleaned, removed, _ = keep_main_components(mask, what=f"{source.name} label {label}")
if not removed:
return None
destination.parent.mkdir(parents=True, exist_ok=True)
# The source header is deliberately not reused: it carries the atlas's own
# datatype and scaling, and this file is a plain 0/1 mask.
nib.save(nib.Nifti1Image(cleaned.astype(np.uint8), image.affine), str(destination))
return str(destination)
|