Skip to content
Open
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
24 changes: 19 additions & 5 deletions CorpusCallosum/cc_visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from pathlib import Path
from typing import Literal

import nibabel as nib
import numpy as np
from nibabel.affines import apply_affine

from CorpusCallosum.data.fsaverage_cc_template import load_fsaverage_cc_template
from CorpusCallosum.shape.contour import CCContour
from CorpusCallosum.shape.mesh import CCMesh
from FastSurferCNN.utils.logging import get_logger, setup_logging
from FastSurferCNN.utils.lta import read_lta

logger = get_logger(__name__)

Expand Down Expand Up @@ -39,7 +42,7 @@ def make_parser() -> argparse.ArgumentParser:
"cc_mesh.vtk - VTK mesh file format "
"cc_mesh.fssurf - FreeSurfer surface file "
"cc_mesh_overlay.curv - FreeSurfer curvature overlay file "
"cc_mesh_snap.png - Screenshot/snapshot of the 3D mesh (requires whippersnappy>=1.3.1)",
"cc_mesh_snap.png - Screenshot/snapshot of the 3D mesh (requires whippersnappy>=2.0)",
metavar="OUTPUT_DIR"
)
parser.add_argument(
Expand Down Expand Up @@ -213,6 +216,17 @@ def main(
# 3D visualization
cc_mesh = CCMesh.from_contours(contours, smooth=0)

if Path(output_dir / "mri" / "upright.mgz").exists():
header = nib.load(output_dir / "mri" / "upright.mgz").header
# we need to get the upright image header, which is the same as cc_up.lta applied to orig.
elif Path(template_dir / "mri/orig.mgz").exists() and Path(template_dir / "mri/transforms/cc_up.lta").exists():
image = nib.load(template_dir / "mri" / "orig.mgz")
lta_mat = read_lta(template_dir / "mri/transforms/cc_up.lta")["lta"]
image.affine = apply_affine(lta_mat, image.affine)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The function apply_affine from nibabel.affines is used to transform points/vertices, not affine matrices. This line incorrectly applies apply_affine(lta_mat, image.affine) when it should be composing two affine matrices. The correct approach would be to matrix multiply: image.affine = lta_mat @ image.affine or use np.dot(lta_mat, image.affine). The current code will likely result in an error or incorrect transformation.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

Should be addressed by matrix multiplication.

header = image.header
else:
header = None

plot_kwargs = dict(
colormap=colormap,
color_range=color_range,
Expand All @@ -225,15 +239,15 @@ def main(
logger.info(f"Writing vtk file to {output_dir / 'cc_mesh.vtk'}")
cc_mesh.write_vtk(str(output_dir / "cc_mesh.vtk"))
logger.info(f"Writing freesurfer surface file to {output_dir / 'cc_mesh.fssurf'}")
cc_mesh.write_fssurf(str(output_dir / "cc_mesh.fssurf"))
cc_mesh.write_fssurf(str(output_dir / "cc_mesh.fssurf"), image=header)
logger.info(f"Writing freesurfer overlay file to {output_dir / 'cc_mesh_overlay.curv'}")
cc_mesh.write_morph_data(str(output_dir / "cc_mesh_overlay.curv"))
try:
cc_mesh.snap_cc_picture(str(output_dir / "cc_mesh_snap.png"))
cc_mesh.snap_cc_picture(str(output_dir / "cc_mesh_snap.png"), ref_header=header)
logger.info(f"Writing 3D snapshot image to {output_dir / 'cc_mesh_snap.png'}")
except RuntimeError:
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The exception handler is catching RuntimeError, but the snap_cc_picture method in mesh.py (line 527-531, 535-539) raises ImportError exceptions, not RuntimeError. The exception type should be changed to ImportError or a broader Exception to properly catch the errors from snap_cc_picture.

Suggested change
except RuntimeError:
except ImportError:

Copilot uses AI. Check for mistakes.
logger.warning("The cc_visualization script requires whippersnappy>=1.3.1 to makes screenshots, install with "
"`pip install whippersnappy>=1.3.1` !")
logger.warning("The cc_visualization script requires whippersnappy>=2.0 to makes screenshots, install with "
"`pip install whippersnappy>=2.0` !")
return 0

if __name__ == "__main__":
Expand Down
118 changes: 40 additions & 78 deletions CorpusCallosum/shape/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import tempfile
from pathlib import Path
from typing import TypeVar

Expand All @@ -27,8 +26,8 @@
import FastSurferCNN.utils.logging as logging
from CorpusCallosum.shape.contour import CCContour
from CorpusCallosum.shape.thickness import make_mesh_from_contour
from FastSurferCNN.utils import AffineMatrix4x4, nibabelImage
from FastSurferCNN.utils.common import suppress_stdout, update_docstring
from FastSurferCNN.utils import AffineMatrix4x4, nibabelHeader, nibabelImage
from FastSurferCNN.utils.common import update_docstring

try:
from pyrr import Matrix44
Expand Down Expand Up @@ -478,11 +477,12 @@ def __create_cc_viewmat() -> "Matrix44":
- -8 degrees around z-axis
3. Adds a small translation for better centering
"""
from whippersnappy.gl.views import ViewType, get_view_matrix
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The import from whippersnappy.gl.views import ViewType, get_view_matrix is placed inside the method, but the method is called from snap_cc_picture which may fail before reaching this line if whippersnappy is not installed. Consider moving this import to be alongside the whippersnappy version check in snap_cc_picture or ensuring this import is protected by the same try-except block. Additionally, this new dependency on whippersnappy.gl.views should be validated to ensure it exists in whippersnappy 2.0.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

I had made ViewType top level import for convenience:
from whippersnappy import ViewType


if not HAS_PYRR:
raise ImportError("Pyrr not installed, install pyrr with `pip install pyrr`.")

viewLeft = np.array([[0, 0, -1, 0], [-1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]]) # left w top up // right
viewLeft = get_view_matrix(ViewType.LEFT) # left w top up // right
transl = Matrix44.from_translation((0, 0, 0.4))
viewmat = transl * viewLeft

Expand All @@ -503,108 +503,70 @@ def __create_cc_viewmat() -> "Matrix44":
def snap_cc_picture(
self,
output_path: Path | str,
fssurf_file: Path | str | None = None,
overlay_file: Path | str | None = None,
ref_image: Path | str | nibabelImage | None = None,
ref_header: Path | str | nibabelHeader | None = None,
) -> None:
"""Snap a picture of the corpus callosum mesh.

Parameters
----------
output_path : Path, str
Path where to save the snapshot image.
fssurf_file : Path, str, optional
Path to a FreeSurfer surface file to use for the snapshot.
If None, the mesh is saved to a temporary file.
overlay_file : Path, str, optional
Path to a FreeSurfer overlay file to use for the snapshot.
If None, the mesh is saved to a temporary file.
ref_image : Path, str, nibabelImage, optional
ref_header : Path, str, nibabelImage, optional
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The docstring parameter description says "ref_header : Path, str, nibabelImage, optional" but the type annotation on line 506 shows it accepts "nibabelHeader" not "nibabelImage". The docstring should be corrected to say "ref_header : Path, str, nibabelHeader, optional" to match the actual type annotation.

Suggested change
ref_header : Path, str, nibabelImage, optional
ref_header : Path, str, nibabelHeader, optional

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

Well, actually this is the old type in the docstring. Most of the types of this string are currently not supported. We should just drop the types from the doc that are no longer supported.

Path to reference image to use for tkr creation. If None, ignores the file for saving.

Raises
------
Warning
If the mesh has no faces and cannot create a snapshot.

Notes
-----
The function:
1. Creates temporary files for mesh and overlay data if needed.
2. Uses whippersnappy to create a snapshot with:
- Custom view matrix for standard orientation.
- Ambient lighting and colorbar settings.
- Thickness overlay if available.
3. Cleans up temporary files after use.
"""
from packaging.version import parse
try:
# Dummy import of OpenCL to ensure it's available for whippersnappy
import OpenGL.GL # noqa: F401
from whippersnappy.core import snap1
import whippersnappy
except ImportError as e:
# whippersnappy not installed
raise ImportError(
f"The snap_cc_picture method of CCMesh requires {e.name}, but {e.name} was not found. "
f"Please install {e.name}!",
name=e.name, path=e.path
) from None
except Exception as e:
# Catch all other types of errors,
raise RuntimeError(
"Could not import OpenGL or whippersnappy. The snap_cc_picture method of CCMesh requires OpenGL and "
"whippersnappy to render the QC thickness image. On headless servers, this also requires a virtual "
"framebuffer like xvfb.",
) from e
self.__make_parent_folder(output_path)
from nibabel.affines import apply_affine

if parse(whippersnappy.__version__) < parse("2.0.0"):
raise ImportError(
f"The snap_cc_picture method of CCMesh requires whippersnappy>=2.0.0, but version "
f"{whippersnappy.__version__} is installed. Please upgrade whippersnappy to version 2.0.0 or higher!",
name="whippersnappy", path=None
)
# Skip snapshot if there are no faces
if len(self.t) == 0:
logger.warning("Cannot create snapshot - no faces in mesh")
return

# create temp file
if fssurf_file:
fssurf_file = Path(fssurf_file)
else:
fssurf_file = tempfile.NamedTemporaryFile(suffix=".fssurf", delete=True).name

ref_image_arg = str(ref_image) if isinstance(ref_image, (Path, str)) else ref_image
self.write_fssurf(fssurf_file, image=ref_image_arg)
self.__make_parent_folder(output_path)

if overlay_file:
overlay_file = Path(overlay_file)
if ref_header is not None:
v = apply_affine(ref_header.get_vox2ras_tkr(), self.v)
else:
overlay_file = tempfile.NamedTemporaryFile(suffix=".w", delete=True).name
# Write thickness values in FreeSurfer '*.w' overlay format
self.write_morph_data(overlay_file)

try:
with suppress_stdout():
snap1(
fssurf_file,
overlaypath=overlay_file,
view=None,
viewmat=self.__create_cc_viewmat(),
width=3 * 500,
height=3 * 300,
outpath=output_path,
ambient=0.6,
colorbar_scale=0.5,
colorbar_y=0.88,
colorbar_x=0.19,
brain_scale=2.1,
fthresh=0,
caption="Corpus Callosum thickness (mm)",
caption_y=0.85,
caption_x=0.17,
caption_scale=0.5,
)
except Exception as e:
raise e from None

if fssurf_file and hasattr(fssurf_file, "close"):
fssurf_file.close()
if overlay_file and hasattr(overlay_file, "close"):
overlay_file.close()
v = self.v
whippersnappy.snap1(
mesh=(v, self.t),
overlay=self.mesh_vertex_colors,
view=None,
viewmat=self.__create_cc_viewmat(),
width=3 * 500,
height=3 * 300,
outpath=str(output_path),
ambient=0.6,
colorbar_scale=0.5,
colorbar_y=0.88,
colorbar_x=0.19,
brain_scale=2.1,
fthresh=0,
caption="Corpus Callosum thickness (mm)",
caption_y=0.85,
caption_x=0.17,
caption_scale=0.5,
)

def smooth_(self, iterations: int = 1) -> None:
"""Smooth the mesh while preserving the z-coordinates.
Expand Down Expand Up @@ -674,7 +636,7 @@ def to_vox_coordinates(
return new_object

@update_docstring(parent_doc=TriaMesh.write_fssurf.__doc__)
def write_fssurf(self, filename: Path | str, image: str | nibabelImage | None = None) -> None:
def write_fssurf(self, filename: Path | str, image: str | nibabelImage | nibabelHeader | None = None) -> None:
"""{parent_doc}
Also creates parent directory if needed before writing the file.
"""
Expand Down
37 changes: 10 additions & 27 deletions CorpusCallosum/shape/postprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,49 +272,32 @@ def _zip_failed(it_idx, it_affine, it_result):
logger.info(f"Saving vtk file to {vtk_file_path}")
io_futures.append(run(cc_mesh.write_vtk, vtk_file_path))

if wants_output("cc_thickness_overlay") and not wants_output("cc_thickness_image"):
if wants_output("cc_thickness_overlay"):
overlay_file_path = output_path("cc_thickness_overlay")
logger.info(f"Saving overlay file to {overlay_file_path}")
io_futures.append(run(cc_mesh.write_morph_data, overlay_file_path))

if any(wants_output(f"cc_{n}") for n in ("thickness_image", "surf")):
import nibabel as nib
up_data: Image3d[np.uint8] = np.empty(upright_header["dims"][:3], dtype=upright_header.get_data_dtype())
upright_img = nib.MGHImage(up_data, fsavg_vox2ras, upright_header)
# the mesh is generated in upright coordinates, so we need to also transform to orig coordinates
# Mesh is fsavg_midplane (RAS); we need to transform to voxel coordinates
# fsavg ras is also on the midslice, so this is fine and we multiply in the IA and SP offsets
cc_mesh = cc_mesh.to_vox_coordinates(mesh_ras2vox=np.linalg.inv(fsavg_vox2ras @ orig2fsavg_vox2vox))
cc_surf_generated = False
cc_mesh_orig = cc_mesh.to_vox_coordinates(mesh_ras2vox=np.linalg.inv(fsavg_vox2ras @ orig2fsavg_vox2vox))
if wants_output("cc_surf"):
surf_file_path = output_path("cc_surf")
logger.info(f"Saving surf file to {surf_file_path}")
io_futures.append(run(cc_mesh_orig.write_fssurf, surf_file_path, image=upright_header))

if wants_output("cc_thickness_image"):
# this will also write overlay and surface
thickness_image_path = output_path("cc_thickness_image")
logger.info(f"Saving thickness image to {thickness_image_path}")
kwargs = {
"fssurf_file": output_path("cc_surf") if wants_output("cc_surf") else None,
"overlay_file": output_path("cc_thickness_overlay")
if wants_output("cc_thickness_overlay") else None,
"ref_image": upright_img,
}
try:
cc_mesh.snap_cc_picture(thickness_image_path, **kwargs)
cc_surf_generated = True
except (ImportError, ModuleNotFoundError) as e:
logger.error(
"The thickness image was not generated because whippersnappy, glfw or OpenGL are not installed."
)
logger.exception(e)
cc_mesh_orig.snap_cc_picture(thickness_image_path, ref_header=upright_header)
except Exception as e:
logger.error(
"The thickness image was not generated (see below). On headless Linux systems or if the "
"x-server cannot/should not be accessed due to other reasons, xvfb-run may be used to provide "
"a virtual framebuffer for offscreen rendering."
"Generation of the thickness image failed (see below). Please ensure that whippersnappy and "
"(for headless rendering) EGL libraries (libegl1) are available."
)
logger.exception(e)
if not cc_surf_generated and wants_output("cc_surf"):
surf_file_path = output_path("cc_surf")
logger.info(f"Saving surf file to {surf_file_path}")
io_futures.append(run(cc_mesh.write_fssurf, str(surf_file_path), image=upright_img))

if not slice_cc_measures:
logger.error("Error: No valid slices were found for postprocessing")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ dependencies = [

[project.optional-dependencies]
qc = [
'whippersnappy>=1.3.1',
'whippersnappy>=2.0',
]
doc = [
'fastsurfer[qc]',
Expand Down
2 changes: 1 addition & 1 deletion requirements.mac.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ yacs>=0.1.8
monai>=1.4.0
meshpy>=2025.1.1
pyrr>=0.10.3
whippersnappy>=1.3.1
whippersnappy>=2.0
pip>=25.0
50 changes: 28 additions & 22 deletions run_fastsurfer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -764,32 +764,39 @@ then
fi
fi

maybe_xvfb=()
# check if we are running on a headless system (CC QC needs a (virtual) display that support OpenGL)
# Check if --thickness_image is in cc_flags and whippersnappy version is >= 2.0
if [[ "$run_seg_pipeline" == "true" ]] && [[ "$run_cc_module" == "true" ]] && \
[[ "${cc_flags[*]}" =~ --thickness_image ]]
[[ "${cc_flags[*]}" == *"--thickness_image"* ]]
then
# if we have xvfb-run, we can use it to provide a virtual display
if [[ -n "$(which xvfb-run)" ]] ; then maybe_xvfb=("xvfb-run" "-a") ; fi

# try loading opengl, if this is successful we are fine
py_opengltest="import sys ; import glfw ; import whippersnappy.core ; sys.exit(1-glfw.init())"
opengl_error_message="$("${maybe_xvfb[@]}" $python -c "$py_opengltest" 2>&1 > /dev/null)"
exit_code="$?"
if [[ "$exit_code" != "0" ]]
# Check if whippersnappy is installed and version is >= 2.0
whippersnappy_check=$($python -c "
try:
import whippersnappy as wspy
from packaging.version import parse
print('OK' if parse(wspy.__version__) >= parse('2.0') else ('OLD_VERSION:' + wspy.__version__))
except ImportError:
print('NOT_INSTALLED')
except Exception as e:
print('ERROR:' + str(e))
" 2>&1)

if [[ "$whippersnappy_check" != "OK" ]]
then
# if we cannot import OpenGL or whippersnappy, its an environment installation issue
if [[ "$opengl_error_message" =~ "ModuleNotFoundError" ]] || [[ "$opengl_error_message" =~ "ImportError" ]]
if [[ "$whippersnappy_check" == "NOT_INSTALLED" ]]
then
echo "WARNING: The --qc_snap option of the corpus callosum module requires the Python packages PyOpenGL, glfw and"
echo " whippersnappy to be installed, but python could not import those three. Please install them and their"
echo " dependencies via 'pip install pyopengl glfw whippersnappy'."
echo "ERROR: The --qc_snap flag requires the 'whippersnappy' package (version >= 2.0) to generate the qc"
echo " thickness image, but whippersnappy is not installed in your Python environment."
elif [[ "$whippersnappy_check" == OLD_VERSION:* ]]
then
installed_version="${whippersnappy_check#OLD_VERSION:}"
echo "ERROR: The --qc_snap flag requires whippersnappy version >= 2.0 to generate the qc thickness image,"
echo " but you only have version $installed_version installed."
else
echo "WARNING: The --qc_snap option of the corpus callosum module requires OpenGL support, but we could not"
echo " create OpenGL handles. For Linux headless systems, you may install xvfb-run to provide a virtual display."
echo "ERROR: Failed to check whippersnappy installation: $whippersnappy_check"
fi
echo " FastSurfer will not fail due to the unavailability of OpenGL, but some QC snapshots (rendered thickness"
echo " image) will not be created."
echo " Please install or upgrade whippersnappy with one of the following commands:"
echo " pip install 'whippersnappy>=2.0'"
exit 1
fi
fi

Expand Down Expand Up @@ -1181,10 +1188,9 @@ then
# note: callosum manedit currently only affects inpainting and not internal FastSurferCC processing (surfaces etc)
callosum_seg_manedit="$(add_file_suffix "$callosum_seg" "manedit")"
# generate callosum segmentation, mesh, shape and downstream measure files
cmd=("${maybe_xvfb[@]}" $python "$CorpusCallosumDir/fastsurfer_cc.py" --sd "$sd" --sid "$subject"
cmd=($python "$CorpusCallosumDir/fastsurfer_cc.py" --sd "$sd" --sid "$subject"
"--threads" "$threads_seg" "--conformed_name" "$conformed_name" "--aseg_name" "$asegdkt_segfile"
"--segmentation_in_orig" "$callosum_seg" "${cc_flags[@]}")
# if we are trying to create the thickness image in a headless setting, wrap call in xvfb-run
echo_quoted "${cmd[@]}" | tee -a "$seg_log"
"${wrap[@]}" "${cmd[@]}" 2>&1 | tee -a "$seg_log"
exit_code=${PIPESTATUS[0]}
Expand Down
Loading