From d1fa79aef93a490d64cba610d6ba7a66cb26a7da Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 25 May 2026 00:28:52 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-25-00-07-30=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E5=AF=B9=E6=AF=94=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E9=87=87=E6=A0=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docker部署/README.md | 3 +- WebSite/server.ts | 27 +- WebSite/src/components/AutoMatchWorkspace.tsx | 267 ++++++++++-------- WebSite/src/components/ReverseWorkspace.tsx | 122 ++++---- 工程分析/实现方案-2026-05-25-00-07-30.md | 64 +++++ 工程分析/测试方案-2026-05-25-00-07-30.md | 54 ++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-25-00-07-30.md | 50 ++++ 8 files changed, 426 insertions(+), 179 deletions(-) create mode 100644 工程分析/实现方案-2026-05-25-00-07-30.md create mode 100644 工程分析/测试方案-2026-05-25-00-07-30.md create mode 100644 工程分析/需求分析-2026-05-25-00-07-30.md diff --git a/Docker部署/README.md b/Docker部署/README.md index 18eb7ce..ce00139 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -20,7 +20,8 @@ - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 - 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 -- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片可人工填写并真实参与评分,可将最佳位姿写回项目库。 +- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片按数量均匀筛选并真实参与评分,可将最佳位姿写回项目库;结果区支持当前位姿/自动匹配结果双视图对比。 +- 大型 STL preview JSON 响应会按浏览器能力 gzip 返回,降低公网 HTTP/2 代理下高精度预览的传输失败概率。 ## 一、本机部署 diff --git a/WebSite/server.ts b/WebSite/server.ts index a16da6e..7774bcf 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import express, { type Request, type Response } from 'express'; import AdmZip from 'adm-zip'; import multer from 'multer'; import { createServer as createViteServer } from 'vite'; @@ -747,6 +747,23 @@ function writeState(state: AppState) { fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2)); } +function sendJsonPayload(req: Request, res: Response, payload: unknown) { + const json = JSON.stringify(payload); + const acceptsGzip = String(req.headers['accept-encoding'] ?? '').includes('gzip'); + + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Vary', 'Accept-Encoding'); + if (acceptsGzip && Buffer.byteLength(json) > 64 * 1024) { + const compressed = zlib.gzipSync(Buffer.from(json)); + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Content-Length', String(compressed.length)); + res.send(compressed); + return; + } + + res.send(json); +} + interface DicomHuVolume { width: number; height: number; @@ -1026,9 +1043,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E const defaultAutoMatchWeights: AutoMatchWeights = { boneReward: 1, - missPenalty: 0.45, - movementPenalty: 0.08, - scalePenalty: 0.12, + missPenalty: 0.1, + movementPenalty: 0, + scalePenalty: 0, }; const defaultAutoMatchAdjustable: AutoMatchParameterSelection = { translateX: true, @@ -3753,7 +3770,7 @@ async function startServer() { } try { - res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); + sendJsonPayload(req, res, createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' }); } diff --git a/WebSite/src/components/AutoMatchWorkspace.tsx b/WebSite/src/components/AutoMatchWorkspace.tsx index dd358df..1c05fbf 100644 --- a/WebSite/src/components/AutoMatchWorkspace.tsx +++ b/WebSite/src/components/AutoMatchWorkspace.tsx @@ -12,15 +12,16 @@ import { SlidersHorizontal, } from 'lucide-react'; import { - AutoMatchCandidate, AutoMatchParameterKey, AutoMatchParameterSelection, AutoMatchWeights, + ModuleStyle, ModelPose, Project, } from '../types'; import { api } from '../lib/api'; import { cn } from '../lib/utils'; +import { VoxelizationMappingView, type MappingDisplayMode } from './ReverseWorkspace'; const defaultModelPose: ModelPose = { rotateX: 0, @@ -44,9 +45,9 @@ const defaultAdjustable: AutoMatchParameterSelection = { const defaultWeights: AutoMatchWeights = { boneReward: 1, - missPenalty: 0.45, - movementPenalty: 0.08, - scalePenalty: 0.12, + missPenalty: 0.1, + movementPenalty: 0, + scalePenalty: 0, }; const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [ @@ -64,7 +65,13 @@ const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: nu ]; const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i; -const maxManualSampleSlices = 96; +const maxSampleSliceCount = 96; +const defaultSampleSliceCount = 9; +type AutoMatchMappingMode = 'selected' | 'score'; +const mappingModeOptions: Array<{ id: AutoMatchMappingMode; label: string }> = [ + { id: 'selected', label: '所选骨骼区域' }, + { id: 'score', label: '得分可视化' }, +]; function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) { if (incomingPose) { @@ -78,7 +85,7 @@ function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose ?? defaultModelPose; } -function defaultBoneFiles(project: Project | null) { +function suggestedBoneFiles(project: Project | null) { const files = project?.stlFiles ?? []; const matched = files.filter((fileName) => boneNamePattern.test(fileName)); return matched.length ? matched : files; @@ -99,57 +106,41 @@ function defaultSampleSliceNumbers(total: number) { .sort((a, b) => a - b); } -function formatSampleSliceText(total: number) { - return defaultSampleSliceNumbers(total).join(', '); -} - -function parseSampleSliceText(input: string, total: number) { +function uniformSampleSliceNumbers(total: number, count: 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); + const safeCount = Math.max(1, Math.min(maxSampleSliceCount, Math.round(count))); + if (safeCount <= 1) { + return [Math.max(1, Math.round(maxSlice / 2))]; + } + return [...new Set(Array.from({ length: safeCount }, (_, index) => ( + Math.max(1, Math.min(maxSlice, Math.round(1 + ((maxSlice - 1) * index) / (safeCount - 1)))) + )))].sort((a, b) => a - b); } function poseHasVisibleChange(base: ModelPose, next: ModelPose) { return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005); } +function buildSelectedModuleStyles( + project: Project | null, + selectedFiles: string[], + mode: AutoMatchMappingMode, + accentColor: string, +) { + const selected = new Set(selectedFiles); + return (project?.stlFiles ?? []).reduce>((accumulator, fileName, index) => { + const source = project?.moduleStyles?.[fileName]; + const visible = selected.has(fileName); + accumulator[fileName] = { + visible, + color: visible && mode === 'score' ? accentColor : source?.color ?? ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'][index % 5], + opacity: visible ? (mode === 'score' ? 0.9 : source?.opacity ?? 0.72) : source?.opacity ?? 0.72, + partId: source?.partId ?? index + 1, + }; + return accumulator; + }, {}); +} + interface AutoMatchWorkspaceProps { projectId: string; initialPose?: ModelPose | null; @@ -164,7 +155,9 @@ 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 [sampleSliceCount, setSampleSliceCount] = useState(defaultSampleSliceCount); + const [previewSlice, setPreviewSlice] = useState(0); + const [mappingMode, setMappingMode] = useState('selected'); const [iterations, setIterations] = useState(6); const [candidatesPerRound, setCandidatesPerRound] = useState(36); const [result, setResult] = useState<{ @@ -173,7 +166,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever bestMode: string; bestChanged: AutoMatchParameterKey[]; evaluated: number; - trace: AutoMatchCandidate[]; sampleSlices: number[]; } | null>(null); const [loadingProject, setLoadingProject] = useState(true); @@ -212,8 +204,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever const incomingPose = selectedProjectId === projectId ? initialPose : null; setProject(nextProject); setBasePose(latestPoseFromProject(nextProject, incomingPose)); - setSelectedBoneFiles(defaultBoneFiles(nextProject)); - setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount)); + setSelectedBoneFiles([]); + setSampleSliceCount(defaultSampleSliceCount); + setPreviewSlice(Math.max(0, Math.floor((nextProject.dicomCount - 1) / 2))); setResult(null); }) .catch((loadError) => { @@ -247,10 +240,21 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever [adjustable], ); const parsedSampleSlices = useMemo( - () => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1), - [project?.dicomCount, sampleSlicesText], + () => uniformSampleSliceNumbers(project?.dicomCount ?? 1, sampleSliceCount), + [project?.dicomCount, sampleSliceCount], ); const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false; + const maxPreviewSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); + const comparePose = result?.bestPose ?? basePose; + const mappingDisplayMode: MappingDisplayMode = mappingMode === 'score' ? 'bone' : 'soft'; + const currentModuleStyles = useMemo( + () => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#f59e0b'), + [project, selectedBoneFiles, mappingMode], + ); + const bestModuleStyles = useMemo( + () => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#22c55e'), + [project, selectedBoneFiles, mappingMode], + ); const toggleBoneFile = (fileName: string) => { setSelectedBoneFiles((current) => ( @@ -293,7 +297,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever bestMode: data.bestMode, bestChanged: data.bestChanged, evaluated: data.evaluated, - trace: data.trace, sampleSlices: data.sampleSlices, }); setLoadingPercent(100); @@ -324,8 +327,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever } }; - const traceRows = result?.trace.slice(0, 10) ?? []; - return (
@@ -423,10 +424,10 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
骨骼区域
@@ -455,21 +456,33 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
采样切片
-