From 692c50e22cec36b42907c082cf55174d5d58c898 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 16:53:01 +0800 Subject: [PATCH] Optimize DICOM UPP registration lazy loading and fusion views --- DICOM_and_UPP配准/app.py | 101 ++++++-- DICOM_and_UPP配准/static/app.js | 356 +++++++++++++++++++--------- DICOM_and_UPP配准/static/index.html | 11 + DICOM_and_UPP配准/static/styles.css | 59 +++++ 4 files changed, 393 insertions(+), 134 deletions(-) diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py index 8155803..d5124b5 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -12,6 +12,7 @@ import shutil import subprocess import threading import time +from concurrent.futures import ThreadPoolExecutor, as_completed from collections import Counter, defaultdict from pathlib import Path from typing import Any @@ -123,6 +124,7 @@ app = FastAPI(title="DICOM UPP Registration Workspace") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") TOKENS: dict[str, str] = {} 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]]] = {} CACHE_REFRESH_LOCK = threading.Lock() CACHE_REFRESH_THREAD: threading.Thread | None = None @@ -663,9 +665,10 @@ def cases( status_filter: str = Query(default="", alias="status"), body_part: 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), -) -> list[dict[str, Any]]: +) -> dict[str, Any]: ensure_registration_table() clauses = [] if q.strip(): @@ -690,7 +693,15 @@ def cases( if algorithm_model.strip(): clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}") 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""" SELECT * FROM ({relation_select()}) relation @@ -701,9 +712,17 @@ def cases( COALESCE(study_time, '') DESC, ct_key LIMIT {int(limit)} + OFFSET {int(offset)} """, 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}") @@ -739,6 +758,23 @@ def read_header(path: Path) -> dict[str, str]: 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]: path, meta = item 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) if not root.exists(): 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"): - try: - meta = read_header(path) - except Exception: - continue - uid = meta.get("SeriesInstanceUID") or path.parent.name - grouped[uid].append((path, meta)) + grouped[path.parent.name].append(path) annotations = get_annotations(ct_number) series = [] file_map = {} - for uid, items in grouped.items(): - items.sort(key=sort_key) - first = items[0][1] - last = items[-1][1] - file_map[uid] = [path for path, _ in items] - annotation = annotations.get(uid) or {} + summary_paths: list[Path] = [] + for paths in grouped.values(): + paths = sorted(paths) + if not paths: + continue + 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_uid": uid, + "series_uid": actual_uid, "description": first.get("SeriesDescription") or "未命名序列", "series_number": first.get("SeriesNumber") or "", - "count": len(items), + "count": len(paths), "modality": first.get("Modality") or "", "rows": first.get("Rows") 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) if not files: 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]: @@ -945,7 +1003,7 @@ def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 9 pil = Image.fromarray(img) pil = resize_for_spacing(pil, spacing[0], spacing[1]) 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() pil.save(output, format="PNG", optimize=True) return output.getvalue() @@ -1016,7 +1074,7 @@ def dicom_fusion_volume( else: start = max(0, 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: step = int(np.ceil((end - start + 1) / max_frames)) indices = list(range(start, end + 1, step)) @@ -1226,6 +1284,7 @@ def run_cache_refresh(reason: str = "manual", user: str = "system") -> None: try: cache_root.mkdir(parents=True, exist_ok=True) STUDY_CACHE.clear() + SERIES_SORT_CACHE.clear() STACK_CACHE.clear() rows = relation_rows_for_cache() total = len(rows) diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 51f6638..de094a3 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -39,6 +39,19 @@ const POSE_CONTROLS = [ ]; 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 = { token: localStorage.getItem("dicom_upp_registration_token") || "", @@ -51,7 +64,13 @@ const state = { partFilter: "", modelFilter: "", search: "", + searchTimer: 0, caseCollapsed: false, + caseLimit: CASE_PAGE_SIZE, + caseOffset: 0, + caseTotal: 0, + caseHasMore: false, + loadingCases: false, activeToolTab: "series", cases: [], activeCase: null, @@ -65,7 +84,7 @@ const state = { pose: { ...DEFAULT_POSE }, windowMode: "default", fusionMode: "fusion", - fusionDetail: "high", + fusionDetail: "low", modelDetail: "standard", mappingMode: "result", sliceIndex: 0, @@ -76,6 +95,7 @@ const state = { lastAutoPose: null, dirty: false, dirtyReason: "", + savedPoseSignature: "", saving: false, fusionVersion: 0, volumeMeta: null, @@ -86,7 +106,7 @@ const state = { scene: null, controls: 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, dicomGroup: null, dicomPlane: null, @@ -175,9 +195,23 @@ function markDirty(reason = "pose") { 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() { state.dirty = false; state.dirtyReason = ""; + state.savedPoseSignature = poseSignature(state.pose); setSaveState("已保存", "ok"); } @@ -341,28 +375,47 @@ function pollCacheWhileRunning(force = false) { }, running ? 1600 : 800); } -async function loadCases(selectFirst = true) { - setTopLoading(true); +async function loadCases(selectFirst = true, append = false) { + if (state.loadingCases) return; + state.loadingCases = true; + if (!append) { + state.caseOffset = 0; + state.caseHasMore = false; + state.cases = []; + } + setTopLoading(!append); try { const params = new URLSearchParams(); if (state.search) params.set("q", state.search); if (state.statusFilter) params.set("status", state.statusFilter); if (state.partFilter) params.set("body_part", state.partFilter); if (state.modelFilter) params.set("algorithm_model", state.modelFilter); - const rows = await api(`/api/cases?${params.toString()}`); - state.cases = rows; + params.set("limit", String(state.caseLimit)); + 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(); if (selectFirst && !state.activeCase && rows.length) { 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; clearDetail(); } } 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) { 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); } +function modelPoseMatrix() { + if (!state.modelGroup) return new THREE.Matrix4(); + state.modelGroup.updateMatrix(); + return state.modelGroup.matrix.clone(); +} + function visibleModelBox() { const records = visibleModelRecords(); if (!records.length) return null; 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; } @@ -455,9 +519,9 @@ function bodyPartTags(row) { } function renderCases() { - $("caseCount").textContent = state.cases.length; + $("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length; const active = state.activeCase; - $("caseList").innerHTML = state.cases.length + const cards = state.cases.length ? state.cases .map((row) => { const tags = bodyPartTags(row).slice(0, 3); @@ -483,9 +547,11 @@ function renderCases() { }) .join("") : `
没有符合条件的完整关联 CT
`; + $("caseList").innerHTML = `${cards}${state.caseHasMore ? `` : ""}`; $("caseList").querySelectorAll(".case-card").forEach((button) => { button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型")); }); + $("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases); } function clearDetail() { @@ -505,7 +571,7 @@ function clearDetail() { } async function confirmChange() { - if (!state.dirty) return true; + if (!state.dirty || !poseChanged()) return true; return window.confirm("当前配准参数还未保存,确定切换吗?"); } @@ -518,7 +584,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { const previousPose = { ...state.pose }; if (!(await confirmChange())) return; setTopLoading(true); - setFusionLoading(true, "正在读取 CT 关联数据", 6); + setFusionLoading(true, "正在读取当前 CT 数据", 6); try { const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm }); const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ @@ -532,10 +598,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { state.series = seriesPayload.series || []; state.stlFiles = stlPayload.files || []; state.registrationStatus = registration.registration_status || "unregistered"; - const savedTransform = registration.transform || {}; + const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 }; const keepPreviousPose = switchingModelOnly; 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.autoResult = null; state.lastAutoPose = null; @@ -617,18 +683,14 @@ function currentSeries() { function renderActiveHeader() { const row = state.activeCase; if (!row) return; - const selected = currentSeries(); const registration = registrationSeries(); - const bodyTags = bodyPartTags(row); 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 || "未指定模型"}`; - $("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 = [ `${statusText(state.registrationStatus)}`, `${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`, - ...(registration ? [`配准 ${escapeHtml(registration.description || "当前序列")}`] : []), ...selectedLabels.map((tag) => `${escapeHtml(tag)}`), - ...bodyTags.map((tag) => `${escapeHtml(tag)}`), ]; $("activeTags").innerHTML = tags.join(""); $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; @@ -766,10 +828,21 @@ async function invertStlSelection() { async function applyStlSelectionChange() { markDirty("stl"); 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(); 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(); applyModelDetail(); scheduleMappingDraw(); @@ -1207,10 +1280,10 @@ function updateFusionMeta() { function fusionDetailSettings() { return { - low: { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, 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 }, - high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 }, - }[state.fusionDetail] || { 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: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 }, + high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 }, + }[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }; } function modelDetailSettings() { @@ -1333,7 +1406,7 @@ async function loadTexture(url) { texture.colorSpace = THREE.SRGBColorSpace; texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; + texture.magFilter = THREE.NearestFilter; texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1); texture.needsUpdate = true; resolve(texture); @@ -1677,7 +1750,6 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit const maskData = maskContext.createImageData(width, height); const radius = solidStrokeRadius(width, height); const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15); - const fallbackGroups = []; let filledPixels = 0; groups.forEach((group) => { @@ -1713,16 +1785,12 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit }); groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius); 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 += fillInternalMaskHoles(maskData, width, height, rgb, alpha); maskContext.putImageData(maskData, 0, 0); context.drawImage(maskCanvas, 0, 0); - fallbackGroups.forEach((group) => { - filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity); - }); if (filledPixels === 0) { context.save(); @@ -1739,6 +1807,19 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit context.stroke(); 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; } @@ -1774,7 +1855,7 @@ async function drawMappingView(version = state.mappingVersion) { x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width, y: (0.5 - point.y / volumeScene.height) * height, }); - state.modelGroup?.updateMatrixWorld(true); + const matrix = modelPoseMatrix(); let activeModules = 0; let segmentCount = 0; @@ -1789,15 +1870,12 @@ async function drawMappingView(version = state.mappingVersion) { const position = record.mesh.geometry.attributes.position; if (!position) continue; 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 = []; for (let tri = 0; tri < triangleCount; tri += step) { - tempA.fromBufferAttribute(position, tri * 3); - tempB.fromBufferAttribute(position, tri * 3 + 1); - tempC.fromBufferAttribute(position, tri * 3 + 2); - record.mesh.localToWorld(tempA); - record.mesh.localToWorld(tempB); - record.mesh.localToWorld(tempC); + tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix); + tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix); + tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix); const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ); if (!segment) continue; const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) }; @@ -1912,11 +1990,11 @@ async function buildDicomVolume(volume) { opacity: fusionDetail.planeOpacity, side: THREE.DoubleSide, depthWrite: false, - depthTest: true, + depthTest: false, }), ); plane.position.z = sliceToSceneZ(centerIndex) + 0.006; - plane.renderOrder = 20; + plane.renderOrder = 80; state.dicomGroup.add(plane); state.dicomPlane = plane; } @@ -1944,46 +2022,51 @@ async function buildDicomVolume(volume) { } } - const pointPositions = []; - const pointColors = []; - for (const [index, frame] of frames.entries()) { - const image = await loadImageData(frame, 180); - const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid)); - const z = sliceToSceneZ(Number(indices[index] ?? centerIndex)); - for (let y = 0; y < image.height; y += stride) { - for (let x = 0; x < image.width; x += stride) { - const offset = (y * image.width + x) * 4; - const lum = image.data[offset]; - if (lum < fusionDetail.pointThreshold) continue; - pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width); - pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height); - pointPositions.push(z); - const v = 0.16 + (lum / 255) * 0.72; - pointColors.push(v * 0.92, v, Math.min(1, v * 1.08)); + if (fusionDetail.pointOpacity > 0) { + const pointPositions = []; + const pointColors = []; + for (const [index, frame] of frames.entries()) { + const image = await loadImageData(frame, 180); + const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid)); + const z = sliceToSceneZ(Number(indices[index] ?? centerIndex)); + for (let y = 0; y < image.height; y += stride) { + for (let x = 0; x < image.width; x += stride) { + const offset = (y * image.width + x) * 4; + const lum = image.data[offset]; + if (lum < fusionDetail.pointThreshold) continue; + pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width); + pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height); + pointPositions.push(z); + const v = 0.16 + (lum / 255) * 0.72; + pointColors.push(v * 0.92, v, Math.min(1, v * 1.08)); + } } } - } - if (pointPositions.length) { - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3)); - geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3)); - const points = new THREE.Points( - geometry, - new THREE.PointsMaterial({ - size: fusionDetail.pointSize, - vertexColors: true, - transparent: true, - opacity: fusionDetail.pointOpacity, - depthWrite: false, - }), - ); - points.renderOrder = 2; - state.dicomGroup.add(points); - state.dicomPoints = points; + if (pointPositions.length) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3)); + geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3)); + const points = new THREE.Points( + geometry, + new THREE.PointsMaterial({ + size: fusionDetail.pointSize, + vertexColors: true, + transparent: true, + opacity: fusionDetail.pointOpacity, + depthWrite: false, + }), + ); + points.renderOrder = 2; + state.dicomGroup.add(points); + state.dicomPoints = points; + } } 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() { @@ -1998,6 +2081,7 @@ function updateBaseModelScaleFromBounds() { function addModelBoundsFrame(size) { if (!state.rawModelGroup || !size) return; + removeModelBoundsFrame(); const frame = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), new THREE.LineBasicMaterial({ @@ -2015,9 +2099,34 @@ function addModelBoundsFrame(size) { 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) { 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(); if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size); applyStlVisibility(); @@ -2027,22 +2136,15 @@ async function buildStlModels(volume, onProgress = null) { onProgress?.(100); return; } - 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 files = state.stlFiles; - if (!files.length) { + if (!files.length && !state.modelMeshes.length) { applyPose(); resetMappingCanvas("当前没有可见 STL 构件"); + onProgress?.(100); return; } const loader = new STLLoader(); const detail = modelDetailSettings(); + const freshRecords = []; for (const [index, file] of files.entries()) { onProgress?.((index / Math.max(files.length, 1)) * 100); const params = new URLSearchParams({ @@ -2069,31 +2171,50 @@ async function buildStlModels(volume, onProgress = null) { mesh.userData.file = file; mesh.userData.color = cssColor(index); 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); } + if (!state.modelLocalBounds && state.modelMeshes.length) { + const rawBox = new THREE.Box3(); + state.modelMeshes.forEach((record) => { + record.mesh.geometry.computeBoundingBox?.(); + if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox); + }); + const center = new THREE.Vector3(); + rawBox.getCenter(center); + state.modelMeshes.forEach((record) => { + const geometry = record.mesh.geometry; + geometry.translate(-center.x, -center.y, -center.z); + geometry.computeBoundingBox?.(); + geometry.computeBoundingSphere?.(); + geometry.computeVertexNormals?.(); + }); + state.modelLocalBounds = { + center: { x: center.x, y: center.y, z: center.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 center = new THREE.Vector3(); const size = new THREE.Vector3(); - bbox.getCenter(center); - bbox.getSize(size); - state.modelMeshes.forEach((record) => { - const geometry = record.mesh.geometry; - geometry.translate(-center.x, -center.y, -center.z); - geometry.computeBoundingBox?.(); - geometry.computeBoundingSphere?.(); - geometry.computeVertexNormals?.(); - }); - state.modelLocalBounds = { - center: { x: center.x, y: center.y, z: center.z }, - size: { x: size.x, y: size.y, z: size.z }, - }; + if (!bbox.isEmpty()) bbox.getSize(size); + if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z }; updateBaseModelScaleFromBounds(); - addModelBoundsFrame(size); + if (size.x || size.y || size.z) addModelBoundsFrame(size); applyStlVisibility(); applyPose(); 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); const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale; state.modelGroup.scale.set( - scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1), - scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1), - scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1), + scale * (pose.flipX ? -1 : 1), + scale * (pose.flipY ? -1 : 1), + scale * (pose.flipZ ? -1 : 1), ); state.modelGroup.updateMatrixWorld(true); scheduleMappingDraw(); @@ -2154,7 +2275,7 @@ function fitCamera() { } 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(); fitCamera(); } @@ -2180,21 +2301,24 @@ function stretchAxis(axis) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } - const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis]; - if (!axisKey) return; + if (!["x", "y", "z"].includes(axis)) return; const dicomSize = new THREE.Vector3(); const modelSize = new THREE.Vector3(); state.dicomSceneBox.getSize(dicomSize); modelBox.getSize(modelSize); - const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1); const targetSize = Math.max(0.001, dicomSize[axis]); const currentSize = Math.max(0.001, modelSize[axis]); - const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize))); - state.pose[axisKey] = Number(next.toFixed(3)); + const currentScale = Math.max(0.001, Number(state.pose.scale) || 1); + 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(); markDirty("pose"); setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok"); - $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`; + $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`; updateAutoPoseResult(); } @@ -2609,6 +2733,12 @@ function wireEvents() { window.clearTimeout(state.searchTimer); 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) => { button.addEventListener("click", async () => { state.statusFilter = button.dataset.status || ""; diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index eeb3953..28dc23f 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -292,6 +292,17 @@
等待选择 DICOM 与 STL
DICOM - · STL -
+