From aa0d51316e601105d11e6cfb7c75dd129fcf2f28 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 7 May 2026 16:26:57 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-07-16-20-46=20=E4=BF=AE=E6=AD=A3DICOM?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E5=92=8C3D=E9=BB=98=E8=AE=A4=E4=BD=8D?= =?UTF-8?q?=E5=A7=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 340 +++++++++++++++++++--- WebSite/src/components/ProjectLibrary.tsx | 294 +++++++++++++++---- WebSite/src/lib/api.ts | 3 +- WebSite/src/types.ts | 63 ++++ 工程分析/实现方案-2026-05-04-05-56-34.md | 85 ++++++ 工程分析/实现方案-2026-05-07-16-20-46.md | 46 +++ 工程分析/测试方案-2026-05-04-05-56-34.md | 68 +++++ 工程分析/测试方案-2026-05-07-16-20-46.md | 45 +++ 工程分析/经验记录.md | 72 +++++ 工程分析/需求分析-2026-05-04-05-56-34.md | 49 ++++ 工程分析/需求分析-2026-05-07-16-20-46.md | 34 +++ 11 files changed, 1012 insertions(+), 87 deletions(-) create mode 100644 工程分析/实现方案-2026-05-04-05-56-34.md create mode 100644 工程分析/实现方案-2026-05-07-16-20-46.md create mode 100644 工程分析/测试方案-2026-05-04-05-56-34.md create mode 100644 工程分析/测试方案-2026-05-07-16-20-46.md create mode 100644 工程分析/需求分析-2026-05-04-05-56-34.md create mode 100644 工程分析/需求分析-2026-05-07-16-20-46.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 29b7f53..4e426b1 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -63,9 +63,37 @@ const dicomVolumeCache = new Map(); const modelPreviewCache = new Map(); +interface DicomAttributes { + patientName: string; + patientId: string; + studyDate: string; + studyDescription: string; + seriesDescription: string; + modality: string; + manufacturer: string; + rows: number; + columns: number; + bitsAllocated: number; + pixelRepresentation: number; + windowCenter: number; + windowWidth: number; + rescaleIntercept: number; + rescaleSlope: number; + rowSpacing: number; + columnSpacing: number; + sliceThickness: number | null; + spacingBetweenSlices: number | null; + imagePosition: number[] | null; +} + function today() { return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()); } @@ -290,6 +318,64 @@ function readAsciiValue(buffer: Buffer, start: number, length: number) { return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim(); } +function readTagString(buffer: Buffer, group: number, element: number) { + const tag = findExplicitTag(buffer, group, element); + return tag ? readAsciiValue(buffer, tag.valueOffset, tag.length) : ''; +} + +function readTagUInt16(buffer: Buffer, group: number, element: number, fallback = 0) { + const tag = findExplicitTag(buffer, group, element); + return tag && tag.valueOffset + 1 < buffer.length ? buffer.readUInt16LE(tag.valueOffset) : fallback; +} + +function parseNumberList(value: string) { + return value + .split('\\') + .map((item) => Number.parseFloat(item.trim())) + .filter((item) => Number.isFinite(item)); +} + +function median(values: number[]) { + if (!values.length) { + return null; + } + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function parseDicomAttributes(buffer: Buffer, mode: DicomDisplayMode): DicomAttributes { + const fallbackCenter = Number.parseFloat(readTagString(buffer, 0x0028, 0x1050).split('\\')[0]) || 40; + const fallbackWidth = Number.parseFloat(readTagString(buffer, 0x0028, 0x1051).split('\\')[0]) || 400; + const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth); + const pixelSpacing = parseNumberList(readTagString(buffer, 0x0028, 0x0030)); + const imagePosition = parseNumberList(readTagString(buffer, 0x0020, 0x0032)); + const sliceThickness = Number.parseFloat(readTagString(buffer, 0x0018, 0x0050)); + const spacingBetweenSlices = Number.parseFloat(readTagString(buffer, 0x0018, 0x0088)); + + return { + patientName: readTagString(buffer, 0x0010, 0x0010) || '未知', + patientId: readTagString(buffer, 0x0010, 0x0020) || '未知', + studyDate: readTagString(buffer, 0x0008, 0x0020) || '未知', + studyDescription: readTagString(buffer, 0x0008, 0x1030) || '未知', + seriesDescription: readTagString(buffer, 0x0008, 0x103e) || '未知', + modality: readTagString(buffer, 0x0008, 0x0060) || '未知', + manufacturer: readTagString(buffer, 0x0008, 0x0070) || '未知', + rows: readTagUInt16(buffer, 0x0028, 0x0010), + columns: readTagUInt16(buffer, 0x0028, 0x0011), + bitsAllocated: readTagUInt16(buffer, 0x0028, 0x0100, 16), + pixelRepresentation: readTagUInt16(buffer, 0x0028, 0x0103), + windowCenter, + windowWidth, + rescaleIntercept: Number.parseFloat(readTagString(buffer, 0x0028, 0x1052)) || 0, + rescaleSlope: Number.parseFloat(readTagString(buffer, 0x0028, 0x1053)) || 1, + rowSpacing: pixelSpacing[0] || 1, + columnSpacing: pixelSpacing[1] || pixelSpacing[0] || 1, + sliceThickness: Number.isFinite(sliceThickness) ? Math.abs(sliceThickness) : null, + spacingBetweenSlices: Number.isFinite(spacingBetweenSlices) ? Math.abs(spacingBetweenSlices) : null, + imagePosition: imagePosition.length >= 3 ? imagePosition.slice(0, 3) : null, + }; +} + function findExplicitTag(buffer: Buffer, group: number, element: number) { const pattern = Buffer.from([ group & 0xff, @@ -332,46 +418,29 @@ function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fa function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') { const buffer = fs.readFileSync(filePath); - const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010); - const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011); - const bitsTag = findExplicitTag(buffer, 0x0028, 0x0100); - const representationTag = findExplicitTag(buffer, 0x0028, 0x0103); - const centerTag = findExplicitTag(buffer, 0x0028, 0x1050); - const widthTag = findExplicitTag(buffer, 0x0028, 0x1051); - const interceptTag = findExplicitTag(buffer, 0x0028, 0x1052); - const slopeTag = findExplicitTag(buffer, 0x0028, 0x1053); + const attrs = parseDicomAttributes(buffer, mode); const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); - - const rows = rowsTag ? buffer.readUInt16LE(rowsTag.valueOffset) : 0; - const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0; - const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16; - const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0; - const fallbackCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40; - const fallbackWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400; - const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth); - const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0; - const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1; const pixelOffset = pixelTag?.valueOffset ?? -1; const pixelLength = pixelTag?.length ?? 0; - if (!rows || !columns || pixelOffset < 0) { + if (!attrs.rows || !attrs.columns || pixelOffset < 0) { throw new Error('无法解析当前 DICOM 像素数据'); } - const count = rows * columns; + const count = attrs.rows * attrs.columns; const pixels = Buffer.alloc(count); - const min = windowCenter - windowWidth / 2; - const max = windowCenter + windowWidth / 2; + const min = attrs.windowCenter - attrs.windowWidth / 2; + const max = attrs.windowCenter + attrs.windowWidth / 2; for (let i = 0; i < count; i += 1) { - const position = pixelOffset + i * (bitsAllocated / 8); + const position = pixelOffset + i * (attrs.bitsAllocated / 8); if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) { break; } - const raw = bitsAllocated === 16 - ? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) + const raw = attrs.bitsAllocated === 16 + ? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) : buffer.readUInt8(position); - const hu = raw * rescaleSlope + rescaleIntercept; + const hu = raw * attrs.rescaleSlope + attrs.rescaleIntercept; let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); if (mode === 'contrast') { normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128))); @@ -379,15 +448,25 @@ function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') pixels[i] = normalized; } - const enhancedPixels = enhanceDicomEdges(pixels, columns, rows); + const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows); return { - width: columns, - height: rows, + width: attrs.columns, + height: attrs.rows, pixels: enhancedPixels.toString('base64'), - windowCenter, - windowWidth, + windowCenter: attrs.windowCenter, + windowWidth: attrs.windowWidth, mode, + spacing: { + row: attrs.rowSpacing, + column: attrs.columnSpacing, + slice: attrs.sliceThickness ?? attrs.spacingBetweenSlices ?? 1, + }, + physicalSize: { + width: attrs.columns * attrs.columnSpacing, + height: attrs.rows * attrs.rowSpacing, + }, + attributes: attrs, }; } @@ -399,6 +478,28 @@ function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') }; } +function estimateSliceSpacing(parsed: ReturnType[]) { + const positionDiffs: number[] = []; + for (let index = 1; index < parsed.length; index += 1) { + const previous = parsed[index - 1].attributes.imagePosition; + const current = parsed[index].attributes.imagePosition; + if (previous && current) { + const dx = current[0] - previous[0]; + const dy = current[1] - previous[1]; + const dz = current[2] - previous[2]; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (distance > 0.0001) { + positionDiffs.push(distance); + } + } + } + + return median(positionDiffs) + ?? parsed[0]?.attributes.spacingBetweenSlices + ?? parsed[0]?.attributes.sliceThickness + ?? 1; +} + function getDicomVolume(files: string[], mode: DicomDisplayMode) { const cached = dicomVolumeCache.get(mode); if (cached) { @@ -406,17 +507,59 @@ function getDicomVolume(files: string[], mode: DicomDisplayMode) { } const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode)); + const sliceSpacing = estimateSliceSpacing(parsed); const volume = { frames: parsed.map((frame) => frame.pixelBuffer), width: parsed[0]?.width ?? 0, height: parsed[0]?.height ?? 0, windowCenter: parsed[0]?.windowCenter ?? 40, windowWidth: parsed[0]?.windowWidth ?? 400, + rowSpacing: parsed[0]?.attributes.rowSpacing ?? 1, + columnSpacing: parsed[0]?.attributes.columnSpacing ?? 1, + sliceSpacing, + sliceThickness: parsed[0]?.attributes.sliceThickness ?? null, + spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null, }; dicomVolumeCache.set(mode, volume); return volume; } +function resampleNearest(pixels: Buffer, width: number, height: number, targetWidth: number, targetHeight: number) { + if (width === targetWidth && height === targetHeight) { + return pixels; + } + + const output = Buffer.alloc(targetWidth * targetHeight); + for (let y = 0; y < targetHeight; y += 1) { + const sourceY = Math.min(height - 1, Math.floor((y / targetHeight) * height)); + for (let x = 0; x < targetWidth; x += 1) { + const sourceX = Math.min(width - 1, Math.floor((x / targetWidth) * width)); + output[y * targetWidth + x] = pixels[sourceY * width + sourceX]; + } + } + return output; +} + +function resampleToPhysicalAspect(pixels: Buffer, width: number, height: number, xSpacing: number, ySpacing: number) { + const physicalWidth = width * xSpacing; + const physicalHeight = height * ySpacing; + const unit = Math.max(0.001, Math.min(xSpacing, ySpacing)); + let targetWidth = Math.max(1, Math.round(physicalWidth / unit)); + let targetHeight = Math.max(1, Math.round(physicalHeight / unit)); + const maxDimension = 960; + const scale = Math.min(1, maxDimension / Math.max(targetWidth, targetHeight)); + targetWidth = Math.max(1, Math.round(targetWidth * scale)); + targetHeight = Math.max(1, Math.round(targetHeight * scale)); + + return { + width: targetWidth, + height: targetHeight, + pixels: resampleNearest(pixels, width, height, targetWidth, targetHeight), + physicalWidth, + physicalHeight, + }; +} + function warmDicomVolumeCache(files: string[]) { setTimeout(() => { try { @@ -447,11 +590,13 @@ function createReformattedPreview(files: string[], plane: Exclude 0.0001) { + diffs.push(distance); + } + } + } + + return { + value: median(diffs) + ?? attributes[0]?.spacingBetweenSlices + ?? attributes[0]?.sliceThickness + ?? 1, + source: diffs.length ? 'ImagePositionPatient' : attributes[0]?.spacingBetweenSlices ? 'SpacingBetweenSlices' : attributes[0]?.sliceThickness ? 'SliceThickness' : '默认 1mm', + }; +} + +function formatNumber(value: number | null | undefined, digits = 3) { + return typeof value === 'number' && Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +} + +function createDicomInfo(project: ProjectRecord, files: string[]) { + const attributes = files.map((fileName) => { + const buffer = fs.readFileSync(path.join(dicomDir, fileName)); + return parseDicomAttributes(buffer, 'default'); + }); + const first = attributes[0]; + const last = attributes[attributes.length - 1]; + const sliceSpacing = estimateSliceSpacingFromAttributes(attributes); + const physicalWidth = first.columns * first.columnSpacing; + const physicalHeight = first.rows * first.rowSpacing; + const physicalDepth = Math.max(files.length - 1, 0) * sliceSpacing.value; + + return { + project: { + id: project.id, + name: project.name, + dicomPath: project.dicomPath, + }, + patient: { + name: first.patientName, + id: first.patientId, + }, + study: { + date: first.studyDate, + description: first.studyDescription, + modality: first.modality, + manufacturer: first.manufacturer, + }, + series: { + description: first.seriesDescription, + files: files.length, + firstFile: files[0] ?? '', + lastFile: files[files.length - 1] ?? '', + }, + image: { + rows: first.rows, + columns: first.columns, + bitsAllocated: first.bitsAllocated, + pixelRepresentation: first.pixelRepresentation, + windowCenter: first.windowCenter, + windowWidth: first.windowWidth, + rescaleIntercept: first.rescaleIntercept, + rescaleSlope: first.rescaleSlope, + }, + spacing: { + row: formatNumber(first.rowSpacing), + column: formatNumber(first.columnSpacing), + slice: formatNumber(sliceSpacing.value), + sliceSource: sliceSpacing.source, + sliceThickness: formatNumber(first.sliceThickness), + spacingBetweenSlices: formatNumber(first.spacingBetweenSlices), + }, + physicalSize: { + width: formatNumber(physicalWidth), + height: formatNumber(physicalHeight), + depth: formatNumber(physicalDepth), + unit: 'mm', + }, + position: { + firstImagePosition: first.imagePosition, + lastImagePosition: last?.imagePosition ?? null, + }, + }; +} + async function startServer() { const app = express(); const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; @@ -799,6 +1049,26 @@ async function startServer() { } }); + app.get('/api/projects/:projectId/dicom-info', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可查询的 DICOM 文件' }); + return; + } + + try { + res.json(createDicomInfo(project, files)); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' }); + } + }); + app.get('/api/projects/:projectId/models/:fileName', (req, res) => { const project = findProject(readState(), req.params.projectId); const fileName = path.basename(req.params.fileName); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 58da0ae..baee127 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -8,6 +8,7 @@ import { RotateCcw, Box, Image as ImageIcon, + Info, ChevronRight, ChevronUp, ChevronDown, @@ -20,12 +21,12 @@ import { Upload } from 'lucide-react'; import * as THREE from 'three'; -import { DicomPreview, Project } from '../types'; +import { DicomInfo, DicomPreview, Project } from '../types'; import { api, downloadDicomArchive, downloadMask } from '../lib/api'; type Plane = 'axial' | 'sagittal' | 'coronal'; type DisplayMode = DicomPreview['mode']; -type SolidityLevel = 'preview' | 'standard' | 'fine'; +type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra'; interface ModuleStyle { visible: boolean; @@ -41,7 +42,6 @@ interface ModelPose { translateY: number; translateZ: number; scale: number; - autoRotate: boolean; } interface ModelPreviewPayload { @@ -56,6 +56,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number } { id: 'preview', label: '预览', limit: 6000 }, { id: 'standard', label: '标准', limit: 16000 }, { id: 'fine', label: '精细', limit: 36000 }, + { id: 'ultra', label: '超精细', limit: 72000 }, ]; const defaultModelPose: ModelPose = { rotateX: 0, @@ -65,7 +66,6 @@ const defaultModelPose: ModelPose = { translateY: 0, translateZ: 0, scale: 1, - autoRotate: true, }; function drawFallbackModelPreview( @@ -181,6 +181,13 @@ function safeFilePart(value: string) { return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom'; } +function displayDicomValue(value: string | number | null | undefined) { + if (value === null || value === undefined || value === '') { + return '未知'; + } + return String(value); +} + function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) { const canvasRef = useRef(null); @@ -205,18 +212,19 @@ function NativeStlViewer({ files, styles, detailLimit, - solidWhite, pose, + onPoseChange, }: { projectId: string; files: string[]; styles: Record; detailLimit: number; - solidWhite: boolean; pose: ModelPose; + onPoseChange: React.Dispatch>; }) { const containerRef = useRef(null); const poseRef = useRef(pose); + const onPoseChangeRef = useRef(onPoseChange); const [progress, setProgress] = useState(0); const [status, setStatus] = useState('准备加载模型'); @@ -224,6 +232,92 @@ function NativeStlViewer({ poseRef.current = pose; }, [pose]); + useEffect(() => { + onPoseChangeRef.current = onPoseChange; + }, [onPoseChange]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const clampPose = (next: ModelPose): ModelPose => ({ + rotateX: Math.max(-180, Math.min(180, next.rotateX)), + rotateY: Math.max(-180, Math.min(180, next.rotateY)), + rotateZ: Math.max(-180, Math.min(180, next.rotateZ)), + translateX: Math.max(-2, Math.min(2, next.translateX)), + translateY: Math.max(-2, Math.min(2, next.translateY)), + translateZ: Math.max(-2, Math.min(2, next.translateZ)), + scale: Math.max(0.5, Math.min(2.5, next.scale)), + }); + const dragState = { + active: false, + mode: 'rotate' as 'rotate' | 'pan', + pointerId: 0, + startX: 0, + startY: 0, + startPose: poseRef.current, + }; + + const handlePointerDown = (event: PointerEvent) => { + dragState.active = true; + dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate'; + dragState.pointerId = event.pointerId; + dragState.startX = event.clientX; + dragState.startY = event.clientY; + dragState.startPose = poseRef.current; + container.setPointerCapture(event.pointerId); + }; + const handlePointerMove = (event: PointerEvent) => { + if (!dragState.active || event.pointerId !== dragState.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (dragState.mode === 'pan') { + onPoseChangeRef.current(clampPose({ + ...dragState.startPose, + translateX: dragState.startPose.translateX + deltaX * 0.006, + translateY: dragState.startPose.translateY - deltaY * 0.006, + })); + return; + } + onPoseChangeRef.current(clampPose({ + ...dragState.startPose, + rotateY: dragState.startPose.rotateY + deltaX * 0.35, + rotateX: dragState.startPose.rotateX + deltaY * 0.35, + })); + }; + const stopPointerDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) return; + dragState.active = false; + if (container.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + onPoseChangeRef.current(clampPose({ + ...poseRef.current, + scale: poseRef.current.scale - event.deltaY * 0.001, + })); + }; + const preventContextMenu = (event: MouseEvent) => event.preventDefault(); + + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerup', stopPointerDrag); + container.addEventListener('pointercancel', stopPointerDrag); + container.addEventListener('wheel', handleWheel, { passive: false }); + container.addEventListener('contextmenu', preventContextMenu); + + return () => { + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerup', stopPointerDrag); + container.removeEventListener('pointercancel', stopPointerDrag); + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('contextmenu', preventContextMenu); + }; + }, []); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -242,7 +336,8 @@ function NativeStlViewer({ const scene = new THREE.Scene(); scene.background = new THREE.Color('#f8fafc'); const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000); - camera.position.set(4.5, 3.5, 5); + camera.up.set(0, 1, 0); + camera.position.set(0, 0, 6); camera.lookAt(0, 0, 0); let renderer: THREE.WebGLRenderer | null = null; try { @@ -263,10 +358,7 @@ function NativeStlViewer({ }) .then((payload) => ({ payload, - style: { - ...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }), - color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6', - }, + style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }, })), ), ).then((results) => { @@ -308,7 +400,6 @@ function NativeStlViewer({ const group = new THREE.Group(); let baseScale = 1; - let autoSpin = 0; scene.add(group); let loaded = 0; let failed = 0; @@ -330,11 +421,11 @@ function NativeStlViewer({ const mesh = new THREE.Mesh( geometry, new THREE.MeshStandardMaterial({ - color: solidWhite ? '#f4f4f2' : style.color, + color: style.color, opacity: style.opacity, transparent: style.opacity < 1, - roughness: solidWhite ? 0.34 : 0.48, - metalness: solidWhite ? 0.02 : 0.08, + roughness: 0.42, + metalness: 0.04, side: THREE.DoubleSide, }), ); @@ -381,12 +472,9 @@ function NativeStlViewer({ const animate = () => { if (disposed) return; const currentPose = poseRef.current; - if (currentPose.autoRotate) { - autoSpin += 0.004; - } group.rotation.set( THREE.MathUtils.degToRad(currentPose.rotateX), - THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin, + THREE.MathUtils.degToRad(currentPose.rotateY), THREE.MathUtils.degToRad(currentPose.rotateZ), ); group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); @@ -414,10 +502,10 @@ function NativeStlViewer({ }); container.innerHTML = ''; }; - }, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]); + }, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]); return ( -
+
{progress < 100 && (
@@ -452,10 +540,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [rotation, setRotation] = useState(0); const [isSliceChanging, setIsSliceChanging] = useState(false); const [solidityLevel, setSolidityLevel] = useState('standard'); - const [solidWhite, setSolidWhite] = useState(true); const [modelPose, setModelPose] = useState(defaultModelPose); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); + const [dicomInfo, setDicomInfo] = useState(null); + const [dicomInfoError, setDicomInfoError] = useState(''); + const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false); const [dicomError, setDicomError] = useState(''); const [newProjectName, setNewProjectName] = useState(''); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -619,7 +709,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const updateModelPose = (partial: Partial) => { setModelPose((current) => ({ ...current, - autoRotate: partial.autoRotate ?? false, ...partial, })); }; @@ -651,6 +740,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri setActionMessage('已生成当前 DICOM 图片 PNG'); }; + const openDicomInfo = async () => { + if (!selectedProject) return; + setIsDicomInfoOpen(true); + setDicomInfoError(''); + try { + setDicomInfo(await api.getDicomInfo(selectedProject.id)); + } catch (error) { + setDicomInfo(null); + setDicomInfoError(error instanceof Error ? error.message : 'DICOM 信息查询失败'); + } + }; + const handleCreateProject = async () => { const name = newProjectName.trim(); if (!name) { @@ -992,6 +1093,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri > DCM +
@@ -1006,8 +1114,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri files={stlFiles} styles={moduleStyles} detailLimit={selectedSolidity.limit} - solidWhite={solidWhite} pose={modelPose} + onPoseChange={setModelPose} />
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label} @@ -1019,14 +1127,9 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

模型显示

- + 左键旋转 · 右键/Shift 平移 · 滚轮缩放
-
+
{solidityOptions.map((option) => (
-
- - -
-

整体位姿

+
+

整体位姿

+ +
{[ { key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX }, { key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY }, @@ -1068,7 +1161,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri { key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX }, { key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY }, { key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ }, - { key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale }, + { key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale }, ].map((item) => (
{item.label} @@ -1246,6 +1339,105 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
)} + {isDicomInfoOpen && ( +
+
+
+
+

DICOM 详细信息

+

包含基础元数据、像素间距、切片间距和物理尺寸

+
+ +
+
+ {dicomInfoError &&

{dicomInfoError}

} + {!dicomInfo && !dicomInfoError &&

正在解析 DICOM 信息...

} + {dicomInfo && ( +
+ {[ + { + title: '患者与检查', + rows: [ + ['患者姓名', dicomInfo.patient.name], + ['患者 ID', dicomInfo.patient.id], + ['检查日期', dicomInfo.study.date], + ['检查类型', dicomInfo.study.modality], + ['设备厂商', dicomInfo.study.manufacturer], + ], + }, + { + title: '序列与文件', + rows: [ + ['序列描述', dicomInfo.series.description], + ['文件数量', dicomInfo.series.files], + ['首文件', dicomInfo.series.firstFile], + ['末文件', dicomInfo.series.lastFile], + ['DICOM 路径', dicomInfo.project.dicomPath], + ], + }, + { + title: '图像矩阵与窗宽窗位', + rows: [ + ['Rows', dicomInfo.image.rows], + ['Columns', dicomInfo.image.columns], + ['Bits Allocated', dicomInfo.image.bitsAllocated], + ['Window Center', dicomInfo.image.windowCenter], + ['Window Width', dicomInfo.image.windowWidth], + ['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`], + ], + }, + { + title: '空间距离', + rows: [ + ['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`], + ['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`], + ['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`], + ['间距来源', dicomInfo.spacing.sliceSource], + ['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`], + ['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`], + ], + }, + { + title: '物理尺寸', + rows: [ + ['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`], + ['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`], + ['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`], + ], + }, + { + title: '空间位置', + rows: [ + ['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'], + ['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'], + ], + }, + ].map((section) => ( +
+

{section.title}

+
+ {section.rows.map(([label, value]) => ( +
+ {label} + {displayDicomValue(value)} +
+ ))} +
+
+ ))} +
+ )} +
+
+
+ )} + {projectToDelete && (
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index a2ab223..640627e 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -1,4 +1,4 @@ -import { DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types'; +import { DicomInfo, DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types'; async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { @@ -52,6 +52,7 @@ export const api = { }), getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') => request(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`), + getDicomInfo: (projectId: string) => request(`/api/projects/${projectId}/dicom-info`), getUsers: () => request('/api/users'), resetDemo: () => request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index 220788d..009c13b 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -66,4 +66,67 @@ export interface DicomPreview { fileName: string; windowCenter: number; windowWidth: number; + spacing?: { + row: number; + column: number; + slice: number; + displayX?: number; + displayY?: number; + }; + physicalSize?: { + width: number; + height: number; + }; +} + +export interface DicomInfo { + project: { + id: string; + name: string; + dicomPath: string; + }; + patient: { + name: string; + id: string; + }; + study: { + date: string; + description: string; + modality: string; + manufacturer: string; + }; + series: { + description: string; + files: number; + firstFile: string; + lastFile: string; + }; + image: { + rows: number; + columns: number; + bitsAllocated: number; + pixelRepresentation: number; + windowCenter: number; + windowWidth: number; + rescaleIntercept: number; + rescaleSlope: number; + }; + spacing: { + row: number | null; + column: number | null; + slice: number | null; + sliceSource: string; + sliceThickness: number | null; + spacingBetweenSlices: number | null; + }; + physicalSize: { + width: number | null; + height: number | null; + depth: number | null; + unit: string; + }; + position: { + firstImagePosition: number[] | null; + lastImagePosition: number[] | null; + }; } diff --git a/工程分析/实现方案-2026-05-04-05-56-34.md b/工程分析/实现方案-2026-05-04-05-56-34.md new file mode 100644 index 0000000..b7ee254 --- /dev/null +++ b/工程分析/实现方案-2026-05-04-05-56-34.md @@ -0,0 +1,85 @@ +# 实现方案 - 2026-05-04-05-56-34 + +## 修改目标 + +修正 DICOM 矢状面/冠状面的物理比例,新增 DICOM 详细信息查询;简化 3D 模型显示控制,加入更高实体化档位,并实现画布内鼠标旋转、平移、滚轮缩放且同步整体位姿控件。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/types.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `工程分析/经验记录.md` + +## 技术路线 + +### 1. DICOM 空间信息解析 + +- 扩展后端 DICOM tag 解析: + - Pixel Spacing `(0028,0030)`:单张切片内行/列像素实际距离。 + - Slice Thickness `(0018,0050)`。 + - Spacing Between Slices `(0018,0088)`。 + - Image Position Patient `(0020,0032)`:优先用相邻切片空间位置差计算真实切片间距。 + - Patient、Study、Series、Modality、Manufacturer、Rows、Columns、Window、Rescale 等基础信息。 +- 在 DICOM volume cache 中保存 `rowSpacing`、`columnSpacing`、`sliceSpacing`。 + +### 2. 多平面物理比例重采样 + +- 当前矢状面/冠状面生成后先得到原始矩阵。 +- 根据物理尺寸计算目标比例: + - 横向:`切片数 * sliceSpacing` + - 矢状面纵向:`rows * rowSpacing` + - 冠状面纵向:`columns * columnSpacing` +- 以较小物理间距作为输出采样单位,将重建图像最近邻重采样到接近真实物理比例的像素宽高。 +- 返回 `spacing` 和 `physicalSize`,供前端信息展示。 + +### 3. DICOM 详细信息查询 + +- 新增后端接口:`GET /api/projects/:projectId/dicom-info`。 +- 返回默认项目第一张 DICOM 与序列聚合信息: + - patient、study、series、image、window、spacing、sequence、source 等分组。 +- 前端 DICOM 影像页新增“信息”按钮,打开弹窗/面板展示基本信息、像素间距、切片间距、图像矩阵、物理尺寸、文件数量、首尾文件等。 + +### 4. 3D 模型控制简化与增强 + +- 去掉“白色实体”开关和“自动旋转”开关。 +- 默认模型不自动旋转,正向放置。 +- 实体化档位改为:`预览 / 标准 / 精细 / 超精细`。 +- 后端 STL preview 抽样上限提升到 `72000`,前端超精细档使用 `72000`。 +- 重置位姿按钮移动到“整体位姿”标题右侧。 + +### 5. 鼠标/滚轮位姿交互 + +- 在 `NativeStlViewer` 容器上监听 pointer 和 wheel: + - 左键拖拽:旋转 X/Y。 + - 右键或 Shift+拖拽:平移 X/Y。 + - 滚轮:缩放。 +- 交互时通过 `onPoseChange` 回写 React state,使滑块数值同步变化。 +- 禁用浏览器右键菜单,避免右键平移时弹出菜单。 +- 位姿仍作用于整体 group,不改变 STL 构件相对位置。 + +## 数据流或交互流程 + +1. 前端请求 DICOM preview,后端解析/缓存体数据和空间信息,按真实物理比例输出矢状面/冠状面。 +2. 前端点击 DICOM 信息按钮,请求 dicom-info,弹窗展示元数据和空间参数。 +3. 前端进入 3D 模型页,按当前实体化档位请求 STL preview。 +4. 用户拖拽/滚轮操作画布,`NativeStlViewer` 更新位姿并回写父组件,右侧滑块同步变化。 +5. 用户点击重置位姿,模型回到默认正向摆放。 + +## 兼容性与回滚方案 + +- 若某些 DICOM tag 缺失,后端使用默认 spacing `1mm`,并在详情中展示“未知/默认”。 +- 多平面重采样使用最近邻,避免引入新依赖;如比例异常可回滚到原始矩阵输出。 +- 超精细档可能更慢,但保留低档位可回退。 +- 鼠标交互只作用于项目库 3D 视图,不影响 DICOM、导出和逆向工作区。 + +## 预计文件变更 + +- 后端:DICOM metadata/spacing 解析、多平面重采样、dicom-info API、STL 上限。 +- 前端:DICOM 信息弹窗、3D 控件重构、鼠标交互回写位姿。 +- 文档:测试结果和经验记录追加。 + +## 人工审核状态 + +用户已明确本次无需人工二次确认,文档落地后直接执行。 diff --git a/工程分析/实现方案-2026-05-07-16-20-46.md b/工程分析/实现方案-2026-05-07-16-20-46.md new file mode 100644 index 0000000..04fe199 --- /dev/null +++ b/工程分析/实现方案-2026-05-07-16-20-46.md @@ -0,0 +1,46 @@ +# 实现方案 - 2026-05-07-16-20-46 + +## 修改目标 + +修正项目库 3D 模型页默认位姿,使初次打开和点击“重置位姿”都恢复到类似参考图的正常俯视/正向姿态。 + +## 涉及路径 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 默认位姿 + - 保持 `defaultModelPose` 的旋转、平移和缩放为中性值,避免默认滑块显示已经偏转。 + - 重置位姿继续设置为 `defaultModelPose`。 + +2. 默认相机 + - 将 `NativeStlViewer` 默认 camera 从斜向等距视角调整为更接近参考图的俯视视角。 + - 使用 `camera.position.set(0, 0, 6)`、`camera.up.set(0, 1, 0)`、`camera.lookAt(0, 0, 0)`,让模型以 XY 平面正向进入视野。 + - resize 后保留相机方向。 + +3. 视觉验证 + - 进入 3D 模型页后,模型不再以明显斜向等距视角显示。 + - 通过鼠标/滚轮改变位姿后,点击重置回到标准默认视角。 + +4. 与上一轮未提交改动合并 + - 保留并验证 DICOM 空间比例、DICOM 信息面板、3D 超精细档、鼠标交互同步等改动。 + +## 数据流或交互流程 + +用户进入项目库 -> 点击 3D 模型 -> 前端创建 Three.js camera 并使用默认俯视相机 -> STL group 居中缩放 -> 默认位姿滑块为 0/0/0 与缩放 1 -> 用户交互后可点击重置恢复。 + +## 兼容性与回滚方案 + +- 如果参考视角需要再微调,可只调整 camera position/up,不影响 STL 数据和后端接口。 +- 回滚可恢复相机到原先 `(4.5, 3.5, 5)` 等距视角。 + +## 预计文件变更 + +- `ProjectLibrary.tsx` 中相机默认位置和说明文字。 +- `经验记录.md` 追加默认位姿经验。 + +## 人工审核状态 + +用户已明确本次无需人工二次确认,文档落地后直接执行。 diff --git a/工程分析/测试方案-2026-05-04-05-56-34.md b/工程分析/测试方案-2026-05-04-05-56-34.md new file mode 100644 index 0000000..48e3c47 --- /dev/null +++ b/工程分析/测试方案-2026-05-04-05-56-34.md @@ -0,0 +1,68 @@ +# 测试方案 - 2026-05-04-05-56-34 + +## 静态检查 + +- 执行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 执行 `npm run build`,确认生产构建通过。 + +## 集成测试 + +- DICOM preview API: + - 验证 axial/sagittal/coronal 均返回。 + - 验证 sagittal/coronal 返回的 `spacing`、`physicalSize`、`width/height` 合理。 +- DICOM info API: + - 验证返回 patient/study/series/image/window/spacing/sequence/source 分组。 + - 验证 Pixel Spacing、Slice Spacing、Rows、Columns、文件数量等信息存在。 +- STL preview API: + - 验证 `limit=6000/16000/36000/72000` 返回不报错。 + +## 关键业务场景验证 + +- DICOM 影像页: + - 矢状面/冠状面不再异常扁平。 + - 点击信息按钮弹出详情面板。 + - 详情面板展示像素间距、切片间距、切片厚度、矩阵、物理尺寸。 +- 3D 模型页: + - 不再显示白色实体和自动旋转开关。 + - 实体化档位包含“超精细”。 + - 默认正向静止摆放。 + - 重置位姿按钮位于“整体位姿”标题右侧。 + - 左键拖拽旋转,右键/Shift 拖拽平移,滚轮缩放。 + - 画布交互后右侧整体位姿滑块数值同步变化。 + +## 医学影像数据相关边界验证 + +- DICOM tag 缺失时使用 fallback,不导致接口 500。 +- 切片间距优先 Image Position Patient 差值,再 fallback 到 Spacing Between Slices、Slice Thickness、1mm。 +- 多平面物理比例不改变切片总数和当前切片编号逻辑。 + +## 回归风险 + +- 物理比例重采样可能增大图像尺寸,需要限制最大输出尺寸。 +- 超精细 STL 预览可能变慢,需要保留低档位。 +- 鼠标交互需避免页面滚动和右键菜单干扰。 + +## 人工审核状态 + +用户已明确本次无需人工二次确认,按本方案执行验证。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;Vite 仍提示 bundle 超过 500 kB,为现有 Three.js/Recharts 依赖导致的非阻断警告。 +- DICOM preview API 验证: + - `sagittal` 返回 `384x421`,`spacing.slice=1mm`、`displayY=0.78125mm`,物理尺寸约 `300mm x 328.906mm`。 + - `coronal` 返回 `384x512`,`spacing.slice=1mm`、`displayY=0.78125mm`,物理尺寸约 `300mm x 400mm`。 +- DICOM info API 验证: + - 返回患者 `WANG FANG`、`CT`、文件数 `300`、矩阵 `512x512`。 + - 返回 `row/column spacing=0.781mm`、`slice=1mm`,来源 `ImagePositionPatient`。 + - 返回物理尺寸 `400mm x 400mm x 299mm`。 +- STL preview API 验证: + - `limit=6000/16000/36000/72000` 均返回,首个 STL 在超精细档达到原始 `17384` 个三角面。 +- 无头 Chrome 前端验证: + - DICOM 信息按钮存在。 + - DICOM 信息弹窗存在,并展示像素行间距、切片间距、物理尺寸。 + - 3D 页存在“超精细”,已移除“白色实体”和“自动旋转”。 + - 默认位姿滑块为 `0,0,0 / 0,0,0 / 1`。 + - 页面包含“左键旋转”和“滚轮缩放”提示。 +- 已重新部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。 diff --git a/工程分析/测试方案-2026-05-07-16-20-46.md b/工程分析/测试方案-2026-05-07-16-20-46.md new file mode 100644 index 0000000..b1cd4cb --- /dev/null +++ b/工程分析/测试方案-2026-05-07-16-20-46.md @@ -0,0 +1,45 @@ +# 测试方案 - 2026-05-07-16-20-46 + +## 静态检查 + +- 执行 `npm run lint`。 +- 执行 `npm run build`。 + +## 集成测试 + +- 验证 STL preview API 在 `6000/16000/36000/72000` 档位下可返回。 +- 验证 DICOM preview 与 DICOM info API 仍可返回,确保上一轮相关改动未受影响。 + +## 前端验证 + +- 无头 Chrome 登录后进入项目库 3D 模型页: + - 控件包含“超精细”,不包含“白色实体/自动旋转”。 + - 默认位姿滑块为旋转 0、平移 0、缩放 1。 + - canvas 非空。 +- 模拟拖拽/滚轮后检查位姿数值变化。 +- 点击重置位姿后检查数值恢复默认。 + +## 回归风险 + +- 无头 Chrome 可能走二维兜底预览,但仍可验证控件和位姿状态。 +- 真实 WebGL 视角需要用户目视确认参考图匹配度;本次以默认俯视相机为工程修正。 + +## 人工审核状态 + +用户已明确本次无需人工二次确认,按本方案执行验证。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;Vite 大 chunk 体积提示为非阻断警告。 +- 已将 3D 默认相机从斜向等距视角改为俯视相机:`camera.position=(0,0,6)`,`lookAt(0,0,0)`。 +- 无头 Chrome 前端验证: + - 3D 页 canvas 非空,尺寸 `1172x567`。 + - 默认位姿滑块为 `0,0,0 / 0,0,0 / 1`。 + - “超精细”存在,“白色实体/自动旋转”不存在。 + - DICOM 信息面板仍可打开。 +- 关联 API 回归验证: + - DICOM 多平面物理比例接口正常。 + - DICOM 信息接口正常。 + - STL 四档预览接口正常。 +- 已重新部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 4627558..e6f1073 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -505,3 +505,75 @@ C. 解决问题方案 D. 后续如何避免问题 页面级标题应由全局导航或内容区二选一承担;当前对象信息只保留在最醒目的单一位置,减少重复文本造成的噪声。 + +## 2026-05-04-05-56-34 DICOM 多平面物理比例 + +A. 具体问题 + +矢状面和冠状面只按像素矩阵重建,没有考虑切片间距与单张图像内像素间距,导致图像观感过扁。 + +B. 产生问题原因 + +后端重建平面时直接使用 `切片数 x 行/列数` 作为输出尺寸,默认把切片方向和像素方向当成等距网格。 + +C. 解决问题方案 + +解析 `PixelSpacing`、`SliceThickness`、`SpacingBetweenSlices` 和 `ImagePositionPatient`;优先用相邻 `ImagePositionPatient` 距离估计真实切片间距,并按 `sliceSpacing` 与像素间距做最近邻重采样,返回 spacing 与 physicalSize。 + +D. 后续如何避免问题 + +医学影像任意重建平面都必须带着物理 spacing 计算,不应只看像素数量;当 DICOM tag 缺失时要明确 fallback 来源。 + +## 2026-05-04-05-56-34 DICOM 信息面板 + +A. 具体问题 + +前端缺少 DICOM 详细信息查询,用户无法看到像素间距、切片间距等判断空间比例的关键信息。 + +B. 产生问题原因 + +原有 API 只服务于灰度预览,没有暴露 DICOM 元数据和序列级空间统计。 + +C. 解决问题方案 + +新增 `GET /api/projects/:projectId/dicom-info`,返回患者、检查、序列、图像矩阵、窗宽窗位、spacing、物理尺寸和首尾切片位置;前端增加“信息”按钮和 DICOM 详细信息弹窗。 + +D. 后续如何避免问题 + +影像显示功能旁应提供可审计的元数据入口,特别是任何影响几何比例、配准和导出的空间参数。 + +## 2026-05-04-05-56-34 3D 模型交互控制简化 + +A. 具体问题 + +3D 模型页存在不需要的白色实体模式和自动旋转,同时缺少更高细节档位;鼠标拖拽、滚轮等画布操作不能同步到右侧位姿控件。 + +B. 产生问题原因 + +前一版位姿控制主要依赖右侧滑块,画布本身只负责渲染;显示开关也偏演示型,没有完全贴近用户的实际浏览习惯。 + +C. 解决问题方案 + +移除白色实体和自动旋转;新增“超精细”档,后端 STL 抽样上限提升到 `72000`;画布监听左键旋转、右键或 Shift 平移、滚轮缩放,并回写整体位姿 state。 + +D. 后续如何避免问题 + +三维浏览默认应遵循常见交互习惯,UI 控件与鼠标操作必须共享同一份状态;演示型开关要及时剔除,避免干扰核心工作流。 + +## 2026-05-07-16-20-46 3D 默认位姿 + +A. 具体问题 + +网页端 3D 模型默认位姿看起来不像用户参考图中的正常位姿,打开后更像斜向观察。 + +B. 产生问题原因 + +默认 Three.js 相机使用 `(4.5, 3.5, 5)` 斜向等距视角,而用户期望的是接近俯视/轴向的标准视角。 + +C. 解决问题方案 + +将默认相机改为俯视方向:`camera.up=(0,1,0)`、`camera.position=(0,0,6)`、`lookAt(0,0,0)`;保留默认位姿滑块为旋转 0、平移 0、缩放 1,重置位姿也回到同一基准。 + +D. 后续如何避免问题 + +默认位姿应该由相机预设和模型位姿共同定义;如果用户提供标准视图截图,应优先匹配相机视角,再决定是否需要固定模型 Z 轴校正。 diff --git a/工程分析/需求分析-2026-05-04-05-56-34.md b/工程分析/需求分析-2026-05-04-05-56-34.md new file mode 100644 index 0000000..bd08423 --- /dev/null +++ b/工程分析/需求分析-2026-05-04-05-56-34.md @@ -0,0 +1,49 @@ +# 需求分析 - 2026-05-04-05-56-34 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流,但本次需求分析、实现方案、测试方案、执行修改均不需要人工二次确认。当前需求包括: + +1. DICOM 矢状面、冠状面需要根据切片间距、单张切片内像素间距进行真实物理比例计算,当前图像看起来过扁。 +2. DICOM 影像增加详细信息查询按钮,提取 DICOM 基本信息,并列出像素间距、切片间距等必要的新信息。 +3. 3D 模型去掉白色实体模式和自动旋转;实体化程度在“预览、标准、精细”基础上再增加一个更细致档位;默认正向摆放;重置位姿放到“整体位姿”标题右侧;支持在图中用鼠标/滚轮操作旋转、平移、缩放,并把操作变化映射回整体位姿控件。 + +## 业务目标 + +DICOM 多平面重建应尽量符合真实空间比例,辅助用户正确理解矢状面和冠状面的解剖形态;DICOM 信息查询应展示基础元数据和关键空间参数;3D 模型交互应贴近常见三维软件操作习惯,减少无用开关,增强手动操控。 + +## 输入与输出 + +- 输入: + - `Head_CT_DICOM/` 中 DICOM 序列及其 DICOM tag。 + - `Head_CT_ReConstruct/` 中 STL 模型。 + - 用户鼠标拖拽、滚轮、位姿滑块操作。 +- 输出: + - 物理比例修正后的矢状面/冠状面预览。 + - DICOM 详细信息面板。 + - 调整后的 3D 实体化档位和手动位姿交互。 + +## 影响范围 + +- `WebSite/server.ts` + - 解析 Pixel Spacing、Slice Thickness、Spacing Between Slices、Image Position Patient 等 DICOM 空间信息。 + - 多平面重建按物理比例重采样。 + - 新增 DICOM 信息 API。 +- `WebSite/src/types.ts` + - 补充 DICOM 预览空间信息和详情信息类型。 +- `WebSite/src/lib/api.ts` + - 增加 DICOM 详情接口。 +- `WebSite/src/components/ProjectLibrary.tsx` + - 增加 DICOM 信息按钮/弹窗。 + - 修改 3D 模型控件和鼠标交互。 + +## 风险点 + +- 当前 DICOM 解析器是轻量解析器,不覆盖所有 DICOM 传输语法;本次仍以现有数据集可解析为目标。 +- 多平面物理重采样会改变返回图像尺寸,前端布局需要继续保持自适应。 +- 高实体化档位会显著增加 STL 预览顶点数和浏览器渲染压力。 +- 自研鼠标旋转/平移/滚轮缩放需要与位姿滑块状态双向同步,避免 UI 状态漂移。 + +## 待确认问题 + +用户已明确本次无需人工二次确认,按合理工程假设直接执行。 diff --git a/工程分析/需求分析-2026-05-07-16-20-46.md b/工程分析/需求分析-2026-05-07-16-20-46.md new file mode 100644 index 0000000..4821b71 --- /dev/null +++ b/工程分析/需求分析-2026-05-07-16-20-46.md @@ -0,0 +1,34 @@ +# 需求分析 - 2026-05-07-16-20-46 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流,本次无需人工二次确认。用户指出正常情况下的 3D 模型默认位姿应类似图 1:模型以更标准的正向/俯视视角展示,而当前网页端默认位姿感觉不正确。 + +## 业务目标 + +项目库 3D 模型页首次打开时,应直接呈现接近用户参考图的正常默认视角,避免用户进入页面后看到斜向、侧向或难以判断解剖结构方向的模型姿态。重置位姿也应恢复到同一标准默认视角。 + +## 输入与输出 + +- 输入:`Head_CT_ReConstruct/` 中 STL 模型,以及用户提供的标准位姿参考截图。 +- 输出:调整后的默认相机视角/默认整体位姿,确保默认和重置位姿一致。 + +## 影响范围 + +- `WebSite/src/components/ProjectLibrary.tsx` + - `defaultModelPose` + - `NativeStlViewer` 的默认 camera position / lookAt / up 方向 + - 可能涉及位姿滑块初始值与重置逻辑 +- 延续上一轮未提交相关改动: + - DICOM 空间比例和信息接口 + - 3D 模型超精细档、鼠标交互和位姿同步 + +## 风险点 + +- STL 坐标系不一定与 Three.js 默认相机坐标完全一致,需要通过合理默认 camera 与旋转组合匹配参考图。 +- 若只改 camera 不改 reset,用户重置后仍可能回到旧姿态。 +- 若只改 group rotation 不改相机,鼠标交互与滑块显示可能不直观。 + +## 待确认问题 + +用户已明确本次无需人工二次确认,按合理工程假设直接执行。参考图呈现为近似俯视/轴向视角,因此默认采用俯视相机和零位姿组合。