Skip to content

ENH Visualize HCP fs_LR 32k data and resample to fsaverage (cortex.hcp)#3

Closed
mvdoc wants to merge 2 commits into
surf2surf-pure-pythonfrom
hcp-fs-lr-visualization
Closed

ENH Visualize HCP fs_LR 32k data and resample to fsaverage (cortex.hcp)#3
mvdoc wants to merge 2 commits into
surf2surf-pure-pythonfrom
hcp-fs-lr-visualization

Conversation

@mvdoc

@mvdoc mvdoc commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Adds a cortex.hcp module to visualize HCP fs_LR 32k data with pycortex — natively on the HCP template and resampled to fsaverage.

Stacked on top of gallantlab#651 (surf2surf-pure-python); base it on main once that merges.

What's new

cortex.hcp

  • download_fs_lr() — fetch the prebuilt 32k_fs_LR pycortex subject (registered with download_subject) for native fs_LR rendering via the usual cortex.Vertex(...) /quickshow path.
  • cifti_to_surface() / get_cifti_vertex_indices() — expand CIFTI cortical grayordinates (59412) to the full 64984-vertex fs_LR surface, NaN medial wall.
  • project_fslr_to_fsaverage() / to_fsaverage() — resample fs_LR 32k data to fsaverage5/6/fsaverage using a pure-python spherical-barycentric matrix built from the HCP standard-mesh spheres. This matches wb_command -metric-resample ... BARYCENTRIC, so Connectome Workbench is not required at runtime.

FreeSurfer-free upsample_to_fsaverage

  • The per-vertex nearest-neighbor mapping is a fixed property of the fsaverage tessellation, so precomputed neighbor tables for fsaverage5/6 now ship with pycortex (cortex/data/upsample_fsaverage_neighbors.npz, 0.68 MB), with a compute-from-sphere (+cache) fallback for other orders. Output is numerically identical to the previous implementation; $SUBJECTS_DIR is no longer needed for the common cases.

Example + tests

  • examples/hcp/plot_hcp_fs_lr.py renders both the native and the resampled flatmaps (runs on CI with no FreeSurfer).
  • cortex/tests/test_hcp.py (barycentric matrix math, CIFTI expansion, wb_command validation when available) and new FreeSurfer-free upsample_to_fsaverage tests.

Notes

  • The 32k_fs_LR subject is derived from HCP S1200 group-average Open Access surfaces and redistributed under the WU-Minn HCP Consortium Open Access Data Use Terms (with the required acknowledgment); its download URL is registered in download_subject.
  • Verified the bundled .npz ships in the built wheel and loads from the installed package.

🤖 Generated with Claude Code

https://claude.ai/code/session_019z37ML4wsmosg5Xvvq6ENN

Add a `cortex.hcp` module for working with HCP fs_LR 32k data in pycortex:

- download_fs_lr(): fetch the prebuilt 32k_fs_LR pycortex subject (registered
  with download_subject) for native fs_LR rendering.
- cifti_to_surface()/get_cifti_vertex_indices(): expand CIFTI grayordinates to
  the full 64984-vertex fs_LR surface, filling the medial wall with NaN.
- project_fslr_to_fsaverage()/to_fsaverage(): resample fs_LR 32k data to
  fsaverage5/6/fsaverage via a pure-python spherical-barycentric matrix built
  from the HCP standard-mesh spheres. This matches
  `wb_command -metric-resample ... BARYCENTRIC`, so Connectome Workbench is not
  required at runtime.

Also make upsample_to_fsaverage() FreeSurfer-free for the common cases: the
per-vertex nearest-neighbor mapping is a fixed property of the fsaverage
tessellation, so ship precomputed neighbor tables for fsaverage5/6 (0.68 MB) and
fall back to computing from the sphere (and caching) for other orders. Output is
numerically identical to the previous sphere-based implementation.

Add examples/hcp/plot_hcp_fs_lr.py (renders both the native and the resampled
flatmaps) and tests in cortex/tests/test_hcp.py, plus FreeSurfer-free
upsample_to_fsaverage tests. The barycentric matrix is validated against
wb_command when it is available.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019z37ML4wsmosg5Xvvq6ENN

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new hcp module to support the visualization and resampling of HCP fs_LR 32k data, along with corresponding tests, examples, and integration updates across the codebase. The code review feedback focuses on enhancing the robustness and performance of this new module. Key recommendations include adding integrity checks for cached sphere files, improving parameter type validation for CIFTI inputs, supporting hemispherically split CIFTI files, ensuring compatibility with older SciPy versions by passing string paths to load_npz and save_npz, optimizing memory usage by preserving float32 precision, and removing a redundant np.abs call on non-negative matrices.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread cortex/hcp.py
Comment on lines +134 to +135
if fpath.exists():
return fpath

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If a previous download was interrupted or corrupted, fpath.exists() will still be True, and the function will return a corrupted file, leading to cryptic errors later when trying to load it.

We can add a quick integrity check by attempting to load the cached file with nib.load. If it fails, we log a warning, delete the corrupted file, and proceed to re-download it.

Suggested change
if fpath.exists():
return fpath
if fpath.exists():
try:
nib.load(str(fpath))
return fpath
except Exception:
logger.warning("Cached sphere file %s is corrupted. Re-downloading...", fpath)
fpath.unlink(missing_ok=True)

Comment thread cortex/hcp.py Outdated
Comment on lines +215 to +216
if not isinstance(cifti, nib.Cifti2Image):
cifti = nib.load(str(cifti))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using str(cifti) when cifti is not a Cifti2Image can lead to confusing errors. If cifti is already a loaded image of another type (e.g., a Nifti1Image passed by mistake), str(cifti) will return its string representation (like "<nibabel.nifti1.Nifti1Image object at 0x...>") rather than a file path, causing a FileNotFoundError when nib.load is called.

It is safer to explicitly check if cifti is a path-like object (a string or Path) before loading it, and raise a clear TypeError if it is neither a path nor a Cifti2Image.

Suggested change
if not isinstance(cifti, nib.Cifti2Image):
cifti = nib.load(str(cifti))
if isinstance(cifti, (str, Path)):
cifti = nib.load(str(cifti))
if not isinstance(cifti, nib.Cifti2Image):
raise TypeError("cifti must be a Cifti2Image object or a path to a CIFTI-2 file.")

Comment thread cortex/hcp.py Outdated
Comment on lines +234 to +236
if "L" not in indices or "R" not in indices:
raise ValueError("CIFTI file is missing a left and/or right cortex structure.")
return indices["L"], indices["R"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Currently, get_cifti_vertex_indices raises a ValueError if either the left or right cortex structure is missing. However, some CIFTI files are hemispherically split and only contain one hemisphere.

We can make this function much more robust and compatible with hemispherically split files by defaulting any missing hemisphere to an empty array instead of raising an error. cifti_to_surface and project_fslr_to_fsaverage already handle empty index arrays gracefully.

Suggested change
if "L" not in indices or "R" not in indices:
raise ValueError("CIFTI file is missing a left and/or right cortex structure.")
return indices["L"], indices["R"]
if "L" not in indices and "R" not in indices:
raise ValueError("CIFTI file is missing both left and right cortex structures.")
left_indices = indices.get("L", np.array([], dtype=np.int64))
right_indices = indices.get("R", np.array([], dtype=np.int64))
return left_indices, right_indices

Comment thread cortex/hcp.py
if cache and cache_path.exists():
logger.info("Loading cached matrix from %s", cache_path)
return load_npz(cache_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In older versions of SciPy, load_npz does not support pathlib.Path objects and requires string paths. To ensure maximum compatibility across different SciPy versions, we should convert cache_path to a string.

Suggested change
return load_npz(str(cache_path))

Comment thread cortex/hcp.py Outdated

if cache:
cache_path.parent.mkdir(parents=True, exist_ok=True)
save_npz(cache_path, matrix)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In older versions of SciPy, save_npz does not support pathlib.Path objects and requires string paths. To ensure maximum compatibility across different SciPy versions, we should convert cache_path to a string.

Suggested change
save_npz(cache_path, matrix)
save_npz(str(cache_path), matrix)

Comment thread cortex/hcp.py Outdated
projected = (matrix @ hemi_clean.T).T

# Weight actually contributed by valid (non-NaN) source vertices.
valid_weight = (np.abs(matrix) @ (~nan_mask).astype(np.float64).T).T

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The resampling matrix contains only non-negative barycentric weights (clamped to >= 0 and normalized to sum to 1) or fallback weights of 1.0. Therefore, taking the absolute value with np.abs(matrix) is completely redundant and adds unnecessary computation overhead on a large sparse matrix.

We can safely remove np.abs and use matrix directly.

Suggested change
valid_weight = (np.abs(matrix) @ (~nan_mask).astype(np.float64).T).T
valid_weight = (matrix @ (~nan_mask).astype(np.float64).T).T

Comment thread cortex/hcp.py Outdated
Data on the target surface. Medial-wall and all-NaN-source vertices are
NaN; apply ``numpy.nan_to_num`` before handing to pycortex if needed.
"""
data = np.asarray(data, dtype=np.float64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Forcing data to np.float64 can double the memory usage for large datasets (e.g., long timeseries), which are extremely common in neuroimaging and typically stored as float32.

We should preserve the input's floating-point precision (or default to float32 for integer inputs) to optimize memory usage and performance.

Suggested change
data = np.asarray(data, dtype=np.float64)
out_dtype = data.dtype if np.issubdtype(data.dtype, np.floating) else np.float32
data = np.asarray(data, dtype=out_dtype)

Comment thread cortex/hcp.py Outdated
)

n_tgt_hemi = N_VERTICES_TARGET_HEM[matrix_target]
result = np.full((data.shape[0], 2 * n_tgt_hemi), np.nan, dtype=np.float64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Initialize result with out_dtype instead of forcing np.float64 to preserve the input's floating-point precision and save memory.

Suggested change
result = np.full((data.shape[0], 2 * n_tgt_hemi), np.nan, dtype=np.float64)
result = np.full((data.shape[0], 2 * n_tgt_hemi), np.nan, dtype=out_dtype)

Comment thread cortex/hcp.py Outdated
Comment on lines +592 to +593
if not isinstance(cifti, nib.Cifti2Image):
cifti = nib.load(str(cifti))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using str(cifti) when cifti is not a Cifti2Image can lead to confusing errors. If cifti is already a loaded image of another type (e.g., a Nifti1Image passed by mistake), str(cifti) will return its string representation (like "<nibabel.nifti1.Nifti1Image object at 0x...>") rather than a file path, causing a FileNotFoundError when nib.load is called.

It is safer to explicitly check if cifti is a path-like object (a string or Path) before loading it, and raise a clear TypeError if it is neither a path nor a Cifti2Image.

Suggested change
if not isinstance(cifti, nib.Cifti2Image):
cifti = nib.load(str(cifti))
if isinstance(cifti, (str, Path)):
cifti = nib.load(str(cifti))
if not isinstance(cifti, nib.Cifti2Image):
raise TypeError("cifti must be a Cifti2Image object or a path to a CIFTI-2 file.")

- get_cifti_vertex_indices/to_fsaverage: raise TypeError for non-path,
  non-Cifti2Image inputs instead of a cryptic FileNotFoundError.
- get_cifti_vertex_indices: tolerate hemispherically split CIFTI files by
  returning an empty index array for a missing hemisphere.
- project_fslr_to_fsaverage: preserve float32 input precision instead of
  forcing float64 (halves memory for timeseries).
- get_fslr_to_fsaverage_matrix: pass str paths to load_npz/save_npz for older
  SciPy compatibility.
- _project_hemi: drop the redundant np.abs on the non-negative weight matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019z37ML4wsmosg5Xvvq6ENN
@mvdoc

mvdoc commented Jul 8, 2026

Copy link
Copy Markdown
Owner Author

Superseded by gallantlab#655 (opened against upstream gallantlab:main).

@mvdoc mvdoc closed this Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant