Skip to content

Commit 329fbcc

Browse files
dbougetdbougetmathildefaanes
authored
2D compatibility and true positives handling (#10)
--------- Co-authored-by: dbouget <david.bouget@sintef.no> Co-authored-by: mathildefaanes <122807133+mathildefaanes@users.noreply.github.com>
1 parent 9fbdbca commit 329fbcc

13 files changed

+221
-96
lines changed

.github/workflows/build_macos.yml

+4-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
strategy:
1818
matrix:
1919
include:
20-
- os: macos-11
20+
- os: macos-13
2121
TARGET: macos
2222
CMD_BUILD: python setup.py bdist_wheel --plat-name macosx_10_15_x86_64
2323

@@ -42,7 +42,7 @@ jobs:
4242
run: ${{matrix.CMD_BUILD}}
4343

4444
- name: Upload Python wheel
45-
uses: actions/upload-artifact@v3
45+
uses: actions/upload-artifact@v4
4646
with:
4747
name: Python wheel
4848
path: ${{github.workspace}}/dist/raidionicsval-*.whl
@@ -52,7 +52,7 @@ jobs:
5252
needs: build
5353
strategy:
5454
matrix:
55-
os: [ macos-11, macos-12 ]
55+
os: [ macos-13 ]
5656
python-version: ["3.8", "3.9", "3.10", "3.11"]
5757
runs-on: ${{ matrix.os }}
5858

@@ -63,7 +63,7 @@ jobs:
6363
python-version: ${{ matrix.python-version }}
6464

6565
- name: Download artifact
66-
uses: actions/download-artifact@v3
66+
uses: actions/download-artifact@v4
6767
with:
6868
name: "Python wheel"
6969

.github/workflows/build_macos_arm.yml

+4-4
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: python3 setup.py bdist_wheel --plat-name macosx_11_0_arm64
3939

4040
- name: Upload Python wheel
41-
uses: actions/upload-artifact@v3
41+
uses: actions/upload-artifact@v4
4242
with:
4343
name: Python wheel
4444
path: ${{github.workspace}}/dist/raidionicsval-*.whl
@@ -58,7 +58,7 @@ jobs:
5858
default: ${{ matrix.python-version }}
5959

6060
- name: Download artifact
61-
uses: actions/download-artifact@v3
61+
uses: actions/download-artifact@v4
6262
with:
6363
name: "Python wheel"
6464

@@ -73,7 +73,7 @@ jobs:
7373
uses: actions/checkout@v1
7474

7575
- name: k-fold cross-validation unit test
76-
run: cd ${{github.workspace}}/tests && python validation_pipeline_test.py
76+
run: cd ${{github.workspace}}/tests && python3 validation_pipeline_test.py
7777

7878
- name: Segmentation study unit test
79-
run: cd ${{github.workspace}}/tests && python studies_pipeline_test.py
79+
run: cd ${{github.workspace}}/tests && python3 studies_pipeline_test.py

.github/workflows/build_ubuntu.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: ${{matrix.CMD_BUILD}}
3939

4040
- name: Upload Python wheel
41-
uses: actions/upload-artifact@v3
41+
uses: actions/upload-artifact@v4
4242
with:
4343
name: Python wheel
4444
path: ${{github.workspace}}/dist/raidionicsval-*.whl
@@ -48,7 +48,7 @@ jobs:
4848
needs: build
4949
strategy:
5050
matrix:
51-
os: [ ubuntu-20.04, ubuntu-22.04 ]
51+
os: [ ubuntu-20.04, ubuntu-22.04, ubuntu-24.04 ]
5252
python-version: ["3.8", "3.9", "3.10", "3.11"]
5353
runs-on: ${{ matrix.os }}
5454

@@ -59,7 +59,7 @@ jobs:
5959
python-version: ${{ matrix.python-version }}
6060

6161
- name: Download artifact
62-
uses: actions/download-artifact@v3
62+
uses: actions/download-artifact@v4
6363
with:
6464
name: "Python wheel"
6565

.github/workflows/build_windows.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: ${{matrix.CMD_BUILD}}
3939

4040
- name: Upload Python wheel
41-
uses: actions/upload-artifact@v3
41+
uses: actions/upload-artifact@v4
4242
with:
4343
name: Python wheel
4444
path: ${{github.workspace}}/dist/raidionicsval-*.whl
@@ -59,7 +59,7 @@ jobs:
5959
python-version: ${{ matrix.python-version }}
6060

6161
- name: Download artifact
62-
uses: actions/download-artifact@v3
62+
uses: actions/download-artifact@v4
6363
with:
6464
name: "Python wheel"
6565

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/gist/dbouget/7560fe410db03e384a45ddc77bbe9a57/compute_validation_example.ipynb)
77

88
The code corresponds to the Raidionics backend for running the k-fold cross-validation and metrics computation.
9-
The module can either be used as a Python library, as CLI, or as Docker container.
9+
The module can either be used as a Python library, as CLI, or as Docker container. It supports both 2D and 3D inputs,
10+
the only hard requirement is the expected folder structure to use as input.
11+
:warning: For using custom structures, modifying the code [here](https://github.com/dbouget/validation_metrics_computation/blob/master/raidionicsval/Validation/kfold_model_validation.py#L155) is a good place to start.
1012

1113
## [Installation](https://github.com/dbouget/validation_metrics_computation#installation)
1214

raidionicsval/Computation/dice_computation.py

+6-8
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,13 @@ def separate_dice_computation(args):
2727
t = np.round(args[0], 2)
2828
fold_number = args[1]
2929
gt = args[2]
30-
detection_ni = args[3]
30+
detection_raw = args[3]
3131
patient_id = args[4]
3232
volumes_extra = args[5]
3333
results = []
3434

35-
detection = deepcopy(detection_ni.get_fdata())
36-
detection[detection < t] = 0
37-
detection[detection >= t] = 1
38-
detection = detection.astype('uint8')
35+
detection = np.zeros(detection_raw.shape, dtype='uint8')
36+
detection[detection_raw >= t] = 1
3937

4038
# # Cleaning the too small objects that might be noise in the detection
4139
# if np.count_nonzero(detection) > 0:
@@ -52,20 +50,20 @@ def separate_dice_computation(args):
5250
if "pixelwise" in SharedResources.getInstance().validation_metric_spaces:
5351
pixelwise_results = __pixelwise_computation(gt, detection)
5452

55-
det_volume = np.round(np.count_nonzero(detection) * np.prod(detection_ni.header.get_zooms()) * 1e-3, 4)
53+
det_volume = np.round(np.count_nonzero(detection) * np.prod(volumes_extra[2]) * 1e-3, 4)
5654

5755
obj_val = InstanceSegmentationValidation(gt_image=gt, detection_image=detection)
5856
if "objectwise" in SharedResources.getInstance().validation_metric_spaces:
5957
try:
6058
# obj_val.set_trace_parameters(self.output_folder, fold_number, patient, t)
61-
obj_val.spacing = detection_ni.header.get_zooms()
59+
obj_val.spacing = volumes_extra[2]
6260
obj_val.run()
6361
except Exception as e:
6462
print('Issue computing instance segmentation parameters for patient {}'.format(patient_id))
6563
print(traceback.format_exc())
6664
instance_results = obj_val.instance_detection_results
6765

68-
results.append([fold_number, patient_id, t] + pixelwise_results + volumes_extra +
66+
results.append([fold_number, patient_id, t] + pixelwise_results + volumes_extra[:-1] +
6967
[det_volume] + instance_results + [len(obj_val.gt_candidates), len(obj_val.detection_candidates)])
7068

7169
return results

raidionicsval/Studies/AbstractStudy.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ def compute_and_plot_metric_over_metric_categories(self, class_name: str, data=N
275275
results_filename = os.path.join(self.input_folder, 'Validation', class_name + '_dice_scores.csv')
276276
results = pd.read_csv(results_filename)
277277
results.replace('inf', np.nan, inplace=True)
278+
if category == 'True Positive':
279+
results = results.loc[results["True Positive"] == True]
278280
else:
279281
results = deepcopy(data)
280282
total_thresholds = [np.round(x, 2) for x in list(np.unique(results['Threshold'].values))]
@@ -355,6 +357,8 @@ def compute_and_plot_categorical_metric_over_metric_categories(self, class_name:
355357
results_filename = os.path.join(self.input_folder, 'Validation', class_name + '_dice_scores.csv')
356358
results = pd.read_csv(results_filename)
357359
results.replace('inf', np.nan, inplace=True)
360+
if category == 'True Positive':
361+
results = results.loc[results["True Positive"] == True]
358362
else:
359363
results = deepcopy(data)
360364
total_thresholds = [np.round(x, 2) for x in list(np.unique(results['Threshold'].values))]
@@ -446,6 +450,12 @@ def compute_fold_average_inner(self, folder, class_name, data=None, best_thresho
446450
unique_folds = np.unique(results['Fold'])
447451
nb_folds = len(unique_folds)
448452
metrics_per_fold = []
453+
tp_volume_threshold = 0.
454+
if len(SharedResources.getInstance().validation_true_positive_volume_thresholds) == 1:
455+
tp_volume_threshold = SharedResources.getInstance().validation_true_positive_volume_thresholds[0]
456+
else:
457+
index_cl = SharedResources.getInstance().validation_class_names.find(class_name)
458+
tp_volume_threshold = SharedResources.getInstance().validation_true_positive_volume_thresholds[index_cl]
449459

450460
metric_names = list(results.columns[3:list(results.columns).index("#Det") + 1])
451461
if metrics is not None:
@@ -460,7 +470,8 @@ def compute_fold_average_inner(self, folder, class_name, data=None, best_thresho
460470
# Regarding the overlap threshold, should the patient discarded for recall be
461471
# used for other metrics computation?
462472
for f in unique_folds:
463-
patientwise_metrics = compute_patientwise_fold_metrics(results, f, best_threshold, best_overlap)
473+
patientwise_metrics = compute_patientwise_fold_metrics(results, f, best_threshold, best_overlap,
474+
tp_volume_threshold)
464475
fold_average_metrics, fold_std_metrics = compute_singe_fold_average_metrics(results, f, best_threshold,
465476
best_overlap, metrics)
466477
fold_metrics = []

raidionicsval/Studies/SegmentationStudy.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@ def run(self):
5050
# Plotting the results based on the selection of categorical parameters
5151
for s in SharedResources.getInstance().studies_selections_categorical:
5252
parsing = s.split(',')
53-
metric1 = parsing[0]
54-
metric2 = parsing[1]
55-
if parsing[2] != '':
53+
metric1 = parsing[0].strip()
54+
metric2 = parsing[1].strip()
55+
if parsing[2].strip() != '':
5656
metric2_cutoff = [x for x in parsing[2].split('-')]
5757
else:
5858
metric2_cutoff = None
59-
category = parsing[3]
59+
category = parsing[3].strip()
6060
self.compute_and_plot_categorical_metric_over_metric_categories(class_name=c, metric1=metric1,
6161
metric2=metric2,
6262
metric2_cutoffs=metric2_cutoff,

raidionicsval/Utils/PatientMetricsStructure.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ def extra_metrics(self) -> str:
7070
def class_names(self) -> List[str]:
7171
return self._class_names
7272

73+
@property
74+
def ground_truth_filepaths(self) -> str:
75+
return self._ground_truth_filepaths
76+
77+
@ground_truth_filepaths.setter
78+
def ground_truth_filepaths(self, ground_truth_filepaths) -> None:
79+
self._ground_truth_filepaths = ground_truth_filepaths
80+
81+
@property
82+
def prediction_filepaths(self) -> str:
83+
return self._prediction_filepaths
84+
85+
@prediction_filepaths.setter
86+
def prediction_filepaths(self, prediction_filepaths) -> None:
87+
self._prediction_filepaths = prediction_filepaths
88+
7389
def init_from_file(self, study_folder: str):
7490
all_scores_filename = os.path.join(study_folder, 'all_dice_scores.csv')
7591

@@ -136,7 +152,7 @@ def set_patient_filenames(self, filenames: dict) -> None:
136152
self._prediction_filepaths.append(filenames[c][1])
137153

138154
def get_class_filenames(self, class_index: int) -> List[str]:
139-
return [self._ground_truth_filepaths[class_index], self._prediction_filepaths[class_index]]
155+
return [self._ground_truth_filepaths[class_index], self.prediction_filepaths[class_index]]
140156

141157
def set_class_regular_metrics(self, class_name: str, results: list):
142158
self._class_metrics[class_name].set_results(results)

raidionicsval/Utils/io_converters.py

+34
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import logging
12
import os
23
import pickle
34
import pandas as pd
45
import numpy as np
6+
import nibabel as nib
7+
from typing import Tuple, List
8+
from PIL import Image
59

610

711
def get_fold_from_file(filename, fold_number):
@@ -52,3 +56,33 @@ def reload_optimal_validation_parameters(study_filename):
5256
optimums = study_df.iloc[-1]
5357

5458
return optimums['Detection threshold'], optimums['Dice threshold']
59+
60+
def open_image_file(input_filename: str) -> Tuple[np.ndarray, str, List]:
61+
ext = '.' + '.'.join(input_filename.split('.')[1:])
62+
input_array = None
63+
input_specifics = []
64+
65+
if ext == ".nii" or ext == ".nii.gz":
66+
input_ni = nib.load(input_filename)
67+
if len(input_ni.shape) == 4:
68+
input_ni = nib.four_to_three(input_ni)[0]
69+
input_array = input_ni.get_fdata()[:]
70+
input_specifics = [input_ni.affine, input_ni.header.get_zooms()]
71+
elif ext in [".tif", ".tiff", ".png"]:
72+
input_array = Image.open(input_filename)
73+
input_specifics = [np.eye(4, dtype=int), [1., 1.]]
74+
else:
75+
logging.error("Working with an unknown file type: {}. Skipping...".format(ext))
76+
77+
return input_array, ext, input_specifics
78+
79+
80+
def save_image_file(output_array, output_filename: str, specifics: List = None) -> None:
81+
ext = '.'.join(output_filename.split('.')[1:])
82+
83+
if ext == ".nii" or ext == ".nii.gz":
84+
nib.save(nib.Nifti1Image(output_array, affine=specifics[0]), output_filename)
85+
elif ext in [".tif", ".tiff", ".png"]:
86+
Image.fromarray(output_array).save(output_filename)
87+
else:
88+
logging.error("Working with an unknown file type: {}. Skipping...".format(ext))

0 commit comments

Comments
 (0)