2026-05-04-03-50-07 完善项目库可视化和项目管理
This commit is contained in:
@@ -29,6 +29,8 @@ interface ProjectRecord {
|
||||
modelCount: number;
|
||||
stlFiles: string[];
|
||||
maskFormats: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
@@ -116,6 +118,25 @@ function buildDefaultProject(): ProjectRecord {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,9 +153,27 @@ function defaultState(): AppState {
|
||||
}
|
||||
|
||||
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: [buildDefaultProject()],
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +260,102 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
||||
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';
|
||||
@@ -268,8 +403,22 @@ async function startServer() {
|
||||
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 = readState().projects.find((candidate) => candidate.id === req.params.projectId);
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
@@ -277,24 +426,85 @@ async function startServer() {
|
||||
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: state.projects.filter((project) => project.status === 'completed').length,
|
||||
processedProjects: exportedMaskProjects,
|
||||
exportedMaskProjects,
|
||||
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 },
|
||||
{ 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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -320,6 +530,8 @@ async function startServer() {
|
||||
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}"`);
|
||||
|
||||
Reference in New Issue
Block a user