diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js
index 3653c30..37a41fa 100644
--- a/DICOM_and_UPP配准/static/app.js
+++ b/DICOM_and_UPP配准/static/app.js
@@ -57,6 +57,7 @@ const state = {
activeCase: null,
series: [],
selectedSeriesUid: "",
+ registrationSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
algorithmModel: "",
@@ -460,6 +461,7 @@ function clearDetail() {
state.stlFiles = [];
state.selectedStlIds = new Set();
state.selectedSeriesUid = "";
+ state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
@@ -513,6 +515,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const defaultSeries = pickDefaultSeries(state.series);
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
+ state.registrationSeriesUid = savedSeries?.series_uid || "";
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
@@ -583,14 +586,15 @@ function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const selected = currentSeries();
+ const registration = registrationSeries();
const bodyTags = bodyPartTags(row);
- const selectedLabels = selected?.annotation_labels?.length ? selected.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
+ const selectedLabels = registration?.annotation_labels?.length ? registration.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 || "-"} · ${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 = [
`${statusText(state.registrationStatus)}`,
`${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`,
- ...(selected ? [`${escapeHtml(selected.description || "当前序列")}`] : []),
+ ...(registration ? [`配准 ${escapeHtml(registration.description || "当前序列")}`] : []),
...selectedLabels.map((tag) => `${escapeHtml(tag)}`),
...bodyTags.map((tag) => `${escapeHtml(tag)}`),
];
@@ -603,23 +607,37 @@ function renderSeries() {
$("seriesCount").textContent = state.series.length;
$("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series)
- .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
+ .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid))
.join("")
: `
未读取到 DICOM 序列
`;
- document.querySelectorAll(".series-card[data-series]").forEach((button) => {
- button.addEventListener("click", () => selectSeries(button.dataset.series));
+ document.querySelectorAll(".series-card[data-series]").forEach((card) => {
+ card.addEventListener("click", (event) => {
+ if (event.target.closest(".series-check")) return;
+ selectSeries(card.dataset.series);
+ });
+ card.addEventListener("keydown", (event) => {
+ if (!["Enter", " "].includes(event.key)) return;
+ event.preventDefault();
+ selectSeries(card.dataset.series);
+ });
+ });
+ document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => {
+ button.addEventListener("click", async (event) => {
+ event.stopPropagation();
+ await setRegistrationSeries(button.dataset.registrationSeries);
+ });
});
updateSliceControl();
renderDicomAnnotation();
}
-function seriesCardHtml(item, active = false) {
+function seriesCardHtml(item, active = false, registrationSelected = false) {
const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
-
+
`;
}
-async function selectSeries(uid) {
+function registrationSeries() {
+ return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null;
+}
+
+async function setRegistrationSeries(uid) {
+ if (!uid) return;
+ if (uid !== state.selectedSeriesUid) {
+ await selectSeries(uid, { markRegistration: true });
+ return;
+ }
+ state.registrationSeriesUid = uid;
+ markDirty("series");
+ renderSeries();
+ renderActiveHeader();
+}
+
+async function selectSeries(uid, options = {}) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
+ if (options.markRegistration) state.registrationSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
- setSaveState("序列未保存", "warn");
+ if (options.markRegistration) markDirty("series");
+ else setSaveState("正在浏览序列", "");
renderSeries();
+ renderActiveHeader();
renderDicomAnnotation();
await loadFusion();
}
@@ -671,7 +708,7 @@ function renderStl() {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
- setSaveState("模型未保存", "warn");
+ markDirty("stl");
renderStl();
await loadFusion();
});
@@ -679,6 +716,27 @@ function renderStl() {
renderAutoBoneOptions();
}
+async function selectAllStl() {
+ if (!state.stlFiles.length) return;
+ state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite));
+ markDirty("stl");
+ renderStl();
+ await loadFusion();
+}
+
+async function invertStlSelection() {
+ if (!state.stlFiles.length) return;
+ const next = new Set();
+ state.stlFiles.forEach((file) => {
+ const id = Number(file.id);
+ if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id);
+ });
+ state.selectedStlIds = next;
+ markDirty("stl");
+ renderStl();
+ await loadFusion();
+}
+
function stlLikelyScore(file) {
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
const priority = [
@@ -1014,6 +1072,11 @@ function clearGroup(group) {
function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
+ if (state.rawModelGroup) {
+ state.rawModelGroup.position.set(0, 0, 0);
+ state.rawModelGroup.rotation.set(0, 0, 0);
+ state.rawModelGroup.scale.set(1, 1, 1);
+ }
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
@@ -1571,15 +1634,49 @@ async function buildDicomVolume(volume) {
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
+function updateBaseModelScaleFromBounds() {
+ const size = state.modelLocalBounds?.size;
+ if (!size) return;
+ const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
+ const volumeSize = state.volumeScene
+ ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
+ : 4.8;
+ state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
+}
+
+function addModelBoundsFrame(size) {
+ if (!state.rawModelGroup || !size) return;
+ const frame = new THREE.LineSegments(
+ new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
+ new THREE.LineBasicMaterial({
+ color: 0xfacc15,
+ transparent: true,
+ opacity: 0.96,
+ depthTest: false,
+ depthWrite: false,
+ toneMapped: false,
+ }),
+ );
+ frame.name = "model-bounds";
+ frame.renderOrder = 1000;
+ state.rawModelGroup.add(frame);
+ state.modelBoundsFrame = frame;
+}
+
async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
+ updateBaseModelScaleFromBounds();
applyPose();
applyModelDetail();
+ if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
scheduleMappingDraw();
onProgress?.(100);
return;
}
+ state.rawModelGroup.position.set(0, 0, 0);
+ state.rawModelGroup.rotation.set(0, 0, 0);
+ state.rawModelGroup.scale.set(1, 1, 1);
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.modelBoundsFrame = null;
@@ -1622,35 +1719,28 @@ async function buildStlModels(volume, onProgress = null) {
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
- const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
+ const bbox = new THREE.Box3();
+ state.modelMeshes.forEach((record) => {
+ record.mesh.geometry.computeBoundingBox?.();
+ if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
+ });
const center = new THREE.Vector3();
const size = new THREE.Vector3();
bbox.getCenter(center);
bbox.getSize(size);
- state.rawModelGroup.position.set(0, 0, 0);
- state.rawModelGroup.traverse((object) => {
- if (!object.geometry) return;
- object.geometry.translate(-center.x, -center.y, -center.z);
- object.geometry.computeBoundingBox?.();
- object.geometry.computeBoundingSphere?.();
- object.geometry.computeVertexNormals?.();
+ state.modelMeshes.forEach((record) => {
+ const geometry = record.mesh.geometry;
+ geometry.translate(-center.x, -center.y, -center.z);
+ geometry.computeBoundingBox?.();
+ geometry.computeBoundingSphere?.();
+ geometry.computeVertexNormals?.();
});
- const maxModelSize = Math.max(size.x, size.y, size.z, 1);
- const volumeSize = state.volumeScene
- ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
- : 4.8;
- state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z },
size: { x: size.x, y: size.y, z: size.z },
};
- const modelBounds = new THREE.LineSegments(
- new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
- new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.78 }),
- );
- modelBounds.name = "model-bounds";
- state.rawModelGroup.add(modelBounds);
- state.modelBoundsFrame = modelBounds;
+ updateBaseModelScaleFromBounds();
+ addModelBoundsFrame(size);
applyPose();
scheduleMappingDraw();
onProgress?.(100);
@@ -1948,11 +2038,21 @@ async function saveRegistration(nextStatus = null) {
}),
});
state.registrationStatus = status;
- if (state.activeCase) state.activeCase.registration_status = status;
+ state.registrationSeriesUid = selectedSeries.series_uid;
+ if (state.activeCase) {
+ state.activeCase.registration_status = status;
+ state.activeCase.series_instance_uid = selectedSeries.series_uid;
+ state.activeCase.series_description = selectedSeries.description || "";
+ }
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
- if (existing) existing.registration_status = status;
+ if (existing) {
+ existing.registration_status = status;
+ existing.series_instance_uid = selectedSeries.series_uid;
+ existing.series_description = selectedSeries.description || "";
+ }
resetDirty();
renderCases();
+ renderSeries();
renderActiveHeader();
} catch (error) {
setSaveState(error.message, "error");
@@ -2038,6 +2138,8 @@ function wireEvents() {
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", () => markDirty("notes"));
+ $("selectAllStlBtn").addEventListener("click", selectAllStl);
+ $("invertStlBtn").addEventListener("click", invertStlSelection);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html
index 48c1ce6..5194f51 100644
--- a/DICOM_and_UPP配准/static/index.html
+++ b/DICOM_and_UPP配准/static/index.html
@@ -170,6 +170,10 @@
diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css
index 768f3b3..59e1ea8 100644
--- a/DICOM_and_UPP配准/static/styles.css
+++ b/DICOM_and_UPP配准/static/styles.css
@@ -499,30 +499,49 @@ button {
.series-card {
position: relative;
padding-right: 48px;
+ cursor: pointer;
}
.series-check {
position: absolute;
top: 13px;
right: 13px;
- width: 26px;
- height: 26px;
+ width: 30px;
+ height: 30px;
display: inline-flex !important;
align-items: center;
justify-content: center;
margin: 0 !important;
- border: 1px solid rgba(25, 214, 195, 0.62);
+ padding: 0;
+ border: 2px solid rgba(45, 212, 191, 0.9);
border-radius: 999px;
- background: rgba(20, 184, 166, 0.16);
+ background: rgba(13, 148, 136, 0.22);
color: #ccfbf1 !important;
font-size: 15px !important;
font-weight: 950 !important;
line-height: 1;
+ cursor: pointer;
+ box-shadow: 0 0 0 1px rgba(6, 182, 212, 0.18), 0 0 18px rgba(20, 184, 166, 0.18);
}
-.series-card:not(.active) .series-check {
- border-color: rgba(148, 163, 184, 0.2);
- background: rgba(148, 163, 184, 0.06);
+.series-card:not(.registration-selected) .series-check {
+ border-color: rgba(148, 163, 184, 0.42);
+ background: rgba(15, 23, 42, 0.82);
+ color: transparent !important;
+ box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.98);
+}
+
+.series-card:not(.registration-selected) .series-check:hover,
+.series-check:focus-visible {
+ border-color: rgba(45, 212, 191, 0.95);
+ background: rgba(20, 184, 166, 0.18);
+ outline: none;
+}
+
+.series-card.registration-selected .series-check {
+ border-color: rgba(153, 246, 228, 0.96);
+ background: linear-gradient(180deg, rgba(20, 184, 166, 0.96), rgba(13, 148, 136, 0.82));
+ color: #ecfeff !important;
}
.case-card.active,
@@ -532,6 +551,10 @@ button {
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
}
+.series-card.registration-selected {
+ border-color: rgba(20, 184, 166, 0.82);
+}
+
.series-card.skipped {
opacity: 0.54;
filter: grayscale(0.95);
@@ -754,11 +777,27 @@ button {
}
.tool-section-title button {
- border: 0;
- background: transparent;
- color: #c4d7ee;
+ border: 1px solid rgba(45, 212, 191, 0.34);
+ border-radius: 8px;
+ background: rgba(8, 17, 26, 0.62);
+ color: #d9fbff;
font-size: 12px;
font-weight: 900;
+ line-height: 1;
+ padding: 6px 9px;
+ cursor: pointer;
+}
+
+.tool-section-title button:hover {
+ border-color: rgba(45, 212, 191, 0.76);
+ background: rgba(20, 184, 166, 0.16);
+}
+
+.title-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
}
.tool-section-title em {