2026-05-04-03-21-40 增加前后端协同和NIfTI导出
This commit is contained in:
350
WebSite/server.ts
Normal file
350
WebSite/server.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
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'>;
|
||||
}
|
||||
|
||||
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'],
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
...state,
|
||||
projects: [buildDefaultProject()],
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.get('/api/projects/:projectId', (req, res) => {
|
||||
const project = readState().projects.find((candidate) => candidate.id === req.params.projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
}
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
res.json({
|
||||
totalProjects: state.projects.length,
|
||||
processedProjects: state.projects.filter((project) => project.status === 'completed').length,
|
||||
dicomCount,
|
||||
modelCount,
|
||||
chartData: [
|
||||
{ name: 'Mon', projects: 1, processing: 12 },
|
||||
{ name: 'Tue', projects: 1, processing: 28 },
|
||||
{ name: 'Wed', projects: 1, processing: 44 },
|
||||
{ name: 'Thu', projects: 1, processing: 58 },
|
||||
{ name: 'Fri', projects: 1, processing: 76 },
|
||||
{ name: 'Sat', projects: 1, processing: 90 },
|
||||
{ name: 'Sun', projects: state.projects.length, processing: 100 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user