241 lines
8.7 KiB
Python
241 lines
8.7 KiB
Python
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
import numpy as np
|
|
|
|
from ...channels import DigMontage, make_dig_montage
|
|
from ...surface import _voxel_neighbors
|
|
from ...transforms import Transform, _frame_to_str, apply_trans
|
|
from ...utils import _check_option, _pl, _require_version, _validate_type, verbose, warn
|
|
|
|
|
|
@verbose
|
|
def warp_montage(montage, moving, static, reg_affine, sdr_morph, verbose=None):
|
|
"""Warp a montage to a template with image volumes using SDR.
|
|
|
|
.. note:: This is likely only applicable for channels inside the brain
|
|
(intracranial electrodes).
|
|
|
|
Parameters
|
|
----------
|
|
montage : instance of mne.channels.DigMontage
|
|
The montage object containing the channels.
|
|
%(moving)s
|
|
%(static)s
|
|
%(reg_affine)s
|
|
%(sdr_morph)s
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
montage_warped : mne.channels.DigMontage
|
|
The modified montage object containing the channels.
|
|
"""
|
|
_require_version("nibabel", "warp montage", "2.1.0")
|
|
_require_version("dipy", "warping points using SDR", "1.6.0")
|
|
|
|
from dipy.align.imwarp import DiffeomorphicMap
|
|
from nibabel import MGHImage
|
|
from nibabel.spatialimages import SpatialImage
|
|
|
|
_validate_type(moving, SpatialImage, "moving")
|
|
_validate_type(static, SpatialImage, "static")
|
|
_validate_type(reg_affine, np.ndarray, "reg_affine")
|
|
_check_option("reg_affine.shape", reg_affine.shape, ((4, 4),))
|
|
_validate_type(sdr_morph, (DiffeomorphicMap, None), "sdr_morph")
|
|
_validate_type(montage, DigMontage, "montage")
|
|
|
|
moving_mgh = MGHImage(np.array(moving.dataobj).astype(np.float32), moving.affine)
|
|
static_mgh = MGHImage(np.array(static.dataobj).astype(np.float32), static.affine)
|
|
del moving, static
|
|
|
|
# get montage channel coordinates
|
|
ch_dict = montage.get_positions()
|
|
if ch_dict["coord_frame"] != "mri":
|
|
bad_coord_frames = np.unique([d["coord_frame"] for d in montage.dig])
|
|
bad_coord_frames = ", ".join(
|
|
[
|
|
_frame_to_str[cf] if cf in _frame_to_str else str(cf)
|
|
for cf in bad_coord_frames
|
|
]
|
|
)
|
|
raise RuntimeError(
|
|
f'Coordinate frame not supported, expected "mri", got {bad_coord_frames}'
|
|
)
|
|
ch_names = list(ch_dict["ch_pos"].keys())
|
|
ch_coords = np.array([ch_dict["ch_pos"][name] for name in ch_names])
|
|
|
|
ch_coords = apply_trans( # convert to moving voxel space
|
|
np.linalg.inv(moving_mgh.header.get_vox2ras_tkr()), ch_coords * 1000
|
|
)
|
|
# next, to moving scanner RAS
|
|
ch_coords = apply_trans(moving_mgh.header.get_vox2ras(), ch_coords)
|
|
|
|
# now, apply reg_affine
|
|
ch_coords = apply_trans(
|
|
Transform( # to static ras
|
|
fro="ras", to="ras", trans=np.linalg.inv(reg_affine)
|
|
),
|
|
ch_coords,
|
|
)
|
|
|
|
# now, apply SDR morph
|
|
if sdr_morph is not None:
|
|
ch_coords = sdr_morph.transform_points(
|
|
ch_coords, sdr_morph.domain_grid2world, sdr_morph.domain_world2grid
|
|
)
|
|
|
|
# back to voxels but now for the static image
|
|
ch_coords = apply_trans(np.linalg.inv(static_mgh.header.get_vox2ras()), ch_coords)
|
|
|
|
# finally, back to surface RAS
|
|
ch_coords = apply_trans(static_mgh.header.get_vox2ras_tkr(), ch_coords) / 1000
|
|
|
|
# make warped montage
|
|
montage_warped = make_dig_montage(dict(zip(ch_names, ch_coords)), coord_frame="mri")
|
|
return montage_warped
|
|
|
|
|
|
def _warn_missing_chs(info, dig_image, after_warp=False):
|
|
"""Warn that channels are missing."""
|
|
# ensure that each electrode contact was marked in at least one voxel
|
|
missing = set(np.arange(1, len(info.ch_names) + 1)).difference(
|
|
set(np.unique(np.array(dig_image.dataobj)))
|
|
)
|
|
missing_ch = [info.ch_names[idx - 1] for idx in missing]
|
|
if missing_ch:
|
|
warn(
|
|
f"Channel{_pl(missing_ch)} "
|
|
f'{", ".join(repr(ch) for ch in missing_ch)} not assigned '
|
|
"voxels " + (f" after applying {after_warp}" if after_warp else "")
|
|
)
|
|
|
|
|
|
@verbose
|
|
def make_montage_volume(
|
|
montage,
|
|
base_image,
|
|
thresh=0.5,
|
|
max_peak_dist=1,
|
|
voxels_max=100,
|
|
use_min=False,
|
|
verbose=None,
|
|
):
|
|
"""Make a volume from intracranial electrode contact locations.
|
|
|
|
Find areas of the input volume with intensity greater than
|
|
a threshold surrounding local extrema near the channel location.
|
|
Monotonicity from the peak is enforced to prevent channels
|
|
bleeding into each other.
|
|
|
|
Parameters
|
|
----------
|
|
montage : instance of mne.channels.DigMontage
|
|
The montage object containing the channels.
|
|
base_image : path-like | nibabel.spatialimages.SpatialImage
|
|
Path to a volumetric scan (e.g. CT) of the subject. Can be in any
|
|
format readable by nibabel. Can also be a nibabel image object.
|
|
Local extrema (max or min) should be nearby montage channel locations.
|
|
thresh : float
|
|
The threshold relative to the peak to determine the size
|
|
of the sensors on the volume.
|
|
max_peak_dist : int
|
|
The number of voxels away from the channel location to
|
|
look in the ``image``. This will depend on the accuracy of
|
|
the channel locations, the default (one voxel in all directions)
|
|
will work only with localizations that are that accurate.
|
|
voxels_max : int
|
|
The maximum number of voxels for each channel.
|
|
use_min : bool
|
|
Whether to hypointensities in the volume as channel locations.
|
|
Default False uses hyperintensities.
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
elec_image : nibabel.spatialimages.SpatialImage
|
|
An image in Freesurfer surface RAS space with voxel values
|
|
corresponding to the index of the channel. The background
|
|
is 0s and this index starts at 1.
|
|
"""
|
|
_require_version("nibabel", "montage volume", "2.1.0")
|
|
import nibabel as nib
|
|
|
|
_validate_type(montage, DigMontage, "montage")
|
|
_validate_type(base_image, nib.spatialimages.SpatialImage, "base_image")
|
|
_validate_type(thresh, float, "thresh")
|
|
if thresh < 0 or thresh >= 1:
|
|
raise ValueError(f"`thresh` must be between 0 and 1, got {thresh}")
|
|
_validate_type(max_peak_dist, int, "max_peak_dist")
|
|
_validate_type(voxels_max, int, "voxels_max")
|
|
_validate_type(use_min, bool, "use_min")
|
|
|
|
# load image and make sure it's in surface RAS
|
|
if not isinstance(base_image, nib.spatialimages.SpatialImage):
|
|
base_image = nib.load(base_image)
|
|
|
|
base_image_mgh = nib.MGHImage(
|
|
np.array(base_image.dataobj).astype(np.float32), base_image.affine
|
|
)
|
|
del base_image
|
|
|
|
# get montage channel coordinates
|
|
ch_dict = montage.get_positions()
|
|
if ch_dict["coord_frame"] != "mri":
|
|
bad_coord_frames = np.unique([d["coord_frame"] for d in montage.dig])
|
|
bad_coord_frames = ", ".join(
|
|
[
|
|
_frame_to_str[cf] if cf in _frame_to_str else str(cf)
|
|
for cf in bad_coord_frames
|
|
]
|
|
)
|
|
raise RuntimeError(
|
|
f'Coordinate frame not supported, expected "mri", got {bad_coord_frames}'
|
|
)
|
|
|
|
ch_names = list(ch_dict["ch_pos"].keys())
|
|
ch_coords = np.array([ch_dict["ch_pos"][name] for name in ch_names])
|
|
|
|
# convert to voxel space
|
|
ch_coords = apply_trans(
|
|
np.linalg.inv(base_image_mgh.header.get_vox2ras_tkr()), ch_coords * 1000
|
|
)
|
|
|
|
# take channel coordinates and use the image to transform them
|
|
# into a volume where all the voxels over a threshold nearby
|
|
# are labeled with an index
|
|
image_data = np.array(base_image_mgh.dataobj)
|
|
if use_min:
|
|
image_data *= -1
|
|
elec_image = np.zeros(base_image_mgh.shape, dtype=int)
|
|
for i, ch_coord in enumerate(ch_coords):
|
|
if np.isnan(ch_coord).any():
|
|
continue
|
|
# this looks up to a voxel away, it may be marked imperfectly
|
|
volume = _voxel_neighbors(
|
|
ch_coord,
|
|
image_data,
|
|
thresh=thresh,
|
|
max_peak_dist=max_peak_dist,
|
|
voxels_max=voxels_max,
|
|
)
|
|
for voxel in volume:
|
|
if elec_image[voxel] != 0:
|
|
# some voxels ambiguous because the contacts are bridged on
|
|
# the image so assign the voxel to the nearest contact location
|
|
dist_old = np.sqrt(
|
|
(ch_coords[elec_image[voxel] - 1] - voxel) ** 2
|
|
).sum()
|
|
dist_new = np.sqrt((ch_coord - voxel) ** 2).sum()
|
|
if dist_new < dist_old:
|
|
elec_image[voxel] = i + 1
|
|
else:
|
|
elec_image[voxel] = i + 1
|
|
|
|
# assemble the volume
|
|
elec_image = nib.spatialimages.SpatialImage(elec_image, base_image_mgh.affine)
|
|
_warn_missing_chs(montage, elec_image, after_warp=False)
|
|
|
|
return elec_image
|