Update app.py
Browse files
app.py
CHANGED
|
@@ -81,4 +81,233 @@ ax.set_title('Slice Selection Simulation')
|
|
| 81 |
ax.legend()
|
| 82 |
ax.grid(True)
|
| 83 |
|
| 84 |
-
st.pyplot(fig)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
ax.legend()
|
| 82 |
ax.grid(True)
|
| 83 |
|
| 84 |
+
st.pyplot(fig)
|
| 85 |
+
|
| 86 |
+
import streamlit as st
|
| 87 |
+
import numpy as np
|
| 88 |
+
import scipy.io
|
| 89 |
+
import gdown
|
| 90 |
+
import matplotlib.pyplot as plt
|
| 91 |
+
import os
|
| 92 |
+
|
| 93 |
+
# ตั้งค่าหน้าเว็บ (ต้องอยู่บนสุดเสมอ)
|
| 94 |
+
st.set_page_config(page_title="MRI Physics & Simulator", layout="wide")
|
| 95 |
+
|
| 96 |
+
# ==========================================
|
| 97 |
+
# ส่วนฟังก์ชันโหลดข้อมูลของอาจารย์
|
| 98 |
+
# ==========================================
|
| 99 |
+
@st.cache_resource
|
| 100 |
+
def load_vobj():
|
| 101 |
+
output = 'BrainHighResolution.mat'
|
| 102 |
+
if not os.path.exists(output):
|
| 103 |
+
file_id = '1vdV5xDNfAiHvmKuH5DPi1YJ5Ptl927nk'
|
| 104 |
+
gdown.download(f'https://drive.google.com/uc?id={file_id}', output, quiet=True)
|
| 105 |
+
try:
|
| 106 |
+
data = scipy.io.loadmat(output)
|
| 107 |
+
v = data['VObj'][0,0]
|
| 108 |
+
mid = v['T2'].shape[2] // 2
|
| 109 |
+
return {
|
| 110 |
+
'rho': v['Rho'][:,:,mid],
|
| 111 |
+
't1': v['T1'][:,:,mid] * 1000, # s -> ms
|
| 112 |
+
't2': v['T2'][:,:,mid] * 1000, # s -> ms
|
| 113 |
+
}
|
| 114 |
+
except:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
# ==========================================
|
| 118 |
+
# เริ่มต้นหน้าเว็บ (แบ่ง Tabs)
|
| 119 |
+
# ==========================================
|
| 120 |
+
st.title("🧠 MRI Physics & Image Formation")
|
| 121 |
+
st.markdown("---")
|
| 122 |
+
|
| 123 |
+
tab1, tab2, tab3 = st.tabs([
|
| 124 |
+
"📍 3. Image Formation & K-space",
|
| 125 |
+
"🌊 4. Basic Pulse Sequences",
|
| 126 |
+
"🕹️ Simulator ภาพสมอง (TR/TE/TI)"
|
| 127 |
+
])
|
| 128 |
+
|
| 129 |
+
# ==========================================
|
| 130 |
+
# TAB 1: Image Formation & K-space
|
| 131 |
+
# ==========================================
|
| 132 |
+
with tab1:
|
| 133 |
+
st.header("**3. การเข้ารหัสในแนวความถี่และแนวเฟส (Frequency and Phase encoding)**")
|
| 134 |
+
st.write("หลังจากที่เลือกตำแหน่งสไลซ์ในแนวแกน Z โดยการใช้สนามแม่เหล็กเกรเดียนท์ Gz เรียบร้อยแล้ว สปินในแนวระนาบ Y และ X ยังคงมีความถี่และมีเฟสเหมือนกัน[cite: 6]")
|
| 135 |
+
st.write("ในการสร้างภาพเอ็มอาร์ไอแบบสองมิติ จำเป็นต้องใช้สนามแม่เหล็กเกรเดียนท์ Gx และ Gy และให้ความถี่และเฟสของสปินมีค่าแตกต่างกันในตำแหน่งต่างๆ[cite: 6]")
|
| 136 |
+
|
| 137 |
+
st.markdown('<p style="color:red;"><b>Interactive Part:</b></p>', unsafe_allow_html=True)
|
| 138 |
+
st.markdown('<p style="color:red;">- ระนาบ X เป็นแนวความถี่ Frequency</p>', unsafe_allow_html=True)
|
| 139 |
+
st.markdown('<p style="color:red;">- ระนาบ Y เป็นแนวเฟส Phase (เกรเดียนท์ Gy จะเปลี่ยนขนาดแอมปลิจูดหลายค่า เริ่มจากลบไปบวก หรือบวกลงลบ)</p>', unsafe_allow_html=True)
|
| 140 |
+
|
| 141 |
+
st.markdown("---")
|
| 142 |
+
|
| 143 |
+
st.header("**4. พิกัด k-space แบบสองมิติ (2 dimension coordinates of k-space)**")
|
| 144 |
+
st.write("การใช้ Gx และ Gy ในการเข้ารหัสสัญญาณ เสมือนเป็นการสร้างพิกัดขึ้นมา เพื่อให้สปินที่มีความถี่และเฟสจำเพาะเข้าไปเติมในพิกัดนั้นๆ ใน k-space โดยมีลำดับดังนี้:[cite: 6]")
|
| 145 |
+
st.write("• **ตำแหน่ง A:** ช่วงเวลาที่ Gx และ Gy เริ่มทำงาน (สัมพันธ์กับจุดกึ่งกลางของ kx และ ky)[cite: 6]")
|
| 146 |
+
st.write("• **ตำแหน่ง B:** เริ่มบันทึกข้อมูลจุดแรก ของเฟสแถวที่ 1[cite: 6]")
|
| 147 |
+
st.write("• **ตำแหน่ง C:** บันทึกข้อมูลได้ครึ่งหนึ่ง ของเฟสแถวที่ 1[cite: 6]")
|
| 148 |
+
st.write("• **ตำแหน่ง D:** บันทึกข้อมูลได้ครบถ้วน ของเฟสแถวที่ 1[cite: 6]")
|
| 149 |
+
st.write("หลังจากนั้นเริ่มต้นที่ตำแหน่ง A B C D อีกครั้ง แต่เป็นเฟสแถวที่ 2, 3, 4 ไปเรื่อยๆ จนถึงเฟสแถวที่ M ถือเป็นการจบกระบวนการเก็บข้อมูลสองมิติ[cite: 6]")
|
| 150 |
+
|
| 151 |
+
st.markdown('<p style="color:red;"><b>Interactive Part: K-space to MRI Image</b></p>', unsafe_allow_html=True)
|
| 152 |
+
st.markdown('<p style="color:red;">[จำลอง] แบ่งหน้าจอเป็น 2 ฝั่ง ซ้ายคือ "K-space" ขวาคือ "Image" (รูปสมองค่อยๆ ชัดขึ้นเมื่อเติมเส้น K-space)</p>', unsafe_allow_html=True)
|
| 153 |
+
|
| 154 |
+
# ==========================================
|
| 155 |
+
# TAB 2: Basic Pulse Sequences
|
| 156 |
+
# ==========================================
|
| 157 |
+
with tab2:
|
| 158 |
+
st.header("**1. Spin Echo**")
|
| 159 |
+
|
| 160 |
+
# a. Conventional Spin Echo
|
| 161 |
+
st.subheader("**a. Spin Echo**")
|
| 162 |
+
st.write("ทำการกระตุ้นสปินด้วย 90° RF pulse ณ ตำแหน่งสไลซ์ที่เลือก[cite: 6]")
|
| 163 |
+
st.write("เมื่อเวลาผ่านไป $TE/2$ จะทำการกลับเฟสของสปินด้วย 180° RF pulse (Refocusing pulse) จะเกิดการรวมเฟสของสปินที่เวลา TE ซึ่งเป็นเวลาที่สัญญาณมีค่าสูงสุด (Echo signal)[cite: 6]")
|
| 164 |
+
st.write("เวลาระหว่าง 90° RF pulse สองอัน เรียกว่า TR[cite: 6]")
|
| 165 |
+
|
| 166 |
+
# b. Fast Spin Echo
|
| 167 |
+
st.subheader("**b. Fast Spin Echo**")
|
| 168 |
+
st.write("เพิ่มคลื่น 180° (Refocusing pulse) หลายๆ ครั้งภายใน 1 TR ทำให้สามารถเก็บข้อมูล Echo ได้หลายเส้นในครั้งเดียว เรียกว่า Echo Train Length (ETL) ซึ่งช่วยลดเวลาสแกน[cite: 6]")
|
| 169 |
+
|
| 170 |
+
# Interactive FSE
|
| 171 |
+
st.markdown('<div style="background-color:#ffebee; padding:15px; border-radius:10px;">', unsafe_allow_html=True)
|
| 172 |
+
st.markdown('<h4 style="color:#c62828; margin-top:0;">Interactive Scan Time Calculator</h4>', unsafe_allow_html=True)
|
| 173 |
+
st.write("ทดลองคำนวณเวลาการสแกนแบบ Fast Spin Echo")
|
| 174 |
+
col1, col2, col3 = st.columns(3)
|
| 175 |
+
with col1:
|
| 176 |
+
tr_val = st.number_input("TR (ms)", value=500, step=100)
|
| 177 |
+
with col2:
|
| 178 |
+
np_val = st.number_input("N-phase (จำนวน Phase)", value=256, step=64)
|
| 179 |
+
with col3:
|
| 180 |
+
etl_val = st.select_slider("Echo Train Length (ETL)", options=[1, 4, 8, 16], value=4)
|
| 181 |
+
|
| 182 |
+
scan_time_base = (tr_val * np_val) / 1000 # แปลงเป็นวินาที
|
| 183 |
+
scan_time_fse = scan_time_base / etl_val
|
| 184 |
+
st.markdown(f'<p style="color:#c62828;"><b>เวลาสแกนลดลง {etl_val} เท่า!</b> (จาก {scan_time_base:.1f} วินาที ➡️ เหลือเพียง <b>{scan_time_fse:.1f} วินาที</b>)</p>', unsafe_allow_html=True)
|
| 185 |
+
st.markdown('</div><br>', unsafe_allow_html=True)
|
| 186 |
+
|
| 187 |
+
# d. Inversion Recovery
|
| 188 |
+
st.subheader("**d. Inversion Recovery (IR)**")
|
| 189 |
+
st.write("ลำดับพัลส์พิเศษที่เริ่มต้นด้วยการยิงคลื่น 180° เพื่อกลับทิศทางของแกนแม่เหล็กให้ติดลบ จากนั้นเครื่องจะรอเป็นเวลาที่เรียกว่า TI (Inversion Time) ก่อนยิงคลื่น 90° เพื่อกดสัญญาณเนื้อเยื่อบางชนิด[cite: 6]")
|
| 190 |
+
|
| 191 |
+
# Interactive IR (STIR vs FLAIR)
|
| 192 |
+
st.markdown('<div style="background-color:#e8eaf6; padding:15px; border-radius:10px;">', unsafe_allow_html=True)
|
| 193 |
+
st.markdown('<h4 style="color:#283593; margin-top:0;">Interactive TI Slider (โหมดจำลองการกดสัญญาณ)</h4>', unsafe_allow_html=True)
|
| 194 |
+
ti_sim = st.slider("เลื่อนปรับค่า Inversion Time (TI) [ms]", 100, 3000, 150, step=50)
|
| 195 |
+
|
| 196 |
+
if 100 <= ti_sim <= 200:
|
| 197 |
+
st.success("**โหมด STIR (Short Tau Inversion Recovery):** เป็นเทคนิคการกดสัญญาณไขมัน (Fat suppression) ทำให้ไขมันในภาพกลายเป็นสีดำ[cite: 6]")
|
| 198 |
+
elif 2000 <= ti_sim <= 2600:
|
| 199 |
+
st.warning("**โหมด FLAIR (Fluid Attenuated Inversion Recovery):** เป็นเทคนิคการกดสัญญาณน้ำไขสันหลัง (CSF) ทำให้ภาพสมองเห็นรอยโรคชัดเจนขึ้นโดยไม่มีสีขาวของน้ำมาบัง[cite: 6]")
|
| 200 |
+
else:
|
| 201 |
+
st.info("กำลังอยู่ในช่วงเวลาพัก (TI ทั่วไป) - เนื้อเยื่อกำลังทยอยฟื้นตัวกลับสู่แกนตั้ง")
|
| 202 |
+
st.markdown('</div><br>', unsafe_allow_html=True)
|
| 203 |
+
|
| 204 |
+
st.markdown("---")
|
| 205 |
+
|
| 206 |
+
st.header("**2. Gradient Echo**")
|
| 207 |
+
st.subheader("**a. Conventional Gradient Echo**")
|
| 208 |
+
st.write("กระตุ้นสปินด้วย 90° (หรือน้อยกว่า) จากนั้นเร่งให้สปินแตกเฟสโดยใช้ GFE ค่าลบ และตามด้วย GFE ค่าบวก เมื่อพื้นที่ลบและบวกรวมกันเป็นศูนย์จะเกิดการรวมเฟส (Echo signal) โดยไม่ต้องใช้คลื่น 180°[cite: 6]")
|
| 209 |
+
|
| 210 |
+
st.subheader("**b. Fast Gradient Echo & SSFP**")
|
| 211 |
+
st.write("เมื่อ TR สั้นมากจนสั้นกว่า T2 ($TR \ll T2$) หางของสัญญาณ FID และ Echo จะรวมกัน (Merge) กลายเป็นสัญญาณที่ต่อเนื่องกัน เรียกว่าสภาวะ Steady-State Free Precession (SSFP)[cite: 6]")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ==========================================
|
| 215 |
+
# TAB 3: MRI Simulator (Professor's Code)
|
| 216 |
+
# ==========================================
|
| 217 |
+
with tab3:
|
| 218 |
+
st.markdown('<h2 style="font-size:30px; text-align:center;">🧠 โปรแกรมจำลองภาพ MRI (T1, T2, FLAIR)</h2>', unsafe_allow_html=True)
|
| 219 |
+
|
| 220 |
+
v = load_vobj()
|
| 221 |
+
|
| 222 |
+
if v is not None:
|
| 223 |
+
rho = np.nan_to_num(v['rho'])
|
| 224 |
+
t1 = np.nan_to_num(v['t1'])
|
| 225 |
+
t2 = np.nan_to_num(v['t2'])
|
| 226 |
+
|
| 227 |
+
T1_CSF = 2569.0
|
| 228 |
+
|
| 229 |
+
t1_safe = np.where(t1 <= 0, 1e10, t1)
|
| 230 |
+
t2_safe = np.where(t2 <= 0, 1e-10, t2)
|
| 231 |
+
|
| 232 |
+
# จำลอง Sidebar มาไว้ในคอลัมน์ซ้ายเพื่อให้เข้ากับ Tab UI
|
| 233 |
+
col_ctrl, col_img = st.columns([1, 2])
|
| 234 |
+
|
| 235 |
+
with col_ctrl:
|
| 236 |
+
st.markdown("### 🕹️ Parameters Settings")
|
| 237 |
+
sequence = st.selectbox("เลือก Sequence", ["T1-Weighted", "T2-Weighted", "FLAIR"])
|
| 238 |
+
|
| 239 |
+
tr_ms = st.slider("TR (ms)", 100, 10000, 600 if sequence == "T1-Weighted" else 9000, step=100)
|
| 240 |
+
te_ms = st.slider("TE (ms)", 10, 200, 15 if sequence == "T1-Weighted" else 80, step=5)
|
| 241 |
+
|
| 242 |
+
ti_ms = None
|
| 243 |
+
if sequence == "FLAIR":
|
| 244 |
+
ti_null = int(T1_CSF * np.log(2.0 / (1.0 + np.exp(-tr_ms / T1_CSF))))
|
| 245 |
+
ti_ms = st.slider(f"TI (ms) [CSF null = {ti_null} ms]", 100, 4000, ti_null, step=25)
|
| 246 |
+
|
| 247 |
+
with col_img:
|
| 248 |
+
# คำนวณสัญญาณ
|
| 249 |
+
if sequence == "T1-Weighted":
|
| 250 |
+
signal = rho * (1 - np.exp(-tr_ms / t1_safe)) * np.exp(-te_ms / t2_safe)
|
| 251 |
+
caption = f"T1-Weighted | TR = {tr_ms} ms, TE = {te_ms} ms"
|
| 252 |
+
elif sequence == "T2-Weighted":
|
| 253 |
+
signal = rho * (1 - np.exp(-tr_ms / t1_safe)) * np.exp(-te_ms / t2_safe)
|
| 254 |
+
caption = f"T2-Weighted | TR = {tr_ms} ms, TE = {te_ms} ms"
|
| 255 |
+
else:
|
| 256 |
+
raw = rho * (1 - 2*np.exp(-ti_ms / t1_safe) + np.exp(-tr_ms / t1_safe)) * np.exp(-te_ms / t2_safe)
|
| 257 |
+
signal = np.clip(raw, 0, None)
|
| 258 |
+
caption = f"FLAIR | TR = {tr_ms} ms, TE = {te_ms} ms, TI = {ti_ms} ms"
|
| 259 |
+
|
| 260 |
+
st.markdown(f'<p style="font-size:18px; font-weight:bold; text-align:center;">{caption}</p>', unsafe_allow_html=True)
|
| 261 |
+
|
| 262 |
+
fig, ax = plt.subplots(figsize=(6, 6))
|
| 263 |
+
ax.imshow(signal, cmap='gray')
|
| 264 |
+
ax.axis('off')
|
| 265 |
+
fig.tight_layout()
|
| 266 |
+
st.pyplot(fig)
|
| 267 |
+
plt.close(fig)
|
| 268 |
+
|
| 269 |
+
st.divider()
|
| 270 |
+
st.markdown('<h2 style="font-size:22px;">📖 คำอธิบายทางทฤษฎี & กราฟ</h2>', unsafe_allow_html=True)
|
| 271 |
+
|
| 272 |
+
TISSUES = {
|
| 273 |
+
'CSF': {'T1': 2569, 'T2': 329, 'color': '#2196F3'},
|
| 274 |
+
'Gray Matter': {'T1': 833, 'T2': 83, 'color': '#9C27B0'},
|
| 275 |
+
'White Matter': {'T1': 500, 'T2': 70, 'color': '#FF9800'},
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
col_g1, col_g2 = st.columns(2)
|
| 279 |
+
|
| 280 |
+
with col_g1:
|
| 281 |
+
st.markdown('<b style="font-size:16px;">T1 Recovery Curve</b>', unsafe_allow_html=True)
|
| 282 |
+
t_rec = np.linspace(0, 10000, 1000)
|
| 283 |
+
fig1, ax1 = plt.subplots(figsize=(5, 3))
|
| 284 |
+
for name, p in TISSUES.items():
|
| 285 |
+
ax1.plot(t_rec, 1 - np.exp(-t_rec / p['T1']), color=p['color'], lw=2, label=f"{name}")
|
| 286 |
+
ax1.axvline(tr_ms, color='red', lw=1.5, ls='--', label=f'TR = {tr_ms}')
|
| 287 |
+
ax1.set_xlabel('TR (ms)')
|
| 288 |
+
ax1.set_ylabel('Mz / M0')
|
| 289 |
+
ax1.legend(fontsize=8)
|
| 290 |
+
ax1.grid(alpha=0.3)
|
| 291 |
+
ax1.set_ylim(0, 1.05)
|
| 292 |
+
ax1.set_xlim(0, 10000)
|
| 293 |
+
st.pyplot(fig1)
|
| 294 |
+
plt.close(fig1)
|
| 295 |
+
|
| 296 |
+
with col_g2:
|
| 297 |
+
st.markdown('<b style="font-size:16px;">T2 Decay Curve</b>', unsafe_allow_html=True)
|
| 298 |
+
t_dec = np.linspace(0, 1200, 1000)
|
| 299 |
+
fig2, ax2 = plt.subplots(figsize=(5, 3))
|
| 300 |
+
for name, p in TISSUES.items():
|
| 301 |
+
ax2.plot(t_dec, np.exp(-t_dec / p['T2']), color=p['color'], lw=2, label=f"{name}")
|
| 302 |
+
ax2.axvline(te_ms, color='red', lw=1.5, ls='--', label=f'TE = {te_ms}')
|
| 303 |
+
ax2.set_xlabel('TE (ms)')
|
| 304 |
+
ax2.set_ylabel('Mxy / M0')
|
| 305 |
+
ax2.legend(fontsize=8)
|
| 306 |
+
ax2.grid(alpha=0.3)
|
| 307 |
+
ax2.set_ylim(0, 1.05)
|
| 308 |
+
ax2.set_xlim(0, 1200)
|
| 309 |
+
st.pyplot(fig2)
|
| 310 |
+
plt.close(fig2)
|
| 311 |
+
|
| 312 |
+
else:
|
| 313 |
+
st.error("Error loading VObj data. (ตรวจดูการเชื่อมต่ออินเทอร์เน็ตสำหรับการโหลดไฟล์ครั้งแรกครับ)")
|