763 lines
24 KiB
TypeScript
763 lines
24 KiB
TypeScript
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';
|
|
|
|
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');
|
|
const dicomPreviewCache = new Map<string, unknown>();
|
|
const dicomVolumeCache = new Map<DicomDisplayMode, {
|
|
frames: Buffer[];
|
|
width: number;
|
|
height: number;
|
|
windowCenter: number;
|
|
windowWidth: number;
|
|
}>();
|
|
const modelPreviewCache = new Map<string, unknown>();
|
|
|
|
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 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 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 fallbackCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
|
|
const fallbackWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
|
|
const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth);
|
|
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;
|
|
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;
|
|
}
|
|
|
|
return {
|
|
width: columns,
|
|
height: rows,
|
|
pixels: pixels.toString('base64'),
|
|
windowCenter,
|
|
windowWidth,
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') {
|
|
const preview = parseDicomPreview(filePath, mode);
|
|
return {
|
|
...preview,
|
|
pixelBuffer: Buffer.from(preview.pixels, 'base64'),
|
|
};
|
|
}
|
|
|
|
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 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,
|
|
};
|
|
dicomVolumeCache.set(mode, volume);
|
|
return volume;
|
|
}
|
|
|
|
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<DicomPlane, 'axial'>, 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;
|
|
}
|
|
});
|
|
|
|
return {
|
|
width: outputWidth,
|
|
height: outputHeight,
|
|
pixels: pixels.toString('base64'),
|
|
windowCenter: volume.windowCenter,
|
|
windowWidth: volume.windowWidth,
|
|
slice: clampedSlice,
|
|
total: maxSlice + 1,
|
|
fileName: `${plane}-${clampedSlice}`,
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
|
const cacheKey = `${fileName}:${limit}`;
|
|
const cached = modelPreviewCache.get(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
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, 12000));
|
|
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
|
|
const vertices: number[] = [];
|
|
let sampledTriangles = 0;
|
|
|
|
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += step) {
|
|
const offset = 84 + triangleIndex * 50;
|
|
if (offset + 50 > buffer.length) {
|
|
break;
|
|
}
|
|
|
|
for (let vertex = 0; vertex < 3; vertex += 1) {
|
|
const vertexOffset = offset + 12 + vertex * 12;
|
|
vertices.push(
|
|
Number(buffer.readFloatLE(vertexOffset).toFixed(3)),
|
|
Number(buffer.readFloatLE(vertexOffset + 4).toFixed(3)),
|
|
Number(buffer.readFloatLE(vertexOffset + 8).toFixed(3)),
|
|
);
|
|
}
|
|
sampledTriangles += 1;
|
|
}
|
|
|
|
const payload = {
|
|
fileName,
|
|
triangleCount,
|
|
sampledTriangles,
|
|
vertices,
|
|
};
|
|
modelPreviewCache.set(cacheKey, payload);
|
|
return payload;
|
|
}
|
|
|
|
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.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/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) });
|
|
});
|
|
|
|
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}/`);
|
|
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject()));
|
|
});
|
|
}
|
|
|
|
startServer().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|