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,
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 = [
`<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>`] : []),
...(registration ? [`<span class="tag">配准 ${escapeHtml(registration.description || "当前序列")}</span>`] : []),
...selectedLabels.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;
$("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("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
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) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
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>
<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">
<button class="series-check" data-registration-series="${escapeHtml(item.series_uid)}" type="button" aria-label="设为配准 DICOM">${registrationSelected ? "✓" : ""}</button>
<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>
@@ -627,20 +645,39 @@ function seriesCardHtml(item, active = false) {
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</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 (!(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"));