From bca3619b9b8abe0dd6e0613dd479e46740a528bc Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 24 May 2026 23:13:34 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-24-22-40-13=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BE=AE=E8=B0=83=E5=8C=B9=E9=85=8D=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docker部署/README.md | 1 + WebSite/server.ts | 443 +++++++++++++- WebSite/src/App.tsx | 27 +- WebSite/src/components/AutoMatchWorkspace.tsx | 559 ++++++++++++++++++ WebSite/src/components/ReverseWorkspace.tsx | 11 + WebSite/src/components/Sidebar.tsx | 2 + WebSite/src/lib/api.ts | 12 +- WebSite/src/types.ts | 53 ++ 工程分析/实现方案-2026-05-24-22-40-13.md | 64 ++ 工程分析/测试方案-2026-05-24-22-40-13.md | 50 ++ 工程分析/经验记录.md | 18 + 工程分析/需求分析-2026-05-24-22-40-13.md | 50 ++ 12 files changed, 1286 insertions(+), 4 deletions(-) create mode 100644 WebSite/src/components/AutoMatchWorkspace.tsx create mode 100644 工程分析/实现方案-2026-05-24-22-40-13.md create mode 100644 工程分析/测试方案-2026-05-24-22-40-13.md create mode 100644 工程分析/需求分析-2026-05-24-22-40-13.md diff --git a/Docker部署/README.md b/Docker部署/README.md index 46ef0b7..4716bd1 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -20,6 +20,7 @@ - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 - 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 +- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,并可将最佳位姿写回项目库。 ## 一、本机部署 diff --git a/WebSite/server.ts b/WebSite/server.ts index f338a02..511d2c5 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -16,6 +16,7 @@ type SegmentationExportScope = 'all' | 'visible'; type SegmentationExportMode = 'combined' | 'separate'; type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high'; +type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale'; interface ModuleStyleRecord { visible: boolean; @@ -43,6 +44,33 @@ interface ModelPoseRecord { pose: ModelPoseValue; } +interface AutoMatchParameterSelection { + translateX: boolean; + translateY: boolean; + translateZ: boolean; + scale: boolean; +} + +interface AutoMatchWeights { + boneReward: number; + missPenalty: number; + movementPenalty: number; + scalePenalty: number; +} + +interface AutoMatchCandidateRecord { + iteration: number; + mode: string; + pose: ModelPoseValue; + score: number; + boneReward: number; + missPenalty: number; + movementPenalty: number; + scalePenalty: number; + contributors: number; + changed: AutoMatchParameterKey[]; +} + interface SegmentationResultRecord { id: string; schemaVersion?: number; @@ -465,7 +493,7 @@ function normalizeModelPoseValue(value: Partial | undefined): Mo translateX: read('translateX', defaultModelPose.translateX, -2, 2), translateY: read('translateY', defaultModelPose.translateY, -2, 2), translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2), - scale: read('scale', defaultModelPose.scale, 0.5, 2), + scale: read('scale', defaultModelPose.scale, 0.5, 3), flipX: readBoolean('flipX', defaultModelPose.flipX), flipY: readBoolean('flipY', defaultModelPose.flipY), flipZ: readBoolean('flipZ', defaultModelPose.flipZ), @@ -996,6 +1024,383 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E }; } +const defaultAutoMatchWeights: AutoMatchWeights = { + boneReward: 1, + missPenalty: 0.45, + movementPenalty: 0.08, + scalePenalty: 0.12, +}; +const defaultAutoMatchAdjustable: AutoMatchParameterSelection = { + translateX: true, + translateY: true, + translateZ: true, + scale: true, +}; +const autoMatchParameterKeys: AutoMatchParameterKey[] = ['translateX', 'translateY', 'translateZ', 'scale']; +const autoMatchBoneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i; + +interface AutoMatchContext { + project: ProjectRecord; + volume: DicomHuVolume; + metrics: ExportSceneMetrics; + samples: Point3DRecord[]; + basePose: ModelPoseValue; + weights: AutoMatchWeights; +} + +function normalizeAutoMatchAdjustable(input: unknown): AutoMatchParameterSelection { + const source = input && typeof input === 'object' && !Array.isArray(input) + ? input as Partial> + : {}; + return autoMatchParameterKeys.reduce((accumulator, key) => { + accumulator[key] = typeof source[key] === 'boolean' ? source[key] === true : defaultAutoMatchAdjustable[key]; + return accumulator; + }, { ...defaultAutoMatchAdjustable }); +} + +function normalizeAutoMatchWeights(input: unknown): AutoMatchWeights { + const source = input && typeof input === 'object' && !Array.isArray(input) + ? input as Partial> + : {}; + const readWeight = (key: keyof AutoMatchWeights, min: number, max: number) => { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) + ? Number(clampNumber(value, min, max).toFixed(3)) + : defaultAutoMatchWeights[key]; + }; + + return { + boneReward: readWeight('boneReward', 0.2, 2), + missPenalty: readWeight('missPenalty', 0, 1.5), + movementPenalty: readWeight('movementPenalty', 0, 0.4), + scalePenalty: readWeight('scalePenalty', 0, 0.6), + }; +} + +function normalizeAutoMatchIterations(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? clampNumber(Math.round(value), 2, 12) + : 6; +} + +function normalizeAutoMatchCandidatesPerRound(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? clampNumber(Math.round(value), 12, 80) + : 36; +} + +function resolveAutoMatchBoneFiles(project: ProjectRecord, input: unknown) { + const requested = Array.isArray(input) + ? input.filter((item): item is string => typeof item === 'string' && project.stlFiles.includes(item)) + : []; + if (requested.length) { + return [...new Set(requested)]; + } + const matched = project.stlFiles.filter((fileName) => autoMatchBoneNamePattern.test(fileName)); + return matched.length ? matched : project.stlFiles; +} + +function chooseAutoMatchSampleSlices(input: unknown, depth: number) { + const maxSlice = Math.max(depth - 1, 0); + const requested = Array.isArray(input) + ? input + .map((item) => typeof item === 'number' && Number.isFinite(item) ? Math.round(item) : NaN) + .filter((item) => Number.isFinite(item)) + .map((item) => clampNumber(item, 0, maxSlice)) + : []; + if (requested.length) { + return [...new Set(requested)].sort((a, b) => a - b); + } + + const fractions = [0.12, 0.22, 0.32, 0.42, 0.5, 0.58, 0.68, 0.78, 0.88]; + return [...new Set(fractions.map((fraction) => clampNumber(Math.round(maxSlice * fraction), 0, maxSlice)))] + .sort((a, b) => a - b); +} + +function collectAutoMatchPreviews(project: ProjectRecord) { + return (project.stlFiles ?? []).reduce>((accumulator, fileName) => { + const filePath = getProjectModelFilePath(project, fileName); + if (fs.existsSync(filePath)) { + accumulator[fileName] = createStlPreview(filePath, fileName, 5000); + } + return accumulator; + }, {}); +} + +function collectAutoMatchSamples(project: ProjectRecord, boneFiles: string[]) { + const sampleBudgetPerFile = Math.max(1200, Math.floor(70000 / Math.max(boneFiles.length, 1))); + const samples: Point3DRecord[] = []; + + boneFiles.forEach((fileName) => { + const filePath = getProjectModelFilePath(project, fileName); + if (!fs.existsSync(filePath)) { + return; + } + const preview = createStlPreview(filePath, fileName, sampleBudgetPerFile); + const vertices = preview.vertices; + for (let offset = 0; offset + 8 < vertices.length; offset += 9) { + samples.push({ + x: (vertices[offset] + vertices[offset + 3] + vertices[offset + 6]) / 3, + y: (vertices[offset + 1] + vertices[offset + 4] + vertices[offset + 7]) / 3, + z: (vertices[offset + 2] + vertices[offset + 5] + vertices[offset + 8]) / 3, + }); + } + }); + + return samples; +} + +function readAutoMatchVolumeHu(volume: DicomHuVolume, slice: number, x: number, y: number) { + if (slice < 0 || slice >= volume.depth || x < 0 || x >= volume.width || y < 0 || y >= volume.height) { + return -Infinity; + } + return volume.data.readInt16LE((slice * volume.width * volume.height + y * volume.width + x) * 2); +} + +function sampleAutoMatchBoneWindow(volume: DicomHuVolume, slice: number, x: number, y: number) { + const pixelX = Math.round(x); + const pixelY = Math.round(y); + if (pixelX < 0 || pixelX >= volume.width || pixelY < 0 || pixelY >= volume.height) { + return { value: -0.6, outside: true }; + } + + const centerHu = readAutoMatchVolumeHu(volume, slice, pixelX, pixelY); + let bestHu = centerHu; + for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + bestHu = Math.max(bestHu, readAutoMatchVolumeHu(volume, slice, pixelX + dx, pixelY + dy)); + } + } + + if (centerHu >= 180) { + return { value: 1, outside: false }; + } + if (bestHu >= 220) { + return { value: 0.65, outside: false }; + } + if (bestHu >= 140) { + return { value: 0.3, outside: false }; + } + return { value: -1, outside: false }; +} + +function mapAutoMatchPointToVolume(point: Point3DRecord, metrics: ExportSceneMetrics, volume: DicomHuVolume) { + const slice = volume.depth <= 1 + ? 0 + : Math.round(((point.z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1)); + const x = ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width; + const y = volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height; + return { slice, x, y }; +} + +function evaluateAutoMatchPose( + context: AutoMatchContext, + pose: ModelPoseValue, + iteration: number, + mode: string, + changed: AutoMatchParameterKey[], +): AutoMatchCandidateRecord { + let hitReward = 0; + let missPenalty = 0; + let contributors = 0; + + context.samples.forEach((sample) => { + const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose); + const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume); + contributors += 1; + + if (mapped.slice < 0 || mapped.slice >= context.volume.depth) { + missPenalty += 0.8; + return; + } + + const bone = sampleAutoMatchBoneWindow(context.volume, mapped.slice, mapped.x, mapped.y); + if (bone.value > 0) { + hitReward += bone.value; + } else { + missPenalty += bone.outside ? 0.65 : 1; + } + }); + + const safeContributors = Math.max(contributors, 1); + const movement = Math.sqrt( + (pose.translateX - context.basePose.translateX) ** 2 + + (pose.translateY - context.basePose.translateY) ** 2 + + (pose.translateZ - context.basePose.translateZ) ** 2, + ); + const movementPenalty = (movement / 0.05) * context.weights.movementPenalty; + const scalePenalty = (Math.abs(pose.scale - context.basePose.scale) / 0.02) * context.weights.scalePenalty; + const normalizedHitReward = (hitReward / safeContributors) * context.weights.boneReward; + const normalizedMissPenalty = (missPenalty / safeContributors) * context.weights.missPenalty; + const score = normalizedHitReward - normalizedMissPenalty - movementPenalty - scalePenalty; + + return { + iteration, + mode, + pose: normalizeModelPoseValue(pose), + score: Number(score.toFixed(6)), + boneReward: Number(normalizedHitReward.toFixed(6)), + missPenalty: Number(normalizedMissPenalty.toFixed(6)), + movementPenalty: Number(movementPenalty.toFixed(6)), + scalePenalty: Number(scalePenalty.toFixed(6)), + contributors, + changed, + }; +} + +function autoMatchStepForParameter(key: AutoMatchParameterKey, iteration: number) { + const translationSteps = [0.04, 0.025, 0.014, 0.008, 0.004, 0.002, 0.001, 0.001]; + const scaleSteps = [0.035, 0.02, 0.011, 0.006, 0.003, 0.0015, 0.001, 0.001]; + const steps = key === 'scale' ? scaleSteps : translationSteps; + return steps[Math.min(iteration, steps.length - 1)]; +} + +function poseWithAutoMatchDelta(pose: ModelPoseValue, key: AutoMatchParameterKey, delta: number) { + return normalizeModelPoseValue({ + ...pose, + [key]: pose[key] + delta, + }); +} + +function generateAutoMatchCandidates( + pose: ModelPoseValue, + adjustable: AutoMatchParameterSelection, + iteration: number, + limit: number, +) { + const candidates = new Map(); + const addCandidate = (candidatePose: ModelPoseValue, mode: string, changed: AutoMatchParameterKey[]) => { + const key = JSON.stringify(candidatePose); + if (!candidates.has(key)) { + candidates.set(key, { pose: candidatePose, mode, changed }); + } + }; + const enabledKeys = autoMatchParameterKeys.filter((key) => adjustable[key]); + + addCandidate(pose, '保持当前', []); + enabledKeys.forEach((key) => { + const step = autoMatchStepForParameter(key, iteration); + addCandidate(poseWithAutoMatchDelta(pose, key, step), `${key} +`, [key]); + addCandidate(poseWithAutoMatchDelta(pose, key, -step), `${key} -`, [key]); + }); + + for (let left = 0; left < enabledKeys.length; left += 1) { + for (let right = left + 1; right < enabledKeys.length; right += 1) { + const leftKey = enabledKeys[left]; + const rightKey = enabledKeys[right]; + const leftStep = autoMatchStepForParameter(leftKey, iteration) * 0.65; + const rightStep = autoMatchStepForParameter(rightKey, iteration) * 0.65; + [-1, 1].forEach((leftSign) => { + [-1, 1].forEach((rightSign) => { + const candidatePose = poseWithAutoMatchDelta( + poseWithAutoMatchDelta(pose, leftKey, leftStep * leftSign), + rightKey, + rightStep * rightSign, + ); + addCandidate(candidatePose, `${leftKey}/${rightKey} 联合`, [leftKey, rightKey]); + }); + }); + } + } + + return [...candidates.values()].slice(0, limit); +} + +function createAutoMatchContext(project: ProjectRecord, body: Record) { + const files = getProjectDicomFiles(project); + if (!files.length) { + throw new Error('当前项目没有可匹配的 DICOM 文件'); + } + if (!project.stlFiles.length) { + throw new Error('当前项目没有可匹配的 STL 文件'); + } + + const volume = readDicomHuVolume(project, files); + const previews = collectAutoMatchPreviews(project); + const metrics = getExportMetrics(project, volume, previews); + if (!metrics) { + throw new Error('无法读取 STL 全局边界'); + } + + const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles); + const samples = collectAutoMatchSamples(project, boneFiles); + if (!samples.length) { + throw new Error('未能从骨骼 STL 中采样到可匹配点'); + } + + return { + context: { + project, + volume, + metrics, + samples, + basePose: normalizeModelPoseValue(body.pose as Partial | undefined), + weights: normalizeAutoMatchWeights(body.weights), + } satisfies AutoMatchContext, + boneFiles, + sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth), + adjustable: normalizeAutoMatchAdjustable(body.adjustable), + iterations: normalizeAutoMatchIterations(body.iterations), + candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound), + }; +} + +function runProjectAutoMatch(project: ProjectRecord, body: Record) { + const { + context, + boneFiles, + sampleSlices, + adjustable, + iterations, + candidatesPerRound, + } = createAutoMatchContext(project, body); + let best = evaluateAutoMatchPose(context, context.basePose, -1, '初始位姿', []); + const trace: AutoMatchCandidateRecord[] = []; + let evaluated = 1; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const candidates = generateAutoMatchCandidates(best.pose, adjustable, iteration, candidatesPerRound); + const evaluatedCandidates = candidates + .map((candidate) => evaluateAutoMatchPose(context, candidate.pose, iteration, candidate.mode, candidate.changed)) + .sort((left, right) => right.score - left.score); + evaluated += evaluatedCandidates.length; + trace.push(...evaluatedCandidates.slice(0, Math.min(6, evaluatedCandidates.length))); + if (evaluatedCandidates[0] && evaluatedCandidates[0].score > best.score + 0.000001) { + best = evaluatedCandidates[0]; + } + } + + return { + projectId: project.id, + basePose: context.basePose, + bestPose: best.pose, + bestScore: best.score, + iterations, + evaluated, + boneFiles, + sampleSlices, + weights: context.weights, + trace, + }; +} + +function applyAutoMatchedPose(project: ProjectRecord, pose: ModelPoseValue) { + const normalizedPose = normalizeModelPoseValue(pose); + const poses = normalizeModelPoses(project.modelPoses).filter((record) => record.id !== 'auto-match'); + project.modelPoses = normalizeModelPoses([ + ...poses, + { + id: 'auto-match', + name: '自动微调匹配', + pose: normalizedPose, + }, + ]); + const latestResult = project.segmentationResults[project.segmentationResults.length - 1]; + if (latestResult) { + latestResult.pose = normalizedPose; + } +} + function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null { const epsilon = 1e-5; const startDistance = start.z - targetZ; @@ -3007,6 +3412,42 @@ async function startServer() { res.json(project); }); + app.post('/api/projects/:projectId/auto-match', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + try { + res.json(runProjectAutoMatch(project, req.body && typeof req.body === 'object' ? req.body as Record : {})); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : '自动微调匹配失败' }); + } + }); + + app.patch('/api/projects/:projectId/model-pose', (req, res) => { + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + if (project.locked) { + res.status(423).json({ message: '项目已锁定,请先解锁后再写入自动匹配位姿' }); + return; + } + + try { + applyAutoMatchedPose(project, normalizeModelPoseValue(req.body?.pose as Partial | undefined)); + touchProject(project); + writeState(state); + res.json(project); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : '位姿保存失败' }); + } + }); + app.post('/api/projects/:projectId/import-assets', (req, res) => { assetUpload.array('files', 5000)(req, res, (uploadError) => { if (uploadError) { diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 8074cf8..6e7915d 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -15,8 +15,9 @@ import Sidebar from './components/Sidebar'; import Overview from './components/Overview'; import ProjectLibrary from './components/ProjectLibrary'; import ReverseWorkspace from './components/ReverseWorkspace'; +import AutoMatchWorkspace from './components/AutoMatchWorkspace'; import UserManagement from './components/UserManagement'; -import { ViewType } from './types'; +import { ModelPose, ViewType } from './types'; import { api } from './lib/api'; export default function App() { @@ -25,13 +26,14 @@ export default function App() { const [activeView, setActiveView] = useState(ViewType.OVERVIEW); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [activeProjectId, setActiveProjectId] = useState('head-ct-demo'); + const [autoMatchInitialPose, setAutoMatchInitialPose] = useState(null); const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom'); const workspaceLeaveGuardRef = useRef<(() => Promise) | null>(null); const bootSessionResetRef = useRef(false); // Automatically collapse main sidebar when entering Project Library or Workspace useEffect(() => { - if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) { + if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE || activeView === ViewType.AUTO_MATCH) { setSidebarCollapsed(true); } else { setSidebarCollapsed(false); @@ -94,6 +96,9 @@ export default function App() { if (leaveWorkspace && nextView === ViewType.PROJECTS) { setProjectLibraryInitialView('mask'); } + if (nextView === ViewType.AUTO_MATCH) { + setAutoMatchInitialPose(null); + } setActiveView(nextView); }; @@ -123,6 +128,12 @@ export default function App() { setActiveView(ViewType.OVERVIEW); }; + const openAutoMatchWorkspace = (projectId: string, pose?: ModelPose) => { + setActiveProjectId(projectId); + setAutoMatchInitialPose(pose ?? null); + setActiveView(ViewType.AUTO_MATCH); + }; + if (sessionLoading) { return (
@@ -153,6 +164,7 @@ export default function App() { {activeView === ViewType.OVERVIEW && '总体概况'} {activeView === ViewType.PROJECTS && '项目库'} {activeView === ViewType.WORKSPACE && '逆向工作区'} + {activeView === ViewType.AUTO_MATCH && '自动微调匹配工作区'} {activeView === ViewType.SYSTEM && '系统管理工作区'}
@@ -185,11 +197,22 @@ export default function App() { {activeView === ViewType.WORKSPACE && ( { workspaceLeaveGuardRef.current = handler; }} /> )} + {activeView === ViewType.AUTO_MATCH && ( + { + setActiveProjectId(projectId); + setActiveView(ViewType.WORKSPACE); + }} + /> + )} {activeView === ViewType.SYSTEM && } diff --git a/WebSite/src/components/AutoMatchWorkspace.tsx b/WebSite/src/components/AutoMatchWorkspace.tsx new file mode 100644 index 0000000..6b8fbea --- /dev/null +++ b/WebSite/src/components/AutoMatchWorkspace.tsx @@ -0,0 +1,559 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + ArrowLeft, + Bone, + CheckCircle2, + Crosshair, + Loader2, + Lock, + Play, + RefreshCcw, + Save, + SlidersHorizontal, +} from 'lucide-react'; +import { + AutoMatchCandidate, + AutoMatchParameterKey, + AutoMatchParameterSelection, + AutoMatchWeights, + ModelPose, + Project, +} from '../types'; +import { api } from '../lib/api'; +import { cn } from '../lib/utils'; + +const defaultModelPose: ModelPose = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + flipX: false, + flipY: false, + flipZ: false, +}; + +const defaultAdjustable: AutoMatchParameterSelection = { + translateX: true, + translateY: true, + translateZ: true, + scale: true, +}; + +const defaultWeights: AutoMatchWeights = { + boneReward: 1, + missPenalty: 0.45, + movementPenalty: 0.08, + scalePenalty: 0.12, +}; + +const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [ + { key: 'translateX', label: '平移 X' }, + { key: 'translateY', label: '平移 Y' }, + { key: 'translateZ', label: '平移 Z' }, + { key: 'scale', label: '缩放' }, +]; + +const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: number; max: number; step: number }> = [ + { key: 'boneReward', label: '骨窗命中奖励', min: 0.2, max: 2, step: 0.05 }, + { key: 'missPenalty', label: '非骨区域惩罚', min: 0, max: 1.5, step: 0.05 }, + { key: 'movementPenalty', label: '移动惩罚', min: 0, max: 0.4, step: 0.01 }, + { key: 'scalePenalty', label: '缩放惩罚', min: 0, max: 0.6, step: 0.01 }, +]; + +const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i; + +function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) { + if (incomingPose) { + return incomingPose; + } + const latestResult = project?.segmentationResults?.[project.segmentationResults.length - 1]; + return latestResult?.pose + ?? project?.modelPoses?.find((pose) => pose.id === 'auto-match')?.pose + ?? project?.modelPoses?.find((pose) => pose.id === 'default')?.pose + ?? project?.modelPoses?.[0]?.pose + ?? defaultModelPose; +} + +function defaultBoneFiles(project: Project | null) { + const files = project?.stlFiles ?? []; + const matched = files.filter((fileName) => boneNamePattern.test(fileName)); + return matched.length ? matched : files; +} + +function formatPoseNumber(value: number) { + return Number.isFinite(value) ? value.toFixed(3) : '0.000'; +} + +function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey) { + return next[key] - base[key]; +} + +interface AutoMatchWorkspaceProps { + projectId: string; + initialPose?: ModelPose | null; + onOpenReverse?: (projectId: string) => void; +} + +export default function AutoMatchWorkspace({ projectId, initialPose, onOpenReverse }: AutoMatchWorkspaceProps) { + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(projectId); + const [project, setProject] = useState(null); + const [basePose, setBasePose] = useState(initialPose ?? defaultModelPose); + const [selectedBoneFiles, setSelectedBoneFiles] = useState([]); + const [adjustable, setAdjustable] = useState(defaultAdjustable); + const [weights, setWeights] = useState(defaultWeights); + const [iterations, setIterations] = useState(6); + const [candidatesPerRound, setCandidatesPerRound] = useState(36); + const [result, setResult] = useState<{ + bestPose: ModelPose; + bestScore: number; + evaluated: number; + trace: AutoMatchCandidate[]; + sampleSlices: number[]; + } | null>(null); + const [loadingProject, setLoadingProject] = useState(true); + const [running, setRunning] = useState(false); + const [loadingPercent, setLoadingPercent] = useState(0); + const [error, setError] = useState(''); + const [status, setStatus] = useState(''); + + useEffect(() => { + setSelectedProjectId(projectId); + }, [projectId]); + + useEffect(() => { + let mounted = true; + api.getProjects() + .then((items) => { + if (mounted) { + setProjects(items); + } + }) + .catch(() => undefined); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + let mounted = true; + setLoadingProject(true); + setError(''); + api.getProject(selectedProjectId) + .then((nextProject) => { + if (!mounted) { + return; + } + const incomingPose = selectedProjectId === projectId ? initialPose : null; + setProject(nextProject); + setBasePose(latestPoseFromProject(nextProject, incomingPose)); + setSelectedBoneFiles(defaultBoneFiles(nextProject)); + setResult(null); + }) + .catch((loadError) => { + if (mounted) { + setError(loadError instanceof Error ? loadError.message : '项目加载失败'); + } + }) + .finally(() => { + if (mounted) { + setLoadingProject(false); + } + }); + return () => { + mounted = false; + }; + }, [selectedProjectId, projectId, initialPose]); + + useEffect(() => { + if (!running) { + return undefined; + } + setLoadingPercent(8); + const timer = window.setInterval(() => { + setLoadingPercent((current) => Math.min(92, current + Math.max(1, (94 - current) * 0.08))); + }, 320); + return () => window.clearInterval(timer); + }, [running]); + + const enabledParameterCount = useMemo( + () => parameterOptions.filter((option) => adjustable[option.key]).length, + [adjustable], + ); + + const toggleBoneFile = (fileName: string) => { + setSelectedBoneFiles((current) => ( + current.includes(fileName) + ? current.filter((item) => item !== fileName) + : [...current, fileName] + )); + }; + + const runAutoMatch = async () => { + if (!project) { + return; + } + if (!selectedBoneFiles.length) { + setError('请选择至少一个骨骼区域 STL'); + return; + } + if (!enabledParameterCount) { + setError('请选择至少一个可微调参数'); + return; + } + + setRunning(true); + setError(''); + setStatus(''); + setResult(null); + try { + const data = await api.runAutoMatch(project.id, { + pose: basePose, + adjustable, + boneFiles: selectedBoneFiles, + iterations, + candidatesPerRound, + weights, + }); + setResult({ + bestPose: data.bestPose, + bestScore: data.bestScore, + evaluated: data.evaluated, + trace: data.trace, + sampleSlices: data.sampleSlices, + }); + setLoadingPercent(100); + setStatus('已完成自动微调匹配'); + } catch (runError) { + setError(runError instanceof Error ? runError.message : '自动微调匹配失败'); + } finally { + setRunning(false); + } + }; + + const applyBestPose = async (returnToWorkspace = false) => { + if (!project || !result) { + return; + } + setError(''); + setStatus(''); + try { + const updated = await api.applyProjectModelPose(project.id, result.bestPose); + setProject(updated); + setBasePose(result.bestPose); + setStatus('最佳位姿已写入项目库'); + if (returnToWorkspace) { + onOpenReverse?.(project.id); + } + } catch (applyError) { + setError(applyError instanceof Error ? applyError.message : '保存最佳位姿失败'); + } + }; + + const traceRows = result?.trace.slice(0, 10) ?? []; + + return ( +
+
+
+ + DICOM {project?.dicomCount ?? '-'} + STL {project?.modelCount ?? 0} +
+
+ +
+
+ + {(running || loadingProject) && ( +
+
+ + + {loadingProject ? '正在读取项目数据' : '正在迭代匹配'} + + {Math.round(loadingProject ? 28 : loadingPercent)}% +
+
+
+
+
+ )} + + {error && ( +
+ {error} +
+ )} + {status && ( +
+ {status} +
+ )} + +
+
+
+
+ +

匹配参数

+
+ + + 旋转锁定 + +
+ +
+
+
可调整参数
+
+ {parameterOptions.map((option) => ( + + ))} +
+
+ +
+
+ 骨骼区域 + +
+
+ {(project?.stlFiles ?? []).map((fileName) => ( + + ))} +
+
+ +
+
评分权重
+
+ {weightOptions.map((option) => ( + + ))} +
+
+ +
+ + +
+ + +
+
+ +
+
+
+ +

匹配结果

+
+ {result && ( + + + score {result.bestScore.toFixed(4)} + + )} +
+ +
+
+ {parameterOptions.map((option) => ( +
+
{option.label}
+
+ {formatPoseNumber((result?.bestPose ?? basePose)[option.key])} +
+
= 0 ? 'text-emerald-600' : 'text-red-500', + )} + > + {result ? `${poseDelta(basePose, result.bestPose, option.key) >= 0 ? '+' : ''}${formatPoseNumber(poseDelta(basePose, result.bestPose, option.key))}` : '+0.000'} +
+
+ ))} +
+ +
+
+
候选评估
+
{result?.evaluated ?? 0}
+
+
+
采样切片
+
+ {result?.sampleSlices.map((slice) => slice + 1).join(', ') ?? '-'} +
+
+
+
骨骼构件
+
{selectedBoneFiles.length}
+
+
+ +
+ + + +
+ +
+
+ 轮次 + 模式 + 评分 + 命中 + 贡献点 +
+
+ {traceRows.length ? traceRows.map((item, index) => ( +
+ {item.iteration + 1} + {item.mode} + {item.score.toFixed(4)} + {item.boneReward.toFixed(3)} + {item.contributors} +
+ )) : ( +
+ 尚未运行自动微调匹配 +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 04a7c34..c977735 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -17,6 +17,7 @@ import { FlipHorizontal2, FlipVertical2, Move3d, + Crosshair, } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; @@ -2782,9 +2783,11 @@ export function VoxelizationMappingView({ export default function ReverseWorkspace({ projectId, onLeaveGuardChange, + onAutoMatch, }: { projectId: string; onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void; + onAutoMatch?: (projectId: string, pose: ModelPose) => void; }) { const [sliceStart, setSliceStart] = useState(0); const [sliceEnd, setSliceEnd] = useState(49); @@ -3732,6 +3735,14 @@ export default function ReverseWorkspace({ {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

}
+