6 Commits

Author SHA1 Message Date
Codex
5dc20cef56 Use fixed bright bone reward 2026-05-31 01:58:57 +08:00
Codex
2fa26320bc Add activated bone brightness scoring 2026-05-31 01:40:41 +08:00
Codex
747dd97949 Tighten bright bone scoring 2026-05-31 01:38:24 +08:00
Codex
e2be49677f Compact STL list and clarify auto iteration 2026-05-31 01:27:32 +08:00
Codex
5cac15fbed Group liver STL models by anatomy 2026-05-31 01:03:50 +08:00
Codex
b13715ac4e Refine STL visibility and pose panels 2026-05-30 16:51:44 +08:00
3 changed files with 771 additions and 123 deletions

View File

@@ -65,6 +65,56 @@ const AUTO_PROGRESS_STEPS = [
{ id: "score", label: "评分" },
{ id: "apply", label: "应用" },
];
const BONE_BRIGHT_THRESHOLD = 185;
const BONE_EDGE_THRESHOLD = 165;
const BONE_FIXED_REWARD = 1;
const BONE_EDGE_REWARD = 0.22;
const STL_DISPLAY_MODES = ["hidden", "solid", "translucent"];
const STL_DISPLAY_LABELS = {
hidden: "取消显示",
solid: "实体显示",
translucent: "半透明",
};
const LIVER_STL_GROUPS = [
{ key: "liver_segments", label: "肝段", order: 10 },
{ key: "liver_body", label: "肝脏", order: 20 },
{ key: "liver_vessels", label: "肝血管", order: 30 },
{ key: "liver_lesions", label: "肿瘤和囊肿", order: 40 },
{ key: "body_surface", label: "骨骼皮肤", order: 50 },
{ key: "other", label: "其他", order: 90 },
];
const LIVER_STL_GROUP_LOOKUP = Object.fromEntries(LIVER_STL_GROUPS.map((group) => [group.key, group]));
const LIVER_STL_LABELS = {
bile_duct: "胆管",
cholecyst: "胆囊",
gallbladder: "胆囊",
hipbone: "髋骨",
liver: "肝脏整体",
liver_artery: "肝动脉",
liver_left: "左肝叶",
liver_right: "右肝叶",
liver_vein: "肝静脉",
pancreas: "胰腺",
portal_vein: "门静脉",
rib: "肋骨",
ribs: "肋骨",
sacrum: "骶骨",
skin: "皮肤",
spleen: "脾脏",
sternum: "胸骨",
vertebra: "椎骨",
vertebrae: "椎骨",
};
const ROMAN_NUMERALS = {
1: "I",
2: "II",
3: "III",
4: "IV",
5: "V",
6: "VI",
7: "VII",
8: "VIII",
};
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
@@ -92,6 +142,8 @@ const state = {
registrationSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
stlDisplayModes: {},
collapsedStlFamilies: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
@@ -109,6 +161,7 @@ const state = {
autoDeltaBaseline: null,
autoFineRunning: false,
poseControlsLocked: false,
collapsedPoseSections: { manual: false, auto: false },
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
@@ -533,7 +586,7 @@ function stlSelectionSignature() {
}
function visibleModelRecords() {
return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
return state.modelMeshes.filter((record) => stlMode(record.file) !== "hidden" && record.mesh?.visible !== false);
}
function modelPoseMatrix() {
@@ -609,6 +662,8 @@ function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.stlDisplayModes = {};
state.collapsedStlFamilies = new Set();
state.selectedSeriesUid = "";
state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
@@ -662,6 +717,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
state.stlDisplayModes = initStlDisplayModes(state.stlFiles, state.selectedStlIds, registration.module_styles || {});
state.collapsedStlFamilies = new Set();
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;
@@ -728,6 +785,237 @@ function pickDefaultStlIds(files) {
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function normalizedStlId(fileOrId) {
const id = typeof fileOrId === "object" ? Number(fileOrId?.id) : Number(fileOrId);
return Number.isFinite(id) ? id : null;
}
function validStlDisplayMode(mode) {
return STL_DISPLAY_MODES.includes(mode) ? mode : "";
}
function stlMode(fileOrId) {
const id = normalizedStlId(fileOrId);
if (id == null || !state.selectedStlIds.has(id)) return "hidden";
return validStlDisplayMode(state.stlDisplayModes[id]) || "translucent";
}
function stlFamilyLabel(file) {
return String(file.family || "未分类");
}
function normalizedStlText(file) {
return [
file.family,
file.segment_name,
file.file_name,
file.category,
].map((value) => String(value || "").toLowerCase()).join(" ");
}
function normalizedStlName(file) {
return String(file.segment_name || file.family || file.file_name || "")
.replace(/\.stl$/i, "")
.toLowerCase();
}
function stlSegmentNumber(file) {
const text = normalizedStlText(file);
const match = text.match(/(?:segment[_\s-]*s?|肝段)\s*([0-9]+)/i) || text.match(/_s([0-9]+)/i);
return match ? Number(match[1]) : 999;
}
function lesionNumber(file) {
const text = normalizedStlText(file);
const match = text.match(/(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion|cyst)[_\s-]*([0-9]+)/i)
|| text.match(/(?:肿瘤|囊肿)[_\s-]*([0-9]+)/i);
return match ? Number(match[1]) : 99;
}
function isTumorStl(file, category = String(file.category || "")) {
const text = normalizedStlText(file);
return /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion)(?:[_\s-]|\d|$)/i.test(text) || category.includes("肿瘤");
}
function isCystStl(file, category = String(file.category || "")) {
const text = normalizedStlText(file);
return /(?:^|[_\s-])(?:liver[_\s-]*)?cyst(?:[_\s-]|\d|$)/i.test(text) || category.includes("囊肿");
}
function numberedLesionLabel(file, baseLabel) {
const number = lesionNumber(file);
return number < 99 ? `${baseLabel} ${number}` : baseLabel;
}
function liverStlPresentation(file) {
const text = normalizedStlText(file);
const name = normalizedStlName(file);
const category = String(file.category || "");
let groupKey = "other";
let itemOrder = 900;
let label = LIVER_STL_LABELS[name] || file.segment_name || file.family || file.file_name;
if (text.includes("liver_segment") || category.includes("肝段")) {
const segment = stlSegmentNumber(file);
groupKey = "liver_segments";
itemOrder = segment;
label = `肝段 ${ROMAN_NUMERALS[segment] || segment}`;
} else if (["liver", "liver_left", "liver_right"].includes(name) || category.includes("肝脏主体")) {
groupKey = "liver_body";
itemOrder = { liver: 1, liver_left: 2, liver_right: 3 }[name] || 99;
} else if (["liver_artery", "liver_vein", "portal_vein"].includes(name)) {
groupKey = "liver_vessels";
itemOrder = { liver_artery: 1, liver_vein: 2, portal_vein: 3 }[name] || 99;
} else if (isTumorStl(file, category) || isCystStl(file, category)) {
const tumor = isTumorStl(file, category);
groupKey = "liver_lesions";
itemOrder = (tumor ? 1 : 20) + lesionNumber(file) / 100;
label = tumor
? numberedLesionLabel(file, "肝脏肿瘤")
: numberedLesionLabel(file, "肝囊肿");
} else if (category.includes("体表骨骼") || ["hipbone", "rib", "ribs", "sacrum", "skin", "sternum", "vertebra", "vertebrae"].includes(name)) {
groupKey = "body_surface";
itemOrder = { vertebrae: 1, vertebra: 1, rib: 2, ribs: 2, sternum: 3, sacrum: 4, hipbone: 5, skin: 6 }[name] || 99;
} else {
itemOrder = { bile_duct: 1, cholecyst: 2, gallbladder: 2, pancreas: 3, spleen: 4 }[name] || 99;
}
const group = LIVER_STL_GROUP_LOOKUP[groupKey] || LIVER_STL_GROUP_LOOKUP.other;
return {
key: group.key,
label: group.label,
order: group.order,
itemOrder,
itemLabel: label,
hierarchy: true,
};
}
function stlPresentation(file) {
if (isLiverBiliaryModel(file.algorithm_model || state.algorithmModel)) return liverStlPresentation(file);
const label = stlFamilyLabel(file);
return {
key: label,
label,
order: 100,
itemOrder: 100,
itemLabel: file.segment_name || file.file_name,
hierarchy: false,
};
}
function compactStlName(file) {
return String(file.segment_name || file.family || file.file_name || `STL_${file.id || ""}`)
.replace(/\.stl$/i, "");
}
function stlFileIndex(file) {
const id = normalizedStlId(file);
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
return index >= 0 ? index : 0;
}
function stlColor(file, fallbackIndex = 0) {
const index = stlFileIndex(file);
return cssColor(index >= 0 ? index : fallbackIndex);
}
function syncStlDisplayModesForSelection(defaultMode = "translucent") {
const next = {};
state.stlFiles.forEach((file) => {
const id = normalizedStlId(file);
if (id == null || !state.selectedStlIds.has(id)) return;
next[id] = validStlDisplayMode(state.stlDisplayModes[id]) || defaultMode;
});
state.stlDisplayModes = next;
}
function initStlDisplayModes(files, selectedIds, moduleStyles = {}) {
const modes = {};
files.forEach((file) => {
const id = normalizedStlId(file);
if (id == null || !selectedIds.has(id)) return;
const style = moduleStyles?.[file.file_name] || moduleStyles?.[file.segment_name] || {};
const savedMode = validStlDisplayMode(style.mode);
if (savedMode && savedMode !== "hidden") {
modes[id] = savedMode;
return;
}
modes[id] = Number(style.opacity) >= 0.95 ? "solid" : "translucent";
});
return modes;
}
async function setStlMode(fileOrId, mode) {
const id = normalizedStlId(fileOrId);
const nextMode = validStlDisplayMode(mode);
if (id == null || !nextMode) return;
if (nextMode === "hidden") {
state.selectedStlIds.delete(id);
delete state.stlDisplayModes[id];
} else {
state.selectedStlIds.add(id);
state.stlDisplayModes[id] = nextMode;
}
await applyStlSelectionChange();
}
async function cycleStlMode(file) {
const next = {
hidden: "solid",
solid: "translucent",
translucent: "hidden",
}[stlMode(file)] || "translucent";
await setStlMode(file, next);
}
async function setAllStlMode(mode) {
const nextMode = validStlDisplayMode(mode);
if (!state.stlFiles.length || !nextMode) return;
if (nextMode === "hidden") {
state.selectedStlIds = new Set();
state.stlDisplayModes = {};
} else {
state.selectedStlIds = new Set(state.stlFiles.map((file) => normalizedStlId(file)).filter((id) => id != null));
state.stlDisplayModes = {};
state.selectedStlIds.forEach((id) => {
state.stlDisplayModes[id] = nextMode;
});
}
await applyStlSelectionChange();
}
function aggregateStlMode() {
if (!state.stlFiles.length || !state.selectedStlIds.size) return "hidden";
const modes = state.stlFiles.map((file) => stlMode(file));
if (modes.every((mode) => mode === "solid")) return "solid";
if (modes.every((mode) => mode === "translucent")) return "translucent";
if (modes.every((mode) => mode === "hidden")) return "hidden";
return "mixed";
}
function renderStlVisibilityAction() {
const button = $("cycleAllStlModeBtn");
if (!button) return;
const mode = aggregateStlMode();
button.classList.remove("hidden-eye", "solid-eye", "translucent-eye", "mixed-eye");
button.classList.add(`${mode === "mixed" ? "mixed" : mode}-eye`);
const label = {
hidden: "当前全部隐藏,点击切换为全部实体",
solid: "当前全部实体,点击切换为全部半透明",
translucent: "当前全部半透明,点击切换为全部隐藏",
mixed: "当前为混合显示,点击切换为全部实体",
}[mode];
button.title = label;
button.setAttribute("aria-label", label);
}
async function cycleAllStlMode() {
const mode = aggregateStlMode();
const next = mode === "solid" ? "translucent" : mode === "translucent" ? "hidden" : "solid";
await setAllStlMode(next);
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
@@ -829,56 +1117,105 @@ async function selectSeries(uid, options = {}) {
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length;
$("stlCount").textContent = state.stlFiles.length ? `${state.selectedStlIds.size}/${state.stlFiles.length}` : "0";
renderStlVisibilityAction();
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `<span class="chip active">${escapeHtml(model)}</span>`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(file.file_name)}">
<input type="checkbox" data-stl="${Number(file.id)}" ${checked ? "checked" : ""} />
<span>
<strong>${escapeHtml(file.segment_name || file.file_name)}</strong>
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
<small style="color:${cssColor(index)}">${escapeHtml(file.file_name)}</small>
</span>
</label>
`;
})
.join("")
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
await applyStlSelectionChange();
if (!state.stlFiles.length) {
$("stlList").innerHTML = `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
renderAutoBoneOptions();
return;
}
const grouped = new Map();
state.stlFiles.forEach((file, index) => {
const presentation = stlPresentation(file);
if (!grouped.has(presentation.key)) {
grouped.set(presentation.key, {
key: presentation.key,
label: presentation.label,
order: presentation.order,
hierarchy: presentation.hierarchy,
rows: [],
});
}
grouped.get(presentation.key).rows.push({ file, index, presentation });
});
$("stlList").innerHTML = [...grouped.values()]
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
.map((group) => {
const rows = group.rows.sort((a, b) => (
a.presentation.itemOrder - b.presentation.itemOrder
|| String(a.presentation.itemLabel).localeCompare(String(b.presentation.itemLabel))
|| a.index - b.index
));
const visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length;
const collapsed = state.collapsedStlFamilies.has(group.key);
return `
<section class="stl-family ${group.hierarchy ? "stl-hierarchy-group" : ""} ${collapsed ? "collapsed" : ""}">
<button class="stl-family-head" type="button" data-family-toggle="${escapeHtml(group.key)}" title="收起/展开 ${escapeHtml(group.label)}">
<span>${escapeHtml(group.label)}</span>
<em>${visibleCount}/${rows.length}</em>
<i></i>
</button>
<div class="stl-family-list">
${rows.map(({ file, index, presentation }) => {
const id = normalizedStlId(file);
const mode = stlMode(file);
const color = stlColor(file, index);
return `
<button class="stl-row stl-mode-${mode} ${mode !== "hidden" ? "active" : ""}" type="button" data-stl-mode="${id}" style="--stl-color:${escapeHtml(color)}" title="${escapeHtml(file.file_name)}">
<span class="stl-row-main">
<strong>${escapeHtml(compactStlName(file))}</strong>
</span>
<span class="stl-visibility-swatch ${mode}" aria-label="${escapeHtml(STL_DISPLAY_LABELS[mode])}"></span>
</button>
`;
}).join("")}
</div>
</section>
`;
}).join("");
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const groupKey = button.dataset.familyToggle;
if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey);
else state.collapsedStlFamilies.add(groupKey);
renderStl();
});
});
$("stlList").querySelectorAll("[data-stl-mode]").forEach((button) => {
button.addEventListener("click", async () => {
const id = Number(button.dataset.stlMode);
const file = state.stlFiles.find((item) => normalizedStlId(item) === id);
if (file) await cycleStlMode(file);
});
});
renderAutoBoneOptions();
}
async function selectAllStl() {
if (!state.stlFiles.length) return;
state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite));
await applyStlSelectionChange();
await setAllStlMode("translucent");
}
async function invertStlSelection() {
if (!state.stlFiles.length) return;
const next = new Set();
const nextSelected = new Set();
const nextModes = {};
state.stlFiles.forEach((file) => {
const id = Number(file.id);
if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id);
const id = normalizedStlId(file);
if (id != null && !state.selectedStlIds.has(id)) {
nextSelected.add(id);
nextModes[id] = "translucent";
}
});
state.selectedStlIds = next;
state.selectedStlIds = nextSelected;
state.stlDisplayModes = nextModes;
await applyStlSelectionChange();
}
async function applyStlSelectionChange() {
markDirty("stl");
syncStlDisplayModesForSelection();
renderStl();
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
@@ -1090,6 +1427,15 @@ function updateStretchButtons() {
});
}
function renderPoseSectionCollapse() {
const manualCollapsed = Boolean(state.collapsedPoseSections.manual);
const autoCollapsed = Boolean(state.collapsedPoseSections.auto);
$("poseManualBody")?.classList.toggle("collapsed", manualCollapsed);
$("poseAutoBody")?.classList.toggle("collapsed", autoCollapsed);
$("poseManualToggle")?.classList.toggle("collapsed", manualCollapsed);
$("poseAutoToggle")?.classList.toggle("collapsed", autoCollapsed);
}
function applyPoseLockState() {
const locked = Boolean(state.poseControlsLocked);
const selectors = [
@@ -1099,6 +1445,7 @@ function applyPoseLockState() {
"#resetRotationBtn",
"#resetTransformBtn",
"#resetFlipBtn",
"#openAutoModalBtn",
"#autoSettingsPanel input",
"#autoSettingsPanel button",
"#savePoseBtn",
@@ -1518,9 +1865,11 @@ function applyModelDetail() {
state.modelMeshes.forEach((record) => {
const material = record.mesh.material;
if (!material) return;
material.opacity = detail.opacity;
material.transparent = detail.opacity < 1;
material.depthWrite = detail.depthWrite;
const mode = stlMode(record.file);
const opacity = mode === "solid" ? 1 : Math.min(0.52, detail.opacity);
material.opacity = opacity;
material.transparent = opacity < 1;
material.depthWrite = mode === "solid" ? true : false;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
@@ -1530,9 +1879,8 @@ function applyModelDetail() {
}
function applyStlVisibility() {
const selected = state.selectedStlIds;
state.modelMeshes.forEach((record) => {
record.mesh.visible = selected.has(Number(record.file?.id));
record.mesh.visible = stlMode(record.file) !== "hidden";
});
if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible);
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
@@ -2325,8 +2673,9 @@ async function buildStlModels(volume, onProgress = null) {
const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
const color = stlColor(file, index);
const material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length],
color,
metalness: detail.metalness,
roughness: detail.roughness,
transparent: true,
@@ -2338,9 +2687,9 @@ async function buildStlModels(volume, onProgress = null) {
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
mesh.userData.file = file;
mesh.userData.color = cssColor(index);
mesh.userData.color = color;
state.rawModelGroup.add(mesh);
const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index };
const record = { mesh, file, color, index };
state.modelMeshes.push(record);
freshRecords.push(record);
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
@@ -2385,6 +2734,7 @@ async function buildStlModels(volume, onProgress = null) {
updateBaseModelScaleFromBounds();
if (size.x || size.y || size.z) addModelBoundsFrame(size);
applyStlVisibility();
applyModelDetail();
applyPose();
scheduleMappingDraw();
onProgress?.(100);
@@ -2524,6 +2874,39 @@ function readAutoSettings() {
};
}
function resetAutoSettings() {
if (state.poseControlsLocked) return;
const checkedDefaults = {
autoX: true,
autoY: true,
autoZ: true,
autoScale: true,
};
Object.entries(checkedDefaults).forEach(([id, value]) => {
const input = $(id);
if (input) input.checked = value;
});
const valueDefaults = {
autoSampleSlices: 9,
autoSampleSlicesNumber: 9,
autoBoneReward: 1,
autoOutsidePenalty: 0.1,
autoMovePenalty: 0,
autoScalePenalty: 0,
autoIterations: 30,
autoCandidates: 36,
};
Object.entries(valueDefaults).forEach(([id, value]) => {
const input = $(id);
if (input) input.value = value;
});
renderAutoBoneOptions();
state.autoResult = null;
setAutoProgress(false);
setAutoState("已恢复", "ok");
$("autoResult").textContent = "迭代参数已恢复初始设置。";
}
function poseDelta(from, to) {
return {
rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)),
@@ -2708,10 +3091,9 @@ function scorePoseAgainstBoneImages(pose, context, settings) {
continue;
}
const lum = image.data[(y * image.width + x) * 4] || 0;
if (lum >= 170) hitReward += 1;
else if (lum >= 115) hitReward += 0.45;
else if (lum >= 80) hitReward += 0.16;
else missPenalty += 0.9 - lum / 255;
if (lum >= BONE_BRIGHT_THRESHOLD) hitReward += BONE_FIXED_REWARD;
else if (lum >= BONE_EDGE_THRESHOLD) hitReward += BONE_EDGE_REWARD;
else missPenalty += 1.05 - (lum / Math.max(BONE_EDGE_THRESHOLD, 1)) * 0.28;
contributors += 1;
}
const safeContributors = Math.max(contributors, 1);
@@ -2778,6 +3160,14 @@ function candidateScore(pose, settings = null) {
return geometryScore;
}
function describeAutoReference(settings) {
const records = autoReferenceRecords(settings);
if (!records.length) return "当前没有可用参考 STL";
const names = records.slice(0, 4).map((record) => compactStlName(record.file));
const suffix = records.length > names.length ? `${records.length}` : "";
return `参考 STL${names.join("、")}${suffix}`;
}
function estimateCoarsePose(settings) {
const records = autoReferenceRecords(settings);
const modelBox = visibleModelBox(records);
@@ -2867,7 +3257,7 @@ async function runAutoFine(options = {}) {
setAutoProgress(true, 22, "采样参考 STL", "正在从勾选的参考 STL 和骨窗切片中构建评分样本。", "sample");
settings.imageContext = await prepareAutoFineImageContext(settings);
const imageContextText = settings.imageContext
? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片。`
? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片;仅高亮骨性像素参与正向奖励`
: "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。";
setAutoProgress(true, 38, "建立候选队列", imageContextText, "score");
await waitForUiFrame();
@@ -2875,6 +3265,7 @@ async function runAutoFine(options = {}) {
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const coarsePose = estimateCoarsePose(settings);
const coarseSeed = coarsePose ? { pose: coarsePose, mode: "粗配准种子" } : null;
if (coarsePose) {
const coarseScore = candidateScore(coarsePose, settings);
evaluated += 1;
@@ -2897,27 +3288,40 @@ async function runAutoFine(options = {}) {
const nextValue = key === "scale" ? Math.max(0.2, Math.min(3, baseValue + delta)) : baseValue + delta;
return { ...sourcePose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) };
};
enabledKeys.forEach((key) => {
candidates.push({ pose: poseWithDelta(best.pose, key, stepForKey(key)), mode: `${key} +` });
candidates.push({ pose: poseWithDelta(best.pose, key, -stepForKey(key)), mode: `${key} -` });
});
for (let left = 0; left < enabledKeys.length; left += 1) {
for (let right = left + 1; right < enabledKeys.length; right += 1) {
[-1, 1].forEach((leftSign) => {
[-1, 1].forEach((rightSign) => {
const leftKey = enabledKeys[left];
const rightKey = enabledKeys[right];
const pose = poseWithDelta(
poseWithDelta(best.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign),
rightKey,
stepForKey(rightKey, 0.65) * rightSign,
);
candidates.push({ pose, mode: `${leftKey}/${rightKey} 联合` });
const searchBases = [{ pose: best.pose, mode: best.mode }];
if (coarseSeed && poseSignature(coarseSeed.pose) !== poseSignature(best.pose)) searchBases.push(coarseSeed);
const seenCandidates = new Set();
const addCandidate = (pose, mode) => {
const signature = poseSignature(pose);
if (seenCandidates.has(signature)) return;
seenCandidates.add(signature);
candidates.push({ pose, mode });
};
searchBases.forEach((base) => {
enabledKeys.forEach((key) => {
addCandidate(poseWithDelta(base.pose, key, stepForKey(key)), `${base.mode} · ${key} +`);
addCandidate(poseWithDelta(base.pose, key, -stepForKey(key)), `${base.mode} · ${key} -`);
});
for (let left = 0; left < enabledKeys.length; left += 1) {
for (let right = left + 1; right < enabledKeys.length; right += 1) {
[-1, 1].forEach((leftSign) => {
[-1, 1].forEach((rightSign) => {
const leftKey = enabledKeys[left];
const rightKey = enabledKeys[right];
const pose = poseWithDelta(
poseWithDelta(base.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign),
rightKey,
stepForKey(rightKey, 0.65) * rightSign,
);
addCandidate(pose, `${base.mode} · ${leftKey}/${rightKey} 联合`);
});
});
});
}
}
}
for (const candidate of candidates.slice(0, settings.candidates)) {
});
const candidateLimit = Math.max(settings.candidates, settings.candidates * searchBases.length);
const candidatesToEvaluate = candidates.slice(0, candidateLimit);
for (const candidate of candidatesToEvaluate) {
const pose = candidate.pose;
const score = candidateScore(pose, settings);
evaluated += 1;
@@ -2925,12 +3329,12 @@ async function runAutoFine(options = {}) {
}
const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48;
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidates.length} 个候选,累计 ${evaluated} 个;当前最 ${best.mode}score ${scoreText}`, "score");
setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidatesToEvaluate.length}/${candidates.length} 个候选,累计 ${evaluated} 个;当前最高分候选 ${best.mode}score ${scoreText}`, "score");
await waitForUiFrame();
}
setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply");
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone" } };
state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "fixed-bright-bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone", boneThreshold: BONE_BRIGHT_THRESHOLD, boneReward: BONE_FIXED_REWARD } };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
renderPoseControls();
@@ -2939,11 +3343,12 @@ async function runAutoFine(options = {}) {
const changed = poseSignature(best.pose) !== poseSignature(before);
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
const referenceText = describeAutoReference(settings);
$("autoResult").textContent = changed
? `最佳候选:${best.mode}score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? "骨窗像素采样 + STL 参考评分" : "参考 STL 盒体评分"}`
: `已完成评估但最佳仍为初始位姿score ${scoreText};评估 ${evaluated} 个候选。说明本轮候选没有比当前位姿更高分;若图像上仍错位,通常需要先调整大角度旋转或粗配准后再微调`;
? `本轮最高分候选:${best.mode}score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? `骨窗固定命中奖励,亮度 ≥ ${BONE_BRIGHT_THRESHOLD} 统一计为骨骼命中` : "参考 STL 盒体评分"}${referenceText}`
: `本轮未找到比当前位姿更高分的候选score ${scoreText};评估 ${evaluated} 个候选。这不代表已配准正确,只说明当前搜索范围和参考 STL 下没有更高分;${referenceText};自动微调目前只调整平移/缩放,明显旋转错位需要先手动校正`;
updateAutoPoseResult(before, state.pose);
setAutoProgress(true, 100, changed ? "微调完成" : "未找到更优候选", changed ? `已应用 ${best.mode}` : "候选均未超过当前位姿,因此没有强行移动模型。", "apply");
setAutoProgress(true, 100, changed ? "微调完成" : "本轮未改动", changed ? `已应用 ${best.mode}` : "没有强行移动模型;请检查参考 STL 和大角度旋转。", "apply");
markDirty();
} catch (error) {
setAutoState("微调失败", "error");
@@ -2999,7 +3404,15 @@ async function saveRegistration(nextStatus = null) {
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])),
module_styles: Object.fromEntries(selectedFiles.map((file, index) => {
const mode = stlMode(file);
return [file.file_name, {
color: stlColor(file, index),
opacity: mode === "solid" ? 1 : 0.52,
detail: state.modelDetail,
mode,
}];
})),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
@@ -3128,17 +3541,22 @@ 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);
$("cycleAllStlModeBtn").addEventListener("click", cycleAllStlMode);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("poseManualToggle").addEventListener("click", () => {
state.collapsedPoseSections.manual = !state.collapsedPoseSections.manual;
renderPoseSectionCollapse();
});
$("poseAutoToggle").addEventListener("click", () => {
state.collapsedPoseSections.auto = !state.collapsedPoseSections.auto;
renderPoseSectionCollapse();
});
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("openAutoModalBtn").addEventListener("click", () => {
$("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" });
});
$("openAutoModalBtn").addEventListener("click", resetAutoSettings);
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn")?.addEventListener("click", runAutoCoarse);
$("autoFineBtn")?.addEventListener("click", runAutoFine);
@@ -3308,6 +3726,7 @@ function wireEvents() {
}
wireEvents();
renderPoseSectionCollapse();
if (state.token) {
bootstrap();

View File

@@ -169,9 +169,8 @@
</div>
<div class="tool-section-title compact-title">
<span>STL</span>
<div class="title-actions">
<button id="selectAllStlBtn" type="button">全选</button>
<button id="invertStlBtn" type="button">反选</button>
<div class="title-actions model-visibility-actions">
<button id="cycleAllStlModeBtn" class="model-eye-btn" type="button" title="切换全部 STL 显示状态" aria-label="切换全部 STL 显示状态"><span></span></button>
</div>
<em id="stlCount">0</em>
</div>
@@ -182,26 +181,30 @@
<section class="tool-pane" data-tool-pane="pose">
<div class="tool-section-title">
<span>位姿手动调整</span>
<button id="poseManualToggle" class="section-toggle" type="button" title="收起/展开位姿手动调整" aria-label="收起或展开位姿手动调整"><span></span></button>
<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 id="poseManualBody" class="pose-section-body">
<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>
<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>
<button id="poseAutoToggle" class="section-toggle" type="button" title="收起/展开位姿自动调整" aria-label="收起或展开位姿自动调整"><span></span></button>
<em id="autoState">未运行</em>
</div>
<div class="auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">自动调整设置</button>
<div id="poseAutoBody" class="pose-section-body auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">恢复初始设置</button>
<div id="autoSettingsPanel" class="auto-settings-panel">
<div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
@@ -228,6 +231,7 @@
</div>
</section>
</div>
<div class="iteration-section-title">迭代参数</div>
<div class="auto-weight-grid">
<label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
@@ -236,9 +240,7 @@
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label>
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div>
<div class="registration-operation-hint">
下方为配准操作区:保存当前位姿、保存并迭代自动微调,或退回上次自动调整前位姿。
</div>
<div class="iteration-section-title">迭代操作区</div>
<div class="pose-actions modal-actions save-pose-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>

View File

@@ -812,6 +812,98 @@ button {
margin-left: auto;
}
.model-visibility-actions {
gap: 5px;
}
.tool-section-title button.model-eye-btn {
width: 34px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.model-eye-btn span {
position: relative;
width: 18px;
height: 13px;
display: block;
}
.model-eye-btn span::before {
content: "";
position: absolute;
inset: 1px 0;
border: 2px solid #c8f7ff;
border-radius: 50% / 60%;
}
.model-eye-btn span::after {
content: "";
position: absolute;
top: 5px;
left: 7px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #c8f7ff;
}
.solid-eye span::before {
background: rgba(45, 212, 191, 0.28);
}
.translucent-eye span::before {
background:
linear-gradient(45deg, rgba(15, 23, 42, 0.82) 25%, transparent 25% 50%, rgba(15, 23, 42, 0.82) 50% 75%, transparent 75%) 0 0 / 5px 5px,
rgba(45, 212, 191, 0.22);
}
.mixed-eye span::before {
border-color: #93c5fd;
background: linear-gradient(90deg, rgba(45, 212, 191, 0.26) 0 50%, rgba(15, 23, 42, 0.74) 50% 100%);
}
.mixed-eye span::after {
background: #93c5fd;
}
.hidden-eye span::after {
top: 5px;
left: -2px;
width: 22px;
height: 2px;
border-radius: 99px;
background: #cbd5e1;
transform: rotate(-38deg);
}
.section-toggle {
width: 30px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0 !important;
}
.section-toggle span {
width: 8px;
height: 8px;
border-right: 2px solid #c8f7ff;
border-bottom: 2px solid #c8f7ff;
transform: rotate(45deg);
transition: transform 0.16s ease;
}
.section-toggle.collapsed span {
transform: rotate(-45deg);
}
.tool-section-title em {
color: var(--muted);
font-size: 12px;
@@ -1486,17 +1578,18 @@ button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
justify-content: flex-start;
gap: 0;
border-left: 1px solid rgba(148, 163, 184, 0.18);
background: #0f172a;
padding: 12px 0 38px;
}
.mapping-slice-rail::before {
content: "";
position: absolute;
top: 26px;
bottom: 56px;
top: 12px;
bottom: 38px;
left: 50%;
width: 8px;
transform: translateX(-50%);
@@ -1507,15 +1600,19 @@ button {
#mappingSliceSlider {
position: relative;
z-index: 1;
width: min(66vh, 520px);
height: 34px;
transform: rotate(-90deg);
width: 34px;
height: 100%;
min-height: 180px;
transform: none;
writing-mode: vertical-lr;
direction: rtl;
appearance: none;
background: transparent;
}
#mappingSliceSlider::-webkit-slider-runnable-track {
height: 8px;
width: 8px;
height: 100%;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
}
@@ -1524,7 +1621,8 @@ button {
appearance: none;
width: 25px;
height: 25px;
margin-top: -8.5px;
margin-left: -8.5px;
margin-top: 0;
border: 3px solid #93c5fd;
border-radius: 7px;
background: #60a5fa;
@@ -1532,7 +1630,8 @@ button {
}
#mappingSliceSlider::-moz-range-track {
height: 8px;
width: 8px;
height: 100%;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
}
@@ -1711,26 +1810,154 @@ input[type="range"] {
.stl-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 10px;
grid-template-columns: minmax(0, 1fr) 30px;
gap: 12px;
align-items: center;
min-height: 38px;
padding: 7px 10px;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, opacity 0.16s ease;
}
.stl-row input {
margin-top: 3px;
accent-color: var(--cyan);
.stl-row.stl-mode-hidden {
opacity: 0.62;
background: rgba(8, 12, 18, 0.78);
}
.stl-row strong {
display: block;
overflow: hidden;
color: #e8f1fb;
font-size: 13px;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stl-file-name {
color: var(--stl-color) !important;
}
.stl-family {
margin-bottom: 7px;
}
.stl-family-head {
width: 100%;
min-height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 14px;
align-items: center;
gap: 8px;
margin: 0 0 7px;
border: 1px solid rgba(45, 212, 191, 0.35);
border-radius: 10px;
background: rgba(20, 184, 166, 0.08);
color: #dffcff;
font-size: 12px;
font-weight: 950;
text-align: left;
padding: 0 10px;
cursor: pointer;
}
.stl-family-head span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stl-family-head em {
color: #9debf4;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-style: normal;
}
.stl-family-head i {
width: 8px;
height: 8px;
border-right: 2px solid #9debf4;
border-bottom: 2px solid #9debf4;
transform: rotate(45deg);
transition: transform 0.16s ease;
}
.stl-family.collapsed .stl-family-list {
display: none;
}
.stl-family.collapsed .stl-family-head i {
transform: rotate(-45deg);
}
.stl-hierarchy-group {
margin-bottom: 8px;
}
.stl-hierarchy-group .stl-family-head {
min-height: 32px;
margin-bottom: 5px;
border-color: rgba(148, 163, 184, 0.22);
border-radius: 7px;
background: linear-gradient(180deg, rgba(31, 41, 59, 0.94), rgba(15, 23, 42, 0.94));
color: #eef7ff;
}
.stl-hierarchy-group:not(.collapsed) .stl-family-head {
border-color: rgba(45, 212, 191, 0.58);
box-shadow: inset 3px 0 0 rgba(45, 212, 191, 0.85);
}
.stl-hierarchy-group .stl-family-list {
margin: -2px 0 8px 12px;
padding-left: 8px;
border-left: 1px solid rgba(45, 212, 191, 0.22);
}
.stl-hierarchy-group .stl-row {
min-height: 38px;
margin-bottom: 5px;
border-radius: 8px;
}
.stl-visibility-swatch {
width: 22px;
height: 22px;
justify-self: end;
border: 2px solid rgba(226, 232, 240, 0.68);
border-radius: 5px;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(2, 6, 23, 0.78);
}
.stl-visibility-swatch.solid {
border-color: color-mix(in srgb, var(--stl-color) 78%, #ffffff 22%);
background: var(--stl-color);
}
.stl-visibility-swatch.translucent {
border-color: color-mix(in srgb, var(--stl-color) 72%, #ffffff 28%);
background:
linear-gradient(45deg, rgba(2, 6, 23, 0.82) 25%, transparent 25% 50%, rgba(2, 6, 23, 0.82) 50% 75%, transparent 75%) 0 0 / 8px 8px,
color-mix(in srgb, var(--stl-color) 48%, transparent);
}
.stl-visibility-swatch.hidden {
border-color: rgba(148, 163, 184, 0.66);
background: rgba(15, 23, 42, 0.5);
}
.stl-visibility-swatch.hidden::after {
content: "";
display: block;
width: 24px;
height: 2px;
margin: 8px 0 0 -3px;
border-radius: 99px;
background: rgba(226, 232, 240, 0.76);
transform: rotate(-45deg);
}
.pose-grid {
display: grid;
gap: 10px;
@@ -1830,6 +2057,18 @@ input[type="range"] {
gap: 8px;
}
.pose-section-body.collapsed {
display: none;
}
.iteration-section-title {
margin: 2px 0 -2px;
color: #9debf4;
font-size: 12px;
font-weight: 950;
letter-spacing: 0;
}
.auto-settings-panel {
display: grid;
gap: 10px;
@@ -2039,18 +2278,6 @@ input[type="range"] {
margin: 12px 0 0;
}
.registration-operation-hint {
border: 1px solid rgba(25, 214, 195, 0.26);
border-radius: 8px;
background: rgba(20, 184, 166, 0.08);
color: #a7f3d0;
font-size: 11px;
font-weight: 900;
line-height: 1.45;
margin-top: 12px;
padding: 9px 10px;
}
.auto-config-grid {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(300px, 1.15fr);