Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Improve cerebellum atlas #26

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

This script is used to compute the mouse isocortex and the mouse thalamus directions vectors.
"""

from __future__ import annotations

import enum
Expand Down
40 changes: 36 additions & 4 deletions atlas_direction_vectors/algorithms/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"""Low-level tools for the computation of direction vectors"""

import logging
from typing import Union

import numpy as np # type: ignore
import pyquaternion as pyq
from atlas_commons.utils import FloatArray, NumericArray, normalize
from joblib import Parallel, delayed
from scipy.ndimage import gaussian_filter # type: ignore
from scipy.ndimage import generate_binary_structure # type: ignore
from scipy.optimize import minimize
from scipy.signal import correlate # type: ignore

L = logging.getLogger(__name__)


def compute_blur_gradient(
scalar_field: FloatArray, gaussian_stddev=3.0, gaussian_radius=None
Expand Down Expand Up @@ -45,26 +53,49 @@ def compute_blur_gradient(


def _quaternion_from_vectors( # pylint: disable=invalid-name
s: NumericArray, t: NumericArray
s: NumericArray, t: NumericArray, align_to: Union[NumericArray, None] = None
) -> NumericArray:
"""
Returns the quaternion (s cross t) + (s dot t + |s||t|).

This quaternion q maps s to t, i.e., qsq^{-1} = t.

If align_to is specified (either `x`, or `z`), we will rotate it around its axis ([0, 1, 0])
so that it is maximally aligned with `align_to`.

Args:
s: numeric array of shape (3,) or (N, 3).
t: numeric array of shape (N, 3) if s has two dimensions and its first dimension is N.
align_to: if not None, the returned quaternion is aligned with the given axis.
Returns:
Numeric array of shape (N, 4) where N is the first dimension of t.
This data is interpreted as a 1D array of quaternions with size N. A quaternion is a 4D
vector [w, x, y, z] where [x, y, z] is the imaginary part.
"""
w = np.matmul(s, np.array(t).T) + np.linalg.norm(s, axis=-1) * np.linalg.norm(t, axis=-1)
return np.hstack([w[:, np.newaxis], np.cross(s, t)])
quaternions = np.hstack([w[:, np.newaxis], np.cross(s, t)])

if align_to is not None:
target_dir = {"x": np.array([1.0, 0.0, 0.0]), "z": np.array([0.0, 0.0, 1.0])}[align_to]

def rotate_q(_q):
q = pyq.Quaternion(_q)

def cost(a):
q_rot = pyq.Quaternion(axis=[0, 1, 0], angle=a)
return -(q * q_rot).rotate(target_dir).dot(target_dir)

angle = minimize(cost, 0.0).x
return (q * pyq.Quaternion(axis=[0, 1, 0], angle=angle)).q.tolist()

L.info("We are aligning %s quaternions to %s", len(quaternions), align_to)
with Parallel(n_jobs=-1, batch_size=10000, verbose=10) as parallel:
quaternions = np.array(parallel(delayed(rotate_q)(q) for q in quaternions))

return quaternions


def vector_to_quaternion(vector_field: FloatArray) -> FloatArray:
def vector_to_quaternion(vector_field: FloatArray, align_to: Union[str, None] = None) -> FloatArray:
"""
Find quaternions which rotate [0.0, 1.0, 0.0] to each vector in `vector_field`.

Expand All @@ -75,6 +106,7 @@ def vector_to_quaternion(vector_field: FloatArray) -> FloatArray:
Arguments:
vector_field: field of floating point 3D unit vectors, i.e., a float array of shape
(..., 3).
align_to: if not None, the returned quaternion is aligned with the given axis.

Returns:
numpy.ndarray of shape (..., 4) and of the same type as the input
Expand All @@ -88,7 +120,7 @@ def vector_to_quaternion(vector_field: FloatArray) -> FloatArray:
quaternions = np.full(vector_field.shape[:-1] + (4,), np.nan, dtype=vector_field.dtype)
non_nan_mask = ~np.isnan(np.linalg.norm(vector_field, axis=-1))
quaternions[non_nan_mask] = _quaternion_from_vectors(
[0.0, 1.0, 0.0], vector_field[non_nan_mask]
[0.0, 1.0, 0.0], vector_field[non_nan_mask], align_to=align_to
)
return quaternions

Expand Down
11 changes: 10 additions & 1 deletion atlas_direction_vectors/app/orientation_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
convention used by the placement algorithm, see
https://bbpteam.epfl.ch/documentation/projects/placement-algorithm/latest/index.html.
"""

import logging
from typing import Union

import click
import voxcell # type: ignore
Expand Down Expand Up @@ -45,15 +47,22 @@
"NaN vectors indicate out-of-domain voxels but also voxels for which an orientation could"
" not be derived.",
)
@click.option(
"--align-to",
type=str,
required=False,
help=("Direction to align the direction vectors to."),
)
@log_args(L)
def cmd(
direction_vectors_path: str,
output_path: str,
align_to: Union[str, None] = None,
) -> None:
"""Turn direction vectors into quaternions interpreted as 3D orientations."""

direction_vectors = voxcell.VoxelData.load_nrrd(direction_vectors_path)
quaternions = vector_to_quaternion(direction_vectors.raw)
quaternions = vector_to_quaternion(direction_vectors.raw, align_to=align_to)
voxcell.OrientationField(
quaternions, direction_vectors.voxel_dimensions, direction_vectors.offset
).save_nrrd(output_path)
33 changes: 19 additions & 14 deletions atlas_direction_vectors/cerebellum.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
and high values where fiber tracts are outgoing. The direction vectors are given by the gradient
of this scalar field.
"""

import logging
from functools import partial
from typing import TYPE_CHECKING, Optional

import numpy as np
from atlas_commons.typing import FloatArray
from joblib import Parallel, delayed

from atlas_direction_vectors.algorithms.layer_based_direction_vectors import (
compute_layered_region_direction_vectors,
Expand Down Expand Up @@ -49,10 +52,16 @@ def compute_direction_vectors(region_map: "RegionMap", annotation: "VoxelData")
and parent_id not in subregion_ids
):
subregion_ids.append(parent_id)
L.info("Computing direction vectors for region %s.", region_map.get(parent_id, "name"))
subregion_direction_vectors = cereb_subregion_direction_vectors(
parent_id, region_map, annotation
)
L.info(
"Computing direction vectors for regions %s.",
[region_map.get(parent_id, "name") for parent_id in subregion_ids],
)

f = partial(cereb_subregion_direction_vectors, region_map=region_map, annotation=annotation)
with Parallel(n_jobs=-1, verbose=10) as parallel:
for subregion_direction_vectors in parallel(
delayed(f)(parent_id) for parent_id in subregion_ids
):
# Assembles subregion direction vectors.
subregion_mask = np.logical_not(np.isnan(subregion_direction_vectors))
direction_vectors[subregion_mask] = subregion_direction_vectors[subregion_mask]
Expand Down Expand Up @@ -91,23 +100,19 @@ def cereb_subregion_direction_vectors(
"layers": {
"names": [
name + ", molecular layer",
name + ", Purkinje layer",
name + ", granular layer",
"cerebellum related fiber tracts",
],
"queries": [acronym + "mo", acronym + "pu", acronym + "gr", "cbf"],
"queries": [acronym + "mo", acronym + "gr", "cbf"],
arnaudon marked this conversation as resolved.
Show resolved Hide resolved
"attribute": "acronym",
"with_descendants": True,
},
}
region_to_weight = {
acronym + "mo": 1.0 if "mo" not in weights else weights["mo"],
acronym + "pu": 0.0 if "pu" not in weights else weights["pu"],
acronym + "gr": -1.0 if "gr" not in weights else weights["gr"],
"cbf": -5.0 if "cbf" not in weights else weights["cbf"],
"outside_of_brain": 3.0
if "outside_of_brain" not in weights
else weights["outside_of_brain"],
acronym + "mo": weights.get("mo", 1.0),
acronym + "gr": weights.get("gr", -1.0),
"cbf": weights.get("cbf", -5.0),
"outside_of_brain": weights.get("outside_of_brain", 3),
}

return compute_layered_region_direction_vectors(
Expand All @@ -117,5 +122,5 @@ def cereb_subregion_direction_vectors(
region_to_weight=region_to_weight,
shading_width=4,
expansion_width=8,
has_hemispheres=False,
has_hemispheres=True,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this change here too. The cerebellum is not like the isocortex and does not have two hemispheres. Do not get me wrong there are some hemispherical regions but all the vermal regions (e.g. Lingula, Declive, etc.) are at the center of the cerebellum and splitting them does not make sense: there is no separation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this is tricky, I have to figure out how to do, as here we do per lobule, and for example Simple Lobule is not near the center, so it has two parts, one in each hemisphere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably this should be done on a subregion basis, I'll play around with it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@drodarie , would this this correct?

INFO:atlas_direction_vectors.app.direction_vectors:Subregion Crus 1 has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Crus 2 has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Paramedian lobule has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Copula pyramidis has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Paraflocculus has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Lobule II has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Lobule III has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Nodulus (X) has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Folium-tuber vermis (VII) has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Uvula (IX) has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Declive (VI) has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Lobules IV-V has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Flocculus has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Simple lobule has hemispheres: True
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Lingula (I) has hemispheres: False
INFO:atlas_direction_vectors.app.direction_vectors:Subregion Pyramus (VIII) has hemispheres: False

I detect hemispheres if they have voxel at the halfplane +- 1 voxel.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The has_hemisphere feature was created to prevent an orientation field that is computed on one hemisphere to affect the other one. But this has an effect only when they directly face each other and are very close to each other. This is the case for isocortex but not for the cerebellar cortex because we deal with each subregion individually.
I believe your change would have close to no effect here. More, this means that you need to compute the orientation field twice for each hemispherical subregion.
IMO, there is a lack of a grounded explanation for this change.

)
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"numpy>=1.15.0",
"scipy>=1.10.0",
"voxcell>=3.0.0",
"joblib>=1.3",
"pyquaternion>=0.9.9",
],
extras_require={
"tests": [
Expand Down
Binary file modified tests/data/cerebellum_shading_gradient_orientations.nrrd
Binary file not shown.
9 changes: 4 additions & 5 deletions tests/test_cerebellum.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,17 +183,16 @@ def _check_vectors_direction_dominance(
def test_compute_cerebellum_direction_vectors(region_map, annotation):
res = tested.compute_direction_vectors(region_map, annotation)
_check_vectors_defined_in_regions(
res, region_map, annotation, ["FLgr", "FLpu", "FLmo"] + ["LINGgr", "LINGpu", "LINGmo"]
res, region_map, annotation, ["FLgr", "FLmo"] + ["LINGgr", "LINGmo"]
)

_check_vectors_direction_dominance(
res, region_map, annotation, ["FLgr", "FLpu", "FLmo"], [-1.0, 0.0, 0.0]
res, region_map, annotation, ["FLgr", "FLmo"], [-1.0, 0.0, 0.0]
)

_check_vectors_direction_dominance(
res, region_map, annotation, ["LINGgr", "LINGpu", "LINGmo"], [-1.0, 0.0, 0.0]
res, region_map, annotation, ["LINGgr", "LINGmo"], [-1.0, 0.0, 0.0]
)

expected_direction_vectors = VoxelData.load_nrrd(
DATA_DIR / "cerebellum_shading_gradient_orientations.nrrd"
)
Expand All @@ -205,4 +204,4 @@ def test_cereb_subreg_direction_vectors(region_map, annotation):
res = tested.cereb_subregion_direction_vectors(
region_map.find("FL", "acronym").pop(), region_map, annotation
)
_check_vectors_defined_in_regions(res, region_map, annotation, ["FLgr", "FLpu", "FLmo"])
_check_vectors_defined_in_regions(res, region_map, annotation, ["FLgr", "FLmo"])
Loading