Simmonstt commited on
Commit
c86581e
·
verified ·
1 Parent(s): b5b2ddc

Add attention visualization to Gradio demo with multi-modal display

Browse files
Files changed (2) hide show
  1. app.py +330 -98
  2. brain_attention_viz.py +556 -0
app.py CHANGED
@@ -4,6 +4,7 @@ BrainAnytime Hugging Face Space Demo
4
 
5
  Interactive demo for brain image analysis with multi-modal support.
6
  Supports 4 tasks and 5 modality combinations.
 
7
  """
8
 
9
  import os
@@ -12,6 +13,7 @@ import json
12
  from pathlib import Path
13
  from typing import Dict, List, Optional, Tuple
14
  import warnings
 
15
  warnings.filterwarnings("ignore")
16
 
17
  # 添加当前目录到路径(用于导入 inference_engine)
@@ -32,10 +34,23 @@ except Exception as e:
32
  print(f"Warning: Inference engine not available: {e}")
33
  INFERENCE_AVAILABLE = False
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # 延迟导入 Gradio(避免启动时的循环导入问题)
36
  try:
37
  import gradio as gr
38
  import numpy as np
 
39
  from PIL import Image
40
  GRADIO_AVAILABLE = True
41
  except Exception as e:
@@ -105,6 +120,9 @@ TASK_CONFIG = {
105
  SAMPLES_DIR = BASE_DIR / "demo_samples"
106
  SAMPLES_INDEX = SAMPLES_DIR / "samples.json"
107
 
 
 
 
108
 
109
  # =============================================================================
110
  # 全局状态
@@ -112,6 +130,7 @@ SAMPLES_INDEX = SAMPLES_DIR / "samples.json"
112
 
113
  _inference_engine: Optional[BrainAnytimeInference] = None
114
  _samples_cache: Optional[Dict] = None
 
115
 
116
 
117
  def get_inference_engine() -> Optional[BrainAnytimeInference]:
@@ -172,78 +191,104 @@ def get_sample_info(task: str, combo: str) -> Optional[Dict]:
172
  return None
173
 
174
 
175
- def get_preview_images(sample_info: Dict) -> List[Tuple[str, str]]:
176
- """获取样本的预览图路径和标题"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  if not sample_info:
178
- return []
179
 
180
- images = []
181
  sample_dir = SAMPLES_DIR / sample_info["task"] / sample_info["modality_combination"] / sample_info["sample_name"]
182
 
183
- for mod in MODALITY_ORDER:
 
184
  preview_file = sample_info.get("files", {}).get("previews", {}).get(mod)
185
  if preview_file:
186
  img_path = sample_dir / preview_file
187
  if img_path.exists():
188
- images.append((str(img_path), f"{mod} - Axial Slice"))
189
 
190
- return images
191
 
192
 
193
  # =============================================================================
194
- # Gradio 回调函数
195
  # =============================================================================
196
 
197
- def update_sample_gallery(task: str, combo: str):
198
- """更新样本画廊"""
199
- sample_info = get_sample_info(task, combo)
200
-
201
- if not sample_info:
202
- return None, "Sample not found"
203
-
204
- # 获取预览图
205
- images = get_preview_images(sample_info)
206
-
207
- if not images:
208
- return None, "No preview images available"
209
-
210
- # 返回第一张图作为代表
211
- return images[0][0], f"Sample: {sample_info['subject_id']} | Label: {sample_info.get('diag_group', 'N/A')}"
212
-
213
-
214
  @spaces.GPU
215
- def run_inference(task: str, combo: str):
216
- """执行推理 (GPU accelerated)"""
 
 
 
 
 
 
 
217
  engine = get_inference_engine()
218
 
219
  if not engine:
220
- return {
221
- "error": "Inference engine not available. Please check model checkpoints."
222
- }
223
 
224
  # 获取样本信息
225
  sample_info = get_sample_info(task, combo)
226
  if not sample_info:
227
- return {"error": f"No sample found for {task}/{combo}"}
228
 
229
- # 构建��本目录路径
230
  sample_dir = SAMPLES_DIR / task / combo / sample_info["sample_name"]
231
 
232
- # 执行推理
233
  try:
 
234
  result = engine.predict_from_sample(str(sample_dir), task, combo)
235
 
236
  if result is None:
237
- return {"error": "Inference failed"}
238
 
239
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
  except Exception as e:
242
- return {"error": f"Inference error: {str(e)}"}
243
 
244
 
245
- def format_prediction(result: Dict) -> str:
246
- """格式化预测结果为 Markdown"""
247
  if "error" in result:
248
  return f"❌ **Error**: {result['error']}"
249
 
@@ -251,7 +296,7 @@ def format_prediction(result: Dict) -> str:
251
  task_type = result.get("task_type", "unknown")
252
 
253
  lines = [
254
- f"## Prediction Result: {TASK_CONFIG.get(task, {}).get('name', task)}",
255
  "",
256
  ]
257
 
@@ -261,6 +306,14 @@ def format_prediction(result: Dict) -> str:
261
  conf = result.get("confidence", 0)
262
  classes = result.get("classes", [])
263
 
 
 
 
 
 
 
 
 
264
  lines.extend([
265
  f"**Predicted Class**: {pred}",
266
  "",
@@ -268,7 +321,7 @@ def format_prediction(result: Dict) -> str:
268
  f"- {classes[0]}: {1-prob:.3f}",
269
  f"- {classes[1]}: {prob:.3f}",
270
  "",
271
- f"**Confidence**: {conf:.1%}",
272
  ])
273
  else:
274
  pred = result.get("prediction", 0)
@@ -289,6 +342,153 @@ def format_prediction(result: Dict) -> str:
289
  return "\n".join(lines)
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  # =============================================================================
293
  # 创建 Gradio 界面
294
  # =============================================================================
@@ -302,8 +502,9 @@ def create_demo():
302
  with gr.Blocks(
303
  title="BrainAnytime Demo",
304
  css="""
305
- .preview-image { max-height: 300px; }
306
  .result-box { font-size: 16px; }
 
307
  """
308
  ) as demo:
309
 
@@ -313,7 +514,7 @@ def create_demo():
313
  **BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis**
314
 
315
  This demo showcases the BrainAnytime model for multi-modal 3D brain image analysis.
316
- The model supports arbitrary missing modality combinations at inference time.
317
 
318
  ---
319
  """)
@@ -362,13 +563,38 @@ def create_demo():
362
 
363
  # 右侧:结果展示
364
  with gr.Column(scale=2):
365
- gr.Markdown("### Sample Preview")
366
 
367
- sample_preview = gr.Image(
368
- label="Brain MRI Preview (Axial Slice)",
369
- type="filepath",
370
- height=300,
371
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  sample_info = gr.Textbox(
374
  label="Sample Info",
@@ -391,6 +617,48 @@ def create_demo():
391
  mods = combo_info.get("modalities", [])
392
  return f"**{combo}**: {', '.join(mods)}"
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  task_selector.change(
395
  update_task_desc,
396
  inputs=task_selector,
@@ -403,38 +671,24 @@ def create_demo():
403
  outputs=modality_desc
404
  )
405
 
406
- # 更新样本预览
407
- def on_config_change(task, combo):
408
- return update_sample_gallery(task, combo)
409
-
410
- task_selector.change(
411
- on_config_change,
412
- inputs=[task_selector, modality_selector],
413
- outputs=[sample_preview, sample_info]
414
- )
415
-
416
  modality_selector.change(
417
- on_config_change,
418
- inputs=[task_selector, modality_selector],
419
- outputs=[sample_preview, sample_info]
420
  )
421
 
422
- # 运行推理
423
- def on_run_inference(task, combo):
424
- result = run_inference(task, combo)
425
- return format_prediction(result)
426
-
427
  run_btn.click(
428
  on_run_inference,
429
  inputs=[task_selector, modality_selector],
430
- outputs=result_display
431
- )
432
-
433
- # 初始化
434
- demo.load(
435
- on_config_change,
436
- inputs=[task_selector, modality_selector],
437
- outputs=[sample_preview, sample_info]
438
  )
439
 
440
  # ==================== Tab 2: 项目信息 ====================
@@ -450,9 +704,9 @@ def create_demo():
450
  ### Key Features
451
 
452
  - **Multi-modal Support**: T1, T2, Flair, PET
453
- - **Missing Modality Robustness**: Handles arbitrary missing combinations
454
  - **Anatomy-Aware**: Uses AAL116 brain atlas for adaptive masking
455
- - **Pretrained Model**: Self-supervised pretraining on large-scale datasets
456
 
457
  ### Supported Tasks
458
 
@@ -482,28 +736,6 @@ def create_demo():
482
  ```
483
  """)
484
 
485
- # ==================== Tab 3: 样本库 ====================
486
- with gr.Tab("🗂️ 样本库 (Sample Gallery)"):
487
- gr.Markdown("""
488
- Browse all 20 pre-selected samples (5 modality combinations × 4 tasks).
489
- Each sample includes brain MRI preview images.
490
- """)
491
-
492
- # 为每个任务创建样本展示
493
- for task_key, task_info in TASK_CONFIG.items():
494
- with gr.Accordion(f"{task_info['name']}", open=False):
495
- for combo_key, combo_info in MODALITY_COMBOS.items():
496
- sample = get_sample_info(task_key, combo_key)
497
- if sample:
498
- with gr.Row():
499
- images = get_preview_images(sample)
500
- for img_path, title in images[:2]: # 最多显示2张
501
- gr.Image(
502
- value=img_path,
503
- label=f"{combo_key} - {title}",
504
- height=200,
505
- )
506
-
507
  gr.Markdown("""
508
  ---
509
 
 
4
 
5
  Interactive demo for brain image analysis with multi-modal support.
6
  Supports 4 tasks and 5 modality combinations.
7
+ Now with attention visualization!
8
  """
9
 
10
  import os
 
13
  from pathlib import Path
14
  from typing import Dict, List, Optional, Tuple
15
  import warnings
16
+ import tempfile
17
  warnings.filterwarnings("ignore")
18
 
19
  # 添加当前目录到路径(用于导入 inference_engine)
 
34
  print(f"Warning: Inference engine not available: {e}")
35
  INFERENCE_AVAILABLE = False
36
 
37
+ # 导入注意力可视化
38
+ try:
39
+ from brain_attention_viz import (
40
+ visualize_sample_attention_precise,
41
+ build_voxel_attention_map,
42
+ extract_last_layer_attention,
43
+ )
44
+ ATTENTION_AVAILABLE = True
45
+ except Exception as e:
46
+ print(f"Warning: Attention visualization not available: {e}")
47
+ ATTENTION_AVAILABLE = False
48
+
49
  # 延迟导入 Gradio(避免启动时的循环导入问题)
50
  try:
51
  import gradio as gr
52
  import numpy as np
53
+ import nibabel as nib
54
  from PIL import Image
55
  GRADIO_AVAILABLE = True
56
  except Exception as e:
 
120
  SAMPLES_DIR = BASE_DIR / "demo_samples"
121
  SAMPLES_INDEX = SAMPLES_DIR / "samples.json"
122
 
123
+ # Atlas 路径
124
+ ATLAS_PATH = BASE_DIR / "BrainAnytime" / "altas" / "AAL116_standard.nii.gz"
125
+
126
 
127
  # =============================================================================
128
  # 全局状态
 
130
 
131
  _inference_engine: Optional[BrainAnytimeInference] = None
132
  _samples_cache: Optional[Dict] = None
133
+ _atlas_cache: Optional[np.ndarray] = None
134
 
135
 
136
  def get_inference_engine() -> Optional[BrainAnytimeInference]:
 
191
  return None
192
 
193
 
194
+ def load_atlas_mask() -> Optional[np.ndarray]:
195
+ """加载 AAL116 atlas 作为 brain mask"""
196
+ global _atlas_cache
197
+
198
+ if _atlas_cache is not None:
199
+ return _atlas_cache
200
+
201
+ if not ATLAS_PATH.exists():
202
+ print(f"Warning: Atlas not found at {ATLAS_PATH}")
203
+ return None
204
+
205
+ try:
206
+ atlas_nii = nib.load(str(ATLAS_PATH))
207
+ atlas_data = atlas_nii.get_fdata().astype(np.int32)
208
+ _atlas_cache = (atlas_data > 0).astype(np.float32)
209
+ return _atlas_cache
210
+ except Exception as e:
211
+ print(f"Error loading atlas: {e}")
212
+ return None
213
+
214
+
215
+ def get_sample_preview_paths(sample_info: Dict) -> Dict[str, str]:
216
+ """获取样本所有模态的预览图路径"""
217
  if not sample_info:
218
+ return {}
219
 
 
220
  sample_dir = SAMPLES_DIR / sample_info["task"] / sample_info["modality_combination"] / sample_info["sample_name"]
221
 
222
+ preview_paths = {}
223
+ for mod in sample_info.get("modalities", []):
224
  preview_file = sample_info.get("files", {}).get("previews", {}).get(mod)
225
  if preview_file:
226
  img_path = sample_dir / preview_file
227
  if img_path.exists():
228
+ preview_paths[mod] = str(img_path)
229
 
230
+ return preview_paths
231
 
232
 
233
  # =============================================================================
234
+ # 推理 + 注意力可视化
235
  # =============================================================================
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  @spaces.GPU
238
+ def run_inference_with_attention(task: str, combo: str):
239
+ """
240
+ 执行推理并生成注意力可视化
241
+
242
+ Returns:
243
+ (prediction_markdown, modality_images_dict)
244
+ - prediction_markdown: 预测结果的 markdown 文本
245
+ - modality_images_dict: {模态名: 注意力可视化图像路径}
246
+ """
247
  engine = get_inference_engine()
248
 
249
  if not engine:
250
+ return "❌ **Error**: Inference engine not available", {}
 
 
251
 
252
  # 获取样本信息
253
  sample_info = get_sample_info(task, combo)
254
  if not sample_info:
255
+ return f"❌ **Error**: No sample found for {task}/{combo}", {}
256
 
 
257
  sample_dir = SAMPLES_DIR / task / combo / sample_info["sample_name"]
258
 
 
259
  try:
260
+ # 执行推理
261
  result = engine.predict_from_sample(str(sample_dir), task, combo)
262
 
263
  if result is None:
264
+ return "❌ **Error**: Inference failed", {}
265
 
266
+ # 格式化预测结果
267
+ prediction_md = format_prediction_with_confidence(result)
268
+
269
+ # 生成注意力可视化(如果可用)
270
+ attention_images = {}
271
+ if ATTENTION_AVAILABLE and engine:
272
+ try:
273
+ attention_images = generate_attention_visualization(
274
+ engine, sample_dir, sample_info, task, combo
275
+ )
276
+ except Exception as e:
277
+ print(f"Attention visualization failed: {e}")
278
+ # 如果注意力可视化失败,返回原始预览图
279
+ attention_images = get_sample_preview_paths(sample_info)
280
+ else:
281
+ # 注意力不可用,返回原始预览图
282
+ attention_images = get_sample_preview_paths(sample_info)
283
+
284
+ return prediction_md, attention_images
285
 
286
  except Exception as e:
287
+ return f" **Error**: {str(e)}", {}
288
 
289
 
290
+ def format_prediction_with_confidence(result: Dict) -> str:
291
+ """格式化预测结果为 Markdown(带置信度颜色)"""
292
  if "error" in result:
293
  return f"❌ **Error**: {result['error']}"
294
 
 
296
  task_type = result.get("task_type", "unknown")
297
 
298
  lines = [
299
+ f"## 🎯 Prediction Result: {TASK_CONFIG.get(task, {}).get('name', task)}",
300
  "",
301
  ]
302
 
 
306
  conf = result.get("confidence", 0)
307
  classes = result.get("classes", [])
308
 
309
+ # 置信度颜色
310
+ if conf >= 0.7:
311
+ conf_color = "🟢"
312
+ elif conf >= 0.5:
313
+ conf_color = "🟡"
314
+ else:
315
+ conf_color = "🔴"
316
+
317
  lines.extend([
318
  f"**Predicted Class**: {pred}",
319
  "",
 
321
  f"- {classes[0]}: {1-prob:.3f}",
322
  f"- {classes[1]}: {prob:.3f}",
323
  "",
324
+ f"**Confidence**: {conf_color} {conf:.1%}",
325
  ])
326
  else:
327
  pred = result.get("prediction", 0)
 
342
  return "\n".join(lines)
343
 
344
 
345
+ def generate_attention_visualization(
346
+ engine: BrainAnytimeInference,
347
+ sample_dir: Path,
348
+ sample_info: Dict,
349
+ task: str,
350
+ combo: str
351
+ ) -> Dict[str, str]:
352
+ """
353
+ 生成注意力可视化图像
354
+
355
+ Returns:
356
+ {模态名: 可视化图像路径}
357
+ """
358
+ from scipy.ndimage import gaussian_filter
359
+ import matplotlib
360
+ matplotlib.use('Agg')
361
+ import matplotlib.pyplot as plt
362
+ from matplotlib import cm
363
+
364
+ # 加载模型
365
+ model = engine.load_model(task)
366
+
367
+ # 加载脑影像
368
+ brain_volumes = {}
369
+ for mod in sample_info.get("modalities", []):
370
+ nii_filename = sample_info.get("files", {}).get("nifti", {}).get(mod)
371
+ if nii_filename:
372
+ nii_path = sample_dir / nii_filename
373
+ if nii_path.exists():
374
+ nii = nib.load(str(nii_path))
375
+ data = nii.get_fdata().astype(np.float32)
376
+ # Min-Max 归一化
377
+ data_min, data_max = data.min(), data.max()
378
+ if data_max > data_min:
379
+ data = (data - data_min) / (data_max - data_min)
380
+ brain_volumes[mod] = data
381
+
382
+ # 准备模型输入
383
+ images_list = []
384
+ for mod in MODALITY_ORDER:
385
+ if mod in brain_volumes:
386
+ images_list.append(brain_volumes[mod])
387
+ else:
388
+ images_list.append(np.zeros((128, 128, 128), dtype=np.float32))
389
+
390
+ images = np.stack(images_list, axis=0)
391
+ images = torch.from_numpy(images).unsqueeze(0).to(engine.device)
392
+
393
+ # observed mask
394
+ observed = torch.zeros(1, 4)
395
+ for i, mod in enumerate(MODALITY_ORDER):
396
+ if mod in sample_info.get("modalities", []):
397
+ observed[0, i] = 1.0
398
+
399
+ # 提取注意力
400
+ attention = extract_last_layer_attention(model.encoder, images, observed)
401
+
402
+ # 处理注意力
403
+ attn_batch = attention[0].mean(dim=0) # [N, N]
404
+ num_global = model.encoder.num_global_tokens
405
+ cls_to_all = attn_batch[0, num_global:] # 跳过 CLS
406
+
407
+ total_patches = cls_to_all.shape[0]
408
+ N_p_actual = total_patches // 4
409
+
410
+ # 加载 atlas mask
411
+ brain_mask = load_atlas_mask()
412
+
413
+ # 为每个模态生成可视化
414
+ attention_images = {}
415
+ mid_slice = 70
416
+
417
+ for mod in sample_info.get("modalities", []):
418
+ mod_idx = MODALITY_ORDER.index(mod)
419
+ start_idx = mod_idx * N_p_actual
420
+ end_idx = (mod_idx + 1) * N_p_actual
421
+ mod_attn = cls_to_all[start_idx:end_idx].cpu().numpy()
422
+
423
+ # 构建注意力图
424
+ atlas_data = np.ones((128, 128, 128), dtype=np.int32)
425
+ attention_map = build_voxel_attention_map(
426
+ mod_attn, atlas_data, (128, 128, 128), (16, 16, 16)
427
+ )
428
+ attention_map = gaussian_filter(attention_map, sigma=2.0)
429
+
430
+ brain_vol = brain_volumes[mod]
431
+
432
+ # 获取 mask 切片
433
+ if brain_mask is not None:
434
+ mask_slice = brain_mask[:, :, mid_slice]
435
+ else:
436
+ mask_slice = (brain_vol[:, :, mid_slice] > 0.01).astype(np.float32)
437
+
438
+ # 计算相对注意力
439
+ masked_attn = attention_map[brain_mask > 0] if brain_mask is not None else attention_map.flatten()
440
+ if len(masked_attn) > 0:
441
+ mean_attn = masked_attn.mean()
442
+ else:
443
+ mean_attn = attention_map.mean()
444
+
445
+ attn_slice = attention_map[:, :, mid_slice]
446
+ brain_slice = brain_vol[:, :, mid_slice]
447
+ attn_relative = attn_slice - mean_attn
448
+ max_abs = np.abs(attn_relative).max()
449
+ if max_abs > 0:
450
+ attn_norm = (attn_relative / max_abs) * 0.5 + 0.5
451
+ else:
452
+ attn_norm = np.ones_like(attn_relative) * 0.5
453
+
454
+ mask_bool = mask_slice > 0
455
+ attn_norm = np.where(mask_bool, attn_norm, 0.5)
456
+
457
+ # 生成图像(黑背景线上版本)
458
+ fig, ax = plt.subplots(1, 1, figsize=(6, 6), facecolor='black')
459
+ ax.set_facecolor('black')
460
+
461
+ # 应用 RdBu_r colormap
462
+ cmap_obj = cm.get_cmap('RdBu_r')
463
+ attn_colored = cmap_obj(attn_norm)
464
+ attn_colored = (attn_colored[:, :, :3] * 255).astype(np.uint8)
465
+
466
+ # 黑背景
467
+ black_bg = np.zeros_like(brain_slice)
468
+ display_img = np.stack([black_bg, black_bg, black_bg], axis=-1)
469
+
470
+ brain_norm = (brain_slice * 255).astype(np.uint8)
471
+ alpha = 0.6
472
+ for c in range(3):
473
+ blended = (1 - alpha * 0.5) * brain_norm + alpha * 1.5 * attn_colored[:, :, c]
474
+ blended = np.clip(blended, 0, 255)
475
+ display_img[:, :, c] = np.where(mask_bool, blended, display_img[:, :, c])
476
+
477
+ ax.imshow(display_img, origin='lower')
478
+ ax.set_title(f'{mod} Attention', fontsize=14, color='white')
479
+ ax.axis('off')
480
+
481
+ # 保存到临时文件
482
+ temp_file = tempfile.NamedTemporaryFile(suffix=f'_{mod}_attention.png', delete=False)
483
+ plt.savefig(temp_file.name, dpi=100, bbox_inches='tight',
484
+ facecolor='black', edgecolor='none')
485
+ plt.close()
486
+
487
+ attention_images[mod] = temp_file.name
488
+
489
+ return attention_images
490
+
491
+
492
  # =============================================================================
493
  # 创建 Gradio 界面
494
  # =============================================================================
 
502
  with gr.Blocks(
503
  title="BrainAnytime Demo",
504
  css="""
505
+ .preview-image { max-height: 250px; }
506
  .result-box { font-size: 16px; }
507
+ .modality-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
508
  """
509
  ) as demo:
510
 
 
514
  **BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis**
515
 
516
  This demo showcases the BrainAnytime model for multi-modal 3D brain image analysis.
517
+ Select a task and modality combination, then run inference to see predictions and attention visualization.
518
 
519
  ---
520
  """)
 
563
 
564
  # 右侧:结果展示
565
  with gr.Column(scale=2):
566
+ gr.Markdown("### Sample Preview & Attention Visualization")
567
 
568
+ # 动态创建图像组件 - 根据选择的模态组合显示对应数量的图像
569
+ with gr.Row():
570
+ # T1 图像(总是显示,因为所有组合都有 T1)
571
+ t1_preview = gr.Image(
572
+ label="T1",
573
+ type="filepath",
574
+ height=250,
575
+ visible=True
576
+ )
577
+ # T2 图像
578
+ t2_preview = gr.Image(
579
+ label="T2",
580
+ type="filepath",
581
+ height=250,
582
+ visible=False
583
+ )
584
+ # Flair 图像
585
+ flair_preview = gr.Image(
586
+ label="Flair",
587
+ type="filepath",
588
+ height=250,
589
+ visible=False
590
+ )
591
+ # PET 图像
592
+ pet_preview = gr.Image(
593
+ label="PET",
594
+ type="filepath",
595
+ height=250,
596
+ visible=False
597
+ )
598
 
599
  sample_info = gr.Textbox(
600
  label="Sample Info",
 
617
  mods = combo_info.get("modalities", [])
618
  return f"**{combo}**: {', '.join(mods)}"
619
 
620
+ def update_preview_visibility(combo):
621
+ """根据选择的模态组合,更新各图像组件的可见性"""
622
+ combo_info = MODALITY_COMBOS.get(combo, {})
623
+ mods = combo_info.get("modalities", [])
624
+
625
+ # 返回各组件的可见性
626
+ return {
627
+ t1_preview: gr.update(visible="T1" in mods),
628
+ t2_preview: gr.update(visible="T2" in mods),
629
+ flair_preview: gr.update(visible="Flair" in mods),
630
+ pet_preview: gr.update(visible="PET" in mods),
631
+ }
632
+
633
+ def on_run_inference(task, combo):
634
+ """运行推理并更新所有输出"""
635
+ pred_md, attention_images = run_inference_with_attention(task, combo)
636
+
637
+ # 获取样本信息
638
+ sample_info_data = get_sample_info(task, combo)
639
+ info_text = f"Sample: {sample_info_data.get('subject_id', 'N/A')} | Label: {sample_info_data.get('diag_group', 'N/A')}"
640
+
641
+ # 构建输出字典
642
+ outputs = {
643
+ result_display: pred_md,
644
+ sample_info: info_text,
645
+ }
646
+
647
+ # 更新各模态图像
648
+ combo_info = MODALITY_COMBOS.get(combo, {})
649
+ mods = combo_info.get("modalities", [])
650
+
651
+ if "T1" in mods:
652
+ outputs[t1_preview] = attention_images.get("T1", None)
653
+ if "T2" in mods:
654
+ outputs[t2_preview] = attention_images.get("T2", None)
655
+ if "Flair" in mods:
656
+ outputs[flair_preview] = attention_images.get("Flair", None)
657
+ if "PET" in mods:
658
+ outputs[pet_preview] = attention_images.get("PET", None)
659
+
660
+ return outputs
661
+
662
  task_selector.change(
663
  update_task_desc,
664
  inputs=task_selector,
 
671
  outputs=modality_desc
672
  )
673
 
 
 
 
 
 
 
 
 
 
 
674
  modality_selector.change(
675
+ update_preview_visibility,
676
+ inputs=modality_selector,
677
+ outputs=[t1_preview, t2_preview, flair_preview, pet_preview]
678
  )
679
 
680
+ # 运行推理 - 更新所有输出
 
 
 
 
681
  run_btn.click(
682
  on_run_inference,
683
  inputs=[task_selector, modality_selector],
684
+ outputs=[
685
+ result_display,
686
+ sample_info,
687
+ t1_preview,
688
+ t2_preview,
689
+ flair_preview,
690
+ pet_preview
691
+ ]
692
  )
693
 
694
  # ==================== Tab 2: 项目信息 ====================
 
704
  ### Key Features
705
 
706
  - **Multi-modal Support**: T1, T2, Flair, PET
707
+ - **Missing Modality Robustness**: Handles arbitrary missing modality combinations
708
  - **Anatomy-Aware**: Uses AAL116 brain atlas for adaptive masking
709
+ - **Attention Visualization**: See which brain regions the model focuses on
710
 
711
  ### Supported Tasks
712
 
 
736
  ```
737
  """)
738
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  gr.Markdown("""
740
  ---
741
 
brain_attention_viz.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ BrainAnytime 精确注意力可视化
4
+
5
+ 利用 AAL116 atlas 的 patch-region mapping 实现精确的 3D 空间映射
6
+ 避免简单的插值造成的 artifact
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import torch
15
+ import nibabel as nib
16
+ import matplotlib
17
+ matplotlib.use('Agg')
18
+ import matplotlib.pyplot as plt
19
+ from matplotlib.colors import LinearSegmentedColormap
20
+
21
+ # 添加路径
22
+ BASE_DIR = Path(__file__).parent
23
+ sys.path.insert(0, str(BASE_DIR))
24
+ sys.path.insert(0, str(BASE_DIR / "BrainAnytime"))
25
+
26
+ from inference_engine import BrainAnytimeInference, MODALITY_ORDER, TASKS
27
+ from models.multimae3d import MultiMAE3D
28
+ from models.multimae3d_utils import patchify
29
+
30
+
31
+ def extract_last_layer_attention(
32
+ model: MultiMAE3D,
33
+ images: torch.Tensor,
34
+ observed: torch.Tensor,
35
+ ) -> torch.Tensor:
36
+ """
37
+ 提取最后一层编码器的 CLS-to-patch 注意力权重
38
+
39
+ Args:
40
+ model: MultiMAE3D 模型
41
+ images: [B, 4, D, H, W] 输入图像
42
+ observed: [B, 4] 模态掩码
43
+
44
+ Returns:
45
+ attention: [B, num_heads, N, N] 注意力权重
46
+ """
47
+ model.eval()
48
+
49
+ with torch.no_grad():
50
+ B = images.shape[0]
51
+ device = images.device
52
+
53
+ # 准备输入 tokens
54
+ all_patches = []
55
+ for i, modality in enumerate(model.MODALITY_NAMES):
56
+ x = images[:, i:i+1, ...]
57
+ patches = patchify(x, model.patch_size)
58
+ patches_emb = model.input_adapters[modality](patches)
59
+ all_patches.append(patches_emb)
60
+
61
+ input_tokens = torch.cat(all_patches, dim=1)
62
+
63
+ # 添加全局 token (CLS)
64
+ if model.num_global_tokens > 0:
65
+ cls = model.global_tokens.unsqueeze(0).expand(B, -1, -1)
66
+ input_tokens = torch.cat([cls, input_tokens], dim=1)
67
+
68
+ # 构建 attention mask
69
+ N_p = model.num_patches
70
+ attn_mask = torch.zeros(B, 1, 1, input_tokens.shape[1], device=device)
71
+ mod_offset = model.num_global_tokens
72
+
73
+ for i, mod in enumerate(model.MODALITY_NAMES):
74
+ mask_val = (1.0 - observed[:, i:i+1]) * -1e9
75
+ attn_mask[:, :, :, mod_offset:mod_offset + N_p] = mask_val.unsqueeze(-1)
76
+ mod_offset += N_p
77
+
78
+ if (attn_mask == 0).all():
79
+ attn_mask = None
80
+
81
+ # 手动执行前向传播直到最后一层
82
+ x = input_tokens
83
+
84
+ if model.pos_embed is not None:
85
+ num_global = model.num_global_tokens
86
+ cls_tokens = x[:, :num_global, :]
87
+ patch_tokens = x[:, num_global:, :]
88
+
89
+ pos_emb = model.pos_embed.expand(B, -1, -1)
90
+ for i in range(4):
91
+ start = i * N_p
92
+ end = (i + 1) * N_p
93
+ if end <= patch_tokens.shape[1]:
94
+ patch_tokens[:, start:end, :] = patch_tokens[:, start:end, :] + pos_emb
95
+
96
+ x = torch.cat([cls_tokens, patch_tokens], dim=1)
97
+
98
+ # 通过所有层直到倒数第二层
99
+ for i, block in enumerate(model.encoder[:-1]):
100
+ x = block(x, attn_mask=attn_mask)
101
+
102
+ # 最后一层:手动提取注意力
103
+ last_block = model.encoder[-1]
104
+ x_norm = last_block.norm1(x)
105
+ B_, N_, C_ = x_norm.shape
106
+
107
+ qkv = last_block.attn.qkv(x_norm).reshape(
108
+ B_, N_, 3, last_block.attn.num_heads, C_ // last_block.attn.num_heads
109
+ ).permute(2, 0, 3, 1, 4)
110
+ q, k, v = qkv.unbind(0)
111
+
112
+ attn = (q @ k.transpose(-2, -1)) * last_block.attn.scale
113
+ if attn_mask is not None:
114
+ attn = attn + attn_mask.unsqueeze(1)
115
+ attn = attn.softmax(dim=-1)
116
+
117
+ return attn
118
+
119
+
120
+ def build_voxel_attention_map(
121
+ patch_attention: np.ndarray,
122
+ atlas_data: np.ndarray,
123
+ img_size: tuple = (128, 128, 128),
124
+ patch_size: tuple = (16, 16, 16),
125
+ ) -> np.ndarray:
126
+ """
127
+ 将 per-patch 注意力映射回 voxel 空间
128
+
129
+ 方法:每个 patch 内的所有 voxel 赋予该 patch 的注意力值
130
+
131
+ Args:
132
+ patch_attention: [N_patches] 每个 patch 的注意力值
133
+ atlas_data: [D, H, W] atlas 数据(用于验证 patch 边界)
134
+ img_size: 目标图像尺寸
135
+ patch_size: patch 大小
136
+
137
+ Returns:
138
+ attention_map: [D, H, W] voxel-level 注意力图
139
+ """
140
+ D, H, W = img_size
141
+ pd, ph, pw = patch_size
142
+
143
+ grid_d = D // pd
144
+ grid_h = H // ph
145
+ grid_w = W // pw
146
+
147
+ attention_map = np.zeros((D, H, W), dtype=np.float32)
148
+
149
+ # 遍历所有 patches
150
+ idx = 0
151
+ for d in range(grid_d):
152
+ for h in range(grid_h):
153
+ for w in range(grid_w):
154
+ if idx < len(patch_attention):
155
+ # 该 patch 对应的 voxel 区域
156
+ d_start, d_end = d * pd, (d + 1) * pd
157
+ h_start, h_end = h * ph, (h + 1) * ph
158
+ w_start, w_end = w * pw, (w + 1) * pw
159
+
160
+ # 赋予注意力值
161
+ attention_map[d_start:d_end, h_start:h_end, w_start:w_end] = patch_attention[idx]
162
+ idx += 1
163
+
164
+ return attention_map
165
+
166
+
167
+ def visualize_attention_brain_regions(
168
+ brain_volume: np.ndarray,
169
+ attention_map: np.ndarray,
170
+ brain_mask: np.ndarray = None,
171
+ slice_indices: list = None,
172
+ plane: str = 'axial',
173
+ alpha: float = 0.6,
174
+ cmap: str = 'viridis',
175
+ output_path: str = None,
176
+ show_colorbar: bool = False,
177
+ relative_mode: bool = False,
178
+ ) -> np.ndarray:
179
+ """
180
+ 可视化脑区注意力分布(带脑轮廓 mask)
181
+
182
+ Args:
183
+ brain_volume: [D, H, W] 原始脑影像
184
+ attention_map: [D, H, W] 注意力图
185
+ brain_mask: [D, H, W] 脑轮廓 mask (非零=脑内, 零=背景)
186
+ slice_indices: 要显示的切片索引列表
187
+ plane: 'axial', 'coronal', 'sagittal'
188
+ alpha: 叠加透明度
189
+ cmap: 热力图颜色映射,默认 'viridis'
190
+ output_path: 保存路径
191
+ show_colorbar: 是否显示 colorbar,默认 False
192
+ relative_mode: 是否显示相对注意力(与平均值差异),默认 False
193
+
194
+ Returns:
195
+ 可视化图像数组
196
+ """
197
+ # 如果没有提供 mask,使用图像非零区域作为 mask
198
+ if brain_mask is None:
199
+ brain_mask = (brain_volume > 0.01).astype(np.float32)
200
+
201
+ # 确保 mask 是二值的
202
+ brain_mask = (brain_mask > 0).astype(np.float32)
203
+
204
+ if slice_indices is None:
205
+ # 默认显示中间附近的切片
206
+ if plane == 'axial':
207
+ max_idx = brain_volume.shape[2]
208
+ elif plane == 'coronal':
209
+ max_idx = brain_volume.shape[1]
210
+ else:
211
+ max_idx = brain_volume.shape[0]
212
+
213
+ slice_indices = [
214
+ max_idx // 4,
215
+ max_idx * 3 // 8,
216
+ max_idx // 2,
217
+ max_idx * 5 // 8,
218
+ max_idx * 3 // 4,
219
+ ]
220
+
221
+ n_slices = len(slice_indices)
222
+
223
+ # 创建图形 - 纯白背景
224
+ fig, axes = plt.subplots(2, n_slices, figsize=(4*n_slices, 8),
225
+ facecolor='white', edgecolor='white')
226
+
227
+ for i, idx in enumerate(slice_indices):
228
+ # 提取切片
229
+ if plane == 'axial':
230
+ brain_slice = brain_volume[:, :, idx]
231
+ attn_slice = attention_map[:, :, idx]
232
+ mask_slice = brain_mask[:, :, idx]
233
+ elif plane == 'coronal':
234
+ brain_slice = brain_volume[:, idx, :]
235
+ attn_slice = attention_map[:, idx, :]
236
+ mask_slice = brain_mask[:, idx, :]
237
+ else: # sagittal
238
+ brain_slice = brain_volume[idx, :, :]
239
+ attn_slice = attention_map[idx, :, :]
240
+ mask_slice = brain_mask[idx, :, :]
241
+
242
+ # 创建白色背景图像
243
+ white_bg = np.ones_like(brain_slice) * 255 # 纯白背景 (8-bit)
244
+
245
+ # 上一行:原始脑影像(mask 外白色,mask 内显示影像)
246
+ ax1 = axes[0, i]
247
+ # 白色背景
248
+ display_img = np.stack([white_bg, white_bg, white_bg], axis=-1).astype(np.uint8)
249
+ # 在 mask 内填入脑影像(灰度转 RGB)
250
+ brain_norm = (brain_slice * 255).astype(np.uint8)
251
+ mask_bool = mask_slice > 0
252
+ for c in range(3):
253
+ display_img[:, :, c] = np.where(mask_bool, brain_norm, display_img[:, :, c])
254
+ ax1.imshow(display_img, origin='lower')
255
+ ax1.set_title(f'{plane.capitalize()} {idx}', fontsize=12)
256
+ ax1.axis('off')
257
+ ax1.set_facecolor('white')
258
+
259
+ # 下一行:注意力叠加(mask 外白色,mask 内显示叠加)
260
+ ax2 = axes[1, i]
261
+ ax2.set_facecolor('white')
262
+
263
+ # 获取 colormap
264
+ from matplotlib import cm
265
+
266
+ # 根据模式选择 colormap 和归一化方式
267
+ if relative_mode:
268
+ # 相对模式:显示与平均值的差异,使用 diverging colormap
269
+ cmap_obj = cm.get_cmap('RdBu_r') # 红-白-蓝,红=高,蓝=低
270
+
271
+ # 计算 mask 内的平均值
272
+ brain_mask_global = brain_mask if brain_mask is not None else (brain_volume > 0.01)
273
+ masked_attn = attention_map[brain_mask_global > 0]
274
+ if len(masked_attn) > 0:
275
+ mean_attn = masked_attn.mean()
276
+ else:
277
+ mean_attn = attention_map.mean()
278
+
279
+ # 计算相对值(差异)
280
+ attn_relative = attn_slice - mean_attn
281
+
282
+ # 对称归一化:以 0 为中心,正负范围相同
283
+ max_abs = np.abs(attn_relative).max()
284
+ if max_abs > 0:
285
+ attn_norm = (attn_relative / max_abs) * 0.5 + 0.5 # 映射到 [0, 1],0.5 为中心
286
+ else:
287
+ attn_norm = np.ones_like(attn_relative) * 0.5
288
+
289
+ # 在 mask 外设为中性色(0.5 = 白色)
290
+ attn_norm = np.where(mask_bool, attn_norm, 0.5)
291
+ else:
292
+ # 绝对模式:显示原始注意力值
293
+ cmap_obj = cm.get_cmap(cmap)
294
+
295
+ # 归一化注意力值
296
+ attn_min, attn_max = attention_map.min(), attention_map.max()
297
+ if attn_max > attn_min:
298
+ attn_norm = (attn_slice - attn_min) / (attn_max - attn_min)
299
+ else:
300
+ attn_norm = np.zeros_like(attn_slice)
301
+
302
+ # 应用 colormap 到注意力图
303
+ attn_colored = cmap_obj(attn_norm) # [H, W, 4] (RGBA)
304
+ attn_colored = (attn_colored[:, :, :3] * 255).astype(np.uint8) # 转为 RGB
305
+
306
+ # 混合:白色背景 + mask 内叠加
307
+ display_attn = np.stack([white_bg, white_bg, white_bg], axis=-1).astype(np.uint8)
308
+
309
+ # 在 mask 内混合原始影像和注意力
310
+ for c in range(3):
311
+ if relative_mode:
312
+ # 相对模式:更多显示 colormap,较少原始影像
313
+ blended = (1 - alpha * 0.5) * brain_norm + alpha * 1.5 * attn_colored[:, :, c]
314
+ blended = np.clip(blended, 0, 255)
315
+ else:
316
+ # 绝对模式:标准混合
317
+ blended = (1 - alpha) * brain_norm + alpha * attn_colored[:, :, c]
318
+ display_attn[:, :, c] = np.where(mask_bool, blended.astype(np.uint8),
319
+ display_attn[:, :, c])
320
+
321
+ ax2.imshow(display_attn, origin='lower')
322
+ ax2.axis('off')
323
+
324
+ # 可选 colorbar(默认不显示)
325
+ if show_colorbar and i == n_slices - 1:
326
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
327
+ divider = make_axes_locatable(ax2)
328
+ cax = divider.append_axes('right', size='5%', pad=0.05)
329
+ sm = plt.cm.ScalarMappable(cmap=cmap,
330
+ norm=plt.Normalize(vmin=attn_min, vmax=attn_max))
331
+ sm.set_array([])
332
+ plt.colorbar(sm, cax=cax)
333
+
334
+ plt.tight_layout()
335
+
336
+ # 转换图像
337
+ fig.canvas.draw()
338
+ img = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
339
+ img = img.reshape(fig.canvas.get_width_height()[::-1] + (3,))
340
+
341
+ # 保存 - 纯白背景
342
+ if output_path:
343
+ plt.savefig(output_path, dpi=150, bbox_inches='tight',
344
+ facecolor='white', edgecolor='white')
345
+ print(f" Saved: {output_path}")
346
+
347
+ plt.close()
348
+
349
+ return img
350
+
351
+
352
+ def visualize_sample_attention_precise(
353
+ engine: BrainAnytimeInference,
354
+ sample_dir: Path,
355
+ task: str,
356
+ combo: str,
357
+ output_dir: Path = None,
358
+ ):
359
+ """
360
+ 精确可视化样本的注意力分布
361
+
362
+ Args:
363
+ engine: 推理引擎
364
+ sample_dir: 样本目录
365
+ task: 任务名称
366
+ combo: 模态组合
367
+ output_dir: 输出目录
368
+
369
+ Returns:
370
+ 生成的图像路径列表
371
+ """
372
+ import json
373
+
374
+ # 加载元数据
375
+ meta_path = list(sample_dir.glob("*_meta.json"))[0]
376
+ with open(meta_path) as f:
377
+ meta = json.load(f)
378
+
379
+ print(f"\nProcessing: {meta['subject_id']} - {task}/{combo}")
380
+ print(f" Modalities: {meta['modalities']}")
381
+
382
+ # 加载模型
383
+ model = engine.load_model(task)
384
+
385
+ # 准备输入
386
+ from inference_engine import SHORT_TO_FULL
387
+
388
+ # 加载脑影像
389
+ brain_volumes = {}
390
+ nifti_files = {}
391
+
392
+ for mod in meta['modalities']:
393
+ nii_filename = meta['files']['nifti'][mod]
394
+ nii_path = sample_dir / nii_filename
395
+
396
+ nii = nib.load(str(nii_path))
397
+ data = nii.get_fdata().astype(np.float32)
398
+
399
+ # Min-Max 归一化
400
+ data_min, data_max = data.min(), data.max()
401
+ if data_max > data_min:
402
+ data = (data - data_min) / (data_max - data_min)
403
+
404
+ brain_volumes[mod] = data
405
+ nifti_files[mod] = str(nii_path)
406
+
407
+ # 构建输入张量
408
+ images_list = []
409
+ for mod in MODALITY_ORDER:
410
+ if mod in brain_volumes:
411
+ images_list.append(brain_volumes[mod])
412
+ else:
413
+ images_list.append(np.zeros((128, 128, 128), dtype=np.float32))
414
+
415
+ images = np.stack(images_list, axis=0)
416
+ images = torch.from_numpy(images).unsqueeze(0).to(engine.device)
417
+
418
+ # observed mask
419
+ observed = torch.zeros(1, 4)
420
+ for i, mod in enumerate(MODALITY_ORDER):
421
+ if mod in meta['modalities']:
422
+ observed[0, i] = 1.0
423
+
424
+ # 执行推理
425
+ with torch.no_grad():
426
+ logits = model(images, observed)
427
+ prob = torch.sigmoid(logits).item() if task in ['CN_vs_AD', 'CN_vs_MCI'] else logits.item()
428
+
429
+ print(f" Prediction: {prob:.4f}")
430
+
431
+ # 提取注意力
432
+ attention = extract_last_layer_attention(model.encoder, images, observed)
433
+
434
+ if attention is None:
435
+ print(" Failed to extract attention")
436
+ return []
437
+
438
+ # 处理注意力
439
+ attn_batch = attention[0].mean(dim=0) # [N, N]
440
+ cls_to_all = attn_batch[0, 1:] # 跳过 CLS
441
+
442
+ # 分配到模态
443
+ num_global = model.encoder.num_global_tokens
444
+ N_p = model.encoder.num_patches # 512
445
+
446
+ # 计算实际 patches 数
447
+ total_patches = cls_to_all.shape[0]
448
+ actual_patches = (total_patches // 4) * 4
449
+ N_p_actual = actual_patches // 4
450
+
451
+ print(f" Patches per modality: {N_p_actual}")
452
+
453
+ # 输出路径
454
+ if output_dir is None:
455
+ output_dir = sample_dir
456
+ output_dir = Path(output_dir)
457
+ output_dir.mkdir(parents=True, exist_ok=True)
458
+
459
+ generated_files = []
460
+
461
+ # 为每个模态生成可视化
462
+ for idx, mod in enumerate(meta['modalities']):
463
+ if mod not in brain_volumes:
464
+ continue
465
+
466
+ mod_idx = MODALITY_ORDER.index(mod)
467
+ start_idx = mod_idx * N_p_actual
468
+ end_idx = (mod_idx + 1) * N_p_actual
469
+
470
+ if end_idx > len(cls_to_all):
471
+ print(f" Warning: {mod} index out of range")
472
+ continue
473
+
474
+ mod_attn = cls_to_all[start_idx:end_idx].cpu().numpy()
475
+
476
+ # 使用精确映射构建 voxel-level 注意力图
477
+ # 创建虚拟 atlas(因为我们没有精确的 patch 位置映射)
478
+ atlas_data = np.ones((128, 128, 128), dtype=np.int32)
479
+
480
+ attention_map = build_voxel_attention_map(
481
+ mod_attn,
482
+ atlas_data,
483
+ img_size=(128, 128, 128),
484
+ patch_size=(16, 16, 16),
485
+ )
486
+
487
+ # 平滑处理(减少 blockiness)
488
+ from scipy.ndimage import gaussian_filter
489
+ attention_map = gaussian_filter(attention_map, sigma=2.0)
490
+
491
+ print(f" {mod}: attention range [{mod_attn.min():.6f}, {mod_attn.max():.6f}]")
492
+
493
+ # 生成可视化
494
+ output_path = output_dir / f"attention_{task}_{combo}_{mod}_{meta['subject_id']}.png"
495
+
496
+ visualize_attention_brain_regions(
497
+ brain_volumes[mod],
498
+ attention_map,
499
+ slice_indices=[40, 55, 70, 85, 100],
500
+ plane='axial',
501
+ alpha=0.5,
502
+ output_path=str(output_path),
503
+ )
504
+
505
+ generated_files.append(output_path)
506
+
507
+ return generated_files
508
+
509
+
510
+ def main():
511
+ """主函数:测试可视化"""
512
+ import argparse
513
+
514
+ parser = argparse.ArgumentParser(description='Brain Attention Visualization')
515
+ parser.add_argument('--sample_dir', type=str,
516
+ default='demo_samples/CN_vs_AD/TMFP/sample_005',
517
+ help='Sample directory')
518
+ parser.add_argument('--task', type=str, default='CN_vs_AD',
519
+ choices=list(TASKS.keys()),
520
+ help='Task name')
521
+ parser.add_argument('--combo', type=str, default='TMFP',
522
+ help='Modality combination')
523
+ parser.add_argument('--output_dir', type=str, default=None,
524
+ help='Output directory (default: sample_dir)')
525
+
526
+ args = parser.parse_args()
527
+
528
+ print("="*70)
529
+ print("Brain Attention Visualization")
530
+ print("="*70)
531
+
532
+ # 初始化引擎
533
+ checkpoints_dir = "/home/23037125r/code/random/multimae_freeze_then_finetune/"
534
+ engine = BrainAnytimeInference(checkpoints_dir=checkpoints_dir)
535
+
536
+ # 生成可视化
537
+ sample_dir = Path(args.sample_dir)
538
+ output_dir = Path(args.output_dir) if args.output_dir else None
539
+
540
+ files = visualize_sample_attention_precise(
541
+ engine,
542
+ sample_dir,
543
+ args.task,
544
+ args.combo,
545
+ output_dir,
546
+ )
547
+
548
+ print("\n" + "="*70)
549
+ print(f"Generated {len(files)} visualizations:")
550
+ for f in files:
551
+ print(f" - {f}")
552
+ print("="*70)
553
+
554
+
555
+ if __name__ == '__main__':
556
+ main()