Group liver STL models by anatomy

This commit is contained in:
Codex
2026-05-31 01:03:50 +08:00
parent b13715ac4e
commit 5cac15fbed
2 changed files with 221 additions and 36 deletions

View File

@@ -71,6 +71,46 @@ const STL_DISPLAY_LABELS = {
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") || "",
@@ -760,6 +800,106 @@ 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 stlFileIndex(file) {
const id = normalizedStlId(file);
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
@@ -947,29 +1087,44 @@ function renderStl() {
}
const grouped = new Map();
state.stlFiles.forEach((file, index) => {
const family = stlFamilyLabel(file);
if (!grouped.has(family)) grouped.set(family, []);
grouped.get(family).push({ 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: [],
});
$("stlList").innerHTML = [...grouped.entries()].map(([family, 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(family);
const collapsed = state.collapsedStlFamilies.has(group.key);
return `
<section class="stl-family ${collapsed ? "collapsed" : ""}">
<button class="stl-family-head" type="button" data-family-toggle="${escapeHtml(family)}" title="收起/展开 ${escapeHtml(family)}">
<span>${escapeHtml(family)}</span>
<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 }) => {
${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(file.segment_name || file.file_name)}</strong>
<strong>${escapeHtml(presentation.itemLabel || file.segment_name || file.file_name)}</strong>
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
<small class="stl-file-name">${escapeHtml(file.file_name)}</small>
</span>
@@ -983,9 +1138,9 @@ function renderStl() {
}).join("");
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const family = button.dataset.familyToggle;
if (state.collapsedStlFamilies.has(family)) state.collapsedStlFamilies.delete(family);
else state.collapsedStlFamilies.add(family);
const groupKey = button.dataset.familyToggle;
if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey);
else state.collapsedStlFamilies.add(groupKey);
renderStl();
});
});

View File

@@ -1880,6 +1880,36 @@ input[type="range"] {
transform: rotate(-45deg);
}
.stl-hierarchy-group {
margin-bottom: 8px;
}
.stl-hierarchy-group .stl-family-head {
min-height: 36px;
margin-bottom: 6px;
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 10px 12px;
padding-left: 10px;
border-left: 1px solid rgba(45, 212, 191, 0.22);
}
.stl-hierarchy-group .stl-row {
min-height: 58px;
margin-bottom: 7px;
border-radius: 10px;
}
.stl-visibility-swatch {
width: 24px;
height: 24px;