Refine registration workspace controls
This commit is contained in:
@@ -468,6 +468,7 @@ def cases(
|
||||
q: str = "",
|
||||
status_filter: str = Query(default="", alias="status"),
|
||||
body_part: str = "",
|
||||
algorithm_model: str = "",
|
||||
limit: int = Query(default=120, ge=1, le=400),
|
||||
_: str = Depends(require_auth),
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -492,6 +493,8 @@ def cases(
|
||||
clauses.append(f"registration_status = {sql_literal(status_filter)}")
|
||||
if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}:
|
||||
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 ""
|
||||
return pg_json_rows(
|
||||
f"""
|
||||
|
||||
@@ -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" },
|
||||
statusFilter: "",
|
||||
partFilter: "",
|
||||
modelFilter: "",
|
||||
search: "",
|
||||
caseCollapsed: false,
|
||||
activeToolTab: "series",
|
||||
@@ -60,11 +61,15 @@ const state = {
|
||||
pose: { ...DEFAULT_POSE },
|
||||
windowMode: "default",
|
||||
fusionMode: "fusion",
|
||||
fusionDetail: "low",
|
||||
modelDetail: "standard",
|
||||
mappingMode: "result",
|
||||
sliceIndex: 0,
|
||||
sliceRangeStart: 0,
|
||||
sliceRangeEnd: 0,
|
||||
dicomPreviewTimer: 0,
|
||||
autoResult: null,
|
||||
lastAutoPose: null,
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
saving: false,
|
||||
@@ -77,6 +82,8 @@ const state = {
|
||||
scene: null,
|
||||
controls: null,
|
||||
dicomGroup: null,
|
||||
dicomPlane: null,
|
||||
dicomPoints: null,
|
||||
modelGroup: null,
|
||||
rawModelGroup: null,
|
||||
modelMeshes: [],
|
||||
@@ -247,6 +254,7 @@ async function loadCases(selectFirst = true) {
|
||||
if (state.search) params.set("q", state.search);
|
||||
if (state.statusFilter) params.set("status", state.statusFilter);
|
||||
if (state.partFilter) params.set("body_part", state.partFilter);
|
||||
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
|
||||
const rows = await api(`/api/cases?${params.toString()}`);
|
||||
state.cases = rows;
|
||||
renderCases();
|
||||
@@ -382,12 +390,6 @@ function clearDetail() {
|
||||
$("activeTitle").textContent = "未选择 CT";
|
||||
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
|
||||
$("activeTags").innerHTML = "";
|
||||
$("patientSummary").textContent = "未选择 CT";
|
||||
$("summaryCt").textContent = "-";
|
||||
$("summaryPatientId").textContent = "-";
|
||||
$("summaryStudyTime").textContent = "-";
|
||||
$("summaryBodyPart").textContent = "-";
|
||||
$("matchedSeries").innerHTML = "";
|
||||
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
|
||||
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
|
||||
$("modelRail").innerHTML = "";
|
||||
@@ -407,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
|
||||
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
|
||||
const previousPose = { ...state.pose };
|
||||
if (!switchingModelOnly && !(await confirmChange())) return;
|
||||
if (!(await confirmChange())) return;
|
||||
setTopLoading(true);
|
||||
setFusionLoading(true, "正在读取 CT 关联数据", 6);
|
||||
try {
|
||||
@@ -426,7 +428,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
const savedTransform = registration.transform || {};
|
||||
const keepPreviousPose = switchingModelOnly;
|
||||
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.lastAutoPose = null;
|
||||
state.sliceIndex = 0;
|
||||
$("notes").value = registration.notes || "";
|
||||
|
||||
@@ -447,6 +452,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
renderSeries();
|
||||
renderStl();
|
||||
renderPoseControls();
|
||||
syncDisplayControls();
|
||||
resetDirty();
|
||||
await loadFusion();
|
||||
} catch (error) {
|
||||
@@ -503,26 +509,25 @@ function currentSeries() {
|
||||
function renderActiveHeader() {
|
||||
const row = state.activeCase;
|
||||
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 || "未指定模型"}`;
|
||||
$("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 = [
|
||||
`<span class="tag ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
||||
...bodyPartTags(row).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
||||
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</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("");
|
||||
$("statusBtn").textContent = 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() {
|
||||
$("seriesCount").textContent = state.series.length;
|
||||
const activeSeries = currentSeries();
|
||||
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
|
||||
$("seriesList").innerHTML = state.series.length
|
||||
? sortedSeriesForDisplay(state.series)
|
||||
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
||||
@@ -541,6 +546,7 @@ function seriesCardHtml(item, active = false) {
|
||||
const skipped = isSkippedSeries(item);
|
||||
return `
|
||||
<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>
|
||||
<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>
|
||||
@@ -589,6 +595,10 @@ function renderStl() {
|
||||
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
|
||||
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
|
||||
input.addEventListener("change", async () => {
|
||||
if (state.dirty && !(await confirmChange())) {
|
||||
input.checked = !input.checked;
|
||||
return;
|
||||
}
|
||||
const id = Number(input.dataset.stl);
|
||||
if (input.checked) state.selectedStlIds.add(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.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
|
||||
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
|
||||
$("sliceSlider").max = max;
|
||||
$("sliceSlider").value = state.sliceIndex;
|
||||
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
|
||||
$("sliceRangeStart").max = max;
|
||||
$("sliceRangeEnd").max = max;
|
||||
$("sliceRangeStart").value = state.sliceRangeStart;
|
||||
@@ -704,7 +711,7 @@ function renderDicomAnnotation() {
|
||||
const row = state.activeCase;
|
||||
const selected = currentSeries();
|
||||
const count = Number(selected?.count || 0);
|
||||
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count} 张` : "当前序列切片";
|
||||
$("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
|
||||
$("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 || "-"}`
|
||||
: "未选择序列";
|
||||
@@ -712,10 +719,7 @@ function renderDicomAnnotation() {
|
||||
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
|
||||
: "";
|
||||
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
|
||||
const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {});
|
||||
$("annotationTags").innerHTML = tags.length
|
||||
? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
|
||||
: `<span>未标注</span>`;
|
||||
renderActiveHeader();
|
||||
}
|
||||
|
||||
function updateDicomPreview() {
|
||||
@@ -823,6 +827,8 @@ function clearFusion() {
|
||||
state.volumeMeta = null;
|
||||
state.volumeScene = null;
|
||||
state.modelMeshes = [];
|
||||
state.dicomPlane = null;
|
||||
state.dicomPoints = null;
|
||||
$("fusionMeta").textContent = "DICOM - · STL -";
|
||||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||||
updateSliceControl();
|
||||
@@ -837,6 +843,58 @@ function applyFusionMode() {
|
||||
: $("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() {
|
||||
const version = ++state.fusionVersion;
|
||||
const selected = currentSeries();
|
||||
@@ -861,17 +919,19 @@ async function loadFusion() {
|
||||
state.volumeMeta = volume;
|
||||
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
|
||||
await buildDicomVolume(volume);
|
||||
applyFusionDetail();
|
||||
if (version !== state.fusionVersion) return;
|
||||
setFusionLoading(true, "正在加载 STL 模型", 62);
|
||||
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
|
||||
applyModelDetail();
|
||||
if (version !== state.fusionVersion) return;
|
||||
if (state.sceneReady) {
|
||||
applyFusionMode();
|
||||
fitCamera();
|
||||
}
|
||||
$("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} 个`;
|
||||
renderDicomAnnotation();
|
||||
setFusionLoading(true, "正在渲染右侧分割映射", 96);
|
||||
@@ -1094,6 +1154,11 @@ function drawMappingView() {
|
||||
const image = $("dicomPreview");
|
||||
const canvas = $("mappingCanvas");
|
||||
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) {
|
||||
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
|
||||
return;
|
||||
@@ -1178,6 +1243,8 @@ function drawMappingView() {
|
||||
|
||||
async function buildDicomVolume(volume) {
|
||||
clearGroup(state.dicomGroup);
|
||||
state.dicomPlane = null;
|
||||
state.dicomPoints = null;
|
||||
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 = 4.8 / maxPhysical;
|
||||
@@ -1221,6 +1288,7 @@ async function buildDicomVolume(volume) {
|
||||
state.dicomGroup.add(rangeBox);
|
||||
|
||||
const centerFrame = (volume.frames || [])[centerPosition];
|
||||
const fusionDetail = fusionDetailSettings();
|
||||
if (centerFrame) {
|
||||
const texture = await loadTexture(centerFrame);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
@@ -1229,13 +1297,14 @@ async function buildDicomVolume(volume) {
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
opacity: fusionDetail.planeOpacity,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
||||
state.dicomGroup.add(plane);
|
||||
state.dicomPlane = plane;
|
||||
}
|
||||
|
||||
const pointPositions = [];
|
||||
@@ -1265,14 +1334,15 @@ async function buildDicomVolume(volume) {
|
||||
const points = new THREE.Points(
|
||||
geometry,
|
||||
new THREE.PointsMaterial({
|
||||
size: 0.018,
|
||||
size: fusionDetail.pointSize,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
opacity: fusionDetail.pointOpacity,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
state.dicomGroup.add(points);
|
||||
state.dicomPoints = points;
|
||||
}
|
||||
|
||||
state.baseModelScale = sceneScale;
|
||||
@@ -1283,6 +1353,7 @@ async function buildStlModels(volume, onProgress = null) {
|
||||
const signature = stlSelectionSignature();
|
||||
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
|
||||
applyPose();
|
||||
applyModelDetail();
|
||||
scheduleMappingDraw();
|
||||
onProgress?.(100);
|
||||
return;
|
||||
@@ -1297,6 +1368,7 @@ async function buildStlModels(volume, onProgress = null) {
|
||||
return;
|
||||
}
|
||||
const loader = new STLLoader();
|
||||
const detail = modelDetailSettings();
|
||||
for (const [index, file] of files.entries()) {
|
||||
onProgress?.((index / Math.max(files.length, 1)) * 100);
|
||||
const params = new URLSearchParams({
|
||||
@@ -1310,12 +1382,13 @@ async function buildStlModels(volume, onProgress = null) {
|
||||
geometry.computeBoundingBox();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: STL_COLORS[index % STL_COLORS.length],
|
||||
metalness: 0.08,
|
||||
roughness: 0.5,
|
||||
metalness: detail.metalness,
|
||||
roughness: detail.roughness,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
opacity: detail.opacity,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
depthWrite: detail.depthWrite,
|
||||
wireframe: detail.wireframe,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = file.segment_name || file.file_name;
|
||||
@@ -1404,10 +1477,20 @@ function stretchAxis(axis) {
|
||||
markDirty("pose");
|
||||
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
||||
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
|
||||
updateAutoPoseResult();
|
||||
}
|
||||
|
||||
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();
|
||||
state.stlFiles.forEach((file) => {
|
||||
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
||||
@@ -1422,8 +1505,66 @@ function suggestBoneSelection() {
|
||||
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;
|
||||
const config = settings || readAutoSettings();
|
||||
const current = { ...state.pose };
|
||||
applyPose(pose);
|
||||
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||||
@@ -1442,8 +1583,9 @@ function candidateScore(pose) {
|
||||
modelBox.getCenter(modelCenter);
|
||||
state.dicomSceneBox.getCenter(dicomCenter);
|
||||
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
|
||||
const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03;
|
||||
return overlapVolume / modelVolume - centerPenalty - scalePenalty;
|
||||
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
|
||||
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() {
|
||||
@@ -1451,13 +1593,17 @@ function runAutoCoarse() {
|
||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||
return;
|
||||
}
|
||||
const before = { ...state.pose };
|
||||
const settings = readAutoSettings();
|
||||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
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");
|
||||
$("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();
|
||||
}
|
||||
|
||||
@@ -1468,40 +1614,37 @@ function runAutoFine() {
|
||||
}
|
||||
setAutoState("运行中", "warn");
|
||||
$("autoResult").textContent = "正在进行几何重叠微调...";
|
||||
const adjustable = {
|
||||
translateX: $("autoX").checked,
|
||||
translateY: $("autoY").checked,
|
||||
translateZ: $("autoZ").checked,
|
||||
scale: $("autoScale").checked,
|
||||
};
|
||||
let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" };
|
||||
const before = { ...state.pose };
|
||||
const settings = readAutoSettings();
|
||||
const adjustable = settings.adjustable;
|
||||
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
|
||||
let evaluated = 1;
|
||||
const rounds = [
|
||||
{ move: 0.34, scale: 0.12 },
|
||||
{ move: 0.18, scale: 0.06 },
|
||||
{ move: 0.08, scale: 0.025 },
|
||||
{ move: 0.035, scale: 0.01 },
|
||||
];
|
||||
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
|
||||
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
|
||||
return { move: 0.34 * t, scale: 0.12 * t };
|
||||
});
|
||||
for (const round of rounds) {
|
||||
const candidates = [];
|
||||
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -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.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 score = candidateScore(pose);
|
||||
const score = candidateScore(pose, settings);
|
||||
evaluated += 1;
|
||||
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
|
||||
}
|
||||
}
|
||||
state.pose = { ...best.pose };
|
||||
state.autoResult = { ...best, evaluated };
|
||||
state.autoResult = { ...best, evaluated, settings };
|
||||
state.lastAutoPose = { before, after: { ...state.pose } };
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
fitCamera();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1532,7 +1675,7 @@ async function saveRegistration(nextStatus = null) {
|
||||
algorithm_model: file.algorithm_model,
|
||||
})),
|
||||
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: {
|
||||
series_uid: selectedSeries.series_uid,
|
||||
series_number: selectedSeries.series_number,
|
||||
@@ -1547,6 +1690,8 @@ async function saveRegistration(nextStatus = null) {
|
||||
selected_count: selectedFiles.length,
|
||||
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
|
||||
fusion_mode: state.fusionMode,
|
||||
fusion_detail: state.fusionDetail,
|
||||
model_detail: state.modelDetail,
|
||||
auto_result: state.autoResult,
|
||||
},
|
||||
notes: $("notes").value,
|
||||
@@ -1606,6 +1751,13 @@ function setWindowMode(mode, reload = true) {
|
||||
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) {
|
||||
$("autoModal").classList.toggle("hidden", !visible);
|
||||
}
|
||||
@@ -1628,12 +1780,10 @@ function wireEvents() {
|
||||
});
|
||||
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
|
||||
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||||
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||||
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
|
||||
$("fitBtn").addEventListener("click", fitCamera);
|
||||
$("saveBtn").addEventListener("click", () => saveRegistration());
|
||||
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
|
||||
$("notes").addEventListener("input", markDirty);
|
||||
$("notes").addEventListener("input", () => markDirty("notes"));
|
||||
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||||
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||||
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||||
@@ -1648,6 +1798,25 @@ function wireEvents() {
|
||||
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||
$("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", () => {
|
||||
$("dicomLoading").classList.add("hidden");
|
||||
scheduleMappingDraw();
|
||||
@@ -1728,12 +1897,22 @@ function wireEvents() {
|
||||
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) => {
|
||||
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
|
||||
});
|
||||
document.querySelectorAll("[data-map-window]").forEach((button) => {
|
||||
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
|
||||
});
|
||||
document.querySelectorAll("[data-map-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
|
||||
});
|
||||
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.fusionMode = button.dataset.fusionMode || "fusion";
|
||||
@@ -1742,11 +1921,21 @@ function wireEvents() {
|
||||
fitCamera();
|
||||
});
|
||||
});
|
||||
$("sliceSlider").addEventListener("input", () => {
|
||||
state.sliceIndex = Number($("sliceSlider").value || 0);
|
||||
updateSliceControl();
|
||||
renderDicomAnnotation();
|
||||
updateDicomPreview();
|
||||
document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.fusionDetail = button.dataset.fusionDetail || "low";
|
||||
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
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", () => {
|
||||
const selected = currentSeries();
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
<button class="filter" data-part="lower_abdomen">下腹部</button>
|
||||
<button class="filter" data-part="pelvis">盆腔</button>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
@@ -90,27 +96,21 @@
|
||||
<aside class="tool-dock panel">
|
||||
<div class="tool-tabs">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section class="tool-pane active" data-tool-pane="series">
|
||||
<div class="patient-card">
|
||||
<strong id="patientSummary">未选择 CT</strong>
|
||||
<dl>
|
||||
<div><dt>检查号</dt><dd id="summaryCt">-</dd></div>
|
||||
<div><dt>患者号</dt><dd id="summaryPatientId">-</dd></div>
|
||||
<div><dt>检查日期</dt><dd id="summaryStudyTime">-</dd></div>
|
||||
<div><dt>检查部位</dt><dd id="summaryBodyPart">-</dd></div>
|
||||
</dl>
|
||||
<div class="tool-section-title">
|
||||
<span>融合显示</span>
|
||||
</div>
|
||||
<div class="display-segmented" id="fusionDetailControls">
|
||||
<button class="active" data-fusion-detail="low" type="button">DICOM 低</button>
|
||||
<button data-fusion-detail="medium" type="button">DICOM 中</button>
|
||||
<button data-fusion-detail="high" type="button">DICOM 高</button>
|
||||
</div>
|
||||
<div class="tool-section-title">
|
||||
<span>3D 重建所需序列</span>
|
||||
<button id="reloadFusionBtn" type="button">重选序列</button>
|
||||
</div>
|
||||
<div id="matchedSeries" class="matched-series"></div>
|
||||
<div class="tool-section-title">
|
||||
<span>全部检查和序列</span>
|
||||
<span>检查序列</span>
|
||||
<em id="seriesCount">0</em>
|
||||
</div>
|
||||
<div id="seriesList" class="series-list compact-list"></div>
|
||||
@@ -118,9 +118,15 @@
|
||||
|
||||
<section class="tool-pane" data-tool-pane="models">
|
||||
<div class="tool-section-title">
|
||||
<span>STL 模型</span>
|
||||
<span>模型可视</span>
|
||||
<em id="stlCount">0</em>
|
||||
</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="stlList" class="stl-list compact-list"></div>
|
||||
</section>
|
||||
@@ -156,23 +162,29 @@
|
||||
|
||||
<section class="viewer-panel fusion-panel panel">
|
||||
<div class="viewer-head">
|
||||
<div class="segmented">
|
||||
<button data-fusion-mode="fusion" class="active">融合结果</button>
|
||||
<button data-fusion-mode="model">单独模型</button>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button data-window="default" class="active">默认</button>
|
||||
<button data-window="bone">骨窗</button>
|
||||
<button data-window="soft">软组织</button>
|
||||
<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>
|
||||
<div class="fusion-head-stack">
|
||||
<div class="fusion-action-row">
|
||||
<div class="segmented">
|
||||
<button data-fusion-mode="fusion" class="active">融合结果</button>
|
||||
<button data-fusion-mode="model">单独模型</button>
|
||||
</div>
|
||||
<button id="saveBtn" class="primary-btn">保存配准</button>
|
||||
<button id="statusBtn" class="state-btn">标为已配准</button>
|
||||
</div>
|
||||
<div class="fusion-control-row">
|
||||
<div class="segmented">
|
||||
<button data-window="default" class="active">默认</button>
|
||||
<button data-window="bone">骨窗</button>
|
||||
<button data-window="soft">软组织</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fusion-stage">
|
||||
@@ -199,30 +211,29 @@
|
||||
<span>范围</span>
|
||||
<span id="sliceEndLabel">终点 0</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="viewer-panel dicom-panel panel">
|
||||
<div class="viewer-head">
|
||||
<div>
|
||||
<h2>逆向分割映射视图</h2>
|
||||
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
|
||||
<div class="mapping-title-row">
|
||||
<div class="segmented">
|
||||
<button data-map-mode="result" class="active">分割结果</button>
|
||||
<button data-map-mode="image">单独影像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-tools">
|
||||
<div class="mapping-toolbar">
|
||||
<div class="segmented mini-segmented">
|
||||
<button data-map-window="default" class="active">默认</button>
|
||||
<button data-map-window="bone">骨窗</button>
|
||||
<button data-map-window="soft">软组织</button>
|
||||
<button data-map-window="contrast">高对比</button>
|
||||
</div>
|
||||
<button id="mappingRotateLeftBtn" 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="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||
<div class="viewer-tools">
|
||||
<button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button>
|
||||
<button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button>
|
||||
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dicom-stage">
|
||||
@@ -240,16 +251,15 @@
|
||||
<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>正在加载逆向分割映射视图</span>
|
||||
<span>正在加载分割结果</span>
|
||||
<div><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-summary">
|
||||
<div class="mapping-summary-head">
|
||||
<span>Overlay Label Map</span>
|
||||
<span id="mappingSummaryTitle">当前分割构件</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>
|
||||
@@ -272,11 +282,53 @@
|
||||
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
|
||||
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
|
||||
</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">
|
||||
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
|
||||
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||
<button id="autoFineBtn" type="button">自动微调</button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ button {
|
||||
|
||||
.filter-row,
|
||||
.part-filter,
|
||||
.model-filter,
|
||||
.chip-rail,
|
||||
.tag-line {
|
||||
display: flex;
|
||||
@@ -355,6 +356,35 @@ button {
|
||||
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,
|
||||
.series-card.active,
|
||||
.stl-row.active {
|
||||
@@ -679,6 +709,34 @@ button {
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -694,6 +752,8 @@ button {
|
||||
background: rgba(18, 28, 42, 0.88);
|
||||
color: #c6d4e5;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
@@ -707,6 +767,37 @@ button {
|
||||
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,
|
||||
.primary-btn,
|
||||
.state-btn {
|
||||
@@ -917,17 +1008,16 @@ button {
|
||||
}
|
||||
|
||||
.slice-range-panel {
|
||||
min-height: 104px;
|
||||
min-height: 82px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 26px auto 24px;
|
||||
grid-template-rows: auto 26px auto;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.slice-range-head,
|
||||
.slice-range-foot,
|
||||
.slice-current-row {
|
||||
.slice-range-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -941,8 +1031,7 @@ button {
|
||||
}
|
||||
|
||||
.slice-range-head span,
|
||||
.slice-range-foot span,
|
||||
.slice-current-row span {
|
||||
.slice-range-foot span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
@@ -985,11 +1074,6 @@ button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.slice-current-row {
|
||||
display: grid;
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dicom-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
@@ -1185,8 +1269,8 @@ button {
|
||||
}
|
||||
|
||||
.mapping-summary {
|
||||
min-height: 122px;
|
||||
max-height: 170px;
|
||||
min-height: 110px;
|
||||
max-height: 162px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -1443,7 +1527,9 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.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-radius: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user