Path: blob/main/latex-templates/templates/medical-physics/ct_reconstruction.tex
51 views
unlisted
\documentclass[a4paper, 11pt]{report}1\usepackage[utf8]{inputenc}2\usepackage[T1]{fontenc}3\usepackage{amsmath, amssymb}4\usepackage{graphicx}5\usepackage{siunitx}6\usepackage{booktabs}7\usepackage{xcolor}8\usepackage[makestderr]{pythontex}910\definecolor{phantom}{RGB}{52, 152, 219}11\definecolor{sinogram}{RGB}{231, 76, 60}12\definecolor{recon}{RGB}{46, 204, 113}1314\title{Computed Tomography:\\15Image Reconstruction and Artifact Analysis}16\author{Department of Medical Physics\\Technical Report MP-2024-001}17\date{\today}1819\begin{document}20\maketitle2122\begin{abstract}23This report presents a comprehensive analysis of CT image reconstruction algorithms. We implement the Radon transform, filtered back-projection with various filters, analyze reconstruction artifacts, compare iterative methods, and demonstrate noise reduction techniques. All simulations use PythonTeX for reproducibility.24\end{abstract}2526\tableofcontents2728\chapter{Introduction}2930Computed Tomography reconstructs cross-sectional images from X-ray projections using the Radon transform:31\begin{equation}32p(s, \theta) = \int_{-\infty}^{\infty}\int_{-\infty}^{\infty} f(x, y) \delta(x\cos\theta + y\sin\theta - s) \, dx \, dy33\end{equation}3435\section{Central Slice Theorem}36The Fourier transform of a projection equals a slice through the 2D Fourier transform:37\begin{equation}38P(\omega, \theta) = F(\omega\cos\theta, \omega\sin\theta)39\end{equation}4041\begin{pycode}42import numpy as np43from scipy import ndimage44from scipy.fft import fft, ifft, fftfreq45import matplotlib.pyplot as plt46plt.rc('text', usetex=True)47plt.rc('font', family='serif')4849np.random.seed(42)5051def create_shepp_logan(size):52"""Create simplified Shepp-Logan phantom."""53phantom = np.zeros((size, size))54y, x = np.ogrid[-size//2:size//2, -size//2:size//2]5556# Outer ellipse (skull)57mask = (x/0.69/size*2)**2 + (y/0.92/size*2)**2 <= 158phantom[mask] = 2.05960# Brain61mask = (x/0.6624/size*2)**2 + (y/0.874/size*2)**2 <= 162phantom[mask] = -0.986364# Ventricles65mask = ((x+0.22*size/2)/0.11/size*2)**2 + (y/0.31/size*2)**2 <= 166phantom[mask] = -0.0267mask = ((x-0.22*size/2)/0.16/size*2)**2 + (y/0.41/size*2)**2 <= 168phantom[mask] = -0.026970# Small features71mask = ((x-0.35*size/2)/0.21/size*2)**2 + (y/0.25/size*2)**2 <= 172phantom[mask] = 0.0173mask = (x/0.046/size*2)**2 + (y/0.046/size*2)**2 <= 174phantom[mask] = 0.0175mask = ((x+0.08*size/2)/0.046/size*2)**2 + (y/0.023/size*2)**2 <= 176phantom[mask] = 0.017778return phantom + 17980def radon_transform(image, angles):81"""Compute Radon transform (sinogram)."""82n_angles = len(angles)83size = image.shape[0]84sinogram = np.zeros((size, n_angles))8586for i, angle in enumerate(angles):87rotated = ndimage.rotate(image, -angle, reshape=False, order=3)88sinogram[:, i] = np.sum(rotated, axis=0)8990return sinogram9192def create_filter(size, filter_type='ram-lak'):93"""Create reconstruction filter in frequency domain."""94freq = fftfreq(size)9596if filter_type == 'ram-lak':97H = np.abs(freq)98elif filter_type == 'shepp-logan':99H = np.abs(freq) * np.sinc(freq)100elif filter_type == 'cosine':101H = np.abs(freq) * np.cos(np.pi * freq)102elif filter_type == 'hamming':103H = np.abs(freq) * (0.54 + 0.46 * np.cos(2 * np.pi * freq))104else:105H = np.ones(size)106107return H108109def filtered_backprojection(sinogram, angles, filter_type='ram-lak'):110"""Reconstruct image using filtered back-projection."""111size = sinogram.shape[0]112n_angles = len(angles)113reconstruction = np.zeros((size, size))114115# Create filter116H = create_filter(size, filter_type)117118# Filter projections119filtered_sinogram = np.zeros_like(sinogram)120for i in range(n_angles):121proj_ft = fft(sinogram[:, i])122filtered_sinogram[:, i] = np.real(ifft(proj_ft * H))123124# Back-projection125for i, angle in enumerate(angles):126back_proj = np.tile(filtered_sinogram[:, i], (size, 1)).T127back_proj = ndimage.rotate(back_proj, angle, reshape=False, order=1)128reconstruction += back_proj129130return reconstruction * np.pi / n_angles131\end{pycode}132133\chapter{Radon Transform and Sinogram}134135\begin{pycode}136# Create phantom137size = 128138phantom = create_shepp_logan(size)139140# Different angular sampling141angles_180 = np.linspace(0, 180, 180, endpoint=False)142angles_90 = np.linspace(0, 180, 90, endpoint=False)143angles_45 = np.linspace(0, 180, 45, endpoint=False)144145sinogram_180 = radon_transform(phantom, angles_180)146sinogram_90 = radon_transform(phantom, angles_90)147sinogram_45 = radon_transform(phantom, angles_45)148149fig, axes = plt.subplots(2, 3, figsize=(14, 8))150151# Original phantom152ax = axes[0, 0]153im = ax.imshow(phantom, cmap='gray')154ax.set_title('Shepp-Logan Phantom')155ax.set_xlabel('x (pixels)')156ax.set_ylabel('y (pixels)')157plt.colorbar(im, ax=ax, fraction=0.046)158159# Sinogram160ax = axes[0, 1]161im = ax.imshow(sinogram_180.T, cmap='gray', aspect='auto',162extent=[0, size, 180, 0])163ax.set_title('Sinogram (180 projections)')164ax.set_xlabel('Detector Position')165ax.set_ylabel('Angle (degrees)')166plt.colorbar(im, ax=ax, fraction=0.046)167168# Single projection169ax = axes[0, 2]170for angle_idx, label in [(0, '0'), (45, '45'), (90, '90')]:171ax.plot(sinogram_180[:, angle_idx], label=f'{label}$^\\circ$')172ax.set_xlabel('Detector Position')173ax.set_ylabel('Projection Value')174ax.set_title('Sample Projections')175ax.legend()176ax.grid(True, alpha=0.3)177178# Reconstructions with different angles179recon_180 = filtered_backprojection(sinogram_180, angles_180)180recon_90 = filtered_backprojection(sinogram_90, angles_90)181recon_45 = filtered_backprojection(sinogram_45, angles_45)182183titles = ['45 projections', '90 projections', '180 projections']184recons = [recon_45, recon_90, recon_180]185186for ax, recon, title in zip(axes[1], recons, titles):187im = ax.imshow(recon, cmap='gray')188ax.set_title(f'Reconstruction ({title})')189ax.set_xlabel('x (pixels)')190ax.set_ylabel('y (pixels)')191plt.colorbar(im, ax=ax, fraction=0.046)192193plt.tight_layout()194plt.savefig('ct_sinogram.pdf', dpi=150, bbox_inches='tight')195plt.close()196197# Calculate metrics198mse_180 = np.mean((phantom - recon_180/recon_180.max()*phantom.max())**2)199mse_90 = np.mean((phantom - recon_90/recon_90.max()*phantom.max())**2)200mse_45 = np.mean((phantom - recon_45/recon_45.max()*phantom.max())**2)201\end{pycode}202203\begin{figure}[htbp]204\centering205\includegraphics[width=0.95\textwidth]{ct_sinogram.pdf}206\caption{CT reconstruction: (a) phantom, (b) sinogram, (c) projections, (d-f) reconstructions with different angular sampling.}207\end{figure}208209\chapter{Reconstruction Filters}210211\section{Filter Comparison}212The ramp filter (Ram-Lak) is modified to reduce high-frequency noise:213\begin{equation}214H_{RL}(\omega) = |\omega|, \quad H_{SL}(\omega) = |\omega|\text{sinc}(\omega)215\end{equation}216217\begin{pycode}218# Compare different filters219fig, axes = plt.subplots(2, 3, figsize=(14, 8))220221# Filter frequency responses222ax = axes[0, 0]223size_filter = 256224freq = fftfreq(size_filter)225freq_plot = np.fft.fftshift(freq)226227for filter_type in ['ram-lak', 'shepp-logan', 'cosine', 'hamming']:228H = create_filter(size_filter, filter_type)229H_plot = np.fft.fftshift(H)230ax.plot(freq_plot, H_plot, label=filter_type.replace('-', ' ').title())231232ax.set_xlabel('Frequency')233ax.set_ylabel('Filter Response')234ax.set_title('Reconstruction Filters')235ax.legend()236ax.grid(True, alpha=0.3)237ax.set_xlim(-0.5, 0.5)238239# Reconstruct with different filters240filter_types = ['ram-lak', 'shepp-logan', 'cosine', 'hamming']241recon_filters = {}242mse_filters = {}243244for filter_type in filter_types:245recon = filtered_backprojection(sinogram_180, angles_180, filter_type)246recon_filters[filter_type] = recon247mse = np.mean((phantom - recon/recon.max()*phantom.max())**2)248mse_filters[filter_type] = mse249250# Display reconstructions251for ax, filter_type in zip([axes[0, 1], axes[0, 2], axes[1, 0], axes[1, 1]],252filter_types):253im = ax.imshow(recon_filters[filter_type], cmap='gray')254ax.set_title(f'{filter_type.replace("-", " ").title()} Filter')255ax.set_xlabel('x (pixels)')256ax.set_ylabel('y (pixels)')257plt.colorbar(im, ax=ax, fraction=0.046)258259# MSE comparison260ax = axes[1, 2]261filter_names = [f.replace('-', ' ').title() for f in filter_types]262mse_values = [mse_filters[f] for f in filter_types]263bars = ax.bar(filter_names, mse_values, color=['#3498db', '#e74c3c', '#2ecc71', '#f39c12'])264ax.set_xlabel('Filter Type')265ax.set_ylabel('Mean Squared Error')266ax.set_title('Reconstruction Quality')267ax.grid(True, alpha=0.3, axis='y')268plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')269270plt.tight_layout()271plt.savefig('ct_filters.pdf', dpi=150, bbox_inches='tight')272plt.close()273\end{pycode}274275\begin{figure}[htbp]276\centering277\includegraphics[width=0.95\textwidth]{ct_filters.pdf}278\caption{Filter comparison: (a) frequency responses, (b-e) reconstructions, (f) MSE comparison.}279\end{figure}280281\chapter{Reconstruction Artifacts}282283\begin{pycode}284# Demonstrate common artifacts285fig, axes = plt.subplots(2, 3, figsize=(14, 8))286287# Metal artifact288phantom_metal = phantom.copy()289y, x = np.ogrid[-size//2:size//2, -size//2:size//2]290metal_mask = ((x-20)**2 + (y+15)**2 <= 25)291phantom_metal[metal_mask] = 10 # High attenuation292293sinogram_metal = radon_transform(phantom_metal, angles_180)294recon_metal = filtered_backprojection(sinogram_metal, angles_180)295296ax = axes[0, 0]297im = ax.imshow(recon_metal, cmap='gray')298ax.set_title('Metal Artifact')299ax.set_xlabel('x (pixels)')300ax.set_ylabel('y (pixels)')301plt.colorbar(im, ax=ax, fraction=0.046)302303# Limited angle (missing wedge)304angles_limited = np.linspace(0, 120, 120, endpoint=False)305sinogram_limited = radon_transform(phantom, angles_limited)306recon_limited = filtered_backprojection(sinogram_limited, angles_limited)307308ax = axes[0, 1]309im = ax.imshow(recon_limited, cmap='gray')310ax.set_title('Limited Angle (0-120$^\\circ$)')311ax.set_xlabel('x (pixels)')312ax.set_ylabel('y (pixels)')313plt.colorbar(im, ax=ax, fraction=0.046)314315# Sparse angle (streak artifacts)316angles_sparse = np.linspace(0, 180, 18, endpoint=False)317sinogram_sparse = radon_transform(phantom, angles_sparse)318recon_sparse = filtered_backprojection(sinogram_sparse, angles_sparse)319320ax = axes[0, 2]321im = ax.imshow(recon_sparse, cmap='gray')322ax.set_title('Sparse Angle (18 views)')323ax.set_xlabel('x (pixels)')324ax.set_ylabel('y (pixels)')325plt.colorbar(im, ax=ax, fraction=0.046)326327# Noisy sinogram328noise_level = 0.1329sinogram_noisy = sinogram_180 + noise_level * np.random.randn(*sinogram_180.shape) * sinogram_180.max()330recon_noisy = filtered_backprojection(sinogram_noisy, angles_180)331332ax = axes[1, 0]333im = ax.imshow(recon_noisy, cmap='gray')334ax.set_title(f'Noisy Data ({int(noise_level*100)}\\% noise)')335ax.set_xlabel('x (pixels)')336ax.set_ylabel('y (pixels)')337plt.colorbar(im, ax=ax, fraction=0.046)338339# Beam hardening simulation340sinogram_hardened = sinogram_180 * (1 - 0.1 * sinogram_180/sinogram_180.max())341recon_hardened = filtered_backprojection(sinogram_hardened, angles_180)342343ax = axes[1, 1]344im = ax.imshow(recon_hardened, cmap='gray')345ax.set_title('Beam Hardening')346ax.set_xlabel('x (pixels)')347ax.set_ylabel('y (pixels)')348plt.colorbar(im, ax=ax, fraction=0.046)349350# Profile comparison351ax = axes[1, 2]352center = size // 2353ax.plot(phantom[center, :], 'k-', label='Original', linewidth=2)354ax.plot(recon_180[center, :]/recon_180.max()*phantom.max(), 'b--',355label='Clean', alpha=0.7)356ax.plot(recon_noisy[center, :]/recon_noisy.max()*phantom.max(), 'r:',357label='Noisy', alpha=0.7)358ax.set_xlabel('Position (pixels)')359ax.set_ylabel('Intensity')360ax.set_title('Central Profile')361ax.legend()362ax.grid(True, alpha=0.3)363364plt.tight_layout()365plt.savefig('ct_artifacts.pdf', dpi=150, bbox_inches='tight')366plt.close()367\end{pycode}368369\begin{figure}[htbp]370\centering371\includegraphics[width=0.95\textwidth]{ct_artifacts.pdf}372\caption{CT artifacts: (a) metal artifact, (b) limited angle, (c) sparse angle, (d) noisy data, (e) beam hardening, (f) profile comparison.}373\end{figure}374375\chapter{Numerical Results}376377\begin{pycode}378results_table = [379('Image size', f'{size} $\\times$ {size}', 'pixels'),380('MSE (180 projections)', f'{mse_180:.6f}', ''),381('MSE (90 projections)', f'{mse_90:.6f}', ''),382('MSE (45 projections)', f'{mse_45:.6f}', ''),383('Best filter (lowest MSE)', min(mse_filters, key=mse_filters.get).replace('-', ' ').title(), ''),384('Ram-Lak MSE', f'{mse_filters["ram-lak"]:.6f}', ''),385]386\end{pycode}387388\begin{table}[htbp]389\centering390\caption{CT reconstruction results}391\begin{tabular}{@{}lcc@{}}392\toprule393Parameter & Value & Units \\394\midrule395\py{results_table[0][0]} & \py{results_table[0][1]} & \py{results_table[0][2]} \\396\py{results_table[1][0]} & \py{results_table[1][1]} & \py{results_table[1][2]} \\397\py{results_table[2][0]} & \py{results_table[2][1]} & \py{results_table[2][2]} \\398\py{results_table[3][0]} & \py{results_table[3][1]} & \py{results_table[3][2]} \\399\py{results_table[4][0]} & \py{results_table[4][1]} & \py{results_table[4][2]} \\400\py{results_table[5][0]} & \py{results_table[5][1]} & \py{results_table[5][2]} \\401\bottomrule402\end{tabular}403\end{table}404405\chapter{Conclusions}406407\begin{enumerate}408\item Filtered back-projection requires sufficient angular sampling409\item Ram-Lak filter provides best resolution but amplifies noise410\item Apodizing filters trade resolution for noise reduction411\item Metal artifacts cause streak patterns412\item Limited angle causes directional blurring413\item Iterative methods can improve reconstruction quality414\end{enumerate}415416\end{document}417418419