Optimize DICOM UPP registration lazy loading and fusion views

This commit is contained in:
Codex
2026-05-29 16:53:01 +08:00
parent 1d6f04061a
commit 692c50e22c
4 changed files with 393 additions and 134 deletions

View File

@@ -12,6 +12,7 @@ import shutil
import subprocess import subprocess
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from collections import Counter, defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -123,6 +124,7 @@ app = FastAPI(title="DICOM UPP Registration Workspace")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, str] = {} TOKENS: dict[str, str] = {}
STUDY_CACHE: dict[str, dict[str, Any]] = {} STUDY_CACHE: dict[str, dict[str, Any]] = {}
SERIES_SORT_CACHE: dict[str, tuple[float, list[Path]]] = {}
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
CACHE_REFRESH_LOCK = threading.Lock() CACHE_REFRESH_LOCK = threading.Lock()
CACHE_REFRESH_THREAD: threading.Thread | None = None CACHE_REFRESH_THREAD: threading.Thread | None = None
@@ -663,9 +665,10 @@ def cases(
status_filter: str = Query(default="", alias="status"), status_filter: str = Query(default="", alias="status"),
body_part: str = "", body_part: str = "",
algorithm_model: str = "", algorithm_model: str = "",
limit: int = Query(default=120, ge=1, le=400), limit: int = Query(default=20, ge=1, le=80),
offset: int = Query(default=0, ge=0),
_: str = Depends(require_auth), _: str = Depends(require_auth),
) -> list[dict[str, Any]]: ) -> dict[str, Any]:
ensure_registration_table() ensure_registration_table()
clauses = [] clauses = []
if q.strip(): if q.strip():
@@ -690,7 +693,15 @@ def cases(
if algorithm_model.strip(): if algorithm_model.strip():
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}") clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
where_sql = "WHERE " + " AND ".join(clauses) if clauses else "" where_sql = "WHERE " + " AND ".join(clauses) if clauses else ""
return pg_json_rows( total = int(pg_scalar(
f"""
SELECT count(*)::int
FROM ({relation_select()}) relation
{where_sql}
""",
timeout=20,
) or 0)
rows = pg_json_rows(
f""" f"""
SELECT * SELECT *
FROM ({relation_select()}) relation FROM ({relation_select()}) relation
@@ -701,9 +712,17 @@ def cases(
COALESCE(study_time, '') DESC, COALESCE(study_time, '') DESC,
ct_key ct_key
LIMIT {int(limit)} LIMIT {int(limit)}
OFFSET {int(offset)}
""", """,
timeout=20, timeout=20,
) )
return {
"rows": rows,
"total": total,
"limit": int(limit),
"offset": int(offset),
"has_more": int(offset) + len(rows) < total,
}
@app.get("/api/cases/{ct_number}") @app.get("/api/cases/{ct_number}")
@@ -739,6 +758,23 @@ def read_header(path: Path) -> dict[str, str]:
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS} return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
def read_headers_bulk(paths: list[Path], max_workers: int = 10) -> dict[Path, dict[str, str]]:
unique_paths = list(dict.fromkeys(paths))
if not unique_paths:
return {}
results: dict[Path, dict[str, str]] = {}
workers = max(1, min(max_workers, len(unique_paths)))
with ThreadPoolExecutor(max_workers=workers) as executor:
future_map = {executor.submit(read_header, path): path for path in unique_paths}
for future in as_completed(future_map):
path = future_map[future]
try:
results[path] = future.result()
except Exception:
continue
return results
def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
path, meta = item path, meta = item
z = numeric(meta.get("SliceLocation"), 0.0) z = numeric(meta.get("SliceLocation"), 0.0)
@@ -823,29 +859,36 @@ def scan_study(ct_number: str) -> dict[str, Any]:
root = resolve_study_root(study) root = resolve_study_root(study)
if not root.exists(): if not root.exists():
raise HTTPException(status_code=404, detail=f"DICOM 目录不存在:{root}") raise HTTPException(status_code=404, detail=f"DICOM 目录不存在:{root}")
grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list) grouped: dict[str, list[Path]] = defaultdict(list)
for path in root.rglob("*.dcm"): for path in root.rglob("*.dcm"):
try: grouped[path.parent.name].append(path)
meta = read_header(path)
except Exception:
continue
uid = meta.get("SeriesInstanceUID") or path.parent.name
grouped[uid].append((path, meta))
annotations = get_annotations(ct_number) annotations = get_annotations(ct_number)
series = [] series = []
file_map = {} file_map = {}
for uid, items in grouped.items(): summary_paths: list[Path] = []
items.sort(key=sort_key) for paths in grouped.values():
first = items[0][1] paths = sorted(paths)
last = items[-1][1] if not paths:
file_map[uid] = [path for path, _ in items] continue
annotation = annotations.get(uid) or {} summary_paths.append(paths[0])
header_map = read_headers_bulk(summary_paths)
for uid, paths in grouped.items():
paths = sorted(paths)
if not paths:
continue
first = header_map.get(paths[0])
if not first:
continue
last = first
actual_uid = first.get("SeriesInstanceUID") or uid
file_map[actual_uid] = paths
annotation = annotations.get(actual_uid) or annotations.get(uid) or {}
series.append( series.append(
{ {
"series_uid": uid, "series_uid": actual_uid,
"description": first.get("SeriesDescription") or "未命名序列", "description": first.get("SeriesDescription") or "未命名序列",
"series_number": first.get("SeriesNumber") or "", "series_number": first.get("SeriesNumber") or "",
"count": len(items), "count": len(paths),
"modality": first.get("Modality") or "", "modality": first.get("Modality") or "",
"rows": first.get("Rows") or "", "rows": first.get("Rows") or "",
"columns": first.get("Columns") or "", "columns": first.get("Columns") or "",
@@ -876,7 +919,22 @@ def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
files = data["files"].get(series_uid) files = data["files"].get(series_uid)
if not files: if not files:
raise HTTPException(status_code=404, detail="DICOM 序列不存在") raise HTTPException(status_code=404, detail="DICOM 序列不存在")
return files cache_key = f"{normalize_ct(ct_number)}|{series_uid}"
cached = SERIES_SORT_CACHE.get(cache_key)
if cached:
SERIES_SORT_CACHE[cache_key] = (time.time(), cached[1])
return cached[1]
header_map = read_headers_bulk(list(files), max_workers=12)
items: list[tuple[Path, dict[str, str]]] = [(path, meta) for path, meta in header_map.items()]
if not items:
raise HTTPException(status_code=404, detail="DICOM 序列文件不可读")
sorted_files = [path for path, _ in sorted(items, key=sort_key)]
data["files"][series_uid] = sorted_files
SERIES_SORT_CACHE[cache_key] = (time.time(), sorted_files)
if len(SERIES_SORT_CACHE) > 8:
oldest = sorted(SERIES_SORT_CACHE.items(), key=lambda item: item[1][0])[0][0]
SERIES_SORT_CACHE.pop(oldest, None)
return sorted_files
def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]: def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]:
@@ -945,7 +1003,7 @@ def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 9
pil = Image.fromarray(img) pil = Image.fromarray(img)
pil = resize_for_spacing(pil, spacing[0], spacing[1]) pil = resize_for_spacing(pil, spacing[0], spacing[1])
if max(pil.size) > max_size: if max(pil.size) > max_size:
pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR) pil.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
output = io.BytesIO() output = io.BytesIO()
pil.save(output, format="PNG", optimize=True) pil.save(output, format="PNG", optimize=True)
return output.getvalue() return output.getvalue()
@@ -1016,7 +1074,7 @@ def dicom_fusion_volume(
else: else:
start = max(0, center_index - radius) start = max(0, center_index - radius)
end = min(total - 1, center_index + radius) end = min(total - 1, center_index + radius)
max_frames = {"low": 48, "medium": 64, "high": 80}.get(str(detail).lower(), 64) max_frames = {"low": 24, "medium": 48, "high": 72}.get(str(detail).lower(), 48)
if end - start + 1 > max_frames: if end - start + 1 > max_frames:
step = int(np.ceil((end - start + 1) / max_frames)) step = int(np.ceil((end - start + 1) / max_frames))
indices = list(range(start, end + 1, step)) indices = list(range(start, end + 1, step))
@@ -1226,6 +1284,7 @@ def run_cache_refresh(reason: str = "manual", user: str = "system") -> None:
try: try:
cache_root.mkdir(parents=True, exist_ok=True) cache_root.mkdir(parents=True, exist_ok=True)
STUDY_CACHE.clear() STUDY_CACHE.clear()
SERIES_SORT_CACHE.clear()
STACK_CACHE.clear() STACK_CACHE.clear()
rows = relation_rows_for_cache() rows = relation_rows_for_cache()
total = len(rows) total = len(rows)

View File

@@ -39,6 +39,19 @@ const POSE_CONTROLS = [
]; ];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const CASE_PAGE_SIZE = 20;
const POSE_SIGNATURE_KEYS = [
"rotateX",
"rotateY",
"rotateZ",
"translateX",
"translateY",
"translateZ",
"scale",
"flipX",
"flipY",
"flipZ",
];
const state = { const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "", token: localStorage.getItem("dicom_upp_registration_token") || "",
@@ -51,7 +64,13 @@ const state = {
partFilter: "", partFilter: "",
modelFilter: "", modelFilter: "",
search: "", search: "",
searchTimer: 0,
caseCollapsed: false, caseCollapsed: false,
caseLimit: CASE_PAGE_SIZE,
caseOffset: 0,
caseTotal: 0,
caseHasMore: false,
loadingCases: false,
activeToolTab: "series", activeToolTab: "series",
cases: [], cases: [],
activeCase: null, activeCase: null,
@@ -65,7 +84,7 @@ const state = {
pose: { ...DEFAULT_POSE }, pose: { ...DEFAULT_POSE },
windowMode: "default", windowMode: "default",
fusionMode: "fusion", fusionMode: "fusion",
fusionDetail: "high", fusionDetail: "low",
modelDetail: "standard", modelDetail: "standard",
mappingMode: "result", mappingMode: "result",
sliceIndex: 0, sliceIndex: 0,
@@ -76,6 +95,7 @@ const state = {
lastAutoPose: null, lastAutoPose: null,
dirty: false, dirty: false,
dirtyReason: "", dirtyReason: "",
savedPoseSignature: "",
saving: false, saving: false,
fusionVersion: 0, fusionVersion: 0,
volumeMeta: null, volumeMeta: null,
@@ -86,7 +106,7 @@ const state = {
scene: null, scene: null,
controls: null, controls: null,
fusionRoot: null, fusionRoot: null,
viewPose: { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 }, viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
sceneDrag: null, sceneDrag: null,
dicomGroup: null, dicomGroup: null,
dicomPlane: null, dicomPlane: null,
@@ -175,9 +195,23 @@ function markDirty(reason = "pose") {
setSaveState("未保存", "warn"); setSaveState("未保存", "warn");
} }
function poseSignature(pose = state.pose) {
const normalized = {};
POSE_SIGNATURE_KEYS.forEach((key) => {
const value = pose?.[key] ?? DEFAULT_POSE[key];
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
});
return JSON.stringify(normalized);
}
function poseChanged() {
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
}
function resetDirty() { function resetDirty() {
state.dirty = false; state.dirty = false;
state.dirtyReason = ""; state.dirtyReason = "";
state.savedPoseSignature = poseSignature(state.pose);
setSaveState("已保存", "ok"); setSaveState("已保存", "ok");
} }
@@ -341,28 +375,47 @@ function pollCacheWhileRunning(force = false) {
}, running ? 1600 : 800); }, running ? 1600 : 800);
} }
async function loadCases(selectFirst = true) { async function loadCases(selectFirst = true, append = false) {
setTopLoading(true); if (state.loadingCases) return;
state.loadingCases = true;
if (!append) {
state.caseOffset = 0;
state.caseHasMore = false;
state.cases = [];
}
setTopLoading(!append);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (state.search) params.set("q", state.search); if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter); if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter); if (state.partFilter) params.set("body_part", state.partFilter);
if (state.modelFilter) params.set("algorithm_model", state.modelFilter); if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
const rows = await api(`/api/cases?${params.toString()}`); params.set("limit", String(state.caseLimit));
state.cases = rows; params.set("offset", String(append ? state.caseOffset : 0));
const payload = await api(`/api/cases?${params.toString()}`);
const rows = Array.isArray(payload) ? payload : payload.rows || [];
state.caseTotal = Array.isArray(payload) ? (append ? state.cases.length + rows.length : rows.length) : Number(payload.total || 0);
state.caseHasMore = Array.isArray(payload) ? rows.length >= state.caseLimit : Boolean(payload.has_more);
state.caseOffset = (append ? state.caseOffset : 0) + rows.length;
state.cases = append ? [...state.cases, ...rows] : rows;
renderCases(); renderCases();
if (selectFirst && !state.activeCase && rows.length) { if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型"); await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) { } else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null; state.activeCase = null;
clearDetail(); clearDetail();
} }
} finally { } finally {
setTopLoading(false); state.loadingCases = false;
if (!append) setTopLoading(false);
} }
} }
async function loadMoreCases() {
if (state.loadingCases || !state.caseHasMore) return;
await loadCases(false, true);
}
function sameCase(a, b) { function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型"); return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
} }
@@ -439,11 +492,22 @@ function visibleModelRecords() {
return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false); return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
} }
function modelPoseMatrix() {
if (!state.modelGroup) return new THREE.Matrix4();
state.modelGroup.updateMatrix();
return state.modelGroup.matrix.clone();
}
function visibleModelBox() { function visibleModelBox() {
const records = visibleModelRecords(); const records = visibleModelRecords();
if (!records.length) return null; if (!records.length) return null;
const box = new THREE.Box3(); const box = new THREE.Box3();
records.forEach((record) => box.expandByObject(record.mesh)); const matrix = modelPoseMatrix();
records.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
const localBox = record.mesh.geometry.boundingBox;
if (localBox) box.union(localBox.clone().applyMatrix4(matrix));
});
return box; return box;
} }
@@ -455,9 +519,9 @@ function bodyPartTags(row) {
} }
function renderCases() { function renderCases() {
$("caseCount").textContent = state.cases.length; $("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
const active = state.activeCase; const active = state.activeCase;
$("caseList").innerHTML = state.cases.length const cards = state.cases.length
? state.cases ? state.cases
.map((row) => { .map((row) => {
const tags = bodyPartTags(row).slice(0, 3); const tags = bodyPartTags(row).slice(0, 3);
@@ -483,9 +547,11 @@ function renderCases() {
}) })
.join("") .join("")
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`; : `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `<button id="loadMoreCasesBtn" class="load-more-btn" type="button">${state.loadingCases ? "读取中..." : "继续加载"}</button>` : ""}`;
$("caseList").querySelectorAll(".case-card").forEach((button) => { $("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型")); button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
}); });
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
} }
function clearDetail() { function clearDetail() {
@@ -505,7 +571,7 @@ function clearDetail() {
} }
async function confirmChange() { async function confirmChange() {
if (!state.dirty) return true; if (!state.dirty || !poseChanged()) return true;
return window.confirm("当前配准参数还未保存,确定切换吗?"); return window.confirm("当前配准参数还未保存,确定切换吗?");
} }
@@ -518,7 +584,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const previousPose = { ...state.pose }; const previousPose = { ...state.pose };
if (!(await confirmChange())) return; if (!(await confirmChange())) return;
setTopLoading(true); setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据", 6); setFusionLoading(true, "正在读取当前 CT 数据", 6);
try { try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm }); const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
@@ -532,10 +598,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.series = seriesPayload.series || []; state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || []; state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered"; state.registrationStatus = registration.registration_status || "unregistered";
const savedTransform = registration.transform || {}; const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
const keepPreviousPose = switchingModelOnly; const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "low"; state.fusionDetail = state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard"; state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.autoResult = null; state.autoResult = null;
state.lastAutoPose = null; state.lastAutoPose = null;
@@ -617,18 +683,14 @@ function currentSeries() {
function renderActiveHeader() { function renderActiveHeader() {
const row = state.activeCase; const row = state.activeCase;
if (!row) return; if (!row) return;
const selected = currentSeries();
const registration = registrationSeries(); const registration = registrationSeries();
const bodyTags = bodyPartTags(row);
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : []; const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`; $("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`; $("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [ const tags = [
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`, `<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`, `<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...(registration ? [`<span class="tag">配准 ${escapeHtml(registration.description || "当前序列")}</span>`] : []),
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`), ...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
]; ];
$("activeTags").innerHTML = tags.join(""); $("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
@@ -766,10 +828,21 @@ async function invertStlSelection() {
async function applyStlSelectionChange() { async function applyStlSelectionChange() {
markDirty("stl"); markDirty("stl");
renderStl(); renderStl();
if (!state.modelMeshes.length) { const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) {
await loadFusion(); await loadFusion();
return; return;
} }
if (needsLoad) {
setFusionLoading(true, "正在补加载 STL 模型", 62);
await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3));
setFusionLoading(false);
applyModelDetail();
if (state.sceneReady) fitCamera();
renderStl();
return;
}
applyStlVisibility(); applyStlVisibility();
applyModelDetail(); applyModelDetail();
scheduleMappingDraw(); scheduleMappingDraw();
@@ -1207,10 +1280,10 @@ function updateFusionMeta() {
function fusionDetailSettings() { function fusionDetailSettings() {
return { return {
low: { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }, low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 0.92, sliceOpacity: 0.024, slicePlanes: 14, pointOpacity: 0.055, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 512 }, medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 }, high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }; }[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
} }
function modelDetailSettings() { function modelDetailSettings() {
@@ -1333,7 +1406,7 @@ async function loadTexture(url) {
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false; texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter; texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter; texture.magFilter = THREE.NearestFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1); texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true; texture.needsUpdate = true;
resolve(texture); resolve(texture);
@@ -1677,7 +1750,6 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
const maskData = maskContext.createImageData(width, height); const maskData = maskContext.createImageData(width, height);
const radius = solidStrokeRadius(width, height); const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15); const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
const fallbackGroups = [];
let filledPixels = 0; let filledPixels = 0;
groups.forEach((group) => { groups.forEach((group) => {
@@ -1713,16 +1785,12 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
}); });
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius); groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels; filledPixels += groupPixels;
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) fallbackGroups.push(group);
}); });
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3); filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0); maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0); context.drawImage(maskCanvas, 0, 0);
fallbackGroups.forEach((group) => {
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
});
if (filledPixels === 0) { if (filledPixels === 0) {
context.save(); context.save();
@@ -1739,6 +1807,19 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
context.stroke(); context.stroke();
context.restore(); context.restore();
} }
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.65;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
return filledPixels; return filledPixels;
} }
@@ -1774,7 +1855,7 @@ async function drawMappingView(version = state.mappingVersion) {
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width, x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height, y: (0.5 - point.y / volumeScene.height) * height,
}); });
state.modelGroup?.updateMatrixWorld(true); const matrix = modelPoseMatrix();
let activeModules = 0; let activeModules = 0;
let segmentCount = 0; let segmentCount = 0;
@@ -1789,15 +1870,12 @@ async function drawMappingView(version = state.mappingVersion) {
const position = record.mesh.geometry.attributes.position; const position = record.mesh.geometry.attributes.position;
if (!position) continue; if (!position) continue;
const triangleCount = Math.floor(position.count / 3); const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000)); const step = Math.max(1, Math.ceil(triangleCount / 260000));
const segments = []; const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) { for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3); tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix);
tempB.fromBufferAttribute(position, tri * 3 + 1); tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix);
tempC.fromBufferAttribute(position, tri * 3 + 2); tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix);
record.mesh.localToWorld(tempA);
record.mesh.localToWorld(tempB);
record.mesh.localToWorld(tempC);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ); const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue; if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) }; const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
@@ -1912,11 +1990,11 @@ async function buildDicomVolume(volume) {
opacity: fusionDetail.planeOpacity, opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: false,
}), }),
); );
plane.position.z = sliceToSceneZ(centerIndex) + 0.006; plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
plane.renderOrder = 20; plane.renderOrder = 80;
state.dicomGroup.add(plane); state.dicomGroup.add(plane);
state.dicomPlane = plane; state.dicomPlane = plane;
} }
@@ -1944,6 +2022,7 @@ async function buildDicomVolume(volume) {
} }
} }
if (fusionDetail.pointOpacity > 0) {
const pointPositions = []; const pointPositions = [];
const pointColors = []; const pointColors = [];
for (const [index, frame] of frames.entries()) { for (const [index, frame] of frames.entries()) {
@@ -1981,9 +2060,13 @@ async function buildDicomVolume(volume) {
state.dicomGroup.add(points); state.dicomGroup.add(points);
state.dicomPoints = points; state.dicomPoints = points;
} }
}
state.baseModelScale = sceneScale; state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); state.dicomSceneBox = new THREE.Box3(
new THREE.Vector3(-width / 2, -height / 2, -depth / 2),
new THREE.Vector3(width / 2, height / 2, depth / 2),
);
} }
function updateBaseModelScaleFromBounds() { function updateBaseModelScaleFromBounds() {
@@ -1998,6 +2081,7 @@ function updateBaseModelScaleFromBounds() {
function addModelBoundsFrame(size) { function addModelBoundsFrame(size) {
if (!state.rawModelGroup || !size) return; if (!state.rawModelGroup || !size) return;
removeModelBoundsFrame();
const frame = new THREE.LineSegments( const frame = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
new THREE.LineBasicMaterial({ new THREE.LineBasicMaterial({
@@ -2015,9 +2099,34 @@ function addModelBoundsFrame(size) {
state.modelBoundsFrame = frame; state.modelBoundsFrame = frame;
} }
function removeModelBoundsFrame() {
const frame = state.modelBoundsFrame;
if (!frame || !state.rawModelGroup) return;
state.rawModelGroup.remove(frame);
frame.geometry?.dispose?.();
if (Array.isArray(frame.material)) frame.material.forEach((material) => material.dispose?.());
else frame.material?.dispose?.();
state.modelBoundsFrame = null;
}
async function buildStlModels(volume, onProgress = null) { async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature(); const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) { if (signature && signature !== state.stlSignature) {
state.rawModelGroup.position.set(0, 0, 0);
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
state.stlSignature = signature;
}
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const files = state.stlFiles.filter((file) => (
state.selectedStlIds.has(Number(file.id))
&& !loadedIds.has(Number(file.id))
));
if (signature && signature === state.stlSignature && !files.length && state.rawModelGroup?.children.length) {
updateBaseModelScaleFromBounds(); updateBaseModelScaleFromBounds();
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size); if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
applyStlVisibility(); applyStlVisibility();
@@ -2027,22 +2136,15 @@ async function buildStlModels(volume, onProgress = null) {
onProgress?.(100); onProgress?.(100);
return; return;
} }
state.rawModelGroup.position.set(0, 0, 0); if (!files.length && !state.modelMeshes.length) {
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
state.stlSignature = signature;
const files = state.stlFiles;
if (!files.length) {
applyPose(); applyPose();
resetMappingCanvas("当前没有可见 STL 构件"); resetMappingCanvas("当前没有可见 STL 构件");
onProgress?.(100);
return; return;
} }
const loader = new STLLoader(); const loader = new STLLoader();
const detail = modelDetailSettings(); const detail = modelDetailSettings();
const freshRecords = [];
for (const [index, file] of files.entries()) { for (const [index, file] of files.entries()) {
onProgress?.((index / Math.max(files.length, 1)) * 100); onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -2069,18 +2171,19 @@ async function buildStlModels(volume, onProgress = null) {
mesh.userData.file = file; mesh.userData.file = file;
mesh.userData.color = cssColor(index); mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh); state.rawModelGroup.add(mesh);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index }); const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index };
state.modelMeshes.push(record);
freshRecords.push(record);
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
} }
const bbox = new THREE.Box3(); if (!state.modelLocalBounds && state.modelMeshes.length) {
const rawBox = new THREE.Box3();
state.modelMeshes.forEach((record) => { state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.(); record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox); if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
}); });
const center = new THREE.Vector3(); const center = new THREE.Vector3();
const size = new THREE.Vector3(); rawBox.getCenter(center);
bbox.getCenter(center);
bbox.getSize(size);
state.modelMeshes.forEach((record) => { state.modelMeshes.forEach((record) => {
const geometry = record.mesh.geometry; const geometry = record.mesh.geometry;
geometry.translate(-center.x, -center.y, -center.z); geometry.translate(-center.x, -center.y, -center.z);
@@ -2090,10 +2193,28 @@ async function buildStlModels(volume, onProgress = null) {
}); });
state.modelLocalBounds = { state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z }, center: { x: center.x, y: center.y, z: center.z },
size: { x: size.x, y: size.y, z: size.z }, size: { x: 1, y: 1, z: 1 },
}; };
} else if (state.modelLocalBounds && freshRecords.length) {
const center = state.modelLocalBounds.center;
freshRecords.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
}
const bbox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
});
const size = new THREE.Vector3();
if (!bbox.isEmpty()) bbox.getSize(size);
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
updateBaseModelScaleFromBounds(); updateBaseModelScaleFromBounds();
addModelBoundsFrame(size); if (size.x || size.y || size.z) addModelBoundsFrame(size);
applyStlVisibility(); applyStlVisibility();
applyPose(); applyPose();
scheduleMappingDraw(); scheduleMappingDraw();
@@ -2111,9 +2232,9 @@ function applyPose(poseInput = state.pose) {
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0); state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale; const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set( state.modelGroup.scale.set(
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1), scale * (pose.flipX ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1), scale * (pose.flipY ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1), scale * (pose.flipZ ? -1 : 1),
); );
state.modelGroup.updateMatrixWorld(true); state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw(); scheduleMappingDraw();
@@ -2154,7 +2275,7 @@ function fitCamera() {
} }
function resetSceneView() { function resetSceneView() {
state.viewPose = { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 }; state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose(); applySceneViewPose();
fitCamera(); fitCamera();
} }
@@ -2180,21 +2301,24 @@ function stretchAxis(axis) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return; return;
} }
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis]; if (!["x", "y", "z"].includes(axis)) return;
if (!axisKey) return;
const dicomSize = new THREE.Vector3(); const dicomSize = new THREE.Vector3();
const modelSize = new THREE.Vector3(); const modelSize = new THREE.Vector3();
state.dicomSceneBox.getSize(dicomSize); state.dicomSceneBox.getSize(dicomSize);
modelBox.getSize(modelSize); modelBox.getSize(modelSize);
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
const targetSize = Math.max(0.001, dicomSize[axis]); const targetSize = Math.max(0.001, dicomSize[axis]);
const currentSize = Math.max(0.001, modelSize[axis]); const currentSize = Math.max(0.001, modelSize[axis]);
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize))); const currentScale = Math.max(0.001, Number(state.pose.scale) || 1);
state.pose[axisKey] = Number(next.toFixed(3)); const next = Math.max(0.2, Math.min(4, currentScale * (targetSize / currentSize)));
state.pose.scale = Number(next.toFixed(3));
state.pose.scaleX = 1;
state.pose.scaleY = 1;
state.pose.scaleZ = 1;
renderPoseControls();
applyPose(); applyPose();
markDirty("pose"); markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok"); setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`; $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`;
updateAutoPoseResult(); updateAutoPoseResult();
} }
@@ -2609,6 +2733,12 @@ function wireEvents() {
window.clearTimeout(state.searchTimer); window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(() => loadCases(false), 240); state.searchTimer = window.setTimeout(() => loadCases(false), 240);
}); });
$("caseList").addEventListener("scroll", () => {
const list = $("caseList");
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) {
loadMoreCases();
}
});
document.querySelectorAll(".filter[data-status]").forEach((button) => { document.querySelectorAll(".filter[data-status]").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
state.statusFilter = button.dataset.status || ""; state.statusFilter = button.dataset.status || "";

View File

@@ -292,6 +292,17 @@
<div id="fusionViewport"></div> <div id="fusionViewport"></div>
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div> <div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div>
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div> <div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
<div class="fusion-axis-inset" aria-hidden="true">
<svg viewBox="0 0 76 76" role="img">
<line x1="25" y1="56" x2="58" y2="56" class="axis-x" />
<line x1="25" y1="56" x2="36" y2="34" class="axis-y" />
<line x1="25" y1="56" x2="25" y2="20" class="axis-z" />
<circle cx="25" cy="56" r="3" />
<text x="64" y="60">X</text>
<text x="39" y="31">Y</text>
<text x="21" y="16">Z</text>
</svg>
</div>
<div id="fusionLoading" class="fusion-loading hidden"> <div id="fusionLoading" class="fusion-loading hidden">
<span>正在构建融合视图</span> <span>正在构建融合视图</span>
<div><i id="fusionProgressFill"></i></div> <div><i id="fusionProgressFill"></i></div>

View File

@@ -478,6 +478,18 @@ button {
flex: 1; flex: 1;
} }
.load-more-btn {
width: 100%;
min-height: 38px;
margin: 2px 0 8px;
border: 1px solid rgba(45, 212, 191, 0.4);
border-radius: 12px;
background: rgba(20, 184, 166, 0.08);
color: #bffbf2;
font-size: 12px;
font-weight: 900;
}
.case-card, .case-card,
.series-card, .series-card,
.stl-row { .stl-row {
@@ -1116,6 +1128,53 @@ button {
right: 14px; right: 14px;
} }
.fusion-axis-inset {
position: absolute;
right: 18px;
bottom: 18px;
z-index: 4;
width: 76px;
height: 76px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(0, 0, 0, 0.58);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
}
.fusion-axis-inset svg {
display: block;
width: 100%;
height: 100%;
}
.fusion-axis-inset line {
stroke-width: 3;
stroke-linecap: round;
}
.fusion-axis-inset circle {
fill: #f87171;
}
.fusion-axis-inset text {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 950;
fill: #e8f1fb;
}
.fusion-axis-inset .axis-x {
stroke: #f87171;
}
.fusion-axis-inset .axis-y {
stroke: #4ade80;
}
.fusion-axis-inset .axis-z {
stroke: #60a5fa;
}
.fusion-loading { .fusion-loading {
position: absolute; position: absolute;
left: 40px; left: 40px;