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'; 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; } 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'); 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((a, b) => a.localeCompare(b, 'zh-Hans-CN')); } 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 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, }; } 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, }; } 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 customProjects = Array.isArray(state.projects) ? state.projects .filter((project) => project.id !== defaultProject.id) .map((project) => ({ ...project, exportedMaskCount: project.exportedMaskCount ?? 0, maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], })) : []; return { ...state, projects: [ { ...defaultProject, name: state.projects?.find((project) => project.id === defaultProject.id)?.name ?? defaultProject.name, exportedMaskCount: state.projects?.find((project) => project.id === defaultProject.id)?.exportedMaskCount ?? 0, }, ...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)); } 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]; 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; if (radius < 1) { data[index] = 1; } 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; } } } } const header = Buffer.alloc(voxOffset); header.writeInt32LE(headerSize, 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(2, 70); header.writeInt16LE(8, 72); header.writeFloatLE(1, 76); header.writeFloatLE(1, 80); header.writeFloatLE(1, 84); header.writeFloatLE(1, 88); header.writeFloatLE(voxOffset, 108); header.writeFloatLE(1, 112); header.write('ReVoxelSeg demo mask', 148, 'ascii'); header.write(`Project ${project.id}`, 228, 'ascii'); header.write('n+1\0', 344, 'ascii'); const nifti = Buffer.concat([header, data]); return compressed ? zlib.gzipSync(nifti) : nifti; } 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').sort((a, b) => Number.parseInt(a) - Number.parseInt(b)); } function readAsciiValue(buffer: Buffer, start: number, length: number) { return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim(); } 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 parseDicomPreview(filePath: string) { const buffer = fs.readFileSync(filePath); const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010); const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011); const bitsTag = findExplicitTag(buffer, 0x0028, 0x0100); const representationTag = findExplicitTag(buffer, 0x0028, 0x0103); const centerTag = findExplicitTag(buffer, 0x0028, 0x1050); const widthTag = findExplicitTag(buffer, 0x0028, 0x1051); const interceptTag = findExplicitTag(buffer, 0x0028, 0x1052); const slopeTag = findExplicitTag(buffer, 0x0028, 0x1053); const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); const rows = rowsTag ? buffer.readUInt16LE(rowsTag.valueOffset) : 0; const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0; const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16; const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0; const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40; const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400; const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0; const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1; const pixelOffset = pixelTag?.valueOffset ?? -1; const pixelLength = pixelTag?.length ?? 0; if (!rows || !columns || pixelOffset < 0) { throw new Error('无法解析当前 DICOM 像素数据'); } const count = rows * columns; const pixels = Buffer.alloc(count); const min = windowCenter - windowWidth / 2; const max = windowCenter + windowWidth / 2; for (let i = 0; i < count; i += 1) { const position = pixelOffset + i * (bitsAllocated / 8); if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) { break; } const raw = bitsAllocated === 16 ? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) : buffer.readUInt8(position); const hu = raw * rescaleSlope + rescaleIntercept; const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); pixels[i] = normalized; } return { width: columns, height: rows, pixels: pixels.toString('base64'), windowCenter, windowWidth, }; } 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.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 requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10); const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); try { const preview = parseDicomPreview(path.join(dicomDir, files[slice])); res.json({ ...preview, slice, total: files.length, fileName: files[slice], }); } 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/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) }); }); app.post('/api/projects/:projectId/export-mask', (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 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); res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(mask); }); 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}/`); }); } startServer().catch((error) => { console.error(error); process.exit(1); });