Migrate registration workspace fusion mapping views

This commit is contained in:
Codex
2026-05-28 23:43:49 +08:00
parent 7a38272e8b
commit c4f0fd54d6
3 changed files with 802 additions and 65 deletions

View File

@@ -20,6 +20,9 @@ const DEFAULT_POSE = {
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
@@ -61,9 +64,11 @@ const state = {
dicomPreviewTimer: 0,
autoResult: null,
dirty: false,
dirtyReason: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
volumeScene: null,
sceneReady: false,
renderer: null,
camera: null,
@@ -72,8 +77,12 @@ const state = {
dicomGroup: null,
modelGroup: null,
rawModelGroup: null,
modelMeshes: [],
baseModelScale: 1,
dicomSceneBox: null,
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
resizeObserver: null,
resizeScene: null,
};
@@ -112,13 +121,15 @@ function setAutoState(text, tone = "") {
el.className = tone;
}
function markDirty() {
function markDirty(reason = "pose") {
state.dirty = true;
state.dirtyReason = reason;
setSaveState("未保存", "warn");
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
setSaveState("已保存", "ok");
}
@@ -185,6 +196,7 @@ async function bootstrap() {
try {
initScene();
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
console.warn(error);
}
@@ -242,6 +254,10 @@ function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function sameCtNumber(a, b) {
return normalize(a) === normalize(b);
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
@@ -263,6 +279,26 @@ function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function seriesLabels(item) {
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
}
function isSkippedSeries(item) {
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
}
function hasLabel(item, matcher) {
return seriesLabels(item).some((label) => matcher(String(label)));
}
function isUpperAbdomenSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部"));
}
function isPortalPhaseSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期")));
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
@@ -331,11 +367,17 @@ async function confirmChange() {
}
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
if (!ctNumber || !(await confirmChange())) return;
if (!ctNumber) return;
const normalizedAlgorithm = algorithmModel || "未指定模型";
const switchingModelOnly = state.activeCase
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据");
try {
const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" });
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
@@ -347,7 +389,9 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
const savedTransform = registration.transform || {};
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.autoResult = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
@@ -363,7 +407,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
renderSeries();
renderStl();
renderPoseControls();
resetDirty();
if (keepPreviousPose) markDirty("model");
else resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
@@ -375,9 +420,27 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
}
function pickDefaultSeries(series) {
return [...series]
.filter((item) => Number(item.count || 0) > 1)
.sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null;
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 (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
score += 120;
}
if (isSkippedSeries(item)) score -= 1000;
return score;
};
return [...pool]
.sort((a, b) => {
const diff = rank(b) - rank(a);
if (Math.abs(diff) > 1e-6) return diff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
})[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
@@ -437,8 +500,9 @@ function renderSeries() {
function seriesCardHtml(item, active = false) {
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
@@ -576,9 +640,14 @@ function renderPoseControls() {
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("sliceSlider").max = Math.max(0, count - 1);
$("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1));
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
$("sliceSlider").max = max;
$("sliceSlider").value = state.sliceIndex;
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
}
function renderDicomAnnotation() {
@@ -604,6 +673,7 @@ function updateDicomPreview() {
if (!state.activeCase || !selected) {
$("dicomPreview").removeAttribute("src");
renderDicomAnnotation();
resetMappingCanvas("未选择 DICOM 序列");
return;
}
window.clearTimeout(state.dicomPreviewTimer);
@@ -616,6 +686,7 @@ function updateDicomPreview() {
access_token: state.token,
});
$("dicomLoading").classList.remove("hidden");
resetMappingCanvas("正在加载 DICOM Base Layer");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
}, 80);
@@ -672,6 +743,15 @@ function initScene() {
animate();
}
function initGeometryFallback() {
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
Object.assign(state, { dicomGroup, modelGroup, rawModelGroup });
}
function clearGroup(group) {
if (!group) return;
while (group.children.length) {
@@ -691,9 +771,12 @@ function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl();
resetMappingCanvas();
}
function applyFusionMode() {
@@ -707,10 +790,7 @@ function applyFusionMode() {
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) {
$("fusionStatus").textContent = "当前浏览器未能启用 WebGL无法显示三维融合视图";
return;
}
if (!state.sceneReady) initGeometryFallback();
if (!state.activeCase || !selected) {
clearFusion();
return;
@@ -732,9 +812,13 @@ async function loadFusion() {
if (version !== state.fusionVersion) return;
await buildStlModels(volume);
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
fitCamera();
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`;
}
$("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}`;
renderDicomAnnotation();
updateDicomPreview();
@@ -752,49 +836,370 @@ async function loadTexture(url) {
});
}
async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return { width, height, data: context.getImageData(0, 0, width, height).data };
}
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
if (!volumeScene) return 0;
return volumeScene.total <= 1
? 0
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
}
function parseCssColor(color) {
const value = String(color || "#19d6c3").replace("#", "");
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
const intValue = Number.parseInt(hex, 16);
return {
r: (intValue >> 16) & 255,
g: (intValue >> 8) & 255,
b: intValue & 255,
};
}
function resetMappingCanvas(message = "当前切片暂无可见构件") {
const canvas = $("mappingCanvas");
const context = canvas?.getContext("2d");
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
$("mappingLegend").innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
}
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
}
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})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
const da = a.z - targetZ;
const db = b.z - targetZ;
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
if (Math.abs(da) < 1e-5) return [a.clone()];
if (Math.abs(db) < 1e-5) return [b.clone()];
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
const t = da / (da - db);
return [new THREE.Vector3(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
targetZ,
)];
}
function uniquePlanePoints(points) {
const unique = [];
points.forEach((point) => {
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
});
return unique;
}
function intersectTriangleWithZ(a, b, c, targetZ) {
const points = uniquePlanePoints([
...intersectEdgeWithZ(a, b, targetZ),
...intersectEdgeWithZ(b, c, targetZ),
...intersectEdgeWithZ(c, a, targetZ),
]);
if (points.length < 2) return null;
return { a: points[0], b: points[1] };
}
function addSegmentIntersectionsToRows(rows, width, height, segment) {
const a = segment.a;
const b = segment.b;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 1e-6) {
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
return;
}
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const t = (sampleY - a.y) / deltaY;
if (t < 0 || t > 1) continue;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
const imageData = maskContext.createImageData(width, height);
const rows = Array.from({ length: height }, () => []);
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
let filled = 0;
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
rows.forEach((intersections, y) => {
if (intersections.length < 2) return;
intersections.sort((a, b) => a - b);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (y * width + x) * 4;
imageData.data[offset] = rgb.r;
imageData.data[offset + 1] = rgb.g;
imageData.data[offset + 2] = rgb.b;
imageData.data[offset + 3] = alpha;
filled += 1;
}
}
});
maskContext.putImageData(imageData, 0, 0);
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();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
return filled;
}
function drawMappingView() {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
const width = image.naturalWidth;
const height = image.naturalHeight;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
const mapPoint = (point) => ({
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
state.modelGroup?.updateMatrixWorld(true);
let activeModules = 0;
let segmentCount = 0;
let filledPixels = 0;
const modules = [];
const tempA = new THREE.Vector3();
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
state.modelMeshes.forEach((record, index) => {
const position = record.mesh.geometry.attributes.position;
if (!position) return;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000));
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);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
if (
mapped.a.x > -width && mapped.a.x < width * 2
&& mapped.b.x > -width && mapped.b.x < width * 2
&& mapped.a.y > -height && mapped.a.y < height * 2
&& mapped.b.y > -height && mapped.b.y < height * 2
) {
segments.push(mapped);
}
}
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
if (segments.length || pixels) {
activeModules += 1;
segmentCount += segments.length;
filledPixels += pixels;
modules.push({
id: index + 1,
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
color: record.color,
segments: segments.length,
pixels,
});
}
});
$("mappingStats").textContent = `${activeModules}/${state.modelMeshes.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
? modules.map((item) => `
<div class="legend-item" title="${escapeHtml(item.name)}">
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
<span class="legend-name">${escapeHtml(item.name)}</span>
<span class="legend-meta">ID ${item.id}</span>
<span></span>
<span class="legend-meta">${item.segments} 边</span>
<span class="legend-meta">${item.pixels} px</span>
</div>
`).join("")
: `<div class="empty-state">当前切片暂无可见构件</div>`;
}
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 5.2 / maxPhysical;
const sceneScale = 4.8 / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = (physical.depth || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
const centerIndex = Number(volume.center || 0);
const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || [];
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame)));
textures.forEach((texture, index) => {
state.volumeScene = {
width,
height,
depth,
total,
sceneScale,
rowSpacing: Number(volume.spacing?.row || 1),
columnSpacing: Number(volume.spacing?.column || 1),
sliceSpacing: Number(volume.spacing?.slice || 1),
};
const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
);
state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(boxMesh.geometry),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
);
state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
const rangeBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.56 }),
);
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
if (centerFrame) {
const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: index === centerPosition ? 0.78 : 0.14,
opacity: 0.42,
side: THREE.DoubleSide,
depthWrite: false,
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale;
plane.position.z = z;
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
state.dicomGroup.add(plane);
});
const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02));
const edges = new THREE.EdgesGeometry(box);
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
state.dicomGroup.add(lines);
}
const pointPositions = [];
const pointColors = [];
const frames = volume.frames || [];
for (const [index, frame] of frames.entries()) {
const image = await loadImageData(frame, 180);
const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72));
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 < 12) 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: 0.018,
vertexColors: true,
transparent: true,
opacity: 0.34,
depthWrite: false,
}),
);
state.dicomGroup.add(points);
}
state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
async function buildStlModels(volume) {
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
if (!files.length) {
applyPose();
resetMappingCanvas("当前没有可见 STL 构件");
return;
}
const loader = new STLLoader();
const token = encodeURIComponent(state.token);
for (const [index, file] of files.entries()) {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
@@ -816,13 +1221,17 @@ async function buildStlModels(volume) {
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
mesh.userData.file = file;
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
}
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
const center = new THREE.Vector3();
bbox.getCenter(center);
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
applyPose();
scheduleMappingDraw();
}
function applyPose(poseInput = state.pose) {
@@ -835,7 +1244,13 @@ 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 * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1));
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),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
}
function fitCamera() {
@@ -857,7 +1272,7 @@ function resetPose(kind) {
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
} else if (kind === "flip") {
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
} else {
@@ -868,6 +1283,29 @@ function resetPose(kind) {
markDirty();
}
function stretchAxis(axis) {
if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
if (!axisKey) return;
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
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));
applyPose();
markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
}
function suggestBoneSelection() {
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
const next = new Set();
@@ -1088,11 +1526,58 @@ function wireEvents() {
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
$("dicomPreview").addEventListener("load", () => {
$("dicomLoading").classList.add("hidden");
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
$("dicomLoading").classList.add("hidden");
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.9 : 1.1;
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
applyMappingTransform();
}, { passive: false });
$("mappingViewport").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
state.mappingDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: state.mappingViewport.offsetX,
offsetY: state.mappingViewport.offsetY,
};
$("mappingViewport").classList.add("dragging");
$("mappingViewport").setPointerCapture(event.pointerId);
});
$("mappingViewport").addEventListener("pointermove", (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
applyMappingTransform();
});
const stopMappingDrag = (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingDrag = null;
$("mappingViewport").classList.remove("dragging");
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
};
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
button.addEventListener("click", () => {
state.activeToolTab = button.dataset.toolTab || "series";
@@ -1141,6 +1626,15 @@ function wireEvents() {
updateDicomPreview();
});
$("sliceSlider").addEventListener("change", () => loadFusion());
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
$("mappingSliceSlider").addEventListener("change", () => loadFusion());
}
wireEvents();

View File

@@ -36,9 +36,6 @@
</div>
</div>
<div class="top-actions">
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
<span></span><span></span><span></span>
</button>
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
<span id="dbStatus" class="status-pill">数据库</span>
@@ -52,10 +49,15 @@
<main class="workspace">
<aside class="case-panel panel">
<div class="panel-head">
<div class="panel-title-row">
<div>
<h2>完整关联 CT</h2>
<p>DICOM + STL</p>
</div>
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
<span></span><span></span><span></span>
</button>
</div>
<span id="caseCount">0</span>
</div>
<input id="caseSearch" class="search-input" placeholder="搜索 CT号 / 姓名 / 模型" />
@@ -175,6 +177,9 @@
<button data-window="contrast">高对比</button>
</div>
<div class="viewer-tools">
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
<button id="fitBtn" class="ghost-btn">视角复位</button>
<button id="saveBtn" class="primary-btn">保存配准</button>
<button id="statusBtn" class="state-btn">标为已配准</button>
@@ -198,24 +203,41 @@
<section class="viewer-panel dicom-panel panel">
<div class="viewer-head">
<div>
<h2>DICOM 标注</h2>
<p id="dicomPanelMeta">当前序列切片</p>
<h2>逆向分割映射视图</h2>
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
</div>
<div class="viewer-tools">
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
</div>
</div>
<div class="dicom-stage">
<div id="mappingViewport" class="mapping-viewport">
<div id="mappingLayer" class="mapping-layer">
<img id="dicomPreview" alt="DICOM 切片" />
<div class="crosshair horizontal"></div>
<div class="crosshair vertical"></div>
<canvas id="mappingCanvas"></canvas>
</div>
</div>
<div class="mapping-slice-rail">
<input id="mappingSliceSlider" type="range" min="0" max="0" value="0" />
<span id="mappingSliceLabel">0 / 0</span>
</div>
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载 DICOM 标注视图</span>
<span>正在加载逆向分割映射视图</span>
<div><i></i></div>
</div>
</div>
<div class="mapping-summary">
<div class="mapping-summary-head">
<span>Overlay Label Map</span>
<em id="mappingStats">0/0 构件 · 0 边 · 0 px</em>
</div>
<div id="annotationTags" class="annotation-tags"></div>
<div id="mappingLegend" class="mapping-legend"></div>
</div>
</section>
</section>
</section>

View File

@@ -115,13 +115,24 @@ button {
}
.workspace.case-collapsed {
grid-template-columns: 0 minmax(0, 1fr);
grid-template-columns: 54px minmax(0, 1fr);
}
.workspace.case-collapsed .case-panel {
opacity: 0;
pointer-events: none;
transform: translateX(-18px);
opacity: 1;
pointer-events: auto;
transform: none;
}
.workspace.case-collapsed .case-panel > :not(.panel-head),
.workspace.case-collapsed .panel-head .panel-title-row > div,
.workspace.case-collapsed .panel-head #caseCount {
display: none;
}
.workspace.case-collapsed .case-panel .panel-head {
justify-content: center;
padding: 9px 7px;
}
.icon-btn {
@@ -174,6 +185,19 @@ button {
padding: 14px;
}
.panel-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.panel-title-row .icon-btn {
width: 34px;
height: 32px;
border-radius: 11px;
}
.panel-head.compact {
min-height: 42px;
padding: 10px 12px;
@@ -338,6 +362,24 @@ button {
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
}
.series-card.skipped {
opacity: 0.54;
filter: grayscale(0.95);
background: rgba(8, 12, 18, 0.7);
}
.series-card.skipped.active {
opacity: 0.78;
border-color: rgba(148, 163, 184, 0.5);
background: linear-gradient(180deg, rgba(34, 41, 53, 0.86), rgba(12, 17, 24, 0.9));
}
.series-card.skipped .mini-tags i {
border-color: rgba(148, 163, 184, 0.48);
background: rgba(148, 163, 184, 0.12);
color: #cbd5e1;
}
.case-card strong,
.series-card strong {
display: block;
@@ -617,6 +659,7 @@ button {
.fusion-panel .viewer-head {
align-content: center;
flex-wrap: wrap;
overflow-x: auto;
}
.viewer-head h2 {
@@ -855,7 +898,29 @@ button {
background: #000;
}
.dicom-stage img {
.mapping-viewport {
position: absolute;
inset: 0 54px 0 0;
overflow: hidden;
touch-action: none;
cursor: grab;
}
.mapping-viewport.dragging {
cursor: grabbing;
}
.mapping-layer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
}
.dicom-stage img,
.dicom-stage canvas {
position: absolute;
inset: 0;
width: 100%;
@@ -864,6 +929,91 @@ button {
image-rendering: auto;
}
.dicom-stage canvas {
z-index: 2;
pointer-events: none;
}
.mapping-slice-rail {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 6;
width: 54px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-left: 1px solid rgba(148, 163, 184, 0.18);
background: #0f172a;
}
.mapping-slice-rail::before {
content: "";
position: absolute;
top: 26px;
bottom: 56px;
left: 50%;
width: 8px;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
}
#mappingSliceSlider {
position: relative;
z-index: 1;
width: min(66vh, 520px);
height: 34px;
transform: rotate(-90deg);
appearance: none;
background: transparent;
}
#mappingSliceSlider::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
}
#mappingSliceSlider::-webkit-slider-thumb {
appearance: none;
width: 25px;
height: 25px;
margin-top: -8.5px;
border: 3px solid #93c5fd;
border-radius: 7px;
background: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
#mappingSliceSlider::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
}
#mappingSliceSlider::-moz-range-thumb {
width: 22px;
height: 22px;
border: 3px solid #93c5fd;
border-radius: 7px;
background: #60a5fa;
}
#mappingSliceLabel {
position: absolute;
right: 9px;
bottom: 14px;
color: #c8f7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 900;
writing-mode: vertical-rl;
}
.crosshair {
position: absolute;
z-index: 3;
@@ -916,13 +1066,12 @@ button {
}
.annotation-tags {
min-height: 48px;
min-height: 36px;
display: flex;
gap: 8px;
align-items: center;
overflow-x: auto;
border-top: 1px solid var(--line);
padding: 8px 12px;
padding: 0;
}
.annotation-tags span {
@@ -936,6 +1085,78 @@ button {
padding: 5px 10px;
}
.mapping-summary {
min-height: 122px;
max-height: 170px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
border-top: 1px solid var(--line);
background: #070c14;
padding: 10px 12px;
}
.mapping-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #dbeafe;
font-size: 12px;
font-weight: 900;
}
.mapping-summary-head em {
flex: 0 0 auto;
color: #c8f7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-style: normal;
}
.mapping-legend {
min-height: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
overflow: auto;
}
.mapping-legend .legend-item {
min-width: 0;
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
color: #b6c8dd;
font-size: 11px;
font-weight: 800;
padding: 6px 8px;
}
.mapping-legend .legend-color {
width: 10px;
height: 10px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 3px;
}
.mapping-legend .legend-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mapping-legend .legend-meta {
color: #9debf4;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
}
.slice-row span {
color: var(--muted);
font-size: 12px;