| """ |
| ISLES 病灶檢視(Gradio) |
| - 上傳 DWI / ADC / MSK (NIfTI)/切片疊圖/結構化報告 |
| - Hugging Face Spaces:app_file 設為本檔;根目錄放 requirements.txt(須含 pydantic==2.10.6) |
| - 本機:python app.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html as html_module |
| import json |
| import os |
| import tempfile |
| from datetime import datetime |
| from pathlib import Path |
|
|
| import gradio as gr |
| import nibabel as nib |
| import numpy as np |
| from PIL import Image |
| from scipy import ndimage |
|
|
| _ROOT = Path(__file__).resolve().parent |
| _APP_VERSION = "1.1" |
| _HAS_GRADIO_TIMER = hasattr(gr, "Timer") |
| _TIMER_INTERVAL_SEC = 0.25 |
| _FALLBACK_INTERVAL_MS = int(_TIMER_INTERVAL_SEC * 1000) |
|
|
|
|
| |
| |
| |
| def default_isles_paths() -> tuple[Path, Path, Path]: |
| dwi = _ROOT / "dwi.nii.gz" |
| adc = _ROOT / "adc.nii.gz" |
| msk = _ROOT / "msk.nii.gz" |
| return dwi, adc, msk |
|
|
|
|
| def default_paths_exist() -> bool: |
| return all(p.exists() for p in default_isles_paths()) |
|
|
|
|
| def _resolve_file_path(f) -> str | None: |
| if f is None: |
| return None |
| if isinstance(f, (str, Path)): |
| return str(f) |
| name = getattr(f, "name", None) |
| if name: |
| return str(name) |
| return str(f) |
|
|
|
|
| def _load_vol(path: str | Path | None) -> tuple[np.ndarray, np.ndarray]: |
| if not path: |
| raise ValueError("缺少檔案路徑") |
| path = str(path) |
| img = nib.load(path) |
| data = img.get_fdata() |
| if data.ndim == 4: |
| data = data[..., 0] |
| return data.astype(np.float32), np.array(img.header.get_zooms()[:3], dtype=np.float32) |
|
|
|
|
| |
| |
| |
| def _norm_u8(v: np.ndarray) -> np.ndarray: |
| lo = np.percentile(v, 1) |
| hi = np.percentile(v, 99) |
| if hi <= lo: |
| hi = lo + 1e-6 |
| x = np.clip((v - lo) / (hi - lo), 0, 1) |
| return (x * 255).astype(np.uint8) |
|
|
|
|
| def _overlay(base: np.ndarray, mask: np.ndarray, alpha: float = 0.5) -> np.ndarray: |
| base_u8 = _norm_u8(base) |
| rgb = np.stack([base_u8, base_u8, base_u8], axis=-1).astype(np.float32) |
| lesion = mask > 0 |
| rgb[lesion, 0] = (1 - alpha) * rgb[lesion, 0] + alpha * 255 |
| rgb[lesion, 1] = (1 - alpha) * rgb[lesion, 1] |
| rgb[lesion, 2] = (1 - alpha) * rgb[lesion, 2] |
| return rgb.astype(np.uint8) |
|
|
|
|
| def _slice_img(vol: np.ndarray, z: int) -> np.ndarray: |
| return np.flipud(vol[:, :, z].T) |
|
|
|
|
| |
| |
| |
| def _intensity_summary(vol: np.ndarray, mask: np.ndarray) -> dict: |
| vals = vol[mask > 0] |
| vals = vals[np.isfinite(vals)] |
| if vals.size == 0: |
| return {} |
| return { |
| "mean": round(float(np.mean(vals)), 4), |
| "std": round(float(np.std(vals)), 4), |
| "min": round(float(np.min(vals)), 4), |
| "max": round(float(np.max(vals)), 4), |
| "p5": round(float(np.percentile(vals, 5)), 4), |
| "p25": round(float(np.percentile(vals, 25)), 4), |
| "p50": round(float(np.percentile(vals, 50)), 4), |
| "p75": round(float(np.percentile(vals, 75)), 4), |
| "p95": round(float(np.percentile(vals, 95)), 4), |
| } |
|
|
|
|
| def _connected_lesion_stats(msk_bin: np.ndarray) -> dict: |
| if int(msk_bin.sum()) == 0: |
| return { |
| "n_components_6_connected": 0, |
| "largest_component_voxels": 0, |
| "largest_component_volume_ml": 0.0, |
| } |
| struct = ndimage.generate_binary_structure(3, 1) |
| labeled, nfeat = ndimage.label(msk_bin.astype(bool), structure=struct) |
| if nfeat == 0: |
| return { |
| "n_components_6_connected": 0, |
| "largest_component_voxels": 0, |
| "largest_component_volume_ml": 0.0, |
| } |
| counts = np.bincount(labeled.ravel()) |
| comp_sizes = counts[1:] if len(counts) > 1 else np.array([], dtype=int) |
| largest = int(comp_sizes.max()) if comp_sizes.size else 0 |
| return { |
| "n_components_6_connected": int(nfeat), |
| "largest_component_voxels": largest, |
| "largest_component_volume_ml": 0.0, |
| } |
|
|
|
|
| def _hemisphere_split_along_axis0(msk_bin: np.ndarray) -> dict: |
| nx = msk_bin.shape[0] |
| mid = nx // 2 |
| low = int(msk_bin[:mid, :, :].sum()) |
| high = int(msk_bin[mid:, :, :].sum()) |
| total = low + high |
| if total == 0: |
| dom = "none" |
| elif low >= high * 1.2: |
| dom = "low_index_half" |
| elif high >= low * 1.2: |
| dom = "high_index_half" |
| else: |
| dom = "bilateral" |
| return { |
| "note_zh": "依資料陣列第 0 維中線切分,非直接等同解剖學左/右;臨床解讀請對照影像方向與 affine。", |
| "low_index_half_voxels": low, |
| "high_index_half_voxels": high, |
| "low_index_fraction": round(low / total, 4) if total else 0.0, |
| "high_index_fraction": round(high / total, 4) if total else 0.0, |
| "dominant": dom, |
| } |
|
|
|
|
| _DOM_LABEL_ZH = { |
| "none": "無病灶", |
| "low_index_half": "低索引半側為主", |
| "high_index_half": "高索引半側為主", |
| "bilateral": "雙側相近", |
| } |
|
|
|
|
| |
| |
| |
| def _metric_card(label: str, value: str, sub: str = "") -> str: |
| sub_html = f'<div class="metric-sub">{sub}</div>' if sub else "" |
| return ( |
| f'<div class="metric-card">' |
| f'<div class="metric-label">{html_module.escape(label)}</div>' |
| f'<div class="metric-value">{value}</div>{sub_html}</div>' |
| ) |
|
|
|
|
| def _intensity_table(title: str, d: dict) -> str: |
| if not d: |
| return ( |
| f'<div class="intensity-panel"><h4 class="intensity-title">' |
| f'{html_module.escape(title)}</h4><p class="intensity-empty">無病灶體素</p></div>' |
| ) |
| rows = "".join( |
| f"<tr><td>{html_module.escape(k)}</td><td>{v}</td></tr>" |
| for k, v in [ |
| ("mean", d.get("mean")), |
| ("std", d.get("std")), |
| ("min", d.get("min")), |
| ("max", d.get("max")), |
| ("p5 / p50 / p95", f"{d.get('p5')} / {d.get('p50')} / {d.get('p95')}"), |
| ] |
| ) |
| return ( |
| f'<div class="intensity-panel">' |
| f'<h4 class="intensity-title">{html_module.escape(title)}</h4>' |
| f'<table class="intensity-table"><tbody>{rows}</tbody></table></div>' |
| ) |
|
|
|
|
| def _format_metrics_html(report: dict) -> str: |
| lv = report.get("lesion_volume_ml", 0) |
| sr = report.get("lesion_slice_range", {}) |
| cc = report.get("connected_lesions", {}) |
| hem = report.get("hemisphere_along_axis0", {}) |
| dwi_s = report.get("dwi_in_lesion", {}) |
| adc_s = report.get("adc_in_lesion", {}) |
| dom_key = hem.get("dominant", "none") |
| dom_zh = _DOM_LABEL_ZH.get(dom_key, html_module.escape(str(dom_key))) |
|
|
| if sr.get("z_min") is not None: |
| slice_range_sub = f"共 {sr.get('n_slices_with_lesion', 0)} 張切片含病灶" |
| slice_card = _metric_card( |
| "病灶 Z 範圍(索引)", |
| f"{sr['z_min']} – {sr['z_max']}", |
| slice_range_sub, |
| ) |
| else: |
| slice_card = _metric_card("病灶 Z 範圍(索引)", "—", "無病灶") |
|
|
| cards = [ |
| _metric_card( |
| "病灶體積", |
| f'{lv} <span class="unit">mL</span>', |
| f"{report.get('lesion_voxels', 0)} voxels", |
| ), |
| slice_card, |
| _metric_card( |
| "連通病灶數(3D 六鄰域)", |
| str(cc.get("n_components_6_connected", 0)), |
| "獨立連通域個數", |
| ), |
| _metric_card( |
| "最大病灶切面(Z)", |
| str(report.get("largest_lesion_slice_z", 0)), |
| f"該層面積 {report.get('largest_lesion_slice_area_voxels', 0)} voxels", |
| ), |
| _metric_card( |
| "最大連通塊", |
| f'{cc.get("largest_component_volume_ml", 0)} <span class="unit">mL</span>', |
| f"{cc.get('largest_component_voxels', 0)} voxels", |
| ), |
| _metric_card( |
| "半側分布(第 0 維)", |
| dom_zh, |
| f"低 {hem.get('low_index_half_voxels', 0)} / 高 {hem.get('high_index_half_voxels', 0)} voxels", |
| ), |
| ] |
|
|
| note = html_module.escape(hem.get("note_zh", "")) |
| return ( |
| '<div class="metrics-wrap">' |
| '<h3 class="metrics-heading">量化摘要</h3>' |
| f'<div class="metric-grid">{"".join(cards)}</div>' |
| '<div class="intensity-row">' |
| f'{_intensity_table("DWI(病灶內)", dwi_s)}' |
| f'{_intensity_table("ADC(病灶內)", adc_s)}' |
| "</div>" |
| f'<p class="metrics-footnote">{note}</p>' |
| "</div>" |
| ) |
|
|
|
|
| |
| |
| |
| def prepare_case(dwi_file, adc_file, msk_file): |
| p_dwi = _resolve_file_path(dwi_file) |
| p_adc = _resolve_file_path(adc_file) |
| p_msk = _resolve_file_path(msk_file) |
| dwi, zooms = _load_vol(p_dwi) |
| adc, _ = _load_vol(p_adc) |
| msk, _ = _load_vol(p_msk) |
|
|
| if dwi.shape != adc.shape or dwi.shape != msk.shape: |
| raise gr.Error(f"shape 不一致: dwi={dwi.shape}, adc={adc.shape}, msk={msk.shape}") |
|
|
| msk_bin = (msk > 0).astype(np.uint8) |
| n_slices = int(dwi.shape[2]) |
| lesion_vox = int(msk_bin.sum()) |
| voxel_ml = float(np.prod(zooms) / 1000.0) |
| lesion_ml = lesion_vox * voxel_ml |
| lesion_slices = int((msk_bin.sum(axis=(0, 1)) > 0).sum()) |
|
|
| z_per_slice = msk_bin.sum(axis=(0, 1)).astype(np.int64) |
| z_with = np.flatnonzero(z_per_slice > 0) |
| if z_with.size > 0: |
| z_min = int(z_with[0]) |
| z_max = int(z_with[-1]) |
| else: |
| z_min = None |
| z_max = None |
|
|
| cc_stats = _connected_lesion_stats(msk_bin) |
| cc_stats["largest_component_volume_ml"] = round( |
| float(cc_stats["largest_component_voxels"]) * voxel_ml, 4 |
| ) |
|
|
| report = { |
| "timestamp": datetime.now().isoformat(timespec="seconds"), |
| "shape": [int(dwi.shape[0]), int(dwi.shape[1]), int(dwi.shape[2])], |
| "voxel_spacing_mm": [float(zooms[0]), float(zooms[1]), float(zooms[2])], |
| "lesion_voxels": lesion_vox, |
| "lesion_volume_ml": round(float(lesion_ml), 4), |
| "lesion_slices": lesion_slices, |
| "lesion_slice_range": { |
| "z_min": z_min, |
| "z_max": z_max, |
| "n_slices_with_lesion": int(z_with.size), |
| }, |
| "connected_lesions": { |
| "connectivity": "6-neighborhood_3D", |
| "n_components_6_connected": cc_stats["n_components_6_connected"], |
| "largest_component_voxels": cc_stats["largest_component_voxels"], |
| "largest_component_volume_ml": cc_stats["largest_component_volume_ml"], |
| }, |
| "hemisphere_along_axis0": _hemisphere_split_along_axis0(msk_bin), |
| "dwi_in_lesion": _intensity_summary(dwi, msk_bin), |
| "adc_in_lesion": _intensity_summary(adc, msk_bin), |
| "dwi_mean_in_lesion": float(dwi[msk_bin > 0].mean()) if lesion_vox > 0 else 0.0, |
| "adc_mean_in_lesion": float(adc[msk_bin > 0].mean()) if lesion_vox > 0 else 0.0, |
| } |
|
|
| state = {"dwi": dwi, "adc": adc, "msk": msk_bin, "n_slices": n_slices, "report": report} |
|
|
| if lesion_vox > 0: |
| z0 = int(np.argmax(z_per_slice)) |
| else: |
| z0 = 0 |
| report["default_slice_z"] = z0 |
| report["largest_lesion_slice_z"] = z0 |
| report["largest_lesion_slice_area_voxels"] = int(z_per_slice[z0]) if lesion_vox > 0 else 0 |
|
|
| first_dwi = Image.fromarray(_overlay(_slice_img(dwi, z0), _slice_img(msk_bin, z0))) |
| first_adc = Image.fromarray(_overlay(_slice_img(adc, z0), _slice_img(msk_bin, z0))) |
| report_pretty = json.dumps(report, ensure_ascii=False, indent=2) |
| metrics_html = _format_metrics_html(report) |
|
|
| tmp = Path(tempfile.gettempdir()) / f"report_isles_{datetime.now().strftime('%Y%m%d%H%M%S')}.json" |
| tmp.write_text(report_pretty, encoding="utf-8") |
| return ( |
| state, |
| gr.update(minimum=0, maximum=n_slices - 1, value=z0, step=1, interactive=True), |
| first_dwi, |
| first_adc, |
| metrics_html, |
| report_pretty, |
| str(tmp), |
| ) |
|
|
|
|
| def update_slice(z, state): |
| if not state: |
| raise gr.Error("請先按「執行分析並產生報告」") |
| z = int(z) |
| dwi = state["dwi"] |
| adc = state["adc"] |
| msk = state["msk"] |
| dwi_img = Image.fromarray(_overlay(_slice_img(dwi, z), _slice_img(msk, z))) |
| adc_img = Image.fromarray(_overlay(_slice_img(adc, z), _slice_img(msk, z))) |
| return dwi_img, adc_img |
|
|
|
|
| def tick_advance_slice(z, state): |
| if not state: |
| return gr.update(), gr.update(), gr.update() |
| n = int(state["n_slices"]) |
| if n <= 1: |
| return gr.update(), gr.update(), gr.update() |
| z_cur = int(z) if z is not None else 0 |
| z_next = (z_cur + 1) % n |
| dwi = state["dwi"] |
| adc = state["adc"] |
| msk = state["msk"] |
| dwi_pil = Image.fromarray(_overlay(_slice_img(dwi, z_next), _slice_img(msk, z_next))) |
| adc_pil = Image.fromarray(_overlay(_slice_img(adc, z_next), _slice_img(msk, z_next))) |
| return z_next, dwi_pil, adc_pil |
|
|
|
|
| def prepare_case_stop_timer(dwi_file, adc_file, msk_file): |
| out = prepare_case(dwi_file, adc_file, msk_file) |
| if _HAS_GRADIO_TIMER: |
| return (*out, gr.Timer(_TIMER_INTERVAL_SEC, active=False)) |
| return out |
|
|
|
|
| def prepare_default_case(): |
| if not default_paths_exist(): |
| raise gr.Error("找不到檔案(dwi.nii.gz、adc.nii.gz、msk.nii.gz)。") |
| dwi_def, adc_def, msk_def = default_isles_paths() |
| return prepare_case(str(dwi_def), str(adc_def), str(msk_def)) |
|
|
|
|
| def prepare_default_case_stop_timer(): |
| out = prepare_default_case() |
| if _HAS_GRADIO_TIMER: |
| return (*out, gr.Timer(_TIMER_INTERVAL_SEC, active=False)) |
| return out |
|
|
|
|
| def _js_clear_fallback_autoplay() -> str: |
| return """ |
| () => { |
| if (window.__islesAutoplayId) { |
| clearInterval(window.__islesAutoplayId); |
| window.__islesAutoplayId = null; |
| } |
| } |
| """.strip() |
|
|
|
|
| def _js_start_fallback_autoplay(interval_ms: int) -> str: |
| return f""" |
| () => {{ |
| if (window.__islesAutoplayId) {{ |
| clearInterval(window.__islesAutoplayId); |
| window.__islesAutoplayId = null; |
| }} |
| const pickSlider = () => {{ |
| const sliders = Array.from(document.querySelectorAll('input[type="range"]')); |
| for (const s of sliders) {{ |
| const max = Number(s.max ?? '0'); |
| if (!Number.isNaN(max) && max > 0) return s; |
| }} |
| return sliders[0] || null; |
| }}; |
| const stepOnce = () => {{ |
| const slider = pickSlider(); |
| if (!slider) return; |
| const min = Number(slider.min ?? '0'); |
| const max = Number(slider.max ?? '0'); |
| if (Number.isNaN(max) || max <= min) return; |
| const cur = Number(slider.value ?? String(min)); |
| const next = cur >= max ? min : (cur + 1); |
| slider.value = String(next); |
| slider.dispatchEvent(new Event('input', {{ bubbles: true }})); |
| slider.dispatchEvent(new Event('change', {{ bubbles: true }})); |
| }}; |
| window.__islesAutoplayId = setInterval(stepOnce, {interval_ms}); |
| }} |
| """.strip() |
|
|
|
|
| _METRICS_PLACEHOLDER_HTML = """ |
| <div class="metrics-placeholder"> |
| <span class="ph-dot"></span> |
| <p>請上傳 NIfTI 後,點選 <strong>執行分析並產生報告</strong></p> |
| <p class="ph-hint">紅色疊圖為病灶標註(mask)與 DWI/ADC 之 overlay</p> |
| </div> |
| """ |
|
|
| _APP_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;600;700&display=swap'); |
| |
| :root { |
| --cl-bg: #ffffff; |
| --cl-surface: #e5e7eb; |
| --cl-elevated: #d9dde3; |
| --cl-border: #cfd5dd; |
| --cl-text: #1f2937; |
| --cl-muted: #6b7280; |
| --cl-accent: #f97316; |
| --cl-accent-soft: rgba(249, 115, 22, 0.12); |
| --cl-lesion: #f87171; |
| --cl-radius: 10px; |
| --cl-font: "Noto Sans TC", "Microsoft JhengHei", "PingFang TC", ui-sans-serif, system-ui, sans-serif; |
| } |
| |
| .gradio-container { |
| font-family: var(--cl-font) !important; |
| max-width: 1440px !important; |
| margin: 0 auto !important; |
| background: var(--cl-bg) !important; |
| } |
| |
| .gradio-container .contain { |
| padding: 0 1.25rem 2rem !important; |
| } |
| |
| .app-header { |
| background: linear-gradient(180deg, #f3f4f6 0%, #eceff3 55%, var(--cl-surface) 100%); |
| border: 1px solid var(--cl-border); |
| border-radius: var(--cl-radius); |
| padding: 1.25rem 1.5rem; |
| margin-bottom: 1rem; |
| box-shadow: 0 4px 18px rgba(15,23,42,0.08); |
| } |
| .app-header-top { |
| display: flex; |
| flex-wrap: wrap; |
| align-items: baseline; |
| justify-content: space-between; |
| gap: 0.75rem; |
| } |
| .app-title { |
| font-size: 1.5rem; |
| font-weight: 700; |
| color: var(--cl-accent); |
| letter-spacing: 0.02em; |
| } |
| .app-badge { |
| font-size: 0.72rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| color: var(--cl-accent); |
| background: var(--cl-accent-soft); |
| border: 1px solid rgba(59,130,246,0.35); |
| padding: 0.2rem 0.55rem; |
| border-radius: 6px; |
| } |
| .app-desc { |
| margin: 0.65rem 0 0; |
| font-size: 0.95rem; |
| color: var(--cl-muted); |
| line-height: 1.55; |
| max-width: 66rem; |
| } |
| .app-disclaimer { |
| margin-top: 0.5rem; |
| font-size: 0.78rem; |
| color: var(--cl-muted); |
| opacity: 0.9; |
| } |
| |
| .panel-card { |
| background: var(--cl-surface) !important; |
| border: 1px solid var(--cl-border) !important; |
| border-radius: var(--cl-radius) !important; |
| padding: 1rem 1.25rem !important; |
| margin-bottom: 1rem !important; |
| } |
| .panel-card label, .panel-card .label-wrap span { |
| font-weight: 500 !important; |
| color: var(--cl-muted) !important; |
| font-size: 0.82rem !important; |
| } |
| .upload-compact { |
| min-height: 40px !important; |
| } |
| .upload-compact [data-testid="file-upload"] { |
| min-height: 42px !important; |
| padding: 6px 8px !important; |
| } |
| .upload-compact button { |
| min-height: 30px !important; |
| height: 30px !important; |
| font-size: 0.78rem !important; |
| } |
| |
| .primary-cta button { |
| min-height: 44px !important; |
| font-weight: 600 !important; |
| border-radius: 8px !important; |
| } |
| |
| .viewer-toolbar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 1rem; |
| flex-wrap: wrap; |
| margin-bottom: 0.5rem; |
| } |
| .viewer-playback { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| background: var(--cl-elevated); |
| border: 1px solid var(--cl-border); |
| border-radius: 999px; |
| padding: 4px 8px; |
| } |
| .viewer-slider-wrap { |
| min-width: 300px; |
| } |
| .viewer-slider-wrap [data-testid="block-label"] { |
| display: none !important; |
| } |
| .viewer-slider-wrap input[type="range"] { |
| accent-color: var(--cl-accent); |
| } |
| .legend-bar { |
| display: inline-flex; |
| align-items: center; |
| gap: 0.5rem; |
| font-size: 0.8rem; |
| color: var(--cl-muted); |
| } |
| .legend-swatch { |
| width: 14px; |
| height: 14px; |
| border-radius: 3px; |
| background: var(--cl-lesion); |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.15); |
| } |
| |
| #slice-viewer-row { |
| flex-wrap: nowrap !important; |
| gap: 14px !important; |
| width: 100% !important; |
| min-height: min(76vh, 880px) !important; |
| } |
| #slice-viewer-row > div:nth-child(1), |
| #slice-viewer-row > div:nth-child(2) { |
| flex: 1 1 50% !important; |
| min-width: 0 !important; |
| background: var(--cl-elevated) !important; |
| border: 1px solid var(--cl-border) !important; |
| border-radius: var(--cl-radius) !important; |
| overflow: hidden !important; |
| } |
| .playback-hint { |
| font-size: 0.72rem !important; |
| color: var(--cl-muted) !important; |
| line-height: 1.45 !important; |
| margin-top: 10px !important; |
| } |
| .playback-mini-btn button { |
| min-height: 26px !important; |
| height: 26px !important; |
| padding: 0 8px !important; |
| font-size: 0.75rem !important; |
| font-weight: 600 !important; |
| border-radius: 999px !important; |
| } |
| #slice-viewer-row .image-container, |
| #slice-viewer-row .image-frame, |
| #slice-viewer-row .wrap, |
| #slice-viewer-row [data-testid="image"] { |
| width: 100% !important; |
| min-height: min(70vh, 800px) !important; |
| max-height: min(76vh, 880px) !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| background: #000000 !important; |
| } |
| #slice-viewer-row img { |
| width: 100% !important; |
| height: 100% !important; |
| max-height: min(76vh, 880px) !important; |
| object-fit: contain !important; |
| background: #000000 !important; |
| } |
| #slice-viewer-row canvas, |
| #slice-viewer-row .empty, |
| #slice-viewer-row [data-testid="image"] > div, |
| #slice-viewer-row [data-testid="image"] > div > div { |
| background: #000000 !important; |
| } |
| #slice-viewer-row *, |
| #slice-viewer-row img, |
| #slice-viewer-row canvas { |
| transition: none !important; |
| animation: none !important; |
| } |
| |
| .metrics-wrap { |
| background: var(--cl-surface); |
| border: 1px solid var(--cl-border); |
| border-radius: var(--cl-radius); |
| padding: 1.25rem 1.35rem; |
| margin: 1rem 0; |
| } |
| .metrics-heading { |
| margin: 0 0 1rem; |
| font-size: 1rem; |
| font-weight: 600; |
| color: var(--cl-text); |
| letter-spacing: 0.04em; |
| } |
| .metric-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| gap: 12px; |
| } |
| .metric-card { |
| background: var(--cl-elevated); |
| border: 1px solid var(--cl-border); |
| border-radius: 8px; |
| padding: 12px 14px; |
| } |
| .metric-label { |
| font-size: 0.72rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| color: var(--cl-muted); |
| margin-bottom: 6px; |
| } |
| .metric-value { |
| font-size: 1.25rem; |
| font-weight: 600; |
| color: var(--cl-text); |
| font-variant-numeric: tabular-nums; |
| line-height: 1.2; |
| } |
| .metric-value .unit { |
| font-size: 0.85rem; |
| font-weight: 500; |
| color: var(--cl-muted); |
| margin-left: 2px; |
| } |
| .metric-sub { |
| font-size: 0.78rem; |
| color: var(--cl-muted); |
| margin-top: 6px; |
| } |
| .intensity-row { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
| gap: 12px; |
| margin-top: 1rem; |
| } |
| .intensity-panel { |
| background: var(--cl-elevated); |
| border: 1px solid var(--cl-border); |
| border-radius: 8px; |
| padding: 12px 14px; |
| } |
| .intensity-title { |
| margin: 0 0 10px; |
| font-size: 0.88rem; |
| font-weight: 600; |
| color: var(--cl-text); |
| } |
| .intensity-empty { margin: 0; font-size: 0.85rem; color: var(--cl-muted); } |
| .intensity-table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 0.82rem; |
| color: var(--cl-text); |
| } |
| .intensity-table td { |
| padding: 6px 8px; |
| border-bottom: 1px solid var(--cl-border); |
| } |
| .intensity-table td:first-child { |
| color: var(--cl-muted); |
| width: 42%; |
| } |
| .metrics-footnote { |
| margin: 1rem 0 0; |
| font-size: 0.75rem; |
| color: var(--cl-muted); |
| line-height: 1.5; |
| } |
| |
| .metrics-placeholder { |
| text-align: center; |
| padding: 2.5rem 1.5rem; |
| background: var(--cl-surface); |
| border: 1px dashed var(--cl-border); |
| border-radius: var(--cl-radius); |
| color: var(--cl-muted); |
| } |
| .metrics-placeholder .ph-dot { |
| display: inline-block; |
| width: 8px; |
| height: 8px; |
| background: var(--cl-accent); |
| border-radius: 50%; |
| margin-bottom: 0.75rem; |
| box-shadow: 0 0 12px var(--cl-accent); |
| } |
| .metrics-placeholder p { margin: 0.35rem 0; font-size: 0.9rem; color: var(--cl-text); } |
| .metrics-placeholder .ph-hint { font-size: 0.8rem !important; color: var(--cl-muted) !important; } |
| |
| .report-accordion { |
| border: 1px solid var(--cl-border) !important; |
| border-radius: var(--cl-radius) !important; |
| overflow: hidden; |
| margin-top: 0.5rem; |
| } |
| |
| .app-footer { |
| margin-top: 2rem; |
| padding-top: 1rem; |
| border-top: 1px solid var(--cl-border); |
| font-size: 0.72rem; |
| color: var(--cl-muted); |
| line-height: 1.5; |
| } |
| """ |
|
|
| try: |
| _APP_THEME = gr.themes.Soft( |
| primary_hue=gr.themes.colors.blue, |
| neutral_hue=gr.themes.colors.slate, |
| font=("ui-sans-serif", "system-ui", "Segoe UI", "Microsoft JhengHei", "PingFang TC", "sans-serif"), |
| ) |
| except Exception: |
| _APP_THEME = None |
|
|
|
|
| |
| |
| |
| _blocks_kw: dict = {"title": "ISLES 病灶檢視與量化", "css": _APP_CSS} |
| if _APP_THEME is not None: |
| _blocks_kw["theme"] = _APP_THEME |
|
|
| with gr.Blocks(**_blocks_kw) as app_ui: |
| gr.HTML( |
| f""" |
| <header class="app-header"> |
| <div class="app-header-top"> |
| <span class="app-title">ISLES 病灶檢視與量化</span> |
| <span class="app-badge">Report schema v1 · UI {_APP_VERSION}</span> |
| </div> |
| <p class="app-desc"> |
| 本介面提供 ISLES 影像(DWI/ADC)與病灶標註(Mask)之視覺化檢閱,支援切片同步瀏覽、連續播放與量化摘要輸出,適用於研究與教學示範。 |
| </p> |
| <p class="app-disclaimer">本介面僅供研究與演算法展示;非醫療器材,不取代醫師判讀。</p> |
| </header> |
| """ |
| ) |
|
|
| state = gr.State() |
|
|
| with gr.Column(elem_classes=["panel-card"]): |
| gr.Markdown("#### 資料載入") |
| with gr.Row(): |
| dwi_file = gr.File(label="DWI", file_types=[".nii", ".gz"], elem_classes=["upload-compact"]) |
| adc_file = gr.File(label="ADC", file_types=[".nii", ".gz"], elem_classes=["upload-compact"]) |
| msk_file = gr.File(label="病灶標註 Mask", file_types=[".nii", ".gz"], elem_classes=["upload-compact"]) |
| run_btn = gr.Button("執行分析並產生報告", variant="primary", elem_classes=["primary-cta"]) |
|
|
| with gr.Column(elem_classes=["panel-card"]): |
| gr.Markdown("#### 切片檢視") |
| gr.HTML( |
| """ |
| <div class="viewer-toolbar"> |
| <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> |
| <div class="legend-bar"> |
| <span class="legend-swatch" title="病灶"></span> |
| <span>疊圖:病灶標註(紅)</span> |
| </div> |
| <div class="viewer-playback"> |
| <span style="font-size:12px;color:#6b7280;">播放</span> |
| </div> |
| </div> |
| </div> |
| """ |
| ) |
| with gr.Row(equal_height=True): |
| with gr.Column(scale=0, min_width=190): |
| play_slice_btn = gr.Button("▶ 播放", interactive=True, elem_classes=["playback-mini-btn"]) |
| with gr.Column(scale=0, min_width=190): |
| pause_slice_btn = gr.Button("⏸ 暫停", interactive=True, elem_classes=["playback-mini-btn"]) |
| with gr.Column(scale=3, elem_classes=["viewer-slider-wrap"]): |
| slice_slider = gr.Slider( |
| label="軸向切片(Z 索引)", |
| minimum=0, |
| maximum=0, |
| value=0, |
| step=1, |
| interactive=False, |
| ) |
| with gr.Row(equal_height=True, elem_id="slice-viewer-row"): |
| dwi_img = gr.Image(label="DWI · 病灶疊圖", type="pil", height=820, show_label=True) |
| adc_img = gr.Image(label="ADC · 病灶疊圖", type="pil", height=820, show_label=True) |
| gr.HTML( |
| f""" |
| <p class="playback-hint"> |
| 每 <strong>{_TIMER_INTERVAL_SEC}</strong> 秒切一張,Z 循環(末尾後回到 0)。 |
| 重新執行分析會自動暫停。 |
| </p> |
| """ |
| + ( |
| "" |
| if _HAS_GRADIO_TIMER |
| else '<p class="playback-hint" style="color:#f59e0b;margin-top:8px;">' |
| "目前環境未啟用 <code>gr.Timer</code>,已改用前端自動步進 fallback。</p>" |
| ) |
| ) |
|
|
| if _HAS_GRADIO_TIMER: |
| slice_timer = gr.Timer(_TIMER_INTERVAL_SEC, active=False) |
| else: |
| slice_timer = None |
|
|
| metrics_html = gr.HTML(value=_METRICS_PLACEHOLDER_HTML) |
|
|
| with gr.Accordion("結構化報告(JSON)", open=False, elem_classes=["report-accordion"]): |
| report_box = gr.Code(label="預覽", language="json") |
| report_file = gr.File(label="下載 report.json") |
|
|
| gr.HTML( |
| """ |
| <footer class="app-footer"> |
| 半側分布係依影像陣列第 0 維中線切分,與解剖學左/右之對應請依實際影像方向與 affine 確認。 |
| </footer> |
| """ |
| ) |
|
|
| _run_outputs = [state, slice_slider, dwi_img, adc_img, metrics_html, report_box, report_file] |
| if slice_timer is not None: |
| _run_outputs.append(slice_timer) |
|
|
| _run_fn = prepare_case_stop_timer if slice_timer is not None else prepare_case |
| _load_fn = prepare_default_case_stop_timer if slice_timer is not None else prepare_default_case |
|
|
| run_btn.click( |
| fn=_run_fn, |
| inputs=[dwi_file, adc_file, msk_file], |
| outputs=_run_outputs, |
| js=_js_clear_fallback_autoplay(), |
| ) |
| slice_slider.change( |
| fn=update_slice, |
| inputs=[slice_slider, state], |
| outputs=[dwi_img, adc_img], |
| queue=False, |
| show_progress="hidden", |
| ) |
|
|
| if slice_timer is not None: |
| slice_timer.tick( |
| fn=tick_advance_slice, |
| inputs=[slice_slider, state], |
| outputs=[slice_slider, dwi_img, adc_img], |
| queue=False, |
| show_progress="hidden", |
| ) |
| play_slice_btn.click(lambda: gr.Timer(_TIMER_INTERVAL_SEC, active=True), None, slice_timer) |
| pause_slice_btn.click(lambda: gr.Timer(_TIMER_INTERVAL_SEC, active=False), None, slice_timer) |
| else: |
| play_slice_btn.click(fn=None, inputs=None, outputs=None, js=_js_start_fallback_autoplay(_FALLBACK_INTERVAL_MS)) |
| pause_slice_btn.click(fn=None, inputs=None, outputs=None, js=_js_clear_fallback_autoplay()) |
|
|
| app_ui.load(fn=_load_fn, outputs=_run_outputs) |
|
|
|
|
| |
| |
| |
| def main() -> None: |
| |
| os.environ.setdefault("GRADIO_SERVER_NAME", "0.0.0.0") |
| port = int(os.environ.get("PORT", "7860")) |
| app_ui.launch( |
| server_name="0.0.0.0", |
| server_port=port, |
| show_error=True, |
| show_api=False, |
| inbrowser=False, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|