RECISTto3D / app.py
junma's picture
Ensure checkpoint download runs on Spaces import (not only __main__)
7ba0ab6 verified
Raw
History Blame Contribute Delete
64.3 kB
from __future__ import annotations
import contextlib
import io
import json
import os
import shutil
import subprocess
import threading
import time
import warnings
import uuid
from pathlib import Path
from urllib.parse import quote
import gradio as gr
from run_three_models_parallel import MODELS, format_param_count, load_all_models, run_three_models
ROOT = Path(__file__).resolve().parent
EXAMPLES_DIR = ROOT / "examples"
EXAMPLE_IMAGES = {
"Kidney cancer": EXAMPLES_DIR / "kidney_cancer.nii.gz",
"Liver cancer": EXAMPLES_DIR / "liver_cancer.nii.gz",
"Lung cancer": EXAMPLES_DIR / "lung_cancer.nii.gz",
"Pancreas cancer": EXAMPLES_DIR / "pancreas_cancer.nii.gz",
}
APP_DATA = ROOT / "tmp"/".gradio_recistto3d"
UPLOAD_DIR = APP_DATA / "uploads"
RUN_DIR = APP_DATA / "runs"
WINDOW_PRESET_VALUES = {
"Brain (W:80 L:40)": (80, 40),
"Soft tissues (W:400 L:40)": (400, 40),
"Lungs (W:1500 L:-600)": (1500, -600),
"Bone (W:1800 L:400)": (1800, 400),
}
DEFAULT_WINDOW_PRESET = "Soft tissues (W:400 L:40)"
DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_LEVEL = WINDOW_PRESET_VALUES[DEFAULT_WINDOW_PRESET]
MODEL_LABELS = {
"eff-medsam2": "EfficientMedSAM2",
"medsam2": "MedSAM2",
"nninteractive": "nnInteractive",
}
# Parameter counts of the shipped checkpoints, measured from the loaded weights
# (EfficientTAM ViT-S 34.1M / SAM2.1 Hiera-T 39.0M / nnInteractive net 102.4M).
# Shown in the overlay legend so the size is visible next to each model name.
# These seed the UI before the models finish loading; once loaded, the real
# counts from run_three_models_parallel.count_parameters() take over.
MODEL_PARAM_COUNTS_M = {
"eff-medsam2": 34.1,
"medsam2": 39.0,
"nninteractive": 102.4,
}
_LOADED_MODELS = None
_LOADED_MODELS_DEVICE: str | None = None
_LOADED_MODELS_LOAD_S: float | None = None
_MODEL_LOAD_LOCK = threading.Lock()
_INFERENCE_LOCK = threading.Lock()
def _detect_device_choices() -> list[str]:
cuda_count = 0
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
torch = __import__("torch")
cuda_count = torch.cuda.device_count()
except Exception:
cuda_count = 0
if cuda_count <= 0:
try:
proc = subprocess.run(["nvidia-smi", "-L"], text=True, capture_output=True, check=False)
if proc.returncode == 0:
cuda_count = sum(1 for line in proc.stdout.splitlines() if line.strip().startswith("GPU "))
except OSError:
cuda_count = 0
return ["cpu", *[f"cuda:{idx}" for idx in range(cuda_count)]]
DEVICE_CHOICES = _detect_device_choices()
DEFAULT_DEVICE = "cuda:0" if "cuda:0" in DEVICE_CHOICES else "cpu"
def _env_flag(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _running_on_spaces() -> bool:
return bool(os.environ.get("SPACE_ID") or os.environ.get("SYSTEM") == "spaces")
def _spaces_has_gpu_runtime() -> bool:
"""True when this Space is ZeroGPU / dedicated GPU (weights should load)."""
if _env_flag("SPACES_ZERO_GPU"):
return True
hardware = (
os.environ.get("SPACE_HARDWARE")
or os.environ.get("HF_HARDWARE")
or os.environ.get("SPACES_HARDWARE")
or ""
).strip().lower()
if not hardware:
return False
return "zero" in hardware or any(
token in hardware for token in ("t4", "l4", "a10", "a100", "l40", "gpu")
)
def _skip_weights() -> bool:
"""Skip loading checkpoints into memory (CPU viewer debugging).
Priority:
1. Explicit ``SKIP_WEIGHTS=0/1``
2. Auto: Spaces on CPU (not ZeroGPU/GPU) -> skip load
3. Otherwise load (local CUDA, ZeroGPU, or SKIP_WEIGHTS=0)
Download is controlled separately by ``_should_download_weights``.
"""
if "SKIP_WEIGHTS" in os.environ:
return _env_flag("SKIP_WEIGHTS")
if _running_on_spaces() and not _spaces_has_gpu_runtime() and DEFAULT_DEVICE == "cpu":
return True
return False
def _should_download_weights() -> bool:
"""Whether to fetch checkpoints to disk (even if not loading them)."""
if "DOWNLOAD_WEIGHTS" in os.environ:
return _env_flag("DOWNLOAD_WEIGHTS")
# Default: download on Spaces so cold-start / disk I/O can be measured.
# Local runs usually already have checkpoints from install.sh.
return _running_on_spaces()
SKIP_WEIGHTS = _skip_weights()
DOWNLOAD_WEIGHTS = _should_download_weights()
MEDSAM2_WEIGHT_REPO = "wanglab/MedSAM2"
MEDSAM2_WEIGHT_FILES = (
"medsam2_FLARE25_RECIST_baseline.pt",
"eff_medsam2_small_FLARE25_RECIST_baseline.pt",
)
NNINTERACTIVE_WEIGHT_REPO = "nnInteractive/nnInteractive"
NNINTERACTIVE_WEIGHT_PATTERN = "nnInteractive_v1.0/*"
APP_CSS = """
#recist-line-box {
display: none !important;
}
#example-buttons {
width: 100%;
max-width: 970px;
padding: 10px 0 0;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
#example-buttons .example-buttons-title {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 10px;
text-align: center;
color: #374151;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#example-buttons .example-buttons-title p {
margin: 0;
white-space: nowrap;
}
#example-buttons .example-buttons-title::before,
#example-buttons .example-buttons-title::after {
content: "";
flex: 1;
height: 1px;
background: #d1d5db;
}
#example-buttons button {
border-radius: 10px !important;
background: #ffffff !important;
border: 1px solid #d1d5db !important;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08) !important;
color: #111827 !important;
}
#example-buttons button:hover {
border-color: #7b6cf0 !important;
box-shadow: 0 3px 8px rgba(123, 108, 240, 0.18) !important;
}
#ct-window-preset .wrap {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
"""
CANVAS_HTML = """
<div id="niivue-wrap" style="display:flex;flex-direction:column;line-height:normal;position:relative;width:100%;max-width:960px;">
<canvas id="niivue-gl" width="960" height="720" style="display:block;width:100%;height:auto;background:#000;"></canvas>
<div id="nv-status-bar" style="background:#111120;border-top:1px solid #2a2a3a;padding:5px 8px;font:12px/1.35 'SF Mono',monospace;color:#ddd;width:100%;box-sizing:border-box;min-height:24px;">
<span id="nv-status" style="display:block;max-width:100%;white-space:normal;color:#f5f5f5 !important;background:transparent !important;">Load a NIfTI or example</span>
</div>
</div>
"""
_JS_TEMPLATE = r"""
(async () => {
const { Niivue, NVImage } = await import("https://unpkg.com/@niivue/niivue@0.68.2/dist/index.js");
const canvas = element.querySelector('#niivue-gl');
const wrap = element.querySelector('#niivue-wrap');
const statusBar = element.querySelector('#nv-status-bar');
const status = element.querySelector('#nv-status');
status.style.setProperty('color', '#f5f5f5', 'important');
status.style.setProperty('background', 'transparent', 'important');
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
overlay.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:2;';
wrap.appendChild(overlay);
const loadingOverlay = document.createElement('div');
loadingOverlay.style.cssText = `
position:absolute;left:0;top:0;display:none;align-items:center;justify-content:center;
z-index:3;pointer-events:none;background:rgba(5,8,18,0.55);
color:#ffffff;font:600 18px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
text-shadow:0 1px 4px rgba(0,0,0,0.75);
`;
loadingOverlay.textContent = 'Loading...';
wrap.appendChild(loadingOverlay);
if (!document.getElementById('nv-range-style')) {
const s = document.createElement('style');
s.id = 'nv-range-style';
s.textContent = `
.nv-r { position:absolute; width:100%; height:100%; top:0; left:0; margin:0;
background:transparent; pointer-events:none;
-webkit-appearance:none; appearance:none; }
.nv-r::-webkit-slider-runnable-track { height:3px; background:transparent; }
.nv-r::-webkit-slider-thumb { pointer-events:all; -webkit-appearance:none;
width:11px; height:11px; border-radius:50%; background:#7b6cf0;
cursor:pointer; margin-top:-4px; }
.recist-table { background:#050816 !important; color:#f8fbff !important; }
.recist-table th {
background:#243056 !important; color:#ffffff !important;
border-bottom:1px solid #8fa4ff !important;
}
.recist-table td {
background:#10162b !important; color:#f8fbff !important;
border-bottom:1px solid #56627f !important;
}
.recist-table-row { background:#10162b !important; color:#f8fbff !important; }
.recist-table-row:hover td { background:#31406c !important; color:#ffffff !important; }
.recist-table-row.active td { background:#455c9a !important; color:#ffffff !important; }
.recist-delete {
padding:2px 7px; border-radius:3px; border:1px solid #ff9cc0 !important;
background:#5f1438 !important; color:#ffffff !important; cursor:pointer; font:12px/1.4 monospace;
}
.recist-delete:hover { border-color:#ffd0df !important; color:#ffffff !important; background:#8f2453 !important; }
`;
document.head.appendChild(s);
}
const bar = document.createElement('div');
bar.style.cssText = `
display:flex; align-items:center; flex-wrap:wrap; gap:6px;
background:#1a1a2e; padding:6px 8px;
font:12px/1.2 'SF Mono',monospace; color:#aaa;
width:100%; box-sizing:border-box;
`;
wrap.insertBefore(bar, statusBar);
const wlDiv = document.createElement('div');
wlDiv.style.cssText = 'display:flex; align-items:center; gap:5px; flex:0 1 auto;';
wlDiv.innerHTML = `
<span style="color:#666;font-size:12px;">WL</span>
<div style="position:relative;width:110px;height:16px;">
<div style="position:absolute;top:50%;left:0;right:0;height:3px;
margin-top:-1.5px;background:#333;border-radius:2px;"></div>
<div id="nv-wl-fill" style="position:absolute;top:50%;height:3px;
margin-top:-1.5px;background:#7b6cf0;border-radius:2px;"></div>
<input id="nv-wl-lo" class="nv-r" type="range" min="-1500" max="3000" value="-160">
<input id="nv-wl-hi" class="nv-r" type="range" min="-1500" max="3000" value="240">
</div>
<span id="nv-wl-lbl" style="min-width:95px;color:#ddd;font-size:12px;">W:400 L:40</span>
`;
bar.appendChild(wlDiv);
const sliceDiv = document.createElement('div');
sliceDiv.style.cssText = 'display:flex; align-items:center; gap:5px; flex:0 1 auto;';
sliceDiv.innerHTML = `
<span id="nv-slice-lbl" style="min-width:50px;color:#ddd;font-size:12px;">0/0</span>
<input id="nv-slice" type="range" min="0" max="1" value="0"
style="width:130px;accent-color:#7b6cf0;cursor:pointer;height:16px;">
`;
bar.appendChild(sliceDiv);
const sp = document.createElement('div');
sp.style.flex = '1 1 24px';
bar.appendChild(sp);
const BTN_DEFAULT = 'padding:3px 8px;border-radius:3px;cursor:pointer;font:12px/1.5 monospace;border:1px solid #444;background:transparent;color:#aaa;white-space:nowrap;';
const BTN_ACTIVE = 'padding:3px 8px;border-radius:3px;cursor:pointer;font:12px/1.5 monospace;border:1px solid #7b6cf0;background:#5a4db8;color:#fff;white-space:nowrap;';
const BTN_DRAW_ACTIVE = 'padding:3px 8px;border-radius:3px;cursor:pointer;font:12px/1.5 monospace;border:1px solid #00ff8a;background:#08754a;color:#fff;white-space:nowrap;';
const drawBtn = document.createElement('button');
drawBtn.textContent = 'Draw RECIST';
drawBtn.style.cssText = BTN_DEFAULT;
bar.appendChild(drawBtn);
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear';
clearBtn.style.cssText = BTN_DEFAULT;
bar.appendChild(clearBtn);
const MODEL_MASKS = [
{ key: 'eff-medsam2', label: 'EfficientMedSAM2', size: '__SIZE_EFF__', colormap: 'recist_eff_medsam2', color: '#ff4d4d', rgb: [255, 77, 77], opacity: 0.50},
{ key: 'medsam2', label: 'MedSAM2', size: '__SIZE_MEDSAM2__', colormap: 'recist_medsam2', color: '#31d158', rgb: [49, 209, 88], opacity: 0.50 },
{ key: 'nninteractive', label: 'nnInteractive', size: '__SIZE_NNINTERACTIVE__', colormap: 'recist_nninteractive', color: '#4cc9f0', rgb: [76, 201, 240], opacity: 0.50 },
];
const VIEWS = [[0,'Axial'],[1,'Coronal'],[2,'Sagittal'],[3,'Multi']];
const btnMap = {};
VIEWS.forEach(([t, name]) => {
const btn = document.createElement('button');
btn.textContent = name;
btn.style.cssText = BTN_DEFAULT;
btn.onclick = () => setView(t);
btnMap[t] = btn;
bar.appendChild(btn);
});
const maskControls = document.createElement('div');
maskControls.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#ddd;margin-left:4px;';
maskControls.innerHTML = MODEL_MASKS.map(model => `
<label title="Show ${model.label} overlay (${model.size} parameters)" style="display:flex;align-items:center;gap:3px;cursor:pointer;white-space:nowrap;color:#ddd;">
<input class="model-mask-check" type="checkbox" data-model="${model.key}" checked
style="accent-color:${model.color};cursor:pointer;">
<span style="color:${model.color};">${model.label}</span>
<span style="color:#9aa;font-size:11px;">(${model.size})</span>
</label>
`).join('');
bar.appendChild(maskControls);
maskControls.querySelectorAll('.model-mask-check').forEach(input => {
input.addEventListener('change', () => applyModelMaskVisibility());
});
const tablePanel = document.createElement('div');
tablePanel.style.cssText = `
width:100%; box-sizing:border-box; background:#050816; color:#f8fbff;
border-top:1px solid #8fa4ff; padding:8px 10px;
font:12px/1.35 'SF Mono',monospace; overflow-x:auto;
`;
wrap.appendChild(tablePanel);
let nSlices = 1;
let recistLines = [];
let activeView = 0;
let drawMode = false;
let isRadiological = true;
let isDragging = false;
let dragStart = null;
let previewLine = null;
let selectedLabel = null;
let currentImageUrl = '';
let maskVolumeIndexByKey = {};
let loadingLockDepth = 0;
const lockedControlStates = new WeakMap();
const lockedControls = new Set();
const RECIST_COLORS = ['#00ff8a', '#ffd166', '#4cc9f0', '#f72585', '#f77f00', '#b8f2e6', '#c77dff', '#90be6d'];
const nv = new Niivue({
backColor: [0.05, 0.05, 0.1, 1],
isResizeCanvas: true,
loadingText: '',
});
await nv.attachTo('niivue-gl');
MODEL_MASKS.forEach(model => {
if (typeof nv.addColormap === 'function') {
nv.addColormap(model.colormap, {
R: [0, model.rgb[0]],
G: [0, model.rgb[1]],
B: [0, model.rgb[2]],
A: [0, 255],
I: [0, 255],
});
}
});
nv.setDrawingEnabled(false);
const sliceSlider = document.getElementById('nv-slice');
const sliceLbl = document.getElementById('nv-slice-lbl');
const wlLo = document.getElementById('nv-wl-lo');
const wlHi = document.getElementById('nv-wl-hi');
const wlLbl = document.getElementById('nv-wl-lbl');
const wlFill = document.getElementById('nv-wl-fill');
const WL_MIN = -1500, WL_MAX = 3000, WL_SPAN = WL_MAX - WL_MIN;
const WL_DEFAULT_LOW = -160, WL_DEFAULT_HIGH = 240;
function setGradioTextbox(elemId, value) {
const host = document.getElementById(elemId);
const input = host?.querySelector('textarea, input');
if (!input) return;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function updateOverlaySize() {
const rect = canvas.getBoundingClientRect();
overlay.setAttribute('width', rect.width);
overlay.setAttribute('height', rect.height);
overlay.setAttribute('viewBox', `0 0 ${rect.width} ${rect.height}`);
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
loadingOverlay.style.width = rect.width + 'px';
loadingOverlay.style.height = rect.height + 'px';
}
function addOverlayLine(start, end, color, strokeWidth = 3) {
if (!start || !end) return;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', start[0]);
line.setAttribute('y1', start[1]);
line.setAttribute('x2', end[0]);
line.setAttribute('y2', end[1]);
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', strokeWidth);
line.setAttribute('stroke-linecap', 'round');
overlay.appendChild(line);
[start, end].forEach(pt => {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', pt[0]);
circle.setAttribute('cy', pt[1]);
circle.setAttribute('r', 4);
circle.setAttribute('fill', color);
overlay.appendChild(circle);
});
}
function renderRecistOverlays() {
updateOverlaySize();
overlay.replaceChildren();
if (activeView !== 0) return;
const z = curSlice();
recistLines
.filter(line => line.z === z)
.forEach(line => {
const [startScreen, endScreen] = lineScreenPointsFromVox(line);
if (startScreen && endScreen) {
line.startScreen = startScreen;
line.endScreen = endScreen;
addOverlayLine(startScreen, endScreen, line.color, line.label === selectedLabel ? 5 : 3);
}
});
if (previewLine) addOverlayLine(previewLine.startScreen, previewLine.endScreen, previewLine.color, 4);
}
function pct(v) { return (v - WL_MIN) / WL_SPAN * 100; }
function syncWLFill() {
const lo = Math.min(+wlLo.value, +wlHi.value);
const hi = Math.max(+wlLo.value, +wlHi.value);
wlFill.style.left = pct(lo) + '%';
wlFill.style.width = (pct(hi) - pct(lo)) + '%';
wlFill.style.right = 'auto';
}
function wlLabel(lo, hi) {
const width = Math.round(hi - lo);
const level = Math.round((hi + lo) / 2);
return `W:${width} L:${level}`;
}
function applyWL() {
const vol = nv.volumes?.[0];
if (!vol) return;
const lo = Math.min(+wlLo.value, +wlHi.value);
const hi = Math.max(+wlLo.value, +wlHi.value);
vol.cal_min = lo; vol.cal_max = hi;
if (nv.updateGLVolume) nv.updateGLVolume(); else nv.drawScene();
wlLbl.textContent = wlLabel(lo, hi);
syncWLFill();
}
wlLo.oninput = wlHi.oninput = applyWL;
function setWindowLevel(width, level) {
const w = Number(width);
const l = Number(level);
if (!Number.isFinite(w) || !Number.isFinite(l) || w <= 0) return;
const lo = Math.max(WL_MIN, Math.min(WL_MAX, Math.round(l - w / 2)));
const hi = Math.max(WL_MIN, Math.min(WL_MAX, Math.round(l + w / 2)));
wlLo.value = Math.min(lo, hi);
wlHi.value = Math.max(lo, hi);
wlLbl.textContent = wlLabel(+wlLo.value, +wlHi.value);
syncWLFill();
applyWL();
}
function modelMaskChecked(key) {
const input = maskControls.querySelector(`.model-mask-check[data-model="${key}"]`);
return !input || input.checked;
}
function setVolumeOpacity(volumeIndex, opacity) {
if (!Number.isInteger(volumeIndex) || volumeIndex < 0 || !nv.volumes?.[volumeIndex]) return;
if (typeof nv.setOpacity === 'function') {
nv.setOpacity(volumeIndex, opacity);
return;
}
nv.volumes[volumeIndex].opacity = opacity;
if (nv.updateGLVolume) nv.updateGLVolume(); else if (nv.drawScene) nv.drawScene();
}
function applyModelMaskVisibility() {
MODEL_MASKS.forEach(model => {
const volumeIndex = maskVolumeIndexByKey[model.key];
setVolumeOpacity(volumeIndex, modelMaskChecked(model.key) ? model.opacity : 0);
});
if (nv.drawScene) nv.drawScene();
}
function resetModelMaskChecks() {
maskControls.querySelectorAll('.model-mask-check').forEach(input => {
input.checked = true;
});
}
function clearModelMasks() {
const indices = Object.values(maskVolumeIndexByKey)
.filter(index => Number.isInteger(index) && index > 0)
.sort((a, b) => b - a);
indices.forEach(index => {
const vol = nv.volumes?.[index];
if (vol && typeof nv.removeVolume === 'function') nv.removeVolume(vol);
});
maskVolumeIndexByKey = {};
resetModelMaskChecks();
if (nv.updateGLVolume) nv.updateGLVolume(); else if (nv.drawScene) nv.drawScene();
}
function curSlice() {
return Math.round((nv.scene?.crosshairPos?.[2] ?? 0.5) * (nSlices - 1));
}
function syncSlice() {
const c = curSlice();
sliceLbl.textContent = c + '/' + (nSlices - 1);
sliceSlider.value = c;
}
sliceSlider.oninput = () => {
const idx = +sliceSlider.value;
if (nv.scene?.crosshairPos && nSlices > 1) nv.scene.crosshairPos[2] = idx / (nSlices - 1);
nv.drawScene();
sliceLbl.textContent = idx + '/' + (nSlices - 1);
renderRecistOverlays();
};
function setView(t) {
activeView = t;
if (t !== 0 && drawMode) {
drawMode = false;
isDragging = false;
dragStart = null;
previewLine = null;
drawBtn.style.cssText = BTN_DEFAULT;
status.textContent = 'RECIST draw mode off outside axial view';
}
if (t === 3) {
canvas.width = 960; canvas.height = 960;
} else {
canvas.width = 960; canvas.height = 720;
}
nv.opts.multiplanarShowRender = (t === 3) ? 1 : 0;
nv.resizeListener();
nv.setSliceType(t);
updateOverlaySize();
Object.values(btnMap).forEach(b => b.style.cssText = BTN_DEFAULT);
btnMap[t].style.cssText = BTN_ACTIVE;
sliceDiv.style.display = (t === 0) ? 'flex' : 'none';
if (t === 0) syncSlice();
renderRecistOverlays();
}
// Coordinates here are simple: the uploaded/example volume is normalised to a
// canonical LPS orientation on the Python side (see prepare_uploaded_image /
// load_example_image), so NiiVue's display and the model's sitk array agree.
function pointFromEvent(ev) {
if (!nv.volumes?.[0] || typeof nv.canvasPos2frac !== 'function' || typeof nv.frac2vox !== 'function') {
status.textContent = 'NiiVue coordinate API unavailable';
return null;
}
const rect = canvas.getBoundingClientRect();
const cssX = ev.clientX - rect.left;
const cssY = ev.clientY - rect.top;
const canvasX = cssX * (canvas.width / rect.width);
const canvasY = cssY * (canvas.height / rect.height);
const frac = nv.canvasPos2frac([canvasX, canvasY]);
if (!frac || frac.some(v => !Number.isFinite(v) || v < -0.001 || v > 1.001)) return null;
const vox = nv.frac2vox(frac).map(v => Math.round(v));
const dims = nv.volumes[0].dims || [];
const maxX = (dims[1] || 1) - 1;
const maxY = (dims[2] || 1) - 1;
const maxZ = (dims[3] || nSlices || 1) - 1;
const x = Math.max(0, Math.min(maxX, vox[0]));
const y = Math.max(0, Math.min(maxY, vox[1]));
const z = Math.max(0, Math.min(maxZ, vox[2]));
return {
screen: [cssX, cssY],
vox: [isRadiological ? maxX - x : x, maxY - y, z],
};
}
function recistLineText(line) {
return `${line.z},${line.x1},${line.y1},${line.x2},${line.y2},${line.label}`;
}
function syncRecistTextbox() {
setGradioTextbox('recist-line-box', recistLines.map(recistLineText).join('\n'));
}
function nextRecistLabel() {
return recistLines.reduce((maxLabel, line) => Math.max(maxLabel, line.label), 0) + 1;
}
function lineLength(line) {
// RECIST is clinically a millimetre long-axis measurement, so scale the
// voxel delta by in-plane spacing (pixDims[1]=x, pixDims[2]=y). NiiVue
// reports pixDims in mm for NIfTI. Fall back to raw voxels when spacing is
// missing/degenerate so the column never silently reads 0.0.
const pd = nv.volumes?.[0]?.pixDims;
const sx = Number(pd?.[1]);
const sy = Number(pd?.[2]);
const ok = Number.isFinite(sx) && Number.isFinite(sy) && sx > 0 && sy > 0;
const dx = line.x2 - line.x1;
const dy = line.y2 - line.y1;
if (!ok) return Math.hypot(dx, dy).toFixed(1) + ' vox';
return Math.hypot(dx * sx, dy * sy).toFixed(1);
}
function lineScreenPointsFromVox(line) {
if (!nv.volumes?.[0]) return [line.startScreen || null, line.endScreen || null];
const dims = nv.volumes[0].dims || [];
const maxX = (dims[1] || 1) - 1;
const maxY = (dims[2] || 1) - 1;
const rect = canvas.getBoundingClientRect();
const x1 = line.x1;
const x2 = line.x2;
const y1 = line.y1;
const y2 = line.y2;
if (typeof nv.vox2frac === 'function' && typeof nv.frac2canvas === 'function') {
const start = nv.frac2canvas(nv.vox2frac([x1, y1, line.z]));
const end = nv.frac2canvas(nv.vox2frac([x2, y2, line.z]));
if (start && end) {
return [
[start[0] * (rect.width / canvas.width), start[1] * (rect.height / canvas.height)],
[end[0] * (rect.width / canvas.width), end[1] * (rect.height / canvas.height)],
];
}
}
const scale = Math.min(rect.width / (maxX + 1), rect.height / (maxY + 1));
const offsetX = (rect.width - (maxX + 1) * scale) / 2;
const offsetY = (rect.height - (maxY + 1) * scale) / 2;
return [
[offsetX + (x1 + 0.5) * scale, offsetY + (y1 + 0.5) * scale],
[offsetX + (x2 + 0.5) * scale, offsetY + (y2 + 0.5) * scale],
];
}
function parseRecistLineValue(value) {
const parts = String(value || '').trim().replaceAll(',', ' ').split(/\s+/).filter(Boolean).map(Number);
if (!parts.length) throw new Error('Enter RECIST as z,x1,y1,x2,y2 or z,x1,y1,x2,y2,label.');
if (parts.some(v => !Number.isFinite(v))) throw new Error('RECIST line contains a non-numeric value.');
if (![5, 6].includes(parts.length)) throw new Error('RECIST line must be z,x1,y1,x2,y2 or z,x1,y1,x2,y2,label.');
const ints = parts.map(v => Math.round(v));
const label = ints.length === 6 ? ints[5] : nextRecistLabel();
if (label <= 0) throw new Error('RECIST label must be a positive nonzero integer.');
if (recistLines.some(line => line.label === label)) throw new Error(`RECIST label ${label} already exists.`);
return {
label,
z: ints[0],
x1: ints[1],
y1: ints[2],
x2: ints[3],
y2: ints[4],
color: RECIST_COLORS[(label - 1) % RECIST_COLORS.length],
};
}
function addRecistLine(line, jumpToLine = false) {
const [startScreen, endScreen] = lineScreenPointsFromVox(line);
const storedLine = { ...line, startScreen, endScreen };
recistLines.push(storedLine);
selectedLabel = line.label;
if (jumpToLine && nv.volumes?.[0]) {
jumpToRecistLine(storedLine);
} else {
syncRecistUi();
status.textContent = nv.volumes?.[0]
? 'RECIST: ' + recistLineText(line)
: 'RECIST added. Load an image to display the overlay.';
}
}
function addManualRecistLine(value) {
try {
addRecistLine(parseRecistLineValue(value), true);
} catch (err) {
status.textContent = err.message || String(err);
}
}
function renderRecistTable() {
if (!recistLines.length) {
tablePanel.innerHTML = '<span style="color:#d5dcff;">No RECIST lines yet. Click Draw RECIST and drag on an axial slice.</span>';
return;
}
tablePanel.innerHTML = `
<table class="recist-table" style="width:100%;border-collapse:collapse;min-width:620px;">
<thead>
<tr style="color:#ffffff;text-align:left;">
<th style="padding:5px 7px;">Label</th>
<th style="padding:5px 7px;">Z</th>
<th style="padding:5px 7px;">X1</th>
<th style="padding:5px 7px;">Y1</th>
<th style="padding:5px 7px;">X2</th>
<th style="padding:5px 7px;">Y2</th>
<th style="padding:5px 7px;">Length(mm)</th>
<th style="padding:5px 7px;">Color</th>
<th style="padding:5px 7px;">Action</th>
</tr>
</thead>
<tbody>
${recistLines.map(line => `
<tr class="recist-table-row${line.label === selectedLabel ? ' active' : ''}" data-label="${line.label}" style="cursor:pointer;">
<td style="padding:5px 7px;font-weight:700;">${line.label}</td>
<td style="padding:5px 7px;">${line.z}</td>
<td style="padding:5px 7px;">${line.x1}</td>
<td style="padding:5px 7px;">${line.y1}</td>
<td style="padding:5px 7px;">${line.x2}</td>
<td style="padding:5px 7px;">${line.y2}</td>
<td style="padding:5px 7px;">${lineLength(line)}</td>
<td style="padding:5px 7px;"><span style="display:inline-block;width:48px;height:12px;border-radius:8px;border:1px solid #fff;background:${line.color};"></span></td>
<td style="padding:5px 7px;"><button class="recist-delete" data-label="${line.label}">Delete</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
tablePanel.querySelectorAll('.recist-table-row').forEach(row => {
row.addEventListener('click', () => {
const line = recistLines.find(item => item.label === Number(row.dataset.label));
if (line) jumpToRecistLine(line);
});
});
tablePanel.querySelectorAll('.recist-delete').forEach(btn => {
btn.addEventListener('click', ev => {
ev.stopPropagation();
deleteRecistLine(Number(btn.dataset.label));
});
});
}
function syncRecistUi() {
syncRecistTextbox();
renderRecistTable();
renderRecistOverlays();
}
function clearAllRecist() {
recistLines = [];
selectedLabel = null;
previewLine = null;
dragStart = null;
isDragging = false;
syncRecistUi();
}
function clearMaskDrawing() {
if (nv.closeDrawing) nv.closeDrawing();
nv.setDrawingEnabled(false);
if (nv.drawScene) nv.drawScene();
}
function clearViewerAnnotations() {
clearAllRecist();
clearModelMasks();
clearMaskDrawing();
status.textContent = 'Cleared RECIST lines and model overlays';
}
function deleteRecistLine(label) {
recistLines = recistLines.filter(line => line.label !== label);
if (selectedLabel === label) selectedLabel = null;
syncRecistUi();
status.textContent = `RECIST label ${label} deleted`;
}
function jumpToRecistLine(line) {
selectedLabel = line.label;
setView(0);
if (nv.scene?.crosshairPos && nSlices > 1) nv.scene.crosshairPos[2] = line.z / (nSlices - 1);
syncSlice();
if (nv.drawScene) nv.drawScene();
renderRecistTable();
renderRecistOverlays();
status.textContent = `RECIST label ${line.label}: z=${line.z}`;
}
function paintStatus(message) {
status.textContent = message;
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
function isVisibleControl(control) {
const style = window.getComputedStyle(control);
return style.display !== 'none' && style.visibility !== 'hidden' && control.getClientRects().length > 0;
}
function setLoadingControlsLocked(locked) {
const appRoot = document.querySelector('.gradio-container') || document.body;
const controls = Array.from(appRoot.querySelectorAll('button, input, select, textarea'))
.filter(isVisibleControl);
if (locked) {
controls.forEach(control => {
if (!lockedControlStates.has(control)) {
lockedControlStates.set(control, {
disabled: control.disabled,
ariaDisabled: control.getAttribute('aria-disabled'),
cursor: control.style.cursor,
});
}
lockedControls.add(control);
control.disabled = true;
control.setAttribute('aria-disabled', 'true');
control.style.cursor = 'not-allowed';
});
return;
}
lockedControls.forEach(control => {
const state = lockedControlStates.get(control);
if (!state) return;
control.disabled = state.disabled;
if (state.ariaDisabled === null) {
control.removeAttribute('aria-disabled');
} else {
control.setAttribute('aria-disabled', state.ariaDisabled);
}
control.style.cursor = state.cursor;
lockedControlStates.delete(control);
});
lockedControls.clear();
}
function showLoading(message = 'Loading...') {
loadingLockDepth += 1;
setLoadingControlsLocked(true);
loadingOverlay.textContent = message;
updateOverlaySize();
loadingOverlay.style.pointerEvents = 'all';
loadingOverlay.style.display = 'flex';
}
function hideLoading() {
loadingLockDepth = Math.max(0, loadingLockDepth - 1);
if (loadingLockDepth > 0) return;
loadingOverlay.style.display = 'none';
loadingOverlay.style.pointerEvents = 'none';
setLoadingControlsLocked(false);
}
function applyOrientation() {
nv.setRadiologicalConvention(isRadiological);
if (nv.drawScene) nv.drawScene();
}
drawBtn.onclick = () => {
drawMode = !drawMode;
drawBtn.style.cssText = drawMode ? BTN_DRAW_ACTIVE : BTN_DEFAULT;
if (drawMode) {
setView(0);
status.textContent = 'Drag a RECIST line on the axial slice';
} else {
status.textContent = 'RECIST draw mode off';
}
};
clearBtn.onclick = clearViewerAnnotations;
canvas.addEventListener('mousedown', (ev) => {
if (!drawMode || activeView !== 0) return;
ev.preventDefault();
ev.stopPropagation();
const pt = pointFromEvent(ev);
if (!pt) return;
isDragging = true;
dragStart = pt;
previewLine = {
z: curSlice(),
color: RECIST_COLORS[recistLines.length % RECIST_COLORS.length],
startScreen: dragStart.screen,
endScreen: dragStart.screen,
};
renderRecistOverlays();
}, true);
canvas.addEventListener('mousemove', (ev) => {
if (!drawMode || activeView !== 0 || !isDragging || !dragStart) return;
ev.preventDefault();
ev.stopPropagation();
const pt = pointFromEvent(ev);
if (pt) {
previewLine = {
z: curSlice(),
color: RECIST_COLORS[recistLines.length % RECIST_COLORS.length],
startScreen: dragStart.screen,
endScreen: pt.screen,
};
renderRecistOverlays();
}
}, true);
window.addEventListener('mouseup', (ev) => {
if (!drawMode || activeView !== 0 || !isDragging || !dragStart) return;
ev.preventDefault();
const pt = pointFromEvent(ev);
isDragging = false;
if (!pt) {
previewLine = null;
renderRecistOverlays();
return;
}
const z = curSlice();
const label = nextRecistLabel();
const line = {
label,
z,
x1: dragStart.vox[0],
y1: dragStart.vox[1],
x2: pt.vox[0],
y2: pt.vox[1],
color: RECIST_COLORS[(label - 1) % RECIST_COLORS.length],
startScreen: dragStart.screen,
endScreen: pt.screen,
};
recistLines.push(line);
selectedLabel = label;
previewLine = null;
syncRecistUi();
status.textContent = 'RECIST: ' + recistLineText(line);
}, true);
async function loadImage(imageUrl) {
if (!imageUrl) {
status.textContent = 'No image selected';
return;
}
currentImageUrl = imageUrl;
maskVolumeIndexByKey = {};
clearAllRecist();
clearMaskDrawing();
showLoading('Loading image...');
try {
await paintStatus('Loading image...');
await nv.loadVolumes([{ url: imageUrl, name: 'image.nii.gz' }]);
applyOrientation();
nv.setDrawingEnabled(false);
const vol = nv.volumes[0];
nSlices = (vol.dims && vol.dims[3]) ? vol.dims[3] : 1;
sliceSlider.max = Math.max(0, nSlices - 1);
wlLo.value = WL_DEFAULT_LOW;
wlHi.value = WL_DEFAULT_HIGH;
applyWL();
setView(0);
status.textContent = 'Image loaded. Draw RECIST lines.';
} finally {
hideLoading();
}
}
function modelMaskVolume(model, url) {
return {
url,
name: `${model.key}.nii.gz`,
colormap: model.colormap,
colorMap: model.colormap,
opacity: modelMaskChecked(model.key) ? model.opacity : 0,
cal_min: 0,
cal_max: 1,
};
}
async function loadModelMasks(effMaskUrl = '', medsam2MaskUrl = '', nninteractiveMaskUrl = '') {
const urlsByKey = {
'eff-medsam2': effMaskUrl,
medsam2: medsam2MaskUrl,
nninteractive: nninteractiveMaskUrl,
};
const available = MODEL_MASKS.filter(model => urlsByKey[model.key]);
if (!available.length) {
status.textContent = 'No model masks to load';
return;
}
if (!currentImageUrl) {
status.textContent = 'Load an image before loading model masks';
return;
}
showLoading('Loading model masks...');
try {
await paintStatus('Loading model masks...');
if (nv.closeDrawing) nv.closeDrawing();
const previousSlice = curSlice();
const loadedMasks = await Promise.all(
available.map(model => NVImage.loadFromUrl(modelMaskVolume(model, urlsByKey[model.key])))
);
clearModelMasks();
available.forEach((model, index) => {
nv.addVolume(loadedMasks[index]);
maskVolumeIndexByKey[model.key] = nv.volumes.length - 1;
});
applyOrientation();
const vol = nv.volumes[0];
nSlices = (vol.dims && vol.dims[3]) ? vol.dims[3] : 1;
sliceSlider.max = Math.max(0, nSlices - 1);
if (nv.scene?.crosshairPos && nSlices > 1) {
const restoredSlice = Math.max(0, Math.min(nSlices - 1, previousSlice));
nv.scene.crosshairPos[2] = restoredSlice / (nSlices - 1);
sliceSlider.value = restoredSlice;
}
nv.setDrawingEnabled(false);
applyWL();
applyModelMaskVisibility();
setView(activeView);
status.textContent = 'Model masks loaded as overlays';
} catch (err) {
status.textContent = 'Failed to load model overlays: ' + (err?.message || String(err));
throw err;
} finally {
hideLoading();
}
}
window.recistTo3DViewer = {
loadImage,
loadModelMasks,
setWindowLevel,
addManualRecistLine,
clearViewerAnnotations,
getRecistLine: () => recistLines.map(recistLineText).join('\n') || document.querySelector('#recist-line-box textarea, #recist-line-box input')?.value || '',
debugState: () => ({
status: status.textContent,
nVolumes: nv.volumes?.length || 0,
volumes: (nv.volumes || []).map((vol, index) => ({
index,
name: vol.name,
colormap: vol.colormap,
opacity: vol.opacity,
cal_min: vol.cal_min,
cal_max: vol.cal_max,
dims: vol.dims,
})),
maskVolumeIndexByKey,
nSlices,
currentSlice: curSlice(),
}),
};
setView(0);
applyOrientation();
updateOverlaySize();
renderRecistTable();
setInterval(() => {
if (sliceDiv.style.display !== 'none' && nv.volumes?.[0]) {
syncSlice();
renderRecistOverlays();
}
}, 400);
})();
"""
def _format_size_m(model: str) -> str:
"""Parameter count for the legend, e.g. "34.1M"."""
return f"{MODEL_PARAM_COUNTS_M[model]:.1f}M"
JS_ON_LOAD = (
_JS_TEMPLATE.replace("__SIZE_EFF__", _format_size_m("eff-medsam2"))
.replace("__SIZE_MEDSAM2__", _format_size_m("medsam2"))
.replace("__SIZE_NNINTERACTIVE__", _format_size_m("nninteractive"))
)
def _ensure_dirs() -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
RUN_DIR.mkdir(parents=True, exist_ok=True)
def _file_url(path: str | Path) -> str:
return f"/gradio_api/file={quote(str(Path(path).resolve()))}"
def _redact_log_paths(text: str, extra_paths: list[Path] | None = None) -> str:
replacements: list[tuple[str, str]] = [
(str(RUN_DIR.resolve()), "<RUN_DIR>"),
(str(UPLOAD_DIR.resolve()), "<UPLOAD_DIR>"),
(str(APP_DATA.resolve()), "<APP_DATA>"),
(str(EXAMPLES_DIR.resolve()), "<EXAMPLES>"),
(str(ROOT.resolve()), "<ROOT>"),
(str(Path.home().resolve()), "<HOME>"),
]
for path in extra_paths or []:
try:
replacements.append((str(path.resolve()), f"<{path.name}>"))
except OSError:
replacements.append((str(path), f"<{path.name}>"))
redacted = text
for raw, replacement in sorted(replacements, key=lambda item: len(item[0]), reverse=True):
redacted = redacted.replace(raw, replacement)
return redacted
def _nifti_suffix(path: str | Path) -> str:
name = Path(path).name.lower()
if name.endswith(".nii.gz"):
return ".nii.gz"
if name.endswith(".nii"):
return ".nii"
raise gr.Error("Please upload a .nii or .nii.gz file.")
NORMALIZED_DIR = APP_DATA / "normalized"
def _normalize_orientation(src: Path, dst: Path) -> Path:
"""Write an orientation-normalised (canonical LPS) copy of ``src`` to ``dst``.
NiiVue displays a volume using the NIfTI affine (RAS+ convention) while the
model reads the sitk array in native stored order (LPS+ convention). When a
volume's stored buffer is mirrored/permuted relative to canonical LPS, the two
disagree and RECIST coordinates land in the wrong place (e.g. the drawn line
jumps to the opposite corner, and the predicted mask is mirrored). Reorienting
both the buffer and the header to a single canonical orientation makes the
viewer and the model agree for any input. If the volume is already canonical
LPS the source is copied unchanged.
"""
import numpy as np
import SimpleITK as sitk
img = sitk.ReadImage(str(src))
direction = np.array(img.GetDirection()).reshape(3, 3)
if np.allclose(direction, np.eye(3)):
shutil.copy2(src, dst)
return dst
oriented = sitk.DICOMOrient(img, "LPS")
sitk.WriteImage(oriented, str(dst))
return dst
def _copy_upload(file_path: str) -> Path:
_ensure_dirs()
src = Path(file_path)
suffix = _nifti_suffix(src)
dst = UPLOAD_DIR / f"{int(time.time())}_{uuid.uuid4().hex[:8]}{suffix}"
_normalize_orientation(src, dst)
return dst
def _example_normalized(src: Path) -> Path:
"""Return a cached canonical-LPS copy of a read-only example volume."""
_ensure_dirs()
NORMALIZED_DIR.mkdir(parents=True, exist_ok=True)
suffix = _nifti_suffix(src)
stem = src.name[: -len(suffix)]
dst = NORMALIZED_DIR / f"{stem}_{int(src.stat().st_mtime)}{suffix}"
if not dst.exists():
_normalize_orientation(src, dst)
return dst
def prepare_uploaded_image(file_path: str | None):
if not file_path:
return (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"Please select a NIfTI file first.",
"",
)
image_path = _copy_upload(file_path)
return (
str(image_path),
_file_url(image_path),
"",
"",
"",
"",
"",
"",
"",
f"Uploaded image loaded: {image_path.name}",
"",
)
def load_example_image(example_path: str, window_preset: str):
image = Path(example_path)
if not image.exists():
raise gr.Error(f"Example file not found: {image}")
image = _example_normalized(image)
window_width, window_level = WINDOW_PRESET_VALUES[window_preset]
return (
str(image),
_file_url(image),
"",
"",
"",
"",
"",
"",
"",
f"Example image loaded: {image.name}",
"",
window_preset,
window_width,
window_level,
)
def _validate_recist_lines(value: str) -> list[str]:
lines: list[str] = []
labels: set[int] = set()
for line_number, raw_line in enumerate((value or "").splitlines(), start=1):
raw_line = raw_line.strip()
if not raw_line:
continue
try:
parts = [int(round(float(x))) for x in raw_line.replace(",", " ").split()]
except ValueError as exc:
raise gr.Error(f"RECIST line {line_number} contains a non-numeric value.") from exc
if len(parts) == 5:
parts.append(len(lines) + 1)
elif len(parts) != 6:
raise gr.Error(
"Please draw at least one RECIST line on the axial image first. "
"Expected one line per row: z,x1,y1,x2,y2,label."
)
label = parts[-1]
if label <= 0:
raise gr.Error(f"RECIST line {line_number} label must be a positive nonzero integer.")
if label in labels:
raise gr.Error(f"RECIST label {label} is duplicated. Each line needs a unique label.")
labels.add(label)
lines.append(",".join(str(v) for v in parts))
if not lines:
raise gr.Error("Please draw at least one RECIST line on the axial image first.")
return lines
def _window_bounds_from_wl(width: float, level: float) -> str:
width_int = int(round(float(width)))
level_int = int(round(float(level)))
if width_int <= 0:
raise gr.Error("Custom CT Window W must be a positive integer.")
low = level_int - width_int / 2
high = level_int + width_int / 2
return f"{low:g},{high:g}"
def _window_values_for_preset(window_preset: str | None):
if not window_preset:
return gr.update(), gr.update()
return WINDOW_PRESET_VALUES[window_preset]
def _preset_for_window_values(window_width: float | None, window_level: float | None):
if window_width is None or window_level is None:
return None
width = int(round(float(window_width)))
level = int(round(float(window_level)))
for preset, (preset_width, preset_level) in WINDOW_PRESET_VALUES.items():
if width == preset_width and level == preset_level:
return preset
return None
def _resolve_window(window_preset: str, window_width: float | None, window_level: float | None) -> str:
has_width = window_width is not None
has_level = window_level is not None
if has_width and has_level:
return _window_bounds_from_wl(window_width, window_level)
if has_width or has_level:
raise gr.Error("Please enter both custom CT Window W and Level L, or leave both empty.")
if not window_preset:
raise gr.Error("Please select a CT window preset or enter both custom W and L values.")
width, level = WINDOW_PRESET_VALUES[window_preset]
return _window_bounds_from_wl(width, level)
def _get_loaded_models(device: str | None):
global _LOADED_MODELS, _LOADED_MODELS_DEVICE, _LOADED_MODELS_LOAD_S
if SKIP_WEIGHTS:
raise gr.Error(
"Weight loading is disabled (CPU viewer / NIfTI-debug mode). "
"Upload and example loading still work. "
"Set SKIP_WEIGHTS=0 or switch the Space to ZeroGPU to enable RUN."
)
requested_device = device or DEFAULT_DEVICE
with _MODEL_LOAD_LOCK:
if _LOADED_MODELS is None:
started = time.time()
_LOADED_MODELS = load_all_models(device=requested_device)
_LOADED_MODELS_LOAD_S = time.time() - started
_LOADED_MODELS_DEVICE = requested_device
elif _LOADED_MODELS_DEVICE != requested_device:
raise gr.Error(
"Static models are already loaded on "
f"{_LOADED_MODELS_DEVICE}. Restart this app to switch to {requested_device}."
)
return _LOADED_MODELS
def _download_weights_to_disk() -> list[str]:
"""Download the three checkpoints from HF Model repos without loading them."""
from huggingface_hub import hf_hub_download, snapshot_download
ready: list[str] = []
medsam_ckpt = ROOT / "MedSAM2" / "checkpoints"
medsam_ckpt.mkdir(parents=True, exist_ok=True)
cache_dir = ROOT / ".hf_cache"
for filename in MEDSAM2_WEIGHT_FILES:
dst = medsam_ckpt / filename
if dst.exists() and dst.stat().st_size > 0:
print(f"checkpoint present: {dst} ({dst.stat().st_size / (1024**2):.1f} MB)", flush=True)
ready.append(str(dst))
continue
started = time.time()
print(f"Downloading {MEDSAM2_WEIGHT_REPO}/{filename} ...", flush=True)
src = Path(
hf_hub_download(
repo_id=MEDSAM2_WEIGHT_REPO,
filename=filename,
cache_dir=str(cache_dir),
)
)
shutil.copy2(src, dst)
elapsed = time.time() - started
print(
f"ready: {dst} ({dst.stat().st_size / (1024**2):.1f} MB) in {elapsed:.1f}s",
flush=True,
)
ready.append(str(dst))
nn_root = ROOT / "checkpoints" / "nnInteractive"
nn_marker = nn_root / "nnInteractive_v1.0" / "fold_0" / "checkpoint_final.pth"
if nn_marker.exists() and nn_marker.stat().st_size > 0:
print(
f"checkpoint present: {nn_marker} ({nn_marker.stat().st_size / (1024**2):.1f} MB)",
flush=True,
)
ready.append(str(nn_marker))
else:
started = time.time()
print(f"Downloading {NNINTERACTIVE_WEIGHT_REPO}/{NNINTERACTIVE_WEIGHT_PATTERN} ...", flush=True)
snapshot_download(
repo_id=NNINTERACTIVE_WEIGHT_REPO,
allow_patterns=[NNINTERACTIVE_WEIGHT_PATTERN],
local_dir=str(nn_root),
)
elapsed = time.time() - started
size_mb = nn_marker.stat().st_size / (1024**2) if nn_marker.exists() else float("nan")
print(f"ready: {nn_marker} ({size_mb:.1f} MB) in {elapsed:.1f}s", flush=True)
ready.append(str(nn_marker))
return ready
def _preload_models_on_startup() -> None:
if DOWNLOAD_WEIGHTS:
print("Downloading model checkpoints to disk (HF Model repos)...", flush=True)
try:
paths = _download_weights_to_disk()
print(f"Checkpoint download finished ({len(paths)} files).", flush=True)
except Exception as exc:
print(f"Checkpoint download failed: {exc}", flush=True)
if not SKIP_WEIGHTS:
raise
if SKIP_WEIGHTS:
print(
"SKIP_WEIGHTS=1: checkpoints may be on disk but will NOT be loaded into memory "
f"(device={DEFAULT_DEVICE}, spaces={_running_on_spaces()}). "
"Viewer / NIfTI load path only; RUN disabled.",
flush=True,
)
return
print(f"Loading static three-model weights on {DEFAULT_DEVICE}...", flush=True)
models = _get_loaded_models(DEFAULT_DEVICE)
per_model = ", ".join(
f"{MODEL_LABELS[model]}="
f"{models.metadata[model.replace('-', '_')].get('load_duration_s', float('nan')):.1f}s"
f"/{format_param_count(models.metadata[model.replace('-', '_')].get('n_parameters'))}"
for model in MODELS
)
print(
f"Static three-model weights loaded in {_LOADED_MODELS_LOAD_S:.1f}s ({per_model}).",
flush=True,
)
def run_inference(
image_path: str,
recist_line: str,
device: str,
window_preset: str,
window_width: float | None,
window_level: float | None,
):
if SKIP_WEIGHTS:
raise gr.Error(
"RUN is disabled while weights are not loaded (CPU NIfTI-debug mode). "
"Checkpoints may already be downloaded to disk. "
"You can still upload / load examples to measure viewer speed. "
"Set env SKIP_WEIGHTS=0 or move the Space to ZeroGPU to enable inference."
)
if not image_path:
raise gr.Error("Please upload an image or click Load example first.")
image = Path(image_path)
if not image.exists():
raise gr.Error(f"Image file does not exist: {image}")
recist_lines = _validate_recist_lines(recist_line)
_ensure_dirs()
run_dir = RUN_DIR / f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
run_dir.mkdir(parents=True, exist_ok=True)
window = _resolve_window(window_preset, window_width, window_level)
stdout = io.StringIO()
stderr = io.StringIO()
log_paths = [image, run_dir]
try:
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
models = _get_loaded_models(device or None)
with _INFERENCE_LOCK:
results = run_three_models(
image=image,
loaded_models=models,
recist_lines=recist_lines,
output_dir=run_dir,
output_prefix="prediction",
intensity="window",
window=window,
)
except Exception as exc:
log = "\n".join(
part
for part in [
f"run_three_models failed: {exc}",
stdout.getvalue(),
"[stderr]\n" + stderr.getvalue() if stderr.getvalue() else "",
]
if part
)
return (
"",
"",
"",
"",
"",
"",
"Three-model inference failed. Please check the log.",
_redact_log_paths(log, log_paths),
)
results_by_model = {result.model: result for result in results}
missing_models = [model for model in MODELS if model not in results_by_model]
missing_files = [
result.output_nifti
for result in results_by_model.values()
if not Path(result.output_nifti).exists()
]
log_paths.extend(Path(result.output_nifti) for result in results_by_model.values())
log = "\n".join(
part
for part in [
f"Static models device: {_LOADED_MODELS_DEVICE}",
"run_three_models(..., loaded_models=<cached>)",
stdout.getvalue(),
"[stderr]\n" + stderr.getvalue() if stderr.getvalue() else "",
json.dumps(
[
{
"model": result.model,
"output_nifti": result.output_nifti,
"duration_s": round(result.duration_s, 3),
}
for result in results
],
indent=2,
sort_keys=True,
),
]
if part
)
if missing_models or missing_files:
return (
"",
"",
"",
"",
"",
"",
f"Inference finished with missing outputs. Missing models={missing_models}, missing files={missing_files}",
_redact_log_paths(log, log_paths),
)
ordered_paths = [Path(results_by_model[model].output_nifti) for model in MODELS]
ordered_urls = [_file_url(path) for path in ordered_paths]
model_meta = _LOADED_MODELS.metadata if _LOADED_MODELS is not None else {}
def _model_load_s(model: str) -> float:
return model_meta.get(model.replace("-", "_"), {}).get("load_duration_s", 0.0)
def _model_size(model: str) -> str:
"""Prefer the count measured at load time; fall back to the static table."""
n = model_meta.get(model.replace("-", "_"), {}).get("n_parameters")
return format_param_count(n) if n else _format_size_m(model)
timing = "; ".join(
f"{MODEL_LABELS[model]} ({_model_size(model)}): load={_model_load_s(model):.1f}s, "
f"run={results_by_model[model].duration_s:.1f}s, "
f"total={_model_load_s(model) + results_by_model[model].duration_s:.1f}s"
for model in MODELS
)
return (
*(str(path) for path in ordered_paths),
*ordered_urls,
f"Three-model inference complete, RECIST lines={len(recist_lines)}; {timing}",
_redact_log_paths(log, log_paths),
)
with gr.Blocks(title="RECIST to 3D for Pan-cancer Segmentation in CT Images") as demo:
gr.Markdown("## RECIST to 3D for Pan-cancer Segmentation in CT Images")
gr.Markdown(
"Upload a `.nii/.nii.gz` file, or click **Load example**. In the **Axial** view, click **Draw RECIST**, "
"drag one or more lines, then run all three models. Spacing is read automatically from the NIfTI header."
)
if SKIP_WEIGHTS:
gr.Markdown(
"> **CPU / NIfTI-debug mode:** checkpoints are **downloaded** from HF Model repos "
"(`wanglab/MedSAM2`, `nnInteractive/nnInteractive`) but **not loaded** into memory. "
"Upload and example loading work; **RUN** is disabled. "
"Set `SKIP_WEIGHTS=0` or switch to ZeroGPU to enable inference."
)
image_path_state = gr.Textbox(visible=False)
image_url_state = gr.Textbox(visible=False)
eff_mask_path_state = gr.Textbox(visible=False)
medsam2_mask_path_state = gr.Textbox(visible=False)
nninteractive_mask_path_state = gr.Textbox(visible=False)
eff_mask_url_state = gr.Textbox(visible=False)
medsam2_mask_url_state = gr.Textbox(visible=False)
nninteractive_mask_url_state = gr.Textbox(visible=False)
with gr.Row():
with gr.Column(scale=1):
upload = gr.File(label="Upload NIfTI (.nii / .nii.gz)", type="filepath")
recist_line = gr.Textbox(
label="RECIST lines (one per row: z,x1,y1,x2,y2,label)",
placeholder="Automatically filled after drawing RECIST lines on the axial NiiVue image",
lines=4,
elem_id="recist-line-box",
)
with gr.Row():
manual_recist_line = gr.Textbox(
label="Debug add RECIST line",
placeholder="z,x1,y1,x2,y2 or z,x1,y1,x2,y2,label",
scale=3,
)
manual_add = gr.Button("Add line", variant="secondary", scale=1)
device = gr.Dropdown(
choices=DEVICE_CHOICES,
value=DEFAULT_DEVICE,
label="Static model device (loaded at startup)",
interactive=False,
)
window_preset = gr.Radio(
choices=list(WINDOW_PRESET_VALUES.keys()),
value=DEFAULT_WINDOW_PRESET,
label="CT Window",
elem_id="ct-window-preset",
)
with gr.Row():
window_width = gr.Number(label="Custom Window W (integer)", value=DEFAULT_WINDOW_WIDTH)
window_level = gr.Number(label="Custom Level L (integer)", value=DEFAULT_WINDOW_LEVEL)
run = gr.Button("RUN", variant="primary")
status = gr.Textbox(label="Status", interactive=False)
log = gr.Textbox(label="Run log", lines=10, interactive=False)
with gr.Column(scale=2):
gr.HTML(value=CANVAS_HTML, js_on_load=JS_ON_LOAD)
with gr.Group(elem_id="example-buttons"):
gr.Markdown("Example cases (click to load)", elem_classes=["example-buttons-title"])
with gr.Row():
load_kidney = gr.Button("Kidney cancer", variant="secondary")
load_liver = gr.Button("Liver cancer", variant="secondary")
load_lung = gr.Button("Lung cancer", variant="secondary")
load_pancreas = gr.Button("Pancreas cancer", variant="secondary")
upload.change(
prepare_uploaded_image,
inputs=upload,
outputs=[
image_path_state,
image_url_state,
eff_mask_path_state,
medsam2_mask_path_state,
nninteractive_mask_path_state,
eff_mask_url_state,
medsam2_mask_url_state,
nninteractive_mask_url_state,
recist_line,
status,
log,
],
).then(
fn=None,
inputs=image_url_state,
outputs=None,
js="async (imageUrl) => { await window.recistTo3DViewer?.loadImage(imageUrl); return []; }",
)
for button, example_path, example_window_preset in [
(load_kidney, EXAMPLE_IMAGES["Kidney cancer"], "Soft tissues (W:400 L:40)"),
(load_liver, EXAMPLE_IMAGES["Liver cancer"], "Soft tissues (W:400 L:40)"),
(load_lung, EXAMPLE_IMAGES["Lung cancer"], "Lungs (W:1500 L:-600)"),
(load_pancreas, EXAMPLE_IMAGES["Pancreas cancer"], "Soft tissues (W:400 L:40)"),
]:
button.click(
load_example_image,
inputs=[gr.State(str(example_path)), gr.State(example_window_preset)],
outputs=[
image_path_state,
image_url_state,
eff_mask_path_state,
medsam2_mask_path_state,
nninteractive_mask_path_state,
eff_mask_url_state,
medsam2_mask_url_state,
nninteractive_mask_url_state,
recist_line,
status,
log,
window_preset,
window_width,
window_level,
],
).then(
fn=None,
inputs=[image_url_state, window_width, window_level],
outputs=None,
js="async (imageUrl, width, level) => { await window.recistTo3DViewer?.loadImage(imageUrl); window.recistTo3DViewer?.setWindowLevel?.(width, level); return []; }",
)
manual_add.click(
fn=None,
inputs=manual_recist_line,
outputs=manual_recist_line,
js="""
(line) => {
window.recistTo3DViewer?.addManualRecistLine?.(line || "");
return [""];
}
""",
)
window_preset.change(
_window_values_for_preset,
inputs=window_preset,
outputs=[window_width, window_level],
).then(
fn=None,
inputs=[window_width, window_level],
outputs=None,
js="(width, level) => { window.recistTo3DViewer?.setWindowLevel?.(width, level); return []; }",
)
window_width.change(
_preset_for_window_values,
inputs=[window_width, window_level],
outputs=window_preset,
).then(
fn=None,
inputs=[window_width, window_level],
outputs=None,
js="(width, level) => { window.recistTo3DViewer?.setWindowLevel?.(width, level); return []; }",
)
window_level.change(
_preset_for_window_values,
inputs=[window_width, window_level],
outputs=window_preset,
).then(
fn=None,
inputs=[window_width, window_level],
outputs=None,
js="(width, level) => { window.recistTo3DViewer?.setWindowLevel?.(width, level); return []; }",
)
run.click(
run_inference,
inputs=[image_path_state, recist_line, device, window_preset, window_width, window_level],
outputs=[
eff_mask_path_state,
medsam2_mask_path_state,
nninteractive_mask_path_state,
eff_mask_url_state,
medsam2_mask_url_state,
nninteractive_mask_url_state,
status,
log,
],
js="""
(imagePath, recistLine, device, windowPreset, windowWidth, windowLevel) => {
const drawnLine = window.recistTo3DViewer?.getRecistLine?.() || recistLine || "";
return [imagePath, drawnLine, device, windowPreset, windowWidth, windowLevel];
}
""",
).then(
fn=None,
inputs=[eff_mask_url_state, medsam2_mask_url_state, nninteractive_mask_url_state],
outputs=None,
js="async (effMaskUrl, medsam2MaskUrl, nninteractiveMaskUrl) => { await window.recistTo3DViewer?.loadModelMasks(effMaskUrl, medsam2MaskUrl, nninteractiveMaskUrl); return []; }",
)
# Spaces may import this module without running ``__main__``; still download/load here.
_preload_models_on_startup()
if __name__ == "__main__":
on_spaces = _running_on_spaces()
port = int(os.environ.get("PORT", "7860" if on_spaces else "7872"))
demo.launch(
server_name="0.0.0.0",
server_port=port,
css=APP_CSS,
allowed_paths=[
str(EXAMPLES_DIR),
str(APP_DATA),
],
share=False,
mcp_server=_env_flag("ENABLE_MCP", default=not on_spaces),
)