Skip to content

electrode_overlay

tit.tools.electrode_overlay

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

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

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

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