Skip to content

Commit fe94e61

Browse files
Merge pull request #15 from brandonjnelsonFDA/main
Fix Octave/MATLAB Demos and Add Integration Tests
2 parents 27d86ed + 241e9a9 commit fe94e61

File tree

11 files changed

+516
-63
lines changed

11 files changed

+516
-63
lines changed

.github/workflows/matlab_tests.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Matlab Tests
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
9+
steps:
10+
- uses: actions/checkout@v3
11+
12+
- name: Install Octave and dependencies
13+
run: |
14+
sudo apt-get update
15+
sudo apt-get install -y octave octave-image octave-io octave-dev
16+
wget https://github.com/apjanke/octave-tablicious/releases/download/v0.4.5/tablicious-0.4.5.tar.gz
17+
octave --eval "pkg install tablicious-0.4.5.tar.gz"
18+
19+
- name: Run Tests
20+
run: |
21+
octave --eval "source('tests/test_measure_LCD.m'); test_measure_LCD();"

.github/workflows/python_tests.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Python Tests
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
strategy:
9+
matrix:
10+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
11+
12+
steps:
13+
- uses: actions/checkout@v3
14+
15+
- name: Set up Python ${{ matrix.python-version }}
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install pytest numpy pandas scipy scikit-image scikit-learn SimpleITK matplotlib
24+
25+
- name: Run Tests
26+
env:
27+
PYTHONPATH: ${{ github.workspace }}/src
28+
run: |
29+
pytest tests/test_lcd_python.py

demo_01_singlerecon_LCD.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@
6868

6969
if is_octave
7070
res_table.recon = recon_name;
71+
res_table.dose_level = dose;
7172
else
7273
res_table.recon(:) = recon_name;
74+
res_table.dose_level(:) = dose;
7375
end
7476

7577
%% save results

demo_02_tworecon_LCD.m

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,20 @@
7171
recon_1_res = measure_LCD(recon_1_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
7272

7373
if is_octave
74-
recon_1_res.recon = "fbp"
74+
recon_1_res.recon = "fbp";
75+
recon_1_res.dose_level = dose;
7576
else
7677
recon_1_res.recon(:) = "fbp";
78+
recon_1_res.dose_level(:) = dose;
7779
end
7880

7981
recon_2_res = measure_LCD(recon_2_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
8082
if is_octave
8183
recon_2_res.recon = "DL denoised";
84+
recon_2_res.dose_level = dose;
8285
else
8386
recon_2_res.recon(:) = "DL denoised";
87+
recon_2_res.dose_level(:) = dose;
8488
end
8589

8690
if is_octave

demo_03_tworecon_dosecurve_LCD.m

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,41 @@
4949
nreader = 10;
5050
pct_split = 0.6
5151
seed_split = randi(1000, nreader,1);
52-
recon_1_res = measure_LCD(recon_1_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
53-
if is_octave
54-
recon_1_res.recon = "fbp"
55-
else
56-
recon_1_res.recon(:) = "fbp";
52+
doses = dir(fullfile(recon_1_dir, 'dose_*'));
53+
recon_1_res = [];
54+
for i = 1:length(doses)
55+
dose_dir = fullfile(doses(i).folder, doses(i).name);
56+
res = measure_LCD(dose_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
57+
if is_octave
58+
res.recon = "fbp";
59+
if isempty(recon_1_res)
60+
recon_1_res = res;
61+
else
62+
recon_1_res = vertcat(recon_1_res, res);
63+
end
64+
else
65+
res.recon(:) = "fbp";
66+
recon_1_res = [recon_1_res; res];
67+
end
5768
end
5869

59-
recon_2_res = measure_LCD(recon_2_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
60-
if is_octave
61-
recon_2_res.recon = "DL denoised";
62-
else
63-
recon_2_res.recon(:) = "DL denoised";
70+
71+
doses = dir(fullfile(recon_2_dir, 'dose_*'));
72+
recon_2_res = [];
73+
for i = 1:length(doses)
74+
dose_dir = fullfile(doses(i).folder, doses(i).name);
75+
res = measure_LCD(dose_dir, observers, ground_truth, offset, nreader, pct_split, seed_split);
76+
if is_octave
77+
res.recon = "DL denoised";
78+
if isempty(recon_2_res)
79+
recon_2_res = res;
80+
else
81+
recon_2_res = vertcat(recon_2_res, res);
82+
end
83+
else
84+
res.recon(:) = "DL denoised";
85+
recon_2_res = [recon_2_res; res];
86+
end
6487
end
6588
%% combine results
6689
if is_octave

install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ conda activate octave
33
conda install -c conda-forge octave -y
44
conda install -c conda-forge cxx-compiler -y
55

6-
octave --eval 'pkg install -forge image; pkg install https://github.com/apjanke/octave-tablicious/releases/download/v0.3.7/tablicious-0.3.7.tar.gz; pkg load image tablicious'
6+
octave --eval 'pkg install -forge image; pkg install https://github.com/apjanke/octave-tablicious/releases/download/v0.4.5/tablicious-0.4.5.tar.gz; pkg load image tablicious'

src/lcdct/measure_LCD.m

Lines changed: 108 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
function results_dict = measure_LCD(signal_present_array, signal_absent_array, ground_truth, observers, n_reader, pct_split, seed_split)
1+
function results = measure_LCD(varargin)
22
% given a dataset calculate low contrast detectability as auc curves and return as a table ready for saving or plotting
33
%
4+
% Usage:
5+
% 1. measure_LCD(signal_present_array, signal_absent_array, ground_truth, observers, n_reader, pct_split, seed_split)
6+
% 2. measure_LCD(base_directory, observers, ground_truth, offset, n_reader, pct_split, seed_split)
7+
%
48
% :param signal_present_array: image stack of signal present images
59
% :param signal_absent_array: corresponding image stack of signal absent images
10+
% :param base_directory: path to directory containing 'signal_present' and 'signal_absent' subfolders
611
% :param observers: list of observer objects or strings of name of observers. Options: LG_CHO_2D, DOG_CHO_2D, GABOR_CHO_2D
7-
% :param ground_truth: image or filename of image with no noise of MITA LCD phantom, see `approximate_groundtruth` for details on how to turn repeat scans into a ground truth image
12+
% :param ground_truth: image or filename of image with no noise of MITA LCD phantom
13+
% :param offset: offset value to subtract from loaded images (only used with directory input)
814
% :param n_reader: number of readers (default is 10)
9-
% :param pct_split: percent of images to be used for training, remainder (1 - split_pct) to be used for testing (default is 0.5)
10-
% :param seed_split: 1d vector containing 'nreader' of random seed values. (defaults to randomly selected seed)
15+
% :param pct_split: percent of images to be used for training (default is 0.5)
16+
% :param seed_split: 1d vector containing 'nreader' of random seed values
1117
%
12-
% :return res_table: table ready for saving or plotting
18+
% :return results: table (or struct in Octave) ready for saving or plotting
1319

1420
if is_octave
1521
if length(pkg('list', 'image')) < 1
@@ -18,50 +24,96 @@
1824
pkg load image
1925
end
2026

21-
observer_idx = 1;
22-
for i=1:length(observers)
23-
switch upper(observers{i})
24-
case 'LG_CHO_2D'
25-
observers{observer_idx} = LG_CHO_2D();
26-
observer_idx = observer_idx + 1;
27-
case 'NPWE_2D'
28-
observers{observer_idx} = NPWE_2D();
29-
observer_idx = observer_idx + 1;
30-
case 'DOG_CHO_2D'
31-
observers{observer_idx} = DOG_CHO_2D();
32-
observer_idx = observer_idx + 1;
33-
case 'GABOR_CHO_2D'
34-
observers{observer_idx} = GABOR_CHO_2D();
35-
observer_idx = observer_idx + 1;
36-
otherwise
37-
error([observers{i} ' not in LG_CHO_2D, DOG_CHO_2D, GABOR_CHO_2D, NPWE_2D'])
27+
% Parse inputs
28+
arg1 = varargin{1};
29+
dose_level_extracted = NaN;
30+
31+
if ischar(arg1) || (isstring(arg1) && isscalar(arg1))
32+
% Signature 2: Directory input
33+
base_dir = char(arg1);
34+
observers = varargin{2};
35+
ground_truth = varargin{3};
36+
if length(varargin) >= 4
37+
offset = varargin{4};
38+
else
39+
offset = 0;
3840
end
39-
end
41+
if length(varargin) >= 5; n_reader = varargin{5}; end
42+
if length(varargin) >= 6; pct_split = varargin{6}; end
43+
if length(varargin) >= 7; seed_split = varargin{7}; end
4044

41-
if ~exist('observers', 'var')
42-
observers = {LG_CHO_2D()};
43-
% observers = {LG_CHO_2D(),... % will need to modify .channel_width attribute later when insert width known
44-
% DOG_CHO_2D(),...
45-
% GABOR_CHO_2D(),...
46-
% };
47-
end
48-
%% check for ground truth
49-
if ~exist('ground_truth', 'var')
50-
ground_truth_fname = fullfile(base_dir, 'ground_truth.mhd');
51-
ground_truth = mhd_read_image(ground_truth_fname); %need to build in offset to dataset
52-
end
45+
% Load images
46+
sp_dir = fullfile(base_dir, 'signal_present');
47+
sa_dir = fullfile(base_dir, 'signal_absent');
5348

54-
if ~exist('n_reader','var')
55-
n_reader = 10;
56-
end
57-
if ~exist('pct_split','var')
58-
pct_split = 0.5;
49+
sp_mhd = fullfile(sp_dir, 'signal_present.mhd');
50+
if exist(sp_mhd, 'file')
51+
signal_present_array = mhd_read_image(sp_mhd);
52+
else
53+
error(['Could not find ' sp_mhd]);
54+
end
55+
56+
sa_mhd = fullfile(sa_dir, 'signal_absent.mhd');
57+
if exist(sa_mhd, 'file')
58+
signal_absent_array = mhd_read_image(sa_mhd);
59+
else
60+
error(['Could not find ' sa_mhd]);
61+
end
62+
63+
if offset ~= 0
64+
signal_present_array = signal_present_array - offset;
65+
signal_absent_array = signal_absent_array - offset;
66+
end
67+
68+
% Try to extract dose from base_dir path
69+
[~, name, ~] = fileparts(base_dir);
70+
% Check for dose_XXX format
71+
tokens = regexp(name, 'dose_(\d+)', 'tokens');
72+
if ~isempty(tokens)
73+
dose_level_extracted = str2double(tokens{1}{1});
74+
end
75+
76+
else
77+
% Signature 1: Array input
78+
signal_present_array = varargin{1};
79+
signal_absent_array = varargin{2};
80+
ground_truth = varargin{3};
81+
observers = varargin{4};
82+
if length(varargin) >= 5; n_reader = varargin{5}; end
83+
if length(varargin) >= 6; pct_split = varargin{6}; end
84+
if length(varargin) >= 7; seed_split = varargin{7}; end
5985
end
60-
if ~exist('seed_val','var')
61-
seed_split = randi(10000, n_reader, 1);
86+
87+
% Default values
88+
if ~exist('n_reader','var'); n_reader = 10; end
89+
if ~exist('pct_split','var'); pct_split = 0.5; end
90+
if ~exist('seed_split','var'); seed_split = randi(10000, n_reader, 1); end
91+
92+
% Instantiate observers if strings are passed
93+
observer_objs = cell(1, length(observers));
94+
for i=1:length(observers)
95+
obs = observers{i};
96+
if ischar(obs) || (isstring(obs) && isscalar(obs))
97+
switch upper(obs)
98+
case 'LG_CHO_2D'
99+
observer_objs{i} = LG_CHO_2D();
100+
case 'NPWE_2D'
101+
observer_objs{i} = NPWE_2D();
102+
case 'DOG_CHO_2D'
103+
observer_objs{i} = DOG_CHO_2D();
104+
case 'GABOR_CHO_2D'
105+
observer_objs{i} = GABOR_CHO_2D();
106+
otherwise
107+
error([obs ' not in LG_CHO_2D, DOG_CHO_2D, GABOR_CHO_2D, NPWE_2D'])
108+
end
109+
else
110+
% Assuming it is already an object
111+
observer_objs{i} = obs;
112+
end
62113
end
63-
% input is a binary mask specifying signal known exactly (SKE)
114+
observers = observer_objs;
64115

116+
% input is a binary mask specifying signal known exactly (SKE)
65117
truth_masks = get_demo_truth_masks(ground_truth);
66118

67119
insert_rs = [];
@@ -128,11 +180,18 @@
128180
end
129181
end
130182

131-
results_dict.observer=observer;
132-
results_dict.insert_HU=insert_HU';
133-
results_dict.snr=snr';
134-
results_dict.auc=auc';
135-
results_dict.reader=reader';
136-
results_dict;
183+
results_dict.observer = observer;
184+
results_dict.insert_HU = insert_HU;
185+
results_dict.snr = snr;
186+
results_dict.auc = auc;
187+
results_dict.reader = reader;
188+
results_dict.diameter = insert_diameter_pix;
189+
results_dict.dose_level = repmat(dose_level_extracted, length(auc), 1);
190+
191+
if is_octave
192+
pkg load tablicious
193+
end
194+
195+
results = struct2table(results_dict);
137196

138197
end

src/lcdct/write_lcd_results.m

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
headers = ["observer,recon,diameter,insert_HU,dose_level,snr,auc,reader"];
99
fid = fopen(fname, 'w'); fdisp(fid, headers);
1010
for r=1:length(res_table.dose_level)
11+
obs_val = res_table.observer(r, :);
12+
if iscell(obs_val), obs_val = obs_val{1}; end
13+
recon_val = res_table.recon(r, :);
14+
if iscell(recon_val), recon_val = recon_val{1}; end
15+
1116
fprintf(fid, "%s, %s, %d, %d, %d, %f, %f, %d\n",...
12-
res_table.observer(r, :),...
13-
res_table.recon(r, :),...
17+
obs_val,...
18+
recon_val,...
1419
res_table.diameter(r),...
1520
res_table.insert_HU(r),...
1621
res_table.dose_level(r),...

0 commit comments

Comments
 (0)