File size: 29,438 Bytes
b0b1f91
 
27c5cb9
80b96b4
 
 
 
 
 
 
70b2410
b981624
80b96b4
 
7a2e3a3
80b96b4
5b9d5f9
9a29d20
858df9c
58990ff
858df9c
f0b371f
858df9c
 
 
 
8a73f1c
 
80b96b4
 
 
 
 
 
5b9d5f9
80b96b4
 
 
 
 
 
 
 
 
 
 
 
5b9d5f9
b8fb806
 
 
 
 
 
 
 
 
 
 
858df9c
 
 
 
 
 
 
 
 
 
 
 
e3fb0db
 
 
 
 
 
5b9d5f9
80b96b4
5b9d5f9
80b96b4
5a1e208
80b96b4
5a1e208
80b96b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2276d3
 
77c9f68
0d9cd1c
c2276d3
 
77c9f68
c2276d3
 
77c9f68
80b96b4
 
 
 
 
5784d55
80b96b4
7f2c59e
80b96b4
27c5cb9
 
 
 
 
 
 
 
 
 
 
 
 
 
58990ff
0d9cd1c
77c9f68
0d9cd1c
70bb5ae
 
77c9f68
0d9cd1c
41728e0
8a73f1c
 
6688505
8a73f1c
70bb5ae
41728e0
 
 
 
70bb5ae
 
2f72674
41728e0
4f27e81
 
77c9f68
4f27e81
 
70bb5ae
41728e0
 
0d9cd1c
77c9f68
41728e0
 
0d9cd1c
 
58990ff
80b96b4
 
77c9f68
80b96b4
 
 
 
 
 
 
41728e0
80b96b4
 
58990ff
80b96b4
 
 
 
 
 
 
 
 
 
41728e0
80b96b4
 
58990ff
80b96b4
 
 
 
aa63381
80b96b4
 
 
 
 
 
 
58990ff
80b96b4
 
77c9f68
80b96b4
41728e0
80b96b4
 
58990ff
80b96b4
 
fc9f389
41728e0
fc9f389
8a73f1c
 
 
80b96b4
41728e0
80b96b4
 
58990ff
ebb3617
307b033
 
b8fb806
65e869d
ebb3617
307b033
 
 
 
 
ebb3617
 
b8fb806
e3fb0db
ebb3617
 
 
 
307b033
b8fb806
65e869d
b8fb806
307b033
ebb3617
 
307b033
e3fb0db
307b033
 
 
 
 
 
 
 
ebb3617
65e869d
ebb3617
307b033
 
 
 
b8fb806
ebb3617
41728e0
 
b8fb806
 
58990ff
0d9cd1c
ebb3617
b8fb806
0d9cd1c
 
 
 
 
 
 
 
 
 
b8fb806
0d9cd1c
df28af7
0d9cd1c
 
b8fb806
0d9cd1c
 
 
 
 
 
b8fb806
41728e0
 
b8fb806
 
80b96b4
ebb3617
80b96b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe16e0a
80b96b4
 
c2276d3
80b96b4
27c5cb9
80b96b4
b8fb806
2f72674
b8fb806
ebb3617
 
 
 
 
df28af7
 
ebb3617
df28af7
ebb3617
 
b8fb806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d9cd1c
 
ebb3617
b8fb806
 
 
 
 
 
 
 
80c7d17
1252899
 
27c5cb9
1252899
27c5cb9
0d9cd1c
 
 
 
 
b8fb806
 
80b96b4
 
0d9cd1c
80b96b4
858df9c
0d9cd1c
 
 
 
80b96b4
0d9cd1c
80b96b4
0d9cd1c
80b96b4
0d9cd1c
 
70bb5ae
80b96b4
 
 
 
 
 
 
 
 
 
 
 
 
 
27c5cb9
80b96b4
27c5cb9
80b96b4
0d9cd1c
 
 
 
 
 
 
 
80b96b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa63381
80b96b4
 
 
aa63381
80b96b4
 
 
aa63381
c2276d3
80b96b4
c2276d3
80b96b4
 
 
 
 
27c5cb9
80b96b4
27c5cb9
a733e54
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
import streamlit as st
import numpy as np
import base64
import scipy.io
import zipfile
import os
import io
from PIL import Image
import matplotlib
matplotlib.use('Agg') 
import matplotlib.pyplot as plt

# --- 1. ตั้งค่าหน้าเว็บ ---
st.set_page_config(layout="wide", page_title="K-Space to MRI")

st.markdown("""
    <style>
    html, body, .stApp {
        overflow-x: hidden !important;
    }
    .stApp {
        overflow-y: scroll !important;
    }
    img {
        max-width: 100% !important;
        height: auto !important;
    }
    
    html, body, [class*="st-"] {
        font-size: 18px;
    }
    .main-title {
        text-align: center;
        font-size: 45px !important;
        font-weight: bold;
        color: #1E88E5;
        margin-bottom: 20px;
    }
    h2 {
        color: #0D47A1;
        border-bottom: 2px solid #1E88E5;
        padding-bottom: 10px;
        margin-top: 40px;
    }
    h3 {
        color: #1565C0;
        margin-top: 20px;
    }
    div.stButton > button:first-child {
        background-color: #f0f2f6;
        color: #0D47A1;
        border-radius: 8px;
        border: 1px solid #1E88E5;
        font-weight: bold;
    }
    div.stButton > button:first-child:hover {
        background-color: #1E88E5;
        color: white;
    }
    /* Info box — force dark text in both light and dark mode */
    .kspace-info-box {
        text-align: center;
        background-color: #dbeafe !important;
        padding: 15px;
        border-radius: 10px;
        margin-bottom: 20px;
        color: #1e3a5f !important;
    }
    .kspace-info-box b, .kspace-info-box strong {
        color: #1e3a5f !important;
    }
    .reference-text {
        font-size: 14px;
        color: #666;
        text-align: center;
        margin-top: 5px;
    }
    </style>
    """, unsafe_allow_html=True)

# --- 2. ฟังก์ชันโหลดข้อมูลและการคำนวณ ---
@st.cache_data
def load_kspace_data():
    try:
        if os.path.exists('kspace.mat'):
            mat_data = scipy.io.loadmat('kspace.mat')
        elif os.path.exists('kspace.zip'):
            with zipfile.ZipFile("kspace.zip", 'r') as zip_ref:
                zip_ref.extractall("temp_kspace")
            mat_data = scipy.io.loadmat("temp_kspace/kspace.mat")
        else:
            return np.zeros((224, 224))
        return mat_data['kspace']
    except:
        return np.zeros((224, 224))

kspace_raw = load_kspace_data()

def format_kspace_display(k_data):
    k_mag = np.abs(k_data)
    max_val = np.max(k_mag)
    if max_val == 0:
        return np.zeros_like(k_mag, dtype=np.float32)
    
    c = 255.0 / np.log(1 + max_val)
    log_img = c * np.log(1 + k_mag)
    
    log_norm = (log_img - np.min(log_img)) / (np.max(log_img) - np.min(log_img) + 1e-8)
    log_boosted = np.power(log_norm, 0.3)
    return log_boosted

kspace_bg_image = format_kspace_display(kspace_raw)

def get_image_from_plot(fig):
    buf = io.BytesIO()
    plt.savefig(buf, format='png', dpi=100)
    plt.close(fig)
    return buf.getvalue()  # Return bytes — reliably cacheable by st.cache_data

def st_image(img_bytes, caption=None):
    """Display image as inline base64 data URI.
    Prevents layout jiggle: browser renders it instantly with no HTTP round-trip,
    so no 0-height placeholder → image-pop-in → scrollbar-flicker cycle."""
    b64 = base64.b64encode(img_bytes).decode('utf-8')
    cap_html = (f'<p style="text-align:center;font-size:14px;color:gray;margin-top:4px">' 
                + caption + '</p>') if caption else ''
    st.markdown(
        f'<div style="width:100%">' 
        f'<img src="data:image/png;base64,{b64}" style="width:100%;height:auto;display:block">' 
        f'{cap_html}</div>',
        unsafe_allow_html=True
    )

@st.cache_data
def draw_kspace_diagram():
    fig, ax = plt.subplots(figsize=(6, 6))
    
    # วาดกรอบนอก
    rect = plt.Rectangle((-1, -1), 2, 2, fill=False, edgecolor='black', lw=3)
    ax.add_patch(rect)
    
    # วาดแกนลูกศรตัดกันตรงกลาง (อยู่ภายในกรอบ)
    ax.annotate('', xy=(0.95, 0), xytext=(-0.95, 0), arrowprops=dict(arrowstyle='<|-|>', color='black', lw=2))
    ax.annotate('', xy=(0, 0.95), xytext=(0, -0.95), arrowprops=dict(arrowstyle='<|-|>', color='black', lw=2))
    
    # ตัวอักษรบอกทิศทาง +kx, -kx, +ky, -ky
    ax.text(1.1, 0, '+kx', fontsize=20, fontweight='bold', va='center')
    
    # ขยับ -kx ออกไปทางซ้ายให้มีช่องว่าง (ha='right' ทำให้ขอบขวาของตัวหนังสืออยู่ที่พิกัด -1.1 ซึ่งไม่ทับกรอบ)
    ax.text(-1.1, 0, '-kx', fontsize=20, fontweight='bold', va='center', ha='right') 
    
    ax.text(0, 1.1, '+ky', fontsize=20, fontweight='bold', ha='center')
    ax.text(0, -1.1, '-ky', fontsize=20, fontweight='bold', ha='center', va='top')
    
    # Label บอกชื่อแกน (เอาไว้ข้างนอกกรอบ)
    ax.annotate('', xy=(1, -1.4), xytext=(-1, -1.4), arrowprops=dict(arrowstyle='<|-|>', color='black', lw=4))
    ax.text(0, -1.5, 'kx (Frequency)', ha='center', va='top', fontsize=16, fontweight='bold')
    
    ax.annotate('', xy=(-1.5, 1), xytext=(-1.5, -1), arrowprops=dict(arrowstyle='<|-|>', color='black', lw=4))
    ax.text(-1.6, 0, 'ky (Phase)', ha='right', va='center', rotation=90, fontsize=16, fontweight='bold')
    
    ax.set_xlim(-2.0, 1.5)
    ax.set_ylim(-1.8, 1.5)
    ax.axis('off')
    
    # ล็อกระยะขอบให้ตายตัวป้องกันการกระตุก
    fig.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.1)
    return get_image_from_plot(fig)

@st.cache_data
def draw_kspace_point(kx, ky, bg_image):
    fig, ax = plt.subplots(figsize=(4, 4))
    ax.imshow(bg_image, cmap='gray', extent=[-112, 112, -112, 112], vmin=0, vmax=1)
    ax.plot(kx, ky, 'ro', markersize=6)
    ax.annotate('', xy=(kx, ky), xytext=(0, 0), arrowprops=dict(arrowstyle='->', color='yellow', lw=2))
    ax.axhline(0, color='white', linewidth=0.5, linestyle='--')
    ax.axvline(0, color='white', linewidth=0.5, linestyle='--')
    ax.set_xlim(-112, 112)
    ax.set_ylim(-112, 112)
    ax.axis('off')
    fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
    return get_image_from_plot(fig)

@st.cache_data
def draw_wave(kx, ky):
    fig, ax = plt.subplots(figsize=(4, 4))
    x = np.linspace(-112, 112, 224)
    y = np.linspace(112, -112, 224) 
    X, Y = np.meshgrid(x, y)
    freq_x = kx / 224.0
    freq_y = ky / 224.0
    wave = np.cos(2 * np.pi * (freq_x * X + freq_y * Y))
    ax.imshow(wave, cmap='gray', extent=[-112, 112, -112, 112])
    ax.axis('off')
    fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
    return get_image_from_plot(fig)

@st.cache_data
def apply_filter(k_data, mode, radius):
    Y, X = np.ogrid[:224, :224]
    dist = np.sqrt((X - 112)**2 + (Y - 112)**2)
    mask = np.ones((224, 224))
    if mode == "Low frequency":
        mask[dist > radius] = 0
    else:
        mask[dist < radius] = 0
    filtered_k = k_data * mask
    img = np.fft.fftshift(np.fft.ifft2(np.fft.ifftshift(filtered_k)))
    return filtered_k, np.abs(img)

@st.cache_data
def draw_filtered_kspace(filtered_k):
    fig, ax = plt.subplots(figsize=(4, 4))
    ax.imshow(format_kspace_display(filtered_k), cmap='gray', vmin=0, vmax=1)
    ax.axis('off')
    fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
    return get_image_from_plot(fig)

@st.cache_data
def draw_mri(mri_result):
    fig, ax = plt.subplots(figsize=(4, 4))
    if np.max(mri_result) > 0:
        vmin, vmax = np.percentile(mri_result, (1, 99.5))
    else:
        vmin, vmax = 0, 1
    
    ax.imshow(np.flipud(mri_result), cmap='gray', vmin=vmin, vmax=vmax)
    ax.axis('off')
    fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
    return get_image_from_plot(fig)

@st.cache_data
def draw_pulse_sequence(current_step, total_steps):
    fig, axes = plt.subplots(4, 1, figsize=(5, 6), sharex=True, gridspec_kw={'height_ratios': [1, 1.5, 1, 1]})
    t = np.linspace(0, 10, 1000)
    
    # 1. RF
    ax = axes[0]
    rf90 = np.sinc(t - 1.5) * (t > 0.5) * (t < 2.5)
    rf180 = np.sinc((t - 4.5)*2) * (t > 3.5) * (t < 5.5) * 1.5
    ax.plot(t, rf90 + rf180, color='black', lw=2)
    ax.text(1.5, 1.2, '90°', ha='center', fontsize=12, fontweight='bold')
    ax.text(4.5, 1.7, '180°', ha='center', fontsize=12, fontweight='bold')
    ax.text(0, 0.5, 'RF', fontsize=14, fontweight='bold', va='center', ha='right', transform=ax.get_yaxis_transform())
    ax.axis('off')
    
    # 2. Gy 
    ax = axes[1]
    gy_vals = np.linspace(1, -1, total_steps)
    for i, gy_amp in enumerate(gy_vals):
        gy = np.zeros_like(t)
        gy[(t > 2.5) & (t < 3.5)] = gy_amp
        if i == current_step:
            ax.plot(t, gy, color='red', lw=3, zorder=10)
        else:
            ax.plot(t, gy, color='black', lw=1, alpha=0.3)
    ax.text(0, 0, 'Gy', fontsize=14, fontweight='bold', va='center', ha='right', transform=ax.get_yaxis_transform())
    ax.axis('off')

    # 3. Gx
    ax = axes[2]
    gx = np.zeros_like(t)
    gx[(t > 2.5) & (t < 3.5)] = -0.5 
    gx[(t > 6) & (t < 9)] = 0.5      
    ax.plot(t, gx, color='black', lw=2)
    ax.fill_between(t, gx, 0, alpha=0.3, color='gray')
    ax.text(0, 0, 'Gx', fontsize=14, fontweight='bold', va='center', ha='right', transform=ax.get_yaxis_transform())
    ax.axis('off')
    
    # 4. Echo
    ax = axes[3]
    echo_env = np.exp(-((t - 7.5)**2) / 0.5) * (t > 6) * (t < 9)
    ax.plot(t, echo_env, color='black', lw=2)
    ax.fill_between(t, echo_env, 0, alpha=0.3, color='gray')
    ax.text(0, 0.5, 'Signal\n(Echo)', fontsize=14, fontweight='bold', va='center', ha='right', transform=ax.get_yaxis_transform())
    ax.axis('off')
    
    # ล็อกระยะกรอบตายตัวเพื่อหยุดอาการสั่น
    fig.subplots_adjust(left=0.2, right=0.95, top=0.95, bottom=0.05, hspace=0.2)
    return get_image_from_plot(fig)

@st.cache_data
def draw_kspace_filling(current_step, total_steps):
    fig, ax = plt.subplots(figsize=(5, 6))
    
    ax.set_facecolor('black')
    ax.axhline(0, color='gray', lw=1, ls='--')
    ax.axvline(0, color='gray', lw=1, ls='--')
    
    y_vals = np.linspace(90, -90, total_steps)
    
    for i in range(current_step + 1):
        y = y_vals[i]
        if i == current_step:
            ax.annotate('', xy=(100, y), xytext=(-100, y), arrowprops=dict(arrowstyle='->', color='red', lw=3))
        else:
            ax.annotate('', xy=(100, y), xytext=(-100, y), arrowprops=dict(arrowstyle='->', color='white', lw=1.5))
            
    for i in range(current_step):
        ax.plot([100, -100], [y_vals[i], y_vals[i+1]], color='gray', ls=':', lw=1)
        
    ax.set_xlim(-112, 112)
    ax.set_ylim(-112, 112)
    
    ax.set_title("K-Space Trajectory (Simulated)", fontweight='bold', color='white')
    fig.patch.set_facecolor('black')
    
    ax.axis('off')
    # ล็อกระยะกรอบตายตัวเพื่อหยุดอาการสั่น
    fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
    return get_image_from_plot(fig)

# ==========================================================
# --- 3. ส่วนแสดงผลเว็บ ---
# ==========================================================

_, col_main, _ = st.columns([1, 6, 1])

with col_main:
    
    st.markdown('<p class="main-title">K-space to MRI image</p>', unsafe_allow_html=True)

    st.markdown("""
    **K-space คืออะไร?** เมื่อเรานำผู้ป่วยเข้าเครื่อง MRI และส่งคลื่น RF เข้าไปกระตุ้น สัญญาณที่ได้มานั้นจะยังไม่ได้ออกมาเป็นภาพอวัยวะทันที แต่จะถูกนำไปเก็บรวบรวมไว้ในพื้นที่ที่เรียกว่า **"K-space"** ซึ่งเป็น **พื้นที่เก็บข้อมูลดิบแบบสองมิติ** ก่อนจะนำไปผ่านกระบวนการทางคณิตศาสตร์ที่เรียกว่า **Fourier Transform** เพื่อให้ได้มาซึ่งภาพ MRI
    
    ข้อมูลใน K-Space ถูกเก็บในรูปแบบ **ความถี่เชิงพื้นที่ (Spatial Frequency)** โดยพิกัดในตารางของ K-space ถูกสร้างขึ้นจากการทำงานของสนามแม่เหล็กเกรเดียนท์ 2 แกน ได้แก่ **Gx (ทำหน้าที่เข้ารหัสแนวความถี่)** และ **Gy (ทำหน้าที่เข้ารหัสแนวเฟส)** ซึ่งจะกำหนดว่าสัญญาณที่มีความถี่ต่างกันต้องถูกจัดเก็บไว้ตรงจุดไหนในพิกัด
    """)

    st.markdown("## 🧩 องค์ประกอบของ K-Space")
    st.markdown("""
    ข้อมูลใน k-space มักจะถูกนำมาแสดงผลในรูปแบบตารางสี่เหลี่ยม (Grid) โดยมีแกนหลักคือ **kx (แนวนอน - Frequency)** และ **ky (แนวตั้ง - Phase)** จุดสำคัญคือ **แกน kx และ ky เหล่านี้ ไม่ได้บอกตำแหน่งพิกัดในภาพอวัยวะ** แต่มันคือแกนที่บอกถึงลักษณะของ **"ความถี่เชิงพื้นที่"** ที่เป็นคลื่น (Sinusoidal wave) ด้วยเหตุนี้ **จุดแต่ละจุดบน K-space จึงไม่ได้จับคู่แบบ 1 ต่อ 1 กับพิกเซลบนภาพ MRI** (เช่น จุดมุมซ้ายบนของ K-space ไม่ได้สร้างภาพมุมซ้ายบนของอวัยวะ)
    """)

    _, col_img_center, _ = st.columns([2.5, 3, 2.5])
    with col_img_center:
        st_image(draw_kspace_diagram())

    # ---------------------------------------------------------
    # K-Space Trajectories
    # ---------------------------------------------------------
    st.markdown("## 🛤️ K-Space Trajectories")
    st.markdown("""
    **การบันทึกข้อมูลลงใน K-space สามารถทำได้หลายรูปแบบ** โดยจะยกตัวอย่างให้การเก็บข้อมูลจะดำเนินไปทีละบรรทัด โดยสัญญาณจะเกิดขึ้นหลังจากกระตุ้นด้วย RF Pulse โดยมีลำดับดังนี้

    1. กระบวนการเริ่มต้นขึ้นเมื่อสนามแม่เหล็กเกรเดียนท์ **Gx** และ **Gy** เริ่มทำงานพร้อมกันเพื่อสร้างพิกัดอ้างอิง
    2. ระบบจะเริ่มบันทึกข้อมูลจุดแรกของเฟสในแถวที่ 1 โดยจะเริ่มต้นที่ค่าแอมปลิจูดของ **Gy ทางฝั่งบวกก่อน (พิกัดด้านบนของ K-Space)**
    3. สัญญาณจะถูกบันทึกไล่ไปตามแกน **Gx (จากซ้าย -kx ไป ขวา +kx)** จนได้ข้อมูลครบถ้วนเต็ม 1 แถว
    4. เมื่อจบแถวแรก ระบบจะเริ่มกระบวนการใหม่เพื่อบันทึกข้อมูลในเฟสแถวที่ 2, 3, 4 ต่อไปเรื่อยๆ
    5. การวนรอบนี้จะดำเนินต่อไปพร้อมกับการเปลี่ยนค่า **Gy ให้ลดลงจนไปถึงค่าทางฝั่งลบ (พิกัดด้านล่าง)** เมื่อบันทึกครบทุกแถวตามจำนวนความละเอียดที่ตั้งไว้ จะถือว่าสิ้นสุดกระบวนการเก็บข้อมูล MRI แบบสองมิติลงบน K-space
    """)

    total_anim_steps = 15
    if 'fill_step' not in st.session_state:
        st.session_state.fill_step = 0

    def step_forward():
        if st.session_state.fill_step < total_anim_steps - 1:
            st.session_state.fill_step += 1
            
    def step_backward():
        if st.session_state.fill_step > 0:
            st.session_state.fill_step -= 1
            
    def run_all():
        st.session_state.fill_step = total_anim_steps - 1
        
    def reset_anim():
        st.session_state.fill_step = 0

    _, col_ctrl, _ = st.columns([1, 4, 1])
    with col_ctrl:
        st.write("ควบคุมการเติมบรรทัด (Gy):")
        c1, c2, c3, c4 = st.columns(4)
        c1.button("⏮ Reset", on_click=reset_anim)
        c2.button("◀ ก่อนหน้า", on_click=step_backward)
        c3.button("ถัดไป ▶", on_click=step_forward)
        c4.button("⏭ รันจนจบ", on_click=run_all)
        
    st.progress((st.session_state.fill_step + 1) / total_anim_steps)

    # ถอด st.empty ออกให้หมดเพื่อให้ Streamlit จัดการ Layout ปกติ ภาพจะไม่กระโดดยุบตัว
    col_anim1, col_anim2 = st.columns([1, 1])
    with col_anim1:
        st_image(draw_pulse_sequence(st.session_state.fill_step, total_anim_steps))
    with col_anim2:
        st_image(draw_kspace_filling(st.session_state.fill_step, total_anim_steps))

    st.markdown("""
    ---
    **📌 หมายเหตุ:** หากพูดถึงจำนวนครั้ง Phase Encoding ที่มากขึ้น หรือจำนวนบรรทัดการเก็บข้อมูลมากขึ้น ภาพก็จะมีความละเอียด (Resolution) ที่มากขึ้น เช่น เพิ่มขึ้นเป็น 128 หรือ 256 บรรทัด เป็นต้น
    """)

    st.markdown("---")
    st.markdown("## 📍 1 จุดบน k-space")

    # Center Text
    st.markdown("""
    <div class="kspace-info-box">
        <b>k-space 1 จุด = ข้อมูลของภาพทั้งภาพ และ ภาพ 1 พิกเซล = ผลรวมของ k-space ทุกจุด</b><br><br>
        <b>1 จุดใน k-space = แผ่นลวดลายคลื่น 1 แผ่น (2D Sinusoidal Wave)</b>
    </div>
    """, unsafe_allow_html=True)

    st.markdown("""
    1. **ตำแหน่งของจุด (พิกัด kx, ky) บอก "ความถี่" และ "ทิศทาง"**
       - **ระยะห่างจากศูนย์กลาง (ความถี่):** ยิ่งจุดนี้อยู่ไกลจากจุดศูนย์กลาง k-space มากเท่าไหร่ แผ่นลวดลายคลื่นก็จะยิ่ง **"ถี่"** (High frequency) ความยาวคลื่นจะสั้นลง มีความละเอียด เส้นจะห่างกันน้อยลง ทำให้ภาพมีดีเทลที่ชัดเจนขึ้น ในขณะที่ส่วนที่ใกล้จุดศูนย์กลาง คลื่นจะมีความถี่น้อย ความยาวคลื่นมาก มีลักษณะเป็นก้อนใหญ่ ๆ (สร้างรูปร่างโดยรวมของภาพ)
       - **มุมของจุด (ทิศทาง):** ตำแหน่งของจุดเมื่อเทียบกับจุดศูนย์กลาง จะเป็นตัวบอกว่าแผ่นลวดลายคลื่นนี้จะ **"เอียง"** ไปในทิศทางไหน
       - **จุดศูนย์กลางเป๊ะ (Origin):** จะเป็นคลื่นที่ไม่มีความถี่ ค่าความถี่ของคลื่นเป็นศูนย์ ดังนั้นตรงกลางจะไม่เห็นลักษณะรูปคลื่น
    2. **ความสว่างของจุด (Amplitude / Magnitude) บอก "ปริมาณ/น้ำหนัก (Weight)"**
       - ความสว่างของจุดไม่ได้แปลว่าภาพ MRI ตรงนั้นจะสว่าง แต่มันบอกถึง **"ปริมาณ/น้ำหนัก (Weight)"**
       - **จุดสว่างมาก:** ภาพ MRI มีแผ่นลวดลายชนิดนี้เป็นส่วนประกอบอยู่ **เยอะมาก (มีความสำคัญต่อภาพสูง)**
       - **จุดมืดหรือจาง:** ภาพ MRI แทบจะไม่มีลวดลายชนิดนี้ประกอบอยู่เลย
    """)

    st.markdown("### 🎛️ ลองปรับตำแหน่งของจุด K-Space เพื่อดูคลื่นความถี่")
    
    _, col_slide1, col_slide2, _ = st.columns([0.5, 2.5, 2.5, 0.5])
    with col_slide1:
        kx_val = st.slider("พิกัด kx (แนวนอน)", -112, 111, 15)
    with col_slide2:
        ky_val = st.slider("พิกัด ky (แนวตั้ง)", -112, 111, 20)

    _, col_img1, col_img2, _ = st.columns([0.5, 2.5, 2.5, 0.5])
    with col_img1:
        st_image(draw_kspace_point(kx_val, ky_val, kspace_bg_image), caption="พิกัด K-Space (มีเส้นบอกทิศทาง)")
    with col_img2:
        st_image(draw_wave(kx_val, ky_val), caption="แผ่นลวดลายคลื่น (2D Sinusoidal Wave)")

    with st.expander("🔍 สังเกตลักษณะคลื่น (คลิกเพื่อดูคำอธิบายเพิ่มเติม)"):
        st.markdown("""
        **ลองปรับเลื่อนพิกัดเพื่อสังเกตความเปลี่ยนแปลง:**
        - **ยิ่งจุดอยู่ใกล้ศูนย์กลาง:** คลื่นจะมีความถี่น้อย ความยาวคลื่นมาก มีลักษณะเป็นก้อนใหญ่ ๆ (แสดงถึงรูปร่างโดยรวม)
        - **ยิ่งจุดอยู่ไกลศูนย์กลาง:** คลื่นจะมีความถี่สูง ความยาวคลื่นสั้นลง เส้นห่างกันน้อยลง มีความละเอียดสูง (แสดงส่วนที่เป็นดีเทลหรือเส้นขอบ)
        - **เมื่อจุดอยู่ตรงกลางเป๊ะ (kx=0, ky=0):** จะไม่มีรูปคลื่นปรากฏให้เห็นเลย เพราะค่าความถี่ของคลื่นเป็นศูนย์
        """)

    st.markdown("## 🔄 Inverse Fourier Transform")
    st.markdown("""
    เมื่อเก็บข้อมูลจนเต็มพื้นที่ k-space เราจะใช้กระบวนการทางคณิตศาสตร์ **2D Inverse Fourier Transform (2D-iFT)** เปลี่ยนข้อมูลความถี่กลับไปเป็นข้อมูลภาพ (Spatial Domain)
    
    ภาพ MRI เกิดจากการนำ **คลื่นความถี่จากทุกจุดใน k-space มาซ้อนทับกัน**
    - บริเวณไหนที่เป็นเนื้อเยื่อจริง คลื่นที่มีเฟสตรงกันจะรวมตัวกันแบบเสริมฤทธิ์ ทำให้เกิด **จุดสว่าง**
    - บริเวณไหนที่เป็นช่องว่าง คลื่นที่มีเฟสตรงข้ามจะหักล้างกัน ทำให้เกิด **จุดมืด**
    """)

    st.markdown("## 📊 ความถี่เชิงพื้นที่ (Spatial Frequency) คือ")
    st.markdown("""
    ในทางสัญญาณภาพ ความถี่ไม่ได้หมายถึงความเร็วของเวลา แต่หมายถึง **"อัตราการเปลี่ยนแปลงความเข้มของแสงในพื้นที่หนึ่งๆ"**

    1. **ความถี่เชิงพื้นที่ต่ำ (Low Spatial Frequency)**
       - **คืออะไร:** พื้นที่ที่ความสว่าง **ค่อยๆ เปลี่ยน** หรือ **เหมือนเดิมเป็นบริเวณกว้าง**
       - **ตัวอย่างในภาพ MRI:** บริเวณเนื้อเยื่อก้อนใหญ่ๆ เช่น เนื้อตับ หรือเนื้อสมอง ที่มีสีโทนเดียวกันกินพื้นที่กว้าง
       - **ตำแหน่งใน k-space:** ข้อมูลเหล่านี้จะรวมตัวกันอยู่บริเวณ **"ตรงกลาง"**
       - **หน้าที่หลัก:** สร้าง **"รูปร่างรวมๆ และคอนทราสต์ (Contrast)"** ให้เรารู้ว่านี่คือก้อนอวัยวะอะไร

    2. **ความถี่เชิงพื้นที่สูง (High Spatial Frequency)**
       - **คืออะไร:** พื้นที่ที่ความสว่าง **เปลี่ยนแบบฉับพลันและรวดเร็ว** ภายในระยะทางสั้นๆ
       - **ตัวอย่างในภาพ MRI:** ขอบของอวัยวะ (Edges) หรือรายละเอียดเส้นเลือดเส้นเล็กๆ
       - **ตำแหน่ง in k-space:** ข้อมูลเหล่านี้จะกระจายตัวอยู่บริเวณ **"ขอบนอก"**
       - **หน้าที่หลัก:** สร้าง **"ความคมชัด (Resolution) และรายละเอียดเล็กๆ"** ทำให้ภาพไม่เบลอ
    """)

    st.markdown("### 🎛️ ลองเลือกช่วงความถี่ (Interactive)")
    
    _, col_radio_center, _ = st.columns([2, 3, 2])
    with col_radio_center:
        mode = st.radio("เลือกช่วงความถี่:", ["Low frequency", "High frequency"], horizontal=True)

    _, col_fslide, _ = st.columns([1.5, 5, 1.5])
    with col_fslide:
        if mode == "Low frequency":
            radius = st.slider("ปรับระดับความถี่ที่ยอมให้ผ่าน (รัศมีจากตรงกลาง)", 1, 160, 160)
        else:
            radius = st.slider("ปรับระดับการตัดข้อมูลส่วนกลาง (รัศมีจากตรงกลาง)", 0, 160, 0)

    filtered_k, mri_result = apply_filter(kspace_raw, mode, radius)

    _, col_fimg1, col_fimg2, _ = st.columns([0.5, 2.5, 2.5, 0.5])
    with col_fimg1:
        st_image(draw_filtered_kspace(filtered_k), caption="ภาพ K-Space ที่ถูกเลือก")
    with col_fimg2:
        st_image(draw_mri(mri_result), caption="ภาพ MRI ผลลัพธ์")

    # Spacer: keeps page taller than viewport at all zoom levels so the
    # scrollbar never disappears and never causes a layout-shift jiggle.
    st.markdown('<div style="height: 300px;"></div>', unsafe_allow_html=True)