import express from 'express'; import { createServer as createViteServer } from 'vite'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import zlib from 'node:zlib'; import { fileURLToPath } from 'node:url'; type ProjectStatus = 'pending' | 'completed' | 'processing'; type DicomPlane = 'axial' | 'sagittal' | 'coronal'; type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast'; type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose'; type SegmentationExportScope = 'all' | 'visible'; interface ModuleStyleRecord { visible: boolean; color: string; opacity: number; 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; account: string; password: string; department: string; date: string; } interface ProjectRecord { id: string; name: string; createTime: string; status: ProjectStatus; dicomCount: number; hasModel: boolean; dicomPath: string; modelPath: string; modelCount: number; stlFiles: string[]; maskFormats: Array<'nii' | 'nii.gz'>; exportedMaskCount: number; isDefault?: boolean; moduleStyles: Record; modelPoses: ModelPoseRecord[]; } interface SessionRecord { authenticated: boolean; account: string | null; lastUpdated: string; } interface AppState { users: UserRecord[]; projects: ProjectRecord[]; session: SessionRecord; updatedAt: string; } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const dataDir = path.join(__dirname, 'data'); const exportDir = path.join(__dirname, 'exports'); const statePath = path.join(dataDir, 'state.json'); const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); const dicomPreviewCache = new Map(); 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; 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()); } function now() { return new Date().toISOString(); } function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } function listFiles(dir: string, extension: string) { if (!fs.existsSync(dir)) { return []; } return fs .readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension)) .map((entry) => entry.name) .sort(naturalFileCompare); } function naturalFileCompare(a: string, b: string) { return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); } function publicUser(user: UserRecord) { const { password: _password, ...rest } = user; return rest; } function publicSession(state: AppState) { const user = state.session.account ? state.users.find((candidate) => candidate.account === state.session.account) : null; return { authenticated: state.session.authenticated && Boolean(user), currentUser: user ? { id: user.id, name: user.name, account: user.account, department: user.department, } : null, lastUpdated: state.session.lastUpdated, }; } function clampNumber(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function normalizeModuleStyle( style: Partial | undefined, index: number, ): ModuleStyleRecord { const opacity = typeof style?.opacity === 'number' && Number.isFinite(style.opacity) ? style.opacity : 0.72; const partId = typeof style?.partId === 'number' && Number.isFinite(style.partId) ? style.partId : index + 1; return { visible: typeof style?.visible === 'boolean' ? style.visible : true, color: typeof style?.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(style.color) ? style.color : defaultModuleColors[index % defaultModuleColors.length], opacity: clampNumber(opacity, 0.1, 1), partId: clampNumber(Math.round(partId), 1, 255), }; } function buildModuleStyles( stlFiles: string[], existing?: Record>, ) { return stlFiles.reduce>((acc, fileName, index) => { acc[fileName] = normalizeModuleStyle(existing?.[fileName], index); return acc; }, {}); } 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'); return { id: 'head-ct-demo', name: '头部 CT 模型逆向体素化演示', createTime: today(), status: 'completed', dicomCount: listFiles(dicomDir, '.dcm').length, hasModel: stlFiles.length > 0, dicomPath: 'Head_CT_DICOM', modelPath: 'Head_CT_ReConstruct', modelCount: stlFiles.length, stlFiles, maskFormats: ['nii', 'nii.gz'], exportedMaskCount: 0, isDefault: true, moduleStyles: buildModuleStyles(stlFiles), modelPoses: defaultModelPoses(), }; } function buildEmptyProject(name: string): ProjectRecord { return { id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, name, createTime: today(), status: 'pending', dicomCount: 0, hasModel: false, dicomPath: '', modelPath: '', modelCount: 0, stlFiles: [], maskFormats: ['nii', 'nii.gz'], exportedMaskCount: 0, moduleStyles: {}, modelPoses: defaultModelPoses(), }; } function defaultState(): AppState { return { users: [ { id: 1, name: 'Admin', account: 'admin', password: '123456', department: 'admin', date: today() }, { id: 2, name: 'Doctor Li', account: 'doctor_li', password: '123456', department: '肝胆外科', date: today() }, ], projects: [buildDefaultProject()], session: { authenticated: false, account: null, lastUpdated: now() }, updatedAt: now(), }; } function normalizeState(state: AppState): AppState { const defaultProject = buildDefaultProject(); const savedDefaultProject = state.projects?.find((project) => project.id === defaultProject.id); const customProjects = Array.isArray(state.projects) ? state.projects .filter((project) => project.id !== defaultProject.id) .map((project) => ({ ...project, stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [], exportedMaskCount: project.exportedMaskCount ?? 0, maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles), modelPoses: normalizeModelPoses(project.modelPoses), })) : []; return { ...state, projects: [ { ...defaultProject, name: savedDefaultProject?.name ?? defaultProject.name, exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0, moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles), modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses), }, ...customProjects, ], }; } function readState(): AppState { ensureDir(dataDir); if (!fs.existsSync(statePath)) { const initialState = defaultState(); writeState(initialState); return initialState; } try { const raw = fs.readFileSync(statePath, 'utf8'); return normalizeState(JSON.parse(raw) as AppState); } catch { const recoveredState = defaultState(); writeState(recoveredState); return recoveredState; } } function writeState(state: AppState) { ensureDir(dataDir); fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2)); } interface DicomHuVolume { width: number; height: number; depth: number; columnSpacing: number; rowSpacing: number; sliceSpacing: number; data: Buffer; minHu: number; maxHu: number; } interface Point2DRecord { x: number; y: number; } interface Point3DRecord { x: number; y: number; z: number; } 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(348, 0); header.writeInt16LE(3, 40); header.writeInt16LE(width, 42); header.writeInt16LE(height, 44); header.writeInt16LE(depth, 46); header.writeInt16LE(1, 48); header.writeInt16LE(1, 50); header.writeInt16LE(1, 52); header.writeInt16LE(1, 54); header.writeInt16LE(datatype, 70); header.writeInt16LE(bitpix, 72); header.writeFloatLE(1, 76); header.writeFloatLE(columnSpacing, 80); header.writeFloatLE(rowSpacing, 84); header.writeFloatLE(sliceSpacing, 88); header.writeFloatLE(voxOffset, 108); header.writeFloatLE(1, 112); 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; } 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 getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord { return project.moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[index % defaultModuleColors.length], opacity: 0.72, partId: index + 1, }; } function isModuleIncludedForExport(style: ModuleStyleRecord, scope: SegmentationExportScope) { return scope === 'all' || style.visible !== false; } function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue, scope: SegmentationExportScope = 'visible') { 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 = getModuleStyle(project, fileName, index); if (!payload || !isModuleIncludedForExport(style, scope)) { 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 createSegmentationLabelMetadata(project: ProjectRecord, scope: SegmentationExportScope, activePose?: ModelPoseValue) { const labels = (project.stlFiles ?? []) .map((fileName, index) => { const style = getModuleStyle(project, fileName, index); if (!isModuleIncludedForExport(style, scope)) { return null; } const name = fileName.replace(/\.stl$/i, ''); const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); return { label, partId: label, name, categoryName: name, className: name, fileName, color: style.color, opacity: style.opacity, visible: style.visible !== false, }; }) .filter((item): item is { label: number; partId: number; name: string; categoryName: string; className: string; fileName: string; color: string; opacity: number; visible: boolean; } => Boolean(item)); return Buffer.from(JSON.stringify({ project: { id: project.id, name: project.name, dicomPath: project.dicomPath, modelPath: project.modelPath, }, generatedAt: now(), segmentationScope: scope, activePose: activePose ?? null, labels, note: 'Label values correspond to ReVoxelSeg STL component hierarchy partId values.', }, null, 2), 'utf8'); } function parseModelPoseQuery(raw: unknown) { if (typeof raw !== 'string' || !raw.trim()) { return undefined; } try { return normalizeModelPoseValue(JSON.parse(raw) as Partial); } catch { return undefined; } } function parseSegmentationScope(raw: unknown): SegmentationExportScope { return raw === 'all' ? 'all' : 'visible'; } function parseExportTargets(raw: unknown): ProjectExportTarget[] { const values = typeof raw === 'string' ? raw.split(',') : []; const targets = values.filter((value): value is ProjectExportTarget => ( value === 'dicom' || value === 'segmentation' || value === 'pose' )); return [...new Set(targets)]; } function createNiftiExport( project: ProjectRecord, files: string[], target: 'dicom' | 'segmentation', compressed: boolean, pose?: ModelPoseValue, segmentationScope: SegmentationExportScope = 'visible', ) { const volume = readDicomHuVolume(files); if (target === 'dicom') { return createNiftiBuffer(volume, volume.data, 'dicom', compressed); } return createNiftiBuffer( volume, createSegmentationData(project, volume, pose ?? defaultModelPose, segmentationScope), '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 createProjectExportBundle({ project, files, targets, compressed, activePose, segmentationScope, }: { project: ProjectRecord; files: string[]; targets: ProjectExportTarget[]; compressed: boolean; activePose?: ModelPoseValue; segmentationScope: SegmentationExportScope; }) { const entries: Array<{ name: string; data: Buffer; mtime?: number }> = []; const needsVolume = targets.includes('dicom') || targets.includes('segmentation'); const volume = needsVolume ? readDicomHuVolume(files) : null; const format = compressed ? 'nii.gz' : 'nii'; const exportRoot = `${project.id}-nifti-export`; if (targets.includes('dicom') && volume) { entries.push({ name: `${exportRoot}/${project.id}-dicom-image.${format}`, data: createNiftiBuffer(volume, volume.data, 'dicom', compressed), }); } if (targets.includes('segmentation') && volume) { entries.push({ name: `${exportRoot}/${project.id}-segmentation-label.${format}`, data: createNiftiBuffer( volume, createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope), 'segmentation', compressed, ), }); entries.push({ name: `${exportRoot}/${project.id}-segmentation-labels.json`, data: createSegmentationLabelMetadata(project, segmentationScope, activePose), }); } if (targets.includes('pose')) { entries.push({ name: `${exportRoot}/${project.id}-pose-data.json`, data: createPoseExport(project, activePose), }); } if (!entries.length) { throw new Error('未选择可导出的内容'); } return createTarGz(entries); } function findProject(state: AppState, projectId: string) { return state.projects.find((candidate) => candidate.id === projectId); } function getProjectDicomFiles(project: ProjectRecord) { if (project.id !== 'head-ct-demo') { return []; } return listFiles(dicomDir, '.dcm'); } 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, (group >> 8) & 0xff, element & 0xff, (element >> 8) & 0xff, ]); const longVr = ['OB', 'OD', 'OF', 'OL', 'OW', 'SQ', 'UC', 'UR', 'UT', 'UN']; let offset = buffer.indexOf(pattern, 132); while (offset >= 0 && offset + 8 < buffer.length) { const vr = buffer.subarray(offset + 4, offset + 6).toString('ascii'); if (/^[A-Z]{2}$/.test(vr)) { if (longVr.includes(vr)) { const length = buffer.readUInt32LE(offset + 8); return { valueOffset: offset + 12, length, vr }; } const length = buffer.readUInt16LE(offset + 6); return { valueOffset: offset + 8, length, vr }; } offset = buffer.indexOf(pattern, offset + 1); } return null; } function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fallbackWidth: number) { if (mode === 'bone') { return { windowCenter: 500, windowWidth: 2000 }; } if (mode === 'soft') { return { windowCenter: 40, windowWidth: 400 }; } if (mode === 'contrast') { return { windowCenter: 80, windowWidth: 180 }; } return { windowCenter: fallbackCenter, windowWidth: fallbackWidth }; } function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') { const buffer = fs.readFileSync(filePath); const attrs = parseDicomAttributes(buffer, mode); const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); const pixelOffset = pixelTag?.valueOffset ?? -1; const pixelLength = pixelTag?.length ?? 0; if (!attrs.rows || !attrs.columns || pixelOffset < 0) { throw new Error('无法解析当前 DICOM 像素数据'); } const count = attrs.rows * attrs.columns; const pixels = Buffer.alloc(count); 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 * (attrs.bitsAllocated / 8); if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) { break; } const raw = attrs.bitsAllocated === 16 ? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) : buffer.readUInt8(position); 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))); } pixels[i] = normalized; } const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows); return { width: attrs.columns, height: attrs.rows, pixels: enhancedPixels.toString('base64'), 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, }; } function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') { const preview = parseDicomPreview(filePath, mode); return { ...preview, pixelBuffer: Buffer.from(preview.pixels, 'base64'), }; } 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) { return cached; } 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 { getDicomVolume(files, 'default'); getDicomVolume(files, 'soft'); } catch (error) { console.warn('DICOM volume warmup failed:', error); } }, 300); } function createReformattedPreview(files: string[], plane: Exclude, slice: number, mode: DicomDisplayMode) { const volume = getDicomVolume(files, mode); const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1; const clampedSlice = Math.max(0, Math.min(maxSlice, slice)); const outputWidth = files.length; const outputHeight = plane === 'sagittal' ? volume.height : volume.width; const pixels = Buffer.alloc(outputWidth * outputHeight); volume.frames.forEach((frame, z) => { for (let row = 0; row < outputHeight; row += 1) { const sourceIndex = plane === 'sagittal' ? row * volume.width + clampedSlice : clampedSlice * volume.width + row; const targetIndex = row * outputWidth + z; pixels[targetIndex] = frame[sourceIndex] ?? 0; } }); const cropped = cropDicomContent(pixels, outputWidth, outputHeight); const ySpacing = plane === 'sagittal' ? volume.rowSpacing : volume.columnSpacing; const physical = resampleToPhysicalAspect(cropped.pixels, cropped.width, cropped.height, volume.sliceSpacing, ySpacing); const enhancedPixels = enhanceDicomEdges(physical.pixels, physical.width, physical.height); return { width: physical.width, height: physical.height, pixels: enhancedPixels.toString('base64'), windowCenter: volume.windowCenter, windowWidth: volume.windowWidth, slice: clampedSlice, total: maxSlice + 1, fileName: `${plane}-${clampedSlice}`, mode, spacing: { row: volume.rowSpacing, column: volume.columnSpacing, slice: volume.sliceSpacing, displayX: volume.sliceSpacing, displayY: ySpacing, }, physicalSize: { width: physical.physicalWidth, height: physical.physicalHeight, }, }; } function createDicomFusionVolume(files: string[], start: number, end: number, mode: DicomDisplayMode) { const volume = getDicomVolume(files, mode); const total = volume.frames.length; const safeStart = Math.max(0, Math.min(total - 1, Number.isFinite(start) ? start : 0)); const safeEnd = Math.max(safeStart, Math.min(total - 1, Number.isFinite(end) ? end : safeStart + 49)); const maxFrames = 64; const rangeLength = safeEnd - safeStart + 1; const step = Math.max(1, Math.ceil(rangeLength / maxFrames)); const indices: number[] = []; for (let index = safeStart; index <= safeEnd; index += step) { indices.push(index); } if (indices[indices.length - 1] !== safeEnd) { indices.push(safeEnd); } const maxTextureDimension = 256; const textureScale = Math.min(1, maxTextureDimension / Math.max(volume.width, volume.height)); const targetWidth = Math.max(1, Math.round(volume.width * textureScale)); const targetHeight = Math.max(1, Math.round(volume.height * textureScale)); const frames = indices.map((index) => ( resampleNearest(volume.frames[index], volume.width, volume.height, targetWidth, targetHeight).toString('base64') )); return { width: targetWidth, height: targetHeight, start: safeStart, end: safeEnd, total, indices, frames, mode, spacing: { row: volume.rowSpacing, column: volume.columnSpacing, slice: volume.sliceSpacing, }, physicalSize: { width: volume.width * volume.columnSpacing, height: volume.height * volume.rowSpacing, depth: Math.max(1, total) * volume.sliceSpacing, unit: 'mm', }, }; } function enhanceDicomEdges(pixels: Buffer, width: number, height: number) { if (width < 3 || height < 3) { return pixels; } const output = Buffer.from(pixels); for (let y = 1; y < height - 1; y += 1) { for (let x = 1; x < width - 1; x += 1) { const index = y * width + x; const center = pixels[index]; const neighborAverage = ( pixels[index - 1] + pixels[index + 1] + pixels[index - width] + pixels[index + width] ) / 4; const sharpened = Math.round(center * 1.08 + (center - neighborAverage) * 0.55); output[index] = Math.max(0, Math.min(255, sharpened)); } } return output; } function cropDicomContent(pixels: Buffer, width: number, height: number) { const threshold = 12; const columnHits = Array.from({ length: width }, () => 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): ModelPreviewRecord { const cacheKey = `${fileName}:${limit}`; const cached = modelPreviewCache.get(cacheKey); if (cached) { return cached as ModelPreviewRecord; } const buffer = fs.readFileSync(filePath); if (buffer.length < 84) { throw new Error('STL 文件内容为空或不完整'); } const triangleCount = buffer.readUInt32LE(80); const expectedLength = 84 + triangleCount * 50; if (triangleCount <= 0 || expectedLength > buffer.length + 1024) { throw new Error('当前仅支持二进制 STL 预览'); } const sampleLimit = Math.max(100, Math.min(limit, 200000)); const step = Math.max(1, Math.ceil(triangleCount / sampleLimit)); const vertices: number[] = []; let sampledTriangles = 0; const bounds = { min: { x: Infinity, y: Infinity, z: Infinity }, max: { x: -Infinity, y: -Infinity, z: -Infinity }, }; for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { const offset = 84 + triangleIndex * 50; if (offset + 50 > buffer.length) { break; } const shouldSample = triangleIndex % step === 0; for (let vertex = 0; vertex < 3; vertex += 1) { const vertexOffset = offset + 12 + vertex * 12; const x = buffer.readFloatLE(vertexOffset); const y = buffer.readFloatLE(vertexOffset + 4); const z = buffer.readFloatLE(vertexOffset + 8); bounds.min.x = Math.min(bounds.min.x, x); bounds.min.y = Math.min(bounds.min.y, y); bounds.min.z = Math.min(bounds.min.z, z); bounds.max.x = Math.max(bounds.max.x, x); bounds.max.y = Math.max(bounds.max.y, y); bounds.max.z = Math.max(bounds.max.z, z); if (shouldSample) { vertices.push( Number(x.toFixed(3)), Number(y.toFixed(3)), Number(z.toFixed(3)), ); } } if (shouldSample) { sampledTriangles += 1; } } const payload: ModelPreviewRecord = { fileName, triangleCount, sampledTriangles, vertices, bounds: { min: { x: Number(bounds.min.x.toFixed(3)), y: Number(bounds.min.y.toFixed(3)), z: Number(bounds.min.z.toFixed(3)), }, max: { x: Number(bounds.max.x.toFixed(3)), y: Number(bounds.max.y.toFixed(3)), z: Number(bounds.max.z.toFixed(3)), }, }, }; modelPreviewCache.set(cacheKey, payload); 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 createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) { const chunks: Buffer[] = []; entries.forEach((entry) => { const data = entry.data; chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000)); 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)); } function createDicomTarGz(files: string[]) { return createTarGz(files.map((fileName) => { const filePath = path.join(dicomDir, fileName); const stat = fs.statSync(filePath); return { name: `Head_CT_DICOM/${fileName}`, data: fs.readFileSync(filePath), mtime: stat.mtimeMs / 1000, }; })); } function estimateSliceSpacingFromAttributes(attributes: DicomAttributes[]) { const diffs: number[] = []; for (let index = 1; index < attributes.length; index += 1) { const previous = attributes[index - 1].imagePosition; const current = attributes[index].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) { 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'; const portArg = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : process.env.PORT; const port = Number(portArg ?? 4000); ensureDir(exportDir); app.use(express.json()); app.get('/api/health', (_req, res) => { res.json({ ok: true, service: 'revoxelseg-dicom', time: now() }); }); app.get('/api/session', (_req, res) => { res.json(publicSession(readState())); }); app.post('/api/login', (req, res) => { const { account, password } = req.body as { account?: string; password?: string }; const state = readState(); const user = state.users.find((candidate) => candidate.account === account && candidate.password === password); if (!user) { res.status(401).json({ message: '账号或密码错误' }); return; } state.session = { authenticated: true, account: user.account, lastUpdated: now() }; writeState(state); res.json(publicSession(state)); }); app.post('/api/logout', (_req, res) => { const state = readState(); state.session = { authenticated: false, account: null, lastUpdated: now() }; writeState(state); res.json(publicSession(state)); }); app.get('/api/users', (_req, res) => { res.json(readState().users.map(publicUser)); }); app.get('/api/projects', (_req, res) => { res.json(readState().projects); }); app.post('/api/projects', (req, res) => { const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { res.status(400).json({ message: '项目名称不能为空' }); return; } const state = readState(); const project = buildEmptyProject(name); state.projects.push(project); writeState(state); res.status(201).json(project); }); app.get('/api/projects/:projectId', (req, res) => { const project = findProject(readState(), req.params.projectId); if (!project) { res.status(404).json({ message: '项目不存在' }); return; } res.json(project); }); app.patch('/api/projects/:projectId', (req, res) => { const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { 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.name = name; writeState(state); res.json(project); }); app.delete('/api/projects/:projectId', (req, res) => { const state = readState(); const index = state.projects.findIndex((project) => project.id === req.params.projectId); if (index < 0) { res.status(404).json({ message: '项目不存在' }); return; } const [deleted] = state.projects.splice(index, 1); writeState(state); res.json({ ok: true, deletedId: deleted.id }); }); app.patch('/api/projects/:projectId/module-styles', (req, res) => { const incoming = req.body?.moduleStyles; if (!incoming || typeof incoming !== 'object' || 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.moduleStyles = buildModuleStyles(project.stlFiles, { ...(project.moduleStyles ?? {}), ...(incoming as Record>), }); writeState(state); 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) { res.status(404).json({ message: '项目不存在' }); return; } const files = getProjectDicomFiles(project); if (!files.length) { res.status(404).json({ message: '当前项目没有可预览的 DICOM 文件' }); return; } const requestedPlane = String(req.query.plane ?? 'axial'); const plane: DicomPlane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial'; const requestedMode = String(req.query.mode ?? 'default'); const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'default'; const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10); const cacheKey = `${project.id}:${plane}:${mode}:${Number.isFinite(requestedSlice) ? requestedSlice : 0}`; if (dicomPreviewCache.has(cacheKey)) { res.json(dicomPreviewCache.get(cacheKey)); return; } try { let payload: unknown; if (plane === 'axial') { const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); const preview = parseDicomPreview(path.join(dicomDir, files[slice]), mode); payload = { ...preview, plane, slice, total: files.length, fileName: files[slice], }; } else { payload = { ...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode), plane, }; } dicomPreviewCache.set(cacheKey, payload); res.json(payload); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' }); } }); app.get('/api/projects/:projectId/dicom-fusion-volume', (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; } const requestedMode = String(req.query.mode ?? 'soft'); const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'soft'; const start = Number.parseInt(String(req.query.start ?? '0'), 10); const end = Number.parseInt(String(req.query.end ?? '49'), 10); try { res.json(createDicomFusionVolume(files, start, end, mode)); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' }); } }); 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/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); if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { res.status(404).json({ message: '模型文件不存在' }); return; } res.sendFile(path.join(modelDir, fileName)); }); app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => { const project = findProject(readState(), req.params.projectId); const fileName = path.basename(req.params.fileName); const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10); if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { res.status(404).json({ message: '模型文件不存在' }); return; } try { res.json(createStlPreview(path.join(modelDir, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' }); } }); app.get('/api/overview', (_req, res) => { const state = readState(); const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0); const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0); const exportedMaskProjects = state.projects.filter((project) => project.exportedMaskCount > 0).length; res.json({ totalProjects: state.projects.length, processedProjects: exportedMaskProjects, exportedMaskProjects, dicomCount, modelCount, chartData: [ { name: 'Mon', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Tue', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Wed', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Thu', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Fri', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Sat', projects: state.projects.length, processing: exportedMaskProjects }, { name: 'Sun', projects: state.projects.length, processing: exportedMaskProjects }, ], }); }); app.post('/api/demo/reset', (_req, res) => { const state = defaultState(); writeState(state); res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) }); }); const handleProjectExport = (req: express.Request, res: express.Response, targetOverride?: 'segmentation') => { const state = readState(); const project = state.projects.find((candidate) => candidate.id === req.params.projectId); if (!project) { res.status(404).json({ message: '项目不存在' }); return; } const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation'; const activePose = parseModelPoseQuery(req.query.pose); const segmentationScope = parseSegmentationScope(req.query.segmentationScope); 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, segmentationScope); 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-bundle', (req, res) => { const state = readState(); const project = state.projects.find((candidate) => candidate.id === req.params.projectId); if (!project) { res.status(404).json({ message: '项目不存在' }); return; } const targets = parseExportTargets(req.query.targets); if (!targets.length) { res.status(400).json({ message: '请至少选择一个导出内容' }); return; } const activePose = parseModelPoseQuery(req.query.pose); const segmentationScope = parseSegmentationScope(req.query.segmentationScope); const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; const compressed = format === 'nii.gz'; try { const files = getProjectDicomFiles(project); const payload = createProjectExportBundle({ project, files, targets, compressed, activePose, segmentationScope, }); const filename = `${project.id}-nifti-export.tar.gz`; fs.writeFileSync(path.join(exportDir, filename), payload); project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0; writeState(state); res.setHeader('Content-Type', 'application/gzip'); 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'))); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); } else { const vite = await createViteServer({ server: { middlewareMode: true, hmr: { port: 24679 } }, appType: 'spa', }); app.use(vite.middlewares); } app.listen(port, host, () => { console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`); warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject())); }); } startServer().catch((error) => { console.error(error); process.exit(1); });