Skip to content

Commit f6a9726

Browse files
committed
- Fix saving the cc_surf (only saved when thickness_image was requested
- Resolved outstanding issues where vox_size was used, but correct application of vox2ras should have been used (subdivision mask, cc_image) - Fix cc_image - Fix subdivision mask - vectorize get_unique_contour_points - Remove resolved FIXME comments - Remove unused variables
1 parent 269e5ef commit f6a9726

File tree

4 files changed

+110
-136
lines changed

4 files changed

+110
-136
lines changed

CorpusCallosum/cc_visualization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def load_contours_from_template_dir(
133133
num_slices = len(indices)
134134
middle_idx = num_slices // 2
135135

136-
for i, thickness_file in enumerate(thickness_files):
136+
for thickness_file in thickness_files:
137137
try:
138138
idx = int(thickness_file.stem.split("_")[-1])
139139
except ValueError:

CorpusCallosum/fastsurfer_cc.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from collections.abc import Iterable
1919
from pathlib import Path
2020
from time import perf_counter_ns
21-
from typing import Literal, TypeVar, cast
21+
from typing import Literal, TypeVar, cast, get_args
2222

2323
import nibabel as nib
2424
import numpy as np
@@ -83,6 +83,18 @@
8383

8484
_TPathLike = TypeVar("_TPathLike", str, Path, Literal[None])
8585

86+
CCMeasures = Literal[
87+
"areas",
88+
"thickness",
89+
"curvature",
90+
"midline_length",
91+
"circularity",
92+
"cc_index",
93+
"total_area",
94+
"total_perimeter",
95+
"thickness_profile",
96+
]
97+
8698

8799
class ArgumentDefaultsHelpFormatter(HelpFormatter):
88100
"""Help message formatter which adds default values to argument help."""
@@ -755,7 +767,6 @@ def main(
755767
# start saving upright volume, this is the image in fsaverage space but not yet oriented via AC-PC
756768
if sd.has_attribute("upright_volume"):
757769
# upright == fsaverage-aligned
758-
# FIXME: upright currently does not get saved correctly
759770
io_futures.append(
760771
thread_executor().submit(
761772
apply_transform_to_volume,
@@ -841,7 +852,6 @@ def _orig2midslab_vox2vox(additional_context: int = 0) -> AffineMatrix4x4:
841852
subdivisions=subdivisions,
842853
subdivision_method=cast(SubdivisionMethod, subdivision_method),
843854
contour_smoothing=contour_smoothing,
844-
vox_size=vox_size,
845855
subject_dir=sd,
846856
)
847857
io_futures.extend(slice_io_futures)
@@ -859,7 +869,7 @@ def _orig2midslab_vox2vox(additional_context: int = 0) -> AffineMatrix4x4:
859869
cc_subseg_midslice = make_subdivision_mask(
860870
(cc_fn_seg_labels.shape[1], cc_fn_seg_labels.shape[2]),
861871
middle_slice_result["split_contours"],
862-
vox_size[1:3],
872+
vox2ras=fsavg_vox2ras @ np.linalg.inv(fsavg2midslice_vox2vox),
863873
)
864874
else:
865875
logger.warning("Too many subsegments for lookup table, skipping sub-division of output segmentation.")
@@ -886,24 +896,14 @@ def _orig2midslab_vox2vox(additional_context: int = 0) -> AffineMatrix4x4:
886896
orig2midslice_vox2vox=orig2midslice_vox2vox,
887897
))
888898

889-
METRICS = [
890-
"areas",
891-
"thickness",
892-
"curvature",
893-
"midline_length",
894-
"circularity",
895-
"cc_index",
896-
"total_area",
897-
"total_perimeter",
898-
"thickness_profile",
899-
]
899+
metrics: tuple[CCMeasures] = get_args(CCMeasures)
900900

901901
# Record key metrics for middle slice
902-
output_metrics_middle_slice = {metric: middle_slice_result[metric] for metric in METRICS}
902+
output_metrics_middle_slice = {metric: middle_slice_result[metric] for metric in metrics}
903903

904904
# Create enhanced output dictionary with all slice results
905905
per_slice_output_dict = {
906-
"slices": [convert_numpy_to_json_serializable({metric: result[metric] for metric in METRICS})
906+
"slices": [convert_numpy_to_json_serializable({metric: result[metric] for metric in metrics})
907907
for result in slice_results],
908908
}
909909

@@ -960,14 +960,14 @@ def _orig2midslab_vox2vox(additional_context: int = 0) -> AffineMatrix4x4:
960960
save_cc_measures_json,
961961
sd.filename_by_attribute('cc_mid_measures'),
962962
output_metrics_middle_slice | additional_metrics,
963-
))
963+
))
964964

965965
if sd.has_attribute("cc_measures"):
966966
io_futures.append(thread_executor().submit(
967967
save_cc_measures_json,
968968
sd.filename_by_attribute("cc_measures"),
969969
per_slice_output_dict | additional_metrics,
970-
))
970+
))
971971

972972
# save lta to fsaverage space
973973

CorpusCallosum/shape/postprocessing.py

Lines changed: 42 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,7 @@
3636
transform_to_acpc_standard,
3737
)
3838
from CorpusCallosum.shape.thickness import cc_thickness
39-
from CorpusCallosum.utils.types import (
40-
CCMeasuresDict,
41-
ContourThickness,
42-
Points2dType,
43-
Polygon2dType,
44-
SliceSelection,
45-
SubdivisionMethod,
46-
)
39+
from CorpusCallosum.utils.types import CCMeasuresDict, ContourThickness, Points2dType, SliceSelection, SubdivisionMethod
4740
from CorpusCallosum.utils.visualization import plot_contours
4841
from FastSurferCNN.utils import AffineMatrix4x4, Image3d, Mask2d, Shape2d, Shape3d, Vector2d
4942
from FastSurferCNN.utils.common import SubjectDirectory, update_docstring
@@ -100,7 +93,6 @@ def recon_cc_surf_measures_multi(
10093
subdivision_method: SubdivisionMethod,
10194
contour_smoothing: int,
10295
subject_dir: SubjectDirectory,
103-
vox_size: tuple[float, float, float],
10496
) -> tuple[list[CCMeasuresDict], list[concurrent.futures.Future]]:
10597
"""Surface reconstruction and metrics computation of corpus callosum slices based on selection mode.
10698
@@ -134,8 +126,6 @@ def recon_cc_surf_measures_multi(
134126
Gaussian sigma for contour smoothing.
135127
subject_dir : SubjectDirectory
136128
The SubjectDirectory object managing file names in the subject directory.
137-
vox_size : 3-tuple of floats
138-
LIA-oriented voxel size in millimeters (x, y, z).
139129
140130
Returns
141131
-------
@@ -162,7 +152,6 @@ def recon_cc_surf_measures_multi(
162152
subdivisions=subdivisions,
163153
subdivision_method=subdivision_method,
164154
contour_smoothing=contour_smoothing,
165-
vox_size=vox_size,
166155
)
167156

168157
# Process multiple slices or specific slice
@@ -224,14 +213,16 @@ def _gen_slice2slab_vox2vox(_slice_idx: int) -> AffineMatrix4x4:
224213
io_futures.append(
225214
run(
226215
plot_contours,
227-
transformed=midslices[current_slice_in_volume:current_slice_in_volume+1],
216+
# select the data of the current slice
217+
slice_or_slab=midslices[[current_slice_in_volume]],
218+
# the following need to be in voxel coordinates...
228219
split_contours=cc_measures["split_contours"],
229220
midline_equidistant=cc_measures["midline_equidistant"],
230221
levelpaths=cc_measures["levelpaths"],
231222
output_path=qc_imgs,
232-
ac_coords=ac_coords_vox,
233-
pc_coords=pc_coords_vox,
234-
vox_size=vox_size,
223+
ac_coords_vox=ac_coords_vox,
224+
pc_coords_vox=pc_coords_vox,
225+
vox2ras=this_slice_vox2ras,
235226
title=f"CC Subsegmentation by {subdivision_method} (Slice {slice_idx + 1})",
236227
)
237228
)
@@ -244,8 +235,6 @@ def _gen_slice2slab_vox2vox(_slice_idx: int) -> AffineMatrix4x4:
244235
f"thickness_measurement_points.txt) to {template_dir}")
245236
run = run
246237
for j in range(len(cc_contours)):
247-
# FIXME: check, if this is fixed (thickness values not nan == 200)
248-
# this does not seem to be thread-safe, do not parallelize!
249238
io_futures.append(run(cc_contours[j].save_contour, template_dir / f"contour_{j}.txt"))
250239
io_futures.append(run(cc_contours[j].save_thickness_values, template_dir / f"thickness_values_{j}.txt"))
251240

@@ -267,17 +256,14 @@ def _gen_slice2slab_vox2vox(_slice_idx: int) -> AffineMatrix4x4:
267256
logger.info(f"Saving overlay file to {overlay_file_path}")
268257
io_futures.append(run(cc_mesh.write_morph_data, overlay_file_path))
269258

270-
if any(wants_output(f"cc_{n}") for n in ("thickness_image", "cc_surf")):
259+
if any(wants_output(f"cc_{n}") for n in ("thickness_image", "surf")):
271260
import nibabel as nib
272261
up_data: Image3d[np.uint8] = np.empty(upright_header["dims"][:3], dtype=upright_header.get_data_dtype())
273262
upright_img = nib.MGHImage(up_data, fsavg_vox2ras, upright_header)
274263
# the mesh is generated in upright coordinates, so we need to also transform to orig coordinates
275-
# FIXME: this is currently not in RAS coordinates!
276-
277264
# Mesh is fsavg_midplane (RAS); we need to transform to voxel coordinates
278265
# fsavg ras is also on the midslice, so this is fine and we multiply in the IA and SP offsets
279266
cc_mesh = cc_mesh.to_vox_coordinates(mesh_ras2vox=np.linalg.inv(fsavg_vox2ras @ orig2fsavg_vox2vox))
280-
#FIXME: to_fs_coordinate needs to transform from upright to
281267
if wants_output("cc_thickness_image"):
282268
# this will also write overlay and surface
283269
thickness_image_path = output_path("cc_thickness_image")
@@ -318,7 +304,6 @@ def recon_cc_surf_measure(
318304
subdivisions: list[float],
319305
subdivision_method: SubdivisionMethod,
320306
contour_smoothing: int,
321-
vox_size: tuple[float, float, float],
322307
) -> tuple[CCMeasuresDict, ContourThickness, tuple[int, int]]:
323308
"""Reconstruct surfaces and compute measures for a single slice for the corpus callosum.
324309
@@ -342,8 +327,6 @@ def recon_cc_surf_measure(
342327
Method for contour subdivision ('shape', 'vertical', 'angular', or 'eigenvector').
343328
contour_smoothing : int
344329
Gaussian sigma for contour smoothing.
345-
vox_size : triplet of floats
346-
LIA-oriented voxel size in millimeters.
347330
348331
Returns
349332
-------
@@ -478,7 +461,7 @@ def recon_cc_surf_measure(
478461

479462

480463
def test_right_of_line(
481-
coords: Polygon2dType,
464+
coords: Points2dType,
482465
line_start: Vector2d,
483466
line_end: Vector2d,
484467
) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
@@ -487,27 +470,28 @@ def test_right_of_line(
487470
Parameters
488471
----------
489472
coords : np.ndarray
490-
Array of coordinates of shape (2, N).
473+
Array of coordinates of shape (..., N).
491474
line_start : array-like
492-
[x, y] coordinates of line start point.
475+
[x, y] coordinates of line start point (N,).
493476
line_end : array-like
494-
[x, y] coordinates of line end point.
477+
[x, y] coordinates of line end point (N,).
495478
496479
Returns
497480
-------
498481
np.ndarray
499-
Boolean array where True means point is to the left of the line.
482+
Boolean array where True means point is to the left of the line of shape coords.shape[:-1].
500483
"""
501484
# Vector from line_start to line_end
502-
line_vec = np.array(line_end) - np.array(line_start)
485+
line_start_arr = np.expand_dims(line_start, axis=np.arange(line_start.ndim, coords.ndim).tolist())
486+
line_vec = np.expand_dims(line_end, axis=np.arange(line_end.ndim, coords.ndim).tolist()) - line_start_arr
503487

504488
# Vectors from line_start to all points (vectorized)
505-
point_vec = coords - np.expand_dims(line_start, axis=list(range(1, coords.ndim)))
489+
point_vec = np.moveaxis(coords, -1, 0) - line_start_arr
506490

507491
# Cross product (vectorized): positive means point is to the left of the line
508492
cross_products = line_vec[0] * point_vec[1] - line_vec[1] * point_vec[0]
509493

510-
return cross_products > 0
494+
return np.greater(cross_products, 0)
511495

512496

513497
def get_unique_contour_points(split_contours: ContourList) -> list[Points2dType]:
@@ -535,41 +519,24 @@ def get_unique_contour_points(split_contours: ContourList) -> list[Points2dType]
535519
3. Collects points unique to each subsegment.
536520
"""
537521
# For each contour point, check if it appears in other contours
538-
unique_contour_points: list[Points2dType] = []
539-
540-
for i, contour in enumerate(split_contours):
541-
# Get points for this contour
542-
contour_points: Points2dType = np.vstack((contour[0], -contour[1])).T # Shape: (N,2)
543-
544-
# Check each point against all other contours
545-
unique_points = []
546-
for point in contour_points:
547-
is_unique = True
548-
549-
# Compare against other contours
550-
for j, other_contour in enumerate(split_contours):
551-
if i == j:
552-
continue
553-
554-
other_points = np.vstack((other_contour[0], -other_contour[1])).T
555-
556-
# Check if point exists in other contour (with small tolerance)
557-
if np.any(np.all(np.abs(other_points - point) < 1e-6, axis=1)):
558-
is_unique = False
559-
break
560-
561-
if is_unique:
562-
unique_points.append(point)
563-
564-
unique_contour_points.append(np.array(unique_points))
522+
# initialize with values for first_contour, which are by definition just "the contour" (empty)
523+
unique_contour_points: list[Points2dType] = [np.zeros((0, 2))]
524+
first_contour = split_contours[0]
525+
# Check each point against all other contours
526+
for contour in split_contours[1:]:
527+
# 0: coord-axis, 1: contour-axis, 2: first_contour_axis
528+
contour_comparison = np.isclose(first_contour[:, None], contour[:, :, None], atol=1e-6)
529+
# mask of contour points, that are also in first_contour (axis 1 after all)
530+
contour_points_in_first_contour_mask = np.any(np.all(contour_comparison, axis=0), axis=1)
531+
unique_contour_points.append(contour[:, ~contour_points_in_first_contour_mask].T)
565532

566533
return unique_contour_points
567534

568535

569536
def make_subdivision_mask(
570537
slice_shape: Shape2d,
571538
split_contours: ContourList,
572-
vox_size: tuple[float, float],
539+
vox2ras: AffineMatrix4x4,
573540
) -> np.ndarray[Shape2d, np.dtype[np.int_]]:
574541
"""Create a mask for subdividing the corpus callosum based on split contours.
575542
@@ -580,8 +547,8 @@ def make_subdivision_mask(
580547
split_contours : ContourList
581548
List of contours defining the subdivisions.
582549
Each contour is a tuple of x and y coordinates.
583-
vox_size : pair of floats
584-
The voxel sizes of the image grid in AS orientation.
550+
vox2ras : AffineMatrix4x4
551+
The vox2ras transformation matrix for the requested shape.
585552
586553
Returns
587554
-------
@@ -599,6 +566,7 @@ def make_subdivision_mask(
599566
- Tests which points lie to the right of the line.
600567
- Updates labels for those points.
601568
"""
569+
from nibabel.affines import apply_affine
602570

603571
# unique_contour_points are the points where sub-division lines were inserted
604572
unique_contour_points: list[Points2dType] = get_unique_contour_points(split_contours) # shape (N, 2)
@@ -610,29 +578,27 @@ def make_subdivision_mask(
610578

611579
# Create coordinate grids for all points in the slice
612580
rows, cols = slice_shape
613-
coords = np.array(np.mgrid[0:rows, 0:cols])[[1, 0]]
581+
coords_vox = np.stack(np.mgrid[0:1, 0:rows, 0:cols], axis=-1)
582+
coords_ras = apply_affine(vox2ras, coords_vox)
583+
584+
cc_labels_posterior_to_anterior = SUBSEGMENT_LABELS
614585

615-
cc_subsegment_lut_anterior_to_posterior = SUBSEGMENT_LABELS.copy()
616-
cc_subsegment_lut_anterior_to_posterior.reverse()
617-
618586
# Initialize with first segment label
619-
subdivision_mask = np.full(slice_shape, cc_subsegment_lut_anterior_to_posterior[0], dtype=np.int32)
620-
587+
subdivision_mask = np.full(slice_shape, cc_labels_posterior_to_anterior[0], dtype=np.int32)
588+
621589
# Process each subdivision line, subdivision_segments has for each division line the two points that are on the
622590
# contour and divide the subsegments
623-
for segment_idx, segment_points in enumerate(subdivision_segments):
591+
for label, segment_points in zip(cc_labels_posterior_to_anterior[1:], reversed(subdivision_segments), strict=True):
624592
# line_start and line_end are the intersection points of the CC subsegmentation boundary and the contour line
593+
line_start, line_end = segment_points
594+
625595
# --> find all voxels posterior to the line in question
626-
line_start: Vector2d = segment_points[0] / vox_size
627-
line_end: Vector2d = segment_points[-1] / vox_size
628-
629596
# Vectorized test: find all points to the right of line (line_start->line_end)
630597
# right_of_line == posterior to line
631-
points_right_of_line = test_right_of_line(coords, line_start, line_end)
598+
points_right_of_line = test_right_of_line(coords_ras[0, ..., 1:], line_start, line_end)
632599

633600
# All points to the right of this line belong to the next segment or beyond
634-
subdivision_mask[points_right_of_line] = cc_subsegment_lut_anterior_to_posterior[segment_idx + 1]
635-
601+
subdivision_mask[points_right_of_line] = label
636602
return subdivision_mask
637603

638604

0 commit comments

Comments
 (0)