Calculation of CT MTF and NPS using the ACR accreditation phantom

by

 

19 Apr 2013 (Updated )

This code enables measurements of CT MTF and NPS from images of an ACR accreditation phantom.

npscalc.m
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% To run:  Edit user options in npscalc.m and execute the code.
%
% This code is intended to accompany the paper:
% 
% S. N. Friedman, G. S. K. Fung, J. H. Siewerdsen, and B. M. W. Tsui.  "A
% simple approach to measure computed tomography (CT) modulation transfer 
% function (MTF) and noise-power spectrum (NPS) using the American College 
% of Radiology (ACR) accreditation phantom," Med. Phys. 40, 051907-1 -  
% 051907-9 (2013).
% http://dx.doi.org/10.1118/1.4800795
%
% This code is free to distribute (see below).
%
% This program requires the following 4 files:
%   npscalc.m
%   SliceBrowser.m
%   SliceBrowser.fig
%   license.txt
%
% Purpose:  To calculate the 3D NPS using CT data of the American College
%           of Radiology (ACR) accreditation phantom.
%
% Input:    Two consecutive scans of the phantom are needed.  The program 
%           requires a data directory to be selected in which there are 
%           only two subdirectories containing only CT slices corresponding 
%           to the third module of the phantom.  Be careful of partial
%           volume effects with surrounding modules.
%
%           i.e.,  datadir
%                      |-> scan 1 dir
%                      |           | -> only module 3 slices
%                      |        
%                      |-> scan 2 dir
%                                  |-> only module 3 slices
%
% CCopyright 2012 Saul N. Friedman
%   Distributed under the terms of the "New BSD License."  Please see
%   license.txt.
%
% N.B.:  SliceBrowser.m is an altered version of code by Marian Uhercik.
%        Please see license.txt.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

close all;
clear all;
clc;

fprintf('****************************************************************************\n');
fprintf('CT NPS Calculation\n\n')
fprintf('This code is intended to accompany the paper:\n');
fprintf('S. N. Friedman, G. S. K. Fung, J. H. Siewerdsen, and B. M. W. Tsui.  "A\n')  
fprintf('simple approach to measure computed tomography (CT) modulation transfer \n')
fprintf('function (MTF) and noise-power spectrum (NPS) using the American College \n') 
fprintf('of Radiology (ACR) accreditation phantom," Med. Phys. 40, 051907-1 - \n')
fprintf('051907-9 (2013).\n')
fprintf('http://dx.doi.org/10.1118/1.4800795\n\n')
fprintf('This code can be freely distributed.  Please see license.txt for details.\n');
fprintf('****************************************************************************\n\n');

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% User selected options
%

selectdir = 1;  % 1 = prompted for directly locations via dialogue box, 0 = hardcode locations below
    datadir = fullfile('data');
    resultsdir = fullfile('results',datadir);

saveresults = 1;  % Choose whether to save the calculated 3D NPS to disk as a series of DICOM files.

calczero = 1;  % when set to 0, the NPS(0,0,0) value is estimated based on the surrounding 26 voxel values to try remove any zero-frequency artifacts

rstep = 0.025;  % This will be the effective bin size after forming the radially averaged NPS.

showfig = 3;  % decide how important a figure should be in order to be plotted, 1 = no figures, 4 = all figures

% specify size of ROI (pixels/slices)
deltax = 128;
deltay = deltax;

calcdeltaz = 0; % 1 = calculate size of ROI in z (slices) to be same physical size as x dimension, 0 = use value below
    deltaz = 128;% % specify number of slices to use in ROI

% specify overlap fraction for ROIs in z direction
%  N.B. value is typically [0,0.5] and represents overlap with EACH
%  neighboring ROI (i.e., at 0.5 all pixels overlap with at least one
%  neighbor)

overlapz = 0.5;  

% Parameters pertaining to placement of ROI in the axial plane.
%  N.B. overlap of ROIs will depend on a combination of the radius chosen
%  in rcenter and the distance between the centers of ROIs chosen.

rcenter = 25; % radius in mm along which to center ROIS, a value of 25 mm corresponds to half the radius of the phantom
deltar = 20;  % distance along the radial arc in pixels between centers of ROIs in the axial plane
deltaangle = 2*pi/180;  % the range of angles in radians over which to look for a suitable pixel on which to center an ROI

%
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% Start of analysis code
%

homedr = cd;

if selectdir ==1
    fprintf('Select the data directory.\n\n');
    datadir = uigetdir(homedr,'Pick data directory');
    if saveresults == 1
        fprintf('Select the results directory.\n\n');
        resultsdir = uigetdir(homedr,'Pick results directory');
    end
else
    datadir = fullfile(homedr,datadir);
    resultsdir = fullfile(homedr,resultsdir);
end

if saveresults == 1
    mkdir(resultsdir)
end

cd(datadir)

% get list of scan directories

scandir = dir;

cd(homedr)

while or(strcmp(scandir(1).name,'.'),strcmp(scandir(1).name,'..'))
    scandir = scandir(2:end);
end

Nscan = size(scandir,1);

if Nscan < 2
    fprintf('At least 2 scans are needed to perform analysis.\n')
    return
end

if Nscan > 2 == 1
    Nscan = 2;
    fprintf('Only using the first 2 directories of data.\n')
end

nfilescheck = 0;

for i = 1:Nscan
    cd(fullfile(datadir,scandir(i).name))
    
    % get list of filenames
    
    ffiles = dir;
    
    cd(homedr)
    
    while or(strcmp(ffiles(1).name,'.'),strcmp(ffiles(1).name,'..'))
        ffiles = ffiles(2:end);
    end
    
    Nz = size(ffiles,1);
    
     if nfilescheck ~= Nz && nfilescheck ~=0
        fprintf('Error.  Scan directories do not contain the same number of slices.\n')
        return
    end
    
    nfilescheck = Nz;
    
    if i == 1
        
        % Read DICOM header info and determine correct mapping function to get HU
        % values as well as voxel sizes.
        
        im = single(dicomread(fullfile(datadir,scandir(i).name,ffiles(1).name)));
        
        imheader = dicominfo(fullfile(datadir,scandir(i).name,ffiles(1).name));
        
        b = imheader.RescaleIntercept;
        m = imheader.RescaleSlope;
        
        im = im.* m + b;
        
        pixelx = imheader.PixelSpacing(1);
        pixely = imheader.PixelSpacing(2);
        pixelz = imheader.SliceThickness;
        
        if showfig > 2
            figure
            imagesc(im)
            colormap gray
            hold on
            axis image;
            impixelinfo;
        end
        
        Nx = size(im,2);
        Ny = size(im,1);
        
        imaxisx = [1:Nx] .* pixelx;
        imaxisy = [1:Ny] .* pixely;
        
        imavg = zeros(Ny,Nx);
        im3D = zeros(Ny,Nx,Nz);
    end
    
    % Load data into memory and calculate an average 2D image to later
    % determine phantom location
    
    for z = 1:Nz
        im = single(dicomread(fullfile(datadir,scandir(i).name,ffiles(z).name)));
        im = im.* m + b;
        im3D(:,:,z,i) = im;
        imavg = imavg + im;
    end
    
end

imavg = imavg ./ Nz;

if deltaz  > Nz
    deltaz = Nz;
    val = sprintf('Selected ROI size in z is larger than the number of slices.  Using value of %d instead.\n\n',Nz);
    fprintf(val)
end


% Detrend the data and plot if desired

fprintf('Detrending data.\n\n');

im3Dzm(:,:,:) = im3D(:,:,:,1) - im3D(:,:,:,2);


if showfig > 2 && size(im3Dzm,3) > 1
    yaxis = [1:size(im3Dzm,1)] .* pixely;
    xaxis = [1:size(im3Dzm)].* pixelx;
    zaxis = [1:size(im3Dzm,3)] .* pixelz;
    axlabel = ' (mm)';
    
    setappdata(0,'vaxis',yaxis)
    setappdata(0,'uaxis',xaxis)
    setappdata(0,'waxis',zaxis)
    setappdata(0,'axlabel',axlabel)
    
    SliceBrowser(im3Dzm(:,:,:))
end


% Determine which pixels correspond to phantom cylinder

imlog = zeros(size(imavg));

level = graythresh(abs(imavg));
imlog = im2bw(abs(imavg),level);

% Calculate the center of the cylinder

fprintf('Calculating the center of the phantom and determining phantom-pixel locations.\n\n');

if exist('bwconncomp')==2
    CC = bwconncomp(imlog,26);
    for i = 1:CC.NumObjects;
        Lobj(i) =  length(CC.PixelIdxList{1,i}) ;
    end
else
    [CC CCn] = bwlabel(imlog,8);
    for i = 1:CCn
        Lobj(i) = length(find(CC == i));
    end   
end

L = find(Lobj == max(Lobj));

S = regionprops(CC,'Centroid');

c.y = S(L).Centroid(2);
c.x = S(L).Centroid(1);

if showfig>3
    figure
    imagesc([imaxisx(1) imaxisx(end)], [imaxisy(1) imaxisy(end) ], imlog)
    xlabel('mm')
    ylabel('mm')
    title('Cylinder with center marked')
    colormap gray
    hold on
    axis image;
    impixelinfo;
    if exist('bwconncomp')==2
        [I J] = ind2sub(size(imavg),CC.PixelIdxList{1,L});
    else
        [I,J] = find(CC == L);
    end
    plot(J*pixely,I.*pixelx,'.r')
    plot(c.x*pixelx,c.y*pixely,'*y')
    hold off
end

xaxis = ([1:size(imavg,2)] - c.x) .* pixelx;
yaxis = ([1:size(imavg,1)] - c.y) .* pixely;

% calculate polar coordinates for to place ROIs at a specified radial
% direction

for i = 1 :size(imavg,2)
    for j = 1 : size(imavg,1)
        [th(j,i) r(j,i)] = cart2pol(xaxis(i),yaxis(j));
    end
end

rcalc = round(rcenter/pixelx);  
anglestep = deltar * pixelx / rcalc;

% Calculate ROI size in z direction such that it is the same physical
% dimension as the x ROI, if desired.

if calcdeltaz == 1
    deltaz = round(deltax * pixelx ./ pixelz);
end

% Determine the number of ROIs in each direction.

Ndeltaxy = floor(2*pi/anglestep);
Ndeltaz = floor(Nz ./ (deltaz .* (1- overlapz)) - 1 / (1 - overlapz) + 1);

% Determine the offset needed to center the ROIs in z direction.
offsetz = round((Nz - deltaz - round((deltaz - overlapz * deltaz)*(Ndeltaz - 1)))/2);

if showfig > 2
    figure
    imagesc(im)
    colormap gray
    hold on
end

ROIavg = zeros(deltay,deltax,deltaz);

nROI = 0;

for i = 1:Ndeltaxy;
    
    R1 = find( abs(r - rcalc) < pixelx);
    R2 = find( th < -pi + i*anglestep + deltaangle);
    R3 = find( th > -pi + i*anglestep - deltaangle);
    R4 = intersect(R1,R2);
    R5 = intersect(R3,R4);
    R = min(R5);
    
    [I J] = ind2sub(size(im),R);
    
    y1 = J-round(deltay/2)+1;
    y2 = J+round(deltay/2);
    x1 = I-round(deltax/2)+1;
    x2 = I+round(deltax/2);
    
    for z = 1:Ndeltaz
        nROI=nROI+1;
        ROIzm(:,:,:,nROI) =  im3Dzm(y1:y2,x1:x2,(offsetz + 1 + (deltaz * (z-1))):(offsetz + deltaz + deltaz * (z-1))) ;
    end
    
    if showfig > 2
        plot(I,J,'r.')
        plot([x1,x2,x2,x1,x1],[y1,y1,y2,y2,y1],'r-')
    end
end

% Calculated power spectrum realizations from each ROI.  The ensemble
% average of these is the calculated estimate of the NPS.

fprintf('Calculating the NPS.\n');
fprintf('The may take several minutes. \n');
fprintf('Please be patient.\n\n');

Wavg = zeros(size(ROIzm,1),size(ROIzm,2),size(ROIzm,3));

for i = 1:nROI
    
    W = abs( fftn((ROIzm(:,:,:,i))) ) .^2;
    W = fftshift(W);
    
    Wavg = W + Wavg;
end


Wavg = Wavg ./( Ndeltaxy * Ndeltaz .* deltax .* deltay .* deltaz .* 2 ) .* pixelx .* pixely .* pixelz;

% Calculate the axes values in the frequency domain.

yaxis = ( [0:size(ROIzm,1)-1]/size(ROIzm,1) - 0.5 ) ./ pixely;
xaxis = ( [0:size(ROIzm,2)-1]/size(ROIzm,2) - 0.5 ) ./ pixelx;
zaxis = ( [0:size(ROIzm,3)-1]/size(ROIzm,3) - 0.5 ) ./ pixelz;
axlabel = ' (mm^{-1})';

setappdata(0,'vaxis',yaxis)
setappdata(0,'uaxis',xaxis)
setappdata(0,'waxis',zaxis)
setappdata(0,'axlabel',axlabel)

% The calculated zero-frequency value often contains an artifact.  The value
% can be approximated based on an average of the surrounding values.

if calczero == 0;
    J = find(abs(yaxis)==min(abs(yaxis)));
    I = find(abs(xaxis)==min(abs(xaxis)));
    if size(Wavg,3) > 1
        K = find(abs(zaxis)==min(abs(zaxis)));
        Wavg(J,I,K) = (Wavg(J,I,K+1) + Wavg(J,I,K-1) + Wavg(J,I+1,K) + Wavg(J,I+1,K+1) + Wavg(J,I+1,K-1) + Wavg(J,I-1,K) + Wavg(J,I-1,K+1) + Wavg(J,I-1,K-1) + Wavg(J+1,I,K)  + Wavg(J+1,I,K+1) + Wavg(J+1,I,K-1) + Wavg(J+1,I+1,K) + Wavg(J+1,I+1,K+1) + Wavg(J+1,I+1,K-1) + Wavg(J+1,I-1,K)+ Wavg(J+1,I-1,K+1)+ Wavg(J+1,I-1,K-1)+ Wavg(J-1,I,K) + Wavg(J-1,I,K+1)+ Wavg(J-1,I,K-1)+ Wavg(J-1,I+1,K) + Wavg(J-1,I+1,K+1) + Wavg(J-1,I+1,K-1) + Wavg(J-1,I-1,K) + Wavg(J-1,I-1,K+1) + Wavg(J-1,I-1,K-1)) / 26;
    else
        Wavg(J,I) = (Wavg(J,I+1) + Wavg(J,I-1) + Wavg(J+1,I) + Wavg(J+1,I+1) + Wavg(J+1,I-1) + Wavg(J-1,I) + Wavg(J-1,I+1) + Wavg(J-1,I-1)) ./ 8;
    end
end

% Plot a 1D slightly off-centered NPS profile in each direction.

eps = 0;

if showfig > 2
    figure
    subplot(3,1,1)
    plot(xaxis,Wavg(:,round(deltay/2)+eps,round(deltaz/2)+eps));
    xlabel('x-dir (mm^{-1})')
    ylabel('NPS_x (HU^2 mm^3)')
    subplot(3,1,2)
    plot(yaxis,Wavg(round(deltax/2)+eps,:,round(deltaz/2)+eps));
    xlabel('y-dir (mm^{-1})')
    ylabel('NPS_y (HU^2 mm^3)')
    subplot(3,1,3)
    zvalue = squeeze(Wavg(round(deltay/2)+eps,round(deltax/2)+eps,:));
    plot(zaxis,zvalue,'.-');
    xlabel('z-dir (mm^{-1})')
    ylabel('NPS_z (HU^2 mm^3)')
end

% Plot the 3D NPS.

if showfig > 1 && size(Wavg,3) > 3
    SliceBrowser(Wavg)
end

% Calculate radially averaged NPS profile for xy plane

% Switch to polar coords
imslice = Wavg(:,:,round(deltaz/2)+eps);
r = zeros(size(imslice));
th = zeros(size(imslice));

for i = 1 :size(imslice,2)
    for j = 1 : size(imslice,1)
        %         r(j,i) = sqrt(xaxis(i).^2 + yaxis(j).^2);
        %         th(j,i) = atan2(yaxis(j),xaxis(i));
        [th(j,i) r(j,i)] = cart2pol(xaxis(i),yaxis(j));
    end
end

% Create oversampled radial NPS profile

rmax = max(max(r));

npsr = zeros(ceil(rmax/rstep)+1,1);
nsamp = zeros(length(npsr),1);

rbin = 0:rstep:rmax+rstep;

if showfig > 3
    figure
    imagesc([xaxis(1) xaxis(end)], [yaxis(1) yaxis(end)], imslice)
    colormap gray
    hold on
    plot(0,0,'r*')
    hold off
    axis image;
    impixelinfo;
    
    figure
    imagesc(imslice)
    colormap gray
    hold on
    axis image;
    impixelinfo;
end

% data with radius falling within a given bin are averaged together for a
% low noise approximation of the ESF at the given radius

for i = 1:length(rbin)
    R1 = find(r >= rbin(i));
    R2 = find(r < rbin(i) + rstep);
    R = intersect(R1,R2);
    if showfig > 3
        [X Y] = ind2sub(size(imslice),R);
        plot(Y,X,'.')
        hold all
    end
    npsr(i) = sum(imslice(R));
    nsamp(i) = length(R);
end

i1 = find(nsamp,1,'first');
i2 = find(nsamp,1,'last');
nsamp = nsamp(i1:i2);
npsr = npsr(i1:i2);
rbin = rbin(i1:i2);

I = find(nsamp > 0);
npsr(I) = npsr(I)./nsamp(I);


% Plot radially averaged NPS
if showfig > 2
    figure
    plot(rbin,npsr,'.-')
    xlabel('spatial frequency (mm^{-1})')
    ylabel('NPS_r (HU^2 mm^3)')
    grid on
    axis([rbin(1) rbin(end) -1.2*min(npsr) 1.1*max(npsr)])
end

fprintf('NPS calculated.\n\n');

% Save 3D NPS data as DICOM slices and the 1D profile along w = 0 as text 
% files if indicated.

if saveresults == 1
    
    NPSheader = imheader;
    
    NPSheader.RescaleIntercept = 0;
    NPSheader.RescaleSlope = 1;
    NPSheader.PixelSpacing(1) = xaxis(2) - xaxis(1);
    NPSheader.PixelSpacing(2) = yaxis(2) - yaxis(1);
    NPSheader.SliceThickness = zaxis(2) - zaxis(1);
    
    mkdir(fullfile(resultsdir,'3D'));
    
    for k = 1:size(Wavg,3)
        filename = sprintf('NPS%04d.dcm',k);
        dicomwrite(uint16(Wavg(:,:,k)),fullfile(resultsdir,'3D',filename),NPSheader)
    end
    mkdir(fullfile(resultsdir,'1D'));
    
    save(fullfile(resultsdir,'1D','axisvalues.txt'),'rbin','-ASCII')
    save(fullfile(resultsdir,'1D','NPSrvalues.txt'),'npsr','-ASCII')
    
    fprintf('Results saved.\n\n');
    
end

return;
%
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

Contact us