Refine registration DICOM selection and model centering
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user