Refine registration series defaults and mapping controls

This commit is contained in:
Codex
2026-05-29 00:33:40 +08:00
parent c4f0fd54d6
commit f9063b674c
4 changed files with 404 additions and 70 deletions

View File

@@ -799,7 +799,9 @@ def dicom_fusion_volume(
series_uid: str,
center_index: int = 0,
window: str = "soft",
radius: int = Query(default=24, ge=4, le=48),
radius: int = Query(default=96, ge=4, le=512),
range_start: int | None = None,
range_end: int | None = None,
_: str = Depends(require_auth),
) -> dict[str, Any]:
stack_data = load_stack_data(ct_number, series_uid)
@@ -807,10 +809,17 @@ def dicom_fusion_volume(
datasets = stack_data["datasets"]
total = int(stack.shape[0])
center_index = min(max(0, center_index), total - 1)
start = max(0, center_index - radius)
end = min(total - 1, center_index + radius)
if end - start + 1 > 48:
step = int(np.ceil((end - start + 1) / 48))
if range_start is not None and range_end is not None:
start = min(max(0, int(range_start)), total - 1)
end = min(max(0, int(range_end)), total - 1)
if start > end:
start, end = end, start
else:
start = max(0, center_index - radius)
end = min(total - 1, center_index + radius)
max_frames = 72
if end - start + 1 > max_frames:
step = int(np.ceil((end - start + 1) / max_frames))
indices = list(range(start, end + 1, step))
else:
indices = list(range(start, end + 1))

View File

@@ -61,6 +61,8 @@ const state = {
windowMode: "default",
fusionMode: "fusion",
sliceIndex: 0,
sliceRangeStart: 0,
sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
dirty: false,
@@ -78,9 +80,10 @@ const state = {
modelGroup: null,
rawModelGroup: null,
modelMeshes: [],
stlSignature: "",
baseModelScale: 1,
dicomSceneBox: null,
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
resizeObserver: null,
@@ -103,9 +106,17 @@ function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
function setFusionLoading(visible, text = "正在构建融合视图") {
function setFusionLoading(visible, text = "正在构建融合视图", progress = null) {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
const fill = $("fusionProgressFill");
const label = $("fusionProgressText");
if (fill && label) {
const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0));
fill.style.width = progress == null ? "34%" : `${value}%`;
fill.classList.toggle("indeterminate", progress == null);
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
}
}
function setSaveState(text, tone = "") {
@@ -296,7 +307,30 @@ function isUpperAbdomenSeries(item) {
}
function isPortalPhaseSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期")));
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || label.includes("门静脉")));
}
function isLiverBiliaryModel(model = state.algorithmModel) {
const value = String(model || "").toLowerCase();
return value.includes("肝胆") || value.includes("liver") || value.includes("bile");
}
function sortedSeriesForDisplay(series) {
return [...series].sort((a, b) => {
const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b));
if (skippedDiff) return skippedDiff;
const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || ""));
if (timeDiff) return timeDiff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
});
}
function stlSelectionSignature() {
return [
normalize(state.activeCase?.ct_number),
state.algorithmModel || "",
[...state.selectedStlIds].map(Number).sort((a, b) => a - b).join(","),
].join("|");
}
function bodyPartTags(row) {
@@ -375,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据");
setFusionLoading(true, "正在读取 CT 关联数据", 6);
try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
@@ -398,17 +432,22 @@ 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.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || "";
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.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;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
state.stlSignature = "";
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
if (keepPreviousPose) markDirty("model");
else resetDirty();
resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
@@ -423,10 +462,9 @@ function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
const algorithm = String(state.algorithmModel || "").toLowerCase();
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
if (isLiverBiliaryModel()) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
@@ -486,7 +524,7 @@ function renderSeries() {
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
$("seriesList").innerHTML = state.series.length
? state.series
? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
.join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
@@ -520,7 +558,9 @@ async function selectSeries(uid) {
state.selectedSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
markDirty();
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
setSaveState("序列未保存", "warn");
renderSeries();
renderDicomAnnotation();
await loadFusion();
@@ -552,7 +592,7 @@ function renderStl() {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
markDirty();
setSaveState("模型未保存", "warn");
renderStl();
await loadFusion();
});
@@ -642,9 +682,19 @@ function updateSliceControl() {
const count = Number(selected?.count || 0);
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
$("sliceSlider").max = max;
$("sliceSlider").value = state.sliceIndex;
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
$("sliceRangeStart").max = max;
$("sliceRangeEnd").max = max;
$("sliceRangeStart").value = state.sliceRangeStart;
$("sliceRangeEnd").value = state.sliceRangeEnd;
$("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0";
$("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0";
$("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
@@ -795,7 +845,7 @@ async function loadFusion() {
clearFusion();
return;
}
setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
try {
const params = new URLSearchParams({
@@ -803,14 +853,17 @@ async function loadFusion() {
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
radius: "24",
range_start: String(state.sliceRangeStart),
range_end: String(state.sliceRangeEnd),
});
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
if (version !== state.fusionVersion) return;
state.volumeMeta = volume;
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
await buildDicomVolume(volume);
if (version !== state.fusionVersion) return;
await buildStlModels(volume);
setFusionLoading(true, "正在加载 STL 模型", 62);
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
@@ -819,8 +872,9 @@ async function loadFusion() {
$("fusionStatus").textContent = state.sceneReady
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`;
$("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation();
setFusionLoading(true, "正在渲染右侧分割映射", 96);
updateDicomPreview();
} catch (error) {
clearFusion();
@@ -888,8 +942,8 @@ function scheduleMappingDraw() {
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
const { scale, offsetX, offsetY } = state.mappingViewport;
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
@@ -983,22 +1037,57 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
}
});
const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity);
maskContext.putImageData(imageData, 0, 0);
context.globalAlpha = 0.45;
context.drawImage(maskCanvas, 0, 0);
context.save();
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.globalAlpha = 1;
return filled + hullPixels;
}
function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
const points = [];
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
[segment.a, segment.b].forEach((point) => {
if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) {
points.push({ x: point.x, y: point.y });
}
});
});
if (points.length < 3) return 0;
const sorted = points
.filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index)
.sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y));
if (sorted.length < 3) return 0;
const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
const lower = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop();
lower.push(point);
});
const upper = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop();
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
if (hull.length < 3) return 0;
context.save();
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)) * 0.58;
context.fillStyle = color;
context.strokeStyle = color;
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002);
context.beginPath();
hull.forEach((point, index) => {
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.closePath();
context.fill();
context.globalAlpha = Math.max(0.22, Math.min(opacity, 1));
context.stroke();
context.restore();
return filled;
return Math.max(1, Math.round(hull.length * 6));
}
function drawMappingView() {
@@ -1190,9 +1279,17 @@ async function buildDicomVolume(volume) {
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
async function buildStlModels(volume) {
async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
applyPose();
scheduleMappingDraw();
onProgress?.(100);
return;
}
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.stlSignature = signature;
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
if (!files.length) {
applyPose();
@@ -1201,6 +1298,7 @@ async function buildStlModels(volume) {
}
const loader = new STLLoader();
for (const [index, file] of files.entries()) {
onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
file_id: String(file.id),
@@ -1225,6 +1323,7 @@ async function buildStlModels(volume) {
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
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 center = new THREE.Vector3();
@@ -1232,6 +1331,7 @@ async function buildStlModels(volume) {
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
applyPose();
scheduleMappingDraw();
onProgress?.(100);
}
function applyPose(poseInput = state.pose) {
@@ -1499,6 +1599,17 @@ function openLinkedApp(kind) {
}
}
function setWindowMode(mode, reload = true) {
state.windowMode = mode || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
if (reload) loadFusion();
}
function setAutoModalVisible(visible) {
$("autoModal").classList.toggle("hidden", !visible);
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
@@ -1529,6 +1640,11 @@ function wireEvents() {
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("openAutoModalBtn").addEventListener("click", () => setAutoModalVisible(true));
$("closeAutoModalBtn").addEventListener("click", () => setAutoModalVisible(false));
$("autoModal").addEventListener("click", (event) => {
if (event.target === $("autoModal")) setAutoModalVisible(false);
});
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
@@ -1541,7 +1657,15 @@ function wireEvents() {
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 };
applyMappingTransform();
});
$("mappingRotateLeftBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90;
applyMappingTransform();
});
$("mappingRotateRightBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90;
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
@@ -1605,11 +1729,10 @@ function wireEvents() {
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", async () => {
state.windowMode = button.dataset.window || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
await loadFusion();
});
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
});
document.querySelectorAll("[data-map-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
@@ -1625,7 +1748,6 @@ function wireEvents() {
renderDicomAnnotation();
updateDicomPreview();
});
$("sliceSlider").addEventListener("change", () => loadFusion());
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
@@ -1634,7 +1756,15 @@ function wireEvents() {
renderDicomAnnotation();
updateDicomPreview();
});
$("mappingSliceSlider").addEventListener("change", () => loadFusion());
const updateRange = () => {
state.sliceRangeStart = Number($("sliceRangeStart").value || 0);
state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0);
updateSliceControl();
};
$("sliceRangeStart").addEventListener("input", updateRange);
$("sliceRangeEnd").addEventListener("input", updateRange);
$("sliceRangeStart").addEventListener("change", () => loadFusion());
$("sliceRangeEnd").addEventListener("change", () => loadFusion());
}
wireEvents();

View File

@@ -91,7 +91,7 @@
<div class="tool-tabs">
<button class="active" data-tool-tab="series">序列</button>
<button data-tool-tab="models">组织分割</button>
<button data-tool-tab="pose">标记</button>
<button data-tool-tab="pose">位姿</button>
</div>
<section class="tool-pane active" data-tool-pane="series">
@@ -146,18 +146,8 @@
<span>位姿自动调整</span>
<em id="autoState">未运行</em>
</div>
<div class="auto-box">
<div class="auto-options">
<label><input id="autoX" type="checkbox" checked /> X</label>
<label><input id="autoY" type="checkbox" checked /> Y</label>
<label><input id="autoZ" type="checkbox" checked /> Z</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="pose-actions">
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
<div class="auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">打开自动调整区域</button>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
@@ -191,12 +181,28 @@
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
<div id="fusionLoading" class="fusion-loading hidden">
<span>正在构建融合视图</span>
<div><i></i></div>
<div><i id="fusionProgressFill"></i></div>
<em id="fusionProgressText">0%</em>
</div>
</div>
<div class="slice-row">
<span id="sliceLabel">切片 0 / 0</span>
<input id="sliceSlider" type="range" min="0" max="0" value="0" />
<div class="slice-range-panel">
<div class="slice-range-head">
<strong>DICOM 切片范围</strong>
<span id="sliceRangeLabel">0 - 0 / 0</span>
</div>
<div class="dual-range">
<input id="sliceRangeStart" type="range" min="0" max="0" value="0" />
<input id="sliceRangeEnd" type="range" min="0" max="0" value="0" />
</div>
<div class="slice-range-foot">
<span id="sliceStartLabel">起点 0</span>
<span>范围</span>
<span id="sliceEndLabel">终点 0</span>
</div>
<div class="slice-current-row">
<span id="sliceLabel">切片 0 / 0</span>
<input id="sliceSlider" type="range" min="0" max="0" value="0" />
</div>
</div>
</section>
@@ -207,6 +213,14 @@
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
</div>
<div class="viewer-tools">
<div class="segmented mini-segmented">
<button data-map-window="default" class="active">默认</button>
<button data-map-window="bone">骨窗</button>
<button data-map-window="soft">软组织</button>
<button data-map-window="contrast">高对比</button>
</div>
<button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button>
<button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button>
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
</div>
@@ -243,6 +257,29 @@
</section>
</main>
<div id="autoModal" class="modal-shell hidden">
<div class="modal-panel">
<div class="modal-head">
<div>
<h2>位姿自动调整</h2>
<p>选择参与自动配准的调整方向</p>
</div>
<button id="closeAutoModalBtn" class="ghost-btn" type="button">关闭</button>
</div>
<div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
<label><input id="autoY" type="checkbox" checked /> Y 方向</label>
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="pose-actions modal-actions">
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
</div>
</div>
<script type="module" src="/static/app.js"></script>
</body>
</html>

View File

@@ -662,6 +662,12 @@ button {
overflow-x: auto;
}
.dicom-panel .viewer-head {
align-content: center;
flex-wrap: wrap;
overflow-x: auto;
}
.viewer-head h2 {
margin: 0;
font-size: 16px;
@@ -696,6 +702,11 @@ button {
color: white;
}
.mini-segmented button {
min-width: 56px;
min-height: 30px;
}
.ghost-btn,
.primary-btn,
.state-btn {
@@ -855,12 +866,36 @@ button {
.top-loading span {
display: block;
height: 100%;
width: 34%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent);
}
.fusion-loading i {
width: 0;
background: linear-gradient(90deg, var(--cyan), var(--blue));
transition: width 0.18s ease;
}
.fusion-loading i.indeterminate {
width: 34%;
animation: sweep 1.1s ease-in-out infinite;
}
.top-loading span {
width: 34%;
animation: sweep 1.1s ease-in-out infinite;
}
.fusion-loading em {
display: block;
margin-top: 6px;
color: #c8f7ff;
font-size: 11px;
font-style: normal;
font-weight: 900;
text-align: right;
}
.top-loading {
position: fixed;
top: 66px;
@@ -881,16 +916,80 @@ button {
}
}
.slice-row {
height: 48px;
.slice-range-panel {
min-height: 104px;
display: grid;
grid-template-columns: 98px minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 0 14px;
grid-template-rows: auto 26px auto 24px;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid var(--line);
}
.slice-range-head,
.slice-range-foot,
.slice-current-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.slice-range-head strong {
color: #dbeafe;
font-size: 13px;
font-weight: 950;
}
.slice-range-head span,
.slice-range-foot span,
.slice-current-row span {
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.slice-range-head span {
color: #8fb5ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.dual-range {
position: relative;
height: 26px;
}
.dual-range::before {
content: "";
position: absolute;
top: 11px;
left: 0;
right: 0;
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.22);
}
.dual-range input {
position: absolute;
inset: 0;
width: 100%;
pointer-events: none;
background: transparent;
}
.dual-range input::-webkit-slider-thumb {
pointer-events: auto;
}
.dual-range input::-moz-range-thumb {
pointer-events: auto;
}
.slice-current-row {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
}
.dicom-stage {
position: relative;
min-height: 0;
@@ -1197,7 +1296,7 @@ input[type="range"] {
.pose-control {
display: grid;
grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px;
grid-template-columns: 62px 28px minmax(96px, 1fr) 28px minmax(82px, 96px);
gap: 6px;
align-items: center;
}
@@ -1210,13 +1309,15 @@ input[type="range"] {
.pose-control input[type="number"] {
width: 100%;
height: 30px;
height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(6, 10, 15, 0.86);
color: var(--text);
text-align: right;
padding: 0 7px;
font-size: 16px;
font-weight: 900;
text-align: center;
padding: 0 6px;
}
.pose-control button,
@@ -1281,6 +1382,16 @@ input[type="range"] {
padding: 10px;
}
.compact-auto {
display: grid;
gap: 8px;
}
.wide-action {
width: 100%;
min-height: 34px;
}
.auto-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -1320,6 +1431,53 @@ input[type="range"] {
text-align: center;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 12, 0.72);
backdrop-filter: blur(8px);
}
.modal-panel {
width: min(560px, calc(100vw - 36px));
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(180deg, rgba(18, 28, 42, 0.98), rgba(8, 13, 20, 0.98));
box-shadow: var(--shadow);
padding: 16px;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.modal-head h2 {
margin: 0;
font-size: 18px;
}
.modal-head p {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
}
.modal-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.modal-actions {
margin: 12px 0 0;
}
.login-overlay {
position: fixed;
inset: 0;