Create NIfTI electrode placement overlays from simulation configs.
Run as a tools job (simnibs_python -m tit.tools.electrode_overlay <args>) this
module reports the two files it writes through tit.jobs.events, the same
emit_artifact/emit_result contract every other runner uses -- see
:func:_report_job_outputs. A tools job takes argv rather than a spec.json, so there
is no shared runner wrapper to do it: each tool script reports its own outputs. Before
this, a finished tools job reported artifacts: [] and the Jobs rail and Results
had nothing to link (job 0465e86914a94ea4).
write_electrode_overlay_lut(path: str | Path, num_pairs: int) -> str
Write the FreeSurfer-shaped channel LUT beside an electrode overlay.
The last column is alpha. Freeview reads it as transparency (0 = opaque)
and so was happy with 0; every other LUT consumer -- the in-app viewer
included -- reads a FreeSurfer LUT's fourth colour column as opacity and
multiplies it into the palette, which made every electrode invisible
(r1 §10.5.1). 255 is opaque under both readings: Freeview clamps a
transparency above its range to fully opaque, which is what these markers
were always meant to be.
Source code in tit/tools/electrode_overlay.py
| def write_electrode_overlay_lut(path: str | Path, num_pairs: int) -> str:
"""Write the FreeSurfer-shaped channel LUT beside an electrode overlay.
The last column is alpha. Freeview reads it as *transparency* (0 = opaque)
and so was happy with 0; every other LUT consumer -- the in-app viewer
included -- reads a FreeSurfer LUT's fourth colour column as *opacity* and
multiplies it into the palette, which made every electrode invisible
(r1 §10.5.1). 255 is opaque under both readings: Freeview clamps a
transparency above its range to fully opaque, which is what these markers
were always meant to be.
"""
color_names = _montage_color_names()
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as f:
f.write("# TI-Toolbox electrode channel LUT\n")
for idx in range(1, num_pairs + 1):
color_name = color_names[(idx - 1) % len(color_names)]
red, green, blue = _MONTAGE_COLOR_RGB.get(color_name, (220, 220, 220))
f.write(f"{idx} Channel_{idx} {red} {green} {blue} 255\n")
return str(path)
|
create_electrode_overlay_nifti(config_path: str | Path, reference_nifti: str | Path, output_path: str | Path, montage_name: str | None = None, eeg_positions_dir: str | Path | None = None) -> str
Write a label-mask NIfTI showing XYZ electrode placements.
The saved simulation config.json provides electrode centers and
dimensions. The reference NIfTI provides the output grid and affine, so
the overlay can be opened alongside that image in Freeview or another
NIfTI viewer. Because SimNIBS configs do not store electrode normals, the
pad volume is an axis-aligned approximation around each center.
Source code in tit/tools/electrode_overlay.py
| def create_electrode_overlay_nifti(
config_path: str | Path,
reference_nifti: str | Path,
output_path: str | Path,
montage_name: str | None = None,
eeg_positions_dir: str | Path | None = None,
) -> str:
"""Write a label-mask NIfTI showing XYZ electrode placements.
The saved simulation ``config.json`` provides electrode centers and
dimensions. The reference NIfTI provides the output grid and affine, so
the overlay can be opened alongside that image in Freeview or another
NIfTI viewer. Because SimNIBS configs do not store electrode normals, the
pad volume is an axis-aligned approximation around each center.
"""
import nibabel as nib
config_path = Path(config_path)
reference_nifti = Path(reference_nifti)
output_path = Path(output_path)
with config_path.open() as f:
config_data = json.load(f)
position_pairs = _extract_position_pairs(
config_data, montage_name=montage_name, eeg_positions_dir=eeg_positions_dir
)
shape, radii_mm = _electrode_geometry(config_data)
reference_img = nib.load(str(reference_nifti))
mask = np.zeros(reference_img.shape[:3], dtype=np.uint16)
affine = np.asarray(reference_img.affine, dtype=float)
for pair_idx, pair_positions in enumerate(position_pairs, start=1):
for center_xyz in pair_positions:
_draw_electrode(mask, affine, center_xyz, radii_mm, pair_idx, shape)
output_path.parent.mkdir(parents=True, exist_ok=True)
header = reference_img.header.copy()
if hasattr(header, "set_data_dtype"):
header.set_data_dtype(np.uint16)
overlay_img = nib.Nifti1Image(mask, affine, header)
nib.save(overlay_img, str(output_path))
write_electrode_overlay_lut(
electrode_overlay_lut_path(output_path), len(position_pairs)
)
return str(output_path)
|
simulation_config_has_xyz_electrodes(config_path: str | Path, montage_name: str | None = None, eeg_positions_dir: str | Path | None = None) -> bool
Return whether config_path contains resolvable electrode coordinates.
Source code in tit/tools/electrode_overlay.py
| def simulation_config_has_xyz_electrodes(
config_path: str | Path,
montage_name: str | None = None,
eeg_positions_dir: str | Path | None = None,
) -> bool:
"""Return whether *config_path* contains resolvable electrode coordinates."""
try:
with Path(config_path).open() as f:
config_data = json.load(f)
_extract_xyz_positions(
config_data,
montage_name=montage_name,
eeg_positions_dir=eeg_positions_dir,
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return False
return True
|