diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/README.md b/examples/apps/cchmc_ped_abd_ct_seg_app/README.md index 5e1a8a21..02d00acf 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/README.md +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/README.md @@ -6,35 +6,156 @@ The PyTorch and TorchScript DynUNet models can be downloaded from the [MONAI Bun For questions, please feel free to contact Elan Somasundaram (Elanchezhian.Somasundaram@cchmc.org) and Bryan Luna (Bryan.Luna@cchmc.org). -## Unique Features +## Pipeline Overview -Some unique features of this MAP pipeline include: -- **Custom Inference Operator:** custom `AbdomenSegOperator` enables either PyTorch or TorchScript model loading -- **DICOM Secondary Capture Output:** custom `DICOMSecondaryCaptureWriterOperator` writes a DICOM SC with organ contours -- **Output Filtering:** model produces Liver-Spleen-Pancreas segmentations, but seg visibility in the outputs (DICOM SEG, SC, SR) can be controlled in `app.py` -- **MONAI Deploy Express MongoDB Write:** custom operators (`MongoDBEntryCreatorOperator` and `MongoDBWriterOperator`) allow for writing to the MongoDB database associated with MONAI Deploy Express +The application executes the following processing DAG: -## Scripts -Several scripts have been compiled that quickly execute useful actions (such as running the app code locally with Python interpreter, MAP packaging, MAP execution, etc.). Some scripts require the input of command line arguments; review the `scripts` folder for more details. +1. **DICOM Data Loader** — loads all DICOM instances from the input folder +2. **DICOM Series Selector** — selects qualifying CT series using JSON-based rules (see [Series Selection](#series-selection)) +3. **DICOM Series to Volume** — converts the selected series to a 3D image volume; registers [nvImageCodec](https://github.com/NVIDIA/nvImageCodec) GPU-accelerated decoder plugin (`decoder_nvimgcodec.py`) with `pydicom` at startup for compressed pixel data (JPEG, JPEG 2000, HTJ2K). **Note:** CUDA 12-compatible `nvJPEG` lossless decoder silently produces zero-filled buffers for JPEG Lossless streams where `9 <= BitsStored <= 15` or `DHT` is before `SOF3`. A custom version of `decoder_nvimgcodec.py` has been created that detects this condition and raises `NotImplementedError` so pydicom falls through to the next capable decoder (e.g. `GDCM`). This issue is resolved in `nvJPEG>= 13.0.2` (see [nvImageCodec issue](https://github.com/NVIDIA/nvImageCodec/pull/51#issuecomment-4407066179)) +4. **Abdomen Seg Operator** — runs DynUNet inference to produce Liver / Spleen / Pancreas segmentation masks (labels 1 / 2 / 3) +5. **Segmentation Metrics Operator** — computes volume, slice count, pixel count, and intensity statistics per organ +6. **Segmentation Z-Score Operator** — compares organ metrics to age/sex-specific normative CSV data; generates z-scores, percentiles, and an optional PDF report +7. **Segmentation Contour Operator** — extracts boundary contours from the segmentation mask +8. **Segmentation Overlay Operator** — blends contours onto the input scan to produce an RGB overlay image; applies VOI LUT windowing from the source series when available; GPU-accelerated +9. **DICOM SEG Writer** — writes organ masks as a DICOM Segmentation object → `output/SEG/` +10. **DICOM SR Writer** — writes organ volumes (Liver, Spleen) and z-scores as a DICOM Enhanced SR → `output/SR/` +11. **DICOM SC Writer** — writes the contour overlay as a DICOM Secondary Capture → `output/SC/` + +## Custom Operators + +| Operator | File | Description | +|---|---|---| +| `AbdomenSegOperator` | `abdomen_seg_operator.py` | Inference with DynUNet; supports PyTorch (`.pt` state dict) and TorchScript model loading | +| `DICOMTextSRWriterOperator` | `dicom_text_sr_writer_operator.py` | Writes DICOM Enhanced SR with a structured content sequence; supports field filtering and custom Concept Name codes | +| `DICOMSCWriterOperator` | `dicom_sc_writer_operator.py` | Writes multi-frame DICOM Secondary Capture with source series metadata copied | +| `SegmentationMetricsOperator` | `segmentation_metrics_operator.py` | Computes volume, slice count, pixel count, and intensity stats; GPU-accelerated via CuPy when available | +| `SegmentationZScoreOperator` | `segmentation_zscore_operator.py` | Computes z-scores and percentiles against normative CSV data; generates `matplotlib` PDF visualization | +| `SegmentationContourOperator` | `segmentation_contour_operator.py` | Extracts label boundaries using MONAI `LabelToContour` transform | +| `SegmentationOverlayOperator` | `segmentation_overlay_operator.py` | Generates RGB overlay via alpha blending; handles CT VOI LUT windowing and per-slice windowing variations | + +## DICOM Outputs + +| Output | Subfolder | Contents | +|---|---|---| +| DICOM SEG | `output/SEG/` | Organ segmentation masks (Liver, Spleen, Pancreas) | +| DICOM SR | `output/SR/` | Liver and Spleen volumes and z-scores (CT Abdomen Report, LOINC 41806-1) | +| DICOM SC | `output/SC/` | Organ contour overlay images | +| DICOM Encapsulated PDF | `output/PDF/` | Z-score quantile curve report | + +Output visibility is controlled by the `labels_dict` parameters in `app.py`. By default, the model segments Liver (1), Spleen (2), and Pancreas (3); SEG and SC outputs have all organs, but only Liver and Spleen are included in the SR and Encapsulated PDF outputs. + +## Z-Score Analysis + +The `SegmentationZScoreOperator` compares computed organ metrics against sex-stratified, age-specific normative reference data to produce clinically interpretable z-scores and percentiles. -## Notes -The DICOM Series selection criteria has been customized based on the model's training and CCHMC use cases. By default, Axial CT series with Slice Thickness between 3.0 - 5.0 mm (inclusive) will be selected for. +### How It Works -If MongoDB writing is not desired, please comment out the relevant sections in `app.py` and the `AbdomenSegOperator`. +1. Patient demographics (age and sex) are extracted directly from the DICOM series tags (`PatientAge`, `PatientSex`). +2. For each organ metric (e.g., liver volume, spleen volume, liver HU), the operator locates the matching normative dataset in the `assets/` folder. +3. Quantile regression curves (5th–95th percentile, in 5-percentile steps) are interpolated at the patient's exact age using linear interpolation with extrapolation at the boundaries. +4. The patient's measured value is placed within those quantile curves to estimate its percentile, which is then converted to a z-score via the inverse normal CDF (`scipy.stats.norm.ppf`). +5. If `generate_plots=True`, a multi-panel PDF is rendered with quantile curves and the patient's value annotated for each organ, then passed downstream via the `pdf_bytes` output port. -To execute the pipeline with MongoDB writing enabled, it is best to create a `.env` file that the `MongoDBWriterOperator` can load in. Below is an example `.env` file that follows the format outlined in this operator; note that these values are the default variable values as defined in the [.env](https://github.com/Project-MONAI/monai-deploy/blob/main/deploy/monai-deploy-express/.env) and [docker-compose.yaml](https://github.com/Project-MONAI/monai-deploy/blob/main/deploy/monai-deploy-express/docker-compose.yml) files of v0.6.0 of MONAI Deploy Express: +### PDF Report -```dotenv -MONGODB_USERNAME=root -MONGODB_PASSWORD=rootpassword -MONGODB_PORT=27017 -MONGODB_IP_DOCKER=172.17.0.1 # default Docker bridge network IP +The `SegmentationZScoreOperator` outputs an optional **PDF visualization** (in-memory `bytes`) containing one subplot per organ. Each panel shows: +- Age-specific quantile curves (5th, 25th, 50th, 75th, 95th percentile) for the patient's sex +- The patient's measured value plotted as a marker at the patient's age +- An annotation box displaying the raw value, percentile, and z-score + +The PDF is passed to `DICOMEncapsulatedPDFWriterOperator`. + +## Assets Folder + +The `assets/` folder contains the sex-stratified normative reference data used by `SegmentationZScoreOperator`. Each subfolder corresponds to one biomarker and must contain two CSV files — one for males (`results_m_fine.csv`) and one for females (`results_f_fine.csv`). + +### Structure + +```text +assets/ +├── liver/ # Liver volume normative data +│ ├── results_m_fine.csv +│ ├── results_f_fine.csv +│ ├── results_df.csv # Raw cohort data +│ ├── outlier_df.csv # Identified outliers +│ ├── stats.json # Cohort summary statistics +│ └── figure.html # Interactive quantile figure +├── liver_hu/ # Liver mean HU normative data +│ └── (same structure) +└── spleen/ # Spleen volume normative data + └── (same structure) ``` -Prior to packaging into a MAP, the MongoDB credentials should be hardcoded into the `MongoDBWriterOperator`. +### CSV Format + +Each `results_{m,f}_fine.csv` contains pre-computed quantile regression curves with the following columns: + +| Column | Description | +|---|---| +| `Age` | Age in years (2.0–19.0, 0.5-year steps) | +| `0.05` – `0.95` | Predicted biomarker value at that quantile level (5th–95th percentile, 5-point steps) | + +### Cohort Summary -The MONAI Deploy Express MongoDB Docker container (`mdl-mongodb`) needs to be connected to the Docker bridge network in order for the MongoDB write to be successful. Executing the following command in a MONAI Deploy Express terminal will establish this connection: +| Biomarker | Males (n) | Females (n) | Age Range | +|---|---|---|---| +| Liver volume (mL) | 1,025 | 1,107 | 2–19 years | +| Liver mean HU | 1,025 | 1,107 | 2–19 years | +| Spleen volume (mL) | 1,013 | 1,089 | 2–19 years | -```bash -docker network connect bridge mdl-mongodb +### Adding a New Biomarker + +To add normative data for a new organ or metric: +1. Create a subfolder under `assets/` (e.g., `assets/pancreas/`). +2. Add `results_m_fine.csv` and `results_f_fine.csv` with the same column format described above. +3. In `app.py`, add the organ to `labels_dict` and update `organ_name_mapping` in `SegmentationZScoreOperator` if the metric key differs from the folder name. + +## Series Selection + +Series selection criteria are defined in JSON within `app.py` and evaluated by `DICOMSeriesSelectorOperator`. The default rules select **Standard Axial CT** series meeting all of the following: + +- **Modality:** `CT` (case-insensitive) +- **ImageOrientationPatient:** Axial orientation (determined programmatically) +- **ImageType:** contains `PRIMARY` (excludes secondary and reformatted series) +- **SliceThickness:** between 2.0 and 5.0 mm (inclusive) +- **SeriesDescription:** does not contain `cor`, `sag`, or `lung` (case-insensitive) + +All series matching the criteria are selected (`all_matched=True`) and sorted by SOP instance count. Downstream operators perform inference and write outputs for the first selected series only. + +## Model Information + +- **Architecture:** DynUNet (3D, instance normalization, residual blocks, deep supervision disabled) +- **Labels:** background (0), liver (1), spleen (2), pancreas (3) +- **Algorithm Name:** CCHMC Pediatric CT Liver-Spleen Segmentation +- **Algorithm Version:** 0.4.3 +- **MAP Version:** 1.10.0 + +## Resource Requirements + +| Resource | Requirement | +|---|---| +| CPU | 1 | +| GPU | 1 | +| System Memory | 1 Gi | +| GPU Memory | 11 Gi | + +With a NVIDIA GeForce RTX 3090 (24 GB), inference for a 204-instance input series takes approximately 21 seconds. + +## Scripts + +The `scripts/` folder contains shell scripts for common tasks (e.g. running the app code locally with Python interpreter, MAP packaging, MAP execution). All scripts expect a `.env` file in the working directory that sets `HOLOSCAN_INPUT_PATH`, `HOLOSCAN_OUTPUT_PATH`, and `HOLOSCAN_MODEL_PATH`. See example below: + +```env +HOLOSCAN_INPUT_PATH=${PWD}/input +HOLOSCAN_MODEL_PATH=${PWD}/model/dynunet_FT.ts +HOLOSCAN_OUTPUT_PATH=${PWD}/output ``` + +| Script | Arguments | Description | +|---|---|---| +| `model_run.sh` | — | Runs the app locally with the Python interpreter | +| `map_build.sh` | ` ` | Packages the app as a MAP using `monai-deploy package` | +| `map_run.sh` | ` ` | Runs the MAP locally using `monai-deploy run` | +| `map_run_interactive.sh` | — | Runs the MAP container interactively for debugging | +| `map_extract.sh` | — | Extracts the MAP container filesystem for inspection | diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/__main__.py b/examples/apps/cchmc_ped_abd_ct_seg_app/__main__.py index 80cca2fa..3cf180d9 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/__main__.py +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/__main__.py @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -13,6 +13,10 @@ # app.py is executed in the application folder path # e.g., python my_app +# # specify GPU to use for this application +# import os +# os.environ["CUDA_VISIBLE_DEVICES"] = "0" + import logging # import AIAbdomenSegApp class from app.py diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/abdomen_seg_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/abdomen_seg_operator.py index 2f412f14..1de13104 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/abdomen_seg_operator.py +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/abdomen_seg_operator.py @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -11,15 +11,13 @@ import logging from pathlib import Path -from typing import List +from typing import Dict import torch from numpy import float32, int16 -# import custom transforms from post_transforms.py -from post_transforms import CalculateVolumeFromMaskd, ExtractVolumeToTextd, LabelToContourd, OverlayImageLabeld - import monai +from monai.data import MetaTensor from monai.deploy.core import AppContext, Fragment, Model, Operator, OperatorSpec from monai.deploy.operators.monai_seg_inference_operator import InfererType, InMemImageReader, MonaiSegInferenceOperator from monai.transforms import ( @@ -31,11 +29,13 @@ EnsureChannelFirstd, EnsureTyped, Invertd, + KeepLargestConnectedComponentd, LoadImaged, Orientationd, SaveImaged, ScaleIntensityRanged, Spacingd, + ToDeviced, ) @@ -52,7 +52,7 @@ def __init__( app_context: AppContext, model_path: Path, output_folder: Path = DEFAULT_OUTPUT_FOLDER, - output_labels: List[int], + output_labels: Dict, **kwargs, ): @@ -69,9 +69,7 @@ def __init__( self.app_context = app_context self.input_name_image = "image" self.output_name_seg = "seg_image" - self.output_name_text_dicom_sr = "result_text_dicom_sr" - self.output_name_text_mongodb = "result_text_mongodb" - self.output_name_sc_path = "dicom_sc_dir" + self.output_name_seg_metatensor = "seg_metatensor" # the base class has an attribute called fragment to hold the reference to the fragment object super().__init__(fragment, *args, **kwargs) @@ -140,14 +138,8 @@ def setup(self, spec: OperatorSpec): # DICOM SEG spec.output(self.output_name_seg) - # DICOM SR - spec.output(self.output_name_text_dicom_sr) - - # MongoDB - spec.output(self.output_name_text_mongodb) - - # DICOM SC - spec.output(self.output_name_sc_path) + # MetaTensor + spec.output(self.output_name_seg_metatensor) def compute(self, op_input, op_output, context): input_image = op_input.receive(self.input_name_image) @@ -183,55 +175,44 @@ def compute(self, op_input, op_output, context): sw_batch_size=4, model_path=self.model_path, name="monai_seg_inference_op", + metatensor_output=True, # keep seg image on GPU as MetaTensor ) # setting the keys used in the dictionary-based transforms infer_operator.input_dataset_key = self._input_dataset_key infer_operator.pred_dataset_key = self._pred_dataset_key + # compute_impl returns a single Image object seg_image = infer_operator.compute_impl(input_image, context) # DICOM SEG + self._logger.info(f"Seg Image Shape: {seg_image._data.shape}, Type: {type(seg_image)}") op_output.emit(seg_image, self.output_name_seg) - # grab result_text_dicom_sr and result_text_mongodb from ExractVolumeToTextd transform - result_text_dicom_sr, result_text_mongodb = self.get_result_text_from_transforms(post_transforms) - if not result_text_dicom_sr or not result_text_mongodb: - raise ValueError("Result text could not be generated.") - - # only log volumes for target organs so logs reflect MAP behavior - self._logger.info(f"Calculated Organ Volumes: {result_text_dicom_sr}") - - # DICOM SR - op_output.emit(result_text_dicom_sr, self.output_name_text_dicom_sr) - - # MongoDB - op_output.emit(result_text_mongodb, self.output_name_text_mongodb) - - # DICOM SC - # temporary DICOM SC (w/o source DICOM metadata) saved in output_folder / temp directory - dicom_sc_dir = self.output_folder / "temp" - - self._logger.info(f"Temporary DICOM SC saved at: {dicom_sc_dir}") - - op_output.emit(dicom_sc_dir, self.output_name_sc_path) + # MetaTensor - wrap numpy array so downstream operators receive a MetaTensor + seg_metatensor = MetaTensor(torch.from_numpy(seg_image._data.copy())) + self._logger.info(f"Seg MetaTensor Shape: {seg_metatensor.shape}, Type: {type(seg_metatensor)}") + op_output.emit(seg_metatensor, self.output_name_seg_metatensor) def pre_process(self, img_reader) -> Compose: """Composes transforms for preprocessing the input image before predicting on a model.""" - my_key = self._input_dataset_key + image_key = self._input_dataset_key return Compose( [ # img_reader: specialized InMemImageReader, derived from MONAI ImageReader - LoadImaged(keys=my_key, reader=img_reader), - EnsureChannelFirstd(keys=my_key), - Orientationd(keys=my_key, axcodes="RAS"), - Spacingd(keys=my_key, pixdim=[1.5, 1.5, 3.0], mode=["bilinear"]), - ScaleIntensityRanged(keys=my_key, a_min=-250, a_max=400, b_min=0.0, b_max=1.0, clip=True), - CropForegroundd(keys=my_key, source_key=my_key, mode="minimum"), - EnsureTyped(keys=my_key), - CastToTyped(keys=my_key, dtype=float32), + LoadImaged(keys=image_key, reader=img_reader), + # Transform to move to GPU if available + ToDeviced(keys=image_key, device="cuda" if torch.cuda.is_available() else "cpu"), + EnsureChannelFirstd(keys=image_key), + Orientationd(keys=image_key, axcodes="RAS"), + Spacingd(keys=image_key, pixdim=[1.5, 1.5, 3.0], mode=["bilinear"]), + ScaleIntensityRanged(keys=image_key, a_min=-250, a_max=400, b_min=0.0, b_max=1.0, clip=True), + # allow_smaller default True --> False from monai==1.2.0 --> 1.5.0; explicitly set + CropForegroundd(keys=image_key, source_key=image_key, allow_smaller=True, mode="minimum"), + EnsureTyped(keys=image_key), + CastToTyped(keys=image_key, dtype=float32), ] ) @@ -240,8 +221,6 @@ def post_process(self, pre_transforms: Compose) -> Compose: pred_key = self._pred_dataset_key - labels = {"background": 0, "liver": 1, "spleen": 2, "pancreas": 3} - return Compose( [ Activationsd(keys=pred_key, softmax=True), @@ -254,41 +233,18 @@ def post_process(self, pre_transforms: Compose) -> Compose: to_tensor=True, ), AsDiscreted(keys=pred_key, argmax=True), - # custom post-processing steps - CalculateVolumeFromMaskd(keys=pred_key, label_names=labels), - # optional code for saving segmentation masks as a NIfTI - # SaveImaged( - # keys=pred_key, - # output_ext=".nii.gz", - # output_dir=self.output_folder / "NIfTI", - # meta_keys="pred_meta_dict", - # separate_folder=False, - # output_dtype=int16 - # ), - # volume data stored in dictionary under pred_key + '_volumes' key - ExtractVolumeToTextd( - keys=[pred_key + "_volumes"], label_names=labels, output_labels=self.output_labels + # change from MONAI Bundle - Keep LCC + KeepLargestConnectedComponentd( + keys=pred_key, applied_labels=[i for i in self.output_labels.values() if i > 0] ), - # comment out LabelToContourd for seg masks instead of contours; organ filtering will be lost - LabelToContourd(keys=pred_key, output_labels=self.output_labels), - OverlayImageLabeld(image_key=self._input_dataset_key, label_key=pred_key, overlay_key="overlay"), + # optional code for saving segmentation masks as a NIfTI SaveImaged( - keys="overlay", - output_ext=".dcm", - # save temporary DICOM SC (w/o source DICOM metadata) in output_folder / temp directory - output_dir=self.output_folder / "temp", + keys=pred_key, + output_ext=".nii.gz", + output_dir=self.output_folder / "NIfTI", + meta_keys="pred_meta_dict", separate_folder=False, output_dtype=int16, ), ] ) - - # grab volume data from ExtractVolumeToTextd transform - def get_result_text_from_transforms(self, post_transforms: Compose): - """Extracts the result_text variables from post-processing transforms output.""" - - # grab the result_text variables from ExractVolumeToTextd transfor - for transform in post_transforms.transforms: - if isinstance(transform, ExtractVolumeToTextd): - return transform.result_text_dicom_sr, transform.result_text_mongodb - return None diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/app.py b/examples/apps/cchmc_ped_abd_ct_seg_app/app.py index 845954b0..f2478c18 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/app.py +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/app.py @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -18,21 +18,33 @@ # custom DICOM Secondary Capture (SC) writer operator from dicom_sc_writer_operator import DICOMSCWriterOperator -# custom MongoDB operators -from mongodb_entry_creator_operator import MongoDBEntryCreatorOperator -from mongodb_writer_operator import MongoDBWriterOperator +# custom DICOMSegmentationWriterOperator +from dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription + +# custom DICOMSeriesSelectorOperator +from dicom_series_selector_operator import DICOMSeriesSelectorOperator + +# DICOMSeriesToVolumeOperator with custom nvimgcodec decoder for 9-15 bit stored JPEGLossless +from dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator + +# custom DICOMTextSRWriterOperator +from dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo # required for setting SegmentDescription attributes # direct import as this is not part of App SDK package from pydicom.sr.codedict import codes +# custom Segmentation operators +from segmentation_contour_operator import SegmentationContourOperator +from segmentation_metrics_operator import SegmentationMetricsOperator +from segmentation_overlay_operator import SegmentationOverlayOperator +from segmentation_zscore_operator import SegmentationZScoreOperator + from monai.deploy.conditions import CountCondition from monai.deploy.core import Application from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator -from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription -from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator -from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator -from monai.deploy.operators.dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo +from monai.deploy.operators.dicom_encapsulated_pdf_writer_operator import DICOMEncapsulatedPDFWriterOperator +from monai.deploy.utils.version import get_sdk_semver # inherit new Application class instance, AIAbdomenSegApp, from MONAI Application base class @@ -43,16 +55,15 @@ class AIAbdomenSegApp(Application): This application loads a set of DICOM instances, selects the appropriate series, converts the series to 3D volume image, performs inference with a custom inference operator, including pre-processing - and post-processing, saves a DICOM SEG (organ contours), a DICOM Secondary Capture (organ contours overlay), - and a DICOM SR (organ volumes), and writes organ volumes and relevant DICOM tags to the MONAI Deploy Express - MongoDB database (optional). + and post-processing, produces segmentation metrics, and saves a DICOM SEG (organ masks), a + DICOM Secondary Capture (organ contours overlay), and a DICOM SR (organ volumes). Pertinent MONAI Bundle: https://github.com/cchmc-dll/pediatric_abdominal_segmentation_bundle/tree/original Execution Time Estimate: With a NVIDIA GeForce RTX 3090 24GB GPU, for an input DICOM Series of 204 instances, the execution time is around - 25 seconds for DICOM SEG, DICOM SC, and DICOM SR outputs, as well as the MDE MongoDB database write. + 21 seconds for DICOM SEG, DICOM SC, and DICOM SR outputs. """ def __init__(self, *args, **kwargs): @@ -95,12 +106,15 @@ def compose(self): series_to_vol_op = DICOMSeriesToVolumeOperator(self, name="series_to_vol_op") # custom inference op - # output_labels specifies which of the organ segmentations are desired in the DICOM SEG, DICOM SC, and DICOM SR outputs - # 1 = Liver, 2 = Spleen, 3 = Pancreas; all segmentations performed, but visibility in outputs (SEG, SC, SR) controlled here - # all organ volumes will be written to MongoDB - output_labels = [1, 2, 3] + # output_labels specifies organ label mapping + output_labels = {"background": 0, "liver": 1, "spleen": 2, "pancreas": 3} abd_seg_op = AbdomenSegOperator( - self, app_context=app_context, model_path=model_path, output_labels=output_labels, name="abd_seg_op" + self, + app_context=app_context, + model_path=model_path, + output_folder=app_output_path, + output_labels=output_labels, + name="abd_seg_op", ) # create DICOM Seg writer providing the required segment description for each segment with @@ -109,7 +123,7 @@ def compose(self): # https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html # general algorithm information - _algorithm_name = "CCHMC Pediatric CT Abdominal Segmentation" + _algorithm_name = "CCHMC Pediatric CT Liver-Spleen Segmentation" _algorithm_family = codes.DCM.ArtificialIntelligence _algorithm_version = "0.4.3" @@ -140,15 +154,44 @@ def compose(self): ), ] - # custom tags - add Device UID to DICOM SEG to match SR and SC tags - custom_tags_seg = {"SeriesDescription": "AI Generated DICOM SEG; Not for Clinical Use.", "DeviceUID": "0.0.1"} - custom_tags_sr = {"SeriesDescription": "AI Generated DICOM SR; Not for Clinical Use."} - custom_tags_sc = {"SeriesDescription": "AI Generated DICOM Secondary Capture; Not for Clinical Use."} + # model info is algorithm information + my_model_info = ModelInfo( + creator="CCHMC CAIIR", # institution name + name=_algorithm_name, # algorithm name + version=_algorithm_version, # algorithm version + uid="1.11.0", # MAP version + ) + + # equipment info is MONAI Deploy App SDK information + my_equipment_info = EquipmentInfo( + manufacturer="The MONAI Consortium", + manufacturer_model="MONAI Deploy App SDK", + software_version_number=get_sdk_semver(), + ) + + # custom tags - add AlgorithmName for monitoring purposes + custom_tags_seg = { + "SeriesDescription": "AI Generated DICOM SEG; Not for Clinical Use.", + "AlgorithmName": f"{my_model_info.name}:{my_model_info.version}:{my_model_info.uid}", + } + custom_tags_sr = { + "SeriesDescription": "AI Generated DICOM SR; Not for Clinical Use.", + "AlgorithmName": f"{my_model_info.name}:{my_model_info.version}:{my_model_info.uid}", + } + custom_tags_sc = { + "SeriesDescription": "AI Generated DICOM Secondary Capture; Not for Clinical Use.", + "AlgorithmName": f"{my_model_info.name}:{my_model_info.version}:{my_model_info.uid}", + } + custom_tags_pdf = { + "SeriesDescription": "AI Generated Z-Score Report; Not for Clinical Use.", + "AlgorithmName": f"{my_model_info.name}:{my_model_info.version}:{my_model_info.uid}", + } # DICOM SEG Writer op writes content from segment_descriptions to output DICOM images as DICOM tags dicom_seg_writer = DICOMSegmentationWriterOperator( self, segment_descriptions=segment_descriptions, + model_info=my_model_info, custom_tags=custom_tags_seg, # store DICOM SEG in SEG subdirectory; necessary for routing in CCHMC MDE workflow definition output_folder=app_output_path / "SEG", @@ -159,9 +202,61 @@ def compose(self): name="dicom_seg_writer", ) - # model and equipment info - my_model_info = ModelInfo("CCHMC CAIIR", "CCHMC Pediatric CT Abdominal Segmentation", "0.4.3", "0.0.1") - my_equipment = EquipmentInfo(manufacturer="The MONAI Consortium", manufacturer_model="MONAI Deploy App SDK") + # Segmentation Metrics Operator computes volume, slices, and intensity stats + # label_dict indicates organs to analyze (output visibility controlled here) + seg_metrics_op = SegmentationMetricsOperator( + self, name="seg_metrics_op", labels_dict={"liver": 1, "spleen": 2, "pancreas": 3}, use_gpu=True + ) + + # Segmentation Z-Score operator computes z-scores and percentiles, and generates a PDF report + # get assets path + app_root = Path(__file__).resolve().parent + assets_dir = app_root / "assets" # works for python script execution + + if not assets_dir.exists(): + assets_dir = Path("/opt/holoscan/app/assets") # works for python script execution + + self._logger.info(f"Using assets path: {assets_dir}") + + seg_zscore_op = SegmentationZScoreOperator( + self, + assets_path=str(assets_dir), + generate_plots=True, + name="seg_zscore_op", + organ_name_mapping={"liver.hu": "liver_hu"}, # assets folder name mapping + sr_name_mapping={"liver": "liver.volume", "spleen": "spleen.volume"}, # DICOM SR Code mapping + # sr_filter_keys=["liver.volume", "spleen.volume"], # only write liver/spleen volumes to SR + additional_metrics_map={ + "liver.hu": { + "organ": "liver", + "metric": "mean.intensity.hu", + } + }, + ) + + # DICOM Encapsulated PDF Writer operator creates a DICOM PDF from the z-score PDF report + dicom_pdf_writer = DICOMEncapsulatedPDFWriterOperator( + self, + model_info=my_model_info, + equipment_info=my_equipment_info, + custom_tags=custom_tags_pdf, + # store DICOM Encapsulated PDF in PDF subdirectory; necessary for routing in CCHMC MDE workflow definition + output_folder=app_output_path / "PDF", + name="dicom_pdf_writer", + ) + + # Segmentation Contour operator creates segmentation contour DICOMs from segmentation + # label_dict indicates organs to analyze (output visibility controlled here) + dicom_contour_creator_op = SegmentationContourOperator( + self, + labels_dict={"liver": 1, "spleen": 2, "pancreas": 3}, + ) + + # Segmentation Overlay operator creates segmentation overlay DICOMs from segmentation + dicom_overlay_creator_op = SegmentationOverlayOperator( + self, + use_gpu=True, + ) # DICOM SR Writer op dicom_sr_writer = DICOMTextSRWriterOperator( @@ -171,33 +266,30 @@ def compose(self): # changed to True to copy DICOM attributes so DICOM SR has same Study UID copy_tags=True, model_info=my_model_info, - equipment_info=my_equipment, + equipment_info=my_equipment_info, custom_tags=custom_tags_sr, + # # optional: restrict SR content to specific metrics (e.g. volume, num.slices, liver.volume, etc.) + # included_fields=["liver.volume", "spleen.volume"], # only include volume metrics + # Concept Name Code Sequence: Concept Name Code (modality specific) + # Determined via PS3.16 - https://dicom.nema.org/medical/dicom/current/output/html/part16.html#PS3.16 + report_code_value="41806-1", + report_coding_scheme_designator="LN", + report_code_meaning="CT Abdomen Report", # store DICOM SR in SR subdirectory; necessary for routing in CCHMC MDE workflow definition output_folder=app_output_path / "SR", + name="dicom_sr_writer", ) # custom DICOM SC Writer op dicom_sc_writer = DICOMSCWriterOperator( self, model_info=my_model_info, - equipment_info=my_equipment, + equipment_info=my_equipment_info, custom_tags=custom_tags_sc, # store DICOM SC in SC subdirectory; necessary for routing in CCHMC MDE workflow definition output_folder=app_output_path / "SC", ) - # MongoDB database, collection, and MAP version info - database_name = "CTLiverSpleenSegPredictions" - collection_name = "OrganVolumes" - map_version = "0.0.1" - - # custom MongoDB Entry Creator op - mongodb_entry_creator = MongoDBEntryCreatorOperator(self, map_version=map_version) - - # custom MongoDB Writer op - mongodb_writer = MongoDBWriterOperator(self, database_name=database_name, collection_name=collection_name) - # create the processing pipeline, by specifying the source and destination operators, and # ensuring the output from the former matches the input of the latter, in both name and type # instantiate and connect operators using self.add_flow(); specify current operator, next operator, and tuple to match I/O @@ -207,8 +299,15 @@ def compose(self): ) self.add_flow(series_to_vol_op, abd_seg_op, {("image", "image")}) - # note below the dicom_seg_writer, dicom_sr_writer, dicom_sc_writer, and mongodb_entry_creator each require - # two inputs, each coming from a source operator + # note below several operators each require two inputs, each coming from a source operator + + # Segmentation Metrics + self.add_flow(abd_seg_op, seg_metrics_op, {("seg_metatensor", "segmentation_mask")}) + self.add_flow(series_to_vol_op, seg_metrics_op, {("image", "input_scan")}) + + # Z-Scores + self.add_flow(series_selector_op, seg_zscore_op, {("study_selected_series_list", "study_selected_series_list")}) + self.add_flow(seg_metrics_op, seg_zscore_op, {("metrics_dict", "metrics_dict")}) # DICOM SEG self.add_flow( @@ -216,37 +315,45 @@ def compose(self): ) self.add_flow(abd_seg_op, dicom_seg_writer, {("seg_image", "seg_image")}) + # DICOM Encapsulated PDF + self.add_flow( + series_selector_op, dicom_pdf_writer, {("study_selected_series_list", "study_selected_series_list")} + ) + self.add_flow(seg_zscore_op, dicom_pdf_writer, {("pdf_bytes", "pdf_bytes")}) + # DICOM SR self.add_flow( series_selector_op, dicom_sr_writer, {("study_selected_series_list", "study_selected_series_list")} ) - self.add_flow(abd_seg_op, dicom_sr_writer, {("result_text_dicom_sr", "text")}) + self.add_flow(seg_zscore_op, dicom_sr_writer, {("zscore_dict", "dict")}) + + # DICOM Contour (for Secondary Capture) + self.add_flow(abd_seg_op, dicom_contour_creator_op, {("seg_image", "segmentation_mask")}) - # DICOM SC + # DICOM Overlay (for Secondary Capture) self.add_flow( - series_selector_op, dicom_sc_writer, {("study_selected_series_list", "study_selected_series_list")} + series_selector_op, dicom_overlay_creator_op, {("study_selected_series_list", "study_selected_series_list")} ) - self.add_flow(abd_seg_op, dicom_sc_writer, {("dicom_sc_dir", "dicom_sc_dir")}) + self.add_flow(dicom_contour_creator_op, dicom_overlay_creator_op, {("contour", "segmentation_mask")}) + self.add_flow(series_to_vol_op, dicom_overlay_creator_op, {("image", "input_scan")}) - # MongoDB + # DICOM Secondary Capture self.add_flow( - series_selector_op, mongodb_entry_creator, {("study_selected_series_list", "study_selected_series_list")} + series_selector_op, dicom_sc_writer, {("study_selected_series_list", "study_selected_series_list")} ) - self.add_flow(abd_seg_op, mongodb_entry_creator, {("result_text_mongodb", "text")}) - self.add_flow(mongodb_entry_creator, mongodb_writer, {("mongodb_database_entry", "mongodb_database_entry")}) + self.add_flow(dicom_overlay_creator_op, dicom_sc_writer, {("overlay", "input_overlay_image")}) logging.info(f"End {self.compose.__name__}") -# series selection rule in JSON, which selects for axial CT series; flexible ST choices: -# StudyDescription: matches any value -# Modality: matches "CT" value (case-insensitive); filters out non-CT modalities -# ImageType: matches value that contains "PRIMARY", "ORIGINAL", and "AXIAL"; filters out most cor and sag views -# SeriesDescription: matches any values that do not contain "cor" or "sag" (case-insensitive); filters out cor and sag views -# SliceThickness: supports list, string, and numerical matching: -# [3, 5]: matches ST values between 3 and 5 -# "^(5(\\\\.0+)?|5)$": RegEx; matches ST values of 5, 5.0, 5.00, etc. -# 5: matches ST values of 5, 5.0, 5.00, etc. +# series selection rule in JSON, which selects for standard axial CT series: +# StudyDescription (Type 3): matches any value +# Modality (Type 1): matches "CT" value (case-insensitive); filters out non-CT modalities +# ImageOrientationPatient (Type 1): matches Axial orientation; filters out Cor and Sag orientations +# ImageType (Type 1): matches value that contains "PRIMARY"; filters out secondary and reformatted series +# SliceThickness (Type 2): matches ST values between 2 and 5, inclusive; filters out thin slices +# SeriesDescription (Type 3): matches any values that do not contain "cor", "sag", or "lung" (case-insensitive); +# filters out Cor, Sag, and Lung views # all valid series will be selected; downstream operators only perform inference and write outputs for 1st selected series # please see more detail in DICOMSeriesSelectorOperator @@ -254,13 +361,14 @@ def compose(self): { "selections": [ { - "name": "Axial CT Series", + "name": "Standard Axial CT Series", "conditions": { "StudyDescription": "(.*?)", "Modality": "(?i)CT", - "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"], - "SeriesDescription": "(?i)^(?!.*(cor|sag)).*$", - "SliceThickness": [3, 5] + "ImageOrientationPatient": "Axial", + "ImageType": ["PRIMARY"], + "SliceThickness": [2, 5], + "SeriesDescription": "(?i)^(?!.*(cor|sag|lung)).*$" } } ] @@ -274,7 +382,7 @@ def compose(self): # -i , for input DICOM CT series folder # -o , for the output folder, default $PWD/output # e.g. - # monai-deploy exec app.py -i input -m model/dynunet_FT.ts + # monai-deploy exec app.py -i input -m model/new_bundle.ts # logging.info(f"Begin {__name__}") AIAbdomenSegApp().run() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/app.yaml b/examples/apps/cchmc_ped_abd_ct_seg_app/app.yaml index 3a116f4b..a5dae60c 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/app.yaml +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/app.yaml @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -15,8 +15,8 @@ # specifies high-level information about our app application: - title: MONAI Deploy App Package - CCHMC Pediatric CT Abdominal Segmentation - version: 0.0.1 + title: MONAI Deploy App Package - CCHMC Pediatric CT Liver-Spleen Segmentation + version: 1.11.0 inputFormats: ["file"] outputFormats: ["file"] @@ -28,5 +28,5 @@ resources: cpu: 1 gpu: 1 memory: 1Gi - # during MAP execution, for an input DICOM Series of 204 instances, GPU usage peaks at just under 8900 MiB ~= 9.3 GB ~= 8.7 Gi - gpuMemory: 9Gi + # during MAP execution, for an input DICOM Series of 204 instances, GPU usage peaks right around 10600 MiB ~= 11.1 GB ~= 10.4 Gi + gpuMemory: 11Gi diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/figure.html b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/figure.html new file mode 100644 index 00000000..f27f0f1e --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/figure.html @@ -0,0 +1,15 @@ + + +Liver Quantile Figure + +
+
+ + \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/outlier_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/outlier_df.csv new file mode 100644 index 00000000..2a5706a7 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/outlier_df.csv @@ -0,0 +1,222 @@ +,id,Age,biomarker,Sex +0,Z1754759,2.943207763,331.0,Male +1,Z1360921,2.129141933,552.0,Male +2,Z1297174,2.175057078,538.0,Male +3,Z1317328,3.536759893,635.0,Male +4,Z1717566,3.799347412,342.0,Male +5,Z1408177,4.288127854,711.0,Male +6,Z995022,4.216010273999999,367.0,Male +7,Z1215792,4.422216514,759.0,Male +8,Z1749727,4.698856545,746.0,Male +9,Z1957250,4.277808219,372.0,Male +10,Z1031396,4.476841705,397.0,Male +11,Z1330208,4.46055175,387.0,Male +12,Z1199636,5.608542618,1019.0,Male +13,Z1744045,5.672804414,394.0,Male +14,Z1438788,5.4498763320000005,804.0,Male +15,Z1359820,5.801238584,448.0,Male +16,Z1137339,5.072865297,418.0,Male +17,Z1106236,6.676740868,891.0,Male +18,Z2000579,6.461518265,436.0,Male +19,Z1358153,6.122265982,389.0,Male +20,Z1108311,7.723085997,995.0,Male +21,Z1102047,7.993995433999999,502.0,Male +22,Z1115931,7.024326484,903.0,Male +23,Z1780155,7.687452435,974.0,Male +24,Z1281259,7.4867541860000015,1118.0,Male +25,Z710267,7.191347032,432.0,Male +26,Z1376856,7.319419711,457.0,Male +27,Z1102383,8.106541096,966.0,Male +28,Z1030933,8.717971842,1039.0,Male +29,Z1310189,8.106708524,528.0,Male +30,Z636626,8.278753805,994.0,Male +31,Z1045502,8.171864536000001,1088.0,Male +32,Z1165775,8.739332192,521.0,Male +33,Z1132070,8.010228311,366.0,Male +34,Z491409,10.49192352,1351.0,Male +35,Z473555,10.95660388,1245.0,Male +36,Z398051,10.13892123,1199.0,Male +37,Z1261786,10.44319825,1195.0,Male +38,Z457387,10.67529871,1201.0,Male +39,Z555727,10.68413813,632.0,Male +40,Z1429940,10.45069254,634.0,Male +41,Z1361959,10.48452435,621.0,Male +42,Z481254,10.70121956,1304.0,Male +43,Z1012131,11.52241629,694.0,Male +44,Z531537,11.38276256,562.0,Male +45,Z931185,11.83516362,728.0,Male +46,Z1205753,11.77636225,672.0,Male +47,Z568755,12.33675799,686.0,Male +48,Z431918,12.21570205,670.0,Male +49,Z1315258,12.93712139,1536.0,Male +50,Z630065,12.93366248,850.0,Male +51,Z552345,12.09854072,737.0,Male +52,Z445108,12.29952055,1472.0,Male +53,Z1267669,12.87658866,800.0,Male +54,Z954661,12.28721651,1586.0,Male +55,Z1005788,12.81734209,1618.0,Male +56,Z1337687,12.08438166,1462.0,Male +57,Z510833,12.03782725,710.0,Male +58,Z1862965,12.29844559,753.0,Male +59,Z463448,13.88155441,800.0,Male +60,Z552199,13.63224125,1830.0,Male +61,Z668727,13.36379947,758.0,Male +62,Z1052441,13.33957382,1649.0,Male +63,Z1130308,13.23271689,1593.0,Male +64,Z918410,13.2139688,1729.0,Male +65,Z1312495,13.16114916,1884.0,Male +66,Z493039,13.9770586,802.0,Male +67,Z876974,14.49362443,1806.0,Male +68,Z878133,14.50107306,869.0,Male +69,Z442225,14.55814878,1805.0,Male +70,Z542803,14.98328387,2015.0,Male +71,Z917389,14.10349125,1825.0,Male +72,Z905257,14.07679224,916.0,Male +73,Z860899,14.05202626,1745.0,Male +74,Z599292,14.08907534,1893.0,Male +75,Z902038,14.46990868,1810.0,Male +76,Z554515,14.08601027,887.0,Male +77,Z1727356,14.04685312,883.0,Male +78,Z907357,15.46044711,903.0,Male +79,Z603086,15.73438356,844.0,Male +80,Z937153,15.69805556,2050.0,Male +81,Z899390,15.63802131,783.0,Male +82,Z1964485,15.51409247,1028.0,Male +83,Z1340242,15.71230403,989.0,Male +84,Z842715,15.89294901,1961.0,Male +85,Z1325498,15.83057268,1999.0,Male +86,Z1862275,15.07251142,1009.0,Male +87,Z934271,15.61552131,2030.0,Male +88,Z1289579,16.60422374,1054.0,Male +89,Z425207,16.93062215,1036.0,Male +90,Z419872,16.09553843,1023.0,Male +91,Z464404,16.79072489,1057.0,Male +92,Z1756918,16.48158295,2331.0,Male +93,Z414897,17.16091705,845.0,Male +94,Z502531,17.44821537,2073.0,Male +95,Z1291489,17.0310293,2047.0,Male +96,Z1769386,17.38268455,1990.0,Male +97,Z908401,17.31318874,2052.0,Male +98,Z374705,17.74393075,2551.0,Male +99,Z928761,17.11947869,1063.0,Male +100,Z890238,18.43711568,2016.0,Male +101,Z891398,18.22637177,2046.0,Male +102,Z943254,18.18899924,1043.0,Male +103,Z283402,18.35983828,972.0,Male +104,Z909622,18.17194064,1064.0,Male +105,Z983086,18.04945586,2463.0,Male +106,Z1225422,2.739436834,324.0,Female +107,Z1329744,2.155597412,302.0,Female +108,Z1409962,2.053105023,543.0,Female +109,Z1160490,3.018814688,306.0,Female +110,Z1106921,4.339149543,717.0,Female +111,Z1335685,5.736700913,350.0,Female +112,Z1168472,5.827749239,386.0,Female +113,Z691673,5.88064688,768.0,Female +114,Z1225746,5.294832572,698.0,Female +115,Z1233068,6.584967656,794.0,Female +116,Z1244498,6.152210807,358.0,Female +117,Z1191932,6.5116000760000015,796.0,Female +118,Z1111969,6.044421613,758.0,Female +119,Z603369,7.744726027,945.0,Female +120,Z1247665,7.204417808,834.0,Female +121,Z524611,7.264067732000001,410.0,Female +122,Z1024771,8.837754947,521.0,Female +123,Z1232902,8.60510274,971.0,Female +124,Z1333302,9.676830289,1338.0,Female +125,Z1046920,9.813424658,588.0,Female +126,Z1280191,9.497212709,565.0,Female +127,Z1336778,9.806716134,521.0,Female +128,Z1049024,9.790133181,567.0,Female +129,Z483463,9.48004376,1068.0,Female +130,Z600428,9.808327626,1119.0,Female +131,Z1272097,9.721891172,1226.0,Female +132,Z1070025,9.516337519,572.0,Female +133,Z1119985,10.01430936,1148.0,Female +134,Z679989,10.14880898,571.0,Female +135,Z472264,10.13382801,613.0,Female +136,Z441308,10.95634703,1346.0,Female +137,Z1780622,10.1182363,1304.0,Female +138,Z675064,10.25319254,621.0,Female +139,Z546239,10.06572108,567.0,Female +140,Z536919,11.81254947,710.0,Female +141,Z1269266,11.74521499,1474.0,Female +142,Z17632,11.78974315,1461.0,Female +143,Z1336461,12.50935693,779.0,Female +144,Z888374,12.96414764,1562.0,Female +145,Z557233,12.93578006,798.0,Female +146,Z1248071,12.67745814,1578.0,Female +147,Z634658,12.38707192,764.0,Female +148,Z1289151,12.66704148,798.0,Female +149,Z990972,12.16354452,761.0,Female +150,Z641126,12.07365487,1482.0,Female +151,Z598645,12.94126712,811.0,Female +152,Z1127243,13.70257991,2535.0,Female +153,Z1141194,13.795732500000002,1657.0,Female +154,Z952990,13.72586948,805.0,Female +155,Z1920056,13.92303272,784.0,Female +156,Z938342,13.90071918,1678.0,Female +157,Z939719,13.02078957,804.0,Female +158,Z1987458,13.11149543,795.0,Female +159,Z1709990,13.73675228,1654.0,Female +160,Z1134612,13.70773592,1714.0,Female +161,Z938166,13.54467846,1809.0,Female +162,Z520362,13.07037671,804.0,Female +163,Z898432,14.42920852,1714.0,Female +164,Z867447,14.38273021,1767.0,Female +165,Z899117,14.69892884,1578.0,Female +166,Z1215779,14.21577245,851.0,Female +167,Z915527,14.20076865,1606.0,Female +168,Z477468,14.38117199,835.0,Female +169,Z930180,14.91724315,884.0,Female +170,Z555437,14.124263699999998,1975.0,Female +171,Z1004562,14.47615107,1757.0,Female +172,Z1224134,14.44361301,1728.0,Female +173,Z477850,15.49031012,879.0,Female +174,Z575497,15.20297945,789.0,Female +175,Z416463,15.11269406,882.0,Female +176,Z666602,15.40237062,743.0,Female +177,Z1123619,15.75351027,851.0,Female +178,Z1323456,15.04009703,1606.0,Female +179,Z432819,15.84090373,830.0,Female +180,Z902300,15.10303272,1614.0,Female +181,Z865813,15.74247527,1705.0,Female +182,Z862165,15.89699391,1613.0,Female +183,Z867391,15.51304795,1641.0,Female +184,Z1694937,15.94798516,766.0,Female +185,Z434815,15.29890221,755.0,Female +186,Z432612,16.73521689,1635.0,Female +187,Z430134,16.21503044,1925.0,Female +188,Z1457561,16.53834855,790.0,Female +189,Z1311911,16.28472793,1706.0,Female +190,Z1335817,16.46307839,886.0,Female +191,Z1770054,16.20888318,1766.0,Female +192,Z914869,16.22644977,1584.0,Female +193,Z444343,16.92578767,1850.0,Female +194,Z892423,16.94350457,1798.0,Female +195,Z1923654,16.60918189,896.0,Female +196,Z1419219,16.25374239,820.0,Female +197,Z1412374,16.00880898,709.0,Female +198,Z574459,16.66201294,1616.0,Female +199,Z1431733,16.98270548,810.0,Female +200,Z1340677,16.09017504,733.0,Female +201,Z1046099,16.74919711,835.0,Female +202,Z1144970,17.81423516,1767.0,Female +203,Z1292901,17.04079148,885.0,Female +204,Z843084,17.24898212,807.0,Female +205,Z1260964,17.96730023,1730.0,Female +206,Z1269305,17.03824962,759.0,Female +207,Z450806,17.05391172,1774.0,Female +208,Z1173810,17.15178463,1709.0,Female +209,Z861756,17.70289003,894.0,Female +210,Z926083,17.32836948,1647.0,Female +211,Z1395609,17.07696918,1710.0,Female +212,Z626059,17.26643455,1726.0,Female +213,Z350003,17.47482686,1918.0,Female +214,Z1244558,17.35324201,1758.0,Female +215,Z770412,17.45147451,889.0,Female +216,Z1033402,17.36817732,744.0,Female +217,Z564983,18.10776826,789.0,Female +218,Z911426,18.81813166,2015.0,Female +219,Z1171094,18.258164,878.0,Female +220,Z416083,18.23755137,747.0,Female diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_df.csv new file mode 100644 index 00000000..fc5a6ebb --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_df.csv @@ -0,0 +1,36 @@ +,Age,Male 0.05,Male 0.25,Male 0.50,Male 0.75,Male 0.95,Female 0.05,Female 0.25,Female 0.50,Female 0.75,Female 0.95 +0,2.0,290.4335157038641,343.4035674147742,416.56510773520876,467.6308246644187,525.6575429219293,303.07107420569093,355.8842468898125,383.2348629492261,459.8480251146002,506.20266303280766 +1,2.5,311.9380488227697,367.30577441001196,441.77707730366694,496.7224863726133,560.910206060756,317.22301091165946,373.78229317392015,406.62204227160595,483.13269005753455,535.1703778798648 +2,3.0,333.44258194167537,391.2079814052496,466.9890468721251,525.8141480808077,596.1628691995827,331.374947617628,391.6803394580281,430.00922159398584,506.4173550004689,564.1380927269216 +3,3.5,354.94866097078943,415.12337465841574,492.20966406090434,554.8981850512654,631.4236069104214,345.5268843235967,409.578385742136,453.3964009163657,529.7020199434031,593.105807573979 +4,4.0,376.4640154611537,439.11788545915283,517.4821669716106,583.9364735953022,666.7327920533322,359.6788210295652,427.47643202624386,476.7835802387453,552.9866848863373,622.0735224210356 +5,4.5,397.99792087401835,463.2706313550316,542.8584413261704,612.8832652864971,702.1388720603869,373.8339697052753,445.3777772276908,500.1743333301096,576.2762623043512,651.0486296179879 +6,5.0,419.5596526706339,487.6607298936232,568.3903728465109,641.6928116984293,737.6902943636579,388.1204076441724,463.4139656753728,523.7111642287188,599.76663714124,680.3258991169175 +7,5.5,441.15848631225026,512.3672986224984,594.1298472545587,670.319364404678,773.4355063952171,402.8284166116511,481.8831370238098,547.7170523065537,623.9017743323143,710.5734145396264 +8,6.0,462.80369726011793,537.4694550892283,620.128750272241,698.7171749788223,809.4229555871362,418.25911877098395,501.09456477354115,572.5270384059187,649.1422184162783,742.4842086888146 +9,6.5,484.5045609754868,563.0463168413834,646.4389676214843,726.840494994441,845.7010893714878,434.71363628544344,521.3575224251059,598.4761633691182,675.9485139318354,776.751314367181 +10,7.0,506.2703529196075,589.177001426535,673.1123850242158,754.6435760251136,882.3183551803438,452.49309131830273,542.9812834790434,625.8994680384562,704.7812054176904,814.0677643774256 +11,7.5,528.1103485537302,615.940626392254,700.2008882023622,782.0806696444191,919.3232004457761,471.89860603283364,566.275121435893,655.1319932562368,736.1008374125468,855.1265915222481 +12,8.0,550.0338233391049,643.416309286111,727.7563628778503,809.1060274259365,956.7640725998566,493.2313025923092,591.5483097961936,686.5087798647646,770.3679544551081,900.6208286043477 +13,8.5,572.1333619249529,671.7155778788764,755.9383099489494,835.9069212838365,994.8788605260007,516.792303160002,619.1101220604851,720.3648687063438,808.0431010840794,951.2435084264247 +14,9.0,594.8347857123783,701.0796008341176,785.336691019299,863.6027044946557,1034.6632189129948,542.8827298991847,649.2698317293062,757.0353006232788,849.5868218381642,1007.6876637911784 +15,9.5,618.6472252904564,731.7819570386009,816.6490828688799,893.545750675522,1077.3022439009692,571.8037049731297,682.3367123031967,796.855116457873,895.4596612560658,1070.646327501308 +16,10.0,644.0798112482626,764.0962253790937,850.5730622776744,927.0884334435639,1123.9810316300536,603.8563505451095,718.6200372826951,840.1593570524312,946.122163876489,1140.8125323595134 +17,10.5,671.6416741748716,798.2959847423615,887.8062060256633,965.5831264159091,1175.884678240378,639.0639433680552,758.1596261984354,886.9303636560248,1001.5401482814731,1218.0134084344309 +18,11.0,701.8419446593588,834.6548140151716,929.0460908928287,1010.3822032096864,1234.198279872072,676.3383785535298,799.9174827014261,935.7396791447933,1059.7005292263987,1298.6124748584425 +19,11.5,735.1897532907993,873.4462920842906,974.9902936591519,1062.838037442023,1300.1069326652657,714.313705802755,842.5861564727716,984.8061468016439,1118.0954955099828,1378.107348029868 +20,12.0,772.1942306582685,914.9439978364848,1026.3363911046144,1124.3030027300476,1374.7957327600884,751.6239748169525,884.8581971935741,1032.3486099094832,1174.2172359309425,1451.995644347026 +21,12.5,812.9283363456242,959.1637254753273,1083.178174602291,1195.1829846259045,1458.4545445734473,786.9032352973434,925.4261545449373,1076.5859117512184,1225.5579392879931,1515.7749802082358 +22,13.0,855.7203459158567,1005.0901304716161,1143.1942938976272,1272.0979164218008,1547.2923056293528,818.7855369451494,962.9825782079639,1115.736895609756,1269.6097943798522,1564.942972011816 +23,13.5,898.4623639267395,1051.4500836129562,1203.4596133291623,1350.721243344961,1636.5227217285933,845.904929461592,996.2200178637579,1148.0204047680027,1303.864990005236,1594.9972361560863 +24,14.0,939.0464949360451,1096.9704556869508,1261.048997235435,1426.7264106226085,1721.3594986719563,866.8954625478929,1023.8310231934225,1171.6552825088659,1325.8157149628607,1601.4353890393654 +25,14.5,975.364843501547,1140.3781174812052,1313.0373099549847,1495.7868634819677,1797.0163422602295,880.9399566650187,1044.8945158546765,1186.0350591759523,1334.9767484652666,1584.6568404820432 +26,15.0,1005.3095141810182,1180.3999397833236,1356.49941582635,1553.5760471502617,1858.7069582942008,889.4163153129173,1060.0349054117019,1195.2520133556702,1338.953231380284,1564.6681739927901 +27,15.5,1027.4773263279474,1216.1691052789984,1389.4748410695488,1597.1384086486707,1903.2241233475895,894.0818279250634,1070.2991898803066,1203.6909437889535,1345.5831405803635,1561.3222762222736 +28,16.0,1043.2839584786832,1248.444044246274,1413.8617594305133,1629.0024021741988,1933.676897085843,896.0162446300586,1076.8792331763386,1212.2079815920408,1355.545435301142,1574.2500727008648 +29,16.5,1054.8498039652904,1278.389498861284,1432.5230065366554,1653.0674837178065,1954.7534099473385,896.1299307302868,1081.0031156906548,1220.777090974998,1367.7293203690535,1598.0269986788612 +30,17.0,1064.295256119833,1307.1702113001609,1448.3214180153861,1673.233109270454,1971.141792370456,895.3332515281315,1083.8989178141137,1229.3722361478897,1381.0240006105312,1627.2284894065601 +31,17.5,1073.3873093856985,1335.7568000430156,1463.6426902228816,1692.7488254912746,1986.7488197205103,894.3848447755789,1086.5900398724295,1237.971720619104,1394.5038133809367,1657.3340742592095 +32,18.0,1082.4793626515636,1364.3433887858703,1478.963962430377,1712.2645417120953,2002.3558470705646,893.4364380230265,1089.2811619307456,1246.5712050903185,1407.9836261513422,1687.439659111859 +33,18.5,1091.571415917429,1392.9299775287252,1494.2852346378725,1731.780257932916,2017.9628744206184,892.4880312704737,1091.9722839890612,1255.1706895615325,1421.4634389217472,1717.5452439645082 +34,19.0,1100.6634691832944,1421.51656627158,1509.606506845368,1751.2959741537366,2033.569901770673,891.5396245179215,1094.6634060473775,1263.7701740327473,1434.9432516921534,1747.6508288171583 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_f_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_f_fine.csv new file mode 100644 index 00000000..1288bdc5 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_f_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,303.07107420569093,305.3440022007079,313.6574481769719,331.539176717781,355.8842468898125,367.5953998445387,369.06744135466613,367.82105752495283,367.1156612719412,383.2348629492261,391.5891883215666,431.92284760968005,435.4859874251922,446.7687611311588,459.84802511463164,470.3998046963083,471.4914728849951,473.6903336591914,506.20266333553514 +1,2.5,317.22301091165946,323.4913117007093,332.57658521315614,349.94016009883137,373.78229317392015,387.06921750092846,390.44936700287775,390.47897956003686,391.20330703780985,406.62204227160595,416.4136802723472,454.3979637926044,459.11295179428174,470.2618147616551,483.1326900575405,494.2794439175249,497.74409993263197,503.1460461762143,535.1703781587167 +2,3.0,331.374947617628,341.6386212007108,351.4957222493404,368.3411434798818,391.6803394580281,406.54303515731823,411.83129265108926,413.1369015951213,415.2909528036787,430.00922159398584,441.23817222312783,476.8730799755287,482.7399161633714,493.7548683921514,506.4173550004491,518.1590831387417,523.9967269802693,532.6017586932375,564.1380929818982 +3,3.5,345.5268843235967,359.7859307007122,370.41485928552476,386.7421268609323,409.578385742136,426.01685281370806,433.2132182993009,435.7948236302057,439.37859856954765,453.3964009163657,466.06266417390873,499.3481961584531,506.3668805324611,517.247922022648,529.7020199433579,542.0387223599585,550.2493540279062,562.0574712102605,593.1058078050797 +4,4.0,359.6788210295652,377.93324020071356,389.33399632170904,405.14311024198264,427.47643202624386,445.4906704700978,454.59514394751227,458.45274566528997,463.46624433541655,476.7835802387453,490.8871561246894,521.8233123413776,529.9938449015507,540.7409756531442,552.9866848862664,565.9183615811753,576.5019810755433,591.5131837272835,622.0735226282613 +5,4.5,373.8339697052753,396.0832583631154,408.2560274348527,423.5473193081954,445.3777772276908,464.9674362454113,475.97999726674936,481.11370271630375,487.5570539660761,500.1743333301096,515.7148129591045,544.3023885491518,553.6247721932809,564.2384793920274,576.2762623042568,589.8038057662405,602.7604157153232,620.9741444721147,651.0486298013415 +6,5.0,388.1204076441724,414.3439931011379,427.29635394371036,442.0833782554222,463.4139656753728,484.56470638173244,497.4845191391568,503.89871604343466,511.7771865700546,523.7111642287188,540.671834412075,566.943330772669,577.4176839479495,587.9178813112186,599.7666371412137,613.9265278486193,629.2562356839508,650.6496265286067,680.3258992765266 +7,5.5,402.8284166116511,432.9602397792268,446.7165280534845,461.04280838021634,481.8831370238098,504.5489171267934,519.3732978336708,527.0820752113084,536.4125764278093,547.7170523065537,566.0442468420581,590.1040262577459,601.7307292992082,612.1813599561693,623.9017743326499,638.8111514361326,656.5143021213539,681.0139384849263,710.5734146759028 +8,6.0,418.25911877098395,452.1859354974293,466.78786947911567,480.72801766655465,501.09456477354115,525.1964546296946,541.9208025089387,550.9483129633134,561.7598358634664,572.5270384059187,592.1287580897772,614.1557273340677,626.9354322446217,637.4461129881367,649.1422184174793,665.0018918895906,685.0790767909441,712.5591016980935,742.4842088024628 +9,6.5,434.71363628544344,472.2750173557924,487.7816979355438,501.44141409841404,521.3575224251059,546.7837050395353,565.4015023236082,575.7819620428363,588.1155772011515,598.4761633691182,619.2220759959552,639.4696863313197,653.403316781754,664.1293380683773,675.9485139346156,693.0429645698036,715.4950214561321,745.7771375251281,776.7513144592006 +10,7.0,452.49309131830273,493.4814224543639,509.9693331377096,523.4854056597711,542.9812834790434,569.5870545054162,590.0898664363269,601.8675551932664,615.7764127649905,625.8994680384562,647.6209084013158,666.417155579188,681.5059069081702,692.6482328581486,704.7812054229728,723.4785848375824,748.3065978803297,781.1600673230498,814.0677644491095 +11,7.5,471.89860603283364,516.0590878931908,533.6220948005532,547.1624003346027,566.275121435893,593.8828891764365,616.2603640057422,629.4896251579902,645.0389548791097,655.1319932562368,677.6219631465823,695.3693874073575,711.6147266214348,723.419995018707,736.1008374214649,756.8529680537366,784.0582678269479,819.1999124488783,855.1265915751825 +12,8.0,493.2313025923092,540.2619507723207,559.0113026390152,572.7748061068853,591.5483097961936,619.947595201697,644.1874641905015,658.9327046803962,676.1998158676347,686.5087798647646,709.5219480724774,726.6976341455135,744.1012999191116,756.8618222113096,770.3679544690054,793.7103295790771,823.294493059398,860.3886942596339,900.6208286404133 +13,8.5,516.792303160002,566.3439481918007,586.4082763680359,600.6250309605964,619.1101220604851,648.0575587302972,674.1456361492525,690.4813265038724,709.5556080546916,720.3648687063438,743.6175710197255,760.7731481233418,779.3371507987656,793.3909120972133,808.0431011045087,834.5948847744145,866.5597353410911,905.2184341123361,951.2435084477954 +14,9.0,542.8827298991847,594.5590172516785,616.0843357025557,631.015482879712,649.2698317293062,678.4891659113372,706.409349040643,724.4200233718063,745.4029437644062,757.0353006232788,780.2055398290488,797.967181670528,817.6938032579613,833.4244623376751,849.5868218668882,880.0508490005584,914.3984564354384,954.1811533640048,1007.6876638003223 +15,9.5,571.8037049731297,625.161095052001,648.310800357515,664.2485698482091,682.3367123031967,711.5188028939168,741.2530720233199,761.0333280275858,784.0384353209046,796.855116457873,819.5825623411712,838.6509871167569,859.542781294263,877.3796705939519,895.4596612950573,930.6224376183193,967.3551181058516,1007.7688733716594,1070.646327500987 +16,10.0,603.8563505451095,658.4041186928155,683.3589900478536,700.6266998500645,718.6200372826951,747.4228558271361,778.951274255931,800.6057732145987,825.7586950483125,840.1593570524312,862.0453463968156,883.1958167917142,905.2556089052349,925.6737345273002,946.1221639279306,986.8538659885079,1025.974182115741,1066.4736154923203,1140.8125323527831 +17,10.5,639.0639433680552,694.2922505411606,721.22840587354,740.1728339085505,758.1596261984354,786.235570602164,819.5270358455422,843.147840841661,870.5535601551644,886.9303636560248,907.5919718925578,931.5980386432178,954.8054564094541,978.278133434123,1001.5401483470465,1048.6532596423613,1090.1314924508788,1130.1740666375501,1218.0134084244778 +18,11.0,676.3383785535298,731.8305540320375,760.8312744746526,781.792145204122,799.9174827014261,827.0226320784449,861.9978806928918,887.5738094792995,917.1857673876276,935.7396791447933,955.0260069483817,982.354483091616,1006.5720794095455,1033.381473151405,1059.7005293044422,1113.3843847928242,1157.0284219864761,1196.2955759370832,1298.6124748479313 +19,11.5,714.313705802755,769.7743178674401,800.8080038762977,824.1103599565297,842.5861564727716,868.6075848574924,905.1299436471373,932.523906863471,964.1112783762793,984.8061468016439,1002.852391740124,1033.587096175389,1058.5368798291474,1088.7266411512774,1118.0954955947805,1177.7749178232689,1223.1977258201048,1261.6501580751983,1378.1073480207797 +20,12.0,751.6239748169525,806.8788307493604,839.7990021035807,865.7532043855238,884.8581971935741,909.81397354082,947.6893595574355,976.63836073013,1009.7860547516956,1032.3486099094832,1049.5760664436207,1083.4178239330176,1108.6812595918966,1142.056524905871,1174.2172360127238,1238.5525351170672,1285.1721590493366,1323.0498277361733,1451.995644340656 +21,12.5,786.9032352973434,841.8993813797914,876.4446771816082,905.3464047108544,925.4261545449373,949.4653427299407,988.4422632729426,1018.5573988152321,1052.6660581444514,1076.5859117512184,1093.7019712347085,1129.968612402982,1154.9866206214308,1191.1140118873147,1225.557939352934,1292.4449130575908,1339.4844767717432,1377.3065996042844,1515.7749802051958 +22,13.0,818.7855369451494,873.5912584607257,909.3854371354857,941.5156871522723,962.9825782079639,986.385237026368,1026.154789642816,1056.9212488547328,1091.2072501851246,1115.736895609756,1133.735046289223,1171.3614076237625,1195.4343648413878,1233.6419895677416,1269.6097944100734,1336.1797280282115,1382.6674340848963,1421.2324883638107,1564.9429720120336 +23,13.5,845.904929461592,900.7097506941564,937.26168999032,972.8867779295282,996.2200178637579,1019.3972010316157,1059.5930735162124,1090.3701385845877,1123.865592504291,1148.0204047680027,1168.1802317830013,1205.7181556338398,1228.0058941754048,1267.3833454192809,1303.8649899788045,1366.4846564123015,1411.2537860863677,1451.63950869903,1594.9972361588036 +24,14.0,866.8954625478929,922.0101467820758,958.7138437712166,998.0854032623724,1023.8310231934225,1047.324779347197,1087.5232497422892,1117.5442957407524,1149.0970467325265,1171.6552825088659,1195.5424678918794,1231.160802471695,1250.68261054712,1290.080966914064,1325.8157148537898,1380.0873745932324,1421.7762878737287,1465.3396752942203,1601.4353890431405 +25,14.5,880.9399566650187,936.7781560682437,973.0660354636925,1016.2278513099074,1044.8945158546765,1069.3369147265387,1109.0130204231677,1137.6172216210884,1166.2178843703393,1186.0350591759523,1214.9989550879613,1246.8640594206067,1262.8987636818724,1301.1885427192124,1334.9767482533687,1376.9501577624867,1414.6229883527742,1462.722712476753,1584.6568404850982 +26,15.0,889.4163153129173,946.4211704634843,982.377317894907,1028.3926579886456,1060.0349054117019,1085.9841425307222,1124.33635667283,1151.8955117710816,1177.985616397966,1195.2520133556702,1228.4159350284222,1256.213698743051,1269.899994511812,1307.002966281813,1338.9532310905877,1369.9736763439882,1405.6031116621875,1458.4891831463758,1564.668173994406 +27,15.5,894.0818279250634,952.6868330257205,989.0290978659057,1036.071094158179,1070.2991898803066,1098.1992812731664,1134.2058277997428,1162.077172389515,1187.4873970555466,1203.6909437889535,1238.0547012287211,1262.9882967000558,1277.2065617139378,1314.0842105369502,1345.5831402804522,1372.0916545042535,1410.362175225338,1467.1767433375428,1561.3222762228754 +28,16.0,896.0162446300586,956.5621088342043,993.957282231636,1040.4431226930128,1076.8792331763386,1107.0626894689879,1139.882126878453,1169.2927580407352,1195.6877141431128,1212.2079815920408,1245.0677134523855,1268.3265845676651,1285.6258037378425,1323.201361591735,1355.5454350510897,1383.4316361477297,1428.4696943714441,1488.3605830511588,1574.2500727009685 +29,16.5,896.1299307302868,958.8437934735198,997.7364028605203,1042.61087947138,1081.0031156906548,1113.6916106337274,1142.7629779250283,1174.5309603804799,1203.0203888506696,1220.777090974998,1250.33022302496,1272.7073323756777,1294.7868289762657,1333.6757838462843,1367.7293202089095,1400.9196201133466,1455.4761839061875,1517.8752757797406,1598.026998678811 +30,17.0,895.3332515281315,960.3286825282513,1000.9409916209821,1043.676500371514,1083.8989178141137,1119.203288282926,1144.246104955536,1178.780471064487,1209.919242368222,1229.3722361478897,1254.7174812719902,1276.609310153892,1304.318745821949,1344.8288417007163,1381.0240005603205,1421.4816052400338,1486.9321586352496,1551.5553950158053,1627.228489406528 +31,17.5,894.3848447755789,961.6807723188854,1004.0498250700399,1044.5584319586092,1086.5900398724295,1124.5287586795343,1145.496277983366,1182.8651998058713,1216.745792354107,1237.971720619104,1258.9588642980962,1280.4314929271397,1313.9124779355086,1356.0950054884622,1394.5038134439967,1442.555923893566,1519.1297142300314,1585.929752003117,1657.3340742592243 +32,18.0,893.4364380230265,963.0328621095196,1007.1586585190978,1045.4403635457047,1089.2811619307456,1129.854229076143,1146.746451011196,1186.9499285472555,1223.572342339992,1246.5712050903185,1263.2002473242026,1284.253675700388,1323.5062100490686,1367.361169276208,1407.9836263276732,1463.6302425470985,1551.3272698248134,1620.304108990429,1687.4396591119207 +33,18.5,892.4880312704737,964.3849519001535,1010.2674919681557,1046.3222951327996,1091.9722839890612,1135.1796994727515,1147.9966240390258,1191.0346572886394,1230.3988923258764,1255.1706895615325,1267.4416303503078,1288.0758584736352,1333.0999421626284,1378.627333063953,1421.4634392113485,1484.7045612006305,1583.5248254195951,1654.6784659777409,1717.5452439646165 +34,19.0,891.5396245179215,965.7370416907878,1013.3763254172135,1047.2042267198954,1094.6634060473775,1140.5051698693599,1149.2467970668563,1195.1193860300239,1237.225442311762,1263.7701740327473,1271.6830133764147,1291.8980412468836,1342.6936742761882,1389.8934968517,1434.9432520950256,1505.778879854163,1615.722381014377,1689.0528229650529,1747.6508288173134 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_m_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_m_fine.csv new file mode 100644 index 00000000..4e7bb8c9 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/results_m_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,290.4335157038641,297.8885580195652,312.8880150012151,331.06389727412136,343.4035674147742,360.3461960636716,388.421196733257,397.3229790222566,408.7568205267491,416.56510773520876,416.3036018474926,424.3910588873676,436.69091858144395,452.0056737887434,467.6308246644635,481.42710776397223,487.4690144955722,503.4340336265784,525.6577971984339 +1,2.5,311.9380488227697,322.7738771145531,337.84960348625475,355.5637744316656,367.30577441001196,384.4811714144164,410.59102582714837,420.47614708177736,432.42814164849307,441.77707730366694,443.4350258104664,452.8027815461049,464.4407517876433,480.3737800349956,496.72248637265034,511.8513093431522,519.3434581320522,534.8013859849764,560.9097340527734 +2,3.0,333.44258194167537,347.6591962095408,362.81119197129414,380.06365158920977,391.2079814052496,408.61614676516115,432.7608549210398,443.629315141298,456.0994627702368,466.9890468721251,470.56644977344024,481.21450420484217,492.19058499384266,508.74188628124784,525.8141480808371,542.2755109223318,551.2179017685319,566.1687383433742,596.1616709071128 +3,3.5,354.94866097078943,372.539104853149,387.76908480423054,404.5691854631489,415.12337465841574,432.76377298345494,454.9486904321048,466.8006790906945,479.78480629170696,492.20966406090434,497.6993326993369,509.62392421389694,519.9414675834727,537.1059171735997,554.898185051287,572.6918215043505,583.0837849048775,597.5505810148337,631.421685683715 +4,4.0,376.4640154611537,397.3865507884789,412.70480372454847,429.1086596354573,439.11788545915283,456.9873044070425,477.2445644462107,490.0812183793461,503.55428421153516,517.4821669716106,524.8409694027699,538.0195283248567,547.6986464736871,565.4454959425476,583.9364735953166,603.0607861034022,614.898305040417,629.0193655646618,666.7301679938942 +5,4.5,397.99792087401835,422.1690713072525,437.5961748196292,453.71601440450434,463.2706313550316,481.3626462412176,499.7565154663983,513.5801083465077,527.492030928079,542.8584413261704,552.000113661276,566.3875006396263,575.4684179650696,593.7361704646885,612.8832652865046,633.3350587365201,646.6100991743444,660.6620338712273,702.135585371227 +6,5.0,419.5596526706339,446.8542037011917,462.4210241768546,478.42519006865945,487.6607298936232,505.9657036912744,522.5925819957087,537.4065243314345,551.6821808396971,568.3903728465109,579.1855192523918,594.7140252601109,603.2570783582048,621.9534886166188,641.6928116984311,663.4672934207375,678.1678043058547,692.5655278128993,737.6864053492903 +7,5.5,441.15848631225026,471.40948526201805,487.15717788360587,503.2701269262921,512.3672986224984,530.8723819625063,545.8608025371828,561.6696416733814,576.2088683447469,594.1298472545587,606.4059399536537,622.9852862882153,631.0709239536769,650.0729982749348,670.319364404675,693.4101441730869,709.5200574341408,724.8167892680461,773.4310954616609 +8,6.0,462.80369726011793,495.80245328145384,511.78246202726467,528.2847652757714,537.4694550892283,556.1585862602077,569.6692155938615,586.4786357116038,601.1562278415864,620.128750272241,633.6701295425983,651.1874678258448,658.9162510520703,678.070247316233,698.7171749788159,723.1162650106021,740.6154955583975,757.502760115037,809.4181232419161 +9,6.5,484.5045609754868,520.0006450512204,536.2747026952123,553.5030454154665,563.0463168413834,581.9002217896717,594.1258596687858,611.9426817853564,626.6083937285737,646.4389676214843,660.9868417967623,679.3067539749038,686.7993559539684,705.9207836171098,726.8404949944332,752.5383099503158,771.4027556778186,790.7103822322402,845.6959562236322 +10,7.0,506.2703529196075,543.97159786304,560.6117259748305,578.9589076437474,589.177001426535,608.1731937561927,619.3387732649965,638.1709552338943,652.6495004040668,673.1123850242158,688.3648304936817,707.329328837298,714.7265349599563,733.6001550541617,754.6435760251063,781.6289330092612,801.8304747915985,824.5265974980249,882.3130619403863 +11,7.5,528.1103485537302,567.6828490086343,584.7713579535006,604.6862922589829,615.940626392254,635.0534073650646,645.4159948855346,665.2726313964729,679.3636822664237,700.2008882023622,715.8128494108936,735.2413765149319,742.704084370618,761.0839095039853,782.0806696444145,810.3407882044716,831.8472898989311,859.0383477907596,919.3179079257554 +12,8.0,550.0338233391049,591.1019357797251,608.731424718604,630.7191395595426,643.416309286111,662.6167678215808,672.465563033441,693.3568856123468,706.8350737140021,727.7563628778503,743.3396523259343,763.0290811097104,770.7383004865372,788.3475948431766,809.1060274259373,838.6265295529798,861.4018379990101,894.3325749888131,956.7589617133159 +13,8.5,572.1333619249529,614.2998549469545,632.5861281746446,657.1437385038532,671.7155778788764,690.9863860326419,700.6386580559149,722.5738517007314,735.2293188647899,755.9383099489494,771.0983720243847,790.8344118890507,798.9967039962828,815.564456917257,835.9069212838453,866.6725877100731,890.6965203814549,930.6466414936433,994.874121115656 +14,9.0,594.8347857123783,637.7614411966459,656.8951734946154,684.255772690572,701.0796008341176,720.4741957115752,730.259027676789,753.2374974006824,765.0381007152939,785.336691019299,799.819657324003,819.4224787824166,828.2917131403584,843.6985334474458,863.6027044946742,895.6004998840542,920.9487954975825,968.8195917990633,1034.6590050614063 +15,9.5,618.6472252904564,662.0749886940429,682.334641668631,712.4032743784136,731.7819570386009,751.4393362733146,761.6935614640537,785.7027489312154,796.8346119816508,816.6490828688799,830.3785360505918,849.7141768847843,859.596970547252,873.9115601238866,893.545750675551,926.7655799214803,953.6298860891354,1009.8408909219759,1077.2986627582093 +16,10.0,644.0798112482626,687.8287916043888,709.580613686807,741.9342758260926,764.0962253790937,784.2409471327938,795.3091489857004,820.3245325113468,831.1920453799971,850.5730622776744,863.6500360299538,882.6304012911298,893.8861188454522,907.365272636724,927.0884334436023,961.5231416689085,990.211014897856,1054.7000038792833,1123.978143413707 +17,10.5,671.6416741748716,715.6111440929272,739.3091705392583,773.196809292324,798.2959847423615,819.2381677049465,831.4726798097196,857.4577743600919,868.6835936264691,887.8062060256633,900.5091850878915,919.092047096429,932.1328006634467,945.2214066761014,965.5831264159555,1001.2284989728951,1032.1634046654858,1104.3863956878877,1175.8824962355407 +18,11.0,701.8419446593588,746.010340324902,772.1963932161001,806.5389070358223,834.6548140151716,856.790137404707,870.5510435041025,897.457400696467,909.882449437204,929.0460908928287,941.8310110502076,960.0200093956583,975.3106586297238,988.6416979321632,1010.3822032097373,1047.236965679998,1080.9582781337676,1159.8895313646924,1234.196770431353 +19,11.5,735.1897532907993,779.6146744655564,808.9183627074473,842.308601315302,873.4462920842906,897.2559956470086,912.9111296368397,940.6783377394875,955.3618055283378,974.9902936591519,988.4905417427044,1006.3351832837935,1024.3933353727714,1038.787882095053,1062.8380374420744,1100.9038556367734,1138.0668580444433,1222.1988759265987,1300.1060152087855 +20,12.0,772.1942306582685,817.012440680134,850.151160003415,880.853924389478,914.9439978364848,940.9948818467852,958.9198277759222,987.4755117081693,1005.6948546160071,1026.3363911046144,1041.3628049911847,1058.9584638558101,1080.3544735210774,1096.8216948549148,1124.3030027300942,1163.5844826897785,1204.960367139255,1292.3038943905094,1374.7952797754804 +21,12.5,812.9283363456242,858.3706102955042,896.0835566023823,922.2743601697532,959.1637254753273,988.0200179623084,1008.5549876417452,1037.8046340061885,1060.9115587373321,1083.178174602291,1100.6289724427675,1118.1198932102054,1143.4023460848925,1163.048942586403,1195.18298462594,1235.7169476051702,1282.0661633306684,1370.3065264381294,1458.4543951576356 +22,13.0,855.7203459158567,902.1708632850388,944.9550860357813,965.675199178282,1005.0901304716161,1036.9609561251978,1060.238299564322,1090.0245567758598,1118.8689572133662,1143.1942938976272,1163.6947910298372,1181.286101459557,1210.683747601514,1234.351714402211,1272.0979164218215,1314.0704988275033,1365.636145214042,1452.7586104103686,1547.292319655673 +23,13.5,898.4623639267395,946.4735567837362,994.5179723433088,1009.913183589908,1051.4500836129562,1086.1013310104115,1112.0024140260712,1142.0949173441588,1176.8808586861462,1203.4596133291623,1227.2721515060955,1245.2328657199644,1278.5801029900033,1306.7561700995439,1350.7212433449656,1394.4971717209344,1450.8783465554586,1535.3244593129432,1636.522793388571 +24,14.0,939.0464949360451,989.3390479265933,1042.52443956466,1053.8450555794736,1096.9704556869508,1133.7247772929059,1161.87998150941,1191.9753530380601,1232.261071797708,1261.048997235435,1288.0729446252428,1306.7359631075249,1343.4728371694196,1376.2884694756049,1426.7264106225966,1472.849001649617,1533.000801120999,1613.6683861515637,1721.3595564753066 +25,14.5,975.364843501547,1028.8276938486088,1086.7267117395327,1096.3275573218223,1140.3781174812052,1178.1149296476399,1207.9036524967566,1237.6255011845392,1282.323405190089,1313.0373099549847,1342.8090611409807,1362.5711707383384,1401.7433750588236,1438.9747723275989,1495.7868634819433,1544.9780239777078,1607.2115426767457,1683.4547039319457,1797.0163490348584 +26,15.0,1005.3095141810182,1062.9998516847795,1124.877012907622,1136.2174309917964,1180.3999397833236,1217.5554227495702,1248.1060774705286,1277.004999110571,1324.3816675053256,1356.49941582635,1388.19239180701,1409.5142657285032,1449.773141577276,1490.84123845273,1553.5760471502306,1606.7362740693613,1668.7186049887812,1740.3477256598014,1858.706911186204 +27,15.5,1027.4773263279474,1090.5414911705564,1155.4480192256372,1172.717623647496,1216.1691052789984,1250.8937031852383,1281.172323313206,1308.7598367925293,1356.6427935465779,1389.4748410695488,1422.0095495470816,1445.3832429882907,1485.1241302594883,1529.1927375540708,1597.1384086486403,1655.2720620977186,1714.2576680904078,1781.4522531335099,1903.2240467905428 +28,16.0,1043.2839584786832,1112.6410324431981,1179.794215318337,1206.415901880047,1248.444044246274,1279.2324651875217,1308.397122507516,1334.2814148043833,1380.8862227614993,1413.8617594305133,1446.3460359651478,1472.164968604666,1510.0806091027819,1556.448978958167,1629.0024021741751,1692.9187974718648,1746.674997083812,1809.6350433221087,1933.6768146779607 +29,16.5,1054.8498039652904,1131.112508240417,1199.990537927493,1238.2462371638326,1278.389498861284,1304.2382149008824,1331.7276239362482,1355.6474863695014,1399.784520758867,1432.5230065366554,1464.3620748352105,1492.8885264587661,1528.1074147201296,1576.308381897434,1653.067483717794,1723.3061644098696,1770.3445033384016,1829.2033419873014,1954.753337420764 +30,17.0,1064.295256119833,1147.7699512999238,1218.1119237948778,1269.1426009732352,1307.1702113001609,1327.5774584697813,1353.1109764821908,1374.9358047112514,1416.010253147458,1448.3214180153861,1479.217889931271,1510.5830004317286,1542.6693837245043,1592.469365604286,1673.2331092704542,1750.0638471298041,1789.6400982235837,1844.464394890791,1971.1417375912606 +31,17.5,1073.3873093856985,1164.1250555698123,1235.8874868719672,1299.8833028702406,1335.7568000430156,1350.6389510146032,1374.1698042143355,1393.8778308491067,1431.7905579345859,1463.6426902228816,1493.5470010649979,1527.772627091168,1556.65387996005,1608.0139461057356,1692.7488254912882,1776.2165824800602,1808.2067078805312,1859.0075735006633,1986.748783999706 +32,18.0,1082.4793626515636,1180.4801598397005,1253.6630499490566,1330.624004767246,1364.3433887858703,1373.7004435594251,1395.22863194648,1412.8198569869621,1447.5708627217136,1478.963962430377,1507.8761121987247,1544.9622537506075,1570.638376195596,1623.558526607185,1712.264541712122,1802.3693178303163,1826.7733175374788,1873.5507521105358,2002.3558304081512 +33,18.5,1091.571415917429,1196.835264109589,1271.4386130261457,1361.3647066642516,1392.9299775287252,1396.7619361042473,1416.2874596786244,1431.7618831248171,1463.3511675088416,1494.2852346378725,1522.2052233324516,1562.1518804100472,1584.622872431142,1639.103107108635,1731.7802579329561,1828.5220531805724,1845.339927194426,1888.0939307204083,2017.9628768165965 +34,19.0,1100.6634691832944,1213.1903683794771,1289.214176103235,1392.105408561257,1421.51656627158,1419.823428649069,1437.346287410769,1450.7039092626726,1479.1314722959694,1509.606506845368,1536.5343344661785,1579.3415070694866,1598.6073686666875,1654.6476876100844,1751.29597415379,1854.6747885308284,1863.9065368513739,1902.6371093302803,2033.5699232250417 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/stats.json b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/stats.json new file mode 100644 index 00000000..6255aa6f --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver/stats.json @@ -0,0 +1 @@ +{"Male": {"count": 1025, "age_mean": 11.548176534148292, "age_median": 12.18841514, "age_std": 4.436705896930332}, "Female": {"count": 1107, "age_mean": 13.016982621581754, "age_median": 14.12575152, "age_std": 4.05686939082313}} \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/figure.html b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/figure.html new file mode 100644 index 00000000..01228035 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/figure.html @@ -0,0 +1,15 @@ + + +Liver HU Quantile Figure + +
+
+ + \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/outlier_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/outlier_df.csv new file mode 100644 index 00000000..5be1142f --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/outlier_df.csv @@ -0,0 +1,222 @@ +,id,Age,biomarker,Sex +0,Z1188429,2.269587139,106.0,Male +1,Z1378013,2.468122146,176.0,Male +2,Z1320140,2.068120244,175.0,Male +3,Z1122127,3.977256469,173.0,Male +4,Z1204836,3.3524638510000004,105.0,Male +5,Z1276776,3.123042237,177.0,Male +6,Z1457223,3.49445586,190.0,Male +7,Z1068362,4.964788813,107.1,Male +8,Z1756629,4.183991628999999,103.0,Male +9,Z1409889,4.598831811,91.0,Male +10,Z1434046,4.860243531,99.0,Male +11,Z1452364,4.584056317,100.0,Male +12,Z625547,5.888710046,84.4,Male +13,Z1146560,5.708285769,171.0,Male +14,Z1433991,5.70961758,103.0,Male +15,Z1067890,5.799613775,108.0,Male +16,Z602277,6.256333714,173.0,Male +17,Z1302641,6.991807457999999,89.2,Male +18,Z684923,6.802127092999999,165.0,Male +19,Z1867298,6.211746575,106.0,Male +20,Z1312138,6.465384322999999,106.0,Male +21,Z1383814,7.146044521,105.9,Male +22,Z1075255,7.451621005,175.0,Male +23,Z701175,7.547435312,96.0,Male +24,Z563026,7.319362633,169.0,Male +25,Z658141,7.711078767,169.0,Male +26,Z1057740,7.552791096,189.0,Male +27,Z1225055,7.047269787,169.0,Male +28,Z463749,8.522305936,100.0,Male +29,Z1008427,8.466870243999999,183.0,Male +30,Z670302,8.1006621,196.0,Male +31,Z472441,9.339802131,98.0,Male +32,Z1691829,9.109649924,182.0,Male +33,Z725461,9.1233086,175.0,Male +34,Z654393,9.126282344,167.0,Male +35,Z600797,10.25122907,177.0,Male +36,Z398051,10.13892123,71.0,Male +37,Z1054245,10.71974886,178.0,Male +38,Z1300146,10.16120624,84.0,Male +39,Z720289,10.35853311,182.0,Male +40,Z1982677,10.71469749,95.0,Male +41,Z1215926,10.53452816,77.0,Male +42,Z736927,11.86347412,95.0,Male +43,Z950429,11.65277397,95.0,Male +44,Z531537,11.38276256,196.0,Male +45,Z1991766,11.94719939,54.0,Male +46,Z720323,11.5411035,181.0,Male +47,Z1205753,11.77636225,173.0,Male +48,Z921490,11.30904871,90.0,Male +49,Z909949,11.76563166,91.4,Male +50,Z942755,12.75851218,88.0,Male +51,Z624389,12.03133181,64.0,Male +52,Z613881,12.88964422,86.0,Male +53,Z438342,12.14127854,67.0,Male +54,Z1012577,12.99657725,174.0,Male +55,Z538349,12.15003995,96.0,Male +56,Z437139,12.30586568,94.0,Male +57,Z1170798,12.21491819,174.0,Male +58,Z602312,12.88509893,162.0,Male +59,Z1267669,12.87658866,181.0,Male +60,Z914553,12.76379566,170.0,Male +61,Z519467,12.36416476,176.0,Male +62,Z1005788,12.81734209,89.4,Male +63,Z667765,13.55518265,161.0,Male +64,Z873786,13.29883942,158.0,Male +65,Z1238977,13.53142314,82.0,Male +66,Z1107055,13.7951465,173.0,Male +67,Z1934105,13.0241895,160.0,Male +68,Z942281,14.20687215,78.0,Male +69,Z524124,14.0119102,154.0,Male +70,Z860899,14.05202626,83.0,Male +71,Z982837,14.66577435,163.0,Male +72,Z554958,14.84141933,82.0,Male +73,Z482583,15.22048326,78.0,Male +74,Z953151,15.75319064,146.0,Male +75,Z487137,15.59559361,86.0,Male +76,Z332716,15.10616629,87.0,Male +77,Z1272833,15.92775114,77.0,Male +78,Z1762020,15.62584475,324.0,Male +79,Z668097,15.44563927,162.0,Male +80,Z919272,15.64330289,152.0,Male +81,Z887306,15.159484400000002,158.0,Male +82,Z846984,15.51659627,83.0,Male +83,Z1862275,15.07251142,166.0,Male +84,Z868599,15.50533676,79.0,Male +85,Z1055988,15.84835807,152.0,Male +86,Z418856,15.24530632,214.0,Male +87,Z425207,16.93062215,153.0,Male +88,Z842219,16.28341895,78.0,Male +89,Z416353,16.5493417,83.0,Male +90,Z839955,16.86594559,76.0,Male +91,Z1199673,16.64004186,82.5,Male +92,Z1468630,16.49182458,153.0,Male +93,Z574481,16.35774924,70.0,Male +94,Z832987,16.08449391,76.0,Male +95,Z822245,17.76638699,79.0,Male +96,Z1769386,17.38268455,65.0,Male +97,Z464078,17.96665335,154.0,Male +98,Z617099,17.796027399999996,68.0,Male +99,Z910473,17.28217656,147.0,Male +100,Z1722108,17.86173896,166.0,Male +101,Z357478,17.57100076,366.0,Male +102,Z900154,17.34888508,157.0,Male +103,Z891398,18.22637177,70.0,Male +104,Z283402,18.35983828,150.0,Male +105,Z1210986,2.54957382,176.0,Female +106,Z1400151,2.593476027,169.0,Female +107,Z1140510,3.898314307,92.6,Female +108,Z1437460,4.0500228310000015,192.0,Female +109,Z617704,5.542243151,79.0,Female +110,Z1421484,5.165479452,110.0,Female +111,Z1401074,5.685260654,106.0,Female +112,Z1228894,6.814010654,109.0,Female +113,Z587798,6.454130518,85.0,Female +114,Z1071830,6.163592085,168.0,Female +115,Z1154679,6.147442922000001,178.0,Female +116,Z1233068,6.584967656,174.0,Female +117,Z1255436,6.952964231,105.0,Female +118,Z1315609,6.555840944,105.0,Female +119,Z1186837,6.2040449010000005,180.0,Female +120,Z726327,7.913888889,108.0,Female +121,Z1247665,7.204417808,104.0,Female +122,Z1013996,7.783059361,190.0,Female +123,Z513203,7.308592085,73.0,Female +124,Z1303521,7.844876332,92.0,Female +125,Z685541,7.150182648,168.0,Female +126,Z1182990,8.800538432,105.0,Female +127,Z589920,8.881746575,166.0,Female +128,Z729318,8.155747717,172.0,Female +129,Z1041531,8.125563166000001,171.0,Female +130,Z1085142,8.758247717,102.0,Female +131,Z1895435,9.793993531,99.0,Female +132,Z1261562,9.788677702,180.0,Female +133,Z1743266,9.106820776,186.0,Female +134,Z1119985,10.01430936,97.8,Female +135,Z1254547,10.062207,167.0,Female +136,Z449657,10.41756659,102.0,Female +137,Z689721,10.03466324,180.0,Female +138,Z683134,10.45101979,166.0,Female +139,Z1433828,10.67896309,186.0,Female +140,Z673496,10.55841324,167.0,Female +141,Z1196674,11.94423326,173.7,Female +142,Z1321044,11.53517504,99.0,Female +143,Z1209286,11.77095129,94.0,Female +144,Z425795,11.73637557,179.0,Female +145,Z423267,12.44340183,101.0,Female +146,Z888374,12.96414764,87.0,Female +147,Z916921,12.75463851,90.4,Female +148,Z436333,12.80005137,94.0,Female +149,Z614746,12.28248097,173.0,Female +150,Z866244,13.93111111,85.0,Female +151,Z489517,13.29479262,394.0,Female +152,Z1776275,13.64820586,72.0,Female +153,Z1066955,13.83188166,172.0,Female +154,Z952990,13.72586948,170.0,Female +155,Z652556,13.49896689,179.0,Female +156,Z606133,13.93813927,75.0,Female +157,Z1709990,13.73675228,87.0,Female +158,Z1735753,13.22320586,177.0,Female +159,Z880672,13.025437599999998,98.0,Female +160,Z520362,13.07037671,197.0,Female +161,Z905421,13.55361111,83.0,Female +162,Z1363878,14.28785578,181.0,Female +163,Z911349,14.99106164,71.0,Female +164,Z1724091,14.83756849,184.0,Female +165,Z453391,14.59791476,90.0,Female +166,Z1340725,14.56833143,189.0,Female +167,Z867447,14.38273021,95.0,Female +168,Z341800,14.73333524,93.0,Female +169,Z899117,14.69892884,88.0,Female +170,Z1332420,14.47844178,442.0,Female +171,Z477468,14.38117199,170.0,Female +172,Z909304,14.84812024,86.0,Female +173,Z563303,14.80412861,169.0,Female +174,Z874467,14.82894787,83.0,Female +175,Z467063,14.64905441,89.9,Female +176,Z1171714,15.57417237,174.0,Female +177,Z463812,15.01813737,197.0,Female +178,Z944864,15.93137557,195.0,Female +179,Z542828,15.99435883,76.0,Female +180,Z926885,15.03707002,182.0,Female +181,Z855275,15.09664574,80.0,Female +182,Z905621,15.26436454,169.0,Female +183,Z1060384,15.17951484,84.3,Female +184,Z1373464,15.89424087,185.0,Female +185,Z1055147,15.25582002,179.0,Female +186,Z1230335,15.58646689,92.0,Female +187,Z852067,15.66447108,53.0,Female +188,Z1694937,15.94798516,176.0,Female +189,Z908057,16.67222793,87.0,Female +190,Z1191015,16.01060122,62.8,Female +191,Z1457561,16.53834855,189.0,Female +192,Z1200968,16.11493151,171.0,Female +193,Z892423,16.94350457,63.0,Female +194,Z1419219,16.25374239,167.0,Female +195,Z1742824,16.30613775,178.0,Female +196,Z880238,16.41543569,70.0,Female +197,Z574459,16.66201294,85.0,Female +198,Z526993,16.25124619,67.4,Female +199,Z678707,16.67755327,73.0,Female +200,Z872316,16.25587519,174.0,Female +201,Z1035002,16.05239155,168.0,Female +202,Z827107,16.80622336,91.0,Female +203,Z863038,16.59391743,181.0,Female +204,Z828131,17.55798706,90.0,Female +205,Z1144970,17.81423516,85.0,Female +206,Z843084,17.24898212,188.0,Female +207,Z1260964,17.96730023,80.0,Female +208,Z823923,17.00807078,45.0,Female +209,Z479400,17.9744825,170.0,Female +210,Z880357,17.34690259,168.0,Female +211,Z1743948,17.78028919,179.0,Female +212,Z916829,17.76074011,165.0,Female +213,Z935463,17.03520928,179.0,Female +214,Z533913,17.79978311,185.0,Female +215,Z414360,17.81636796,85.0,Female +216,Z1182130,17.55489155,63.1,Female +217,Z815269,17.95001142,91.0,Female +218,Z468716,18.22273021,169.0,Female +219,Z819717,18.78874049,86.0,Female +220,Z897356,18.48884703,196.0,Female diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_df.csv new file mode 100644 index 00000000..9b84149a --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_df.csv @@ -0,0 +1,36 @@ +,Age,Male 0.05,Male 0.25,Male 0.50,Male 0.75,Male 0.95,Female 0.05,Female 0.25,Female 0.50,Female 0.75,Female 0.95 +0,2.0,105.7978772754965,127.7632517633784,139.31228357132554,151.30248949145712,170.3146170517289,110.24155536054639,126.54466421525595,139.610329405455,147.99593994800884,169.34873647283536 +1,2.5,106.17275281856826,126.54175817337605,138.17305063066578,150.13133971079714,169.16533245131873,110.20501057461541,126.71674048493077,139.78388571854094,148.3263336290588,169.0549280535346 +2,3.0,106.54762836163997,125.3202645833737,137.03381769000598,148.96018993013718,168.01604785090845,110.16846578868439,126.88881675460556,139.9574420316269,148.65672731010875,168.7611196342338 +3,3.5,106.91882024794631,124.10622465819903,135.90282923309616,147.79683932501095,166.87414492933775,110.13192100275339,127.06089302428036,140.13099834471285,148.98712099115866,168.4673112149331 +4,4.0,107.26791019366041,122.93690672199027,134.821307678686,146.68028377308704,165.77653208080412,110.09537621682237,127.23296929395514,140.30455465779877,149.3175146722086,168.1735027956323 +5,4.5,107.57279625819001,121.85703276371333,133.8387199292752,145.65731832756785,164.76749937834478,110.05870566435195,127.40493610534686,140.47798446184748,149.64779500571362,167.8797499691942 +6,5.0,107.81137650094287,120.91132477233418,133.00453288736347,144.77473804165578,163.89133689499678,110.01689440458318,127.5724288094182,140.64624320899912,149.97344225832003,167.58826950101658 +7,5.5,107.96154898132674,120.14450473681875,132.36821345545047,144.0793379685531,163.19233470379734,109.95857628651699,127.7255551138368,140.7978976450134,150.28421264565569,167.3040855960607 +8,6.0,108.00121175874938,119.60129464613293,131.97922853603595,143.6179131614622,162.71478287778356,109.87196069708375,127.85405330456507,140.92108754764928,150.56947983538436,167.03241008519927 +9,6.5,107.90826289261855,119.32641648924266,131.88704503161944,143.4372586735855,162.50297148999252,109.74525702321391,127.94766166756533,141.0039526946658,150.8186174951697,166.77845479930494 +10,7.0,107.66060044234197,119.36459225511389,132.14112984470083,143.58416955812527,162.60119061346137,109.56667465183787,127.99611848879997,141.03463286382197,151.0209992926755,166.5474315692504 +11,7.5,107.23612246732746,119.76054393271252,132.7909498777797,144.10544086828398,163.05373032122728,109.32442296988603,127.98916205423131,141.00126783287683,151.1659988955656,166.3445522259084 +12,8.0,106.6127270269827,120.55899351100445,133.88597203335567,145.04786765726385,163.90488068632732,109.00671136428876,127.91653064982177,140.89199737958938,151.24298997150362,166.17502860015148 +13,8.5,105.77735000689869,121.76174921305014,135.4289946840022,146.41369326238492,165.1541023862454,108.60174922197652,127.76796256153361,140.69496128171858,151.24134618815333,166.0440725228524 +14,9.0,104.7530785973993,123.1989641982879,137.23614208258724,148.02695415743747,166.62153851625234,108.09774592987966,127.5331960753293,140.39829931702351,151.1504412131785,165.95689582488387 +15,9.5,103.57203781499156,124.6578778602505,139.0768699520524,149.66713510032938,168.0825027760657,107.48291087492868,127.2019694771711,139.9901512632632,150.9596487142429,165.91871033711857 +16,10.0,102.26635267618258,125.92572959247083,140.72063401533944,151.1137208489686,169.3123088654031,106.74545344405385,126.76402105302135,139.45865689819655,150.65834235901013,165.93472789042903 +17,10.5,100.86814819747937,126.78975878848163,141.9368899953899,152.14619616126294,170.08627048398205,105.87358302418572,126.20908908884253,138.7919559995827,150.2358958151442,166.0101603156882 +18,11.0,99.40954939538906,127.0372048418158,142.4950936151455,152.54404579512038,170.17970133152022,104.85550900225462,125.52691187059689,137.97818834518057,149.68168275030857,166.15021944376852 +19,11.5,97.9226812864187,126.45530714600606,142.16470059754784,152.08675450844876,169.36791510773514,103.67944076519092,124.70722768424677,137.0054937127492,148.98507683216712,166.36011710554277 +20,12.0,96.43966888707531,124.83130509458528,140.7151666655386,150.55380705915601,167.42622551234436,102.3335876999251,123.73977481575461,135.86201188004765,148.13545172838363,166.64506513188363 +21,12.5,94.98512421654779,122.05664233834622,138.0197005673978,147.82882441082748,164.24925501175034,100.82258586766046,122.62434379128364,134.55332359378488,147.1398590240343,167.00028850660897 +22,13.0,93.55360730475182,118.43957955712146,134.3665231527588,144.21197234975835,160.20886113909472,99.216778026692,121.40093409780063,133.15477347646984,146.07606197384547,167.3810648253172 +23,13.5,92.13216518428506,114.39258168800365,130.14760829659338,140.10755286792127,155.7962101942042,97.60293360958762,120.11959746247328,131.7591471195616,145.0395017499561,167.73268483655204 +24,14.0,90.70784488774491,110.32811366808522,125.75492987387327,135.9198679572889,151.5024684769051,96.0678220489153,118.83038561246937,130.45923011451907,144.12561952450494,168.0004392888572 +25,14.5,89.26769344772902,106.65864043445886,121.5804617595702,132.05321960983386,147.8188022870242,94.69821277724293,117.58335027495659,129.3478080528013,143.42985646963095,168.12961893077627 +26,15.0,87.79875789683486,103.79662692421708,118.01617782865596,128.91190981752882,145.23637792438788,93.58087522713842,116.4285431771026,128.5176665258672,143.04765375747297,168.06551451085298 +27,15.5,86.29198314048875,102.03496372408381,115.34696639117526,126.78578855088753,144.09815030022372,92.7739905443194,115.40174575959608,128.03054077133922,143.0415039683611,167.7728680409278 +28,16.0,84.7539055754321,101.1882440193084,113.42937349746501,125.50689769458822,144.15422877136294,92.22138672710224,114.48165831720945,127.8239646114932,143.34210531539043,167.29422658602857 +29,16.5,83.19495947123508,100.95148664477158,112.012859632935,124.79282711185023,145.00651130603777,91.83830348695295,113.63271085823608,127.80442151476853,143.84720741984717,166.69158847447994 +30,17.0,81.62557909746789,101.01971043535404,110.84688528299515,124.36116666589291,146.25689587248038,91.53998053533763,112.81933339096935,127.87839494960456,144.45455990301764,166.02695203460667 +31,17.5,80.05445967877233,101.13876442012305,109.7226675188203,123.97657457606569,147.57363077755096,91.25578429847798,112.01188425565373,127.96795447303404,145.07895411597374,165.3519825400126 +32,18.0,78.48334026007677,101.25781840489205,108.59844975464547,123.59198248623848,148.89036568262156,90.97158806161832,111.20443512033808,128.0575139964635,145.70334832892985,164.67701304541856 +33,18.5,76.91222084138121,101.37687238966107,107.47423199047063,123.20739039641124,150.20710058769214,90.68739182475862,110.39698598502244,128.14707351989293,146.32774254188587,164.00204355082442 +34,19.0,75.34110142268565,101.49592637443006,106.3500142262958,122.82279830658403,151.52383549276271,90.40319558789898,109.5895368497068,128.23663304332243,146.95213675484203,163.32707405623046 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_f_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_f_fine.csv new file mode 100644 index 00000000..43f12dc5 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_f_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,110.24155536054639,120.84888894050344,124.02136701277225,122.74551138393286,126.54466421525595,128.74945736564914,135.36653769709727,137.3629880208453,137.89275878247872,139.610329405455,141.81844786275255,141.37943184180412,142.137265054907,146.250290232893,147.99593968693713,156.0209289153077,159.43647605310218,164.44388811515273,169.3487365405382 +1,2.5,110.20501057461541,120.43959884579004,123.71015662880544,122.90475929248558,126.71674048493077,128.96559871178556,135.27065237846867,137.4629124346699,138.1338571113271,139.78388571854094,141.94435983748463,141.80981583516026,142.62435753869227,146.54800079909612,148.32633338489973,155.8238750515693,159.2936191186173,164.19224537578887,169.05492806419795 +2,3.0,110.16846578868439,120.03030875107662,123.39894624483863,123.06400720103831,126.88881675460556,129.18174005792196,135.17476705984,137.56283684849444,138.3749554401755,139.9574420316269,142.07027181221667,142.24019982851644,143.11145002247758,146.84571136529917,148.65672708286223,155.62682118783087,159.1507621841324,163.940602636425,168.76111958785765 +3,3.5,110.13192100275339,119.62101865636323,123.0877358608718,123.22325510959107,127.06089302428036,129.39788140405844,135.07888174121138,137.66276126231898,138.61605376902386,140.13099834471285,142.19618378694875,142.67058382187255,143.59854250626285,147.14342193150225,148.98712078082474,155.4297673240925,159.00790524964748,163.68895989706115,168.46731111151735 +4,4.0,110.09537621682237,119.21172856164979,122.77652547690498,123.38250301814375,127.23296929395514,129.61402275019483,134.98299642258274,137.76268567614352,138.85715209787224,140.30455465779877,142.3220957616808,143.10096781522873,144.0856349900481,147.44113249770527,149.31751447878725,155.2327134603541,158.8650483151626,163.4373171576973,168.17350263517702 +5,4.5,110.05870566435195,118.80237945881493,122.46525028618575,123.54165058061666,127.40493610534686,129.83005067028603,134.88702695423254,137.86247946016982,139.09810359400936,140.47798446184748,142.44789164547467,143.5312043146068,144.5725895154308,147.7387347637412,149.64779482920358,155.03562450118378,158.72214817864293,163.1856585952077,167.8797497517302 +6,5.0,110.01689440458318,118.39061839901697,122.15132611946306,123.6966964970764,127.5724288094182,130.0414423007784,134.78761786601365,137.95693375119225,139.33305330307462,140.64624320899912,142.56894231216904,143.95541199763232,145.05390499110877,148.03191026044536,149.97344209866858,154.83710101623473,158.57748215895322,162.93335326245446,167.58826922780335 +7,5.5,109.95857628651699,117.97111252328236,121.82889606649069,123.83857199055805,127.7255551138368,130.23794676283424,134.67716412684018,138.0342428811932,139.54873121878978,140.7978976450134,142.67475604322289,144.36026109603938,145.51711342645035,148.3108713602125,150.2842125026935,154.63397125584925,158.4271458722037,162.67897114445077,167.30408527114253 +8,6.0,109.87196069708375,117.53832982022774,121.4918844942331,123.95786961607732,127.85405330456507,130.40893036471311,134.5477767003161,138.08216030658576,139.73137177447614,140.92108754764928,142.75444931317867,144.7319240493862,145.9492812212152,148.5654649223734,150.56947970882118,154.422945023287,158.2670891276373,162.42102882316067,167.03240971550915 +9,6.5,109.74525702321391,117.08673827846977,121.13421576965479,124.04518192864984,127.94766166756533,130.5437594146746,134.39156655004518,138.0884394837832,139.8672094034551,141.0039526946658,142.7971385965789,145.056573297231,146.33747477516306,148.7855378062588,150.8186173845944,154.2007321218078,158.09326173449702,162.15804288054812,166.77845439466466 +10,7.0,109.56667465183787,116.61080588662517,120.74981425972028,124.09110148329133,127.99611848879997,130.63180022097836,134.20064463963138,138.0408338691987,139.94247853904795,141.03463286382197,142.79194036796602,145.3203812791321,146.66876048805364,148.9609368711994,151.020999197556,153.96404235467122,157.90161350202584,161.88852989857716,166.54743114237053 +11,7.5,109.32442296988603,116.1050006333106,120.33260433139407,124.08622083501743,127.98916205423131,130.66241909188398,133.96712193267865,137.92709691924546,139.94341361457614,141.00126783287683,142.72797110188256,145.5095204346477,146.9302047596467,149.08150897652604,151.16599881524883,153.70958552513707,157.68809423946675,161.6110064592118,166.34455179238816 +12,8.0,109.00671136428876,115.5637905071427,119.8765103516406,124.0211325388438,127.91653064982177,130.62498233565103,133.68310939279075,137.7349820903367,139.85624906336096,140.89199737958938,142.59434727287095,145.61016320333607,147.10887398970198,149.13710098156932,151.24298990521567,153.43407143646502,157.4486537560627,161.32398914441598,166.17502817847898 +13,8.5,108.60174922197652,114.98164349673814,119.37545668742442,123.8864291497861,127.76796256153361,130.5088562605391,133.34071798357166,137.45224283888558,139.66721931872377,140.69496128171858,142.38018535547369,145.60848202475532,147.19183457797905,149.11755974566,151.24134613499928,153.13420989191474,157.17924186105665,161.02599453615363,166.04407213440447 +14,9.0,108.09774592987966,114.35302759071365,118.82336770571006,123.67270322286005,127.5331960753293,130.3034071748079,132.93205866862527,137.06663262130527,139.3625588139859,140.39829931702351,142.07460182423327,145.49064933846387,147.16615292423776,149.0127321281289,151.1504411721426,152.80671069474596,156.8758083636917,160.71553921638892,165.9568954939261 +15,9.5,107.48291087492868,113.67241077768584,118.21416777346195,123.37054731308126,127.2019694771711,129.99800138671685,132.44924241155547,136.56590489400904,138.92850198246873,139.9901512632632,141.66671315369214,145.2428375840198,147.01889542823773,148.81246498830666,150.95964868418835,152.44828364821836,156.53430307321065,160.3911397670857,165.91871009080523 +16,10.0,106.74545344405385,112.93426104627139,117.5417812576446,122.97055397546544,126.76402105302135,129.58200520452567,131.88438017596604,135.93781311340996,138.35128325749352,139.45865689819655,141.14563581839275,144.85121920098146,146.73712848973867,148.506605185524,150.6583423386793,152.0556385555916,156.15067579885655,160.05131277020794,165.93472775880332 +17,10.5,105.87358302418572,112.133046385087,116.80013252522252,122.46331576502823,126.20908908884253,129.044784936494,131.229582925461,135.1701107359214,137.61713707238175,138.7919559995827,140.50048629287775,144.3019666289071,146.30791850850045,148.0849995791118,150.23589580315843,151.62548522012548,155.72087634987247,159.6945748077198,166.01016033168185 +18,11.0,104.85550900225462,111.26323478274928,115.98314594316022,121.8394252367853,125.52691187059689,128.37570689088133,130.47696162364423,134.2505512179564,136.71229786045467,137.97818834518057,139.7203810516894,143.58125230735487,145.7183318842826,147.53749502840066,149.6816827451684,151.15453344507966,155.24085453550123,159.31944246158514,166.15021964320226 +19,11.5,103.67944076519092,110.31929422787493,115.08474587842217,121.08947494575234,124.70722768424677,127.5641373759473,129.61862723411954,133.1668880159282,135.62300005503369,137.0054937127492,138.7944365693703,142.67524867588307,144.9554350168449,146.8539383927213,148.98507683225202,150.6394930337138,154.7065601649859,158.92443231376794,166.3601175271259 +20,12.0,102.3335876999251,109.29569270908063,114.09885669797289,120.204057446945,123.73977481575461,126.59944269995155,128.64669072049088,131.90687458625007,134.3354780894401,135.86201188004765,137.7117693204629,141.57012817404996,144.00629430594705,146.02417653140452,148.1354517319522,150.07707378928765,154.11394304756942,158.50806094623226,166.64506581721434 +21,12.5,100.82258586766046,108.19473308963669,113.02794162354654,119.1813964458452,122.62434379128364,125.4829807108988,127.5627364987706,130.47501667532177,132.85671425101043,134.55332359378488,136.4774320928145,140.2705580617866,142.87554195431875,145.05316506858216,147.1398590293139,149.47119670344318,153.46752869856863,158.0724832965464,167.00028949154634 +22,13.0,99.216778026692,107.05005773142784,111.90861929595587,118.05024024979969,121.40093409780063,124.26407541577427,126.40624279460484,128.94282918948966,131.2766822431416,133.15477347646984,135.16022292749147,138.85518488051574,141.63807337656945,144.00629468759064,146.0760619793909,148.85462752135132,152.80614545759562,157.6344077246959,167.38106610547032 +23,13.5,97.60293360958762,105.90314387099257,110.78604721078338,116.84696831662148,120.11959746247328,123.00404236130834,125.22616128604827,127.39857932508679,129.70610362324572,131.7591471195616,133.84487617886512,137.42114999203292,140.38634979027867,142.96406483656756,145.03950175473915,148.2673431765654,152.1771973703363,157.2141809462708,167.7326863586522 +24,14.0,96.0678220489153,104.79546874486924,109.70538286361152,115.6079601041236,118.83038561246937,121.76419709423143,124.0714436511555,125.93053427844617,128.2556999487349,130.45923011451907,132.61612620130663,136.0655947581338,139.2128324130258,142.00697496365038,144.1256195279146,147.7493206026387,151.6280884824767,156.83214967686092,168.0004409507578 +25,14.5,94.69821277724293,103.76850958959628,108.71178375002273,114.36959507011908,117.58335027495659,120.60585516127391,122.99104156798124,124.62696124590082,127.03619277702123,129.3478080528013,131.55870734918727,134.88566054061397,138.20998246239034,141.21552451697673,143.42985647147324,147.34053673312445,151.20622283970266,156.50866063205623,168.12962058145297 +26,15.0,93.58087522713842,102.8637436417121,107.85040736559948,113.16825267242086,116.4285431771026,119.59033210916613,122.03390671458004,123.57612742378375,126.15830366551674,128.5176665258672,130.75735397687825,133.97848870126893,137.47026115595173,140.67021294468407,143.047653757971,147.0809685015759,150.9590044877002,156.26406052744665,168.06551595040352 +27,15.5,92.7739905443194,102.10891566316842,107.15149411396263,112.03119070528487,115.40174575959608,118.76021862570093,121.23365636817131,122.83738020875737,125.69545309466399,128.03054077133922,130.2684780532647,133.4096177190963,137.05590326411883,140.4245131901155,143.0415039680589,146.99604699547422,150.91668465885516,156.11117014036606,167.7728690390843 +28,16.0,92.22138672710224,101.47684051757012,106.58561603088674,110.94918030873838,114.48165831720945,118.08320596292081,120.56257020263338,122.35438779880198,125.57185723702722,127.8239646114932,130.0352020052873,133.1181745419015,136.90823776861814,140.42379217743584,143.3421053147681,147.05301991801176,151.04090333235325,156.03270649512422,167.294226956206 +29,16.5,91.83830348695295,100.92660059393543,106.10842806018485,109.90387095925155,113.63271085823608,117.5082605139308,119.97759349100932,122.04189859222721,125.67443118820108,127.80442151476853,129.97232587440067,133.0116832346919,136.9383672040056,140.58639032601548,143.84720741922484,147.20458912630875,151.27614767408005,156.00386067777487,166.69158809228833 +30,17.0,91.53998053533763,100.41727828128253,105.67558514566997,108.87691213329458,112.81933339096935,116.98434867183579,119.43567150634217,121.81466098734278,125.8900900437803,127.87839494960456,129.99464970205946,132.9976678624749,137.05739410483716,140.8306480552248,144.45455990255536,147.40345647748555,151.56690484992123,155.9998237743718,166.02695083785096 +31,17.5,91.25578429847798,99.91477557045997,105.25013307385294,107.85301172792592,112.01188425565373,116.46894243088997,118.90092530950122,121.60163198274009,126.12459638342708,127.96795447303404,130.03117352280913,132.99906514609003,137.1912372499094,141.08851571453903,145.07895411569817,147.6102068524756,151.86691416478146,155.9999216899598,165.351980518327 +32,18.0,90.97158806161832,99.41227285963735,104.82468100203586,106.82911132255724,111.20443512033808,115.95353618994416,118.3661791126602,121.38860297813734,126.35910272307383,128.0575139964635,130.06769734355885,133.00046242970518,137.32508039498163,141.3463833738533,145.703348328841,147.8169572274657,152.16692347964172,156.00001960554778,164.67701019880303 +33,18.5,90.68739182475862,98.90977014881473,104.3992289302188,105.80521091718856,110.39698598502244,115.4381299489983,117.83143291581924,121.17557397353461,126.59360906272056,128.14707351989293,130.10422116430848,133.00185971332021,137.45892354005383,141.6042510331675,146.32774254198375,148.02370760245577,152.46693279450196,156.0001175211357,164.002039879279 +34,19.0,90.40319558789898,98.4072674379922,103.97377685840175,104.7813105118199,109.5895368497068,114.92272370805253,117.29668671897826,120.9625449689319,126.82811540236739,128.23663304332243,130.1407449850582,133.0032569969354,137.59276668512607,141.8621186924818,146.9521367551266,148.23045797744587,152.76694210936222,156.0002154367237,163.32706955975513 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_m_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_m_fine.csv new file mode 100644 index 00000000..54b5dc4d --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/results_m_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,105.7978772754965,113.71717428048036,120.31961480400223,126.14738740892584,127.7632517633784,129.23025620434785,129.90461282932304,132.68714608977575,138.24602446817462,139.31228357132554,141.91193985165071,142.91415370947576,146.99175918347726,150.22559513612816,151.30248949145522,154.87925235862258,156.8803637519919,164.19485548060496,170.3146166787079 +1,2.5,106.17275281856826,113.54252503798475,119.55976475196778,124.8551041302202,126.54175817337605,128.07398129155803,128.97477891057622,131.99024611664896,137.13523893266623,138.17305063066578,140.71347614542333,141.79142824270028,145.650818757291,148.91076924005515,150.1313397107966,153.53216357182265,155.70977936534757,162.95988621708864,169.16533213239376 +2,3.0,106.54762836163997,113.36787579548911,118.79991469993331,123.5628208515145,125.3202645833737,126.91770637876817,128.04494499182942,131.29334614352223,136.02445339715777,137.03381769000598,139.51501243919589,140.66870277592471,144.30987833110467,147.59594334398207,148.9601899301379,152.18507478502266,154.53919497870316,161.7249169535722,168.01604758607957 +3,3.5,106.91882024794631,113.19449268548799,118.0447041213285,122.27785348045083,124.10622465819903,125.7694854094447,127.12256022102268,130.60240392024275,134.9210037104621,135.90282923309616,138.32488521438893,139.55448345113263,142.97776996547807,146.28978644485986,147.7968393248676,150.84636091558696,153.37591570855474,160.49689064452775,166.8741447184455 +4,4.0,107.26791019366041,113.02870637045378,117.31733038330167,121.03678155523916,122.93690672199027,124.66958810091937,126.2448703378563,129.94720819604694,133.86156911664264,134.821307678686,137.1847768781048,138.49130097824087,141.69865396320964,145.0356435274426,146.6802837719272,149.55789655033695,152.256467137382,159.31052206231493,165.7765319228921 +5,4.5,107.57279625819001,112.87811364535337,116.64563032643078,119.88350052173145,121.85703276371333,123.66633811399029,125.4565702299706,129.3635054700184,132.8901647085758,133.8387199292752,136.14470631886624,137.5301922091499,140.52552268765757,143.88552857343527,145.65731832364676,148.3699311934582,151.22467996416066,158.20746893376526,164.76749927149996 +6,5.0,107.81137650094287,112.75031130515374,116.05744079129383,118.86190582577977,120.91132477233418,122.80805910945571,124.80235478500599,128.88704224124078,132.05080557913783,133.00453288736347,135.25469242519608,136.72219399576016,139.51136850217998,142.89145556454292,144.77473803235628,147.33271434913644,150.32438488786642,157.22938898571033,163.89133683634978 +7,5.5,107.96154898132674,112.65289614482175,115.58059861846874,118.01589291323607,120.14450473681875,122.14307474811368,124.3269188906028,128.55356500879782,131.3875068212051,132.36821345545047,134.56475408561712,136.11834318997197,138.70918377013518,142.10543848247048,144.0793379503857,146.49649552155734,149.599412607475,156.41793994498187,163.1923346895222 +8,6.0,108.00121175874938,112.59346495932435,115.24294064853356,117.38935722995237,119.60129464613293,121.71970869076243,124.07495743440143,128.39882027177313,130.94428352765382,131.97922853603595,134.12491018865217,135.76967664368576,138.17196085488126,141.57949130892297,143.61791313006503,145.9115242149065,149.09359382196206,155.81477953841133,162.7147829030978 +9,6.5,107.90826289261855,112.57961454362845,115.07230372206625,117.02619422178063,119.32641648924266,121.58628459820004,124.09116530404218,128.45855452925025,130.76515079136033,131.88704503161944,133.98517962282406,135.72723120880192,137.95269211977634,141.36562802560522,143.43725862372415,145.6280499333696,148.85075923030328,155.46156549283035,162.50297154915737 +10,7.0,107.66060044234197,112.61894169270099,115.09652467964476,116.97029933457293,119.36459225511389,121.79112613122469,124.42023738716546,128.768514280313,130.89412370520085,132.14112984470083,134.1955812766555,136.04204373722084,138.1043699281787,141.51586261422244,143.58416948369316,145.6963221811324,148.91473953147442,155.39995553507057,162.60119069978145 +11,7.5,107.23612246732746,112.71904320150882,115.34344036184709,117.26556801418118,119.76054393271252,122.38255695063457,125.10686857141161,129.36444602404495,131.37521736205176,132.7909498777797,134.8061340386694,136.76515108084294,138.67998664344648,142.08220905647943,144.10544076230195,146.16659046238038,149.3293654244512,155.67160739196356,163.05373042705077 +12,8.0,106.6127270269827,112.88751586501891,115.84088760925118,117.95589570645743,120.55899351100445,123.40890071722774,126.19575374442094,130.28209625952974,132.25244685478935,133.88597203335567,135.8668567973885,137.94759009156857,139.73253462893788,143.11668133408125,145.04786751188055,147.08910428129934,150.13846760820925,156.31817879034088,163.90488080304584 +13,8.5,105.77735000689869,113.11579365399973,116.58574455233318,119.04281473528262,121.76174921305014,124.87163573139044,127.68781464451153,131.5202738514215,133.52764315090465,135.4289946840022,137.3803167032878,139.5916463996622,141.2655874686443,144.62196463857515,146.41369306997632,148.468178300217,151.34592919909636,157.34265069021097,165.1541025047562 +14,9.0,104.7530785973993,113.33065924242598,117.4510544811618,120.35840493665354,123.1989641982879,126.58485885186097,129.40888041271202,132.93003712665632,135.03390071634766,137.23614208258724,139.15927395465104,141.50460074884475,143.08504362909022,146.40342900087757,148.0269539150062,150.1243878140298,152.7958429829485,158.59329698428948,166.62153862880655 +15,9.5,103.57203781499156,113.44273248007399,118.27890197570389,121.69238302459603,124.6578778602505,128.31582157696565,131.1410070407288,134.32550677774066,136.5621298916832,139.0768699520524,140.9690370117141,143.44498266120098,144.9473827974333,148.2171156617473,149.6671348106046,151.83237327577623,154.29235416297362,159.8797147984688,168.08250287673025 +16,10.0,102.26635267618258,113.36263321672006,118.91137161592629,122.83446571313587,125.92572959247083,129.83177540503084,132.66625052026814,135.52080349718116,137.9032410174761,140.72063401533944,142.57491433471307,145.17132165881577,146.6090846608313,149.81906586194307,151.11372052040593,153.3667751384948,155.6396079423799,161.0115012586415,169.31230895006078 +17,10.5,100.86814819747937,113.0009813021404,119.19054798179586,123.57436971629872,126.78975878848163,130.89997183438288,133.76666684303635,136.33004797748424,138.848144434291,141.9368899953899,143.74221438388372,146.44214726377405,147.8266289064419,150.96532084222366,152.1461958080445,154.5022338552241,156.64174952437517,161.7982534907,170.08627055033156 +18,11.0,99.40954939538906,112.26839658611141,118.95851565327945,123.70181174811049,127.0372048418158,131.2876623633481,134.22431200073984,136.56736091115647,139.1877504826928,142.4950936151455,144.23624561946207,147.0159889981607,148.35649522142285,151.41192184334784,152.54404543715475,155.01338987900263,157.1029241121676,162.04956862053675,170.17970137907616 +19,11.5,97.9226812864187,111.07549891840931,118.05735921034392,123.00650852259687,126.45530714600606,130.76209849025287,133.82124198508487,136.04686299070443,138.71296950324623,142.16470059754784,143.822316501684,146.65137638406063,147.95516329293181,150.91491010607427,152.086754171371,154.6748836628689,156.82727690896516,161.57504377404416,169.36791513782785 +20,12.0,96.43966888707531,109.33290814881042,116.32916323295609,121.27817675378367,124.83130509458528,129.09053171342347,132.33951278777778,134.58267490863457,137.2147118365161,140.7151666655386,142.26573549078552,145.10683894355867,146.37911280812654,149.23032687116182,150.55380677432768,153.26135565986144,155.61895311797582,160.18427607711476,167.42622552812017 +21,12.5,94.98512421654779,107.003976164506,113.69757096450354,118.41002827192747,122.05664233834622,126.15324352687445,129.6629933342232,132.07493976401716,134.5798834834984,138.0197005673978,139.43971110086588,142.25112679357034,143.49716337136022,146.22865681100953,147.8288242118914,150.65221691943978,153.37015542404882,157.77385514020972,164.24925501771497 +22,13.0,93.55360730475182,104.26298300234714,110.41246030205642,114.70925537220802,118.43957955712146,122.28263540737271,126.08280428461832,128.76789028217695,131.07937308691402,134.3665231527588,135.6490520614777,138.39387243033343,139.62749425576794,142.23815832457782,144.21197225885794,147.14596087674715,150.32531643859787,154.58834051206497,160.20886113928754 +23,13.5,92.13216518428506,101.33694073660004,106.80526780610572,110.58654546603594,114.39258168800365,117.92413882737345,121.99187923285896,124.99178159500255,127.08006494991496,130.14760829659338,131.30646715603723,133.9549289449165,135.20062465168039,137.7015332424675,140.1075528922553,143.145851563348,146.8169262546779,150.95928422598524,155.79621019174166 +24,14.0,90.70784488774491,98.45286144153069,103.20743003714222,106.4525859648218,110.32811366808522,113.52318525933168,117.78315177284058,121.0768688343823,122.94884337565331,125.75492987387327,126.8246651679603,129.35414942838793,130.6470737494281,133.0614833952792,135.91986808911145,139.0551530108064,143.1774749653437,147.2182383152752,151.50246847398097 +25,14.5,89.26769344772902,95.83775719140525,99.95038355565688,102.71806427997612,106.65864043445886,109.52520617570256,113.84955549845878,117.3534071322048,119.05259266728113,121.5804617595702,122.61635488066315,125.01138697181612,126.39736073934174,128.7607106136138,132.05321982645447,135.27712925068678,139.73945266365027,143.6967548132396,147.81880228490925 +26,15.0,87.79875789683486,93.71864006048979,97.36556492214055,99.79366782290954,103.79662692421708,106.3756330489412,110.58402400360917,114.15165162035846,115.75819712795054,118.01617782865596,119.09424507756175,121.34649466626962,122.88200481175191,125.24191672807206,128.91191008131236,132.2150443145534,136.83534944265242,140.72638575318305,145.23637792343018 +27,15.5,86.29198314048875,92.25456181094458,95.68735191998316,97.97076890983128,102.03496372408381,104.39174887987171,108.26979368431192,111.7113165203246,113.33102953228013,115.34696639117526,116.55647185868567,118.66361161921816,120.411235447233,122.82299156720771,126.78578881393582,130.15831810972855,134.70613999765632,138.54664672638935,144.0981503000262 +28,16.0,84.7539055754321,91.33273295650643,94.7618872241707,97.06347947614523,101.1882440193084,103.37824278279435,106.75131214508548,109.90995241195552,111.63041654075495,113.42937349746501,114.84288059051845,116.80402100373696,118.81412328733444,121.32257695138667,125.50689791746642,128.94099404656657,133.22673743297307,137.02890755605847,144.15422877149447 +29,16.5,83.19495947123508,90.77240369880633,94.33825473258821,96.76659636205403,100.95148664477158,103.01165540037843,105.76332979257293,108.53456896469628,110.41417328532657,112.012859632935,113.67874395615715,115.49329200930262,117.79944926384947,120.4345026989278,124.79282726826847,128.28327141117984,132.18053945516507,135.95250162336953,145.00651130621097 +30,17.0,81.62557909746789,90.39282423947526,94.1655383431207,96.77491640776023,101.01971043535404,102.96852737529322,105.04059703341733,107.37217584799195,109.4401148979466,110.84688528299515,112.7893346386988,114.45699382539172,117.07599430857127,119.85259862815005,124.36116674270623,127.90534948968073,131.35094377079471,135.09676230950149,146.25689587255152 +31,17.5,80.05445967877233,90.04336974653901,94.03464130400567,96.834103646766,101.13876442012305,102.97930924309645,104.36207253982121,106.24528111971345,108.50642065524136,109.7226675188203,111.94571320739095,113.46643410990137,116.40107586466087,119.32172292098579,123.97657457108328,127.57406102049623,130.55744846866395,134.27780076543695,147.5736307774961 +32,18.0,78.48334026007677,89.69391525360278,93.90374426489065,96.89329088577176,101.25781840489205,102.9900911108997,103.6835480462251,105.11838639143495,107.57272641253611,108.59844975464547,111.1020917760831,112.47587439441104,115.72615742075047,118.79084721382155,123.5919823994603,127.24277255131172,129.76395316653318,133.45883922137241,148.8903656824407 +33,18.5,76.91222084138121,89.34446076066652,93.77284722577564,96.95247812477754,101.37687238966107,103.00087297870293,103.00502355262898,103.99149166315645,106.63903216983087,107.47423199047063,110.25847034477522,111.48531467892069,115.05123897684007,118.25997150665731,123.20739022783738,126.91148408212723,128.97045786440242,132.63987767730788,150.2071005873853 +34,19.0,75.34110142268565,88.99500626773029,93.64195018666061,97.0116653637833,101.49592637443006,103.01165484650618,102.32649905903287,102.86459693487797,105.70533792712563,106.3500142262958,109.41484891346738,110.49475496343035,114.37632053292967,117.72909579949305,122.8227980562144,126.58019561294272,128.17696256227163,131.82091613324332,151.52383549232988 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/stats.json b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/stats.json new file mode 100644 index 00000000..6255aa6f --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/liver_hu/stats.json @@ -0,0 +1 @@ +{"Male": {"count": 1025, "age_mean": 11.548176534148292, "age_median": 12.18841514, "age_std": 4.436705896930332}, "Female": {"count": 1107, "age_mean": 13.016982621581754, "age_median": 14.12575152, "age_std": 4.05686939082313}} \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/figure.html b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/figure.html new file mode 100644 index 00000000..60612311 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/figure.html @@ -0,0 +1,15 @@ + + +Spleen Quantile Figure + +
+
+ + \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/outlier_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/outlier_df.csv new file mode 100644 index 00000000..e3f5d09c --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/outlier_df.csv @@ -0,0 +1,221 @@ +,id,Age,biomarker,Sex +0,Z1360921,2.129141933,89.0,Male +1,Z1293964,2.875521309,154.0,Male +2,Z1765942,2.002243151,33.0,Male +3,Z1035555,2.77164003,101.0,Male +4,Z1728709,3.227267884,24.0,Male +5,Z1707457,3.333436073,29.0,Male +6,Z1009724,3.609809741,31.0,Male +7,Z1408177,4.288127854,142.0,Male +8,Z711883,4.494421613,133.0,Male +9,Z1957250,4.277808219,36.0,Male +10,Z1331617,5.075534627,165.0,Male +11,Z1199636,5.608542618,169.0,Male +12,Z1249475,5.662070015,175.0,Male +13,Z1117038,5.96956621,231.0,Male +14,Z1326208,5.310458524,227.0,Male +15,Z1405772,5.758105023,32.0,Male +16,Z602277,6.256333714,32.0,Male +17,Z1785035,6.51924277,180.0,Male +18,Z1106236,6.676740868,201.0,Male +19,Z1360855,6.45689688,35.0,Male +20,Z666001,6.661942542,41.0,Male +21,Z2000579,6.461518265,38.0,Male +22,Z1815276,6.046982496,35.0,Male +23,Z1108311,7.723085997,206.0,Male +24,Z1203688,7.813483637999999,42.0,Male +25,Z643482,7.840620244,44.0,Male +26,Z1780155,7.687452435,292.0,Male +27,Z1025660,7.886318492999999,192.0,Male +28,Z1074598,8.536708524,53.0,Male +29,Z664930,8.260371005,42.0,Male +30,Z1165775,8.739332192,54.0,Male +31,Z1196783,8.621255708,193.0,Male +32,Z481927,9.956046423,322.0,Male +33,Z511354,9.332218417,210.0,Male +34,Z710167,9.569821157,60.0,Male +35,Z491409,10.49192352,305.0,Male +36,Z1078800,10.00513318,225.0,Male +37,Z398051,10.13892123,323.0,Male +38,Z633602,10.86159627,71.0,Male +39,Z415727,10.90667618,290.0,Male +40,Z1721400,10.0831583,67.0,Male +41,Z1852236,10.91890982,80.0,Male +42,Z1361959,10.48452435,42.0,Male +43,Z481254,10.70121956,228.0,Male +44,Z619680,10.4574296,63.0,Male +45,Z492491,11.04888889,67.0,Male +46,Z1271507,11.77483638,74.0,Male +47,Z454451,11.40853311,239.0,Male +48,Z484963,11.94214612,256.0,Male +49,Z531537,11.38276256,77.0,Male +50,Z931185,11.83516362,93.0,Male +51,Z641678,11.44369482,72.0,Male +52,Z980720,12.78916286,306.0,Male +53,Z1170798,12.21491819,77.0,Male +54,Z1267669,12.87658866,80.0,Male +55,Z676455,12.20401826,331.0,Male +56,Z954661,12.28721651,389.0,Male +57,Z546432,12.65626903,321.0,Male +58,Z711174,13.69607877,97.0,Male +59,Z429948,13.79716895,94.0,Male +60,Z532415,13.59724505,388.0,Male +61,Z1126993,13.21918569,386.0,Male +62,Z668727,13.36379947,105.0,Male +63,Z1130308,13.23271689,494.0,Male +64,Z1312495,13.16114916,436.0,Male +65,Z493039,13.9770586,106.0,Male +66,Z328106,14.72508752,483.0,Male +67,Z542803,14.98328387,467.0,Male +68,Z559736,14.91927321,73.0,Male +69,Z1250450,14.31995814,383.0,Male +70,Z896053,14.67368721,104.0,Male +71,Z860899,14.05202626,415.0,Male +72,Z982837,14.66577435,112.0,Male +73,Z599292,14.08907534,448.0,Male +74,Z491503,14.53159817,65.0,Male +75,Z1727356,14.04685312,91.0,Male +76,Z445300,14.30250761,396.0,Male +77,Z415848,15.69758562,446.0,Male +78,Z944277,15.75001903,549.0,Male +79,Z937153,15.69805556,458.0,Male +80,Z899390,15.63802131,62.0,Male +81,Z1894082,15.15328957,102.0,Male +82,Z418158,15.44452245,79.0,Male +83,Z1325498,15.83057268,440.0,Male +84,Z934271,15.61552131,558.0,Male +85,Z918969,15.82489155,454.0,Male +86,Z893017,16.74738014,524.0,Male +87,Z632911,16.64519977,114.0,Male +88,Z687098,16.58330289,108.0,Male +89,Z415539,16.70070396,117.0,Male +90,Z419872,16.09553843,112.0,Male +91,Z829322,16.6050723,19.0,Male +92,Z889996,16.82732686,532.0,Male +93,Z934453,16.27002093,442.0,Male +94,Z873156,16.74210426,108.0,Male +95,Z414897,17.16091705,67.0,Male +96,Z955265,17.21517694,106.0,Male +97,Z502531,17.44821537,490.0,Male +98,Z847490,17.92479833,460.0,Male +99,Z645173,17.79601027,106.0,Male +100,Z413747,17.72289193,487.0,Male +101,Z1232896,18.71944064,473.0,Male +102,Z890238,18.43711568,916.0,Male +103,Z926746,18.58814688,120.0,Male +104,Z909622,18.17194064,95.0,Male +105,Z1215964,2.720947489,105.0,Female +106,Z1400151,2.593476027,36.0,Female +107,Z1238640,2.58304414,86.0,Female +108,Z1295431,3.4060083710000004,29.0,Female +109,Z1013953,3.519524353,38.0,Female +110,Z1437460,4.0500228310000015,121.0,Female +111,Z1166481,5.352011035,26.0,Female +112,Z1421484,5.165479452,138.0,Female +113,Z700636,5.084847793,43.0,Female +114,Z1082812,6.99946347,48.0,Female +115,Z1055664,6.711778919,159.0,Female +116,Z685766,6.176482116,44.0,Female +117,Z572313,7.170195967000001,192.0,Female +118,Z1303521,7.844876332,443.0,Female +119,Z1463828,7.22136035,191.0,Female +120,Z990913,7.651432647999999,53.0,Female +121,Z714338,8.802479072,51.0,Female +122,Z652905,8.959419711,253.0,Female +123,Z654664,8.306915906,201.0,Female +124,Z1232902,8.60510274,232.0,Female +125,Z486791,8.43391933,365.0,Female +126,Z1421847,9.058987823,49.0,Female +127,Z1333302,9.676830289,266.0,Female +128,Z1336778,9.806716134,383.0,Female +129,Z483463,9.48004376,234.0,Female +130,Z1218699,9.190214992,54.0,Female +131,Z1070025,9.516337519,63.0,Female +132,Z1743266,9.106820776,37.0,Female +133,Z469954,10.5791876,58.0,Female +134,Z987763,10.59931507,66.0,Female +135,Z1264379,10.79133181,80.0,Female +136,Z1971874,10.90300419,264.0,Female +137,Z675064,10.25319254,74.0,Female +138,Z546239,10.06572108,67.0,Female +139,Z513177,11.54483828,293.0,Female +140,Z425795,11.73637557,87.0,Female +141,Z594103,11.64054033,311.0,Female +142,Z714324,11.52594939,285.0,Female +143,Z1336461,12.50935693,88.0,Female +144,Z460805,12.94162671,82.0,Female +145,Z888374,12.96414764,395.0,Female +146,Z652918,12.67900495,322.0,Female +147,Z557233,12.93578006,102.0,Female +148,Z692815,12.68414003,81.0,Female +149,Z1289151,12.66704148,81.0,Female +150,Z1392505,12.32847603,314.0,Female +151,Z604506,12.62446157,71.0,Female +152,Z555174,13.6591191,350.0,Female +153,Z1017001,13.78103881,330.0,Female +154,Z926698,13.03035198,104.0,Female +155,Z573707,13.41151446,72.0,Female +156,Z449627,13.77178272,349.0,Female +157,Z1140827,13.45715373,307.0,Female +158,Z455791,13.37635654,338.0,Female +159,Z1801393,13.49041667,68.0,Female +160,Z612946,13.05460807,304.0,Female +161,Z938166,13.54467846,308.0,Female +162,Z1279217,14.19617199,323.0,Female +163,Z453391,14.59791476,108.0,Female +164,Z380903,14.74514269,391.0,Female +165,Z940808,14.32383181,88.0,Female +166,Z895749,14.49554224,319.0,Female +167,Z240479,14.87143075,313.0,Female +168,Z1087586,14.53695967,314.0,Female +169,Z481159,14.15376332,96.0,Female +170,Z472801,14.52788813,108.0,Female +171,Z921775,14.95254566,318.0,Female +172,Z874467,14.82894787,307.0,Female +173,Z537287,14.62665335,107.0,Female +174,Z531344,14.57640601,108.0,Female +175,Z497573,14.99928082,107.0,Female +176,Z510810,15.66777968,405.0,Female +177,Z565691,15.34042047,96.0,Female +178,Z936324,15.24474696,88.0,Female +179,Z944864,15.93137557,99.0,Female +180,Z538768,15.17634893,105.0,Female +181,Z903915,15.25496005,78.0,Female +182,Z1284613,15.66714802,101.0,Female +183,Z358008,15.47119292,315.0,Female +184,Z1230335,15.58646689,74.0,Female +185,Z867391,15.51304795,346.0,Female +186,Z434815,15.29890221,105.0,Female +187,Z1304275,16.57655441,342.0,Female +188,Z886533,16.87914764,102.0,Female +189,Z867999,16.17898782,361.0,Female +190,Z448966,16.16101979,336.0,Female +191,Z949881,16.45990487,347.0,Female +192,Z850232,16.88156583,366.0,Female +193,Z1311911,16.28472793,380.0,Female +194,Z448762,16.39923706,102.0,Female +195,Z898890,16.17549848,84.0,Female +196,Z1923654,16.60918189,93.0,Female +197,Z686701,16.75907154,367.0,Female +198,Z1243452,16.45521309,388.0,Female +199,Z1412374,16.00880898,90.0,Female +200,Z1261728,16.43119863,339.0,Female +201,Z820362,16.97808219,389.0,Female +202,Z826839,17.81957572,387.0,Female +203,Z923266,17.42976979,596.0,Female +204,Z910858,17.48166667,97.0,Female +205,Z298760,17.03851408,358.0,Female +206,Z1269305,17.03824962,67.0,Female +207,Z1173810,17.15178463,491.0,Female +208,Z926083,17.32836948,414.0,Female +209,Z626059,17.26643455,382.0,Female +210,Z948820,17.13434551,98.0,Female +211,Z864960,17.2731621,101.0,Female +212,Z1033402,17.36817732,91.0,Female +213,Z815269,17.95001142,91.0,Female +214,Z564983,18.10776826,99.0,Female +215,Z911426,18.81813166,453.0,Female +216,Z937373,18.94304033,92.0,Female +217,Z416083,18.23755137,84.0,Female +218,Z1225663,18.92503425,447.0,Female +219,Z897356,18.48884703,98.0,Female diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_df.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_df.csv new file mode 100644 index 00000000..19bcf6e1 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_df.csv @@ -0,0 +1,36 @@ +,Age,Male 0.05,Male 0.25,Male 0.50,Male 0.75,Male 0.95,Female 0.05,Female 0.25,Female 0.50,Female 0.75,Female 0.95 +0,2.0,37.446465713565864,45.14247499825288,54.60234256495102,66.56179758390321,86.4857045595883,34.32570250908793,50.775684887162534,59.0568111316117,64.23090468028634,75.86239047129594 +1,2.5,37.20979151962006,48.27079013637609,59.3824123545094,72.97029688231531,95.89054006231477,35.73628846164803,52.88768350118045,61.93957216235206,68.90939420460421,84.55607939147268 +2,3.0,36.97311732567425,51.39910527449929,64.16248214406777,79.3787961807274,105.29537556504123,37.14687441420811,54.999682115198446,64.82233319309232,73.5878837289221,93.24976831164948 +3,3.5,36.75110594654424,54.529498893112105,68.93892772347415,85.7830285441274,114.68504415292153,38.557460366768204,57.111680729216395,67.70509422383265,78.26637325323993,101.94345723182612 +4,4.0,36.617071456308985,57.67236339466245,73.69362804196855,92.16165929745485,123.98371125172477,39.968046319328295,59.223679343234345,70.58785525457293,82.94486277755782,110.63714615200288 +5,4.5,36.65899074386324,60.84016966208792,78.40483783863898,98.48908683063718,133.10037537237383,41.37871404932986,61.335777794035295,73.47076680099711,87.62350616701258,119.33093318140186 +6,5.0,36.9648406981018,64.04538857832608,83.0508118525735,104.73970953360181,141.94403502579172,42.806882151806114,63.46924131639504,76.38588870377228,92.335076695763,128.0457155843659 +7,5.5,37.62259820791938,67.30049102631448,87.60980482286007,110.88792579627616,150.42368872290132,44.30864995160592,65.69255794344137,79.43647472204228,97.18512584771948,136.84879628736982 +8,6.0,38.72024016221077,70.61794788899068,92.06007148858679,116.90813400858774,158.4483349746256,45.94535052983222,68.08060526241309,82.7354116187195,102.28905247555376,145.81375720711358 +9,6.5,40.34574344987072,74.01023004929223,96.37986658884161,122.7747325604639,165.9269722918874,47.778316967587955,70.70826086054896,86.39558615671629,107.76225543193746,155.0141802602971 +10,7.0,42.587084959794005,77.4898083901567,100.54744486271258,128.4621198418322,172.76859918560982,49.868882345976075,73.65040232508788,90.52988509894499,113.72013356954237,164.5236473636202 +11,7.5,45.53224158087538,81.06915379452164,104.54106104928775,133.94469424261996,178.88221416671564,52.27837974609952,76.98190724326861,95.25119520831798,120.27808574104014,174.41574043378293 +12,8.0,49.269190202009575,84.76073714532463,108.3389698876551,139.19685415275464,184.17681574612783,55.06814224906124,80.77765320232992,100.6724032477476,127.55151079910257,184.76404138748507 +13,8.5,53.840056574305805,88.59833574956157,111.98051379436488,144.2601303961384,188.70272836052013,58.299502935964206,85.11251778951069,106.90639598014631,135.65580759640127,195.64213214142666 +14,9.0,59.10356189773091,92.7009526104618,115.74938589581625,149.44458353257207,193.07558014956922,62.033794887911355,90.06137859204978,114.0660601684264,144.70637498560805,207.12359461230758 +15,9.5,64.87257623446615,97.20889715531301,119.99036699587046,155.1274065558312,198.0523251787025,66.33235118600564,95.69911319718585,122.26428257550019,154.81861181939453,219.28201071682773 +16,10.0,70.95996964669284,102.26247881140293,125.04823789838893,161.68579245969127,204.38991751334763,71.25650491134999,102.10059919215782,131.61394996428012,166.10791695043255,232.19096237168705 +17,10.5,77.17861219659218,108.0020070060192,131.26777940723295,169.49693423792792,212.84531121893193,76.80464885599471,109.2675781641138,142.0975141517623,178.55558217861469,245.80010385184784 +18,11.0,83.34137394634553,114.56779116644955,138.99377232626392,178.93802488431658,224.175460360883,82.72341465577945,116.90924769983938,153.1756871712779,191.60647109271778,259.56337886532225 +19,11.5,89.26112495813406,122.1001407199817,148.5709974593431,190.38625739263287,239.13731900462827,88.69649365749112,124.66166938602944,164.17874611024178,204.57134022873961,272.8108034783847 +20,12.0,94.75073529413912,130.73936509390327,160.34423561033185,204.21882475665225,258.4878412155951,94.40757720791675,132.16090480937893,174.43696805606902,216.76094612267795,284.87239375730985 +21,12.5,99.65530801848915,140.50635414717934,174.4119582892699,220.54157842530844,282.5368530683563,99.54035665384323,139.0430155565827,183.2806300961745,227.4860453105306,295.07816576837195 +22,13.0,103.94887820310137,150.9443194654841,189.88739983091037,238.37500366816715,309.8056686740646,103.77852334205758,144.94406321433567,190.04000931797302,236.0573943282953,302.7581355778457 +23,13.5,107.6377139218403,161.47705306616916,205.63748527618486,256.46824420995256,338.3684741530182,106.80576861934678,149.50010936933273,194.0453828088796,241.78574971196988,307.2423192520056 +24,14.0,110.7280832485703,171.5283469665859,220.5291396660247,273.5704437753884,366.29945562551507,108.30578383249775,152.3472156082688,194.62702765630925,243.98186799755206,307.86073285712615 +25,14.5,113.22625425715586,180.52199318408603,233.42928804136164,288.4307460891987,391.6727992118533,108.16575150121315,153.32124886911572,191.8070938839188,242.63435762086843,305.11742564731844 +26,15.0,115.1384950214614,187.88178373602096,243.204855443127,299.7982948761075,412.5626910323309,107.0868188368583,153.05729749495285,188.37522326033317,240.4432346170606,304.2125796280406 +27,15.5,116.49418152680795,193.21963128885932,249.08901506929047,306.82836031514205,427.6313389224281,105.8242787408669,152.26790168693773,187.11311229570506,240.11779496077702,310.11584594437875 +28,16.0,117.41512140434284,196.8999311055374,251.77993274597395,310.3007184025434,437.89303757835324,104.53604218328358,151.1761876734325,188.0031847213308,241.69304678537802,322.17861954858523 +29,16.5,118.04623019666992,199.47519909810867,252.34202245633742,311.4012715888559,444.95010341149725,103.23067465130565,149.88292818960062,190.3280460737923,244.53542626390217,338.3477313447039 +30,17.0,118.53242344639312,201.49795117862658,251.8396981835409,311.31592232462395,450.4048528332505,101.9167416321304,148.48889597060537,193.3703018896718,248.0113695693879,356.5700122367787 +31,17.5,118.99446410568234,203.42861727380225,251.1599715802177,311.03292265196797,455.59254951977204,100.6013810317556,147.07806829574955,196.53212344945425,251.59290684603388,375.1344879781796 +32,18.0,119.45650476497154,205.35928336897788,250.4802449768945,310.749922979312,460.7802462062935,99.28602043138079,145.66724062089375,199.6939450092367,255.1744441226799,393.69896371958043 +33,18.5,119.91854542426077,207.28994946415352,249.80051837357126,310.466923306656,465.9679428928151,97.97065983100599,144.25641294603793,202.85576656901912,258.7559813993259,412.2634394609813 +34,19.0,120.38058608354997,209.22061555932922,249.12079177024813,310.183923634,471.1556395793365,96.65529923063119,142.84558527118213,206.0175881288016,262.33751867597186,430.82791520238214 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_f_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_f_fine.csv new file mode 100644 index 00000000..8139e658 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_f_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,34.32570250908793,39.656656645608464,42.96484793866642,47.896139761465065,50.775684887162534,53.2544393880541,54.149166677765905,57.431778507709204,57.40267298858901,59.0568111316117,60.71792307977692,61.40057862165231,63.81962286495193,63.55699378099049,64.23090334348817,63.48631488825757,66.97796799176334,78.52370651589447,75.86239047129587 +1,2.5,35.73628846164803,41.60867198862412,45.2586628308193,49.92038034582015,52.88768350118045,55.437153490307885,56.417745850331414,59.61436610420874,59.946882063785125,61.93957216235206,63.77003954579162,64.99478264285456,67.4261401960429,67.88794307948041,68.90939304664371,68.80957771572331,73.10314095835793,84.79641233519597,84.55607939147261 +2,3.0,37.14687441420811,43.56068733163977,47.552477722972185,51.944620930175304,54.999682115198446,57.6198675925617,58.686325022896916,61.79695370070822,62.49109113898123,64.82233319309232,66.82215601180634,68.5889866640568,71.0326575271338,72.21889237797038,73.58788274979926,74.13284054318898,79.22831392495256,91.06911815449757,93.24976831164943 +3,3.5,38.557460366768204,45.51270267465543,49.84629261512508,53.96886151453041,57.111680729216395,59.80258169481545,60.9549041954624,63.9795412972077,65.03530021417731,67.70509422383265,69.87427247782108,72.18319068525902,74.6391748582247,76.5498416764603,78.26637245295477,79.45610337065467,85.3534868915472,97.34182397379918,101.94345723182612 +4,4.0,39.968046319328295,47.46471801767109,52.14010750727794,55.99310209888552,59.223679343234345,61.98529579706925,63.223483368027885,66.16212889370718,67.57950928937342,70.58785525457293,72.92638894383578,75.77739470646125,78.24569218931562,80.88079097495026,82.94486215611033,84.77936619812037,91.47865985814184,103.61452979310076,110.63714615200293 +5,4.5,41.37871404932986,49.41681316718326,54.43399701478904,58.017437811085436,61.335777794035295,64.16811877466473,65.4921997498787,68.34486001333761,70.12387020618725,73.47076680099711,75.97866215137415,79.37174326572591,81.85238036605323,85.2118999613134,87.62350572439702,90.10278499847334,97.60397419728586,109.88740940728664,119.33093318140192 +6,5.0,42.806882151806114,51.38598690694971,56.74385420896053,60.06213088207319,63.46924131639504,66.37424107538767,67.79027891878621,70.55830508298894,72.70072522919597,76.38588870377228,79.0644780449771,82.99702297035184,85.49562951118608,89.57718215254584,92.335076430752,95.45958199670005,103.75954226200352,116.19748112671662,128.045715584366 +7,5.5,44.30864995160592,53.426986493579975,59.12086522553542,62.192439013383705,65.69255794344137,68.67835118365593,70.2118464444744,72.90092097048512,75.41423770816458,79.43647472204228,82.29136130987945,86.75238693117002,89.29263973835491,94.08618342967704,97.18512575515106,100.95675459346118,110.0423456211897,122.66396824203287,136.84879628736996 +8,6.0,45.94535052983222,55.59966679946056,61.620991583183304,64.47970808861925,68.08060526241309,71.1621056057573,72.85980929092705,75.48035002403027,78.3782888563929,82.7354116187195,85.77686808883087,90.74623869500738,93.37154528258974,98.85866969762273,102.28905254596432,106.71128245420222,116.55841368690145,129.41721691647405,145.8137572071137 +9,6.5,47.778316967587955,57.96388269697796,64.30019480057364,66.9952839913821,70.70826086054896,73.90716084797954,75.83707442212808,78.40423459182844,81.70675988718085,86.39558615671629,89.63855452458083,95.08698180869075,97.86048037892057,104.01440686129851,107.76225565156192,112.84014524436847,123.41377587119585,136.5875733132786,155.0141802602972 +10,7.0,49.868882345976075,60.579489058518675,67.21443639637597,69.81051260527451,73.65040232508788,76.99517341661043,79.24654880206134,81.78021702208369,85.51353201382832,90.52988509894499,93.99397675987892,99.88301981904702,102.88757926237739,109.67316082562012,113.72013392031386,119.46032262940527,130.71446158612994,144.3053835956852,164.52364736362026 +11,7.5,52.27837974609952,63.506340756469214,70.41967788925987,72.99673981389881,76.98190724326861,80.50779981793771,83.19113939471069,85.71593966300009,89.91248644963517,95.25119520831798,98.96069093747464,105.24275627290311,108.58097616799013,115.95469749550311,120.27808620059025,126.68879427475808,138.56650024376094,152.7009939269324,174.41574043378287 +12,8.0,55.06814224906124,66.80429266321606,73.97188079789484,76.62531150085724,80.77765320232992,84.52669655824916,87.77375316405995,90.31904486278167,95.01750440790123,100.6724032477476,104.6562532001176,111.27459471708586,115.0688053307888,122.97878277586318,127.55151134076121,134.64253984587228,147.07592125614573,161.90475047025865,184.76404138748487 +13,8.5,58.299502935964206,70.53319965114582,77.92700664095035,80.76757354975217,85.11251778951069,89.13352014383258,93.09729707409305,95.6971749696326,100.94246710192655,106.90639598014631,111.19821969055734,118.08693869842222,122.47920098580353,130.8651825716161,135.6558081891969,143.43853900819323,156.34875403534164,172.0469993889028,195.64213214142626 +14,9.0,62.033794887911355,74.75291659264491,82.34101693709603,85.49487184418582,90.06137859204978,94.40992708097579,99.26467808879383,101.95797233175686,107.80125574501083,114.0660601684264,118.70414655154345,125.78819176373906,130.94029736806425,139.73366278767742,144.70637559426729,153.19377142716633,166.4910279934056,183.25808684610328,207.12359461230693 +15,9.5,66.33235118600564,79.5232983600998,87.26987320500129,90.87855226776044,95.69911319718585,100.43757387596644,106.37880317214615,109.20907929735851,115.70775155045405,122.26428257550019,127.29158992582535,134.4867574598632,140.58022871260084,149.70398932896282,154.8186124043425,164.02521676823693,177.60877254239463,195.66835900509852,219.2820107168267 +16,10.0,71.25650491134999,84.9041998258971,92.76953696333577,96.98996070407838,102.10059919215782,107.2981170350924,114.54257928813391,117.55813821464172,124.77583573155611,131.61394996428012,137.07810595615274,144.29103933362157,151.52712925444342,160.895928100388,166.1079174677927,176.04985469685045,189.80801709436597,209.40816202912734,232.19096237168557 +17,10.5,76.80464885599471,90.89112064405296,98.83238255656867,103.82811928601461,109.2675781641138,114.99101886997606,123.7493929230148,127.00105421331872,134.9939711042859,142.0975141517623,148.04323633207667,155.18416403911888,163.7563751889818,173.2863549414581,178.5555825838109,189.2447742639923,203.05209195816315,224.42464899265386,245.80010385184588 +18,11.0,82.72341465577945,97.22213959510246,105.19643563236835,111.10275514353486,116.90924769983938,123.18696491357846,133.55454865214207,137.08678354913494,145.8489468952885,153.1756871712779,159.61446493035436,166.6481506595709,176.63231055304513,186.28058543003633,191.60647135488207,203.0275020628081,216.733531029776,239.93220061504505,259.56337886531975 +19,11.5,88.69649365749112,103.57098024121039,111.53613466420282,118.45127165587776,124.66166938602944,131.47444650419536,143.40383057314295,147.25254525934395,156.70213393387803,164.17874611024178,171.08126117454478,178.03974138547136,189.36652134382257,199.14104507857547,204.57134033431427,216.6756740719837,230.10216910198096,254.96200452689354,272.81080347838184 +20,12.0,94.40757720791675,109.61136614454156,117.52591812554024,125.5110722022817,132.16090480937893,139.4419549801224,152.74302278364453,156.93555838119948,166.91490304936875,174.43696805606902,181.73309448820677,188.7156784073139,201.17059355850316,211.13015939952834,216.76094607541552,229.4669262702048,242.40784096755456,268.54524835879204,284.87239375730684 +21,12.5,99.54035665384323,115.0170208672607,122.84022448984861,131.91956016198517,139.0430155565827,146.67798167965537,161.017909381274,165.57304195195513,175.84862507107465,183.2806300961745,190.8594342948991,198.0327039155921,211.25611319427588,221.51035390534756,227.48604513149385,240.678894636157,252.90038141927286,279.71311974133306,295.0781657683692 +22,13.0,103.77852334205758,119.4616679715326,127.153492230596,137.31413891422673,144.94406321433567,152.77101794108992,167.67427446365846,172.60221500886456,182.86467082831007,190.04000931797302,197.74975001818063,205.34756010079965,218.8346662483298,229.54405410848597,236.05739405585712,249.58921514852614,260.8296252499125,287.4968063051094,302.75813557784363 +23,13.5,106.80576861934678,122.6190310195221,130.14015982125048,141.33221183824486,149.50010936933273,157.3095551027218,172.15790212842515,177.46029658918144,187.32441115038907,194.0453828088796,201.6935110816103,210.0169891534303,223.117838717854,234.49368552139617,241.7857494018134,255.47552378599795,265.4454072522499,290.9274956807135,307.2423192520048 +24,14.0,108.30578383249775,124.16283357339395,131.47466573528015,143.61118231327808,152.3472156082688,159.8820845028467,173.91457647320115,179.58450573015935,188.58921686662578,194.62702765630925,201.98018690874693,211.39773326397756,223.31721660003765,235.62167365653113,243.98186772267067,257.6154565272581,265.99756221906125,289.03637549873815,307.8607328571273 +25,14.5,108.16575150121315,123.97478109529719,131.1123228873569,144.04238924456098,153.32124886911572,160.3695075254159,172.84874548242092,178.87950212629858,186.61682377436063,191.8070938839188,198.64267021248344,209.4481056642479,219.44851720546893,232.89671105304691,242.63435745562714,256.0392042359596,262.74979655801326,282.27666363166645,305.1174256473221 +26,15.0,107.0868188368583,122.76850664731808,130.13194195696792,143.5329136413132,153.05729749495285,159.8223657370029,170.6995126877484,177.11970810108602,183.7504275430396,188.37522326033317,194.6875468630496,206.53270375129892,214.7439830983337,229.11255835691392,240.443234573442,253.78717731562423,260.0213031363338,276.78969891954375,304.2125796280463 +27,15.5,105.8242787408669,121.31604798984084,129.63569191809643,143.04108869951915,152.26790168693773,159.34693603108,169.25191903730212,176.12388144849197,182.35443047478878,187.11311229570506,193.1093077223154,205.03998480189134,212.43390337807702,227.08420372952594,240.11779498826016,253.89029722918255,262.0124549923924,278.5101174207798,310.11584594438494 +28,16.0,104.53604218328358,119.79113407650475,129.695677157894,142.71451425823875,151.1761876734325,159.10279642609228,168.64009959778994,176.01835921543415,182.49260153035016,188.0031847213308,193.88037046137632,205.04756144634715,212.52422803158433,226.88647728715668,241.69304683420899,256.3313101373301,268.3928593895656,286.85762309967953,322.1786195485908 +29,16.5,103.23067465130565,118.21791665926268,130.12648591680423,142.4981068173008,149.88292818960062,159.01285022172797,168.58546296556722,176.50637326206717,183.65355133511966,190.3280460737923,196.2176344536339,206.05533515337845,214.21482226760085,227.9091696347999,244.53542630091164,260.3309183752038,277.69943214748207,299.6231868970217,338.34773134470794 +30,17.0,101.9167416321304,116.6205474900677,130.74270643527083,142.3367828765341,148.48889597060537,159.00000071767542,168.80941773698922,177.29115544854562,185.32589051449327,193.3703018896718,199.33799907248988,207.56320739169755,216.705551294872,229.54207137744942,248.01136957799127,265.1098242779406,288.4690890857701,314.59777975358514,356.5700122367808 +31,17.5,100.6013810317556,115.01915302888054,131.38982891366348,142.18463951912926,147.07806829574955,159.00000066367483,169.0798044090187,178.12539899166498,187.0834612563009,196.53212344945425,202.58888046244556,209.15442938523128,219.32963612068562,231.2766746859333,251.59290682346705,270.0186131248213,299.48259338745345,329.9405441200188,375.13448797817966 +32,18.0,99.28602043138079,113.4177585676934,132.03695139205615,142.03249616172442,145.66724062089375,159.0000006096742,169.35019108104817,178.95964253478434,188.8410319981085,199.6939450092367,205.83976185240124,210.74565137876502,221.95372094649926,233.01127799441718,255.17444406894282,274.92740197170195,310.4960976891367,345.2833084864524,393.69896371957844 +33,18.5,97.97065983100599,111.81636410650623,132.6840738704488,141.8803528043196,144.25641294603793,159.00000055567358,169.62057775307758,179.79388607790366,190.59860273991612,202.85576656901912,209.09064324235692,212.3368733722987,224.57780577231284,234.74588130290104,258.7559813144186,279.8361908185827,321.5096019908201,360.626072852886,412.2634394609773 +34,19.0,96.65529923063119,110.21496964531909,133.33119634884144,141.72820944691472,142.84558527118213,159.00000050167301,169.8909644251071,180.62812962102305,192.35617348172372,206.0175881288016,212.34152463231263,213.9280953658325,227.20189059812648,236.48048461138495,262.33751855989436,284.74497966546323,332.5231062925033,375.9688372193197,430.8279152023761 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_m_fine.csv b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_m_fine.csv new file mode 100644 index 00000000..2f3b133c --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/results_m_fine.csv @@ -0,0 +1,36 @@ +,Age,0.05,0.10,0.15,0.20,0.25,0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95 +0,2.0,37.446465713565864,36.536410877702004,38.75084718141828,42.48483165336253,45.14247499825288,48.638376732944046,49.24258515886808,51.27517600105776,52.824742160531166,54.60234256495102,58.00531647625988,58.78237818194983,59.56812455217613,64.4693971888561,66.5617975839098,69.01277349173253,73.63792062186351,79.3308544856746,86.48570455959172 +1,2.5,37.20979151962006,37.85800359454688,41.01142276623037,45.133088759422265,48.27079013637609,51.98631832167586,52.90827599202345,55.25500954111442,57.11435652859921,59.3824123545094,62.85985939206354,64.27955978079409,65.46599783101908,70.47792764053645,72.97029688231265,76.46121716075349,81.4443215580024,87.78337577334328,95.89054006231599 +2,3.0,36.97311732567425,39.17959631139176,43.27199835104245,47.78134586548199,51.39910527449929,55.334259910407646,56.57396682517881,59.23484308117106,61.40397089666723,64.16248214406777,67.7144023078672,69.77674137963832,71.36387110986202,76.48645809221675,79.37879618071553,83.90966082977444,89.25072249414127,96.2358970610119,105.29537556504022 +3,3.5,36.75110594654424,40.51077134951952,45.53757393565802,50.43241031039203,54.529498893112105,58.6837655366738,60.24032746358846,63.213766942539394,65.69140779405618,68.93892772347415,72.56459253008782,75.26772501505748,77.25518819617022,82.48855575226405,85.7830285441064,91.34968174519157,97.04863495607584,104.67599511691749,114.68504415291858 +4,4.0,36.617071456308985,41.89944031534453,47.833149519094526,53.10031878840393,57.67236339466245,62.04265538814609,63.91070693352381,67.18723273177787,69.96577986737051,73.69362804196855,77.38866659081016,80.721520869926,83.10716812726986,88.45205666251314,92.16165929742539,98.7391661389857,104.7956165727845,113.0415537822449,123.98371125172157 +5,4.5,36.65899074386324,43.403097136564014,50.188725100172874,55.80191533261954,60.84016966208792,65.42031369003062,67.58912406651055,71.14978237675653,74.2140222925356,78.40483783863898,82.16050832853593,86.10094116369326,88.88047374795244,94.33836407316586,98.48908683060054,106.02757748953384,112.4407364990413,121.25803366641591,133.10037537237383 +6,5.0,36.9648406981018,45.07923574087526,52.63430067771401,58.55404397614073,64.04538857832608,68.82612466753359,71.27959769407441,75.09595780534565,78.42307024547692,83.0508118525735,86.85400158176685,91.36879811580869,94.53576790300943,100.10888123442409,104.73970953355997,113.16437927521301,119.93306388962037,129.2508953788523,141.94403502580008 +7,5.5,37.62259820791938,46.98535005597548,55.199876250538864,61.37354875206936,67.30049102631448,72.26947254586108,74.9861466477411,79.02030094541519,82.57985890211977,87.60980482286007,91.44303018900465,96.48790394572163,100.03371343723227,105.72501139648953,110.88792579623177,120.09903497440017,127.22166789929574,136.94559952897595,150.42368872292488 +8,6.0,38.72024016221077,49.178934009561964,57.915451817468345,64.27727369350731,70.61794788899068,75.75974155021925,78.71278975903628,82.91735372483538,86.6713234383896,92.06007148858679,95.90147798875104,101.42107087288146,105.33497319541243,111.14815780956408,116.90813400854405,126.7810080654724,134.25561768284157,144.2676067262086,158.44833497467292 +9,6.5,40.34574344987072,51.71748152933191,60.811027377323406,67.2820628335564,74.01023004929223,79.30631590581422,82.46354585948568,86.7816580714763,90.68439903021176,96.37986658884161,100.20322881950773,106.13111111673754,110.40021002234143,116.33972372384953,122.77473256042487,133.15976202680662,140.9839823950319,151.14237757997205,165.9269722919688 +10,7.0,42.587084959794005,54.658486542982615,63.916602928924966,70.40476020531851,77.4898083901567,82.91857983785218,86.242433780615,90.60775591320807,94.60602085351172,100.54744486271258,104.32216651977645,110.58083689673933,115.19008676281067,121.26111238954775,128.46211984180232,139.18476033677996,147.35583119064077,157.49537269968818,172.76859918573723 +11,7.5,45.53224158087538,58.05944297821129,67.26217847109396,73.66220984189549,81.06915379452164,86.60591757153918,90.05347235394996,94.39018917790088,98.42312408421483,104.54106104928775,108.23217492805895,114.73306043233612,119.66526626161166,125.87372705686046,133.94469424260458,144.80546647376931,153.32023322444238,163.2520526947787,178.88221416690286 +12,8.0,49.269190202009575,61.977844762715186,70.8777540026513,77.07125577638921,84.76073714532463,90.37771333208138,93.90068041101622,98.12349979342474,102.12264389824644,108.3389698876551,111.90713788285687,118.55059394297733,123.78641136353582,130.13897097598957,139.1968541527596,149.97134391615174,158.82625765121065,168.33787817466546,184.17681574639025 +13,8.5,53.840056574305805,66.4476402171782,74.80300703776196,80.66969199872067,88.59833574956157,94.27065271062935,97.81992613161175,101.84352610185371,105.74118470940641,111.98051379436488,115.39034526293854,122.07447663084332,127.5950447331094,134.09643639863538,144.26013039616905,154.7178730934046,163.91341044934487,172.8011141418718,188.70272836087318 +14,9.0,59.10356189773091,71.40859523423067,79.11632515196698,84.57911232608728,92.7009526104618,98.43062676211122,101.97447508862327,105.7512921020772,109.51402788299207,115.74938589581625,119.00271110813837,125.65865562903836,131.45612831379762,138.09847158249232,149.44458353262837,159.42460223940668,168.9829448917444,177.18124317132703,193.0755801500216 +15,9.5,64.87257623446615,76.77693009948965,83.9057734361515,88.94206053250568,97.20889715531301,103.03082790739951,106.55944220320974,110.08911820718849,113.72612402217518,119.99036699587046,123.13455549855733,129.73530505339775,135.81548386880036,142.57561378675337,155.12740655590636,164.5570965391372,174.52655107493374,182.14055223106203,198.05232517925452 +16,10.0,70.95996964669284,82.4688650985721,89.25941698120063,93.90108039199248,102.26247881140293,108.24444856736679,111.76994239653018,115.09932483028089,118.66242373012764,125.04823789838893,128.1761985142965,134.73659901975677,141.11893316131767,147.9584002706115,161.6857924597721,170.58092117757536,181.03591909543746,188.34132828910776,204.3899175139911 +17,10.5,77.17861219659218,88.400620517095,95.26532087799941,99.5987156785642,108.0020070060192,114.24468116288554,117.80109058974352,121.0242323844476,124.6078776100211,131.26777940723295,134.5179602354567,141.09471164395063,147.81229795454936,154.67736829325972,169.49693423799437,177.9616413397003,189.0027390497799,196.44585831349497,212.84531121965014 +18,11.0,83.34137394634553,94.48841664067544,102.01155021743303,106.17751016623751,114.56779116644955,121.20471811482841,124.84800170400881,128.10616128278204,131.84743626502745,138.99377232626392,142.55016074213904,149.24181704181473,156.3414000116956,163.16305511389095,178.93802488434216,187.16482221049125,198.9187010344857,207.11642927225466,224.1754603616509 +19,11.5,89.26112495813406,100.64847375493032,109.58617009038653,113.78000762902897,122.1001407199817,129.29775184406782,133.1057906604849,136.58743193837736,140.66605029831837,148.5709974593431,152.6631201144445,159.6100893291842,167.15206109595613,173.84599799169817,190.38625739258435,198.6560289749274,211.27549514607932,221.01532813341763,239.13731900541228 +20,12.0,94.75073529413912,106.7970121454767,118.07724558774501,122.54875184095512,130.73936509390327,138.6969747714764,142.76957238033086,146.7103647643269,151.34867031306567,160.34423561033185,165.24715843247398,172.63170262189442,180.69010297053097,187.15673418587437,204.2188247564898,212.90082681798788,226.56481148108517,238.80484186501474,258.48784121635333 +21,12.5,99.65530801848915,112.82701734825291,127.44427174857606,132.48757481682367,140.50635414717934,149.42930997805368,153.87973155038918,158.54057955330512,163.98061519728503,174.4119582892699,180.41895987364555,188.44259979691043,197.0975405761377,203.23558003646932,220.5415784249908,230.06232775289516,244.96253241984002,260.74520470700867,282.5368530690422 +22,13.0,103.94887820310137,118.53853590048271,137.13246340467688,143.0454615346064,150.9443194654841,160.93660318530738,165.85773192023632,171.43689361631112,177.8486769783676,189.88739983091037,197.20066500464486,205.99379877571647,215.30116156356402,221.0619682069598,238.37500366767267,249.09383110584437,265.38130947792934,285.4884399870892,309.8056686746429 +23,13.5,107.6377139218403,123.70837958971127,146.4584653360273,153.53268521306632,161.47705306616916,172.5144307748722,177.9703070051325,184.58142364392532,192.04001596854854,205.63748527618486,214.3407784894749,223.94008624092675,233.92394676111547,239.32511044167967,256.4682442092842,268.646183031274,286.4179864547508,311.28451830487774,338.3684741534689 +24,14.0,110.7280832485703,128.11336020348352,154.7389223226069,163.25951907096612,171.5283469665859,183.45836912838288,189.48419032033755,197.15628632672775,205.64179248006266,220.5291396660247,230.5878049921383,240.93624887515514,251.5888769970973,256.7142184849624,273.5704437745738,287.37022968362237,306.6694071497019,336.3834102599957,366.2994556258331 +25,14.5,113.22625425715586,131.5302895293447,161.29047914439553,171.53623632706885,180.52199318408603,193.06399462747413,199.66611538111184,208.34359835529895,217.7411668251452,233.42928804136164,244.69024917663788,255.63707336101592,266.9189330998149,271.91850408114175,288.43074608829045,303.91681721732795,324.7324153621806,359.0350864520646,391.6727992120489 +26,15.0,115.1384950214614,133.73597935483997,165.42978058137277,177.6731102001373,187.88178373602096,200.6268836537807,207.7828157027152,217.3254764202191,227.42529931603116,243.204855443127,255.3966157069764,266.6973463811231,278.53709589757375,283.6271789745515,299.7982948751826,316.93679178682913,339.2038548915843,377.489517480706,412.5626910324294 +27,15.5,116.49418152680795,134.604207324291,166.71634772538977,181.21326712931858,193.21963128885932,205.67448152912942,213.33983968219405,223.54632265001644,234.07216027716478,249.08901506929047,261.85764067774613,273.20379703695255,285.50788743382213,290.9481862420711,306.82836031429406,325.5090154712315,349.12254244089763,390.5361486190963,427.6313389224658 +28,16.0,117.41512140434284,134.39661450912632,165.68120691578306,182.63124643529625,196.8999311055374,208.66170933611565,216.79799524373985,227.49968092501067,238.2229600818272,251.77993274597395,264.8329859058972,275.97092410542734,288.66199461258043,294.6643942907636,310.30071840184866,330.4244140483094,355.2951863274531,399.1223238346321,437.8930375783606 +29,16.5,118.04623019666992,133.47180783755127,163.09826080376055,182.63444065913828,199.47519909810867,210.2753570975269,218.85690519333025,229.94138056346958,240.70971911550893,252.34202245633742,265.48454463896917,276.24516878233237,289.271645553012,295.97740286023816,311.4012715883654,332.9019292205043,358.97046777216997,404.7348617682648,444.9501034114943 +30,17.0,118.53242344639312,132.18839423777095,159.74141204053015,181.93024234191248,201.49795117862658,211.20221483615063,220.21619233694292,231.6272508836608,242.36445776370024,251.8396981835409,264.9742101245015,275.2729722634525,288.60906837428024,296.0888116901037,311.31592232436316,334.1605026902577,361.39706799596746,408.86058106094595,450.40485283324756 +31,17.5,118.99446410568234,130.8452128166231,156.2555795020984,181.10814526784202,203.42861727380225,212.01460757097647,221.45887567955933,233.18714965080738,243.88052634764313,251.1599715802177,264.27022673544394,274.0930355452752,287.7344531756879,295.999953896701,311.03292265194113,335.2159192096042,363.61555468294506,412.73849724680184,455.59254951977067 +32,18.0,119.45650476497154,129.50203139547526,152.76974696366668,180.28604819377156,205.35928336897788,212.82700030580227,222.70155902217576,234.74704841795395,245.39659493158604,250.4802449768945,263.5662433463864,272.9130988270978,286.85983797709554,295.9110961032984,310.74992297951906,336.2713357289507,365.8340413699227,416.61641343265774,460.7802462062938 +33,18.5,119.91854542426077,128.1588499743274,149.28391442523494,179.4639511197011,207.28994946415352,213.6393930406281,223.9442423647921,236.30694718510057,246.91266351552892,249.80051837357126,262.8622599573287,271.73316210892045,285.98522277850327,295.82223830989574,310.46692330709703,337.3267522482971,368.0525280569003,420.49432961851363,465.967942892817 +34,19.0,120.38058608354997,126.81566855317955,145.7980818868032,178.64185404563062,209.22061555932922,214.4517857754539,225.18692570740856,237.86684595224713,248.4287320994718,249.12079177024813,262.1582765682713,270.55322539074314,285.1106075799109,295.73338051649307,310.18392363467495,338.38216876764363,370.2710147438779,424.3722458043696,471.15563957934006 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/stats.json b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/stats.json new file mode 100644 index 00000000..16da5d93 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/assets/spleen/stats.json @@ -0,0 +1 @@ +{"Male": {"count": 1013, "age_mean": 11.541538207294176, "age_median": 12.19196537, "age_std": 4.430998361257283}, "Female": {"count": 1089, "age_mean": 13.018425373791551, "age_median": 14.12575152, "age_std": 4.054727624299754}} \ No newline at end of file diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/decoder_nvimgcodec.py b/examples/apps/cchmc_ped_abd_ct_seg_app/decoder_nvimgcodec.py new file mode 100644 index 00000000..118d049e --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/decoder_nvimgcodec.py @@ -0,0 +1,597 @@ +# Copyright 2025-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This decoder plugin for nvimgcodec decompresses +encoded Pixel Data for the following transfer syntaxes: + JPEGBaseline8Bit, 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1) + JPEGLossless, 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14) + NOTE: 9 <= BitsStored <= 15 is NOT supported by nvimgcodec (nvjpeg GPU kernel silently + returns zeros for SOF3 streams with P in this range). BitsStored=8 is handled correctly + by nvimgcodec via its internal self-rejection (UINT8 output is unsupported by the lossless + backend, so it falls through cleanly). Frames with 9 <= BitsStored <= 15 are automatically + routed to the next capable decoder. See https://github.com/NVIDIA/nvImageCodec. + JPEGLosslessSV1, 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction + NOTE: 9 <= BitsStored <= 15 is NOT supported by nvimgcodec (same limitation as above). + Frames with 9 <= BitsStored <= 15 are automatically routed to the next capable decoder. + JPEG2000Lossless, 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only) + JPEG2000, 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression + HTJ2KLossless, 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only) + HTJ2KLosslessRPCL, 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only) + HTJ2K, 1.2.840.10008.1.2.4.203, HTJ2K Image Compression + +There are two ways to add a custom decoding plugin to pydicom: +1. Using the pixel_data_handlers backend, though pydicom.pixel_data_handlers module is deprecated + and will be removed in v4.0. +2. Using the pixels backend by adding a decoder plugin to existing decoders with the add_plugin method, + see https://pydicom.github.io/pydicom/stable/guides/decoding/decoder_plugins.html + +It is noted that pydicom.dataset.Dataset.pixel_array changed in version 3.0 where the backend used for +pixel data decoding changed from the pixel_data_handlers module to the pixels module. + +So, this implementation uses the pixels backend. + +Plugin Requirements: +A custom decoding plugin must implement three objects within the same module: + - A function named is_available with the following signature: + def is_available(uid: pydicom.uid.UID) -> bool: + Where uid is the Transfer Syntax UID for the corresponding decoder as a UID + - A dict named DECODER_DEPENDENCIES with the type dict[pydicom.uid.UID, tuple[str, ...], such as: + DECODER_DEPENDENCIES = {JPEG2000Lossless: ('numpy', 'pillow', 'imagecodecs'),} + This will be used to provide the user with a list of dependencies required by the plugin. + - A function that performs the decoding with the following function signature as in Github repo: + def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes + src is a single frame's worth of raw compressed data to be decoded, and + runner is a DecodeRunner instance that manages the decoding process. + +Adding plugins to a Decoder: +Additional plugins can be added to an existing decoder with the add_plugin() method + ```python + from pydicom.pixels.decoders import RLELosslessDecoder + RLELosslessDecoder.add_plugin( + 'my_decoder', the plugin's label + ('my_package.decoders', 'my_decoder_func') the import paths + ) + ``` +""" + +import inspect +import logging +import sys +from pathlib import Path +from typing import Any, Callable, Iterable + +import numpy as np +from pydicom.pixels.common import PhotometricInterpretation as PI # noqa: N817 +from pydicom.pixels.common import ( + RunnerBase, +) +from pydicom.pixels.decoders import ( + HTJ2KDecoder, + HTJ2KLosslessDecoder, + HTJ2KLosslessRPCLDecoder, + JPEG2000Decoder, + JPEG2000LosslessDecoder, + JPEGBaseline8BitDecoder, + JPEGLosslessDecoder, + JPEGLosslessSV1Decoder, +) +from pydicom.pixels.decoders.base import DecodeRunner +from pydicom.pixels.utils import _passes_version_check +from pydicom.uid import UID, JPEG2000TransferSyntaxes + +try: + import cupy as cp +except ImportError: + cp = None + +try: + from nvidia import nvimgcodec + + # Parse version string, extracting only numeric components to handle suffixes like "0.6.0rc1" + try: + import re + + version_parts = [] + for part in nvimgcodec.__version__.split("."): + # Extract leading digits from each version component + match = re.match(r"^(\d+)", part) + if match: + version_parts.append(int(match.group(1))) + else: + break # Stop at first non-numeric component + nvimgcodec_version = tuple(version_parts) if version_parts else (0,) + except (AttributeError, ValueError): + nvimgcodec_version = (0,) +except ImportError: + nvimgcodec = None + +# nvimgcodec pypi package name, minimum version required and the label for this decoder plugin. +NVIMGCODEC_MODULE_NAME = "nvidia.nvimgcodec" # from nvidia-nvimgcodec-cu12 or other variants +NVIMGCODEC_MIN_VERSION = "0.6" +NVIMGCODEC_MIN_VERSION_TUPLE = tuple(int(x) for x in NVIMGCODEC_MIN_VERSION.split(".")) +NVIMGCODEC_PLUGIN_LABEL = "0.6+nvimgcodec" # to be sorted to first in ascending order of plugins + +# Supported decoder classes of the corresponding transfer syntaxes by this decoder plugin. +SUPPORTED_DECODER_CLASSES = [ + JPEGBaseline8BitDecoder, # 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1) + JPEGLosslessDecoder, # 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14) + JPEGLosslessSV1Decoder, # 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction + JPEG2000LosslessDecoder, # 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only) + JPEG2000Decoder, # 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression + HTJ2KLosslessDecoder, # 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only) + HTJ2KLosslessRPCLDecoder, # 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only) + HTJ2KDecoder, # 1.2.840.10008.1.2.4.203, HTJ2K Image Compression +] + +SUPPORTED_TRANSFER_SYNTAXES: Iterable[UID] = [x.UID for x in SUPPORTED_DECODER_CLASSES] + +_logger = logging.getLogger(__name__) + +# Transfer syntaxes that use JPEG Lossless (SOF3) encoding, where sub-16-bit precision +# causes nvjpeg to silently zero-fill the output buffer +_JPEG_LOSSLESS_SYNTAXES = (JPEGLosslessDecoder.UID, JPEGLosslessSV1Decoder.UID) + +# Set of (tsyntax_str, bits_stored) pairs already warned about; prevents repeated per-frame warnings +_BITS_STORED_FALLBACK_WARNED: set = set() + +# Set of (tsyntax_str, dtype_str) pairs already logged at INFO; subsequent frames log at DEBUG only +_DECODE_SUCCESS_LOGGED: set = set() + + +class _SuppressFallbackFilter(logging.Filter): + """Filter installed on pydicom.pixels.decoders.base to suppress per-frame ERROR logs + that pydicom emits whenever a decoder plugin raises NotImplementedError. We emit a single + WARNING ourselves instead, so the repeated ERROR+traceback output is just noise. + """ + + def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 + return "nvimgcodec does not reliably decode" not in record.getMessage() + + +# Lazy singleton for nvimgcodec decoder; initialized on first use +# Decode params are created per-decode based on image characteristics +if nvimgcodec: + _NVIMGCODEC_DECODER: Any = None +else: # pragma: no cover - nvimgcodec not installed + _NVIMGCODEC_DECODER = None + +# Required for decoder plugin +DECODER_DEPENDENCIES = { + x: ("numpy", "cupy", f"{NVIMGCODEC_MODULE_NAME}>={NVIMGCODEC_MIN_VERSION}, nvidia-nvjpeg2k-cu12>=0.9.1,") + for x in SUPPORTED_TRANSFER_SYNTAXES +} + +DEFAULT_PI_NAME = "nvimgcodec_default_photometric_interpretation" + + +# Required for decoder plugin +def is_available(uid: UID) -> bool: + """Return ``True`` if a pixel data decoder for ``uid`` is available. + + Args: + uid (UID): The transfer syntax UID to check. + + Returns: + bool: ``True`` if a pixel data decoder for ``uid`` is available, + ``False`` otherwise. + """ + + _logger.debug(f"Checking if CUDA and nvimgcodec available for transfer syntax: {uid}") + + if uid not in SUPPORTED_TRANSFER_SYNTAXES: + _logger.debug(f"Transfer syntax {uid} not supported by nvimgcodec.") + return False + if not _is_nvimgcodec_available(): + _logger.debug(f"Module {NVIMGCODEC_MODULE_NAME} is not available.") + return False + + return True + + +# Required for decoder plugin +def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes: + """Return the decoded image data in `src` as a :class:`bytearray` or :class:`bytes`.""" + tsyntax = runner.transfer_syntax + _logger.debug(f"transfer_syntax: {tsyntax}") + + if not is_available(tsyntax): + raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.") + + # nvimgcodec silently returns zero-filled buffers for JPEG Lossless (SOF3) streams + # where 9 <= BitsStored <= 15, e.g. Philips scanners with BitsStored=12 + # + # BitsStored=8 does NOT need this guard: nvimgcodec's lossless decoder internally + # rejects UINT8 output (it only supports UINT16), so it falls through to libjpeg-turbo + # cleanly without ever calling nvjpegDecodeBatched. Only the 9-15 range reaches the + # buggy nvjpegDecodeBatched path that zero-fills silently + # + # Raise NotImplementedError so pydicom falls through to the next capable decoder + if tsyntax in _JPEG_LOSSLESS_SYNTAXES and 9 <= runner.bits_stored <= 15: + warn_key = (str(tsyntax), runner.bits_stored) + if warn_key not in _BITS_STORED_FALLBACK_WARNED: + _BITS_STORED_FALLBACK_WARNED.add(warn_key) + _logger.warning( + f"nvimgcodec does not support {tsyntax} with BitsStored={runner.bits_stored} (9-15); " + "falling back to non-nvimgcodec decoder for this series" + ) + # Suppress the per-frame ERROR+traceback that pydicom.pixels.decoders.base emits + # whenever a plugin raises NotImplementedError — we already logged one warning above + _pydicom_base_logger = logging.getLogger("pydicom.pixels.decoders.base") + if not any(isinstance(f, _SuppressFallbackFilter) for f in _pydicom_base_logger.filters): + _pydicom_base_logger.addFilter(_SuppressFallbackFilter()) + raise NotImplementedError( + f"nvimgcodec does not reliably decode {tsyntax} with BitsStored={runner.bits_stored} (9-15); " + "falling back to non-nvimgcodec decoder for this series" + ) + + # runner.set_frame_option(runner.index, "decoding_plugin", NVIMGCODEC_PLUGIN_LABEL) # type: ignore[attr-defined] + # in pydicom v3.1.0 can use the above call, but do we want to limit to this plugin? + is_jpeg2k = tsyntax in JPEG2000TransferSyntaxes + samples_per_pixel = runner.samples_per_pixel + photometric_interpretation = runner.photometric_interpretation + + # --- JPEG 2000: Precision/Bit depth --- + if is_jpeg2k: + precision, bits_allocated = _jpeg2k_precision_bits(runner) + # runner.set_frame_option(runner.index, "bits_allocated", bits_allocated) # type: ignore[attr-defined] + # in pydicom v3.1.0 can use the above call + runner.set_option("bits_allocated", bits_allocated) + _logger.debug(f"Set bits_allocated to {bits_allocated} for J2K precision {precision}") + + # Check if RGB conversion requested (following Pillow decoder logic) + convert_to_rgb = ( + samples_per_pixel > 1 and runner.get_option("as_rgb", False) and "YBR" in photometric_interpretation + ) + + decoder = _get_decoder_resources() + params = _get_decode_params(runner) + decoded_data = decoder.decode(src, params=params) + if decoded_data: + decoded_data = decoded_data.cpu() + else: + raise RuntimeError(f"Decoding failed: decoder.decode() returned a falsy value of type {type(decoded_data)}") + np_surface = np.ascontiguousarray(np.asarray(decoded_data)) + + # Update photometric interpretation if we converted to RGB, or JPEG 2000 YBR* + if convert_to_rgb or photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT): + # runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined] + # in pydicom v3.1.0 can use the above call + runner.set_option("photometric_interpretation", PI.RGB) + _logger.debug( + "Set photometric_interpretation to RGB after conversion" + if convert_to_rgb + else f"Set photometric_interpretation to RGB for {photometric_interpretation}" + ) + + log_key = (str(tsyntax), str(np_surface.dtype)) + if log_key not in _DECODE_SUCCESS_LOGGED: + _DECODE_SUCCESS_LOGGED.add(log_key) + _logger.info(f"nvimgcodec decoding active: tsyntax={tsyntax} dtype={np_surface.dtype}") + _logger.debug(f"nvimgcodec decoded frame: tsyntax={tsyntax} shape={np_surface.shape} dtype={np_surface.dtype}") + return np_surface.tobytes() + + +def _get_decoder_resources() -> Any: + """Return cached nvimgcodec decoder (parameters are created per decode).""" + + if not _is_nvimgcodec_available(): + raise RuntimeError("nvimgcodec package is not available.") + + global _NVIMGCODEC_DECODER + + if _NVIMGCODEC_DECODER is None: + _NVIMGCODEC_DECODER = nvimgcodec.Decoder(options=":fancy_upsampling=1") + + return _NVIMGCODEC_DECODER + + +def _get_decode_params(runner: RunnerBase) -> Any: + """Create decode parameters based on DICOM image characteristics. + + Mimics the behavior of pydicom's Pillow decoder: + - By default, keeps JPEG data in YCbCr format (no conversion) + - If as_rgb option is True and photometric interpretation is YBR*, converts to RGB + + This matches the logic in pydicom.pixels.decoders.pillow._decode_frame() + + Args: + runner: The DecodeRunner or RunnerBase instance with access to DICOM metadata. + + Returns: + nvimgcodec.DecodeParams: Configured decode parameters. + """ + if not _is_nvimgcodec_available(): + raise RuntimeError("nvimgcodec package is not available.") + + # Access DICOM metadata from the runner + samples_per_pixel = runner.samples_per_pixel + photometric_interpretation = runner.get_option(DEFAULT_PI_NAME, runner.photometric_interpretation) + + # we will change the PI at the end of the function if we convert to rgb + # but we need to have original PI to decide if we need to apply color transform for JPEG + if runner.get_option(DEFAULT_PI_NAME, None) is None: + runner.set_option(DEFAULT_PI_NAME, photometric_interpretation) + + transfer_syntax = runner.transfer_syntax + as_rgb = runner.get_option("as_rgb", False) + force_rgb = runner.get_option("force_rgb", False) + force_ybr = runner.get_option("force_ybr", False) + + _logger.debug("DecodeRunner options:") + _logger.debug(f"transfer_syntax: {transfer_syntax}") + _logger.debug(f"photometric_interpretation: {photometric_interpretation}") + _logger.debug(f"samples_per_pixel: {samples_per_pixel}") + _logger.debug(f"as_rgb: {as_rgb}") + _logger.debug(f"force_rgb: {force_rgb}") + _logger.debug(f"force_ybr: {force_ybr}") + + # Default: keep color space unchanged + color_spec = nvimgcodec.ColorSpec.UNCHANGED + + # For multi-sample (color) images, check if RGB conversion is requested + if samples_per_pixel > 1: + # JPEG 2000 color transformations are always returned as RGB (matches Pillow) + if photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT): + color_spec = nvimgcodec.ColorSpec.SRGB + _logger.debug( + f"Using RGB color spec for JPEG 2000 color transformation " f"(PI: {photometric_interpretation})" + ) + elif transfer_syntax in (JPEGBaseline8BitDecoder.UID): + # approach is similar to pylibjpeg from pydicom - for ybr full and 422 it needs conversion from ycbcr to rgb + # for any other PI it just skips color conversion (ignoring what is inside jpeg header) + if photometric_interpretation in (PI.YBR_FULL, PI.YBR_FULL_422): + # we want to apply ycbcr -> rgb conversion + color_spec = nvimgcodec.ColorSpec.SRGB + else: + # ignore color conversion as image should already be in rgb or grayscale (but jpeg header may contain wrong data) + color_spec = nvimgcodec.ColorSpec.SYCC + else: + # Check the as_rgb option - same as Pillow decoder + convert_to_rgb = as_rgb or (force_rgb and "YBR" in photometric_interpretation) + + if convert_to_rgb: + # Convert YCbCr to RGB as requested + color_spec = nvimgcodec.ColorSpec.SRGB + _logger.debug(f"Using RGB color spec (as_rgb=True, PI: {photometric_interpretation})") + else: + # Keep YCbCr unchanged - matches Pillow's image.draft("YCbCr") behavior + _logger.debug( + f"Using UNCHANGED color spec to preserve YCbCr " f"(as_rgb=False, PI: {photometric_interpretation})" + ) + else: + # Grayscale image - keep unchanged + _logger.debug( + f"Using UNCHANGED color spec for grayscale image (samples_per_pixel: {samples_per_pixel}," + f" PI: {photometric_interpretation}, transfer_syntax: {transfer_syntax})" + ) + + return nvimgcodec.DecodeParams( + allow_any_depth=True, + color_spec=color_spec, + ) + + +def _jpeg2k_precision_bits(runner: DecodeRunner) -> tuple[int, int]: + # precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined] + # in pydicom v3.1.0 can use the above call + precision = runner.get_option("j2k_precision", runner.bits_stored) + if 0 < precision <= 8: + return precision, 8 + elif 8 < precision <= 16: + if runner.samples_per_pixel > 1: + _logger.warning( + f"JPEG 2000 with {precision}-bit multi-sample data may have precision issues with some decoders" + ) + return precision, 16 + else: + raise ValueError(f"Only 'Bits Stored' values up to 16 are supported, got {precision}") + + +def _is_nvimgcodec_available() -> bool: + """Return ``True`` if nvimgcodec is available, ``False`` otherwise.""" + + if not nvimgcodec or not _passes_version_check(NVIMGCODEC_MODULE_NAME, NVIMGCODEC_MIN_VERSION_TUPLE) or not cp: + _logger.debug(f"nvimgcodec (version >= {NVIMGCODEC_MIN_VERSION}) or CuPy missing.") + return False + try: + if not cp.cuda.is_available(): + _logger.debug("CUDA device not found.") + return False + except Exception as exc: # pragma: no cover - environment specific + _logger.debug(f"CUDA availability check failed: {exc}") + return False + + return True + + +# Helper functions for an application to register/unregister this decoder plugin with Pydicom at application startup. + + +def register_as_decoder_plugin(module_path: str | None = None) -> bool: + """Register as a preferred decoder plugin with supported decoder classes. + + The Decoder class does not support sorting the plugins and uses the order in which plugins were added. + Furthermore, the properties of ``available_plugins`` returns sorted labels only but not the Callables or + their module and function names, and the function ``remove_plugin`` only returns a boolean. + So there is no way to remove the available plugins before adding them back after this plugin is added. + + For now, have to access the ``private`` property ``_available`` of the Decoder class to sort the available + plugins and make sure this custom plugin is the first in the sorted list by its label. It is known that the + first plugin in the default list is always ``gdcm`` for the supported decoder classes, so label name needs + to be lexicographically less than ``gdcm`` to be the first in the sorted list. + + Args: + module_path (str | None): The importable module path for this plugin. + When ``None`` or ``"__main__"``, search the loaded modules for an entry whose ``__file__`` resolves + to the current file, e.g. module paths that start with ``monai.deploy.operators`` or ``monai.data``. + + Returns: + bool: ``True`` if the decoder plugin is registered successfully, ``False`` otherwise. + """ + + if not _is_nvimgcodec_available(): + _logger.warning(f"Module {NVIMGCODEC_MODULE_NAME} is not available.") + return False + + try: + func_name = getattr(_decode_frame, "__name__", None) + except NameError: + _logger.error("Decoder function `_decode_frame` not found.") + return False + + if module_path is None: + module_path = _find_module_path(__name__) + else: + # Double check if the module path exists and if it is the same as the one for the callable origin. + module_path_found, func_name_found = _get_callable_origin(_decode_frame) # get the func's module path. + if module_path_found: + if module_path.casefold() != module_path_found.casefold(): + _logger.warning(f"Module path {module_path} does not match {module_path_found} for decoder plugin.") + else: + _logger.error(f"Module path {module_path} not found for decoder plugin.") + return False + + if func_name != func_name_found: + _logger.warning( + f"Function {func_name_found} in {module_path_found} instead of {func_name} used for decoder plugin." + ) + + for decoder_class in SUPPORTED_DECODER_CLASSES: + if NVIMGCODEC_PLUGIN_LABEL in decoder_class.available_plugins: + _logger.debug(f"{NVIMGCODEC_PLUGIN_LABEL} already registered for transfer syntax {decoder_class.UID}.") + continue + + decoder_class.add_plugin(NVIMGCODEC_PLUGIN_LABEL, (module_path, str(func_name))) + _logger.debug( + f"Added plugin for transfer syntax {decoder_class.UID}: " + f"{NVIMGCODEC_PLUGIN_LABEL} with {func_name} in module path {module_path}." + ) + + # Need to sort the plugins to make sure the custom plugin is the first in items() of + # the decoder class search for the plugin to be used. + decoder_class._available = dict(sorted(decoder_class._available.items(), key=lambda item: item[0])) + _logger.debug(f"Sorted plugins for transfer syntax {decoder_class.UID}: {decoder_class._available}") + + _logger.info(f"{NVIMGCODEC_MODULE_NAME} registered with {len(SUPPORTED_DECODER_CLASSES)} decoder classes.") + + return True + + +def unregister_as_decoder_plugin() -> bool: + """Unregister the decoder plugin from the supported decoder classes.""" + + for decoder_class in SUPPORTED_DECODER_CLASSES: + if NVIMGCODEC_PLUGIN_LABEL in decoder_class.available_plugins: + decoder_class.remove_plugin(NVIMGCODEC_PLUGIN_LABEL) + _logger.debug(f"Unregistered plugin for transfer syntax {decoder_class.UID}: {NVIMGCODEC_PLUGIN_LABEL}") + _logger.info(f"Unregistered plugin {NVIMGCODEC_PLUGIN_LABEL} for all supported transfer syntaxes.") + + return True + + +def _find_module_path(module_name: str | None) -> str: + """Return the importable module path for *module_name* file. + + When *module_name* is ``None`` or ``"__main__"``, search the loaded modules + for an entry whose ``__file__`` resolves to the current file. + + When *module_name* is provided and not ``"__main__"``, validate it exists in + loaded modules and corresponds to the current file, returning it if valid. + + When used in MONAI, likely in module paths ``monai.deploy.operators`` or ``monai.data``. + """ + + current_file = Path(__file__).resolve() + + # If a specific module name is provided (not None or "__main__"), validate it + if module_name and module_name != "__main__": + module = sys.modules.get(module_name) + if module: + module_file = getattr(module, "__file__", None) + if module_file: + try: + if Path(module_file).resolve() == current_file: + return module_name + else: + _logger.warning(f"Module {module_name} found but its file path does not match current file.") + except (OSError, RuntimeError): + _logger.warning(f"Could not resolve file path for module {module_name}.") + else: + _logger.warning(f"Module {module_name} has no __file__ attribute.") + else: + _logger.warning(f"Module {module_name} not found in loaded modules.") + # Fall through to search for the correct module + + # Search for modules that correspond to the current file + candidates: list[str] = [] + + for name, module in sys.modules.items(): + if not name or name == "__main__": + continue + module_file = getattr(module, "__file__", None) + if not module_file: + continue + try: + if Path(module_file).resolve() == current_file: + candidates.append(name) + except (OSError, RuntimeError): + continue + + preferred_prefixes = ("monai.deploy.operators", "monai.data") + for prefix in preferred_prefixes: + for name in candidates: + if name.startswith(prefix): + return name + + if candidates: + # deterministic fallback + return sorted(candidates)[0] + + return __name__ + + +def _get_callable_origin(obj: Callable[..., Any]) -> tuple[str | None, str | None]: + """Return the importable module path and attribute(function) name for *obj*. + + Can be used to get the importable module path and func name of existing callables. + + Args: + obj: Callable retrieved via :func:`getattr` or similar. + + Returns: + tuple[str | None, str | None]: ``(module_path, attr_name)``; each element + is ``None`` if it cannot be determined. When both values are available, + the same callable can be re-imported using + :func:`importlib.import_module` followed by :func:`getattr`. + """ + + if not callable(obj): + return None, None + + target = inspect.unwrap(obj) + attr_name = getattr(target, "__name__", None) + module = inspect.getmodule(target) + module_path = getattr(module, "__name__", None) + + # If the callable is defined in a different module, find the attribute name in the module. + if module_path and attr_name: + module_obj = sys.modules.get(module_path) + if module_obj and getattr(module_obj, attr_name, None) is not target: + for name in dir(module_obj): + try: + if getattr(module_obj, name) is target: + attr_name = name + break + except AttributeError: + continue + + return module_path, attr_name diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_sc_writer_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_sc_writer_operator.py index 9479485e..b0e52bc4 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_sc_writer_operator.py +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_sc_writer_operator.py @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -9,14 +9,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import logging -import os from pathlib import Path from typing import Dict, Optional, Union -import pydicom +import numpy as np -from monai.deploy.core import Fragment, Operator, OperatorSpec +from monai.deploy.core import Fragment, Image, Operator, OperatorSpec from monai.deploy.core.domain.dicom_series import DICOMSeries from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries from monai.deploy.operators.dicom_utils import EquipmentInfo, ModelInfo, write_common_modules @@ -25,10 +25,13 @@ dcmread, _ = optional_import("pydicom", name="dcmread") dcmwrite, _ = optional_import("pydicom.filewriter", name="dcmwrite") -generate_uid, _ = optional_import("pydicom.uid", name="generate_uid") -ImplicitVRLittleEndian, _ = optional_import("pydicom.uid", name="ImplicitVRLittleEndian") -Dataset, _ = optional_import("pydicom.dataset", name="Dataset") -FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset") +_PYDICOM_UID = "pydicom.uid" +_PYDICOM_DATASET = "pydicom.dataset" +generate_uid, _ = optional_import(_PYDICOM_UID, name="generate_uid") +ImplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ImplicitVRLittleEndian") +ExplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ExplicitVRLittleEndian") +Dataset, _ = optional_import(_PYDICOM_DATASET, name="Dataset") +FileDataset, _ = optional_import(_PYDICOM_DATASET, name="FileDataset") Sequence, _ = optional_import("pydicom.sequence", name="Sequence") @@ -36,7 +39,7 @@ class DICOMSCWriterOperator(Operator): """Class to write a new DICOM Secondary Capture (DICOM SC) instance with source DICOM Series metadata included. Named inputs: - dicom_sc_dir: file path of temporary DICOM SC (w/o source DICOM Series metadata). + input_overlay_image: Image object or numpy array of the secondary capture content (RGB). study_selected_series_list: DICOM Series for copying metadata from. Named output: @@ -81,8 +84,9 @@ def __init__( # need to init the output folder until the execution context supports dynamic FS path # not trying to create the folder to avoid exception on init self.output_folder = Path(output_folder) if output_folder else DICOMSCWriterOperator.DEFAULT_OUTPUT_FOLDER - self.input_name_sc_dir = "dicom_sc_dir" + self.input_name_study_series = "study_selected_series_list" + self.input_overlay_image = "input_overlay_image" # for copying DICOM attributes from a provided DICOMSeries # required input for write_common_modules; will always be True for this implementation @@ -125,8 +129,7 @@ def setup(self, spec: OperatorSpec): Args: spec (OperatorSpec): The Operator specification for inputs and outputs etc. """ - - spec.input(self.input_name_sc_dir) + spec.input(self.input_overlay_image) spec.input(self.input_name_study_series) def compute(self, op_input, op_output, context): @@ -145,13 +148,8 @@ def compute(self, op_input, op_output, context): IOError: If the input content is blank. """ - # receive the temporary DICOM SC file path and study selected series list - dicom_sc_dir = Path(op_input.receive(self.input_name_sc_dir)) - if not dicom_sc_dir: - raise IOError("Temporary DICOM SC path is read but blank.") - if not dicom_sc_dir.is_dir(): - raise NotADirectoryError(f"Provided temporary DICOM SC path is not a directory: {dicom_sc_dir}") - self._logger.info(f"Received temporary DICOM SC path: {dicom_sc_dir}") + # receive input overlay image and study selected series list + overlay_image = op_input.receive(self.input_overlay_image) study_selected_series_list = op_input.receive(self.input_name_study_series) if not study_selected_series_list or len(study_selected_series_list) < 1: @@ -167,44 +165,28 @@ def compute(self, op_input, op_output, context): dicom_series = selected_series.series break - # log basic DICOM metadata for the retrieved DICOM Series - self._logger.debug(f"Dicom Series: {dicom_series}") - # the output folder should come from the execution context when it is supported self.output_folder.mkdir(parents=True, exist_ok=True) # write the new DICOM SC instance - self.write(dicom_sc_dir, dicom_series, self.output_folder) + self.process_images(overlay_image, dicom_series, self.output_folder) - def write(self, dicom_sc_dir, dicom_series: DICOMSeries, output_dir: Path): - """Writes a new, updated DICOM SC instance and deletes the temporary DICOM SC instance. - The new, updated DICOM SC instance is the temporary DICOM SC instance with source - DICOM Series metadata copied. + def process_images(self, overlay_image, dicom_series: DICOMSeries, output_folder: Path): + """Process the overlay image and write the DICOM SC instance with source DICOM + Series metadata copied. Args: - dicom_sc_dir: temporary DICOM SC file path. + overlay_image: The overlay image (temporary DICOM SC). dicom_series (DICOMSeries): DICOMSeries object encapsulating the original series. + output_folder (Path): The folder for saving the generated DICOM SC instance file. Returns: None File output: - New, updated DICOM SC file (with source DICOM Series metadata) in the provided output folder. + DICOM SC file (with source DICOM Series metadata) in the provided output folder. """ - if not isinstance(output_dir, Path): - raise ValueError("output_dir is not a valid Path.") - - output_dir.mkdir(parents=True, exist_ok=True) # just in case - - # find the temporary DICOM SC file in the directory; there should only be one .dcm file present - dicom_files = list(dicom_sc_dir.glob("*.dcm")) - dicom_sc_file = dicom_files[0] - - # load the temporary DICOM SC file using pydicom - dicom_sc_dataset = pydicom.dcmread(dicom_sc_file) - self._logger.info(f"Loaded temporary DICOM SC file: {dicom_sc_file}") - # use write_common_modules to copy metadata from dicom_series # this will copy metadata and return an updated Dataset ds = write_common_modules( @@ -216,8 +198,116 @@ def write(self, dicom_sc_dir, dicom_series: DICOMSeries, output_dir: Path): self.equipment_info, ) - # Secondary Capture specific tags + # ── Secondary Capture module (DICOM PS3.3 C.8.6) ────────────────────────── ds.ImageType = ["DERIVED", "SECONDARY"] + ds.ConversionType = "DI" # Digital Interface (pixel data transferred from a digital source) + ds.BurnedInAnnotation = "YES" # Segmentation contours are baked into pixel data + ds.LossyImageCompression = "00" # Pixel data is not lossy-compressed + ds.RecognizableVisualFeatures = "YES" # Patient anatomy is recognizable + + # Convert overlay_image to numpy array if it's an Image object + if isinstance(overlay_image, Image): + image_numpy = overlay_image.asnumpy() + elif isinstance(overlay_image, np.ndarray): + image_numpy = overlay_image + else: + raise ValueError(f"Unsupported overlay_image type: {type(overlay_image)}") + + # Normalize to (Slices, Height, Width, 3) for multi-frame or (Height, Width, 3) for single-frame. + # Priority: trailing-channel first, then channel-in-axis-1, then channel-in-axis-0. + if image_numpy.ndim == 4: + if image_numpy.shape[-1] == 3: + pass # (Slices, Height, Width, 3) + elif image_numpy.shape[1] == 3: + image_numpy = np.transpose(image_numpy, (0, 2, 3, 1)) # (Slices, 3, Height, Width) + elif image_numpy.shape[0] == 3: + image_numpy = np.transpose(image_numpy, (1, 2, 3, 0)) # (3, Slices, Height, Width) + else: + raise ValueError(f"Expected RGB data, got shape: {image_numpy.shape}") + # Now in format: (Slices, Height, Width, 3) + num_frames, rows, cols, samples_per_pixel = image_numpy.shape + elif image_numpy.ndim == 3: + if image_numpy.shape[-1] == 3: + pass # (Height, Width, 3) + elif image_numpy.shape[0] == 3: + image_numpy = np.transpose(image_numpy, (1, 2, 0)) # (3, Height, Width) + else: + raise ValueError(f"Expected single-frame RGB data, got shape: {image_numpy.shape}") + rows, cols, samples_per_pixel = image_numpy.shape + num_frames = 1 + # Add frame dimension: (Height, Width, 3) -> (1, Height, Width, 3) + image_numpy = image_numpy[np.newaxis, ...] + else: + raise ValueError(f"Unexpected image dimensions: {image_numpy.shape}") + + # Ensure uint8 data type for RGB + if image_numpy.dtype != np.uint8: + # Normalize to 0-255 range if needed + if image_numpy.max() <= 1.0 and image_numpy.min() >= 0.0: + image_numpy = image_numpy * 255.0 + image_numpy = np.clip(image_numpy, 0, 255).astype(np.uint8) + + # Set image-specific DICOM tags for RGB Secondary Capture + ds.Rows = rows + ds.Columns = cols + ds.SamplesPerPixel = samples_per_pixel + ds.PhotometricInterpretation = "RGB" + ds.BitsAllocated = 8 + ds.BitsStored = 8 + ds.HighBit = 7 + ds.PixelRepresentation = 0 # unsigned + ds.PlanarConfiguration = 0 # interleaved RGB (R1G1B1R2G2B2...) + ds.NumberOfFrames = num_frames + + # ── Copy spatial calibration from the source series ────────────────────── + # PixelSpacing, ImageOrientationPatient etc. allow PACS measurement tools + # to work correctly on the SC output + try: + sop_instances = dicom_series.get_sop_instances() + if sop_instances: + # Pick the middle slice for a representative instance + native_ds = sop_instances[len(sop_instances) // 2].get_native_sop_instance() + + # Pixel spacing - prefer PixelSpacing (CT/MR), fall back to ImagerPixelSpacing + for _ps_tag in ("PixelSpacing", "ImagerPixelSpacing"): + if hasattr(native_ds, _ps_tag): + setattr(ds, _ps_tag, getattr(native_ds, _ps_tag)) + break + # Slice geometry - important for MR and CT measurement tools + for _slice_tag in ("SliceThickness", "SpacingBetweenSlices"): + if hasattr(native_ds, _slice_tag): + setattr(ds, _slice_tag, getattr(native_ds, _slice_tag)) + # Spatial orientation / frame-of-reference + for _spatial_tag in ( + "ImageOrientationPatient", + "FrameOfReferenceUID", + "PositionReferenceIndicator", + ): + if hasattr(native_ds, _spatial_tag): + setattr(ds, _spatial_tag, getattr(native_ds, _spatial_tag)) + except Exception as _exc: + self._logger.debug(f"Could not copy spatial tags from source series: {_exc}") + + # WindowCenter/WindowWidth/VOILUTFunction are intentionally NOT written to the SC dataset. + # Per PS3.3 C.11.2.1.2.2, VOI LUT tags are only valid for MONOCHROME1/MONOCHROME2 images. + # On an RGB SC, conformant viewers apply a second window pass to the already-windowed + # 0-255 RGB pixel values, causing a visible brightness shift. The windowing is baked + # into the pixel data; provenance is captured in the log above. + + # Set the pixel data (flatten all frames) + ds.PixelData = image_numpy.tobytes() + + # Generate unique SOP Instance UID for this instance + sc_sop_instance_uid = generate_uid() + ds.SOPInstanceUID = sc_sop_instance_uid + + # Add date and time stamps + dt_now = datetime.datetime.now() + ds.SeriesDate = dt_now.strftime("%Y%m%d") + ds.SeriesTime = dt_now.strftime("%H%M%S") + ds.ContentDate = dt_now.strftime("%Y%m%d") + ds.ContentTime = dt_now.strftime("%H%M%S") + ds.TimezoneOffsetFromUTC = dt_now.astimezone().isoformat()[-6:].replace(":", "") # for now, only allow str Keywords and str value if self.custom_tags: @@ -229,25 +319,100 @@ def write(self, dicom_sc_dir, dicom_series: DICOMSeries, output_dir: Path): # best effort for now logging.warning(f"Tag {k} was not written, due to {ex}") - # merge the copied metadata into the loaded temporary DICOM SC file (dicom_sc_dataset) - for tag, value in ds.items(): - dicom_sc_dataset[tag] = value + # Ensure required UIDs are set + if not hasattr(ds, "SeriesInstanceUID") or not ds.SeriesInstanceUID: + ds.SeriesInstanceUID = generate_uid() - # save the updated DICOM SC file to the output folder - # instance file name is the same as the new SOP instance UID - output_file_path = self.output_folder.joinpath( - f"{dicom_sc_dataset.SOPInstanceUID}{DICOMSCWriterOperator.DCM_EXTENSION}" - ) - dicom_sc_dataset.save_as(output_file_path) - self._logger.info(f"Saved updated DICOM SC file at: {output_file_path}") + # Set file meta information + file_meta = Dataset() + file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + file_meta.MediaStorageSOPClassUID = self.sop_class_uid + file_meta.MediaStorageSOPInstanceUID = sc_sop_instance_uid + file_meta.ImplementationClassUID = generate_uid() + + # Create output path + output_path = output_folder / f"{sc_sop_instance_uid}{DICOMSCWriterOperator.DCM_EXTENSION}" + + # Create FileDataset and save + ds.file_meta = file_meta + ds.is_little_endian = True + ds.is_implicit_VR = False + + # Save the DICOM file + ds.save_as(output_path, write_like_original=False) - # remove the temporary DICOM SC file - os.remove(dicom_sc_file) - self._logger.info(f"Removed temporary DICOM SC file: {dicom_sc_file}") + self._logger.info(f"DICOM Secondary Capture saved to: {output_path}") + self._logger.info(f"Number of frames: {num_frames}, Dimensions: {rows}x{cols}, Channels: {samples_per_pixel}") - # check if the temp directory is empty, then delete it - if not any(dicom_sc_dir.iterdir()): - os.rmdir(dicom_sc_dir) - self._logger.info(f"Removed temporary directory: {dicom_sc_dir}") + # Verify the file was created + try: + if output_path.exists(): + self._logger.info(f"File size: {output_path.stat().st_size} bytes") + else: + self._logger.error(f"File was not created: {output_path}") + except Exception as ex: + self._logger.warning(f"Could not verify output file: {ex}") + + +def test(): + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator + + current_file_dir = Path(__file__).parent.resolve() + # Update data_path to point to a valid DICOM series folder on your system for testing metadata copy + data_path = current_file_dir.joinpath("../../../inputs/livertumor_ct/dcm/1-CT_series_liver_tumor_from_nii014") + out_path = Path("output_sc_op").absolute() + + # 1. Generate Synthetic RGB Image (Slices, Channels, H, W) -> (2, 3, 256, 256) + # This simulates a 2-frame RGB overlay + print("Generating synthetic RGB image...") + rng = np.random.default_rng(42) + dummy_image = rng.integers(0, 255, size=(2, 3, 256, 256), dtype=np.uint8) + + # 2. Setup Operators + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="loader_op") + series_selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") + sc_writer = DICOMSCWriterOperator( + fragment, + output_folder=out_path, + model_info=None, + equipment_info=EquipmentInfo(), + custom_tags={"SeriesDescription": "Secondary Capture from AI Algorithm"}, + name="sc_writer", + ) + + # 3. Load DICOM Series (if available) + dicom_series = None + try: + print(f"Loading DICOM series from: {data_path}") + study_list = loader.load_data_to_studies(Path(data_path).absolute()) + study_selected_series_list = series_selector.filter(None, study_list) + + if study_selected_series_list and len(study_selected_series_list) > 0: + for study_selected_series in study_selected_series_list: + for selected_series in study_selected_series.selected_series: + dicom_series = selected_series.series + break + print("DICOM Series loaded successfully.") + else: + print("Warning: No DICOM series found. Test will fail if Series metadata is required.") + # Create a dummy series object if needed for robust testing without files, + # but for now we assume files exist or we accept failure. + except Exception as e: + print(f"Skipping DICOM loading due to environment error: {e}") + + # 4. Run Writer Logic directly + print(f"Writing Secondary Capture to {out_path}...") + try: + if dicom_series: + sc_writer.process_images(dummy_image, dicom_series, out_path) + print("Test Success: DICOM SC written.") else: - self._logger.warning(f"Temporary directory {dicom_sc_dir} is not empty, skipping removal.") + print("Test Aborted: No valid input DICOM series available to copy metadata from.") + except Exception as e: + print(f"Test Failed during write: {e}") + + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_seg_writer_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_seg_writer_operator.py new file mode 100644 index 00000000..7a27831a --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_seg_writer_operator.py @@ -0,0 +1,529 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import logging +import os +from pathlib import Path +from random import randint +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union + +import numpy as np +from typeguard import typechecked + +from monai.deploy.utils.importutil import optional_import +from monai.deploy.utils.version import get_sdk_semver + +dcmread, _ = optional_import("pydicom", name="dcmread") +_PYDICOM_UID = "pydicom.uid" +_PYDICOM_DATASET = "pydicom.dataset" +_PYDICOM_VALUEREP = "pydicom.valuerep" +generate_uid, _ = optional_import(_PYDICOM_UID, name="generate_uid") +ImplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ImplicitVRLittleEndian") +ExplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ExplicitVRLittleEndian") +Dataset, _ = optional_import(_PYDICOM_DATASET, name="Dataset") +FileDataset, _ = optional_import(_PYDICOM_DATASET, name="FileDataset") +DA, _ = optional_import(_PYDICOM_VALUEREP, name="DA") +TM, _ = optional_import(_PYDICOM_VALUEREP, name="TM") +PyDicomSequence, _ = optional_import("pydicom.sequence", name="Sequence") +sitk, _ = optional_import("SimpleITK") +codes, _ = optional_import("pydicom.sr.codedict", name="codes") +if TYPE_CHECKING: + import highdicom as hd + from pydicom.sr.coding import Code +else: + Code, _ = optional_import("pydicom.sr.coding", name="Code") + hd, _ = optional_import("highdicom") + +from monai.deploy.core import ConditionType, Fragment, Image, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series import DICOMSeries +from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +from monai.deploy.operators.dicom_utils import ModelInfo + + +class SegmentDescription: + @typechecked + def __init__( + self, + segment_label: str, + segmented_property_category: Code, + segmented_property_type: Code, + algorithm_name: str, + algorithm_version: str, + algorithm_family: Code = codes.DCM.ArtificialIntelligence, + tracking_id: Optional[str] = None, + tracking_uid: Optional[str] = None, + anatomic_regions: Optional[Sequence[Code]] = None, + primary_anatomic_structures: Optional[Sequence[Code]] = None, + ): + """Class encapsulating the description of a segment within the segmentation. + + Args: + segment_label: str + User-defined label identifying this segment, + DICOM VR Long String (LO) (see C.8.20-4 + https://dicom.nema.org/medical/Dicom/current/output/chtml/part03/sect_C.8.20.4.html + "Segment Description Macro Attributes") + segmented_property_category: pydicom.sr.coding.Code + Category of the property the segment represents, + e.g. ``Code("49755003", "SCT", "Morphologically Abnormal + Structure")`` (see CID 7150 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_7150.html + "Segmentation Property Categories") + segmented_property_type: pydicom.sr.coding.Code + Property the segment represents, + e.g. ``Code("108369006", "SCT", "Neoplasm")`` (see CID 7151 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_7151.html + "Segmentation Property Types") + algorithm_name: str + Name of algorithm used to generate the segment, also as the name assigned by a + manufacturer to a specific software algorithm, + DICOM VR Long String (LO) (see C.8.20-2 + https://dicom.nema.org/medical/dicom/2019a/output/chtml/part03/sect_C.8.20.2.html + "Segmentation Image Module Attribute", and see 10-19 + https://dicom.nema.org/medical/dicom/2020b/output/chtml/part03/sect_10.16.html + "Algorithm Identification Macro Attributes") + algorithm_version: str + The software version identifier assigned by a manufacturer to a specific software algorithm, + DICOM VR Long String (LO) (see 10-19 + https://dicom.nema.org/medical/dicom/2020b/output/chtml/part03/sect_10.16.html + "Algorithm Identification Macro Attributes") + tracking_id: Optional[str], optional + Tracking identifier (unique only with the domain of use). + tracking_uid: Optional[str], optional + Unique tracking identifier (universally unique) in the DICOM format + for UIDs. This is only permissible if a ``tracking_id`` is also + supplied. You may use ``pydicom.uid.generate_uid`` to generate a + suitable UID. If ``tracking_id`` is supplied but ``tracking_uid`` is + not supplied, a suitable UID will be generated for you. + anatomic_regions: Optional[Sequence[pydicom.sr.coding.Code]], optional + Anatomic region(s) into which segment falls, + e.g. ``Code("41216001", "SCT", "Prostate")`` (see CID 4 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_4.html + "Anatomic Region", CID 403 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_4031.html + "Common Anatomic Regions", as as well as other CIDs for + domain-specific anatomic regions) + primary_anatomic_structures: Optional[Sequence[pydicom.sr.coding.Code]], optional + Anatomic structure(s) the segment represents + (see CIDs for domain-specific primary anatomic structures) + """ + self._segment_label = segment_label + self._segmented_property_category = segmented_property_category + self._segmented_property_type = segmented_property_type + self._tracking_id = tracking_id + + self._anatomic_regions = anatomic_regions + self._primary_anatomic_structures = primary_anatomic_structures + + # Generate a UID if one was not provided + if tracking_id is not None and tracking_uid is None: + tracking_uid = hd.UID() + self._tracking_uid = tracking_uid + + self._algorithm_identification = hd.AlgorithmIdentificationSequence( + name=algorithm_name, + family=algorithm_family, + version=algorithm_version, + ) + + def to_segment_description(self, segment_number: int) -> hd.seg.SegmentDescription: + """Get a corresponding highdicom Segment Description object. + + Args: + segment_number: int + Number of the segment. Must start at 1 and increase by 1 within a + given segmentation object. + + Returns + highdicom.seg.SegmentDescription: + highdicom Segment Description containing the information in this + object. + """ + return hd.seg.SegmentDescription( + segment_number=segment_number, + segment_label=self._segment_label, + segmented_property_category=self._segmented_property_category, + segmented_property_type=self._segmented_property_type, + algorithm_identification=self._algorithm_identification, + algorithm_type="AUTOMATIC", + tracking_uid=self._tracking_uid, + tracking_id=self._tracking_id, + anatomic_regions=self._anatomic_regions, + primary_anatomic_structures=self._primary_anatomic_structures, + ) + + +# @md.env(pip_packages=["pydicom >= 2.3.0", "highdicom >= 0.18.2, "SimpleITK>=2.0.0"]) +class DICOMSegmentationWriterOperator(Operator): + """ + This operator writes out a DICOM Segmentation Part 10 file to disk + + Named inputs: + seg_image: The Image object of the segment. + study_selected_series_list: The DICOM series from which the segment was derived. + output_folder: Optional, folder for file output, overriding what is set on the object. + + Named output: + None + + File output: + Generated DICOM instance file in the output folder set on this object or optional input. + """ + + DEFAULT_OUTPUT_FOLDER = Path.cwd() / "output" + # Supported input image format, based on extension. Intended for file based input. + SUPPORTED_EXTENSIONS = [".nii", ".nii.gz", ".mhd"] + # DICOM instance file extension. Case insensitive in string comparison. + DCM_EXTENSION = ".dcm" + + def __init__( + self, + fragment: Fragment, + *args, + segment_descriptions: List[SegmentDescription], + output_folder: Path, + model_info: Optional[ModelInfo] = None, + custom_tags: Optional[Dict[str, str]] = None, + omit_empty_frames: bool = True, + **kwargs, + ): + """Instantiates the DICOM Seg Writer instance with optional list of segment label strings. + + Each unique, non-zero integer value in the segmentation image represents a segment that must be + described by an item of the segment descriptions list with the corresponding segment number. + Items in the list must be arranged starting at segment number 1 and increasing by 1. + + For example, in the CT Spleen Segmentation application, the whole image background has a value + of 0, and the Spleen segment of value 1. This then only requires the caller to pass in a list + containing a segment description, which is used as label for the Spleen in the DICOM Seg instance. + + Note: this interface is subject to change. It is planned that a new object will encapsulate the + segment label information, including label value, name, description etc. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + segment_descriptions: List[SegmentDescription] + Object encapsulating the description of each segment present in the segmentation. + output_folder: Folder for file output, overridden by named input on compute. + Defaults to current working dir's child folder, output. + model_info (ModelInfo, optional): Object encapsulating model creator, name, version and UID. + custom_tags: Optional[Dict[str, str]], optional + Dictionary for setting custom DICOM tags using Keywords and str values only + omit_empty_frames: bool, optional + Whether to omit frames that contain no segmented pixels from the output segmentation. + Defaults to True, same as the underlying lib API. + """ + + self._seg_descs = [sd.to_segment_description(n) for n, sd in enumerate(segment_descriptions, 1)] + self._custom_tags = custom_tags + self._omit_empty_frames = omit_empty_frames + self.output_folder = output_folder if output_folder else DICOMSegmentationWriterOperator.DEFAULT_OUTPUT_FOLDER + self.model_info = model_info if model_info else ModelInfo() + + self.input_name_seg = "seg_image" + self.input_name_series = "study_selected_series_list" + self.input_name_output_folder = "output_folder" + + # Print type of all objects used for initialization for debugging + logging.debug(f"Segment Descriptions Type: {type(self._seg_descs)}") + logging.debug(f"Output Folder: {self.output_folder}") + logging.debug(f"Model Info Type: {type(self.model_info)}") + + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + """Set up the named input(s), and output(s) if applicable, aka ports. + + Args: + spec (OperatorSpec): The Operator specification for inputs and outputs etc. + """ + spec.input(self.input_name_seg) + spec.input(self.input_name_series) + spec.input(self.input_name_output_folder).condition(ConditionType.NONE) # Optional input not requiring sender. + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O. + + For now, only a single segmentation image object or file is supported and the selected DICOM + series for inference is required, because the DICOM Seg IOD needs to refer to original instance. + When there are multiple selected series in the input, the first series' containing study will + be used for retrieving DICOM Study module attributes, e.g. StudyInstanceUID. + + Raises: + FileNotFoundError: When image object not in the input, and segmentation image file not found either. + ValueError: Neither image object nor image file's folder is in the input, or no selected series. + """ + + # Gets the input, prepares the output folder, and then delegates the processing. + study_selected_series_list = op_input.receive(self.input_name_series) + if not study_selected_series_list or len(study_selected_series_list) < 1: + raise ValueError(f"Missing input, [{StudySelectedSeries}].") + for study_selected_series in study_selected_series_list: + if not isinstance(study_selected_series, StudySelectedSeries): + raise ValueError(f"Element in input is not expected type, {StudySelectedSeries}.") + + seg_image = op_input.receive(self.input_name_seg) + + # In case the input is not the Image object, rather image file path. + if not isinstance(seg_image, (Image, np.ndarray)) and (isinstance(seg_image, (Path, str))): + seg_image_file, _ = self.select_input_file(str(seg_image)) + if Path(seg_image_file).is_file(): + seg_image = self._image_file_to_numpy(seg_image_file) + else: + raise ValueError("Input 'seg_image' is not an Image or a path.") + + # If the optional named input, output_folder, has content, use it instead of the one set on the object. + # Since this input is optional, must check if data present and if Path or str. + output_folder = None + try: + output_folder = op_input.receive(self.input_name_output_folder) + except Exception: + pass + + if not output_folder or not isinstance(output_folder, (Path, str)): + output_folder = self.output_folder + + output_folder.mkdir(parents=True, exist_ok=True) + self.process_images(seg_image, study_selected_series_list, output_folder) + + def process_images( + self, image: Union[Image, Path], study_selected_series_list: List[StudySelectedSeries], output_dir: Path + ): + """ """ + + if isinstance(image, Image): + seg_image_numpy = image.asnumpy() + elif isinstance(image, (Path, str)): + seg_image_numpy = self._image_file_to_numpy(str(image)) + else: + if not isinstance(image, np.ndarray): + raise ValueError("'image' is not a numpy array, Image object, or supported image file.") + seg_image_numpy = image + + # Pick DICOM Series that was used as input for getting the seg image. + # For now, first one in the list. + for study_selected_series in study_selected_series_list: + if not isinstance(study_selected_series, StudySelectedSeries): + raise ValueError(f"Element in input is not expected type, {StudySelectedSeries}.") + selected_series = study_selected_series.selected_series[0] + dicom_series = selected_series.series + self.create_dicom_seg(seg_image_numpy, dicom_series, output_dir) + break + + def create_dicom_seg(self, image: np.ndarray, dicom_series: DICOMSeries, output_dir: Path): + # Generate SOP instance UID, and use it as dcm file name too + seg_sop_instance_uid = hd.UID() # generate_uid() can be used too. + + output_dir.mkdir(parents=True, exist_ok=True) # Bubble up the exception if fails. + output_path = output_dir / f"{seg_sop_instance_uid}{DICOMSegmentationWriterOperator.DCM_EXTENSION}" + + dicom_dataset_list = [i.get_native_sop_instance() for i in dicom_series.get_sop_instances()] + + try: + version_str = get_sdk_semver() # SDK Version + except Exception: + version_str = "" # Fall back to blank for unknown version + + seg = hd.seg.Segmentation( + source_images=dicom_dataset_list, + pixel_array=image, + segmentation_type=hd.seg.SegmentationTypeValues.BINARY, + segment_descriptions=self._seg_descs, + series_instance_uid=hd.UID(), + series_number=random_with_n_digits(4), + sop_instance_uid=seg_sop_instance_uid, + instance_number=1, + manufacturer="The MONAI Consortium", + manufacturer_model_name="MONAI Deploy App SDK", + software_versions=version_str, + device_serial_number="0000", + omit_empty_frames=self._omit_empty_frames, + ) + + # Adding a few tags that are not in the Dataset + # Also try to set the custom tags that are of string type + dt_now = datetime.datetime.now() + seg.SeriesDate = DA(dt_now.strftime("%Y%m%d")) + seg.SeriesTime = TM(dt_now.strftime("%H%M%S")) + seg.TimezoneOffsetFromUTC = ( + dt_now.astimezone().isoformat()[-6:].replace(":", "") + ) # '2022-09-27T22:36:20.143857-07:00' + + if self._custom_tags: + for k, v in self._custom_tags.items(): + if isinstance(k, str) and isinstance(v, str): + try: + if k in seg: + data_element = seg.data_element(k) + if data_element: + data_element.value = v + else: + seg.update({k: v}) # type: ignore + except Exception as ex: + # Best effort for now. + logging.warning(f"Tag {k} was not written, due to {ex}") + + # write model info + # code copied from write_common_modules method in monai.deploy.operators.dicom_utils + + # Contributing Equipment Sequence + # The Creator shall describe each algorithm that was used to generate the results in the + # Contributing Equipment Sequence (0018,A001). Multiple items may be included. The Creator + # shall encode the following details in the Contributing Equipment Sequence: + # • Purpose of Reference Code Sequence (0040,A170) shall be (Newcode1, 99IHE, 1630 "Processing Algorithm") + # • Manufacturer (0008,0070) + # • Manufacturer’s Model Name (0008,1090) + # • Software Versions (0018,1020) + # • Device UID (0018,1002) + + if self.model_info: + # First create the Purpose of Reference Code Sequence + seq_purpose_of_reference_code = PyDicomSequence() + seg_purpose_of_reference_code = Dataset() + seg_purpose_of_reference_code.CodeValue = "Newcode1" + seg_purpose_of_reference_code.CodingSchemeDesignator = "99IHE" + seg_purpose_of_reference_code.CodeMeaning = "Processing Algorithm" + seq_purpose_of_reference_code.append(seg_purpose_of_reference_code) + + seq_contributing_equipment = PyDicomSequence() + seg_contributing_equipment = Dataset() + seg_contributing_equipment.PurposeOfReferenceCodeSequence = seq_purpose_of_reference_code + # '(121014, DCM, “Device Observer Manufacturer")' + seg_contributing_equipment.Manufacturer = self.model_info.creator + # u'(121015, DCM, “Device Observer Model Name")' + seg_contributing_equipment.ManufacturerModelName = self.model_info.name + # u'(111003, DCM, “Algorithm Version")' + seg_contributing_equipment.SoftwareVersions = self.model_info.version + seg_contributing_equipment.DeviceUID = self.model_info.uid # u'(121012, DCM, “Device Observer UID")' + seq_contributing_equipment.append(seg_contributing_equipment) + seg.ContributingEquipmentSequence = seq_contributing_equipment + + seg.save_as(output_path) + + try: + # Test reading back + _ = self._read_from_dcm(str(output_path)) + except Exception as ex: + print("DICOMSeg creation failed. Error:\n{}".format(ex)) + raise + + def _read_from_dcm(self, file_path: str): + """Read dcm file into pydicom Dataset + + Args: + file_path (str): The path to dcm file + """ + return dcmread(file_path) + + def select_input_file(self, input_folder, extensions=SUPPORTED_EXTENSIONS): + """Select the input files based on supported extensions. + + Args: + input_folder (string): the path of the folder containing the input file(s) + extensions (array): the supported file formats identified by the extensions. + + Returns: + file_path (string) : The path of the selected file + ext (string): The extension of the selected file + """ + + def which_supported_ext(file_path, extensions): + for ext in extensions: + if file_path.casefold().endswith(ext.casefold()): + return ext + return None + + if os.path.isdir(input_folder): + for file_name in os.listdir(input_folder): + file_path = os.path.join(input_folder, file_name) + if os.path.isfile(file_path): + ext = which_supported_ext(file_path, extensions) + if ext: + return file_path, ext + raise IOError("No supported input file found ({})".format(extensions)) + elif os.path.isfile(input_folder): + ext = which_supported_ext(input_folder, extensions) + if ext: + return input_folder, ext + else: + raise FileNotFoundError("{} is not found.".format(input_folder)) + + def _image_file_to_numpy(self, input_path: str): + """Converts image file to numpy""" + + img = sitk.ReadImage(input_path) + data_np = sitk.GetArrayFromImage(img) + if data_np is None: + raise RuntimeError("Failed to convert image file to numpy: {}".format(input_path)) + return data_np.astype(np.uint8) + + +def random_with_n_digits(n): + assert isinstance(n, int), "Argument n must be a int." + n = n if n >= 1 else 1 + range_start = 10 ** (n - 1) + range_end = (10**n) - 1 + return randint(range_start, range_end) + + +def test(): + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator + from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator + + current_file_dir = Path(__file__).parent.resolve() + data_path = current_file_dir.joinpath("../../../inputs/spleen_ct_tcia") + out_dir = Path.cwd() / "output_seg_op" + segment_descriptions = [ + SegmentDescription( + segment_label="Spleen", + segmented_property_category=codes.SCT.Organ, + segmented_property_type=codes.SCT.Spleen, + algorithm_name="Test algorithm", + algorithm_family=codes.DCM.ArtificialIntelligence, + algorithm_version="0.0.2", + ) + ] + + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="dcm_loader") + series_selector = DICOMSeriesSelectorOperator(fragment, name="series_selector") + dcm_to_volume_op = DICOMSeriesToVolumeOperator(fragment, name="series_to_vol") + seg_writer = DICOMSegmentationWriterOperator( + fragment, segment_descriptions=segment_descriptions, output_folder=out_dir, name="seg_writer" + ) + + # Testing with more granular functions + study_list = loader.load_data_to_studies(data_path.absolute()) + series = study_list[0].get_all_series()[0] + + dcm_to_volume_op.prepare_series(series) + voxels = dcm_to_volume_op.generate_voxel_data(series) + metadata = dcm_to_volume_op.create_metadata(series) + image = dcm_to_volume_op.create_volumetric_image(voxels, metadata) + # Very crude thresholding + image_numpy = (image.asnumpy() > 400).astype(np.uint8) + + seg_writer.create_dicom_seg(image_numpy, series, out_dir) + + # Testing with the main entry functions + study_list = loader.load_data_to_studies(data_path.absolute()) + study_selected_series_list = series_selector.filter(None, study_list) + image = dcm_to_volume_op.convert_to_image(study_selected_series_list) + # Very crude thresholding + image_numpy = (image.asnumpy() > 400).astype(np.uint8) + image = Image(image_numpy) + seg_writer.process_images(image, study_selected_series_list, out_dir) + + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_selector_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_selector_operator.py new file mode 100644 index 00000000..8eb53297 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_selector_operator.py @@ -0,0 +1,636 @@ +# Copyright 2021-2025 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import numbers +import re +from json import loads as json_loads +from typing import List + +import numpy as np + +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series import DICOMSeries +from monai.deploy.core.domain.dicom_series_selection import SelectedSeries, StudySelectedSeries +from monai.deploy.core.domain.dicom_study import DICOMStudy + + +class DICOMSeriesSelectorOperator(Operator): + """This operator selects a list of DICOM Series in a DICOM Study for a given set of selection rules. + + Named input: + dicom_study_list: A list of DICOMStudy objects. + + Named output: + study_selected_series_list: A list of StudySelectedSeries objects. Downstream receiver optional. + + This class can be considered a base class, and a derived class can override the 'filter' function with + custom logic. + + In its default implementation, this class + 1. selects a series or all matched series within the scope of a study in a list of studies + 2. uses rules defined in JSON string, see below for details + 3. supports DICOM Study and Series module attribute matching + 4. supports multiple named selections, in the scope of each DICOM study + 5. outputs a list of StudySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated) + + The selection rules are defined in JSON, + 1. attribute "selections" value is a list of selections + 2. each selection has a "name", and its "conditions" value is a list of matching criteria + 3. each condition uses the implicit equal operator; in addition, the following are supported: + - regex, relational, and range matching for float and int types + - regex matching for str type + - inclusion and exclusion matching for set type + - image orientation check for the ImageOrientationPatient tag + 4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties + + An example selection rules: + { + "selections": [ + { + "name": "CT Series 1", + "conditions": { + "StudyDescription": "(?i)^Spleen", + "Modality": "(?i)CT", + "SeriesDescription": "(?i)^No series description|(.*?)" + } + }, + { + "name": "CT Series 2", + "conditions": { + "Modality": "CT", + "BodyPartExamined": "Abdomen", + "SeriesDescription" : "Not to be matched. For illustration only." + } + }, + { + "name": "CT Series 3", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"], + "SliceThickness": [3, 5] + } + }, + { + "name": "CT Series 4", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageOrientationPatient": "Axial", + "SliceThickness": [2, ">"] + } + }, + { + "name": "CT Series 5", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "!SECONDARY"] + } + } + ] + } + """ + + def __init__( + self, + fragment: Fragment, + *args, + rules: str = "", + all_matched: bool = False, + sort_by_sop_instance_count: bool = False, + **kwargs, + ) -> None: + """Instantiate an instance. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + rules (Text): Selection rules in JSON string. + all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. + """ + + # Delay loading the rules as JSON string till compute time. + self._rules_json_str = rules if rules and rules.strip() else None + self._all_matched = all_matched # all_matched + self._sort_by_sop_instance_count = sort_by_sop_instance_count # sort_by_sop_instance_count + self.input_name_study_list = "dicom_study_list" + self.output_name_selected_series = "study_selected_series_list" + + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + spec.input(self.input_name_study_list) + spec.output(self.output_name_selected_series).condition(ConditionType.NONE) # Receiver optional + + # Can use the config file to alter the selection rules per app run + # spec.param("selection_rules") + + def compute(self, op_input, op_output, context): + """Performs computation for this operator.""" + + dicom_study_list = op_input.receive(self.input_name_study_list) + selection_rules = self._load_rules() if self._rules_json_str else None + study_selected_series = self.filter( + selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count + ) + + # Log Series Description and Series Instance UID of the first selected DICOM Series (i.e. the one to be used for inference) + if study_selected_series and len(study_selected_series) > 0: + inference_study = study_selected_series[0] + if inference_study.selected_series and len(inference_study.selected_series) > 0: + inference_series = inference_study.selected_series[0].series + logging.info("Series Selection finalized") + logging.info( + f"Series Description of selected DICOM Series for inference: {inference_series.SeriesDescription}" + ) + logging.info( + f"Series Instance UID of selected DICOM Series for inference: {inference_series.SeriesInstanceUID}" + ) + + op_output.emit(study_selected_series, self.output_name_selected_series) + + def filter( + self, selection_rules, dicom_study_list, all_matched: bool = False, sort_by_sop_instance_count: bool = False + ) -> List[StudySelectedSeries]: + """Selects the series with the given matching rules. + + If rules object is None, all series will be returned with series instance UID as the selection name. + + Supported matching logic: + Float + Int: exact matching, relational matching, range matching, and regex matching + String: matches case insensitive, if fails then tries RegEx search + String array (set): inclusive and exclusive (via !) matching as subsets, case insensitive + ImageOrientationPatient tag: image orientation (Axial, Coronal, Sagittal) matching + + Args: + selection_rules (object): JSON object containing the matching rules. + dicom_study_list (list): A list of DICOMStudy objects. + all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. + + Returns: + list: A list of objects of type StudySelectedSeries. + + Raises: + ValueError: If the selection_rules object does not contain "selections" attribute. + """ + + if not dicom_study_list or len(dicom_study_list) < 1: + return [] + + if not selection_rules: + # Return all series if no selection rules are supplied + logging.warn("No selection rules given; select all series.") + return self._select_all_series(dicom_study_list) + + selections = selection_rules.get("selections", None) # TODO type is not json now. + # If missing selections in the rules then it is an error. + if not selections: + raise ValueError('Expected "selections" not found in the rules.') + + study_selected_series_list = [] # List of StudySelectedSeries objects + + for study in dicom_study_list: + study_selected_series = StudySelectedSeries(study) + for selection in selections: + # Get the selection name. Blank name will be handled by the SelectedSeries + selection_name = selection.get("name", "").strip() + logging.info(f"Finding series for Selection named: {selection_name}") + + # Skip if no selection conditions are provided. + conditions = selection.get("conditions", None) + if not conditions: + continue + + # Select only the first series that matches the conditions, list of one + series_list = self._select_series(conditions, study, all_matched, sort_by_sop_instance_count) + if series_list and len(series_list) > 0: + for series in series_list: + selected_series = SelectedSeries(selection_name, series, None) # No Image obj yet. + study_selected_series.add_selected_series(selected_series) + + if len(study_selected_series.selected_series) > 0: + study_selected_series_list.append(study_selected_series) + + return study_selected_series_list + + def _load_rules(self): + return json_loads(self._rules_json_str) if self._rules_json_str else None + + def _select_all_series(self, dicom_study_list: List[DICOMStudy]) -> List[StudySelectedSeries]: + """Select all series in studies + + Returns: + list: list of StudySelectedSeries objects + """ + + study_selected_series_list = [] + for study in dicom_study_list: + logging.info(f"Working on study, instance UID: {study.StudyInstanceUID}") + study_selected_series = StudySelectedSeries(study) + for series in study.get_all_series(): + logging.info(f"Working on series, instance UID: {str(series.SeriesInstanceUID)}") + selected_series = SelectedSeries("", series, None) # No selection name or Image obj. + study_selected_series.add_selected_series(selected_series) + study_selected_series_list.append(study_selected_series) + return study_selected_series_list + + def _select_series( + self, attributes: dict, study: DICOMStudy, all_matched=False, sort_by_sop_instance_count=False + ) -> List[DICOMSeries]: + """Finds series whose attributes match the given attributes. + + Args: + attributes (dict): Dictionary of attributes for matching + all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. + + Returns: + List of DICOMSeries. At most one element if all_matched is False. + + Raises: + NotImplementedError: If the value_to_match type is not supported for matching or unsupported PatientPosition value. + """ + assert isinstance(attributes, dict), '"attributes" must be a dict.' + + logging.info(f"Searching study, : {study.StudyInstanceUID}\n # of series: {len(study.get_all_series())}") + study_attr = self._get_instance_properties(study) + + found_series = [] + for series in study.get_all_series(): + logging.info(f"Working on series, instance UID: {series.SeriesInstanceUID}") + + # Combine Study and current Series properties for matching + series_attr = self._get_instance_properties(series) + series_attr.update(study_attr) + + matched = True + # Simple matching on attribute value + for key, value_to_match in attributes.items(): + logging.info(f" On attribute: {key!r} to match value: {value_to_match!r}") + # Ignore None + if not value_to_match: + continue + # Try getting the attribute value from Study and current Series prop dict + attr_value = series_attr.get(key, None) + logging.info(f" Series attribute {key} value: {attr_value}") + + # If not found, try the best at the native instance level for string VR + # This is mainly for attributes like ImageType + if not attr_value: + try: + # Can use some enhancements, especially multi-value where VM > 1 + elem = series.get_sop_instances()[0].get_native_sop_instance()[key] + if elem.VM > 1: + attr_value = [elem.repval] # repval: str representation of the element’s value + else: + attr_value = elem.value # element's value + + logging.info(f" Instance level attribute {key} value: {attr_value}") + series_attr.update({key: attr_value}) + except Exception: + logging.info(f" Attribute {key} not at instance level either") + + if not attr_value: + logging.info(f" Missing attribute: {key!r}") + # CT Liver-Spleen specific case + # for our rules, SeriesDescription check can only rule out series that has undesired values + # Type 3 tag for CT modality - if tag absent, don't rule out series + if key == "SeriesDescription": + logging.warning(" Due to negative check per our rules, not ruling out series") + matched = True + else: + matched = False + # Image orientation check + elif key == "ImageOrientationPatient": + patient_position = series_attr.get("PatientPosition") + if patient_position is None: + raise NotImplementedError( + "PatientPosition tag absent; value required for image orientation calculation" + ) + if patient_position not in ("HFP", "HFS", "HFDL", "HFDR", "FFP", "FFS", "FFDL", "FFDR"): + raise NotImplementedError(f"No support for PatientPosition value {patient_position}") + matched = self._match_image_orientation(value_to_match, attr_value) + elif isinstance(attr_value, (float, int)): + matched = self._match_numeric_condition(value_to_match, attr_value) + elif isinstance(attr_value, str): + matched = attr_value.casefold() == (value_to_match.casefold()) + if not matched: + # For str, also try RegEx search to check for a match anywhere in the string + # unless the user constrains it in the expression. + if re.search(value_to_match, attr_value, re.IGNORECASE): + matched = True + elif isinstance(attr_value, list): + # Assume multi value string attributes + meta_data_list = str(attr_value).lower() + if isinstance(value_to_match, list): + value_set = {str(element).lower() for element in value_to_match} + # split inclusion and exclusion matches using ! indicator + include_terms = {v for v in value_set if not v.startswith("!")} + exclude_terms = {v[1:] for v in value_set if v.startswith("!")} + matched = all(term in meta_data_list for term in include_terms) and all( + term not in meta_data_list for term in exclude_terms + ) + elif isinstance(value_to_match, (str, numbers.Number)): + v = str(value_to_match).lower() + # ! indicates exclusion match + if v.startswith("!"): + matched = v[1:] not in meta_data_list + else: + matched = v in meta_data_list + else: + raise NotImplementedError( + f"No support for matching condition {value_to_match} (type: {type(value_to_match)})" + ) + + if not matched: + logging.info("This series does not match the selection conditions") + break + + if matched: + logging.info(f"Selected Series, UID: {series.SeriesInstanceUID}") + found_series.append(series) + + if not all_matched: + return found_series + + # If sorting indicated and multiple series found, sort series in descending SOP instance count + if sort_by_sop_instance_count and len(found_series) > 1: + logging.info( + "Multiple series matched the selection criteria; choosing series with the highest number of DICOM images." + ) + found_series.sort(key=lambda x: len(x.get_sop_instances()), reverse=True) + + return found_series + + def _match_numeric_condition(self, value_to_match, attr_value): + """ + Helper method to match numeric conditions, supporting relational, inclusive range, regex, and exact match checks. + + Supported formats: + - [val, ">"]: match if attr_value > val + - [val, ">="]: match if attr_value >= val + - [val, "<"]: match if attr_value < val + - [val, "<="]: match if attr_value <= val + - [val, "!="]: match if attr_value != val + - [min_val, max_val]: inclusive range check + - "regex": regular expression match + - number: exact match + + Args: + value_to_match (Union[list, str, int, float]): The condition to match against. + attr_value (Union[int, float]): The attribute value from the series. + + Returns: + bool: True if the attribute value matches the condition, else False. + + Raises: + NotImplementedError: If the value_to_match condition is not supported for numeric matching. + """ + + if isinstance(value_to_match, list): + # Relational operator check: >, >=, <, <=, != + if len(value_to_match) == 2 and isinstance(value_to_match[1], str): + val = float(value_to_match[0]) + op = value_to_match[1] + if op == ">": + return attr_value > val + elif op == ">=": + return attr_value >= val + elif op == "<": + return attr_value < val + elif op == "<=": + return attr_value <= val + elif op == "!=": + return attr_value != val + else: + raise NotImplementedError( + f"Unsupported relational operator {op!r} in numeric condition. Must be one of: '>', '>=', '<', '<=', '!='" + ) + + # Inclusive range check + elif len(value_to_match) == 2 and all(isinstance(v, (int, float)) for v in value_to_match): + return value_to_match[0] <= attr_value <= value_to_match[1] + + else: + raise NotImplementedError(f"No support for numeric matching condition {value_to_match}") + + # Regular expression match + elif isinstance(value_to_match, str): + return bool(re.fullmatch(value_to_match, str(attr_value))) + + # Exact numeric match + elif isinstance(value_to_match, (int, float)): + return value_to_match == attr_value + + else: + raise NotImplementedError(f"No support for numeric matching on this type: {type(value_to_match)}") + + def _match_image_orientation(self, value_to_match, attr_value): + """ + Helper method to calculate and match the image orientation using the ImageOrientationPatient tag. + The following PatientPosition values are supported and have been tested: + - "HFP" + - "HFS" + - "HFDL" + - "HFDR" + - "FFP" + - "FFS" + - "FFDL" + - "FFDR" + + Supported image orientation inputs for matching (case-insensitive): + - "Axial" + - "Coronal" + - "Sagittal" + + Args: + value_to_match (str): The image orientation condition to match against. + attr_value (List[str]): Raw ImageOrientationPatient tag value from the series. + + Returns: + bool: True if the computed orientation matches the expected orientation, else False. + + Raises: + ValueError: If the expected orientation is invalid or the normal vector cannot be computed. + """ + + # Validate image orientation to match input + value_to_match = value_to_match.strip().lower().capitalize() + allowed_orientations = {"Axial", "Coronal", "Sagittal"} + if value_to_match not in allowed_orientations: + raise ValueError(f"Invalid orientation string {value_to_match!r}. Must be one of: {allowed_orientations}") + + # Format ImageOrientationPatient tag value as an array and grab row and column cosines + iop_str = attr_value[0].strip("[]") + iop = [float(x.strip()) for x in iop_str.split(",")] + row_cosines = np.array(iop[:3], dtype=np.float64) + col_cosines = np.array(iop[3:], dtype=np.float64) + + # Validate DICOM constraints (normal row and column cosines + should be orthogonal) + # Throw warnings if tolerance exceeded + tolerance = 1e-4 + row_norm = np.linalg.norm(row_cosines) + col_norm = np.linalg.norm(col_cosines) + dot_product = np.dot(row_cosines, col_cosines) + + if abs(row_norm - 1.0) > tolerance: + logging.warn(f"Row direction cosine normal is {row_norm}, deviates from 1 by more than {tolerance}") + if abs(col_norm - 1.0) > tolerance: + logging.warn(f"Column direction cosine normal is {col_norm}, deviates from 1 by more than {tolerance}") + if abs(dot_product) > tolerance: + logging.warn(f"Row and Column cosines are not orthogonal: dot product = {dot_product}") + + # Normalize row and column vectors + row_cosines /= np.linalg.norm(row_cosines) + col_cosines /= np.linalg.norm(col_cosines) + + # Compute and validate slice normal + normal = np.cross(row_cosines, col_cosines) + if np.linalg.norm(normal) == 0: + raise ValueError("Invalid normal vector computed from IOP") + + # Normalize the slice normal + normal /= np.linalg.norm(normal) + + # Identify the dominant image orientation + axis_labels = ["Sagittal", "Coronal", "Axial"] + major_axis = np.argmax(np.abs(normal)) + computed_orientation = axis_labels[major_axis] + + logging.info(f" Computed orientation from ImageOrientationPatient value: {computed_orientation}") + + return bool(computed_orientation == value_to_match) + + @staticmethod + def _get_instance_properties(obj: object): + if not obj: + return {} + else: + return {x: getattr(obj, x, None) for x in type(obj).__dict__ if isinstance(type(obj).__dict__[x], property)} + + +# Module functions +# Helper function to get console output of the selection content when testing the script +def _print_instance_properties(obj: object, pre_fix: str = "", print_val=True): + print(f"{pre_fix}Instance of {type(obj)}") + for attribute in [x for x in type(obj).__dict__ if isinstance(type(obj).__dict__[x], property)]: + attr_val = getattr(obj, attribute, None) + print(f"{pre_fix} {attribute}: {type(attr_val)} {attr_val if print_val else ''}") + + +def test(): + from pathlib import Path + + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + + current_file_dir = Path(__file__).parent.resolve() + data_path = current_file_dir.joinpath("../../../inputs/spleen_ct/dcm").absolute() + + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="loader_op") + selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") + study_list = loader.load_data_to_studies(data_path) + sample_selection_rule = json_loads(Sample_Rules_Text) + print(f"Selection rules in JSON:\n{sample_selection_rule}") + study_selected_series_list = selector.filter(sample_selection_rule, study_list) + + for sss_obj in study_selected_series_list: + _print_instance_properties(sss_obj, pre_fix="", print_val=False) + study = sss_obj.study + pre_fix = " " + print(f"{pre_fix}==== Details of the study ====") + _print_instance_properties(study, pre_fix, print_val=False) + print(f"{pre_fix}==============================") + + # The following commented code block accesses and prints the flat list of all selected series. + # for ss_obj in sss_obj.selected_series: + # pre_fix = " " + # _print_instance_properties(ss_obj, pre_fix, print_val=False) + # pre_fix = " " + # print(f"{pre_fix}==== Details of the series ====") + # _print_instance_properties(ss_obj, pre_fix) + # print(f"{pre_fix}===============================") + + # The following block uses hierarchical grouping by selection name, and prints the list of series for each. + for selection_name, ss_list in sss_obj.series_by_selection_name.items(): + pre_fix = " " + print(f"{pre_fix}Selection name: {selection_name}") + for ss_obj in ss_list: + pre_fix = " " + _print_instance_properties(ss_obj, pre_fix, print_val=False) + print(f"{pre_fix}==== Details of the series ====") + _print_instance_properties(ss_obj, pre_fix) + print(f"{pre_fix}===============================") + + print(f" A total of {len(sss_obj.selected_series)} series selected for study {study.StudyInstanceUID}") + + +# Sample rule used for testing +Sample_Rules_Text = """ +{ + "selections": [ + { + "name": "CT Series 1", + "conditions": { + "StudyDescription": "(?i)^Spleen", + "Modality": "(?i)CT", + "SeriesDescription": "(?i)^No series description|(.*?)" + } + }, + { + "name": "CT Series 2", + "conditions": { + "Modality": "CT", + "BodyPartExamined": "Abdomen", + "SeriesDescription" : "Not to be matched" + } + }, + { + "name": "CT Series 3", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"], + "SliceThickness": [3, 5] + } + }, + { + "name": "CT Series 4", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)MR", + "ImageOrientationPatient": "Axial", + "SliceThickness": [2, ">"] + } + }, + { + "name": "CT Series 5", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "!SECONDARY"] + } + } + ] +} +""" + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_to_volume_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_to_volume_operator.py new file mode 100644 index 00000000..918f9d6e --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_series_to_volume_operator.py @@ -0,0 +1,515 @@ +# Copyright 2021-2025 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import logging +import math +from typing import Dict, List, Union + +import numpy as np + +from monai.deploy.utils.importutil import optional_import + +apply_presentation_lut, _ = optional_import("pydicom.pixels", name="apply_presentation_lut") +apply_rescale, _ = optional_import("pydicom.pixels", name="apply_rescale") + +import decoder_nvimgcodec + +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +from monai.deploy.core.domain.image import Image + + +class DICOMSeriesToVolumeOperator(Operator): + """This operator converts an instance of DICOMSeries into an Image object. + + The loaded Image Object can be used for further processing via other operators. + The data array will be a 3D image NumPy array with index order of `DHW`. + Channel is limited to 1 as of now, and `C` is absent in the NumPy array. + + Named Input: + study_selected_series_list: List of StudySelectedSeries. + Named Output: + image: Image object. + """ + + # Use constants instead of enums in monai to avoid dependency at this level. + MONAI_UTIL_ENUMS_SPACEKEYS_RAS = "RAS" + MONAI_UTIL_ENUMS_SPACEKEYS_LPS = "LPS" + MONAI_TRANSFORMS_SPATIAL_METADATA_NAME = "space" + METADATA_SPACE_RAS = {MONAI_TRANSFORMS_SPATIAL_METADATA_NAME: MONAI_UTIL_ENUMS_SPACEKEYS_RAS} + METADATA_SPACE_LPS = {MONAI_TRANSFORMS_SPATIAL_METADATA_NAME: MONAI_UTIL_ENUMS_SPACEKEYS_LPS} + ATTRIBUTE_NIFTI_AFFINE = "nifti_affine_transform" + ATTRIBUTE_DICOM_AFFINE = "dicom_affine_transform" + + def __init__(self, fragment: Fragment, *args, affine_lps_to_ras: bool = True, **kwargs): + """Create an instance for a containing application object. + + This operator converts instances of DICOMSeries into an Image object. + The loaded Image Object can be used for further processing via other operators. + The data array will be a 3D image NumPy array with index order of `DHW`. + Channel is limited to 1 as of now, and `C` is absent in the NumPy array. + + This operator registers `nvimgcodec` based compressed pixel data decoder plugin with Pydicom + at application startup to support and improve the performance of decoding DICOM files with compressed + pixel data of in JPEG, JPEG 2000, and HTJ2K, irrespective of if python-gdcm, Python libjpg and openjpeg + based decoder plugins are available at runtime. + + Registering the decoder plugin is all automatic and does not require any additional change in user's application + except for adding a dependency on the `nvimgcodec-cu12` and `nvidia-nvjpeg2k-cu12` packages (suffix of cu12 means + CUDA 12.0 though cu13 is also supported). + + Named Input: + study_selected_series_list: List of StudySelectedSeries. + Named Output: + image: Image object. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + affine_lps_to_ras (bool): If true, the affine transform in the image metadata is RAS oriented, + otherwise it is LPS oriented. Default is True. + """ + + self.input_name_series = "study_selected_series_list" + self.output_name_image = "image" + self.affine_lps_to_ras = affine_lps_to_ras + if not decoder_nvimgcodec.register_as_decoder_plugin(): + logging.warning("The nvimgcodec decoder plugin did not register successfully.") + + # Need to call the base class constructor last + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + spec.input(self.input_name_series) + spec.output(self.output_name_image).condition(ConditionType.NONE) + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O.""" + + study_selected_series_list = op_input.receive(self.input_name_series) + + # TODO: need to get a solution to correctly annotate and consume multiple image outputs. + # For now, only supports the one and only one selected series. + image = self.convert_to_image(study_selected_series_list) + op_output.emit(image, self.output_name_image) + + def convert_to_image(self, study_selected_series_list: List[StudySelectedSeries]) -> Union[Image, None]: + """Extracts the pixel data from a DICOM Series and other attributes to create an Image object""" + # For now, only supports the one and only one selected series. + if not study_selected_series_list or len(study_selected_series_list) < 1: + raise ValueError("Missing expected input 'study_selected_series_list'") + + for study_selected_series in study_selected_series_list: + if not isinstance(study_selected_series, StudySelectedSeries): + raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.") + selected_series = study_selected_series.selected_series[0] + dicom_series = selected_series.series + selection_name = selected_series.selection_name + self.prepare_series(dicom_series) + metadata = self.create_metadata(dicom_series) + + # Add to the metadata the DICOMStudy properties and selection metadata + metadata.update(self._get_instance_properties(study_selected_series.study)) + selection_metadata = {"selection_name": selection_name} + metadata.update(selection_metadata) + # The affine transform and the coordinate space are set based on the flag affine_lps_to_ras. + # If the flag is true, the NIFTI affine (RAS) is used, otherwise the DICOM affine (LPS) is used. + if self.affine_lps_to_ras: + if hasattr(dicom_series, self.ATTRIBUTE_NIFTI_AFFINE): + metadata["affine"] = getattr(dicom_series, self.ATTRIBUTE_NIFTI_AFFINE) + metadata.update(self.METADATA_SPACE_RAS) + else: + if hasattr(dicom_series, self.ATTRIBUTE_DICOM_AFFINE): + metadata["affine"] = getattr(dicom_series, self.ATTRIBUTE_DICOM_AFFINE) + metadata.update(self.METADATA_SPACE_LPS) + + voxel_data = self.generate_voxel_data(dicom_series) + image = self.create_volumetric_image(voxel_data, metadata) + + # Now it is time to assign the converted image to the SelectedSeries obj + selected_series.image = image + + # Break out since limited to one series/image for now + break + + # TODO: This needs to be updated once allowed to output multiple Image objects + return study_selected_series_list[0].selected_series[0].image + + def generate_voxel_data(self, series): + """Applies rescale slope and rescale intercept to the pixels. + + Supports monochrome image only for now. Photometric Interpretation attribute, + tag (0028,0004), is considered. Both MONOCHROME2 (IDENTITY) and MONOCHROME1 (INVERSE) + result in an output image where The minimum sample value is intended to be displayed as black. + + Args: + series: DICOM Series for which the pixel data needs to be extracted. + + Returns: + A 3D numpy tensor representing the volumetric data. + """ + + def _get_rescaled_pixel_array(sop_instance): + # Use pydicom utility to apply a modality lookup table or rescale operator to the pixel array. + # The pydicom Dataset is required which can be obtained from the first slice's native SOP instance. + # If Modality LUT is present the return array is of np.uint8 or np.uint16, and if Rescale + # Intercept and Rescale Slope are present, np.float64. + # If the pixel array is already in the correct type, the return array is the same as the input array. + + if not sop_instance: + return np.array([]) + + native_sop = None + try: + native_sop = sop_instance.get_native_sop_instance() + rescaled_pixel_data = apply_rescale(sop_instance.get_pixel_array(), native_sop) + # In our use cases, pixel data will be interpreted as if MONOCHROME2, hence need to + # apply the presentation lut. + rescaled_pixel_data = apply_presentation_lut(rescaled_pixel_data, native_sop) + except Exception as e: + logging.error(f"Failed to apply rescale to DICOM volume: {e}") + raise RuntimeError("Failed to apply rescale to DICOM volume.") from e + + # The following tests are expecting the array is already of the Numpy type. + if rescaled_pixel_data.dtype == np.uint8 or rescaled_pixel_data.dtype == np.uint16: + logging.debug("Rescaled pixel array is already of type uint8 or uint16.") + # Check if casting to uint16 and back to float results in the same values. + elif np.all(rescaled_pixel_data > 0) and np.array_equal( + rescaled_pixel_data, rescaled_pixel_data.astype(np.uint16) + ): + logging.debug("Rescaled pixel array can be safely casted to uint16 with equivalence test.") + rescaled_pixel_data = rescaled_pixel_data.astype(dtype=np.uint16) + # Check if casting to int16 and back to float results in the same values. + elif np.array_equal(rescaled_pixel_data, rescaled_pixel_data.astype(np.int16)): + logging.debug("Rescaled pixel array can be safely casted to int16 with equivalence test.") + rescaled_pixel_data = rescaled_pixel_data.astype(dtype=np.int16) + # Check casting to float32 with equivalence test + elif np.array_equal(rescaled_pixel_data, rescaled_pixel_data.astype(np.float32)): + logging.debug("Rescaled pixel array can be safely casted to float32 with equivalence test.") + rescaled_pixel_data = rescaled_pixel_data.astype(np.float32) + else: + logging.debug("Rescaled pixel data remains as of type float64.") + + return rescaled_pixel_data + + slices = series.get_sop_instances() + # The sop_instance get_pixel_array() returns a 2D NumPy array with index order + # of `HW`. The pixel array of all instances will be stacked along the first axis, + # so the final 3D NumPy array will have index order of [DHW]. This is consistent + # with the NumPy array returned from the ITK GetArrayViewFromImage on the image + # loaded from the same DICOM series. + # The below code loads all slice pixel data into a list of NumPy arrays in memory + # before stacking them into a single 3D volume. This can be inefficient for series + # with many slices. + if not slices: + return np.array([]) + + # Get shape and dtype from the first slice to pre-allocate numpy array. + try: + first_slice_pixel_array = _get_rescaled_pixel_array(slices[0]) + vol_shape = (len(slices),) + first_slice_pixel_array.shape + dtype = first_slice_pixel_array.dtype + except Exception as e: + logging.error(f"Failed to get pixel array from the first slice: {e}") + raise + + # Pre-allocate the volume data array. + vol_data = np.empty(vol_shape, dtype=dtype) + vol_data[0] = first_slice_pixel_array + + # Read subsequent slices directly into the pre-allocated array. + for i, s in enumerate(slices[1:], 1): + try: + vol_data[i] = _get_rescaled_pixel_array(s) + except Exception as e: + logging.error(f"Failed to get pixel array from slice {i}: {e}") + raise + + return vol_data + + def create_volumetric_image(self, vox_data, metadata): + """Creates an instance of 3D image. + + Args: + vox_data: A numpy array representing the volumetric data. + metadata: DICOM attributes in a dictionary. + + Returns: + An instance of Image object. + """ + image = Image(vox_data, metadata) + return image + + def prepare_series(self, series): + """Computes the slice normal for each slice and then projects the first voxel of each + slice on that slice normal. + + It computes the distance of that point from the origin of the patient coordinate system along the slice normal. + It orders the slices in the series according to that distance. + + Args: + series: An instance of DICOMSeries. + """ + + if len(series._sop_instances) <= 1: + series.depth_pixel_spacing = 1.0 # Default to 1, e.g. for CR image, similar to (Simple) ITK + return + + slice_indices_to_be_removed = [] + depth_pixel_spacing = 0.0 + last_slice_normal = [0.0, 0.0, 0.0] + + for slice_index, slice in enumerate(series._sop_instances): + distance = 0.0 + point = [0.0, 0.0, 0.0] + slice_normal = [0.0, 0.0, 0.0] + slice_position = None + cosines = None + + try: + image_orientation_patient_de = slice[0x0020, 0x0037] + if image_orientation_patient_de is not None: + image_orientation_patient = image_orientation_patient_de.value + cosines = image_orientation_patient + except KeyError: + pass + + try: + image_poisition_patient_de = slice[0x0020, 0x0032] + if image_poisition_patient_de is not None: + image_poisition_patient = image_poisition_patient_de.value + slice_position = image_poisition_patient + except KeyError: + pass + + distance = 0.0 + + if (cosines is not None) and (slice_position is not None): + slice_normal[0] = cosines[1] * cosines[5] - cosines[2] * cosines[4] + slice_normal[1] = cosines[2] * cosines[3] - cosines[0] * cosines[5] + slice_normal[2] = cosines[0] * cosines[4] - cosines[1] * cosines[3] + + last_slice_normal = copy.deepcopy(slice_normal) + + i = 0 + while i < 3: + point[i] = slice_normal[i] * slice_position[i] + i += 1 + + distance += point[0] + point[1] + point[2] + + series._sop_instances[slice_index].distance = distance + series._sop_instances[slice_index].first_pixel_on_slice_normal = point + else: + logging.debug(f"Slice index to remove: {slice_index}") + slice_indices_to_be_removed.append(slice_index) + + logging.debug(f"Total slices before removal (if applicable): {len(series._sop_instances)}") + + # iterate in reverse order to avoid affecting subsequent indices after a deletion + for sl_index in sorted(slice_indices_to_be_removed, reverse=True): + del series._sop_instances[sl_index] + logging.info(f"Removed slice index: {sl_index}") + + logging.debug(f"Total slices after removal (if applicable): {len(series._sop_instances)}") + + series._sop_instances = sorted(series._sop_instances, key=lambda s: s.distance) + series.depth_direction_cosine = copy.deepcopy(last_slice_normal) + + if len(series._sop_instances) > 1: + p1 = series._sop_instances[0].first_pixel_on_slice_normal + p2 = series._sop_instances[1].first_pixel_on_slice_normal + depth_pixel_spacing = ( + (p1[0] - p2[0]) * (p1[0] - p2[0]) + + (p1[1] - p2[1]) * (p1[1] - p2[1]) + + (p1[2] - p2[2]) * (p1[2] - p2[2]) + ) + depth_pixel_spacing = math.sqrt(depth_pixel_spacing) + series.depth_pixel_spacing = depth_pixel_spacing + + s_1 = series._sop_instances[0] + s_n = series._sop_instances[-1] + num_slices = len(series._sop_instances) + self.compute_affine_transform(s_1, s_n, num_slices, series) + + def compute_affine_transform(self, s_1, s_n, n, series): + """Computes the affine transform for this series. It does it in both DICOM Patient oriented + coordinate system as well as the pne preferred by NIFTI standard. Accordingly, the two attributes + dicom_affine_transform and nifti_affine_transform are stored in the series instance. + + The Image Orientation Patient contains two triplets, [rx ry rz cx cy cz], which encode + direction cosines of the row and column of an image slice. The Image Position Patient of the first slice in + a volume, [x1 y1 z1], is the x, y, z coordinates of the upper-left corner voxel of the slice. These two + parameters define the location of the slice in PCS. To determine the location of a volume, the Image + Position Patient of another slice is normally needed. In practice, we tend to use the position of the last + slice in a volume, [xn yn zn]. The voxel size within the slice plane, [vr vc], is stored in object Pixel Spacing. + + Args: + s_1: A first slice in the series. + s_n: A last slice in the series. + n: A number of slices in the series. + series: An instance of DICOMSeries. + """ + + m1 = np.arange(1, 17, dtype=float).reshape(4, 4) + m2 = np.arange(1, 17, dtype=float).reshape(4, 4) + + image_orientation_patient = None + try: + image_orientation_patient_de = s_1[0x0020, 0x0037] + if image_orientation_patient_de is not None: + image_orientation_patient = image_orientation_patient_de.value + except KeyError: + pass + rx = image_orientation_patient[0] + ry = image_orientation_patient[1] + rz = image_orientation_patient[2] + cx = image_orientation_patient[3] + cy = image_orientation_patient[4] + cz = image_orientation_patient[5] + + vr = 0.0 + vc = 0.0 + try: + pixel_spacing_de = s_1[0x0028, 0x0030] + if pixel_spacing_de is not None: + vr = pixel_spacing_de.value[0] + vc = pixel_spacing_de.value[1] + except KeyError: + pass + + x1 = 0.0 + y1 = 0.0 + z1 = 0.0 + + xn = 0.0 + yn = 0.0 + zn = 0.0 + + ip1 = None + ipn = None + try: + ip1_de = s_1[0x0020, 0x0032] + ipn_de = s_n[0x0020, 0x0032] + ip1 = ip1_de.value + ipn = ipn_de.value + + except KeyError: + pass + + x1 = ip1[0] + y1 = ip1[1] + z1 = ip1[2] + + xn = ipn[0] + yn = ipn[1] + zn = ipn[2] + + m1[0, 0] = rx * vr + m1[0, 1] = cx * vc + m1[0, 2] = (xn - x1) / (n - 1) + m1[0, 3] = x1 + + m1[1, 0] = ry * vr + m1[1, 1] = cy * vc + m1[1, 2] = (yn - y1) / (n - 1) + m1[1, 3] = y1 + + m1[2, 0] = rz * vr + m1[2, 1] = cz * vc + m1[2, 2] = (zn - z1) / (n - 1) + m1[2, 3] = z1 + + m1[3, 0] = 0 + m1[3, 1] = 0 + m1[3, 2] = 0 + m1[3, 3] = 1 + + setattr(series, self.ATTRIBUTE_DICOM_AFFINE, m1) + + m2[0, 0] = -rx * vr + m2[0, 1] = -cx * vc + m2[0, 2] = -(xn - x1) / (n - 1) + m2[0, 3] = -x1 + + m2[1, 0] = -ry * vr + m2[1, 1] = -cy * vc + m2[1, 2] = -(yn - y1) / (n - 1) + m2[1, 3] = -y1 + + m2[2, 0] = rz * vr + m2[2, 1] = cz * vc + m2[2, 2] = (zn - z1) / (n - 1) + m2[2, 3] = z1 + + m2[3, 0] = 0 + m2[3, 1] = 0 + m2[3, 2] = 0 + m2[3, 3] = 1 + + setattr(series, self.ATTRIBUTE_NIFTI_AFFINE, m2) + + def create_metadata(self, series) -> Dict: + """Collects all relevant metadata from the DICOM Series and creates a dictionary. + + Args: + series: An instance of DICOMSeries. + + Returns: + An instance of a dictionary containing metadata for the volumetric image. + """ + + # Set metadata with series properties that are not None. + metadata = {} + if series: + metadata = self._get_instance_properties(series) + return metadata + + @staticmethod + def _get_instance_properties(obj: object, not_none: bool = True) -> Dict: + prop_dict = {} + if obj: + for attribute in [x for x in type(obj).__dict__ if isinstance(type(obj).__dict__[x], property)]: + attr_val = getattr(obj, attribute, None) + if not_none: + if attr_val is not None: + prop_dict[attribute] = attr_val + else: + prop_dict[attribute] = attr_val + + return prop_dict + + +def test(): + from pathlib import Path + + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator + + current_file_dir = Path(__file__).parent.resolve() + data_path = current_file_dir.joinpath("../../../inputs/spleen_ct/dcm").absolute() + + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="loader_op") + series_selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") + vol_op = DICOMSeriesToVolumeOperator(fragment, name="series_to_vol_op") + + study_list = loader.load_data_to_studies(data_path) + study_selected_series_list = series_selector.filter(None, study_list) + image = vol_op.convert_to_image(study_selected_series_list) + + print(f"Image NumPy array shape (index order DHW): {image.asnumpy().shape}") + for k, v in image.metadata().items(): + print(f"{(k)}: {(v)}") + + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_text_sr_writer_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_text_sr_writer_operator.py new file mode 100644 index 00000000..16671f4f --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/dicom_text_sr_writer_operator.py @@ -0,0 +1,595 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from pathlib import Path +from typing import Dict, List, Optional, Set, Union + +from monai.deploy.utils.importutil import optional_import + +dcmread, _ = optional_import("pydicom", name="dcmread") +dcmwrite, _ = optional_import("pydicom.filewriter", name="dcmwrite") +_PYDICOM_UID = "pydicom.uid" +_PYDICOM_DATASET = "pydicom.dataset" +generate_uid, _ = optional_import(_PYDICOM_UID, name="generate_uid") +ImplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ImplicitVRLittleEndian") +ExplicitVRLittleEndian, _ = optional_import(_PYDICOM_UID, name="ExplicitVRLittleEndian") +Dataset, _ = optional_import(_PYDICOM_DATASET, name="Dataset") +FileDataset, _ = optional_import(_PYDICOM_DATASET, name="FileDataset") +Sequence, _ = optional_import("pydicom.sequence", name="Sequence") + +_CODE_MEANING_AREA = "square centimeters" + +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series import DICOMSeries +from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +from monai.deploy.operators.dicom_utils import EquipmentInfo, ModelInfo, save_dcm_file, write_common_modules +from monai.deploy.utils.version import get_sdk_semver + + +# @md.env(pip_packages=["pydicom >= 1.4.2", "monai"]) +class DICOMTextSRWriterOperator(Operator): + """Class to write DICOM Enhanced SR Instance with provided text input as a Content Sequence. + + Named inputs: + dict: dictionary content to be encapsulated as a Content Sequence in a DICOM instance file. + study_selected_series_list: Optional, DICOM series for copying metadata from. + + Named output: + None + + File output: + Generated DICOM instance file in the provided output folder. + """ + + # File extension for the generated DICOM Part 10 file. + DCM_EXTENSION = ".dcm" + # The default output folder for saving the generated DICOM instance file. + # DEFAULT_OUTPUT_FOLDER = Path(os.path.join(os.path.dirname(__file__))) / "output" + DEFAULT_OUTPUT_FOLDER = Path.cwd() / "output" + + def __init__( + self, + fragment: Fragment, + *args, + output_folder: Union[str, Path], + model_info: Optional[ModelInfo] = None, + copy_tags: bool = True, + equipment_info: Optional[EquipmentInfo] = None, + custom_tags: Optional[Dict[str, str]] = None, + included_fields: Optional[List[str]] = None, + report_code_value: str, + report_coding_scheme_designator: str, + report_code_meaning: str, + **kwargs, + ): + """Class to write DICOM Enhanced SR SOP Instance for AI textual result in memory or in a file. + + Args: + output_folder (str or Path): The folder for saving the generated DICOM instance file. + copy_tags (bool): True, default, for copying DICOM attributes from a provided DICOMSeries. + If True and no DICOMSeries obj provided, runtime exception is thrown. + model_info (ModelInfo): Object encapsulating model creator, name, version and UID. + equipment_info (EquipmentInfo, optional): Object encapsulating info for DICOM Equipment Module. + Defaults to None. + custom_tags (Dict[str, str], optional): Dictionary for setting custom DICOM tags using Keywords and str values only. + Defaults to None. + included_fields (List[str], optional): SR measurement names to include in the output. + Supports raw metric names such as "volume" + and fully-qualified output names such as + "liver.volume". Defaults to None, which + includes all supported fields. + report_code_value (str): DICOM Code Value for the report type. + report_coding_scheme_designator (str): DICOM Coding Scheme Designator for the report. + report_code_meaning (str): Human-readable meaning of the report code. + + Raises: + ValueError: If copy_tags is true and no DICOMSeries object provided, or + if result cannot be found either in memory or from file. + """ + self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) + + # Need to init the output folder until the execution context supports dynamic FS path + # Not trying to create the folder to avoid exception on init + self.output_folder = Path(output_folder) if output_folder else DICOMTextSRWriterOperator.DEFAULT_OUTPUT_FOLDER + self.copy_tags = copy_tags + self.model_info = model_info if model_info else ModelInfo() + self.equipment_info = equipment_info if equipment_info else EquipmentInfo() + self.custom_tags = custom_tags + self.included_fields = self._normalize_included_fields(included_fields) + self.report_code_value = report_code_value + self.report_coding_scheme_designator = report_coding_scheme_designator + self.report_code_meaning = report_code_meaning + self.input_name_dict = "dict" + self.input_name_dcm_series = "study_selected_series_list" + + # Set own Modality and SOP Class UID + # Modality, e.g., + # "OT" for PDF + # "SR" for Structured Report. + # Media Storage SOP Class UID, e.g., + # "1.2.840.10008.5.1.4.1.1.88.11" for Basic Text SR Storage + # "1.2.840.10008.5.1.4.1.1.88.22" for Enhanced SR + # "1.2.840.10008.5.1.4.1.1.104.1" for Encapsulated PDF Storage + # "1.2.840.10008.5.1.4.1.1.88.34" for Comprehensive 3D SR IOD + # "1.2.840.10008.5.1.4.1.1.66.4" for Segmentation Storage + # full list: https://dicom.nema.org/dicom/2013/output/chtml/part04/sect_i.4.html + self.modality_type = "SR" + self.sop_class_uid = "1.2.840.10008.5.1.4.1.1.88.22" # Enhanced SR + + # Equipment version may be different from contributing equipment version + try: + self.software_version_number = get_sdk_semver() # SDK Version + except Exception: + self.software_version_number = "" + self.operators_name = f"AI Algorithm {self.model_info.name}" + + super().__init__(fragment, *args, **kwargs) + + def _normalize_included_fields(self, included_fields: Optional[List[str]]) -> Optional[Set[str]]: + if not included_fields: + return None + + normalized_fields = {str(field).strip().lower() for field in included_fields if str(field).strip()} + return normalized_fields or None + + def _should_include_metric(self, biomarker_name: str, metric_name: Optional[str] = None) -> bool: + if not self.included_fields: + return True + + normalized_biomarker_name = str(biomarker_name).strip().lower() + candidate_names = {normalized_biomarker_name} + if metric_name: + normalized_metric_name = str(metric_name).strip().lower() + candidate_names.add(normalized_metric_name) + candidate_names.add(f"{normalized_biomarker_name}.{normalized_metric_name}") + + return any(candidate in self.included_fields for candidate in candidate_names) + + def _get_formatted_value(self, value) -> str: + """ + Formats the numeric value based on dynamic rounding rules: + > 1000: 0 decimals + > 10: 1 decimal + < 0.1: 3 decimals + < 1: 2 decimals + Else (1 <= value <= 10): 2 decimals (inferred default) + """ + if value is None: + return "0" + + try: + val_float = float(value) + abs_val = abs(val_float) + + if abs_val > 1000: + return f"{val_float:.0f}" + elif abs_val > 10: + return f"{val_float:.1f}" + elif abs_val < 0.1: + return f"{val_float:.3f}" + elif abs_val < 1: + return f"{val_float:.2f}" + else: + return f"{val_float:.2f}" + except ValueError: + return str(value) + + def _build_measurement_item( + self, biomarker_name: str, value, unit: Optional[str], code_meaning: Optional[str] = None + ): + concept_name_code = Dataset() + concept_name_code.update( + {"CodeValue": biomarker_name, "CodeMeaning": biomarker_name, "CodingSchemeDesignator": "99_BH"} + ) + + measured_value = Dataset() + measured_value.NumericValue = self._get_formatted_value(value) + + if unit: + measurement_units_code = Dataset() + measurement_units_code.update( + {"CodeValue": unit, "CodingSchemeDesignator": "UCUM", "CodeMeaning": code_meaning or unit} + ) + measured_value.MeasurementUnitsCodeSequence = Sequence([measurement_units_code]) + else: + measured_value.MeasurementUnitsCodeSequence = Sequence([]) + + data = Dataset() + data.update( + { + "ValueType": "NUM", + "RelationshipType": "CONTAINS", + "ConceptNameCodeSequence": Sequence([concept_name_code]), + "MeasuredValueSequence": Sequence([measured_value]), + } + ) + return data + + def _get_source_modality(self, dicom_series: Optional[DICOMSeries]) -> Optional[str]: + if dicom_series is None: + return None + + for attr_name in ("Modality", "modality", "_modality"): + modality = getattr(dicom_series, attr_name, None) + if modality: + return str(modality).upper() + + try: + sop_instances = dicom_series.get_sop_instances() + if sop_instances: + source_dataset = sop_instances[0].get_native_sop_instance() + modality = getattr(source_dataset, "Modality", None) + if modality: + return str(modality).upper() + except Exception: + pass + + return None + + def _normalize_metric_entries( + self, result_text: Dict, source_modality: Optional[str] = None + ) -> Dict[str, Dict[str, object]]: + normalized_entries = {} + is_ct_source = source_modality == "CT" + + for biomarker_name, biomarker_dict in result_text.items(): + if not isinstance(biomarker_dict, dict): + continue + + if "biomarker_value" in biomarker_dict and "unit" in biomarker_dict: + if not self._should_include_metric(biomarker_name): + continue + if biomarker_dict.get("biomarker_value") is None: + continue # Skip absent measurements (e.g. organ not segmented) + normalized_entries[biomarker_name] = biomarker_dict + continue + + for metric_name, metric_value in biomarker_dict.items(): + if metric_value is None or isinstance(metric_value, (dict, list, tuple, set)): + continue + + if not self._should_include_metric(biomarker_name, metric_name): + continue + + metric_name_lower = metric_name.lower() + output_name = f"{biomarker_name}.{metric_name}" + unit = None + code_meaning = None + + if metric_name_lower == "volume": + unit = "mL" + code_meaning = "milliliter" + elif metric_name_lower == "area": + unit = "cm2" + code_meaning = _CODE_MEANING_AREA + elif "intensity" in metric_name_lower and is_ct_source: + unit = "HU" + code_meaning = "Hounsfield Unit" + elif metric_name_lower in {"num.slices", "pixel.count", "num.connected.components"}: + unit = None + code_meaning = None + elif metric_name_lower == "error": + continue + elif not isinstance(metric_value, (int, float)): + continue + + normalized_entries[output_name] = { + "biomarker_value": metric_value, + "unit": unit, + "code_meaning": code_meaning, + } + + return normalized_entries + + def _create_content_sequence(self, result_text: Dict, source_modality: Optional[str] = None) -> List[object]: + """ + Internal helper to parse the dictionary and create the DICOM Content Sequence elements. + Separated to allow easier testing of logic without full operator execution. + """ + content_sequence_elements = [] + normalized_result_text = self._normalize_metric_entries(result_text, source_modality) + + for biomarker_name, biomarker_dict in normalized_result_text.items(): + + # Parse result_text for Measured Value Sequence writing + value, unit = biomarker_dict.get("biomarker_value"), biomarker_dict.get("unit") + if value is None: + raise ValueError(f"Missing value for biomarker: {biomarker_name}") + + if unit is not None and not isinstance(unit, str): + raise ValueError(f"Unit must be a string for biomarker: {biomarker_name}") + + # Extract CodeMeaning based on unit + if unit == "HU": + code_meaning = "Hounsfield Unit" + elif unit and unit.lower() in ["ml", "milliliter", "milliliters"]: + code_meaning = "milliliter" + elif unit and unit.lower() in ["cm2", "cm^2", "square centimeters"]: + code_meaning = _CODE_MEANING_AREA + else: + code_meaning = unit or "" + + # Apply dynamic rounding + formatted_value = self._get_formatted_value(value) + code_meaning_override = biomarker_dict.get("code_meaning") + if not isinstance(code_meaning_override, str): + code_meaning_override = None + + self._logger.info( + f"Preparing Content Sequence for biomarker: {biomarker_name}, value: {value} -> {formatted_value}, unit: {unit}" + ) + content_sequence_elements.append( + self._build_measurement_item( + biomarker_name, + value, + unit, + code_meaning_override or code_meaning, + ) + ) + + # Add Z-score + z_score = biomarker_dict.get("z_score") + if z_score is not None: + content_sequence_elements.append( + self._build_measurement_item( + f"{biomarker_name}.z", + z_score, + None, + ) + ) + + # Add Percentile if available + percentile = biomarker_dict.get("percentile_pct") + if percentile is not None: + content_sequence_elements.append( + self._build_measurement_item( + f"{biomarker_name}.p", + percentile, + "%", + "percentile", + ) + ) + + return content_sequence_elements + + def setup(self, spec: OperatorSpec): + """Set up the named input(s), and output(s) if applicable. + + This operator does not have an output for the next operator, rather file output only. + + Args: + spec (OperatorSpec): The Operator specification for inputs and outputs etc. + """ + + spec.input(self.input_name_dict) + spec.input(self.input_name_dcm_series).condition(ConditionType.NONE) # Optional input + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O. + + For now, only a single result content is supported, which could be in memory or an accessible file. + The DICOM series used during inference is optional, but is required if the + `copy_tags` is true indicating the generated DICOM object needs to copy study level metadata. + + When there are multiple selected series in the input, the first series' containing study will + be used for retrieving DICOM Study module attributes, e.g. StudyInstanceUID. + + Raises: + FileNotFoundError: When result object not in the input, and result file not found either. + ValueError: Content object and file path not in the inputs, or no DICOM series when required. + IOError: If the input content is blank. + """ + + # Gets the input, prepares the output folder, and then delegates the processing. + result_text = op_input.receive(self.input_name_dict) + if not result_text: + raise IOError("Input is read but blank.") + + study_selected_series_list = None + try: + study_selected_series_list = op_input.receive(self.input_name_dcm_series) + except Exception: + pass + + dicom_series = None # It can be None if not to copy_tags. + if self.copy_tags: + # Get the first DICOM Series to retrieve study level tags. + if not study_selected_series_list or len(study_selected_series_list) < 1: + raise ValueError("Missing input, list of 'StudySelectedSeries'.") + for study_selected_series in study_selected_series_list: + if not isinstance(study_selected_series, StudySelectedSeries): + raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.") + for selected_series in study_selected_series.selected_series: + dicom_series = selected_series.series + break + if dicom_series is not None: + break + + source_modality = self._get_source_modality(dicom_series) + self._logger.info(f"Detected source modality for DICOM SR content: {source_modality}") + + # Prepare content sequence elements after source_modality is known so that + # modality-aware normalization (e.g. HU units for CT intensity metrics) applies. + content_sequence_elements = self._create_content_sequence(result_text, source_modality) + self.output_folder.mkdir(parents=True, exist_ok=True) + + # Now ready to starting writing the DICOM instance + self.write(content_sequence_elements, dicom_series, self.output_folder) + + def write(self, content_text, dicom_series: Optional[DICOMSeries], output_dir: Path): + """Writes DICOM object + + Args: + content_text (list): list containing the contents for Content Sequence writing + dicom_series (DicomSeries): DicomSeries object encapsulating the original series. + model_info (MoelInfo): Object encapsulating model creator, name, version and UID. + + Returns: + PyDicom Dataset + """ + self._logger.debug("Writing DICOM object...\n") + + if not content_text: + raise ValueError("Content is empty.") + if not isinstance(output_dir, Path): + raise ValueError("output_dir is not a valid Path.") + + output_dir.mkdir(parents=True, exist_ok=True) # Just in case + + ds = write_common_modules( + dicom_series, self.copy_tags, self.modality_type, self.sop_class_uid, self.model_info, self.equipment_info + ) + + # SR specific + ds.CompletionFlag = "COMPLETE" # Estimated degree of completeness + ds.VerificationFlag = "UNVERIFIED" # Not attested by a legally accountable person. + + # Required by SR Document Series (Type 2 - Mandatory) + ds.ReferencedPerformedProcedureStepSequence = Sequence( + [] + ) # Type 3 for CT/MR Image CIOD; not copied by write_common_modules + # Required by SR Document General (Type 2 - Mandatory) + ds.PerformedProcedureCodeSequence = Sequence([]) # Not present for CT/MR Image CIOD + + # Per recommendation of IHE Radiology Technical Framework Supplement + # AI Results (AIR) Rev1.1 - Trial Implementation + # Specifically for Qualitative Findings, + # Qualitative findings shall be encoded in an instance of the DICOM Comprehensive 3D SR SOP + # Class using TID 1500 (Measurement Report) as the root template. + # DICOM PS3.16: TID 1500 Measurement Report + # http://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_A.html#sect_TID_1500 + # The value for Procedure Reported (121058, DCM, "Procedure reported") shall describe the + # imaging procedure analyzed, not the algorithm used. + + # The Comprehensive SR IOD and the Enhanced SR IOD are subsets of the Comprehensive 3D SR IOD, so an Image + # Display that has implemented support for the Comprehensive 3D SR IOD will have implemented all the + # capabilities to support the Comprehensive SR IOD and the Enhanced SR IOD + + # Use text value for example + ds.ValueType = "CONTAINER" + + # ConceptNameCode Sequence + seq_concept_name_code = Sequence() + ds.ConceptNameCodeSequence = seq_concept_name_code + + # Concept Name Code Sequence: Concept Name Code + # Determined via PS3.16 - https://dicom.nema.org/medical/dicom/current/output/html/part16.html#PS3.16 + ds_concept_name_code = Dataset() + ds_concept_name_code.CodeValue = self.report_code_value + ds_concept_name_code.CodingSchemeDesignator = self.report_coding_scheme_designator + ds_concept_name_code.CodeMeaning = self.report_code_meaning + seq_concept_name_code.append(ds_concept_name_code) + + ds.ContinuityOfContent = "SEPARATE" + + # Content Sequence + content_sequence = Sequence() + ds.ContentSequence = content_sequence + + # The actual report content text + for content_element in content_text: + content_sequence.append(content_element) + + # For now, only allow str Keywords and str value + if self.custom_tags: + for k, v in self.custom_tags.items(): + if isinstance(k, str) and isinstance(v, str): + try: + ds.update({k: v}) + except Exception as ex: + # Best effort for now + logging.warning(f"Tag {k} was not written, due to {ex}") + + # Instance file name is the same as the new SOP instance UID + file_path = output_dir.joinpath(f"{ds.SOPInstanceUID}{self.DCM_EXTENSION}") + + # write with Explicit VR Little Endian Transfer Syntax to render private tags correctly + ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + ds.file_meta.FileMetaInformationVersion = b"\x00\x01" # fixes '\x30\x31' writing from write_common_modules + ds.is_implicit_VR = False + ds.is_little_endian = True + save_dcm_file(ds, file_path) + self._logger.info(f"DICOM SOP instance saved in {file_path}") + + +def test(test_copy_tags: bool = True): + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator + + current_file_dir = Path(__file__).parent.resolve() + # Update these paths to match your actual environment or test data location + data_path = current_file_dir.joinpath("../../../inputs/livertumor_ct/dcm/1-CT_series_liver_tumor_from_nii014") + out_path = Path("output_sr_op").absolute() + + # UPDATED: Dictionary input with values testing the dynamic rounding logic + test_data_dict = { + "Liver_Volume": {"biomarker_value": 1500.12345, "unit": "ml", "z_score": 1.2, "percentile_pct": 95}, + "Tumor_Density_HU": {"biomarker_value": 45.6789, "unit": "HU", "z_score": 2.345, "percentile_pct": 88.1}, + "Small_Nodule_Area": {"biomarker_value": 0.856, "unit": "cm^2", "z_score": 0.5}, + "Tiny_Calcification": {"biomarker_value": 0.0456, "unit": "ml"}, + } + + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="loader_op") + series_selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") + sr_writer = DICOMTextSRWriterOperator( + fragment, + output_folder=out_path, + copy_tags=test_copy_tags, + model_info=None, + equipment_info=EquipmentInfo(), + custom_tags={"SeriesDescription": "Textual report from AI algorithm. Not for clinical use."}, + report_code_value="126000", + report_coding_scheme_designator="DCM", + report_code_meaning="Imaging Measurement Report", + name="sr_writer", + ) + + dicom_series = None + if test_copy_tags: + # Note: This block relies on actual DICOM files existing at data_path + try: + study_list = loader.load_data_to_studies(Path(data_path).absolute()) + study_selected_series_list = series_selector.filter(None, study_list) + + if not study_selected_series_list or len(study_selected_series_list) < 1: + print("Warning: No DICOM series found for test. Running without Series metadata copy.") + dicom_series = None + else: + for study_selected_series in study_selected_series_list: + for selected_series in study_selected_series.selected_series: + dicom_series = selected_series.series + break + except Exception as e: + print(f"Skipping DICOM loading due to environment error: {e}") + dicom_series = None + + # UPDATED TEST LOGIC: + # 1. Manually trigger the creation of content sequence (test logic) + print("Testing Content Sequence Creation & Rounding...") + content_sequence = sr_writer._create_content_sequence(test_data_dict) + + # 2. Write the file + print(f"Writing SR to {out_path}...") + try: + sr_writer.write(content_sequence, dicom_series, out_path) + print("Test Success: DICOM SR written.") + except Exception as e: + print(f"Test Failed during write: {e}") + + +if __name__ == "__main__": + # Ensure pydicom and monai are installed before running + try: + test(True) + except Exception as e: + print(f"Test execution failed: {e}") diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_entry_creator_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_entry_creator_operator.py deleted file mode 100644 index 4f2f275c..00000000 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_entry_creator_operator.py +++ /dev/null @@ -1,349 +0,0 @@ -# Copyright 2021-2025 MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -from datetime import datetime -from typing import Any, Dict, Union - -import pydicom -import pytz - -from monai.deploy.core import Fragment, Operator, OperatorSpec -from monai.deploy.core.domain.dicom_series import DICOMSeries -from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries - - -class MongoDBEntryCreatorOperator(Operator): - """Class to create a database entry for downstream MONAI Deploy Express MongoDB database writing. - Provided text input and source DICOM Series DICOM tags are used to create the entry. - - Named inputs: - text: text content to be included in the database entry. - study_selected_series_list: DICOM series for copying metadata from. - - Named output: - mongodb_database_entry: formatted MongoDB database entry. Downstream receiver MongoDBWriterOperator will write - the entry to the MONAI Deploy Express MongoDB database. - """ - - def __init__(self, fragment: Fragment, *args, map_version: str, **kwargs): - """Class to create a MONAI Deploy Express MongoDB database entry. Provided text input and - source DICOM Series DICOM tags are used to create the entry. - - Args: - map_version (str): version of the MAP. - - Raises: - ValueError: If result cannot be found either in memory or from file. - """ - - self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) - - self.map_version = map_version - - self.input_name_text = "text" - self.input_name_dcm_series = "study_selected_series_list" - - self.output_name_db_entry = "mongodb_database_entry" - - super().__init__(fragment, *args, **kwargs) - - def setup(self, spec: OperatorSpec): - """Set up the named input(s), and output(s). - - Args: - spec (OperatorSpec): The Operator specification for inputs and outputs etc. - """ - - spec.input(self.input_name_text) - spec.input(self.input_name_dcm_series) - - spec.output(self.output_name_db_entry) - - def compute(self, op_input, op_output, context): - """Performs computation for this operator and handles I/O. - - For now, only a single result content is supported, which could be in memory or an accessible file. - The DICOM Series used during inference is required. - - When there are multiple selected series in the input, the first series' containing study will - be used for retrieving DICOM Study module attributes, e.g. StudyInstanceUID. - - Raises: - FileNotFoundError: When result object not in the input, and result file not found either. - ValueError: Content object and file path not in the inputs, or no DICOM series provided. - IOError: If the input content is blank. - """ - - # receive the result text and study selected series list - result_text = str(op_input.receive(self.input_name_text)).strip() - if not result_text: - raise IOError("Input is read but blank.") - - study_selected_series_list = None - try: - study_selected_series_list = op_input.receive(self.input_name_dcm_series) - except Exception: - pass - if not study_selected_series_list or len(study_selected_series_list) < 1: - raise ValueError("Missing input, list of 'StudySelectedSeries'.") - - # retrieve the DICOM Series used during inference in order to grab appropriate Study/Series level tags - # this will be the 1st Series in study_selected_series_list - dicom_series = None - for study_selected_series in study_selected_series_list: - if not isinstance(study_selected_series, StudySelectedSeries): - raise ValueError(f"Element in input is not expected type, {StudySelectedSeries}.") - selected_series = study_selected_series.selected_series[0] - dicom_series = selected_series.series - break - - # create MongoDB entry - mongodb_database_entry = self.create_entry(result_text, dicom_series, self.map_version) - - # emit MongoDB entry - op_output.emit(mongodb_database_entry, self.output_name_db_entry) - - def create_entry(self, result_text: str, dicom_series: DICOMSeries, map_version: str): - """Creates the MONAI Deploy Express MongoDB database entry. - - Args: - result_text (str): text content to be included in the database entry. - dicom_series (DICOMSeries): DICOMSeries object encapsulating the original series. - map_version (str): version of the MAP. - - Returns: - mongodb_database_entry: formatted MongoDB database entry. - """ - - if not result_text or not len(result_text.strip()): - raise ValueError("Content is empty.") - - # get one of the SOP instance's native sop instance dataset - # we will pull Study level (and some Series level) DICOM tags from this SOP instance - # this same strategy is employed by write_common_modules - orig_ds = dicom_series.get_sop_instances()[0].get_native_sop_instance() - - # # loop through dicom series tags; look for discrepancies from SOP instances - # for sop_instance in dicom_series.get_sop_instances(): - # # get the native SOP instance dataset - # dicom_image = sop_instance.get_native_sop_instance() - - # # check if the tag is present in the dataset - # if hasattr(dicom_image, 'Exposure'): - # tag = dicom_image.Exposure - # print(f"Exposure: {tag}") - # else: - # print("Exposure tag not found in this SOP instance.") - - # DICOM TAG WRITING TO MONGODB - # edge cases addressed by looking at DICOM tag Type, Value Representation (VR), - # and Value Multiplicity (VM) specifically for the CT Image CIOD - # https://dicom.innolitics.com/ciods/ct-image - - # define Tag Absent variable - tag_absent = "Tag Absent" - - # STUDY AND SERIES LEVEL DICOM TAGS - - # AccessionNumber - Type: Required (2), VR: SH, VM: 1 - accession_number = orig_ds.AccessionNumber - - # StudyInstanceUID - Type: Required (1), VR: UI, VM: 1 - study_instance_uid = orig_ds.StudyInstanceUID - - # StudyDescription: Type: Optional (3), VR: LO, VM: 1 - # while Optional, only studies with this tag will be routed from Compass and MAP launched per workflow def - study_description = orig_ds.get("StudyDescription", tag_absent) - - # SeriesInstanceUID: Type: Required (1), VR: UI, VM: 1 - series_instance_uid = dicom_series._series_instance_uid - - # SeriesDescription: Type: Optional (3), VR: LO, VM: 1 - series_description = orig_ds.get("SeriesDescription", tag_absent) - - # sop instances should always be available on the MONAI DICOM Series object - series_sop_instances = len(dicom_series._sop_instances) - - # PATIENT DETAIL DICOM TAGS - - # PatientID - Type: Required (2), VR: LO, VM: 1 - patient_id = orig_ds.PatientID - - # PatientName - Type: Required (2), VR: PN, VM: 1 - # need to convert to str; pydicom can't encode PersonName object - patient_name = str(orig_ds.PatientName) - - # PatientSex - Type: Required (2), VR: CS, VM: 1 - patient_sex = orig_ds.PatientSex - - # PatientBirthDate - Type: Required (2), VR: DA, VM: 1 - patient_birth_date = orig_ds.PatientBirthDate - - # PatientAge - Type: Optional (3), VR: AS, VM: 1 - patient_age = orig_ds.get("PatientAge", tag_absent) - - # EthnicGroup - Type: Optional (3), VR: SH, VM: 1 - ethnic_group = orig_ds.get("EthnicGroup", tag_absent) - - # SCAN ACQUISITION PARAMETER DICOM TAGS - - # on CCHMC test cases, the following tags had consistent values for all SOP instances - - # Manufacturer - Type: Required (2), VR: LO, VM: 1 - manufacturer = orig_ds.Manufacturer - - # ManufacturerModelName - Type: Optional (3), VR: LO, VM: 1 - manufacturer_model_name = orig_ds.get("ManufacturerModelName", tag_absent) - - # BodyPartExamined - Type: Optional (3), VR: CS, VM: 1 - body_part_examined = orig_ds.get("BodyPartExamined", tag_absent) - - # row and column pixel spacing are derived from PixelSpacing - # PixelSpacing - Type: Required (1), VR: DS, VM: 2 (handled by MONAI) - row_pixel_spacing = dicom_series._row_pixel_spacing - column_pixel_spacing = dicom_series._col_pixel_spacing - - # per DICOMSeriesToVolumeOperator, depth pixel spacing will always be defined - depth_pixel_spacing = dicom_series._depth_pixel_spacing - - # SliceThickness - Type: Required (2), VR: DS, VM: 1 - slice_thickness = orig_ds.SliceThickness - - # PixelRepresentation - Type: Required (1), VR: US, VM: 1 - pixel_representation = orig_ds.PixelRepresentation - - # BitsStored - Type: Required (1), VR: US, VM: 1 - bits_stored = orig_ds.BitsStored - - # WindowWidth - Type: Conditionally Required (1C), VR: DS, VM: 1-n - window_width = orig_ds.get("WindowWidth", tag_absent) - # for MultiValue case: - if isinstance(window_width, pydicom.multival.MultiValue): - # join multiple values into a single string separated by a | - # convert DSfloat objects to strs to allow joining - window_width = " | ".join([str(window) for window in window_width]) - - # RevolutionTime - Type: Optional (3), VR: FD, VM: 1 - revolution_time = orig_ds.get("RevolutionTime", tag_absent) - - # FocalSpots - Type: Optional (3), VR: DS, VM: 1-n - focal_spots = orig_ds.get("FocalSpots", tag_absent) - # for MultiValue case: - if isinstance(focal_spots, pydicom.multival.MultiValue): - # join multiple values into a single string separated by a | - # convert DSfloat objects to strs to allow joining - focal_spots = " | ".join([str(spot) for spot in focal_spots]) - - # SpiralPitchFactor - Type: Optional (3), VR: FD, VM: 1 - spiral_pitch_factor = orig_ds.get("SpiralPitchFactor", tag_absent) - - # ConvolutionKernel - Type: Optional (3), VR: SH, VM: 1-n - convolution_kernel = orig_ds.get("ConvolutionKernel", tag_absent) - # for MultiValue case: - if isinstance(convolution_kernel, pydicom.multival.MultiValue): - # join multiple values into a single string separated by a | - convolution_kernel = " | ".join(convolution_kernel) - - # ReconstructionDiameter - Type: Optional (3), VR: DS, VM: 1 - reconstruction_diameter = orig_ds.get("ReconstructionDiameter", tag_absent) - - # KVP - Type: Required (2), VR: DS, VM: 1 - kvp = orig_ds.KVP - - # on CCHMC test cases, the following tags did NOT have consistent values for all SOP instances - # as such, if the tag value exists, it will be averaged over all SOP instances - - # initialize an averaged values dictionary - averaged_values: Dict[str, Union[float, str]] = {} - - # tags to check and average - tags_to_average = { - "XRayTubeCurrent": tag_absent, # Type: Optional (3), VR: IS, VM: 1 - "Exposure": tag_absent, # Type: Optional (3), VR: IS, VM: 1 - "CTDIvol": tag_absent, # Type: Optional (3), VR: FD, VM: 1 - } - - # check which tags are present on the 1st SOP instance - for tag, default_value in tags_to_average.items(): - # if the tag exists - if tag in orig_ds: - # loop through SOP instances, grab tag values - values = [] - for sop_instance in dicom_series.get_sop_instances(): - ds = sop_instance.get_native_sop_instance() - value = ds.get(tag, default_value) - # if tag is present on current SOP instance - if value != default_value: - # add tag value to values; convert to float for averaging - values.append(float(value)) - # compute the average if values were collected - if values: - averaged_values[tag] = round(sum(values) / len(values), 3) - else: - averaged_values[tag] = default_value - else: - # if the tag is absent in the first SOP instance, keep the default value - averaged_values[tag] = default_value - - # parse result_text (i.e. predicted organ volumes) and format - map_results = {} - for line in result_text.split("\n"): - if ":" in line: - key, value = line.split(":") - key = key.replace(" ", "") - map_results[key] = value.strip() - - # create the MongoDB database entry - mongodb_database_entry: Dict[str, Any] = { - "Timestamp": datetime.now(pytz.UTC), # timestamp in UTC - "MAPVersion": map_version, - "DICOMSeriesDetails": { - "AccessionNumber": accession_number, - "StudyInstanceUID": study_instance_uid, - "StudyDescription": study_description, - "SeriesInstanceUID": series_instance_uid, - "SeriesDescription": series_description, - "SeriesFileCount": series_sop_instances, - }, - "PatientDetails": { - "PatientID": patient_id, - "PatientName": patient_name, - "PatientSex": patient_sex, - "PatientBirthDate": patient_birth_date, - "PatientAge": patient_age, - "EthnicGroup": ethnic_group, - }, - "ScanAcquisitionDetails": { - "Manufacturer": manufacturer, - "ManufacturerModelName": manufacturer_model_name, - "BodyPartExamined": body_part_examined, - "RowPixelSpacing": row_pixel_spacing, - "ColumnPixelSpacing": column_pixel_spacing, - "DepthPixelSpacing": depth_pixel_spacing, - "SliceThickness": slice_thickness, - "PixelRepresentation": pixel_representation, - "BitsStored": bits_stored, - "WindowWidth": window_width, - "RevolutionTime": revolution_time, - "FocalSpots": focal_spots, - "SpiralPitchFactor": spiral_pitch_factor, - "ConvolutionKernel": convolution_kernel, - "ReconstructionDiameter": reconstruction_diameter, - "KVP": kvp, - }, - "MAPResults": map_results, - } - - # integrate averaged tags into MongoDB entry: - mongodb_database_entry["ScanAcquisitionDetails"].update(averaged_values) - - return mongodb_database_entry diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_writer_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_writer_operator.py deleted file mode 100644 index 6d18e395..00000000 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/mongodb_writer_operator.py +++ /dev/null @@ -1,235 +0,0 @@ -# Copyright 2021-2025 MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -import os - -from dotenv import load_dotenv - -load_dotenv() - -from pymongo import MongoClient, errors - -from monai.deploy.core import Fragment, Operator, OperatorSpec - - -class MongoDBWriterOperator(Operator): - """Class to write the MONAI Deploy Express MongoDB database with provided database entry. - - Named inputs: - mongodb_database_entry: formatted MongoDB database entry. - - Named output: - None - - Result: - MONAI Deploy Express MongoDB database write of the database entry. - """ - - def __init__(self, fragment: Fragment, *args, database_name: str, collection_name: str, **kwargs): - """Class to write the MONAI Deploy Express MongoDB database with provided database entry. - - Args: - database_name (str): name of the MongoDB database that will be written. - collection_name (str): name of the MongoDB collection that will be written. - - Raises: - Relevant MongoDB errors if database writing is unsuccessful. - """ - - self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) - - self.database_name = database_name - self.collection_name = collection_name - - self.input_name_db_entry = "mongodb_database_entry" - - # MongoDB credentials - self.mongodb_username = os.environ.get("MONGODB_USERNAME") - self.mongodb_password = os.environ.get("MONGODB_PASSWORD") - self.mongodb_port = os.environ.get("MONGODB_PORT") - self.docker_mongodb_ip = os.environ.get("MONGODB_IP_DOCKER") - - # determine the MongoDB IP address based on execution environment - self.mongo_ip = self._get_mongo_ip() - self._logger.info(f"Using MongoDB IP: {self.mongo_ip}") - - # connect to the MongoDB database - self.client = None - - try: - self.client = MongoClient( - f"mongodb://{self.mongodb_username}:{self.mongodb_password}@{self.mongo_ip}:{self.mongodb_port}/?authSource=admin", - serverSelectionTimeoutMS=10000, # 10s timeout for testing connection; 20s by default - ) - if self.client is None: - raise RuntimeError("MongoClient was not created successfully") - ping_response = self.client.admin.command("ping") - self._logger.info( - f"Successfully connected to MongoDB at: {self.client.address}. Ping response: {ping_response}" - ) - self.db = self.client[self.database_name] - self.collection = self.db[self.collection_name] - except errors.ServerSelectionTimeoutError as e: - self._logger.error("Failed to connect to MongoDB: Server selection timeout.") - self._logger.debug(f"Detailed error: {e}") - raise - except errors.ConnectionFailure as e: - self._logger.error("Failed to connect to MongoDB: Connection failure.") - self._logger.debug(f"Detailed error: {e}") - raise - except errors.OperationFailure as e: - self._logger.error("Failed to authenticate with MongoDB.") - self._logger.debug(f"Detailed error: {e}") - raise - except Exception as e: - self._logger.error("Unexpected error occurred while connecting to MongoDB.") - self._logger.debug(f"Detailed error: {e}") - raise - super().__init__(fragment, *args, **kwargs) - - def setup(self, spec: OperatorSpec): - """Set up the named input(s), and output(s) if applicable. - - This operator does not have an output for the next operator - MongoDB write only. - - Args: - spec (OperatorSpec): The Operator specification for inputs and outputs etc. - """ - - spec.input(self.input_name_db_entry) - - def compute(self, op_input, op_output, context): - """Performs computation for this operator""" - - mongodb_database_entry = op_input.receive(self.input_name_db_entry) - - # write to MongoDB - self.write(mongodb_database_entry) - - def write(self, mongodb_database_entry): - """Writes the database entry to the MONAI Deploy Express MongoDB database. - - Args: - mongodb_database_entry: formatted MongoDB database entry. - - Returns: - None - """ - - # MongoDB writing - try: - insert_result = self.collection.insert_one(mongodb_database_entry) - if insert_result.acknowledged: - self._logger.info(f"Document inserted with ID: {insert_result.inserted_id}") - else: - self._logger.error("Failed to write document to MongoDB.") - except errors.PyMongoError as e: - self._logger.error("Failed to insert document into MongoDB.") - self._logger.debug(f"Detailed error: {e}") - raise - - def _get_mongo_ip(self): - """Determine the MongoDB IP based on the execution environment. - - If the pipeline is being run pythonically, use localhost. - - If MAP is being run via MAR or MONAI Deploy Express, use Docker bridge network IP. - """ - - # if running in a Docker container (/.dockerenv file present) - if os.path.exists("/.dockerenv"): - self._logger.info("Detected Docker environment") - return self.docker_mongodb_ip - - # if not executing as Docker container, we are executing pythonically - self._logger.info("Detected local environment (pythonic execution)") - return "localhost" - - -# Module function (helper function) -def test(): - """Test writing to and deleting from the MDE MongoDB instance locally""" - - # MongoDB credentials - mongodb_username = os.environ.get("MONGODB_USERNAME") - mongodb_password = os.environ.get("MONGODB_PASSWORD") - mongodb_port = os.environ.get("MONGODB_PORT") - - # sample information - database_name = "CTLiverSpleenSegPredictions" - collection_name = "OrganVolumes" - test_entry = {"test_key": "test_value"} - - # connect to MongoDB instance (localhost as we are testing locally) - try: - client = MongoClient( - f"mongodb://{mongodb_username}:{mongodb_password}@localhost:{mongodb_port}/?authSource=admin", - serverSelectionTimeoutMS=10000, # 10s timeout for testing connection; 20s by default - ) - if client is None: - raise RuntimeError("MongoClient was not created successfully") - ping_response = client.admin.command("ping") - print(f"Successfully connected to MongoDB at: {client.address}. Ping response: {ping_response}") - db = client[database_name] - collection = db[collection_name] - except errors.ServerSelectionTimeoutError as e: - print("Failed to connect to MongoDB: Server selection timeout.") - print(f"Detailed error: {e}") - raise - except errors.ConnectionFailure as e: - print("Failed to connect to MongoDB: Connection failure.") - print(f"Detailed error: {e}") - raise - except errors.OperationFailure as e: - print("Failed to authenticate with MongoDB.") - print(f"Detailed error: {e}") - raise - except Exception as e: - print("Unexpected error occurred while connecting to MongoDB.") - print(f"Detailed error: {e}") - raise - - # insert document - try: - insert_result = collection.insert_one(test_entry) - if insert_result.acknowledged: - print(f"Document inserted with ID: {insert_result.inserted_id}") - else: - print("Failed to write document to MongoDB.") - except errors.PyMongoError as e: - print("Failed to insert document into MongoDB.") - print(f"Detailed error: {e}") - raise - - # verify the inserted document - try: - inserted_doc = collection.find_one({"_id": insert_result.inserted_id}) - if inserted_doc: - print(f"Inserted document: {inserted_doc}") - else: - print("Document not found in the collection after insertion.") - except errors.PyMongoError as e: - print("Failed to retrieve the inserted document from MongoDB.") - print(f"Detailed error: {e}") - return - - # # delete a database - # try: - # client.drop_database(database_name) - # print(f"Test database '{database_name}' deleted successfully.") - # except errors.PyMongoError as e: - # print("Failed to delete the test database.") - # print(f"Detailed error: {e}") - - -if __name__ == "__main__": - test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/post_transforms.py b/examples/apps/cchmc_ped_abd_ct_seg_app/post_transforms.py deleted file mode 100644 index 607bcd47..00000000 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/post_transforms.py +++ /dev/null @@ -1,387 +0,0 @@ -# Copyright 2021-2025 MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import logging -import os -from typing import List - -import matplotlib.cm as cm -import numpy as np - -from monai.config import KeysCollection -from monai.data import MetaTensor -from monai.transforms import LabelToContour, MapTransform - - -# Calculate segmentation volumes in ml -class CalculateVolumeFromMaskd(MapTransform): - """ - Dictionary-based transform to calculate the volume of predicted organ masks. - - Args: - keys (list): The keys corresponding to the predicted organ masks in the dictionary. - label_names (list): The list of organ names corresponding to the masks. - """ - - def __init__(self, keys, label_names): - self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}") - super().__init__(keys) - self.label_names = label_names - - def __call__(self, data): - # Initialize a dictionary to store the volumes of each organ - pred_volumes = {} - - for key in self.keys: - for label_name in self.label_names.keys(): - # self._logger.info('Key: ', key, ' organ_name: ', label_name) - if label_name != "background": - # Get the predicted mask from the dictionary - pred_mask = data[key] - # Calculate the voxel size in cubic millimeters (voxel size should be in the metadata) - # Assuming the metadata contains 'spatial_shape' with voxel dimensions in mm - if hasattr(pred_mask, "affine"): - voxel_size = np.abs(np.linalg.det(pred_mask.affine[:3, :3])) - else: - raise ValueError("Affine transformation matrix with voxel spacing information is required.") - - # Calculate the volume in cubic millimeters - label_volume_mm3 = np.sum(pred_mask == self.label_names[label_name]) * voxel_size - - # Convert to milliliters (1 ml = 1000 mm^3) - label_volume_ml = label_volume_mm3 / 1000.0 - - # Store the result in the pred_volumes dictionary - # convert to int - radiologists prefer whole number with no decimals - pred_volumes[label_name] = int(round(label_volume_ml, 0)) - - # Add the calculated volumes to the data dictionary - key_name = key + "_volumes" - - data[key_name] = pred_volumes - # self._logger.info('pred_volumes: ', pred_volumes) - return data - - -class LabelToContourd(MapTransform): - def __init__(self, keys: KeysCollection, output_labels: list, allow_missing_keys: bool = False): - - self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}") - super().__init__(keys, allow_missing_keys) - - self.output_labels = output_labels - - def __call__(self, data): - d = dict(data) - for key in self.keys: - label_image = d[key] - assert isinstance(label_image, MetaTensor), "Input image must be a MetaTensor." - - # Initialize the contour image with the same shape as the label image - contour_image = np.zeros_like(label_image.cpu().numpy()) - - if label_image.ndim == 4: # Check if the label image is 4D with a channel dimension - # Process each 2D slice independently along the last axis (z-axis) - for i in range(label_image.shape[-1]): - slice_image = label_image[:, :, :, i].cpu().numpy() - - # Extract unique labels excluding background (assumed to be 0) - unique_labels = np.unique(slice_image) - unique_labels = unique_labels[unique_labels != 0] - - slice_contour = np.zeros_like(slice_image) - - # Generate contours for each label in the slice - for label in unique_labels: - # skip contour generation for labels that are not in output_labels - if label not in self.output_labels: - continue - - # Create a binary mask for the current label - binary_mask = np.zeros_like(slice_image) - binary_mask[slice_image == label] = 1.0 - - # Apply LabelToContour to the 2D slice (replace this with actual contour logic) - thick_edges = LabelToContour()(binary_mask) - - # Assign the label value to the contour image at the edge positions - slice_contour[thick_edges > 0] = label - - # Stack the processed slice back into the 4D contour image - contour_image[:, :, :, i] = slice_contour - else: - # If the label image is not 4D, process it directly - slice_image = label_image.cpu().numpy() - unique_labels = np.unique(slice_image) - unique_labels = unique_labels[unique_labels != 0] - - for label in unique_labels: - binary_mask = np.zeros_like(slice_image) - binary_mask[slice_image == label] = 1.0 - - thick_edges = LabelToContour()(binary_mask) - contour_image[thick_edges > 0] = label - - # Convert the contour image back to a MetaTensor with the original metadata - contour_image_meta = MetaTensor(contour_image, meta=label_image.meta) # , affine=label_image.affine) - - # Store the contour MetaTensor in the output dictionary - d[key] = contour_image_meta - - return d - - -class OverlayImageLabeld(MapTransform): - def __init__( - self, - image_key: KeysCollection, - label_key: str, - overlay_key: str = "overlay", - alpha: float = 0.7, - allow_missing_keys: bool = False, - ): - - self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}") - super().__init__(image_key, allow_missing_keys) - - self.image_key = image_key - self.label_key = label_key - self.overlay_key = overlay_key - self.alpha = alpha - self.jet_colormap = cm.get_cmap("jet", 256) # Get the Jet colormap with 256 discrete colors - - def apply_jet_colormap(self, label_volume): - """ - Apply the Jet colormap to a 3D label volume using matplotlib's colormap. - """ - assert label_volume.ndim == 3, "Label volume should have 3 dimensions (H, W, D) after removing channel." - - label_volume_normalized = (label_volume / label_volume.max()) * 255.0 - label_volume_uint8 = label_volume_normalized.astype(np.uint8) - - # Apply the colormap to each label - label_rgb = self.jet_colormap(label_volume_uint8)[:, :, :, :3] # Only take the RGB channels - - label_rgb = (label_rgb * 255).astype(np.uint8) - # Rearrange axes to get (3, H, W, D) - label_rgb = np.transpose(label_rgb, (3, 0, 1, 2)) - - assert label_rgb.shape == ( - 3, - *label_volume.shape, - ), f"Label RGB shape should be (3,H, W, D) but got {label_rgb.shape}" - - return label_rgb - - def convert_to_rgb(self, image_volume): - """ - Convert a single-channel grayscale 3D image to an RGB 3D image. - """ - assert image_volume.ndim == 3, "Image volume should have 3 dimensions (H, W, D) after removing channel." - - image_volume_normalized = (image_volume - image_volume.min()) / (image_volume.max() - image_volume.min()) - image_rgb = np.stack([image_volume_normalized] * 3, axis=0) - image_rgb = (image_rgb * 255).astype(np.uint8) - - assert image_rgb.shape == ( - 3, - *image_volume.shape, - ), f"Image RGB shape should be (3,H, W, D) but got {image_rgb.shape}" - - return image_rgb - - def _create_overlay(self, image_volume, label_volume): - # Convert the image volume and label volume to RGB - image_rgb = self.convert_to_rgb(image_volume) - label_rgb = self.apply_jet_colormap(label_volume) - - # Create an alpha-blended overlay - overlay = image_rgb.copy() - mask = label_volume > 0 - - # Apply the overlay where the mask is present - for i in range(3): # For each color channel - overlay[i, mask] = (self.alpha * label_rgb[i, mask] + (1 - self.alpha) * overlay[i, mask]).astype(np.uint8) - - assert ( - overlay.shape == image_rgb.shape - ), f"Overlay shape should match image RGB shape: {overlay.shape} vs {image_rgb.shape}" - - return overlay - - def __call__(self, data): - d = dict(data) - - # Get the image and label tensors - image = d[self.image_key] # Expecting shape (1, H, W, D) - label = d[self.label_key] # Expecting shape (1, H, W, D) - - # uncomment when running pipeline with mask (non-contour) outputs, i.e. LabelToContourd transform absent - # if image.device.type == "cuda": - # image = image.cpu() - # d[self.image_key] = image - # if label.device.type == "cuda": - # label = label.cpu() - # d[self.label_key] = label - # # ----------------------- - - # Ensure that the input has the correct dimensions - assert image.shape[0] == 1 and label.shape[0] == 1, "Image and label must have a channel dimension of 1." - assert image.shape == label.shape, f"Image and label must have the same shape: {image.shape} vs {label.shape}" - - # Remove the channel dimension for processing - image_volume = image[0] # Shape: (H, W, D) - label_volume = label[0] # Shape: (H, W, D) - - # Convert to 3D overlay - overlay = self._create_overlay(image_volume, label_volume) - - # Add the channel dimension back - # d[self.overlay_key] = np.expand_dims(overlay, axis=0) # Shape: (1, H, W, D, 3) - d[self.overlay_key] = MetaTensor(overlay, meta=label.meta, affine=label.affine) # Shape: (3, H, W, D) - - # Assert the final output shape - # assert d[self.overlay_key].shape == (1, *image_volume.shape, 3), \ - # f"Final overlay shape should be (1, H, W, D, 3) but got {d[self.overlay_key].shape}" - - assert d[self.overlay_key].shape == ( - 3, - *image_volume.shape, - ), f"Final overlay shape should be (3, H, W, D) but got {d[self.overlay_key].shape}" - - # Log the overlay creation (debugging) - self._logger.info(f"Overlay created with shape: {overlay.shape}") - # self._logger.info(f"Dictionary keys: {d.keys()}") - - # self._logger.info('overlay_image shape: ', d[self.overlay_key].shape) - return d - - -class SaveData(MapTransform): - """ - Save the output dictionary into JSON files. - - The name of the saved file will be `{key}_{output_postfix}.json`. - - Args: - keys: keys of the corresponding items to be saved in the dictionary. - output_dir: directory to save the output files. - output_postfix: a string appended to all output file names, default is `data`. - separate_folder: whether to save each file in a separate folder. Default is `True`. - print_log: whether to print logs when saving. Default is `True`. - """ - - def __init__( - self, - keys: KeysCollection, - namekey: str = "image", - output_dir: str = "./", - output_postfix: str = "data", - separate_folder: bool = False, - print_log: bool = True, - allow_missing_keys: bool = False, - ): - self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}") - super().__init__(keys, allow_missing_keys) - self.output_dir = output_dir - self.output_postfix = output_postfix - self.separate_folder = separate_folder - self.print_log = print_log - self.namekey = namekey - - def __call__(self, data): - d = dict(data) - image_name = os.path.basename(d[self.namekey].meta["filename_or_obj"]).split(".")[0] - for key in self.keys: - # Get the data - output_data = d[key] - - # Determine the file name - file_name = f"{image_name}_{self.output_postfix}.json" - if self.separate_folder: - file_path = os.path.join(self.output_dir, image_name, file_name) - os.makedirs(os.path.dirname(file_path), exist_ok=True) - else: - file_path = os.path.join(self.output_dir, file_name) - - # Save the dictionary as a JSON file - with open(file_path, "w") as f: - json.dump(output_data, f) - - if self.print_log: - self._logger.info(f"Saved data to {file_path}") - - return d - - -# custom transform (not in original post_transforms.py in bundle): -class ExtractVolumeToTextd(MapTransform): - """ - Custom transform to extract volume information from the segmentation results and format it as a textual summary. - Filters organ volumes based on output_labels for DICOM SR write, while including all organs for MongoDB write. - The upstream CalculateVolumeFromMaskd transform calculates organ volumes and stores them in the dictionary - under the pred_key + '_volumes' key. The input dictionary is outputted unchanged as to not affect downstream operators. - - Args: - keys: keys of the corresponding items to be saved in the dictionary. - label_names: dictionary mapping organ names to their corresponding label indices. - output_labels: list of target label indices for organs to include in the DICOM SR output. - """ - - def __init__( - self, - keys: KeysCollection, - label_names: dict, - output_labels: List[int], - allow_missing_keys: bool = False, - ): - self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}") - super().__init__(keys, allow_missing_keys) - - self.label_names = label_names - self.output_labels = output_labels - - # create separate result_texts for DICOM SR write (target organs) and MongoDB write (all organs) - self.result_text_dicom_sr: str = "" - self.result_text_mongodb: str = "" - - def __call__(self, data): - d = dict(data) - # use the first key in `keys` to access the volume data (e.g., pred_key + '_volumes') - volumes_key = self.keys[0] - organ_volumes = d.get(volumes_key, None) - - if organ_volumes is None: - raise ValueError(f"Volume data not found for key {volumes_key}.") - - # create the volume text outputs - volume_text_dicom_sr = [] - volume_text_mongodb = [] - - # loop through calculated organ volumes - for organ, volume in organ_volumes.items(): - - # append all organ volumes for MongoDB entry - volume_entry = f"{organ.capitalize()} Volume: {volume} mL" - volume_text_mongodb.append(volume_entry) - - # if the organ's label index is in output_labels - label_index = self.label_names.get(organ, None) - if label_index in self.output_labels: - # append organ volume for DICOM SR entry - volume_text_dicom_sr.append(volume_entry) - - self.result_text_dicom_sr = "\n".join(volume_text_dicom_sr) - self.result_text_mongodb = "\n".join(volume_text_mongodb) - - # not adding result_text to dictionary; return dictionary unchanged as to not affect downstream operators - return d diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/requirements.txt b/examples/apps/cchmc_ped_abd_ct_seg_app/requirements.txt index 309428d7..d3e4e886 100644 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/requirements.txt +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/requirements.txt @@ -1,27 +1,58 @@ -monai>=1.3.0 -torch>=1.12.0 -pytorch-ignite>=0.4.9 -fire>=0.4.0 -numpy>=1.22.2 -nibabel>=4.0.1 -# pydicom v3.0.0 removed pydicom._storage_sopclass_uids; don't meet or exceed this version -pydicom>=2.3.0,<3.0.0 -highdicom>=0.18.2 +# requirements.txt file specifies dependencies our Python project needs to run + +# install MONAI and necessary image processing packages (base list pulled from MONAI Bundle Spleen Seg App example) +# based on CCHMC Ped Abd CT MONAI Bundle dependencies: +# monai, numpy, nibabel versions upgraded +# pytorch-ignite and fire dependencies added +# python 3.10 is required to install monai-deploy-app-sdk>=3.1.0 + +# upgraded per monai-deploy-app-sdk==3.5.0 +monai==1.5.1 + +# upgraded per monai==1.5.1 +# pin PyTorch to a CUDA 12.8-compatible build so pip does not resolve to the +# newer PyPI wheel that pulls CUDA 13 runtime packages +torch==2.10.0 +pytorch-ignite==0.4.11 +numpy>=1.24,<3.0 + +# pydicom>=3.0.0 needed for monai-deploy-app-sdk>=3.1.0 +pydicom==3.0.1 +# highdicom depends on pydicom>=3.0.1 starting with 0.23.0 +highdicom>=0.23.0 + +fire==0.4.0 +nibabel==4.0.1 itk>=5.3.0 SimpleITK>=2.0.0 scikit-image>=0.17.2 Pillow>=8.0.0 numpy-stl>=2.12.0 trimesh>=3.8.11 -matplotlib>=3.7.2 -setuptools>=59.5.0 # for pkg_resources -python-dotenv>=1.0.1 -# pymongo for MongoDB writing -pymongo>=4.10.1 +# segmentation operator dependencies +pandas>=1.3.0 +matplotlib>=3.5.1 +pypdf>=6.6.2 +cupy-cuda12x>=12.0.0 +cucim>=23.10.0 + +# suggested to pin <81 due to pkg_resources deprecation +setuptools>=59.5.0,<81.0.0 -# pytz for MongoDB Timestamp -pytz>=2024.1 +# dependencies for processing compressed DICOM pixel data (from monai-deploy-app-sdk>=3.4.0) +nvidia-nvimgcodec-cu12>=0.6.1 +nvidia-nvjpeg-cu12 # not listed by app=sdk; may be absent based on build base image +nvidia-nvjpeg2k-cu12>=0.9.1 +nvidia-nvtiff-cu12 # nvTIFF wheel +python-gdcm>=3.0.10 +pylibjpeg[all] # MONAI Deploy App SDK package installation -monai-deploy-app-sdk +# includes holoscan-cu12 and holoscan-cli (no pins) +monai-deploy-app-sdk==3.5.0 + +# fine control over holoscan-cu12 and holoscan-cli versions +# use CUDA 12 for now +holoscan-cu12==3.10.0 +holoscan-cli==3.10.0 diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/map_build.sh b/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/map_build.sh index 5d78c37e..385d6fc9 100755 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/map_build.sh +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/map_build.sh @@ -1,4 +1,4 @@ -# Copyright 2021-2025 MONAI Consortium +# Copyright 2021-2026 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -12,18 +12,28 @@ # build a MAP # check if the correct number of arguments are provided -if [ "$#" -ne 3 ]; then - echo "Please provide all arguments. Usage: $0 " +if [ "$#" -ne 4 ]; then + echo "Please provide all arguments. Usage: $0 " exit 1 fi # assign command-line arguments to variables tag_prefix=$1 image_version=$2 -sdk_version=$3 +monai_deploy_app_sdk_version=$3 +cuda_version=$4 + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # load in environment variables -source .env +source "${script_dir}/../.env" -# build MAP -monai-deploy package cchmc_ped_abd_ct_seg_app -m $HOLOSCAN_MODEL_PATH -c cchmc_ped_abd_ct_seg_app/app.yaml -t ${tag_prefix}:${image_version} --platform x86_64 --sdk-version ${sdk_version} -l DEBUG +# build MAP - let packager choose base image +monai-deploy package "${script_dir}/.." \ + -m "$HOLOSCAN_MODEL_PATH" \ + -c "${script_dir}/../app.yaml" \ + -t "${tag_prefix}:${image_version}" \ + --platform x86_64 \ + --sdk-version "${monai_deploy_app_sdk_version}" \ + --cuda "${cuda_version}" \ + -l DEBUG diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/model_run.sh b/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/model_run.sh index 6decca04..929d6df6 100755 --- a/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/model_run.sh +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/scripts/model_run.sh @@ -18,4 +18,4 @@ source .env rm -rf "$HOLOSCAN_OUTPUT_PATH" # execute model bundle locally (pythonically) -python cchmc_ped_abd_ct_seg_app -i "$HOLOSCAN_INPUT_PATH" -o "$HOLOSCAN_OUTPUT_PATH" -m "$HOLOSCAN_MODEL_PATH" +python3 ../cchmc_ped_abd_ct_seg_app -i "$HOLOSCAN_INPUT_PATH" -o "$HOLOSCAN_OUTPUT_PATH" -m "$HOLOSCAN_MODEL_PATH" diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_contour_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_contour_operator.py new file mode 100644 index 00000000..fc592d8e --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_contour_operator.py @@ -0,0 +1,214 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from typing import Dict, Optional, Union + +import numpy as np +import torch + +from monai.data import MetaTensor +from monai.deploy.core import Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.image import Image +from monai.deploy.utils.importutil import optional_import +from monai.transforms import LabelToContour + +cupy, has_cupy = optional_import("cupy") + +import copy + + +class SegmentationContourOperator(Operator): + """ + This operator generates contour images from segmentation masks for each specified label. + + The operator takes a segmentation mask and a label dictionary, and produces a contour image where + the boundaries of each labeled region are highlighted. This is useful for visualization, quality control, + or exporting contours for further analysis or reporting. + + Named Input: + segmentation_mask: Segmentation mask as a tensor, numpy array, MetaTensor, or Image object. + Named Output: + contour: Contour image (same type as input, typically Image) with boundaries of each label highlighted. + """ + + def __init__(self, fragment: Fragment, *args, labels_dict: Optional[dict] = None, **kwargs): + """Create an instance for a containing application object. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + labels_dict (dict): Dictionary mapping label names to their corresponding mask indices. + """ + self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) + self.input_name_seg_mask = "segmentation_mask" + self.input_labels = labels_dict if labels_dict is not None else {"organ1": 1} + self.output_name_contour = "contour" + + # Need to call the base class constructor last + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + spec.input(self.input_name_seg_mask) + spec.output(self.output_name_contour) + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O.""" + + # Receive inputs + segmentation_mask = op_input.receive(self.input_name_seg_mask) + + # Validate inputs + if self.input_labels is None or not isinstance(self.input_labels, dict): + raise ValueError("label_dict must be a dictionary mapping label names to mask indices") + + # Calculate metrics + contour = self.create_contour(segmentation_mask, self.input_labels) + + # Emit output + op_output.emit(contour, self.output_name_contour) + + def create_contour( + self, + segmentation_mask: Union[np.ndarray, torch.Tensor, Image], + label_dict: Dict[str, int], + ) -> Union[np.ndarray, Image]: + """Create contours from segmentation mask with CuPy acceleration when possible. + + Args: + segmentation_mask: Segmentation mask as numpy array, torch tensor, or Image object + label_dict: Dictionary mapping label names to their mask indices + + Returns: + Contour image with the same type as input (numpy array or Image) + """ + + label_image = copy.deepcopy(segmentation_mask) + if isinstance(segmentation_mask, MetaTensor): + metadata = segmentation_mask.meta + elif isinstance(segmentation_mask, Image): + metadata = segmentation_mask.metadata() + else: + metadata = None + + self._logger.info(f"Segmentation_mask is of type: {type(segmentation_mask)}") + + xp = np # Cupy does not work with LabelToContour currently, so we use numpy for now + if isinstance(label_image, Image): + label_image = label_image.asnumpy() + # Transpose DWH to HWD + if label_image.ndim == 3: + label_image = np.transpose(label_image, (2, 1, 0)) + elif label_image.ndim == 2: + label_image = np.transpose(label_image, (1, 0)) + else: + raise ValueError( + f"Unsupported number of dimensions in label image: {label_image.ndim} Expected 2 or 3." + ) + # Unsqueeze to add channel dimension + label_image = np.expand_dims(label_image, axis=0) + elif isinstance(label_image, MetaTensor): + _meta = label_image # retain MetaTensor reference for fallback + try: + label_image = xp.asarray(_meta) # Direct conversion to cupy array if possible + except Exception: + label_image = _meta.cpu().numpy() # Fallback to CPU numpy array - if MetaTensor is on CPU + elif isinstance(label_image, torch.Tensor): + t = label_image.detach() + if t.is_cuda: + t = t.cpu() + label_image = t.numpy() + else: + label_image = np.asarray(label_image) + + # Does not apply - using numpy for now + # Move to GPU if cupy is available and not already a cupy array + # if has_cupy and not isinstance(label_image, cupy.ndarray): + # label_image = cupy.asarray(label_image) + + self._logger.info(f"Label image shape: {label_image.shape}, dtype: {label_image.dtype}") + + # Initialize the contour image with the same shape as the label image + contour_image = xp.zeros_like(label_image) + + if label_image.ndim == 4: # Check if the label image is 4D with a channel dimension + # Process each 2D slice independently along the last axis (z-axis) + for i in range(label_image.shape[-1]): + slice_image = label_image[:, :, :, i] + + # Extract unique labels excluding background (assumed to be 0) + unique_labels = xp.unique(slice_image) + unique_labels = unique_labels[unique_labels != 0] + + slice_contour = xp.zeros_like(slice_image) + + # Generate contours for each label in the slice + for label in unique_labels: + # skip contour generation for labels that are not in output_labels + if label not in label_dict.values(): + continue + + # Create a binary mask for the current label + binary_mask = xp.zeros_like(slice_image) + binary_mask[slice_image == label] = 1.0 + + # Squeeze the channel dimension + thick_edges = LabelToContour()(binary_mask.astype(xp.float32)) + + # Assign the label value to the contour image at the edge positions + slice_contour[thick_edges > 0] = label + + # Stack the processed slice back into the 4D contour image + contour_image[:, :, :, i] = slice_contour + else: + # If the label image is not 4D, process it directly + unique_labels = xp.unique(label_image) + unique_labels = unique_labels[unique_labels != 0] + + for label in unique_labels: + if label not in label_dict.values(): + continue + + binary_mask = xp.zeros_like(label_image) + binary_mask[label_image == label] = 1.0 + + thick_edges = LabelToContour()(binary_mask.astype(xp.float32)) + contour_image[thick_edges > 0] = label + + self._logger.info(f"Contour image shape: {contour_image.shape}, dtype: {contour_image.dtype}") + result_image = self._mt_array_to_image(contour_image, metadata) + + return result_image + + def _mt_array_to_image(self, out_ndarray, input_img_metadata) -> Image: + """ + Converts a MetaTensor or ndarray output to an Image object with correct shape and metadata. + Squeezes channel dimension, transposes to DHW, and casts to uint8. + Args: + out_ndarray: The output array (typically from post-transforms, shape [C, W, H, D] or [1, W, H, D]). + input_img_metadata: Metadata dictionary for the Image object. + Returns: + seg_image: Image object with correct shape and metadata. + """ + # make sure out_ndarray is a numpy array by converting from cupy if needed + if has_cupy and isinstance(out_ndarray, cupy.ndarray): + out_ndarray = cupy.asnumpy(out_ndarray) + + # Squeeze channel dim only when present as singleton + if out_ndarray.ndim >= 4 and out_ndarray.shape[0] == 1: + out_ndarray = np.squeeze(out_ndarray, 0) + + # Transpose to DHW (see note in original code) + out_ndarray = out_ndarray.T.astype(np.uint8) + self._logger.debug(f"Output Seg image numpy array of type {type(out_ndarray)} shape: {out_ndarray.shape}") + self._logger.debug(f"Output Seg image pixel max value: {np.amax(out_ndarray)}") + seg_image = Image(out_ndarray, input_img_metadata) + + return seg_image diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_metrics_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_metrics_operator.py new file mode 100644 index 00000000..f2c864aa --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_metrics_operator.py @@ -0,0 +1,463 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from time import time +from typing import Any, Dict, Optional, Tuple, Union + +import numpy as np +import torch +from scipy import ndimage + +from monai.deploy.utils.importutil import optional_import + +cupy, has_cupy = optional_import("cupy") +cupyx_scipy_ndimage, has_cupyx_scipy = optional_import("cupyx.scipy.ndimage") + +from monai.data import MetaTensor +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.image import Image + + +class SegmentationMetricsOperator(Operator): + """This operator computes segmentation metrics for predicted segmentation masks. + + The computed metrics include volume/area, slice information, pixel counts, and intensity statistics + for each labeled region in the segmentation mask. + + Named Input: + segmentation_mask: Segmentation mask as tensor, numpy array, or Image object. + input_scan: Input scan/image as tensor, or Image object. + label_dict: Dictionary mapping label names to their corresponding mask indices. + segmentation_metatensor: Optional MetaTensor version of segmentation mask for GPU processing. + use_gpu: If True and GPU is available, use CuPy for GPU acceleration. + Named Output: + metrics_dict: Dictionary containing metrics for each label. + """ + + def __init__( + self, + fragment: Fragment, + *args, + compute_components: bool = True, + labels_dict: Optional[dict] = None, + use_gpu: Optional[bool] = True, + **kwargs, + ): + """Create an instance for a containing application object. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + compute_components (bool): If True, computes connected components for each labeled region > 5 pixels + and outputs in the metrics dictionary. Set to False if not needed. Default is True. + labels_dict (dict): Dictionary mapping label names to their corresponding mask indices. + Provide only labels for which metrics are desired. + use_gpu (bool): If True and GPU is available, use CuPy for GPU acceleration. Default is True. + """ + self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) + self.input_name_seg_mask = "segmentation_mask" + self.input_name_scan = "input_scan" + self.input_name_labels = labels_dict if labels_dict is not None else {"organ1": 1} + + self.output_name_metrics = "metrics_dict" + self.use_gpu = use_gpu and has_cupy + self.compute_components = compute_components + + # Need to call the base class constructor last + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + spec.input(self.input_name_seg_mask) + spec.input(self.input_name_scan) + spec.output(self.output_name_metrics).condition(ConditionType.NONE) + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O.""" + + # Receive inputs + segmentation_mask = op_input.receive(self.input_name_seg_mask) + input_scan = op_input.receive(self.input_name_scan) + + # Log type of inputs + self._logger.info(f"Received segmentation mask of type: {type(segmentation_mask).__name__}") + self._logger.info(f"Received input scan of type: {type(input_scan).__name__}") + + label_dict = self.input_name_labels + + # Validate inputs + if label_dict is None or not isinstance(label_dict, dict): + raise ValueError("label_dict must be a dictionary mapping label names to mask indices") + + # Calculate metrics + # log if calculate_metrics is using GPU or CPU + in_gpu = segmentation_mask.device.type == "cuda" if hasattr(segmentation_mask, "device") else False + backend = "GPU" if self.use_gpu else "CPU" + self._logger.info( + f"Calculating segmentation metrics using {backend} backend | " + f"Segmentation_mask is in GPU already: {in_gpu}." + ) + # Time the calculate_metrics function + start_time = time() + metrics = self.calculate_metrics(segmentation_mask, input_scan, label_dict) + end_time = time() + self._logger.info(f"Segmentation metrics calculation took {end_time - start_time:.4f} seconds.") + # Emit output + op_output.emit(metrics, self.output_name_metrics) + + def _get_spacing(self, image_obj: Union[torch.Tensor, np.ndarray, Image]) -> Optional[Tuple[float, ...]]: + """Extract spacing from Image object metadata. + + Args: + image_obj: Image object that must contain spacing metadata. + Returns: + Tuple of spacing values. + + Raises: + ValueError: If spacing cannot be extracted. + """ + if not isinstance(image_obj, Image): + raise ValueError("Spacing required: input must be an Image with metadata containing spacing.") + + # Type of Image object + self._logger.info(f"Extracting spacing from image metadata for image type: {type(image_obj).__name__} ") + metadata = image_obj.metadata() or {} + + spacing = None + if metadata: + # Try common spacing keys in order of preference + spacing = metadata.get("spacing") or metadata.get("pixdim") or metadata.get("pixel_spacing") + + # If not found, try DICOM-specific pixel spacing keys + if spacing is None: + row_spacing = metadata.get("row_pixel_spacing") + col_spacing = metadata.get("col_pixel_spacing") + depth_spacing = metadata.get("depth_pixel_spacing") + + if row_spacing is not None and col_spacing is not None and depth_spacing is not None: + spacing = (float(row_spacing), float(col_spacing), float(depth_spacing)) + + if spacing is not None and not isinstance(spacing, (list, tuple, np.ndarray)): + raise ValueError(f"Spacing required: expected list/tuple/ndarray, got {type(spacing).__name__}.") + + if spacing is None: + affine = getattr(image_obj, "affine", None) + if affine is not None: + affine_arr = np.asarray(affine) + if affine_arr.shape[0] < 3 or affine_arr.shape[1] < 3: + raise ValueError("Spacing required: affine matrix missing spatial axes.") + spacing = ( + float(np.linalg.norm(affine_arr[:3, 0])), + float(np.linalg.norm(affine_arr[:3, 1])), + float(np.linalg.norm(affine_arr[:3, 2])), + ) + else: + raise ValueError( + "Spacing required: metadata missing and affine attribute not available for spacing extraction." + ) + + return tuple(spacing) + + def _compute_volume_or_area( + self, pixel_count: Union[int, Any], spacing: Optional[Tuple[float, ...]], is_3d: bool, xp: Any + ) -> float: + """Compute volume (3D) or area (2D) from pixel count and spacing. + + Args: + pixel_count: Number of pixels in the mask. + spacing: Pixel/voxel spacing in mm. + is_3d: Whether the data is 3D or 2D. + xp: numpy or cupy module. + + Returns: + Volume in mL (3D) or area in cm² (2D). + """ + if spacing is None: + # Return pixel/voxel count if spacing is not available + return float(pixel_count) + + if is_3d: + # Volume = pixel_count * spacing_x * spacing_y * spacing_z (in mm³) + # Convert mm³ to mL: 1 mL = 1000 mm³ + volume_per_voxel_mm3 = spacing[0] * spacing[1] * spacing[2] + volume_ml = float(pixel_count * volume_per_voxel_mm3) / 1000.0 + return volume_ml + else: + # Area = pixel_count * spacing_x * spacing_y (in mm²) + # Convert mm² to cm²: 1 cm² = 100 mm² + area_per_pixel_mm2 = spacing[0] * spacing[1] + area_cm2 = float(pixel_count * area_per_pixel_mm2) / 100.0 + return area_cm2 + + def calculate_metrics( + self, + segmentation_mask: Union[Image, MetaTensor], + input_scan: Image, + label_dict: Dict[str, int], + ) -> Dict[str, Dict[str, Any]]: + """Calculate segmentation metrics for each label. + + Args: + segmentation_mask: Segmentation mask (Image or MetaTensor). + input_scan: Input scan/image (Image). + label_dict: Dictionary mapping label names to mask indices. + + Returns: + Dictionary with metrics for each label: + - volume (3D) or area (2D): Volume in mL or area in cm² of the segmented region + - num.slices: Number of slices containing the organ + - slice.range: Tuple (first_slice, last_slice) containing the organ + - pixel.count: Number of pixels/voxels with this label + - mean.intensity.hu: Mean intensity in HU of pixels in the mask region + - std.intensity.hu: Standard deviation of intensity in HU in the mask region + """ + + # Get spacing from input scan + spacing = self._get_spacing(input_scan) + + scan_array = input_scan.asnumpy() + + xp = np # Default to numpy + if self.use_gpu and has_cupy: + xp = cupy + + # Parameter to Determine if 3D or 2D + is_3d = False + + # Process segmentation mask to array, check if 3D or 2D, and if cupy or numpy array + if isinstance(segmentation_mask, Image): + seg_array = segmentation_mask.asnumpy() + if len(seg_array.shape) == 3: + is_3d = True + else: + try: + seg_array = xp.asarray(segmentation_mask) + except Exception as e: + seg_array = ( + segmentation_mask.cpu().numpy() + ) # Fallback to CPU numpy array, applies if self.use_gpu is False or CuPy not available + + seg_array = seg_array[0] if seg_array.shape[0] == 1 else seg_array # Remove batch dimension if present + # Align orientation with scan: only transpose if shapes don't already match + # (e.g., MR MetaTensor in WHD order needs transpose to DHW; CT MetaTensor is already DHW) + if seg_array.ndim == 3 and seg_array.shape != scan_array.shape: + if has_cupy and isinstance(seg_array, cupy.ndarray): + transposed = cupy.transpose(seg_array, (2, 1, 0)) + else: + transposed = np.transpose(seg_array, (2, 1, 0)) + if transposed.shape == scan_array.shape: + seg_array = transposed + self._logger.info("Transposed segmentation array to match scan orientation (WHD -> DHW).") + if len(seg_array.shape) == 3: + is_3d = True + + if ( + self.use_gpu and has_cupy and not isinstance(seg_array, cupy.ndarray) + ): # If input segmentation mask is not already on GPU, move it there, applies when input is on CPU + self._logger.info("Moving segmentation mask from CPU to GPU for processing.") + seg_array = xp.asarray(seg_array) + + if seg_array.shape != scan_array.shape: + raise ValueError( + f"Segmentation shape {seg_array.shape} does not match scan shape {scan_array.shape}. " + "Inputs must already be spatially aligned before metric computation." + ) + + # Print mean, max min for scan_array for debugging + self._logger.info( + f"Input scan array stats - mean: {xp.mean(scan_array):.2f}, " + f"max: {xp.max(scan_array):.2f}, min: {xp.min(scan_array):.2f}" + ) + + # Initialize results dictionary + results: Dict[str, Dict[str, Any]] = {} + + # Calculate metrics for each label + for label_name, label_idx in label_dict.items(): + try: + label_mask = seg_array == label_idx + + # Pixel count + pixel_count = xp.sum(label_mask) + + # Skip if label not present + if pixel_count == 0: + results[label_name] = { + "volume" if is_3d else "area": 0.0, + "num.slices": 0, + "slice.range": None, + "pixel.count": 0, + "mean.intensity.hu": 0.0, + "std.intensity.hu": 0.0, + } + if self.compute_components: + results[label_name]["num.connected.components"] = 0 + continue + + # Compute volume or area + volume_or_area = self._compute_volume_or_area(pixel_count, spacing, is_3d, xp) + + # Slice information (assumes first dimension is depth/slices for 3D) + if is_3d: + # Find which slices contain the label + slices_with_label = xp.any(label_mask, axis=(1, 2)) + slice_indices = xp.where(slices_with_label)[0] + num_slices = len(slice_indices) + slice_range = (int(slice_indices[0]), int(slice_indices[-1])) if num_slices > 0 else None + else: + # For 2D, there's only one "slice" + num_slices = 1 + slice_range = (0, 0) + + # Print the device of label_mask using is_cuda + if has_cupy and isinstance(label_mask, cupy.ndarray): + self._logger.info(f"Label mask for {label_name!r} is on GPU.") + masked_intensities = scan_array[label_mask.get()] + else: + self._logger.info(f"Label mask for {label_name!r} is on CPU.") + masked_intensities = scan_array[label_mask] + + # Intensity statistics (mean and std of pixels within the mask) + mean_intensity = float(xp.mean(masked_intensities)) + std_intensity = float(xp.std(masked_intensities)) + + # Store results for this label + results[label_name] = { + "volume" if is_3d else "area": float(volume_or_area), + "num.slices": int(num_slices), + "slice.range": slice_range, + "pixel.count": int(pixel_count), + "mean.intensity.hu": float(mean_intensity), + "std.intensity.hu": float(std_intensity), + } + + # Connected components analysis (> 5 pixels) - optional + if self.compute_components: + num_components = self._count_connected_components(label_mask, min_size=5) + results[label_name]["num.connected.components"] = int(num_components) + + except Exception as e: + self._logger.error(f"Error calculating metrics for label {label_name!r} (index {label_idx}): {e}") + results[label_name] = {"error": str(e)} + + self._logger.info("Segmentation metrics calculation completed.") + self._logger.info(f"Metrics results: {results}") + + return results + + def _count_connected_components(self, binary_mask: Union[np.ndarray, Any], min_size: int = 5) -> int: + """Count connected components with size greater than min_size pixels. + + Connected components analysis is performed on CPU as it's faster than GPU + for typical medical imaging segmentation tasks. + + Args: + binary_mask: Binary mask array (numpy or cupy). + min_size: Minimum component size in pixels to count. Default is 5. + + Returns: + Number of connected components with size > min_size. + """ + # Always use CPU for connected components (faster for typical sizes) + if has_cupy and isinstance(binary_mask, cupy.ndarray): + binary_mask = cupy.asnumpy(binary_mask) + + # Convert to numpy if it's a different array type + binary_mask = np.asarray(binary_mask) + + # Label connected components using scipy + labeled_array, num_features = ndimage.label(binary_mask) + + if num_features == 0: + return 0 + + # Use bincount for efficient counting + component_sizes = np.bincount(labeled_array.ravel()) + + # Skip index 0 (background) and count components > min_size + num_large_components = int(np.sum(component_sizes[1:] > min_size)) + + return num_large_components + + +def test(): + """Test function for the SegmentationMetricsOperator.""" + + import numpy as np + + from monai.deploy.core import Fragment + from monai.deploy.core.domain.image import Image + + # Create a larger 3D test case for timing comparison + print("Testing SegmentationMetricsOperator...") + print("=" * 60) + + # Create synthetic data: 100x100x100 volume for better timing comparison + rng = np.random.default_rng(42) + scan_data = rng.random((100, 100, 100)) * 100 # Random intensities 0-100 + seg_data = np.zeros((100, 100, 100), dtype=np.int32) + + # Create multiple labeled regions with various sizes + seg_data[20:50, 20:80, 20:80] = 1 # Label 1: liver (large region) + seg_data[60:90, 30:70, 30:70] = 2 # Label 2: spleen (medium region) + seg_data[10:15, 10:15, 10:15] = 3 # Label 3: kidney (small region) + + # Add some fragmentation to test connected components + seg_data[25:28, 25:28, 25:28] = 2 # Small isolated spleen fragment + seg_data[85:88, 85:88, 85:88] = 2 # Another small spleen fragment + + # Create Image objects with spacing metadata + scan_image = Image(scan_data, metadata={"spacing": [1.0, 1.0, 1.0]}) # 1mm spacing (mL = mm³/1000) + seg_image = Image(seg_data) + + # Define label dictionary + label_dict = { + "liver": 1, + "spleen": 2, + "kidney": 3, + } + + # Helper classes to simulate op_input and op_output for compute() + class MockOpInput: + def __init__(self, seg_mask, scan): + self.data = {"segmentation_mask": seg_mask, "input_scan": scan} + + def receive(self, name): + return self.data[name] + + class MockOpOutput: + def __init__(self): + self.outputs = {} + + def emit(self, value, name): + self.outputs[name] = value + + # Test: Operator compute() with CPU + print("\n[Test] Running SegmentationMetricsOperator.compute() with CPU...") + fragment = Fragment() + operator = SegmentationMetricsOperator(fragment, use_gpu=False, labels_dict=label_dict) + op_input = MockOpInput(seg_image, scan_image) + op_output = MockOpOutput() + operator.compute(op_input, op_output, context=None) + metrics = op_output.outputs[operator.output_name_metrics] + + print("\n" + "=" * 60) + print("Segmentation Metrics Results:") + print("=" * 60) + for label_name, label_metrics in metrics.items(): + print(f"\n{label_name}:") + for metric_name, metric_value in label_metrics.items(): + print(f" {metric_name}: {metric_value}") + print("\n" + "=" * 60) + print("Test completed successfully!") + + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_overlay_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_overlay_operator.py new file mode 100644 index 00000000..5d740626 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_overlay_operator.py @@ -0,0 +1,945 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from typing import Any, List, Union + +import matplotlib +import numpy as np +import torch + +from monai.deploy.utils.importutil import optional_import + +cupy, has_cupy = optional_import("cupy") +cupyx_scipy_ndimage, has_cupyx_scipy = optional_import("cupyx.scipy.ndimage") + +from monai.deploy.core import Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +from monai.deploy.core.domain.image import Image + + +class SegmentationOverlayOperator(Operator): + """ + This operator generates an RGB overlay image by blending a segmentation mask with the corresponding input scan. + + The overlay highlights segmented regions on top of the grayscale or intensity image, using alpha blending for visualization. + GPU acceleration is used if available and enabled. + + VOI LUT Module tags are extracted from the source DICOM series when available to apply + appropriate windowing to the Secondary Capture. + + See DICOM PS3.3 C.11.2 for details on VOI LUT Module. + + Windowing behaviour by case: + + 1. CT with VOI LUT tags present — WindowCenter/WindowWidth (Hounsfield Units) are read + from the source series. If the values are identical across all instances (typical for + CT) a single scalar window is used. If they vary per instance the per-slice path is + taken (see case 3). + + 2. CT with no VOI LUT tags — the class-level soft-tissue HU defaults + (CENTER=40 HU, WIDTH=400 HU) are applied as a scalar window. + + 3. MR (or any non-CT modality) with VOI LUT tags present — per-instance + WindowCenter/WindowWidth values are collected from every SOP instance, sorted + ascending by dot(slice_normal, ImagePositionPatient) to match the axis-0 ordering + produced by DICOMSeriesToVolumeOperator, then inspected for uniformity (±0.5 + threshold). If uniform, a single scalar window is used; if varying, per-slice + ndarrays of shape (N_instances,) are passed to the windowing path, where they are + broadcast along the detected slice axis of the image volume. + + 4. MR (or any non-CT modality) with no VOI LUT tags — the window is auto-computed + from the 1st/99th percentile of non-zero pixel values so that arbitrary scanner + signal units are mapped correctly to the [0, 255] display range. + + Named Input: + segmentation_mask: Segmentation mask as a tensor, numpy array, or Image object. + input_scan: Input scan/image as a tensor, numpy array, or Image object. + study_selected_series_list: The DICOM series from which the segmentation mask was derived. + Named Output: + overlay: RGB overlay image (same type as input, typically Image) with segmentation regions highlighted. + """ + + # Default CT soft-tissue display window (Hounsfield Units) - range [-160, 240] HU + # Used only when the source series modality is CT and no DICOM VOI LUT tags are present + DEFAULT_WINDOW_CENTER = float(40.0) + DEFAULT_WINDOW_WIDTH = float(400.0) + + # Default VOI LUT Function - LINEAR (modality agnostic) + DEFAULT_VOI_LUT_FUNCTION = "LINEAR" + + def __init__(self, fragment: Fragment, *args, use_gpu: bool = True, alpha: float = 0.7, **kwargs): + """Create an instance for a containing application object. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + use_gpu (bool): If True and GPU is available, use CuPy for GPU acceleration. Default is True. + alpha (float): Alpha blending factor for overlay (0.0 to 1.0). Default is 0.7. + """ + + self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) + self.input_name_seg_mask = "segmentation_mask" + self.input_name_scan = "input_scan" + self.input_name_study_series = "study_selected_series_list" + self.output_name_overlay = "overlay" + self.use_gpu = use_gpu and has_cupy + self.alpha = alpha + self.window_center_default = SegmentationOverlayOperator.DEFAULT_WINDOW_CENTER + self.window_width_default = SegmentationOverlayOperator.DEFAULT_WINDOW_WIDTH + self.voi_lut_function_default = SegmentationOverlayOperator.DEFAULT_VOI_LUT_FUNCTION + + # Need to call the base class constructor last + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + """Set up the named input(s), and output(s) if applicable, aka ports. + + Args: + spec (OperatorSpec): The Operator specification for inputs and outputs etc. + """ + + spec.input(self.input_name_seg_mask) + spec.input(self.input_name_scan) + spec.input(self.input_name_study_series) + spec.output(self.output_name_overlay) + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O.""" + + # Receive inputs + segmentation_mask = op_input.receive(self.input_name_seg_mask) + scan = op_input.receive(self.input_name_scan) + study_selected_series_list = op_input.receive(self.input_name_study_series) + + # Try to extract VOI window & LUT function from the source DICOM series + # All SOP instances are iterated: per-instance WindowCenter/WindowWidth values + # are collected, sorted by slice distance (matching DICOMSeriesToVolumeOperator + # axis-0 order), and returned as scalar floats (uniform series, e.g. CT) or + # per-slice ndarrays (varying series, e.g. MR). CT soft-tissue HU defaults are + # used when tags are absent; non-CT modalities fall back to auto-windowing. + window_center, window_width, voi_lut_function = self._extract_dicom_window( + study_selected_series_list, + default_window_center=self.window_center_default, + default_window_width=self.window_width_default, + default_voi_lut_function=self.voi_lut_function_default, + ) + + # create overlay + overlay = self.create_overlay( + segmentation_mask, + scan, + window_center=window_center, + window_width=window_width, + voi_lut_function=voi_lut_function, + ) + + # Emit output + op_output.emit(overlay, self.output_name_overlay) + + def _extract_dicom_window( + self, + study_selected_series_list, + default_window_center: float, + default_window_width: float, + default_voi_lut_function: str, + ): + """Extract WindowCenter, WindowWidth, and VOILUTFunction from the source DICOM series. + + Iterates every SOP instance in the series, collecting per-instance WindowCenter and + WindowWidth values. Each instance is assigned a sort key equal to + dot(slice_normal, ImagePositionPatient) — the exact signed distance used by + DICOMSeriesToVolumeOperator.prepare_series to order volume slices — so that the + returned window arrays are in the same axis-0 order as the stacked image volume. + + When multiple preset windows are stored per instance (e.g. soft-tissue and bone + windows on a CT), the first entry is used, which conventionally is the scanner's + preferred display window. + + VOILUTFunction is read once from the first instance that carries the tag; it is + assumed to be constant across a series. + + Args: + study_selected_series_list: Selected source DICOM series. + default_window_center: Value used when no WindowCenter tag is found (CT only). + default_window_width: Value used when no WindowWidth tag is found (CT only). + default_voi_lut_function: Value used when no VOILUTFunction tag is found. + + Returns: + Tuple (window_center, window_width, voi_lut_function). + - CT (or unknown modality) with uniform tags: scalar floats. + - MR (or any modality) with uniform tags: scalar floats. + - MR with per-instance varying tags: np.ndarray of shape (N_instances,) for both + window_center and window_width, sorted in the same axis-0 order as the image + volume produced by DICOMSeriesToVolumeOperator (ascending dot(slice_normal, + ImagePositionPatient)). + - When no tags are found, CT returns HU-based scalar defaults; non-CT returns + (None, None, ...) so the caller can auto-compute from pixel data. + """ + + modality = None + voi_fn = default_voi_lut_function + voi_fn_set = False + + # List of (sort_key, wc, ww) tuples for per-instance collection. + # sort_key is dot(slice_normal, ImagePositionPatient), mirroring the distance + # used by DICOMSeriesToVolumeOperator.prepare_series to order volume slices. + # Falls back to InstanceNumber, then iteration index, if geometry tags are absent. + per_instance: List = [] + + try: + for study_selected_series in study_selected_series_list or []: + if not isinstance(study_selected_series, StudySelectedSeries): + continue + for selected_series in study_selected_series.selected_series: + for iter_idx, sop_instance in enumerate(selected_series.series.get_sop_instances()): + native = sop_instance.get_native_sop_instance() + + # Read Modality once for fallback decision + if modality is None and hasattr(native, "Modality"): + modality = str(native.Modality).upper().strip() + + # VOILUTFunction stays constant across a series; read from first instance + if not voi_fn_set and hasattr(native, "VOILUTFunction") and native.VOILUTFunction: + voi_fn = str(native.VOILUTFunction).upper().strip() + voi_fn_set = True + + # WindowCenter / WindowWidth may be scalar or a list (multiple preset windows) + if hasattr(native, "WindowCenter") and hasattr(native, "WindowWidth"): + wc_raw = native.WindowCenter + ww_raw = native.WindowWidth + # pydicom can return DSfloat, a list, or a MultiValue; take the first window + wc = float(wc_raw[0] if hasattr(wc_raw, "__iter__") else wc_raw) + ww = float(ww_raw[0] if hasattr(ww_raw, "__iter__") else ww_raw) + + # Compute sort key matching DICOMSeriesToVolumeOperator.prepare_series: + # slice_normal = cross(row_cosines, col_cosines) from ImageOrientationPatient + # distance = dot(slice_normal, ImagePositionPatient) + sort_key = self._compute_slice_distance(native, fallback=iter_idx) + per_instance.append((sort_key, wc, ww)) + except Exception as exc: + self._logger.warning(f"Could not read VOI LUT module from source DICOM: {exc}") + + if per_instance: + # Sort ascending by slice distance to match image volume axis-0 ordering + per_instance.sort(key=lambda x: x[0]) + wc_arr = np.array([v[1] for v in per_instance], dtype=np.float64) + ww_arr = np.array([v[2] for v in per_instance], dtype=np.float64) + + _units = "HU" if modality == "CT" else "signal units" + # Detect whether windowing is effectively uniform across the series + wc_range = wc_arr.max() - wc_arr.min() + ww_range = ww_arr.max() - ww_arr.min() + is_uniform = wc_range < 0.5 and ww_range < 0.5 + + if is_uniform: + self._logger.info( + f"Uniform VOI LUT across {len(wc_arr)} instances " + f"(Modality={modality}): Center={wc_arr[0]:.1f} {_units}, " + f"Width={ww_arr[0]:.1f} {_units}, VOILUTFunction={voi_fn}. " + f"Uniform windowing will be applied" + ) + return float(wc_arr[0]), float(ww_arr[0]), voi_fn + else: + self._logger.info( + f"Non-uniform VOI LUT found across {len(wc_arr)} instances " + f"(Modality={modality}, VOILUTFunction={voi_fn}): " + f"Center {_units} range [{wc_arr.min():.1f}, {wc_arr.max():.1f}], " + f"Width {_units} range [{ww_arr.min():.1f}, {ww_arr.max():.1f}]. " + f"Per-slice windowing will be applied (sorted by slice distance)" + ) + return wc_arr, ww_arr, voi_fn + + # No VOI LUT tags found in source series + # For CT (or unknown modality), fall back to the class-level HU-based soft-tissue defaults + # For MR and other non-CT modalities, return None so the caller auto-computes the window + # from the actual pixel data, since signal values are scanner-dependent + is_ct = (modality == "CT") if modality is not None else True + if is_ct: + self._logger.info( + f"No VOI LUT DICOM tags found (Modality={modality or 'unknown'}); " + f"using CT soft tissue defaults: Center={default_window_center:.1f} HU, " + f"Width={default_window_width:.1f} HU, VOILUTFunction={default_voi_lut_function}" + ) + return default_window_center, default_window_width, default_voi_lut_function + else: + self._logger.info( + f"No VOI LUT DICOM tags found (Modality={modality}); " + f"window will be auto-computed from pixel data percentiles" + ) + return None, None, default_voi_lut_function + + def create_overlay( + self, + segmentation_mask: Union[np.ndarray, torch.Tensor, Image], + scan: Union[np.ndarray, torch.Tensor, Image], + window_center: Union[float, np.ndarray, None], + window_width: Union[float, np.ndarray, None], + voi_lut_function: str, + ) -> Union[np.ndarray, Image]: + """Create overlay image from segmentation mask and scan with CuPy acceleration when possible. + + Args: + segmentation_mask: Segmentation mask as numpy array, torch tensor, or Image object + scan: Input scan/image as numpy array, torch tensor, or Image object + window_center: VOI window center - scalar float, per-slice np.ndarray (MR with + instance-varying tags), or None for non-CT modalities (auto-computed below). + window_width: VOI window width - scalar float, per-slice np.ndarray (MR with + instance-varying tags), or None for non-CT modalities (auto-computed below). + voi_lut_function: VOI LUT function string - from source series or LINEAR default. + + Returns: + RGB overlay image with the same type as input (numpy array or Image) + """ + + # Handle different input types + original_type = type(segmentation_mask) + metadata = None + + if isinstance(segmentation_mask, Image): + mask_data = segmentation_mask.asnumpy() + metadata = segmentation_mask.metadata() + elif isinstance(segmentation_mask, torch.Tensor): + # Check if tensor is on GPU to avoid unnecessary CPU transfer + if segmentation_mask.is_cuda and self.use_gpu and has_cupy: + # Convert directly to CuPy without going through CPU + mask_data = cupy.asarray(segmentation_mask.detach()) + else: + # For CPU tensors, convert to numpy (will be transferred to GPU later if use_gpu=True) + mask_data = segmentation_mask.detach().numpy() + else: + # NumPy arrays or other types - will be transferred to GPU later if use_gpu=True + mask_data = segmentation_mask + + if isinstance(scan, Image): + scan_data = scan.asnumpy() + elif isinstance(scan, torch.Tensor): + # Check if tensor is on GPU to avoid unnecessary CPU transfer + if scan.is_cuda and self.use_gpu and has_cupy: + # Convert directly to CuPy without going through CPU + scan_data = cupy.asarray(scan.detach()) + else: + # For CPU tensors, convert to numpy (will be transferred to GPU later if use_gpu=True) + scan_data = scan.detach().numpy() + else: + # NumPy arrays or other types - will be transferred to GPU later if use_gpu=True + scan_data = scan + + # Remove channel dimension if present + if mask_data.ndim == 4 and mask_data.shape[0] == 1: + mask_data = mask_data[0] + if scan_data.ndim == 4 and scan_data.shape[0] == 1: + scan_data = scan_data[0] + + # Auto-compute window for non-CT modalities when DICOM VOI tags are absent + if window_center is None or window_width is None: + _scan_np = ( + cupy.asnumpy(scan_data) if (has_cupy and isinstance(scan_data, cupy.ndarray)) else np.asarray(scan_data) + ) + window_center, window_width = self._auto_window_from_data(_scan_np) + self._logger.info( + f"Auto-computed VOI window from pixel data: Center={window_center:.1f}, " + f"Width={window_width:.1f} (VOILUTFunction={voi_lut_function})" + ) + + # Check if we should use GPU + use_cupy = self.use_gpu and has_cupy + + if use_cupy: + try: + # Ensure both arrays are on GPU independently + if not isinstance(mask_data, cupy.ndarray): + mask_data = cupy.asarray(mask_data) + if not isinstance(scan_data, cupy.ndarray): + scan_data = cupy.asarray(scan_data) + overlay_image = self._create_overlay_cupy( + scan_data, mask_data, window_center, window_width, voi_lut_function + ) + # Transfer back to CPU + overlay_image = cupy.asnumpy(overlay_image) + except Exception as e: + self._logger.warning(f"CuPy processing failed, falling back to CPU: {e}") + if isinstance(mask_data, cupy.ndarray): + mask_data = cupy.asnumpy(mask_data) + if isinstance(scan_data, cupy.ndarray): + scan_data = cupy.asnumpy(scan_data) + overlay_image = self._create_overlay_numpy( + scan_data, mask_data, window_center, window_width, voi_lut_function + ) + else: + overlay_image = self._create_overlay_numpy( + scan_data, mask_data, window_center, window_width, voi_lut_function + ) + + # Return in original format + if original_type == Image: + return Image(overlay_image, metadata=metadata) + else: + return np.asarray(overlay_image) + + def _compute_slice_distance(self, native_ds, fallback: int = 0) -> float: + """Compute the signed slice distance used by DICOMSeriesToVolumeOperator.prepare_series. + + Replicates the exact formula from prepare_series so that per-instance VOI window arrays + are sorted in the same axis-0 order as the stacked image volume: + + slice_normal = cross(row_cosines, col_cosines) # from ImageOrientationPatient + distance = dot(slice_normal, ImagePositionPatient) + + Args: + native_ds: pydicom Dataset for one SOP instance. + fallback: Value returned when required geometry tags are absent. + + Returns: + Scalar float distance along the slice normal, or the fallback value. + """ + try: + iop = native_ds.ImageOrientationPatient # [rx, ry, rz, cx, cy, cz] + ipp = native_ds.ImagePositionPatient # [x, y, z] + cosines = [float(v) for v in iop] + pos = [float(v) for v in ipp] + # Cross product of row and column direction cosines gives the slice normal + n0 = cosines[1] * cosines[5] - cosines[2] * cosines[4] + n1 = cosines[2] * cosines[3] - cosines[0] * cosines[5] + n2 = cosines[0] * cosines[4] - cosines[1] * cosines[3] + # Dot product with ImagePositionPatient = signed distance from origin + return n0 * pos[0] + n1 * pos[1] + n2 * pos[2] + except Exception: + # Fall back to InstanceNumber if available, else iteration index + try: + return float(native_ds.InstanceNumber) + except Exception: + return float(fallback) + + def _auto_window_from_data(self, image_data: np.ndarray): + """Compute a VOI window (center, width) from pixel-data percentiles. + + Used as a fallback when DICOM VOI LUT tags are absent and the modality is not CT. + MR signal values are scanner- and sequence-dependent; the 1st and 99th percentiles + of non-zero voxels give a robust tissue-based window without being biased by + background air / zero-padding voxels. + + Args: + image_data: N-dimensional image array (any numeric dtype). + + Returns: + Tuple (window_center, window_width) as floats. + """ + flat = image_data.ravel().astype(np.float32) + nonzero = flat[flat > 0] + if nonzero.size == 0: + nonzero = flat # all-zero volume; prevent empty-sequence error + low = float(np.percentile(nonzero, 1)) + high = float(np.percentile(nonzero, 99)) + center = (low + high) / 2.0 + width = max(high - low, 1.0) # PS3.3 C.11.2.1.3: width must be > 0 + return center, width + + def _create_overlay_cupy( + self, + image_volume: Any, + label_volume: Any, + window_center: Union[float, np.ndarray], + window_width: Union[float, np.ndarray], + voi_lut_function: str, + ) -> Any: + """Create overlay using CuPy for GPU acceleration. + + Args: + image_volume: Image volume as CuPy array (3D: D, H, W) + label_volume: Label volume as CuPy array (3D: D, H, W) + window_center: Scalar or per-slice ndarray of VOI window centers. + window_width: Scalar or per-slice ndarray of VOI window widths. + voi_lut_function: VOI LUT function string - from source series or LINEAR default. + + Returns: + RGB overlay image as CuPy array (3, D, H, W) + """ + + # Log scan range (sampled on CPU to keep it lightweight) + _s = cupy.asnumpy(image_volume) + _fn = voi_lut_function.upper().strip() + + if isinstance(window_center, np.ndarray): + _ww_arr = np.asarray(window_width) + self._logger.info( + f"Scan Value Range: [{_s.min():.1f}, {_s.max():.1f}] " + f"VOI Window: per-slice ({len(window_center)} slices), " + f"Center range [{window_center.min():.1f}, {window_center.max():.1f}], " + f"Width range [{_ww_arr.min():.1f}, {_ww_arr.max():.1f}], " + f"Function={_fn}" + ) + else: + if _fn == "SIGMOID": + _window_str = f"SIGMOID (no hard clip, inflection={window_center:.1f}, scale={window_width:.1f})" + elif _fn == "LINEAR_EXACT": + _low = window_center - (window_width / 2.0) + _high = window_center + (window_width / 2.0) + _window_str = f"→ Range [{_low:.1f}, {_high:.1f}]" + else: # LINEAR (default) + _low = window_center - 0.5 - ((window_width - 1) / 2.0) + _high = window_center - 0.5 + ((window_width - 1) / 2.0) + _window_str = f"→ Range [{_low:.1f}, {_high:.1f}]" + self._logger.info( + f"Scan Value Range: [{_s.min():.1f}, {_s.max():.1f}] " + f"VOI Window: Center={window_center:.1f}, Width={window_width:.1f}, " + f"Function={_fn} {_window_str}" + ) + + del _s + + # Convert image and label to RGB + image_rgb = self._convert_to_rgb_cupy(image_volume, window_center, window_width, voi_lut_function) + label_rgb = self._apply_jet_colormap_cupy(label_volume) + + # Create alpha-blended overlay + overlay = image_rgb.copy() + mask = label_volume > 0 + + # Apply overlay where mask is present + for i in range(3): # For each color channel + overlay[i][mask] = (self.alpha * label_rgb[i][mask] + (1 - self.alpha) * overlay[i][mask]).astype( + cupy.uint8 + ) + + return overlay + + def _create_overlay_numpy( + self, + image_volume: np.ndarray, + label_volume: np.ndarray, + window_center: Union[float, np.ndarray], + window_width: Union[float, np.ndarray], + voi_lut_function: str, + ) -> np.ndarray: + """Create overlay using NumPy for CPU processing. + + Args: + image_volume: Image volume as NumPy array (3D: D, H, W) + label_volume: Label volume as NumPy array (3D: D, H, W) + window_center: Scalar or per-slice ndarray of VOI window centers. + window_width: Scalar or per-slice ndarray of VOI window widths. + voi_lut_function: VOI LUT function string - from source series or LINEAR default. + + Returns: + RGB overlay image as NumPy array (3, H, W, D) + """ + + # Log scan range once so the effective window can be verified against actual data + _fn = voi_lut_function.upper().strip() + + if isinstance(window_center, np.ndarray): + _ww_arr = np.asarray(window_width) + self._logger.info( + f"Scan Value Range: [{image_volume.min():.1f}, {image_volume.max():.1f}] " + f"VOI Window: per-slice ({len(window_center)} slices), " + f"Center range [{window_center.min():.1f}, {window_center.max():.1f}], " + f"Width range [{_ww_arr.min():.1f}, {_ww_arr.max():.1f}], " + f"Function={_fn}" + ) + else: + if _fn == "SIGMOID": + _window_str = f"SIGMOID (no hard clip, inflection={window_center:.1f}, scale={window_width:.1f})" + elif _fn == "LINEAR_EXACT": + _low = window_center - (window_width / 2.0) + _high = window_center + (window_width / 2.0) + _window_str = f"→ Range [{_low:.1f}, {_high:.1f}]" + else: # LINEAR (default) + _low = window_center - 0.5 - ((window_width - 1) / 2.0) + _high = window_center - 0.5 + ((window_width - 1) / 2.0) + _window_str = f"→ Range [{_low:.1f}, {_high:.1f}]" + self._logger.info( + f"Scan Value Range: [{image_volume.min():.1f}, {image_volume.max():.1f}] " + f"VOI Window: Center={window_center:.1f}, Width={window_width:.1f}, " + f"Function={_fn} {_window_str}" + ) + + # Convert image and label to RGB + image_rgb = self._convert_to_rgb_numpy(image_volume, window_center, window_width, voi_lut_function) + label_rgb = self._apply_jet_colormap_numpy(label_volume) + + # Create alpha-blended overlay + overlay = image_rgb.copy() + mask = label_volume > 0 + + # Ensure shapes match + if not (overlay.shape[1:] == label_rgb.shape[1:] == mask.shape): + raise ValueError( + f"Shape mismatch: overlay {overlay.shape}, label_rgb {label_rgb.shape}, mask {mask.shape}.\n" + f"image_volume shape: {image_volume.shape}, label_volume shape: {label_volume.shape}" + ) + + # Apply overlay where mask is present + for i in range(3): # For each color channel + overlay[i][mask] = (self.alpha * label_rgb[i][mask] + (1 - self.alpha) * overlay[i][mask]).astype(np.uint8) + + return overlay + + def _apply_jet_colormap_cupy(self, label_volume: Any) -> Any: + """Apply Jet colormap to label volume using CuPy. + + Args: + label_volume: 3D label volume (D, H, W) + + Returns: + RGB label volume (3, D, H, W) + """ + + # Normalize to 0-255 range + max_val = cupy.max(label_volume) + if max_val > 0: + label_normalized = (label_volume / max_val) * 255.0 + else: + label_normalized = cupy.zeros_like(label_volume, dtype=cupy.float32) + + label_uint8 = label_normalized.astype(cupy.uint8) + + # Apply Jet colormap manually (since matplotlib is CPU-only) + val = label_uint8.astype(cupy.float32) / 255.0 + + # Red channel + r = cupy.clip((1.5 - cupy.abs(4.0 * val - 3.0)) * 255, 0, 255).astype(cupy.uint8) + # Green channel + g = cupy.clip((1.5 - cupy.abs(4.0 * val - 2.0)) * 255, 0, 255).astype(cupy.uint8) + # Blue channel + b = cupy.clip((1.5 - cupy.abs(4.0 * val - 1.0)) * 255, 0, 255).astype(cupy.uint8) + + # Stack to create RGB volume (3, D, H, W) + label_rgb = cupy.stack([r, g, b], axis=0) + + return label_rgb + + def _apply_jet_colormap_numpy(self, label_volume: np.ndarray) -> np.ndarray: + """Apply Jet colormap to label volume using NumPy. + + Args: + label_volume: 3D label volume (D, H, W) + + Returns: + RGB label volume (3, D, H, W) + """ + + # Normalize to 0-255 range + max_val = np.max(label_volume) + if max_val > 0: + label_normalized = (label_volume / max_val) * 255.0 + else: + label_normalized = np.zeros_like(label_volume, dtype=np.float32) + + label_uint8 = label_normalized.astype(np.uint8) + + # Apply Jet colormap + jet_colormap = matplotlib.colormaps["jet"].resampled(256) + label_rgb = np.asarray(jet_colormap(label_uint8))[:, :, :, :3] # Take only RGB channels + + # Convert to uint8 and rearrange to (3, D, H, W) + label_rgb = (label_rgb * 255).astype(np.uint8) + label_rgb = np.transpose(label_rgb, (3, 0, 1, 2)) + + return label_rgb + + def _convert_to_rgb_cupy( + self, + image_volume: Any, + window_center: Union[float, np.ndarray], + window_width: Union[float, np.ndarray], + voi_lut_function: str, + ) -> Any: + """Convert grayscale image to RGB using CuPy with VOI windowing applied. + + Applies the configured VOI window so that pixel values map to the [0, 255] display range, + preserving clinically relevant contrast. For CT the input values are Hounsfield Units; + for MR they are scanner signal units. Implements all three DICOM PS3.3 C.11.2 VOI LUT + Functions (LINEAR, LINEAR_EXACT, SIGMOID). + + For per-slice windowing (MR), the slice axis is detected dynamically as the axis + whose size matches the number of DICOM instances, so this method handles both + (D, H, W) and (H, W, D) volume orderings correctly. + + Args: + image_volume: 3D grayscale image volume (D, H, W from this pipeline) + window_center: Scalar float (CT / uniform MR) or per-slice ndarray sorted by + dot(slice_normal, ImagePositionPatient), matching image volume axis-0 order. + window_width: Scalar float or per-slice ndarray (same ordering as window_center). + voi_lut_function: VOI LUT function string - from source series or LINEAR default. + + Returns: + RGB volume (3, D, H, W) with VOI windowing applied + """ + + img = image_volume.astype(cupy.float32) + fn = voi_lut_function.upper().strip() + + if isinstance(window_center, np.ndarray): + # Per-slice windowing (common in MR). + # The scan volume axis order varies (D,H,W) or (H,W,D) depending on the upstream + # transform pipeline. Detect the slice axis as the one whose size matches the + # number of DICOM instances. If multiple axes match (or none), fall back to the + # smallest axis, which is almost always the slice dimension in a medical volume. + n_win = len(window_center) + match_axes = [ax for ax, s in enumerate(img.shape) if s == n_win] + if len(match_axes) == 1: + slice_axis = match_axes[0] + else: + slice_axis = int(np.argmin(img.shape)) + self._logger.debug(f"Per-slice window: {n_win} windows, img shape {img.shape}, slice_axis={slice_axis}") + + wc = window_center.astype(np.float64) + ww = np.asarray(window_width).astype(np.float64) + d = img.shape[slice_axis] + if d != n_win: + idx = np.linspace(0, n_win - 1, d) + wc = np.interp(idx, np.arange(n_win), wc) + ww = np.interp(idx, np.arange(n_win), ww) + + # Build a shape that broadcasts the 1-D window array along slice_axis only + bc_shape = [1, 1, 1] + bc_shape[slice_axis] = -1 + wc_gpu = cupy.asarray(wc.reshape(bc_shape).astype(np.float32)) + ww_gpu = cupy.asarray(ww.reshape(bc_shape).astype(np.float32)) + if fn == "SIGMOID": + image_normalized = 1.0 / (1.0 + cupy.exp(-4.0 * (img - wc_gpu) / ww_gpu)) + elif fn == "LINEAR_EXACT": + low = wc_gpu - (ww_gpu / 2.0) + high = wc_gpu + (ww_gpu / 2.0) + image_normalized = cupy.clip((img - low) / (high - low), 0.0, 1.0) + else: # LINEAR + low = wc_gpu - 0.5 - ((ww_gpu - 1) / 2.0) + high = wc_gpu - 0.5 + ((ww_gpu - 1) / 2.0) + image_normalized = cupy.clip((img - low) / (high - low), 0.0, 1.0) + else: + # Scalar windowing (CT, or uniform MR) + if fn == "SIGMOID": + # PS3.3 C.11.2.1.3.1: y = 1 / (1 + exp(-4*(x-c)/w)) + image_normalized = 1.0 / (1.0 + cupy.exp(-4.0 * (img - window_center) / window_width)) + elif fn == "LINEAR_EXACT": + # PS3.3 C.11.2.1.3.2: floor = c-w/2, ceiling = c+w/2 + low = float(window_center - (window_width / 2.0)) + high = float(window_center + (window_width / 2.0)) + image_normalized = cupy.clip((img - low) / (high - low), 0.0, 1.0) + else: # LINEAR + # Default when VOILUTFunction tag is absent + # PS3.3 C.11.2.1.2.1: floor = c - 0.5 - (w-1)/2, ceiling = c - 0.5 + (w-1)/2 + low = float(window_center - 0.5 - ((window_width - 1) / 2.0)) + high = float(window_center - 0.5 + ((window_width - 1) / 2.0)) + image_normalized = cupy.clip((img - low) / (high - low), 0.0, 1.0) + + # Replicate to 3-channel RGB (3, D, H, W) + image_rgb = cupy.stack([image_normalized] * 3, axis=0) + image_rgb = (image_rgb * 255).astype(cupy.uint8) + + return image_rgb + + def _convert_to_rgb_numpy( + self, + image_volume: np.ndarray, + window_center: Union[float, np.ndarray], + window_width: Union[float, np.ndarray], + voi_lut_function: str, + ) -> np.ndarray: + """Convert grayscale image to RGB using NumPy with VOI windowing applied. + + Applies the configured VOI window so that pixel values map to the [0, 255] display range, + preserving clinically relevant contrast. For CT the input values are Hounsfield Units; + for MR they are scanner signal units. Implements all three DICOM PS3.3 C.11.2 VOI LUT + Functions (LINEAR, LINEAR_EXACT, SIGMOID). + + For per-slice windowing (MR), the slice axis is detected dynamically as the axis + whose size matches the number of DICOM instances, so this method handles both + (D, H, W) and (H, W, D) volume orderings correctly. + + Args: + image_volume: 3D grayscale image volume (D, H, W from this pipeline) + window_center: Scalar float (CT / uniform MR) or per-slice ndarray sorted by + dot(slice_normal, ImagePositionPatient), matching image volume axis-0 order. + window_width: Scalar float or per-slice ndarray (same ordering as window_center). + voi_lut_function: VOI LUT function string - from source series or LINEAR default. + + Returns: + RGB volume (3, D, H, W) with VOI windowing applied + """ + + img = image_volume.astype(np.float32) + fn = voi_lut_function.upper().strip() + + if isinstance(window_center, np.ndarray): + # Per-slice windowing (common in MR). + # The scan volume axis order varies (D,H,W) or (H,W,D) depending on the upstream + # transform pipeline. Detect the slice axis as the one whose size matches the + # number of DICOM instances. If multiple axes match (or none), fall back to the + # smallest axis, which is almost always the slice dimension in a medical volume. + n_win = len(window_center) + match_axes = [ax for ax, s in enumerate(img.shape) if s == n_win] + if len(match_axes) == 1: + slice_axis = match_axes[0] + else: + slice_axis = int(np.argmin(img.shape)) + self._logger.debug(f"Per-slice window: {n_win} windows, img shape {img.shape}, slice_axis={slice_axis}") + + wc = window_center.astype(np.float64) + ww = np.asarray(window_width).astype(np.float64) + d = img.shape[slice_axis] + if d != n_win: + idx = np.linspace(0, n_win - 1, d) + wc = np.interp(idx, np.arange(n_win), wc) + ww = np.interp(idx, np.arange(n_win), ww) + + # Build a shape that broadcasts the 1-D window array along slice_axis only + bc_shape = [1, 1, 1] + bc_shape[slice_axis] = -1 + wc = wc.reshape(bc_shape).astype(np.float32) + ww = ww.reshape(bc_shape).astype(np.float32) + if fn == "SIGMOID": + image_normalized = 1.0 / (1.0 + np.exp(-4.0 * (img - wc) / ww)) + elif fn == "LINEAR_EXACT": + low = wc - (ww / 2.0) + high = wc + (ww / 2.0) + image_normalized = np.clip((img - low) / (high - low), 0.0, 1.0) + else: # LINEAR + low = wc - 0.5 - ((ww - 1) / 2.0) + high = wc - 0.5 + ((ww - 1) / 2.0) + image_normalized = np.clip((img - low) / (high - low), 0.0, 1.0) + else: + # Scalar windowing (CT, or uniform MR) + if fn == "SIGMOID": + # PS3.3 C.11.2.1.3.1: y = 1 / (1 + exp(-4*(x-c)/w)) + image_normalized = 1.0 / (1.0 + np.exp(-4.0 * (img - window_center) / window_width)) + elif fn == "LINEAR_EXACT": + # PS3.3 C.11.2.1.3.2: floor = c-w/2, ceiling = c+w/2 + low_s = window_center - (window_width / 2.0) + high_s = window_center + (window_width / 2.0) + image_normalized = np.clip((img - low_s) / (high_s - low_s), 0.0, 1.0) + else: # LINEAR + # Default when VOILUTFunction tag is absent + # PS3.3 C.11.2.1.2.1: floor = c - 0.5 - (w-1)/2, ceiling = c - 0.5 + (w-1)/2 + low_s = window_center - 0.5 - ((window_width - 1) / 2.0) + high_s = window_center - 0.5 + ((window_width - 1) / 2.0) + image_normalized = np.clip((img - low_s) / (high_s - low_s), 0.0, 1.0) + + # Replicate to 3-channel RGB (3, D, H, W) + image_rgb = np.stack([np.asarray(image_normalized)] * 3, axis=0) + image_rgb = (image_rgb * 255).astype(np.uint8) + + return image_rgb + + +def test(): + """Test function for the SegmentationOverlayOperator.""" + import time + + import numpy as np + + from monai.deploy.core import Fragment + from monai.deploy.core.domain.image import Image + + print("Testing SegmentationOverlayOperator...") + print("=" * 60) + + # Create synthetic data: 100x100x100 volume + rng = np.random.default_rng(42) + scan_data = rng.random((100, 100, 100)) * 100 # Random intensities 0-100 (HU-like) + seg_data = np.zeros((100, 100, 100), dtype=np.int32) + + # Create labeled regions + seg_data[20:50, 20:80, 20:80] = 1 # Label 1: liver (large region) + seg_data[60:90, 30:70, 30:70] = 2 # Label 2: spleen (medium region) + seg_data[10:15, 10:15, 10:15] = 3 # Label 3: kidney (small region) + + scan_image = Image(scan_data, metadata={"spacing": [1.0, 1.0, 1.0]}) + seg_image = Image(seg_data) + + # Test 1: CPU path with scalar window (CT soft-tissue defaults) + print("\n[Test 1] Running create_overlay() with CPU, scalar window...") + fragment1 = Fragment() + operator_cpu = SegmentationOverlayOperator(fragment1, use_gpu=False, alpha=0.7) + start_time = time.time() + overlay_cpu = operator_cpu.create_overlay( + seg_image, + scan_image, + window_center=40.0, + window_width=400.0, + voi_lut_function="LINEAR", + ) + cpu_time = time.time() - start_time + print(f"CPU Time: {cpu_time:.4f} seconds") + overlay_arr = overlay_cpu.asnumpy() if isinstance(overlay_cpu, Image) else np.asarray(overlay_cpu) + print(f"Output shape: {overlay_arr.shape}, dtype: {overlay_arr.dtype}") + assert overlay_arr.ndim == 4 and overlay_arr.shape[0] == 3, f"Expected (3, D, H, W), got {overlay_arr.shape}" + + # Test 2: CPU path with LINEAR_EXACT + print("\n[Test 2] Running create_overlay() with CPU, LINEAR_EXACT window...") + overlay_le = operator_cpu.create_overlay( + seg_image, + scan_image, + window_center=50.0, + window_width=200.0, + voi_lut_function="LINEAR_EXACT", + ) + arr_le = overlay_le.asnumpy() if isinstance(overlay_le, Image) else np.asarray(overlay_le) + assert arr_le.shape == overlay_arr.shape, "LINEAR_EXACT output shape mismatch" + print(f"Output shape: {arr_le.shape} — OK") + + # Test 3: CPU path with SIGMOID + print("\n[Test 3] Running create_overlay() with CPU, SIGMOID window...") + overlay_sig = operator_cpu.create_overlay( + seg_image, + scan_image, + window_center=50.0, + window_width=400.0, + voi_lut_function="SIGMOID", + ) + arr_sig = overlay_sig.asnumpy() if isinstance(overlay_sig, Image) else np.asarray(overlay_sig) + assert arr_sig.shape == overlay_arr.shape, "SIGMOID output shape mismatch" + print(f"Output shape: {arr_sig.shape} — OK") + + # Test 4: CPU path with per-slice window (MR-like) + print("\n[Test 4] Running create_overlay() with CPU, per-slice window (MR)...") + n_slices = scan_data.shape[0] + wc_arr = np.linspace(30.0, 50.0, n_slices) + ww_arr = np.full(n_slices, 300.0) + overlay_ps = operator_cpu.create_overlay( + seg_image, + scan_image, + window_center=wc_arr, + window_width=ww_arr, + voi_lut_function="LINEAR", + ) + arr_ps = overlay_ps.asnumpy() if isinstance(overlay_ps, Image) else np.asarray(overlay_ps) + assert arr_ps.shape == overlay_arr.shape, "Per-slice output shape mismatch" + print(f"Output shape: {arr_ps.shape} — OK") + + # Test 5: GPU path (if CuPy is available) + print("\n[Test 5] Running create_overlay() with GPU (if available)...") + fragment2 = Fragment() + operator_gpu = SegmentationOverlayOperator(fragment2, use_gpu=True, alpha=0.7) + if operator_gpu.use_gpu: + start_time = time.time() + overlay_gpu = operator_gpu.create_overlay( + seg_image, + scan_image, + window_center=40.0, + window_width=400.0, + voi_lut_function="LINEAR", + ) + gpu_time = time.time() - start_time + arr_gpu = overlay_gpu.asnumpy() if isinstance(overlay_gpu, Image) else np.asarray(overlay_gpu) + print(f"GPU Time: {gpu_time:.4f} seconds, Output shape: {arr_gpu.shape}") + assert arr_gpu.shape == overlay_arr.shape, "GPU output shape mismatch" + speedup = cpu_time / gpu_time if gpu_time > 0 else float("inf") + print(f"Speedup vs CPU: {speedup:.2f}x") + else: + print("CuPy not available — skipping GPU test") + + print("\n" + "=" * 60) + print("Test completed successfully!") + + +if __name__ == "__main__": + test() diff --git a/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_zscore_operator.py b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_zscore_operator.py new file mode 100644 index 00000000..539067c8 --- /dev/null +++ b/examples/apps/cchmc_ped_abd_ct_seg_app/segmentation_zscore_operator.py @@ -0,0 +1,728 @@ +# Copyright 2021-2026 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import tempfile +from io import BytesIO +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Set matplotlib to use non-interactive backend for containerized environments +import matplotlib +import numpy as np +import pandas as pd +from scipy.interpolate import interp1d +from scipy.stats import norm + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec + + +class SegmentationZScoreOperator(Operator): + """This operator computes z-scores and percentiles for segmentation metrics against normative data. + + Takes metrics from SegmentationMetricsOperator and compares them to age/sex-specific normative + values stored in CSV files. Outputs z-scores, percentiles, and generates PDF visualization. + + Named Input: + metrics_dict: Dictionary of metrics from SegmentationMetricsOperator + patient_age: Patient age in years (float) + patient_sex: Patient sex ("Male" or "Female") + assets_path: Path to assets folder containing normative data CSVs + + Named Output: + zscore_dict: Dictionary with z-scores and percentiles for each organ + pdf_bytes: Bytes of PDF file with quantile curves and patient values (optional) + zscore_text: Formatted text summary for DICOM SR (filtered organs) + + Notes: + - Normative data CSVs should be organized in subfolders under assets_path. + - Refer to examples/apps/cchmc_ped_abd_ct_seg_app/assets for expected structure. + """ + + def __init__( + self, + fragment: Fragment, + *args, + assets_path: Optional[str] = None, + organ_name_mapping: Optional[Dict[str, str]] = None, + sr_name_mapping: Optional[Dict[str, str]] = None, + sr_filter_keys: Optional[List[str]] = None, + generate_plots: bool = True, + additional_metrics_map: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ): + """Create an instance for a containing application object. + + Args: + fragment (Fragment): An instance of the Application class which is derived from Fragment. + assets_path (str, optional): Path to assets folder with normative data. + If None, must be provided as input to compute(). + organ_name_mapping (dict, optional): Maps organ names from metrics_dict to asset subfolder names. + Example: {"liver_volume": "liver", "liver_density": "liver_HU"} + If None, uses exact matching. + sr_filter_keys (list, optional): If provided, only these keys are included in the emitted + zscore_dict (after sr_name_mapping is applied). If None, all computed keys are emitted. + generate_plots (bool): If True, generates matplotlib visualization and outputs as PDF bytes. Default True. + """ + self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) + self.input_name_metrics = "metrics_dict" + self.input_name_dcm_series = "study_selected_series_list" + self.input_name_assets = assets_path + self.additional_metrics_map = additional_metrics_map + + self.output_name_zscore = "zscore_dict" + self.output_name_pdf_bytes = "pdf_bytes" + + self.assets_path = assets_path + self.organ_name_mapping = organ_name_mapping or {} + self.sr_name_mapping = sr_name_mapping or {} + self.sr_filter_keys = sr_filter_keys + self.generate_plots = generate_plots + + # Need to call the base class constructor last + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + + spec.input(self.input_name_metrics) + spec.input(self.input_name_dcm_series).condition(ConditionType.NONE) # Optional input + + spec.output(self.output_name_zscore).condition(ConditionType.NONE) + spec.output(self.output_name_pdf_bytes).condition(ConditionType.NONE) + + def compute(self, op_input, op_output, context): + """Performs computation for this operator and handles I/O.""" + + # Receive inputs + metrics_dict = op_input.receive(self.input_name_metrics) + + study_selected_series_list = None + try: + study_selected_series_list = op_input.receive(self.input_name_dcm_series) + except Exception: + pass + + if not study_selected_series_list: + raise ValueError("study_selected_series_list is required for patient demographics (PatientAge, PatientSex)") + + # Extract patient demographics from DICOM series + dicom_series = None + for study_selected_series in study_selected_series_list: + selected_series = study_selected_series.selected_series[0] + dicom_series = selected_series.series + break + + if dicom_series is None: + raise ValueError("Could not extract DICOM series from study_selected_series_list") + + # Get patient info from first SOP instance + orig_ds = dicom_series.get_sop_instances()[0].get_native_sop_instance() + patient_sex = orig_ds.get("PatientSex", "") + patient_age_str = orig_ds.get("PatientAge", "") + + # Convert DICOM age string (e.g., "012Y", "006M", "003W", "010D") to years + patient_age = None + if len(patient_age_str) == 4 and patient_age_str[:3].isdigit(): + age_value = float(patient_age_str[:3]) + age_unit = patient_age_str[3].upper() + patient_age = { + "Y": age_value, + "M": age_value / 12.0, + "W": age_value / 52.1429, + "D": age_value / 365.25, + }.get(age_unit) + if patient_age is None: + raise ValueError(f"Invalid or missing PatientAge: {patient_age_str}") + + self._logger.info(f"Extracted PatientSex: {patient_sex}, PatientAge: {patient_age}") + + if self.assets_path is None: + raise ValueError("assets_path must be provided in constructor if not passed as input") + + # Validate inputs + if metrics_dict is None or not isinstance(metrics_dict, dict): + raise ValueError("metrics_dict must be a dictionary from SegmentationMetricsOperator") + + # If patient_sex is m or f, convert to full string + if patient_sex.lower() == "m": + patient_sex = "Male" + elif patient_sex.lower() == "f": + patient_sex = "Female" + + if patient_sex is None or not isinstance(patient_sex, str): + raise ValueError("patient_sex must be a string ('Male' or 'Female')") + + patient_sex = patient_sex.strip().capitalize() + if patient_sex not in ["Male", "Female"]: + raise ValueError(f"patient_sex must be 'Male' or 'Female', got: {patient_sex}") + + # If additional_metrics_map is provided, augment metrics_dict + if self.additional_metrics_map: + for metric_key, mapping in self.additional_metrics_map.items(): + organ = mapping.get("organ") + metric = mapping.get("metric") + if organ in metrics_dict and metric in metrics_dict[organ]: + metrics_dict[metric_key] = {"biomarker_value": metrics_dict[organ][metric]} + + # Calculate z-scores and percentiles for all organs + zscore_dict, processed_organs, units_dict = self.calculate_zscores_batch( + metrics_dict, patient_age, patient_sex, self.assets_path + ) + self._logger.info(f"Z-score calculation complete, z_score_dict: {zscore_dict}") + + # Add units to zscore_dict + for organ_name, data in zscore_dict.items(): + unit = units_dict.get(organ_name, "") + data["unit"] = unit + + # Generate plot if requested and data is available + pdf_bytes = None + if self.generate_plots and processed_organs: + try: + # Create and save visualization to BytesIO buffer + pdf_bytes = self.create_visualization(processed_organs, patient_age, patient_sex) + + self._logger.info(f"PDF report generated in memory ({len(pdf_bytes)} bytes)") + except Exception as e: + self._logger.error(f"Error generating PDF visualization: {e}") + pdf_bytes = None + + self._logger.info("Emitting outputs") + + # Filter zscore_dict to only the requested SR keys, if specified, + # and strip z-score/percentile fields so only biomarker_value and unit are written + if self.sr_filter_keys is not None: + zscore_dict = { + k: {ik: iv for ik, iv in v.items() if ik in ("biomarker_value", "unit")} + for k, v in zscore_dict.items() + if k in self.sr_filter_keys + } + self._logger.info(f"Filtered zscore_dict to keys {self.sr_filter_keys}: {list(zscore_dict.keys())}") + + # Emit outputs + op_output.emit(zscore_dict, self.output_name_zscore) + op_output.emit(pdf_bytes, self.output_name_pdf_bytes) + + def _load_biomarker_data( + self, biomarker_name: str, assets_path: str + ) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]: + """Load the quantile regression data for a specific biomarker. + + Args: + biomarker_name: Name of the biomarker (should match subfolder in assets) + assets_path: Path to assets folder + + Returns: + Tuple of (male_df, female_df) or (None, None) if loading fails + """ + try: + biomarker_path = Path(assets_path) / biomarker_name + df_m = pd.read_csv(biomarker_path / "results_m_fine.csv", index_col=0) + df_f = pd.read_csv(biomarker_path / "results_f_fine.csv", index_col=0) + return df_m, df_f + except Exception as e: + self._logger.error(f"Error loading data for biomarker {biomarker_name!r}: {e}") + return None, None + + def _calculate_percentile( + self, age: float, sex: str, biomarker_value: float, df_m: pd.DataFrame, df_f: pd.DataFrame + ) -> Optional[float]: + """Calculate the percentile for a biomarker value given age and sex. + + Args: + age: Patient age in years + sex: Patient sex ("Male" or "Female") + biomarker_value: Measured biomarker value + df_m: Male quantile DataFrame + df_f: Female quantile DataFrame + + Returns: + Percentile as float between 0 and 1, or None if calculation fails + """ + # Select appropriate dataframe + df = df_m if sex == "Male" else df_f + + # Get quantile column names (exclude 'Age' and index columns) + quantile_cols = [col for col in df.columns if col not in ["Age", "Unnamed: 0"] and col != "index"] + quantile_cols_sorted = sorted(quantile_cols, key=float) + quantiles = [float(q) for q in quantile_cols_sorted] + + # Sort by age for interpolation + df = df.sort_values("Age").reset_index(drop=True) + + # Create interpolation functions for each quantile level + interp_functions = {} + for i, q in enumerate(quantiles): + col_name = quantile_cols_sorted[i] + interp_functions[q] = interp1d( + df["Age"], df[col_name], kind="linear", fill_value="extrapolate", bounds_error=False + ) + + # Get the biomarker values at each quantile for the patient's age + biomarker_quantiles = [] + for q in quantiles: + biomarker_val = interp_functions[q](age) + biomarker_quantiles.append(biomarker_val) + + # Pair quantiles with their corresponding biomarker values + quantile_biomarker = zip(quantiles, biomarker_quantiles) + + # Sort by biomarker values to ensure proper ordering + quantile_biomarker_sorted = sorted(quantile_biomarker, key=lambda x: x[1]) + sorted_quantiles = [q for q, b in quantile_biomarker_sorted] + sorted_biomarkers = [b for q, b in quantile_biomarker_sorted] + + # Handle edge cases + if np.isnan(biomarker_value): + return None + + percentile = None + + # Below lowest quantile + if biomarker_value <= sorted_biomarkers[0]: + q_low, q_high = sorted_quantiles[0], sorted_quantiles[1] + b_low, b_high = sorted_biomarkers[0], sorted_biomarkers[1] + if b_high == b_low: + percentile = q_low + else: + slope = (q_high - q_low) / (b_high - b_low) + percentile = q_low + slope * (biomarker_value - b_low) + + # Above highest quantile + elif biomarker_value >= sorted_biomarkers[-1]: + q_low, q_high = sorted_quantiles[-2], sorted_quantiles[-1] + b_low, b_high = sorted_biomarkers[-2], sorted_biomarkers[-1] + if b_high == b_low: + percentile = q_high + else: + slope = (q_high - q_low) / (b_high - b_low) + percentile = q_high + slope * (biomarker_value - b_high) + + # Between quantiles - interpolate + else: + for i in range(len(sorted_biomarkers) - 1): + b_low = sorted_biomarkers[i] + b_high = sorted_biomarkers[i + 1] + q_low = sorted_quantiles[i] + q_high = sorted_quantiles[i + 1] + + if b_low <= biomarker_value <= b_high: + percentile = q_low + (q_high - q_low) * (biomarker_value - b_low) / (b_high - b_low) + break + + # Clamp to valid range to avoid numerical issues with norm.ppf + if percentile is not None: + epsilon = 0.001 + percentile = np.clip(percentile, epsilon, 1 - epsilon) + + return percentile + + def calculate_zscores_batch( + self, metrics_dict: Dict[str, Dict], patient_age: float, patient_sex: str, assets_path: str + ) -> Tuple[Dict[str, Dict], Dict[str, Dict], Dict[str, str]]: + """Calculate z-scores and percentiles for all organs in metrics_dict. + + Args: + metrics_dict: Dictionary from SegmentationMetricsOperator + patient_age: Patient age in years + patient_sex: Patient sex ("Male" or "Female") + assets_path: Path to assets folder + + Returns: + Tuple of (zscore_dict, processed_organs_dict, units_dict) where: + - zscore_dict contains results for output + - processed_organs_dict contains data needed for plotting + """ + zscore_dict = {} + processed_organs = {} + # Establish units dict + units_dict = {} + + for organ_name, metrics in metrics_dict.items(): + # Map organ name to asset folder name (for CSV lookup) and SR name (for output key) + asset_name = self.organ_name_mapping.get(organ_name, organ_name) + sr_name = self.sr_name_mapping.get(organ_name, organ_name) + + # Skip if metrics contain an error + if "error" in metrics: + self._logger.warning(f"Skipping organ {organ_name!r} due to error in metrics: {metrics['error']}") + zscore_dict[sr_name] = {"error": metrics["error"]} + continue + + # Extract biomarker value (volume_ml/volume for 3D, area_cm2/area for 2D, or explicit biomarker_value) + # Use explicit None checks so that a legitimate 0.0 value is not discarded by the falsy `or` chain. + biomarker_value = None + for _key in ("volume_ml", "volume", "area_cm2", "area", "biomarker_value"): + if _key in metrics and metrics[_key] is not None: + biomarker_value = metrics[_key] + break + + if "volume_ml" in metrics or "volume" in metrics: + units_dict[sr_name] = "mL" + elif "area_cm2" in metrics or "area" in metrics: + units_dict[sr_name] = "cm²" + elif "hu" in organ_name.lower() or "hu" in asset_name.lower(): + units_dict[sr_name] = "HU" + else: + units_dict[sr_name] = "" + + # Detect absent segmentation via presence fields rather than the biomarker value itself, + # so that a legitimate 0.0 derived metric (e.g. from additional_metrics_map) is not + # silently discarded as "no segmentation". + if biomarker_value is None: + self._logger.warning(f"No valid biomarker value for organ {organ_name!r}, skipping") + zscore_dict[sr_name] = { + "percentile": None, + "z_score": None, + "biomarker_value": None, + "message": "No segmentation detected", + } + continue + + # Log when an organ has no detected segmentation (volume == 0.0) but still + # proceed so that a z-score/percentile and PDF plot are generated. + no_segmentation = ( + metrics.get("pixel.count", metrics.get("pixel_count")) == 0 + or metrics.get("num.slices", metrics.get("num_slices")) == 0 + ) + if no_segmentation: + self._logger.info( + f"Organ {organ_name!r} has no detected segmentation (volume=0.0); " + "proceeding with z-score calculation." + ) + + # Load normative data + df_m, df_f = self._load_biomarker_data(asset_name, assets_path) + + if df_m is None or df_f is None: + self._logger.error(f"Could not load normative data for {asset_name!r}, skipping {organ_name!r}") + zscore_dict[sr_name] = {"error": f"Normative data not available for {asset_name!r}"} + continue + + try: + # Calculate percentile + percentile = self._calculate_percentile(patient_age, patient_sex, biomarker_value, df_m, df_f) + + if percentile is None: + zscore_dict[sr_name] = {"error": "Failed to calculate percentile"} + continue + + # Calculate z-score from percentile + z_score = norm.ppf(percentile) + + # Store results + zscore_dict[sr_name] = { + "biomarker_value": float(biomarker_value), + "percentile": float(percentile), + "percentile_pct": float(percentile * 100), + "z_score": float(z_score), + "patient_age": float(patient_age), + "patient_sex": patient_sex, + "interpretation": self._generate_interpretation(percentile, z_score), + } + + # Store data for plotting + processed_organs[organ_name] = { + "asset_name": asset_name, + "biomarker_value": biomarker_value, + "percentile": percentile, + "z_score": z_score, + "display_name": sr_name, + "unit": units_dict.get(sr_name, ""), + "df_m": df_m, + "df_f": df_f, + } + + except Exception as e: + self._logger.error(f"Error calculating z-score for {organ_name!r}: {e}") + zscore_dict[sr_name] = {"error": str(e)} + + return zscore_dict, processed_organs, units_dict + + def _generate_interpretation(self, percentile: float, z_score: float) -> str: + """Generate human-readable interpretation of results. + + Args: + percentile: Percentile value (0-1) + z_score: Z-score value + + Returns: + Interpretation string + """ + direction = "above" if z_score > 0 else "below" + return ( + f"This value is at the {percentile*100:.1f}th percentile, " + f"{abs(z_score):.2f} standard deviations {direction} the population mean" + ) + + @staticmethod + def _format_value(value) -> str: + """Format a numeric value using dynamic rounding (mirrors DICOMTextSRWriterOperator logic).""" + try: + val = float(value) + abs_val = abs(val) + if abs_val > 1000: + return f"{val:.0f}" + elif abs_val > 10: + return f"{val:.1f}" + elif abs_val < 0.1: + return f"{val:.3f}" + elif abs_val < 1: + return f"{val:.2f}" + else: + return f"{val:.2f}" + except (TypeError, ValueError): + return str(value) + + def create_visualization(self, processed_organs: Dict[str, Dict], patient_age: float, patient_sex: str) -> bytes: + """Create matplotlib visualization with quantile curves in an Nx2 grid. + + Args: + processed_organs: Dictionary with organ data + patient_age: Patient age in years + patient_sex: Patient sex ("Male" or "Female") + + Returns: + bytes: PDF file content as bytes + """ + if not processed_organs: + self._logger.warning("No organs to visualize") + return b"" + + num_organs = len(processed_organs) + + # Calculate rows needed for 2 columns (No math library needed) + ncols = 2 + nrows = (num_organs + 1) // ncols + + # Create figure with subplots + fig, axes = plt.subplots( + nrows=nrows, ncols=ncols, figsize=(15, 5 * nrows), squeeze=False # Ensures axes is always a 2D array + ) + + # Flatten axes for easy 1D iteration + axes_flat = axes.flatten() + + # Quantiles configuration + quantiles_to_plot = [0.05, 0.25, 0.50, 0.75, 0.95] + colors = ["red", "orange", "blue", "green", "purple"] + labels = ["5th", "25th", "50th", "75th", "95th"] + percentile_label = "Percentile" + + for i, (organ_name, organ_data) in enumerate(processed_organs.items()): + ax = axes_flat[i] + + # Select relevant dataframe based on sex + if patient_sex == "Male": + df = organ_data["df_m"] + else: + df = organ_data["df_f"] + + biomarker_value = organ_data["biomarker_value"] + percentile = organ_data["percentile"] + z_score = organ_data["z_score"] + + # Extract only quantile columns (exclude metadata) + quantile_cols = [col for col in df.columns if col not in ["Age", "Unnamed: 0", "index"]] + quantile_mapping = {float(col): col for col in quantile_cols} + + # Plot quantile curves + for j, q in enumerate(quantiles_to_plot): + if q in quantile_mapping: + col_name = quantile_mapping[q] + ax.plot( + df["Age"], + df[col_name], + color=colors[j], + label=f"{labels[j]} {percentile_label}" if i == 0 else "", + linewidth=2, + ) + + # Plot Patient Point + ax.scatter( + [patient_age], + [biomarker_value], + color="red", + s=150, + marker="X", + zorder=5, + edgecolors="black", + linewidths=1.5, + label="Patient" if i == 0 else "", + ) + + # Annotation Box: Value, Percentile, Z-score + ax.annotate( + f"Val: {self._format_value(biomarker_value)}\nP: {percentile*100:.1f}%\nZ: {z_score:.2f}", + (patient_age, biomarker_value), + xytext=(10, 10), + textcoords="offset points", + bbox={"boxstyle": "round,pad=0.5", "facecolor": "yellow", "alpha": 0.7}, + fontsize=9, + fontweight="bold", + ) + + # Styling + ax.set_xlabel("Age (years)", fontsize=10) + unit = organ_data.get("unit", "") + if unit == "mL": + ylabel = "Volume (mL)" + elif unit == "cm²": + ylabel = "Area (cm²)" + elif unit: + ylabel = unit + else: + ylabel = "Value" + ax.set_ylabel(ylabel, fontsize=10) + raw_title = str(organ_data.get("display_name", organ_name)) + pdf_title = raw_title.replace(".", " ").replace("_", " ").title() + ax.set_title(pdf_title, fontsize=12, fontweight="bold") + ax.grid(True, alpha=0.3) + + # Add legend outside the plot area (top center), only on the first plot + if i == 0: + ax.legend( + loc="lower center", + bbox_to_anchor=(0.5, 1.05), # Moves legend above the plot + ncol=3, # Arranges items in 3 columns for a flatter look + fontsize=9, + frameon=False, + ) + + # Hide any unused subplots + for j in range(num_organs, nrows * ncols): + axes_flat[j].axis("off") + + # Overall Title + # Y ages reported with no decimals; M/W/D ages reported with 4 decimal places + age_display = str(int(patient_age)) if patient_age == int(patient_age) else f"{patient_age:.4f}" + fig.suptitle( + f"Organ Quantile Curves - {patient_sex} Patient, Age {age_display} years", + fontsize=16, + fontweight="bold", + y=0.995, + ) + + plt.tight_layout() + + # Save to BytesIO buffer + buffer = BytesIO() + fig.savefig(buffer, format="pdf", bbox_inches="tight", dpi=150) + plt.close(fig) + + pdf_bytes = buffer.getvalue() + buffer.close() + + return pdf_bytes + + +def test(): + """Test function for the SegmentationZScoreOperator.""" + + from monai.deploy.core import Fragment + + print("Testing SegmentationZScoreOperator...") + print("=" * 60) + + # Create mock metrics_dict (as if from SegmentationMetricsOperator) + metrics_dict = { + "liver": { + "volume_ml": 1200.0, # 1200 mL + "num_slices": 80, + "slice_range": (20, 100), + "pixel_count": 1200000, + "mean_intensity_hu": 55.3, + "std_intensity_hu": 12.1, + }, + "spleen": { + "volume_ml": 180.0, # 180 mL + "num_slices": 40, + "slice_range": (30, 70), + "pixel_count": 180000, + "mean_intensity_hu": 48.7, + "std_intensity_hu": 10.5, + }, + } + + # Patient information + patient_age = 12.5 + patient_sex = "Female" + + # Assets path (adjust to your local path) + assets_path = "/mnt/projects/monai-deploy-app-sdk/examples/apps/cchmc_ped_abd_ct_seg_app/assets" + + # Check if assets path exists + if not os.path.exists(assets_path): + print(f"Warning: Assets path does not exist: {assets_path}") + print("Please update the assets_path variable in the test function.") + return + + # Create operator instance + fragment = Fragment() + operator = SegmentationZScoreOperator(fragment, assets_path=assets_path, generate_plots=True) + + print("\nPatient Info:") + print(f" Age: {patient_age} years") + print(f" Sex: {patient_sex}") + print("\nInput Metrics:") + for organ, metrics in metrics_dict.items(): + print(f" {organ}: {metrics['volume_ml']} mL") + + # Calculate z-scores + print("\nCalculating z-scores and percentiles...") + zscore_dict, processed_organs, _units_dict = operator.calculate_zscores_batch( + metrics_dict, patient_age, patient_sex, assets_path + ) + + # Display results + print("\n" + "=" * 60) + print("Z-Score Results:") + print("=" * 60) + for organ_name, results in zscore_dict.items(): + print(f"\n{organ_name.upper()}:") + for key, value in results.items(): + if key != "interpretation": + print(f" {key}: {value}") + if "interpretation" in results: + print(f"\n Interpretation: {results['interpretation']}") + + # Generate visualization + if processed_organs: + print("\n" + "=" * 60) + print("Generating PDF visualization...") + try: + pdf_bytes = operator.create_visualization(processed_organs, patient_age, patient_sex) + print(f"PDF generated successfully: {len(pdf_bytes)} bytes") + + # Optionally save for testing + test_output_path = Path(tempfile.mkdtemp()) + test_pdf_file = test_output_path / "zscore_report_test.pdf" + with open(test_pdf_file, "wb") as f: + f.write(pdf_bytes) + print(f"Test PDF saved to: {test_pdf_file}") + except Exception as e: + print(f"Error creating visualization: {e}") + import traceback + + traceback.print_exc() + else: + print("\nVisualization skipped (no organs processed)") + + print("\n" + "=" * 60) + print("Test completed successfully!") + + +if __name__ == "__main__": + test() diff --git a/requirements-examples.txt b/requirements-examples.txt index bc9754eb..090b163b 100644 --- a/requirements-examples.txt +++ b/requirements-examples.txt @@ -1,4 +1,5 @@ scikit-image>=0.17.2 +pandas>=1.3.0 pydicom>=3.0.0 pypdf>=4.0.0 types-pytz>=2024.1.0.20240203 @@ -9,6 +10,7 @@ nibabel>=3.2.1 numpy-stl>=2.12.0 trimesh>=3.8.11 torch>=2.6.0 +matplotlib>=3.5.1 monai>=1.3.0 nvidia-nvimgcodec-cu12>=0.6.1 nvidia-nvjpeg2k-cu12>=0.9.1