From 7099bfde8d3da9e9b245883f0ad66ee2ab7f8ed1 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 01:56:54 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-01-38-33=20=E5=AE=8C=E5=96=84NII?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=B8=8E=E4=BD=8D=E5=A7=BF=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 708 ++++++++++++++++++-- WebSite/src/components/ReverseWorkspace.tsx | 191 ++++-- WebSite/src/lib/api.ts | 74 +- WebSite/src/types.ts | 17 + 工程分析/实现方案-2026-05-20-01-38-33.md | 71 ++ 工程分析/测试方案-2026-05-20-01-38-33.md | 59 ++ 工程分析/经验记录.md | 54 ++ 工程分析/需求分析-2026-05-20-01-38-33.md | 55 ++ 8 files changed, 1084 insertions(+), 145 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-01-38-33.md create mode 100644 工程分析/测试方案-2026-05-20-01-38-33.md create mode 100644 工程分析/需求分析-2026-05-20-01-38-33.md diff --git a/WebSite/server.ts b/WebSite/server.ts index e5bdd50..be39317 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -17,6 +17,22 @@ interface ModuleStyleRecord { partId: number; } +interface ModelPoseValue { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +interface ModelPoseRecord { + id: string; + name: string; + pose: ModelPoseValue; +} + interface UserRecord { id: number; name: string; @@ -41,6 +57,7 @@ interface ProjectRecord { exportedMaskCount: number; isDefault?: boolean; moduleStyles: Record; + modelPoses: ModelPoseRecord[]; } interface SessionRecord { @@ -79,6 +96,15 @@ const dicomVolumeCache = new Map(); const modelPreviewCache = new Map(); const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const defaultModelPose: ModelPoseValue = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, +}; interface DicomAttributes { patientName: string; @@ -186,6 +212,62 @@ function buildModuleStyles( }, {}); } +function defaultModelPoses(): ModelPoseRecord[] { + return [ + { id: 'default', name: '默认', pose: { ...defaultModelPose } }, + { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } }, + { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } }, + ]; +} + +function normalizeModelPoseValue(value: Partial | undefined): ModelPoseValue { + const read = (key: keyof ModelPoseValue, fallback: number, min: number, max: number) => { + const nextValue = value?.[key]; + return typeof nextValue === 'number' && Number.isFinite(nextValue) + ? clampNumber(nextValue, min, max) + : fallback; + }; + + return { + rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180), + rotateY: read('rotateY', defaultModelPose.rotateY, -180, 180), + rotateZ: read('rotateZ', defaultModelPose.rotateZ, -180, 180), + translateX: read('translateX', defaultModelPose.translateX, -2, 2), + translateY: read('translateY', defaultModelPose.translateY, -2, 2), + translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2), + scale: read('scale', defaultModelPose.scale, 0.5, 2), + }; +} + +function normalizeModelPoseRecord(record: Partial | undefined, fallback: ModelPoseRecord): ModelPoseRecord { + const id = typeof record?.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 80) : fallback.id; + const name = typeof record?.name === 'string' && record.name.trim() ? record.name.trim().slice(0, 80) : fallback.name; + + return { + id, + name, + pose: normalizeModelPoseValue(record?.pose ?? fallback.pose), + }; +} + +function normalizeModelPoses(existing?: Partial[]) { + const defaults = defaultModelPoses(); + const incoming = Array.isArray(existing) + ? existing + .map((record, index) => normalizeModelPoseRecord(record, defaults[index] ?? { + id: `pose-${index}`, + name: `位姿${index + 1}`, + pose: defaultModelPose, + })) + .filter((record) => record.id) + : []; + const incomingById = new Map(incoming.map((record) => [record.id, record])); + const normalizedDefaults = defaults.map((pose) => incomingById.get(pose.id) ?? pose); + const custom = incoming.filter((record) => !defaults.some((pose) => pose.id === record.id)); + + return [...normalizedDefaults, ...custom]; +} + function buildDefaultProject(): ProjectRecord { const stlFiles = listFiles(modelDir, '.stl'); @@ -204,6 +286,7 @@ function buildDefaultProject(): ProjectRecord { exportedMaskCount: 0, isDefault: true, moduleStyles: buildModuleStyles(stlFiles), + modelPoses: defaultModelPoses(), }; } @@ -222,6 +305,7 @@ function buildEmptyProject(name: string): ProjectRecord { maskFormats: ['nii', 'nii.gz'], exportedMaskCount: 0, moduleStyles: {}, + modelPoses: defaultModelPoses(), }; } @@ -249,6 +333,7 @@ function normalizeState(state: AppState): AppState { exportedMaskCount: project.exportedMaskCount ?? 0, maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles), + modelPoses: normalizeModelPoses(project.modelPoses), })) : []; @@ -260,6 +345,7 @@ function normalizeState(state: AppState): AppState { name: savedDefaultProject?.name ?? defaultProject.name, exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0, moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles), + modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses), }, ...customProjects, ], @@ -290,41 +376,141 @@ function writeState(state: AppState) { fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2)); } -function createNiftiMask(project: ProjectRecord, compressed: boolean) { - const width = 64; - const height = 64; - const depth = 64; - const headerSize = 348; - const voxOffset = 352; - const voxelCount = width * height * depth; - const data = Buffer.alloc(voxelCount); - const center = [width / 2, height / 2, depth / 2]; +interface DicomHuVolume { + width: number; + height: number; + depth: number; + columnSpacing: number; + rowSpacing: number; + sliceSpacing: number; + data: Buffer; + minHu: number; + maxHu: number; +} - for (let z = 0; z < depth; z += 1) { - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const dx = (x - center[0]) / 18; - const dy = (y - center[1]) / 15; - const dz = (z - center[2]) / 20; - const index = z * width * height + y * width + x; - const radius = dx * dx + dy * dy + dz * dz; +interface Point2DRecord { + x: number; + y: number; +} - if (radius < 1) { - data[index] = 1; - } +interface Point3DRecord { + x: number; + y: number; + z: number; +} - const tumorDx = (x - 42) / 8; - const tumorDy = (y - 30) / 7; - const tumorDz = (z - 34) / 7; - if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) { - data[index] = 2; - } - } - } +interface PlaneSegmentRecord { + a: Point2DRecord; + b: Point2DRecord; +} + +interface ModelBoundsRecord { + min: Point3DRecord; + max: Point3DRecord; +} + +interface ModelPreviewRecord { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; + bounds: ModelBoundsRecord; +} + +interface ExportSceneMetrics { + center: Point3DRecord; + modelBaseScale: number; + modelPivotOffsetZ: number; + dicomWidth: number; + dicomHeight: number; + dicomDepth: number; +} + +const exportFusionBaseExtent = 4.6; + +function readDicomHuVolume(files: string[]): DicomHuVolume { + if (!files.length) { + throw new Error('当前项目没有可导出的 DICOM 序列'); } + const parsed = files.map((fileName) => { + const buffer = fs.readFileSync(path.join(dicomDir, fileName)); + const attributes = parseDicomAttributes(buffer, 'default'); + const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); + if (!attributes.rows || !attributes.columns || !pixelTag) { + throw new Error(`无法解析 DICOM 像素数据:${fileName}`); + } + return { fileName, buffer, attributes, pixelOffset: pixelTag.valueOffset, pixelLength: pixelTag.length }; + }); + const first = parsed[0]; + const width = first.attributes.columns; + const height = first.attributes.rows; + const depth = parsed.length; + const data = Buffer.alloc(width * height * depth * 2); + let minHu = Infinity; + let maxHu = -Infinity; + + parsed.forEach((slice, z) => { + if (slice.attributes.columns !== width || slice.attributes.rows !== height) { + throw new Error(`DICOM 尺寸不一致:${slice.fileName}`); + } + + for (let index = 0; index < width * height; index += 1) { + const position = slice.pixelOffset + index * (slice.attributes.bitsAllocated / 8); + if (position + 1 >= slice.buffer.length || position >= slice.pixelOffset + slice.pixelLength) { + continue; + } + const raw = slice.attributes.bitsAllocated === 16 + ? (slice.attributes.pixelRepresentation ? slice.buffer.readInt16LE(position) : slice.buffer.readUInt16LE(position)) + : slice.buffer.readUInt8(position); + const hu = clampNumber(Math.round(raw * slice.attributes.rescaleSlope + slice.attributes.rescaleIntercept), -32768, 32767); + const outputOffset = (z * width * height + index) * 2; + data.writeInt16LE(hu, outputOffset); + minHu = Math.min(minHu, hu); + maxHu = Math.max(maxHu, hu); + } + }); + + const sliceSpacing = estimateSliceSpacingFromAttributes(parsed.map((item) => item.attributes)).value; + return { + width, + height, + depth, + columnSpacing: first.attributes.columnSpacing, + rowSpacing: first.attributes.rowSpacing, + sliceSpacing, + data, + minHu: Number.isFinite(minHu) ? minHu : 0, + maxHu: Number.isFinite(maxHu) ? maxHu : 0, + }; +} + +function writeNiftiHeader({ + width, + height, + depth, + columnSpacing, + rowSpacing, + sliceSpacing, + datatype, + bitpix, + description, + auxFile, +}: { + width: number; + height: number; + depth: number; + columnSpacing: number; + rowSpacing: number; + sliceSpacing: number; + datatype: number; + bitpix: number; + description: string; + auxFile: string; +}) { + const voxOffset = 352; const header = Buffer.alloc(voxOffset); - header.writeInt32LE(headerSize, 0); + header.writeInt32LE(348, 0); header.writeInt16LE(3, 40); header.writeInt16LE(width, 42); header.writeInt16LE(height, 44); @@ -333,22 +519,396 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) { header.writeInt16LE(1, 50); header.writeInt16LE(1, 52); header.writeInt16LE(1, 54); - header.writeInt16LE(2, 70); - header.writeInt16LE(8, 72); + header.writeInt16LE(datatype, 70); + header.writeInt16LE(bitpix, 72); header.writeFloatLE(1, 76); - header.writeFloatLE(1, 80); - header.writeFloatLE(1, 84); - header.writeFloatLE(1, 88); + header.writeFloatLE(columnSpacing, 80); + header.writeFloatLE(rowSpacing, 84); + header.writeFloatLE(sliceSpacing, 88); header.writeFloatLE(voxOffset, 108); header.writeFloatLE(1, 112); - header.write('ReVoxelSeg demo mask', 148, 'ascii'); - header.write(`Project ${project.id}`, 228, 'ascii'); + header.writeUInt8(2, 123); + header.writeInt16LE(1, 252); + header.writeInt16LE(1, 254); + header.write(description.slice(0, 79), 148, 'ascii'); + header.write(auxFile.slice(0, 23), 228, 'ascii'); + header.writeFloatLE(columnSpacing, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(rowSpacing, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(sliceSpacing, 320); + header.writeFloatLE(0, 324); header.write('n+1\0', 344, 'ascii'); + return header; +} - const nifti = Buffer.concat([header, data]); +function createNiftiBuffer(volume: DicomHuVolume, data: Buffer, kind: 'dicom' | 'segmentation', compressed: boolean) { + const isSegmentation = kind === 'segmentation'; + const nifti = Buffer.concat([ + writeNiftiHeader({ + width: volume.width, + height: volume.height, + depth: volume.depth, + columnSpacing: volume.columnSpacing, + rowSpacing: volume.rowSpacing, + sliceSpacing: volume.sliceSpacing, + datatype: isSegmentation ? 2 : 4, + bitpix: isSegmentation ? 8 : 16, + description: isSegmentation ? 'ReVoxelSeg label map' : 'ReVoxelSeg DICOM HU volume', + auxFile: isSegmentation ? 'segmentation' : 'dicom', + }), + data, + ]); return compressed ? zlib.gzipSync(nifti) : nifti; } +function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, previews: Record): ExportSceneMetrics | null { + const bounds = (project.stlFiles ?? []).reduce((accumulator, fileName) => { + const payload = previews[fileName]; + if (!payload) { + return accumulator; + } + accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x); + accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y); + accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z); + accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x); + accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y); + accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z); + return accumulator; + }, { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }); + + if (!Number.isFinite(bounds.min.x)) { + return null; + } + + const spanX = Math.max(bounds.max.x - bounds.min.x, 0.001); + const spanY = Math.max(bounds.max.y - bounds.min.y, 0.001); + const spanZ = Math.max(bounds.max.z - bounds.min.z, 0.001); + const maxModelSize = Math.max(spanX, spanY, spanZ, 1); + const physicalWidth = volume.width * volume.columnSpacing; + const physicalHeight = volume.height * volume.rowSpacing; + const physicalDepth = Math.max(volume.depth, 1) * volume.sliceSpacing; + const maxPhysical = Math.max(physicalWidth, physicalHeight, physicalDepth, 1); + const dicomWidth = (physicalWidth / maxPhysical) * exportFusionBaseExtent; + const dicomHeight = (physicalHeight / maxPhysical) * exportFusionBaseExtent; + const dicomDepth = Math.max((physicalDepth / maxPhysical) * exportFusionBaseExtent, 0.18); + const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92; + + return { + center: { + x: (bounds.min.x + bounds.max.x) / 2, + y: (bounds.min.y + bounds.max.y) / 2, + z: (bounds.min.z + bounds.max.z) / 2, + }, + modelBaseScale, + modelPivotOffsetZ: dicomDepth * 0.08, + dicomWidth, + dicomHeight, + dicomDepth, + }; +} + +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; + const rotateX = (pose.rotateX * Math.PI) / 180; + const rotateY = (pose.rotateY * Math.PI) / 180; + const rotateZ = (pose.rotateZ * Math.PI) / 180; + const cosX = Math.cos(rotateX); + const sinX = Math.sin(rotateX); + const cosY = Math.cos(rotateY); + const sinY = Math.sin(rotateY); + const cosZ = Math.cos(rotateZ); + const sinZ = Math.sin(rotateZ); + const afterX = { + x: px, + y: py * cosX - pz * sinX, + z: py * sinX + pz * cosX, + }; + const afterY = { + x: afterX.x * cosY + afterX.z * sinY, + y: afterX.y, + z: -afterX.x * sinY + afterX.z * cosY, + }; + px = afterY.x * cosZ - afterY.y * sinZ; + py = afterY.x * sinZ + afterY.y * cosZ; + pz = afterY.z; + + return { + x: px + pose.translateX, + y: py + pose.translateY, + z: pz + pose.translateZ, + }; +} + +function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null { + const epsilon = 1e-5; + const startDistance = start.z - targetZ; + const endDistance = end.z - targetZ; + + if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) { + return null; + } + if (Math.abs(startDistance) <= epsilon) { + return { x: start.x, y: start.y }; + } + if (Math.abs(endDistance) <= epsilon) { + return { x: end.x, y: end.y }; + } + if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) { + return null; + } + + const t = startDistance / (startDistance - endDistance); + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; +} + +function exportPointDistanceSquared(a: Point2DRecord, b: Point2DRecord) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function intersectExportTriangleWithPlane(a: Point3DRecord, b: Point3DRecord, c: Point3DRecord, targetZ: number): PlaneSegmentRecord | null { + const intersections = [ + intersectExportEdgeWithPlane(a, b, targetZ), + intersectExportEdgeWithPlane(b, c, targetZ), + intersectExportEdgeWithPlane(c, a, targetZ), + ].filter((point): point is Point2DRecord => Boolean(point)); + const uniquePoints: Point2DRecord[] = []; + + intersections.forEach((point) => { + if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-8)) { + uniquePoints.push(point); + } + }); + + if (uniquePoints.length < 2) { + return null; + } + + let segment: PlaneSegmentRecord = { a: uniquePoints[0], b: uniquePoints[1] }; + let maxDistance = exportPointDistanceSquared(segment.a, segment.b); + for (let first = 0; first < uniquePoints.length; first += 1) { + for (let second = first + 1; second < uniquePoints.length; second += 1) { + const distance = exportPointDistanceSquared(uniquePoints[first], uniquePoints[second]); + if (distance > maxDistance) { + maxDistance = distance; + segment = { a: uniquePoints[first], b: uniquePoints[second] }; + } + } + } + + return maxDistance > 1e-8 ? segment : null; +} + +function addExportSegmentToRows(rows: number[][], width: number, height: number, segment: PlaneSegmentRecord) { + const deltaY = segment.b.y - segment.a.y; + if (Math.abs(deltaY) < 0.01) { + return; + } + + const minY = Math.max(0, Math.floor(Math.min(segment.a.y, segment.b.y))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(segment.a.y, segment.b.y))); + for (let row = minY; row <= maxY; row += 1) { + const sampleY = row + 0.5; + const crosses = (sampleY >= segment.a.y && sampleY < segment.b.y) + || (sampleY >= segment.b.y && sampleY < segment.a.y); + if (!crosses) { + continue; + } + + const t = (sampleY - segment.a.y) / deltaY; + const x = segment.a.x + (segment.b.x - segment.a.x) * t; + if (Number.isFinite(x)) { + rows[row].push(x); + } + } +} + +function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { + const sliceOffset = slice * width * height; + rows.forEach((intersections, row) => { + if (intersections.length < 2) { + return; + } + + intersections.sort((left, right) => left - right); + const cleaned: number[] = []; + intersections.forEach((x) => { + const previous = cleaned[cleaned.length - 1]; + if (previous === undefined || Math.abs(previous - x) > 0.35) { + cleaned.push(x); + } + }); + + for (let index = 0; index + 1 < cleaned.length; index += 2) { + const rawStartX = cleaned[index]; + const rawEndX = cleaned[index + 1]; + if (rawEndX < 0 || rawStartX > width - 1) { + continue; + } + + const startX = clampNumber(Math.ceil(rawStartX), 0, width - 1); + const endX = clampNumber(Math.floor(rawEndX), 0, width - 1); + for (let x = startX; x <= endX; x += 1) { + data[sliceOffset + row * width + x] = label; + } + } + }); +} + +function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue) { + const data = Buffer.alloc(volume.width * volume.height * volume.depth); + const previews = (project.stlFiles ?? []).reduce>((accumulator, fileName) => { + const filePath = path.join(modelDir, fileName); + if (fs.existsSync(filePath)) { + accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord; + } + return accumulator; + }, {}); + const metrics = getExportMetrics(project, volume, previews); + if (!metrics) { + return data; + } + + const sliceToZ = (slice: number) => ( + volume.depth <= 1 + ? 0 + : -metrics.dicomDepth / 2 + (metrics.dicomDepth * slice) / (volume.depth - 1) + ); + const zToSlice = (z: number) => ( + volume.depth <= 1 + ? 0 + : ((z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1) + ); + const mapPoint = (point: Point2DRecord): Point2DRecord => ({ + x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width, + y: volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height, + }); + + (project.stlFiles ?? []).forEach((fileName, index) => { + const payload = previews[fileName]; + const style = project.moduleStyles[fileName] ?? { + visible: true, + color: defaultModuleColors[index % defaultModuleColors.length], + opacity: 0.72, + partId: index + 1, + }; + + if (!payload || style.visible === false) { + return; + } + + const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); + const rowsBySlice = new Map(); + const rowsForSlice = (slice: number) => { + const existing = rowsBySlice.get(slice); + if (existing) { + return existing; + } + const rows = Array.from({ length: volume.height }, () => [] as number[]); + rowsBySlice.set(slice, rows); + return rows; + }; + + for (let vertexIndex = 0; vertexIndex + 8 < payload.vertices.length; vertexIndex += 9) { + const a = transformPointForExportPose( + payload.vertices[vertexIndex], + payload.vertices[vertexIndex + 1], + payload.vertices[vertexIndex + 2], + metrics, + pose, + ); + const b = transformPointForExportPose( + payload.vertices[vertexIndex + 3], + payload.vertices[vertexIndex + 4], + payload.vertices[vertexIndex + 5], + metrics, + pose, + ); + const c = transformPointForExportPose( + payload.vertices[vertexIndex + 6], + payload.vertices[vertexIndex + 7], + payload.vertices[vertexIndex + 8], + metrics, + pose, + ); + const minSlice = clampNumber(Math.floor(zToSlice(Math.min(a.z, b.z, c.z))) - 1, 0, volume.depth - 1); + const maxSlice = clampNumber(Math.ceil(zToSlice(Math.max(a.z, b.z, c.z))) + 1, 0, volume.depth - 1); + + for (let slice = minSlice; slice <= maxSlice; slice += 1) { + const segment = intersectExportTriangleWithPlane(a, b, c, sliceToZ(slice)); + if (!segment) { + continue; + } + + addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, { + a: mapPoint(segment.a), + b: mapPoint(segment.b), + }); + } + } + + rowsBySlice.forEach((rows, slice) => { + fillExportRows(data, volume.width, volume.height, slice, rows, label); + }); + }); + + return data; +} + +function parseModelPoseQuery(raw: unknown) { + if (typeof raw !== 'string' || !raw.trim()) { + return undefined; + } + + try { + return normalizeModelPoseValue(JSON.parse(raw) as Partial); + } catch { + return undefined; + } +} + +function createNiftiExport(project: ProjectRecord, files: string[], target: 'dicom' | 'segmentation', compressed: boolean, pose?: ModelPoseValue) { + const volume = readDicomHuVolume(files); + if (target === 'dicom') { + return createNiftiBuffer(volume, volume.data, 'dicom', compressed); + } + + return createNiftiBuffer(volume, createSegmentationData(project, volume, pose ?? defaultModelPose), 'segmentation', compressed); +} + +function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) { + return Buffer.from(JSON.stringify({ + project: { + id: project.id, + name: project.name, + dicomPath: project.dicomPath, + modelPath: project.modelPath, + }, + generatedAt: now(), + activePose: activePose ?? null, + modelPoses: project.modelPoses, + moduleStyles: project.moduleStyles, + note: 'Pose values are stored in the ReVoxelSeg fusion view coordinate system.', + }, null, 2), 'utf8'); +} + function findProject(state: AppState, projectId: string) { return state.projects.find((candidate) => candidate.id === projectId); } @@ -776,11 +1336,11 @@ function cropDicomContent(pixels: Buffer, width: number, height: number) { return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight }; } -function createStlPreview(filePath: string, fileName: string, limit: number) { +function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord { const cacheKey = `${fileName}:${limit}`; const cached = modelPreviewCache.get(cacheKey); if (cached) { - return cached; + return cached as ModelPreviewRecord; } const buffer = fs.readFileSync(filePath); @@ -834,7 +1394,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) { } } - const payload = { + const payload: ModelPreviewRecord = { fileName, triangleCount, sampledTriangles, @@ -1120,6 +1680,25 @@ async function startServer() { res.json(project); }); + app.patch('/api/projects/:projectId/model-poses', (req, res) => { + const incoming = req.body?.modelPoses; + if (!Array.isArray(incoming)) { + res.status(400).json({ message: '位姿数据无效' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + project.modelPoses = normalizeModelPoses(incoming as Partial[]); + writeState(state); + res.json(project); + }); + app.get('/api/projects/:projectId/dicom-preview', (req, res) => { const project = findProject(readState(), req.params.projectId); if (!project) { @@ -1298,7 +1877,7 @@ async function startServer() { res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) }); }); - app.post('/api/projects/:projectId/export-mask', (req, res) => { + const handleProjectExport = (req: express.Request, res: express.Response, targetOverride?: 'segmentation') => { const state = readState(); const project = state.projects.find((candidate) => candidate.id === req.params.projectId); @@ -1307,19 +1886,42 @@ async function startServer() { return; } - const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; - const compressed = format === 'nii.gz'; - const mask = createNiftiMask(project, compressed); - const filename = `${project.id}-segmentation-mask.${format}`; - const outputPath = path.join(exportDir, filename); - fs.writeFileSync(outputPath, mask); - project.exportedMaskCount += 1; - writeState(state); + const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); + const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation'; + const activePose = parseModelPoseQuery(req.query.pose); - res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(mask); - }); + try { + if (target === 'pose') { + const posePayload = createPoseExport(project, activePose); + const filename = `${project.id}-pose-data.json`; + fs.writeFileSync(path.join(exportDir, filename), posePayload); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(posePayload); + return; + } + + const files = getProjectDicomFiles(project); + const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; + const compressed = format === 'nii.gz'; + const payload = createNiftiExport(project, files, target, compressed, activePose); + const suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label'; + const filename = `${project.id}-${suffix}.${format}`; + fs.writeFileSync(path.join(exportDir, filename), payload); + project.exportedMaskCount += target === 'segmentation' ? 1 : 0; + writeState(state); + + res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(payload); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : '导出失败' }); + } + }; + + app.get('/api/projects/:projectId/export-nifti', (req, res) => handleProjectExport(req, res)); + app.get('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation')); + app.post('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation')); if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, 'dist'))); diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 19eda0a..40d68ca 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; import { - Dices, Settings2, Download, Rotate3d, @@ -12,18 +11,8 @@ import { Save, } from 'lucide-react'; import * as THREE from 'three'; -import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types'; -import { api, downloadMask } from '../lib/api'; - -interface ModelPose { - rotateX: number; - rotateY: number; - rotateZ: number; - translateX: number; - translateY: number; - translateZ: number; - scale: number; -} +import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; +import { api, downloadMask, downloadSelectedProjectExports, ProjectExportTarget } from '../lib/api'; interface ModelPreviewPayload { fileName: string; @@ -71,6 +60,16 @@ const defaultModelPose: ModelPose = { scale: 1, }; +const defaultSavedPoses: SavedModelPose[] = [ + { id: 'default', name: '默认', pose: defaultModelPose }, + { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } }, + { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } }, +]; +const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ + { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, + { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, + { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, +]; const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const fusionBaseExtent = 4.6; @@ -120,6 +119,33 @@ function createDicomTexture(frame: string, width: number, height: number) { return texture; } +function CoordinateAxesInset() { + return ( +
+ +
+ ); +} + function FusionThreeView({ project, volume, @@ -492,6 +518,7 @@ function FusionThreeView({
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
+ {loadProgress < 100 && (
@@ -1679,14 +1706,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [showBounds, setShowBounds] = useState(true); const [cutEnabled, setCutEnabled] = useState(false); const [moduleStyles, setModuleStyles] = useState>({}); - const [savedPoses, setSavedPoses] = useState>([ - { id: 'default', name: '默认', pose: defaultModelPose }, - { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } }, - { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } }, - ]); + const [savedPoses, setSavedPoses] = useState(defaultSavedPoses); const [selectedPoseId, setSelectedPoseId] = useState('default'); - const [isRegistering, setIsRegistering] = useState(false); - const [progress, setProgress] = useState(0); + const [showExportMenu, setShowExportMenu] = useState(false); + const [exportSelection, setExportSelection] = useState>({ + dicom: true, + segmentation: true, + pose: true, + }); const [project, setProject] = useState(null); const [fusionVolume, setFusionVolume] = useState(null); const [fusionError, setFusionError] = useState(''); @@ -1694,15 +1721,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const fusionVolumeCacheRef = useRef(new Map()); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); - const handleStartRegistration = () => { - setIsRegistering(true); - setProgress(0); - }; - const handleExport = async (format: 'nii' | 'nii.gz') => { setExporting(true); try { - await downloadMask(projectId, format); + await downloadMask(projectId, format, modelPose); } catch (error) { setFusionError(error instanceof Error ? error.message : '导出失败'); } finally { @@ -1710,6 +1732,27 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { } }; + const handleExportSelected = async () => { + const selectedItems = exportOptions + .filter((option) => exportSelection[option.id]) + .map((option) => option.id); + if (!selectedItems.length) { + setFusionError('请至少选择一个导出内容'); + return; + } + + setExporting(true); + setFusionError(''); + try { + await downloadSelectedProjectExports(projectId, selectedItems, 'nii.gz', { pose: modelPose }); + window.setTimeout(() => setExporting(false), selectedItems.length * 220 + 200); + setShowExportMenu(false); + } catch (error) { + setFusionError(error instanceof Error ? error.message : '导出失败'); + setExporting(false); + } + }; + const makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ visible: fallback?.visible ?? true, color: fallback?.color ?? moduleColors[index % moduleColors.length], @@ -1764,6 +1807,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]); }); setModuleStyles(nextStyles); + setSavedPoses(item.modelPoses?.length ? item.modelPoses : defaultSavedPoses); + setSelectedPoseId('default'); }).catch(() => { setProject(null); setFusionVolume(null); @@ -1796,17 +1841,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { } }, []); - useEffect(() => { - if (isRegistering && progress < 100) { - const timer = setTimeout(() => setProgress((value) => value + 2), 50); - return () => clearTimeout(timer); - } - if (progress >= 100) { - setIsRegistering(false); - } - return undefined; - }, [isRegistering, progress]); - const updateModelPose = (partial: Partial) => { setModelPose((current) => ({ ...current, @@ -1885,20 +1919,35 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) }); }; + const commitSavedPoses = (next: SavedModelPose[]) => { + setSavedPoses(next); + if (!project) { + return; + } + api.updateProjectModelPoses(project.id, next) + .then((updated) => { + setProject(updated); + setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next); + }) + .catch(() => { + setFusionError('位姿保存失败,请稍后重试'); + }); + }; + const saveCurrentPose = () => { const nextPose = { id: `pose-${Date.now()}`, name: `位姿${savedPoses.length - 2}`, pose: { ...modelPose }, }; - setSavedPoses((current) => [...current, nextPose]); + commitSavedPoses([...savedPoses, nextPose]); setSelectedPoseId(nextPose.id); }; const renamePose = (poseId: string, name: string) => { if (poseId === 'default') return; const nextName = name.trim(); - setSavedPoses((current) => current.map((item) => ( + commitSavedPoses(savedPoses.map((item) => ( item.id === poseId ? { ...item, name: nextName || item.name } : item ))); }; @@ -1935,24 +1984,52 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

}
- - +
+ + {showExportMenu && ( +
+
+

导出内容

+ +
+
+ {exportOptions.map((option) => ( + + ))} +
+ +
+ )} +
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index f6d0195..88983af 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -1,4 +1,6 @@ -import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, OverviewSummary, Project, SessionState, UserRecord } from '../types'; +import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types'; + +export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose'; async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { @@ -55,6 +57,11 @@ export const api = { method: 'PATCH', body: JSON.stringify({ moduleStyles }), }), + updateProjectModelPoses: (projectId: string, modelPoses: SavedModelPose[]) => + request(`/api/projects/${projectId}/model-poses`, { + method: 'PATCH', + body: JSON.stringify({ modelPoses }), + }), 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}`), getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') => @@ -67,46 +74,43 @@ export const api = { }), }; -export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz') { - const response = await fetch(`/api/projects/${projectId}/export-mask?format=${encodeURIComponent(format)}`, { - method: 'POST', - }); - - if (!response.ok) { - throw new Error(`导出失败:${response.status}`); - } - - const blob = await response.blob(); - const disposition = response.headers.get('Content-Disposition') ?? ''; - const match = disposition.match(/filename="([^"]+)"/); - const filename = match?.[1] ?? `segmentation-mask.${format}`; - const url = URL.createObjectURL(blob); +function triggerFileDownload(url: string) { const link = document.createElement('a'); link.href = url; - link.download = filename; + link.rel = 'noopener'; document.body.appendChild(link); link.click(); link.remove(); - URL.revokeObjectURL(url); +} + +function appendPose(params: URLSearchParams, pose?: ModelPose) { + if (pose) { + params.set('pose', JSON.stringify(pose)); + } +} + +export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose) { + const params = new URLSearchParams({ format }); + appendPose(params, pose); + triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`); +} + +export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) { + const params = new URLSearchParams({ target, format }); + if (target !== 'dicom') { + appendPose(params, options.pose); + } + triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); +} + +export async function downloadSelectedProjectExports(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) { + targets.forEach((target, index) => { + window.setTimeout(() => { + void downloadProjectExport(projectId, target, format, options); + }, index * 180); + }); } 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); + triggerFileDownload(`/api/projects/${projectId}/dicom-archive`); } diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index f4806be..fc56164 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -21,6 +21,7 @@ export interface Project { exportedMaskCount?: number; isDefault?: boolean; moduleStyles?: Record; + modelPoses?: SavedModelPose[]; } export interface ModuleStyle { @@ -30,6 +31,22 @@ export interface ModuleStyle { partId: number; } +export interface ModelPose { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +export interface SavedModelPose { + id: string; + name: string; + pose: ModelPose; +} + export interface MaskMapping { className: string; color: string; diff --git a/工程分析/实现方案-2026-05-20-01-38-33.md b/工程分析/实现方案-2026-05-20-01-38-33.md new file mode 100644 index 0000000..45f2acb --- /dev/null +++ b/工程分析/实现方案-2026-05-20-01-38-33.md @@ -0,0 +1,71 @@ +# 实现方案-2026-05-20-01-38-33 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-20-01-38-33.md` + +## 修改目标 + +完善逆向工作区导出与位姿持久化:移除自动配准演示入口,新增可选导出 DICOM NIfTI、分割 NIfTI 和位姿 JSON,修复 blob 下载警告,增加三维坐标轴示意,并让保存位姿跨项目进入持久保留。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/需求分析-2026-05-20-01-38-33.md` +- `工程分析/实现方案-2026-05-20-01-38-33.md` +- `工程分析/测试方案-2026-05-20-01-38-33.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 后端扩展项目状态: + - 增加 `modelPoses` 字段,保存默认、俯视、侧视与用户自定义位姿。 + - 新增 `PATCH /api/projects/:projectId/model-poses`。 +2. 后端重写导出: + - 新增通用 NIfTI-1 写入函数。 + - DICOM 原始影像导出为 int16 HU `.nii.gz`,维度与 spacing 来自 DICOM。 + - 分割影像导出为 STL 构件采样切面填充后的 uint8 label map `.nii.gz`,维度与 spacing 与 DICOM 完全一致。 + - 位姿数据导出为 JSON,并记录导出时的当前 activePose。 + - 保留 `/export-mask` 兼容旧分割导出。 +3. 前端下载方式: + - 用直链 `` 下载替代 `fetch -> Blob -> URL.createObjectURL`。 + - 新增导出选项复选框和“导出所选”动作,分割和位姿导出会携带当前模型位姿。 +4. UI 调整: + - 去掉“开始自动配准”按钮和相关演示进度状态。 + - 顶部按钮改为“导出全部 NII.GZ”。 + - 三维融合视角右下角增加固定 XYZ 坐标轴示意。 +5. 位姿持久化: + - 项目加载时读取 `project.modelPoses`。 + - 保存/重命名位姿时写回后端。 + +## 执行步骤 + +- 更新类型定义和后端状态归一化。 +- 实现 NIfTI/位姿导出接口。 +- 更新前端 API 下载方法。 +- 更新 ReverseWorkspace UI、导出选项、位姿保存逻辑和坐标轴。 +- 执行 `npm run lint` 与 `npm run build`。 +- 验证新导出接口返回文件,重新部署并推送。 + +## 兼容性与回滚方案 + +- 旧 `downloadMask` 与 `/export-mask` 保持可用,只把底层分割 NIfTI 改为同维度数据。 +- 如果完整 DICOM NIfTI 导出耗时过高,可后续增加范围导出或后台任务。 +- 如果浏览器仍对 HTTP 下载提示安全警告,需要部署 HTTPS;本次先消除 blob URL 警告来源。 + +## 预计文件变更 + +- `server.ts`:项目位姿字段、DICOM NIfTI 导出、STL Label Map 导出、位姿导出、兼容旧导出。 +- `api.ts`:直链下载和导出选项 API。 +- `types.ts`:项目位姿类型。 +- `ReverseWorkspace.tsx`:移除自动配准、导出选项、坐标轴、位姿持久化。 +- 新增本次三份工程文档,更新经验记录。 + +## 提交与部署策略 + +- 提交信息:`2026-05-20-01-38-33 完善NII导出与位姿持久化` +- 显式暂存本次相关文件,避免提交历史删除状态。 +- 推送到 Gitea `origin/main` 并重新部署。 diff --git a/工程分析/测试方案-2026-05-20-01-38-33.md b/工程分析/测试方案-2026-05-20-01-38-33.md new file mode 100644 index 0000000..9895c49 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-01-38-33.md @@ -0,0 +1,59 @@ +# 测试方案-2026-05-20-01-38-33 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-20-01-38-33.md` + +## 静态检查 + +- 在 `WebSite/` 下执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 下执行 `npm run build`。 + +## 关键业务场景验证 + +- 顶部不再显示“开始自动配准”。 +- 顶部显示“导出全部 NII.GZ”,并能展开 DICOM 原始影像、分割影像、位姿数据选项。 +- 三维融合视角右下角显示小型 XYZ 坐标轴。 +- 保存自定义位姿后重新进入项目,位姿仍存在。 +- 重命名自定义位姿后刷新项目,名称仍保留。 + +## 导出验证 + +- `curl` 验证 DICOM 原始影像导出接口返回 `.nii.gz`。 +- `curl` 验证分割影像导出接口返回 `.nii.gz`。 +- `curl` 验证位姿数据导出接口返回 JSON。 +- 使用 gzip 文件头或响应大小确认导出不是旧 `64x64x64` 演示 Mask。 +- 确认前端下载不再使用 `URL.createObjectURL(blob)`。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证: + - `curl http://127.0.0.1:4000/api/health` + - `curl -I http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- 显式暂存本次相关代码和文档。 +- 创建包含时间戳和描述的 commit。 +- 推送到 Gitea `origin/main`。 + +## 实测结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;仅保留 Vite chunk size 提醒。 +- 临时服务 `127.0.0.1:4100` 导出 DICOM 原始影像:HTTP 200,`.nii.gz` 约 75.76 MB。 +- DICOM NIfTI 头验证:`512x512x300`,datatype `4`,bitpix `16`,vox_offset `352`。 +- 临时服务导出 STL 分割影像:HTTP 200,`.nii.gz` 约 146 KB。 +- STL 分割 NIfTI 头验证:`512x512x300`,datatype `2`,bitpix `8`,非零体素 `1131842`,最大标签 `9`。 +- 临时服务导出位姿数据:HTTP 200,JSON 内包含 `project=head-ct-demo`、当前 `activePose` 和 3 个默认/保存位姿。 +- 前端下载实现已确认不再使用 `URL.createObjectURL(blob)`,改为后端直链附件下载。 + +## 回归关注点 + +- 右侧分割 NII/NII.GZ 导出按钮仍可工作。 +- 项目库中的 Mask 下载入口仍可工作。 +- DICOM/STL 预览与三维融合视图不受导出改动影响。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 67df574..a0a9fba 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -991,3 +991,57 @@ C. 解决问题方案 D. 后续如何避免问题 调整任何滑条或步进按钮的 step 时,同步检查数值展示、长按步进和手动输入精度,避免控制精度和显示精度不一致。 + +## 2026-05-20-01-38-33 NIfTI 导出必须与 DICOM 体数据同维同距 + +A. 具体问题 + +旧的 NII/NII.GZ 导出生成的是固定 `64x64x64` 演示 Mask,用户在 ITK-SNAP 中打开时无法与真实 DICOM 序列对应,表现为尺度、层数和内容都不可信。 + +B. 产生问题原因 + +早期导出接口没有读取真实 DICOM Pixel Data、Pixel Spacing 和 slice spacing,而是直接写入一个合成球体数据;这类演示数据适合验证下载链路,但不能用于医学影像工具复核。 + +C. 解决问题方案 + +后端新增真实 NIfTI-1 写入链路:DICOM 原始影像导出为 `512x512x300` int16 HU volume,分割影像导出为同维度 uint8 Label Map,并把 spacing 写入 NIfTI header。分割数据复用 STL 构件采样切面、当前位姿变换和扫描线填充逻辑生成,标签值对应构件 `partId`。 + +D. 后续如何避免问题 + +任何影像导出功能都必须先确认维度、spacing、datatype、vox_offset 和真实数据来源。不能用固定尺寸演示数据替代生产导出;新增导出后至少用脚本读取 NIfTI header,并确认体素数和 DICOM 序列一致。 + +## 2026-05-20-01-38-33 浏览器下载不应依赖 blob:http 临时地址 + +A. 具体问题 + +浏览器控制台出现 `blob:http://... was loaded over an insecure connection`,用户误以为导出的 NII.GZ 文件本身或服务部署存在安全错误。 + +B. 产生问题原因 + +前端先 `fetch` 后端文件,再用 `URL.createObjectURL(blob)` 生成临时 `blob:http` 地址触发下载;在 HTTP 部署环境下,浏览器会对 blob 来源继承非安全上下文并给出警告。 + +C. 解决问题方案 + +将 NIfTI、DICOM 压缩包和多文件导出统一改成后端直链附件下载,由 Express 设置 `Content-Disposition`,前端只创建 `` 触发下载,不再生成 blob URL。 + +D. 后续如何避免问题 + +面向大文件、医疗影像和压缩包下载时优先使用后端附件直链。如果必须用 blob,应明确浏览器安全提示的影响;生产环境仍应考虑 HTTPS,但不能把 blob 临时地址当作常规下载路径。 + +## 2026-05-20-01-38-33 位姿列表需要进入项目状态而不是组件内存 + +A. 具体问题 + +用户保存的可视化工具栏位姿在重新进入项目后消失,造成调好的 STL/DICOM 对齐姿态无法继续复核或用于导出。 + +B. 产生问题原因 + +旧实现把 `savedPoses` 放在 `ReverseWorkspace` 组件本地 state 中,页面重新加载或重新进入项目时只会恢复默认、俯视、侧视三组临时位姿,没有写回后端项目状态。 + +C. 解决问题方案 + +为项目状态新增 `modelPoses` 字段,后端归一化默认位姿并提供 `PATCH /api/projects/:projectId/model-poses`;前端加载项目时读取 `project.modelPoses`,保存或重命名位姿时立即写回后端。 + +D. 后续如何避免问题 + +凡是用户明确“保存”的可视化参数都应进入项目级持久状态,不能只存在于组件 state。新增保存类交互时同时设计读取、写入、归一化和刷新后的回显验证。 diff --git a/工程分析/需求分析-2026-05-20-01-38-33.md b/工程分析/需求分析-2026-05-20-01-38-33.md new file mode 100644 index 0000000..d391e04 --- /dev/null +++ b/工程分析/需求分析-2026-05-20-01-38-33.md @@ -0,0 +1,55 @@ +# 需求分析-2026-05-20-01-38-33 + +## 开始时间 + +2026-05-20-01-38-33 + +## 原始需求摘要 + +用户提出五项修改:去掉“开始自动配准”;将上方导出改为“导出全部 NII.GZ”,并提供 DICOM 原始影像、分割影像、位姿数据选项,方便 ITK-SNAP 打开检查;在三维融合视角右下角增加小型 XYZ 坐标轴;解决重新进入项目后可视化工具栏保存位姿丢失;处理浏览器对 `blob:http` 下载的 insecure connection 报错。 + +## 业务目标 + +- 顶部操作区更聚焦导出,不再展示自动配准演示按钮。 +- 导出的医学影像文件与 DICOM 原始序列维度、spacing 对齐,能被 ITK-SNAP 作为主影像/分割影像加载。 +- 位姿数据能随项目保存,并能作为导出侧车 JSON 下载。 +- 三维融合视角提供固定的 XYZ 空间方向提示。 +- 前端下载避免 `URL.createObjectURL(blob)`,减少 `blob:http` insecure connection 警告。 + +## 输入与输出 + +- 输入: + - 当前项目 DICOM 序列、模块样式、已保存位姿。 + - 用户在导出选项中勾选的 DICOM 原始影像、分割影像、位姿数据。 +- 输出: + - DICOM 原始影像 `.nii.gz`。 + - 与原始影像同维度/spacing 的分割影像 `.nii.gz`。 + - 项目位姿与构件样式 JSON。 + - 重新进入项目仍可读取的已保存位姿。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- 本次工程分析文档与 `工程分析/经验记录.md` + +## 关键约束 + +- 不引入新依赖。 +- 旧的分割导出入口仍需兼容。 +- NIfTI 文件需要保持标准 NIfTI-1 单文件布局,设置正确 dim、datatype、pixdim、vox_offset、magic。 +- 位姿保存不能只停留在 React 组件内存中,需要写入项目状态文件。 +- 本次提交不能混入历史 `工程分析` 文档删除状态。 + +## 风险点 + +- 导出完整 512x512x300 体数据会比旧 64x64x64 演示 Mask 大很多,生成和下载耗时增加。 +- 当前分割影像仍是后端基于 DICOM 像素阈值生成的同维度标签图,不等同于完整 STL 体素化算法。 +- HTTP 访问环境下浏览器仍可能对非 HTTPS 下载有提示,但去掉 blob URL 可消除当前报错指向的 `blob:http` 链路。 + +## 默认假设 + +- “DICOM 原始影像”导出优先保证 ITK-SNAP 可打开与空间尺寸匹配,使用当前解析到的 CT HU int16 数据写入 NIfTI。 +- “位姿数据”作为 JSON 侧车文件导出,供复现实验参数使用,不作为 ITK-SNAP 主影像打开。