diff --git a/Docker部署/README.md b/Docker部署/README.md index 4716bd1..18eb7ce 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -20,7 +20,7 @@ - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 - 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 -- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,并可将最佳位姿写回项目库。 +- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片可人工填写并真实参与评分,可将最佳位姿写回项目库。 ## 一、本机部署 diff --git a/WebSite/server.ts b/WebSite/server.ts index 511d2c5..a16da6e 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -1044,6 +1044,7 @@ interface AutoMatchContext { volume: DicomHuVolume; metrics: ExportSceneMetrics; samples: Point3DRecord[]; + sampleSlices: number[]; basePose: ModelPoseValue; weights: AutoMatchWeights; } @@ -1117,6 +1118,19 @@ function chooseAutoMatchSampleSlices(input: unknown, depth: number) { .sort((a, b) => a - b); } +function nearestAutoMatchSampleSlice(slice: number, sampleSlices: number[], tolerance = 1) { + let nearest: number | null = null; + let nearestDistance = Infinity; + sampleSlices.forEach((candidate) => { + const distance = Math.abs(candidate - slice); + if (distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + }); + return nearestDistance <= tolerance ? nearest : null; +} + function collectAutoMatchPreviews(project: ProjectRecord) { return (project.stlFiles ?? []).reduce>((accumulator, fileName) => { const filePath = getProjectModelFilePath(project, fileName); @@ -1207,14 +1221,18 @@ function evaluateAutoMatchPose( context.samples.forEach((sample) => { const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose); const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume); + const sampleSlice = nearestAutoMatchSampleSlice(mapped.slice, context.sampleSlices); + if (sampleSlice === null) { + return; + } contributors += 1; - if (mapped.slice < 0 || mapped.slice >= context.volume.depth) { + if (sampleSlice < 0 || sampleSlice >= context.volume.depth) { missPenalty += 0.8; return; } - const bone = sampleAutoMatchBoneWindow(context.volume, mapped.slice, mapped.x, mapped.y); + const bone = sampleAutoMatchBoneWindow(context.volume, sampleSlice, mapped.x, mapped.y); if (bone.value > 0) { hitReward += bone.value; } else { @@ -1323,6 +1341,7 @@ function createAutoMatchContext(project: ProjectRecord, body: Record | undefined), weights: normalizeAutoMatchWeights(body.weights), } satisfies AutoMatchContext, boneFiles, - sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth), + sampleSlices, adjustable: normalizeAutoMatchAdjustable(body.adjustable), iterations: normalizeAutoMatchIterations(body.iterations), candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound), @@ -1375,6 +1395,8 @@ function runProjectAutoMatch(project: ProjectRecord, body: Record Math.max(0, Math.min(maxSlice, Math.round(maxSlice * fraction))) + 1))] + .sort((a, b) => a - b); +} + +function formatSampleSliceText(total: number) { + return defaultSampleSliceNumbers(total).join(', '); +} + +function parseSampleSliceText(input: string, total: number) { + const maxSlice = Math.max(total, 1); + const values: number[] = []; + const normalized = input + .replace(/[,、;;]/g, ',') + .replace(/\s+/g, ',') + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + + normalized.forEach((token) => { + const rangeMatch = token.match(/^(\d+)\s*[-~]\s*(\d+)(?::(\d+))?$/); + if (rangeMatch) { + const start = Number(rangeMatch[1]); + const end = Number(rangeMatch[2]); + const rawStep = Number(rangeMatch[3] ?? 1); + const step = Math.max(1, Math.min(50, Number.isFinite(rawStep) ? Math.round(rawStep) : 1)); + const direction = start <= end ? 1 : -1; + for (let value = start; direction > 0 ? value <= end : value >= end; value += step * direction) { + values.push(value); + if (values.length > maxManualSampleSlices * 2) { + break; + } + } + return; + } + + const value = Number(token); + if (Number.isFinite(value)) { + values.push(value); + } + }); + + const slices = [...new Set(values + .map((value) => Math.round(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.max(1, Math.min(maxSlice, value))))] + .sort((a, b) => a - b) + .slice(0, maxManualSampleSlices); + + return slices.length ? slices : defaultSampleSliceNumbers(total); +} + +function poseHasVisibleChange(base: ModelPose, next: ModelPose) { + return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005); +} + interface AutoMatchWorkspaceProps { projectId: string; initialPose?: ModelPose | null; @@ -105,11 +164,14 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever const [selectedBoneFiles, setSelectedBoneFiles] = useState([]); const [adjustable, setAdjustable] = useState(defaultAdjustable); const [weights, setWeights] = useState(defaultWeights); + const [sampleSlicesText, setSampleSlicesText] = useState(''); const [iterations, setIterations] = useState(6); const [candidatesPerRound, setCandidatesPerRound] = useState(36); const [result, setResult] = useState<{ bestPose: ModelPose; bestScore: number; + bestMode: string; + bestChanged: AutoMatchParameterKey[]; evaluated: number; trace: AutoMatchCandidate[]; sampleSlices: number[]; @@ -151,6 +213,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever setProject(nextProject); setBasePose(latestPoseFromProject(nextProject, incomingPose)); setSelectedBoneFiles(defaultBoneFiles(nextProject)); + setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount)); setResult(null); }) .catch((loadError) => { @@ -183,6 +246,11 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever () => parameterOptions.filter((option) => adjustable[option.key]).length, [adjustable], ); + const parsedSampleSlices = useMemo( + () => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1), + [project?.dicomCount, sampleSlicesText], + ); + const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false; const toggleBoneFile = (fileName: string) => { setSelectedBoneFiles((current) => ( @@ -214,6 +282,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever pose: basePose, adjustable, boneFiles: selectedBoneFiles, + sampleSlices: parsedSampleSlices.map((slice) => slice - 1), iterations, candidatesPerRound, weights, @@ -221,6 +290,8 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever setResult({ bestPose: data.bestPose, bestScore: data.bestScore, + bestMode: data.bestMode, + bestChanged: data.bestChanged, evaluated: data.evaluated, trace: data.trace, sampleSlices: data.sampleSlices, @@ -380,6 +451,28 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever +
+
+ 采样切片 + +
+