Group liver STL models by anatomy
This commit is contained in:
@@ -71,6 +71,46 @@ const STL_DISPLAY_LABELS = {
|
|||||||
solid: "实体显示",
|
solid: "实体显示",
|
||||||
translucent: "半透明",
|
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 = {
|
const state = {
|
||||||
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
||||||
@@ -760,6 +800,106 @@ function stlFamilyLabel(file) {
|
|||||||
return String(file.family || "未分类");
|
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) {
|
function stlFileIndex(file) {
|
||||||
const id = normalizedStlId(file);
|
const id = normalizedStlId(file);
|
||||||
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
|
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
|
||||||
@@ -947,29 +1087,44 @@ function renderStl() {
|
|||||||
}
|
}
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
state.stlFiles.forEach((file, index) => {
|
state.stlFiles.forEach((file, index) => {
|
||||||
const family = stlFamilyLabel(file);
|
const presentation = stlPresentation(file);
|
||||||
if (!grouped.has(family)) grouped.set(family, []);
|
if (!grouped.has(presentation.key)) {
|
||||||
grouped.get(family).push({ file, index });
|
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 visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length;
|
||||||
const collapsed = state.collapsedStlFamilies.has(family);
|
const collapsed = state.collapsedStlFamilies.has(group.key);
|
||||||
return `
|
return `
|
||||||
<section class="stl-family ${collapsed ? "collapsed" : ""}">
|
<section class="stl-family ${group.hierarchy ? "stl-hierarchy-group" : ""} ${collapsed ? "collapsed" : ""}">
|
||||||
<button class="stl-family-head" type="button" data-family-toggle="${escapeHtml(family)}" title="收起/展开 ${escapeHtml(family)}">
|
<button class="stl-family-head" type="button" data-family-toggle="${escapeHtml(group.key)}" title="收起/展开 ${escapeHtml(group.label)}">
|
||||||
<span>${escapeHtml(family)}</span>
|
<span>${escapeHtml(group.label)}</span>
|
||||||
<em>${visibleCount}/${rows.length}</em>
|
<em>${visibleCount}/${rows.length}</em>
|
||||||
<i></i>
|
<i></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="stl-family-list">
|
<div class="stl-family-list">
|
||||||
${rows.map(({ file, index }) => {
|
${rows.map(({ file, index, presentation }) => {
|
||||||
const id = normalizedStlId(file);
|
const id = normalizedStlId(file);
|
||||||
const mode = stlMode(file);
|
const mode = stlMode(file);
|
||||||
const color = stlColor(file, index);
|
const color = stlColor(file, index);
|
||||||
return `
|
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)}">
|
<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">
|
<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>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
|
||||||
<small class="stl-file-name">${escapeHtml(file.file_name)}</small>
|
<small class="stl-file-name">${escapeHtml(file.file_name)}</small>
|
||||||
</span>
|
</span>
|
||||||
@@ -983,9 +1138,9 @@ function renderStl() {
|
|||||||
}).join("");
|
}).join("");
|
||||||
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
|
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const family = button.dataset.familyToggle;
|
const groupKey = button.dataset.familyToggle;
|
||||||
if (state.collapsedStlFamilies.has(family)) state.collapsedStlFamilies.delete(family);
|
if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey);
|
||||||
else state.collapsedStlFamilies.add(family);
|
else state.collapsedStlFamilies.add(groupKey);
|
||||||
renderStl();
|
renderStl();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1880,6 +1880,36 @@ input[type="range"] {
|
|||||||
transform: rotate(-45deg);
|
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 {
|
.stl-visibility-swatch {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user