From 4922c2d9911c2ce92f123ab37208c6cdbc55696c Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 05:32:34 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-04-05-20-16=20=E4=BC=98=E5=8C=96DICOM?= =?UTF-8?q?=E5=88=87=E7=89=87=E4=B8=8B=E8=BD=BD=E5=92=8C3D=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 156 +++++++++++++- WebSite/src/components/ProjectLibrary.tsx | 224 +++++++++++++++++--- WebSite/src/components/ReverseWorkspace.tsx | 2 +- WebSite/src/lib/api.ts | 21 ++ 工程分析/实现方案-2026-05-04-05-20-16.md | 61 ++++++ 工程分析/测试方案-2026-05-04-05-20-16.md | 62 ++++++ 工程分析/经验记录.md | 54 +++++ 工程分析/需求分析-2026-05-04-05-20-16.md | 41 ++++ 8 files changed, 584 insertions(+), 37 deletions(-) create mode 100644 工程分析/实现方案-2026-05-04-05-20-16.md create mode 100644 工程分析/测试方案-2026-05-04-05-20-16.md create mode 100644 工程分析/需求分析-2026-05-04-05-20-16.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 710b7ee..738601e 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -87,7 +87,11 @@ function listFiles(dir: string, extension: string) { .readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension)) .map((entry) => entry.name) - .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); + .sort(naturalFileCompare); +} + +function naturalFileCompare(a: string, b: string) { + return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); } function publicUser(user: UserRecord) { @@ -279,7 +283,7 @@ function getProjectDicomFiles(project: ProjectRecord) { if (project.id !== 'head-ct-demo') { return []; } - return listFiles(dicomDir, '.dcm').sort((a, b) => Number.parseInt(a) - Number.parseInt(b)); + return listFiles(dicomDir, '.dcm'); } function readAsciiValue(buffer: Buffer, start: number, length: number) { @@ -375,10 +379,12 @@ function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') pixels[i] = normalized; } + const enhancedPixels = enhanceDicomEdges(pixels, columns, rows); + return { width: columns, height: rows, - pixels: pixels.toString('base64'), + pixels: enhancedPixels.toString('base64'), windowCenter, windowWidth, mode, @@ -440,10 +446,13 @@ function createReformattedPreview(files: string[], plane: Exclude 0); + const rowHits = Array.from({ length: height }, () => 0); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (pixels[y * width + x] > threshold) { + columnHits[x] += 1; + rowHits[y] += 1; + } + } + } + + const minColumnHits = Math.max(4, Math.floor(height * 0.012)); + const minRowHits = Math.max(4, Math.floor(width * 0.012)); + let minX = columnHits.findIndex((hits) => hits >= minColumnHits); + let maxX = width - 1 - [...columnHits].reverse().findIndex((hits) => hits >= minColumnHits); + let minY = rowHits.findIndex((hits) => hits >= minRowHits); + let maxY = height - 1 - [...rowHits].reverse().findIndex((hits) => hits >= minRowHits); + + if (maxX < minX || maxY < minY) { + return { pixels, width, height }; + } + + const padding = 18; + minX = Math.max(0, minX - padding); + minY = Math.max(0, minY - padding); + maxX = Math.min(width - 1, maxX + padding); + maxY = Math.min(height - 1, maxY + padding); + + const croppedWidth = maxX - minX + 1; + const croppedHeight = maxY - minY + 1; + const croppedPixels = Buffer.alloc(croppedWidth * croppedHeight); + for (let row = 0; row < croppedHeight; row += 1) { + const sourceStart = (minY + row) * width + minX; + pixels.copy(croppedPixels, row * croppedWidth, sourceStart, sourceStart + croppedWidth); + } + + return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight }; +} + function createStlPreview(filePath: string, fileName: string, limit: number) { const cacheKey = `${fileName}:${limit}`; const cached = modelPreviewCache.get(cacheKey); @@ -503,6 +577,52 @@ function createStlPreview(filePath: string, fileName: string, limit: number) { return payload; } +function writeOctal(buffer: Buffer, value: number, offset: number, length: number) { + const text = value.toString(8).padStart(length - 1, '0').slice(-(length - 1)); + buffer.write(`${text}\0`, offset, length, 'ascii'); +} + +function createTarEntryHeader(name: string, size: number, mtime: number) { + const header = Buffer.alloc(512); + const safeName = name.slice(0, 100); + header.write(safeName, 0, 100, 'utf8'); + 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('ustar', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeOctal(header, checksum, 148, 8); + return header; +} + +function createDicomTarGz(files: string[]) { + const chunks: Buffer[] = []; + + files.forEach((fileName) => { + const filePath = path.join(dicomDir, fileName); + const stat = fs.statSync(filePath); + const data = fs.readFileSync(filePath); + chunks.push(createTarEntryHeader(`Head_CT_DICOM/${fileName}`, data.length, stat.mtimeMs / 1000)); + chunks.push(data); + const remainder = data.length % 512; + if (remainder > 0) { + chunks.push(Buffer.alloc(512 - remainder)); + } + }); + + chunks.push(Buffer.alloc(1024)); + return zlib.gzipSync(Buffer.concat(chunks)); +} + async function startServer() { const app = express(); const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; @@ -655,6 +775,30 @@ async function startServer() { } }); + app.get('/api/projects/:projectId/dicom-archive', (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 { + const archive = createDicomTarGz(files); + const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`; + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(archive); + } catch (error) { + res.status(500).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 3963063..7b02460 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -3,10 +3,14 @@ import { Plus, Search, Eye, + FileArchive, RotateCw, + RotateCcw, Box, Image as ImageIcon, ChevronRight, + ChevronUp, + ChevronDown, Edit2, FolderRoot, Download, @@ -17,7 +21,7 @@ import { } from 'lucide-react'; import * as THREE from 'three'; import { DicomPreview, Project } from '../types'; -import { api, downloadMask } from '../lib/api'; +import { api, downloadDicomArchive, downloadMask } from '../lib/api'; type Plane = 'axial' | 'sagittal' | 'coronal'; type DisplayMode = DicomPreview['mode']; @@ -42,8 +46,9 @@ function drawFallbackModelPreview( previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>, ) { const rect = canvas.getBoundingClientRect(); - const width = Math.max(Math.floor(rect.width), 1); - const height = Math.max(Math.floor(rect.height), 1); + const parentRect = canvas.parentElement?.getBoundingClientRect(); + const width = Math.max(Math.floor(rect.width || parentRect?.width || 720), 1); + const height = Math.max(Math.floor(rect.height || parentRect?.height || 460), 1); canvas.width = width * window.devicePixelRatio; canvas.height = height * window.devicePixelRatio; canvas.style.width = `${width}px`; @@ -100,7 +105,56 @@ function drawFallbackModelPreview( context.globalAlpha = 1; } -function DicomCanvas({ preview }: { preview: DicomPreview }) { +function drawDicomPreviewToCanvas(canvas: HTMLCanvasElement, preview: DicomPreview, rotation: number) { + const normalizedRotation = ((rotation % 360) + 360) % 360; + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = preview.width; + sourceCanvas.height = preview.height; + const sourceContext = sourceCanvas.getContext('2d'); + const targetContext = canvas.getContext('2d'); + if (!sourceContext || !targetContext) { + return; + } + + const binary = atob(preview.pixels); + const imageData = sourceContext.createImageData(preview.width, preview.height); + for (let i = 0; i < binary.length; i += 1) { + const value = binary.charCodeAt(i); + const offset = i * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = 255; + } + sourceContext.putImageData(imageData, 0, 0); + + const isQuarterTurn = normalizedRotation === 90 || normalizedRotation === 270; + canvas.width = isQuarterTurn ? preview.height : preview.width; + canvas.height = isQuarterTurn ? preview.width : preview.height; + targetContext.clearRect(0, 0, canvas.width, canvas.height); + targetContext.save(); + targetContext.imageSmoothingEnabled = true; + + if (normalizedRotation === 90) { + targetContext.translate(canvas.width, 0); + targetContext.rotate(Math.PI / 2); + } else if (normalizedRotation === 180) { + targetContext.translate(canvas.width, canvas.height); + targetContext.rotate(Math.PI); + } else if (normalizedRotation === 270) { + targetContext.translate(0, canvas.height); + targetContext.rotate(-Math.PI / 2); + } + + targetContext.drawImage(sourceCanvas, 0, 0); + targetContext.restore(); +} + +function safeFilePart(value: string) { + return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom'; +} + +function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) { const canvasRef = useRef(null); useEffect(() => { @@ -108,30 +162,13 @@ function DicomCanvas({ preview }: { preview: DicomPreview }) { if (!canvas) { return; } - const context = canvas.getContext('2d'); - if (!context) { - return; - } - - const binary = atob(preview.pixels); - const imageData = context.createImageData(preview.width, preview.height); - for (let i = 0; i < binary.length; i += 1) { - const value = binary.charCodeAt(i); - const offset = i * 4; - imageData.data[offset] = value; - imageData.data[offset + 1] = value; - imageData.data[offset + 2] = value; - imageData.data[offset + 3] = 255; - } - context.putImageData(imageData, 0, 0); - }, [preview]); + drawDicomPreviewToCanvas(canvas, preview, rotation); + }, [preview, rotation]); return ( ); } @@ -167,6 +204,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.lookAt(0, 0, 0); let renderer: THREE.WebGLRenderer | null = null; try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); @@ -265,10 +304,16 @@ function NativeStlViewer({ const box = new THREE.Box3().setFromObject(group); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - group.position.sub(center); const maxSize = Math.max(size.x, size.y, size.z) || 1; - group.scale.setScalar(4 / maxSize); - camera.position.set(4.5, 3.5, 5); + group.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.translate(-center.x, -center.y, -center.z); + object.geometry.computeBoundingSphere(); + object.geometry.computeVertexNormals(); + } + }); + group.position.set(0, 0, 0); + group.scale.setScalar(4.2 / maxSize); camera.lookAt(0, 0, 0); setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); } @@ -350,6 +395,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [sliceIndex, setSliceIndex] = useState(0); const [plane, setPlane] = useState('axial'); const [displayMode, setDisplayMode] = useState('default'); + const [rotation, setRotation] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [dicomError, setDicomError] = useState(''); @@ -359,6 +405,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [editingProjectId, setEditingProjectId] = useState(''); const [editingName, setEditingName] = useState(''); const [actionMessage, setActionMessage] = useState(''); + const sliceRepeatRef = useRef(null); const refreshProjects = () => { setLoading(true); @@ -400,6 +447,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri { id: 'contrast', label: '高对比' }, ]; const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); + const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0; useEffect(() => { const next: Record = {}; @@ -439,6 +487,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri cancelled = true; }; }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]); + + useEffect(() => () => { + if (sliceRepeatRef.current !== null) { + window.clearInterval(sliceRepeatRef.current); + } + }, []); + + useEffect(() => { + const max = Math.max(sliceTotal - 1, 0); + if (sliceIndex > max) { + setSliceIndex(max); + } + }, [sliceIndex, sliceTotal]); const updateModuleStyle = (fileName: string, partial: Partial) => { setModuleStyles(prev => ({ @@ -468,6 +529,49 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri }); }; + const stepSlice = (delta: number) => { + setSliceIndex((current) => { + const max = Math.max((dicomPreview?.total ?? selectedProject?.dicomCount ?? 1) - 1, 0); + return Math.max(0, Math.min(max, current + delta)); + }); + }; + + const stopSliceStep = () => { + if (sliceRepeatRef.current !== null) { + window.clearInterval(sliceRepeatRef.current); + sliceRepeatRef.current = null; + } + }; + + const startSliceStep = (delta: number) => { + stopSliceStep(); + stepSlice(delta); + sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 110); + }; + + const rotateDicom = (delta: number) => { + setRotation((current) => ((current + delta) % 360 + 360) % 360); + }; + + const downloadCurrentDicomPng = () => { + if (!dicomPreview || !selectedProject) { + setActionMessage('当前没有可下载的 DICOM 图片'); + return; + } + + const canvas = document.createElement('canvas'); + drawDicomPreviewToCanvas(canvas, dicomPreview, rotation); + 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; + link.href = canvas.toDataURL('image/png'); + link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + setActionMessage('已生成当前 DICOM 图片 PNG'); + }; + const handleCreateProject = async () => { const name = newProjectName.trim(); if (!name) { @@ -712,6 +816,22 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri ))} +
+ + +

PATIENT ID: {selectedProject.id}_XYZ

SCAN DATE: {selectedProject.createTime}

@@ -719,7 +839,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{dicomPreview ? ( - + ) : (

{dicomError || '正在解析 DICOM 像素...'}

)} @@ -733,18 +853,62 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
切片 - {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} + {sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount} + setSliceIndex(Number(e.target.value))} className="flex-1 w-6 accent-blue-600 cursor-pointer" style={{ writingMode: 'vertical-lr', direction: 'rtl' }} /> + #{sliceIndex + 1} +
+ + +
)} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 7e86436..d84268a 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -117,7 +117,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

逆向工作区

- {project ? `${project.name} · ${project.dicomPath} ↔ ${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'} + {project ? `当前项目:${project.name}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}

{project && (
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 10273d8..a2ab223 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -81,3 +81,24 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = link.remove(); URL.revokeObjectURL(url); } + +export async function downloadDicomArchive(projectId: string) { + const response = await fetch(`/api/projects/${projectId}/dicom-archive`); + + if (!response.ok) { + throw new Error(`DICOM 压缩包下载失败:${response.status}`); + } + + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') ?? ''; + const match = disposition.match(/filename="([^"]+)"/); + const filename = match?.[1] ?? `${projectId}-dicom-series.tar.gz`; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/工程分析/实现方案-2026-05-04-05-20-16.md b/工程分析/实现方案-2026-05-04-05-20-16.md new file mode 100644 index 0000000..326290b --- /dev/null +++ b/工程分析/实现方案-2026-05-04-05-20-16.md @@ -0,0 +1,61 @@ +# 实现方案 - 2026-05-04-05-20-16 + +## 修改目标 + +围绕项目库 DICOM/3D 浏览体验和逆向工作区标题信息进行收敛修复,保证 DICOM 切片控制可连续移动、三平面可旋转且显示完整清晰,补充下载能力,并修复 3D 模型加载完成后仍不可见的问题。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `工程分析/经验记录.md` + +## 技术路线 + +1. DICOM 文件排序 + - 将 DICOM 文件列表改为基于文件名的自然排序,确保 `1.dcm`、`2.dcm`、`10.dcm` 顺序正确。 + +2. DICOM 重建与清晰度 + - 后端对矢状面/冠状面重建结果进行非黑边界裁切并保留安全边距,减少“只显示一半”的视觉问题。 + - 对预览灰度图做轻量边缘增强,提高 DCM 影像边界辨识度。 + +3. 切片控制与旋转 + - 前端新增上下箭头按钮,长按时用定时器连续推进或回退切片。 + - DICOM 三平面统一支持左/右 90 度旋转,旋转状态参与画布绘制和 PNG 导出。 + +4. 下载能力 + - 后端新增 DICOM 序列压缩包接口,使用 `tar.gz` 生成压缩包,不引入额外依赖。 + - 前端增加下载按钮,支持当前图片 PNG 和 DICOM 压缩包。 + +5. 3D 模型显示 + - 继续使用后端 STL 抽样预览,前端原生 Three.js 渲染。 + - 修正模型居中和相机适配逻辑,增加坐标轴容错、投影兜底和加载进度。 + +6. 逆向工作区 + - 去掉 `Head_CT_DICOM ↔ Head_CT_ReConstruct` 路径说明,仅保留当前项目、DICOM 数量、STL 数量等上下文。 + +## 数据流与交互流程 + +- 项目库加载项目 -> 选择 DICOM 面/模式/切片 -> 后端返回灰度预览 -> 前端 canvas 绘制并按旋转角度显示。 +- 用户点击 PNG 下载 -> 使用当前 canvas 像素、平面、切片、模式、旋转角度生成命名完整的 PNG。 +- 用户点击 DICOM 压缩包下载 -> 后端按文件名自然排序打包 `Head_CT_DICOM`。 +- 用户进入 3D 模型页 -> 前端请求 STL 抽样预览 -> Three.js 居中缩放显示全部可见构件。 + +## 兼容性与回滚方案 + +- 若 DICOM 压缩包下载异常,不影响 DICOM 预览和 PNG 下载。 +- 若 WebGL 不可用,仍保留二维 canvas 投影兜底预览。 +- 回滚时可恢复本次提交前的 `server.ts`、项目库和逆向工作区组件。 + +## 预计文件变更 + +- 前后端代码 4-5 个文件。 +- 本次流程文档 3 个文件。 +- 经验记录追加 2-3 条。 + +## 人工审核状态 + +用户已在需求中明确:“本次的 需求分析、实现方案、测试方案、执行修改 都不用我人工二次确认了”。因此本方案记录后直接执行。 diff --git a/工程分析/测试方案-2026-05-04-05-20-16.md b/工程分析/测试方案-2026-05-04-05-20-16.md new file mode 100644 index 0000000..e26509e --- /dev/null +++ b/工程分析/测试方案-2026-05-04-05-20-16.md @@ -0,0 +1,62 @@ +# 测试方案 - 2026-05-04-05-20-16 + +## 静态检查 + +- 执行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 执行 `npm run build`,确认生产构建通过。 + +## 集成验证 + +- 调用 `/api/projects/head-ct-demo/dicom-preview` 验证: + - `plane=axial` + - `plane=sagittal` + - `plane=coronal` + - `mode=bone` +- 调用 `/api/projects/head-ct-demo/dicom-archive` 验证压缩包响应头和文件名。 +- 调用 `/api/projects/head-ct-demo/models/:fileName/preview` 验证 STL 预览顶点数据非空。 + +## 关键业务场景验证 + +- 项目库 DICOM 视图: + - 长按上下箭头能连续改变切片。 + - 三个面切换后图像显示完整。 + - 左/右旋转 90 度后画布方向变化。 + - 下载当前 PNG 命名包含项目、平面、切片、模式、旋转角度。 + - 下载 DICOM 压缩包可触发。 + +- 项目库 3D 模型视图: + - 模型加载进度可见。 + - 加载完成后模型可见,而不是空白画布。 + - 颜色、透明度、显示/隐藏状态仍可影响渲染。 + +- 逆向工作区: + - 顶部不再显示 `Head_CT_DICOM ↔ Head_CT_ReConstruct`。 + - 当前项目上下文仍可见。 + +## 医学影像数据相关边界验证 + +- DICOM 序列按文件名自然排序。 +- 矢状面/冠状面重建裁切保留边距,避免把真实人体区域裁掉。 +- 显示模式与三平面组合时返回的 `total`、`slice`、`width`、`height` 合理。 + +## 回归风险 + +- DICOM 预览缓存键应包含平面、模式和切片,避免错图。 +- PNG 下载应和当前旋转状态一致。 +- 3D 模型抽样数量不能过高导致页面阻塞。 + +## 人工审核状态 + +用户已明确本次测试方案无需二次确认,记录后直接执行。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;Vite 提示 bundle 超过 500 kB,为 Three.js/Recharts 等现有依赖带来的体积警告,不阻断构建。 +- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=150&mode=default`:通过,返回 `512x512`,`total=300`,文件名 `151.dcm`。 +- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256&mode=bone`:通过,返回 `300x421`,`total=512`。 +- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256&mode=soft`:通过,返回 `300x512`,`total=512`。 +- `HEAD /api/projects/head-ct-demo/dicom-archive`:通过,返回 `Content-Type: application/gzip`,文件名 `head-ct-demo-Head_CT_DICOM-300-files.tar.gz`。 +- `GET /api/projects/head-ct-demo/models/会厌.stl/preview?limit=1200`:通过,返回 `1159` 个抽样三角面、`10431` 个顶点数值。 +- 无头 Chrome 前端验证:登录后进入项目库 3D 模型页,在 WebGL 不可用场景自动生成二维兜底预览,canvas 尺寸 `1236x567`,像素采样 `nonBackground=67`,确认不是空白画布。 +- 已重启部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 3bec884..1351292 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -397,3 +397,57 @@ C. 解决问题方案 D. 后续如何避免问题 跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。 + +## 2026-05-04-05-20-16 DICOM 三平面预览与下载 + +A. 具体问题 + +DICOM 切片长按不能连续移动,三平面不能旋转,矢状面/冠状面存在大量黑边导致主体像只显示一半,同时缺少当前 PNG 和整套 DICOM 压缩包下载。 + +B. 产生问题原因 + +前端切片控制只有原生 range 输入,缺少连续步进逻辑;canvas 绘制没有旋转参数;后端重建平面直接输出完整矩阵,没有对非黑内容边界做裁切;下载链路只覆盖了分割 Mask。 + +C. 解决问题方案 + +前端新增上下箭头长按定时步进、左/右 90 度旋转和与显示一致的 PNG 导出;后端 DICOM 文件按自然文件名排序,对重建平面做内容裁切并做轻量边缘增强;新增 `dicom-archive` 接口生成 `tar.gz` 压缩包。 + +D. 后续如何避免问题 + +医学影像预览的显示状态应统一由平面、切片、窗宽窗位、旋转角度组成;下载当前视图时复用同一套 canvas 绘制逻辑,避免屏幕显示和导出结果不一致。 + +## 2026-05-04-05-20-16 3D 模型加载完成但画布空白 + +A. 具体问题 + +项目库 3D 模型页提示加载完成,但用户界面仍可能看不到模型。 + +B. 产生问题原因 + +STL 抽样顶点仍保留原始模型坐标,先给 group 位置减中心再缩放时,模型可能被缩放后的坐标体系推离相机视野;WebGL 不可用时二维兜底 canvas 尺寸也依赖容器测量,可能拿到过小尺寸。 + +C. 解决问题方案 + +模型加载完成后先计算整体包围盒,将中心偏移直接平移到每个 mesh 的 geometry 顶点,再统一缩放 group 和设置相机;二维兜底 canvas 在容器尺寸不可用时使用父容器尺寸或默认尺寸,保证兜底预览可见。 + +D. 后续如何避免问题 + +三维预览应在模型坐标归一化后再设置相机;验证时要覆盖 WebGL 正常路径和 WebGL 不可用兜底路径,并做 canvas 尺寸和非背景像素检查。 + +## 2026-05-04-05-20-16 逆向工作区路径信息噪声 + +A. 具体问题 + +逆向工作区标题中显示 `Head_CT_DICOM ↔ Head_CT_ReConstruct`,对用户当前操作帮助不大。 + +B. 产生问题原因 + +早期为了证明项目数据源接入,将数据目录路径直接放进了页面副标题。 + +C. 解决问题方案 + +副标题改为只显示当前项目名称;DICOM 数量和 STL 数量保留在上下文标签中。 + +D. 后续如何避免问题 + +工作区首屏应优先显示“当前正在处理哪个项目”和“下一步操作”,底层路径只在诊断、详情或设置区域出现。 diff --git a/工程分析/需求分析-2026-05-04-05-20-16.md b/工程分析/需求分析-2026-05-04-05-20-16.md new file mode 100644 index 0000000..6a24ba7 --- /dev/null +++ b/工程分析/需求分析-2026-05-04-05-20-16.md @@ -0,0 +1,41 @@ +# 需求分析 - 2026-05-04-05-20-16 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流,但本次需求分析、实现方案、测试方案、执行修改均不需要人工二次确认。当前需要继续完善项目库与逆向工作区: + +- 切片控制支持长按上下箭头连续移动进度条。 +- DICOM 影像序列导入后必须按文件名顺序排列。 +- DICOM 三个面都支持左/右 90 度旋转。 +- 修复矢状面、冠状面显示疑似只显示一半的问题。 +- DCM 影像边界更清晰。 +- DICOM 右侧增加下载按钮,可下载 DCM 影像压缩包或当前图片 PNG,命名信息更完整。 +- 修复 3D 模型提示加载完成但画布仍为空白的问题。 +- 逆向工作区去掉 `Head_CT_DICOM ↔ Head_CT_ReConstruct` 这类路径说明。 + +## 业务目标 + +项目库应能稳定浏览真实 DICOM 切片和 STL 模型,用户可快速调整切片、平面、显示模式、旋转方向,并导出当前影像或整套 DICOM 原始文件压缩包。逆向工作区应突出当前项目和融合工作目标,减少无效路径文本。 + +## 输入与输出 + +- 输入:`Head_CT_DICOM/` 中的 DICOM 序列、`Head_CT_ReConstruct/` 中的 STL 模型文件。 +- 输出:改进后的前后端代码、可下载 DICOM 压缩包、当前切片 PNG、稳定可见的 3D 模型预览、更新后的工程分析文档和经验记录。 + +## 影响范围 + +- `WebSite/server.ts`:DICOM 排序、重建裁切/边界增强、DICOM 压缩包下载、STL 预览数据增强。 +- `WebSite/src/components/ProjectLibrary.tsx`:切片控制、旋转、PNG 下载、DICOM 压缩包下载、3D 模型视图。 +- `WebSite/src/components/ReverseWorkspace.tsx`:去除路径连接说明,保留项目上下文。 +- `WebSite/src/lib/api.ts` / `WebSite/src/types.ts`:下载接口和预览数据结构补充。 + +## 风险点 + +- DICOM 多平面重建需要避免过度裁切,不能把真实影像内容裁掉。 +- 当前 STL 可能体积较大,浏览器端预览应继续使用抽样数据,避免直接加载全部原始 STL。 +- PNG 下载需要与当前前端旋转角度一致。 +- 压缩包实现应避免引入额外不稳定依赖。 + +## 待确认问题 + +本次用户已明确无需二次人工确认,按合理工程假设直接执行。