diff --git a/Docker部署/README.md b/Docker部署/README.md index 8fe21be..ba5fade 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -10,6 +10,14 @@ - `revoxelseg_web`:ReVoxelSeg DICOM 前后端一体服务,容器端口 `4000`。 - `revoxelseg_frpc`:FRPC 客户端,将 `revoxelseg_web:4000` 映射到 `82.157.255.195` 的远程端口 `10008`。 +当前 Docker 构建会同步包含以下能力: + +- 二维逆向分割映射按实体填充显示,导出的分割 Label Map 也按填充区域写入。 +- 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,保存、项目库预览和导出均沿用该位姿。 +- “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。 +- 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。 +- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 + ## 一、本机部署 在项目根目录执行: @@ -125,4 +133,3 @@ cd WebSite npm run build npm run serve -- --host 0.0.0.0 --port 4000 ``` - diff --git a/Docker部署/威联通NAS/docker_compose.yaml b/Docker部署/威联通NAS/docker_compose.yaml index 14d7a90..80481b2 100644 --- a/Docker部署/威联通NAS/docker_compose.yaml +++ b/Docker部署/威联通NAS/docker_compose.yaml @@ -8,7 +8,7 @@ name: revoxelseg-dicom-qnap services: revoxelseg_web: - image: revoxelseg-dicom:web-qnap-20260521 + image: revoxelseg-dicom:web-qnap-20260524 build: context: /share/Container/revoxelseg_dicom dockerfile: Docker部署/Dockerfile @@ -67,4 +67,3 @@ services: depends_on: revoxelseg_web: condition: service_healthy - diff --git a/Docker部署/本机/docker_compose.yaml b/Docker部署/本机/docker_compose.yaml index 3adc0e6..8d9d792 100644 --- a/Docker部署/本机/docker_compose.yaml +++ b/Docker部署/本机/docker_compose.yaml @@ -7,7 +7,7 @@ name: revoxelseg-dicom-local services: revoxelseg_web: - image: revoxelseg-dicom:web-local-20260521 + image: revoxelseg-dicom:web-local-20260524 build: context: ../.. dockerfile: Docker部署/Dockerfile @@ -57,4 +57,3 @@ services: depends_on: revoxelseg_web: condition: service_healthy - diff --git a/WebSite/server.ts b/WebSite/server.ts index b31dddf..57c0655 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -32,6 +32,9 @@ interface ModelPoseValue { translateY: number; translateZ: number; scale: number; + flipX: boolean; + flipY: boolean; + flipZ: boolean; } interface ModelPoseRecord { @@ -141,6 +144,9 @@ const defaultModelPose: ModelPoseValue = { translateY: 0, translateZ: 0, scale: 1, + flipX: false, + flipY: false, + flipZ: false, }; interface DicomAttributes { @@ -379,6 +385,9 @@ function normalizeModelPoseValue(value: Partial | undefined): Mo ? clampNumber(nextValue, min, max) : fallback; }; + const readBoolean = (key: keyof ModelPoseValue, fallback: boolean) => ( + typeof value?.[key] === 'boolean' ? Boolean(value?.[key]) : fallback + ); return { rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180), @@ -388,6 +397,9 @@ function normalizeModelPoseValue(value: Partial | undefined): Mo translateY: read('translateY', defaultModelPose.translateY, -2, 2), translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2), scale: read('scale', defaultModelPose.scale, 0.5, 2), + flipX: readBoolean('flipX', defaultModelPose.flipX), + flipY: readBoolean('flipY', defaultModelPose.flipY), + flipZ: readBoolean('flipZ', defaultModelPose.flipZ), }; } @@ -859,9 +871,10 @@ function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, preview function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord { const scalar = metrics.modelBaseScale * pose.scale; - let px = (x - metrics.center.x) * scalar; - let py = (y - metrics.center.y) * scalar; - let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar; + let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1); + let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1); + let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1); + pz += metrics.modelPivotOffsetZ * scalar; const rotateX = (pose.rotateX * Math.PI) / 180; const rotateY = (pose.rotateY * Math.PI) / 180; const rotateZ = (pose.rotateZ * Math.PI) / 180; @@ -1135,6 +1148,70 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb return filledPixels; } +function fillExportFallbackClosedRegion( + data: Buffer, + width: number, + height: number, + slice: number, + segments: PlaneSegmentRecord[], + label: number, +) { + const points = segments.flatMap((segment) => [segment.a, segment.b]) + .filter((point) => ( + Number.isFinite(point.x) + && Number.isFinite(point.y) + && point.x >= -width + && point.x <= width * 2 + && point.y >= -height + && point.y <= height * 2 + )); + + if (points.length < 3) { + return 0; + } + + const uniquePoints: Point2DRecord[] = []; + points.forEach((point) => { + if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-6)) { + uniquePoints.push(point); + } + }); + if (uniquePoints.length < 3) { + return 0; + } + + const sorted = [...uniquePoints].sort((left, right) => ( + Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y + )); + const cross = (origin: Point2DRecord, a: Point2DRecord, b: Point2DRecord) => ( + (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x) + ); + const lower: Point2DRecord[] = []; + 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: Point2DRecord[] = []; + [...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)]; + const polygon = hull.length >= 3 ? hull : uniquePoints; + const rows = Array.from({ length: height }, () => [] as number[]); + + polygon.forEach((point, index) => { + const nextPoint = polygon[(index + 1) % polygon.length]; + addExportSegmentToRows(rows, width, height, { a: point, b: nextPoint }); + }); + + return fillExportRows(data, width, height, slice, rows, label); +} + function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord { return project.moduleStyles[fileName] ?? { visible: true, @@ -1192,15 +1269,18 @@ function createSegmentationData( } const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); - const rowsBySlice = new Map(); - const rowsForSlice = (slice: number) => { - const existing = rowsBySlice.get(slice); + const slicesByIndex = new Map(); + const entryForSlice = (slice: number) => { + const existing = slicesByIndex.get(slice); if (existing) { return existing; } - const rows = Array.from({ length: volume.height }, () => [] as number[]); - rowsBySlice.set(slice, rows); - return rows; + const entry = { + rows: Array.from({ length: volume.height }, () => [] as number[]), + segments: [] as PlaneSegmentRecord[], + }; + slicesByIndex.set(slice, entry); + return entry; }; const filePath = getProjectModelFilePath(project, fileName); @@ -1245,15 +1325,21 @@ function createSegmentationData( continue; } - addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, { + const mappedSegment = { a: mapPoint(segment.a), b: mapPoint(segment.b), - }); + }; + const entry = entryForSlice(slice); + addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment); + entry.segments.push(mappedSegment); } }); - rowsBySlice.forEach((rows, slice) => { - fillExportRows(data, volume.width, volume.height, slice, rows, label); + slicesByIndex.forEach(({ rows, segments }, slice) => { + const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label); + if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) { + fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, segments, label); + } }); }); @@ -1568,7 +1654,7 @@ function createProjectExportBundle({ } const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`); entries.push({ - name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`, + name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`, data: createNiftiBuffer( volume, createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName), @@ -2134,17 +2220,35 @@ function writeOctal(buffer: Buffer, value: number, offset: number, length: numbe buffer.write(`${text}\0`, offset, length, 'ascii'); } -function createTarEntryHeader(name: string, size: number, mtime: number) { +function writeTarText(buffer: Buffer, value: string, offset: number, length: number) { + const source = Buffer.from(value, 'utf8'); + source.copy(buffer, offset, 0, Math.min(length, source.length)); +} + +function createPaxRecord(key: string, value: string) { + const payload = `${key}=${value}\n`; + let length = Buffer.byteLength(payload, 'utf8') + 3; + + while (true) { + const record = `${length} ${payload}`; + const nextLength = Buffer.byteLength(record, 'utf8'); + if (nextLength === length) { + return record; + } + length = nextLength; + } +} + +function createTarEntryHeader(name: string, size: number, mtime: number, typeFlag: '0' | 'x' = '0') { const header = Buffer.alloc(512); - const safeName = name.slice(0, 100); - header.write(safeName, 0, 100, 'utf8'); + writeTarText(header, name, 0, 100); writeOctal(header, 0o644, 100, 8); writeOctal(header, 0, 108, 8); writeOctal(header, 0, 116, 8); writeOctal(header, size, 124, 12); writeOctal(header, Math.floor(mtime), 136, 12); header.fill(' ', 148, 156); - header.write('0', 156, 1, 'ascii'); + header.write(typeFlag, 156, 1, 'ascii'); header.write('ustar', 257, 6, 'ascii'); header.write('00', 263, 2, 'ascii'); @@ -2159,14 +2263,29 @@ function createTarEntryHeader(name: string, size: number, mtime: number) { function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) { const chunks: Buffer[] = []; - entries.forEach((entry) => { - const data = entry.data; - chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000)); + const pushEntry = (name: string, data: Buffer, mtime: number, typeFlag: '0' | 'x' = '0') => { + chunks.push(createTarEntryHeader(name, data.length, mtime, typeFlag)); chunks.push(data); const remainder = data.length % 512; if (remainder > 0) { chunks.push(Buffer.alloc(512 - remainder)); } + }; + + entries.forEach((entry, index) => { + const data = entry.data; + const mtime = entry.mtime ?? Date.now() / 1000; + const needsPaxPath = Buffer.byteLength(entry.name, 'utf8') > 100 || /[^\x20-\x7e]/.test(entry.name); + let headerName = entry.name; + + if (needsPaxPath) { + const paxData = Buffer.from(createPaxRecord('path', entry.name), 'utf8'); + const paxName = `PaxHeaders/${String(index + 1).padStart(6, '0')}`; + headerName = `entries/${String(index + 1).padStart(6, '0')}`; + pushEntry(paxName, paxData, mtime, 'x'); + } + + pushEntry(headerName, data, mtime); }); chunks.push(Buffer.alloc(1024)); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index b970e1f..32b0fe1 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -18,7 +18,11 @@ import { Layers, X, Trash2, - Upload + Upload, + RefreshCcw, + FlipHorizontal2, + FlipVertical2, + Move3d } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; @@ -47,6 +51,9 @@ interface ModelPose { translateY: number; translateZ: number; scale: number; + flipX: boolean; + flipY: boolean; + flipZ: boolean; } interface ModelPreviewPayload { @@ -60,7 +67,8 @@ interface ModelPreviewPayload { }; } -type ModelPoseKey = keyof ModelPose; +type ModelPoseKey = Exclude; +type ModelPoseFlipKey = Extract; const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ @@ -75,7 +83,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri ]; const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [ { id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' }, - { id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' }, + { id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' }, ]; const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, @@ -91,7 +99,15 @@ const defaultModelPose: ModelPose = { translateY: 0, translateZ: 0, scale: 1, + flipX: false, + flipY: false, + flipZ: false, }; +const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [ + { key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 }, + { key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 }, + { key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d }, +]; const emptyOverlayStats: OverlayStats = { activeModules: 0, filledPixels: 0, @@ -140,9 +156,22 @@ function clampModelPose(next: ModelPose): ModelPose { translateY: clampModelPoseValue('translateY', next.translateY), translateZ: clampModelPoseValue('translateZ', next.translateZ), scale: clampModelPoseValue('scale', next.scale), + flipX: Boolean(next.flipX), + flipY: Boolean(next.flipY), + flipZ: Boolean(next.flipZ), }; } +function normalizeModelPose(pose: Partial | undefined): ModelPose { + return clampModelPose({ + ...defaultModelPose, + ...(pose ?? {}), + flipX: typeof pose?.flipX === 'boolean' ? pose.flipX : defaultModelPose.flipX, + flipY: typeof pose?.flipY === 'boolean' ? pose.flipY : defaultModelPose.flipY, + flipZ: typeof pose?.flipZ === 'boolean' ? pose.flipZ : defaultModelPose.flipZ, + }); +} + function formatPoseCompactValue(value: number, digits = 2) { return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; } @@ -290,8 +319,26 @@ function displayDicomValue(value: string | number | null | undefined) { return String(value); } +function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) { + const total = Math.max(Math.round(totalSlices), 0); + if (!total) { + return 0; + } + return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex))); +} + function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) { const canvasRef = useRef(null); + const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 }); + const [isPanning, setIsPanning] = useState(false); + const panRef = useRef({ + active: false, + pointerId: 0, + startX: 0, + startY: 0, + offsetX: 0, + offsetY: 0, + }); useEffect(() => { const canvas = canvasRef.current; @@ -301,11 +348,86 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n drawDicomPreviewToCanvas(canvas, preview, rotation); }, [preview, rotation]); + const resetViewport = () => { + setViewport({ scale: 1, offsetX: 0, offsetY: 0 }); + }; + const handleWheel = (event: React.WheelEvent) => { + event.preventDefault(); + const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; + setViewport((current) => ({ + ...current, + scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)), + })); + }; + const handlePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + panRef.current = { + active: true, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + offsetX: viewport.offsetX, + offsetY: viewport.offsetY, + }; + setIsPanning(true); + event.currentTarget.setPointerCapture(event.pointerId); + }; + const handlePointerMove = (event: React.PointerEvent) => { + const dragState = panRef.current; + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + setViewport((current) => ({ + ...current, + offsetX: dragState.offsetX + event.clientX - dragState.startX, + offsetY: dragState.offsetY + event.clientY - dragState.startY, + })); + }; + const stopPointerDrag = (event: React.PointerEvent) => { + const dragState = panRef.current; + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + panRef.current = { ...dragState, active: false }; + setIsPanning(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + return ( - +
+
+ +
+ +
); } @@ -318,9 +440,9 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) { 'XYZ', )); return [ - { id: 'X', color: '#ef4444', vector: new THREE.Vector3(1, 0, 0).applyMatrix4(rotation) }, - { id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, 1, 0).applyMatrix4(rotation) }, - { id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, 1).applyMatrix4(rotation) }, + { id: 'X', color: '#ef4444', vector: new THREE.Vector3(pose.flipX ? -1 : 1, 0, 0).applyMatrix4(rotation) }, + { id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, pose.flipY ? -1 : 1, 0).applyMatrix4(rotation) }, + { id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, pose.flipZ ? -1 : 1).applyMatrix4(rotation) }, ] .map((axis) => ({ ...axis, @@ -331,7 +453,7 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) { opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45, })) .sort((a, b) => b.vector.z - a.vector.z); - }, [pose.rotateX, pose.rotateY, pose.rotateZ]); + }, [pose.rotateX, pose.rotateY, pose.rotateZ, pose.flipX, pose.flipY, pose.flipZ]); return (
@@ -609,7 +731,12 @@ function NativeStlViewer({ poseGroup.position.set(0, 0, 0); pivotGroup.position.set(0, 0, 0); baseScale = 4.2 / maxSize; - pivotGroup.scale.setScalar(baseScale * poseRef.current.scale); + const initialPoseScale = baseScale * poseRef.current.scale; + pivotGroup.scale.set( + poseRef.current.flipX ? -initialPoseScale : initialPoseScale, + poseRef.current.flipY ? -initialPoseScale : initialPoseScale, + poseRef.current.flipZ ? -initialPoseScale : initialPoseScale, + ); camera.lookAt(0, 0, 0); setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); } @@ -639,7 +766,12 @@ function NativeStlViewer({ THREE.MathUtils.degToRad(currentPose.rotateY), THREE.MathUtils.degToRad(currentPose.rotateZ), ); - pivotGroup.scale.setScalar(baseScale * currentPose.scale); + const poseScale = baseScale * currentPose.scale; + pivotGroup.scale.set( + currentPose.flipX ? -poseScale : poseScale, + currentPose.flipY ? -poseScale : poseScale, + currentPose.flipZ ? -poseScale : poseScale, + ); renderer.render(scene, camera); animationId = window.requestAnimationFrame(animate); }; @@ -812,6 +944,12 @@ export default function ProjectLibrary({ ]; const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0; + const dicomSliceTotal = sliceTotal || selectedProject?.dicomCount || 0; + const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0); + const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex)); + const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal); + const dicomSliderValue = dicomMaxSlice - safeDicomSlice; + const dicomSlicePercent = dicomMaxSlice > 0 ? (dicomSliderValue / dicomMaxSlice) * 100 : 0; const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0]; const savedSegmentationResults = selectedProject?.segmentationResults ?? []; const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; @@ -955,8 +1093,9 @@ export default function ProjectLibrary({ nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]); }); setModuleStyles(nextStyles); - setModelPose(latestResult?.pose ?? defaultModelPose); - setResultPose(latestResult?.pose ?? defaultModelPose); + const nextPose = normalizeModelPose(latestResult?.pose); + setModelPose(nextPose); + setResultPose(nextPose); setSliceIndex(0); setDicomPreview(null); setDicomError(''); @@ -994,15 +1133,17 @@ export default function ProjectLibrary({ useEffect(() => { const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1]; + const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const next: Record = {}; stlFiles.forEach((fileName, index) => { next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]); }); setModuleStyles(next); setSliceIndex(0); - setModelPose(latestResult?.pose ?? defaultModelPose); - setResultPose(latestResult?.pose ?? defaultModelPose); - setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0))); + const nextPose = normalizeModelPose(latestResult?.pose); + setModelPose(nextPose); + setResultPose(nextPose); + setResultPreviewSlice(Math.max(0, Math.min(maxIndex, latestResult?.mappingSlice ?? maxIndex))); setResultDisplayMode('soft'); setResultRotation(0); }, [selectedProject?.id]); @@ -1163,6 +1304,22 @@ export default function ProjectLibrary({ })); }; + const toggleModelFlip = (key: ModelPoseFlipKey) => { + setModelPose((current) => ({ + ...current, + [key]: !current[key], + })); + }; + + const resetModelFlipPose = () => { + setModelPose((current) => ({ + ...current, + flipX: false, + flipY: false, + flipZ: false, + })); + }; + const rotateDicom = (delta: number) => { setRotation((current) => ((current + delta) % 360 + 360) % 360); }; @@ -1178,8 +1335,9 @@ export default function ProjectLibrary({ const link = document.createElement('a'); const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane; const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode; + const displaySlice = getDicomDisplaySliceNumber(dicomPreview.slice, dicomPreview.total); link.href = canvas.toDataURL('image/png'); - link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`; + link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${displaySlice}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`; document.body.appendChild(link); link.click(); link.remove(); @@ -1618,7 +1776,7 @@ export default function ProjectLibrary({ key={option.id} onClick={() => { setPlane(option.id); - setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256); + setSliceIndex(0); }} className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${ plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white' @@ -1678,38 +1836,15 @@ export default function ProjectLibrary({
WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label} - 第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张 + 第 {selectedProject.dicomCount ? dicomDisplaySlice : 0} / {dicomSliceTotal || selectedProject.dicomCount} 张
{/* Right: Vertical Progress Bar */}
切片 - {sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount} + {dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount} - - setSliceIndex(Number(e.target.value))} - className="flex-1 w-6 accent-blue-600 cursor-pointer" - style={{ writingMode: 'vertical-lr', direction: 'rtl' }} - /> +
+
+
+ setSliceIndex(dicomMaxSlice - Number(event.target.value))} + className="mapping-slice-dark-vertical-input" + aria-label="项目库 DICOM 切片导航" + /> +
+ - #{sliceIndex + 1} + #{dicomDisplaySlice}
+
+
+ {modelPoseFlipOptions.map((item) => { + const Icon = item.icon; + const enabled = modelPose[item.key]; + return ( + + ); + })} +
{[ { key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 }, { key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 }, @@ -2048,6 +2240,9 @@ export default function ProjectLibrary({ TY {formatPoseCompactValue(latestResultPose.translateY, 3)} TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)} Scale {formatPoseCompactValue(latestResultPose.scale, 3)} + FX {latestResultPose.flipX ? '开' : '关'} + FY {latestResultPose.flipY ? '开' : '关'} + FZ {latestResultPose.flipZ ? '开' : '关'}
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 14ca5d6..5599f98 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -13,6 +13,9 @@ import { RefreshCcw, Save, Upload, + FlipHorizontal2, + FlipVertical2, + Move3d, } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; @@ -32,7 +35,8 @@ export interface ModelPreviewPayload { export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; export type DicomOpacityLevel = 'low' | 'medium' | 'high'; export type MappingDisplayMode = DicomPreview['mode']; -type ModelPoseKey = keyof ModelPose; +type ModelPoseKey = Exclude; +type ModelPoseFlipKey = Extract; type PoseDraftValues = Record; type AxisKey = 'x' | 'y' | 'z'; @@ -55,6 +59,11 @@ interface WorkspaceLoadState { } const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale']; +const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [ + { key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 }, + { key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 }, + { key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d }, +]; export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, @@ -91,6 +100,9 @@ const defaultModelPose: ModelPose = { translateY: 0, translateZ: 0, scale: 1, + flipX: false, + flipY: false, + flipZ: false, }; const defaultSavedPoses: SavedModelPose[] = [ @@ -110,7 +122,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri ]; const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [ { id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' }, - { id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' }, + { id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' }, ]; const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const fusionBaseExtent = 4.6; @@ -195,6 +207,23 @@ function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } +function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) { + const total = Math.max(Math.round(totalSlices), 0); + if (!total) { + return 0; + } + return total - clamp(Math.round(sliceIndex), 0, total - 1); +} + +function getDicomDisplayRange(startIndex: number, endIndex: number, totalSlices: number) { + const first = getDicomDisplaySliceNumber(startIndex, totalSlices); + const second = getDicomDisplaySliceNumber(endIndex, totalSlices); + return { + start: Math.min(first, second), + end: Math.max(first, second), + }; +} + function getStepPrecision(step: number) { if (step >= 1) { return 0; @@ -271,6 +300,14 @@ function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPo normalized[key] = clamp(numericValue, limit.min, limit.max); hasPoseValue = true; }); + modelPoseFlipOptions.forEach(({ key }) => { + const rawValue = input[key]; + if (typeof rawValue !== 'boolean') { + return; + } + normalized[key] = rawValue; + hasPoseValue = true; + }); return hasPoseValue ? normalized : null; } @@ -327,7 +364,8 @@ function mergeImportedModelPoses(imported: SavedModelPose[]) { } function poseValuesMatch(left: ModelPose, right: ModelPose) { - return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6); + return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6) + && modelPoseFlipOptions.every(({ key }) => left[key] === right[key]); } function stableModuleStyles(styles: Record) { @@ -852,7 +890,12 @@ export function FusionThreeView({ pose.translateY, pose.translateZ, ); - modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale); + const poseScale = modelBaseScale * pose.scale; + modelPoseGroup.scale.set( + pose.flipX ? -poseScale : poseScale, + pose.flipY ? -poseScale : poseScale, + pose.flipZ ? -poseScale : poseScale, + ); modelPoseGroup.updateMatrixWorld(true); const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup); const nextAxisSignature = axisProjectionSignature(nextAxisProjection); @@ -908,6 +951,8 @@ export function FusionThreeView({ viewPreset, ]); + const volumeDisplayRange = volume ? getDicomDisplayRange(volume.start, volume.end, volume.total) : null; + return (
@@ -924,7 +969,7 @@ export function FusionThreeView({ {status}
- DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0} + DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
@@ -2212,8 +2285,8 @@ export function VoxelizationMappingView({ type="range" min="0" max={maxSlice} - value={safeSlice} - onChange={(event) => onSliceChange(Number(event.target.value))} + value={sliderSliceValue} + onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))} className="mapping-slice-dark-vertical-input" aria-label="项目库逆向分割映射视图切片导航" /> @@ -2236,7 +2309,7 @@ export function VoxelizationMappingView({ Overlay Label Map - Z {safeSlice + 1}/{Math.max(totalSlices, 1)} + Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
顶层 {Math.max(totalSlices, 1)} - 当前 {safeSlice + 1} + 当前 {displaySliceNumber} 底层 1
@@ -2707,9 +2780,14 @@ export default function ReverseWorkspace({ setSliceStart(restoredSliceStart); setSliceEnd(restoredSliceEnd); setMappingSlice(restoredMappingSlice); - const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses; + const nextPoses = (item.modelPoses?.length ? item.modelPoses : defaultSavedPoses).map((pose) => ({ + ...pose, + pose: normalizePoseValue(pose.pose) ?? defaultModelPose, + })); const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0]; - const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose; + const restoredPose = normalizePoseValue(latestResult?.pose) + ?? normalizePoseValue(preferredPose?.pose) + ?? defaultModelPose; initialZStretchRef.current = { projectId: item.id, pending: !latestResult }; setModelPose(restoredPose); setPoseValueDrafts(formatPoseDraftValues(restoredPose)); @@ -2919,6 +2997,30 @@ export default function ReverseWorkspace({ setPoseImportStatus(''); }; + const toggleModelFlip = (key: ModelPoseFlipKey) => { + const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null; + setModelPose((current) => ({ + ...current, + [key]: !current[key], + })); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + restoreVisualToolbarScroll(scrollTop); + }; + + const resetModelFlipPose = () => { + const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null; + setModelPose((current) => ({ + ...current, + flipX: false, + flipY: false, + flipZ: false, + })); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + restoreVisualToolbarScroll(scrollTop); + }; + const updateModuleStyle = (fileName: string, partial: Partial) => { const stlFiles = project?.stlFiles ?? []; const index = Math.max(0, stlFiles.indexOf(fileName)); @@ -3019,6 +3121,7 @@ export default function ReverseWorkspace({ const safeMappingSlice = clamp(mappingSlice, 0, maxSlice); const displayStart = Math.min(safeSliceStart, safeSliceEnd); const displayEnd = Math.max(safeSliceStart, safeSliceEnd); + const displaySliceRange = getDicomDisplayRange(displayStart, displayEnd, project?.dicomCount ?? 0); const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0; const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0; const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0]; @@ -3339,7 +3442,7 @@ export default function ReverseWorkspace({
- Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0} + Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
@@ -3398,7 +3501,7 @@ export default function ReverseWorkspace({

DICOM 切片范围

- {displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0} + {displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
@@ -3433,9 +3536,9 @@ export default function ReverseWorkspace({ />
- 起点 {safeSliceStart + 1} + 起点 {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)} 范围 - 终点 {safeSliceEnd + 1} + 终点 {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}
@@ -3508,7 +3611,7 @@ export default function ReverseWorkspace({

- 按 DICOM 切片范围 {displayStart + 1}-{displayEnd + 1} 保留模型中间区域 + 按 DICOM 切片范围 {displaySliceRange.start}-{displaySliceRange.end} 保留模型中间区域

@@ -3556,7 +3659,7 @@ export default function ReverseWorkspace({ {poseImportStatus}

)} -
+
+ +
+
+ {modelPoseFlipOptions.map((item) => { + const Icon = item.icon; + const enabled = modelPose[item.key]; + return ( + + ); + })}
{[ diff --git a/WebSite/src/index.css b/WebSite/src/index.css index 512b44b..9fa361e 100644 --- a/WebSite/src/index.css +++ b/WebSite/src/index.css @@ -145,14 +145,15 @@ .mapping-slice-vertical-input::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; - background: #2563eb; - border: 3px solid #ffffff; - border-radius: 9999px; - box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + background: #60a5fa; + border: 2px solid #dbeafe; + border-radius: 5px; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28); cursor: grab; - height: 22px; - margin-left: -7px; - width: 22px; + height: 18px; + margin-left: -5px; + transform: rotate(45deg); + width: 18px; } .mapping-slice-vertical-input::-moz-range-track { @@ -162,13 +163,14 @@ } .mapping-slice-vertical-input::-moz-range-thumb { - background: #2563eb; - border: 3px solid #ffffff; - border-radius: 9999px; - box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + background: #60a5fa; + border: 2px solid #dbeafe; + border-radius: 5px; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28); cursor: grab; - height: 16px; - width: 16px; + height: 14px; + transform: rotate(45deg); + width: 14px; } .mapping-slice-vertical-input:active::-webkit-slider-thumb { @@ -207,14 +209,15 @@ .mapping-slice-dark-vertical-input::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; - background: #22d3ee; - border: 3px solid #0f172a; - border-radius: 9999px; - box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + background: #60a5fa; + border: 2px solid #dbeafe; + border-radius: 5px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45); cursor: grab; - height: 20px; - margin-left: -7px; - width: 20px; + height: 18px; + margin-left: -6px; + transform: rotate(45deg); + width: 18px; } .mapping-slice-dark-vertical-input::-moz-range-track { @@ -224,12 +227,13 @@ } .mapping-slice-dark-vertical-input::-moz-range-thumb { - background: #22d3ee; - border: 3px solid #0f172a; - border-radius: 9999px; - box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + background: #60a5fa; + border: 2px solid #dbeafe; + border-radius: 5px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45); cursor: grab; height: 14px; + transform: rotate(45deg); width: 14px; } diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index 3bae540..5103e0b 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -40,6 +40,9 @@ export interface ModelPose { translateY: number; translateZ: number; scale: number; + flipX: boolean; + flipY: boolean; + flipZ: boolean; } export interface SavedModelPose { diff --git a/工程分析/实现方案-2026-05-24-10-45-43.md b/工程分析/实现方案-2026-05-24-10-45-43.md new file mode 100644 index 0000000..4d6558f --- /dev/null +++ b/工程分析/实现方案-2026-05-24-10-45-43.md @@ -0,0 +1,68 @@ +# 实现方案-2026-05-24-10-45-43 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-24-10-45-43.md` + +## 修改目标 + +- 定位并修正分割映射显示为空心线段的问题,使二维映射区域默认填充显示。 +- 为模型位姿增加 X/Y/Z 镜像翻转开关,并贯通保存、导入、导出、前后端类型和渲染。 +- 修改“构件分别导出”包结构,将所有构件文件集中到同一目录层级。 +- 为项目库 DICOM 首页预览增加滚轮缩放、拖拽平移和位置重置。 +- 修正 DICOM 切片编号显示与滑块样式。 +- 同步更新 `Docker部署/README.md`,说明容器构建会包含这些前后端能力和生产模式注意事项。 + +## 涉及路径 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/types.ts` +- `WebSite/server.ts` +- `Docker部署/README.md` +- `工程分析/需求分析-2026-05-24-10-45-43.md` +- `工程分析/实现方案-2026-05-24-10-45-43.md` +- `工程分析/测试方案-2026-05-24-10-45-43.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 使用 `rg` 定位分割映射、DICOM 预览、位姿控件、导出包逻辑。 +2. 梳理现有 `ModelPoseValue` 与服务端导出函数,增加镜像字段和默认值。 +3. 在前端三维/二维变换路径中使用统一位姿对象,新增镜像按钮组。 +4. 调整二维分割映射绘制逻辑,优先使用闭合轮廓填充;对 sparse/edge-only 结果提供填充兜底。 +5. 修改导出包 `separate` 模式的目录结构。 +6. 为项目库 DICOM 画布加入 viewport state:scale、offset、dragging、reset。 +7. 封装或局部实现 DICOM 切片显示编号反向映射与暗色菱形 range 样式。 +8. 更新 Docker 部署说明。 + +## 执行步骤 + +1. 创建本次三件套,最终执行前再次阅读 `经验记录.md`。 +2. 阅读相关源码和 API,确认现有数据结构。 +3. 修改类型、服务端和前端实现。 +4. 更新 Docker 部署文档。 +5. 执行 `npm run lint`、`npm run build`。 +6. 重新部署并验证本机、公网、关键 API。 +7. 追加经验记录。 +8. 提交并推送 Gitea。 + +## 兼容性与回滚方案 + +- 新增镜像字段设置默认值,旧状态文件和旧位姿导出没有该字段时按不翻转处理。 +- 导出包结构改变只影响 `separate` 模式;如需回滚,可恢复服务端包路径生成逻辑。 +- DICOM 显示缩放/拖动仅改变前端视图,不改变后端切片、导出或空间基准。 +- 所有历史文档可从 Git 历史恢复。 + +## 预计文件变更 + +- 前端组件和类型文件。 +- 后端导出、位姿解析和体素坐标变换逻辑。 +- Docker 部署说明。 +- 本次工程分析与经验记录。 + +## 提交与部署策略 + +- commit message:`2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互` +- 提交范围包含本次源码、Docker 文档和工程分析文档。 +- 部署使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`,并保留现有 frpc 容器公网入口。 diff --git a/工程分析/测试方案-2026-05-24-10-45-43.md b/工程分析/测试方案-2026-05-24-10-45-43.md new file mode 100644 index 0000000..6773d49 --- /dev/null +++ b/工程分析/测试方案-2026-05-24-10-45-43.md @@ -0,0 +1,50 @@ +# 测试方案-2026-05-24-10-45-43 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-24-10-45-43.md` + +## 静态检查 + +- `git status --short --branch`:确认只包含本次相关文件。 +- `rg` 检查新增镜像字段在前端、后端和类型中均有默认值与使用点。 +- 检查 Docker 部署文档已同步说明。 + +## 构建检查 + +- `cd WebSite && npm run lint` +- `cd WebSite && npm run build` + +## 关键业务场景验证 + +- 项目库 DICOM 首页:滚轮缩放、拖拽平移、位置重置可用。 +- 逆向工作区:X/Y/Z 镜像翻转按钮可改变模型位姿,并能保存/导出。 +- 分割映射:构件区域以填充实体显示,不再只呈现稀疏线段。 +- 导出项目结果:`separate` 模式中所有构件 NIfTI 文件位于同一目录层级。 +- DICOM 切片编号:初始显示符合用户期望,滑块视觉接近截图 5。 + +## 医学影像数据相关边界验证 + +- 默认 DICOM/STL 项目仍可加载。 +- 分割填充显示不改变 DICOM 图像本身。 +- 导出的 NIfTI 文件仍能生成 `.nii` 或 `.nii.gz`。 +- 镜像翻转参与导出坐标变换,不只停留在 UI。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证 `http://127.0.0.1:4000/api/health`。 +- 验证 `http://127.0.0.1:4000/`。 +- 验证 `https://revoxel.huijutec.cn/` 与公网 API。 + +## Git/Gitea 备份验证 + +- commit message 包含 `2026-05-24-10-45-43`。 +- 推送后确认远端 `main` 指向新提交。 + +## 风险与回归关注点 + +- 分割填充不能把构件之间错误合并。 +- 镜像翻转字段必须向后兼容旧状态。 +- 切片显示编号与实际 `sliceIndex` 请求不能错位。 +- 导出包结构改变应在文档和最终汇报中明确。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index df15063..f1013dc 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1657,3 +1657,21 @@ C. 解决问题方案 D. 后续如何避免问题 后续做程序修改时优先阅读核心三文档和当次三件套,不要在目录中长期堆积几十个历史流水文件。若某次需求产生可复用经验,应写入 `经验记录.md`;若只是一时执行细节,则交由 Git commit 历史保存。文档治理提交前必须确认没有混入源码、医学数据或运行态文件。 + +## 2026-05-24-10-45-43 分割显示、镜像位姿、DICOM 顺序与导出包结构联动修正 + +A. 具体问题 + +逆向分割映射中部分 STL 构件在二维 DICOM 切片上显示为线段或横向连接线,不像填充实体;模型位姿缺少以模型中心沿 X/Y/Z 轴镜像翻转的能力;“构件分别导出”需要所有构件 NIfTI 集中在同一目录;项目库 DICOM 首页缺少滚轮缩放、拖拽平移和位置重置;项目库和工作区的 DICOM 切片编号使用了数组索引顺序,初始显示为 `1/300` 而不是用户期望的 `300/300`,滑条也被误读成进度条。 + +B. 产生问题原因 + +前端叠加层虽然已有扫描线填充,但在交点稀疏或切面轮廓退化时仍会绘制大量切面线段,视觉上变成“线连接”;服务端导出只保留扫描线交点,缺少稀疏轮廓兜底。`ModelPose` 只包含旋转、平移、缩放,保存、预览、导出都没有镜像字段。DICOM 预览使用零基索引直显,未转换为医学查看习惯的反向层号。自写 tar 导出头只写 100 字节 name 字段,长中文路径会被截断,集中目录后更容易暴露。 + +C. 解决问题方案 + +为 `ModelPose` 和 `ModelPoseValue` 增加 `flipX/flipY/flipZ`,在默认值、归一化、保存、导入、三维预览、二维映射和服务端 NIfTI 导出中统一生效。二维叠加层改为优先实体填充,稀疏切面用闭合外轮廓兜底,已有填充结果不再叠加粗线段;服务端导出同步增加闭合外轮廓兜底写入。项目库 DICOM 首页新增独立 viewport 状态,支持滚轮缩放、拖拽平移和位置重置。DICOM 显示层号统一使用 `total - sliceIndex`,滑条值反向映射且使用菱形非进度条样式。`separate` 导出改到 `segmentation-parts/` 同一目录,并给 tar 生成补充 PAX path 头,保证中文长路径和 `.nii.gz` 扩展完整。Docker Compose 镜像标签和 README 同步到 20260524 能力说明。 + +D. 后续如何避免问题 + +凡是新增位姿字段,必须同时检查前端类型、默认状态、保存/导入、三维渲染、二维映射、服务端归一化、导出坐标变换和项目库回放,不能只改一个界面。涉及 DICOM 层号时要明确区分“数组索引”和“对用户显示的医学层号”。导出压缩包如果包含中文或深层路径,必须用 `tar -tzf` 验证路径完整,不要依赖 100 字节 tar name 字段。分割显示的视觉修正也应同步确认导出数据路径,避免 UI 看似实体而 NIfTI 仍是轮廓。 diff --git a/工程分析/需求分析-2026-05-24-10-45-43.md b/工程分析/需求分析-2026-05-24-10-45-43.md new file mode 100644 index 0000000..5f0a383 --- /dev/null +++ b/工程分析/需求分析-2026-05-24-10-45-43.md @@ -0,0 +1,60 @@ +# 需求分析-2026-05-24-10-45-43 + +## 开始时间 + +2026-05-24-10-45-43 + +## 原始需求摘要 + +用户要求修正逆向工作区和项目库中的分割显示、镜像翻转、导出结构、DICOM 预览交互和 DICOM 切片序号/滚动条样式问题,并同步修正 `Docker部署/` 中相关内容。 + +## 业务目标 + +- 让二维分割映射中的所有构件尽量以填充实体呈现,避免只显示为线段连接的空心效果。 +- 在模型位姿中增加沿 X/Y/Z 轴、以模型中心为基准的镜像翻转能力。 +- 调整“构件分别导出”结果结构,方便后续统一处理各构件 NIfTI 文件。 +- 项目库 DICOM 首页支持滚轮缩放、拖拽移动和位置重置。 +- 修正项目库与工作区 DICOM 切片显示顺序和滑块样式,使切片编号符合用户观察习惯。 +- 保证 Docker 部署说明和容器化运行包含本次能力。 + +## 输入与输出 + +- 输入: + - 用户提供的 5 张截图与问题描述。 + - 当前 React/Express 一体工程源码。 + - 现有 Docker 部署目录。 +- 输出: + - 前端 DICOM/分割/位姿 UI 与交互修正。 + - 后端导出结构修正。 + - 类型、构建、部署验证结果。 + - 更新后的 `Docker部署/` 说明或配置。 + - 本次工程分析文档、经验记录和 Git/Gitea 备份提交。 + +## 影响范围 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/types.ts` +- `WebSite/server.ts` +- `Docker部署/` +- `工程分析/` + +## 关键约束 + +- 不伪造真实医学级算法能力;如果显示填充来自二维映射渲染增强,应明确其与导出体素结果的关系。 +- 镜像翻转必须纳入保存位姿、导出位姿和服务端导出计算链路,避免 UI 与导出不一致。 +- DICOM/STL 空间基准不能因显示缩放或拖动而改变。 +- 提交时避免混入运行态数据、医学数据和无关文件。 + +## 风险点 + +- 现有分割映射可能基于 STL 与 DICOM 切面交线或投影采样,空心线段与真实截面、视角或采样算法有关;直接填充需要避免把多个不连通区域错误合并。 +- 镜像翻转会影响三维显示、二维映射、保存位姿和导出体素化坐标,需要统一变换模型。 +- NIfTI 导出包结构改变可能影响已有用户脚本,需兼容命名清晰。 +- 切片顺序修正需要兼顾 axial/sagittal/coronal 与已有 API `sliceIndex` 约定。 + +## 待确认问题或默认假设 + +- 默认假设:用户希望前端所有分割映射默认显示为填充实体,并且导出体素结果也应按填充体素生成,而不是仅导出轮廓线。 +- 默认假设:“构建分别导出”指“构件分别导出”,目标是一个导出包内统一放置所有构件 NIfTI 文件,而不是为每个构件再套一个独立目录。 +- 默认假设:切片编号应按用户看到的顺序显示,初始页显示最后一张即 `300 / 300`,滑块视觉采用截图 5 的暗色轨道和蓝色菱形滑块样式。