diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 0000000..93c0f73 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1 @@ +settings.local.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61baeef..cce1e5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,7 @@ jobs: uses: codecov/codecov-action@v4 if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' with: + token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: unittests name: codecov-unit-${{ matrix.os }}-py${{ matrix.python-version }} @@ -233,6 +234,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: integration-tests name: codecov-integration @@ -317,6 +319,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: gpu-tests name: codecov-gpu @@ -380,7 +383,6 @@ jobs: # - tests/test_register_images_icon.py (requires CUDA for ICON) # - tests/test_transform_tools.py (depends on slow registration tests) # - tests/test_segment_chest_total_segmentator.py (requires CUDA for TotalSegmentator) -# - tests/test_segment_chest_vista_3d.py (requires CUDA for VISTA-3D, 20GB+ RAM) # # Experiment tests (EXTREMELY SLOW - hours to complete): # - tests/test_experiments.py (runs all notebooks in experiments/ subdirectories) diff --git a/.github/workflows/test-slow.yml b/.github/workflows/test-slow.yml index 3ce4e49..80ebcc7 100644 --- a/.github/workflows/test-slow.yml +++ b/.github/workflows/test-slow.yml @@ -80,6 +80,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: slow-tests-gpu name: codecov-slow-gpu diff --git a/CLAUDE.md b/CLAUDE.md index e577399..fac7117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ uv pip install -e . ruff check . --fix && ruff format . # Type checking -mypy src/ +mypy src/ tests/ # All pre-commit hooks pre-commit run --all-files @@ -28,7 +28,6 @@ py -m pytest tests/test_contour_tools.py::test_extract_surface -v # Skip GPU-dependent tests py -m pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py \ - --ignore=tests/test_segment_chest_vista_3d.py \ --ignore=tests/test_register_images_icon.py # With coverage diff --git a/README.md b/README.md index 83cab7d..f95c8b9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ PhysioMotion4D is a comprehensive medical imaging package that converts 4D CT sc ## šŸš€ Key Features - **Complete 4D Medical Imaging Pipeline**: End-to-end processing from 4D CT data to animated USD models -- **Multiple AI Segmentation Methods**: TotalSegmentator, VISTA-3D, and ensemble approaches +- **Multiple AI Segmentation Methods**: TotalSegmentator and Simpleware cardiac segmentation - **Deep Learning Registration**: GPU-accelerated image registration using Icon algorithm - **NVIDIA Omniverse Integration**: Direct USD file export for medical visualization - **Physiological Motion Analysis**: Capture and visualize cardiac and respiratory motion @@ -106,9 +106,6 @@ print(f"PhysioMotion4D version: {physiomotion4d.__version__}") - `WorkflowFitStatisticalModelToPatient`: Model-to-patient registration workflow - **Segmentation Classes**: Multiple AI-based chest segmentation implementations - `SegmentChestTotalSegmentator`: TotalSegmentator-based segmentation - - `SegmentChestVista3D`: VISTA-3D model-based segmentation - - `SegmentChestVista3DNIM`: NVIDIA NIM version of VISTA-3D - - `SegmentChestEnsemble`: Ensemble segmentation combining multiple methods - `SegmentAnatomyBase`: Base class for custom segmentation methods - **Registration Classes**: Multiple registration methods for different use cases - Image-to-Image Registration: @@ -142,7 +139,7 @@ print(f"PhysioMotion4D version: {physiomotion4d.__version__}") - **AI/ML**: PyTorch, CuPy (CUDA 13 default; CUDA 12 via `[cuda12]` extra), transformers, MONAI - **Registration**: icon-registration, unigradicon - **Visualization**: USD-core, PyVista -- **Segmentation**: TotalSegmentator, VISTA-3D models +- **Segmentation**: TotalSegmentator ## šŸŽÆ Quick Start @@ -262,11 +259,11 @@ registered_mesh = workflow.run_workflow() ### Custom Segmentation ```python -from physiomotion4d import SegmentChestVista3D +from physiomotion4d import SegmentChestTotalSegmentator import itk -# Initialize VISTA-3D segmentation -segmenter = SegmentChestVista3D() +# Initialize TotalSegmentator segmentation +segmenter = SegmentChestTotalSegmentator() # Load and segment image image = itk.imread("chest_ct.nrrd") @@ -578,14 +575,12 @@ pytest tests/test_register_images_greedy.py -v # Greedy registration pytest tests/test_register_images_icon.py -v # Icon registration pytest tests/test_register_time_series_images.py -v # Time series registration pytest tests/test_segment_chest_total_segmentator.py -v # TotalSegmentator -pytest tests/test_segment_chest_vista_3d.py -v # VISTA-3D segmentation pytest tests/test_contour_tools.py -v # Mesh and contour tools pytest tests/test_image_tools.py -v # Image processing utilities pytest tests/test_transform_tools.py -v # Transform operations # Skip GPU-dependent tests (segmentation and registration) pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py \ - --ignore=tests/test_segment_chest_vista_3d.py \ --ignore=tests/test_register_images_icon.py # Run with coverage report @@ -594,7 +589,7 @@ pytest tests/ --cov=src/physiomotion4d --cov-report=html **Test Categories:** - **Data Pipeline**: Download, conversion, and preprocessing -- **Segmentation**: TotalSegmentator and VISTA-3D (GPU required) +- **Segmentation**: TotalSegmentator (GPU required) - **Registration**: ANTs, Icon, and time series methods (slow, ~5-10 min) - **Geometry & Visualization**: Contour tools, transform tools, VTK to USD - **USD Utilities**: Merging, time preservation, material handling @@ -637,7 +632,7 @@ Use `/plan` to get an inspection of the affected classes, a numbered implementat plan, and a list of open questions — without touching any files. ```text -/plan add a confidence-weighted voting mode to SegmentChestEnsemble +/plan add a new segmentation method to SegmentChestTotalSegmentator ``` Claude will read the relevant source, summarize current behavior, list files that @@ -767,7 +762,7 @@ This project is licensed under the Apache 2.0 License - see the LICENSE file for - **NVIDIA Omniverse** team for USD format and visualization platform - **MONAI** community for medical imaging AI tools - **DirLab** for providing the 4D-CT benchmark datasets -- **TotalSegmentator** and **VISTA-3D** teams for segmentation models +- **TotalSegmentator** team for segmentation models - **Icon Registration** team for deep learning registration methods ## šŸ“ž Support diff --git a/data/test/.gitignore b/data/test/.gitignore new file mode 100644 index 0000000..1850c69 --- /dev/null +++ b/data/test/.gitignore @@ -0,0 +1,3 @@ +*.hdf +*.mha +*.nrrd diff --git a/docs/API_MAP.md b/docs/API_MAP.md index 07d6aba..6b0b7ec 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -31,10 +31,6 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def transform_contours(contours, transform_filenames, frame_indices, base_name, output_dir)` (line 31) - `def convert_contours(base_name, output_dir, project_name, compute_normals=False)` (line 49) -## experiments/Heart-GatedCT_To_USD/test_vista3d_inMem.py - -- `def vista3d_inference_from_itk(itk_image, label_prompt=None, points=None, point_labels=None, device=None, bundle_path=None, model_cache_dir=None)` (line 8) - ## experiments/Lung-GatedCT_To_USD/0-register_dirlab_4dct.py - `def dilate_mask(mask, dilation)` (line 30) @@ -137,56 +133,6 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def convert_array_to_image_of_vectors(self, arr_data, reference_image, ptype=itk.D)` (line 218): Convert a numpy array to an ITK image of vector type. - `def flip_image(self, in_image, in_mask=None, flip_x=False, flip_y=False, flip_z=False, flip_and_make_identity=False)` (line 249): Flip the image and mask. -## src/physiomotion4d/network_weights/vista3d/hugging_face_pipeline.py - -- **class HuggingFacePipelineHelper** (line 7) - - `def __init__(self, pipeline_name='vista3d')` (line 9) - - `def get_pipeline(self)` (line 18) - - `def init_pipeline(self, pretrained_model_name_or_path, **kwargs)` (line 30) - -## src/physiomotion4d/network_weights/vista3d/scripts/early_stop_score_function.py - -- `def score_function(engine)` (line 7) - -## src/physiomotion4d/network_weights/vista3d/scripts/evaluator.py - -- **class Vista3dEvaluator** (line 39): Supervised detection evaluation method with image and label, inherits from ``SupervisedEvaluator`` and ``Workflow``. - - `def __init__(self, device, val_data_loader, network, epoch_length=None, non_blocking=False, prepare_batch=default_prepare_batch, iteration_update=None, inferer=None, postprocessing=None, key_val_metric=None, additional_metrics=None, metric_cmp_fn=default_metric_cmp_fn, val_handlers=None, amp=False, mode=ForwardMode.EVAL, event_names=None, event_to_attr=None, decollate=True, to_kwargs=None, amp_kwargs=None, hyper_kwargs=None)` (line 85) - - `def transform_points(self, point, affine)` (line 137): transform point to the coordinates of the transformed image - - `def check_prompts_format(self, label_prompt, points, point_labels)` (line 148): check the format of user prompts - -## src/physiomotion4d/network_weights/vista3d/scripts/inferer.py - -- **class Vista3dInferer** (line 21): Vista3D Inferer - - `def __init__(self, roi_size, overlap, use_point_window=False, sw_batch_size=1)` (line 30) - -## src/physiomotion4d/network_weights/vista3d/scripts/trainer.py - -- **class Vista3dTrainer** (line 39): Supervised detection training method with image and label, inherits from ``Trainer`` and ``Workflow``. - - `def __init__(self, device, max_epochs, train_data_loader, network, optimizer, loss_function, epoch_length=None, non_blocking=False, prepare_batch=default_prepare_batch, iteration_update=None, inferer=None, postprocessing=None, key_train_metric=None, additional_metrics=None, metric_cmp_fn=default_metric_cmp_fn, train_handlers=None, amp=False, event_names=None, event_to_attr=None, decollate=True, optim_set_to_none=False, to_kwargs=None, amp_kwargs=None, hyper_kwargs=None)` (line 88) - -## src/physiomotion4d/network_weights/vista3d/vista3d_config.py - -- **class VISTA3DConfig** (line 4): Configuration class for vista3d - - `def __init__(self, encoder_embed_dim=48, input_channels=1, **kwargs)` (line 9): Set the hyperparameters for the VISTA3D model. - -## src/physiomotion4d/network_weights/vista3d/vista3d_model.py - -- **class VISTA3DModel** (line 9): VISTA3D model for hugging face - - `def __init__(self, config)` (line 14) - - `def forward(self, input)` (line 22) -- `def register_my_model()` (line 26): Utility function to register VISTA3D model so that it can be instantiate by the AutoModel function. - -## src/physiomotion4d/network_weights/vista3d/vista3d_pipeline.py - -- **class VISTA3DPipeline** (line 48): Define the VISTA3D pipeline. - - `def __init__(self, model, **kwargs)` (line 80) - - `def check_prompts_format(self, label_prompt, points, point_labels)` (line 213): check the format of user prompts - - `def transform_points(self, point, affine)` (line 287): transform point to the coordinates of the transformed image - - `def preprocess(self, inputs, **kwargs)` (line 298) - - `def postprocess(self, outputs, **kwargs)` (line 435) -- `def register_simple_pipeline()` (line 454) - ## src/physiomotion4d/notebook_utils.py - `def running_as_test()` (line 11): True when the notebook is run as a test (e.g. by pytest experiment tests). @@ -313,18 +259,11 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def preprocess_input(self, input_image)` (line 123): Preprocess the input image for segmentation. - `def postprocess_labelmap(self, labelmap_image, input_image)` (line 245): Resample the labelmap to match the input image spacing. - `def segment_connected_component(self, preprocessed_image, labelmap_image, lower_threshold, upper_threshold, labelmap_ids=None, mask_id=0, use_mid_slice=True, hole_fill=2)` (line 341): Segment connected components based on intensity thresholding. - - `def segment_contrast_agent(self, preprocessed_image, labelmap_image)` (line 440): Include contrast-enhanced blood in the labelmap. - - `def create_anatomy_group_masks(self, labelmap_image)` (line 482): Create binary masks for different anatomical groups from the labelmap. - - `def segmentation_method(self, preprocessed_image)` (line 568): Abstract method for image segmentation - must be implemented by subclasses. - - `def dilate_mask(self, mask, dilation)` (line 590): Dilate a binary mask using morphological operations. - - `def segment(self, input_image, contrast_enhanced_study=False)` (line 613): Perform complete chest CT segmentation. - -## src/physiomotion4d/segment_chest_ensemble.py - -- **class SegmentChestEnsemble** (line 20): A class that inherits from physioSegmentChest and implements the - - `def __init__(self, log_level=logging.INFO)` (line 26): Initialize the vista3d class. - - `def ensemble_segmentation(self, labelmap_vista, labelmap_totseg)` (line 309): Combine two segmentation results using label mapping and priority rules. - - `def segmentation_method(self, preprocessed_image)` (line 398): Run VISTA3D on the preprocessed image and return result. + - `def segment_contrast_agent(self, preprocessed_image, labelmap_image)` (line 448): Include contrast-enhanced blood in the labelmap. + - `def create_anatomy_group_masks(self, labelmap_image)` (line 490): Create binary masks for different anatomical groups from the labelmap. + - `def segmentation_method(self, preprocessed_image)` (line 576): Abstract method for image segmentation - must be implemented by subclasses. + - `def dilate_mask(self, mask, dilation)` (line 598): Dilate a binary mask using morphological operations. + - `def segment(self, input_image, contrast_enhanced_study=False)` (line 621): Perform complete chest CT segmentation. ## src/physiomotion4d/segment_chest_total_segmentator.py @@ -332,22 +271,6 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def __init__(self, log_level=logging.INFO)` (line 57): Initialize the TotalSegmentator-based chest segmentation. - `def segmentation_method(self, preprocessed_image)` (line 199): Run TotalSegmentator on the preprocessed image and return result. -## src/physiomotion4d/segment_chest_vista_3d.py - -- **class SegmentChestVista3D** (line 32): Chest CT segmentation using NVIDIA VISTA-3D foundational model. - - `def __init__(self, log_level=logging.INFO)` (line 68): Initialize the VISTA-3D based chest segmentation. - - `def set_label_prompt(self, label_prompt)` (line 232): Set specific anatomical structure labels to segment. - - `def set_whole_image_segmentation(self)` (line 253): Configure for automatic whole-image segmentation. - - `def segment_soft_tissue(self, preprocessed_image, labelmap_image)` (line 269): Add soft tissue segmentation to fill gaps in VISTA-3D output. - - `def preprocess_input(self, input_image)` (line 302): Preprocess the input image for VISTA-3D segmentation. - - `def segmentation_method(self, preprocessed_image)` (line 329): Run VISTA-3D segmentation on the preprocessed image. - -## src/physiomotion4d/segment_chest_vista_3d_nim.py - -- **class SegmentChestVista3DNIM** (line 25): A class that inherits from physioSegmentChest and implements the - - `def __init__(self, log_level=logging.INFO)` (line 31): Initialize the vista3d class. - - `def segmentation_method(self, preprocessed_image)` (line 45): Run VISTA3D on the preprocessed image using the NIM and return result. - ## src/physiomotion4d/segment_heart_simpleware.py - **class SegmentHeartSimpleware** (line 23): Heart CT segmentation using Simpleware Medical's ASCardio module. @@ -362,18 +285,17 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def set_create_baseline_if_missing(value)` (line 28): Set whether to create baseline files when missing (used by pytest conftest). - **class TestTools** (line 34): Utilities for pytest image comparison: baseline directory, results directory, - - `def __init__(self, results_dir, baselines_dir, class_name, *, log_level=logging.INFO)` (line 41) - - `def compare_2d_to_3d_slice(self, image_2d, image_3d, slice_index, axis=0, *, per_pixel_absolute_error_tol=0.0, max_number_of_pixels_above_tol=0, total_absolute_error_tol=0.0)` (line 75): Compare a 2D itk.Image to a slice of a 3D itk.Image. Converts to numpy only for computing differences. - - `def image_pass_fail_and_pixels_above_tolerance(self)` (line 149): Return (pass, value) for number of pixels above tolerance from the most recent compare_2d_to_3d_slice call. - - `def image_pass_fail_and_total_absolute_error(self)` (line 163): Return (pass, value) for total absolute error from the most recent compare_2d_to_3d_slice call. - - `def image_difference(self)` (line 177): Return the difference image (itk.Image) from the most recent compare_2d_to_3d_slice call. - - `def transform_pass_fail_and_number_of_values_above_tolerance(self)` (line 183): Return (pass, value) for number of values above tolerance from the most recent compare_result_to_baseline_transform call. - - `def transform_pass_fail_and_total_absolute_error(self)` (line 199): Return (pass, value) for total absolute error from the most recent compare_result_to_baseline_transform call. - - `def transform_difference(self)` (line 213): Return the difference transform (itk.Transform) from the most recent compare_result_to_baseline_transform call. - - `def write_result_image(self, image, filename)` (line 219): Write the image to the results directory. - - `def write_result_transform(self, transform, filename)` (line 223): Write the transform to the results directory. - - `def compare_result_to_baseline_transform(self, filename, *, per_value_absolute_error_tol=0.0, max_number_of_values_above_tol=0, total_absolute_error_tol=0.0)` (line 229): Compare the transform to the baseline transform. - - `def compare_result_to_baseline_image(self, filename, slice_index=None, axis=0, *, per_pixel_absolute_error_tol=0.0, max_number_of_pixels_above_tol=0, total_absolute_error_tol=0.0)` (line 307): Load 3D image from results_filename and 2D baseline from baseline_filename (.mha), compare the given slice to baseline, + - `def __init__(self, results_dir, baselines_dir, class_name, *, log_level=logging.INFO)` (line 44) + - `def image_pass_fail_and_pixels_above_tolerance(self)` (line 78): Return (pass, value) for number of pixels above tolerance from the most + - `def image_pass_fail_and_total_absolute_error(self)` (line 93): Return (pass, value) for total absolute error from the most recent + - `def image_difference(self)` (line 108): Return the difference image (itk.Image) from the most recent + - `def transform_pass_fail_and_number_of_values_above_tolerance(self)` (line 115): Return (pass, value) for number of values above tolerance from the most recent compare_result_to_baseline_transform call. + - `def transform_pass_fail_and_total_absolute_error(self)` (line 131): Return (pass, value) for total absolute error from the most recent compare_result_to_baseline_transform call. + - `def transform_difference(self)` (line 145): Return the difference transform (itk.Transform) from the most recent compare_result_to_baseline_transform call. + - `def write_result_image(self, image, filename)` (line 151): Write the image to the results directory. + - `def write_result_transform(self, transform, filename)` (line 155): Write the transform to the results directory. + - `def compare_result_to_baseline_transform(self, filename, *, per_value_absolute_error_tol=0.0, max_number_of_values_above_tol=0, total_absolute_error_tol=0.0)` (line 161): Compare the transform to the baseline transform. + - `def compare_result_to_baseline_image(self, filename, *, per_pixel_absolute_error_tol=0.0, max_number_of_pixels_above_tol=0, total_absolute_error_tol=0.0)` (line 239): Load a 3D result image and a 3D baseline image (.mha), compare the full ## src/physiomotion4d/transform_tools.py @@ -391,9 +313,9 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def compute_jacobian_determinant_from_field(self, field)` (line 620): Compute Jacobian determinant of a displacement field. - `def detect_folding_in_field(self, jacobian_det, threshold=0.1)` (line 649): Detect spatial folding in a transform. - `def reduce_folding_in_field(self, field, jacobian_det, reduction_factor=0.8, threshold=0.1)` (line 673): Reduce folding by scaling displacement field in problematic regions. - - `def generate_grid_image(self, reference_image, grid_size=60, line_width=3)` (line 712): Generate a grid image. - - `def convert_field_to_grid_visualization(self, tfm, reference_image, grid_size=60, line_width=3)` (line 745): Generate a visual deformation grid for transform visualization. - - `def convert_itk_transform_to_usd_visualization(self, tfm, reference_image, output_filename, visualization_type='arrows', subsample_factor=4, arrow_scale=1.0, magnitude_threshold=0.0)` (line 781): Convert an ITK transform to a USD visualization for NVIDIA Omniverse. + - `def generate_grid_image(self, reference_image, grid_size=60, line_width=3)` (line 719): Generate a grid image. + - `def convert_field_to_grid_visualization(self, tfm, reference_image, grid_size=60, line_width=3)` (line 752): Generate a visual deformation grid for transform visualization. + - `def convert_itk_transform_to_usd_visualization(self, tfm, reference_image, output_filename, visualization_type='arrows', subsample_factor=4, arrow_scale=1.0, magnitude_threshold=0.0)` (line 788): Convert an ITK transform to a USD visualization for NVIDIA Omniverse. ## src/physiomotion4d/usd_anatomy_tools.py @@ -490,13 +412,13 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/workflow_convert_ct_to_vtk.py -- **class WorkflowConvertCTToVTK** (line 59): Segment a CT image and produce per-anatomy-group VTK surfaces and meshes. - - `def __init__(self, segmentation_method='total_segmentator', log_level=logging.INFO)` (line 100): Initialize the workflow. - - `def run_workflow(self, input_image, contrast_enhanced_study=False, anatomy_groups=None)` (line 247): Segment the CT image and extract per-anatomy-group VTK objects. - - `def save_surfaces(surfaces, output_dir, prefix='')` (line 350): Save each group surface to its own VTP file. - - `def save_meshes(meshes, output_dir, prefix='')` (line 377): Save each group voxel mesh to its own VTU file. - - `def save_combined_surface(surfaces, output_dir, prefix='')` (line 403): Merge all group surfaces into a single VTP file. - - `def save_combined_mesh(meshes, output_dir, prefix='')` (line 438): Merge all group meshes into a single VTU file. +- **class WorkflowConvertCTToVTK** (line 58): Segment a CT image and produce per-anatomy-group VTK surfaces and meshes. + - `def __init__(self, segmentation_method='total_segmentator', log_level=logging.INFO)` (line 98): Initialize the workflow. + - `def run_workflow(self, input_image, contrast_enhanced_study=False, anatomy_groups=None)` (line 240): Segment the CT image and extract per-anatomy-group VTK objects. + - `def save_surfaces(surfaces, output_dir, prefix='')` (line 343): Save each group surface to its own VTP file. + - `def save_meshes(meshes, output_dir, prefix='')` (line 370): Save each group voxel mesh to its own VTU file. + - `def save_combined_surface(surfaces, output_dir, prefix='')` (line 396): Merge all group surfaces into a single VTP file. + - `def save_combined_mesh(meshes, output_dir, prefix='')` (line 431): Merge all group meshes into a single VTU file. ## src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py @@ -516,23 +438,23 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - **class WorkflowCreateStatisticalModel** (line 35): Create a PCA statistical shape model from a sample of meshes aligned to a reference. - `def __init__(self, sample_meshes, reference_mesh, pca_number_of_components=15, reference_spatial_resolution=1.0, reference_buffer_factor=0.25, solve_for_surface_pca=True, log_level=logging.INFO)` (line 56): Initialize the create-statistical-model workflow. - `def set_pca_number_of_components(self, n)` (line 102): Set number of PCA components to retain. - - `def run_workflow(self)` (line 310): Run the full pipeline and return a dictionary of results (no file I/O). + - `def run_workflow(self)` (line 314): Run the full pipeline and return a dictionary of results (no file I/O). ## src/physiomotion4d/workflow_fit_statistical_model_to_patient.py - **class WorkflowFitStatisticalModelToPatient** (line 44): Register anatomical models using multi-stage ICP, mask-based, and image-based - `def __init__(self, template_model, patient_models=None, patient_image=None, segmentation_method='simpleware_heart', log_level=logging.INFO)` (line 123): Initialize the model-to-image-and-model registration pipeline. - - `def set_mask_dilation_mm(self, mask_dilation_mm)` (line 330): Set mask dilation amount for auto-generated masks. - - `def set_roi_dilation_mm(self, roi_dilation_mm)` (line 339): Set ROI mask dilation amount. - - `def set_use_pca_registration(self, use_pca_registration, pca_model=None, pca_number_of_modes=0, pca_uses_surface=True)` (line 348): Set whether to use PCA-based registration and provide the PCA model. - - `def set_use_mask_to_mask_registration(self, use_mask_to_mask_registration)` (line 383): Set whether to use mask-to-mask registration. - - `def set_use_mask_to_image_registration(self, use_mask_to_image_registration, template_labelmap=None, template_labelmap_organ_mesh_ids=None, template_labelmap_organ_extra_ids=None, template_labelmap_background_ids=None)` (line 394): Set whether to use mask-to-image registration. - - `def register_model_to_model_icp(self)` (line 448): Perform ICP alignment of template model to patient model. - - `def register_model_to_model_pca(self)` (line 508): Perform PCA-based registration after ICP alignment. - - `def register_mask_to_mask(self, use_icon_refinement=False)` (line 632): Perform mask-based deformable registration of model to patient model. - - `def register_labelmap_to_image(self, use_icon_refinement=False)` (line 700): Perform labelmap-to-image refinement. - - `def transform_model(self, base_model=None)` (line 819): Apply registration transforms to the model. - - `def run_workflow(self, use_icon_registration_refinement=False)` (line 894): Execute the complete multi-stage registration workflow. + - `def set_mask_dilation_mm(self, mask_dilation_mm)` (line 337): Set mask dilation amount for auto-generated masks. + - `def set_roi_dilation_mm(self, roi_dilation_mm)` (line 346): Set ROI mask dilation amount. + - `def set_use_pca_registration(self, use_pca_registration, pca_model=None, pca_number_of_modes=0, pca_uses_surface=True)` (line 355): Set whether to use PCA-based registration and provide the PCA model. + - `def set_use_mask_to_mask_registration(self, use_mask_to_mask_registration)` (line 390): Set whether to use mask-to-mask registration. + - `def set_use_mask_to_image_registration(self, use_mask_to_image_registration, template_labelmap=None, template_labelmap_organ_mesh_ids=None, template_labelmap_organ_extra_ids=None, template_labelmap_background_ids=None)` (line 401): Set whether to use mask-to-image registration. + - `def register_model_to_model_icp(self)` (line 455): Perform ICP alignment of template model to patient model. + - `def register_model_to_model_pca(self)` (line 515): Perform PCA-based registration after ICP alignment. + - `def register_mask_to_mask(self, use_icon_refinement=False)` (line 639): Perform mask-based deformable registration of model to patient model. + - `def register_labelmap_to_image(self, use_icon_refinement=False)` (line 707): Perform labelmap-to-image refinement. + - `def transform_model(self, base_model=None)` (line 826): Apply registration transforms to the model. + - `def run_workflow(self, use_icon_registration_refinement=False)` (line 901): Execute the complete multi-stage registration workflow. ## src/physiomotion4d/workflow_reconstruct_highres_4d_ct.py @@ -554,257 +476,239 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def pytest_addoption(parser)` (line 36): Add custom command-line options for pytest. - `def pytest_configure(config)` (line 52): Configure pytest with custom markers and settings. - `def pytest_collection_modifyitems(config, items)` (line 75): Automatically skip experiment tests unless --run-experiments is passed. -- `def pytest_runtest_logreport(report)` (line 95): Collect test timing information after each test completes. -- `def pytest_terminal_summary(terminalreporter, exitstatus, config)` (line 119): Print comprehensive test timing report after all tests complete. -- `def test_directories()` (line 253): Set up test directories for data and results. -- `def download_truncal_valve_data(test_directories)` (line 268): Download TruncalValve 4D CT data. -- `def converted_3d_images(download_truncal_valve_data, test_directories)` (line 310): Convert 4D NRRD to 3D time series and return slice files. -- `def test_images(converted_3d_images)` (line 338): Load time points from the converted 3D data for testing. -- `def segmenter_total_segmentator()` (line 379): Create a SegmentChestTotalSegmentator instance. -- `def segmenter_vista_3d()` (line 385): Create a SegmentChestVista3D instance. -- `def segmenter_simpleware()` (line 391): Create a SegmentHeartSimpleware instance. -- `def heart_simpleware_image_path()` (line 397): Path to cardiac CT image used by experiments/Heart-Simpleware_Segmentation notebook. -- `def heart_simpleware_image(heart_simpleware_image_path)` (line 416): Load cardiac CT image for SegmentHeartSimpleware tests (same as notebook). -- `def segmentation_results(segmenter_total_segmentator, test_images, test_directories)` (line 422): Get or create segmentation results using TotalSegmentator. -- `def contour_tools()` (line 483): Create a ContourTools instance. -- `def registrar_ants()` (line 494): Create a RegisterImagesANTs instance. -- `def registrar_greedy()` (line 500): Create a RegisterImagesGreedy instance. -- `def registrar_icon()` (line 506): Create a RegisterImagesICON instance. -- `def ants_registration_results(registrar_ants, test_images, test_directories)` (line 512): Perform ANTs registration and return results. -- `def transform_tools()` (line 565): Create a TransformTools instance. +- `def pytest_runtest_logreport(report)` (line 97): Collect test timing information after each test completes. +- `def pytest_terminal_summary(terminalreporter, exitstatus, config)` (line 121): Print comprehensive test timing report after all tests complete. +- `def test_directories()` (line 259): Set up test directories for data and results. +- `def download_test_data(test_directories)` (line 274): Download TruncalValve 4D CT data. +- `def test_images(download_test_data, test_directories)` (line 310): Convert and resample 4D NRRD data; return pre-resampled time points. +- `def test_labelmaps(segmenter_total_segmentator, test_images, test_directories)` (line 362): Segment each time point with TotalSegmentator and return result dicts. +- `def test_transforms(registrar_ants, test_images, test_directories)` (line 403): Perform ANTs registration and return results. +- `def segmenter_total_segmentator()` (line 459): Create a SegmentChestTotalSegmentator instance. +- `def segmenter_simpleware()` (line 465): Create a SegmentHeartSimpleware instance. +- `def contour_tools()` (line 471): Create a ContourTools instance. +- `def registrar_ants()` (line 477): Create a RegisterImagesANTs instance. +- `def registrar_greedy()` (line 483): Create a RegisterImagesGreedy instance. +- `def registrar_icon()` (line 489): Create a RegisterImagesICON instance. +- `def transform_tools()` (line 495): Create a TransformTools instance. ## tests/test_contour_tools.py -- **class TestContourTools** (line 17): Test suite for ContourTools functionality. - - `def test_contour_tools_initialization(self, contour_tools)` (line 20): Test that ContourTools initializes correctly. - - `def test_extract_contours_from_heart_mask(self, contour_tools, segmentation_results, test_directories)` (line 25): Test extracting contours from heart mask. - - `def test_extract_contours_from_lung_mask(self, contour_tools, segmentation_results, test_directories)` (line 55): Test extracting contours from lung mask. - - `def test_extract_contours_multiple_anatomy(self, contour_tools, segmentation_results, test_directories)` (line 81): Test extracting contours from multiple anatomical structures. - - `def test_create_mask_from_mesh(self, contour_tools, segmentation_results, test_images, test_directories)` (line 116): Test creating a mask from extracted mesh. - - `def test_merge_meshes(self, contour_tools, segmentation_results, test_directories)` (line 153): Test merging multiple meshes. - - `def test_transform_contours_identity(self, contour_tools, segmentation_results, test_directories)` (line 198): Test transforming contours with identity transform. - - `def test_transform_contours_with_deformation(self, contour_tools, segmentation_results, test_directories)` (line 241): Test transforming contours with deformation magnitude calculation. - - `def test_contours_from_both_time_points(self, contour_tools, segmentation_results, test_directories)` (line 287): Test extracting contours from both time points. +- **class TestContourTools** (line 22): Test suite for ContourTools functionality. + - `def test_contour_tools_initialization(self, contour_tools)` (line 25): Test that ContourTools initializes correctly. + - `def test_extract_contours_from_heart_mask(self, contour_tools, test_labelmaps, test_directories)` (line 30): Test extracting contours from heart mask. + - `def test_extract_contours_from_lung_mask(self, contour_tools, test_labelmaps, test_directories)` (line 63): Test extracting contours from lung mask. + - `def test_extract_contours_multiple_anatomy(self, contour_tools, test_labelmaps, test_directories)` (line 92): Test extracting contours from multiple anatomical structures. + - `def test_create_mask_from_mesh(self, contour_tools, test_labelmaps, test_images, test_directories)` (line 130): Test creating a mask from extracted mesh. + - `def test_merge_meshes(self, contour_tools, test_labelmaps, test_directories)` (line 171): Test merging multiple meshes. + - `def test_transform_contours_identity(self, contour_tools, test_labelmaps, test_directories)` (line 221): Test transforming contours with identity transform. + - `def test_transform_contours_with_deformation(self, contour_tools, test_labelmaps, test_directories)` (line 267): Test transforming contours with deformation magnitude calculation. + - `def test_contours_from_both_time_points(self, contour_tools, test_labelmaps, test_directories)` (line 316): Test extracting contours from both time points. ## tests/test_convert_nrrd_4d_to_3d.py - **class TestConvertNRRD4DTo3D** (line 17): Test suite for converting 4D NRRD to 3D time series. - - `def test_convert_4d_to_3d(self, download_truncal_valve_data, test_directories)` (line 20): Test conversion of 4D NRRD to 3D time series (replicates notebook cell 3). - - `def test_slice_files_created(self, download_truncal_valve_data, test_directories)` (line 50): Test that all expected slice files are present after conversion. - - `def test_fixed_image_output(self, download_truncal_valve_data, test_directories)` (line 66): Test that fixed/reference image is copied to output directory. - - `def test_load_nrrd_4d(self, download_truncal_valve_data)` (line 81): Test loading 4D NRRD file. - - `def test_save_3d_images(self, download_truncal_valve_data, test_directories)` (line 94): Test saving 3D images from 4D NRRD. + - `def test_convert_4d_to_3d(self, download_test_data, test_directories)` (line 20): Test conversion of 4D NRRD to 3D time series (replicates notebook cell 3). + - `def test_slice_files_created(self, download_test_data, test_directories)` (line 46): Test that all expected slice files are present after conversion. + - `def test_load_nrrd_4d(self, download_test_data)` (line 67): Test loading 4D NRRD file. + - `def test_save_3d_images(self, download_test_data, test_directories)` (line 80): Test saving 3D images from 4D NRRD. ## tests/test_convert_vtk_to_usd.py -- **class TestConvertVTKToUSD** (line 19): Test suite for VTK to USD PolyMesh conversion. - - `def contour_meshes(self, contour_tools, segmentation_results, test_directories)` (line 23): Extract or load contour meshes for USD conversion testing. - - `def test_converter_initialization(self)` (line 56): Test that ConvertVTKToUSD initializes correctly. - - `def test_supports_mesh_type(self, contour_meshes)` (line 67): Test that converter correctly identifies supported mesh types. - - `def test_convert_single_time_point(self, contour_meshes, test_directories)` (line 80): Test converting a single time point to USD. - - `def test_convert_multiple_time_points(self, contour_meshes, test_directories)` (line 111): Test converting multiple time points to USD. - - `def test_convert_with_deformation(self, contour_tools, segmentation_results, test_directories)` (line 144): Test converting meshes with deformation magnitude. - - `def test_convert_with_colormap(self, contour_meshes, test_directories)` (line 182): Test converting meshes with colormap visualization. - - `def test_convert_unstructured_grid_to_surface(self, test_directories)` (line 219): Test converting UnstructuredGrid to surface mesh. - - `def test_usd_file_structure(self, contour_meshes, test_directories)` (line 265): Test the structure of generated USD file. - - `def test_time_varying_topology(self, contour_meshes, test_directories)` (line 295): Test handling of time-varying topology. - - `def test_batch_conversion(self, contour_tools, segmentation_results, test_directories)` (line 334): Test converting multiple anatomy structures in batch. +- **class TestConvertVTKToUSD** (line 23): Test suite for VTK to USD PolyMesh conversion. + - `def contour_meshes(self, contour_tools, test_labelmaps, test_directories)` (line 27): Extract or load contour meshes for USD conversion testing. + - `def test_converter_initialization(self)` (line 65): Test that ConvertVTKToUSD initializes correctly. + - `def test_supports_mesh_type(self, contour_meshes)` (line 76): Test that converter correctly identifies supported mesh types. + - `def test_convert_single_time_point(self, contour_meshes, test_directories)` (line 89): Test converting a single time point to USD. + - `def test_convert_multiple_time_points(self, contour_meshes, test_directories)` (line 122): Test converting multiple time points to USD. + - `def test_convert_with_deformation(self, contour_tools, test_labelmaps, test_directories)` (line 157): Test converting meshes with deformation magnitude. + - `def test_convert_with_colormap(self, contour_meshes, test_directories)` (line 198): Test converting meshes with colormap visualization. + - `def test_convert_unstructured_grid_to_surface(self, test_directories)` (line 237): Test converting UnstructuredGrid to surface mesh. + - `def test_usd_file_structure(self, contour_meshes, test_directories)` (line 285): Test the structure of generated USD file. + - `def test_time_varying_topology(self, contour_meshes, test_directories)` (line 317): Test handling of time-varying topology. + - `def test_batch_conversion(self, contour_tools, test_labelmaps, test_directories)` (line 358): Test converting multiple anatomy structures in batch. ## tests/test_download_heart_data.py -- **class TestDownloadHeartData** (line 13): Test suite for downloading and converting Slicer-Heart-CT data. - - `def test_directories_created(self, test_directories)` (line 16): Test that directories are created successfully. - - `def test_data_downloaded(self, download_truncal_valve_data, test_directories)` (line 26): Test that the TruncalValve 4D CT data file is downloaded. +- **class TestDownloadHeartData** (line 15): Test suite for downloading and converting Slicer-Heart-CT data. + - `def test_directories_created(self, test_directories)` (line 18): Test that directories are created successfully. + - `def test_data_downloaded(self, download_test_data, test_directories)` (line 28): Test that the TruncalValve 4D CT data file is downloaded. ## tests/test_experiments.py -- `def get_scripts_in_subdir(subdir_name)` (line 55): Get all Python scripts in a subdirectory, sorted alphanumerically. -- `def execute_script(script_path, timeout=3600)` (line 73): Execute a Python experiment script. -- `def run_experiment_scripts(subdir_name, timeout_per_script=3600)` (line 175): Run all Python scripts in an experiment subdirectory in alphanumeric order. +- `def get_scripts_in_subdir(subdir_name)` (line 56): Get all Python scripts in a subdirectory, sorted alphanumerically. +- `def execute_script(script_path, timeout=3600)` (line 74): Execute a Python experiment script. +- `def run_experiment_scripts(subdir_name, timeout_per_script=3600)` (line 176): Run all Python scripts in an experiment subdirectory in alphanumeric order. - `def test_experiment_colormap_vtk_to_usd()` (line 297): Test Colormap-VTK_To_USD experiment scripts. -- `def test_experiment_reconstruct_4dct()` (line 331): Test Reconstruct4DCT experiment scripts. -- `def test_experiment_heart_vtk_series_to_usd()` (line 350): Test Heart-VTKSeries_To_USD experiment scripts. -- `def test_experiment_heart_gated_ct_to_usd()` (line 371): Test Heart-GatedCT_To_USD experiment scripts. -- `def test_experiment_convert_vtk_to_usd()` (line 397): Test Convert_VTK_To_USD experiment scripts. -- `def test_experiment_create_statistical_model()` (line 417): Test Heart-Create_Statistical_Model experiment scripts. -- `def test_experiment_heart_statistical_model_to_patient()` (line 442): Test Heart-Statistical_Model_To_Patient experiment scripts. -- `def test_experiment_lung_gated_ct_to_usd()` (line 477): Test Lung-GatedCT_To_USD experiment scripts. -- `def test_experiment_structure()` (line 522): Validate the structure of the experiments directory. -- `def test_list_scripts_in_subdir(subdir_name)` (line 576): List all scripts in each experiment subdirectory. +- `def test_experiment_reconstruct_4dct()` (line 329): Test Reconstruct4DCT experiment scripts. +- `def test_experiment_heart_vtk_series_to_usd()` (line 347): Test Heart-VTKSeries_To_USD experiment scripts. +- `def test_experiment_heart_gated_ct_to_usd()` (line 367): Test Heart-GatedCT_To_USD experiment scripts. +- `def test_experiment_convert_vtk_to_usd()` (line 390): Test Convert_VTK_To_USD experiment scripts. +- `def test_experiment_create_statistical_model()` (line 409): Test Heart-Create_Statistical_Model experiment scripts. +- `def test_experiment_heart_statistical_model_to_patient()` (line 433): Test Heart-Statistical_Model_To_Patient experiment scripts. +- `def test_experiment_lung_gated_ct_to_usd()` (line 467): Test Lung-GatedCT_To_USD experiment scripts. +- `def test_experiment_structure()` (line 511): Validate the structure of the experiments directory. +- `def test_list_scripts_in_subdir(subdir_name)` (line 565): List all scripts in each experiment subdirectory. ## tests/test_image_tools.py -- **class TestImageTools** (line 19): Test suite for ImageTools conversions. - - `def image_tools(self)` (line 23): Create ImageTools instance. - - `def test_itk_to_sitk_scalar_image(self, image_tools)` (line 27): Test conversion of scalar ITK image to SimpleITK. - - `def test_sitk_to_itk_scalar_image(self, image_tools)` (line 62): Test conversion of scalar SimpleITK image to ITK. - - `def test_roundtrip_scalar_image(self, image_tools)` (line 94): Test roundtrip conversion: ITK -> SimpleITK -> ITK. - - `def test_itk_to_sitk_vector_image(self, image_tools)` (line 130): Test conversion of vector ITK image to SimpleITK. - - `def test_sitk_to_itk_vector_image(self, image_tools)` (line 168): Test conversion of vector SimpleITK image to ITK. - - `def test_roundtrip_vector_image(self, image_tools)` (line 199): Test roundtrip conversion for vector images: ITK -> SimpleITK -> ITK. - - `def test_imwrite_imread_vd3(self, image_tools, ants_registration_results, test_images, test_directories)` (line 238): Test reading and writing double precision vector images. -- **class TestFlipImage** (line 331): Unit tests for ImageTools.flip_image (axis flips and direction reset). - - `def image_tools(self)` (line 335) - - `def test_flip_x_flips_along_last_array_axis(self, image_tools)` (line 338): flip_x flips the image along the x (last) array dimension. - - `def test_flip_y_flips_along_middle_array_axis(self, image_tools)` (line 351): flip_y flips the image along the y (middle) array dimension. - - `def test_flip_z_flips_along_first_array_axis(self, image_tools)` (line 365): flip_z flips the image along the z (first) array dimension. - - `def test_flip_xy_combines_flips(self, image_tools)` (line 377): flip_x and flip_y together flip both axes. - - `def test_no_flip_returns_same_image(self, image_tools)` (line 387): With no flip flags, image is returned unchanged. - - `def test_mask_flipped_in_lockstep_with_image(self, image_tools)` (line 398): When a mask is provided, it is flipped with the same axes as the image. - - `def test_flip_and_make_identity_sets_direction_to_identity(self, image_tools)` (line 419): flip_and_make_identity flips as needed and sets direction matrix to identity. - - `def test_flip_and_make_identity_with_mask_sets_both_directions_to_identity(self, image_tools)` (line 435): With mask and flip_and_make_identity, both image and mask get identity direction. +- **class TestImageTools** (line 22): Test suite for ImageTools conversions. + - `def image_tools(self)` (line 26): Create ImageTools instance. + - `def test_itk_to_sitk_scalar_image(self, image_tools)` (line 30): Test conversion of scalar ITK image to SimpleITK. + - `def test_sitk_to_itk_scalar_image(self, image_tools)` (line 65): Test conversion of scalar SimpleITK image to ITK. + - `def test_roundtrip_scalar_image(self, image_tools)` (line 97): Test roundtrip conversion: ITK -> SimpleITK -> ITK. + - `def test_itk_to_sitk_vector_image(self, image_tools)` (line 133): Test conversion of vector ITK image to SimpleITK. + - `def test_sitk_to_itk_vector_image(self, image_tools)` (line 171): Test conversion of vector SimpleITK image to ITK. + - `def test_roundtrip_vector_image(self, image_tools)` (line 202): Test roundtrip conversion for vector images: ITK -> SimpleITK -> ITK. + - `def test_imwrite_imread_vd3(self, image_tools, test_transforms, test_images, test_directories)` (line 241): Test reading and writing double precision vector images. +- **class TestFlipImage** (line 334): Unit tests for ImageTools.flip_image (axis flips and direction reset). + - `def image_tools(self)` (line 338) + - `def test_flip_x_flips_along_last_array_axis(self, image_tools)` (line 341): flip_x flips the image along the x (last) array dimension. + - `def test_flip_y_flips_along_middle_array_axis(self, image_tools)` (line 354): flip_y flips the image along the y (middle) array dimension. + - `def test_flip_z_flips_along_first_array_axis(self, image_tools)` (line 368): flip_z flips the image along the z (first) array dimension. + - `def test_flip_xy_combines_flips(self, image_tools)` (line 380): flip_x and flip_y together flip both axes. + - `def test_no_flip_returns_same_image(self, image_tools)` (line 390): With no flip flags, image is returned unchanged. + - `def test_mask_flipped_in_lockstep_with_image(self, image_tools)` (line 401): When a mask is provided, it is flipped with the same axes as the image. + - `def test_flip_and_make_identity_sets_direction_to_identity(self, image_tools)` (line 422): flip_and_make_identity flips as needed and sets direction matrix to identity. + - `def test_flip_and_make_identity_with_mask_sets_both_directions_to_identity(self, image_tools)` (line 438): With mask and flip_and_make_identity, both image and mask get identity direction. ## tests/test_register_images_ants.py -- **class TestRegisterImagesANTs** (line 22): Test suite for ANTs-based image registration. - - `def test_registrar_initialization(self, registrar_ants)` (line 25): Test that RegisterImagesANTs initializes correctly. - - `def test_set_modality(self, registrar_ants)` (line 33): Test setting imaging modality. - - `def test_set_fixed_image(self, registrar_ants, test_images)` (line 43): Test setting fixed image. - - `def test_register_without_mask(self, registrar_ants, test_images, test_directories)` (line 54): Test basic registration without masks. - - `def test_register_with_mask(self, registrar_ants, test_images, test_directories)` (line 103): Test registration with binary masks. - - `def test_transform_application(self, registrar_ants, test_images, test_directories)` (line 191): Test applying registration transforms to images. - - `def test_preprocess_images(self, registrar_ants, test_images)` (line 240): Test image preprocessing. - - `def test_registration_with_initial_transform(self, registrar_ants, test_images, test_directories)` (line 256): Test registration with initial transform. - - `def test_multiple_registrations(self, registrar_ants, test_images)` (line 288): Test running multiple registrations in sequence. - - `def test_transform_types(self, registrar_ants, test_images)` (line 314): Test that transforms are correct ITK types. - - `def test_image_conversion_cycle_scalar(self, registrar_ants, test_images)` (line 340): Test round-trip conversion: ITK image -> ANTs -> ITK for scalar images. - - `def test_image_conversion_cycle_different_dtypes(self, registrar_ants, test_images)` (line 414): Test round-trip conversion with different data types. - - `def test_image_conversion_preserves_metadata(self, registrar_ants)` (line 444): Test that image conversion preserves all metadata. - - `def test_transform_conversion_cycle_affine(self, registrar_ants, test_images)` (line 489): Test round-trip conversion: ITK affine transform -> ANTs -> ITK. - - `def test_transform_conversion_cycle_displacement_field(self, registrar_ants, test_images)` (line 593): Test round-trip conversion: ITK displacement field -> ANTs -> ITK. - - `def test_transform_conversion_with_composite(self, registrar_ants, test_images)` (line 677): Test conversion of composite transforms. +- **class TestRegisterImagesANTs** (line 25): Test suite for ANTs-based image registration. + - `def test_registrar_initialization(self, registrar_ants)` (line 28): Test that RegisterImagesANTs initializes correctly. + - `def test_set_modality(self, registrar_ants)` (line 36): Test setting imaging modality. + - `def test_set_fixed_image(self, registrar_ants, test_images)` (line 46): Test setting fixed image. + - `def test_register_without_mask(self, registrar_ants, test_images, test_directories)` (line 59): Test basic registration without masks. + - `def test_register_with_mask(self, registrar_ants, test_images, test_directories)` (line 113): Test registration with binary masks. + - `def test_transform_application(self, registrar_ants, test_images, test_directories)` (line 206): Test applying registration transforms to images. + - `def test_preprocess_images(self, registrar_ants, test_images)` (line 260): Test image preprocessing. + - `def test_registration_with_initial_transform(self, registrar_ants, test_images, test_directories)` (line 278): Test registration with initial transform. + - `def test_multiple_registrations(self, registrar_ants, test_images)` (line 313): Test running multiple registrations in sequence. + - `def test_transform_types(self, registrar_ants, test_images)` (line 341): Test that transforms are correct ITK types. + - `def test_image_conversion_cycle_scalar(self, registrar_ants, test_images)` (line 369): Test round-trip conversion: ITK image -> ANTs -> ITK for scalar images. + - `def test_image_conversion_cycle_different_dtypes(self, registrar_ants, test_images)` (line 445): Test round-trip conversion with different data types. + - `def test_image_conversion_preserves_metadata(self, registrar_ants)` (line 477): Test that image conversion preserves all metadata. + - `def test_transform_conversion_cycle_affine(self, registrar_ants, test_images)` (line 524): Test round-trip conversion: ITK affine transform -> ANTs -> ITK. + - `def test_transform_conversion_cycle_displacement_field(self, registrar_ants, test_images)` (line 630): Test round-trip conversion: ITK displacement field -> ANTs -> ITK. + - `def test_transform_conversion_with_composite(self, registrar_ants, test_images)` (line 714): Test conversion of composite transforms. ## tests/test_register_images_greedy.py -- **class TestRegisterImagesGreedy** (line 18): Test suite for Greedy-based image registration. - - `def test_registrar_initialization(self, registrar_greedy)` (line 21): Test that RegisterImagesGreedy initializes correctly. - - `def test_set_modality(self, registrar_greedy)` (line 29): Test setting imaging modality. - - `def test_set_transform_type_and_metric(self, registrar_greedy)` (line 39): Test setting transform type and metric. - - `def test_set_fixed_image(self, registrar_greedy, test_images)` (line 64): Test setting fixed image. - - `def test_register_affine_without_mask(self, registrar_greedy, test_images, test_directories)` (line 73): Test affine registration without masks. - - `def test_register_affine_with_mask(self, registrar_greedy, test_images, test_directories)` (line 115): Test affine registration with binary masks. - - `def test_transform_application(self, registrar_greedy, test_images, test_directories)` (line 172): Test applying registration transform to moving image. +- **class TestRegisterImagesGreedy** (line 22): Test suite for Greedy-based image registration. + - `def test_registrar_initialization(self, registrar_greedy)` (line 25): Test that RegisterImagesGreedy initializes correctly. + - `def test_set_modality(self, registrar_greedy)` (line 35): Test setting imaging modality. + - `def test_set_transform_type_and_metric(self, registrar_greedy)` (line 45): Test setting transform type and metric. + - `def test_set_fixed_image(self, registrar_greedy, test_images)` (line 72): Test setting fixed image. + - `def test_register_affine_without_mask(self, registrar_greedy, test_images, test_directories)` (line 83): Test affine registration without masks. + - `def test_register_affine_with_mask(self, registrar_greedy, test_images, test_directories)` (line 128): Test affine registration with binary masks. + - `def test_transform_application(self, registrar_greedy, test_images, test_directories)` (line 188): Test applying registration transform to moving image. ## tests/test_register_images_icon.py -- **class TestRegisterImagesICON** (line 19): Test suite for ICON-based image registration. - - `def test_registrar_initialization(self, registrar_icon)` (line 22): Test that RegisterImagesICON initializes correctly. - - `def test_set_modality(self, registrar_icon)` (line 35): Test setting imaging modality. - - `def test_set_number_of_iterations(self, registrar_icon)` (line 45): Test setting number of iterations. - - `def test_set_fixed_image(self, registrar_icon, test_images)` (line 57): Test setting fixed image. - - `def test_set_mass_preservation(self, registrar_icon)` (line 68): Test setting mass preservation flag. - - `def test_set_multi_modality(self, registrar_icon)` (line 80): Test setting multi-modality flag. - - `def test_register_without_mask(self, registrar_icon, test_images, test_directories)` (line 90): Test basic ICON registration without masks. - - `def test_register_with_mask(self, registrar_icon, test_images, test_directories)` (line 140): Test ICON registration with binary masks. - - `def test_transform_application(self, registrar_icon, test_images, test_directories)` (line 229): Test applying ICON registration transforms to images. - - `def test_inverse_consistency(self, registrar_icon, test_images)` (line 278): Test ICON's inverse consistency property. - - `def test_preprocess_images(self, registrar_icon, test_images)` (line 322): Test image preprocessing for ICON. - - `def test_registration_with_initial_transform(self, registrar_icon, test_images, test_directories)` (line 338): Test ICON registration with initial transform. - - `def test_transform_types(self, registrar_icon, test_images)` (line 374): Test that ICON transforms are correct ITK types. - - `def test_different_iteration_counts(self, registrar_icon, test_images)` (line 413): Test ICON with different iteration counts. +- **class TestRegisterImagesICON** (line 23): Test suite for ICON-based image registration. + - `def test_registrar_initialization(self, registrar_icon)` (line 26): Test that RegisterImagesICON initializes correctly. + - `def test_set_modality(self, registrar_icon)` (line 39): Test setting imaging modality. + - `def test_set_number_of_iterations(self, registrar_icon)` (line 49): Test setting number of iterations. + - `def test_set_fixed_image(self, registrar_icon, test_images)` (line 61): Test setting fixed image. + - `def test_set_mass_preservation(self, registrar_icon)` (line 74): Test setting mass preservation flag. + - `def test_set_multi_modality(self, registrar_icon)` (line 86): Test setting multi-modality flag. + - `def test_register_without_mask(self, registrar_icon, test_images, test_directories)` (line 96): Test basic ICON registration without masks. + - `def test_register_with_mask(self, registrar_icon, test_images, test_directories)` (line 151): Test ICON registration with binary masks. + - `def test_transform_application(self, registrar_icon, test_images, test_directories)` (line 245): Test applying ICON registration transforms to images. + - `def test_inverse_consistency(self, registrar_icon, test_images)` (line 299): Test ICON's inverse consistency property. + - `def test_preprocess_images(self, registrar_icon, test_images)` (line 345): Test image preprocessing for ICON. + - `def test_registration_with_initial_transform(self, registrar_icon, test_images, test_directories)` (line 363): Test ICON registration with initial transform. + - `def test_transform_types(self, registrar_icon, test_images)` (line 399): Test that ICON transforms are correct ITK types. + - `def test_different_iteration_counts(self, registrar_icon, test_images)` (line 440): Test ICON with different iteration counts. ## tests/test_register_time_series_images.py -- **class TestRegisterTimeSeriesImages** (line 20): Test suite for time series image registration. - - `def test_registrar_initialization_ants(self)` (line 23): Test that RegisterTimeSeriesImages initializes correctly with ANTs. - - `def test_registrar_initialization_icon(self)` (line 37): Test that RegisterTimeSeriesImages initializes correctly with ICON. - - `def test_registrar_initialization_invalid_method(self)` (line 51): Test that invalid registration method raises error. - - `def test_set_modality(self)` (line 58): Test setting imaging modality. - - `def test_set_fixed_image(self, test_images)` (line 66): Test setting fixed image. - - `def test_set_number_of_iterations(self)` (line 77): Test setting number of iterations. - - `def test_register_time_series_basic(self, test_images, test_directories)` (line 97): Test basic time series registration without prior transform. - - `def test_register_time_series_with_prior(self, test_images, test_directories)` (line 179): Test time series registration with prior transform usage. - - `def test_register_time_series_identity_start(self, test_images)` (line 241): Test time series registration with identity for starting image. - - `def test_register_time_series_different_starting_indices(self, test_images)` (line 267): Test time series registration with different starting indices. - - `def test_register_time_series_error_no_fixed_image(self)` (line 295): Test that error is raised if fixed image not set. - - `def test_register_time_series_error_invalid_starting_index(self, test_images)` (line 306): Test that error is raised for invalid starting index. - - `def test_register_time_series_error_invalid_prior_portion(self, test_images)` (line 327): Test that error is raised for invalid prior portion value. - - `def test_transform_application_time_series(self, test_images, test_directories)` (line 350): Test applying transforms from time series registration. - - `def test_register_time_series_icon(self, test_images)` (line 404): Test time series registration with ICON method. - - `def test_register_time_series_with_mask(self, test_images, test_directories)` (line 429): Test time series registration with fixed image mask. - - `def test_bidirectional_registration(self, test_images)` (line 472): Test that bidirectional registration works correctly. +- **class TestRegisterTimeSeriesImages** (line 25): Test suite for time series image registration. + - `def test_registrar_initialization_ants(self)` (line 30): Test that RegisterTimeSeriesImages initializes correctly with ANTs. + - `def test_registrar_initialization_icon(self)` (line 44): Test that RegisterTimeSeriesImages initializes correctly with ICON. + - `def test_registrar_initialization_invalid_method(self)` (line 58): Test that invalid registration method raises error. + - `def test_set_modality(self)` (line 65): Test setting imaging modality. + - `def test_set_fixed_image(self, test_images)` (line 73): Test setting fixed image. + - `def test_set_number_of_iterations(self)` (line 84): Test setting number of iterations. + - `def test_register_time_series_basic(self, test_images, test_directories)` (line 104): Test basic time series registration without prior transform. + - `def test_register_time_series_with_prior(self, test_images, test_directories)` (line 186): Test time series registration with prior transform usage. + - `def test_register_time_series_identity_start(self, test_images)` (line 247): Test time series registration with identity for starting image. + - `def test_register_time_series_different_starting_indices(self, test_images)` (line 273): Test time series registration with different starting indices. + - `def test_register_time_series_error_no_fixed_image(self)` (line 303): Test that error is raised if fixed image not set. + - `def test_register_time_series_error_invalid_starting_index(self, test_images)` (line 314): Test that error is raised for invalid starting index. + - `def test_register_time_series_error_invalid_prior_portion(self, test_images)` (line 337): Test that error is raised for invalid prior portion value. + - `def test_transform_application_time_series(self, test_images, test_directories)` (line 362): Test applying transforms from time series registration. + - `def test_register_time_series_icon(self, test_images)` (line 414): Test time series registration with ICON method. + - `def test_register_time_series_with_mask(self, test_images, test_directories)` (line 439): Test time series registration with fixed image mask. + - `def test_bidirectional_registration(self, test_images)` (line 484): Test that bidirectional registration works correctly. ## tests/test_segment_chest_total_segmentator.py -- **class TestSegmentChestTotalSegmentator** (line 16): Test suite for TotalSegmentator chest CT segmentation. - - `def test_segmenter_initialization(self, segmenter_total_segmentator)` (line 19): Test that SegmentChestTotalSegmentator initializes correctly. - - `def test_segment_single_image(self, segmenter_total_segmentator, test_images, test_directories)` (line 54): Test segmentation on a single time point. - - `def test_segment_multiple_images(self, segmenter_total_segmentator, test_images, test_directories)` (line 109): Test segmentation on two time points. - - `def test_anatomy_group_masks(self, segmenter_total_segmentator, test_images)` (line 137): Test that anatomy group masks are created correctly. - - `def test_contrast_detection(self, segmenter_total_segmentator, test_images)` (line 177): Test contrast detection functionality. - - `def test_preprocessing(self, segmenter_total_segmentator, test_images)` (line 204): Test preprocessing functionality. - - `def test_postprocessing(self, segmenter_total_segmentator, test_images)` (line 224): Test postprocessing functionality. - -## tests/test_segment_chest_vista_3d.py - -- **class TestSegmentChestVista3D** (line 16): Test suite for VISTA-3D chest CT segmentation. - - `def test_segmenter_initialization(self, segmenter_vista_3d)` (line 19): Test that SegmentChestVista3D initializes correctly. - - `def test_segment_single_image(self, segmenter_vista_3d, test_images, test_directories)` (line 51): Test automatic segmentation on a single time point. - - `def test_segment_multiple_images(self, segmenter_vista_3d, test_images, test_directories)` (line 107): Test automatic segmentation on two time points. - - `def test_anatomy_group_masks(self, segmenter_vista_3d, test_images)` (line 138): Test that anatomy group masks are created correctly. - - `def test_label_prompt_segmentation(self, segmenter_vista_3d, test_images, test_directories)` (line 177): Test segmentation with specific label prompts. - - `def test_contrast_detection(self, segmenter_vista_3d, test_images)` (line 213): Test contrast detection functionality. - - `def test_preprocessing(self, segmenter_vista_3d, test_images)` (line 241): Test preprocessing functionality. - - `def test_postprocessing(self, segmenter_vista_3d, test_images)` (line 260): Test postprocessing functionality. - - `def test_set_and_reset_prompts(self, segmenter_vista_3d)` (line 288): Test setting and resetting label prompt mode. +- **class TestSegmentChestTotalSegmentator** (line 21): Test suite for TotalSegmentator chest CT segmentation. + - `def test_segmenter_initialization(self, segmenter_total_segmentator)` (line 24): Test that SegmentChestTotalSegmentator initializes correctly. + - `def test_segment_single_image(self, segmenter_total_segmentator, test_images, test_directories)` (line 61): Test segmentation on a single time point. + - `def test_segment_multiple_images(self, segmenter_total_segmentator, test_images, test_directories)` (line 119): Test segmentation on two time points. + - `def test_anatomy_group_masks(self, segmenter_total_segmentator, test_images)` (line 150): Test that anatomy group masks are created correctly. + - `def test_contrast_detection(self, segmenter_total_segmentator, test_images)` (line 194): Test contrast detection functionality. + - `def test_preprocessing(self, segmenter_total_segmentator, test_images)` (line 225): Test preprocessing functionality. + - `def test_postprocessing(self, segmenter_total_segmentator, test_images)` (line 249): Test postprocessing functionality. ## tests/test_segment_heart_simpleware.py -- **class TestSegmentHeartSimpleware** (line 28): Test suite for SegmentHeartSimpleware (Simpleware Medical ASCardio). - - `def test_segmenter_initialization(self, segmenter_simpleware)` (line 31): Test that SegmentHeartSimpleware initializes correctly. - - `def test_set_simpleware_executable_path(self, segmenter_simpleware)` (line 55): Test setting custom Simpleware executable path. - - `def test_segment_single_image(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path, test_directories)` (line 66): Test segmentation on the same cardiac CT as the notebook (RVOT28-Dias.nii.gz). - - `def test_anatomy_group_masks(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 125): Test that anatomy group masks are created (heart, vessels, etc.). - - `def test_contrast_detection(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 160): Test contrast mask is returned (base class behavior). - - `def test_postprocessing(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 174): Test that output labelmap matches input size and spacing. +- **class TestSegmentHeartSimpleware** (line 32): Test suite for SegmentHeartSimpleware (Simpleware Medical ASCardio). + - `def test_segmenter_initialization(self, segmenter_simpleware)` (line 35): Test that SegmentHeartSimpleware initializes correctly. + - `def test_set_simpleware_executable_path(self, segmenter_simpleware)` (line 61): Test setting custom Simpleware executable path. + - `def test_segment_single_image(self, segmenter_simpleware, test_images, test_directories)` (line 74): Test segmentation on a cardiac CT time point. + - `def test_anatomy_group_masks(self, segmenter_simpleware, test_images)` (line 129): Test that anatomy group masks are created (heart, vessels, etc.). + - `def test_contrast_detection(self, segmenter_simpleware, test_images)` (line 166): Test contrast mask is returned (base class behavior). + - `def test_postprocessing(self, segmenter_simpleware, test_images)` (line 182): Test that output labelmap matches input size and spacing. ## tests/test_transform_tools.py -- **class TestTransformTools** (line 20): Test suite for TransformTools functionality. - - `def test_contour(self, test_images)` (line 24): Create a simple test contour mesh. - - `def test_transform_tools_initialization(self, transform_tools)` (line 30): Test that TransformTools initializes correctly. - - `def test_transform_image_linear(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 35): Test transforming image with linear interpolation. - - `def test_transform_image_nearest(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 71): Test transforming image with nearest neighbor interpolation. - - `def test_transform_image_sinc(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 101): Test transforming image with sinc interpolation. - - `def test_transform_image_invalid_method(self, transform_tools, ants_registration_results, test_images)` (line 131): Test that invalid interpolation method raises error. - - `def test_transform_pvcontour_without_deformation(self, transform_tools, test_contour, ants_registration_results)` (line 151): Test transforming PyVista contour without deformation magnitude. - - `def test_transform_pvcontour_with_deformation(self, transform_tools, test_contour, ants_registration_results, test_directories)` (line 183): Test transforming PyVista contour with deformation magnitude. - - `def test_convert_transform_to_displacement_field(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 222): Test converting transform to deformation field image. - - `def test_convert_vtk_matrix_to_itk_transform(self, transform_tools)` (line 259): Test converting VTK matrix to ITK transform. - - `def test_compute_jacobian_determinant_from_field(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 289): Test computing Jacobian determinant from deformation field. - - `def test_detect_folding_in_field(self, transform_tools, ants_registration_results, test_images)` (line 336): Test detecting spatial folding in deformation field. - - `def test_interpolate_transforms(self, transform_tools, ants_registration_results, test_images)` (line 363): Test temporal interpolation between transforms. - - `def test_combine_displacement_field_transforms(self, transform_tools, ants_registration_results, test_images)` (line 397): Test composing two transforms with various weights. - - `def test_smooth_transform(self, transform_tools, ants_registration_results, test_images)` (line 508): Test smoothing a transform. - - `def test_combine_transforms_with_masks(self, transform_tools, ants_registration_results, test_images)` (line 531): Test combining transforms with spatial masks. - - `def test_multiple_transform_applications(self, transform_tools, ants_registration_results, test_images)` (line 575): Test applying multiple transforms in sequence. - - `def test_identity_transform(self, transform_tools, test_images)` (line 600): Test that identity transform doesn't change the image. +- **class TestTransformTools** (line 24): Test suite for TransformTools functionality. + - `def test_contour(self, test_images)` (line 28): Create a simple test contour mesh. + - `def test_transform_tools_initialization(self, transform_tools)` (line 34): Test that TransformTools initializes correctly. + - `def test_transform_image_linear(self, transform_tools, test_transforms, test_images, test_directories)` (line 41): Test transforming image with linear interpolation. + - `def test_transform_image_nearest(self, transform_tools, test_transforms, test_images, test_directories)` (line 81): Test transforming image with nearest neighbor interpolation. + - `def test_transform_image_sinc(self, transform_tools, test_transforms, test_images, test_directories)` (line 115): Test transforming image with sinc interpolation. + - `def test_transform_image_invalid_method(self, transform_tools, test_transforms, test_images)` (line 149): Test that invalid interpolation method raises error. + - `def test_transform_pvcontour_without_deformation(self, transform_tools, test_contour, test_transforms)` (line 172): Test transforming PyVista contour without deformation magnitude. + - `def test_transform_pvcontour_with_deformation(self, transform_tools, test_contour, test_transforms, test_directories)` (line 207): Test transforming PyVista contour with deformation magnitude. + - `def test_convert_transform_to_displacement_field(self, transform_tools, test_transforms, test_images, test_directories)` (line 250): Test converting transform to deformation field image. + - `def test_convert_vtk_matrix_to_itk_transform(self, transform_tools)` (line 291): Test converting VTK matrix to ITK transform. + - `def test_compute_jacobian_determinant_from_field(self, transform_tools, test_transforms, test_images, test_directories)` (line 323): Test computing Jacobian determinant from deformation field. + - `def test_detect_folding_in_field(self, transform_tools, test_transforms, test_images)` (line 374): Test detecting spatial folding in deformation field. + - `def test_interpolate_transforms(self, transform_tools, test_transforms, test_images)` (line 404): Test temporal interpolation between transforms. + - `def test_combine_displacement_field_transforms(self, transform_tools, test_transforms, test_images)` (line 441): Test composing two transforms with various weights. + - `def test_smooth_transform(self, transform_tools, test_transforms, test_images)` (line 555): Test smoothing a transform. + - `def test_combine_transforms_with_masks(self, transform_tools, test_transforms, test_images)` (line 581): Test combining transforms with spatial masks. + - `def test_multiple_transform_applications(self, transform_tools, test_transforms, test_images)` (line 628): Test applying multiple transforms in sequence. + - `def test_identity_transform(self, transform_tools, test_images)` (line 656): Test that identity transform doesn't change the image. ## tests/test_usd_merge.py -- `def analyze_usd_file(filepath)` (line 16): Analyze a USD file for materials and time samples. -- **class TestUSDMerge** (line 72): Test suite for USD file merging. - - `def test_data_files(self)` (line 76): Locate test USD files with materials and time-varying data. - - `def output_dir(self, tmp_path_factory)` (line 91): Create temporary output directory for test results. - - `def input_stats(self, test_data_files)` (line 97): Analyze input USD files. - - `def test_merge_usd_files_copy_method(self, test_data_files, input_stats, output_dir)` (line 103): Test merge_usd_files() manual copy method. - - `def test_merge_usd_files_flattened_method(self, test_data_files, input_stats, output_dir)` (line 161): Test merge_usd_files_flattened() composition method. - - `def test_both_methods_produce_equivalent_results(self, test_data_files, output_dir)` (line 219): Verify both merge methods produce equivalent results. +- `def analyze_usd_file(filepath)` (line 17): Analyze a USD file for materials and time samples. +- **class TestUSDMerge** (line 73): Test suite for USD file merging. + - `def test_data_files(self)` (line 77): Locate test USD files with materials and time-varying data. + - `def output_dir(self, tmp_path_factory)` (line 92): Create temporary output directory for test results. + - `def input_stats(self, test_data_files)` (line 98): Analyze input USD files. + - `def test_merge_usd_files_copy_method(self, test_data_files, input_stats, output_dir)` (line 104): Test merge_usd_files() manual copy method. + - `def test_merge_usd_files_flattened_method(self, test_data_files, input_stats, output_dir)` (line 165): Test merge_usd_files_flattened() composition method. + - `def test_both_methods_produce_equivalent_results(self, test_data_files, output_dir)` (line 226): Verify both merge methods produce equivalent results. ## tests/test_usd_time_preservation.py -- `def get_time_metadata(filepath)` (line 16): Extract time metadata from a USD file. -- `def get_mesh_time_samples(filepath, mesh_name='inferior_vena_cava')` (line 40): Get time sample data for a specific mesh in a USD file. -- **class TestUSDTimePreservation** (line 84): Test suite for USD time-varying data preservation. - - `def test_data_files(self)` (line 88): Locate test USD files with time-varying data. - - `def output_dir(self, tmp_path_factory)` (line 103): Create temporary output directory for test results. - - `def source_metadata(self, test_data_files)` (line 109): Get time metadata from source file. - - `def source_time_samples(self, test_data_files)` (line 114): Get time sample data from source file. - - `def test_merge_copy_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 118): Test that merge_usd_files() preserves time metadata. - - `def test_merge_flattened_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 147): Test that merge_usd_files_flattened() preserves time metadata. - - `def test_merge_copy_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 176): Test that merge_usd_files() preserves actual time sample data. - - `def test_merge_flattened_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 214): Test that merge_usd_files_flattened() preserves actual time sample data. - - `def test_animation_range_matches_actual_motion(self, test_data_files, source_time_samples, output_dir)` (line 252): Test that the full animation range is accessible. +- `def get_time_metadata(filepath)` (line 17): Extract time metadata from a USD file. +- `def get_mesh_time_samples(filepath, mesh_name='inferior_vena_cava')` (line 41): Get time sample data for a specific mesh in a USD file. +- **class TestUSDTimePreservation** (line 88): Test suite for USD time-varying data preservation. + - `def test_data_files(self)` (line 92): Locate test USD files with time-varying data. + - `def output_dir(self, tmp_path_factory)` (line 107): Create temporary output directory for test results. + - `def source_metadata(self, test_data_files)` (line 113): Get time metadata from source file. + - `def source_time_samples(self, test_data_files)` (line 118): Get time sample data from source file. + - `def test_merge_copy_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 124): Test that merge_usd_files() preserves time metadata. + - `def test_merge_flattened_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 156): Test that merge_usd_files_flattened() preserves time metadata. + - `def test_merge_copy_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 188): Test that merge_usd_files() preserves actual time sample data. + - `def test_merge_flattened_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 229): Test that merge_usd_files_flattened() preserves actual time sample data. + - `def test_animation_range_matches_actual_motion(self, test_data_files, source_time_samples, output_dir)` (line 270): Test that the full animation range is accessible. ## tests/test_vtk_to_usd_library.py @@ -812,28 +716,28 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def check_kcl_heart_data()` (line 42): Check if KCL Heart Model data is available. - `def check_valve4d_data()` (line 49): Check if CHOP Valve4D data is available. - `def get_or_create_average_surface(test_directories)` (line 56): Get or create average_surface.vtp from average_mesh.vtk. -- `def kcl_average_surface(test_directories)` (line 100): Fixture providing the KCL average heart surface. -- **class TestGenericArray** (line 116): Test GenericArray data structure validation and reshaping. - - `def test_scalar_1d_array(self)` (line 119): Test that 1D scalar arrays (num_components=1) are kept as-is. - - `def test_flat_multicomponent_array_reshape(self)` (line 132): Test that flat 1D arrays with num_components>1 are reshaped to 2D. - - `def test_2d_array_valid(self)` (line 148): Test that 2D arrays with correct shape are accepted. - - `def test_flat_array_not_divisible_raises_error(self)` (line 161): Test that flat arrays with length not divisible by num_components raise error. - - `def test_2d_array_wrong_shape_raises_error(self)` (line 172): Test that 2D arrays with wrong shape raise error. - - `def test_3d_array_raises_error(self)` (line 183): Test that 3D arrays are rejected. - - `def test_flat_array_large_components(self)` (line 194): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). -- **class TestVTKReader** (line 210): Test VTK file reading capabilities. - - `def test_read_vtp_file(self, kcl_average_surface)` (line 213): Test reading VTP (PolyData) files. - - `def test_read_legacy_vtk_file(self)` (line 234): Test reading legacy VTK files. - - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 261): Test that generic data arrays are preserved during reading. -- **class TestVTKToUSDConversion** (line 285): Test VTK to USD conversion capabilities. - - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 288): Test converting a single VTK file to USD. - - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 328): Test conversion with custom material. - - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 373): Test conversion with custom settings. - - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 406): Test that VTK data arrays are preserved as USD primvars. -- **class TestTimeSeriesConversion** (line 444): Test time-series conversion capabilities. - - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 447): Test converting multiple VTK files as time series. -- **class TestIntegration** (line 495): Integration tests combining multiple features. - - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 498): Test complete conversion workflow with all features. +- `def kcl_average_surface(test_directories)` (line 102): Fixture providing the KCL average heart surface. +- **class TestGenericArray** (line 118): Test GenericArray data structure validation and reshaping. + - `def test_scalar_1d_array(self)` (line 121): Test that 1D scalar arrays (num_components=1) are kept as-is. + - `def test_flat_multicomponent_array_reshape(self)` (line 134): Test that flat 1D arrays with num_components>1 are reshaped to 2D. + - `def test_2d_array_valid(self)` (line 150): Test that 2D arrays with correct shape are accepted. + - `def test_flat_array_not_divisible_raises_error(self)` (line 163): Test that flat arrays with length not divisible by num_components raise error. + - `def test_2d_array_wrong_shape_raises_error(self)` (line 174): Test that 2D arrays with wrong shape raise error. + - `def test_3d_array_raises_error(self)` (line 185): Test that 3D arrays are rejected. + - `def test_flat_array_large_components(self)` (line 196): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). +- **class TestVTKReader** (line 212): Test VTK file reading capabilities. + - `def test_read_vtp_file(self, kcl_average_surface)` (line 215): Test reading VTP (PolyData) files. + - `def test_read_legacy_vtk_file(self)` (line 236): Test reading legacy VTK files. + - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 263): Test that generic data arrays are preserved during reading. +- **class TestVTKToUSDConversion** (line 287): Test VTK to USD conversion capabilities. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 290): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 332): Test conversion with custom material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 379): Test conversion with custom settings. + - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 414): Test that VTK data arrays are preserved as USD primvars. +- **class TestTimeSeriesConversion** (line 454): Test time-series conversion capabilities. + - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 457): Test converting multiple VTK files as time series. +- **class TestIntegration** (line 507): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 510): Test complete conversion workflow with all features. ## utils/claude_github_reviews.py diff --git a/docs/README.md b/docs/README.md index 7028656..0c7fbb4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,13 +70,10 @@ docs/ │ ā”œā”€ā”€ index.rst # Main API hub │ ā”œā”€ā”€ base.rst # Core base class │ ā”œā”€ā”€ workflows.rst # Workflow classes -│ ā”œā”€ā”€ segmentation/ # Segmentation (6 files) +│ ā”œā”€ā”€ segmentation/ # Segmentation (3 files) │ │ ā”œā”€ā”€ index.rst │ │ ā”œā”€ā”€ base.rst -│ │ ā”œā”€ā”€ totalsegmentator.rst -│ │ ā”œā”€ā”€ vista3d.rst -│ │ ā”œā”€ā”€ vista3d_nim.rst -│ │ └── ensemble.rst +│ │ └── totalsegmentator.rst │ ā”œā”€ā”€ registration/ # Image registration (5 files) │ │ ā”œā”€ā”€ index.rst │ │ ā”œā”€ā”€ base.rst diff --git a/docs/api/index.rst b/docs/api/index.rst index 4ff8f53..dd70f8e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -20,9 +20,6 @@ This section provides detailed documentation for all PhysioMotion4D classes, fun segmentation/index segmentation/base segmentation/totalsegmentator - segmentation/vista3d - segmentation/vista3d_nim - segmentation/ensemble .. toctree:: :maxdepth: 2 @@ -82,9 +79,7 @@ By Category **Segmentation** * :class:`~physiomotion4d.SegmentAnatomyBase` - Base segmentation class * :class:`~physiomotion4d.SegmentChestTotalSegmentator` - TotalSegmentator - * :class:`~physiomotion4d.SegmentChestVista3D` - VISTA-3D model - * :class:`~physiomotion4d.SegmentChestVista3DNIM` - VISTA-3D NIM - * :class:`~physiomotion4d.SegmentChestEnsemble` - Ensemble segmentation + * :class:`~physiomotion4d.SegmentHeartSimpleware` - Simpleware cardiac segmentation **Image Registration** * :class:`~physiomotion4d.RegisterImagesBase` - Base registration class diff --git a/docs/api/registration/index.rst b/docs/api/registration/index.rst index 8b2ee8b..c0bf8c3 100644 --- a/docs/api/registration/index.rst +++ b/docs/api/registration/index.rst @@ -116,4 +116,4 @@ See Also .. rubric:: Navigation -:doc:`../segmentation/ensemble` | :doc:`../index` | :doc:`base` +:doc:`../segmentation/index` | :doc:`../index` | :doc:`base` diff --git a/docs/api/segmentation/base.rst b/docs/api/segmentation/base.rst index 895dd90..f2bfa3d 100644 --- a/docs/api/segmentation/base.rst +++ b/docs/api/segmentation/base.rst @@ -347,9 +347,8 @@ See Also * :doc:`index` - Segmentation module overview * :doc:`totalsegmentator` - TotalSegmentator implementation -* :doc:`vista3d` - VISTA-3D implementation * :doc:`../../developer/extending` - Extending PhysioMotion4D .. rubric:: Navigation -:doc:`index` | :doc:`totalsegmentator` | :doc:`vista3d` +:doc:`index` | :doc:`totalsegmentator` diff --git a/docs/api/segmentation/ensemble.rst b/docs/api/segmentation/ensemble.rst deleted file mode 100644 index a9eecde..0000000 --- a/docs/api/segmentation/ensemble.rst +++ /dev/null @@ -1,76 +0,0 @@ -==================================== -Ensemble Segmentation -==================================== - -.. currentmodule:: physiomotion4d - -Combine multiple segmentation methods for improved accuracy and robustness. - -Class Reference -=============== - -.. autoclass:: SegmentChestEnsemble - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - -Overview -======== - -Ensemble segmentation combines predictions from multiple methods using voting or averaging strategies to achieve higher accuracy than any single method. - -**Key Features**: - * Combines TotalSegmentator and VISTA-3D - * Voting or averaging fusion strategies - * Improved boundary delineation - * Higher confidence in predictions - * Better robustness to image variations - -Usage Examples -============== - -Basic Ensemble --------------- - -.. code-block:: python - - from physiomotion4d import SegmentChestEnsemble - - # Combine methods with voting - segmentator = SegmentChestEnsemble( - methods=['totalsegmentator', 'vista3d'], - fusion_strategy='voting', - verbose=True - ) - - labelmap = segmentator.segment("ct_scan.nrrd") - - # Get confidence map - confidence = segmentator.get_confidence_map() - -With Weighted Fusion --------------------- - -.. code-block:: python - - # Weight methods differently - segmentator = SegmentChestEnsemble( - methods=['totalsegmentator', 'vista3d'], - fusion_strategy='weighted', - weights=[0.4, 0.6], # Trust VISTA-3D more - verbose=True - ) - - labelmap = segmentator.segment("cardiac_ct.nrrd") - -See Also -======== - -* :doc:`index` - Segmentation overview -* :doc:`totalsegmentator` - Fast baseline method -* :doc:`vista3d` - High-accuracy method - -.. rubric:: Navigation - -:doc:`vista3d_nim` | :doc:`index` | :doc:`../registration/index` diff --git a/docs/api/segmentation/index.rst b/docs/api/segmentation/index.rst index d326ef1..b621c81 100644 --- a/docs/api/segmentation/index.rst +++ b/docs/api/segmentation/index.rst @@ -12,9 +12,7 @@ Overview PhysioMotion4D supports multiple segmentation approaches: * **TotalSegmentator**: Whole-body CT segmentation (100+ structures) -* **VISTA-3D**: MONAI-based foundation model for medical imaging -* **VISTA-3D NIM**: NVIDIA Inference Microservice version -* **Ensemble**: Combine multiple methods for improved accuracy +* **Simpleware**: Cardiac-focused segmentation (requires Simpleware Medical) All segmentation classes inherit from :class:`SegmentAnatomyBase` and provide consistent interfaces. @@ -24,9 +22,6 @@ Quick Links **Segmentation Classes**: * :doc:`base` - Base class for all segmentation methods * :doc:`totalsegmentator` - TotalSegmentator implementation - * :doc:`vista3d` - VISTA-3D foundation model - * :doc:`vista3d_nim` - VISTA-3D NIM for cloud deployment - * :doc:`ensemble` - Ensemble segmentation Choosing a Method ================= @@ -36,11 +31,7 @@ Choosing a Method +==================+==================+==================+==================+ | TotalSegmentator | Fast (~30s) | Good | General purpose | +------------------+------------------+------------------+------------------+ -| VISTA-3D | Medium (~60s) | Excellent | Cardiac imaging | -+------------------+------------------+------------------+------------------+ -| VISTA-3D NIM | Fast (cloud) | Excellent | Production | -+------------------+------------------+------------------+------------------+ -| Ensemble | Slow (~90s) | Best | Research/QC | +| Simpleware | Medium | Excellent | Cardiac imaging | +------------------+------------------+------------------+------------------+ Quick Start @@ -52,54 +43,10 @@ Basic Segmentation .. code-block:: python from physiomotion4d import SegmentChestTotalSegmentator - - # Initialize segmentator - segmentator = SegmentChestTotalSegmentator(fast=True, verbose=True) - - # Segment image - labelmap = segmentator.segment("ct_scan.nrrd") - - # Extract specific structure - heart = segmentator.extract_structure(labelmap, "heart") - - # Save results - segmentator.save_labelmap(labelmap, "output_labels.mha") - -With VISTA-3D -------------- - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3D - - # Initialize with GPU - segmentator = SegmentChestVista3D( - device="cuda:0", - use_auto_prompts=True, - verbose=True - ) - - # Segment specific structures - labelmap = segmentator.segment( - image_path="cardiac_ct.nrrd", - structures=["heart_left_ventricle", "heart_myocardium"] - ) - -Ensemble Approach ------------------ -.. code-block:: python - - from physiomotion4d import SegmentChestEnsemble - - # Combine multiple methods - segmentator = SegmentChestEnsemble( - methods=['totalsegmentator', 'vista3d'], - fusion_strategy='voting', - verbose=True - ) - - labelmap = segmentator.segment("ct_scan.nrrd") + segmenter = SegmentChestTotalSegmentator() + result = segmenter.segment(ct_image, contrast_enhanced_study=False) + labelmap = result['labelmap'] Module Documentation ==================== @@ -109,9 +56,6 @@ Module Documentation base totalsegmentator - vista3d - vista3d_nim - ensemble Common Operations ================= @@ -123,17 +67,10 @@ Extract individual anatomical structures from segmentation results: .. code-block:: python - # Segment entire image - labelmap = segmentator.segment("ct.nrrd") - - # Extract cardiac structures - lv = segmentator.extract_structure(labelmap, "heart_left_ventricle") - rv = segmentator.extract_structure(labelmap, "heart_right_ventricle") - myocardium = segmentator.extract_structure(labelmap, "heart_myocardium") - - # Compute volumes - lv_volume = segmentator.compute_volume(lv) - print(f"LV volume: {lv_volume} mm³") + result = segmenter.segment(ct_image) + heart_mask = result['heart'] + lung_mask = result['lung'] + bone_mask = result['bone'] Batch Processing ---------------- @@ -143,53 +80,15 @@ Process multiple images efficiently: .. code-block:: python from pathlib import Path - - segmentator = SegmentChestTotalSegmentator(fast=True) - - for image_file in Path("data").glob("*.nrrd"): - print(f"Segmenting {image_file}...") - - labelmap = segmentator.segment(str(image_file)) - - output_file = f"{image_file.stem}_labels.mha" - segmentator.save_labelmap(labelmap, output_file) + import itk -Quality Control ---------------- + segmenter = SegmentChestTotalSegmentator() -Validate segmentation quality: - -.. code-block:: python - - import numpy as np - - def validate_segmentation(labelmap, ground_truth): - """Compute Dice coefficient.""" - intersection = np.logical_and(labelmap, ground_truth).sum() - dice = 2 * intersection / (labelmap.sum() + ground_truth.sum()) - return dice - - # Validate results - dice_score = validate_segmentation(labelmap, reference_labelmap) - print(f"Dice score: {dice_score:.3f}") - -Best Practices -============== - -Method Selection ----------------- - -* **TotalSegmentator**: Use for general-purpose segmentation, fast iterations -* **VISTA-3D**: Use for cardiac structures, when accuracy is critical -* **Ensemble**: Use when maximum accuracy is needed, can tolerate longer processing - -Parameter Tuning ----------------- - -* Start with default parameters -* Enable ``fast`` mode for quick prototyping -* Use GPU (``device="cuda:0"``) for VISTA-3D when available -* Adjust post-processing based on image quality + for image_file in Path("data").glob("*.nrrd"): + image = itk.imread(str(image_file)) + result = segmenter.segment(image) + labelmap = result['labelmap'] + itk.imwrite(labelmap, f"{image_file.stem}_labels.mha") Error Handling -------------- @@ -197,20 +96,9 @@ Error Handling .. code-block:: python try: - labelmap = segmentator.segment(image_path) + result = segmenter.segment(image) except RuntimeError as e: print(f"Segmentation failed: {e}") - # Fallback to alternative method - segmentator_backup = SegmentChestTotalSegmentator(fast=True) - labelmap = segmentator_backup.segment(image_path) - -Performance Tips -================ - -* Use GPU acceleration when available -* Enable fast mode for development -* Process time series in batch -* Cache segmentation results for repeated use See Also ======== diff --git a/docs/api/segmentation/totalsegmentator.rst b/docs/api/segmentation/totalsegmentator.rst index fdce36d..bb8f15d 100644 --- a/docs/api/segmentation/totalsegmentator.rst +++ b/docs/api/segmentation/totalsegmentator.rst @@ -368,7 +368,7 @@ If certain structures are not segmented: * Check input image quality (resolution, contrast, noise) * Verify proper windowing/leveling for CT * Try different preprocessing (denoising, normalization) -* Consider ensemble with VISTA-3D for better coverage +* Try alternative preprocessing (denoising, normalization) Incorrect Boundaries -------------------- @@ -376,7 +376,7 @@ Incorrect Boundaries If boundaries are imprecise: * Disable fast mode for better quality * Apply custom post-processing (smoothing, morphological operations) -* Use ensemble approach combining multiple methods +* Try Simpleware segmentation for higher-quality cardiac boundaries Performance Issues ------------------ @@ -391,10 +391,8 @@ See Also ======== * :doc:`index` - Segmentation overview -* :doc:`vista3d` - VISTA-3D for cardiac structures -* :doc:`ensemble` - Combine with other methods * :doc:`../workflows` - Use in complete workflows .. rubric:: Navigation -:doc:`base` | :doc:`index` | :doc:`vista3d` +:doc:`base` | :doc:`index` diff --git a/docs/api/segmentation/vista3d.rst b/docs/api/segmentation/vista3d.rst deleted file mode 100644 index 791c081..0000000 --- a/docs/api/segmentation/vista3d.rst +++ /dev/null @@ -1,80 +0,0 @@ -==================================== -VISTA-3D Foundation Model -==================================== - -.. currentmodule:: physiomotion4d - -MONAI-based foundation model for high-accuracy medical image segmentation. - -Class Reference -=============== - -.. autoclass:: SegmentChestVista3D - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - -Overview -======== - -VISTA-3D is a foundation model architecture that provides state-of-the-art accuracy, especially for cardiac structures. It supports interactive segmentation with point prompts and bounding boxes. - -**Key Features**: - * Foundation model trained on diverse medical datasets - * Interactive prompting with points and boxes - * Excellent accuracy on cardiac structures - * Supports automatic prompt generation - * Requires GPU for optimal performance - -Usage Examples -============== - -Basic Usage ------------ - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3D - - # Initialize with GPU - segmentator = SegmentChestVista3D( - device="cuda:0", - verbose=True - ) - - # Segment with automatic prompts - labelmap = segmentator.segment( - image_path="cardiac_ct.nrrd", - structures=["heart_left_ventricle", "heart_myocardium"] - ) - -With Manual Prompts -------------------- - -.. code-block:: python - - # Segment with point prompts - labelmap = segmentator.segment( - image_path="ct.nrrd", - point_prompts=[(128, 128, 150)], # (x, y, z) coordinates - structure_name="heart_left_ventricle" - ) - - # Or with bounding box - labelmap = segmentator.segment( - image_path="ct.nrrd", - bbox_prompt=[100, 100, 120, 150, 150, 180], # xmin,ymin,zmin,xmax,ymax,zmax - structure_name="heart" - ) - -See Also -======== - -* :doc:`index` - Segmentation overview -* :doc:`vista3d_nim` - Cloud deployment version -* :doc:`totalsegmentator` - Alternative method - -.. rubric:: Navigation - -:doc:`totalsegmentator` | :doc:`index` | :doc:`vista3d_nim` diff --git a/docs/api/segmentation/vista3d_nim.rst b/docs/api/segmentation/vista3d_nim.rst deleted file mode 100644 index 61d720a..0000000 --- a/docs/api/segmentation/vista3d_nim.rst +++ /dev/null @@ -1,60 +0,0 @@ -========================================== -VISTA-3D NIM (Inference Microservice) -========================================== - -.. currentmodule:: physiomotion4d - -NVIDIA Inference Microservice version of VISTA-3D for cloud deployment. - -Class Reference -=============== - -.. autoclass:: SegmentChestVista3DNIM - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - -Overview -======== - -VISTA-3D NIM provides optimized inference through a REST API, ideal for production deployments and scalable cloud applications. - -**Key Features**: - * Optimized for high-throughput inference - * REST API interface - * Cloud/server deployment - * Scalable for multiple concurrent requests - -Usage Examples -============== - -Basic Usage ------------ - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3DNIM - - # Initialize with API endpoint - segmentator = SegmentChestVista3DNIM( - api_endpoint="https://api.nvidia.com/nim/vista3d", - api_key="your_api_key", - verbose=True - ) - - # Segment via API - labelmap = segmentator.segment( - image_path="ct_scan.nrrd", - structures=["heart", "lungs"] - ) - -See Also -======== - -* :doc:`vista3d` - Local deployment version -* :doc:`index` - Segmentation overview - -.. rubric:: Navigation - -:doc:`vista3d` | :doc:`index` | :doc:`ensemble` diff --git a/docs/api/workflows.rst b/docs/api/workflows.rst index 5239bfd..0f648b8 100644 --- a/docs/api/workflows.rst +++ b/docs/api/workflows.rst @@ -133,7 +133,7 @@ Customize workflow parameters: output_directory="./results", # Segmentation options - segmentation_method="vista3d", # or "totalsegmentator" + segmentation_method="total_segmentator", contrast_enhanced=True, # Registration options diff --git a/docs/architecture.rst b/docs/architecture.rst index bdb21f5..778e538 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -32,7 +32,7 @@ Core Components 2. **Segmentation Module** * Base class: :class:`SegmentAnatomyBase` - * Implementations: TotalSegmentator, VISTA-3D, Ensemble + * Implementations: TotalSegmentator, Simpleware 3. **Registration Module** diff --git a/docs/cli_scripts/heart_gated_ct.rst b/docs/cli_scripts/heart_gated_ct.rst index 80152be..fb32a91 100644 --- a/docs/cli_scripts/heart_gated_ct.rst +++ b/docs/cli_scripts/heart_gated_ct.rst @@ -124,7 +124,7 @@ The script executes these steps automatically: * Selects reference image (default: 70% cardiac phase) 2. **Segmentation** - * Segments reference image using AI ensemble methods + * Segments reference image using AI segmentation (TotalSegmentator or Simpleware) * Identifies: heart chambers, myocardium, vessels, lungs, bones 3. **Registration** diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 203b77d..623cfb7 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -50,10 +50,7 @@ The package is organized into functional modules: │ ā”œā”€ā”€ Segmentation │ ā”œā”€ā”€ segment_anatomy_base.py Base segmentation - │ ā”œā”€ā”€ segment_chest_total_segmentator.py TotalSegmentator - │ ā”œā”€ā”€ segment_chest_vista_3d.py VISTA-3D - │ ā”œā”€ā”€ segment_chest_vista_3d_nim.py VISTA-3D NIM - │ └── segment_chest_ensemble.py Ensemble methods + │ └── segment_chest_total_segmentator.py TotalSegmentator │ ā”œā”€ā”€ Registration │ ā”œā”€ā”€ Image Registration @@ -99,9 +96,7 @@ Most PhysioMotion4D classes inherit from :class:`PhysioMotion4DBase`: │ └── WorkflowFitStatisticalModelToPatient ā”œā”€ā”€ Segmentation Classes │ ā”œā”€ā”€ SegmentAnatomyBase - │ │ ā”œā”€ā”€ SegmentChestTotalSegmentator - │ │ ā”œā”€ā”€ SegmentChestVista3D - │ │ └── SegmentChestEnsemble + │ │ └── SegmentChestTotalSegmentator ā”œā”€ā”€ Registration Classes │ ā”œā”€ā”€ RegisterImagesBase │ │ ā”œā”€ā”€ RegisterImagesANTs @@ -275,7 +270,7 @@ Optional Dependencies * **ANTsPy**: ANTs registration (alternative to Icon) * **TotalSegmentator**: AI segmentation backend -* **MONAI**: Medical imaging AI framework (VISTA-3D) +* **MONAI**: Medical imaging AI framework * **CuPy**: GPU-accelerated array operations See ``pyproject.toml`` for complete dependency list. @@ -287,7 +282,7 @@ GPU Acceleration ---------------- * Registration (Icon): 5-10x speedup with GPU -* Segmentation (VISTA-3D): Requires GPU +* Segmentation: Requires GPU for best performance * Automatic GPU detection and fallback to CPU Memory Management diff --git a/docs/developer/segmentation.rst b/docs/developer/segmentation.rst index 656cd00..3c1a980 100644 --- a/docs/developer/segmentation.rst +++ b/docs/developer/segmentation.rst @@ -17,9 +17,7 @@ Overview PhysioMotion4D supports multiple segmentation approaches: * **TotalSegmentator**: Whole-body CT segmentation (100+ structures) -* **VISTA-3D**: MONAI-based foundation model for medical imaging -* **VISTA-3D NIM**: NVIDIA Inference Microservice version -* **Ensemble**: Combine multiple methods for improved accuracy +* **Simpleware**: High-quality cardiac segmentation via Synopsys Simpleware Medical All segmentation classes inherit from :class:`SegmentAnatomyBase` and provide consistent interfaces. @@ -93,118 +91,6 @@ Uses the TotalSegmentator model for comprehensive anatomical segmentation. * Liver, kidneys, spleen * And many more... -VISTA-3D --------- - -MONAI-based foundation model for medical image segmentation. - -.. autoclass:: physiomotion4d.SegmentChestVista3D - :members: - :undoc-members: - :show-inheritance: - -**Features**: - * Foundation model architecture - * Supports point prompts and bounding boxes - * High accuracy on cardiac structures - * Requires GPU - -**Example Usage**: - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3D - - # Initialize with GPU - segmentator = SegmentChestVista3D( - device="cuda:0", - use_auto_prompts=True, - verbose=True - ) - - # Segment with automatic prompts - labelmap = segmentator.segment( - image_path="cardiac_ct.nrrd", - structures=["heart_left_ventricle", "heart_myocardium"] - ) - - # Or provide manual prompts - labelmap = segmentator.segment( - image_path="cardiac_ct.nrrd", - point_prompts=[(128, 128, 150)], # (x, y, z) - structure_name="heart_left_ventricle" - ) - -VISTA-3D NIM ------------- - -NVIDIA Inference Microservice version for cloud deployment. - -.. autoclass:: physiomotion4d.SegmentChestVista3DNIM - :members: - :undoc-members: - :show-inheritance: - -**Features**: - * Optimized inference - * Cloud/server deployment - * REST API interface - * Scalable for multiple requests - -**Example Usage**: - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3DNIM - - # Initialize with API endpoint - segmentator = SegmentChestVista3DNIM( - api_endpoint="https://api.nvidia.com/nim/vista3d", - api_key="your_api_key", - verbose=True - ) - - # Segment via API - labelmap = segmentator.segment( - image_path="ct_scan.nrrd", - structures=["heart", "lungs"] - ) - -Ensemble Segmentation ---------------------- - -Combines multiple segmentation methods for improved accuracy. - -.. autoclass:: physiomotion4d.SegmentChestEnsemble - :members: - :undoc-members: - :show-inheritance: - -**Features**: - * Combines TotalSegmentator + VISTA-3D - * Voting or averaging strategies - * Improved robustness - * Better boundary delineation - -**Example Usage**: - -.. code-block:: python - - from physiomotion4d import SegmentChestEnsemble - - # Initialize ensemble - segmentator = SegmentChestEnsemble( - methods=['totalsegmentator', 'vista3d'], - fusion_strategy='voting', # or 'averaging' - verbose=True - ) - - # Segment with ensemble - labelmap = segmentator.segment(image_path="ct_scan.nrrd") - - # Get confidence maps - confidence = segmentator.get_confidence_map() - Common Usage Patterns ===================== @@ -359,7 +245,7 @@ Leverage GPU for faster inference: print("Using CPU") # Initialize with GPU - segmentator = SegmentChestVista3D(device=device) + segmentator = SegmentChestTotalSegmentator() Batch Processing ---------------- @@ -414,22 +300,6 @@ Assess segmentation quality: 'volume_difference': vol_diff } -Confidence Assessment ---------------------- - -Assess segmentation confidence: - -.. code-block:: python - - # For VISTA-3D (supports confidence) - segmentator = SegmentChestVista3D() - - labelmap, confidence_map = segmentator.segment_with_confidence("ct.nrrd") - - # Check low-confidence regions - low_confidence_mask = confidence_map < 0.5 - print(f"Low confidence regions: {low_confidence_mask.sum()} voxels") - Best Practices ============== @@ -437,7 +307,6 @@ Method Selection ---------------- * **TotalSegmentator**: General purpose, fast, comprehensive -* **VISTA-3D**: High accuracy, especially for cardiac structures * **Ensemble**: When accuracy is critical, can tolerate longer processing Parameter Tuning diff --git a/docs/developer/workflows.rst b/docs/developer/workflows.rst index cadd1da..0cfbf7f 100644 --- a/docs/developer/workflows.rst +++ b/docs/developer/workflows.rst @@ -175,7 +175,7 @@ Configure workflow behavior programmatically: reference_image_filename="custom_ref.mha", # Segmentation configuration - segmentation_method='ensemble', # Use ensemble + segmentation_method='totalsegmentator', # Use TotalSegmentator # Output configuration verbose=True # Enable detailed logging diff --git a/docs/examples.rst b/docs/examples.rst index 2fb4b6d..f8d4bbf 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -93,46 +93,6 @@ Quick segmentation with TotalSegmentator: itk.imwrite(heart, "heart_mask.nrrd") itk.imwrite(lungs, "lungs_mask.nrrd") -VISTA-3D with Point Prompts ----------------------------- - -Advanced segmentation with user-provided points: - -.. code-block:: python - - from physiomotion4d import SegmentChestVista3D - import itk - - segmenter = SegmentChestVista3D() - image = itk.imread("chest_ct.nrrd") - - # Define point prompts (x, y, z in voxel coordinates) - heart_points = [(120, 150, 80), (130, 160, 85)] - - masks = segmenter.segment( - image, - contrast_enhanced_study=True, - point_prompts=heart_points - ) - -Ensemble Segmentation ---------------------- - -Combine multiple methods for best results: - -.. code-block:: python - - from physiomotion4d import SegmentChestEnsemble - import itk - - segmenter = SegmentChestEnsemble( - methods=['totalsegmentator', 'vista3d'], - fusion_strategy='voting' - ) - - image = itk.imread("chest_ct.nrrd") - masks = segmenter.segment(image, contrast_enhanced_study=True) - Registration Examples ===================== @@ -466,19 +426,19 @@ Segment multiple images in parallel: .. code-block:: python - from physiomotion4d import SegmentChestVista3D + from physiomotion4d import SegmentChestTotalSegmentator import itk import glob from concurrent.futures import ProcessPoolExecutor def segment_image(filename): - segmenter = SegmentChestVista3D() + segmenter = SegmentChestTotalSegmentator() image = itk.imread(filename) - masks = segmenter.segment(image, contrast_enhanced_study=True) + result = segmenter.segment(image, contrast_enhanced_study=True) # Save heart mask output_name = filename.replace('.nrrd', '_heart.nrrd') - itk.imwrite(masks[0], output_name) + itk.imwrite(result['heart'], output_name) return output_name # Process in parallel @@ -559,7 +519,7 @@ Mix and match different components: .. code-block:: python from physiomotion4d import ( - SegmentChestVista3D, + SegmentChestTotalSegmentator, RegisterImagesICON, TransformTools, ConvertVTKToUSDPolyMesh, @@ -572,9 +532,9 @@ Mix and match different components: frames = [itk.imread(f"frame_{i:03d}.mha") for i in range(10)] # Segment reference - segmenter = SegmentChestVista3D() - masks = segmenter.segment(reference, contrast_enhanced_study=True) - heart_mask = masks[0] + segmenter = SegmentChestTotalSegmentator() + result = segmenter.segment(reference, contrast_enhanced_study=True) + heart_mask = result['heart'] # Extract reference contour contour_tools = ContourTools() diff --git a/docs/faq.rst b/docs/faq.rst index 698bb00..d9266de 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -93,9 +93,8 @@ Typical processing time for 10-frame cardiac CT (with GPU): Which segmentation method should I use? ---------------------------------------- -* **TotalSegmentator**: Fast, good quality -* **VISTA-3D**: Best quality, requires more GPU memory -* **Ensemble**: Best quality, slowest +* **TotalSegmentator**: Fast, good quality, general purpose +* **Simpleware**: Best quality for cardiac imaging, requires Simpleware Medical See :doc:`api/segmentation/index` for comparison. diff --git a/docs/index.rst b/docs/index.rst index 0de1edf..d919b99 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,9 +20,9 @@ PhysioMotion4D is a comprehensive medical imaging package that converts 3D and 4 :target: https://github.com/Project-MONAI/physiomotion4d/blob/main/LICENSE :alt: License -.. image:: https://img.shields.io/github/actions/workflow/status/Project-MONAI/physiomotion4d/ci.yml?branch=main&label=CI%20Tests - :target: https://github.com/Project-MONAI/physiomotion4d/actions/workflows/ci.yml - :alt: CI Tests +.. image:: https://img.shields.io/github/actions/workflow/status/Project-MONAI/physiomotion4d/nightly-health.yml?branch=main&label=Nightly%20CI%20Tests + :target: https://github.com/Project-MONAI/physiomotion4d/actions/workflows/nightly-health.yml + :alt: Nightly CI Tests .. image:: https://img.shields.io/badge/tests-Windows%20%7C%20Linux%20%7C%20Python%203.10--3.12-blue :target: https://github.com/Project-MONAI/physiomotion4d/actions/workflows/ci.yml @@ -36,7 +36,7 @@ PhysioMotion4D is a comprehensive medical imaging package that converts 3D and 4 =============== * **Complete 4D Medical Imaging Pipeline**: End-to-end processing from 4D CT/MR data to animated USD models -* **Multiple AI Segmentation Methods**: TotalSegmentator, VISTA-3D, and ensemble approaches +* **Multiple AI Segmentation Methods**: TotalSegmentator and Simpleware cardiac segmentation * **Deep Learning Registration**: GPU-accelerated image registration using Icon algorithm * **NVIDIA Omniverse Integration**: Direct USD file export for medical visualization * **Physiological Motion Analysis**: Capture and visualize cardiac and respiratory motion diff --git a/docs/installation.rst b/docs/installation.rst index eb06c43..677759e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -25,7 +25,7 @@ PhysioMotion4D relies on several key packages: * **AI/ML**: PyTorch, CuPy (CUDA 13 default; CUDA 12 via ``[cuda12]`` extra), transformers, MONAI * **Registration**: icon-registration, unigradicon * **Visualization**: USD-core, PyVista -* **Segmentation**: TotalSegmentator, VISTA-3D models +* **Segmentation**: TotalSegmentator Installation Methods ==================== diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 9601ece..145e85c 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -116,11 +116,11 @@ If you only need segmentation: .. code-block:: python - from physiomotion4d import SegmentChestVista3D + from physiomotion4d import SegmentChestTotalSegmentator import itk # Initialize segmenter - segmenter = SegmentChestVista3D() + segmenter = SegmentChestTotalSegmentator() # Load and segment image image = itk.imread("chest_ct.nrrd") @@ -279,7 +279,6 @@ Common Issues **Segmentation quality issues** -* Try ensemble method: Use ``SegmentChestEnsemble`` * Adjust contrast parameters * Preprocess images (denoising, normalization) diff --git a/docs/references.rst b/docs/references.rst index 4dfe9b4..c193fe5 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -38,10 +38,6 @@ Segmentation Methods * Wasserthal, J., et al. "TotalSegmentator: Robust Segmentation of 104 Anatomical Structures in CT Images" * `Paper `_ -**VISTA-3D** - -* NVIDIA MONAI Team. "VISTA-3D: A Foundation Model for Medical Image Segmentation" - Datasets ======== diff --git a/docs/testing.rst b/docs/testing.rst index 1e7ac4c..a6b4b79 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -48,7 +48,6 @@ PhysioMotion4D uses pytest markers to categorize tests: # Skip GPU-dependent tests pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py \ - --ignore=tests/test_segment_chest_vista_3d.py \ --ignore=tests/test_register_images_icon.py Specific Test Modules @@ -77,7 +76,6 @@ Specific Test Modules # Segmentation (GPU required, ~2-5 minutes each) pytest tests/test_segment_chest_total_segmentator.py -v - pytest tests/test_segment_chest_vista_3d.py -v Coverage Reports ---------------- @@ -103,8 +101,7 @@ Tests are organized by functionality: │ └── test_convert_nrrd_4d_to_3d.py # 4D to 3D conversion │ ā”œā”€ā”€ Segmentation Tests (GPU Required) - │ ā”œā”€ā”€ test_segment_chest_total_segmentator.py # TotalSegmentator - │ └── test_segment_chest_vista_3d.py # VISTA-3D segmentation + │ └── test_segment_chest_total_segmentator.py # TotalSegmentator │ ā”œā”€ā”€ Registration Tests (Slow ~5-10 min) │ ā”œā”€ā”€ test_register_images_ants.py # ANTs registration diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 85136b2..b7bd81c 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -91,7 +91,7 @@ Poor Segmentation Quality .. code-block:: python - processor.set_segmentation_method('ensemble') + processor.set_segmentation_method('simpleware_heart') 2. Check if image is contrast-enhanced: diff --git a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py index 1125025..553adff 100644 --- a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py +++ b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py @@ -71,7 +71,7 @@ if not vtp_file.exists(): vtk_mesh = pv.read(vtk_file) contour_tools = ContourTools() - vtp_surface = vtk_mesh.extract_surface() + vtp_surface = vtk_mesh.extract_surface(algorithm="dataset_surface") vtp_surface.save(vtp_file) print(f" VTP: {vtp_file.exists()} - {vtp_file}") diff --git a/experiments/Heart-Create_Statistical_Model/1-input_meshes_to_input_surfaces.py b/experiments/Heart-Create_Statistical_Model/1-input_meshes_to_input_surfaces.py index a318af7..5858577 100644 --- a/experiments/Heart-Create_Statistical_Model/1-input_meshes_to_input_surfaces.py +++ b/experiments/Heart-Create_Statistical_Model/1-input_meshes_to_input_surfaces.py @@ -37,7 +37,7 @@ mesh = pv.read(input_dir / vtk_file) # Extract the surface - surface = mesh.extract_surface() + surface = mesh.extract_surface(algorithm="dataset_surface") # Generate the output filename base_name = Path(vtk_file).stem @@ -62,7 +62,7 @@ mesh = pv.read(_HERE / "../../data/KCL-Heart-Model/average_mesh.vtk") # Extract the surface -surface = mesh.extract_surface() +surface = mesh.extract_surface(algorithm="dataset_surface") # Save the surface as VTP surface.save(f"{output_dir}/../average_surface.vtp") diff --git a/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py b/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py index 6093de0..822fe27 100644 --- a/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py +++ b/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py @@ -91,7 +91,7 @@ # Extract surface if needed (in case it's a volume mesh) if isinstance(moving_mesh, pv.UnstructuredGrid): print(" Extracting surface from volume mesh...") - moving_mesh = moving_mesh.extract_surface() + moving_mesh = moving_mesh.extract_surface(algorithm="dataset_surface") print(f" Surface mesh: {moving_mesh.n_points} points") registrar = RegisterModelsICP(fixed_model=template_mesh) @@ -139,7 +139,7 @@ # Load original mesh original_mesh = pv.read(_HERE / f"kcl-heart-model/surfaces/{mesh_id}.vtp") if isinstance(original_mesh, pv.UnstructuredGrid): - original_mesh = original_mesh.extract_surface() + original_mesh = original_mesh.extract_surface(algorithm="dataset_surface") # Create side-by-side comparison plotter = pv.Plotter(shape=(1, 2)) diff --git a/experiments/Heart-GatedCT_To_USD/test_vista3d_class.py b/experiments/Heart-GatedCT_To_USD/test_vista3d_class.py deleted file mode 100644 index 9147d51..0000000 --- a/experiments/Heart-GatedCT_To_USD/test_vista3d_class.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -# %% -import os - -import itk - -from physiomotion4d.segment_chest_vista_3d import SegmentChestVista3D - -_HERE = os.path.dirname(os.path.abspath(__file__)) - -output_dir = os.path.join(_HERE, "results") -max_image = itk.imread(os.path.join(output_dir, "slice_fixed.mha")) - -# %% -seg = SegmentChestVista3D() -result = seg.segment(max_image, contrast_enhanced_study=True) -labelmap_image = result["labelmap"] -itk.imwrite( - labelmap_image, - os.path.join(output_dir, "slice_fixed.all_mask_vista3d.mha"), - compression=True, -) diff --git a/experiments/Heart-GatedCT_To_USD/test_vista3d_inMem.py b/experiments/Heart-GatedCT_To_USD/test_vista3d_inMem.py deleted file mode 100644 index 4c9c49c..0000000 --- a/experiments/Heart-GatedCT_To_USD/test_vista3d_inMem.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python -# %% -import itk -import numpy as np -import torch - - -def vista3d_inference_from_itk( - itk_image, - label_prompt=None, - points=None, - point_labels=None, - device=None, - bundle_path=None, - model_cache_dir=None, -): - # 1. Import dependencies - import itk - from monai.bundle import download - from monai.data.itk_torch_bridge import itk_image_to_metatensor - from monai.inferers import sliding_window_inference - from monai.networks.nets import vista3d132 - from monai.transforms import ( - CropForeground, - EnsureChannelFirst, - EnsureType, - ScaleIntensityRange, - Spacing, - ) - from monai.utils import set_determinism - - set_determinism(seed=42) - if device is None: - device = "cuda" if torch.cuda.is_available() else "cpu" - - # 2. Handle "no prompts" case: segment all classes - if label_prompt is None and points is None: - everything_labels = list( - set([i + 1 for i in range(132)]) - set([2, 16, 18, 20, 21, 23, 24, 25, 26]) - ) - label_prompt = everything_labels - print( - f"No prompt provided. Using everything_labels for {len(everything_labels)} classes." - ) - - if points is not None and point_labels is None: - raise ValueError("point_labels must be provided when points are specified") - - # 3. Download model bundle if needed - if bundle_path is None: - import tempfile - - if model_cache_dir is None: - model_cache_dir = tempfile.mkdtemp() - try: - download(name="vista3d", bundle_dir=model_cache_dir, source="monaihosting") - except Exception: - download(name="vista3d", bundle_dir=model_cache_dir, source="github") - bundle_path = f"{model_cache_dir}/vista3d" - - # 4. ITK->MetaTensor (in memory) - meta_tensor = itk_image_to_metatensor( - itk_image, channel_dim=None, dtype=torch.float32 - ) - - # 5. Preprocessing pipeline - processed = meta_tensor - processed = EnsureChannelFirst(channel_dim=None)(processed) - processed = EnsureType(dtype=torch.float32)(processed) - processed = Spacing(pixdim=[1.5, 1.5, 1.5], mode="bilinear")(processed) - processed = ScaleIntensityRange( - a_min=-1024, a_max=1024, b_min=0.0, b_max=1.0, clip=True - )(processed) - processed = CropForeground()(processed) - - # Save the MONAI affine now (Spacing + CropForeground have updated it). - # We need it later to place the label map in the processed world space before - # resampling back to the original ITK grid. - processed_affine = ( - processed.meta["affine"].numpy() - if hasattr(processed, "meta") and "affine" in processed.meta - else None - ) - - # 6. Load VISTA3D - model = vista3d132(encoder_embed_dim=48, in_channels=1) - model_path = f"{bundle_path}/models/model.pt" - checkpoint = torch.load(model_path, map_location=device) - model.load_state_dict(checkpoint) - model.eval() - model.to(device) - - # 7. Prepare input tensor - input_tensor = processed - if not isinstance(input_tensor, torch.Tensor): - input_tensor = torch.tensor(np.asarray(input_tensor), dtype=torch.float32) - if input_tensor.dim() == 3: - input_tensor = input_tensor.unsqueeze(0) - if input_tensor.dim() == 4: - input_tensor = input_tensor.unsqueeze(0) - input_tensor = input_tensor.to(device) - - # 8. Prepare model inputs - model_inputs = {"image": input_tensor} - if label_prompt is not None: - label_prompt_tensor = torch.tensor( - label_prompt, dtype=torch.long, device=device - ) - model_inputs["label_prompt"] = label_prompt_tensor - print("label_prompt_tensor shape", label_prompt_tensor.shape) - if points is not None: - point_coords = torch.tensor( - points, dtype=torch.float32, device=device - ).unsqueeze(0) - point_labels_tensor = torch.tensor( - point_labels, dtype=torch.float32, device=device - ).unsqueeze(0) - model_inputs["points"] = point_coords - model_inputs["point_labels"] = point_labels_tensor - print("point_coords shape", point_coords.shape) - - # 9. Sliding window inference for large images - def predictor_fn(x): - args = {k: v for k, v in model_inputs.items() if k != "image"} - print(x.shape) - return model(x, **args) - - with torch.no_grad(): - if any(dim > 128 for dim in input_tensor.shape[2:]): - print("Sliding window inference") - output = sliding_window_inference( - input_tensor, - roi_size=[128, 128, 128], - sw_batch_size=1, - predictor=predictor_fn, - overlap=0.5, - mode="gaussian", - device=device, - ) - else: - print("Single window inference") - output = model( - input_tensor, **{k: v for k, v in model_inputs.items() if k != "image"} - ) - - print("output shape", output.shape) - # 10. Postprocess: multi-class to label map - output = output.cpu() - if hasattr(output, "detach"): - output = output.detach() - if isinstance(output, dict): - if "pred" in output: - output = output["pred"] - else: - output = list(output.values())[0] - - if output.shape[1] > 1: - label_map = torch.argmax(output, dim=1).squeeze(0).numpy().astype(np.uint16) - else: - label_map = (output > 0.5).squeeze(0).cpu().numpy().astype(np.uint8) - - # MONAI outputs are in (D, H, W) = (z, y, x) — matches ITK's GetImageFromArray - # convention, so no transpose is needed. - label_map_for_itk = label_map - - # Build an ITK image in the processed (1.5 mm, cropped) world space. - output_itk = itk.GetImageFromArray(label_map_for_itk) - if processed_affine is not None: - # Extract spacing and origin from the MONAI affine matrix. - # Columns norms of the 3Ɨ3 rotation-scale block give voxel spacing. - spacing_processed = np.sqrt( - (processed_affine[:3, :3] ** 2).sum(axis=0) - ).tolist() - origin_processed = processed_affine[:3, 3].tolist() - output_itk.SetSpacing(spacing_processed) - output_itk.SetOrigin(origin_processed) - output_itk.SetDirection(itk_image.GetDirection()) - - # Resample the label map back to the original input image grid using - # nearest-neighbour interpolation (preserves discrete label values). - resampler = itk.ResampleImageFilter.New(output_itk) - resampler.SetReferenceImage(itk_image) - resampler.SetUseReferenceImage(True) - resampler.SetInterpolator( - itk.NearestNeighborInterpolateImageFunction.New(output_itk) - ) - resampler.SetDefaultPixelValue(0) - resampler.Update() - output_itk = resampler.GetOutput() - else: - # Fallback: copy input metadata (spatial alignment may be approximate). - output_itk.SetSpacing(itk_image.GetSpacing()) - output_itk.SetOrigin(itk_image.GetOrigin()) - output_itk.SetDirection(itk_image.GetDirection()) - - return output_itk - - -# %% -# Load an ITK image -image = itk.imread("results/slice_fixed.mha") - -spleen_segmentation = vista3d_inference_from_itk( - image, model_cache_dir="./network_weights" -) - -itk.imwrite(spleen_segmentation, "results/slice_fixed.all_mask_vista3d_inMem.mha") diff --git a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py index 6b339a5..b66e04c 100644 --- a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py +++ b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py @@ -148,7 +148,7 @@ # Load the pca model print("Loading template heart model...") template_model = pv.read(str(heart_model_path)) -template_model_surface = template_model.extract_surface() +template_model_surface = template_model.extract_surface(algorithm="dataset_surface") icp_registrar = RegisterModelsICPITK( fixed_model=patient_heart_surface, reference_image=patient_image diff --git a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py index b58de14..10f4639 100644 --- a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py +++ b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py @@ -112,7 +112,7 @@ patient_model = pv.read(str(output_dir / "patient_mesh.vtp")) template_model = pv.read(str(atlas_vtu_path)) -template_model_surface = template_model.extract_surface() +template_model_surface = template_model.extract_surface(algorithm="dataset_surface") template_model_surface.save(str(output_dir / "model_surface.vtp")) template_model_surface = pv.read(str(output_dir / "model_surface.vtp")) template_labelmap = itk.imread(str(atlas_labelmap_path)) diff --git a/experiments/Lung-GatedCT_To_USD/0-register_dirlab_4dct.py b/experiments/Lung-GatedCT_To_USD/0-register_dirlab_4dct.py index 5b4056c..2a08ad7 100644 --- a/experiments/Lung-GatedCT_To_USD/0-register_dirlab_4dct.py +++ b/experiments/Lung-GatedCT_To_USD/0-register_dirlab_4dct.py @@ -95,7 +95,6 @@ def register_image( # %% -# seg_image = SegmentChestVista3D() seg_image = SegmentChestTotalSegmentator() os.makedirs(output_dir, exist_ok=True) diff --git a/experiments/Lung-GatedCT_To_USD/Experiment_SegReg.py b/experiments/Lung-GatedCT_To_USD/Experiment_SegReg.py index cebb9e5..a428df7 100644 --- a/experiments/Lung-GatedCT_To_USD/Experiment_SegReg.py +++ b/experiments/Lung-GatedCT_To_USD/Experiment_SegReg.py @@ -8,7 +8,6 @@ from physiomotion4d import RegisterImagesICON from physiomotion4d import SegmentChestTotalSegmentator -from physiomotion4d import SegmentChestVista3D _HERE = os.path.dirname(os.path.abspath(__file__)) _DATA_DIR = os.path.join(_HERE, "..", "..", "data", "DirLab-4DCT") @@ -44,50 +43,3 @@ os.path.join(_RESULTS_DIR, "Experiment_totseg.mha"), compression=True, ) - -# %% -# This section requires the Vista3D container to be running - -vista3d_running = False -if vista3d_running: - img = itk.imread(os.path.join(_RESULTS_DIR, "Experiment_reg.mha")) - - tot_seg = SegmentChestVista3D() - - seg_image = tot_seg.segment(img, contrast_enhanced_study=False) - - itk.imwrite( - seg_image[0], - os.path.join(_RESULTS_DIR, "Experiment_vista3d.mha"), - compression=True, - ) - itk.imwrite( - seg_image[1], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_lung.mha"), - compression=True, - ) - itk.imwrite( - seg_image[2], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_heart.mha"), - compression=True, - ) - itk.imwrite( - seg_image[3], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_bone.mha"), - compression=True, - ) - itk.imwrite( - seg_image[4], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_soft_tissue.mha"), - compression=True, - ) - itk.imwrite( - seg_image[5], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_other.mha"), - compression=True, - ) - itk.imwrite( - seg_image[6], - os.path.join(_RESULTS_DIR, "Experiment_vista3d_contrast.mha"), - compression=True, - ) diff --git a/experiments/README.md b/experiments/README.md index 275bf89..ebf5e64 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -64,7 +64,7 @@ visualization and manipulation. **Pipeline stages:** 1. 4D CT reconstruction (using methods from `Reconstruct4DCT`) -2. AI segmentation (TotalSegmentator, Vista3D/Clara Segment Open Model as NIM or local) +2. AI segmentation (TotalSegmentator) 3. Per-organ mesh generation 4. OpenUSD conversion with animation 5. Tissue property mapping (subsurface scatter, color, etc.) @@ -317,7 +317,7 @@ Each subdirectory represents a different experimental domain: This experimental code was instrumental in: 1. Defining the final library architecture 2. Testing registration algorithms (ICON, SyN, LDDMM) -3. Evaluating segmentation approaches (TotalSegmentator, VISTA-3D) +3. Evaluating segmentation approaches (TotalSegmentator) 4. Developing the USD export pipeline 5. Optimizing the complete 4D CT → USD workflow 6. Identifying modular extension points for new anatomical regions and tasks diff --git a/pyproject.toml b/pyproject.toml index e117666..209e872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ keywords = [ "ai", "deep-learning", "totalsegmentator", - "vista-3d", "icon", "ants", "physiological-motion", @@ -75,7 +74,7 @@ dependencies = [ "unigradicon>=1.0.0", # Visualization and USD - "pyvista[all]>=0.42.0", + "pyvista[all]>=0.43.0", "usd-core>=23.11", "trimesh>=4.0.0", @@ -230,7 +229,6 @@ warn_no_return = true warn_unreachable = true strict_equality = true show_error_codes = true -exclude = '(?x)(^src[/\\\\]physiomotion4d[/\\\\]network_weights[/\\\\]vista3d[/\\\\])' # Third-party libs (itk, pyvista, pxr, vtk, simpleware, etc.) have no stubs disable_error_code = ["import-not-found", "import-untyped"] @@ -240,6 +238,10 @@ disable_error_code = ["import-not-found", "import-untyped"] module = ["physiomotion4d", "physiomotion4d.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["tests", "tests.*", "conftest"] +ignore_missing_imports = true + [tool.pyright] # Third-party packages (e.g. pyvista) are in dependencies but may have no stubs; # do not report import-not-found so analysis matches mypy overrides above. diff --git a/src/physiomotion4d/__init__.py b/src/physiomotion4d/__init__.py index 9f86afb..7ce40e1 100644 --- a/src/physiomotion4d/__init__.py +++ b/src/physiomotion4d/__init__.py @@ -62,11 +62,9 @@ # Segmentation classes from .segment_anatomy_base import SegmentAnatomyBase -from .segment_chest_ensemble import SegmentChestEnsemble from .segment_chest_total_segmentator import SegmentChestTotalSegmentator -from .segment_chest_vista_3d import SegmentChestVista3D -from .segment_chest_vista_3d_nim import SegmentChestVista3DNIM from .segment_heart_simpleware import SegmentHeartSimpleware +from .test_tools import TestTools from .transform_tools import TransformTools from .usd_anatomy_tools import USDAnatomyTools from .usd_tools import USDTools @@ -91,10 +89,7 @@ "WorkflowFitStatisticalModelToPatient", # Segmentation classes "SegmentAnatomyBase", - "SegmentChestEnsemble", "SegmentChestTotalSegmentator", - "SegmentChestVista3D", - "SegmentChestVista3DNIM", "SegmentHeartSimpleware", # Registration classes "RegisterImagesBase", @@ -110,6 +105,7 @@ "PhysioMotion4DBase", # Utility classes "ImageTools", + "TestTools", "TransformTools", "USDTools", "ContourTools", diff --git a/src/physiomotion4d/cli/convert_ct_to_vtk.py b/src/physiomotion4d/cli/convert_ct_to_vtk.py index 6d72cfa..fb7d4c7 100644 --- a/src/physiomotion4d/cli/convert_ct_to_vtk.py +++ b/src/physiomotion4d/cli/convert_ct_to_vtk.py @@ -43,15 +43,6 @@ def main() -> int: --input-image chest_ct.nii.gz \\ --output-dir ./results - # VISTA-3D, contrast-enhanced, split per group - %(prog)s \\ - --input-image chest_ct.nii.gz \\ - --segmentation-method vista_3d \\ - --contrast \\ - --split-files \\ - --output-dir ./results \\ - --output-prefix patient01 - # Simpleware heart-only, cardiac anatomy groups, combined output %(prog)s \\ --input-image chest_ct.nii.gz \\ @@ -85,10 +76,7 @@ def main() -> int: "--segmentation-method", default="total_segmentator", choices=list(WorkflowConvertCTToVTK.SEGMENTATION_METHODS), - help=( - "Segmentation backend. " - "total_segmentator (default) | vista_3d | simpleware_heart" - ), + help=("Segmentation backend. total_segmentator (default) | simpleware_heart"), ) parser.add_argument( "--contrast", diff --git a/src/physiomotion4d/contour_tools.py b/src/physiomotion4d/contour_tools.py index 6832405..587fcd2 100644 --- a/src/physiomotion4d/contour_tools.py +++ b/src/physiomotion4d/contour_tools.py @@ -172,7 +172,7 @@ def create_mask_from_mesh( # Create trimesh object with LPS coordinates if isinstance(mesh, pv.UnstructuredGrid): - mesh = mesh.extract_surface() + mesh = mesh.extract_surface(algorithm="dataset_surface") if hasattr(mesh, "n_faces_strict"): # PyVista PolyData diff --git a/src/physiomotion4d/convert_vtk_to_usd.py b/src/physiomotion4d/convert_vtk_to_usd.py index b58fd89..5c79828 100644 --- a/src/physiomotion4d/convert_vtk_to_usd.py +++ b/src/physiomotion4d/convert_vtk_to_usd.py @@ -401,7 +401,7 @@ def _vtk_to_mesh_data( # Extract surface if needed if isinstance(vtk_mesh, pv.UnstructuredGrid) and self.convert_to_surface: - vtk_mesh = vtk_mesh.extract_surface() + vtk_mesh = vtk_mesh.extract_surface(algorithm="dataset_surface") # Get points points = np.array(vtk_mesh.points, dtype=np.float64) @@ -479,7 +479,7 @@ def _split_by_labels( # Extract surface if needed if isinstance(vtk_mesh, pv.UnstructuredGrid) and self.convert_to_surface: - vtk_mesh = vtk_mesh.extract_surface() + vtk_mesh = vtk_mesh.extract_surface(algorithm="dataset_surface") # Get boundary labels if "boundary_labels" not in vtk_mesh.cell_data: diff --git a/src/physiomotion4d/register_models_distance_maps.py b/src/physiomotion4d/register_models_distance_maps.py index 5ee8ead..7e0a936 100644 --- a/src/physiomotion4d/register_models_distance_maps.py +++ b/src/physiomotion4d/register_models_distance_maps.py @@ -26,7 +26,7 @@ >>> from physiomotion4d import RegisterModelsDistanceMaps >>> >>> # Load models and reference image - >>> moving_model = pv.read('generic_model.vtu').extract_surface() + >>> moving_model = pv.read('generic_model.vtu').extract_surface(algorithm="dataset_surface") >>> fixed_model = pv.read('patient_surface.stl') >>> reference_image = itk.imread('patient_ct.nii.gz') >>> @@ -136,7 +136,7 @@ def __init__( Note: The moving_model and fixed_model are typically extracted from VTU models - using model.extract_surface() before passing to this class. + using model.extract_surface(algorithm="dataset_surface") before passing to this class. """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) diff --git a/src/physiomotion4d/register_models_icp.py b/src/physiomotion4d/register_models_icp.py index c0f081c..b3b2b61 100644 --- a/src/physiomotion4d/register_models_icp.py +++ b/src/physiomotion4d/register_models_icp.py @@ -111,7 +111,7 @@ def __init__( Note: The moving_model is typically extracted from a VTU model using - model.extract_surface() before passing to this class. + model.extract_surface(algorithm="dataset_surface") before passing to this class. """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) diff --git a/src/physiomotion4d/register_models_icp_itk.py b/src/physiomotion4d/register_models_icp_itk.py index c7da398..ee1429c 100644 --- a/src/physiomotion4d/register_models_icp_itk.py +++ b/src/physiomotion4d/register_models_icp_itk.py @@ -33,7 +33,7 @@ class RegisterModelsICPITK(PhysioMotion4DBase): Note: The fixed_model and moving_model are typically extracted from VTU models - using model.extract_surface() before passing to this class. + using model.extract_surface(algorithm="dataset_surface") before passing to this class. """ def __init__( diff --git a/src/physiomotion4d/segment_anatomy_base.py b/src/physiomotion4d/segment_anatomy_base.py index 8a7e25d..fa17e93 100644 --- a/src/physiomotion4d/segment_anatomy_base.py +++ b/src/physiomotion4d/segment_anatomy_base.py @@ -177,7 +177,7 @@ def preprocess_input( self.log_info("Input image has spacing: %s", str(input_image.GetSpacing())) self.log_info("Resampling to isotropic: %.3f", self.target_spacing) interpolator = itk.LinearInterpolateImageFunction.New(input_image) - results_image = itk.ResampleImageFilter( + results_image = itk.resample_image_filter( input_image, interpolator=interpolator, output_spacing=[ @@ -272,7 +272,7 @@ def postprocess_labelmap( interpolator = itk.LabelImageGaussianInterpolateImageFunction.New( labelmap_image ) - results_image = itk.ResampleImageFilter( + results_image = itk.resample_image_filter( labelmap_image, interpolator=interpolator, ReferenceImage=input_image, @@ -378,7 +378,7 @@ def segment_connected_component( ... preprocessed_image, labels, 700, 4000, mask_id=135 ... ) """ - thresh_image = itk.BinaryThresholdImageFilter( + thresh_image = itk.binary_threshold_image_filter( Input=preprocessed_image, LowerThreshold=lower_threshold, UpperThreshold=upper_threshold, @@ -396,7 +396,7 @@ def segment_connected_component( label_image = itk.GetImageFromArray(label_arr.astype(np.int16)) label_image.CopyInformation(labelmap_image) - connected_component_image = itk.ConnectedComponentImageFilter( + connected_component_image = itk.connected_component_image_filter( Input=thresh_image, MaskImage=label_image, ) @@ -413,9 +413,17 @@ def segment_connected_component( ids = np.unique(connected_component_arr) ids = ids[ids != 0] + if ids.size == 0: + self.log_debug( + "segment_connected_component: no connected components found " + "in threshold [%d, %d]; returning labelmap unchanged", + lower_threshold, + upper_threshold, + ) + return labelmap_image component_sums = [np.sum(connected_component_arr == id) for id in ids] largest_id = ids[np.argmax(component_sums)] - connected_component_image = itk.BinaryThresholdImageFilter( + connected_component_image = itk.binary_threshold_image_filter( Input=connected_component_image, LowerThreshold=int(largest_id), UpperThreshold=int(largest_id), diff --git a/src/physiomotion4d/segment_chest_ensemble.py b/src/physiomotion4d/segment_chest_ensemble.py deleted file mode 100644 index b51bb3c..0000000 --- a/src/physiomotion4d/segment_chest_ensemble.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Module for segmenting chest CT images using VISTA3D.""" - -# Please start vista3d docker: -# docker run --rm -it --name vista3d --runtime=nvidia -# -e CUDA_VISIBLE_DEVICES=0 -# -e NGC_API_KEY=$NGC_API_KEY -# --shm-size=8G -p 8000:8000 -# -v /tmp/data:/home/aylward/tmp/data nvcr.io/nim/nvidia/vista3d:latest - -import logging - -import itk -import numpy as np - -from physiomotion4d.segment_anatomy_base import SegmentAnatomyBase -from physiomotion4d.segment_chest_total_segmentator import SegmentChestTotalSegmentator -from physiomotion4d.segment_chest_vista_3d import SegmentChestVista3D - - -class SegmentChestEnsemble(SegmentAnatomyBase): - """ - A class that inherits from physioSegmentChest and implements the - segmentation method using VISTA3D. - """ - - def __init__(self, log_level: int | str = logging.INFO): - """Initialize the vista3d class. - - Args: - log_level: Logging level (default: logging.INFO) - """ - super().__init__(log_level=log_level) - - self.target_spacing = 0.0 - - self.heart_mask_ids = { - 108: "left_atrial_appendage", - 115: "heart", - 140: "heart_envelope", - } - - self.major_vessels_mask_ids = { - 6: "aorta", - 7: "inferior_vena_cava", - 17: "portal_vein_and_splenic_vein", - 58: "left_iliac_artery", - 59: "right_iliac_artery", - 60: "left_iliac_vena", - 61: "right_iliac_vena", - 110: "left_brachiocephalic_vena", - 111: "right_brachiocephalic_vena", - 112: "left_common_carotid_artery", - 113: "right_common_carotid_artery", - 119: "pulmonary_vein", - 123: "left_subclavian_artery", - 124: "right_subclavian_artery", - 125: "superior_vena_cava", - 146: "brachiocephalic_trunk", - } - - self.lung_mask_ids = { - 28: "left_lung_upper_lobe", - 29: "left_lung_lower_lobe", - 30: "right_lung_upper_lobe", - 31: "right_lung_middle_lobe", - 32: "right_lung_lower_lobe", - 57: "trachea", - 132: "airway", - 147: "esophagus", - } - - self.bone_mask_ids = { - 33: "vertebrae_L5", - 34: "vertebrae_L4", - 35: "vertebrae_L3", - 36: "vertebrae_L2", - 37: "vertebrae_L1", - 38: "vertebrae_T12", - 39: "vertebrae_T11", - 40: "vertebrae_T10", - 41: "vertebrae_T9", - 42: "vertebrae_T8", - 43: "vertebrae_T7", - 44: "vertebrae_T6", - 45: "vertebrae_T5", - 46: "vertebrae_T4", - 47: "vertebrae_T3", - 48: "vertebrae_T2", - 49: "vertebrae_T1", - 50: "vertebrae_C7", - 51: "vertebrae_C6", - 52: "vertebrae_C5", - 53: "vertebrae_C4", - 54: "vertebrae_C3", - 55: "vertebrae_C2", - 56: "vertebrae_C1", - 63: "left_rib_1", - 64: "left_rib_2", - 65: "left_rib_3", - 66: "left_rib_4", - 67: "left_rib_5", - 68: "left_rib_6", - 69: "left_rib_7", - 70: "left_rib_8", - 71: "left_rib_9", - 72: "left_rib_10", - 73: "left_rib_11", - 74: "left_rib_12", - 75: "right_rib_1", - 76: "right_rib_2", - 77: "right_rib_3", - 78: "right_rib_4", - 79: "right_rib_5", - 80: "right_rib_6", - 81: "right_rib_7", - 82: "right_rib_8", - 83: "right_rib_9", - 84: "right_rib_10", - 85: "right_rib_11", - 86: "right_rib_12", - 87: "left_humerus", - 88: "right_humerus", - 89: "left_scapula", - 90: "right_scapula", - 91: "left_clavicula", - 92: "right_clavicula", - 93: "left_femur", - 94: "right_femur", - 95: "left_hip", - 96: "right_hip", - 120: "skull", - 122: "sternum", - 114: "costal_cartilages", - 127: "vertebrae_S1", - } - - self.soft_tissue_mask_ids = { - 3: "spleen", - 5: "right_kidney", - 14: "left_kidney", - 10: "gallbladder", - 1: "liver", - 12: "stomach", - 4: "pancreas", - 8: "right_adrenal_gland", - 9: "left_adrenal_gland", - 126: "thyroid_gland", - 19: "small_bowel", - 13: "duodenum", - 62: "colon", - 15: "bladder", - 118: "prostate", - 121: "spinal_cord", - 22: "brain", - 133: "soft_tissue", - 148: "sacrum", - 149: "gluteus_maximus_left", - 150: "gluteus_maximus_right", - 151: "gluteus_medius_left", - 152: "gluteus_medius_right", - 153: "gluteus_minimus_left", - 154: "gluteus_minimus_right", - } - - self.set_other_and_all_mask_ids() - - self.heart_ids_map = { - 108: 61, # left_atrial_appendage / atrial_appendage_left - 115: 51, # heart - 140: 140, # heart_envelope / heart_envelop - } - - self.major_vessels_ids_map = { - 6: 52, # aorta - 7: 63, # inferior_vena_cava - 17: -1, # portal_vein_and_splenic_vein - 58: -1, # left_iliac_artery - 59: -1, # right_iliac_artery - 60: -1, # left_iliac_vena - 61: -1, # right_iliac_vena - 110: 59, # left_brachiocephalic_vena / brachiocephalic_vein_left - 111: 60, # right_brachiocephalic_vena / brachiocephalic_vein_right - 112: 58, # left_common_carotid_artery / common_carotid_artery_left - 113: 57, # right_common_carotid_artery / common_carotid_artery_right - 119: 53, # pulmonary_vein - 123: 56, # left_subclavian_artery - 124: 55, # right_subclavian_artery - 125: 62, # superior_vena_cava - 146: 54, # brachiocephalic_trunk (new key for only-in-2nd-list) - } - - self.lung_ids_map = { - 28: 10, # left_lung_upper_lobe / lung_upper_lobe_left - 29: 11, # left_lung_lower_lobe / lung_lower_lobe_left - 30: 12, # right_lung_upper_lobe / lung_upper_lobe_right - 31: 13, # right_lung_middle_lobe / lung_middle_lobe_right - 32: 14, # right_lung_lower_lobe / lung_lower_lobe_right - 57: 16, # trachea - 132: -1, # airway (only in list1) - 147: 15, # esophagus (only in list2) - } - - self.bone_ids_map = { - 33: 27, # vertebrae L5 / vertebra_L5 - 34: 28, - 35: 29, - 36: 30, - 37: 31, - 38: 32, - 39: 33, - 40: 34, - 41: 35, - 42: 36, - 43: 37, - 44: 38, - 45: 39, - 46: 40, - 47: 41, - 48: 42, - 49: 43, - 50: 44, - 51: 45, - 52: 46, - 53: 47, - 54: 48, - 55: 49, - 56: 50, - 63: 92, # left_rib_1 / rib_left_1 - 64: 93, - 65: 94, - 66: 95, - 67: 96, - 68: 97, - 69: 98, - 70: 99, - 71: 100, - 72: 101, - 73: 102, - 74: 103, - 75: 104, # right_rib_1 / rib_right_1 - 76: 105, - 77: 106, - 78: 107, - 79: 108, - 80: 109, - 81: 110, - 82: 111, - 83: 112, - 84: 113, - 85: 114, - 86: 115, - 87: 69, # left_humerus / humerus_left - 88: 70, # right_humerus / humerus_right - 89: 71, # left_scapula / scapula_left - 90: 72, # right_scapula / scapula_right - 91: 73, # left_clavicula / clavicula_left - 92: 74, # right_clavicula / clavicula_right - 93: 75, # left_femur / femur_left - 94: 76, # right_femur / femur_right - 95: 77, # left_hip / hip_left - 96: 78, # right_hip / hip_right - 120: 91, # skull - 122: 116, # sternum - 114: 117, # costal_cartilages - 127: 26, # vertebrae_S1 / vertebra_S1 - } - - self.soft_tissue_ids_map = { - 3: 1, # spleen - 5: 2, # right_kidney / kidney_right - 14: 3, # left_kidney / kidney_left - 10: 4, # gallbladder - 1: 5, # liver - 12: 6, # stomach - 4: 7, # pancreas - 8: 8, # right_adrenal_gland / adrenal_gland_right - 9: 9, # left_adrenal_gland / adrenal_gland_left - 126: 17, # thyroid_gland - 19: 18, # small_bowel - 13: 19, # duodenum - 62: 20, # colon - 15: 21, # bladder / urinary_bladder - 118: 22, # prostate - 121: -1, # spinal_cord (only in list1) - 22: 90, # brain - 133: 133, # soft_tissue - # Only-in-second-list below: - 148: 25, # sacrum (new unique key) - 149: 80, # gluteus_maximus_left (new unique key) - 150: 81, # gluteus_maximus_right (new unique key) - 151: 82, # gluteus_medius_left (new unique key) - 152: 83, # gluteus_medius_right (new unique key) - 153: 84, # gluteus_minimus_left (new unique key) - 154: 85, # gluteus_minimus_right (new unique key) - } - - self.vista3d_to_totseg_ids_map = { - **self.heart_ids_map, - **self.major_vessels_ids_map, - **self.lung_ids_map, - **self.bone_ids_map, - **self.soft_tissue_ids_map, - } - - self.totseg_to_vista3d_ids_map = { - v: k for k, v in self.vista3d_to_totseg_ids_map.items() if v != -1 - } - - def ensemble_segmentation( - self, labelmap_vista: itk.image, labelmap_totseg: itk.image - ) -> itk.image: - """ - Combine two segmentation results using label mapping and priority rules. - - Args: - labelmap_vista (itk.image): The VISTA3D segmentation result. - labelmap_totseg (itk.image): The TotalSegmentator segmentation result. - - Returns: - itk.image: The combined segmentation result. - """ - - self.log_info("Running ensemble segmentation: combining results") - - labelmap_vista_arr = itk.GetArrayFromImage(labelmap_vista) - labelmap_totseg_arr = itk.GetArrayFromImage(labelmap_totseg) - self.log_info("Segmentations loaded") - - results_arr = np.zeros_like(labelmap_vista_arr) - - self.log_info("Setting interpolators") - labelmap_vista_interp = itk.LabelImageGaussianInterpolateImageFunction.New( - labelmap_vista - ) - labelmap_totseg_interp = itk.LabelImageGaussianInterpolateImageFunction.New( - labelmap_totseg - ) - - self.log_info("Iterating through labelmaps") - lastidx0 = -1 - total_slices = labelmap_vista_arr.shape[0] - for idx in np.ndindex(labelmap_vista_arr.shape): - if idx[0] != lastidx0: - if idx[0] % 10 == 0 or idx[0] == total_slices - 1: - self.log_progress( - idx[0] + 1, total_slices, prefix="Processing slices" - ) - lastidx0 = idx[0] - # Skip if both are zero - vista_label = labelmap_vista_arr[idx] - totseg_label = labelmap_totseg_arr[idx] - if vista_label == 0 and totseg_label == 0: - continue - - totseg_vista_label = self.totseg_to_vista3d_ids_map.get(totseg_label, 0) - if vista_label == 0: - results_arr[idx] = totseg_vista_label - elif totseg_label == 0: - results_arr[idx] = vista_label - else: - # print("Conflict detected at", idx, vista_label, totseg_label, end="", flush=True) - # Softtissue label in Vista3D is a catch-all label, - # so use the TotalSegmentator label instead - label = totseg_vista_label - if vista_label != 133: - for sigma in [2, 4, 8]: - labelmap_vista_interp.SetSigma([sigma, sigma, sigma]) - labelmap_totseg_interp.SetSigma([sigma, sigma, sigma]) - tmp_vista = labelmap_vista_interp.EvaluateAtIndex(idx[::-1]) - tmp_totseg = labelmap_totseg_interp.EvaluateAtIndex(idx[::-1]) - # print(" ", tmp_vista, tmp_totseg, end="", flush=True) - if tmp_vista == 0 and tmp_totseg == 0: - label = 0 - # print("...agreeing on 0", flush=True) - break - tmp_totseg_vista = self.totseg_to_vista3d_ids_map.get( - tmp_totseg, 0 - ) - # print(f"({tmp_totseg_vista})", end="", flush=True) - if tmp_vista == tmp_totseg_vista: - label = tmp_vista - # print("...agreeing on", label, flush=True) - break - label = totseg_vista_label - # print(" assigning =", label, flush=True) - results_arr[idx] = label - labelmap_vista_arr[idx] = label - totseg_label = self.vista3d_to_totseg_ids_map.get(vista_label, 0) - totseg_label = max(totseg_label, 0) - labelmap_totseg_arr[idx] = totseg_label - - results_arr = results_arr.reshape(labelmap_vista_arr.shape) - results_image = itk.GetImageFromArray(results_arr) - results_image.CopyInformation(labelmap_vista) - - return results_image - - def segmentation_method(self, preprocessed_image: itk.image) -> itk.image: - """ - Run VISTA3D on the preprocessed image and return result. - - Args: - preprocessed_image (itk.image): The preprocessed image to segment. - - Returns: - the segmented image. - """ - vista3d_result = SegmentChestVista3D().segment(preprocessed_image) - vista3d_labelmap = vista3d_result["labelmap"] - - total_result = SegmentChestTotalSegmentator().segment(preprocessed_image) - total_labelmap = total_result["labelmap"] - - ensemble_segmentation = self.ensemble_segmentation( - vista3d_labelmap, total_labelmap - ) - - return ensemble_segmentation diff --git a/src/physiomotion4d/segment_chest_vista_3d.py b/src/physiomotion4d/segment_chest_vista_3d.py deleted file mode 100644 index 2373178..0000000 --- a/src/physiomotion4d/segment_chest_vista_3d.py +++ /dev/null @@ -1,411 +0,0 @@ -"""Module for segmenting chest CT images using VISTA3D. - -This module provides the SegmentChestVista3D class that implements chest CT -segmentation using the VISTA-3D foundational model from NVIDIA. VISTA-3D is -a versatile segmentation model that can perform both automatic segmentation -and interactive segmentation with point or label prompts. - -The module requires the VISTA-3D model weights to be downloaded from Hugging Face -and supports both local inference and NVIDIA NIM deployment modes. -""" - -# Please start vista3d docker: -# docker run --rm -it --name vista3d --runtime=nvidia -# -e CUDA_VISIBLE_DEVICES=0 -# -e NGC_API_KEY=$NGC_API_KEY -# --shm-size=8G -p 8000:8000 -# -v /tmp/data:/home/aylward/tmp/data nvcr.io/nim/nvidia/vista3d:latest - -import logging -import os -import sys -import tempfile -from typing import Optional - -import itk -import torch -from huggingface_hub import snapshot_download - -from physiomotion4d.segment_anatomy_base import SegmentAnatomyBase - - -class SegmentChestVista3D(SegmentAnatomyBase): - """ - Chest CT segmentation using NVIDIA VISTA-3D foundational model. - - This class implements chest CT segmentation using the VISTA-3D model, - a versatile foundational segmentation model that supports both automatic - ('everything') segmentation and interactive segmentation with prompts. - - VISTA-3D is a state-of-the-art 3D medical image segmentation model that - can segment 132+ anatomical structures. It supports two interaction modes: - 1. Everything segmentation: Segments all detectable structures - 2. Label prompts: Segments specific structures by ID - - The class automatically downloads model weights from Hugging Face and - supports GPU acceleration. It includes additional soft tissue segmentation - to fill gaps not covered by the base VISTA-3D segmentation. - - Attributes: - target_spacing (float): Adaptive spacing based on input image - device (torch.device): GPU device for model inference - bundle_path (str): Path to VISTA-3D model weights - hf_pipeline (object): Hugging Face pipeline for inference - label_prompt (list): Specific anatomical structure IDs to segment - - Example: - >>> # Automatic segmentation - >>> segmenter = SegmentChestVista3D() - >>> result = segmenter.segment(ct_image, contrast_enhanced_study=True) - >>> labelmap = result['labelmap'] - >>> heart_mask = result['heart'] - >>> - >>> # Segment specific structures - >>> segmenter.set_label_prompt([115, 6, 28]) # Heart, aorta, left lung - >>> result = segmenter.segment(ct_image) - """ - - def __init__(self, log_level: int | str = logging.INFO) -> None: - """Initialize the VISTA-3D based chest segmentation. - - Sets up the VISTA-3D model including downloading weights from Hugging Face, - configuring GPU device, and initializing anatomical structure mappings - specific to VISTA-3D's label set. - - The initialization automatically downloads the VISTA-3D model weights - from the MONAI/VISTA3D-HF repository on Hugging Face if not already present. - - Args: - log_level: Logging level (default: logging.INFO) - - Raises: - RuntimeError: If CUDA is not available for GPU acceleration - ConnectionError: If model weights cannot be downloaded - """ - super().__init__(log_level=log_level) - - self.target_spacing = 0.0 - self.resale_intensity_range = False - self.input_percentile_range = None - self.output_percentile_range = None - self.output_intensity_range = None - - self.device = torch.device("cuda:0") - - self.bundle_path = os.path.join( - os.path.dirname(__file__), "network_weights/vista3d" - ) - os.makedirs(self.bundle_path, exist_ok=True) - - self.model_name = "vista3d" - - self.label_prompt: Optional[list[int]] = None - - repo_id = "MONAI/VISTA3D-HF" - snapshot_download(repo_id=repo_id, local_dir=self.bundle_path) - - self.heart_mask_ids = { - 108: "left_atrial_appendage", - 115: "heart", - 140: "heart_envelope", - } - - self.major_vessels_mask_ids = { - 6: "aorta", - 7: "inferior_vena_cava", - 17: "portal_vein_and_splenic_vein", - 58: "left_iliac_artery", - 59: "right_iliac_artery", - 60: "left_iliac_vena", - 61: "right_iliac_vena", - 110: "left_brachiocephalic_vena", - 111: "right_brachiocephalic_vena", - 112: "left_common_carotid_artery", - 113: "right_common_carotid_artery", - 119: "pulmonary_vein", - 123: "left_subclavian_artery", - 124: "right_subclavian_artery", - 125: "superior_vena_cava", - } - - self.lung_mask_ids = { - 28: "left_lung_upper_lobe", - 29: "left_lung_lower_lobe", - 30: "right_lung_upper_lobe", - 31: "right_lung_middle_lobe", - 32: "right_lung_lower_lobe", - 57: "trachea", - 132: "airway", - } - - self.bone_mask_ids = { - 33: "vertebrae_L5", - 34: "vertebrae_L4", - 35: "vertebrae_L3", - 36: "vertebrae_L2", - 37: "vertebrae_L1", - 38: "vertebrae_T12", - 39: "vertebrae_T11", - 40: "vertebrae_T10", - 41: "vertebrae_T9", - 42: "vertebrae_T8", - 43: "vertebrae_T7", - 44: "vertebrae_T6", - 45: "vertebrae_T5", - 46: "vertebrae_T4", - 47: "vertebrae_T3", - 48: "vertebrae_T2", - 49: "vertebrae_T1", - 50: "vertebrae_C7", - 51: "vertebrae_C6", - 52: "vertebrae_C5", - 53: "vertebrae_C4", - 54: "vertebrae_C3", - 55: "vertebrae_C2", - 56: "vertebrae_C1", - 63: "left_rib_1", - 64: "left_rib_2", - 65: "left_rib_3", - 66: "left_rib_4", - 67: "left_rib_5", - 68: "left_rib_6", - 69: "left_rib_7", - 70: "left_rib_8", - 71: "left_rib_9", - 72: "left_rib_10", - 73: "left_rib_11", - 74: "left_rib_12", - 75: "right_rib_1", - 76: "right_rib_2", - 77: "right_rib_3", - 78: "right_rib_4", - 79: "right_rib_5", - 80: "right_rib_6", - 81: "right_rib_7", - 82: "right_rib_8", - 83: "right_rib_9", - 84: "right_rib_10", - 85: "right_rib_11", - 86: "right_rib_12", - 87: "left_humerus", - 88: "right_humerus", - 89: "left_scapula", - 90: "right_scapula", - 91: "left_clavicula", - 92: "right_clavicula", - 93: "left_femur", - 94: "right_femur", - 95: "left_hip", - 96: "right_hip", - 114: "costal_cartilages", - 120: "skull", - 122: "sternum", - 127: "vertebrae_S1", - } - - self.soft_tissue_mask_ids = { - 121: "spinal_cord", - 118: "prostate", - 126: "thyroid_gland", - 62: "colon", - 19: "small_bowel", - 22: "brain", - 14: "left_kidney", - 15: "bladder", - 12: "stomach", - 13: "duodenum", - 8: "right_adrenal_gland", - 9: "left_adrenal_gland", - 10: "gallbladder", - 1: "liver", - 3: "spleen", - 4: "pancreas", - 5: "right_kidney", - 133: "soft_tissue", - } - - # From Base Class - # self.contrast_mask_ids = [135] - - self.set_other_and_all_mask_ids() - - def set_label_prompt(self, label_prompt: list[int]) -> None: - """ - Set specific anatomical structure labels to segment. - - Configures the segmentation to target specific anatomical structures - by their VISTA-3D label IDs instead of performing automatic segmentation. - - Args: - label_prompt (list): List of VISTA-3D anatomical structure IDs - to segment. See class attributes for available IDs - - Example: - >>> # Segment heart, aorta, and lungs only - >>> segmenter.set_label_prompt([115, 6, 28, 29, 30, 31, 32]) - >>> - >>> # Segment all cardiac structures - >>> heart_ids = list(segmenter.heart_mask_ids.keys()) - >>> segmenter.set_label_prompt(heart_ids) - """ - self.label_prompt = label_prompt - - def set_whole_image_segmentation(self) -> None: - """ - Configure for automatic whole-image segmentation. - - Resets the segmentation mode to automatic 'everything' segmentation, - clearing any previously set label prompts. - This is the default mode that segments all detectable structures. - - Example: - >>> # Reset to automatic segmentation after using label prompts - >>> segmenter.set_label_prompt([115, 6]) - >>> # ... perform label-prompted segmentation - >>> segmenter.set_whole_image_segmentation() # Reset to automatic - """ - self.label_prompt = None - - def segment_soft_tissue( - self, preprocessed_image: itk.image, labelmap_image: itk.image - ) -> itk.image: - """ - Add soft tissue segmentation to fill gaps in VISTA-3D output. - - VISTA-3D may not segment all tissue regions, leaving gaps between - structures. This method identifies soft tissue regions based on - intensity thresholds and adds them to the labelmap. - - Args: - preprocessed_image (itk.image): The preprocessed CT image - labelmap_image (itk.image): Existing VISTA-3D segmentation - - Returns: - itk.image: Updated labelmap with soft tissue regions filled - - Example: - >>> filled_labelmap = segmenter.segment_soft_tissue(preprocessed_image, vista_labelmap) - """ - hole_ids = [0] - labelmap_plus_soft_tissue_image = self.segment_connected_component( - preprocessed_image, - labelmap_image, - lower_threshold=-150, - upper_threshold=700, - labelmap_ids=hole_ids, - mask_id=list(self.soft_tissue_mask_ids.keys())[-1], - use_mid_slice=True, - ) - - return labelmap_plus_soft_tissue_image - - def preprocess_input(self, input_image: itk.image) -> itk.image: - """ - Preprocess the input image for VISTA-3D segmentation. - - Extends the base preprocessing with VISTA-3D specific adaptations: - - Adaptive spacing calculation based on input image properties - - No intensity rescaling (VISTA-3D handles raw CT intensities) - - Args: - input_image (itk.image): The input 3D CT image - - Returns: - itk.image: Preprocessed image optimized for VISTA-3D - - Note: - VISTA-3D works best with the original image spacing and intensity - values, so minimal preprocessing is applied. - """ - if self.target_spacing == 0.0: - spacing = input_image.GetSpacing() - self.target_spacing = (spacing[0] + spacing[1] + spacing[2]) / 3 - self.target_spacing = max(self.target_spacing, 0.5) - - preprocessed_image = super().preprocess_input(input_image) - - return preprocessed_image - - def segmentation_method(self, preprocessed_image: itk.image) -> itk.image: - """ - Run VISTA-3D segmentation on the preprocessed image. - - Performs segmentation using the VISTA-3D model with the configured - interaction mode (automatic, point prompts, or label prompts). The - method handles model loading, inference, and post-processing including - soft tissue gap filling. - - Args: - preprocessed_image (itk.image): The preprocessed CT image ready - for VISTA-3D inference - - Returns: - itk.image: The segmentation labelmap with VISTA-3D labels and - additional soft tissue segmentation - - Raises: - ValueError: If no segmentation output is produced - RuntimeError: If model inference fails - - Note: - The method automatically selects the interaction mode based on - the configured prompts: - - No prompts: Everything segmentation - - Label prompt set: Specific structure segmentation - - Example: - >>> labelmap = segmenter.segmentation_method(preprocessed_ct) - """ - # Add bundle_path to sys.path only if not already present to avoid duplicates - if self.bundle_path not in sys.path: - sys.path.append(self.bundle_path) - - from hugging_face_pipeline import HuggingFacePipelineHelper - - hf_pipeline_helper = HuggingFacePipelineHelper(self.model_name) - hf_pipeline = hf_pipeline_helper.init_pipeline( - os.path.join(self.bundle_path, "vista3d_pretrained_model"), - device=self.device, - resample_spacing=( - self.target_spacing, - self.target_spacing, - self.target_spacing, - ), - ) - - # Use TemporaryDirectory context manager for exception-safe cleanup - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_input_file_name = os.path.join(tmp_dir, "tmp.nii.gz") - itk.imwrite(preprocessed_image, tmp_input_file_name, compression=True) - - hf_inputs: list[dict[str, str | int | list[int]]] = [ - {"image": tmp_input_file_name} - ] - if self.label_prompt is None: - hf_inputs[0].update( - { - "label_prompt": hf_pipeline.EVERYTHING_LABEL, - } - ) - else: - hf_inputs[0].update( - { - "label_prompt": self.label_prompt, - } - ) - - hf_pipeline(hf_inputs, output_dir=tmp_dir) - - output_itk: Optional[itk.image] = None - for file_name in os.listdir(os.path.join(tmp_dir, "tmp")): - if file_name.endswith(".nii.gz"): - output_itk = itk.imread(os.path.join(tmp_dir, "tmp", file_name)) - output_itk.CopyInformation(preprocessed_image) - break - - if output_itk is None: - raise ValueError("No output image found") - - output_itk = self.segment_soft_tissue(preprocessed_image, output_itk) - - return output_itk diff --git a/src/physiomotion4d/segment_chest_vista_3d_nim.py b/src/physiomotion4d/segment_chest_vista_3d_nim.py deleted file mode 100644 index c5f76f2..0000000 --- a/src/physiomotion4d/segment_chest_vista_3d_nim.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Module for segmenting chest CT images using VISTA3D.""" - -# Please start vista3d docker: -# docker run --rm -it --name vista3d --runtime=nvidia -# -e CUDA_VISIBLE_DEVICES=0 -# -e NGC_API_KEY=$NGC_API_KEY -# --shm-size=8G -p 8000:8000 -# -v /tmp/data:/home/aylward/tmp/data nvcr.io/nim/nvidia/vista3d:latest - -import io -import json -import logging -import os -import socket -import tempfile -import zipfile -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -import itk - -from physiomotion4d.segment_chest_vista_3d import SegmentChestVista3D - - -class SegmentChestVista3DNIM(SegmentChestVista3D): - """ - A class that inherits from physioSegmentChest and implements the - segmentation method using VISTA3D. - """ - - def __init__(self, log_level: int | str = logging.INFO) -> None: - """Initialize the vista3d class. - - Args: - log_level: Logging level (default: logging.INFO) - """ - super().__init__(log_level=log_level) - - self.invoke_url = "http://localhost:8000/v1/vista3d/inference" - self.wsl_docker_tmp_file = ( - "//wsl.localhost/Ubuntu/home/saylward/tmp/data/tmp.nii.gz" - ) - self.docker_tmp_file = "/tmp/data/tmp.nii.gz" - - def segmentation_method(self, preprocessed_image: itk.image) -> itk.image: - """ - Run VISTA3D on the preprocessed image using the NIM and return result. - - Args: - preprocessed_image (itk.image): The preprocessed image to segment. - - Returns: - the segmented image. - """ - - # Post the image to file.io and get the link - itk.imwrite(preprocessed_image, self.wsl_docker_tmp_file, compression=True) - - payload = {"image": self.docker_tmp_file, "prompts": {}} - - # Call the API (stdlib HTTP client; avoids needing requests stubs) - payload_bytes = json.dumps(payload).encode("utf-8") - req = Request( - self.invoke_url, - data=payload_bytes, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - # Use timeout to prevent indefinite hanging (300s = 5 minutes) - with urlopen(req, timeout=300) as resp: - response_content = resp.read() - except socket.timeout as e: - raise RuntimeError("VISTA3D NIM request timed out after 300 seconds") from e - except HTTPError as e: - raise RuntimeError( - f"VISTA3D NIM request failed: HTTP {e.code} {e.reason}" - ) from e - except URLError as e: - raise RuntimeError(f"VISTA3D NIM request failed: {e.reason}") from e - - # Get the result - labelmap_image = None - with tempfile.TemporaryDirectory() as temp_dir: - z = zipfile.ZipFile(io.BytesIO(response_content)) - z.extractall(temp_dir) - file_list = os.listdir(temp_dir) - for filename in file_list: - self.log_debug("Found file: %s", filename) - filepath = os.path.join(temp_dir, filename) - if os.path.isfile(filepath) and filename.endswith(".nii.gz"): - # SUCCESS: Return the results - labelmap_image = itk.imread(filepath, pixel_type=itk.SS) - break - - if labelmap_image is None: - raise Exception("Failed to get labelmap image from VISTA3D") - - # HERE - itk.imwrite(labelmap_image, "vista3d_labelmap.nii.gz", compression=True) - - # Include Soft Tissue - labelmap_image = self.segment_soft_tissue(preprocessed_image, labelmap_image) - - return labelmap_image diff --git a/src/physiomotion4d/test_tools.py b/src/physiomotion4d/test_tools.py index 11d08f4..d46dbe8 100644 --- a/src/physiomotion4d/test_tools.py +++ b/src/physiomotion4d/test_tools.py @@ -2,8 +2,8 @@ Test utilities for comparing images in pytest. Provides TestTools for baseline vs results comparison with configurable -tolerances. All image I/O uses ITK with .mha (compressed); 2D and 3D -images are passed as itk.Image at the API level. +tolerances. All image I/O uses ITK with .mha (compressed); 3D images are +passed as itk.Image at the API level. """ from __future__ import annotations @@ -38,6 +38,9 @@ class TestTools(PhysioMotion4DBase): for logging. All image I/O uses ITK .mha with compression. """ + # Prevent pytest from collecting this as a test class + __test__ = False + def __init__( self, results_dir: Path, @@ -54,7 +57,7 @@ def __init__( self._baselines_dir = baselines_dir / class_name self._baselines_dir.mkdir(parents=True, exist_ok=True) - self._last_image_per_pixdel_absolute_error_tol: float | None = None + self._last_image_per_pixel_absolute_error_tol: float | None = None self._last_image_number_of_pixels_above_tol: int | None = None self._last_image_max_number_of_pixels_above_tol: int | None = None self._last_image_total_absolute_error: float | None = None @@ -72,112 +75,41 @@ def __init__( None # itk.Transform (type depends on template) ) - def compare_2d_to_3d_slice( - self, - image_2d: Any, - image_3d: Any, - slice_index: int, - axis: int = 0, - *, - per_pixel_absolute_error_tol: float = 0.0, - max_number_of_pixels_above_tol: int = 0, - total_absolute_error_tol: float = 0.0, - ) -> bool: - """ - Compare a 2D itk.Image to a slice of a 3D itk.Image. Converts to numpy only for computing differences. - Stores the difference image and counts for later access via image_difference(), - image_pass_fail_and_pixels_above_tolerance(), and image_pass_fail_and_total_absolute_error(). - - Returns: - True if number_of_pixels_above_tol <= max_number_of_pixels_above_tol and - total_absolute_error <= total_absolute_error_tol; False otherwise. - """ - arr_2d = np.asarray(itk.array_from_image(image_2d), dtype=np.float64) - arr_3d = np.asarray(itk.array_from_image(image_3d), dtype=np.float64) - if arr_2d.ndim != 2: - raise ValueError("image_2d must be 2D") - if arr_3d.ndim != 3: - raise ValueError("image_3d must be 3D") - - slice_3d = np.take(arr_3d, slice_index, axis=axis) - slice_2d = np.squeeze(slice_3d) if slice_3d.ndim == 3 else slice_3d - if slice_2d.shape != arr_2d.shape: - raise ValueError( - f"Shape mismatch: image_2d {arr_2d.shape} vs 3D slice {slice_2d.shape} (axis={axis}, index={slice_index})" - ) - - diff = arr_2d - slice_2d - diff_magnitude = np.abs(diff) - total_absolute_error = float(np.sum(diff_magnitude)) - number_of_pixels_above_tol = int( - np.sum(diff_magnitude > per_pixel_absolute_error_tol) - ) - - self._last_image_number_of_pixels_above_tol = number_of_pixels_above_tol - self._last_image_total_absolute_error = total_absolute_error - - self._last_image_per_pixel_absolute_error_tol = per_pixel_absolute_error_tol - self._last_image_max_number_of_pixels_above_tol = max_number_of_pixels_above_tol - self._last_image_total_absolute_error_tol = total_absolute_error_tol - - self._last_image_difference_image = itk.image_from_array( - diff_magnitude.astype(np.float64) - ) - - passed = ( - number_of_pixels_above_tol <= max_number_of_pixels_above_tol - and total_absolute_error <= total_absolute_error_tol - ) - if passed: - self.log_info( - "PASS: number_of_pixels_above_tol=%d (max=%d), total_absolute_error=%.6g (tol=%.6g)", - number_of_pixels_above_tol, - max_number_of_pixels_above_tol, - total_absolute_error, - total_absolute_error_tol, - ) - else: - self.log_error( - "FAIL: number_of_pixels_above_tol=%d (max=%d), total_absolute_error=%.6g (tol=%.6g)", - number_of_pixels_above_tol, - max_number_of_pixels_above_tol, - total_absolute_error, - total_absolute_error_tol, - ) - return passed - def image_pass_fail_and_pixels_above_tolerance(self) -> tuple[bool, int]: """ - Return (pass, value) for number of pixels above tolerance from the most recent compare_2d_to_3d_slice call. + Return (pass, value) for number of pixels above tolerance from the most + recent compare_result_to_baseline_image call. pass is True if value <= max_pixels_above_tol that was used in that call. """ if ( self._last_image_number_of_pixels_above_tol is None or self._last_image_max_number_of_pixels_above_tol is None ): - raise RuntimeError("No previous compare_2d_to_3d_slice call") + raise RuntimeError("No previous compare_result_to_baseline_image call") val = self._last_image_number_of_pixels_above_tol passed = val <= self._last_image_max_number_of_pixels_above_tol return (passed, val) def image_pass_fail_and_total_absolute_error(self) -> tuple[bool, float]: """ - Return (pass, value) for total absolute error from the most recent compare_2d_to_3d_slice call. + Return (pass, value) for total absolute error from the most recent + compare_result_to_baseline_image call. pass is True if value <= total_absolute_error_tol that was used in that call. """ if ( self._last_image_total_absolute_error is None or self._last_image_total_absolute_error_tol is None ): - raise RuntimeError("No previous compare_2d_to_3d_slice call") + raise RuntimeError("No previous compare_result_to_baseline_image call") val = self._last_image_total_absolute_error passed = val <= self._last_image_total_absolute_error_tol return (passed, val) def image_difference(self) -> Any: - """Return the difference image (itk.Image) from the most recent compare_2d_to_3d_slice call.""" + """Return the difference image (itk.Image) from the most recent + compare_result_to_baseline_image call.""" if self._last_image_difference_image is None: - raise RuntimeError("No previous compare_2d_to_3d_slice call") + raise RuntimeError("No previous compare_result_to_baseline_image call") return self._last_image_difference_image def transform_pass_fail_and_number_of_values_above_tolerance( @@ -307,29 +239,34 @@ def compare_result_to_baseline_transform( def compare_result_to_baseline_image( self, filename: str, - slice_index: int | None = None, - axis: int = 0, *, per_pixel_absolute_error_tol: float = 0.0, max_number_of_pixels_above_tol: int = 0, total_absolute_error_tol: float = 0.0, ) -> bool: """ - Load 3D image from results_filename and 2D baseline from baseline_filename (.mha), compare the given slice to baseline, - save the difference image with \"_diff\" in the name (.mha), and log pass/fail and values (INFO/ERROR). - If slice_index is not given, the middle slice is used. - If the baseline file does not exist, it is created from the corresponding slice of the 3D result and a warning is logged. - Returns True if comparison passed (pixels and total absolute error within tolerance). + Load a 3D result image and a 3D baseline image (.mha), compare the full + volumes voxel-by-voxel, save the difference image with "_diff" in the + name on failure, and log pass/fail. + + If the baseline file does not exist and --create-baselines was given, + the 3D result is copied as the new baseline. + + Returns True if comparison passed (pixels and total absolute error + within tolerance). + + Args: + filename: File name (relative to class results/baselines dirs). + per_pixel_absolute_error_tol: Per-voxel absolute error threshold. + max_number_of_pixels_above_tol: Max allowed voxels exceeding threshold. + total_absolute_error_tol: Max allowed sum of absolute differences. """ results_path = Path(self._results_dir / filename) baseline_path = Path(self._baselines_dir / filename) if not results_path.exists(): raise FileNotFoundError(f"Results image not found: {results_path}") - image_3d = itk.imread(str(results_path)) - arr_3d = np.asarray(itk.array_from_image(image_3d), dtype=np.float64) - if slice_index is None: - slice_index = arr_3d.shape[axis] // 2 + image_result = itk.imread(str(results_path)) if not baseline_path.exists(): if not _create_baseline_if_missing: @@ -338,48 +275,55 @@ def compare_result_to_baseline_image( baseline_path, ) return False - # Create baseline from the corresponding slice of the 3D result - slice_3d = np.take(arr_3d, slice_index, axis=axis) - slice_2d = np.squeeze(slice_3d) if slice_3d.ndim == 3 else slice_3d - slice_itk = itk.image_from_array(slice_2d.astype(np.float64)) - baseline_path = ( - (self._baselines_dir / filename).with_suffix(".mha") - if baseline_path.suffix.lower() != ".mha" - else baseline_path - ) baseline_path.parent.mkdir(parents=True, exist_ok=True) - itk.imwrite(slice_itk, str(baseline_path), compression=True) + shutil.copy(str(results_path), str(baseline_path)) self.log_warning( - "Baseline file did not exist; created from 3D result slice: %s", + "Baseline file did not exist; copied 3D result: %s", baseline_path, ) - image_2d = slice_itk + image_baseline = image_result else: - image_2d = itk.imread(str(baseline_path)) - - passed = self.compare_2d_to_3d_slice( - image_2d, - image_3d, - slice_index, - axis=axis, - per_pixel_absolute_error_tol=per_pixel_absolute_error_tol, - max_number_of_pixels_above_tol=max_number_of_pixels_above_tol, - total_absolute_error_tol=total_absolute_error_tol, + image_baseline = itk.imread(str(baseline_path)) + + arr_result = np.asarray(itk.array_from_image(image_result), dtype=np.float64) + arr_baseline = np.asarray( + itk.array_from_image(image_baseline), dtype=np.float64 + ) + + if arr_result.shape != arr_baseline.shape: + raise ValueError( + f"Shape mismatch: result {arr_result.shape} vs baseline {arr_baseline.shape}" + ) + + diff_magnitude = np.abs(arr_result - arr_baseline) + total_absolute_error = float(np.sum(diff_magnitude)) + number_of_pixels_above_tol = int( + np.sum(diff_magnitude > per_pixel_absolute_error_tol) + ) + + self._last_image_per_pixel_absolute_error_tol = per_pixel_absolute_error_tol + self._last_image_number_of_pixels_above_tol = number_of_pixels_above_tol + self._last_image_max_number_of_pixels_above_tol = max_number_of_pixels_above_tol + self._last_image_total_absolute_error = total_absolute_error + self._last_image_total_absolute_error_tol = total_absolute_error_tol + self._last_image_difference_image = itk.image_from_array( + diff_magnitude.astype(np.float64) ) - # Save difference image to results dir - stem = Path(filename).stem - if "." in stem: - stem = stem.split(".")[0] - diff_filename = stem + "_diff.mha" - diff_path = self._results_dir / diff_filename - itk.imwrite(self._last_image_difference_image, str(diff_path), compression=True) - - # Log pass/fail and values - _, number_of_pixels_above_tol = ( - self.image_pass_fail_and_pixels_above_tolerance() + passed = ( + number_of_pixels_above_tol <= max_number_of_pixels_above_tol + and total_absolute_error <= total_absolute_error_tol ) - _, total_absolute_error = self.image_pass_fail_and_total_absolute_error() + + if not passed: + stem = Path(filename).stem + if "." in stem: + stem = stem.split(".")[0] + diff_path = self._results_dir / (stem + "_diff.mha") + itk.imwrite( + self._last_image_difference_image, str(diff_path), compression=True + ) + if passed: self.log_info( "PASS: number_of_pixels_above_tol=%d (max=%d), total_absolute_error=%.6g (tol=%.6g)", diff --git a/src/physiomotion4d/transform_tools.py b/src/physiomotion4d/transform_tools.py index e488f27..c2724bb 100644 --- a/src/physiomotion4d/transform_tools.py +++ b/src/physiomotion4d/transform_tools.py @@ -705,8 +705,15 @@ def reduce_folding_in_field( thresholder.SetUpperThreshold(threshold) thresholder.SetInsideValue(reduction_factor) thresholder.SetOutsideValue(1.0) + thresholder.Update() - corrected_field = itk.MultiplyImageFilter(field, thresholder.GetOutput()) + thresh_arr = itk.array_from_image(thresholder.GetOutput()) + field_arr = itk.array_from_image(field) + for i in range(field_arr.shape[3]): + field_arr[:, :, :, i] *= thresh_arr + corrected_field = ImageTools().convert_array_to_image_of_vectors( + field_arr, field, itk.F + ) return corrected_field def generate_grid_image( diff --git a/src/physiomotion4d/workflow_convert_ct_to_vtk.py b/src/physiomotion4d/workflow_convert_ct_to_vtk.py index a522bfc..0d207a6 100644 --- a/src/physiomotion4d/workflow_convert_ct_to_vtk.py +++ b/src/physiomotion4d/workflow_convert_ct_to_vtk.py @@ -51,7 +51,6 @@ #: Supported segmentation backend identifiers. SEGMENTATION_METHODS: tuple[str, ...] = ( "total_segmentator", - "vista_3d", "simpleware_heart", ) @@ -63,7 +62,6 @@ class WorkflowConvertCTToVTK(PhysioMotion4DBase): - ``'total_segmentator'`` — :class:`SegmentChestTotalSegmentator` (CPU-capable, default). - - ``'vista_3d'`` — :class:`SegmentChestVista3D` (GPU-accelerated MONAI VISTA-3D). - ``'simpleware_heart'`` — :class:`SegmentHeartSimpleware` (cardiac only; requires a Simpleware Medical installation). @@ -106,8 +104,7 @@ def __init__( Args: segmentation_method: Segmentation backend to use. One of - ``'total_segmentator'`` (default), ``'vista_3d'``, or - ``'simpleware_heart'``. + ``'total_segmentator'`` (default) or ``'simpleware_heart'``. log_level: Logging level. Default: ``logging.INFO``. Raises: @@ -147,10 +144,6 @@ def _create_segmenter(self) -> SegmentAnatomyBase: ) return SegmentChestTotalSegmentator(log_level=self.log_level) - if self.segmentation_method_name == "vista_3d": - from physiomotion4d.segment_chest_vista_3d import SegmentChestVista3D - - return SegmentChestVista3D(log_level=self.log_level) if self.segmentation_method_name == "simpleware_heart": from physiomotion4d.segment_heart_simpleware import SegmentHeartSimpleware diff --git a/src/physiomotion4d/workflow_create_statistical_model.py b/src/physiomotion4d/workflow_create_statistical_model.py index 1c3e4fd..5984142 100644 --- a/src/physiomotion4d/workflow_create_statistical_model.py +++ b/src/physiomotion4d/workflow_create_statistical_model.py @@ -26,10 +26,10 @@ def _extract_surface(mesh: pv.DataSet) -> pv.PolyData: """Extract surface from a mesh (PolyData or UnstructuredGrid).""" if isinstance(mesh, pv.UnstructuredGrid): - return mesh.extract_surface() + return mesh.extract_surface(algorithm="dataset_surface") if isinstance(mesh, pv.PolyData): return mesh - return mesh.extract_surface() + return mesh.extract_surface(algorithm="dataset_surface") class WorkflowCreateStatisticalModel(PhysioMotion4DBase): @@ -138,13 +138,15 @@ def _step2_icp_align(self) -> None: if self.solve_for_surface_pca: reference_surface = self.reference_model else: - reference_surface = self.reference_model.extract_surface() + reference_surface = self.reference_model.extract_surface( + algorithm="dataset_surface" + ) for i, (sid, moving) in enumerate(zip(self.sample_ids, self.sample_models)): self.log_info( "ICP aligning %s (%d/%d)", sid, i + 1, len(self.sample_models) ) # Always extract surfaces for ICP alignment - moving_surface = moving.extract_surface() + moving_surface = moving.extract_surface(algorithm="dataset_surface") registrar = RegisterModelsICP(fixed_model=reference_surface) result = registrar.register( moving_model=moving_surface, @@ -290,7 +292,9 @@ def _step5_compute_pca(self) -> None: self.pca_mean_surface = pca_mean_model else: self.pca_mean_mesh = pca_mean_model - self.pca_mean_surface = pca_mean_model.extract_surface() + self.pca_mean_surface = pca_mean_model.extract_surface( + algorithm="dataset_surface" + ) def _build_result(self) -> dict[str, Any]: """Build result dictionary: surfaces, meshes, and PCA model structure.""" diff --git a/src/physiomotion4d/workflow_fit_statistical_model_to_patient.py b/src/physiomotion4d/workflow_fit_statistical_model_to_patient.py index 8f9c907..3064e5a 100644 --- a/src/physiomotion4d/workflow_fit_statistical_model_to_patient.py +++ b/src/physiomotion4d/workflow_fit_statistical_model_to_patient.py @@ -146,7 +146,9 @@ def __init__( ) self.template_model = template_model - self.template_model_surface = template_model.extract_surface() + self.template_model_surface = template_model.extract_surface( + algorithm="dataset_surface" + ) self.template_labelmap: Optional[itk.Image] = None self.template_labelmap_organ_mesh_ids: Optional[list[int]] = None self.template_labelmap_organ_extra_ids: Optional[list[int]] = None @@ -166,9 +168,14 @@ def __init__( elif patient_models is None: raise ValueError("Either patient_models or patient_image must be provided.") self.patient_models = patient_models - patient_models_surfaces = [model.extract_surface() for model in patient_models] + patient_models_surfaces = [ + model.extract_surface(algorithm="dataset_surface") + for model in patient_models + ] self.combined_patient_model = pv.merge(patient_models_surfaces) - self.patient_model_surface = self.combined_patient_model.extract_surface() + self.patient_model_surface = self.combined_patient_model.extract_surface( + algorithm="dataset_surface" + ) # Utilities (needed for create_reference_image when patient_image is None) self.transform_tools = TransformTools() @@ -566,7 +573,7 @@ def register_model_to_model_pca(self) -> dict: else: self.pca_template_model_surface = result[ "registered_model" - ].extract_surface() + ].extract_surface(algorithm="dataset_surface") pca_transforms = self.pca_registrar.compute_pca_transforms( reference_image=self.patient_image, diff --git a/tests/GITHUB_WORKFLOWS.md b/tests/GITHUB_WORKFLOWS.md index 7e75674..25d42d0 100644 --- a/tests/GITHUB_WORKFLOWS.md +++ b/tests/GITHUB_WORKFLOWS.md @@ -218,12 +218,6 @@ These tests are **NOT** run in CI (even on GPU runners) because they require ext - Run locally: `pytest tests/test_segment_chest_total_segmentator.py -v -s` - Why excluded: Requires GPU, model inference -5. **VISTA-3D Tests** (`test_segment_chest_vista_3d.py`) - - Requires: CUDA GPU, VISTA-3D model weights - - Markers: `@pytest.mark.requires_data`, `@pytest.mark.slow` - - Run locally: `pytest tests/test_segment_chest_vista_3d.py -v -s` - - Why excluded: Requires GPU, model inference - **Why excluded**: - These tests take 5-15 minutes each, even with GPU acceleration - Registration algorithms are computationally intensive @@ -240,7 +234,7 @@ pytest tests/ -v -m "slow" pytest tests/test_register_images_ants.py tests/test_register_images_icon.py -v -s # Run only segmentation tests -pytest tests/test_segment_chest_total_segmentator.py tests/test_segment_chest_vista_3d.py -v -s +pytest tests/test_segment_chest_total_segmentator.py -v -s ``` **Scheduled slow tests**: diff --git a/tests/README.md b/tests/README.md index f9ca10c..50e95cd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,7 +19,6 @@ This directory contains comprehensive test suites for the PhysioMotion4D package ### Segmentation Tests (GPU Required) - **`test_segment_chest_total_segmentator.py`** - TotalSegmentator chest CT segmentation -- **`test_segment_chest_vista_3d.py`** - NVIDIA VISTA-3D segmentation (requires 20GB+ RAM) ### Registration Tests (Slow ~5-10 min) - **`test_register_images_ants.py`** - ANTs deformable registration @@ -103,8 +102,7 @@ pytest tests/test_experiments.py::test_experiment_heart_gated_ct_to_usd -v -s -- ### Common Test Commands ```bash # Skip GPU-dependent tests -pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py \ - --ignore=tests/test_segment_chest_vista_3d.py +pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py # Run with coverage pytest tests/ --cov=src/physiomotion4d --cov-report=html @@ -146,8 +144,8 @@ test_convert_nrrd_4d_to_3d ↓ ā”œā”€ā†’ test_register_images_icon ↓ ↓ test_segment_chest_total_segmentator ────→ test_contour_tools - ↓ ↓ -test_segment_chest_vista_3d test_convert_vtk_to_usd_polymesh + ↓ + test_convert_vtk_to_usd_polymesh ``` Fixtures in `conftest.py` automatically manage these dependencies. @@ -179,8 +177,8 @@ Tests automatically run on pull requests via GitHub Actions. The CI workflow: - Place `TruncalValve_4DCT.seq.nrrd` there to avoid download **Problem: Out of memory errors** -- VISTA-3D requires 20GB+ RAM -- Skip with: `pytest tests/ --ignore=tests/test_segment_chest_vista_3d.py` +- Some segmentation models require significant RAM +- Skip segmentation tests with: `pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py` **Problem: Test timeout** - Global timeout: 900 seconds (15 minutes) diff --git a/tests/TESTING_GUIDE.md b/tests/TESTING_GUIDE.md index 2c15246..78f8fa1 100644 --- a/tests/TESTING_GUIDE.md +++ b/tests/TESTING_GUIDE.md @@ -6,7 +6,7 @@ This guide explains how to set up and use the PhysioMotion4D test suite. The test suite validates the complete PhysioMotion4D pipeline: - **Data download and conversion** - 4D NRRD to 3D time series -- **Segmentation** - TotalSegmentator and VISTA-3D chest CT segmentation +- **Segmentation** - TotalSegmentator chest CT segmentation - **Registration** - ANTs and ICON deformable registration - **Contour extraction** - PyVista mesh generation from segmentation masks - **USD conversion** - VTK to USD format for Omniverse @@ -80,8 +80,8 @@ test_convert_nrrd_4d_to_3d ↓ ā”œā”€ā†’ test_register_images_icon ↓ ↓ test_segment_chest_total_segmentator ────→ test_contour_tools - ↓ ↓ -test_segment_chest_vista_3d test_convert_vtk_to_usd_polymesh + ↓ + test_convert_vtk_to_usd_polymesh ``` ### Test Markers @@ -97,7 +97,6 @@ Some tests are skipped or marked slow due to: **GPU-Dependent Tests** (skipped in CI): - `test_segment_chest_total_segmentator.py` - Requires GPU for inference -- `test_segment_chest_vista_3d.py` - Requires GPU + 20GB+ RAM **Computationally Intensive Tests**: - Registration tests (ANTs, ICON) - Marked slow, run locally only @@ -142,7 +141,6 @@ tests/ │ └── slice_001.mha └── results/ # Test outputs ā”œā”€ā”€ segmentation_total_segmentator/ - ā”œā”€ā”€ segmentation_vista3d/ ā”œā”€ā”€ contour_tools/ ā”œā”€ā”€ usd_polymesh/ ā”œā”€ā”€ registration_ants/ @@ -164,15 +162,15 @@ tests/ **Manual fix**: Place `TruncalValve_4DCT.seq.nrrd` in `data/Slicer-Heart-CT/` -### Memory Errors (VISTA-3D Tests) +### Memory Errors (Segmentation Tests) -**Problem**: `RuntimeError: not enough memory: you tried to allocate 20GB` +**Problem**: `RuntimeError: not enough memory` -**Root Cause**: VISTA-3D requires full-resolution CT images, needs 20GB+ RAM +**Root Cause**: Segmentation models require significant RAM and GPU memory **Solutions**: -- Skip VISTA-3D tests: `pytest tests/ --ignore=tests/test_segment_chest_vista_3d.py` -- Run on system with 24GB+ RAM +- Skip segmentation tests: `pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py` +- Run on system with sufficient RAM and GPU memory - Tests are automatically skipped in CI ### Test Timeout @@ -205,7 +203,6 @@ size = (int(size_itk[0]), int(size_itk[1]), int(size_itk[2])) **Solution**: āœ… **Fixed!** Tests now use correct fixture names: - `segmenter_total_segmentator` for TotalSegmentator -- `segmenter_vista_3d` for VISTA-3D - `registrar_ants` for ANTs - `registrar_icon` for ICON @@ -329,19 +326,19 @@ pytest tests/test_usd_merge.py::TestUSDMerge::test_specific_test -v **Q: What if I don't have a GPU?** - Most tests run on CPU (slower but functional) -- Segmentation tests (TotalSegmentator, VISTA-3D) require GPU +- Segmentation tests (TotalSegmentator) require GPU - Registration tests (ANTs, ICON) benefit from GPU but work on CPU -- Skip GPU tests: `pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py --ignore=tests/test_segment_chest_vista_3d.py` +- Skip GPU tests: `pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py` **Q: How do I run tests without downloading data?** Place `TruncalValve_4DCT.seq.nrrd` in `data/Slicer-Heart-CT/` or `tests/data/Slicer-Heart-CT/` before running tests. The test will automatically detect and use it. **Q: Can I use different test data?** -Yes! Modify the `download_truncal_valve_data` fixture in `tests/conftest.py` to point to your data file. You may need to adjust expected results. +Yes! Modify the `download_test_data` fixture in `tests/conftest.py` to point to your data file. You may need to adjust expected results. **Q: Why do some tests take so long?** - Registration (ANTs/ICON): 5-10 minutes each (deformable registration is computationally intensive) -- Segmentation (TotalSegmentator/VISTA-3D): 10-15 minutes (deep learning inference on full CT volumes) +- Segmentation (TotalSegmentator): 10-15 minutes (deep learning inference on full CT volumes) - Data download: First time only (~1.2GB file) - Everything else: <1 minute diff --git a/tests/baselines/TestRegisterTimeSeriesImages/basic_forward_transform_0.hdf b/tests/baselines/TestRegisterTimeSeriesImages/basic_forward_transform_0.hdf deleted file mode 100644 index 1d5152c..0000000 --- a/tests/baselines/TestRegisterTimeSeriesImages/basic_forward_transform_0.hdf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:698f1e781cceabe975120e1fb087c62491e583b1ad1ba83eaeec264bd846db50 -size 67444631 diff --git a/tests/baselines/TestRegisterTimeSeriesImages/basic_time_series_registered_0.mha b/tests/baselines/TestRegisterTimeSeriesImages/basic_time_series_registered_0.mha deleted file mode 100644 index 165c2c4..0000000 --- a/tests/baselines/TestRegisterTimeSeriesImages/basic_time_series_registered_0.mha +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:56fdeef3e9d15cde10237a6a31a4cd1feb54cfc2a02d2f2a9242891eafc6f38d -size 101448 diff --git a/tests/baselines/TestRegisterTimeSeriesImages/prior_forward_transform_0.hdf b/tests/baselines/TestRegisterTimeSeriesImages/prior_forward_transform_0.hdf deleted file mode 100644 index 2922e11..0000000 --- a/tests/baselines/TestRegisterTimeSeriesImages/prior_forward_transform_0.hdf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c00b9c1dd6d6f7fb97d869a201f9a8c37d2294716723b0f254df788c941c9f25 -size 67444631 diff --git a/tests/baselines/TestRegisterTimeSeriesImages/prior_time_series_registered_0.mha b/tests/baselines/TestRegisterTimeSeriesImages/prior_time_series_registered_0.mha deleted file mode 100644 index 165c2c4..0000000 --- a/tests/baselines/TestRegisterTimeSeriesImages/prior_time_series_registered_0.mha +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:56fdeef3e9d15cde10237a6a31a4cd1feb54cfc2a02d2f2a9242891eafc6f38d -size 101448 diff --git a/tests/baselines/TestRegisterTimeSeriesImages/transform_application_time_series_0.mha b/tests/baselines/TestRegisterTimeSeriesImages/transform_application_time_series_0.mha deleted file mode 100644 index 165c2c4..0000000 --- a/tests/baselines/TestRegisterTimeSeriesImages/transform_application_time_series_0.mha +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:56fdeef3e9d15cde10237a6a31a4cd1feb54cfc2a02d2f2a9242891eafc6f38d -size 101448 diff --git a/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf b/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf new file mode 100644 index 0000000..7582346 --- /dev/null +++ b/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24697b25fa57cc7d83ad221b42789e75409ef96470a1ad2d89981b90386ccfc6 +size 40878719 diff --git a/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha b/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha new file mode 100644 index 0000000..3098be9 --- /dev/null +++ b/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4deb7987fcaa77c9f77660ff8ccbe6446e19051c954912de6d7514a14aa8927a +size 4814459 diff --git a/tests/baselines/registration_time_series_images/prior_forward_transform_0.hdf b/tests/baselines/registration_time_series_images/prior_forward_transform_0.hdf new file mode 100644 index 0000000..664096c --- /dev/null +++ b/tests/baselines/registration_time_series_images/prior_forward_transform_0.hdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd4bb595e748dcc777ffe290b18f8b1a6436bc2e579b298bc529157326e7b40f +size 40878719 diff --git a/tests/baselines/registration_time_series_images/prior_time_series_registered_0.mha b/tests/baselines/registration_time_series_images/prior_time_series_registered_0.mha new file mode 100644 index 0000000..3098be9 --- /dev/null +++ b/tests/baselines/registration_time_series_images/prior_time_series_registered_0.mha @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4deb7987fcaa77c9f77660ff8ccbe6446e19051c954912de6d7514a14aa8927a +size 4814459 diff --git a/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha b/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha new file mode 100644 index 0000000..3098be9 --- /dev/null +++ b/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4deb7987fcaa77c9f77660ff8ccbe6446e19051c954912de6d7514a14aa8927a +size 4814459 diff --git a/tests/conftest.py b/tests/conftest.py index 4bdda9f..f7c127a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,15 @@ in the tests directory via pytest's automatic fixture discovery. """ -import shutil +import os +import urllib.error import urllib.request from datetime import datetime, timedelta from pathlib import Path +from typing import Any, Optional import itk import pytest -from itk import TubeTK as ttk from physiomotion4d.contour_tools import ContourTools from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D @@ -21,7 +22,6 @@ from physiomotion4d.register_images_greedy import RegisterImagesGreedy from physiomotion4d.register_images_icon import RegisterImagesICON from physiomotion4d.segment_chest_total_segmentator import SegmentChestTotalSegmentator -from physiomotion4d.segment_chest_vista_3d import SegmentChestVista3D from physiomotion4d.segment_heart_simpleware import SegmentHeartSimpleware from physiomotion4d.transform_tools import TransformTools @@ -30,10 +30,10 @@ # ============================================================================ # Module-level variable to store config for access in hooks -_pytest_config = None +_pytest_config: Optional[pytest.Config] = None -def pytest_addoption(parser): +def pytest_addoption(parser: pytest.Parser) -> None: """Add custom command-line options for pytest.""" parser.addoption( "--run-experiments", @@ -49,7 +49,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: pytest.Config) -> None: """Configure pytest with custom markers and settings.""" global _pytest_config _pytest_config = config @@ -65,14 +65,16 @@ def pytest_configure(config): "experiment: marks tests that run experiment notebooks (extremely slow, manual only)", ) # Initialize test timing storage - config._test_timings = { + config._test_timings = { # type: ignore[attr-defined] "tests": [], "total_time": 0.0, "start_time": datetime.now(), } -def pytest_collection_modifyitems(config, items): +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: """ Automatically skip experiment tests unless --run-experiments is passed. @@ -92,7 +94,7 @@ def pytest_collection_modifyitems(config, items): item.add_marker(skip_experiments) -def pytest_runtest_logreport(report): +def pytest_runtest_logreport(report: pytest.TestReport) -> None: """ Collect test timing information after each test completes. @@ -112,18 +114,22 @@ def pytest_runtest_logreport(report): "is_experiment": "experiment" in report.keywords, } - _pytest_config._test_timings["tests"].append(test_info) - _pytest_config._test_timings["total_time"] += report.duration + _pytest_config._test_timings["tests"].append(test_info) # type: ignore[attr-defined] + _pytest_config._test_timings["total_time"] += report.duration # type: ignore[attr-defined] -def pytest_terminal_summary(terminalreporter, exitstatus, config): +def pytest_terminal_summary( + terminalreporter: Any, + exitstatus: int, + config: pytest.Config, +) -> None: """ Print comprehensive test timing report after all tests complete. This hook is called at the end of the test session to display timing statistics for all tests, including experiment tests. """ - timings = config._test_timings + timings = config._test_timings # type: ignore[attr-defined] tests = timings["tests"] if not tests: @@ -168,7 +174,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): # Show all regular tests with timing terminalreporter.write_line("Individual Test Times:") for test in sorted_regular: - outcome_symbol = "āœ“" if test["outcome"] == "passed" else "āœ—" + outcome_symbol = "+" if test["outcome"] == "passed" else "x" duration_str = _format_duration(test["duration"]) terminalreporter.write_line( f" {outcome_symbol} {duration_str:>10s} {test['nodeid']}" @@ -195,7 +201,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): # Show all experiment tests with timing terminalreporter.write_line("Individual Test Times:") for test in sorted_experiments: - outcome_symbol = "āœ“" if test["outcome"] == "passed" else "āœ—" + outcome_symbol = "+" if test["outcome"] == "passed" else "x" duration_str = _format_duration(test["duration"]) terminalreporter.write_line( f" {outcome_symbol} {duration_str:>10s} {test['nodeid']}" @@ -208,7 +214,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): sorted_all = sorted(tests, key=lambda x: x["duration"], reverse=True)[:10] for i, test in enumerate(sorted_all, 1): - outcome_symbol = "āœ“" if test["outcome"] == "passed" else "āœ—" + outcome_symbol = "+" if test["outcome"] == "passed" else "x" duration_str = _format_duration(test["duration"]) test_type = "[EXP]" if test["is_experiment"] else "[REG]" terminalreporter.write_line( @@ -228,7 +234,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): terminalreporter.write_line("") -def _format_duration(seconds): +def _format_duration(seconds: float) -> str: """Format duration in a human-readable way.""" if seconds < 1: return f"{seconds * 1000:.0f}ms" @@ -250,11 +256,11 @@ def _format_duration(seconds): @pytest.fixture(scope="session") -def test_directories(): +def test_directories() -> dict[str, Path]: """Set up test directories for data and results.""" - data_dir = Path("tests/data/Slicer-Heart-CT") - output_dir = Path("tests/results") - baselines_dir = Path("tests/baselines") + data_dir = Path(__file__).parent.parent / "data" / "test" + output_dir = Path(__file__).parent / "results" + baselines_dir = Path(__file__).parent / "baselines" # Create directories if they don't exist data_dir.mkdir(parents=True, exist_ok=True) @@ -265,38 +271,32 @@ def test_directories(): @pytest.fixture(scope="session") -def download_truncal_valve_data(test_directories): +def download_test_data(test_directories: dict[str, Path]) -> Path: """Download TruncalValve 4D CT data.""" data_dir = test_directories["data"] input_image_filename = data_dir / "TruncalValve_4DCT.seq.nrrd" - # Check if file already exists in test data directory + # Check if file already exists if input_image_filename.exists(): print(f"\nData file already exists: {input_image_filename}") return input_image_filename - # Check if file exists in main data directory (one level up from project root) - main_data_file = Path("data/Slicer-Heart-CT/TruncalValve_4DCT.seq.nrrd") - if main_data_file.exists(): - print(f"\nCopying data from main data directory: {main_data_file}") - import shutil - - data_dir.mkdir(parents=True, exist_ok=True) - shutil.copy(str(main_data_file), str(input_image_filename)) - print(f"Copied to {input_image_filename}") - return input_image_filename - # Try to download if not found locally - input_image_url = "https://github.com/Slicer-Heart-CT/Slicer-Heart-CT/releases/download/TestingData/TruncalValve_4DCT.seq.nrrd" + input_image_url = "https://github.com/SlicerHeart/SlicerHeart/releases/download/TestingData/TruncalValve_4DCT.seq.nrrd" print(f"\nDownloading TruncalValve 4D CT data from {input_image_url}...") try: urllib.request.urlretrieve(input_image_url, str(input_image_filename)) print(f"Downloaded to {input_image_filename}") - except urllib.error.HTTPError as e: - pytest.skip( - f"Could not download test data: {e}. Please manually place TruncalValve_4DCT.seq.nrrd in {data_dir}" + except urllib.error.URLError as e: + msg = ( + f"Could not download test data: {e}. " + f"Please manually place TruncalValve_4DCT.seq.nrrd in {data_dir}" ) + if os.environ.get("CI"): + pytest.fail(msg) + else: + pytest.skip(msg) return input_image_filename @@ -307,219 +307,114 @@ def download_truncal_valve_data(test_directories): @pytest.fixture(scope="session") -def converted_3d_images(download_truncal_valve_data, test_directories): - """Convert 4D NRRD to 3D time series and return slice files.""" +def test_images( + download_test_data: Path, + test_directories: dict[str, Path], +) -> list[Any]: + """Convert and resample 4D NRRD data; return pre-resampled time points.""" data_dir = test_directories["data"] - output_dir = test_directories["output"] - input_4d_file = download_truncal_valve_data - # Check if conversion already done + # Convert 4D NRRD to 3D time series if not already done slice_000 = data_dir / "slice_000.mha" slice_007 = data_dir / "slice_007.mha" - if not slice_000.exists() or not slice_007.exists(): - # Convert 4D to 3D time series print("\nConverting 4D NRRD to 3D time series...") conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(input_4d_file)) + conv.load_nrrd_4d(str(download_test_data)) conv.save_3d_images(str(data_dir / "slice")) - - # Copy mid-stroke slice as fixed/reference image - fixed_image_output = output_dir / "slice_fixed.mha" - shutil.copyfile(str(slice_007), str(fixed_image_output)) - print(f"Conversion complete, saved fixed image to: {fixed_image_output}") else: print("\n3D slice files already exist") - return data_dir - - -@pytest.fixture(scope="session") -def test_images(converted_3d_images): - """Load time points from the converted 3D data for testing.""" - data_dir = converted_3d_images - - # Load time points - slice_000 = data_dir / "slice_000.mha" - slice_001 = data_dir / "slice_001.mha" - slice_002 = data_dir / "slice_002.mha" - slice_003 = data_dir / "slice_003.mha" - slice_004 = data_dir / "slice_004.mha" - slice_005 = data_dir / "slice_005.mha" - - # Ensure the files exist - if not slice_000.exists() or not slice_001.exists() or not slice_002.exists(): - pytest.skip("Converted 3D slice files not found. Run conversion test first.") - - images = [ - itk.imread(str(slice_000)), - itk.imread(str(slice_001)), - itk.imread(str(slice_002)), - itk.imread(str(slice_003)), - itk.imread(str(slice_004)), - itk.imread(str(slice_005)), - ] - - for i, img in enumerate(images): - resampler = ttk.ResampleImage.New(Input=img) - resampler.SetResampleFactor([0.5, 0.5, 0.5]) - resampler.Update() - images[i] = resampler.GetOutput() - + # Resample each slice_???.mha to 1.5x1.5x1.5 mm and save as slice_???_sml.mha + target_spacing = [1.5, 1.5, 1.5] + for slice_file in sorted(data_dir.glob("slice_???.mha")): + sml_file = slice_file.with_name(slice_file.stem + "_sml.mha") + if not sml_file.exists(): + print(f"\nResampling {slice_file.name} -> {sml_file.name} ...") + img = itk.imread(str(slice_file)) + input_spacing = list(img.GetSpacing()) + input_size = list(itk.size(img)) + output_size = [ + int(round(input_size[i] * input_spacing[i] / target_spacing[i])) + for i in range(3) + ] + interpolator = itk.LinearInterpolateImageFunction.New(img) + resampler = itk.ResampleImageFilter.New(Input=img) + resampler.SetInterpolator(interpolator) + resampler.SetOutputSpacing(target_spacing) + resampler.SetSize(output_size) + resampler.SetOutputOrigin(img.GetOrigin()) + resampler.SetOutputDirection(img.GetDirection()) + resampler.Update() + itk.imwrite(resampler.GetOutput(), str(sml_file), compression=True) + print("\nResampled slice files up to date") + + slice_files = sorted(data_dir.glob("slice_???_sml.mha")) + if len(slice_files) < 3: + pytest.skip("Resampled slice files not found.") + + images = [itk.imread(str(f)) for f in slice_files] print(f"\nLoaded {len(images)} time points for testing") return images -# ============================================================================ -# Segmentation Fixtures -# ============================================================================ - - -@pytest.fixture(scope="session") -def segmenter_total_segmentator(): - """Create a SegmentChestTotalSegmentator instance.""" - return SegmentChestTotalSegmentator() - - -@pytest.fixture(scope="session") -def segmenter_vista_3d(): - """Create a SegmentChestVista3D instance.""" - return SegmentChestVista3D() - - -@pytest.fixture(scope="session") -def segmenter_simpleware(): - """Create a SegmentHeartSimpleware instance.""" - return SegmentHeartSimpleware() - - -@pytest.fixture(scope="session") -def heart_simpleware_image_path(): - """Path to cardiac CT image used by experiments/Heart-Simpleware_Segmentation notebook.""" - # Heart-Simpleware_Segmentation uses same data as the notebook: data/CHOP-Valve4D/CT/RVOT28-Dias.nii.gz - image_path = ( - Path(__file__).resolve().parent.parent - / "data" - / "CHOP-Valve4D" - / "CT" - / "RVOT28-Dias.nii.gz" - ) - if not image_path.exists(): - pytest.skip( - f"Heart Simpleware test data not found: {image_path}. " - "Place RVOT28-Dias.nii.gz there or run from repo with data/CHOP-Valve4D/CT/ populated." - ) - return image_path - - @pytest.fixture(scope="session") -def heart_simpleware_image(heart_simpleware_image_path): - """Load cardiac CT image for SegmentHeartSimpleware tests (same as notebook).""" - return itk.imread(str(heart_simpleware_image_path)) - - -@pytest.fixture(scope="session") -def segmentation_results(segmenter_total_segmentator, test_images, test_directories): +def test_labelmaps( + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + test_directories: dict[str, Path], +) -> list[dict[str, Any]]: """ - Get or create segmentation results using TotalSegmentator. - Used by multiple tests (contour, USD conversion, etc.) + Segment each time point with TotalSegmentator and return result dicts. + Labelmaps are cached at data_dir / slice_???_sml_labelmap.mha. """ - output_dir = test_directories["output"] - seg_output_dir = output_dir / "segmentation_total_segmentator" - - # Check if segmentation files exist - labelmap_000 = seg_output_dir / "slice_000_labelmap.mha" - labelmap_001 = seg_output_dir / "slice_001_labelmap.mha" - - if not labelmap_000.exists() or not labelmap_001.exists(): - # Run segmentation if results don't exist - print("\nSegmentation results not found, generating them...") - seg_output_dir.mkdir(parents=True, exist_ok=True) + data_dir = test_directories["data"] + slice_files = sorted(data_dir.glob("slice_???_sml.mha")) - results = [] - for i, input_image in enumerate(test_images): + results: list[dict[str, Any]] = [] + for img, slice_file in zip(test_images, slice_files): + labelmap_file = data_dir / f"{slice_file.stem}_labelmap.mha" + if not labelmap_file.exists(): + print(f"\nSegmenting {slice_file.name} ...") result = segmenter_total_segmentator.segment( - input_image, contrast_enhanced_study=False + img, contrast_enhanced_study=False ) - results.append(result) - - # Save labelmap - labelmap = result["labelmap"] - output_file = seg_output_dir / f"slice_{i:03d}_labelmap.mha" - itk.imwrite(labelmap, str(output_file), compression=True) - - return results - # Load existing segmentation results - print("\nLoading existing segmentation results...") - results = [] - for i in range(2): - labelmap_file = seg_output_dir / f"slice_{i:03d}_labelmap.mha" - labelmap = itk.imread(str(labelmap_file)) + itk.imwrite(result["labelmap"], str(labelmap_file), compression=True) - # Create anatomy group masks from labelmap + labelmap = itk.imread(str(labelmap_file)) masks = segmenter_total_segmentator.create_anatomy_group_masks(labelmap) - - result = { - "labelmap": labelmap, - "lung": masks["lung"], - "heart": masks["heart"], - "major_vessels": masks["major_vessels"], - "bone": masks["bone"], - "soft_tissue": masks["soft_tissue"], - "other": masks["other"], - "contrast": masks["contrast"], - } - results.append(result) + results.append( + { + "labelmap": labelmap, + "lung": masks["lung"], + "heart": masks["heart"], + "major_vessels": masks["major_vessels"], + "bone": masks["bone"], + "soft_tissue": masks["soft_tissue"], + "other": masks["other"], + "contrast": masks["contrast"], + } + ) return results -# ============================================================================ -# Contour Tool Fixtures -# ============================================================================ - - -@pytest.fixture(scope="session") -def contour_tools(): - """Create a ContourTools instance.""" - return ContourTools() - - -# ============================================================================ -# Registration Fixtures -# ============================================================================ - - -@pytest.fixture(scope="session") -def registrar_ants(): - """Create a RegisterImagesANTs instance.""" - return RegisterImagesANTs() - - -@pytest.fixture(scope="session") -def registrar_greedy(): - """Create a RegisterImagesGreedy instance.""" - return RegisterImagesGreedy() - - @pytest.fixture(scope="session") -def registrar_icon(): - """Create a RegisterImagesICON instance.""" - return RegisterImagesICON() - - -@pytest.fixture(scope="session") -def ants_registration_results(registrar_ants, test_images, test_directories): +def test_transforms( + registrar_ants: RegisterImagesANTs, + test_images: list[Any], + test_directories: dict[str, Path], +) -> dict[str, Any]: """ Perform ANTs registration and return results. Generates them if not already present, otherwise loads from disk. + Transforms are cached in data_dir alongside the slice files. """ - output_dir = test_directories["output"] - reg_output_dir = output_dir / "registration_ants" - reg_output_dir.mkdir(exist_ok=True) + data_dir = test_directories["data"] - inverse_transform_path = reg_output_dir / "ants_inverse_transform_no_mask.hdf" - forward_transform_path = reg_output_dir / "ants_forward_transform_no_mask.hdf" + frame_tag = "001_to_007" + inverse_transform_path = data_dir / f"ants_inverse_transform_{frame_tag}.hdf" + forward_transform_path = data_dir / f"ants_forward_transform_{frame_tag}.hdf" if inverse_transform_path.exists() and forward_transform_path.exists(): print("\nLoading existing ANTs registration results...") @@ -533,13 +428,12 @@ def ants_registration_results(registrar_ants, test_images, test_directories): except (RuntimeError, Exception) as e: print(f"Error loading transforms: {e}") print("Regenerating registration results...") - # Delete corrupt files inverse_transform_path.unlink(missing_ok=True) forward_transform_path.unlink(missing_ok=True) # Perform registration if files don't exist or loading failed print("\nPerforming ANTs registration...") - fixed_image = test_images[0] + fixed_image = test_images[7] moving_image = test_images[1] registrar_ants.set_fixed_image(fixed_image) @@ -557,11 +451,47 @@ def ants_registration_results(registrar_ants, test_images, test_directories): # ============================================================================ -# Transform Tool Fixtures +# Fixtures # ============================================================================ @pytest.fixture(scope="session") -def transform_tools(): +def segmenter_total_segmentator() -> SegmentChestTotalSegmentator: + """Create a SegmentChestTotalSegmentator instance.""" + return SegmentChestTotalSegmentator() + + +@pytest.fixture(scope="session") +def segmenter_simpleware() -> SegmentHeartSimpleware: + """Create a SegmentHeartSimpleware instance.""" + return SegmentHeartSimpleware() + + +@pytest.fixture(scope="session") +def contour_tools() -> ContourTools: + """Create a ContourTools instance.""" + return ContourTools() + + +@pytest.fixture(scope="session") +def registrar_ants() -> RegisterImagesANTs: + """Create a RegisterImagesANTs instance.""" + return RegisterImagesANTs() + + +@pytest.fixture(scope="session") +def registrar_greedy() -> RegisterImagesGreedy: + """Create a RegisterImagesGreedy instance.""" + return RegisterImagesGreedy() + + +@pytest.fixture(scope="session") +def registrar_icon() -> RegisterImagesICON: + """Create a RegisterImagesICON instance.""" + return RegisterImagesICON() + + +@pytest.fixture(scope="session") +def transform_tools() -> TransformTools: """Create a TransformTools instance.""" return TransformTools() diff --git a/tests/test_contour_tools.py b/tests/test_contour_tools.py index d4239fe..42f6efc 100644 --- a/tests/test_contour_tools.py +++ b/tests/test_contour_tools.py @@ -6,32 +6,40 @@ segmentation results to test contour extraction and manipulation. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest import pyvista as pv +from physiomotion4d.contour_tools import ContourTools + @pytest.mark.requires_data @pytest.mark.slow class TestContourTools: """Test suite for ContourTools functionality.""" - def test_contour_tools_initialization(self, contour_tools): + def test_contour_tools_initialization(self, contour_tools: ContourTools) -> None: """Test that ContourTools initializes correctly.""" assert contour_tools is not None, "ContourTools not initialized" - print("\nāœ“ ContourTools initialized successfully") + print("\nContourTools initialized successfully") def test_extract_contours_from_heart_mask( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test extracting contours from heart mask.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" contour_output_dir.mkdir(exist_ok=True) # Extract contours from heart mask of first time point - heart_mask = segmentation_results[0]["heart"] + heart_mask = test_labelmaps[0]["heart"] print("\nExtracting contours from heart mask...") print(f" Heart mask size: {itk.size(heart_mask)}") @@ -43,7 +51,7 @@ def test_extract_contours_from_heart_mask( assert isinstance(contours, pv.PolyData), "Contours should be PyVista PolyData" assert contours.n_points > 0, "Contours should have points" - print("āœ“ Heart contours extracted successfully") + print("Heart contours extracted successfully") print(f" Number of points: {contours.n_points}") print(f" Number of cells: {contours.n_cells}") @@ -53,15 +61,18 @@ def test_extract_contours_from_heart_mask( print(f" Saved to: {output_file}") def test_extract_contours_from_lung_mask( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test extracting contours from lung mask.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" contour_output_dir.mkdir(exist_ok=True) # Extract contours from lung mask - lung_mask = segmentation_results[0]["lung"] + lung_mask = test_labelmaps[0]["lung"] print("\nExtracting contours from lung mask...") contours = contour_tools.extract_contours(lung_mask) @@ -69,7 +80,7 @@ def test_extract_contours_from_lung_mask( assert contours is not None, "Lung contours not extracted" assert contours.n_points > 0, "Lung contours should have points" - print("āœ“ Lung contours extracted successfully") + print("Lung contours extracted successfully") print(f" Number of points: {contours.n_points}") print(f" Number of cells: {contours.n_cells}") @@ -79,8 +90,11 @@ def test_extract_contours_from_lung_mask( print(f" Saved to: {output_file}") def test_extract_contours_multiple_anatomy( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test extracting contours from multiple anatomical structures.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" @@ -88,10 +102,10 @@ def test_extract_contours_multiple_anatomy( # Test on multiple anatomy groups anatomy_groups = ["lung", "heart", "bone"] - contours_dict = {} + contours_dict: dict[str, pv.PolyData] = {} for group in anatomy_groups: - mask = segmentation_results[0][group] + mask = test_labelmaps[0][group] # Check if mask has any foreground voxels mask_arr = itk.array_from_image(mask) @@ -111,18 +125,22 @@ def test_extract_contours_multiple_anatomy( assert len(contours_dict) > 0, ( "Should extract contours from at least one anatomy group" ) - print(f"\nāœ“ Extracted contours from {len(contours_dict)} anatomy groups") + print(f"\nExtracted contours from {len(contours_dict)} anatomy groups") def test_create_mask_from_mesh( - self, contour_tools, segmentation_results, test_images, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test creating a mask from extracted mesh.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" contour_output_dir.mkdir(exist_ok=True) # Extract contours from heart mask - heart_mask = segmentation_results[0]["heart"] + heart_mask = test_labelmaps[0]["heart"] contours = contour_tools.extract_contours(heart_mask) # Create mask from the extracted mesh @@ -141,7 +159,7 @@ def test_create_mask_from_mesh( num_foreground = np.sum(mask_arr > 0) assert num_foreground > 0, "Recreated mask should have foreground voxels" - print("āœ“ Mask created from mesh successfully") + print("Mask created from mesh successfully") print(f" Mask size: {itk.size(recreated_mask)}") print(f" Foreground voxels: {num_foreground}") @@ -150,7 +168,12 @@ def test_create_mask_from_mesh( itk.imwrite(recreated_mask, str(output_file), compression=True) print(f" Saved to: {output_file}") - def test_merge_meshes(self, contour_tools, segmentation_results, test_directories): + def test_merge_meshes( + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test merging multiple meshes.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" @@ -161,7 +184,7 @@ def test_merge_meshes(self, contour_tools, segmentation_results, test_directorie anatomy_groups = ["lung", "heart"] for group in anatomy_groups: - mask = segmentation_results[0][group] + mask = test_labelmaps[0][group] mask_arr = itk.array_from_image(mask) if np.sum(mask_arr > 0) > 100: @@ -184,7 +207,7 @@ def test_merge_meshes(self, contour_tools, segmentation_results, test_directorie "Should return same number of individual meshes" ) - print("āœ“ Meshes merged successfully") + print("Meshes merged successfully") print(f" Merged mesh points: {merged_mesh.n_points}") print(f" Merged mesh cells: {merged_mesh.n_cells}") @@ -196,15 +219,18 @@ def test_merge_meshes(self, contour_tools, segmentation_results, test_directorie pytest.skip("Not enough meshes with sufficient voxels to test merging") def test_transform_contours_identity( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test transforming contours with identity transform.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" contour_output_dir.mkdir(exist_ok=True) # Extract contours - heart_mask = segmentation_results[0]["heart"] + heart_mask = test_labelmaps[0]["heart"] contours = contour_tools.extract_contours(heart_mask) # Create identity transform @@ -230,7 +256,7 @@ def test_transform_contours_identity( transformed_points = transformed_contours.points max_diff = np.max(np.abs(original_points - transformed_points)) - print("āœ“ Contours transformed successfully") + print("Contours transformed successfully") print(f" Number of points: {transformed_contours.n_points}") print(f" Max point difference (identity): {max_diff:.6f} mm") @@ -239,15 +265,18 @@ def test_transform_contours_identity( ) def test_transform_contours_with_deformation( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test transforming contours with deformation magnitude calculation.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" contour_output_dir.mkdir(exist_ok=True) # Extract contours - heart_mask = segmentation_results[0]["heart"] + heart_mask = test_labelmaps[0]["heart"] contours = contour_tools.extract_contours(heart_mask) # Create a translation transform (10mm in each direction) @@ -268,7 +297,7 @@ def test_transform_contours_with_deformation( mean_deformation = np.mean(deformation) expected_deformation = np.sqrt(10**2 + 10**2 + 10**2) # ~17.32 mm - print("āœ“ Deformation magnitude calculated") + print("Deformation magnitude calculated") print(f" Mean deformation: {mean_deformation:.2f} mm") print(f" Expected: {expected_deformation:.2f} mm") @@ -285,8 +314,11 @@ def test_transform_contours_with_deformation( print(" Note: DeformationMagnitude not in point data") def test_contours_from_both_time_points( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test extracting contours from both time points.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" @@ -294,7 +326,7 @@ def test_contours_from_both_time_points( print("\nExtracting heart contours from both time points...") - for i, result in enumerate(segmentation_results): + for i, result in enumerate(test_labelmaps): heart_mask = result["heart"] contours = contour_tools.extract_contours(heart_mask) @@ -304,7 +336,7 @@ def test_contours_from_both_time_points( output_file = contour_output_dir / f"heart_contours_slice{i:03d}.vtp" contours.save(str(output_file)) - print(f"āœ“ Extracted contours from {len(segmentation_results)} time points") + print(f"Extracted contours from {len(test_labelmaps)} time points") if __name__ == "__main__": diff --git a/tests/test_convert_nrrd_4d_to_3d.py b/tests/test_convert_nrrd_4d_to_3d.py index 2d118d8..3ad47cb 100644 --- a/tests/test_convert_nrrd_4d_to_3d.py +++ b/tests/test_convert_nrrd_4d_to_3d.py @@ -6,7 +6,7 @@ from cell 3 of the notebook Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.ipynb. """ -import shutil +from pathlib import Path import pytest @@ -17,70 +17,56 @@ class TestConvertNRRD4DTo3D: """Test suite for converting 4D NRRD to 3D time series.""" - def test_convert_4d_to_3d(self, download_truncal_valve_data, test_directories): + def test_convert_4d_to_3d( + self, + download_test_data: Path, + test_directories: dict[str, Path], + ) -> None: """Test conversion of 4D NRRD to 3D time series (replicates notebook cell 3).""" - data_dir = test_directories["data"] - output_dir = test_directories["output"] - input_4d_file = download_truncal_valve_data + output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + output_dir.mkdir(parents=True, exist_ok=True) + + input_4d_file = download_test_data # Convert 4D to 3D time series print("\nConverting 4D NRRD to 3D time series...") conv = ConvertNRRD4DTo3D() conv.load_nrrd_4d(str(input_4d_file)) - conv.save_3d_images(str(data_dir / "slice")) + conv.save_3d_images(str(output_dir / "slice")) # Verify that slice files were created - slice_007 = data_dir / "slice_007.mha" + slice_007 = output_dir / "slice_007.mha" assert slice_007.exists(), f"Expected slice file not created: {slice_007}" # Count how many slice files were created - slice_files = list(data_dir.glob("slice_*.mha")) - print(f"āœ“ Created {len(slice_files)} slice files") + slice_files = list(output_dir.glob("slice_*.mha")) + print(f"Created {len(slice_files)} slice files") assert len(slice_files) > 0, "No slice files were created" - # Copy mid-stroke slice as fixed/reference image (as in notebook) - fixed_image_output = output_dir / "slice_fixed.mha" - shutil.copyfile(str(slice_007), str(fixed_image_output)) - - assert fixed_image_output.exists(), ( - f"Fixed image not created: {fixed_image_output}" - ) - print(f"āœ“ Fixed/reference image saved to: {fixed_image_output}") - - def test_slice_files_created(self, download_truncal_valve_data, test_directories): + def test_slice_files_created( + self, + download_test_data: Path, + test_directories: dict[str, Path], + ) -> None: """Test that all expected slice files are present after conversion.""" - data_dir = test_directories["data"] + output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + output_dir.mkdir(parents=True, exist_ok=True) # Check that slice files exist - slice_files = list(data_dir.glob("slice_*.mha")) + slice_files = list(output_dir.glob("slice_*.mha")) assert len(slice_files) > 10, ( f"Expected more than 10 slice files, found {len(slice_files)}" ) # Verify specific slice file exists (mid-stroke) - slice_007 = data_dir / "slice_007.mha" + slice_007 = output_dir / "slice_007.mha" assert slice_007.exists(), "Expected slice_007.mha not found" - print(f"\nāœ“ Found {len(slice_files)} slice files") - - def test_fixed_image_output(self, download_truncal_valve_data, test_directories): - """Test that fixed/reference image is copied to output directory.""" - output_dir = test_directories["output"] + print(f"\nFound {len(slice_files)} slice files") - # Check that fixed image was copied to output - fixed_image_path = output_dir / "slice_fixed.mha" - assert fixed_image_path.exists(), "Fixed image not in output directory" - - # Check file size is reasonable - file_size = fixed_image_path.stat().st_size - assert file_size > 100_000, f"Fixed image seems too small: {file_size} bytes" - - print(f"\nāœ“ Fixed/reference image exists: {fixed_image_path}") - print(f" File size: {file_size / 1_000_000:.2f} MB") - - def test_load_nrrd_4d(self, download_truncal_valve_data): + def test_load_nrrd_4d(self, download_test_data: Path) -> None: """Test loading 4D NRRD file.""" - input_4d_file = download_truncal_valve_data + input_4d_file = download_test_data conv = ConvertNRRD4DTo3D() conv.load_nrrd_4d(str(input_4d_file)) @@ -89,12 +75,18 @@ def test_load_nrrd_4d(self, download_truncal_valve_data): assert conv.nrrd_4d is not None, "4D NRRD data not loaded" assert conv.get_number_of_3d_images() > 0, "No time points found in 4D image" - print(f"\nāœ“ Loaded 4D NRRD with {conv.get_number_of_3d_images()} time points") + print(f"\nLoaded 4D NRRD with {conv.get_number_of_3d_images()} time points") - def test_save_3d_images(self, download_truncal_valve_data, test_directories): + def test_save_3d_images( + self, + download_test_data: Path, + test_directories: dict[str, Path], + ) -> None: """Test saving 3D images from 4D NRRD.""" - data_dir = test_directories["data"] - input_4d_file = download_truncal_valve_data + output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + output_dir.mkdir(parents=True, exist_ok=True) + + input_4d_file = download_test_data conv = ConvertNRRD4DTo3D() conv.load_nrrd_4d(str(input_4d_file)) @@ -102,17 +94,17 @@ def test_save_3d_images(self, download_truncal_valve_data, test_directories): num_time_points = conv.get_number_of_3d_images() # Save to a test subdirectory - test_output_prefix = data_dir / "test_slice" + test_output_prefix = output_dir / "test_slice" conv.save_3d_images(str(test_output_prefix)) # Verify files were created - test_slice_files = list(data_dir.glob("test_slice_*.mha")) + test_slice_files = list(output_dir.glob("test_slice_*.mha")) assert len(test_slice_files) > 0, "No test slice files were created" assert len(test_slice_files) == num_time_points, ( f"Expected {num_time_points} files, found {len(test_slice_files)}" ) - print(f"\nāœ“ Saved {len(test_slice_files)} 3D images") + print(f"\nSaved {len(test_slice_files)} 3D images") # Clean up test files for test_file in test_slice_files: diff --git a/tests/test_convert_vtk_to_usd.py b/tests/test_convert_vtk_to_usd.py index cb93ba4..0bdc253 100644 --- a/tests/test_convert_vtk_to_usd.py +++ b/tests/test_convert_vtk_to_usd.py @@ -6,12 +6,16 @@ to test USD conversion functionality. """ +from pathlib import Path +from typing import Any + import itk import pytest import pyvista as pv from pxr import UsdGeom from physiomotion4d import ConvertVTKToUSD +from physiomotion4d.contour_tools import ContourTools @pytest.mark.requires_data @@ -20,7 +24,12 @@ class TestConvertVTKToUSD: """Test suite for VTK to USD PolyMesh conversion.""" @pytest.fixture(scope="class") - def contour_meshes(self, contour_tools, segmentation_results, test_directories): + def contour_meshes( + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> list[Any]: """Extract or load contour meshes for USD conversion testing.""" output_dir = test_directories["output"] contour_output_dir = output_dir / "contour_tools" @@ -35,7 +44,7 @@ def contour_meshes(self, contour_tools, segmentation_results, test_directories): contour_output_dir.mkdir(parents=True, exist_ok=True) meshes = [] - for i, result in enumerate(segmentation_results): + for i, result in enumerate(test_labelmaps): heart_mask = result["heart"] contours = contour_tools.extract_contours(heart_mask) meshes.append(contours) @@ -53,7 +62,7 @@ def contour_meshes(self, contour_tools, segmentation_results, test_directories): ] return meshes - def test_converter_initialization(self): + def test_converter_initialization(self) -> None: """Test that ConvertVTKToUSD initializes correctly.""" converter = ConvertVTKToUSD( data_basename="TestModel", input_polydata=[], mask_ids=None @@ -64,7 +73,7 @@ def test_converter_initialization(self): print("\nConverter initialized successfully") - def test_supports_mesh_type(self, contour_meshes): + def test_supports_mesh_type(self, contour_meshes: list[Any]) -> None: """Test that converter correctly identifies supported mesh types.""" mesh = contour_meshes[0] @@ -77,7 +86,9 @@ def test_supports_mesh_type(self, contour_meshes): print("\nMesh type support check passed") - def test_convert_single_time_point(self, contour_meshes, test_directories): + def test_convert_single_time_point( + self, contour_meshes: list[Any], test_directories: dict[str, Path] + ) -> None: """Test converting a single time point to USD.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -108,7 +119,9 @@ def test_convert_single_time_point(self, contour_meshes, test_directories): print(f" Output: {output_file}") print(f" File size: {output_file.stat().st_size / 1024:.2f} KB") - def test_convert_multiple_time_points(self, contour_meshes, test_directories): + def test_convert_multiple_time_points( + self, contour_meshes: list[Any], test_directories: dict[str, Path] + ) -> None: """Test converting multiple time points to USD.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -142,15 +155,18 @@ def test_convert_multiple_time_points(self, contour_meshes, test_directories): print(f" File size: {output_file.stat().st_size / 1024:.2f} KB") def test_convert_with_deformation( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test converting meshes with deformation magnitude.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" usd_output_dir.mkdir(exist_ok=True) # Extract contours - heart_mask = segmentation_results[0]["heart"] + heart_mask = test_labelmaps[0]["heart"] contours = contour_tools.extract_contours(heart_mask) # Add deformation magnitude (simulate with random values) @@ -179,7 +195,9 @@ def test_convert_with_deformation( print("Mesh with deformation converted to USD") print(f" Output: {output_file}") - def test_convert_with_colormap(self, contour_meshes, test_directories): + def test_convert_with_colormap( + self, contour_meshes: list[Any], test_directories: dict[str, Path] + ) -> None: """Test converting meshes with colormap visualization.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -216,7 +234,9 @@ def test_convert_with_colormap(self, contour_meshes, test_directories): print(" Colormap: plasma") print(f" Output: {output_file}") - def test_convert_unstructured_grid_to_surface(self, test_directories): + def test_convert_unstructured_grid_to_surface( + self, test_directories: dict[str, Path] + ) -> None: """Test converting UnstructuredGrid to surface mesh.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -262,7 +282,9 @@ def test_convert_unstructured_grid_to_surface(self, test_directories): print("UnstructuredGrid converted to surface USD") print(f" Output: {output_file}") - def test_usd_file_structure(self, contour_meshes, test_directories): + def test_usd_file_structure( + self, contour_meshes: list[Any], test_directories: dict[str, Path] + ) -> None: """Test the structure of generated USD file.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -292,7 +314,9 @@ def test_usd_file_structure(self, contour_meshes, test_directories): print(f" Root: {root_prim.GetPath()}") print(f" Mesh: {mesh_prim.GetPath()}") - def test_time_varying_topology(self, contour_meshes, test_directories): + def test_time_varying_topology( + self, contour_meshes: list[Any], test_directories: dict[str, Path] + ) -> None: """Test handling of time-varying topology.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -332,8 +356,11 @@ def test_time_varying_topology(self, contour_meshes, test_directories): print(f" Output: {output_file}") def test_batch_conversion( - self, contour_tools, segmentation_results, test_directories - ): + self, + contour_tools: ContourTools, + test_labelmaps: list[dict[str, Any]], + test_directories: dict[str, Path], + ) -> None: """Test converting multiple anatomy structures in batch.""" output_dir = test_directories["output"] usd_output_dir = output_dir / "usd_polymesh" @@ -344,7 +371,7 @@ def test_batch_conversion( meshes_dict = {} for group in anatomy_groups: - mask = segmentation_results[0][group] + mask = test_labelmaps[0][group] mask_arr = itk.array_from_image(mask) import numpy as np diff --git a/tests/test_download_heart_data.py b/tests/test_download_heart_data.py index 0c45e95..421ce1e 100644 --- a/tests/test_download_heart_data.py +++ b/tests/test_download_heart_data.py @@ -6,6 +6,8 @@ Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.ipynb. """ +from pathlib import Path + import pytest @@ -13,7 +15,7 @@ class TestDownloadHeartData: """Test suite for downloading and converting Slicer-Heart-CT data.""" - def test_directories_created(self, test_directories): + def test_directories_created(self, test_directories: dict[str, Path]) -> None: """Test that directories are created successfully.""" data_dir = test_directories["data"] output_dir = test_directories["output"] @@ -23,9 +25,13 @@ def test_directories_created(self, test_directories): assert data_dir.is_dir(), f"Data path is not a directory: {data_dir}" assert output_dir.is_dir(), f"Output path is not a directory: {output_dir}" - def test_data_downloaded(self, download_truncal_valve_data, test_directories): + def test_data_downloaded( + self, + download_test_data: Path, + test_directories: dict[str, Path], + ) -> None: """Test that the TruncalValve 4D CT data file is downloaded.""" - data_file = download_truncal_valve_data + data_file = download_test_data assert data_file.exists(), f"Data file not found: {data_file}" assert data_file.is_file(), f"Data path is not a file: {data_file}" @@ -36,7 +42,7 @@ def test_data_downloaded(self, download_truncal_valve_data, test_directories): f"Downloaded file seems too small: {file_size} bytes" ) - print(f"\nāœ“ Data file downloaded successfully: {data_file}") + print(f"\nData file downloaded successfully: {data_file}") print(f" File size: {file_size / 1_000_000:.2f} MB") diff --git a/tests/test_experiments.py b/tests/test_experiments.py index 341f324..eb8407f 100644 --- a/tests/test_experiments.py +++ b/tests/test_experiments.py @@ -30,6 +30,7 @@ import subprocess import sys from pathlib import Path +from typing import Any import pytest @@ -70,7 +71,7 @@ def get_scripts_in_subdir(subdir_name: str) -> list[Path]: return scripts -def execute_script(script_path: Path, timeout: int = 3600) -> dict: +def execute_script(script_path: Path, timeout: int = 3600) -> dict[str, Any]: """ Execute a Python experiment script. @@ -172,7 +173,7 @@ def _heart_statistical_model_pca_prerequisites_met() -> tuple[bool, str]: return (True, "") -def run_experiment_scripts(subdir_name: str, timeout_per_script: int = 3600): +def run_experiment_scripts(subdir_name: str, timeout_per_script: int = 3600) -> None: """ Run all Python scripts in an experiment subdirectory in alphanumeric order. @@ -203,8 +204,8 @@ def run_experiment_scripts(subdir_name: str, timeout_per_script: int = 3600): print("# Sequential execution enforced (scripts run in order)") print(f"{'#' * 80}\n") - failed_scripts = [] - successful_scripts = [] + failed_scripts: list[dict[str, Any]] = [] + successful_scripts: list[str] = [] for i, script in enumerate(scripts, 1): print(f"\n--- Script {i}/{len(scripts)} ---") @@ -290,11 +291,10 @@ def run_experiment_scripts(subdir_name: str, timeout_per_script: int = 3600): @pytest.mark.experiment @pytest.mark.slow -@pytest.mark.timeout(7200) # 2 hours total timeout @pytest.mark.xdist_group( name="experiment_colormap" ) # Prevent parallel execution within group -def test_experiment_colormap_vtk_to_usd(): +def test_experiment_colormap_vtk_to_usd() -> None: """ Test Colormap-VTK_To_USD experiment scripts. @@ -312,7 +312,6 @@ def test_experiment_colormap_vtk_to_usd(): # DISABLED - Scripts not ready # @pytest.mark.experiment # @pytest.mark.slow -# @pytest.mark.timeout(7200) # 2 hours total timeout # def test_experiment_displacement_field_to_usd(): # """ # Test DisplacementField_To_USD experiment scripts. @@ -326,9 +325,8 @@ def test_experiment_colormap_vtk_to_usd(): @pytest.mark.experiment @pytest.mark.slow @pytest.mark.requires_gpu -@pytest.mark.timeout(14400) # 4 hours total timeout @pytest.mark.xdist_group(name="experiment_reconstruct4dct") -def test_experiment_reconstruct_4dct(): +def test_experiment_reconstruct_4dct() -> None: """ Test Reconstruct4DCT experiment scripts. @@ -345,9 +343,8 @@ def test_experiment_reconstruct_4dct(): @pytest.mark.experiment @pytest.mark.slow @pytest.mark.requires_data -@pytest.mark.timeout(10800) # 3 hours total timeout @pytest.mark.xdist_group(name="experiment_heart_vtk") -def test_experiment_heart_vtk_series_to_usd(): +def test_experiment_heart_vtk_series_to_usd() -> None: """ Test Heart-VTKSeries_To_USD experiment scripts. @@ -366,9 +363,8 @@ def test_experiment_heart_vtk_series_to_usd(): @pytest.mark.slow @pytest.mark.requires_gpu @pytest.mark.requires_data -@pytest.mark.timeout(21600) # 6 hours total timeout @pytest.mark.xdist_group(name="experiment_heart_gated_ct") -def test_experiment_heart_gated_ct_to_usd(): +def test_experiment_heart_gated_ct_to_usd() -> None: """ Test Heart-GatedCT_To_USD experiment scripts. @@ -380,8 +376,6 @@ def test_experiment_heart_gated_ct_to_usd(): 3. 2-generate_segmentation.py (segments registered images) 4. 3-transform_dynamic_and_static_contours.py (transforms segmentations) 5. 4-merge_dynamic_and_static_usd.py (merges into final USD) - 6. test_vista3d_class.py (tests segmentation class) - 7. test_vista3d_inMem.py (tests in-memory segmentation) Each script depends on outputs from previous scripts. Execution stops on first failure to prevent cascading errors. @@ -392,9 +386,8 @@ def test_experiment_heart_gated_ct_to_usd(): @pytest.mark.experiment @pytest.mark.slow @pytest.mark.requires_data -@pytest.mark.timeout(7200) # 2 hours total timeout @pytest.mark.xdist_group(name="experiment_convert_vtk_to_usd") -def test_experiment_convert_vtk_to_usd(): +def test_experiment_convert_vtk_to_usd() -> None: """ Test Convert_VTK_To_USD experiment scripts. @@ -412,9 +405,8 @@ def test_experiment_convert_vtk_to_usd(): @pytest.mark.experiment @pytest.mark.slow @pytest.mark.requires_data -@pytest.mark.timeout(10800) # 3 hours total timeout @pytest.mark.xdist_group(name="experiment_create_statistical_model") -def test_experiment_create_statistical_model(): +def test_experiment_create_statistical_model() -> None: """ Test Heart-Create_Statistical_Model experiment scripts. @@ -437,9 +429,8 @@ def test_experiment_create_statistical_model(): @pytest.mark.slow @pytest.mark.requires_gpu @pytest.mark.requires_data -@pytest.mark.timeout(14400) # 4 hours total timeout @pytest.mark.xdist_group(name="experiment_heart_statistical_model") -def test_experiment_heart_statistical_model_to_patient(): +def test_experiment_heart_statistical_model_to_patient() -> None: """ Test Heart-Statistical_Model_To_Patient experiment scripts. @@ -472,9 +463,8 @@ def test_experiment_heart_statistical_model_to_patient(): @pytest.mark.slow @pytest.mark.requires_gpu @pytest.mark.requires_data -@pytest.mark.timeout(21600) # 6 hours total timeout @pytest.mark.xdist_group(name="experiment_lung_gated_ct") -def test_experiment_lung_gated_ct_to_usd(): +def test_experiment_lung_gated_ct_to_usd() -> None: """ Test Lung-GatedCT_To_USD experiment scripts. @@ -500,7 +490,6 @@ def test_experiment_lung_gated_ct_to_usd(): # @pytest.mark.slow # @pytest.mark.requires_gpu # @pytest.mark.requires_data -# @pytest.mark.timeout(7200) # 2 hours total timeout # def test_experiment_lung_vessels_airways(): # """ # Test Lung-VesselsAirways experiment scripts. @@ -519,7 +508,7 @@ def test_experiment_lung_gated_ct_to_usd(): @pytest.mark.experiment -def test_experiment_structure(): +def test_experiment_structure() -> None: """ Validate the structure of the experiments directory. @@ -573,7 +562,7 @@ def test_experiment_structure(): @pytest.mark.experiment @pytest.mark.parametrize("subdir_name", EXPERIMENT_SUBDIRS) -def test_list_scripts_in_subdir(subdir_name): +def test_list_scripts_in_subdir(subdir_name: str) -> None: """ List all scripts in each experiment subdirectory. diff --git a/tests/test_image_tools.py b/tests/test_image_tools.py index eb9d11c..0afcb8d 100644 --- a/tests/test_image_tools.py +++ b/tests/test_image_tools.py @@ -8,6 +8,9 @@ from __future__ import annotations +from pathlib import Path +from typing import Any, Optional + import itk import numpy as np import pytest @@ -57,7 +60,7 @@ def test_itk_to_sitk_scalar_image(self, image_tools: ImageTools) -> None: array_sitk = sitk.GetArrayFromImage(sitk_image) assert np.allclose(array_sitk, 42.0) - print("āœ“ ITK to SimpleITK scalar conversion successful") + print("ITK to SimpleITK scalar conversion successful") def test_sitk_to_itk_scalar_image(self, image_tools: ImageTools) -> None: """Test conversion of scalar SimpleITK image to ITK.""" @@ -89,7 +92,7 @@ def test_sitk_to_itk_scalar_image(self, image_tools: ImageTools) -> None: array_itk = itk.array_from_image(itk_image) assert np.allclose(array_itk, 99.0) - print("āœ“ SimpleITK to ITK scalar conversion successful") + print("SimpleITK to ITK scalar conversion successful") def test_roundtrip_scalar_image(self, image_tools: ImageTools) -> None: """Test roundtrip conversion: ITK -> SimpleITK -> ITK.""" @@ -125,7 +128,7 @@ def test_roundtrip_scalar_image(self, image_tools: ImageTools) -> None: array_final = itk.array_from_image(itk_image_final) assert np.allclose(array_original, array_final) - print("āœ“ Roundtrip scalar conversion successful") + print("Roundtrip scalar conversion successful") def test_itk_to_sitk_vector_image(self, image_tools: ImageTools) -> None: """Test conversion of vector ITK image to SimpleITK.""" @@ -163,7 +166,7 @@ def test_itk_to_sitk_vector_image(self, image_tools: ImageTools) -> None: array_sitk = sitk.GetArrayFromImage(sitk_image) assert np.allclose(array, array_sitk) - print("āœ“ ITK to SimpleITK vector conversion successful") + print("ITK to SimpleITK vector conversion successful") def test_sitk_to_itk_vector_image(self, image_tools: ImageTools) -> None: """Test conversion of vector SimpleITK image to ITK.""" @@ -194,7 +197,7 @@ def test_sitk_to_itk_vector_image(self, image_tools: ImageTools) -> None: array_itk = itk.array_from_image(itk_image) assert np.allclose(array, array_itk) - print("āœ“ SimpleITK to ITK vector conversion successful") + print("SimpleITK to ITK vector conversion successful") def test_roundtrip_vector_image(self, image_tools: ImageTools) -> None: """Test roundtrip conversion for vector images: ITK -> SimpleITK -> ITK.""" @@ -231,16 +234,16 @@ def test_roundtrip_vector_image(self, image_tools: ImageTools) -> None: array_final = itk.array_from_image(itk_image_final) assert np.allclose(array_original, array_final) - print("āœ“ Roundtrip vector conversion successful") + print("Roundtrip vector conversion successful") @pytest.mark.requires_data @pytest.mark.slow def test_imwrite_imread_vd3( self, image_tools: ImageTools, - ants_registration_results: dict, - test_images: list, - test_directories: dict, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], ) -> None: """Test reading and writing double precision vector images.""" from physiomotion4d.transform_tools import TransformTools @@ -250,7 +253,7 @@ def test_imwrite_imread_vd3( img_output_dir.mkdir(exist_ok=True) fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTesting imwriteVD3 and imreadVD3...") @@ -294,7 +297,7 @@ def test_imwrite_imread_vd3( max_diff = np.max(np.abs(read_arr - original_arr)) mean_diff = np.mean(np.abs(read_arr - original_arr)) - print("āœ“ Vector field I/O test complete") + print("Vector field I/O test complete") print(f" Max difference: {max_diff:.6e}") print(f" Mean difference: {mean_diff:.6e}") @@ -305,9 +308,9 @@ def test_imwrite_imread_vd3( def _make_synthetic_itk_image( shape_xyz: tuple[int, int, int], - arr: np.ndarray | None = None, - direction: np.ndarray | None = None, -) -> itk.Image[itk.F, 3]: + arr: Optional[np.ndarray] = None, + direction: Optional[np.ndarray] = None, +) -> Any: """Create a small 3D ITK image. shape_xyz is (nx, ny, nz); array from ITK is (nz, ny, nx).""" # ITK uses (x, y, z) for size; array_from_image gives (z, y, x) if arr is None: diff --git a/tests/test_register_images_ants.py b/tests/test_register_images_ants.py index edcc712..5f95755 100644 --- a/tests/test_register_images_ants.py +++ b/tests/test_register_images_ants.py @@ -8,12 +8,15 @@ import os import tempfile +from pathlib import Path +from typing import Any import ants import itk import numpy as np import pytest +from physiomotion4d.register_images_ants import RegisterImagesANTs from physiomotion4d.transform_tools import TransformTools @@ -22,15 +25,15 @@ class TestRegisterImagesANTs: """Test suite for ANTs-based image registration.""" - def test_registrar_initialization(self, registrar_ants): + def test_registrar_initialization(self, registrar_ants: RegisterImagesANTs) -> None: """Test that RegisterImagesANTs initializes correctly.""" assert registrar_ants is not None, "Registrar not initialized" assert hasattr(registrar_ants, "fixed_image"), "Missing fixed_image attribute" assert hasattr(registrar_ants, "fixed_mask"), "Missing fixed_mask attribute" - print("\nāœ“ ANTs registrar initialized successfully") + print("\nANTs registrar initialized successfully") - def test_set_modality(self, registrar_ants): + def test_set_modality(self, registrar_ants: RegisterImagesANTs) -> None: """Test setting imaging modality.""" registrar_ants.set_modality("ct") assert registrar_ants.modality == "ct", "Modality not set correctly" @@ -38,20 +41,27 @@ def test_set_modality(self, registrar_ants): registrar_ants.set_modality("mr") assert registrar_ants.modality == "mr", "Modality change failed" - print("\nāœ“ Modality setting works correctly") + print("\nModality setting works correctly") - def test_set_fixed_image(self, registrar_ants, test_images): + def test_set_fixed_image( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test setting fixed image.""" fixed_image = test_images[0] registrar_ants.set_fixed_image(fixed_image) assert registrar_ants.fixed_image is not None, "Fixed image not set" - print("\nāœ“ Fixed image set successfully") + print("\nFixed image set successfully") print(f" Image size: {itk.size(registrar_ants.fixed_image)}") print(f" Image spacing: {itk.spacing(registrar_ants.fixed_image)}") - def test_register_without_mask(self, registrar_ants, test_images, test_directories): + def test_register_without_mask( + self, + registrar_ants: RegisterImagesANTs, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test basic registration without masks.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_ants" @@ -83,7 +93,7 @@ def test_register_without_mask(self, registrar_ants, test_images, test_directori assert inverse_transform is not None, "inverse_transform is None" assert forward_transform is not None, "forward_transform is None" - print("āœ“ Registration complete without mask") + print("Registration complete without mask") print(f" inverse_transform type: {type(inverse_transform).__name__}") print(f" forward_transform type: {type(forward_transform).__name__}") @@ -100,7 +110,12 @@ def test_register_without_mask(self, registrar_ants, test_images, test_directori ) print(f" Saved transforms to: {reg_output_dir}") - def test_register_with_mask(self, registrar_ants, test_images, test_directories): + def test_register_with_mask( + self, + registrar_ants: RegisterImagesANTs, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test registration with binary masks.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_ants" @@ -174,7 +189,7 @@ def test_register_with_mask(self, registrar_ants, test_images, test_directories) assert inverse_transform is not None, "inverse_transform is None" assert forward_transform is not None, "forward_transform is None" - print("āœ“ Registration complete with masks") + print("Registration complete with masks") # Save transforms itk.transformwrite( @@ -188,7 +203,12 @@ def test_register_with_mask(self, registrar_ants, test_images, test_directories) compression=True, ) - def test_transform_application(self, registrar_ants, test_images, test_directories): + def test_transform_application( + self, + registrar_ants: RegisterImagesANTs, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test applying registration transforms to images.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_ants" @@ -225,7 +245,7 @@ def test_transform_application(self, registrar_ants, test_images, test_directori np.abs(moving_arr.astype(float) - registered_arr.astype(float)) ) - print("āœ“ Transform applied successfully") + print("Transform applied successfully") print(f" Registered image size: {itk.size(registered_image)}") print(f" Total difference: {difference:.2f}") @@ -237,7 +257,9 @@ def test_transform_application(self, registrar_ants, test_images, test_directori ) print(f" Saved to: {reg_output_dir / 'ants_registered_image.mha'}") - def test_preprocess_images(self, registrar_ants, test_images): + def test_preprocess_images( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test image preprocessing.""" test_image = test_images[0] @@ -250,12 +272,15 @@ def test_preprocess_images(self, registrar_ants, test_images): assert preprocessed is not None, "Preprocessed image is None" preprocessed_spacing = itk.spacing(preprocessed) - print("āœ“ Image preprocessing complete") + print("Image preprocessing complete") print(f" Preprocessed spacing: {preprocessed_spacing}") def test_registration_with_initial_transform( - self, registrar_ants, test_images, test_directories - ): + self, + registrar_ants: RegisterImagesANTs, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test registration with initial transform.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_ants" @@ -283,9 +308,11 @@ def test_registration_with_initial_transform( assert result["inverse_transform"] is not None, "inverse_transform is None" assert result["forward_transform"] is not None, "forward_transform is None" - print("āœ“ Registration with initial transform complete") + print("Registration with initial transform complete") - def test_multiple_registrations(self, registrar_ants, test_images): + def test_multiple_registrations( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test running multiple registrations in sequence.""" fixed_image = test_images[0] moving_image = test_images[1] @@ -309,9 +336,11 @@ def test_multiple_registrations(self, registrar_ants, test_images): f"Missing forward_transform in result {i + 1}" ) - print(f"āœ“ Multiple registrations complete: {len(results)} runs") + print(f"Multiple registrations complete: {len(results)} runs") - def test_transform_types(self, registrar_ants, test_images): + def test_transform_types( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test that transforms are correct ITK types.""" fixed_image = test_images[0] moving_image = test_images[1] @@ -333,11 +362,13 @@ def test_transform_types(self, registrar_ants, test_images): f"forward_transform should be CompositeTransform, got {type(forward_transform)}" ) - print("āœ“ Transform types verified") + print("Transform types verified") print(f" inverse_transform: {type(inverse_transform).__name__}") print(f" forward_transform: {type(forward_transform).__name__}") - def test_image_conversion_cycle_scalar(self, registrar_ants, test_images): + def test_image_conversion_cycle_scalar( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test round-trip conversion: ITK image -> ANTs -> ITK for scalar images.""" original_image = test_images[0] @@ -409,9 +440,11 @@ def test_image_conversion_cycle_scalar(self, registrar_ants, test_images): print(f" Max direction difference: {direction_diff}") assert direction_diff < 1e-6, f"Direction differs: {direction_diff}" - print("āœ“ Scalar image conversion cycle successful") + print("Scalar image conversion cycle successful") - def test_image_conversion_cycle_different_dtypes(self, registrar_ants, test_images): + def test_image_conversion_cycle_different_dtypes( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test round-trip conversion with different data types.""" original_image = test_images[0] @@ -437,11 +470,13 @@ def test_image_conversion_cycle_different_dtypes(self, registrar_ants, test_imag f"Size mismatch for dtype {dtype}" ) - print(f" āœ“ {dtype} conversion successful") + print(f" {dtype} conversion successful") - print("āœ“ All dtype conversions successful") + print("All dtype conversions successful") - def test_image_conversion_preserves_metadata(self, registrar_ants): + def test_image_conversion_preserves_metadata( + self, registrar_ants: RegisterImagesANTs + ) -> None: """Test that image conversion preserves all metadata.""" print("\nTesting metadata preservation in image conversion...") @@ -484,9 +519,11 @@ def test_image_conversion_preserves_metadata(self, registrar_ants): f"Origin mismatch: {recovered_origin} vs {origin}" ) - print("āœ“ Metadata preservation verified") + print("Metadata preservation verified") - def test_transform_conversion_cycle_affine(self, registrar_ants, test_images): + def test_transform_conversion_cycle_affine( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test round-trip conversion: ITK affine transform -> ANTs -> ITK.""" reference_image = test_images[0] @@ -588,11 +625,11 @@ def test_transform_conversion_cycle_affine(self, registrar_ants, test_images): # Allow some tolerance due to displacement field discretization assert max_point_diff < 0.5, f"Transform difference too large: {max_point_diff}" - print("āœ“ Affine transform conversion cycle successful") + print("Affine transform conversion cycle successful") def test_transform_conversion_cycle_displacement_field( - self, registrar_ants, test_images - ): + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test round-trip conversion: ITK displacement field -> ANTs -> ITK.""" reference_image = test_images[0] @@ -672,9 +709,11 @@ def test_transform_conversion_cycle_displacement_field( assert max_diff < 1.0, f"Displacement field difference too large: {max_diff}" assert mean_diff < 0.1, f"Mean displacement difference too large: {mean_diff}" - print("āœ“ Displacement field transform conversion cycle successful") + print("Displacement field transform conversion cycle successful") - def test_transform_conversion_with_composite(self, registrar_ants, test_images): + def test_transform_conversion_with_composite( + self, registrar_ants: RegisterImagesANTs, test_images: list[Any] + ) -> None: """Test conversion of composite transforms.""" reference_image = test_images[0] @@ -738,7 +777,7 @@ def test_transform_conversion_with_composite(self, registrar_ants, test_images): pt_transformed = composite_tfm.TransformPoint(pt_itk) print(f" Point {pt_coords} -> {[pt_transformed[i] for i in range(3)]}") - print("āœ“ Composite transform conversion successful") + print("Composite transform conversion successful") if __name__ == "__main__": diff --git a/tests/test_register_images_greedy.py b/tests/test_register_images_greedy.py index b757bba..ca8397c 100644 --- a/tests/test_register_images_greedy.py +++ b/tests/test_register_images_greedy.py @@ -6,10 +6,14 @@ Requires the picsl-greedy package and test data. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest +from physiomotion4d.register_images_greedy import RegisterImagesGreedy from physiomotion4d.transform_tools import TransformTools @@ -18,15 +22,17 @@ class TestRegisterImagesGreedy: """Test suite for Greedy-based image registration.""" - def test_registrar_initialization(self, registrar_greedy) -> None: + def test_registrar_initialization( + self, registrar_greedy: RegisterImagesGreedy + ) -> None: """Test that RegisterImagesGreedy initializes correctly.""" assert registrar_greedy is not None, "Registrar not initialized" assert hasattr(registrar_greedy, "fixed_image"), "Missing fixed_image attribute" assert hasattr(registrar_greedy, "fixed_mask"), "Missing fixed_mask attribute" - print("\nāœ“ Greedy registrar initialized successfully") + print("\nGreedy registrar initialized successfully") - def test_set_modality(self, registrar_greedy) -> None: + def test_set_modality(self, registrar_greedy: RegisterImagesGreedy) -> None: """Test setting imaging modality.""" registrar_greedy.set_modality("ct") assert registrar_greedy.modality == "ct", "Modality not set correctly" @@ -34,9 +40,11 @@ def test_set_modality(self, registrar_greedy) -> None: registrar_greedy.set_modality("mr") assert registrar_greedy.modality == "mr", "Modality change failed" - print("\nāœ“ Modality setting works correctly") + print("\nModality setting works correctly") - def test_set_transform_type_and_metric(self, registrar_greedy) -> None: + def test_set_transform_type_and_metric( + self, registrar_greedy: RegisterImagesGreedy + ) -> None: """Test setting transform type and metric.""" registrar_greedy.set_transform_type("Rigid") assert registrar_greedy.transform_type == "Rigid" @@ -59,19 +67,24 @@ def test_set_transform_type_and_metric(self, registrar_greedy) -> None: with pytest.raises(ValueError, match="Invalid metric"): registrar_greedy.set_metric("Invalid") - print("\nāœ“ Transform type and metric setting work correctly") + print("\nTransform type and metric setting work correctly") - def test_set_fixed_image(self, registrar_greedy, test_images) -> None: + def test_set_fixed_image( + self, registrar_greedy: RegisterImagesGreedy, test_images: list[Any] + ) -> None: """Test setting fixed image.""" fixed_image = test_images[0] registrar_greedy.set_fixed_image(fixed_image) assert registrar_greedy.fixed_image is not None, "Fixed image not set" - print("\nāœ“ Fixed image set successfully") + print("\nFixed image set successfully") print(f" Image size: {itk.size(registrar_greedy.fixed_image)}") def test_register_affine_without_mask( - self, registrar_greedy, test_images, test_directories + self, + registrar_greedy: RegisterImagesGreedy, + test_images: list[Any], + test_directories: dict[str, Path], ) -> None: """Test affine registration without masks.""" output_dir = test_directories["output"] @@ -99,7 +112,7 @@ def test_register_affine_without_mask( assert inverse_transform is not None, "inverse_transform is None" assert forward_transform is not None, "forward_transform is None" - print("āœ“ Greedy affine registration complete without mask") + print("Greedy affine registration complete without mask") itk.transformwrite( [inverse_transform], @@ -113,7 +126,10 @@ def test_register_affine_without_mask( ) def test_register_affine_with_mask( - self, registrar_greedy, test_images, test_directories + self, + registrar_greedy: RegisterImagesGreedy, + test_images: list[Any], + test_directories: dict[str, Path], ) -> None: """Test affine registration with binary masks.""" output_dir = test_directories["output"] @@ -167,10 +183,13 @@ def test_register_affine_with_mask( assert result["inverse_transform"] is not None assert result["forward_transform"] is not None - print("āœ“ Greedy affine registration complete with masks") + print("Greedy affine registration complete with masks") def test_transform_application( - self, registrar_greedy, test_images, test_directories + self, + registrar_greedy: RegisterImagesGreedy, + test_images: list[Any], + test_directories: dict[str, Path], ) -> None: """Test applying registration transform to moving image.""" output_dir = test_directories["output"] @@ -200,7 +219,7 @@ def test_transform_application( np.abs(moving_arr.astype(float) - registered_arr.astype(float)) ) - print("āœ“ Greedy transform applied successfully") + print("Greedy transform applied successfully") print(f" Registered image size: {itk.size(registered_image)}") print(f" Total difference: {difference:.2f}") diff --git a/tests/test_register_images_icon.py b/tests/test_register_images_icon.py index 80c7824..aeb63e2 100644 --- a/tests/test_register_images_icon.py +++ b/tests/test_register_images_icon.py @@ -7,10 +7,14 @@ Note: ICON requires CUDA-enabled GPU. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest +from physiomotion4d.register_images_icon import RegisterImagesICON from physiomotion4d.transform_tools import TransformTools @@ -19,7 +23,7 @@ class TestRegisterImagesICON: """Test suite for ICON-based image registration.""" - def test_registrar_initialization(self, registrar_icon): + def test_registrar_initialization(self, registrar_icon: RegisterImagesICON) -> None: """Test that RegisterImagesICON initializes correctly.""" assert registrar_icon is not None, "Registrar not initialized" assert hasattr(registrar_icon, "fixed_image"), "Missing fixed_image attribute" @@ -32,7 +36,7 @@ def test_registrar_initialization(self, registrar_icon): print("\nICON registrar initialized successfully") print(f" Default iterations: {registrar_icon.number_of_iterations}") - def test_set_modality(self, registrar_icon): + def test_set_modality(self, registrar_icon: RegisterImagesICON) -> None: """Test setting imaging modality.""" registrar_icon.set_modality("ct") assert registrar_icon.modality == "ct", "Modality not set correctly" @@ -42,7 +46,7 @@ def test_set_modality(self, registrar_icon): print("\nModality setting works correctly") - def test_set_number_of_iterations(self, registrar_icon): + def test_set_number_of_iterations(self, registrar_icon: RegisterImagesICON) -> None: """Test setting number of iterations.""" registrar_icon.set_number_of_iterations(10) assert registrar_icon.number_of_iterations == 10, "Number of iterations not set" @@ -54,7 +58,9 @@ def test_set_number_of_iterations(self, registrar_icon): print("\nNumber of iterations setting works correctly") - def test_set_fixed_image(self, registrar_icon, test_images): + def test_set_fixed_image( + self, registrar_icon: RegisterImagesICON, test_images: list[Any] + ) -> None: """Test setting fixed image.""" fixed_image = test_images[0] @@ -65,7 +71,7 @@ def test_set_fixed_image(self, registrar_icon, test_images): print(f" Image size: {itk.size(registrar_icon.fixed_image)}") print(f" Image spacing: {itk.spacing(registrar_icon.fixed_image)}") - def test_set_mass_preservation(self, registrar_icon): + def test_set_mass_preservation(self, registrar_icon: RegisterImagesICON) -> None: """Test setting mass preservation flag.""" registrar_icon.set_mass_preservation(True) assert registrar_icon.use_mass_preservation, "Mass preservation not set" @@ -77,7 +83,7 @@ def test_set_mass_preservation(self, registrar_icon): print("\nMass preservation setting works correctly") - def test_set_multi_modality(self, registrar_icon): + def test_set_multi_modality(self, registrar_icon: RegisterImagesICON) -> None: """Test setting multi-modality flag.""" registrar_icon.set_multi_modality(True) assert registrar_icon.use_multi_modality, "Multi-modality not set" @@ -87,7 +93,12 @@ def test_set_multi_modality(self, registrar_icon): print("\nMulti-modality setting works correctly") - def test_register_without_mask(self, registrar_icon, test_images, test_directories): + def test_register_without_mask( + self, + registrar_icon: RegisterImagesICON, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test basic ICON registration without masks.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_icon" @@ -137,7 +148,12 @@ def test_register_without_mask(self, registrar_icon, test_images, test_directori ) print(f" Saved transforms to: {reg_output_dir}") - def test_register_with_mask(self, registrar_icon, test_images, test_directories): + def test_register_with_mask( + self, + registrar_icon: RegisterImagesICON, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test ICON registration with binary masks.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_icon" @@ -226,7 +242,12 @@ def test_register_with_mask(self, registrar_icon, test_images, test_directories) compression=True, ) - def test_transform_application(self, registrar_icon, test_images, test_directories): + def test_transform_application( + self, + registrar_icon: RegisterImagesICON, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test applying ICON registration transforms to images.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_icon" @@ -275,7 +296,9 @@ def test_transform_application(self, registrar_icon, test_images, test_directori ) print(f" Saved to: {reg_output_dir / 'icon_registered_image.mha'}") - def test_inverse_consistency(self, registrar_icon, test_images): + def test_inverse_consistency( + self, registrar_icon: RegisterImagesICON, test_images: list[Any] + ) -> None: """Test ICON's inverse consistency property.""" fixed_image = test_images[0] moving_image = test_images[1] @@ -319,7 +342,9 @@ def test_inverse_consistency(self, registrar_icon, test_images): # ICON should have small inverse consistency error assert error < 5.0, f"Inverse consistency error too large: {error:.2f} mm" - def test_preprocess_images(self, registrar_icon, test_images): + def test_preprocess_images( + self, registrar_icon: RegisterImagesICON, test_images: list[Any] + ) -> None: """Test image preprocessing for ICON.""" test_image = test_images[0] @@ -336,8 +361,11 @@ def test_preprocess_images(self, registrar_icon, test_images): print(f" Preprocessed spacing: {preprocessed_spacing}") def test_registration_with_initial_transform( - self, registrar_icon, test_images, test_directories - ): + self, + registrar_icon: RegisterImagesICON, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test ICON registration with initial transform.""" output_dir = test_directories["output"] reg_output_dir = output_dir / "registration_icon" @@ -347,14 +375,11 @@ def test_registration_with_initial_transform( moving_image = test_images[1] # Create initial translation transform - initial_tfm_inverse = itk.TranslationTransform[itk.D, 3].New() - initial_tfm_inverse.SetOffset([5.0, 5.0, 5.0]) - initial_tfm_forward = itk.TranslationTransform[itk.D, 3].New() initial_tfm_forward.SetOffset([-5.0, -5.0, -5.0]) print("\nRegistering with initial transform...") - print(" Initial offset: [5.0, 5.0, 5.0]") + print(" Initial offset: [-5.0, -5.0, -5.0]") registrar_icon.set_modality("ct") registrar_icon.set_fixed_image(fixed_image) @@ -371,7 +396,9 @@ def test_registration_with_initial_transform( print("Registration with initial transform complete") - def test_transform_types(self, registrar_icon, test_images): + def test_transform_types( + self, registrar_icon: RegisterImagesICON, test_images: list[Any] + ) -> None: """Test that ICON transforms are correct ITK types.""" fixed_image = test_images[0] moving_image = test_images[1] @@ -410,7 +437,9 @@ def test_transform_types(self, registrar_icon, test_images): print(f" inverse_transform: {type(inverse_transform).__name__}") print(f" forward_transform: {type(forward_transform).__name__}") - def test_different_iteration_counts(self, registrar_icon, test_images): + def test_different_iteration_counts( + self, registrar_icon: RegisterImagesICON, test_images: list[Any] + ) -> None: """Test ICON with different iteration counts.""" fixed_image = test_images[0] moving_image = test_images[1] diff --git a/tests/test_register_time_series_images.py b/tests/test_register_time_series_images.py index da991d7..7f70bca 100644 --- a/tests/test_register_time_series_images.py +++ b/tests/test_register_time_series_images.py @@ -6,13 +6,18 @@ an ordered sequence of images to a fixed reference image. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest -from physiomotion4d import RegisterTimeSeriesImages -from physiomotion4d.test_tools import TestTools -from physiomotion4d.transform_tools import TransformTools +from physiomotion4d import ( + RegisterTimeSeriesImages, + TestTools, + TransformTools, +) @pytest.mark.requires_data @@ -20,7 +25,9 @@ class TestRegisterTimeSeriesImages: """Test suite for time series image registration.""" - def test_registrar_initialization_ants(self): + _class_name = "registration_time_series_images" + + def test_registrar_initialization_ants(self) -> None: """Test that RegisterTimeSeriesImages initializes correctly with ANTs.""" registrar = RegisterTimeSeriesImages(registration_method="ants") assert registrar is not None, "Registrar not initialized" @@ -32,9 +39,9 @@ def test_registrar_initialization_ants(self): "Internal ICON registrar not created" ) - print("\nāœ“ Time series registrar initialized with ANTs") + print("\nTime series registrar initialized with ANTs") - def test_registrar_initialization_icon(self): + def test_registrar_initialization_icon(self) -> None: """Test that RegisterTimeSeriesImages initializes correctly with ICON.""" registrar = RegisterTimeSeriesImages(registration_method="icon") assert registrar is not None, "Registrar not initialized" @@ -46,24 +53,24 @@ def test_registrar_initialization_icon(self): "Internal ICON registrar not created" ) - print("\nāœ“ Time series registrar initialized with ICON") + print("\nTime series registrar initialized with ICON") - def test_registrar_initialization_invalid_method(self): + def test_registrar_initialization_invalid_method(self) -> None: """Test that invalid registration method raises error.""" with pytest.raises(ValueError, match="registration_method must be"): RegisterTimeSeriesImages(registration_method="invalid") - print("\nāœ“ Invalid method correctly rejected") + print("\nInvalid method correctly rejected") - def test_set_modality(self): + def test_set_modality(self) -> None: """Test setting imaging modality.""" registrar = RegisterTimeSeriesImages(registration_method="ants") registrar.set_modality("ct") assert registrar.modality == "ct", "Modality not set correctly" - print("\nāœ“ Modality setting works correctly") + print("\nModality setting works correctly") - def test_set_fixed_image(self, test_images): + def test_set_fixed_image(self, test_images: list[Any]) -> None: """Test setting fixed image.""" registrar = RegisterTimeSeriesImages(registration_method="ants") fixed_image = test_images[0] @@ -71,10 +78,10 @@ def test_set_fixed_image(self, test_images): registrar.set_fixed_image(fixed_image) assert registrar.fixed_image is not None, "Fixed image not set" - print("\nāœ“ Fixed image set successfully") + print("\nFixed image set successfully") print(f" Image size: {itk.size(registrar.fixed_image)}") - def test_set_number_of_iterations(self): + def test_set_number_of_iterations(self) -> None: """Test setting number of iterations.""" registrar_ants = RegisterTimeSeriesImages(registration_method="ants") iterations_ants = [30, 15, 5] @@ -92,9 +99,11 @@ def test_set_number_of_iterations(self): "ICON iterations not set correctly" ) - print("\nāœ“ Number of iterations set successfully") + print("\nNumber of iterations set successfully") - def test_register_time_series_basic(self, test_images, test_directories) -> bool: + def test_register_time_series_basic( + self, test_images: list[Any], test_directories: dict[str, Path] + ) -> None: """Test basic time series registration without prior transform.""" # Use first 3 images for quick test fixed_image = test_images[0] @@ -142,7 +151,7 @@ def test_register_time_series_basic(self, test_images, test_directories) -> bool assert forward_transform is not None, f"forward_transform[{i}] is None" assert inverse_transform is not None, f"inverse_transform[{i}] is None" - print("āœ“ Time series registration complete") + print("Time series registration complete") print(f" Transforms generated: {len(forward_transforms)}") print(f" Average loss: {np.mean(losses):.6f}") @@ -155,7 +164,7 @@ def test_register_time_series_basic(self, test_images, test_directories) -> bool ) test_tools = TestTools( - class_name="TestRegisterTimeSeriesImages", + class_name=self._class_name, results_dir=test_directories["output"], baselines_dir=test_directories["baselines"], ) @@ -163,22 +172,20 @@ def test_register_time_series_basic(self, test_images, test_directories) -> bool test_tools.write_result_transform( forward_transforms[0], "basic_forward_transform_0.hdf" ) - success = test_tools.compare_result_to_baseline_transform( + assert test_tools.compare_result_to_baseline_transform( "basic_forward_transform_0.hdf", ) test_tools.write_result_image( moving_image, "basic_time_series_registered_0.mha" ) - success = success and test_tools.compare_result_to_baseline_image( + assert test_tools.compare_result_to_baseline_image( "basic_time_series_registered_0.mha", ) - return success - def test_register_time_series_with_prior( - self, test_images, test_directories - ) -> bool: + self, test_images: list[Any], test_directories: dict[str, Path] + ) -> None: """Test time series registration with prior transform usage.""" fixed_image = test_images[0] moving_images = test_images[1:4] @@ -214,11 +221,11 @@ def test_register_time_series_with_prior( for i, forward_transform in enumerate(forward_transforms): assert forward_transform is not None, f"forward_transform[{i}] is None" - print("āœ“ Time series registration with prior complete") + print("Time series registration with prior complete") print(f" Losses: {[f'{loss:.6f}' for loss in losses]}") test_tools = TestTools( - class_name="TestRegisterTimeSeriesImages", + class_name=self._class_name, results_dir=test_directories["output"], baselines_dir=test_directories["baselines"], ) @@ -226,19 +233,18 @@ def test_register_time_series_with_prior( test_tools.write_result_transform( forward_transforms[0], "prior_forward_transform_0.hdf" ) - success = test_tools.compare_result_to_baseline_transform( + assert test_tools.compare_result_to_baseline_transform( "prior_forward_transform_0.hdf", ) test_tools.write_result_image( moving_image, "prior_time_series_registered_0.mha" ) - success = success and test_tools.compare_result_to_baseline_image( + assert test_tools.compare_result_to_baseline_image( "prior_time_series_registered_0.mha", ) - return success - def test_register_time_series_identity_start(self, test_images): + def test_register_time_series_identity_start(self, test_images: list[Any]) -> None: """Test time series registration with identity for starting image.""" fixed_image = test_images[0] moving_images = test_images[1:4] @@ -262,9 +268,11 @@ def test_register_time_series_identity_start(self, test_images): print(f" Starting image loss: {losses[0]}") assert losses[0] == 0.0, "Starting image should have zero loss with identity" - print("āœ“ Identity start registration complete") + print("Identity start registration complete") - def test_register_time_series_different_starting_indices(self, test_images): + def test_register_time_series_different_starting_indices( + self, test_images: list[Any] + ) -> None: """Test time series registration with different starting indices.""" fixed_image = test_images[0] moving_images = test_images[1:3] # 2 images @@ -290,9 +298,9 @@ def test_register_time_series_different_starting_indices(self, test_images): f"Wrong number of transforms for reference_frame={starting_index}" ) - print("āœ“ Different starting indices work correctly") + print("Different starting indices work correctly") - def test_register_time_series_error_no_fixed_image(self): + def test_register_time_series_error_no_fixed_image(self) -> None: """Test that error is raised if fixed image not set.""" registrar = RegisterTimeSeriesImages(registration_method="ants") @@ -301,9 +309,11 @@ def test_register_time_series_error_no_fixed_image(self): with pytest.raises(ValueError, match="Fixed image must be set"): registrar.register_time_series(moving_images=moving_images) - print("\nāœ“ Error correctly raised when fixed image not set") + print("\nError correctly raised when fixed image not set") - def test_register_time_series_error_invalid_starting_index(self, test_images): + def test_register_time_series_error_invalid_starting_index( + self, test_images: list[Any] + ) -> None: """Test that error is raised for invalid starting index.""" registrar = RegisterTimeSeriesImages(registration_method="ants") registrar.set_fixed_image(test_images[0]) @@ -322,9 +332,11 @@ def test_register_time_series_error_invalid_starting_index(self, test_images): moving_images=moving_images, reference_frame=10 ) - print("\nāœ“ Invalid starting index correctly rejected") + print("\nInvalid starting index correctly rejected") - def test_register_time_series_error_invalid_prior_portion(self, test_images): + def test_register_time_series_error_invalid_prior_portion( + self, test_images: list[Any] + ) -> None: """Test that error is raised for invalid prior portion value.""" registrar = RegisterTimeSeriesImages(registration_method="ants") registrar.set_fixed_image(test_images[0]) @@ -345,11 +357,11 @@ def test_register_time_series_error_invalid_prior_portion(self, test_images): prior_weight=1.5, ) - print("\nāœ“ Invalid prior portion correctly rejected") + print("\nInvalid prior portion correctly rejected") def test_transform_application_time_series( - self, test_images, test_directories - ) -> bool: + self, test_images: list[Any], test_directories: dict[str, Path] + ) -> None: """Test applying transforms from time series registration.""" fixed_image = test_images[0] moving_images = test_images[1:3] @@ -382,12 +394,12 @@ def test_transform_application_time_series( assert registered_image is not None, "Registered image is None" assert itk.size(registered_image) == itk.size(fixed_image), "Size mismatch" - print("āœ“ Transform application successful") + print("Transform application successful") print(f" Registered image size: {itk.size(registered_image)}") # Save registered image test_tools = TestTools( - class_name="TestRegisterTimeSeriesImages", + class_name=self._class_name, results_dir=test_directories["output"], baselines_dir=test_directories["baselines"], ) @@ -395,13 +407,11 @@ def test_transform_application_time_series( test_tools.write_result_image( registered_image, "transform_application_time_series_0.mha" ) - success = test_tools.compare_result_to_baseline_image( + assert test_tools.compare_result_to_baseline_image( "transform_application_time_series_0.mha", ) - return success - - def test_register_time_series_icon(self, test_images): + def test_register_time_series_icon(self, test_images: list[Any]) -> None: """Test time series registration with ICON method.""" fixed_image = test_images[0] moving_images = test_images[1:3] @@ -424,9 +434,11 @@ def test_register_time_series_icon(self, test_images): assert len(result["inverse_transforms"]) == len(moving_images) assert len(result["losses"]) == len(moving_images) - print("āœ“ ICON time series registration complete") + print("ICON time series registration complete") - def test_register_time_series_with_mask(self, test_images, test_directories): + def test_register_time_series_with_mask( + self, test_images: list[Any], test_directories: dict[str, Path] + ) -> None: """Test time series registration with fixed image mask.""" fixed_image = test_images[0] moving_images = test_images[1:3] @@ -467,9 +479,9 @@ def test_register_time_series_with_mask(self, test_images, test_directories): assert len(result["forward_transforms"]) == len(moving_images) - print("āœ“ Masked time series registration complete") + print("Masked time series registration complete") - def test_bidirectional_registration(self, test_images): + def test_bidirectional_registration(self, test_images: list[Any]) -> None: """Test that bidirectional registration works correctly.""" fixed_image = test_images[0] moving_images = test_images[1:6] # 5 images @@ -496,7 +508,7 @@ def test_bidirectional_registration(self, test_images): for i, forward_transform in enumerate(forward_transforms): assert forward_transform is not None, f"Transform {i} is None" - print("āœ“ Bidirectional registration successful") + print("Bidirectional registration successful") print(f" All {len(forward_transforms)} transforms generated") diff --git a/tests/test_segment_chest_total_segmentator.py b/tests/test_segment_chest_total_segmentator.py index 64c348c..1fb5ae5 100644 --- a/tests/test_segment_chest_total_segmentator.py +++ b/tests/test_segment_chest_total_segmentator.py @@ -6,17 +6,24 @@ functionality on two time points from the converted 3D data. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest +from physiomotion4d.segment_chest_total_segmentator import SegmentChestTotalSegmentator + @pytest.mark.requires_data @pytest.mark.slow class TestSegmentChestTotalSegmentator: """Test suite for TotalSegmentator chest CT segmentation.""" - def test_segmenter_initialization(self, segmenter_total_segmentator): + def test_segmenter_initialization( + self, segmenter_total_segmentator: SegmentChestTotalSegmentator + ) -> None: """Test that SegmentChestTotalSegmentator initializes correctly.""" assert segmenter_total_segmentator is not None, "Segmenter not initialized" assert segmenter_total_segmentator.target_spacing == 1.5, ( @@ -40,7 +47,7 @@ def test_segmenter_initialization(self, segmenter_total_segmentator): "Soft tissue mask IDs not defined" ) - print("\nāœ“ Segmenter initialized with correct parameters") + print("\nSegmenter initialized with correct parameters") print(f" Heart structures: {len(segmenter_total_segmentator.heart_mask_ids)}") print( f" Major vessels: {len(segmenter_total_segmentator.major_vessels_mask_ids)}" @@ -52,8 +59,11 @@ def test_segmenter_initialization(self, segmenter_total_segmentator): ) def test_segment_single_image( - self, segmenter_total_segmentator, test_images, test_directories - ): + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test segmentation on a single time point.""" output_dir = test_directories["output"] @@ -93,7 +103,7 @@ def test_segment_single_image( unique_labels = np.unique(labelmap_arr) assert len(unique_labels) > 1, "Labelmap should contain multiple labels" - print("āœ“ Segmentation complete for time point 0") + print("Segmentation complete for time point 0") print(f" Labelmap size: {itk.size(labelmap)}") print(f" Unique labels: {len(unique_labels)}") @@ -107,8 +117,11 @@ def test_segment_single_image( print(f" Saved labelmap to: {seg_output_dir / 'slice_000_labelmap.mha'}") def test_segment_multiple_images( - self, segmenter_total_segmentator, test_images, test_directories - ): + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test segmentation on two time points.""" output_dir = test_directories["output"] seg_output_dir = output_dir / "segmentation_total_segmentator" @@ -128,13 +141,17 @@ def test_segment_multiple_images( output_file = seg_output_dir / f"slice_{i:03d}_labelmap.mha" itk.imwrite(labelmap, str(output_file), compression=True) - print(f"āœ“ Time point {i} complete") + print(f"Time point {i} complete") print(f" Saved to: {output_file}") assert len(results) == 2, "Expected 2 segmentation results" - print(f"\nāœ“ Successfully segmented {len(results)} time points") + print(f"\nSuccessfully segmented {len(results)} time points") - def test_anatomy_group_masks(self, segmenter_total_segmentator, test_images): + def test_anatomy_group_masks( + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + ) -> None: """Test that anatomy group masks are created correctly.""" input_image = test_images[0] @@ -168,13 +185,17 @@ def test_anatomy_group_masks(self, segmenter_total_segmentator, test_images): f"{group} mask size mismatch" ) - print("\nāœ“ All anatomy group masks created correctly") + print("\nAll anatomy group masks created correctly") for group in anatomy_groups: mask_arr = itk.array_from_image(result[group]) num_voxels = np.sum(mask_arr > 0) print(f" {group}: {num_voxels} voxels") - def test_contrast_detection(self, segmenter_total_segmentator, test_images): + def test_contrast_detection( + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + ) -> None: """Test contrast detection functionality.""" input_image = test_images[0] @@ -194,14 +215,18 @@ def test_contrast_detection(self, segmenter_total_segmentator, test_images): assert contrast_mask_no is not None, "Contrast mask (no flag) is None" assert contrast_mask_yes is not None, "Contrast mask (with flag) is None" - print("\nāœ“ Contrast detection tested") + print("\nContrast detection tested") contrast_arr_no = itk.array_from_image(contrast_mask_no) contrast_arr_yes = itk.array_from_image(contrast_mask_yes) print(f" Without contrast flag: {np.sum(contrast_arr_no > 0)} voxels") print(f" With contrast flag: {np.sum(contrast_arr_yes > 0)} voxels") - def test_preprocessing(self, segmenter_total_segmentator, test_images): + def test_preprocessing( + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + ) -> None: """Test preprocessing functionality.""" input_image = test_images[0] @@ -218,10 +243,14 @@ def test_preprocessing(self, segmenter_total_segmentator, test_images): assert result is not None, "Segmentation result is None" assert "labelmap" in result, "Labelmap not in result" - print("\nāœ“ Preprocessing tested (via successful segmentation)") + print("\nPreprocessing tested (via successful segmentation)") print(f" Original image spacing: {original_spacing}") - def test_postprocessing(self, segmenter_total_segmentator, test_images): + def test_postprocessing( + self, + segmenter_total_segmentator: SegmentChestTotalSegmentator, + test_images: list[Any], + ) -> None: """Test postprocessing functionality.""" input_image = test_images[0] @@ -246,7 +275,7 @@ def test_postprocessing(self, segmenter_total_segmentator, test_images): f"Spacing mismatch at dimension {i}" ) - print("\nāœ“ Postprocessing tested") + print("\nPostprocessing tested") print(f" Original spacing: {original_spacing}") print(f" Labelmap spacing: {labelmap_spacing}") diff --git a/tests/test_segment_chest_vista_3d.py b/tests/test_segment_chest_vista_3d.py deleted file mode 100644 index dc88884..0000000 --- a/tests/test_segment_chest_vista_3d.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env python -""" -Test for chest CT segmentation using VISTA-3D. - -This test depends on test_convert_nrrd_4d_to_3d and tests segmentation -functionality on two time points from the converted 3D data. -""" - -import itk -import numpy as np -import pytest - - -@pytest.mark.requires_data -@pytest.mark.slow -class TestSegmentChestVista3D: - """Test suite for VISTA-3D chest CT segmentation.""" - - def test_segmenter_initialization(self, segmenter_vista_3d): - """Test that SegmentChestVista3D initializes correctly.""" - assert segmenter_vista_3d is not None, "Segmenter not initialized" - assert segmenter_vista_3d.device is not None, "CUDA device not initialized" - - # Check that anatomical structure ID mappings are defined - assert len(segmenter_vista_3d.heart_mask_ids) > 0, "Heart mask IDs not defined" - assert len(segmenter_vista_3d.major_vessels_mask_ids) > 0, ( - "Major vessels mask IDs not defined" - ) - assert len(segmenter_vista_3d.lung_mask_ids) > 0, "Lung mask IDs not defined" - assert len(segmenter_vista_3d.bone_mask_ids) > 0, "Bone mask IDs not defined" - assert len(segmenter_vista_3d.soft_tissue_mask_ids) > 0, ( - "Soft tissue mask IDs not defined" - ) - - # Check VISTA-3D specific attributes - assert segmenter_vista_3d.bundle_path is not None, "Bundle path not set" - assert segmenter_vista_3d.label_prompt is None, ( - "Label prompt should be None initially" - ) - - print("\nāœ“ Segmenter initialized with correct parameters") - print(f" Heart structures: {len(segmenter_vista_3d.heart_mask_ids)}") - print(f" Major vessels: {len(segmenter_vista_3d.major_vessels_mask_ids)}") - print(f" Lung structures: {len(segmenter_vista_3d.lung_mask_ids)}") - print(f" Bone structures: {len(segmenter_vista_3d.bone_mask_ids)}") - print( - f" Soft tissue structures: {len(segmenter_vista_3d.soft_tissue_mask_ids)}" - ) - print(f" Bundle path: {segmenter_vista_3d.bundle_path}") - - def test_segment_single_image( - self, segmenter_vista_3d, test_images, test_directories - ): - """Test automatic segmentation on a single time point.""" - output_dir = test_directories["output"] - - # Ensure we're in automatic segmentation mode - segmenter_vista_3d.set_whole_image_segmentation() - - # Test on first time point only - input_image = test_images[0] - - print("\nSegmenting time point 0 (automatic mode)...") - print(f" Input image size: {itk.size(input_image)}") - - # Run segmentation - result = segmenter_vista_3d.segment(input_image, contrast_enhanced_study=False) - - # Verify result is a dictionary with expected keys - assert isinstance(result, dict), "Result should be a dictionary" - expected_keys = [ - "labelmap", - "lung", - "heart", - "major_vessels", - "bone", - "soft_tissue", - "other", - "contrast", - ] - for key in expected_keys: - assert key in result, f"Missing key '{key}' in result" - assert result[key] is not None, f"Result['{key}'] is None" - - # Verify labelmap properties - labelmap = result["labelmap"] - assert itk.size(labelmap) == itk.size(input_image), "Labelmap size mismatch" - - # Check that labels are present - labelmap_arr = itk.array_from_image(labelmap) - unique_labels = np.unique(labelmap_arr) - assert len(unique_labels) > 1, "Labelmap should contain multiple labels" - - print("āœ“ Segmentation complete for time point 0") - print(f" Labelmap size: {itk.size(labelmap)}") - print(f" Unique labels: {len(unique_labels)}") - - # Save results - seg_output_dir = output_dir / "segmentation_vista3d" - seg_output_dir.mkdir(exist_ok=True) - - itk.imwrite( - labelmap, str(seg_output_dir / "slice_000_labelmap.mha"), compression=True - ) - print(f" Saved labelmap to: {seg_output_dir / 'slice_000_labelmap.mha'}") - - def test_segment_multiple_images( - self, segmenter_vista_3d, test_images, test_directories - ): - """Test automatic segmentation on two time points.""" - output_dir = test_directories["output"] - seg_output_dir = output_dir / "segmentation_vista3d" - seg_output_dir.mkdir(exist_ok=True) - - # Ensure automatic segmentation mode - segmenter_vista_3d.set_whole_image_segmentation() - - results = [] - for i, input_image in enumerate(test_images[0:2]): - print(f"\nSegmenting time point {i}...") - - result = segmenter_vista_3d.segment( - input_image, contrast_enhanced_study=False - ) - results.append(result) - - # Save labelmap for each time point - labelmap = result["labelmap"] - output_file = seg_output_dir / f"slice_{i:03d}_labelmap.mha" - itk.imwrite(labelmap, str(output_file), compression=True) - - print(f"āœ“ Time point {i} complete") - print(f" Saved to: {output_file}") - - assert len(results) == 2, "Expected 2 segmentation results" - print(f"\nāœ“ Successfully segmented {len(results)} time points") - - def test_anatomy_group_masks(self, segmenter_vista_3d, test_images): - """Test that anatomy group masks are created correctly.""" - segmenter_vista_3d.set_whole_image_segmentation() - input_image = test_images[0] - - # Run segmentation - result = segmenter_vista_3d.segment(input_image, contrast_enhanced_study=False) - - # Check each anatomy group mask - anatomy_groups = [ - "lung", - "heart", - "major_vessels", - "bone", - "soft_tissue", - "other", - ] - - for group in anatomy_groups: - mask = result[group] - assert mask is not None, f"{group} mask is None" - - # Check that mask is binary - mask_arr = itk.array_from_image(mask) - unique_values = np.unique(mask_arr) - assert len(unique_values) <= 2, f"{group} mask should be binary" - assert 0 in unique_values, f"{group} mask should contain background" - - # Check that mask has same size as input - assert itk.size(mask) == itk.size(input_image), ( - f"{group} mask size mismatch" - ) - - print("\nāœ“ All anatomy group masks created correctly") - for group in anatomy_groups: - mask_arr = itk.array_from_image(result[group]) - num_voxels = np.sum(mask_arr > 0) - print(f" {group}: {num_voxels} voxels") - - def test_label_prompt_segmentation( - self, segmenter_vista_3d, test_images, test_directories - ): - """Test segmentation with specific label prompts.""" - output_dir = test_directories["output"] - seg_output_dir = output_dir / "segmentation_vista3d" - seg_output_dir.mkdir(exist_ok=True) - - input_image = test_images[0] - - # Test with heart and aorta labels only - heart_aorta_labels = [115, 6] # Heart and aorta - segmenter_vista_3d.set_label_prompt(heart_aorta_labels) - - print(f"\nSegmenting with label prompts: {heart_aorta_labels}") - result = segmenter_vista_3d.segment(input_image, contrast_enhanced_study=False) - - # Verify result - assert isinstance(result, dict), "Result should be a dictionary" - labelmap = result["labelmap"] - - # Check that only prompted labels are present (plus background and soft tissue fill) - labelmap_arr = itk.array_from_image(labelmap) - unique_labels = np.unique(labelmap_arr) - - print("āœ“ Label prompt segmentation complete") - print(f" Unique labels: {unique_labels}") - - # Save result - output_file = seg_output_dir / "slice_000_label_prompt.mha" - itk.imwrite(labelmap, str(output_file), compression=True) - print(f" Saved to: {output_file}") - - # Reset to whole image segmentation - segmenter_vista_3d.set_whole_image_segmentation() - - def test_contrast_detection(self, segmenter_vista_3d, test_images): - """Test contrast detection functionality.""" - segmenter_vista_3d.set_whole_image_segmentation() - input_image = test_images[0] - - # Test without contrast - result_no_contrast = segmenter_vista_3d.segment( - input_image, contrast_enhanced_study=False - ) - contrast_mask_no = result_no_contrast["contrast"] - - # Test with contrast flag - result_with_contrast = segmenter_vista_3d.segment( - input_image, contrast_enhanced_study=True - ) - contrast_mask_yes = result_with_contrast["contrast"] - - # Both should return valid masks - assert contrast_mask_no is not None, "Contrast mask (no flag) is None" - assert contrast_mask_yes is not None, "Contrast mask (with flag) is None" - - print("\nāœ“ Contrast detection tested") - - contrast_arr_no = itk.array_from_image(contrast_mask_no) - contrast_arr_yes = itk.array_from_image(contrast_mask_yes) - print(f" Without contrast flag: {np.sum(contrast_arr_no > 0)} voxels") - print(f" With contrast flag: {np.sum(contrast_arr_yes > 0)} voxels") - - def test_preprocessing(self, segmenter_vista_3d, test_images): - """Test preprocessing functionality.""" - segmenter_vista_3d.set_whole_image_segmentation() - input_image = test_images[0] - - # Get original properties - original_spacing = itk.spacing(input_image) - - # Preprocessing is done internally by segment(), not exposed as public method - # Just verify that segment() works (which includes preprocessing) - result = segmenter_vista_3d.segment(input_image, contrast_enhanced_study=False) - - # Check that segmentation was successful (which means preprocessing worked) - assert result is not None, "Segmentation result is None" - assert "labelmap" in result, "Labelmap not in result" - - print("\nāœ“ Preprocessing tested (via successful segmentation)") - print(f" Original image spacing: {original_spacing}") - - def test_postprocessing(self, segmenter_vista_3d, test_images): - """Test postprocessing functionality.""" - segmenter_vista_3d.set_whole_image_segmentation() - input_image = test_images[0] - - # Run full segmentation to get labelmap - result = segmenter_vista_3d.segment(input_image, contrast_enhanced_study=False) - labelmap = result["labelmap"] - - # Postprocessing is part of segment(), verify output is properly sized - assert itk.size(labelmap) == itk.size(input_image), ( - "Postprocessing failed: size mismatch" - ) - - # Check that labelmap has been resampled to original spacing - original_spacing = itk.spacing(input_image) - labelmap_spacing = itk.spacing(labelmap) - - # Spacing should match (within floating point tolerance) - for i in range(3): - assert abs(labelmap_spacing[i] - original_spacing[i]) < 0.01, ( - f"Spacing mismatch at dimension {i}" - ) - - print("\nāœ“ Postprocessing tested") - print(f" Original spacing: {original_spacing}") - print(f" Labelmap spacing: {labelmap_spacing}") - - def test_set_and_reset_prompts(self, segmenter_vista_3d): - """Test setting and resetting label prompt mode.""" - # Initially should be in automatic mode - assert segmenter_vista_3d.label_prompt is None, ( - "Label prompt should be None initially" - ) - - # Set label prompt - segmenter_vista_3d.set_label_prompt([115, 6]) - assert segmenter_vista_3d.label_prompt == [ - 115, - 6, - ], "Label prompt not set correctly" - - # Reset to whole image - segmenter_vista_3d.set_whole_image_segmentation() - assert segmenter_vista_3d.label_prompt is None, ( - "Label prompt should be None after reset" - ) - - print("\nāœ“ Prompt setting and resetting works correctly") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/tests/test_segment_heart_simpleware.py b/tests/test_segment_heart_simpleware.py index 448a036..8d24188 100644 --- a/tests/test_segment_heart_simpleware.py +++ b/tests/test_segment_heart_simpleware.py @@ -10,13 +10,17 @@ """ import os +from pathlib import Path +from typing import Any import itk import numpy as np import pytest +from physiomotion4d.segment_heart_simpleware import SegmentHeartSimpleware -def _simpleware_available(segmenter): + +def _simpleware_available(segmenter: SegmentHeartSimpleware) -> bool: """Return True if Simpleware Medical executable and script exist.""" return os.path.exists(segmenter.simpleware_exe_path) and os.path.exists( segmenter.simpleware_script_path @@ -28,7 +32,9 @@ def _simpleware_available(segmenter): class TestSegmentHeartSimpleware: """Test suite for SegmentHeartSimpleware (Simpleware Medical ASCardio).""" - def test_segmenter_initialization(self, segmenter_simpleware): + def test_segmenter_initialization( + self, segmenter_simpleware: SegmentHeartSimpleware + ) -> None: """Test that SegmentHeartSimpleware initializes correctly.""" seg = segmenter_simpleware assert seg is not None, "Segmenter not initialized" @@ -47,12 +53,14 @@ def test_segmenter_initialization(self, segmenter_simpleware): assert seg.simpleware_script_path is not None, "Simpleware script path not set" assert "SimplewareScript_heart_segmentation" in seg.simpleware_script_path - print("\nāœ“ Segmenter initialized with correct parameters") + print("\nSegmenter initialized with correct parameters") print(f" Target spacing: {seg.target_spacing} mm") print(f" Heart structures: {len(seg.heart_mask_ids)}") print(f" Major vessels: {len(seg.major_vessels_mask_ids)}") - def test_set_simpleware_executable_path(self, segmenter_simpleware): + def test_set_simpleware_executable_path( + self, segmenter_simpleware: SegmentHeartSimpleware + ) -> None: """Test setting custom Simpleware executable path.""" seg = segmenter_simpleware original = seg.simpleware_exe_path @@ -61,16 +69,15 @@ def test_set_simpleware_executable_path(self, segmenter_simpleware): assert seg.simpleware_exe_path == custom seg.set_simpleware_executable_path(original) assert seg.simpleware_exe_path == original - print("\nāœ“ set_simpleware_executable_path works correctly") + print("\nset_simpleware_executable_path works correctly") def test_segment_single_image( self, - segmenter_simpleware, - heart_simpleware_image, - heart_simpleware_image_path, - test_directories, - ): - """Test segmentation on the same cardiac CT as the notebook (RVOT28-Dias.nii.gz).""" + segmenter_simpleware: SegmentHeartSimpleware, + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: + """Test segmentation on a cardiac CT time point.""" if not _simpleware_available(segmenter_simpleware): pytest.skip( "Simpleware Medical not found (executable or script). " @@ -78,12 +85,9 @@ def test_segment_single_image( ) output_dir = test_directories["output"] - input_image = heart_simpleware_image + input_image = test_images[3] - print( - "\nSegmenting cardiac CT (same as Heart-Simpleware_Segmentation notebook)..." - ) - print(f" Input: {heart_simpleware_image_path.name}") + print("\nSegmenting cardiac CT...") print(f" Image size: {itk.size(input_image)}") result = segmenter_simpleware.segment(input_image, contrast_enhanced_study=True) @@ -110,7 +114,7 @@ def test_segment_single_image( unique_labels = np.unique(labelmap_arr) assert len(unique_labels) > 1, "Labelmap should contain multiple labels" - print("āœ“ Segmentation complete") + print("Segmentation complete") print(f" Unique labels: {len(unique_labels)}") seg_output_dir = output_dir / "segmentation_simpleware" @@ -123,13 +127,15 @@ def test_segment_single_image( print(f" Saved to: {seg_output_dir / 'heart_labelmap_simpleware.nii.gz'}") def test_anatomy_group_masks( - self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path - ): + self, + segmenter_simpleware: SegmentHeartSimpleware, + test_images: list[Any], + ) -> None: """Test that anatomy group masks are created (heart, vessels, etc.).""" if not _simpleware_available(segmenter_simpleware): pytest.skip("Simpleware Medical not found. Install to run this test.") - input_image = heart_simpleware_image + input_image = test_images[3] result = segmenter_simpleware.segment(input_image, contrast_enhanced_study=True) anatomy_groups = [ @@ -153,32 +159,36 @@ def test_anatomy_group_masks( heart_arr = itk.array_from_image(result["heart"]) vessels_arr = itk.array_from_image(result["major_vessels"]) - print("\nāœ“ All anatomy group masks created correctly") + print("\nAll anatomy group masks created correctly") print(f" heart: {np.sum(heart_arr > 0)} voxels") print(f" major_vessels: {np.sum(vessels_arr > 0)} voxels") def test_contrast_detection( - self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path - ): + self, + segmenter_simpleware: SegmentHeartSimpleware, + test_images: list[Any], + ) -> None: """Test contrast mask is returned (base class behavior).""" if not _simpleware_available(segmenter_simpleware): pytest.skip("Simpleware Medical not found. Install to run this test.") - input_image = heart_simpleware_image + input_image = test_images[3] result = segmenter_simpleware.segment(input_image, contrast_enhanced_study=True) contrast_mask = result["contrast"] assert contrast_mask is not None assert itk.size(contrast_mask) == itk.size(input_image) - print("\nāœ“ Contrast mask returned") + print("\nContrast mask returned") def test_postprocessing( - self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path - ): + self, + segmenter_simpleware: SegmentHeartSimpleware, + test_images: list[Any], + ) -> None: """Test that output labelmap matches input size and spacing.""" if not _simpleware_available(segmenter_simpleware): pytest.skip("Simpleware Medical not found. Install to run this test.") - input_image = heart_simpleware_image + input_image = test_images[3] result = segmenter_simpleware.segment(input_image, contrast_enhanced_study=True) labelmap = result["labelmap"] @@ -189,7 +199,7 @@ def test_postprocessing( assert abs(labelmap_spacing[i] - original_spacing[i]) < 0.01, ( f"Spacing mismatch at dimension {i}" ) - print("\nāœ“ Postprocessing: labelmap size and spacing match input") + print("\nPostprocessing: labelmap size and spacing match input") if __name__ == "__main__": diff --git a/tests/test_transform_tools.py b/tests/test_transform_tools.py index 17330e2..9b1f676 100644 --- a/tests/test_transform_tools.py +++ b/tests/test_transform_tools.py @@ -6,6 +6,9 @@ transforms to test transform manipulation and application. """ +from pathlib import Path +from typing import Any + import itk import numpy as np import pytest @@ -13,6 +16,7 @@ import vtk from physiomotion4d.image_tools import ImageTools +from physiomotion4d.transform_tools import TransformTools @pytest.mark.requires_data @@ -21,20 +25,26 @@ class TestTransformTools: """Test suite for TransformTools functionality.""" @pytest.fixture(scope="class") - def test_contour(self, test_images): + def test_contour(self, test_images: list[Any]) -> Any: """Create a simple test contour mesh.""" # Create a sphere mesh for testing sphere = pv.Sphere(radius=50.0, center=(100, 100, 100)) return sphere - def test_transform_tools_initialization(self, transform_tools): + def test_transform_tools_initialization( + self, transform_tools: TransformTools + ) -> None: """Test that TransformTools initializes correctly.""" assert transform_tools is not None, "TransformTools not initialized" - print("\nāœ“ TransformTools initialized successfully") + print("\nTransformTools initialized successfully") def test_transform_image_linear( - self, transform_tools, ants_registration_results, test_images, test_directories - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test transforming image with linear interpolation.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" @@ -42,7 +52,7 @@ def test_transform_image_linear( moving_image = test_images[1] fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTransforming image with linear interpolation...") @@ -57,7 +67,7 @@ def test_transform_image_linear( "Spacing mismatch" ) - print("āœ“ Image transformed with linear interpolation") + print("Image transformed with linear interpolation") print(f" Output size: {itk.size(transformed_image)}") print(f" Output spacing: {itk.spacing(transformed_image)}") @@ -69,8 +79,12 @@ def test_transform_image_linear( ) def test_transform_image_nearest( - self, transform_tools, ants_registration_results, test_images, test_directories - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test transforming image with nearest neighbor interpolation.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" @@ -78,7 +92,7 @@ def test_transform_image_nearest( moving_image = test_images[1] fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTransforming image with nearest neighbor interpolation...") @@ -89,7 +103,7 @@ def test_transform_image_nearest( assert transformed_image is not None, "Transformed image is None" assert itk.size(transformed_image) == itk.size(fixed_image), "Size mismatch" - print("āœ“ Image transformed with nearest neighbor interpolation") + print("Image transformed with nearest neighbor interpolation") # Save transformed image itk.imwrite( @@ -99,8 +113,12 @@ def test_transform_image_nearest( ) def test_transform_image_sinc( - self, transform_tools, ants_registration_results, test_images, test_directories - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test transforming image with sinc interpolation.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" @@ -108,7 +126,7 @@ def test_transform_image_sinc( moving_image = test_images[1] fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTransforming image with sinc interpolation...") @@ -119,7 +137,7 @@ def test_transform_image_sinc( assert transformed_image is not None, "Transformed image is None" assert itk.size(transformed_image) == itk.size(fixed_image), "Size mismatch" - print("āœ“ Image transformed with sinc interpolation") + print("Image transformed with sinc interpolation") # Save transformed image itk.imwrite( @@ -129,12 +147,15 @@ def test_transform_image_sinc( ) def test_transform_image_invalid_method( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test that invalid interpolation method raises error.""" moving_image = test_images[1] fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTesting invalid interpolation method...") @@ -146,13 +167,16 @@ def test_transform_image_invalid_method( interpolation_method="invalid", ) - print("āœ“ Invalid method correctly raises ValueError") + print("Invalid method correctly raises ValueError") def test_transform_pvcontour_without_deformation( - self, transform_tools, test_contour, ants_registration_results - ): + self, + transform_tools: TransformTools, + test_contour: Any, + test_transforms: dict[str, Any], + ) -> None: """Test transforming PyVista contour without deformation magnitude.""" - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTransforming contour without deformation magnitude...") print(f" Original contour points: {test_contour.n_points}") @@ -176,19 +200,23 @@ def test_transform_pvcontour_without_deformation( max_diff = np.max(np.abs(transformed_points - original_points)) - print("āœ“ Contour transformed without deformation magnitude") + print("Contour transformed without deformation magnitude") print(f" Transformed contour points: {transformed_contour.n_points}") print(f" Max point displacement: {max_diff:.2f} mm") def test_transform_pvcontour_with_deformation( - self, transform_tools, test_contour, ants_registration_results, test_directories - ): + self, + transform_tools: TransformTools, + test_contour: Any, + test_transforms: dict[str, Any], + test_directories: dict[str, Path], + ) -> None: """Test transforming PyVista contour with deformation magnitude.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" tfm_output_dir.mkdir(exist_ok=True) - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nTransforming contour with deformation magnitude...") @@ -212,7 +240,7 @@ def test_transform_pvcontour_with_deformation( mean_def = np.mean(deformation) max_def = np.max(deformation) - print("āœ“ Contour transformed with deformation magnitude") + print("Contour transformed with deformation magnitude") print(f" Mean deformation: {mean_def:.2f} mm") print(f" Max deformation: {max_def:.2f} mm") @@ -220,15 +248,19 @@ def test_transform_pvcontour_with_deformation( transformed_contour.save(str(tfm_output_dir / "transformed_contour.vtp")) def test_convert_transform_to_displacement_field( - self, transform_tools, ants_registration_results, test_images, test_directories - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test converting transform to deformation field image.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" tfm_output_dir.mkdir(exist_ok=True) fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nConverting transform to deformation field...") @@ -244,7 +276,7 @@ def test_convert_transform_to_displacement_field( field_arr = itk.array_from_image(deformation_field) assert field_arr.shape[-1] == 3, "Should have 3 components (x, y, z)" - print("āœ“ Transform converted to deformation field") + print("Transform converted to deformation field") print(f" Field size: {itk.size(deformation_field)}") print(f" Field shape: {field_arr.shape}") @@ -256,7 +288,9 @@ def test_convert_transform_to_displacement_field( compression=True, ) - def test_convert_vtk_matrix_to_itk_transform(self, transform_tools): + def test_convert_vtk_matrix_to_itk_transform( + self, transform_tools: TransformTools + ) -> None: """Test converting VTK matrix to ITK transform.""" # Create a VTK matrix vtk_matrix = vtk.vtkMatrix4x4() @@ -283,19 +317,23 @@ def test_convert_vtk_matrix_to_itk_transform(self, transform_tools): assert abs(offset[1] - 20.0) < 0.01, "Y translation incorrect" assert abs(offset[2] - 30.0) < 0.01, "Z translation incorrect" - print("āœ“ VTK matrix converted to ITK transform") + print("VTK matrix converted to ITK transform") print(f" Translation: [{offset[0]:.1f}, {offset[1]:.1f}, {offset[2]:.1f}]") def test_compute_jacobian_determinant_from_field( - self, transform_tools, ants_registration_results, test_images, test_directories - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + test_directories: dict[str, Path], + ) -> None: """Test computing Jacobian determinant from deformation field.""" output_dir = test_directories["output"] tfm_output_dir = output_dir / "transform_tools" tfm_output_dir.mkdir(exist_ok=True) fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] # First convert transform to field print("\nComputing Jacobian determinant from deformation field...") @@ -318,7 +356,7 @@ def test_compute_jacobian_determinant_from_field( min_jac = np.min(jac_arr) max_jac = np.max(jac_arr) - print("āœ“ Jacobian determinant computed") + print("Jacobian determinant computed") print(f" Mean: {mean_jac:.3f}") print(f" Min: {min_jac:.3f}") print(f" Max: {max_jac:.3f}") @@ -334,11 +372,14 @@ def test_compute_jacobian_determinant_from_field( ) def test_detect_folding_in_field( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test detecting spatial folding in deformation field.""" fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] # Convert transform to field print("\nDetecting folding in deformation field...") @@ -357,14 +398,17 @@ def test_detect_folding_in_field( # Verify result assert isinstance(has_folding, bool), "Result should be boolean" - print("āœ“ Folding detection complete") + print("Folding detection complete") print(f" Has folding: {has_folding}") def test_interpolate_transforms( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test temporal interpolation between transforms.""" - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] # Create an identity transform as second transform identity_tfm = itk.AffineTransform[itk.D, 3].New() @@ -390,15 +434,18 @@ def test_interpolate_transforms( "Should be a DisplacementFieldTransform" ) - print("āœ“ Transform interpolation complete") + print("Transform interpolation complete") print(" Interpolation alpha: 0.5") print(f" Result type: {type(interpolated_tfm).__name__}") def test_combine_displacement_field_transforms( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test composing two transforms with various weights.""" - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] fixed_image = test_images[0] # Create an identity transform as second transform @@ -496,7 +543,7 @@ def test_combine_displacement_field_transforms( mag2 = np.mean(np.linalg.norm(arr2, axis=-1)) mag3 = np.mean(np.linalg.norm(arr3, axis=-1)) - print("āœ“ Transform composition complete") + print("Transform composition complete") print(f" Field magnitude (0.5, 0.5): {mag1:.3f} mm") print(f" Field magnitude (1.0, 0.0): {mag2:.3f} mm") print(f" Field magnitude (0.0, 1.0): {mag3:.3f} mm") @@ -506,10 +553,13 @@ def test_combine_displacement_field_transforms( assert diff_2_3 > 0, "Different weights should produce different results" def test_smooth_transform( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test smoothing a transform.""" - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] fixed_image = test_images[0] print("\nSmoothing transform...") @@ -525,14 +575,17 @@ def test_smooth_transform( "Should be a DisplacementFieldTransform" ) - print("āœ“ Transform smoothing complete") + print("Transform smoothing complete") print(" Smoothing sigma: 2.0") def test_combine_transforms_with_masks( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test combining transforms with spatial masks.""" - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] fixed_image = test_images[0] # Create identity transform @@ -570,15 +623,18 @@ def test_combine_transforms_with_masks( "Should be a DisplacementFieldTransform" ) - print("āœ“ Transforms combined with masks") + print("Transforms combined with masks") def test_multiple_transform_applications( - self, transform_tools, ants_registration_results, test_images - ): + self, + transform_tools: TransformTools, + test_transforms: dict[str, Any], + test_images: list[Any], + ) -> None: """Test applying multiple transforms in sequence.""" moving_image = test_images[1] fixed_image = test_images[0] - forward_transform = ants_registration_results["forward_transform"] + forward_transform = test_transforms["forward_transform"] print("\nApplying transforms multiple times...") @@ -595,9 +651,11 @@ def test_multiple_transform_applications( assert result1 is not None, "First transform result is None" assert result2 is not None, "Second transform result is None" - print("āœ“ Multiple sequential transforms applied") + print("Multiple sequential transforms applied") - def test_identity_transform(self, transform_tools, test_images): + def test_identity_transform( + self, transform_tools: TransformTools, test_images: list[Any] + ) -> None: """Test that identity transform doesn't change the image.""" moving_image = test_images[1] fixed_image = test_images[0] @@ -626,7 +684,7 @@ def test_identity_transform(self, transform_tools, test_images): diff = np.abs(resampled_arr - transformed_arr) mean_diff = np.mean(diff) - print("āœ“ Identity transform tested") + print("Identity transform tested") print(f" Mean difference: {mean_diff:.4f}") # Should be very small (just interpolation error) diff --git a/tests/test_usd_merge.py b/tests/test_usd_merge.py index 9c31edf..d8822dd 100644 --- a/tests/test_usd_merge.py +++ b/tests/test_usd_merge.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from typing import Any import pytest from pxr import Usd, UsdGeom, UsdShade @@ -13,7 +14,7 @@ from physiomotion4d import USDTools -def analyze_usd_file(filepath: str) -> dict: +def analyze_usd_file(filepath: str) -> dict[str, Any]: """ Analyze a USD file for materials and time samples. @@ -73,7 +74,7 @@ class TestUSDMerge: """Test suite for USD file merging.""" @pytest.fixture(scope="class") - def test_data_files(self): + def test_data_files(self) -> dict[str, str]: """Locate test USD files with materials and time-varying data.""" dynamic_file = Path( "experiments/Heart-GatedCT_To_USD/results/Slicer_CardiacGatedCT.dynamic_anatomy_painted.usd" @@ -88,21 +89,24 @@ def test_data_files(self): return {"dynamic": str(dynamic_file), "static": str(static_file)} @pytest.fixture(scope="class") - def output_dir(self, tmp_path_factory): + def output_dir(self, tmp_path_factory: pytest.TempPathFactory) -> Path: """Create temporary output directory for test results.""" output_dir = tmp_path_factory.mktemp("usd_merge_tests") return output_dir @pytest.fixture(scope="class") - def input_stats(self, test_data_files): + def input_stats(self, test_data_files: dict[str, str]) -> dict[str, dict[str, Any]]: """Analyze input USD files.""" dynamic_stats = analyze_usd_file(test_data_files["dynamic"]) static_stats = analyze_usd_file(test_data_files["static"]) return {"dynamic": dynamic_stats, "static": static_stats} def test_merge_usd_files_copy_method( - self, test_data_files, input_stats, output_dir - ): + self, + test_data_files: dict[str, str], + input_stats: dict[str, dict[str, Any]], + output_dir: Path, + ) -> None: """Test merge_usd_files() manual copy method.""" # Merge files using manual copy method usd_tools = USDTools() @@ -159,8 +163,11 @@ def test_merge_usd_files_copy_method( ) def test_merge_usd_files_flattened_method( - self, test_data_files, input_stats, output_dir - ): + self, + test_data_files: dict[str, str], + input_stats: dict[str, dict[str, Any]], + output_dir: Path, + ) -> None: """Test merge_usd_files_flattened() composition method.""" # Merge files using flattened method usd_tools = USDTools() @@ -216,7 +223,9 @@ def test_merge_usd_files_flattened_method( f"Time samples not preserved: expected {expected_max_samples}, got {merged_stats['max_time_samples']}" ) - def test_both_methods_produce_equivalent_results(self, test_data_files, output_dir): + def test_both_methods_produce_equivalent_results( + self, test_data_files: dict[str, str], output_dir: Path + ) -> None: """Verify both merge methods produce equivalent results.""" usd_tools = USDTools() diff --git a/tests/test_usd_time_preservation.py b/tests/test_usd_time_preservation.py index 50c407a..c1ab909 100644 --- a/tests/test_usd_time_preservation.py +++ b/tests/test_usd_time_preservation.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from typing import Any, Optional import pytest from pxr import Usd, UsdGeom @@ -13,7 +14,7 @@ from physiomotion4d import USDTools -def get_time_metadata(filepath: str) -> dict: +def get_time_metadata(filepath: str) -> dict[str, Any]: """ Extract time metadata from a USD file. @@ -37,7 +38,9 @@ def get_time_metadata(filepath: str) -> dict: } -def get_mesh_time_samples(filepath: str, mesh_name: str = "inferior_vena_cava") -> dict: +def get_mesh_time_samples( + filepath: str, mesh_name: str = "inferior_vena_cava" +) -> Optional[dict[str, Any]]: """ Get time sample data for a specific mesh in a USD file. @@ -50,8 +53,9 @@ def get_mesh_time_samples(filepath: str, mesh_name: str = "inferior_vena_cava") Returns ------- - dict - Time sample information including codes and point positions + dict or None + Time sample information including codes and point positions, + or None if the mesh is not found """ stage = Usd.Stage.Open(filepath) @@ -85,7 +89,7 @@ class TestUSDTimePreservation: """Test suite for USD time-varying data preservation.""" @pytest.fixture(scope="class") - def test_data_files(self): + def test_data_files(self) -> dict[str, str]: """Locate test USD files with time-varying data.""" dynamic_file = Path( "experiments/Heart-GatedCT_To_USD/results/Slicer_CardiacGatedCT.dynamic_anatomy_painted.usd" @@ -100,24 +104,29 @@ def test_data_files(self): return {"dynamic": str(dynamic_file), "static": str(static_file)} @pytest.fixture(scope="class") - def output_dir(self, tmp_path_factory): + def output_dir(self, tmp_path_factory: pytest.TempPathFactory) -> Path: """Create temporary output directory for test results.""" output_dir = tmp_path_factory.mktemp("usd_time_tests") return output_dir @pytest.fixture(scope="class") - def source_metadata(self, test_data_files): + def source_metadata(self, test_data_files: dict[str, str]) -> dict[str, Any]: """Get time metadata from source file.""" return get_time_metadata(test_data_files["dynamic"]) @pytest.fixture(scope="class") - def source_time_samples(self, test_data_files): + def source_time_samples( + self, test_data_files: dict[str, str] + ) -> Optional[dict[str, Any]]: """Get time sample data from source file.""" return get_mesh_time_samples(test_data_files["dynamic"]) def test_merge_copy_preserves_time_metadata( - self, test_data_files, source_metadata, output_dir - ): + self, + test_data_files: dict[str, str], + source_metadata: dict[str, Any], + output_dir: Path, + ) -> None: """Test that merge_usd_files() preserves time metadata.""" usd_tools = USDTools() merged_file = output_dir / "test_time_copy.usd" @@ -145,8 +154,11 @@ def test_merge_copy_preserves_time_metadata( ) def test_merge_flattened_preserves_time_metadata( - self, test_data_files, source_metadata, output_dir - ): + self, + test_data_files: dict[str, str], + source_metadata: dict[str, Any], + output_dir: Path, + ) -> None: """Test that merge_usd_files_flattened() preserves time metadata.""" usd_tools = USDTools() merged_file = output_dir / "test_time_flattened.usd" @@ -174,8 +186,11 @@ def test_merge_flattened_preserves_time_metadata( ) def test_merge_copy_preserves_time_samples( - self, test_data_files, source_time_samples, output_dir - ): + self, + test_data_files: dict[str, str], + source_time_samples: Optional[dict[str, Any]], + output_dir: Path, + ) -> None: """Test that merge_usd_files() preserves actual time sample data.""" if source_time_samples is None: pytest.skip("Test mesh not found in source data") @@ -212,8 +227,11 @@ def test_merge_copy_preserves_time_samples( ) def test_merge_flattened_preserves_time_samples( - self, test_data_files, source_time_samples, output_dir - ): + self, + test_data_files: dict[str, str], + source_time_samples: Optional[dict[str, Any]], + output_dir: Path, + ) -> None: """Test that merge_usd_files_flattened() preserves actual time sample data.""" if source_time_samples is None: pytest.skip("Test mesh not found in source data") @@ -250,8 +268,11 @@ def test_merge_flattened_preserves_time_samples( ) def test_animation_range_matches_actual_motion( - self, test_data_files, source_time_samples, output_dir - ): + self, + test_data_files: dict[str, str], + source_time_samples: Optional[dict[str, Any]], + output_dir: Path, + ) -> None: """ Test that the full animation range is accessible. @@ -274,6 +295,7 @@ def test_animation_range_matches_actual_motion( # Calculate the time duration that the animation represents # This should equal the number of time codes (e.g., 21 codes = 21 seconds at TCPS=1.0) + assert samples is not None, "Test mesh not found in merged file" num_time_codes = len(samples["time_codes"]) expected_duration = metadata["end_time"] - metadata["start_time"] diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index 0470466..e910e76 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -32,28 +32,28 @@ # Helper to get data paths -def get_data_dir(): +def get_data_dir() -> Path: """Get the data directory path.""" tests_dir = Path(__file__).parent project_root = tests_dir.parent return project_root / "data" -def check_kcl_heart_data(): +def check_kcl_heart_data() -> bool: """Check if KCL Heart Model data is available.""" data_dir = get_data_dir() / "KCL-Heart-Model" vtk_file = data_dir / "average_mesh.vtk" return vtk_file.exists() -def check_valve4d_data(): +def check_valve4d_data() -> bool: """Check if CHOP Valve4D data is available.""" data_dir = get_data_dir() / "CHOP-Valve4D" alterra_dir = data_dir / "Alterra" return alterra_dir.exists() and any(alterra_dir.glob("*.vtk")) -def get_or_create_average_surface(test_directories): +def get_or_create_average_surface(test_directories: dict[str, Path]) -> Path: """ Get or create average_surface.vtp from average_mesh.vtk. @@ -66,38 +66,40 @@ def get_or_create_average_surface(test_directories): Returns: Path to the average_surface.vtp file """ - output_dir = test_directories["output"] + output_dir = test_directories["output"] / "vtk_to_usd_library" + output_dir.mkdir(parents=True, exist_ok=True) + surface_file = output_dir / "average_surface.vtp" # If surface file already exists, return it if surface_file.exists(): - print(f"\nāœ“ Using cached surface file: {surface_file}") + print(f"\nUsing cached surface file: {surface_file}") return surface_file # Create surface from volumetric mesh data_dir = get_data_dir() / "KCL-Heart-Model" vtk_file = data_dir / "average_mesh.vtk" - print(f"\nāš™ Creating surface from: {vtk_file}") + print(f"\nCreating surface from: {vtk_file}") # Load volumetric mesh vtk_mesh = pv.read(str(vtk_file)) # Extract surface - surface = vtk_mesh.extract_surface() + surface = vtk_mesh.extract_surface(algorithm="dataset_surface") # Save to output directory surface.save(str(surface_file)) - print(f"āœ“ Created and saved surface: {surface_file}") + print(f"Created and saved surface: {surface_file}") print(f" Points: {surface.n_points:,}") - print(f" Faces: {surface.n_faces:,}") + print(f" Faces: {surface.n_faces_strict:,}") return surface_file @pytest.fixture(scope="session") -def kcl_average_surface(test_directories): +def kcl_average_surface(test_directories: dict[str, Path]) -> Path: """ Fixture providing the KCL average heart surface. @@ -116,7 +118,7 @@ def kcl_average_surface(test_directories): class TestGenericArray: """Test GenericArray data structure validation and reshaping.""" - def test_scalar_1d_array(self): + def test_scalar_1d_array(self) -> None: """Test that 1D scalar arrays (num_components=1) are kept as-is.""" data = np.array([1.0, 2.0, 3.0, 4.0]) array = GenericArray( @@ -129,7 +131,7 @@ def test_scalar_1d_array(self): assert len(array.data) == 4 np.testing.assert_array_equal(array.data, data) - def test_flat_multicomponent_array_reshape(self): + def test_flat_multicomponent_array_reshape(self) -> None: """Test that flat 1D arrays with num_components>1 are reshaped to 2D.""" # 12 values that should reshape to (4, 3) data = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], dtype=float) @@ -145,7 +147,7 @@ def test_flat_multicomponent_array_reshape(self): expected = data.reshape(-1, 3) np.testing.assert_array_equal(array.data, expected) - def test_2d_array_valid(self): + def test_2d_array_valid(self) -> None: """Test that 2D arrays with correct shape are accepted.""" data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], dtype=float) array = GenericArray( @@ -158,7 +160,7 @@ def test_2d_array_valid(self): assert array.data.shape == (4, 3) np.testing.assert_array_equal(array.data, data) - def test_flat_array_not_divisible_raises_error(self): + def test_flat_array_not_divisible_raises_error(self) -> None: """Test that flat arrays with length not divisible by num_components raise error.""" data = np.array([1, 2, 3, 4, 5], dtype=float) # 5 values, not divisible by 3 with pytest.raises(ValueError, match="not divisible by num_components"): @@ -169,7 +171,7 @@ def test_flat_array_not_divisible_raises_error(self): data_type=DataType.FLOAT, ) - def test_2d_array_wrong_shape_raises_error(self): + def test_2d_array_wrong_shape_raises_error(self) -> None: """Test that 2D arrays with wrong shape raise error.""" data = np.array([[1, 2], [3, 4], [5, 6]], dtype=float) # Shape (3, 2) with pytest.raises(ValueError, match="incompatible with num_components"): @@ -180,7 +182,7 @@ def test_2d_array_wrong_shape_raises_error(self): data_type=DataType.FLOAT, ) - def test_3d_array_raises_error(self): + def test_3d_array_raises_error(self) -> None: """Test that 3D arrays are rejected.""" data = np.ones((2, 3, 4), dtype=float) with pytest.raises(ValueError, match="must be 1D or 2D"): @@ -191,7 +193,7 @@ def test_3d_array_raises_error(self): data_type=DataType.FLOAT, ) - def test_flat_array_large_components(self): + def test_flat_array_large_components(self) -> None: """Test reshaping with large num_components (e.g., 9 for 3x3 tensors).""" # 18 values that should reshape to (2, 9) data = np.arange(18, dtype=float) @@ -210,7 +212,7 @@ def test_flat_array_large_components(self): class TestVTKReader: """Test VTK file reading capabilities.""" - def test_read_vtp_file(self, kcl_average_surface): + def test_read_vtp_file(self, kcl_average_surface: Path) -> None: """Test reading VTP (PolyData) files.""" vtp_file = kcl_average_surface @@ -226,12 +228,12 @@ def test_read_vtp_file(self, kcl_average_surface): assert mesh_data.face_vertex_counts is not None assert mesh_data.face_vertex_indices is not None - print(f"\nāœ“ Read VTP file: {vtp_file.name}") + print(f"\nRead VTP file: {vtp_file.name}") print(f" Points: {len(mesh_data.points):,}") print(f" Faces: {len(mesh_data.face_vertex_counts):,}") print(f" Data arrays: {len(mesh_data.generic_arrays)}") - def test_read_legacy_vtk_file(self): + def test_read_legacy_vtk_file(self) -> None: """Test reading legacy VTK files.""" if not check_kcl_heart_data(): pytest.skip( @@ -253,12 +255,12 @@ def test_read_legacy_vtk_file(self): assert mesh_data.face_vertex_counts is not None assert mesh_data.face_vertex_indices is not None - print(f"\nāœ“ Read legacy VTK file: {vtk_file.name}") + print(f"\nRead legacy VTK file: {vtk_file.name}") print(f" Points: {len(mesh_data.points):,}") print(f" Faces: {len(mesh_data.face_vertex_counts):,}") print(f" Data arrays: {len(mesh_data.generic_arrays)}") - def test_generic_arrays_preserved(self, kcl_average_surface): + def test_generic_arrays_preserved(self, kcl_average_surface: Path) -> None: """Test that generic data arrays are preserved during reading.""" vtp_file = kcl_average_surface @@ -274,7 +276,7 @@ def test_generic_arrays_preserved(self, kcl_average_surface): assert array.num_components > 0 assert array.interpolation in ["vertex", "uniform", "constant"] - print("\nāœ“ Generic arrays preserved:") + print("\nGeneric arrays preserved:") for array in mesh_data.generic_arrays: print( f" - {array.name}: {array.num_components} components, {len(array.data):,} values" @@ -285,7 +287,9 @@ def test_generic_arrays_preserved(self, kcl_average_surface): class TestVTKToUSDConversion: """Test VTK to USD conversion capabilities.""" - def test_single_file_conversion(self, test_directories, kcl_average_surface): + def test_single_file_conversion( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test converting a single VTK file to USD.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -320,12 +324,14 @@ def test_single_file_conversion(self, test_directories, kcl_average_surface): points = mesh.GetPointsAttr().Get() assert len(points) > 0 - print("\nāœ“ Converted single file to USD") + print("\nConverted single file to USD") print(f" Input: {vtp_file.name}") print(f" Output: {output_usd}") print(f" Points: {len(points):,}") - def test_conversion_with_material(self, test_directories, kcl_average_surface): + def test_conversion_with_material( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test conversion with custom material.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -366,11 +372,13 @@ def test_conversion_with_material(self, test_directories, kcl_average_surface): bound_material = binding_api.ComputeBoundMaterial()[0] assert bound_material.GetPrim().IsValid() - print("\nāœ“ Converted with custom material") + print("\nConverted with custom material") print(f" Material: {material.name}") print(f" Color: {material.diffuse_color}") - def test_conversion_settings(self, test_directories, kcl_average_surface): + def test_conversion_settings( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test conversion with custom settings.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -398,12 +406,14 @@ def test_conversion_settings(self, test_directories, kcl_average_surface): assert UsdGeom.GetStageMetersPerUnit(stage) == 0.001 assert UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.y - print("\nāœ“ Converted with custom settings") + print("\nConverted with custom settings") print(f" Meters per unit: {settings.meters_per_unit}") print(f" Up axis: {settings.up_axis}") print(f" Compute normals: {settings.compute_normals}") - def test_primvar_preservation(self, test_directories, kcl_average_surface): + def test_primvar_preservation( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test that VTK data arrays are preserved as USD primvars.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -433,7 +443,7 @@ def test_primvar_preservation(self, test_directories, kcl_average_surface): # Verify at least some arrays were converted to primvars assert len(primvar_names) > 0 - print("\nāœ“ Primvars preserved:") + print("\nPrimvars preserved:") print(f" Source arrays: {len(array_names)}") print(f" USD primvars: {len(primvar_names)}") for name in primvar_names[:5]: # Show first 5 @@ -444,7 +454,9 @@ def test_primvar_preservation(self, test_directories, kcl_average_surface): class TestTimeSeriesConversion: """Test time-series conversion capabilities.""" - def test_time_series_conversion(self, test_directories, kcl_average_surface): + def test_time_series_conversion( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test converting multiple VTK files as time series.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -483,7 +495,7 @@ def test_time_series_conversion(self, test_directories, kcl_average_surface): assert len(time_samples) == 3 assert time_samples == time_codes - print("\nāœ“ Converted time series") + print("\nConverted time series") print(f" Frames: {len(vtk_files)}") print(f" Time codes: {time_codes}") print( @@ -495,7 +507,9 @@ def test_time_series_conversion(self, test_directories, kcl_average_surface): class TestIntegration: """Integration tests combining multiple features.""" - def test_end_to_end_conversion(self, test_directories, kcl_average_surface): + def test_end_to_end_conversion( + self, test_directories: dict[str, Path], kcl_average_surface: Path + ) -> None: """Test complete conversion workflow with all features.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) @@ -552,7 +566,7 @@ def test_end_to_end_conversion(self, test_directories, kcl_average_surface): primvars = primvars_api.GetPrimvars() assert len(primvars) > 0 - print("\nāœ“ End-to-end conversion complete") + print("\nEnd-to-end conversion complete") print(f" Output: {output_usd}") print(f" Size: {output_usd.stat().st_size / 1024:.1f} KB") print(f" Points: {len(points):,}")