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 -
+
+
+
正在构建融合视图
diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css
index 59e1ea8..f69af20 100644
--- a/DICOM_and_UPP配准/static/styles.css
+++ b/DICOM_and_UPP配准/static/styles.css
@@ -478,6 +478,18 @@ button {
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,
.series-card,
.stl-row {
@@ -1116,6 +1128,53 @@ button {
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 {
position: absolute;
left: 40px;