Skip to content

Commit df80acf

Browse files
authored
Merge pull request #1235 from EmmaRenauld/unit_bundles
First unit tests in tractoanalysis module
2 parents bfe8225 + c70592f commit df80acf

File tree

4 files changed

+160
-17
lines changed

4 files changed

+160
-17
lines changed

src/scilpy/reconst/sh.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,12 @@ def peaks_from_sh(shm_coeff, sphere, mask=None, relative_peak_threshold=0.5,
285285
286286
Returns
287287
-------
288-
tuple of np.ndarray
289-
peak_dirs, peak_values, peak_indices
288+
peak_dirs: np.ndarray
289+
Shape: [x, y, z, npeaks * 3]
290+
peak_values: np.ndarray
291+
Shape: [x, y, z, npeaks]
292+
peak_indices: np.ndarray
293+
Shape: [x, y, z, npeaks]
290294
"""
291295
sh_order = order_from_ncoef(shm_coeff.shape[-1],
292296
full_basis=full_basis)

src/scilpy/tractanalysis/afd_along_streamlines.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
def afd_map_along_streamlines(sft, fodf, fodf_basis, length_weighting,
1414
is_legacy=True):
1515
"""
16-
Compute the mean Apparent Fiber Density (AFD) and mean Radial fODF
16+
Compute the mean Apparent Fiber Density (AFD) [1] and mean Radial fODF
1717
(radfODF) maps along a bundle.
1818
19+
[1] Raffelt et. al (2012). Apparent fibre density: a novel measure for the
20+
analysis of diffusion-weighted magnetic resonance images.
21+
Neuroimage, 59(4), 3976-3994.
22+
1923
Parameters
2024
----------
2125
sft : StatefulTractogram
@@ -27,6 +31,8 @@ def afd_map_along_streamlines(sft, fodf, fodf_basis, length_weighting,
2731
Has to be descoteaux07 or tournier07
2832
length_weighting : bool
2933
If set, will weigh the AFD values according to segment lengths
34+
is_legacy : bool
35+
If true, uses legacy basis.
3036
3137
Returns
3238
-------
@@ -37,9 +43,9 @@ def afd_map_along_streamlines(sft, fodf, fodf_basis, length_weighting,
3743
"""
3844

3945
afd_sum, rd_sum, weights = \
40-
afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
41-
length_weighting,
42-
is_legacy=is_legacy)
46+
_afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
47+
length_weighting,
48+
is_legacy=is_legacy)
4349

4450
non_zeros = np.nonzero(afd_sum)
4551
weights_nz = weights[non_zeros]
@@ -49,8 +55,8 @@ def afd_map_along_streamlines(sft, fodf, fodf_basis, length_weighting,
4955
return afd_sum, rd_sum
5056

5157

52-
def afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
53-
length_weighting, is_legacy=True):
58+
def _afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
59+
length_weighting, is_legacy=True):
5460
"""
5561
Compute the mean Apparent Fiber Density (AFD) and
5662
mean Radial fODF (radfODF) maps along a bundle.
@@ -67,7 +73,7 @@ def afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
6773
length_weighting : bool
6874
If set, will weigh the AFD values according to segment lengths.
6975
is_legacy : bool, optional
70-
Whether or not the SH basis is in its legacy form.
76+
Whether the SH basis is in its legacy form.
7177
7278
Returns
7379
-------
@@ -89,42 +95,46 @@ def afd_and_rd_sums_along_streamlines(sft, fodf, fodf_basis,
8995
_, n = sph_harm_ind_list(order)
9096
legendre0_at_n = legendre_p_all(order, 0)[0][n]
9197
sphere_norm = np.linalg.norm(sphere.vertices)
98+
p_matrix = np.eye(fodf_data.shape[3]) * legendre0_at_n
9299

100+
# Initializing
93101
afd_sum_map = np.zeros(shape=fodf_data.shape[:-1])
94102
rd_sum_map = np.zeros(shape=fodf_data.shape[:-1])
95103
weight_map = np.zeros(shape=fodf_data.shape[:-1])
96104

97-
p_matrix = np.eye(fodf_data.shape[3]) * legendre0_at_n
98105
all_split_streamlines =\
99106
subdivide_streamlines_at_voxel_faces(sft.streamlines)
100107
for split_streamlines in all_split_streamlines:
108+
# Get the direction of each segment
101109
segments = split_streamlines[1:] - split_streamlines[:-1]
102110
seg_lengths = np.linalg.norm(segments, axis=1)
103111

104-
# Remove points where the segment is zero.
112+
# Remove segments of length zero.
105113
# This removes numpy warnings of division by zero.
106114
non_zero_lengths = np.nonzero(seg_lengths)[0]
107115
segments = segments[non_zero_lengths]
108116
seg_lengths = seg_lengths[non_zero_lengths]
109117

118+
# Find closest point on sphere
110119
test = np.dot(segments, sphere.vertices.T)
111120
test2 = (test.T / (seg_lengths * sphere_norm)).T
112121
angles = np.arccos(test2)
113122
sorted_angles = np.argsort(angles, axis=1)
114123
closest_vertex_indices = sorted_angles[:, 0]
115124

116-
# Those starting points are used for the segment vox_idx computations
117-
strl_start = split_streamlines[non_zero_lengths]
118-
vox_indices = (strl_start + (0.5 * segments)).astype(int)
125+
# Get the middle voxel of each segment
126+
# (They are already cut per voxel, so the middle voxel is probably the
127+
# same as the start voxel.)
128+
seg_starts = split_streamlines[non_zero_lengths]
129+
vox_indices = (seg_starts + (0.5 * segments)).astype(int)
119130

120131
normalization_weights = np.ones_like(seg_lengths)
121132
if length_weighting:
122133
normalization_weights = seg_lengths / \
123134
np.linalg.norm(fodf.header.get_zooms()[:3])
124135

125-
for vox_idx, closest_vertex_index, norm_weight in zip(vox_indices,
126-
closest_vertex_indices,
127-
normalization_weights):
136+
for vox_idx, closest_vertex_index, norm_weight in zip(
137+
vox_indices, closest_vertex_indices, normalization_weights):
128138
vox_idx = tuple(vox_idx)
129139
b_at_idx = b_matrix.T[closest_vertex_index]
130140
fodf_at_index = fodf_data[vox_idx]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# -*- coding: utf-8 -*-
2+
import os
3+
4+
import nibabel as nib
5+
import numpy as np
6+
from dipy.io.stateful_tractogram import StatefulTractogram, Space, Origin
7+
from scilpy.tractanalysis.streamlines_metrics import compute_tract_counts_map
8+
9+
from scilpy.tractanalysis.afd_along_streamlines import \
10+
afd_map_along_streamlines
11+
from scilpy import SCILPY_HOME
12+
from scilpy.io.fetcher import fetch_data, get_testing_files_dict
13+
14+
15+
fetch_data(get_testing_files_dict(), keys=['processing.zip'])
16+
17+
18+
def test_afd_map_along_streamlines():
19+
fodf_img = nib.load(os.path.join(SCILPY_HOME, 'processing', 'fodf.nii.gz'))
20+
fodf = fodf_img.get_fdata(dtype=np.float32)
21+
fodf_mask = np.sum(fodf, axis=-1) > 0
22+
23+
peaks = nib.load(os.path.join(SCILPY_HOME, 'processing', 'peaks.nii.gz'))
24+
peaks = peaks.get_fdata(dtype=np.float32)
25+
peaks = peaks[:, :, :, 0:3] # Keeping only the first peak
26+
27+
# Creating a few very short streamlines (2 points) aligned on peaks
28+
# in the middle of the image (shape 57x67x56)
29+
streamlines = []
30+
for i in range(23, 26):
31+
start_pt = np.asarray([i + 0.1, i + 0.1, i + 0.1])
32+
peak = peaks[tuple(start_pt.astype(int))]
33+
peak = np.asarray(peak) / np.linalg.norm(peak)
34+
second_pt = np.asarray(start_pt) + peak
35+
streamlines.append(np.vstack([start_pt, second_pt], dtype=np.float32))
36+
sft = StatefulTractogram(streamlines=streamlines,
37+
reference=fodf_img,
38+
space=Space.VOX, origin=Origin('corner'))
39+
40+
# Processing
41+
afd_map, rd_map = afd_map_along_streamlines(
42+
sft, fodf_img, 'descoteaux07',
43+
length_weighting=False, is_legacy=False)
44+
45+
# Should have the same size as fodf
46+
assert np.array_equal(fodf_img.shape[0:3], afd_map.shape)
47+
48+
# Should only have values where we have streamlines + inside fodf mask.
49+
# Note. If the streamline starts exactly on the border of the voxel,
50+
# we get weird results, because the code for afd uses segments, which means
51+
# that the coordinate [i, i, i] may belong to the voxel [i, i, i] or to its
52+
# neighbor, depending on the direction of the segment, but the density_map
53+
# always considers it to be in voxel [i, i, i]. But ok, streamlines exactly
54+
# on a border should not happen too often in real life.
55+
density_map = compute_tract_counts_map(sft.streamlines, sft.dimensions)
56+
density_mask = density_map > 0
57+
mask = np.logical_and(density_mask, fodf_mask)
58+
assert np.array_equal(mask, afd_map != 0)
59+
60+
# Now processing again, but with more of the same streamlines
61+
# AFD will be the same (divided by weight map = by number of streamlines)
62+
streamlines2 = [s for s in streamlines]
63+
for translation in [0.1, 0.11, 0.12]:
64+
streamlines2 += [s + translation for s in streamlines]
65+
sft.streamlines = streamlines2
66+
afd_map2, rd_map2 = afd_map_along_streamlines(
67+
sft, fodf_img, 'descoteaux07',
68+
length_weighting=False, is_legacy=False)
69+
assert np.count_nonzero(afd_map) == np.count_nonzero(afd_map2)
70+
assert np.count_nonzero(rd_map) == np.count_nonzero(rd_map2)
71+
assert np.allclose(afd_map, afd_map2)
72+
assert np.allclose(rd_map, rd_map2)
73+
74+
# Now processing again, but with rotated peaks. Values should be in the
75+
# same voxels, but different
76+
streamlines2 = [s for s in streamlines]
77+
for s in streamlines:
78+
# Same starting point. Moving a little the end point, but we want to
79+
# stay in the same voxel
80+
modified_s = [s[0, :], s[1, :] + 0.1]
81+
streamlines2.append(modified_s)
82+
sft.streamlines = streamlines2
83+
afd_map2, rd_map2 = afd_map_along_streamlines(
84+
sft, fodf_img, 'descoteaux07',
85+
length_weighting=False, is_legacy=False)
86+
assert np.count_nonzero(afd_map) == np.count_nonzero(afd_map2)
87+
assert np.count_nonzero(rd_map) == np.count_nonzero(rd_map2)
88+
assert not np.allclose(afd_map, afd_map2)
89+
assert not np.allclose(rd_map, rd_map2)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# -*- coding: utf-8 -*-
2+
import numpy as np
3+
import nibabel as nib
4+
from dipy.io.stateful_tractogram import StatefulTractogram, Origin, Space
5+
6+
from scilpy.tractanalysis.voxel_boundary_intersection import \
7+
subdivide_streamlines_at_voxel_faces
8+
9+
10+
def test_subdivide():
11+
# Creating a few very short streamlines (2 points).
12+
13+
# streamline1: stays inside its voxel
14+
streamline1 = np.asarray([[23.0, 23.0, 23.0],
15+
[23.5, 23.5, 23.5]], dtype=np.float32)
16+
17+
# streamline2: changes to next voxel on second dim only. Covers 2 voxels
18+
streamline2 = np.asarray([[23.0, 23.1, 23.0],
19+
[23.5, 22.2, 23.5]], dtype=np.float32)
20+
21+
# streamline3: covers 11 voxels
22+
streamline3 = np.asarray([[0.0, 0.0, 0.0],
23+
[10.99, 0.0, 0.0]], dtype=np.float32)
24+
25+
fake_ref = nib.Nifti1Image(np.zeros((3, 3, 3)), affine=np.eye(4))
26+
sft = StatefulTractogram([streamline1, streamline2,
27+
streamline3], reference=fake_ref,
28+
origin=Origin('corner'), space=Space('vox'))
29+
split = subdivide_streamlines_at_voxel_faces(sft.streamlines)
30+
31+
# Streamline 1 should stay the same
32+
assert np.array_equal(split[0], streamline1)
33+
34+
# Streamline 2 should be split into 2 (one more point)
35+
assert split[1].shape[0] == 3
36+
assert np.array_equal(split[1][0, :], streamline2[0, :])
37+
assert np.array_equal(split[1][-1, :], streamline2[-1, :])
38+
39+
# streamline 3 should be split into 11 (12 points)
40+
assert split[2].shape[0] == 12

0 commit comments

Comments
 (0)