from Calculation of CT MTF and NPS using the ACR accreditation phantom by Saul
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