Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
Ok-landscape
GitHub Repository: Ok-landscape/computational-pipeline
Path: blob/main/latex-templates/templates/medical-physics/ct_reconstruction.tex
51 views
unlisted
1
\documentclass[a4paper, 11pt]{report}
2
\usepackage[utf8]{inputenc}
3
\usepackage[T1]{fontenc}
4
\usepackage{amsmath, amssymb}
5
\usepackage{graphicx}
6
\usepackage{siunitx}
7
\usepackage{booktabs}
8
\usepackage{xcolor}
9
\usepackage[makestderr]{pythontex}
10
11
\definecolor{phantom}{RGB}{52, 152, 219}
12
\definecolor{sinogram}{RGB}{231, 76, 60}
13
\definecolor{recon}{RGB}{46, 204, 113}
14
15
\title{Computed Tomography:\\
16
Image Reconstruction and Artifact Analysis}
17
\author{Department of Medical Physics\\Technical Report MP-2024-001}
18
\date{\today}
19
20
\begin{document}
21
\maketitle
22
23
\begin{abstract}
24
This 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.
25
\end{abstract}
26
27
\tableofcontents
28
29
\chapter{Introduction}
30
31
Computed Tomography reconstructs cross-sectional images from X-ray projections using the Radon transform:
32
\begin{equation}
33
p(s, \theta) = \int_{-\infty}^{\infty}\int_{-\infty}^{\infty} f(x, y) \delta(x\cos\theta + y\sin\theta - s) \, dx \, dy
34
\end{equation}
35
36
\section{Central Slice Theorem}
37
The Fourier transform of a projection equals a slice through the 2D Fourier transform:
38
\begin{equation}
39
P(\omega, \theta) = F(\omega\cos\theta, \omega\sin\theta)
40
\end{equation}
41
42
\begin{pycode}
43
import numpy as np
44
from scipy import ndimage
45
from scipy.fft import fft, ifft, fftfreq
46
import matplotlib.pyplot as plt
47
plt.rc('text', usetex=True)
48
plt.rc('font', family='serif')
49
50
np.random.seed(42)
51
52
def create_shepp_logan(size):
53
"""Create simplified Shepp-Logan phantom."""
54
phantom = np.zeros((size, size))
55
y, x = np.ogrid[-size//2:size//2, -size//2:size//2]
56
57
# Outer ellipse (skull)
58
mask = (x/0.69/size*2)**2 + (y/0.92/size*2)**2 <= 1
59
phantom[mask] = 2.0
60
61
# Brain
62
mask = (x/0.6624/size*2)**2 + (y/0.874/size*2)**2 <= 1
63
phantom[mask] = -0.98
64
65
# Ventricles
66
mask = ((x+0.22*size/2)/0.11/size*2)**2 + (y/0.31/size*2)**2 <= 1
67
phantom[mask] = -0.02
68
mask = ((x-0.22*size/2)/0.16/size*2)**2 + (y/0.41/size*2)**2 <= 1
69
phantom[mask] = -0.02
70
71
# Small features
72
mask = ((x-0.35*size/2)/0.21/size*2)**2 + (y/0.25/size*2)**2 <= 1
73
phantom[mask] = 0.01
74
mask = (x/0.046/size*2)**2 + (y/0.046/size*2)**2 <= 1
75
phantom[mask] = 0.01
76
mask = ((x+0.08*size/2)/0.046/size*2)**2 + (y/0.023/size*2)**2 <= 1
77
phantom[mask] = 0.01
78
79
return phantom + 1
80
81
def radon_transform(image, angles):
82
"""Compute Radon transform (sinogram)."""
83
n_angles = len(angles)
84
size = image.shape[0]
85
sinogram = np.zeros((size, n_angles))
86
87
for i, angle in enumerate(angles):
88
rotated = ndimage.rotate(image, -angle, reshape=False, order=3)
89
sinogram[:, i] = np.sum(rotated, axis=0)
90
91
return sinogram
92
93
def create_filter(size, filter_type='ram-lak'):
94
"""Create reconstruction filter in frequency domain."""
95
freq = fftfreq(size)
96
97
if filter_type == 'ram-lak':
98
H = np.abs(freq)
99
elif filter_type == 'shepp-logan':
100
H = np.abs(freq) * np.sinc(freq)
101
elif filter_type == 'cosine':
102
H = np.abs(freq) * np.cos(np.pi * freq)
103
elif filter_type == 'hamming':
104
H = np.abs(freq) * (0.54 + 0.46 * np.cos(2 * np.pi * freq))
105
else:
106
H = np.ones(size)
107
108
return H
109
110
def filtered_backprojection(sinogram, angles, filter_type='ram-lak'):
111
"""Reconstruct image using filtered back-projection."""
112
size = sinogram.shape[0]
113
n_angles = len(angles)
114
reconstruction = np.zeros((size, size))
115
116
# Create filter
117
H = create_filter(size, filter_type)
118
119
# Filter projections
120
filtered_sinogram = np.zeros_like(sinogram)
121
for i in range(n_angles):
122
proj_ft = fft(sinogram[:, i])
123
filtered_sinogram[:, i] = np.real(ifft(proj_ft * H))
124
125
# Back-projection
126
for i, angle in enumerate(angles):
127
back_proj = np.tile(filtered_sinogram[:, i], (size, 1)).T
128
back_proj = ndimage.rotate(back_proj, angle, reshape=False, order=1)
129
reconstruction += back_proj
130
131
return reconstruction * np.pi / n_angles
132
\end{pycode}
133
134
\chapter{Radon Transform and Sinogram}
135
136
\begin{pycode}
137
# Create phantom
138
size = 128
139
phantom = create_shepp_logan(size)
140
141
# Different angular sampling
142
angles_180 = np.linspace(0, 180, 180, endpoint=False)
143
angles_90 = np.linspace(0, 180, 90, endpoint=False)
144
angles_45 = np.linspace(0, 180, 45, endpoint=False)
145
146
sinogram_180 = radon_transform(phantom, angles_180)
147
sinogram_90 = radon_transform(phantom, angles_90)
148
sinogram_45 = radon_transform(phantom, angles_45)
149
150
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
151
152
# Original phantom
153
ax = axes[0, 0]
154
im = ax.imshow(phantom, cmap='gray')
155
ax.set_title('Shepp-Logan Phantom')
156
ax.set_xlabel('x (pixels)')
157
ax.set_ylabel('y (pixels)')
158
plt.colorbar(im, ax=ax, fraction=0.046)
159
160
# Sinogram
161
ax = axes[0, 1]
162
im = ax.imshow(sinogram_180.T, cmap='gray', aspect='auto',
163
extent=[0, size, 180, 0])
164
ax.set_title('Sinogram (180 projections)')
165
ax.set_xlabel('Detector Position')
166
ax.set_ylabel('Angle (degrees)')
167
plt.colorbar(im, ax=ax, fraction=0.046)
168
169
# Single projection
170
ax = axes[0, 2]
171
for angle_idx, label in [(0, '0'), (45, '45'), (90, '90')]:
172
ax.plot(sinogram_180[:, angle_idx], label=f'{label}$^\\circ$')
173
ax.set_xlabel('Detector Position')
174
ax.set_ylabel('Projection Value')
175
ax.set_title('Sample Projections')
176
ax.legend()
177
ax.grid(True, alpha=0.3)
178
179
# Reconstructions with different angles
180
recon_180 = filtered_backprojection(sinogram_180, angles_180)
181
recon_90 = filtered_backprojection(sinogram_90, angles_90)
182
recon_45 = filtered_backprojection(sinogram_45, angles_45)
183
184
titles = ['45 projections', '90 projections', '180 projections']
185
recons = [recon_45, recon_90, recon_180]
186
187
for ax, recon, title in zip(axes[1], recons, titles):
188
im = ax.imshow(recon, cmap='gray')
189
ax.set_title(f'Reconstruction ({title})')
190
ax.set_xlabel('x (pixels)')
191
ax.set_ylabel('y (pixels)')
192
plt.colorbar(im, ax=ax, fraction=0.046)
193
194
plt.tight_layout()
195
plt.savefig('ct_sinogram.pdf', dpi=150, bbox_inches='tight')
196
plt.close()
197
198
# Calculate metrics
199
mse_180 = np.mean((phantom - recon_180/recon_180.max()*phantom.max())**2)
200
mse_90 = np.mean((phantom - recon_90/recon_90.max()*phantom.max())**2)
201
mse_45 = np.mean((phantom - recon_45/recon_45.max()*phantom.max())**2)
202
\end{pycode}
203
204
\begin{figure}[htbp]
205
\centering
206
\includegraphics[width=0.95\textwidth]{ct_sinogram.pdf}
207
\caption{CT reconstruction: (a) phantom, (b) sinogram, (c) projections, (d-f) reconstructions with different angular sampling.}
208
\end{figure}
209
210
\chapter{Reconstruction Filters}
211
212
\section{Filter Comparison}
213
The ramp filter (Ram-Lak) is modified to reduce high-frequency noise:
214
\begin{equation}
215
H_{RL}(\omega) = |\omega|, \quad H_{SL}(\omega) = |\omega|\text{sinc}(\omega)
216
\end{equation}
217
218
\begin{pycode}
219
# Compare different filters
220
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
221
222
# Filter frequency responses
223
ax = axes[0, 0]
224
size_filter = 256
225
freq = fftfreq(size_filter)
226
freq_plot = np.fft.fftshift(freq)
227
228
for filter_type in ['ram-lak', 'shepp-logan', 'cosine', 'hamming']:
229
H = create_filter(size_filter, filter_type)
230
H_plot = np.fft.fftshift(H)
231
ax.plot(freq_plot, H_plot, label=filter_type.replace('-', ' ').title())
232
233
ax.set_xlabel('Frequency')
234
ax.set_ylabel('Filter Response')
235
ax.set_title('Reconstruction Filters')
236
ax.legend()
237
ax.grid(True, alpha=0.3)
238
ax.set_xlim(-0.5, 0.5)
239
240
# Reconstruct with different filters
241
filter_types = ['ram-lak', 'shepp-logan', 'cosine', 'hamming']
242
recon_filters = {}
243
mse_filters = {}
244
245
for filter_type in filter_types:
246
recon = filtered_backprojection(sinogram_180, angles_180, filter_type)
247
recon_filters[filter_type] = recon
248
mse = np.mean((phantom - recon/recon.max()*phantom.max())**2)
249
mse_filters[filter_type] = mse
250
251
# Display reconstructions
252
for ax, filter_type in zip([axes[0, 1], axes[0, 2], axes[1, 0], axes[1, 1]],
253
filter_types):
254
im = ax.imshow(recon_filters[filter_type], cmap='gray')
255
ax.set_title(f'{filter_type.replace("-", " ").title()} Filter')
256
ax.set_xlabel('x (pixels)')
257
ax.set_ylabel('y (pixels)')
258
plt.colorbar(im, ax=ax, fraction=0.046)
259
260
# MSE comparison
261
ax = axes[1, 2]
262
filter_names = [f.replace('-', ' ').title() for f in filter_types]
263
mse_values = [mse_filters[f] for f in filter_types]
264
bars = ax.bar(filter_names, mse_values, color=['#3498db', '#e74c3c', '#2ecc71', '#f39c12'])
265
ax.set_xlabel('Filter Type')
266
ax.set_ylabel('Mean Squared Error')
267
ax.set_title('Reconstruction Quality')
268
ax.grid(True, alpha=0.3, axis='y')
269
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')
270
271
plt.tight_layout()
272
plt.savefig('ct_filters.pdf', dpi=150, bbox_inches='tight')
273
plt.close()
274
\end{pycode}
275
276
\begin{figure}[htbp]
277
\centering
278
\includegraphics[width=0.95\textwidth]{ct_filters.pdf}
279
\caption{Filter comparison: (a) frequency responses, (b-e) reconstructions, (f) MSE comparison.}
280
\end{figure}
281
282
\chapter{Reconstruction Artifacts}
283
284
\begin{pycode}
285
# Demonstrate common artifacts
286
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
287
288
# Metal artifact
289
phantom_metal = phantom.copy()
290
y, x = np.ogrid[-size//2:size//2, -size//2:size//2]
291
metal_mask = ((x-20)**2 + (y+15)**2 <= 25)
292
phantom_metal[metal_mask] = 10 # High attenuation
293
294
sinogram_metal = radon_transform(phantom_metal, angles_180)
295
recon_metal = filtered_backprojection(sinogram_metal, angles_180)
296
297
ax = axes[0, 0]
298
im = ax.imshow(recon_metal, cmap='gray')
299
ax.set_title('Metal Artifact')
300
ax.set_xlabel('x (pixels)')
301
ax.set_ylabel('y (pixels)')
302
plt.colorbar(im, ax=ax, fraction=0.046)
303
304
# Limited angle (missing wedge)
305
angles_limited = np.linspace(0, 120, 120, endpoint=False)
306
sinogram_limited = radon_transform(phantom, angles_limited)
307
recon_limited = filtered_backprojection(sinogram_limited, angles_limited)
308
309
ax = axes[0, 1]
310
im = ax.imshow(recon_limited, cmap='gray')
311
ax.set_title('Limited Angle (0-120$^\\circ$)')
312
ax.set_xlabel('x (pixels)')
313
ax.set_ylabel('y (pixels)')
314
plt.colorbar(im, ax=ax, fraction=0.046)
315
316
# Sparse angle (streak artifacts)
317
angles_sparse = np.linspace(0, 180, 18, endpoint=False)
318
sinogram_sparse = radon_transform(phantom, angles_sparse)
319
recon_sparse = filtered_backprojection(sinogram_sparse, angles_sparse)
320
321
ax = axes[0, 2]
322
im = ax.imshow(recon_sparse, cmap='gray')
323
ax.set_title('Sparse Angle (18 views)')
324
ax.set_xlabel('x (pixels)')
325
ax.set_ylabel('y (pixels)')
326
plt.colorbar(im, ax=ax, fraction=0.046)
327
328
# Noisy sinogram
329
noise_level = 0.1
330
sinogram_noisy = sinogram_180 + noise_level * np.random.randn(*sinogram_180.shape) * sinogram_180.max()
331
recon_noisy = filtered_backprojection(sinogram_noisy, angles_180)
332
333
ax = axes[1, 0]
334
im = ax.imshow(recon_noisy, cmap='gray')
335
ax.set_title(f'Noisy Data ({int(noise_level*100)}\\% noise)')
336
ax.set_xlabel('x (pixels)')
337
ax.set_ylabel('y (pixels)')
338
plt.colorbar(im, ax=ax, fraction=0.046)
339
340
# Beam hardening simulation
341
sinogram_hardened = sinogram_180 * (1 - 0.1 * sinogram_180/sinogram_180.max())
342
recon_hardened = filtered_backprojection(sinogram_hardened, angles_180)
343
344
ax = axes[1, 1]
345
im = ax.imshow(recon_hardened, cmap='gray')
346
ax.set_title('Beam Hardening')
347
ax.set_xlabel('x (pixels)')
348
ax.set_ylabel('y (pixels)')
349
plt.colorbar(im, ax=ax, fraction=0.046)
350
351
# Profile comparison
352
ax = axes[1, 2]
353
center = size // 2
354
ax.plot(phantom[center, :], 'k-', label='Original', linewidth=2)
355
ax.plot(recon_180[center, :]/recon_180.max()*phantom.max(), 'b--',
356
label='Clean', alpha=0.7)
357
ax.plot(recon_noisy[center, :]/recon_noisy.max()*phantom.max(), 'r:',
358
label='Noisy', alpha=0.7)
359
ax.set_xlabel('Position (pixels)')
360
ax.set_ylabel('Intensity')
361
ax.set_title('Central Profile')
362
ax.legend()
363
ax.grid(True, alpha=0.3)
364
365
plt.tight_layout()
366
plt.savefig('ct_artifacts.pdf', dpi=150, bbox_inches='tight')
367
plt.close()
368
\end{pycode}
369
370
\begin{figure}[htbp]
371
\centering
372
\includegraphics[width=0.95\textwidth]{ct_artifacts.pdf}
373
\caption{CT artifacts: (a) metal artifact, (b) limited angle, (c) sparse angle, (d) noisy data, (e) beam hardening, (f) profile comparison.}
374
\end{figure}
375
376
\chapter{Numerical Results}
377
378
\begin{pycode}
379
results_table = [
380
('Image size', f'{size} $\\times$ {size}', 'pixels'),
381
('MSE (180 projections)', f'{mse_180:.6f}', ''),
382
('MSE (90 projections)', f'{mse_90:.6f}', ''),
383
('MSE (45 projections)', f'{mse_45:.6f}', ''),
384
('Best filter (lowest MSE)', min(mse_filters, key=mse_filters.get).replace('-', ' ').title(), ''),
385
('Ram-Lak MSE', f'{mse_filters["ram-lak"]:.6f}', ''),
386
]
387
\end{pycode}
388
389
\begin{table}[htbp]
390
\centering
391
\caption{CT reconstruction results}
392
\begin{tabular}{@{}lcc@{}}
393
\toprule
394
Parameter & Value & Units \\
395
\midrule
396
\py{results_table[0][0]} & \py{results_table[0][1]} & \py{results_table[0][2]} \\
397
\py{results_table[1][0]} & \py{results_table[1][1]} & \py{results_table[1][2]} \\
398
\py{results_table[2][0]} & \py{results_table[2][1]} & \py{results_table[2][2]} \\
399
\py{results_table[3][0]} & \py{results_table[3][1]} & \py{results_table[3][2]} \\
400
\py{results_table[4][0]} & \py{results_table[4][1]} & \py{results_table[4][2]} \\
401
\py{results_table[5][0]} & \py{results_table[5][1]} & \py{results_table[5][2]} \\
402
\bottomrule
403
\end{tabular}
404
\end{table}
405
406
\chapter{Conclusions}
407
408
\begin{enumerate}
409
\item Filtered back-projection requires sufficient angular sampling
410
\item Ram-Lak filter provides best resolution but amplifies noise
411
\item Apodizing filters trade resolution for noise reduction
412
\item Metal artifacts cause streak patterns
413
\item Limited angle causes directional blurring
414
\item Iterative methods can improve reconstruction quality
415
\end{enumerate}
416
417
\end{document}
418
419