Refine registration workspace controls

This commit is contained in:
Codex
2026-05-29 01:46:52 +08:00
parent f9063b674c
commit 559266d5e6
4 changed files with 576 additions and 129 deletions

View File

@@ -468,6 +468,7 @@ def cases(
q: str = "", q: str = "",
status_filter: str = Query(default="", alias="status"), status_filter: str = Query(default="", alias="status"),
body_part: str = "", body_part: str = "",
algorithm_model: str = "",
limit: int = Query(default=120, ge=1, le=400), limit: int = Query(default=120, ge=1, le=400),
_: str = Depends(require_auth), _: str = Depends(require_auth),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -492,6 +493,8 @@ def cases(
clauses.append(f"registration_status = {sql_literal(status_filter)}") clauses.append(f"registration_status = {sql_literal(status_filter)}")
if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}: if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}:
clauses.append(f"body_part_keys ? {sql_literal(body_part)}") clauses.append(f"body_part_keys ? {sql_literal(body_part)}")
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 "" where_sql = "WHERE " + " AND ".join(clauses) if clauses else ""
return pg_json_rows( return pg_json_rows(
f""" f"""

View File

@@ -46,6 +46,7 @@ const state = {
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
statusFilter: "", statusFilter: "",
partFilter: "", partFilter: "",
modelFilter: "",
search: "", search: "",
caseCollapsed: false, caseCollapsed: false,
activeToolTab: "series", activeToolTab: "series",
@@ -60,11 +61,15 @@ const state = {
pose: { ...DEFAULT_POSE }, pose: { ...DEFAULT_POSE },
windowMode: "default", windowMode: "default",
fusionMode: "fusion", fusionMode: "fusion",
fusionDetail: "low",
modelDetail: "standard",
mappingMode: "result",
sliceIndex: 0, sliceIndex: 0,
sliceRangeStart: 0, sliceRangeStart: 0,
sliceRangeEnd: 0, sliceRangeEnd: 0,
dicomPreviewTimer: 0, dicomPreviewTimer: 0,
autoResult: null, autoResult: null,
lastAutoPose: null,
dirty: false, dirty: false,
dirtyReason: "", dirtyReason: "",
saving: false, saving: false,
@@ -77,6 +82,8 @@ const state = {
scene: null, scene: null,
controls: null, controls: null,
dicomGroup: null, dicomGroup: null,
dicomPlane: null,
dicomPoints: null,
modelGroup: null, modelGroup: null,
rawModelGroup: null, rawModelGroup: null,
modelMeshes: [], modelMeshes: [],
@@ -247,6 +254,7 @@ async function loadCases(selectFirst = true) {
if (state.search) params.set("q", state.search); if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter); if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter); 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()}`); const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows; state.cases = rows;
renderCases(); renderCases();
@@ -382,12 +390,6 @@ function clearDetail() {
$("activeTitle").textContent = "未选择 CT"; $("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = ""; $("activeTags").innerHTML = "";
$("patientSummary").textContent = "未选择 CT";
$("summaryCt").textContent = "-";
$("summaryPatientId").textContent = "-";
$("summaryStudyTime").textContent = "-";
$("summaryBodyPart").textContent = "-";
$("matchedSeries").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`; $("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`; $("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("modelRail").innerHTML = ""; $("modelRail").innerHTML = "";
@@ -407,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
&& sameCtNumber(state.activeCase.ct_number, ctNumber) && sameCtNumber(state.activeCase.ct_number, ctNumber)
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm; && (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
const previousPose = { ...state.pose }; const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return; if (!(await confirmChange())) return;
setTopLoading(true); setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据", 6); setFusionLoading(true, "正在读取 CT 关联数据", 6);
try { try {
@@ -426,7 +428,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedTransform = registration.transform || {}; const savedTransform = registration.transform || {};
const keepPreviousPose = switchingModelOnly; const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.autoResult = null; state.autoResult = null;
state.lastAutoPose = null;
state.sliceIndex = 0; state.sliceIndex = 0;
$("notes").value = registration.notes || ""; $("notes").value = registration.notes || "";
@@ -447,6 +452,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
renderSeries(); renderSeries();
renderStl(); renderStl();
renderPoseControls(); renderPoseControls();
syncDisplayControls();
resetDirty(); resetDirty();
await loadFusion(); await loadFusion();
} catch (error) { } catch (error) {
@@ -503,26 +509,25 @@ function currentSeries() {
function renderActiveHeader() { function renderActiveHeader() {
const row = state.activeCase; const row = state.activeCase;
if (!row) return; if (!row) return;
const selected = currentSeries();
const bodyTags = bodyPartTags(row);
const selectedLabels = selected?.annotation_labels?.length ? selected.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`; $("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${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) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [ const tags = [
`<span class="tag ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`, `<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
...bodyPartTags(row).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`), `<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...(selected ? [`<span class="tag">${escapeHtml(selected.description || "当前序列")}</span>`] : []),
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
]; ];
$("activeTags").innerHTML = tags.join(""); $("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered"); $("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
$("patientSummary").textContent = `${row.patient_name || row.upp_patient_name || "-"} ${row.patient_sex || ""}`.trim();
$("summaryCt").textContent = row.ct_number || "-";
$("summaryPatientId").textContent = row.patient_id || "-";
$("summaryStudyTime").textContent = fmtDateTime(row.study_date, row.study_time) || "-";
$("summaryBodyPart").textContent = bodyPartTags(row).join("、") || row.study_description || "-";
} }
function renderSeries() { function renderSeries() {
$("seriesCount").textContent = state.series.length; $("seriesCount").textContent = state.series.length;
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
$("seriesList").innerHTML = state.series.length $("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series) ? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
@@ -541,6 +546,7 @@ function seriesCardHtml(item, active = false) {
const skipped = isSkippedSeries(item); const skipped = isSkippedSeries(item);
return ` return `
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" 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)}">
<span class="series-check" aria-hidden="true">${active ? "✓" : ""}</span>
<strong>${escapeHtml(item.description || "未命名序列")}</strong> <strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span> <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> <small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
@@ -589,6 +595,10 @@ function renderStl() {
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`; : `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => { $("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => { input.addEventListener("change", async () => {
if (state.dirty && !(await confirmChange())) {
input.checked = !input.checked;
return;
}
const id = Number(input.dataset.stl); const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id); if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id); else state.selectedStlIds.delete(id);
@@ -685,9 +695,6 @@ function updateSliceControl() {
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, 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)); 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]; 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; $("sliceRangeStart").max = max;
$("sliceRangeEnd").max = max; $("sliceRangeEnd").max = max;
$("sliceRangeStart").value = state.sliceRangeStart; $("sliceRangeStart").value = state.sliceRangeStart;
@@ -704,7 +711,7 @@ function renderDicomAnnotation() {
const row = state.activeCase; const row = state.activeCase;
const selected = currentSeries(); const selected = currentSeries();
const count = Number(selected?.count || 0); const count = Number(selected?.count || 0);
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count}` : "当前序列切片"; $("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
$("dicomInfoLeft").textContent = row && selected $("dicomInfoLeft").textContent = row && selected
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}` ? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
: "未选择序列"; : "未选择序列";
@@ -712,10 +719,7 @@ function renderDicomAnnotation() {
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}` ? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
: ""; : "";
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-"; $("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {}); renderActiveHeader();
$("annotationTags").innerHTML = tags.length
? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
: `<span>未标注</span>`;
} }
function updateDicomPreview() { function updateDicomPreview() {
@@ -823,6 +827,8 @@ function clearFusion() {
state.volumeMeta = null; state.volumeMeta = null;
state.volumeScene = null; state.volumeScene = null;
state.modelMeshes = []; state.modelMeshes = [];
state.dicomPlane = null;
state.dicomPoints = null;
$("fusionMeta").textContent = "DICOM - · STL -"; $("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl(); updateSliceControl();
@@ -837,6 +843,58 @@ function applyFusionMode() {
: $("fusionMeta").textContent; : $("fusionMeta").textContent;
} }
function fusionDetailSettings() {
return {
low: { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 },
medium: { planeOpacity: 0.38, pointOpacity: 0.3, pointSize: 0.018 },
high: { planeOpacity: 0.52, pointOpacity: 0.44, pointSize: 0.022 },
}[state.fusionDetail] || { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 };
}
function modelDetailSettings() {
return {
standard: { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 },
fine: { opacity: 0.66, wireframe: false, depthWrite: false, metalness: 0.04, roughness: 0.48 },
ultra: { opacity: 0.82, wireframe: false, depthWrite: false, metalness: 0.02, roughness: 0.42 },
solid: { opacity: 1, wireframe: false, depthWrite: true, metalness: 0.02, roughness: 0.36 },
}[state.modelDetail] || { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 };
}
function applyFusionDetail() {
const detail = fusionDetailSettings();
if (state.dicomPlane?.material) {
state.dicomPlane.material.opacity = detail.planeOpacity;
state.dicomPlane.material.needsUpdate = true;
}
if (state.dicomPoints?.material) {
state.dicomPoints.material.opacity = detail.pointOpacity;
state.dicomPoints.material.size = detail.pointSize;
state.dicomPoints.material.needsUpdate = true;
}
}
function applyModelDetail() {
const detail = modelDetailSettings();
state.modelMeshes.forEach((record) => {
const material = record.mesh.material;
if (!material) return;
material.opacity = detail.opacity;
material.transparent = detail.opacity < 1;
material.depthWrite = detail.depthWrite;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
material.needsUpdate = true;
});
scheduleMappingDraw();
}
function syncDisplayControls() {
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
}
async function loadFusion() { async function loadFusion() {
const version = ++state.fusionVersion; const version = ++state.fusionVersion;
const selected = currentSeries(); const selected = currentSeries();
@@ -861,17 +919,19 @@ async function loadFusion() {
state.volumeMeta = volume; state.volumeMeta = volume;
setFusionLoading(true, "正在构建 DICOM 切片范围", 38); setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
await buildDicomVolume(volume); await buildDicomVolume(volume);
applyFusionDetail();
if (version !== state.fusionVersion) return; if (version !== state.fusionVersion) return;
setFusionLoading(true, "正在加载 STL 模型", 62); setFusionLoading(true, "正在加载 STL 模型", 62);
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3)); await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
applyModelDetail();
if (version !== state.fusionVersion) return; if (version !== state.fusionVersion) return;
if (state.sceneReady) { if (state.sceneReady) {
applyFusionMode(); applyFusionMode();
fitCamera(); fitCamera();
} }
$("fusionStatus").textContent = state.sceneReady $("fusionStatus").textContent = state.sceneReady
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}` ? "三维融合场景已就绪"
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`; : "2D 映射已就绪,当前浏览器未启用 WebGL";
$("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size}`; $("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation(); renderDicomAnnotation();
setFusionLoading(true, "正在渲染右侧分割映射", 96); setFusionLoading(true, "正在渲染右侧分割映射", 96);
@@ -1094,6 +1154,11 @@ function drawMappingView() {
const image = $("dicomPreview"); const image = $("dicomPreview");
const canvas = $("mappingCanvas"); const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene; const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) { if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件"); resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return; return;
@@ -1178,6 +1243,8 @@ function drawMappingView() {
async function buildDicomVolume(volume) { async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup); clearGroup(state.dicomGroup);
state.dicomPlane = null;
state.dicomPoints = null;
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 }; 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 maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical; const sceneScale = 4.8 / maxPhysical;
@@ -1221,6 +1288,7 @@ async function buildDicomVolume(volume) {
state.dicomGroup.add(rangeBox); state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition]; const centerFrame = (volume.frames || [])[centerPosition];
const fusionDetail = fusionDetailSettings();
if (centerFrame) { if (centerFrame) {
const texture = await loadTexture(centerFrame); const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
@@ -1229,13 +1297,14 @@ async function buildDicomVolume(volume) {
new THREE.MeshBasicMaterial({ new THREE.MeshBasicMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
opacity: 0.42, opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: false,
}), }),
); );
plane.position.z = sliceToSceneZ(centerIndex) + 0.006; plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
state.dicomGroup.add(plane); state.dicomGroup.add(plane);
state.dicomPlane = plane;
} }
const pointPositions = []; const pointPositions = [];
@@ -1265,14 +1334,15 @@ async function buildDicomVolume(volume) {
const points = new THREE.Points( const points = new THREE.Points(
geometry, geometry,
new THREE.PointsMaterial({ new THREE.PointsMaterial({
size: 0.018, size: fusionDetail.pointSize,
vertexColors: true, vertexColors: true,
transparent: true, transparent: true,
opacity: 0.34, opacity: fusionDetail.pointOpacity,
depthWrite: false, depthWrite: false,
}), }),
); );
state.dicomGroup.add(points); state.dicomGroup.add(points);
state.dicomPoints = points;
} }
state.baseModelScale = sceneScale; state.baseModelScale = sceneScale;
@@ -1283,6 +1353,7 @@ async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature(); const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) { if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
applyPose(); applyPose();
applyModelDetail();
scheduleMappingDraw(); scheduleMappingDraw();
onProgress?.(100); onProgress?.(100);
return; return;
@@ -1297,6 +1368,7 @@ async function buildStlModels(volume, onProgress = null) {
return; return;
} }
const loader = new STLLoader(); const loader = new STLLoader();
const detail = modelDetailSettings();
for (const [index, file] of files.entries()) { for (const [index, file] of files.entries()) {
onProgress?.((index / Math.max(files.length, 1)) * 100); onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -1310,12 +1382,13 @@ async function buildStlModels(volume, onProgress = null) {
geometry.computeBoundingBox(); geometry.computeBoundingBox();
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length], color: STL_COLORS[index % STL_COLORS.length],
metalness: 0.08, metalness: detail.metalness,
roughness: 0.5, roughness: detail.roughness,
transparent: true, transparent: true,
opacity: 0.58, opacity: detail.opacity,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: detail.depthWrite,
wireframe: detail.wireframe,
}); });
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name; mesh.name = file.segment_name || file.file_name;
@@ -1404,10 +1477,20 @@ function stretchAxis(axis) {
markDirty("pose"); markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok"); setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`; $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
updateAutoPoseResult();
} }
function suggestBoneSelection() { function suggestBoneSelection() {
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"]; const selectedBoneKeys = [...document.querySelectorAll("#autoBoneOptions input:checked")].map((input) => input.value);
const keyMap = {
vertebra: ["vertebra", "spine", "椎", "脊柱"],
rib: ["rib", "肋"],
sternum: ["sternum", "胸骨"],
pelvis: ["pelvis", "髂", "骨盆"],
};
const boneKeys = (selectedBoneKeys.length ? selectedBoneKeys : ["vertebra", "rib"])
.flatMap((key) => keyMap[key] || [key])
.concat(["bone", "骨"]);
const next = new Set(); const next = new Set();
state.stlFiles.forEach((file) => { state.stlFiles.forEach((file) => {
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
@@ -1422,8 +1505,66 @@ function suggestBoneSelection() {
loadFusion(); loadFusion();
} }
function candidateScore(pose) { function readAutoSettings() {
const numericValue = (id, fallback) => {
const value = Number($(id)?.value);
return Number.isFinite(value) ? value : fallback;
};
return {
adjustable: {
translateX: $("autoX")?.checked,
translateY: $("autoY")?.checked,
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
movePenalty: numericValue("autoMovePenalty", 0),
scalePenalty: numericValue("autoScalePenalty", 0),
iterations: Math.max(1, Math.min(20, Math.round(numericValue("autoIterations", 6)))),
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
};
}
function poseDelta(from, to) {
return {
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
};
}
function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose, after = state.pose) {
const delta = poseDelta(before, after);
const el = $("autoPoseResult");
if (!el) return;
el.innerHTML = [
`<span>平移 X ${delta.translateX}</span>`,
`<span>平移 Y ${delta.translateY}</span>`,
`<span>平移 Z ${delta.translateZ}</span>`,
`<span>缩放 ${delta.scale}</span>`,
].join("");
}
function restoreAutoPose() {
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
}
state.pose = { ...state.lastAutoPose.before };
renderPoseControls();
applyPose();
fitCamera();
updateAutoPoseResult(state.lastAutoPose.after, state.pose);
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity; if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
const config = settings || readAutoSettings();
const current = { ...state.pose }; const current = { ...state.pose };
applyPose(pose); applyPose(pose);
const modelBox = new THREE.Box3().setFromObject(state.modelGroup); const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
@@ -1442,8 +1583,9 @@ function candidateScore(pose) {
modelBox.getCenter(modelCenter); modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter); state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001); const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03; const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
return overlapVolume / modelVolume - centerPenalty - scalePenalty; const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
return (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
} }
function runAutoCoarse() { function runAutoCoarse() {
@@ -1451,13 +1593,17 @@ function runAutoCoarse() {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return; return;
} }
const before = { ...state.pose };
const settings = readAutoSettings();
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 }; state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
renderPoseControls(); renderPoseControls();
applyPose(); applyPose();
fitCamera(); fitCamera();
state.autoResult = { score: candidateScore(state.pose), pose: { ...state.pose }, evaluated: 1 }; state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
setAutoState("粗配准完成", "ok"); setAutoState("粗配准完成", "ok");
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)}`; $("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)};采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty(); markDirty();
} }
@@ -1468,40 +1614,37 @@ function runAutoFine() {
} }
setAutoState("运行中", "warn"); setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调..."; $("autoResult").textContent = "正在进行几何重叠微调...";
const adjustable = { const before = { ...state.pose };
translateX: $("autoX").checked, const settings = readAutoSettings();
translateY: $("autoY").checked, const adjustable = settings.adjustable;
translateZ: $("autoZ").checked, let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
scale: $("autoScale").checked,
};
let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" };
let evaluated = 1; let evaluated = 1;
const rounds = [ const rounds = Array.from({ length: settings.iterations }, (_, index) => {
{ move: 0.34, scale: 0.12 }, const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
{ move: 0.18, scale: 0.06 }, return { move: 0.34 * t, scale: 0.12 * t };
{ move: 0.08, scale: 0.025 }, });
{ move: 0.035, scale: 0.01 },
];
for (const round of rounds) { for (const round of rounds) {
const candidates = []; const candidates = [];
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]); if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]); if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]); if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]); if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
for (const [key, delta] of candidates) { for (const [key, delta] of candidates.slice(0, settings.candidates)) {
const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta }; const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
const score = candidateScore(pose); const score = candidateScore(pose, settings);
evaluated += 1; evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` }; if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
} }
} }
state.pose = { ...best.pose }; state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated }; state.autoResult = { ...best, evaluated, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
renderPoseControls(); renderPoseControls();
applyPose(); applyPose();
fitCamera(); fitCamera();
setAutoState("微调完成", "ok"); setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`; $("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices}`;
updateAutoPoseResult(before, state.pose);
markDirty(); markDirty();
} }
@@ -1532,7 +1675,7 @@ async function saveRegistration(nextStatus = null) {
algorithm_model: file.algorithm_model, algorithm_model: file.algorithm_model,
})), })),
transform: state.pose, transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])), module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])),
dicom_reference: { dicom_reference: {
series_uid: selectedSeries.series_uid, series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number, series_number: selectedSeries.series_number,
@@ -1547,6 +1690,8 @@ async function saveRegistration(nextStatus = null) {
selected_count: selectedFiles.length, selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))], families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode, fusion_mode: state.fusionMode,
fusion_detail: state.fusionDetail,
model_detail: state.modelDetail,
auto_result: state.autoResult, auto_result: state.autoResult,
}, },
notes: $("notes").value, notes: $("notes").value,
@@ -1606,6 +1751,13 @@ function setWindowMode(mode, reload = true) {
if (reload) loadFusion(); if (reload) loadFusion();
} }
function setMappingMode(mode) {
state.mappingMode = mode === "image" ? "image" : "result";
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
renderDicomAnnotation();
scheduleMappingDraw();
}
function setAutoModalVisible(visible) { function setAutoModalVisible(visible) {
$("autoModal").classList.toggle("hidden", !visible); $("autoModal").classList.toggle("hidden", !visible);
} }
@@ -1628,12 +1780,10 @@ function wireEvents() {
}); });
$("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
$("fitBtn").addEventListener("click", fitCamera); $("fitBtn").addEventListener("click", fitCamera);
$("saveBtn").addEventListener("click", () => saveRegistration()); $("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", markDirty); $("notes").addEventListener("input", () => markDirty("notes"));
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
@@ -1648,6 +1798,25 @@ function wireEvents() {
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse); $("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine); $("autoFineBtn").addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", async () => {
await saveRegistration();
runAutoFine();
});
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {
const range = $("autoSampleSlices");
const number = $("autoSampleSlicesNumber");
const value = Math.max(3, Math.min(60, Number(source.value || 9)));
range.value = value;
number.value = value;
};
$("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices")));
$("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber")));
$("autoDefaultsBtn").addEventListener("click", () => {
$("autoSampleSlices").value = 9;
$("autoSampleSlicesNumber").value = 9;
});
$("dicomPreview").addEventListener("load", () => { $("dicomPreview").addEventListener("load", () => {
$("dicomLoading").classList.add("hidden"); $("dicomLoading").classList.add("hidden");
scheduleMappingDraw(); scheduleMappingDraw();
@@ -1728,12 +1897,22 @@ function wireEvents() {
await loadCases(false); await loadCases(false);
}); });
}); });
document.querySelectorAll(".filter[data-model-filter]").forEach((button) => {
button.addEventListener("click", async () => {
state.modelFilter = button.dataset.modelFilter || "";
document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll("[data-window]").forEach((button) => { document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default")); button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
}); });
document.querySelectorAll("[data-map-window]").forEach((button) => { document.querySelectorAll("[data-map-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default")); button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
}); });
document.querySelectorAll("[data-map-mode]").forEach((button) => {
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => { document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion"; state.fusionMode = button.dataset.fusionMode || "fusion";
@@ -1742,11 +1921,21 @@ function wireEvents() {
fitCamera(); fitCamera();
}); });
}); });
$("sliceSlider").addEventListener("input", () => { document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
state.sliceIndex = Number($("sliceSlider").value || 0); button.addEventListener("click", () => {
updateSliceControl(); state.fusionDetail = button.dataset.fusionDetail || "low";
renderDicomAnnotation(); document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
updateDicomPreview(); applyFusionDetail();
markDirty("display");
});
});
document.querySelectorAll("[data-model-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.modelDetail = button.dataset.modelDetail || "standard";
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyModelDetail();
markDirty("display");
});
}); });
$("mappingSliceSlider").addEventListener("input", () => { $("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries(); const selected = currentSeries();

View File

@@ -74,6 +74,12 @@
<button class="filter" data-part="lower_abdomen">下腹部</button> <button class="filter" data-part="lower_abdomen">下腹部</button>
<button class="filter" data-part="pelvis">盆腔</button> <button class="filter" data-part="pelvis">盆腔</button>
</div> </div>
<div class="model-filter">
<button class="filter active" data-model-filter="">全部模型</button>
<button class="filter" data-model-filter="肝胆模型">肝胆模型</button>
<button class="filter" data-model-filter="泌尿模型">泌尿模型</button>
<button class="filter" data-model-filter="胸部模型">胸部模型</button>
</div>
<div id="caseList" class="case-list"></div> <div id="caseList" class="case-list"></div>
</aside> </aside>
@@ -90,27 +96,21 @@
<aside class="tool-dock panel"> <aside class="tool-dock panel">
<div class="tool-tabs"> <div class="tool-tabs">
<button class="active" data-tool-tab="series">序列</button> <button class="active" data-tool-tab="series">序列</button>
<button data-tool-tab="models">组织分割</button> <button data-tool-tab="models">模型可视</button>
<button data-tool-tab="pose">位姿</button> <button data-tool-tab="pose">位姿</button>
</div> </div>
<section class="tool-pane active" data-tool-pane="series"> <section class="tool-pane active" data-tool-pane="series">
<div class="patient-card"> <div class="tool-section-title">
<strong id="patientSummary">未选择 CT</strong> <span>融合显示</span>
<dl> </div>
<div><dt>检查号</dt><dd id="summaryCt">-</dd></div> <div class="display-segmented" id="fusionDetailControls">
<div><dt>患者号</dt><dd id="summaryPatientId">-</dd></div> <button class="active" data-fusion-detail="low" type="button">DICOM 低</button>
<div><dt>检查日期</dt><dd id="summaryStudyTime">-</dd></div> <button data-fusion-detail="medium" type="button">DICOM 中</button>
<div><dt>检查部位</dt><dd id="summaryBodyPart">-</dd></div> <button data-fusion-detail="high" type="button">DICOM 高</button>
</dl>
</div> </div>
<div class="tool-section-title"> <div class="tool-section-title">
<span>3D 重建所需序列</span> <span>检查序列</span>
<button id="reloadFusionBtn" type="button">重选序列</button>
</div>
<div id="matchedSeries" class="matched-series"></div>
<div class="tool-section-title">
<span>全部检查和序列</span>
<em id="seriesCount">0</em> <em id="seriesCount">0</em>
</div> </div>
<div id="seriesList" class="series-list compact-list"></div> <div id="seriesList" class="series-list compact-list"></div>
@@ -118,9 +118,15 @@
<section class="tool-pane" data-tool-pane="models"> <section class="tool-pane" data-tool-pane="models">
<div class="tool-section-title"> <div class="tool-section-title">
<span>STL 模型</span> <span>模型可视</span>
<em id="stlCount">0</em> <em id="stlCount">0</em>
</div> </div>
<div class="display-segmented" id="modelDetailControls">
<button class="active" data-model-detail="standard" type="button">标准</button>
<button data-model-detail="fine" type="button">精细</button>
<button data-model-detail="ultra" type="button">超精细</button>
<button data-model-detail="solid" type="button">实体</button>
</div>
<div id="modelRail" class="chip-rail"></div> <div id="modelRail" class="chip-rail"></div>
<div id="stlList" class="stl-list compact-list"></div> <div id="stlList" class="stl-list compact-list"></div>
</section> </section>
@@ -156,10 +162,16 @@
<section class="viewer-panel fusion-panel panel"> <section class="viewer-panel fusion-panel panel">
<div class="viewer-head"> <div class="viewer-head">
<div class="fusion-head-stack">
<div class="fusion-action-row">
<div class="segmented"> <div class="segmented">
<button data-fusion-mode="fusion" class="active">融合结果</button> <button data-fusion-mode="fusion" class="active">融合结果</button>
<button data-fusion-mode="model">单独模型</button> <button data-fusion-mode="model">单独模型</button>
</div> </div>
<button id="saveBtn" class="primary-btn">保存配准</button>
<button id="statusBtn" class="state-btn">标为已配准</button>
</div>
<div class="fusion-control-row">
<div class="segmented"> <div class="segmented">
<button data-window="default" class="active">默认</button> <button data-window="default" class="active">默认</button>
<button data-window="bone">骨窗</button> <button data-window="bone">骨窗</button>
@@ -171,8 +183,8 @@
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button> <button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button> <button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
<button id="fitBtn" class="ghost-btn">视角复位</button> <button id="fitBtn" class="ghost-btn">视角复位</button>
<button id="saveBtn" class="primary-btn">保存配准</button> </div>
<button id="statusBtn" class="state-btn">标为已配准</button> </div>
</div> </div>
</div> </div>
<div class="fusion-stage"> <div class="fusion-stage">
@@ -199,30 +211,29 @@
<span>范围</span> <span>范围</span>
<span id="sliceEndLabel">终点 0</span> <span id="sliceEndLabel">终点 0</span>
</div> </div>
<div class="slice-current-row">
<span id="sliceLabel">切片 0 / 0</span>
<input id="sliceSlider" type="range" min="0" max="0" value="0" />
</div>
</div> </div>
</section> </section>
<section class="viewer-panel dicom-panel panel"> <section class="viewer-panel dicom-panel panel">
<div class="viewer-head"> <div class="viewer-head">
<div> <div class="mapping-title-row">
<h2>逆向分割映射视图</h2> <div class="segmented">
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p> <button data-map-mode="result" class="active">分割结果</button>
<button data-map-mode="image">单独影像</button>
</div> </div>
<div class="viewer-tools"> </div>
<div class="mapping-toolbar">
<div class="segmented mini-segmented"> <div class="segmented mini-segmented">
<button data-map-window="default" class="active">默认</button> <button data-map-window="default" class="active">默认</button>
<button data-map-window="bone">骨窗</button> <button data-map-window="bone">骨窗</button>
<button data-map-window="soft">软组织</button> <button data-map-window="soft">软组织</button>
<button data-map-window="contrast">高对比</button> <button data-map-window="contrast">高对比</button>
</div> </div>
<div class="viewer-tools">
<button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button> <button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button>
<button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button> <button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button>
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button> <button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button> </div>
</div> </div>
</div> </div>
<div class="dicom-stage"> <div class="dicom-stage">
@@ -240,16 +251,15 @@
<div class="dicom-overlay top-right" id="dicomInfoRight"></div> <div class="dicom-overlay top-right" id="dicomInfoRight"></div>
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div> <div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden"> <div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载逆向分割映射视图</span> <span>正在加载分割结果</span>
<div><i></i></div> <div><i></i></div>
</div> </div>
</div> </div>
<div class="mapping-summary"> <div class="mapping-summary">
<div class="mapping-summary-head"> <div class="mapping-summary-head">
<span>Overlay Label Map</span> <span id="mappingSummaryTitle">当前分割构件</span>
<em id="mappingStats">0/0 构件 · 0 边 · 0 px</em> <em id="mappingStats">0/0 构件 · 0 边 · 0 px</em>
</div> </div>
<div id="annotationTags" class="annotation-tags"></div>
<div id="mappingLegend" class="mapping-legend"></div> <div id="mappingLegend" class="mapping-legend"></div>
</div> </div>
</section> </section>
@@ -272,11 +282,53 @@
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label> <label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label> <label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div> </div>
<div class="auto-config-grid">
<section>
<div class="modal-section-head">
<strong>骨骼区域</strong>
<button id="suggestBoneBtn" type="button">建议选择</button>
</div>
<div id="autoBoneOptions" class="bone-option-list">
<label><input type="checkbox" value="vertebra" checked /> 椎骨</label>
<label><input type="checkbox" value="rib" checked /> 肋骨</label>
<label><input type="checkbox" value="sternum" /> 胸骨</label>
<label><input type="checkbox" value="pelvis" /> 骨盆</label>
</div>
</section>
<section>
<div class="modal-section-head">
<strong>采样切片</strong>
<button id="autoDefaultsBtn" type="button">默认数量</button>
</div>
<div class="modal-slider">
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
</div>
</section>
</div>
<div class="auto-weight-grid">
<label>骨窗命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>非骨区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="20" value="6" /></label>
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div>
<div class="pose-actions modal-actions"> <div class="pose-actions modal-actions">
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
<button id="autoCoarseBtn" type="button">自动粗配准</button> <button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button> <button id="autoFineBtn" type="button">自动微调</button>
</div> </div>
<div id="autoPoseResult" class="auto-pose-result">
<span>平移 X 0</span>
<span>平移 Y 0</span>
<span>平移 Z 0</span>
<span>缩放 1</span>
</div>
<div class="pose-actions modal-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div>
</div> </div>
</div> </div>

View File

@@ -256,6 +256,7 @@ button {
.filter-row, .filter-row,
.part-filter, .part-filter,
.model-filter,
.chip-rail, .chip-rail,
.tag-line { .tag-line {
display: flex; display: flex;
@@ -355,6 +356,35 @@ button {
padding: 13px; padding: 13px;
} }
.series-card {
position: relative;
padding-right: 48px;
}
.series-check {
position: absolute;
top: 13px;
right: 13px;
width: 26px;
height: 26px;
display: inline-flex !important;
align-items: center;
justify-content: center;
margin: 0 !important;
border: 1px solid rgba(25, 214, 195, 0.62);
border-radius: 999px;
background: rgba(20, 184, 166, 0.16);
color: #ccfbf1 !important;
font-size: 15px !important;
font-weight: 950 !important;
line-height: 1;
}
.series-card:not(.active) .series-check {
border-color: rgba(148, 163, 184, 0.2);
background: rgba(148, 163, 184, 0.06);
}
.case-card.active, .case-card.active,
.series-card.active, .series-card.active,
.stl-row.active { .stl-row.active {
@@ -679,6 +709,34 @@ button {
font-size: 11px; font-size: 11px;
} }
.fusion-head-stack {
width: 100%;
min-width: 0;
display: grid;
gap: 8px;
}
.fusion-action-row,
.fusion-control-row,
.mapping-toolbar,
.mapping-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.fusion-action-row {
justify-content: space-between;
}
.fusion-control-row,
.mapping-toolbar {
justify-content: space-between;
overflow-x: auto;
padding-bottom: 2px;
}
.segmented { .segmented {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -694,6 +752,8 @@ button {
background: rgba(18, 28, 42, 0.88); background: rgba(18, 28, 42, 0.88);
color: #c6d4e5; color: #c6d4e5;
font-weight: 900; font-weight: 900;
line-height: 1.1;
white-space: nowrap;
} }
.segmented button.active { .segmented button.active {
@@ -707,6 +767,37 @@ button {
min-height: 30px; min-height: 30px;
} }
.display-segmented {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 10px;
border-radius: 12px;
background: rgba(148, 163, 184, 0.08);
padding: 6px;
}
#modelDetailControls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.display-segmented button {
min-height: 34px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: #93a8bf;
font-weight: 950;
white-space: nowrap;
}
.display-segmented button.active {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.92);
color: #1d4ed8;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
}
.ghost-btn, .ghost-btn,
.primary-btn, .primary-btn,
.state-btn { .state-btn {
@@ -917,17 +1008,16 @@ button {
} }
.slice-range-panel { .slice-range-panel {
min-height: 104px; min-height: 82px;
display: grid; display: grid;
grid-template-rows: auto 26px auto 24px; grid-template-rows: auto 26px auto;
gap: 8px; gap: 8px;
padding: 10px 16px; padding: 10px 16px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.slice-range-head, .slice-range-head,
.slice-range-foot, .slice-range-foot {
.slice-current-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -941,8 +1031,7 @@ button {
} }
.slice-range-head span, .slice-range-head span,
.slice-range-foot span, .slice-range-foot span {
.slice-current-row span {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
@@ -985,11 +1074,6 @@ button {
pointer-events: auto; pointer-events: auto;
} }
.slice-current-row {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
}
.dicom-stage { .dicom-stage {
position: relative; position: relative;
min-height: 0; min-height: 0;
@@ -1185,8 +1269,8 @@ button {
} }
.mapping-summary { .mapping-summary {
min-height: 122px; min-height: 110px;
max-height: 170px; max-height: 162px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -1443,7 +1527,9 @@ input[type="range"] {
} }
.modal-panel { .modal-panel {
width: min(560px, calc(100vw - 36px)); width: min(960px, calc(100vw - 36px));
max-height: calc(100vh - 36px);
overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 16px;
background: linear-gradient(180deg, rgba(18, 28, 42, 0.98), rgba(8, 13, 20, 0.98)); background: linear-gradient(180deg, rgba(18, 28, 42, 0.98), rgba(8, 13, 20, 0.98));
@@ -1478,6 +1564,123 @@ input[type="range"] {
margin: 12px 0 0; margin: 12px 0 0;
} }
.auto-config-grid {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(300px, 1.15fr);
gap: 12px;
margin-top: 12px;
}
.auto-config-grid section,
.auto-weight-grid {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 12px;
background: rgba(8, 13, 20, 0.56);
padding: 12px;
}
.modal-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
color: #dbeafe;
font-size: 13px;
font-weight: 950;
}
.modal-section-head button {
min-height: 28px;
border: 1px solid rgba(25, 214, 195, 0.48);
border-radius: 8px;
background: rgba(20, 184, 166, 0.1);
color: #ccfbf1;
font-size: 11px;
font-weight: 900;
padding: 0 9px;
}
.bone-option-list {
max-height: 156px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: auto;
}
.bone-option-list label,
.auto-weight-grid label {
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 9px;
background: rgba(15, 22, 32, 0.74);
color: #c7d6e8;
font-size: 12px;
font-weight: 900;
padding: 0 10px;
}
.modal-slider {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
gap: 12px;
align-items: center;
}
.modal-slider input[type="number"],
.auto-weight-grid input {
width: 100%;
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(6, 10, 15, 0.86);
color: var(--text);
font-weight: 900;
text-align: center;
padding: 0 8px;
}
.auto-weight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.auto-weight-grid label {
justify-content: space-between;
}
.auto-weight-grid input {
max-width: 86px;
flex: 0 0 86px;
}
.auto-pose-result {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.auto-pose-result span {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(25, 214, 195, 0.38);
border-radius: 10px;
background: rgba(20, 184, 166, 0.09);
color: #ccfbf1;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 900;
}
.login-overlay { .login-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;