diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py
index c29565e..6a4150f 100644
--- a/DICOM_and_UPP配准/app.py
+++ b/DICOM_and_UPP配准/app.py
@@ -799,7 +799,9 @@ def dicom_fusion_volume(
series_uid: str,
center_index: int = 0,
window: str = "soft",
- radius: int = Query(default=24, ge=4, le=48),
+ radius: int = Query(default=96, ge=4, le=512),
+ range_start: int | None = None,
+ range_end: int | None = None,
_: str = Depends(require_auth),
) -> dict[str, Any]:
stack_data = load_stack_data(ct_number, series_uid)
@@ -807,10 +809,17 @@ def dicom_fusion_volume(
datasets = stack_data["datasets"]
total = int(stack.shape[0])
center_index = min(max(0, center_index), total - 1)
- start = max(0, center_index - radius)
- end = min(total - 1, center_index + radius)
- if end - start + 1 > 48:
- step = int(np.ceil((end - start + 1) / 48))
+ if range_start is not None and range_end is not None:
+ start = min(max(0, int(range_start)), total - 1)
+ end = min(max(0, int(range_end)), total - 1)
+ if start > end:
+ start, end = end, start
+ else:
+ start = max(0, center_index - radius)
+ end = min(total - 1, center_index + radius)
+ max_frames = 72
+ if end - start + 1 > max_frames:
+ step = int(np.ceil((end - start + 1) / max_frames))
indices = list(range(start, end + 1, step))
else:
indices = list(range(start, end + 1))
diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js
index c3927fc..9bddb0b 100644
--- a/DICOM_and_UPP配准/static/app.js
+++ b/DICOM_and_UPP配准/static/app.js
@@ -61,6 +61,8 @@ const state = {
windowMode: "default",
fusionMode: "fusion",
sliceIndex: 0,
+ sliceRangeStart: 0,
+ sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
dirty: false,
@@ -78,9 +80,10 @@ const state = {
modelGroup: null,
rawModelGroup: null,
modelMeshes: [],
+ stlSignature: "",
baseModelScale: 1,
dicomSceneBox: null,
- mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
+ mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
resizeObserver: null,
@@ -103,9 +106,17 @@ function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
-function setFusionLoading(visible, text = "正在构建融合视图") {
+function setFusionLoading(visible, text = "正在构建融合视图", progress = null) {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
+ const fill = $("fusionProgressFill");
+ const label = $("fusionProgressText");
+ if (fill && label) {
+ const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0));
+ fill.style.width = progress == null ? "34%" : `${value}%`;
+ fill.classList.toggle("indeterminate", progress == null);
+ label.textContent = progress == null ? "" : `${Math.round(value)}%`;
+ }
}
function setSaveState(text, tone = "") {
@@ -296,7 +307,30 @@ function isUpperAbdomenSeries(item) {
}
function isPortalPhaseSeries(item) {
- return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期")));
+ return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || label.includes("门静脉")));
+}
+
+function isLiverBiliaryModel(model = state.algorithmModel) {
+ const value = String(model || "").toLowerCase();
+ return value.includes("肝胆") || value.includes("liver") || value.includes("bile");
+}
+
+function sortedSeriesForDisplay(series) {
+ return [...series].sort((a, b) => {
+ const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b));
+ if (skippedDiff) return skippedDiff;
+ const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || ""));
+ if (timeDiff) return timeDiff;
+ return Number(a.series_number || 999999) - Number(b.series_number || 999999);
+ });
+}
+
+function stlSelectionSignature() {
+ return [
+ normalize(state.activeCase?.ct_number),
+ state.algorithmModel || "",
+ [...state.selectedStlIds].map(Number).sort((a, b) => a - b).join(","),
+ ].join("|");
}
function bodyPartTags(row) {
@@ -375,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return;
setTopLoading(true);
- setFusionLoading(true, "正在读取 CT 关联数据");
+ setFusionLoading(true, "正在读取 CT 关联数据", 6);
try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
@@ -398,17 +432,22 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
- state.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || "";
+ const defaultSeries = pickDefaultSeries(state.series);
+ const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
+ const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
+ state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
+ state.sliceRangeStart = 0;
+ state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
+ state.stlSignature = "";
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
- if (keepPreviousPose) markDirty("model");
- else resetDirty();
+ resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
@@ -423,10 +462,9 @@ function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
- const algorithm = String(state.algorithmModel || "").toLowerCase();
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
- if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
+ if (isLiverBiliaryModel()) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
@@ -486,7 +524,7 @@ function renderSeries() {
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `
尚未选择配准序列
`;
$("seriesList").innerHTML = state.series.length
- ? state.series
+ ? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
.join("")
: `未读取到 DICOM 序列
`;
@@ -520,7 +558,9 @@ async function selectSeries(uid) {
state.selectedSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
- markDirty();
+ state.sliceRangeStart = 0;
+ state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
+ setSaveState("序列未保存", "warn");
renderSeries();
renderDicomAnnotation();
await loadFusion();
@@ -552,7 +592,7 @@ function renderStl() {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
- markDirty();
+ setSaveState("模型未保存", "warn");
renderStl();
await loadFusion();
});
@@ -642,9 +682,19 @@ function updateSliceControl() {
const count = Number(selected?.count || 0);
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
+ state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
+ state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
+ if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
$("sliceSlider").max = max;
$("sliceSlider").value = state.sliceIndex;
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
+ $("sliceRangeStart").max = max;
+ $("sliceRangeEnd").max = max;
+ $("sliceRangeStart").value = state.sliceRangeStart;
+ $("sliceRangeEnd").value = state.sliceRangeEnd;
+ $("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0";
+ $("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0";
+ $("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
@@ -795,7 +845,7 @@ async function loadFusion() {
clearFusion();
return;
}
- setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
+ setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
try {
const params = new URLSearchParams({
@@ -803,14 +853,17 @@ async function loadFusion() {
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
- radius: "24",
+ range_start: String(state.sliceRangeStart),
+ range_end: String(state.sliceRangeEnd),
});
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
if (version !== state.fusionVersion) return;
state.volumeMeta = volume;
+ setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
await buildDicomVolume(volume);
if (version !== state.fusionVersion) return;
- await buildStlModels(volume);
+ setFusionLoading(true, "正在加载 STL 模型", 62);
+ await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
@@ -819,8 +872,9 @@ async function loadFusion() {
$("fusionStatus").textContent = state.sceneReady
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
- $("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
+ $("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size} 个`;
renderDicomAnnotation();
+ setFusionLoading(true, "正在渲染右侧分割映射", 96);
updateDicomPreview();
} catch (error) {
clearFusion();
@@ -888,8 +942,8 @@ function scheduleMappingDraw() {
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
- const { scale, offsetX, offsetY } = state.mappingViewport;
- layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
+ const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
+ layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
@@ -983,22 +1037,57 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
}
});
+ const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity);
maskContext.putImageData(imageData, 0, 0);
+ context.globalAlpha = 0.45;
context.drawImage(maskCanvas, 0, 0);
- context.save();
- context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
- context.strokeStyle = color;
- context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
- context.lineCap = "round";
- context.lineJoin = "round";
- context.beginPath();
+ context.globalAlpha = 1;
+ return filled + hullPixels;
+}
+
+function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
+ const points = [];
segments.forEach((segment) => {
- context.moveTo(segment.a.x, segment.a.y);
- context.lineTo(segment.b.x, segment.b.y);
+ [segment.a, segment.b].forEach((point) => {
+ if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) {
+ points.push({ x: point.x, y: point.y });
+ }
+ });
});
+ if (points.length < 3) return 0;
+ const sorted = points
+ .filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index)
+ .sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y));
+ if (sorted.length < 3) return 0;
+ const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
+ const lower = [];
+ sorted.forEach((point) => {
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop();
+ lower.push(point);
+ });
+ const upper = [];
+ [...sorted].reverse().forEach((point) => {
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop();
+ upper.push(point);
+ });
+ const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
+ if (hull.length < 3) return 0;
+ context.save();
+ context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)) * 0.58;
+ context.fillStyle = color;
+ context.strokeStyle = color;
+ context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002);
+ context.beginPath();
+ hull.forEach((point, index) => {
+ if (index === 0) context.moveTo(point.x, point.y);
+ else context.lineTo(point.x, point.y);
+ });
+ context.closePath();
+ context.fill();
+ context.globalAlpha = Math.max(0.22, Math.min(opacity, 1));
context.stroke();
context.restore();
- return filled;
+ return Math.max(1, Math.round(hull.length * 6));
}
function drawMappingView() {
@@ -1190,9 +1279,17 @@ async function buildDicomVolume(volume) {
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
-async function buildStlModels(volume) {
+async function buildStlModels(volume, onProgress = null) {
+ const signature = stlSelectionSignature();
+ if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
+ applyPose();
+ scheduleMappingDraw();
+ onProgress?.(100);
+ return;
+ }
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
+ state.stlSignature = signature;
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
if (!files.length) {
applyPose();
@@ -1201,6 +1298,7 @@ async function buildStlModels(volume) {
}
const loader = new STLLoader();
for (const [index, file] of files.entries()) {
+ onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
file_id: String(file.id),
@@ -1225,6 +1323,7 @@ async function buildStlModels(volume) {
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
+ onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
const center = new THREE.Vector3();
@@ -1232,6 +1331,7 @@ async function buildStlModels(volume) {
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
applyPose();
scheduleMappingDraw();
+ onProgress?.(100);
}
function applyPose(poseInput = state.pose) {
@@ -1499,6 +1599,17 @@ function openLinkedApp(kind) {
}
}
+function setWindowMode(mode, reload = true) {
+ state.windowMode = mode || "default";
+ document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
+ document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
+ if (reload) loadFusion();
+}
+
+function setAutoModalVisible(visible) {
+ $("autoModal").classList.toggle("hidden", !visible);
+}
+
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
@@ -1529,6 +1640,11 @@ function wireEvents() {
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
+ $("openAutoModalBtn").addEventListener("click", () => setAutoModalVisible(true));
+ $("closeAutoModalBtn").addEventListener("click", () => setAutoModalVisible(false));
+ $("autoModal").addEventListener("click", (event) => {
+ if (event.target === $("autoModal")) setAutoModalVisible(false);
+ });
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
@@ -1541,7 +1657,15 @@ function wireEvents() {
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
- state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
+ state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 };
+ applyMappingTransform();
+ });
+ $("mappingRotateLeftBtn").addEventListener("click", () => {
+ state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90;
+ applyMappingTransform();
+ });
+ $("mappingRotateRightBtn").addEventListener("click", () => {
+ state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90;
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
@@ -1605,11 +1729,10 @@ function wireEvents() {
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
- button.addEventListener("click", async () => {
- state.windowMode = button.dataset.window || "default";
- document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
- await loadFusion();
- });
+ button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
+ });
+ document.querySelectorAll("[data-map-window]").forEach((button) => {
+ button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
@@ -1625,7 +1748,6 @@ function wireEvents() {
renderDicomAnnotation();
updateDicomPreview();
});
- $("sliceSlider").addEventListener("change", () => loadFusion());
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
@@ -1634,7 +1756,15 @@ function wireEvents() {
renderDicomAnnotation();
updateDicomPreview();
});
- $("mappingSliceSlider").addEventListener("change", () => loadFusion());
+ const updateRange = () => {
+ state.sliceRangeStart = Number($("sliceRangeStart").value || 0);
+ state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0);
+ updateSliceControl();
+ };
+ $("sliceRangeStart").addEventListener("input", updateRange);
+ $("sliceRangeEnd").addEventListener("input", updateRange);
+ $("sliceRangeStart").addEventListener("change", () => loadFusion());
+ $("sliceRangeEnd").addEventListener("change", () => loadFusion());
}
wireEvents();
diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html
index c76536e..b2695b9 100644
--- a/DICOM_and_UPP配准/static/index.html
+++ b/DICOM_and_UPP配准/static/index.html
@@ -91,7 +91,7 @@
-
+
+
+
+
+
+
位姿自动调整
+
选择参与自动配准的调整方向
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+