The tit.tools module provides standalone utilities for mesh and field manipulation, format conversion, electrode mapping, and visualization. These tools are typically called from scripts, the CLI, or other pipeline stages rather than forming a pipeline of their own.
Mesh / NIfTI Conversion
Mesh to NIfTI
Convert SimNIBS .msh mesh files to NIfTI volumes in subject or MNI space:
from tit.tools.mesh2nii import msh_to_nifti, msh_to_mni, convert_mesh_dir
# Single mesh to subject-space NIfTI
msh_to_nifti(
mesh_path="TI_max.msh",
m2m_dir="/data/project/derivatives/SimNIBS/sub-001/m2m_001",
output_path="/data/output/TI_max_subject",
fields=["TI_max"], # optional: convert only specific fields
)
# Single mesh to MNI-space NIfTI
msh_to_mni(
mesh_path="TI_max.msh",
m2m_dir="/data/project/derivatives/SimNIBS/sub-001/m2m_001",
output_path="/data/output/TI_max_MNI",
)
# Batch-convert all meshes in a directory (both subject and MNI space)
convert_mesh_dir(
mesh_dir="/data/project/derivatives/SimNIBS/sub-001/Simulations/motor/TI/mesh",
output_dir="/data/output/niftis",
m2m_dir="/data/project/derivatives/SimNIBS/sub-001/m2m_001",
fields=["TI_max"],
skip_patterns=["normal"], # skip surface-only meshes (default)
)
NIfTI to Mesh
Convert a NIfTI segmentation or mask to a surface mesh (.stl or .msh) using marching cubes:
from tit.tools.nifti_to_mesh import nifti_to_mesh
result = nifti_to_mesh(
input_file="segmentation.nii.gz",
output_file="thalamus.stl", # .stl or .msh; defaults to .stl
clean_components=True, # remove small disconnected pieces
clean_threshold=0.1, # keep components >= 10% of largest
)
print(result)
# {'vertices': 12345, 'faces': 24680, 'output_file': 'thalamus.stl', 'removed_components': 3}
Extract grey-matter and white-matter sub-meshes from a full SimNIBS head mesh:
from tit.tools.field_extract import main as extract_fields
extract_fields(
input_file="/data/sims/TI_max.msh",
subject_id="001",
# gm_output_file and wm_output_file default to BIDS-compliant paths
)
Visualization
Montage Visualizer
Render a PNG showing electrode positions and connection arcs on the EEG cap template:
from tit.tools.montage_visualizer import visualize_montage
visualize_montage(
montage_name="motor_cortex",
electrode_pairs=[["E030", "E020"], ["E095", "E070"]],
eeg_net="GSN-HydroCel-185.csv",
output_dir="/data/output/montage_imgs",
sim_mode="U", # "U" = one image per montage, "M" = combined image
)
Gmsh Option Files
Generate .opt files for Gmsh visualization of mesh fields:
from tit.tools.gmsh_opt import create_mesh_opt_file
opt_path = create_mesh_opt_file(
mesh_path="/data/sims/TI_max.msh",
field_info={
"fields": ["TI_max", "magnE"],
"max_values": {"TI_max": 0.25, "magnE": 0.5},
},
)
# writes /data/sims/TI_max.msh.opt
Atlas and Labels
Extract specific label values from a NIfTI segmentation file:
from tit.tools.extract_labels import extract_labels
output = extract_labels(
input_file="atlas_parcellation.nii.gz",
labels=[10, 11, 12], # label integers to keep
output_file="extracted.nii.gz", # optional; defaults to *_extracted suffix
)
FreeSurfer Annotation Reading
Read and inspect FreeSurfer .annot annotation files:
from tit.tools.read_annot import read_annot_file
# Print all regions and optionally look up a specific vertex
read_annot_file(
annot_path="/data/freesurfer/sub-001/label/lh.aparc.annot",
vertex_id=1024, # optional
)
Electrode Mapping
Map optimized electrode positions to the nearest positions in a physical EEG net using the Hungarian algorithm:
from tit.tools.map_electrodes import (
read_csv_positions,
load_electrode_positions_json,
map_electrodes_to_net,
save_mapping_result,
log_mapping_summary,
)
# Load net positions from SimNIBS CSV
net_positions, net_labels = read_csv_positions("GSN-HydroCel-256.csv")
# Load optimized positions from flex-search output
opt_positions, channel_indices = load_electrode_positions_json(
"electrode_positions.json"
)
# Compute optimal assignment
mapping = map_electrodes_to_net(
opt_positions, net_positions, net_labels, channel_indices
)
# Save and log
save_mapping_result(mapping, "mapping_result.json", eeg_net_name="GSN-HydroCel-256")
log_mapping_summary(mapping)
API Reference
Mesh to NIfTI
msh_to_nifti(mesh_path: str, m2m_dir: str, output_path: str, fields: list[str] | None = None, tags: list[int] | None = None) -> None
Convert a mesh file to subject-space NIfTI.
mesh_path : str
Path to the .msh file.
m2m_dir : str
Path to the m2m_{subject} directory (used as reference grid).
output_path : str
Output file prefix. SimNIBS appends the field name
(e.g. prefix_magnE.nii.gz).
fields : list[str] | None
If given, only these fields are written. Otherwise all fields in the
mesh are converted.
tags : list[int] | None
If given, the mesh is first cropped to the elements with these tissue tags
(:data:TISSUE_TAGS).
Source code in tit/tools/mesh2nii.py
| def msh_to_nifti(
mesh_path: str,
m2m_dir: str,
output_path: str,
fields: list[str] | None = None,
tags: list[int] | None = None,
) -> None:
"""Convert a mesh file to subject-space NIfTI.
Parameters
----------
mesh_path : str
Path to the ``.msh`` file.
m2m_dir : str
Path to the ``m2m_{subject}`` directory (used as reference grid).
output_path : str
Output file prefix. SimNIBS appends the field name
(e.g. ``prefix_magnE.nii.gz``).
fields : list[str] | None
If given, only these fields are written. Otherwise all fields in the
mesh are converted.
tags : list[int] | None
If given, the mesh is first cropped to the elements with these tissue tags
(:data:`TISSUE_TAGS`).
"""
mesh = _cropped(mesh_io.read_msh(mesh_path), tags)
if fields:
mesh = _filter_mesh_fields(mesh, fields)
transformations.interpolate_to_volume(mesh, m2m_dir, output_path)
|
msh_to_mni(mesh_path: str, m2m_dir: str, output_path: str, fields: list[str] | None = None, tags: list[int] | None = None) -> None
Convert a mesh file to MNI-space NIfTI.
mesh_path : str
Path to the .msh file.
m2m_dir : str
Path to the m2m_{subject} directory.
output_path : str
Output file prefix. SimNIBS appends the field name
(e.g. prefix_magnE.nii.gz).
fields : list[str] | None
If given, only these fields are written.
tags : list[int] | None
If given, the mesh is first cropped to the elements with these tissue tags.
Source code in tit/tools/mesh2nii.py
| def msh_to_mni(
mesh_path: str,
m2m_dir: str,
output_path: str,
fields: list[str] | None = None,
tags: list[int] | None = None,
) -> None:
"""Convert a mesh file to MNI-space NIfTI.
Parameters
----------
mesh_path : str
Path to the ``.msh`` file.
m2m_dir : str
Path to the ``m2m_{subject}`` directory.
output_path : str
Output file prefix. SimNIBS appends the field name
(e.g. ``prefix_magnE.nii.gz``).
fields : list[str] | None
If given, only these fields are written.
tags : list[int] | None
If given, the mesh is first cropped to the elements with these tissue tags.
"""
temp = None
if fields or tags:
mesh = _cropped(mesh_io.read_msh(mesh_path), tags)
if fields:
mesh = _filter_mesh_fields(mesh, fields)
mesh_path = temp = _write_temp_mesh(mesh)
try:
transformations.warp_volume(mesh_path, m2m_dir, output_path)
finally:
if temp is not None:
os.unlink(temp)
|
convert_mesh_dir(mesh_dir: str, output_dir: str, m2m_dir: str, fields: list[str] | None = None, skip_patterns: list[str] | None = None, max_workers: int | None = None, map_to_mni: bool = True) -> None
Batch-convert every .msh file in mesh_dir to NIfTI.
Subject-space NIfTI is always produced; MNI output is optional:
{basename}_subject_{field}.nii.gz – subject space
{basename}_MNI_{field}.nii.gz – MNI space
The subject-space and MNI-space conversions are independent (distinct
output files, no shared state), so all conversions across every mesh
are run concurrently in a process pool. The SimNIBS transforms are
single-threaded (OMP_NUM_THREADS is typically 1 in the container),
so this yields a substantial wall-clock speedup on multi-core hosts.
mesh_dir : str
Directory containing .msh files.
output_dir : str
Where the NIfTI files are written.
m2m_dir : str
Path to the m2m_{subject} directory.
fields : list[str] | None
If given, only these fields are converted.
skip_patterns : list[str] | None
Basenames containing any of these substrings are skipped. By default
only basenames ending in _normal are -- the surface-only TI_normal
meshes runs made before the central surface carried it left, which have
no volume elements -- so a montage or subject whose name merely contains
"normal" is still converted.
map_to_mni : bool
Include MNI-space outputs (default True for standalone conversion).
max_workers : int | None
Number of worker processes. Defaults to the TI_NIFTI_WORKERS
environment variable, or min(n_tasks, job_cpus(), 8). Set to
1 to run serially (e.g. for debugging or memory-constrained
hosts).
convert_mesh_dirs : Convert several directories in a single pool.
Source code in tit/tools/mesh2nii.py
| def convert_mesh_dir(
mesh_dir: str,
output_dir: str,
m2m_dir: str,
fields: list[str] | None = None,
skip_patterns: list[str] | None = None,
max_workers: int | None = None,
map_to_mni: bool = True,
) -> None:
"""Batch-convert every ``.msh`` file in *mesh_dir* to NIfTI.
Subject-space NIfTI is always produced; MNI output is optional:
* ``{basename}_subject_{field}.nii.gz`` – subject space
* ``{basename}_MNI_{field}.nii.gz`` – MNI space
The subject-space and MNI-space conversions are independent (distinct
output files, no shared state), so all conversions across every mesh
are run concurrently in a process pool. The SimNIBS transforms are
single-threaded (``OMP_NUM_THREADS`` is typically 1 in the container),
so this yields a substantial wall-clock speedup on multi-core hosts.
Parameters
----------
mesh_dir : str
Directory containing ``.msh`` files.
output_dir : str
Where the NIfTI files are written.
m2m_dir : str
Path to the ``m2m_{subject}`` directory.
fields : list[str] | None
If given, only these fields are converted.
skip_patterns : list[str] | None
Basenames containing any of these substrings are skipped. By default
only basenames ending in ``_normal`` are -- the surface-only ``TI_normal``
meshes runs made before the central surface carried it left, which have
no volume elements -- so a montage or subject whose name merely contains
"normal" is still converted.
map_to_mni : bool
Include MNI-space outputs (default True for standalone conversion).
max_workers : int | None
Number of worker processes. Defaults to the ``TI_NIFTI_WORKERS``
environment variable, or ``min(n_tasks, job_cpus(), 8)``. Set to
``1`` to run serially (e.g. for debugging or memory-constrained
hosts).
See Also
--------
convert_mesh_dirs : Convert several directories in a single pool.
"""
tasks, temp_paths = _collect_tasks(
mesh_dir, output_dir, fields, skip_patterns, map_to_mni
)
if not tasks:
logger.warning("No .msh files to convert in %s", mesh_dir)
return
_run_tasks(tasks, m2m_dir, temp_paths, max_workers)
logger.info("NIfTI conversion complete: %s", output_dir)
|
NIfTI to Mesh
nifti_to_mesh(input_file, output_file=None, clean_components=False, clean_threshold=0.1)
Convert a NIfTI segmentation/mask to a surface mesh.
input_file : str or Path
Path to input NIfTI file
output_file : str or Path, optional
Path to output mesh file (.stl or .msh). If None, defaults to input_file with .stl extension
clean_components : bool
Whether to remove small disconnected components
clean_threshold : float
Minimum size threshold for component removal (as fraction of largest component)
dict
Dictionary with mesh statistics: {'vertices': int, 'faces': int, 'output_file': str}
Source code in tit/tools/nifti_to_mesh.py
| def nifti_to_mesh(
input_file, output_file=None, clean_components=False, clean_threshold=0.1
):
"""
Convert a NIfTI segmentation/mask to a surface mesh.
Parameters
----------
input_file : str or Path
Path to input NIfTI file
output_file : str or Path, optional
Path to output mesh file (.stl or .msh). If None, defaults to input_file with .stl extension
clean_components : bool
Whether to remove small disconnected components
clean_threshold : float
Minimum size threshold for component removal (as fraction of largest component)
Returns
-------
dict
Dictionary with mesh statistics: {'vertices': int, 'faces': int, 'output_file': str}
"""
input_path = Path(input_file)
if output_file is None:
output_file = input_path.parent / f"{input_path.stem}.stl"
output_path = Path(output_file)
# Validate output format
if output_path.suffix.lower() not in [".stl", ".msh"]:
raise ValueError("Output file must have .stl or .msh extension")
verts_world, faces, removed_components = mask_to_surface(
input_path, clean_components=clean_components, clean_threshold=clean_threshold
)
# Save based on extension
if output_path.suffix.lower() == ".stl":
save_stl(verts_world, faces, str(output_path))
else: # .msh
save_gmsh(verts_world, faces, str(output_path))
return {
"vertices": len(verts_world),
"faces": len(faces),
"output_file": str(output_path),
"removed_components": removed_components,
}
|
remove_small_components(mask, threshold=0.1)
Remove connected components smaller than threshold * largest component.
mask : ndarray
Binary mask array
threshold : float
Minimum size threshold as fraction of largest component (0-1)
tuple
(cleaned_mask, removed_count) -- the cleaned binary mask and the
number of components removed.
Source code in tit/tools/nifti_to_mesh.py
| def remove_small_components(mask, threshold=0.1):
"""
Remove connected components smaller than threshold * largest component.
Parameters
----------
mask : ndarray
Binary mask array
threshold : float
Minimum size threshold as fraction of largest component (0-1)
Returns
-------
tuple
``(cleaned_mask, removed_count)`` -- the cleaned binary mask and the
number of components removed.
"""
labeled, num_features = ndimage.label(mask)
if num_features <= 1:
return mask, 0
# Get size of each component
component_sizes = ndimage.sum(mask, labeled, range(1, num_features + 1))
max_size = component_sizes.max()
min_size = max_size * threshold
# Keep only components above threshold
keep_labels = np.where(component_sizes >= min_size)[0] + 1
cleaned = np.isin(labeled, keep_labels)
return cleaned, num_features - len(keep_labels)
|
save_stl(verts, faces, filename)
Save mesh as binary STL format using numpy-stl.
Source code in tit/tools/nifti_to_mesh.py
| def save_stl(verts, faces, filename):
"""Save mesh as binary STL format using ``numpy-stl``."""
from stl import mesh as stl_mesh
surface = stl_mesh.Mesh(np.zeros(faces.shape[0], dtype=stl_mesh.Mesh.dtype))
for i, f in enumerate(faces):
for j in range(3):
surface.vectors[i][j] = verts[f[j], :]
surface.save(filename)
|
save_gmsh(verts, faces, filename)
Save mesh as Gmsh .msh format (v2.2 ASCII).
Source code in tit/tools/nifti_to_mesh.py
| def save_gmsh(verts, faces, filename):
"""Save mesh as Gmsh .msh format (v2.2 ASCII)."""
with open(filename, "w") as f:
# Header
f.write("$MeshFormat\n2.2 0 8\n$EndMeshFormat\n")
# Nodes
f.write("$Nodes\n")
f.write(f"{len(verts)}\n")
for i, v in enumerate(verts, 1):
f.write(f"{i} {v[0]} {v[1]} {v[2]}\n")
f.write("$EndNodes\n")
# Elements (triangles = type 2)
f.write("$Elements\n")
f.write(f"{len(faces)}\n")
for i, face in enumerate(faces, 1):
# elem_id, elem_type(2=tri), num_tags, tag1, tag2, node1, node2, node3
f.write(f"{i} 2 2 1 1 {face[0]+1} {face[1]+1} {face[2]+1}\n")
f.write("$EndElements\n")
|
tit.tools.field_extract.main
main(input_file, project_dir=None, subject_id=None, gm_output_file=None, wm_output_file=None)
Load the original mesh, crop to grey matter (tag #2) and white matter
(tag #1), and save to separate files.
Directory structure (BIDS-compliant):
project_dir/
├── sub-{subject_id}/
└── derivatives/
└── SimNIBS/
└── sub-{subject_id}/
├── m2m_{subject_id}/
└── Simulations/
Parameters
input_file : str
Path to the input mesh file.
project_dir : str, optional
Path to the project directory (BIDS structure).
subject_id : str, optional
Subject ID (without "sub-" prefix).
gm_output_file : str, optional
Path to the output grey matter mesh file.
wm_output_file : str, optional
Path to the output white matter mesh file.
Source code in tit/tools/field_extract.py
| def main(
input_file,
project_dir=None,
subject_id=None,
gm_output_file=None,
wm_output_file=None,
):
"""
Load the original mesh, crop to grey matter (tag #2) and white matter
(tag #1), and save to separate files.
Directory structure (BIDS-compliant):
project_dir/
├── sub-{subject_id}/
└── derivatives/
└── SimNIBS/
└── sub-{subject_id}/
├── m2m_{subject_id}/
└── Simulations/
Parameters
----------
input_file : str
Path to the input mesh file.
project_dir : str, optional
Path to the project directory (BIDS structure).
subject_id : str, optional
Subject ID (without "sub-" prefix).
gm_output_file : str, optional
Path to the output grey matter mesh file.
wm_output_file : str, optional
Path to the output white matter mesh file.
"""
full_mesh = mesh_io.read_msh(input_file)
gm_mesh = full_mesh.crop_mesh(tags=[2])
wm_mesh = full_mesh.crop_mesh(tags=[1])
if project_dir and subject_id:
derivatives_dir = os.path.join(project_dir, "derivatives")
simnibs_dir = os.path.join(derivatives_dir, "SimNIBS", f"sub-{subject_id}")
output_base = os.path.join(simnibs_dir, "Simulations")
os.makedirs(output_base, exist_ok=True)
input_filename = os.path.basename(input_file)
if gm_output_file is None:
gm_output_file = os.path.join(
output_base, f"sub-{subject_id}_space-MNI305_desc-grey_{input_filename}"
)
if wm_output_file is None:
wm_output_file = os.path.join(
output_base,
f"sub-{subject_id}_space-MNI305_desc-white_{input_filename}",
)
else:
# Use original directory structure
input_dir = os.path.dirname(input_file)
input_filename = os.path.basename(input_file)
if gm_output_file is None:
gm_output_file = os.path.join(input_dir, "grey_" + input_filename)
if wm_output_file is None:
wm_output_file = os.path.join(input_dir, "white_" + input_filename)
# Save grey matter mesh
mesh_io.write_msh(gm_mesh, gm_output_file)
logger.info("Grey matter mesh saved to %s", gm_output_file)
# Save white matter mesh
mesh_io.write_msh(wm_mesh, wm_output_file)
logger.info("White matter mesh saved to %s", wm_output_file)
|
Montage Visualizer
visualize_montage(montage_name: str, electrode_pairs: list[list[str]], eeg_net: str, output_dir: str, sim_mode: str = 'U', logger=None) -> None
Render a PNG showing electrode positions and connection arcs.
montage_name : str
Used as the output filename base (unipolar) or "combined"
(multipolar).
electrode_pairs : list of list of str
Electrode pairs, e.g.
[["E030", "E020"], ["E095", "E070"]].
eeg_net : str
EEG cap name, e.g. "GSN-HydroCel-185.csv".
output_dir : str
Directory to write the output PNG(s) into.
sim_mode : str, optional
"U" produces one image per montage; "M" produces a
single combined image. Default "U". Either way the image is
drawn on the blank template, so a re-run never keeps an earlier
run's rings.
Source code in tit/tools/montage_visualizer.py
| def visualize_montage(
montage_name: str,
electrode_pairs: list[list[str]],
eeg_net: str,
output_dir: str,
sim_mode: str = "U",
logger=None,
) -> None:
"""Render a PNG showing electrode positions and connection arcs.
Parameters
----------
montage_name : str
Used as the output filename base (unipolar) or ``"combined"``
(multipolar).
electrode_pairs : list of list of str
Electrode pairs, e.g.
``[["E030", "E020"], ["E095", "E070"]]``.
eeg_net : str
EEG cap name, e.g. ``"GSN-HydroCel-185.csv"``.
output_dir : str
Directory to write the output PNG(s) into.
sim_mode : str, optional
``"U"`` produces one image per montage; ``"M"`` produces a
single combined image. Default ``"U"``. Either way the image is
drawn on the blank template, so a re-run never keeps an earlier
run's rings.
"""
if is_skipped_net(eeg_net):
expected = get_expected_output_filename(montage_name, sim_mode)
if logger is not None:
logger.warning(
"Montage visualization unavailable for EEG net '%s'; "
"skipping render. Expected output would be %s in %s.",
eeg_net,
expected,
output_dir,
)
return
coords = _load_coordinates(eeg_net)
if not electrode_pairs:
raise ValueError("No electrode pairs provided for montage visualization")
for pair in electrode_pairs:
if len(pair) != 2 or any(label not in coords for label in pair):
raise ValueError(
f"Electrode pair {pair!r} is not in the {eeg_net} coordinate map"
)
# Cap centre: fallback arc target when a pair has no "other" channels.
center = (
sum(x for x, _ in coords.values()) / len(coords),
sum(y for _, y in coords.values()) / len(coords),
)
# Consecutive channels beat together: (0,1) work together, (2,3) work
# together, ... so each channel's arc bulges toward its *partner* channel --
# channel 1 faces channel 2, channel 3 faces channel 4 -- rather than the
# whole montage.
def _partner_target(idx: int) -> tuple[float, float]:
partner = idx + 1 if idx % 2 == 0 else idx - 1
pts = (
[coords[e] for e in electrode_pairs[partner] if e in coords]
if 0 <= partner < len(electrode_pairs)
else []
)
if not pts:
return center
return (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
template = os.path.join(_RESOURCES_DIR, "GSN-256.png")
os.makedirs(output_dir, exist_ok=True)
final_image = os.path.join(
output_dir, get_expected_output_filename(montage_name, sim_mode)
)
# Publish only a fully annotated image. A missing renderer or failed drawing command must
# never leave a bare template at the successful-output path (or damage an existing image).
with TemporaryDirectory(prefix=".montage-", dir=output_dir) as staging:
out_image = os.path.join(staging, "montage.png")
shutil.copy2(template, out_image)
for i, pair in enumerate(electrode_pairs):
e1, e2 = pair
color = _COLORS[i % len(_COLORS)]
ring = os.path.join(_RESOURCES_DIR, _RINGS[i % len(_RINGS)])
_overlay_ring(out_image, *coords[e1], color, ring)
_overlay_ring(out_image, *coords[e2], color, ring)
_draw_arc(out_image, *coords[e1], *coords[e2], color, _partner_target(i))
_draw_legend(out_image, len(electrode_pairs))
os.replace(out_image, final_image)
|
Gmsh Options
create_mesh_opt_file(mesh_path, field_info=None)
Create a .opt file for Gmsh visualization of mesh fields.
mesh_path : str
Path to the .msh file. The .opt extension is appended
automatically.
field_info : dict, optional
Dictionary with field information. Recognised keys:
* ``'fields'`` -- list of field names to visualise.
* ``'max_values'`` -- dict mapping field names to their max values.
* ``'field_type'`` -- ``'node'`` or ``'element'`` (default ``'node'``).
str
Path to the written .opt file.
Source code in tit/tools/gmsh_opt.py
| def create_mesh_opt_file(mesh_path, field_info=None):
"""Create a ``.opt`` file for Gmsh visualization of mesh fields.
Parameters
----------
mesh_path : str
Path to the ``.msh`` file. The ``.opt`` extension is appended
automatically.
field_info : dict, optional
Dictionary with field information. Recognised keys:
* ``'fields'`` -- list of field names to visualise.
* ``'max_values'`` -- dict mapping field names to their max values.
* ``'field_type'`` -- ``'node'`` or ``'element'`` (default ``'node'``).
Returns
-------
str
Path to the written ``.opt`` file.
"""
if field_info is None:
field_info = {}
fields = field_info.get("fields", [])
max_values = field_info.get("max_values", {})
opt_content = """// Gmsh visualization settings for mesh fields
Mesh.SurfaceFaces = 0;
Mesh.SurfaceEdges = 0;
Mesh.Points = 0;
Mesh.Lines = 0;
"""
for i, field_name in enumerate(fields):
view_index = i + 1
max_value = max_values.get(field_name, 1.0)
opt_content += f"""// View[{view_index}] ({field_name})
View[{view_index}].Visible = 1;
View[{view_index}].ColormapNumber = {i + 1};
View[{view_index}].RangeType = 2;
View[{view_index}].CustomMin = 0;
View[{view_index}].CustomMax = {max_value};
View[{view_index}].ShowScale = 1;
View[{view_index}].ColormapAlpha = 1;
View[{view_index}].ColormapAlphaPower = 0.08;
"""
opt_content += "// Field information:\n"
for i, field_name in enumerate(fields):
max_value = max_values.get(field_name, 1.0)
opt_content += f"// View[{i + 1}]: {field_name} (max: {max_value:.6f})\n"
opt_path = f"{mesh_path}.opt"
with open(opt_path, "w") as f:
f.write(opt_content)
return opt_path
|
extract_labels(input_file, labels, output_file=None)
Extract specific labels from a NIfTI segmentation file.
input_file : str or Path
Path to input NIfTI file
labels : list of int
List of label values to extract
output_file : str or Path, optional
Path to output NIfTI file. If None, defaults to input_file with '_extracted' suffix
str
Path to the output file
Source code in tit/tools/extract_labels.py
| def extract_labels(input_file, labels, output_file=None):
"""
Extract specific labels from a NIfTI segmentation file.
Parameters
----------
input_file : str or Path
Path to input NIfTI file
labels : list of int
List of label values to extract
output_file : str or Path, optional
Path to output NIfTI file. If None, defaults to input_file with '_extracted' suffix
Returns
-------
str
Path to the output file
"""
input_path = Path(input_file)
if output_file is None:
output_file = (
input_path.parent / f"{input_path.stem}_extracted{input_path.suffix}"
)
output_path = Path(output_file)
img = nib.load(str(input_path))
data = img.get_fdata()
mask = np.isin(data, labels)
output_data = np.where(mask, data, 0).astype(np.int16)
out_img = nib.Nifti1Image(output_data, img.affine, img.header)
nib.save(out_img, str(output_path))
return str(output_path)
|
FreeSurfer Annotations
read_annot_file(annot_path, vertex_id=None)
Read a FreeSurfer annotation and print its contents.
annot_path : str
Path to the .annot file.
vertex_id : int, optional
If given, also print the region label for this vertex.
Source code in tit/tools/read_annot.py
| def read_annot_file(annot_path, vertex_id=None):
"""Read a FreeSurfer annotation and print its contents.
Parameters
----------
annot_path : str
Path to the ``.annot`` file.
vertex_id : int, optional
If given, also print the region label for this vertex.
"""
# Load annotation data
labels, ctab, names = fsio.read_annot(annot_path)
print(f"\nLoaded .annot file: {annot_path}")
print(f"Number of vertices: {len(labels)}")
print(f"Number of regions: {len(names)}\n")
# Print all region names and their colors
print("Regions and RGBA colors:")
for i, name in enumerate(names):
color = ctab[i][:4] # R, G, B, A
print(f" {i:2d}: {name.decode('utf-8')} | Color: {color}")
# If a specific vertex is given, show its label
if vertex_id is not None:
if 0 <= vertex_id < len(labels):
label_val = labels[vertex_id]
try:
index = list(ctab[:, -1]).index(label_val)
region_name = names[index].decode("utf-8")
print(f"\nVertex {vertex_id} belongs to region: {region_name}")
except ValueError:
print(f"\nVertex {vertex_id} has an unknown label value: {label_val}")
else:
print(f"\nVertex ID {vertex_id} is out of range (0 to {len(labels) - 1})")
|
Electrode Mapping
read_csv_positions(csv_path)
Read the stimulation electrodes of a SimNIBS EEG net CSV.
The rule every net reader in TI-Toolbox shares (the catalog's net
pickers, job preflight, simulation coordinates, the electrode overlay):
Electrode,x,y,z,label rows only. ReferenceElectrode (Cz in the
10-10 and SPM12 caps) and Fiducial rows are not offered as
stimulation electrodes anywhere else, so a mapping onto them would name
a label that preflight then refuses. Rows with fewer than five cells (a
blank line) are skipped rather than failing the whole net.
csv_path : str
Path to the file containing electrode positions.
positions : np.ndarray
Array of electrode positions (Nx3).
labels : list of str
List of electrode labels/names.
load_electrode_positions_json : Load positions from optimiser output.
Source code in tit/tools/map_electrodes.py
| def read_csv_positions(csv_path):
"""
Read the stimulation electrodes of a SimNIBS EEG net CSV.
The rule every net reader in TI-Toolbox shares (the catalog's net
pickers, job preflight, simulation coordinates, the electrode overlay):
``Electrode,x,y,z,label`` rows only. ``ReferenceElectrode`` (Cz in the
10-10 and SPM12 caps) and ``Fiducial`` rows are not offered as
stimulation electrodes anywhere else, so a mapping onto them would name
a label that preflight then refuses. Rows with fewer than five cells (a
blank line) are skipped rather than failing the whole net.
Parameters
----------
csv_path : str
Path to the file containing electrode positions.
Returns
-------
positions : np.ndarray
Array of electrode positions (Nx3).
labels : list of str
List of electrode labels/names.
See Also
--------
load_electrode_positions_json : Load positions from optimiser output.
"""
positions = []
labels = []
with open(csv_path, newline="") as handle:
for row in csv.reader(handle):
if len(row) >= 5 and row[0].strip().lower() == "electrode":
positions.append([float(row[1]), float(row[2]), float(row[3])])
labels.append(row[4].strip())
return np.array(positions, dtype=float).reshape(-1, 3), labels
|
load_electrode_positions_json(json_path)
Load optimized electrode positions from electrode_positions.json.
json_path : str
Path to the electrode_positions.json file.
positions : np.ndarray
Array of optimized electrode positions (Nx3).
channel_array_indices : list of list
List of [channel, array] indices for each electrode.
read_csv_positions : Load positions from a SimNIBS EEG net CSV.
Source code in tit/tools/map_electrodes.py
| def load_electrode_positions_json(json_path):
"""
Load optimized electrode positions from electrode_positions.json.
Parameters
----------
json_path : str
Path to the electrode_positions.json file.
Returns
-------
positions : np.ndarray
Array of optimized electrode positions (Nx3).
channel_array_indices : list of list
List of ``[channel, array]`` indices for each electrode.
See Also
--------
read_csv_positions : Load positions from a SimNIBS EEG net CSV.
"""
with open(json_path, "r") as f:
data = json.load(f)
positions = np.array(data["optimized_positions"])
channel_array_indices = data["channel_array_indices"]
return positions, channel_array_indices
|
map_electrodes_to_net(optimized_positions, net_positions, net_labels, channel_array_indices)
Map optimized electrode positions to nearest EEG net positions using
the Hungarian algorithm for optimal assignment.
optimized_positions : np.ndarray
Array of optimized electrode positions (Nx3).
net_positions : np.ndarray
Array of EEG net electrode positions (Mx3).
net_labels : list
List of EEG net electrode labels.
channel_array_indices : list
List of [channel, array] indices for each optimized electrode.
mapping_result : dict
Dictionary containing:
- optimized_positions: Original optimized positions
- mapped_positions: Corresponding net positions
- mapped_labels: Labels of mapped net electrodes
- distances: Distances between optimized and mapped positions
- channel_array_indices: Channel and array indices
Source code in tit/tools/map_electrodes.py
| def map_electrodes_to_net(
optimized_positions, net_positions, net_labels, channel_array_indices
):
"""
Map optimized electrode positions to nearest EEG net positions using
the Hungarian algorithm for optimal assignment.
Parameters
----------
optimized_positions : np.ndarray
Array of optimized electrode positions (Nx3).
net_positions : np.ndarray
Array of EEG net electrode positions (Mx3).
net_labels : list
List of EEG net electrode labels.
channel_array_indices : list
List of [channel, array] indices for each optimized electrode.
Returns
-------
mapping_result : dict
Dictionary containing:
- optimized_positions: Original optimized positions
- mapped_positions: Corresponding net positions
- mapped_labels: Labels of mapped net electrodes
- distances: Distances between optimized and mapped positions
- channel_array_indices: Channel and array indices
"""
logger.info("Calculating distance matrix...")
distance_matrix = np.array(
[
[np.linalg.norm(opt_pos - net_pos) for net_pos in net_positions]
for opt_pos in optimized_positions
]
)
logger.info("Finding optimal electrode assignment using Hungarian algorithm...")
row_ind, col_ind = linear_sum_assignment(distance_matrix)
mapping_result = {
"optimized_positions": [optimized_positions[i].tolist() for i in row_ind],
"mapped_positions": [net_positions[j].tolist() for j in col_ind],
"mapped_labels": [net_labels[j] for j in col_ind],
"distances": [distance_matrix[i, j] for i, j in zip(row_ind, col_ind)],
"channel_array_indices": [channel_array_indices[i] for i in row_ind],
}
return mapping_result
|
save_mapping_result(mapping_result, output_path, eeg_net_name=None)
Save mapping result to a JSON file.
mapping_result : dict
Dictionary containing mapping information.
output_path : str
Path where to save the JSON file.
eeg_net_name : str, optional
Name of the EEG net file used.
Source code in tit/tools/map_electrodes.py
| def save_mapping_result(mapping_result, output_path, eeg_net_name=None):
"""
Save mapping result to a JSON file.
Parameters
----------
mapping_result : dict
Dictionary containing mapping information.
output_path : str
Path where to save the JSON file.
eeg_net_name : str, optional
Name of the EEG net file used.
"""
json_data = mapping_result.copy()
if eeg_net_name:
json_data["eeg_net"] = eeg_net_name
with open(output_path, "w") as f:
json.dump(json_data, f, indent=2)
logger.info("Mapping data saved to: %s", output_path)
|
log_mapping_summary(mapping_result)
Log a summary of the electrode mapping results.
mapping_result : dict
Dictionary containing mapping information.
Source code in tit/tools/map_electrodes.py
| def log_mapping_summary(mapping_result):
"""
Log a summary of the electrode mapping results.
Parameters
----------
mapping_result : dict
Dictionary containing mapping information.
"""
total_distance = sum(mapping_result["distances"])
avg_distance = (
total_distance / len(mapping_result["distances"])
if mapping_result["distances"]
else 0
)
max_distance = (
max(mapping_result["distances"]) if mapping_result["distances"] else 0
)
logger.info("=" * 80)
logger.info("ELECTRODE MAPPING SUMMARY")
logger.info("=" * 80)
logger.info("Total electrodes mapped: %d", len(mapping_result["mapped_labels"]))
logger.info("Average distance: %.2f mm", avg_distance)
logger.info("Maximum distance: %.2f mm", max_distance)
logger.info("Total distance: %.2f mm", total_distance)
logger.info("Detailed mapping:")
logger.info("-" * 80)
logger.info(
"%-5s %-8s %-6s %-15s %-15s",
"Idx",
"Channel",
"Array",
"Mapped Label",
"Distance (mm)",
)
logger.info("-" * 80)
for i, (label, dist, indices) in enumerate(
zip(
mapping_result["mapped_labels"],
mapping_result["distances"],
mapping_result["channel_array_indices"],
)
):
channel, array = indices
logger.info("%-5d %-8s %-6s %-15s %-15.2f", i, channel, array, label, dist)
logger.info("=" * 80)
|