Refine registration series defaults and mapping controls
This commit is contained in:
@@ -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) : `<div class="empty-state">尚未选择配准序列</div>`;
|
||||
$("seriesList").innerHTML = state.series.length
|
||||
? state.series
|
||||
? sortedSeriesForDisplay(state.series)
|
||||
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
||||
.join("")
|
||||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user