Refine DICOM UPP registration workstation layout
This commit is contained in:
@@ -44,6 +44,8 @@ const state = {
|
|||||||
statusFilter: "",
|
statusFilter: "",
|
||||||
partFilter: "",
|
partFilter: "",
|
||||||
search: "",
|
search: "",
|
||||||
|
caseCollapsed: false,
|
||||||
|
activeToolTab: "series",
|
||||||
cases: [],
|
cases: [],
|
||||||
activeCase: null,
|
activeCase: null,
|
||||||
series: [],
|
series: [],
|
||||||
@@ -54,7 +56,10 @@ const state = {
|
|||||||
registrationStatus: "unregistered",
|
registrationStatus: "unregistered",
|
||||||
pose: { ...DEFAULT_POSE },
|
pose: { ...DEFAULT_POSE },
|
||||||
windowMode: "default",
|
windowMode: "default",
|
||||||
|
fusionMode: "fusion",
|
||||||
sliceIndex: 0,
|
sliceIndex: 0,
|
||||||
|
dicomPreviewTimer: 0,
|
||||||
|
autoResult: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
fusionVersion: 0,
|
fusionVersion: 0,
|
||||||
@@ -68,7 +73,9 @@ const state = {
|
|||||||
modelGroup: null,
|
modelGroup: null,
|
||||||
rawModelGroup: null,
|
rawModelGroup: null,
|
||||||
baseModelScale: 1,
|
baseModelScale: 1,
|
||||||
|
dicomSceneBox: null,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
|
resizeScene: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -98,6 +105,13 @@ function setSaveState(text, tone = "") {
|
|||||||
el.className = tone;
|
el.className = tone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAutoState(text, tone = "") {
|
||||||
|
const el = $("autoState");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = tone;
|
||||||
|
}
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
setSaveState("未保存", "warn");
|
setSaveState("未保存", "warn");
|
||||||
@@ -298,9 +312,16 @@ 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 = "";
|
||||||
|
renderDicomAnnotation();
|
||||||
clearFusion();
|
clearFusion();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +348,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
state.stlFiles = stlPayload.files || [];
|
state.stlFiles = stlPayload.files || [];
|
||||||
state.registrationStatus = registration.registration_status || "unregistered";
|
state.registrationStatus = registration.registration_status || "unregistered";
|
||||||
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
||||||
|
state.autoResult = null;
|
||||||
state.sliceIndex = 0;
|
state.sliceIndex = 0;
|
||||||
$("notes").value = registration.notes || "";
|
$("notes").value = registration.notes || "";
|
||||||
|
|
||||||
@@ -389,34 +411,43 @@ function renderActiveHeader() {
|
|||||||
$("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
|
||||||
? state.series
|
? state.series
|
||||||
.map((item) => {
|
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
||||||
const active = item.series_uid === state.selectedSeriesUid;
|
|
||||||
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
|
||||||
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
|
||||||
return `
|
|
||||||
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
|
||||||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
|
||||||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
|
||||||
<small>序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
|
||||||
<div class="mini-tags">
|
|
||||||
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
|
|
||||||
${labels || `<i>未标注</i>`}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("")
|
.join("")
|
||||||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||||||
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
|
document.querySelectorAll(".series-card[data-series]").forEach((button) => {
|
||||||
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
||||||
});
|
});
|
||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
|
renderDicomAnnotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesCardHtml(item, active = false) {
|
||||||
|
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
||||||
|
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
||||||
|
return `
|
||||||
|
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||||||
|
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||||||
|
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||||||
|
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||||||
|
<div class="mini-tags">
|
||||||
|
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
|
||||||
|
${labels || `<i>未标注</i>`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectSeries(uid) {
|
async function selectSeries(uid) {
|
||||||
@@ -427,6 +458,7 @@ async function selectSeries(uid) {
|
|||||||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||||||
markDirty();
|
markDirty();
|
||||||
renderSeries();
|
renderSeries();
|
||||||
|
renderDicomAnnotation();
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,8 +511,11 @@ function buildPoseControls() {
|
|||||||
(item) => `
|
(item) => `
|
||||||
<div class="pose-control" data-pose="${item.key}">
|
<div class="pose-control" data-pose="${item.key}">
|
||||||
<label>${item.label}</label>
|
<label>${item.label}</label>
|
||||||
|
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
|
||||||
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
|
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||||||
|
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
|
||||||
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
|
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||||||
|
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
).join("");
|
).join("");
|
||||||
@@ -499,6 +534,9 @@ function buildPoseControls() {
|
|||||||
range.addEventListener("input", () => sync(range.value, range));
|
range.addEventListener("input", () => sync(range.value, range));
|
||||||
number.addEventListener("input", () => sync(number.value, number));
|
number.addEventListener("input", () => sync(number.value, number));
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll("[data-nudge]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0)));
|
||||||
|
});
|
||||||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const key = button.dataset.flip;
|
const key = button.dataset.flip;
|
||||||
@@ -510,6 +548,17 @@ function buildPoseControls() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nudgePose(key, delta) {
|
||||||
|
if (!key || !Number.isFinite(delta)) return;
|
||||||
|
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||||
|
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
||||||
|
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
|
||||||
|
state.pose[key] = Number(next.toFixed(3));
|
||||||
|
renderPoseControls();
|
||||||
|
applyPose();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
function renderPoseControls() {
|
function renderPoseControls() {
|
||||||
POSE_CONTROLS.forEach((item) => {
|
POSE_CONTROLS.forEach((item) => {
|
||||||
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
|
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
|
||||||
@@ -532,6 +581,46 @@ function updateSliceControl() {
|
|||||||
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDicomAnnotation() {
|
||||||
|
const row = state.activeCase;
|
||||||
|
const selected = currentSeries();
|
||||||
|
const count = Number(selected?.count || 0);
|
||||||
|
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count} 张` : "当前序列切片";
|
||||||
|
$("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 || "-"}`
|
||||||
|
: "未选择序列";
|
||||||
|
$("dicomInfoRight").textContent = selected
|
||||||
|
? `${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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDicomPreview() {
|
||||||
|
const selected = currentSeries();
|
||||||
|
if (!state.activeCase || !selected) {
|
||||||
|
$("dicomPreview").removeAttribute("src");
|
||||||
|
renderDicomAnnotation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(state.dicomPreviewTimer);
|
||||||
|
state.dicomPreviewTimer = window.setTimeout(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ct_number: state.activeCase.ct_number,
|
||||||
|
series_uid: selected.series_uid,
|
||||||
|
index: String(state.sliceIndex),
|
||||||
|
window: state.windowMode,
|
||||||
|
access_token: state.token,
|
||||||
|
});
|
||||||
|
$("dicomLoading").classList.remove("hidden");
|
||||||
|
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||||||
|
renderDicomAnnotation();
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
if (state.sceneReady) return;
|
if (state.sceneReady) return;
|
||||||
const viewport = $("fusionViewport");
|
const viewport = $("fusionViewport");
|
||||||
@@ -570,6 +659,7 @@ function initScene() {
|
|||||||
camera.aspect = rect.width / rect.height;
|
camera.aspect = rect.width / rect.height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
};
|
};
|
||||||
|
state.resizeScene = resize;
|
||||||
state.resizeObserver = new ResizeObserver(resize);
|
state.resizeObserver = new ResizeObserver(resize);
|
||||||
state.resizeObserver.observe(viewport);
|
state.resizeObserver.observe(viewport);
|
||||||
resize();
|
resize();
|
||||||
@@ -606,6 +696,14 @@ function clearFusion() {
|
|||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyFusionMode() {
|
||||||
|
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
|
||||||
|
if (state.modelGroup) state.modelGroup.visible = true;
|
||||||
|
$("fusionMeta").textContent = state.fusionMode === "model"
|
||||||
|
? `单独模型 · STL ${state.selectedStlIds.size} 个`
|
||||||
|
: $("fusionMeta").textContent;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFusion() {
|
async function loadFusion() {
|
||||||
const version = ++state.fusionVersion;
|
const version = ++state.fusionVersion;
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
@@ -634,9 +732,12 @@ async function loadFusion() {
|
|||||||
if (version !== state.fusionVersion) return;
|
if (version !== state.fusionVersion) return;
|
||||||
await buildStlModels(volume);
|
await buildStlModels(volume);
|
||||||
if (version !== state.fusionVersion) return;
|
if (version !== state.fusionVersion) return;
|
||||||
|
applyFusionMode();
|
||||||
fitCamera();
|
fitCamera();
|
||||||
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
||||||
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
||||||
|
renderDicomAnnotation();
|
||||||
|
updateDicomPreview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearFusion();
|
clearFusion();
|
||||||
$("fusionStatus").textContent = error.message;
|
$("fusionStatus").textContent = error.message;
|
||||||
@@ -682,6 +783,7 @@ async function buildDicomVolume(volume) {
|
|||||||
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
||||||
state.dicomGroup.add(lines);
|
state.dicomGroup.add(lines);
|
||||||
state.baseModelScale = sceneScale;
|
state.baseModelScale = sceneScale;
|
||||||
|
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildStlModels(volume) {
|
async function buildStlModels(volume) {
|
||||||
@@ -723,9 +825,9 @@ async function buildStlModels(volume) {
|
|||||||
applyPose();
|
applyPose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPose() {
|
function applyPose(poseInput = state.pose) {
|
||||||
if (!state.modelGroup) return;
|
if (!state.modelGroup) return;
|
||||||
const pose = { ...DEFAULT_POSE, ...state.pose };
|
const pose = { ...DEFAULT_POSE, ...poseInput };
|
||||||
state.modelGroup.rotation.set(
|
state.modelGroup.rotation.set(
|
||||||
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
|
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
|
||||||
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
|
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
|
||||||
@@ -751,6 +853,120 @@ function fitCamera() {
|
|||||||
state.controls.update();
|
state.controls.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetPose(kind) {
|
||||||
|
if (kind === "rotation") {
|
||||||
|
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||||||
|
} else if (kind === "transform") {
|
||||||
|
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
||||||
|
} else if (kind === "flip") {
|
||||||
|
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
|
||||||
|
} else {
|
||||||
|
state.pose = { ...DEFAULT_POSE };
|
||||||
|
}
|
||||||
|
renderPoseControls();
|
||||||
|
applyPose();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestBoneSelection() {
|
||||||
|
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
|
||||||
|
const next = new Set();
|
||||||
|
state.stlFiles.forEach((file) => {
|
||||||
|
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
||||||
|
if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id));
|
||||||
|
});
|
||||||
|
if (!next.size) {
|
||||||
|
state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id)));
|
||||||
|
}
|
||||||
|
state.selectedStlIds = next;
|
||||||
|
renderStl();
|
||||||
|
markDirty();
|
||||||
|
loadFusion();
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateScore(pose) {
|
||||||
|
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
|
||||||
|
const current = { ...state.pose };
|
||||||
|
applyPose(pose);
|
||||||
|
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||||||
|
applyPose(current);
|
||||||
|
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
|
||||||
|
const overlapSize = new THREE.Vector3();
|
||||||
|
overlap.getSize(overlapSize);
|
||||||
|
const modelSize = new THREE.Vector3();
|
||||||
|
const dicomSize = new THREE.Vector3();
|
||||||
|
modelBox.getSize(modelSize);
|
||||||
|
state.dicomSceneBox.getSize(dicomSize);
|
||||||
|
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
|
||||||
|
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
|
||||||
|
const modelCenter = new THREE.Vector3();
|
||||||
|
const dicomCenter = new THREE.Vector3();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAutoCoarse() {
|
||||||
|
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
|
||||||
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
setAutoState("粗配准完成", "ok");
|
||||||
|
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放,score ${state.autoResult.score.toFixed(4)}`;
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAutoFine() {
|
||||||
|
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
|
||||||
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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: "初始位姿" };
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
evaluated += 1;
|
||||||
|
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.pose = { ...best.pose };
|
||||||
|
state.autoResult = { ...best, evaluated };
|
||||||
|
renderPoseControls();
|
||||||
|
applyPose();
|
||||||
|
fitCamera();
|
||||||
|
setAutoState("微调完成", "ok");
|
||||||
|
$("autoResult").textContent = `最佳候选:${best.mode};score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`;
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRegistration(nextStatus = null) {
|
async function saveRegistration(nextStatus = null) {
|
||||||
if (!state.activeCase || !currentSeries() || state.saving) return;
|
if (!state.activeCase || !currentSeries() || state.saving) return;
|
||||||
state.saving = true;
|
state.saving = true;
|
||||||
@@ -792,6 +1008,8 @@ async function saveRegistration(nextStatus = null) {
|
|||||||
algorithm_model: state.algorithmModel,
|
algorithm_model: state.algorithmModel,
|
||||||
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,
|
||||||
|
auto_result: state.autoResult,
|
||||||
},
|
},
|
||||||
notes: $("notes").value,
|
notes: $("notes").value,
|
||||||
}),
|
}),
|
||||||
@@ -846,16 +1064,42 @@ function openLinkedApp(kind) {
|
|||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
$("loginForm").addEventListener("submit", login);
|
$("loginForm").addEventListener("submit", login);
|
||||||
$("logoutBtn").addEventListener("click", () => logout());
|
$("logoutBtn").addEventListener("click", () => logout());
|
||||||
|
$("caseToggleBtn").addEventListener("click", () => {
|
||||||
|
state.caseCollapsed = !state.caseCollapsed;
|
||||||
|
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
|
||||||
|
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
state.resizeScene?.();
|
||||||
|
fitCamera();
|
||||||
|
}, 230);
|
||||||
|
});
|
||||||
$("refreshBtn").addEventListener("click", async () => {
|
$("refreshBtn").addEventListener("click", async () => {
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
await loadCases(false);
|
await loadCases(false);
|
||||||
});
|
});
|
||||||
$("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);
|
||||||
|
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||||||
|
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||||||
|
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||||||
|
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||||
|
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||||
|
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||||
|
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
|
||||||
|
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
|
||||||
|
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.activeToolTab = button.dataset.toolTab || "series";
|
||||||
|
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
|
||||||
|
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
|
||||||
|
});
|
||||||
|
});
|
||||||
$("caseSearch").addEventListener("input", () => {
|
$("caseSearch").addEventListener("input", () => {
|
||||||
state.search = $("caseSearch").value.trim();
|
state.search = $("caseSearch").value.trim();
|
||||||
window.clearTimeout(state.searchTimer);
|
window.clearTimeout(state.searchTimer);
|
||||||
@@ -875,16 +1119,26 @@ function wireEvents() {
|
|||||||
await loadCases(false);
|
await loadCases(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll(".segmented button").forEach((button) => {
|
document.querySelectorAll("[data-window]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
state.windowMode = button.dataset.window || "default";
|
state.windowMode = button.dataset.window || "default";
|
||||||
document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button));
|
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.fusionMode = button.dataset.fusionMode || "fusion";
|
||||||
|
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
||||||
|
applyFusionMode();
|
||||||
|
fitCamera();
|
||||||
|
});
|
||||||
|
});
|
||||||
$("sliceSlider").addEventListener("input", () => {
|
$("sliceSlider").addEventListener("input", () => {
|
||||||
state.sliceIndex = Number($("sliceSlider").value || 0);
|
state.sliceIndex = Number($("sliceSlider").value || 0);
|
||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
|
renderDicomAnnotation();
|
||||||
|
updateDicomPreview();
|
||||||
});
|
});
|
||||||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
$("sliceSlider").addEventListener("change", () => loadFusion());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
|
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
||||||
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
||||||
<span id="dbStatus" class="status-pill">数据库</span>
|
<span id="dbStatus" class="status-pill">数据库</span>
|
||||||
@@ -81,17 +84,90 @@
|
|||||||
<div id="activeTags" class="tag-line"></div>
|
<div id="activeTags" class="tag-line"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="work-grid">
|
<section class="registration-board">
|
||||||
<aside class="series-panel panel">
|
<aside class="tool-dock panel">
|
||||||
<div class="panel-head compact">
|
<div class="tool-tabs">
|
||||||
<h2>DICOM 序列</h2>
|
<button class="active" data-tool-tab="series">序列</button>
|
||||||
<span id="seriesCount">0</span>
|
<button data-tool-tab="models">组织分割</button>
|
||||||
|
<button data-tool-tab="pose">标记</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="seriesList" class="series-list"></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>
|
||||||
|
<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>
|
||||||
|
<em id="seriesCount">0</em>
|
||||||
|
</div>
|
||||||
|
<div id="seriesList" class="series-list compact-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tool-pane" data-tool-pane="models">
|
||||||
|
<div class="tool-section-title">
|
||||||
|
<span>STL 模型</span>
|
||||||
|
<em id="stlCount">0</em>
|
||||||
|
</div>
|
||||||
|
<div id="modelRail" class="chip-rail"></div>
|
||||||
|
<div id="stlList" class="stl-list compact-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tool-pane" data-tool-pane="pose">
|
||||||
|
<div class="tool-section-title">
|
||||||
|
<span>位姿手动调整</span>
|
||||||
|
<em id="saveState">未保存</em>
|
||||||
|
</div>
|
||||||
|
<div class="pose-actions">
|
||||||
|
<button id="resetRotationBtn" type="button">重置旋转</button>
|
||||||
|
<button id="resetTransformBtn" type="button">重置平移缩放</button>
|
||||||
|
<button id="resetFlipBtn" type="button">重置镜像</button>
|
||||||
|
</div>
|
||||||
|
<div class="flip-row">
|
||||||
|
<button data-flip="flipX">镜像 X</button>
|
||||||
|
<button data-flip="flipY">镜像 Y</button>
|
||||||
|
<button data-flip="flipZ">镜像 Z</button>
|
||||||
|
</div>
|
||||||
|
<div id="poseGrid" class="pose-grid"></div>
|
||||||
|
|
||||||
|
<div class="tool-section-title">
|
||||||
|
<span>位姿自动调整</span>
|
||||||
|
<em id="autoState">未运行</em>
|
||||||
|
</div>
|
||||||
|
<div class="auto-box">
|
||||||
|
<div class="auto-options">
|
||||||
|
<label><input id="autoX" type="checkbox" checked /> X</label>
|
||||||
|
<label><input id="autoY" type="checkbox" checked /> Y</label>
|
||||||
|
<label><input id="autoZ" type="checkbox" checked /> Z</label>
|
||||||
|
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
|
||||||
|
</div>
|
||||||
|
<div class="pose-actions">
|
||||||
|
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
|
||||||
|
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||||
|
<button id="autoFineBtn" type="button">自动微调</button>
|
||||||
|
</div>
|
||||||
|
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="viewer-panel panel">
|
<section class="viewer-panel fusion-panel panel">
|
||||||
<div class="viewer-head">
|
<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">
|
<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>
|
||||||
@@ -119,30 +195,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="right-panel">
|
<section class="viewer-panel dicom-panel panel">
|
||||||
<section class="panel stl-panel">
|
<div class="viewer-head">
|
||||||
<div class="panel-head compact">
|
<div>
|
||||||
<h2>STL 模型</h2>
|
<h2>DICOM 标注</h2>
|
||||||
<span id="stlCount">0</span>
|
<p id="dicomPanelMeta">当前序列切片</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="modelRail" class="chip-rail"></div>
|
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||||
<div id="stlList" class="stl-list"></div>
|
</div>
|
||||||
</section>
|
<div class="dicom-stage">
|
||||||
|
<img id="dicomPreview" alt="DICOM 切片" />
|
||||||
<section class="panel pose-panel">
|
<div class="crosshair horizontal"></div>
|
||||||
<div class="panel-head compact">
|
<div class="crosshair vertical"></div>
|
||||||
<h2>位姿参数</h2>
|
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
|
||||||
<span id="saveState">未保存</span>
|
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
|
||||||
|
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||||
|
<div id="dicomLoading" class="fusion-loading hidden">
|
||||||
|
<span>正在加载 DICOM 标注视图</span>
|
||||||
|
<div><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="poseGrid" class="pose-grid"></div>
|
</div>
|
||||||
<div class="flip-row">
|
<div id="annotationTags" class="annotation-tags"></div>
|
||||||
<button data-flip="flipX">镜像 X</button>
|
</section>
|
||||||
<button data-flip="flipY">镜像 Y</button>
|
|
||||||
<button data-flip="flipZ">镜像 Z</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -111,6 +111,50 @@ button {
|
|||||||
grid-template-columns: 338px minmax(0, 1fr);
|
grid-template-columns: 338px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
transition: grid-template-columns 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.case-collapsed {
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.case-collapsed .case-panel {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8px 1fr;
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.38);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(25, 38, 56, 0.95), rgba(9, 15, 23, 0.95));
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn span {
|
||||||
|
border: 2px solid #afbfd2;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn span:first-child {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn span:nth-child(2),
|
||||||
|
.icon-btn span:nth-child(3) {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.active {
|
||||||
|
border-color: rgba(25, 214, 195, 0.66);
|
||||||
|
background: rgba(20, 184, 166, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -153,6 +197,8 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input,
|
.search-input,
|
||||||
@@ -179,6 +225,11 @@ button {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-pane .notes {
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-row,
|
.filter-row,
|
||||||
.part-filter,
|
.part-filter,
|
||||||
.chip-rail,
|
.chip-rail,
|
||||||
@@ -189,6 +240,10 @@ button {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-pane .chip-rail {
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter,
|
.filter,
|
||||||
.chip,
|
.chip,
|
||||||
.tag,
|
.tag,
|
||||||
@@ -376,6 +431,148 @@ button {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.registration-board {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(520px, 1.08fr) minmax(420px, 0.92fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dock {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 44px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(6, 10, 15, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tabs button {
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background: rgba(18, 24, 36, 0.72);
|
||||||
|
color: #c5d3e5;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tabs button.active {
|
||||||
|
background: linear-gradient(180deg, rgba(75, 86, 111, 0.88), rgba(30, 39, 58, 0.9));
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(25, 214, 195, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-pane {
|
||||||
|
min-height: 0;
|
||||||
|
display: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-pane.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(28, 35, 49, 0.92), rgba(15, 21, 31, 0.92));
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px 14px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card dd {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #edf5ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-section-title {
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 8px -10px 8px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
|
background: rgba(24, 31, 45, 0.9);
|
||||||
|
color: #f1f7ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-section-title button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #c4d7ee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-section-title em {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-series {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-series .series-card {
|
||||||
|
border-color: rgba(25, 214, 195, 0.75);
|
||||||
|
background: rgba(20, 184, 166, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-list {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-pane .compact-list {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.series-panel,
|
.series-panel,
|
||||||
.right-panel {
|
.right-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -402,6 +599,10 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dicom-panel {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-head {
|
.viewer-head {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -410,7 +611,23 @@ button {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-panel .viewer-head {
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-head p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented {
|
.segmented {
|
||||||
@@ -506,15 +723,18 @@ button {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#saveState.ok {
|
#saveState.ok,
|
||||||
|
#autoState.ok {
|
||||||
color: #b8f7da;
|
color: #b8f7da;
|
||||||
}
|
}
|
||||||
|
|
||||||
#saveState.warn {
|
#saveState.warn,
|
||||||
|
#autoState.warn {
|
||||||
color: #ffe1a6;
|
color: #ffe1a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#saveState.error {
|
#saveState.error,
|
||||||
|
#autoState.error {
|
||||||
color: #fecdd3;
|
color: #fecdd3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +848,94 @@ button {
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dicom-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-stage img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair.horizontal {
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(52, 211, 153, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair.vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(248, 113, 113, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-overlay {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
max-width: 48%;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-shadow: 0 1px 2px #000;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-overlay.top-left {
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-overlay.top-right {
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-overlay.bottom-left {
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tags {
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tags span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(25, 214, 195, 0.55);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(20, 184, 166, 0.12);
|
||||||
|
color: #ccfbf1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.slice-row span {
|
.slice-row span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -663,13 +971,13 @@ input[type="range"] {
|
|||||||
.pose-grid {
|
.pose-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 12px 12px;
|
padding: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pose-control {
|
.pose-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,11 +998,44 @@ input[type="range"] {
|
|||||||
padding: 0 7px;
|
padding: 0 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pose-control button,
|
||||||
|
.pose-actions button,
|
||||||
|
.auto-box button {
|
||||||
|
min-height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(8, 13, 20, 0.82);
|
||||||
|
color: #c6d4e5;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pose-control button:hover,
|
||||||
|
.pose-actions button:hover,
|
||||||
|
.auto-box button:hover {
|
||||||
|
border-color: rgba(25, 214, 195, 0.65);
|
||||||
|
color: #ccfbf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-row {
|
||||||
|
grid-column: 2 / span 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pose-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.flip-row {
|
.flip-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px 12px;
|
padding: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flip-row button {
|
.flip-row button {
|
||||||
@@ -712,6 +1053,45 @@ input[type="range"] {
|
|||||||
background: rgba(20, 184, 166, 0.16);
|
background: rgba(20, 184, 166, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-box {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(8, 13, 20, 0.62);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-options label {
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 22, 32, 0.82);
|
||||||
|
color: #c7d6e8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-result {
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(7, 10, 15, 0.78);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -776,4 +1156,15 @@ input[type="range"] {
|
|||||||
.work-grid {
|
.work-grid {
|
||||||
grid-template-columns: 270px minmax(360px, 1fr) 300px;
|
grid-template-columns: 270px minmax(360px, 1fr) 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.registration-board {
|
||||||
|
grid-template-columns: 320px minmax(460px, 1fr) minmax(360px, 0.8fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
.registration-board {
|
||||||
|
overflow-x: auto;
|
||||||
|
grid-template-columns: 320px 560px 420px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user