Refine registration DICOM selection and model centering

This commit is contained in:
Codex
2026-05-29 09:35:44 +08:00
parent 69a5926e3c
commit 45e719aab0
3 changed files with 189 additions and 44 deletions

View File

@@ -57,6 +57,7 @@ const state = {
activeCase: null, activeCase: null,
series: [], series: [],
selectedSeriesUid: "", selectedSeriesUid: "",
registrationSeriesUid: "",
stlFiles: [], stlFiles: [],
selectedStlIds: new Set(), selectedStlIds: new Set(),
algorithmModel: "", algorithmModel: "",
@@ -460,6 +461,7 @@ function clearDetail() {
state.stlFiles = []; state.stlFiles = [];
state.selectedStlIds = new Set(); state.selectedStlIds = new Set();
state.selectedSeriesUid = ""; state.selectedSeriesUid = "";
state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT"; $("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = ""; $("activeTags").innerHTML = "";
@@ -513,6 +515,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const defaultSeries = pickDefaultSeries(state.series); const defaultSeries = pickDefaultSeries(state.series);
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid); const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries; const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
state.registrationSeriesUid = savedSeries?.series_uid || "";
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || ""; state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries(); const selected = currentSeries();
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;
@@ -583,14 +586,15 @@ function renderActiveHeader() {
const row = state.activeCase; const row = state.activeCase;
if (!row) return; if (!row) return;
const selected = currentSeries(); const selected = currentSeries();
const registration = registrationSeries();
const bodyTags = bodyPartTags(row); 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 || "未指定模型"}`; $("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)}`; $("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [ const tags = [
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`, `<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`, `<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...(selected ? [`<span class="tag">${escapeHtml(selected.description || "当前序列")}</span>`] : []), ...(registration ? [`<span class="tag">配准 ${escapeHtml(registration.description || "当前序列")}</span>`] : []),
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`), ...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`), ...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
]; ];
@@ -603,23 +607,37 @@ function renderSeries() {
$("seriesCount").textContent = state.series.length; $("seriesCount").textContent = state.series.length;
$("seriesList").innerHTML = state.series.length $("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series) ? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid))
.join("") .join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`; : `<div class="empty-state">未读取到 DICOM 序列</div>`;
document.querySelectorAll(".series-card[data-series]").forEach((button) => { document.querySelectorAll(".series-card[data-series]").forEach((card) => {
button.addEventListener("click", () => selectSeries(button.dataset.series)); 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(); updateSliceControl();
renderDicomAnnotation(); renderDicomAnnotation();
} }
function seriesCardHtml(item, active = false) { function seriesCardHtml(item, active = false, registrationSelected = false) {
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join(""); const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item); const skipped = isSkippedSeries(item);
return ` return `
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}"> <div class="series-card ${active ? "active" : ""} ${registrationSelected ? "registration-selected" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}" role="button" tabindex="0">
<span class="series-check" aria-hidden="true">${active ? "✓" : ""}</span> <button class="series-check" data-registration-series="${escapeHtml(item.series_uid)}" type="button" aria-label="设为配准 DICOM">${registrationSelected ? "✓" : ""}</button>
<strong>${escapeHtml(item.description || "未命名序列")}</strong> <strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span> <span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small> <small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
@@ -627,20 +645,39 @@ function seriesCardHtml(item, active = false) {
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""} ${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`} ${labels || `<i>未标注</i>`}
</div> </div>
</button> </div>
`; `;
} }
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 (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return; if (!(await confirmChange())) return;
state.selectedSeriesUid = uid; state.selectedSeriesUid = uid;
if (options.markRegistration) state.registrationSeriesUid = uid;
const selected = currentSeries(); const selected = currentSeries();
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;
state.sliceRangeStart = 0; state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
setSaveState("序列未保存", "warn"); if (options.markRegistration) markDirty("series");
else setSaveState("正在浏览序列", "");
renderSeries(); renderSeries();
renderActiveHeader();
renderDicomAnnotation(); renderDicomAnnotation();
await loadFusion(); await loadFusion();
} }
@@ -671,7 +708,7 @@ function renderStl() {
const id = Number(input.dataset.stl); const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id); if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id); else state.selectedStlIds.delete(id);
setSaveState("模型未保存", "warn"); markDirty("stl");
renderStl(); renderStl();
await loadFusion(); await loadFusion();
}); });
@@ -679,6 +716,27 @@ function renderStl() {
renderAutoBoneOptions(); 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) { function stlLikelyScore(file) {
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
const priority = [ const priority = [
@@ -1014,6 +1072,11 @@ function clearGroup(group) {
function clearFusion() { function clearFusion() {
clearGroup(state.dicomGroup); clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup); 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.volumeMeta = null;
state.volumeScene = null; state.volumeScene = null;
state.modelMeshes = []; state.modelMeshes = [];
@@ -1571,15 +1634,49 @@ async function buildDicomVolume(volume) {
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); 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) { async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature(); const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) { if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
updateBaseModelScaleFromBounds();
applyPose(); applyPose();
applyModelDetail(); applyModelDetail();
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
scheduleMappingDraw(); scheduleMappingDraw();
onProgress?.(100); onProgress?.(100);
return; 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); clearGroup(state.rawModelGroup);
state.modelMeshes = []; state.modelMeshes = [];
state.modelBoundsFrame = null; state.modelBoundsFrame = null;
@@ -1622,35 +1719,28 @@ async function buildStlModels(volume, onProgress = null) {
state.modelMeshes.push({ mesh, file, color: cssColor(index), index }); state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); 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 center = new THREE.Vector3();
const size = new THREE.Vector3(); const size = new THREE.Vector3();
bbox.getCenter(center); bbox.getCenter(center);
bbox.getSize(size); bbox.getSize(size);
state.rawModelGroup.position.set(0, 0, 0); state.modelMeshes.forEach((record) => {
state.rawModelGroup.traverse((object) => { const geometry = record.mesh.geometry;
if (!object.geometry) return; geometry.translate(-center.x, -center.y, -center.z);
object.geometry.translate(-center.x, -center.y, -center.z); geometry.computeBoundingBox?.();
object.geometry.computeBoundingBox?.(); geometry.computeBoundingSphere?.();
object.geometry.computeBoundingSphere?.(); geometry.computeVertexNormals?.();
object.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 = { state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z }, center: { x: center.x, y: center.y, z: center.z },
size: { x: size.x, y: size.y, z: size.z }, size: { x: size.x, y: size.y, z: size.z },
}; };
const modelBounds = new THREE.LineSegments( updateBaseModelScaleFromBounds();
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), addModelBoundsFrame(size);
new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.78 }),
);
modelBounds.name = "model-bounds";
state.rawModelGroup.add(modelBounds);
state.modelBoundsFrame = modelBounds;
applyPose(); applyPose();
scheduleMappingDraw(); scheduleMappingDraw();
onProgress?.(100); onProgress?.(100);
@@ -1948,11 +2038,21 @@ async function saveRegistration(nextStatus = null) {
}), }),
}); });
state.registrationStatus = status; 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)); 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(); resetDirty();
renderCases(); renderCases();
renderSeries();
renderActiveHeader(); renderActiveHeader();
} catch (error) { } catch (error) {
setSaveState(error.message, "error"); setSaveState(error.message, "error");
@@ -2038,6 +2138,8 @@ function wireEvents() {
$("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")); $("notes").addEventListener("input", () => markDirty("notes"));
$("selectAllStlBtn").addEventListener("click", selectAllStl);
$("invertStlBtn").addEventListener("click", invertStlSelection);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip"));

View File

@@ -170,6 +170,10 @@
</div> </div>
<div class="tool-section-title compact-title"> <div class="tool-section-title compact-title">
<span>STL</span> <span>STL</span>
<div class="title-actions">
<button id="selectAllStlBtn" type="button">全选</button>
<button id="invertStlBtn" type="button">反选</button>
</div>
<em id="stlCount">0</em> <em id="stlCount">0</em>
</div> </div>
<div id="modelRail" class="chip-rail"></div> <div id="modelRail" class="chip-rail"></div>

View File

@@ -499,30 +499,49 @@ button {
.series-card { .series-card {
position: relative; position: relative;
padding-right: 48px; padding-right: 48px;
cursor: pointer;
} }
.series-check { .series-check {
position: absolute; position: absolute;
top: 13px; top: 13px;
right: 13px; right: 13px;
width: 26px; width: 30px;
height: 26px; height: 30px;
display: inline-flex !important; display: inline-flex !important;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0 !important; 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; border-radius: 999px;
background: rgba(20, 184, 166, 0.16); background: rgba(13, 148, 136, 0.22);
color: #ccfbf1 !important; color: #ccfbf1 !important;
font-size: 15px !important; font-size: 15px !important;
font-weight: 950 !important; font-weight: 950 !important;
line-height: 1; 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 { .series-card:not(.registration-selected) .series-check {
border-color: rgba(148, 163, 184, 0.2); border-color: rgba(148, 163, 184, 0.42);
background: rgba(148, 163, 184, 0.06); 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, .case-card.active,
@@ -532,6 +551,10 @@ button {
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96)); 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 { .series-card.skipped {
opacity: 0.54; opacity: 0.54;
filter: grayscale(0.95); filter: grayscale(0.95);
@@ -754,11 +777,27 @@ button {
} }
.tool-section-title button { .tool-section-title button {
border: 0; border: 1px solid rgba(45, 212, 191, 0.34);
background: transparent; border-radius: 8px;
color: #c4d7ee; background: rgba(8, 17, 26, 0.62);
color: #d9fbff;
font-size: 12px; font-size: 12px;
font-weight: 900; 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 { .tool-section-title em {