Skip to content

ENH: Per-organ tutorial parameters and distance-map registration fixes - #119

Open
aylward wants to merge 2 commits into
Project-MONAI:mainfrom
aylward:params_and_distance_maps
Open

ENH: Per-organ tutorial parameters and distance-map registration fixes#119
aylward wants to merge 2 commits into
Project-MONAI:mainfrom
aylward:params_and_distance_maps

Conversation

@aylward

@aylward aylward commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Introduce tutorials/parameters_lung_ct_dirlab.py and tutorials/parameters_heart_ct_kcl.py as the single source for each use case's mask dilation, distance-map saturation radius, PCA component counts, Greedy iteration schedule, segmenter class, and (heart only) the interior chamber label ids. Every tutorial that rasterizes or registers a distance map now reads the same values, so the maps a network is finetuned on match the maps it later infers over. No paths live in these modules; each tutorial keeps its own inputs and outputs.

Add tutorials/tutorial_02_heart_distancemap_finetune_icon.py, which finetunes uniGradICON on heart distance maps built from the Duke-Heart-4DLabelmaps labelmaps with the chambers excluded. The heart needs its own run rather than reusing the lung weights: its registration mask is much tighter, so its distance maps saturate over a shorter radius and do not share an intensity distribution with the lung ones.

Library fixes:

  • transform_tools.transform_image gains an explicit background_value. Resampling previously fell back to ITK's default of 0, which for CT is water, not air, so pre-warped moving images carried a false soft-tissue shell wherever they had no data. register_images_base now fills with -1000 HU for CT (exactly uniGradICON's window floor) on the image warp only; masks and labelmaps keep 0.
  • register_models_distance_maps composed the Greedy and ICON transforms in the wrong order. ITK's CompositeTransform applies back to front, so the residual must be added last in the forward and first in the inverse. Also drops two unconditional debug_*.nii.gz writes that crashed when mask_dilation_mm was 0.
  • register_from restores moving_image and clears moving_image_registered, so a registrar can be reused after an initialized run.
  • workflow_fit_statistical_model_to_patient grids the PCA field on a template-frame reference image rather than the patient image, and pads physically from spacing.
  • Default registrar for intensity registration switches from RegisterImagesGreedyICON/ICON to RegisterImagesGreedy in register_time_series_images and workflow_convert_image_to_usd. Distance-map registration keeps ICON, now with the finetuned weights.
  • convert_vtk_to_usd validates object names as USD identifiers and rejects duplicates; workflow_convert_vtk_to_usd wraps raw vtkDataSet.

Rename number_of_components / number_of_modes to
number_of_pca_components throughout the workflows and tutorials.

tutorial_02_lung_finetune_icon now writes difference images (fixed minus registered) instead of the resampled volumes, and reports the chain's Greedy-stage-only score as its own row. The chain remains unconditional: on DIR-Lab, ICON's 175^3 residual grid is about 1.4 mm over the FOV, coarser than the 1.10 mm Greedy already achieves, so it cannot refine and the tutorial reports that honestly.

tutorial_02_lung_distancemap_finetune_icon restricts its cached labelmaps to the lung labels. They previously held all 97 whole-body classes, and uniGradICON's Dice loss one-hots every shared class at 175^3 by batch 4, which saturated GPU memory.

Baselines for the slow and GPU buckets will need refreshing: the composition-order fix and the PCA field grid change alter registration output.

Summary by CodeRabbit

  • New Features

    • Added Duke Heart tutorials for labelmap conversion, statistical modeling, patient fitting, time-series modeling, and inference.
    • Added label-aware VTK-to-USD conversion with anatomy names and materials.
    • Added improved surface, tetrahedral mesh, resampling, and topology handling.
    • Added configurable distance-map limits and modality-specific background handling.
  • Documentation

    • Expanded the tutorial index to 17 runnable scripts.
    • Documented the Duke Heart dataset’s unavailable status and expected layout.
  • Updates

    • Applicable workflows and tutorials now default to CPU-based Greedy registration.
    • Added shared lung, heart, and Duke Heart tutorial configurations.

Introduce tutorials/parameters_lung_ct_dirlab.py and
tutorials/parameters_heart_ct_kcl.py as the single source for each use
case's mask dilation, distance-map saturation radius, PCA component
counts, Greedy iteration schedule, segmenter class, and (heart only) the
interior chamber label ids. Every tutorial that rasterizes or registers a
distance map now reads the same values, so the maps a network is
finetuned on match the maps it later infers over. No paths live in these
modules; each tutorial keeps its own inputs and outputs.

Add tutorials/tutorial_02_heart_distancemap_finetune_icon.py, which
finetunes uniGradICON on heart distance maps built from the
Duke-Heart-4DLabelmaps labelmaps with the chambers excluded. The heart
needs its own run rather than reusing the lung weights: its registration
mask is much tighter, so its distance maps saturate over a shorter radius
and do not share an intensity distribution with the lung ones.

Library fixes:

- transform_tools.transform_image gains an explicit background_value.
  Resampling previously fell back to ITK's default of 0, which for CT is
  water, not air, so pre-warped moving images carried a false soft-tissue
  shell wherever they had no data. register_images_base now fills with
  -1000 HU for CT (exactly uniGradICON's window floor) on the image warp
  only; masks and labelmaps keep 0.
- register_models_distance_maps composed the Greedy and ICON transforms
  in the wrong order. ITK's CompositeTransform applies back to front, so
  the residual must be added last in the forward and first in the
  inverse. Also drops two unconditional debug_*.nii.gz writes that
  crashed when mask_dilation_mm was 0.
- register_from restores moving_image and clears moving_image_registered,
  so a registrar can be reused after an initialized run.
- workflow_fit_statistical_model_to_patient grids the PCA field on a
  template-frame reference image rather than the patient image, and pads
  physically from spacing.
- Default registrar for intensity registration switches from
  RegisterImagesGreedyICON/ICON to RegisterImagesGreedy in
  register_time_series_images and workflow_convert_image_to_usd.
  Distance-map registration keeps ICON, now with the finetuned weights.
- convert_vtk_to_usd validates object names as USD identifiers and
  rejects duplicates; workflow_convert_vtk_to_usd wraps raw vtkDataSet.

Rename number_of_components / number_of_modes to
number_of_pca_components throughout the workflows and tutorials.

tutorial_02_lung_finetune_icon now writes difference images
(fixed minus registered) instead of the resampled volumes, and reports
the chain's Greedy-stage-only score as its own row. The chain remains
unconditional: on DIR-Lab, ICON's 175^3 residual grid is about 1.4 mm
over the FOV, coarser than the 1.10 mm Greedy already achieves, so it
cannot refine and the tutorial reports that honestly.

tutorial_02_lung_distancemap_finetune_icon restricts its cached
labelmaps to the lung labels. They previously held all 97
whole-body classes, and uniGradICON's Dice loss one-hots every shared
class at 175^3 by batch 4, which saturated GPU memory.

Baselines for the slow and GPU buckets will need refreshing: the
composition-order fix and the PCA field grid change alter registration
output.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Walkthrough

The PR changes registration defaults, adds modality-aware resampling and mesh conversion, centralizes tutorial parameters, and adds Duke heart labelmap, statistical-model, and PhysicsNeMo workflows.

Changes

Registration and mesh processing

Layer / File(s) Summary
Registration defaults and background handling
src/physiotwin4d/register_images_base.py, src/physiotwin4d/transform_tools.py, src/physiotwin4d/register_time_series_images.py, src/physiotwin4d/workflow_convert_image_to_usd.py, tests/test_register_images_base.py
Greedy registration becomes the default in selected workflows. Intensity warps use modality-specific backgrounds, while masks and labelmaps use zero.
Contour, surface, and tetrahedral processing
src/physiotwin4d/contour_tools.py, src/physiotwin4d/image_tools.py, tests/test_contour_mesh_extraction.py, tests/test_image_tools.py
The PR adds anisotropic resampling, watertight surfaces, labeled surfaces, tetrahedral extraction and trimming, anatomy coloring, and ACVD remeshing.
Label-aware USD conversion and topology
src/physiotwin4d/workflow_convert_vtk_to_usd.py, src/physiotwin4d/convert_vtk_to_usd.py, src/physiotwin4d/vtk_to_usd/usd_mesh_converter.py, src/physiotwin4d/usd_tools.py, tests/test_workflow_convert_vtk_to_usd.py, tests/test_convert_vtk_to_usd.py
VTK-to-USD conversion detects labels, creates structure-specific prims, applies taxonomy materials, and authors topology per frame when topology changes.

Tutorial configuration and workflows

Layer / File(s) Summary
Shared tutorial parameters and registration migration
tutorials/parameters_*.py, tutorials/tutorial_01_*, tutorials/tutorial_03_*, tutorials/tutorial_06_*, tutorials/tutorial_07_*, tutorials/tutorial_08_*, docs/tutorials.rst
Immutable heart, lung, and Duke heart configurations provide paths, schedules, PCA settings, distance-map values, and held-out cases. Selected tutorials use direct Greedy registration.
Distance-map finetuning and diagnostics
src/physiotwin4d/workflow_fit_statistical_model_to_patient.py, tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py, tutorials/tutorial_02_lung_distancemap_finetune_icon.py, tutorials/tutorial_02_lung_finetune_icon.py
Distance-map saturation is configurable. Duke heart finetuning trains or loads ICON weights. Lung evaluation adds iteration sweeps, chain diagnostics, coverage metrics, and shared warping.
Duke heart mesh and statistical-model pipeline
tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py, tutorials/tutorial_05_duke_heart_vtk_to_usd.py, tutorials/tutorial_06_duke_heart_create_statistical_model.py, tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py, tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py, data/Duke-Heart-4DLabelmaps/*
The new tutorials convert labelmaps to meshes and USD, build and fit a PCA model, and propagate fitted surfaces through gated frames. Dataset availability and expected layout are documented.
PhysicsNeMo training and inference
tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py, tutorials/tutorial_10_duke_heart_infer_physicsnemo.py, tutorials/tutorial_09_lung_train_physicsnemo_mgn.py, tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py, tests/test_tutorials.py
Duke heart training prepares displacement manifests and evaluates MeshGraphNet predictions. Lung training and inference use separate configured checkpoint and tutorial-output paths.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DukeHeartLabelmaps
  participant ContourTools
  participant StatisticalModel
  participant PhysicsNeMo
  DukeHeartLabelmaps->>ContourTools: provide labeled frames
  ContourTools->>StatisticalModel: provide reference surfaces
  StatisticalModel->>PhysicsNeMo: provide fitted surfaces and displacement targets
Loading

Possibly related PRs

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.14956% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.56%. Comparing base (743938d) to head (18d225e).

Files with missing lines Patch % Lines
src/physiotwin4d/contour_tools.py 88.88% 23 Missing ⚠️
...win4d/workflow_fit_statistical_model_to_patient.py 42.85% 4 Missing ⚠️
src/physiotwin4d/workflow_convert_image_to_usd.py 50.00% 3 Missing ⚠️
src/physiotwin4d/workflow_convert_vtk_to_usd.py 93.61% 3 Missing ⚠️
src/physiotwin4d/workflow_convert_image_to_vtk.py 0.00% 2 Missing ⚠️
src/physiotwin4d/image_tools.py 96.00% 1 Missing ⚠️
src/physiotwin4d/transform_tools.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #119      +/-   ##
==========================================
+ Coverage   42.18%   44.56%   +2.37%     
==========================================
  Files          72       72              
  Lines        8742     9039     +297     
==========================================
+ Hits         3688     4028     +340     
+ Misses       5054     5011      -43     
Flag Coverage Δ
integration-tests 44.37% <89.14%> (?)
unittests 44.56% <89.14%> (+2.37%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai 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.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/physiotwin4d/transform_tools.py (1)

452-475: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use modality-aware fill values for off-grid intensity resampling.

Keep background_value=0.0 for masks and labelmaps. Use _prewarp_background_value() where intensity data is resampled to avoid zero-filling CT data as water. Update RegisterImagesBase.get_registered_image() and the time-series reconstruction path, then add a regression test for off-grid CT fill values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/transform_tools.py` around lines 452 - 475, Update
RegisterImagesBase.get_registered_image() and the time-series reconstruction
path to pass _prewarp_background_value() for intensity-image resampling, while
preserving background_value=0.0 for masks and labelmaps. Add a regression test
verifying off-grid CT voxels receive the modality-appropriate fill value rather
than zero.

Source: Coding guidelines

🧹 Nitpick comments (3)
tutorials/parameters_heart_ct_kcl.py (1)

64-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the two interior-id fields use the same type.

interior_object_ids_totalsegmentator is Optional[list[int]] and interior_object_ids_simpleware is list[int]. Both always hold a list. The consumer parameter labelmap_interior_object_ids accepts Optional[list], so list[int] works for both. Drop the Optional for consistency, and then the Optional import becomes unused.

♻️ Proposed change
-    interior_object_ids_totalsegmentator: Optional[list[int]] = field(
+    interior_object_ids_totalsegmentator: list[int] = field(
         default_factory=lambda: [141, 142, 143, 144]
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/parameters_heart_ct_kcl.py` around lines 64 - 69, Update the
interior_object_ids_totalsegmentator field to use list[int], matching
interior_object_ids_simpleware and the labelmap_interior_object_ids consumer,
and remove the now-unused Optional import.
src/physiotwin4d/workflow_fit_statistical_model_to_patient.py (1)

248-248: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add distancemap_squared_max to the class Attributes docstring.

The class docstring at lines 68-110 lists the configurable state, including mask_dilation_mm. The new attribute is missing there. Add one line so readers find the knob and its default derivation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/workflow_fit_statistical_model_to_patient.py` at line 248,
Update the Attributes class docstring to document distancemap_squared_max
alongside the existing configurable state, including that its default is derived
as appropriate from the implementation. Leave the attribute declaration and
surrounding behavior unchanged.
tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py (1)

95-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider defaulting run_finetuning to False.

run_finetuning = True combined with lines 225-230 deletes experiment_dir on every run. tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py reads the checkpoint from that tree, so a re-run of this tutorial discards the weights Tutorial 7 depends on before it retrains them. The sibling tutorial tutorials/tutorial_02_lung_finetune_icon.py documents the opposite default: finetuning is off so runs reuse the checkpoint. Align the two defaults, or state in the module docstring why the heart run always retrains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py` around lines
95 - 97, Default the run_finetuning setting in the tutorial to False, matching
tutorials/tutorial_02_lung_finetune_icon.py so existing checkpoints are reused
and experiment_dir is not deleted on reruns; retain the True option for
explicitly requested retraining.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/api/registration/chained.rst`:
- Around line 12-13: Update the documentation for RegisterImagesGreedyICON to
remove the incorrect claim that Tutorial 2 uses it. Either name a verified
consumer of the chained registrar or describe it only as the Greedy-then-ICON
pairing, while preserving the accurate statistical-model fit reference.

In `@docs/tutorials.rst`:
- Line 14: Update the tutorial summary near “Ten numbered stages across 17
runnable Python scripts” to avoid claiming all scripts are runnable: use “17
Python scripts” or explicitly distinguish the 16 runnable scripts from the
dataset-gated tutorial_02_duke_heart_distancemap_finetune_icon.py.

In `@src/physiotwin4d/register_images_base.py`:
- Around line 137-169: Add regression tests covering all branches of
_prewarp_background_value: an explicit override, CT returning -1000.0, and
non-CT using the moving image minimum. Add nearest-neighbor mask and labelmap
cases that verify zero fill, and assert the actual off-grid voxel value rather
than only output shape.

In `@src/physiotwin4d/transform_tools.py`:
- Around line 520-535: Update the background-value conversion in the resampling
flow before itk.resample_image_filter: for integer and discrete types, round to
an integer, validate it against np.iinfo(dtype), and reject out-of-range values
before invoking ITK; include boolean types via np.issubdtype(dtype, np.bool_).
Preserve floating-point conversion for non-discrete pixel types.

In `@src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py`:
- Line 58: Update the class and constructor docstrings in
src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py to consistently document
the standalone RegisterImagesGreedy backend and remove references to the retired
combined backend. In docs/tutorials.rst lines 313-321, remove Tutorial 3 from
Tutorial 2’s weight dependency; in lines 658-660, state that Tutorial 2 is
optional when stock weights are acceptable.

In `@tests/test_workflow_convert_image_to_usd.py`:
- Around line 121-122: Extend the migration test around workflow.registrar after
setting iterations to validate registration semantics, not just the
RegisterImagesGreedy type and artifact creation. Compare the registered image or
contours against an appropriate TestTools baseline, or assert a near-identity
result for the same-frame input, ensuring incorrect transform direction or
composition fails the test.

In `@tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py`:
- Around line 276-289: Update read_landmarks to validate the loaded markups
metadata before using control points: assert that the file’s coordinateSystem
field is exactly “LPS”, and fail clearly otherwise. Preserve the existing point
extraction only after this validation.
- Around line 198-217: After the training-case loop, validate that the collected
cohort is non-empty before the existing “Finetuning cohort” log and before
constructing WorkflowFinetuneICONRegistration. Mirror the held-out validation
behavior and fail immediately when subject_distance_map_files (and corresponding
subject IDs/labels) contains no surviving cases.
- Around line 139-151: The distance-map cache in distance_map_for currently keys
files only by frame stem, causing collisions across case directories. Include
the case directory name when constructing distance_map_file, while preserving
the existing derived_dir location and cache lookup behavior.

In `@tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py`:
- Around line 164-169: Update the set_use_pca_registration call in the pca_model
block to pass the configured HEART_CT_KCL.pca_components(test_mode) value as
number_of_pca_components, matching the heart PCA builder and lung fit tutorial
instead of relying on the default.

---

Outside diff comments:
In `@src/physiotwin4d/transform_tools.py`:
- Around line 452-475: Update RegisterImagesBase.get_registered_image() and the
time-series reconstruction path to pass _prewarp_background_value() for
intensity-image resampling, while preserving background_value=0.0 for masks and
labelmaps. Add a regression test verifying off-grid CT voxels receive the
modality-appropriate fill value rather than zero.

---

Nitpick comments:
In `@src/physiotwin4d/workflow_fit_statistical_model_to_patient.py`:
- Line 248: Update the Attributes class docstring to document
distancemap_squared_max alongside the existing configurable state, including
that its default is derived as appropriate from the implementation. Leave the
attribute declaration and surrounding behavior unchanged.

In `@tutorials/parameters_heart_ct_kcl.py`:
- Around line 64-69: Update the interior_object_ids_totalsegmentator field to
use list[int], matching interior_object_ids_simpleware and the
labelmap_interior_object_ids consumer, and remove the now-unused Optional
import.

In `@tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py`:
- Around line 95-97: Default the run_finetuning setting in the tutorial to
False, matching tutorials/tutorial_02_lung_finetune_icon.py so existing
checkpoints are reused and experiment_dir is not deleted on reruns; retain the
True option for explicitly requested retraining.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e3e3d426-db8d-4bc2-a2e9-01e6df24641a

📥 Commits

Reviewing files that changed from the base of the PR and between 743938d and a34fc1d.

📒 Files selected for processing (30)
  • data/Duke-Heart-4DLabelmaps/.gitignore
  • data/Duke-Heart-4DLabelmaps/README.md
  • docs/api/registration/chained.rst
  • docs/tutorials.rst
  • pyproject.toml
  • src/physiotwin4d/register_images_base.py
  • src/physiotwin4d/register_images_chain.py
  • src/physiotwin4d/register_models_distance_maps.py
  • src/physiotwin4d/register_time_series_images.py
  • src/physiotwin4d/transform_tools.py
  • src/physiotwin4d/workflow_convert_image_to_usd.py
  • src/physiotwin4d/workflow_fit_statistical_model_to_patient.py
  • src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py
  • tests/test_workflow_convert_image_to_usd.py
  • tests/test_workflow_reconstruct_highres_4d_ct.py
  • tutorials/README.md
  • tutorials/parameters_heart_ct_kcl.py
  • tutorials/parameters_lung_ct_dirlab.py
  • tutorials/tutorial_01_heart_gated_ct_to_usd.py
  • tutorials/tutorial_01_lung_gated_ct_to_usd.py
  • tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
  • tutorials/tutorial_02_lung_distancemap_finetune_icon.py
  • tutorials/tutorial_02_lung_finetune_icon.py
  • tutorials/tutorial_03_heart_reconstruct_highres_4d_ct.py
  • tutorials/tutorial_03_lung_reconstruct_highres_4d_ct.py
  • tutorials/tutorial_06_heart_create_statistical_model.py
  • tutorials/tutorial_06_lung_create_statistical_model.py
  • tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py
  • tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py
  • tutorials/tutorial_08_lung_fit_model_to_4d_patients.py

Comment on lines +12 to +13
``RegisterImagesGreedyICON`` is the Greedy-then-ICON pairing, used by Tutorial 2
and by the distance-map stage of the statistical-model fit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching chained.rst:"
fd -a 'chained\.rst$' . || true

echo
echo "Target file excerpt:"
if [ -f docs/api/registration/chained.rst ]; then
  nl -ba docs/api/registration/chained.rst | sed -n '1,80p'
fi

echo
echo "Search for RegisterImagesGreedyICON/RegisterModelsDistanceMaps/Tutorial 2:"
rg -n "RegisterImagesGreedyICON|RegisterModelsDistanceMaps|RegisterImagesGreedy|RegisterImagesICON|Tutorial 2|Tutorial.*2|distance-map|distance map" . || true

Repository: Project-MONAI/physiotwin4d

Length of output: 308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files relevant to registration:"
git ls-files | rg 'registration|tutorial|models|fit|distance' || true

echo
echo "All definitions/usages with some context:"
rg -n "class RegisterImagesGreedyICON|RegisterImagesGreedyICON\s*=|class RegisterModelsDistanceMaps|RegisterModelsDistanceMaps\s*=|RegisterImagesGreedy|RegisterImagesICON" . || true

echo
echo "Candidate files by name:"
git ls-files | rg 'registration|tutorial|statistical|model|distance' | head -200 || true

Repository: Project-MONAI/physiotwin4d

Length of output: 40451


Correct the documented consumers of RegisterImagesGreedyICON.

Tutorial 2 distance-map stages use separate RegisterImagesGreedy and RegisterImagesICON instances; they do not use RegisterImagesGreedyICON. Replace the Tutorial 2 claim with a consumer that actually uses the chained registrar, or keep the wording as the documented Greedy-then-ICON pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/api/registration/chained.rst` around lines 12 - 13, Update the
documentation for RegisterImagesGreedyICON to remove the incorrect claim that
Tutorial 2 uses it. Either name a verified consumer of the chained registrar or
describe it only as the Greedy-then-ICON pairing, while preserving the accurate
statistical-model fit reference.

Comment thread docs/tutorials.rst
Comment thread src/physiotwin4d/register_images_base.py
Comment thread src/physiotwin4d/transform_tools.py
Comment thread src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py
Comment on lines +121 to +122
assert isinstance(workflow.registrar, RegisterImagesGreedy)
workflow.registrar.set_number_of_iterations([2])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add a semantic registration assertion to this migration test.

The test verifies the Greedy type and artifact creation, but it does not verify registration correctness. Because the backend and transform composition changed, an incorrect transform direction can still pass. Compare the registered image or contours with a TestTools baseline, or assert a near-identity result for the same-frame input.

As per coding guidelines: “When a test produces an image or surface, compare it with a baseline using utilities such as TestTools.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_workflow_convert_image_to_usd.py` around lines 121 - 122, Extend
the migration test around workflow.registrar after setting iterations to
validate registration semantics, not just the RegisterImagesGreedy type and
artifact creation. Compare the registered image or contours against an
appropriate TestTools baseline, or assert a near-identity result for the
same-frame input, ensuring incorrect transform direction or composition fails
the test.

Source: Coding guidelines

Comment thread tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
Comment thread tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
Comment thread tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
Comment thread tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py
ContourTools gains the mesh generation the labelmap tutorials need:
extract_watertight_surface (pad + blur + flying edges, outward normals),
extract_label_surfaces (per-label surfaces that conform at shared walls via
signed distance maps), extract_tetrahedra + trim_tetrahedra_to_surface,
remesh_and_smooth_surface (ACVD clustering + Taubin), and
apply_anatomy_color. extract_contours now resamples anisotropic labelmaps
onto an isotropic grid and Taubin-smooths, so contours no longer terrace on
the voxel pitch. Adds pyacvd dependency.

USD export: WorkflowConvertVTKToUSD takes label_names/segmenter and splits
each mesh on its per-cell label array, giving one prim per structure at
/World/{project}/{group}/{structure} so identity survives a time series.
Labels now take precedence over static_merge (combining both raises).
UsdMeshConverter time-samples faceVertexCounts/Indices when frames disagree
on topology, instead of silently reusing frame 0's triangulation.
USDTools.get_mesh_paths descends the full subtree.

Registration: out-of-FOV voxels fill with the modality background value
rather than 0 in get_registered_image and the 4D reconstruction;
transform_tools validates background_value against the pixel type range and
handles bool images.

ImageTools.resample_image_by_scale resamples by voxel-count multiplier while
preserving physical extent.

Breaking: surface_target_reduction renamed to surface_reduction_rate
(WorkflowConvertImageToVTK.process, convert-image-to-vtk CLI flag);
WorkflowConvertImageToUSD gains surface_reduction_rate.

Tutorials: new Duke heart labelmap track (parameters module plus tutorials
04-10), per-organ parameter modules updated, docs/tutorials.rst and
tutorials/README.md refreshed, LFS + gitignore rules for the lung MGN
weights.

Tests: test_contour_mesh_extraction.py, test_register_images_base.py, plus
coverage for labeled and topology-varying USD export.

@coderabbitai coderabbitai 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.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/tutorials.rst (1)

232-236: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Name parameters_duke_heart_labelmaps.py for the Duke heart tutorials. The Duke distance-map tutorial imports this module, not the lung or KCL heart parameter modules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tutorials.rst` around lines 232 - 236, Update the Duke heart tutorial
documentation near the referenced parameter-module descriptions to name
parameters_duke_heart_labelmaps.py as the module used by the Duke distance-map
tutorial, replacing the unrelated lung and KCL heart module references while
preserving the existing availability note.
🧹 Nitpick comments (4)
tests/test_image_tools.py (1)

521-538: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Strengthen the nearest-neighbor assertion and cover the upsampling border.

set(np.unique(...)) <= {0.0, 7.0} also passes when the output is entirely zeros, so it does not prove that the block survived. Add an assertion that 7.0 is present.

No test covers scale > 1.0 output values. That is the path where resample_image_by_scale samples before the first input voxel center and ResampleImageFilter writes its default 0 (see the comment on src/physiotwin4d/image_tools.py lines 316-326).

💚 Proposed test changes
         out = image_tools.resample_image_by_scale(itk_image, 0.5, interpolate=False)
 
-        assert set(np.unique(itk.array_from_image(out))) <= {0.0, 7.0}
+        values = set(np.unique(itk.array_from_image(out)))
+        assert values <= {0.0, 7.0}
+        assert 7.0 in values, "nearest-neighbor resampling dropped the block"
+
+    def test_upsampling_border_is_not_zero_filled(
+        self, image_tools: ImageTools
+    ) -> None:
+        """Upsampling must not write a background shell into a constant image."""
+        arr = np.full((4, 4, 4), -1000.0, dtype=np.float32)
+        itk_image = _make_synthetic_itk_image((4, 4, 4), arr=arr)
+
+        out = image_tools.resample_image_by_scale(itk_image, 2.0)
+
+        assert np.allclose(itk.array_from_image(out), -1000.0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_image_tools.py` around lines 521 - 538, Strengthen
test_nearest_neighbor_keeps_input_values by asserting that 7.0 is present in the
unique output values in addition to restricting values to {0.0, 7.0}. Add
coverage for a scale greater than 1.0 that verifies resample_image_by_scale
preserves the expected input intensities while allowing the documented default
zero values at the upsampling border.
src/physiotwin4d/workflow_convert_image_to_vtk.py (1)

316-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two reduction algorithms now share the name surface_reduction_rate.

ContourTools.remesh_and_smooth_surface replaced decimate_pro with ACVD remeshing, and its docstring states that decimate_pro leaves a watertight input non-watertight. This workflow still calls decimate_pro directly for both the group surface and the label surfaces. The same parameter therefore selects a different algorithm depending on the entry point.

Route both reductions through self._contour_tools.remesh_and_smooth_surface so the parameter has one meaning.

♻️ Proposed refactor
             export_surface = base_surface
             if surface_reduction_rate > 0.0:
-                export_surface = export_surface.decimate_pro(
-                    surface_reduction_rate, preserve_topology=True
-                )
+                export_surface = self._contour_tools.remesh_and_smooth_surface(
+                    export_surface, surface_reduction_rate
+                )
                     if surface_reduction_rate > 0.0:
-                        label_surface = label_surface.decimate_pro(
-                            surface_reduction_rate, preserve_topology=True
-                        )
+                        label_surface = self._contour_tools.remesh_and_smooth_surface(
+                            label_surface, surface_reduction_rate
+                        )

Update the process docstring at lines 236-239 accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/workflow_convert_image_to_vtk.py` around lines 316 - 334, In
the process flow, replace both direct decimate_pro calls for export_surface and
label_surface with self._contour_tools.remesh_and_smooth_surface, preserving the
existing surface_reduction_rate control and topology behavior through that
helper. Update the process docstring to describe the parameter as ACVD remeshing
rather than decimation.
src/physiotwin4d/contour_tools.py (2)

147-157: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider making the isotropic resample opt-out or bounded.

The resample always targets the finest spacing. On a 0.7 x 0.7 x 2.5 mm CT the voxel count grows by about 3.6x on the slice axis, and the triangle count grows with it. Existing callers such as WorkflowConvertImageToVTK._extract_surface and workflow_convert_image_to_usd.py (line 363) get this cost without any new argument.

A parameter (target spacing or a disable flag) would let a caller cap the cost on large chest volumes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/contour_tools.py` around lines 147 - 157, Add an opt-out or
configurable target-spacing parameter to the contouring flow around the
anisotropy check and _resample_labelmap_isotropic, allowing callers such as
_extract_surface to avoid or cap expensive finest-spacing resampling. Preserve
the current finest-spacing behavior by default, while ensuring the selected
target spacing is isotropic and does not increase resolution beyond the
configured limit.

667-667: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard the tetrahedra-only assumption on the clipped mesh.

cells_dict[np.uint8(pv.CellType.TETRA)] raises KeyError when the clipped mesh holds no tetrahedra, and it silently ignores non-tetrahedral cells when the mesh is mixed. quality at line 716 covers every cell, so connectivity[below_bound] then indexes the tetrahedra array with cell positions that belong to other cell types.

The method is public and documents only tetrahedra as the input, so a mixed-cell caller gets a wrong damping set rather than an error. Reject a non-tetrahedral input explicitly.

♻️ Proposed guard
+        if set(relaxed.celltypes) != {pv.CellType.TETRA}:
+            raise ValueError(
+                "trim_tetrahedra_to_surface requires a tetrahedral mesh; got cell "
+                f"types {sorted(set(relaxed.celltypes))}"
+            )
         connectivity = relaxed.cells_dict[np.uint8(pv.CellType.TETRA)]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/contour_tools.py` at line 667, Update the method containing
connectivity and quality so it validates that the clipped mesh contains only
tetrahedral cells before accessing cells_dict[np.uint8(pv.CellType.TETRA)].
Reject empty or mixed-cell meshes explicitly with an appropriate error,
preserving the existing damping logic only for tetrahedra-only input.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/physiotwin4d/contour_tools.py`:
- Around line 491-499: Update the is_watertight method to explicitly return
False when the triangulated surface has no faces, before computing edge counts;
preserve the existing exactly-two-shared-edges check for non-empty surfaces.
- Around line 530-542: Update extract_tetrahedra around the mask_arr
bounding-box calculation to detect when mask_arr contains no non-zero voxels
before calling axis_extent.min(). Return the same empty pv.UnstructuredGrid used
by the existing analogous empty path, while preserving the current cropping and
tetrahedra generation for non-empty masks.
- Around line 369-388: In the label-processing loop around extract_surface,
check whether the extracted and triangulated surface has zero cells before
removing data or computing normals. For empty surfaces, skip adding the label to
surfaces and emit a warning; retain the existing cleanup, normal computation,
and debug logging for labels with extracted cells.

In `@src/physiotwin4d/convert_vtk_to_usd.py`:
- Around line 662-669: Update the labeled-mesh dispatch around
_convert_with_labels so a static merge with exactly one labeled mesh uses
create_mesh() rather than create_time_varying_mesh(), while preserving the
existing labeled conversion for non-static or multi-mesh cases. Add a workflow
test assertion confirming the labeled static output contains no point or
topology time samples.

In `@src/physiotwin4d/image_tools.py`:
- Around line 316-326: The resample_image_by_scale path must support
configurable border fill values: add a background_value parameter following the
transform_image convention and apply it with SetDefaultPixelValue before
Update(). In src/physiotwin4d/image_tools.py lines 316-326, make this change; in
tests/test_image_tools.py lines 521-538, add a scale greater than 1 test using a
constant-valued image that asserts every output voxel retains that value, and
ensure the nearest-neighbor test still asserts the 7.0 block value.

In `@tests/test_contour_mesh_extraction.py`:
- Around line 47-70: Update the docstrings of _left_handed_box and
_touching_boxes to explicitly state their synthetic volume dimensions: 6 × 6 × 6
and 60 × 48 × 14 voxels respectively, while preserving the existing fixture
descriptions.

In `@tests/test_register_images_base.py`:
- Around line 1-5: Update the module docstring for
tests/test_register_images_base.py to explicitly state that the synthetic images
are 10×10×10 voxels, while preserving its existing description of the off-grid
resampling scenario.

In `@tutorials/README.md`:
- Around line 37-51: Update the tutorial index’s recommended-order documentation
to include the Duke heart dependency chain from Tutorial 4 through Tutorial 10,
or explicitly mark the Duke sequence as unavailable until Duke-Heart-4DLabelmaps
is released. Ensure the descriptions for the Duke Tutorials 8–10 do not imply
they use DIR-Lab inputs.

In `@tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py`:
- Around line 189-198: Guard the volume percentage calculation in the
reporter.log_debug call within the mesh refinement loop so surface.volume equal
to zero cannot cause division by zero. Use a safe fallback for the logged ratio
while preserving the existing percentage calculation for positive surface
volumes.
- Around line 263-272: Update the whole_heart extraction flow around
contour_tools.extract_label_surfaces to store its returned mapping, check
whether label id 1 exists before indexing it, and skip the current frame with a
warning when the mapping is empty or lacks that label. Preserve the existing
coloring and annotation behavior for valid whole-heart surfaces.
- Around line 234-244: Update the per-case output handling in the loop over
case_dir so case_output_dir is created as output_dir / case_dir.name rather than
output_dir itself. Keep directory creation and surface/whole-heart file
generation using this case-specific directory, producing the pm????/ layout
expected by Tutorial 5.

In `@tutorials/tutorial_05_duke_heart_vtk_to_usd.py`:
- Around line 69-77: Restore dynamic case discovery by assigning case_dirs to
the sorted directories matching input_dir.glob("pm[0-9][0-9][0-9][0-9]"),
filtering to directories, and remove the hard-coded pm0004 assignment and
commented-out code. Keep the existing FileNotFoundError guard so missing cohorts
fail as documented.

In `@tutorials/tutorial_06_duke_heart_create_statistical_model.py`:
- Around line 88-92: The Tutorial 4 surface consumers use the wrong output
layout. In tutorials/tutorial_06_duke_heart_create_statistical_model.py lines
88-92, update the sample_files glob to search
*_ref_heart_minus_interior_chambers.vtp within pm???? case directories while
preserving the hold-out filter; in
tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py lines 189-192,
include labelmap_file.parent.name between tutorial_04_dir and the surface
filename when constructing the surface path.
- Around line 117-120: Update the template_surface argument in
WorkflowCreateMeanSurface to use a valid entry from sample_surfaces, such as the
middle element, while preserving the existing minimum-count check and the
remaining workflow configuration.

In `@tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py`:
- Around line 114-118: Update the PCA model loading logic around pca_model and
pca_json so that pca_json is required whenever pca_mean_file exists; when it is
absent, raise the same prerequisite error used for the missing mean surface
instead of continuing with pca_model unset. Preserve JSON loading when the file
exists and ensure fitting cannot proceed in a non-PCA mode under these
conditions.

In `@tutorials/tutorial_10_duke_heart_infer_physicsnemo.py`:
- Around line 130-135: Clamp test_index in the frame-selection logic before
indexing stages and phase_files, ensuring values such as stage_fraction=1.0
resolve to the final frame rather than len(stages). Preserve the existing
fractional selection behavior for in-range values and use the last valid index
as the upper bound.

---

Outside diff comments:
In `@docs/tutorials.rst`:
- Around line 232-236: Update the Duke heart tutorial documentation near the
referenced parameter-module descriptions to name
parameters_duke_heart_labelmaps.py as the module used by the Duke distance-map
tutorial, replacing the unrelated lung and KCL heart module references while
preserving the existing availability note.

---

Nitpick comments:
In `@src/physiotwin4d/contour_tools.py`:
- Around line 147-157: Add an opt-out or configurable target-spacing parameter
to the contouring flow around the anisotropy check and
_resample_labelmap_isotropic, allowing callers such as _extract_surface to avoid
or cap expensive finest-spacing resampling. Preserve the current finest-spacing
behavior by default, while ensuring the selected target spacing is isotropic and
does not increase resolution beyond the configured limit.
- Line 667: Update the method containing connectivity and quality so it
validates that the clipped mesh contains only tetrahedral cells before accessing
cells_dict[np.uint8(pv.CellType.TETRA)]. Reject empty or mixed-cell meshes
explicitly with an appropriate error, preserving the existing damping logic only
for tetrahedra-only input.

In `@src/physiotwin4d/workflow_convert_image_to_vtk.py`:
- Around line 316-334: In the process flow, replace both direct decimate_pro
calls for export_surface and label_surface with
self._contour_tools.remesh_and_smooth_surface, preserving the existing
surface_reduction_rate control and topology behavior through that helper. Update
the process docstring to describe the parameter as ACVD remeshing rather than
decimation.

In `@tests/test_image_tools.py`:
- Around line 521-538: Strengthen test_nearest_neighbor_keeps_input_values by
asserting that 7.0 is present in the unique output values in addition to
restricting values to {0.0, 7.0}. Add coverage for a scale greater than 1.0 that
verifies resample_image_by_scale preserves the expected input intensities while
allowing the documented default zero values at the upsampling border.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 80e9d1ca-cbfb-4d02-9e8f-fe45d0f90ca8

📥 Commits

Reviewing files that changed from the base of the PR and between a34fc1d and 18d225e.

⛔ Files ignored due to path filters (3)
  • docs/assets/tutorial_04_duke_heart.png is excluded by !**/*.png
  • docs/assets/tutorial_04_heart-2png.png is excluded by !**/*.png
  • docs/assets/tutorial_04_lung-2.png is excluded by !**/*.png
📒 Files selected for processing (47)
  • .gitattributes
  • .gitignore
  • docs/tutorials.rst
  • pyproject.toml
  • src/physiotwin4d/cli/convert_image_to_vtk.py
  • src/physiotwin4d/contour_tools.py
  • src/physiotwin4d/convert_vtk_to_usd.py
  • src/physiotwin4d/image_tools.py
  • src/physiotwin4d/register_images_base.py
  • src/physiotwin4d/register_time_series_images.py
  • src/physiotwin4d/transform_tools.py
  • src/physiotwin4d/usd_tools.py
  • src/physiotwin4d/vtk_to_usd/usd_mesh_converter.py
  • src/physiotwin4d/workflow_convert_image_to_usd.py
  • src/physiotwin4d/workflow_convert_image_to_vtk.py
  • src/physiotwin4d/workflow_convert_vtk_to_usd.py
  • src/physiotwin4d/workflow_fit_statistical_model_to_patient.py
  • src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py
  • tests/test_contour_mesh_extraction.py
  • tests/test_convert_vtk_to_usd.py
  • tests/test_image_tools.py
  • tests/test_register_images_base.py
  • tests/test_tutorials.py
  • tests/test_workflow_convert_vtk_to_usd.py
  • tutorials/README.md
  • tutorials/parameters_duke_heart_labelmaps.py
  • tutorials/parameters_heart_ct_kcl.py
  • tutorials/parameters_lung_ct_dirlab.py
  • tutorials/tutorial_01_heart_gated_ct_to_usd.py
  • tutorials/tutorial_01_lung_gated_ct_to_usd.py
  • tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
  • tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py
  • tutorials/tutorial_04_heart_ct_to_vtk.py
  • tutorials/tutorial_04_lung_ct_to_vtk.py
  • tutorials/tutorial_05_duke_heart_vtk_to_usd.py
  • tutorials/tutorial_06_duke_heart_create_statistical_model.py
  • tutorials/tutorial_06_heart_create_statistical_model.py
  • tutorials/tutorial_06_lung_create_statistical_model.py
  • tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py
  • tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py
  • tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py
  • tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py
  • tutorials/tutorial_08_lung_fit_model_to_4d_patients.py
  • tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py
  • tutorials/tutorial_09_lung_train_physicsnemo_mgn.py
  • tutorials/tutorial_10_duke_heart_infer_physicsnemo.py
  • tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py
  • tutorials/tutorial_01_heart_gated_ct_to_usd.py
  • tutorials/tutorial_01_lung_gated_ct_to_usd.py
  • tutorials/tutorial_08_lung_fit_model_to_4d_patients.py
  • src/physiotwin4d/transform_tools.py
  • src/physiotwin4d/register_time_series_images.py
  • src/physiotwin4d/workflow_fit_statistical_model_to_patient.py
  • tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py
  • src/physiotwin4d/register_images_base.py
  • src/physiotwin4d/workflow_reconstruct_highres_4d_ct.py

Comment on lines +369 to +388
for label_id in label_ids:
cell_ids: list[int] = np.flatnonzero(merged_ids == label_id).tolist()
surface = self.extract_surface(merged.extract_cells(cell_ids)).triangulate()
# The bookkeeping arrays of the merge and the split; the label is
# the key of the returned mapping, so it is not data on the mesh.
for array_name in ("LabelId", "vtkOriginalCellIds", "vtkOriginalPointIds"):
surface.cell_data.pop(array_name, None)
surface.point_data.pop(array_name, None)
# VTK winds faces for a right-handed direction matrix, so an LPS
# image with a negative-determinant direction comes out inward.
surfaces[label_id] = surface.compute_normals(
auto_orient_normals=True, consistent_normals=True
)
self.log_debug(
"Label %d: %d triangles, %.3f of its voxel volume",
label_id,
surfaces[label_id].n_cells,
float(surfaces[label_id].volume)
/ (int(np.count_nonzero(labels == label_id)) * voxel_volume),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check for existing empty-mesh guards and tests around extract_label_surfaces.
rg -n -C4 'extract_label_surfaces' --type=py
rg -n -C3 'n_cells == 0|n_cells > 0' src/physiotwin4d/contour_tools.py

Repository: Project-MONAI/physiotwin4d

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- contour_tools.py outline ---'
ast-grep outline src/physiotwin4d/contour_tools.py --view expanded
printf '%s\n' '--- relevant implementation ---'
sed -n '260,405p' src/physiotwin4d/contour_tools.py
printf '%s\n' '--- surface extraction and dependency references ---'
rg -n -C5 'def extract_surface|extract_surface\(|pyvista|PyVista|pyacvd' src pyproject.toml tests 2>/dev/null || true

Repository: Project-MONAI/physiotwin4d

Length of output: 50383


🏁 Script executed:

#!/bin/bash
set -u
python3 - <<'PY'
import numpy as np

try:
    import pyvista as pv
except Exception as exc:
    print(f"IMPORT_ERROR: {type(exc).__name__}: {exc}")
    raise SystemExit(0)

print(f"PYVISTA_VERSION: {getattr(pv, '__version__', 'unknown')}")

# Reproduce the empty-label branch with a standalone PyVista mesh.
empty = pv.PolyData()
for name, operation in [
    ("extract_cells", lambda: empty.extract_cells([])),
    ("triangulate", lambda: empty.triangulate()),
    ("compute_normals", lambda: empty.compute_normals(
        auto_orient_normals=True, consistent_normals=True
    )),
]:
    try:
        result = operation()
        print(
            f"{name}: OK type={type(result).__name__} "
            f"n_points={result.n_points} n_cells={result.n_cells}"
        )
    except Exception as exc:
        print(f"{name}: ERROR {type(exc).__name__}: {exc}")

# Reproduce the complete selection sequence used by the review target.
try:
    points = np.array(
        [[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=float
    )
    nonempty = pv.PolyData(points, faces=np.array([3, 0, 1, 2]))
    nonempty.cell_data["LabelId"] = np.array([1], dtype=np.int32)
    empty_label = pv.PolyData()
    empty_label.cell_data["LabelId"] = np.array([], dtype=np.int32)
    merged = pv.merge([nonempty, empty_label], merge_points=True)
    print(
        f"merged: n_points={merged.n_points} n_cells={merged.n_cells} "
        f"LabelId={np.asarray(merged.cell_data['LabelId']).tolist()}"
    )
    merged_ids = np.asarray(merged.cell_data["LabelId"])
    cell_ids = np.flatnonzero(merged_ids == 2).tolist()
    selected = merged.extract_cells(cell_ids)
    surface = selected.triangulate()
    result = surface.compute_normals(
        auto_orient_normals=True, consistent_normals=True
    )
    print(
        f"missing_label_sequence: OK cell_ids={cell_ids} "
        f"type={type(result).__name__} n_points={result.n_points} "
        f"n_cells={result.n_cells}"
    )
except Exception as exc:
    print(
        f"missing_label_sequence: ERROR {type(exc).__name__}: {exc}"
    )
PY

Repository: Project-MONAI/physiotwin4d

Length of output: 282


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- available repository-local Python environments ---'
find .. -maxdepth 3 -type f \( -path '*/.venv/bin/python*' -o -path '*/venv/bin/python*' -o -path '*/.venv/Scripts/python.exe' -o -path '*/venv/Scripts/python.exe' \) -print 2>/dev/null
printf '%s\n' '--- available Python executables ---'
command -v py || true
command -v python || true
command -v python3 || true
printf '%s\n' '--- installed PyVista candidates ---'
find /usr /opt /root -type f \( -name 'pyvista.py' -o -path '*/pyvista/__init__.py' \) -print 2>/dev/null | head -20

Repository: Project-MONAI/physiotwin4d

Length of output: 326


🌐 Web query:

PyVista empty PolyData extract_cells([]) triangulate compute_normals behavior

💡 Result:

When working with PyVista meshes, passing an empty list (or an empty array) to extract_cells([]) returns an empty mesh of the same type as the input [1][2]. PyVista is designed to handle empty meshes gracefully to maintain pipeline continuity, consistent with VTK's behavior [2]. Following this, the behavior of triangulate and compute_normals on such an empty mesh is as follows: 1. triangulate(): Calling this method on an empty PolyData will return an empty PolyData object. It does not perform operations on non-existent cells [3][4]. 2. compute_normals(): Attempting to compute normals on an empty mesh (or a mesh containing only vertex/line cells) generally does not produce a "Normals" array [5][6]. - Historically, this could lead to a KeyError if the user attempted to access the "Normals" array immediately after the call [5]. - Recent versions of PyVista include protections and have introduced warnings or TypeError exceptions when attempting to compute normals on meshes that contain only vertex or line cells, as surface normals cannot be mathematically defined for such geometry [5][7]. In summary, empty inputs propagate empty outputs, and subsequent filters generally detect the lack of valid surface geometry and will not create the requested normal data arrays [5][6].

Citations:


🌐 Web query:

PyVista 0.47.0 compute_normals empty PolyData source vtkPolyDataNormals empty mesh

💡 Result:

In PyVista 0.47.0, when calling compute_normals on an empty PolyData source or a mesh that contains only points/lines (no polygons or triangle strips), the compute_normals filter does not produce a Normals array [1][2]. The underlying VTK filter, vtkPolyDataNormals, is designed to calculate normals exclusively for polygons and triangle strips [3][4]. If a mesh lacks these cell types, the filter performs no computation and does not add the Normals data array to the output [3][5]. Because compute_normals does not raise an error in these cases, attempts to subsequently access the mesh["Normals"] array will result in a KeyError [1]. While there have been community discussions and pull requests regarding adding explicit error or warning handling for these scenarios [1][6][7], PyVista maintains its reliance on the standard VTK behavior where the output simply lacks the requested data if it cannot be computed [3][5]. To avoid this, you should check for the presence of polygons or triangle strips in your mesh before invoking compute_normals, or handle the potential KeyError when accessing the normals data [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- call sites ---'
rg -n -C4 'extract_label_surfaces' src tests || true
printf '%s\n' '--- consumers of returned surfaces ---'
rg -n -C3 'surfaces\[|for .*surface|surface\.n_cells|compute_normals\(' src/physiotwin4d tests/test_contour_mesh_extraction.py | head -300

Repository: Project-MONAI/physiotwin4d

Length of output: 36781


Skip labels with no extracted cells. The empty mesh path returns an empty pv.PolyData without raising, but it has no surface cells or normals. Omit that label from surfaces and emit a warning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/contour_tools.py` around lines 369 - 388, In the
label-processing loop around extract_surface, check whether the extracted and
triangulated surface has zero cells before removing data or computing normals.
For empty surfaces, skip adding the label to surfaces and emit a warning; retain
the existing cleanup, normal computation, and debug logging for labels with
extracted cells.

Comment on lines 491 to +499
@staticmethod
def smooth_and_decimate_surface(
def is_watertight(surface: pv.PolyData) -> bool:
"""Report whether every edge of *surface* is shared by exactly two faces."""
faces = surface.triangulate().faces.reshape(-1, 4)[:, 1:]
edges = np.sort(
np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]), axis=1
)
_, counts = np.unique(edges, axis=0, return_counts=True)
return bool(np.all(counts == 2))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

is_watertight returns True for an empty surface.

With no faces, counts is empty and np.all(counts == 2) evaluates to True. extract_watertight_surface at line 242 then logs no warning for a surface that holds no geometry.

Reject an empty surface explicitly.

🐛 Proposed fix
     `@staticmethod`
     def is_watertight(surface: pv.PolyData) -> bool:
         """Report whether every edge of *surface* is shared by exactly two faces."""
         faces = surface.triangulate().faces.reshape(-1, 4)[:, 1:]
+        if faces.size == 0:
+            return False
         edges = np.sort(
             np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]), axis=1
         )
         _, counts = np.unique(edges, axis=0, return_counts=True)
         return bool(np.all(counts == 2))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@staticmethod
def smooth_and_decimate_surface(
def is_watertight(surface: pv.PolyData) -> bool:
"""Report whether every edge of *surface* is shared by exactly two faces."""
faces = surface.triangulate().faces.reshape(-1, 4)[:, 1:]
edges = np.sort(
np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]), axis=1
)
_, counts = np.unique(edges, axis=0, return_counts=True)
return bool(np.all(counts == 2))
`@staticmethod`
def is_watertight(surface: pv.PolyData) -> bool:
"""Report whether every edge of *surface* is shared by exactly two faces."""
faces = surface.triangulate().faces.reshape(-1, 4)[:, 1:]
if faces.size == 0:
return False
edges = np.sort(
np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]), axis=1
)
_, counts = np.unique(edges, axis=0, return_counts=True)
return bool(np.all(counts == 2))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/contour_tools.py` around lines 491 - 499, Update the
is_watertight method to explicitly return False when the triangulated surface
has no faces, before computing edge counts; preserve the existing
exactly-two-shared-edges check for non-empty surfaces.

Comment on lines +530 to +542
mask_arr = itk.GetArrayViewFromImage(mask_image) != 0
# mask_arr axes are reversed relative to the ITK image, so the per-axis
# extents come back as (z, y, x) and are flipped to (x, y, z).
starts, stops = [], []
for axis_extent in np.nonzero(mask_arr):
starts.append(int(axis_extent.min()))
stops.append(int(axis_extent.max()) + 1)
start_zyx, stop_zyx = np.array(starts), np.array(stops)
cropped_arr = mask_arr[
start_zyx[0] : stop_zyx[0],
start_zyx[1] : stop_zyx[1],
start_zyx[2] : stop_zyx[2],
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

An empty mask crashes extract_tetrahedra.

If mask_image holds no non-zero voxel, np.nonzero(mask_arr) returns empty index arrays and axis_extent.min() raises ValueError: zero-size array to reduction operation minimum. The method already returns an empty pv.UnstructuredGrid for the analogous case at line 569, so the two empty paths behave differently.

Return an empty grid for an empty mask as well.

🐛 Proposed fix
         mask_arr = itk.GetArrayViewFromImage(mask_image) != 0
+        if not mask_arr.any():
+            self.log_warning("Mask holds no voxel; its tetrahedral mesh is empty")
+            return pv.UnstructuredGrid()
         # mask_arr axes are reversed relative to the ITK image, so the per-axis
         # extents come back as (z, y, x) and are flipped to (x, y, z).
         starts, stops = [], []
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mask_arr = itk.GetArrayViewFromImage(mask_image) != 0
# mask_arr axes are reversed relative to the ITK image, so the per-axis
# extents come back as (z, y, x) and are flipped to (x, y, z).
starts, stops = [], []
for axis_extent in np.nonzero(mask_arr):
starts.append(int(axis_extent.min()))
stops.append(int(axis_extent.max()) + 1)
start_zyx, stop_zyx = np.array(starts), np.array(stops)
cropped_arr = mask_arr[
start_zyx[0] : stop_zyx[0],
start_zyx[1] : stop_zyx[1],
start_zyx[2] : stop_zyx[2],
]
mask_arr = itk.GetArrayViewFromImage(mask_image) != 0
if not mask_arr.any():
self.log_warning("Mask holds no voxel; its tetrahedral mesh is empty")
return pv.UnstructuredGrid()
# mask_arr axes are reversed relative to the ITK image, so the per-axis
# extents come back as (z, y, x) and are flipped to (x, y, z).
starts, stops = [], []
for axis_extent in np.nonzero(mask_arr):
starts.append(int(axis_extent.min()))
stops.append(int(axis_extent.max()) + 1)
start_zyx, stop_zyx = np.array(starts), np.array(stops)
cropped_arr = mask_arr[
start_zyx[0] : stop_zyx[0],
start_zyx[1] : stop_zyx[1],
start_zyx[2] : stop_zyx[2],
]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/contour_tools.py` around lines 530 - 542, Update
extract_tetrahedra around the mask_arr bounding-box calculation to detect when
mask_arr contains no non-zero voxels before calling axis_extent.min(). Return
the same empty pv.UnstructuredGrid used by the existing analogous empty path,
while preserving the current cropping and tetrahedra generation for non-empty
masks.

Comment on lines +662 to +669
# Process meshes. Labels win over the static layout: a per-cell label
# array names the structures outright, which the static layout's one
# prim per input mesh cannot.
if self.mask_ids:
# Split by anatomical regions
self._convert_with_labels(stage, root_path, material_mgr, mesh_converter)
elif self._is_static_merge:
self._convert_static_merge(stage, root_path, material_mgr, mesh_converter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep labeled static merges static.

When static_merge=True and one labeled mesh is provided, this dispatch calls
_convert_with_labels(). That method always calls create_time_varying_mesh().
The output then has time samples even though the constructor documents a static
layout and the workflow suppresses stage time metadata for static output.

Call create_mesh() for the single labeled mesh when _is_static_merge is true.
Add an assertion in tests/test_workflow_convert_vtk_to_usd.py that the labeled
static mesh has no point or topology time samples.

Proposed fix
-            mesh_converter.create_time_varying_mesh(
-                label_mesh_sequence, mesh_path, label_time_codes, bind_material=True
-            )
+            if self._is_static_merge:
+                mesh_converter.create_mesh(
+                    label_mesh_sequence[0], mesh_path, bind_material=True
+                )
+            else:
+                mesh_converter.create_time_varying_mesh(
+                    label_mesh_sequence,
+                    mesh_path,
+                    label_time_codes,
+                    bind_material=True,
+                )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Process meshes. Labels win over the static layout: a per-cell label
# array names the structures outright, which the static layout's one
# prim per input mesh cannot.
if self.mask_ids:
# Split by anatomical regions
self._convert_with_labels(stage, root_path, material_mgr, mesh_converter)
elif self._is_static_merge:
self._convert_static_merge(stage, root_path, material_mgr, mesh_converter)
if self._is_static_merge:
mesh_converter.create_mesh(
label_mesh_sequence[0], mesh_path, bind_material=True
)
else:
mesh_converter.create_time_varying_mesh(
label_mesh_sequence,
mesh_path,
label_time_codes,
bind_material=True,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/convert_vtk_to_usd.py` around lines 662 - 669, Update the
labeled-mesh dispatch around _convert_with_labels so a static merge with exactly
one labeled mesh uses create_mesh() rather than create_time_varying_mesh(),
while preserving the existing labeled conversion for non-static or multi-mesh
cases. Add a workflow test assertion confirming the labeled static output
contains no point or topology time samples.

Comment on lines +316 to +326
direction = itk.array_from_matrix(image.GetDirection())
resampler = itk.ResampleImageFilter[ImageType, ImageType].New()
resampler.SetInput(image)
resampler.SetInterpolator(interpolator)
resampler.SetOutputSpacing([float(v) for v in new_spacing])
resampler.SetSize([int(n) for n in new_size])
resampler.SetOutputOrigin(
np.asarray(image.GetOrigin(), dtype=np.float64)
+ direction @ ((new_spacing - spacing) / 2.0)
)
resampler.SetOutputDirection(image.GetDirection())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

resample_image_by_scale never sets a default pixel value, and no test covers the border it fills. The origin shift at line 322-325 places the first output voxel center before the first input voxel center whenever scale > 1.0. ITK reports that continuous index as outside the buffer and writes DefaultPixelValue, which is 0. For CT, 0 HU is water rather than air, the exact problem TransformTools.transform_image already documents.

  • src/physiotwin4d/image_tools.py#L316-L326: add a background_value parameter and call resampler.SetDefaultPixelValue(background_value) before Update(), following the transform_image convention.
  • tests/test_image_tools.py#L521-L538: add a scale > 1.0 test that resamples a constant-valued image and asserts every output voxel keeps that value, and assert that the nearest-neighbor test's block value 7.0 is still present.
📍 Affects 2 files
  • src/physiotwin4d/image_tools.py#L316-L326 (this comment)
  • tests/test_image_tools.py#L521-L538
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/physiotwin4d/image_tools.py` around lines 316 - 326, The
resample_image_by_scale path must support configurable border fill values: add a
background_value parameter following the transform_image convention and apply it
with SetDefaultPixelValue before Update(). In src/physiotwin4d/image_tools.py
lines 316-326, make this change; in tests/test_image_tools.py lines 521-538, add
a scale greater than 1 test using a constant-valued image that asserts every
output voxel retains that value, and ensure the nearest-neighbor test still
asserts the 7.0 block value.

Comment on lines +69 to +77
case_dirs = [input_dir / "pm0004"]
# sorted(
# path for path in input_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir()
# )
if not case_dirs:
raise FileNotFoundError(
f"No pm???? case directories found under {input_dir}.\n"
"Run tutorial_04_duke_heart_labelmap_to_vtk.py first."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Restore the cohort glob; the hard-coded case is a leftover debug artifact.

Line 69 pins the run to pm0004 and comments out the case discovery. The docstring (Line 29) documents pm????/*_surfaces.vtp, so the script no longer matches its own contract. The if not case_dirs guard at Line 73 is also dead, because the list literal is never empty. If pm0004 is absent, the run only logs a warning and writes nothing.

♻️ Proposed fix
-    case_dirs = [input_dir / "pm0004"]
-    # sorted(
-    # path for path in input_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir()
-    # )
+    case_dirs = sorted(
+        path for path in input_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir()
+    )
     if not case_dirs:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case_dirs = [input_dir / "pm0004"]
# sorted(
# path for path in input_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir()
# )
if not case_dirs:
raise FileNotFoundError(
f"No pm???? case directories found under {input_dir}.\n"
"Run tutorial_04_duke_heart_labelmap_to_vtk.py first."
)
case_dirs = sorted(
path for path in input_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir()
)
if not case_dirs:
raise FileNotFoundError(
f"No pm???? case directories found under {input_dir}.\n"
"Run tutorial_04_duke_heart_labelmap_to_vtk.py first."
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_05_duke_heart_vtk_to_usd.py` around lines 69 - 77, Restore
dynamic case discovery by assigning case_dirs to the sorted directories matching
input_dir.glob("pm[0-9][0-9][0-9][0-9]"), filtering to directories, and remove
the hard-coded pm0004 assignment and commented-out code. Keep the existing
FileNotFoundError guard so missing cohorts fail as documented.

Comment on lines +88 to +92
sample_files = [
path
for path in sorted(input_dir.glob("*_ref_heart_minus_interior_chambers.vtp"))
if not path.name.startswith(DUKE_HEART.hold_out_case)
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Read Duke Tutorial 4 outputs from their case directories.

Both consumers assume that Tutorial 4 writes surfaces directly under its output root. The documented output layout stores each surface under a pm???? case directory. Tutorial 6 finds no samples, and Tutorial 8 never reuses Tutorial 4 surfaces.

  • tutorials/tutorial_06_duke_heart_create_statistical_model.py#L88-L92: search pm????/*_ref_heart_minus_interior_chambers.vtp.
  • tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py#L189-L192: include labelmap_file.parent.name between tutorial_04_dir and the surface filename.
📍 Affects 2 files
  • tutorials/tutorial_06_duke_heart_create_statistical_model.py#L88-L92 (this comment)
  • tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py#L189-L192
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_06_duke_heart_create_statistical_model.py` around lines 88
- 92, The Tutorial 4 surface consumers use the wrong output layout. In
tutorials/tutorial_06_duke_heart_create_statistical_model.py lines 88-92, update
the sample_files glob to search *_ref_heart_minus_interior_chambers.vtp within
pm???? case directories while preserving the hold-out filter; in
tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py lines 189-192,
include labelmap_file.parent.name between tutorial_04_dir and the surface
filename when constructing the surface path.

Comment on lines +117 to +120
mean_workflow = WorkflowCreateMeanSurface(
surfaces=sample_surfaces,
template_surface=sample_surfaces[4],
log_level=log_level,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Select a template surface that exists.

Test mode limits sample_files to three entries. Line 119 then selects the fifth surface and raises IndexError. Select a valid sample, such as the middle surface, after the existing minimum-count check.

Proposed fix
-            template_surface=sample_surfaces[4],
+            template_surface=sample_surfaces[len(sample_surfaces) // 2],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mean_workflow = WorkflowCreateMeanSurface(
surfaces=sample_surfaces,
template_surface=sample_surfaces[4],
log_level=log_level,
mean_workflow = WorkflowCreateMeanSurface(
surfaces=sample_surfaces,
template_surface=sample_surfaces[len(sample_surfaces) // 2],
log_level=log_level,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_06_duke_heart_create_statistical_model.py` around lines
117 - 120, Update the template_surface argument in WorkflowCreateMeanSurface to
use a valid entry from sample_surfaces, such as the middle element, while
preserving the existing minimum-count check and the remaining workflow
configuration.

Comment on lines +114 to +118
pca_model: Optional[dict[str, Any]] = None
if pca_json.exists():
with pca_json.open(encoding="utf-8") as f:
pca_model = json.load(f)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require the PCA model JSON before fitting.

If pca_mean_file exists but pca_json is absent, this code disables PCA registration and continues. The tutorial then performs a non-PCA fit while its inputs and outputs claim PCA behavior. Reject a missing pca_json with the same prerequisite error used for the mean surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py` around
lines 114 - 118, Update the PCA model loading logic around pca_model and
pca_json so that pca_json is required whenever pca_mean_file exists; when it is
absent, raise the same prerequisite error used for the missing mean surface
instead of continuing with pca_model unset. Preserve JSON loading when the file
exists and ensure fitting cannot proceed in a non-PCA mode under these
conditions.

Comment on lines +130 to +135
# Step 1: pick the test frame - the one 70% of the way through the case's
# ordered gated frames - and read its stage and ground-truth surface.
stages = [_cardiac_stage_from_filename(f) for f in phase_files]
test_index = int(stage_fraction * len(stages))
test_stage = stages[test_index]
ground_truth_file = phase_files[test_index]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clamp the frame index.

Line 133 computes test_index = int(stage_fraction * len(stages)). The docs invite users to change stage_fraction; a value of 1.0 produces an index equal to len(stages) and raises IndexError at Line 134. Clamp the index to the last frame.

🛡️ Proposed fix
-    test_index = int(stage_fraction * len(stages))
+    test_index = min(int(stage_fraction * len(stages)), len(stages) - 1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Step 1: pick the test frame - the one 70% of the way through the case's
# ordered gated frames - and read its stage and ground-truth surface.
stages = [_cardiac_stage_from_filename(f) for f in phase_files]
test_index = int(stage_fraction * len(stages))
test_stage = stages[test_index]
ground_truth_file = phase_files[test_index]
# Step 1: pick the test frame - the one 70% of the way through the case's
# ordered gated frames - and read its stage and ground-truth surface.
stages = [_cardiac_stage_from_filename(f) for f in phase_files]
test_index = min(int(stage_fraction * len(stages)), len(stages) - 1)
test_stage = stages[test_index]
ground_truth_file = phase_files[test_index]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tutorials/tutorial_10_duke_heart_infer_physicsnemo.py` around lines 130 -
135, Clamp test_index in the frame-selection logic before indexing stages and
phase_files, ensuring values such as stage_fraction=1.0 resolve to the final
frame rather than len(stages). Preserve the existing fractional selection
behavior for in-range values and use the last valid index as the upper bound.

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