醫學影像重建 — 互動式教學¶
三種常見的醫學影像重建技術:
| 項目 | 量測到什麼 | 重建方法 |
|---|---|---|
| CT 電腦斷層 | 各角度的 X 光投影 | 濾波反投影(FBP) |
| MRI 磁振造影 | 頻率域訊號(k-space) | 逆傅立葉變換 + SENSE |
| PET 正子造影 | 各角度的光子計數 + 時間差 | 迭代重建(MLEM / OSEM),可選 TOF |
需要的套件
pip install ipympl numpy scipy scikit-image matplotlib pydicom pillow
In [ ]:
!pip install ipympl numpy scipy scikit-image matplotlib pydicom pillow #安裝套件
In [ ]:
%matplotlib widget
import matplotlib as mpl
mpl.rcParams['font.sans-serif'] = [
'Microsoft JhengHei', # Windows(微軟正黑體)
'PingFang TC', # macOS
'Heiti TC', # macOS(舊版)
'Noto Sans CJK TC', # Linux(apt install fonts-noto-cjk)
'SimHei', # Windows(黑體)
'Arial Unicode MS',
'DejaVu Sans', # fallback
]
mpl.rcParams['axes.unicode_minus'] = False
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, RadioButtons, CheckButtons, TextBox
from matplotlib.gridspec import GridSpec
from scipy import fft
from scipy.ndimage import map_coordinates, gaussian_filter1d
from skimage.transform import radon, iradon, rescale
from skimage.data import shepp_logan_phantom
from skimage import io, color
# (可選)檢查目前使用的字型:
# from matplotlib.font_manager import findfont, FontProperties
# print(findfont(FontProperties(family='Microsoft JhengHei')))
Part 0|共用工具¶
In [ ]:
def normalize_image(image):
image = image.astype(np.float64)
lo, hi = image.min(), image.max()
return (image - lo) / (hi - lo + 1e-8)
def load_grayscale_image(source, target_size=256):
if isinstance(source, str):
if source.lower().endswith(('.dcm', '.dicom')):
import pydicom
image = pydicom.dcmread(source).pixel_array
else:
image = io.imread(source)
else:
image = np.asarray(source)
if image.ndim == 3:
image = color.rgb2gray(image)
image = normalize_image(image)
return rescale(image, target_size / max(image.shape),
mode='reflect', anti_aliasing=True)
def ask_open_file_dialog():
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk(); root.withdraw()
path = filedialog.askopenfilename(
filetypes=[("Image / DICOM", "*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.dcm"),
("All files", "*.*")])
root.destroy()
return path if path else None
except Exception as e:
print(f"無法開啟檔案對話框({e})。可改用 load_grayscale_image('path')")
return None
Part 1|CT:投影重建¶
- Radon 變換(前向):影像 → sinogram
- 逆 Radon / FBP(反向):sinogram → 影像
滑桿控制「用了幾個角度」:角度少 → 模糊,角度多 → 清晰。
重建圖亮度固定在完整重建時的範圍,讓「角度少 → 模糊變暗」的差異看得出來。
In [ ]:
class CTReconstructionDemo:
def __init__(self, image, name="Phantom", n_angles=180):
self.n_angles = n_angles
self.theta = np.linspace(0, 180, n_angles, endpoint=False)
self.current_index = 0
self.is_playing = False
self.recon_vmax = 1.0
self.fig, (self.ax_image, self.ax_sino, self.ax_recon) = plt.subplots(
1, 3, figsize=(6, 3))
plt.subplots_adjust(bottom=0.28, wspace=0.3)
self._build_widgets()
self.set_image(image, name)
self.timer = self.fig.canvas.new_timer(interval=50)
self.timer.add_callback(self._advance)
plt.show()
def _build_widgets(self):
ax_slider = plt.axes([0.2, 0.10, 0.6, 0.03])
self.slider = Slider(ax_slider, '已用角度', 0, self.n_angles - 1,
valinit=0, valstep=1)
self.slider.on_changed(self._on_slider_change)
ax_play = plt.axes([0.20, 0.03, 0.10, 0.05])
self.btn_play = Button(ax_play, '播放 / 暫停', color='lightgreen')
self.btn_play.on_clicked(self._toggle_play)
ax_load = plt.axes([0.35, 0.03, 0.12, 0.05])
self.btn_load = Button(ax_load, '載入圖片', color='lightblue')
self.btn_load.on_clicked(self._load_dialog)
def set_image(self, image, name):
self.image = load_grayscale_image(image, target_size=256)
self.name = name
self.sinogram = radon(self.image, theta=self.theta)
self.sinogram_vmax = max(float(self.sinogram.max()), 1e-6)
recon_full = iradon(self.sinogram, theta=self.theta)
self.recon_vmax = max(float(np.percentile(recon_full, 99.5)), 1e-6)
self.ax_image.clear()
self.ax_image.imshow(self.image, cmap='gray', vmin=0, vmax=1)
self.ax_image.set_title(f'原始圖:{name}')
self.ax_image.axis('off')
self.ax_sino.clear()
self.im_sino = self.ax_sino.imshow(
np.zeros_like(self.sinogram), cmap='gray', aspect='auto',
vmin=0, vmax=self.sinogram_vmax,
extent=[0, 180, 0, self.sinogram.shape[0]])
self.ax_sino.set_title('Sinogram(投影數據)')
self.ax_sino.set_xlabel('角度(度)')
self.indicator = self.ax_sino.axvline(0, color='red', lw=2)
self.ax_recon.clear()
self.im_recon = self.ax_recon.imshow(
np.zeros_like(self.image), cmap='gray',
vmin=0, vmax=self.recon_vmax)
self.ax_recon.set_title('重建結果(FBP)')
self.ax_recon.axis('off')
self.current_index = 0
self.slider.set_val(0)
self._update_display(0)
def _update_display(self, index):
index = int(index)
visible = np.zeros_like(self.sinogram)
visible[:, :index + 1] = self.sinogram[:, :index + 1]
self.im_sino.set_data(visible)
self.indicator.set_xdata([self.theta[index], self.theta[index]])
used_theta = self.theta[:index + 1]
recon = iradon(self.sinogram[:, :index + 1], theta=used_theta)
self.im_recon.set_data(recon)
self.im_recon.set_clim(0, self.recon_vmax)
self.ax_recon.set_title(f'重建結果(已用 {index + 1} 個角度)')
self.fig.canvas.draw_idle()
def _on_slider_change(self, val):
self.current_index = int(val)
self._update_display(self.current_index)
def _toggle_play(self, _event):
if self.is_playing:
self.timer.stop()
else:
self.timer.start()
self.is_playing = not self.is_playing
def _advance(self):
self.current_index = (self.current_index + 1) % self.n_angles
self.slider.set_val(self.current_index)
def _load_dialog(self, _event):
self.timer.stop(); self.is_playing = False
path = ask_open_file_dialog()
if path:
self.set_image(path, '自訂影像')
ct_demo = CTReconstructionDemo(shepp_logan_phantom(), name='Shepp-Logan Phantom')
Part 2|MRI:K-space 重建與 SENSE 並行成像¶
- FFT2(前向):影像 → k-space;IFFT2(反向):k-space → 影像
- 三種取樣策略:中心密集、均勻採樣、隨機採樣
- SENSE:多線圈同時接收,欠採樣時沿 Y 方向折疊;用 C×R 線性方程組解回
SENSE 需要「沿 Y 方向的均勻欠採樣」,啟用時採樣模式自動切換為均勻採樣。 4 個線圈時,加速倍率 R 最大為 4(採樣比例 ≥ 0.25)。
In [ ]:
def sense_1d_reconstruction(image, sens_maps, acceleration):
Ny, Nx = image.shape
n_coils = len(sens_maps)
R = acceleration
if R == 1:
recon = np.zeros((Ny, Nx), dtype=complex)
for s in sens_maps:
recon += fft.ifft2(fft.fft2(image * s)) * np.conj(s)
weight = sum(np.abs(s) ** 2 for s in sens_maps) + 1e-10
return np.abs(recon / weight)
if Ny % R != 0:
raise ValueError(f"Ny ({Ny}) 必須是 acceleration ({R}) 的整數倍")
Ny_alias = Ny // R
aliased = []
for s in sens_maps:
kspace = fft.fft2(image * s)
kspace_sampled = np.zeros_like(kspace)
kspace_sampled[::R, :] = kspace[::R, :]
aliased.append(fft.ifft2(kspace_sampled))
recon = np.zeros((Ny, Nx), dtype=complex)
for x in range(Nx):
for y0 in range(Ny_alias):
S = np.zeros((n_coils, R), dtype=complex)
I = np.zeros(n_coils, dtype=complex)
for c in range(n_coils):
for p in range(R):
y_orig = y0 + p * Ny_alias
if y_orig < Ny:
S[c, p] = sens_maps[c][y_orig, x]
I[c] = R * aliased[c][y0, x]
try:
rho, *_ = np.linalg.lstsq(S, I, rcond=None)
except np.linalg.LinAlgError:
rho = np.zeros(R, dtype=complex)
for p in range(R):
y_orig = y0 + p * Ny_alias
if y_orig < Ny:
recon[y_orig, x] = rho[p]
return np.abs(recon)
class MRIKSpaceDemo:
N_COILS = 4
def __init__(self, image, name="Brain Phantom"):
self.size = 256
self.use_parallel = False
self.image = None
self.kspace = None
self.fig = plt.figure(figsize=(6, 4))
self._build_widgets()
self.set_image(image, name)
plt.show()
def _build_widgets(self):
ax_slider = plt.axes([0.25, 0.15, 0.45, 0.03])
self.slider = Slider(ax_slider, '採樣比例', 0.1, 1.0, valinit=0.4)
self.slider.on_changed(lambda _v: self._refresh())
ax_radio = plt.axes([0.05, 0.02, 0.12, 0.12], facecolor='#f8f8f8')
self.radio = RadioButtons(ax_radio, ('中心密集', '均勻採樣', '隨機採樣'))
self.radio.on_clicked(lambda _v: self._refresh())
ax_check = plt.axes([0.20, 0.02, 0.22, 0.08], facecolor='#f8f8f8')
self.check = CheckButtons(ax_check, ('開啟 SENSE 並行模式',), (False,))
self.check.on_clicked(self._toggle_mode)
ax_btn = plt.axes([0.85, 0.03, 0.10, 0.05])
self.btn_load = Button(ax_btn, '載入圖片')
self.btn_load.on_clicked(self._load_dialog)
def set_image(self, image, name):
self.image = load_grayscale_image(image, target_size=self.size)
self.kspace = fft.fftshift(fft.fft2(self.image))
self.name = name
self._refresh()
def _toggle_mode(self, _label):
self.use_parallel = bool(self.check.get_status()[0])
self._refresh()
def _build_mask(self, ratio, mode):
res = self.size
mask = np.zeros((res, res), dtype=bool)
if mode == '中心密集':
r = max(2, int(res * ratio / 2))
mask[res // 2 - r: res // 2 + r, res // 2 - r: res // 2 + r] = True
elif mode == '均勻採樣':
step = max(1, int(round(1 / ratio)))
mask[::step, :] = True
else:
mask = np.random.rand(res, res) < ratio
return mask
def _refresh(self):
widget_axes = [self.slider.ax, self.radio.ax,
self.check.ax, self.btn_load.ax]
for ax in list(self.fig.axes):
if ax not in widget_axes:
ax.remove()
ratio = float(self.slider.val)
if self.use_parallel:
self._draw_sense(ratio)
else:
mask = self._build_mask(ratio, self.radio.value_selected)
aliased = np.abs(fft.ifft2(fft.ifftshift(self.kspace * mask)))
self._draw_basic(mask, aliased)
self.fig.canvas.draw_idle()
def _draw_basic(self, mask, aliased):
res = self.size
low_mask = np.zeros((res, res), dtype=bool)
low_mask[res // 2 - 12: res // 2 + 12,
res // 2 - 12: res // 2 + 12] = True
low = np.abs(fft.ifft2(fft.ifftshift(self.kspace * low_mask)))
high = np.abs(fft.ifft2(fft.ifftshift(self.kspace * (~low_mask))))
panels = [
(self.image, '原始解剖圖', 'gray'),
(np.log(1 + np.abs(self.kspace)), 'K-space (log)', 'magma'),
(mask, '採樣遮罩', 'gray'),
(aliased, '零填充重建', 'gray'),
(low, '低頻(對比)', 'gray'),
(high, '高頻(邊緣)', 'gray'),
(np.angle(self.kspace), '相位圖', 'hsv'),
(np.abs(self.image - aliased), '重建誤差', 'gray'),
]
gs = GridSpec(2, 4, figure=self.fig)
plt.subplots_adjust(bottom=0.25, hspace=0.3)
for i, (data, title, cmap) in enumerate(panels):
ax = self.fig.add_subplot(gs[i // 4, i % 4])
if i == 6:
ax.imshow(data, cmap=cmap, vmin=-np.pi, vmax=np.pi)
else:
ax.imshow(data, cmap=cmap)
ax.set_title(title, fontsize=10)
ax.axis('off')
def _draw_sense(self, ratio):
res = self.size
x, y = np.meshgrid(np.linspace(-1, 1, res), np.linspace(-1, 1, res))
angles = np.linspace(0, 2 * np.pi, self.N_COILS, endpoint=False)
sens_maps = [
np.exp(-((x - 0.7 * np.cos(a)) ** 2 + (y - 0.7 * np.sin(a)) ** 2) / 0.3)
for a in angles
]
R = int(round(1 / ratio))
R = max(1, min(self.N_COILS, R))
if R == 1:
recon = self.image.copy()
else:
recon = sense_1d_reconstruction(self.image, sens_maps, R)
raw_mask = np.zeros((res, res), dtype=bool)
raw_mask[::R, :] = True
display_mask = fft.fftshift(raw_mask)
coil_images = []
for s in sens_maps:
kspace = fft.fft2(self.image * s)
ks = np.zeros_like(kspace)
ks[::R, :] = kspace[::R, :]
coil_images.append(np.abs(fft.ifft2(ks)))
error = np.abs(self.image - recon)
gs = GridSpec(3, 4, figure=self.fig)
plt.subplots_adjust(bottom=0.25, hspace=0.45, wspace=0.3)
row0 = [
(self.image, '原始影像', 'gray'),
(display_mask, f'欠採樣遮罩(R={R})', 'bone'),
(error, '重建誤差', 'hot'),
(recon, f'SENSE 重建(R={R})', 'gray'),
]
for i, (data, title, cmap) in enumerate(row0):
ax = self.fig.add_subplot(gs[0, i])
ax.imshow(data, cmap=cmap)
ax.set_title(title, fontsize=9)
ax.axis('off')
for i in range(self.N_COILS):
ax_s = self.fig.add_subplot(gs[1, i])
ax_s.imshow(sens_maps[i], cmap='viridis')
ax_s.set_title(f'線圈 {i + 1} 敏感度', fontsize=9)
ax_s.axis('off')
ax_i = self.fig.add_subplot(gs[2, i])
ax_i.imshow(coil_images[i], cmap='gray')
ax_i.set_title(f'線圈 {i + 1} 折疊圖', fontsize=9)
ax_i.axis('off')
def _load_dialog(self, _event):
path = ask_open_file_dialog()
if path:
self.set_image(path, '自訂影像')
def make_brain_phantom(size=256):
x, y = np.meshgrid(np.linspace(-1, 1, size), np.linspace(-1, 1, size))
img = np.zeros((size, size))
img[(x ** 2 / 0.75 ** 2 + y ** 2 / 0.85 ** 2) < 1] = 0.2
img[(x ** 2 / 0.60 ** 2 + y ** 2 / 0.75 ** 2) < 1] = 0.5
img[((x - 0.2) ** 2 + (y - 0.3) ** 2) < 0.05] = 0.8
img += np.random.normal(0, 0.01, (size, size))
return np.clip(img, 0, 1)
mri_demo = MRIKSpaceDemo(make_brain_phantom(), name='Brain Phantom')
Part 3|PET:迭代重建(MLEM / OSEM)+ TOF¶
理論背景¶
標準 MLEM 更新式¶
$$\lambda_{\text{new}} = \frac{\lambda_{\text{old}}}{\text{sens}} \cdot A^T \left( \frac{y}{A \cdot \lambda_{\text{old}}} \right)$$
- $A$:系統矩陣(forward projection)
- $A^T$:反向投影(backprojection)
- $\text{sens} = A^T \cdot 1$:敏感度圖
衰減(Attenuation)與 TOF-PET¶
- 模擬光子吸收與時間差 $\Delta x = c \cdot \Delta t / 2$
- 透過高斯分佈描述時間解析度,有效降低雜訊並提升對比。
In [ ]:
def tof_resolution_to_sigma(fwhm_ps, c_mm_per_ps=0.3):
"""時間解析度 FWHM (ps) → 空間 σ (mm)"""
return c_mm_per_ps * fwhm_ps / (2 * 2.355)
def _build_tof_kernel(t_fine_px, t_centers_px, sigma_px):
"""
(n_tof, n_fine) TOF 權重矩陣,列和 = 1。
嚴格伴隨對的核心:forward 和 backward 用同一個矩陣。
"""
diff = t_fine_px[None, :] - t_centers_px[:, None] # (n_tof, n_fine)
G = np.exp(-diff ** 2 / (2 * sigma_px ** 2))
G[np.abs(diff) > 4 * sigma_px] = 0.0 # 截斷,加速
col_sum = G.sum(axis=0, keepdims=True) + 1e-10
return G / col_sum
def _sample_along_lors(image, theta, meta):
"""
沿所有 LOR 做 fine 採樣(bilinear 插值)。
回傳 (n_det, n_fine, n_angles)。
"""
Ny, Nx = image.shape
R_px, T_px = np.meshgrid(meta['r_indices_px'], meta['t_centers_fine_px'],
indexing='ij')
center_y_px, center_x_px = (Ny - 1) / 2, (Nx - 1) / 2
th_rad = np.deg2rad(theta)
n_det, n_fine = R_px.shape
n_angles = len(theta)
profiles = np.zeros((n_det, n_fine, n_angles))
for i, th in enumerate(th_rad):
c, s = np.cos(th), np.sin(th)
x_px = center_x_px + R_px * c - T_px * s
y_px = center_y_px + R_px * s + T_px * c
profiles[:, :, i] = map_coordinates(
image, [y_px, x_px], order=1, mode='constant', cval=0)
return profiles
def make_tof_sinogram(image, theta, sigma_tof_mm, fov_mm, step_px=4):
"""嚴格生成 TOF sinogram(與 tof_forward 一致)。"""
Ny, Nx = image.shape
px_mm = fov_mm / Ny
max_r_px = int(np.ceil(fov_mm * np.sqrt(2) / 2 / px_mm))
r_indices = np.arange(-max_r_px, max_r_px + 1)
max_t_px = max_r_px
t_fine_px = np.arange(-max_t_px, max_t_px + 1)
t_centers_px = np.arange(-max_t_px + step_px // 2,
max_t_px + 1, step_px)
sigma_px = sigma_tof_mm / px_mm
G_ker = _build_tof_kernel(t_fine_px, t_centers_px, sigma_px)
meta = {
'r_indices_px': r_indices,
't_centers_fine_px': t_fine_px, # ← 所有 fine 位置
't_centers_px': t_centers_px, # ← 粗 bin 中心
'px_mm': px_mm,
'fov_mm': fov_mm,
'sigma_px': sigma_px,
'step_px': step_px,
'max_t_px': max_t_px,
'n_fine': len(t_fine_px),
'n_tof': len(t_centers_px),
'bin_mm': step_px * px_mm,
'G_ker': G_ker, # ← TOF 權重矩陣
}
profiles = _sample_along_lors(image, theta, meta) # (n_det, n_fine, n_angles)
# 注意:einsum 索引 t 是 n_fine、k 是 n_tof
tof_sino = np.einsum('kt,rti->kri', G_ker, profiles) * px_mm
return tof_sino, meta
def tof_forward(image, theta, meta):
"""TOF forward:與 make_tof_sinogram 完全一致。"""
profiles = _sample_along_lors(image, theta, meta)
return np.einsum('kt,rti->kri', meta['G_ker'], profiles) * meta['px_mm']
def tof_backward(tof_sino, theta, image_shape, meta):
"""
TOF backward:嚴格伴隨。
先反投影回 fine 位置,再 scatter 到像素。
"""
Ny, Nx = image_shape
# 反投影到 fine:fine[t, r, θ] = Σ_k G_ker[k, t] · tof_sino[k, r, θ] · px_mm
fine = np.einsum('kt,kri->tri', meta['G_ker'], tof_sino) * meta['px_mm']
R_px, T_px = np.meshgrid(meta['r_indices_px'], meta['t_centers_fine_px'],
indexing='ij')
center_y_px, center_x_px = (Ny - 1) / 2, (Nx - 1) / 2
th_rad = np.deg2rad(theta)
recon_flat = np.zeros(Ny * Nx)
for i, th in enumerate(th_rad):
c, s = np.cos(th), np.sin(th)
x_px = center_x_px + R_px * c - T_px * s
y_px = center_y_px + R_px * s + T_px * c
xi = np.round(x_px).astype(int).ravel()
yi = np.round(y_px).astype(int).ravel()
# fine[:, :, i] 形狀 (n_fine, n_det),.T 後 (n_det, n_fine) 與 R_px 對齊
vals = fine[:, :, i].T.ravel()
valid = (xi >= 0) & (xi < Nx) & (yi >= 0) & (yi < Ny)
flat_idx = yi[valid] * Nx + xi[valid]
recon_flat += np.bincount(flat_idx, weights=vals[valid],
minlength=Ny * Nx)
return recon_flat.reshape((Ny, Nx))
def radon_forward(image, theta, meta):
"""非 TOF forward:TOF forward 對 t 加總即得(因列和為 1)。"""
profiles = _sample_along_lors(image, theta, meta)
return profiles.sum(axis=1) * meta['px_mm']
def radon_backward(sino, theta, image_shape, meta):
"""非 TOF backward。"""
Ny, Nx = image_shape
R_px, T_px = np.meshgrid(meta['r_indices_px'], meta['t_centers_fine_px'],
indexing='ij')
center_y_px, center_x_px = (Ny - 1) / 2, (Nx - 1) / 2
th_rad = np.deg2rad(theta)
n_fine = len(meta['t_centers_fine_px'])
recon_flat = np.zeros(Ny * Nx)
for i, th in enumerate(th_rad):
c, s = np.cos(th), np.sin(th)
x_px = center_x_px + R_px * c - T_px * s
y_px = center_y_px + R_px * s + T_px * c
xi = np.round(x_px).astype(int).ravel()
yi = np.round(y_px).astype(int).ravel()
vals = np.repeat(sino[:, i] * meta['px_mm'], n_fine)
valid = (xi >= 0) & (xi < Nx) & (yi >= 0) & (yi < Ny)
flat_idx = yi[valid] * Nx + xi[valid]
recon_flat += np.bincount(flat_idx, weights=vals[valid],
minlength=Ny * Nx)
return recon_flat.reshape((Ny, Nx))
In [ ]:
class PETIterativeDemo:
"""PET 迭代重建互動展示:MLEM / OSEM、衰減、TOF。"""
def __init__(self, image, name='Phantom', n_angles=60,
image_size=128, fov_mm=256.0):
self.size = image_size
self.fov_mm = fov_mm
self.n_angles = n_angles
self.theta = np.linspace(0, 180, n_angles, endpoint=False)
self.algorithm = 'MLEM'
self.atten_coeff = 0.005
self.tof_fwhm_ps = 400.0
self.history = []
self.fig, (self.ax_image, self.ax_sino, self.ax_recon) = plt.subplots(
1, 3, figsize=(6, 4))
plt.subplots_adjust(bottom=0.35, wspace=0.3)
self._build_widgets()
self.set_image(image, name)
plt.show()
# ---------------------------------------------------------------
# UI
# ---------------------------------------------------------------
def _build_widgets(self):
ax_check = plt.axes([0.05, 0.05, 0.16, 0.12], facecolor='#f9f9f9')
self.check = CheckButtons(
ax_check,
('模擬衰減', '衰減補償', '啟用 TOF'),
(False, False, False))
self.check.on_clicked(self._toggle_physics)
ax_box = plt.axes([0.10, 0.20, 0.06, 0.03])
self.text_box = TextBox(ax_box, 'μ (1/mm) ',
initial=str(self.atten_coeff))
self.text_box.on_submit(self._submit_atten)
ax_tof_box = plt.axes([0.24, 0.20, 0.06, 0.03])
self.tof_box = TextBox(ax_tof_box, 'TOF FWHM (ps) ',
initial=str(self.tof_fwhm_ps))
self.tof_box.on_submit(self._submit_tof_fwhm)
ax_radio = plt.axes([0.24, 0.05, 0.08, 0.10], facecolor='#f9f9f9')
self.radio = RadioButtons(ax_radio, ('MLEM', 'OSEM'))
self.radio.on_clicked(self._change_algo)
ax_slider = plt.axes([0.36, 0.16, 0.50, 0.03])
self.slider = Slider(ax_slider, '迭代步', 0, 1, valinit=0, valstep=1)
self.slider.on_changed(self._on_slider)
ax_run = plt.axes([0.36, 0.05, 0.14, 0.05])
self.btn_run = Button(ax_run, '跑一次迭代', color='#FFD700')
self.btn_run.on_clicked(self._run_iteration)
ax_load = plt.axes([0.52, 0.05, 0.12, 0.05])
self.btn_load = Button(ax_load, '載入圖片', color='#ADD8E6')
self.btn_load.on_clicked(self._load_dialog)
# ---------------------------------------------------------------
# 影像 / sinogram
# ---------------------------------------------------------------
def set_image(self, image, name):
self.image = load_grayscale_image(image, target_size=self.size)
self.name = name
self._compute_sinogram()
self._reset_iterations()
def _compute_sinogram(self):
"""生成 TOF sinogram(含衰減與 Poisson 噪聲)。"""
sigma_tof_mm = tof_resolution_to_sigma(self.tof_fwhm_ps)
tof_true, self.tof_meta = make_tof_sinogram(
self.image, self.theta, sigma_tof_mm, self.fov_mm)
# 衰減
simulate_atten = self.check.get_status()[0]
if simulate_atten:
body = (self.image > 0.02).astype(float)
path = radon_forward(body, self.theta, self.tof_meta) * self.atten_coeff
self.atten_map = np.exp(-path)
tof_obs = tof_true * self.atten_map[None, :, :]
else:
self.atten_map = np.ones((len(self.tof_meta['r_indices_px']),
self.n_angles))
tof_obs = tof_true
# Poisson 噪聲
scale = 100
self.tof_sinogram = np.random.poisson(tof_obs * scale) / scale
self.sinogram_2d = self.tof_sinogram.sum(axis=0)
# 靜態圖
self.ax_image.clear()
self.ax_image.imshow(self.image, cmap='hot')
self.ax_image.set_title(f'原始圖:{self.name}')
self.ax_image.axis('off')
self.ax_sino.clear()
self.ax_sino.imshow(self.sinogram_2d, cmap='magma', aspect='auto')
self.ax_sino.set_title('觀測 Sinogram(非 TOF,即 TOF 對時間軸加總)')
self.ax_sino.set_xlabel('角度(度)')
def _reset_iterations(self):
self.history = [np.ones_like(self.image) * 0.5]
self.slider.valmax = 1
self.slider.ax.set_xlim(0, 1)
self.slider.set_val(0)
self._show_slice(0)
# ---------------------------------------------------------------
# 迭代
# ---------------------------------------------------------------
def _run_iteration(self, _event):
status = self.check.get_status()
simulate_atten, apply_ac, use_tof = status
apply_correction = apply_ac and simulate_atten
if use_tof:
new_recon = self._run_tof_iteration(apply_correction)
else:
new_recon = self._run_2d_iteration(apply_correction)
if new_recon is None:
return
if not np.isfinite(new_recon).all() or new_recon.max() > 1e10:
print('警告:重建發散,請降低衰減係數或改用 MLEM。')
return
self.history.append(new_recon)
new_max = len(self.history) - 1
self.slider.valmax = new_max
self.slider.ax.set_xlim(0, new_max)
self.slider.set_val(new_max)
def _run_2d_iteration(self, apply_correction):
"""非 TOF 的 MLEM / OSEM。"""
recon = self.history[-1].copy()
n_subsets = 4 if self.algorithm == 'OSEM' else 1
for subset in range(n_subsets):
idx = np.arange(subset, self.n_angles, n_subsets)
theta_sub = self.theta[idx]
obs_sub = self.sinogram_2d[:, idx]
if apply_correction:
ac_sub = self.atten_map[:, idx]
else:
ac_sub = np.ones((len(self.tof_meta['r_indices_px']), len(idx)))
forward = radon_forward(recon, theta_sub, self.tof_meta) * ac_sub + 1e-7
ratio = np.nan_to_num(obs_sub / forward)
correction = radon_backward(ratio * ac_sub, theta_sub,
recon.shape, self.tof_meta)
sens = radon_backward(ac_sub, theta_sub,
recon.shape, self.tof_meta) + 1e-7
recon = np.clip(recon * correction / sens, 0, None)
return recon
def _run_tof_iteration(self, apply_correction):
"""
TOF-MLEM / TOF-OSEM 通用更新。
MLEM 是 N=1 特例;OSEM 用 N=4 子集,每個子集各自用子集 sens 歸一化
(Hudson & Larkin 1994 原版)。
"""
recon = self.history[-1].copy()
ac = self.atten_map if apply_correction else np.ones_like(self.atten_map)
ac_3d = ac[None, :, :]
n_subsets = 4 if self.algorithm == 'OSEM' else 1
for subset in range(n_subsets):
idx = np.arange(subset, self.n_angles, n_subsets)
theta_sub = self.theta[idx]
obs_sub = self.tof_sinogram[:, :, idx]
ac_sub = ac_3d[:, :, idx]
# 前向投影(子集版 A_TOF)
forward = tof_forward(recon, theta_sub, self.tof_meta) * ac_sub + 1e-7
ratio = np.nan_to_num(obs_sub / forward)
# 反投影
correction = tof_backward(ratio * ac_sub, theta_sub, recon.shape,
self.tof_meta)
# 子集敏感度 s_n = A_TOFᵀ · AC
sens_sub = tof_backward(ac_sub * np.ones_like(obs_sub),
theta_sub, recon.shape, self.tof_meta) + 1e-7
# MLEM 更新
recon = np.clip(recon * correction / sens_sub, 0, None)
return recon
# ---------------------------------------------------------------
# 顯示
# ---------------------------------------------------------------
def _on_slider(self, val):
self._show_slice(int(val))
def _show_slice(self, index):
if index >= len(self.history):
return
data = self.history[index]
vmax = np.percentile(data, 99.5) or 1
use_tof = self.check.get_status()[2]
algo_name = f'{self.algorithm} (TOF)' if use_tof else self.algorithm
self.ax_recon.clear()
self.ax_recon.imshow(data, cmap='hot', vmin=0, vmax=vmax)
if use_tof:
self.ax_recon.set_title(
f'重建圖({algo_name},第 {index} 次迭代)\n'
f'TOF FWHM = {self.tof_fwhm_ps:.0f} ps')
else:
self.ax_recon.set_title(
f'重建圖({algo_name},第 {index} 次迭代)')
self.ax_recon.axis('off')
self.fig.canvas.draw_idle()
# ---------------------------------------------------------------
# 輸入 / 開關
# ---------------------------------------------------------------
def _submit_atten(self, text):
try:
self.atten_coeff = float(text)
self._compute_sinogram()
self._reset_iterations()
except ValueError:
print('請輸入數字')
def _submit_tof_fwhm(self, text):
try:
self.tof_fwhm_ps = float(text)
self._compute_sinogram()
self._reset_iterations()
except ValueError:
print('請輸入數字')
def _toggle_physics(self, _label):
self._compute_sinogram()
self._reset_iterations()
def _change_algo(self, label):
self.algorithm = label
self._reset_iterations()
def _load_dialog(self, _event):
path = ask_open_file_dialog()
if path:
self.set_image(path, '自訂影像')
pet_demo = PETIterativeDemo(shepp_logan_phantom(), name='Shepp-Logan Phantom')