2026-05-20-22-35-42 导入资产与分割导出优化

This commit is contained in:
2026-05-20 22:58:39 +08:00
parent ec4cb1eae7
commit 67295ddd9f
9 changed files with 660 additions and 77 deletions

View File

@@ -11,6 +11,7 @@ type DicomPlane = 'axial' | 'sagittal' | 'coronal';
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast'; type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl'; type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
type SegmentationExportScope = 'all' | 'visible'; type SegmentationExportScope = 'all' | 'visible';
type SegmentationExportMode = 'combined' | 'separate';
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high'; type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
@@ -95,16 +96,22 @@ interface AppState {
updatedAt: string; updatedAt: string;
} }
interface UploadedAssetPayload {
name: string;
data: string;
}
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(__dirname, '..');
const dataDir = path.join(__dirname, 'data'); const dataDir = path.join(__dirname, 'data');
const exportDir = path.join(__dirname, 'exports'); const exportDir = path.join(__dirname, 'exports');
const uploadDir = path.join(dataDir, 'uploads');
const statePath = path.join(dataDir, 'state.json'); const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
const dicomPreviewCache = new Map<string, unknown>(); const dicomPreviewCache = new Map<string, unknown>();
const dicomVolumeCache = new Map<DicomDisplayMode, { const dicomVolumeCache = new Map<string, {
frames: Buffer[]; frames: Buffer[];
width: number; width: number;
height: number; height: number;
@@ -209,6 +216,47 @@ function naturalFileCompare(a: string, b: string) {
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' });
} }
function toRepoRelativePath(dir: string) {
return path.relative(repoRoot, dir).split(path.sep).join('/');
}
function resolveStoredAssetDir(storedPath: string | undefined, fallbackDir: string) {
if (!storedPath) {
return fallbackDir;
}
return path.isAbsolute(storedPath) ? storedPath : path.resolve(repoRoot, storedPath);
}
function getProjectDicomDir(project: ProjectRecord) {
return resolveStoredAssetDir(project.dicomPath, project.id === 'head-ct-demo' ? dicomDir : '');
}
function getProjectModelDir(project: ProjectRecord) {
return resolveStoredAssetDir(project.modelPath, project.id === 'head-ct-demo' ? modelDir : '');
}
function getProjectDicomFilePath(project: ProjectRecord, fileName: string) {
return path.join(getProjectDicomDir(project), fileName);
}
function getProjectModelFilePath(project: ProjectRecord, fileName: string) {
return path.join(getProjectModelDir(project), fileName);
}
function clearProjectRuntimeCaches(projectId: string) {
[...dicomPreviewCache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
dicomPreviewCache.delete(key);
}
});
[...dicomVolumeCache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
dicomVolumeCache.delete(key);
}
});
modelPreviewCache.clear();
}
function publicUser(user: UserRecord) { function publicUser(user: UserRecord) {
const { password: _password, ...rest } = user; const { password: _password, ...rest } = user;
return rest; return rest;
@@ -446,20 +494,34 @@ function normalizeState(state: AppState): AppState {
? state.projects ? state.projects
.filter((project) => project.id !== defaultProject.id) .filter((project) => project.id !== defaultProject.id)
.map((project) => { .map((project) => {
const stlFiles = Array.isArray(project.stlFiles) ? project.stlFiles : []; const dicomPath = typeof project.dicomPath === 'string' ? project.dicomPath : '';
const modelPath = typeof project.modelPath === 'string' ? project.modelPath : '';
const dicomCount = dicomPath ? listFiles(resolveStoredAssetDir(dicomPath, ''), '.dcm').length : project.dicomCount ?? 0;
const stlFiles = modelPath
? listFiles(resolveStoredAssetDir(modelPath, ''), '.stl')
: Array.isArray(project.stlFiles) ? project.stlFiles : [];
const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles); const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
return { return {
...project, ...project,
dicomPath,
modelPath,
dicomCount,
stlFiles, stlFiles,
hasModel: stlFiles.length > 0,
modelCount: stlFiles.length,
exportedMaskCount: project.exportedMaskCount ?? 0, exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles, moduleStyles,
modelPoses: normalizeModelPoses(project.modelPoses), modelPoses: normalizeModelPoses(project.modelPoses),
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, project.dicomCount ?? 0), segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount),
}; };
}) })
: []; : [];
const defaultModuleStyles = buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles); const defaultDicomPath = savedDefaultProject?.dicomPath ?? defaultProject.dicomPath;
const defaultModelPath = savedDefaultProject?.modelPath ?? defaultProject.modelPath;
const defaultDicomCount = listFiles(resolveStoredAssetDir(defaultDicomPath, dicomDir), '.dcm').length;
const defaultStlFiles = listFiles(resolveStoredAssetDir(defaultModelPath, modelDir), '.stl');
const defaultModuleStyles = buildModuleStyles(defaultStlFiles, savedDefaultProject?.moduleStyles);
return { return {
...state, ...state,
@@ -467,14 +529,20 @@ function normalizeState(state: AppState): AppState {
{ {
...defaultProject, ...defaultProject,
name: savedDefaultProject?.name ?? defaultProject.name, name: savedDefaultProject?.name ?? defaultProject.name,
dicomPath: defaultDicomPath,
modelPath: defaultModelPath,
dicomCount: defaultDicomCount,
hasModel: defaultStlFiles.length > 0,
modelCount: defaultStlFiles.length,
stlFiles: defaultStlFiles,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0, exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: defaultModuleStyles, moduleStyles: defaultModuleStyles,
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses), modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
segmentationResults: normalizeSegmentationResults( segmentationResults: normalizeSegmentationResults(
savedDefaultProject?.segmentationResults, savedDefaultProject?.segmentationResults,
defaultProject.stlFiles, defaultStlFiles,
defaultModuleStyles, defaultModuleStyles,
defaultProject.dicomCount, defaultDicomCount,
), ),
}, },
...customProjects, ...customProjects,
@@ -558,13 +626,13 @@ interface ExportSceneMetrics {
const exportFusionBaseExtent = 4.6; const exportFusionBaseExtent = 4.6;
function readDicomHuVolume(files: string[]): DicomHuVolume { function readDicomHuVolume(project: ProjectRecord, files: string[]): DicomHuVolume {
if (!files.length) { if (!files.length) {
throw new Error('当前项目没有可导出的 DICOM 序列'); throw new Error('当前项目没有可导出的 DICOM 序列');
} }
const parsed = files.map((fileName) => { const parsed = files.map((fileName) => {
const buffer = fs.readFileSync(path.join(dicomDir, fileName)); const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName));
const attributes = parseDicomAttributes(buffer, 'default'); const attributes = parseDicomAttributes(buffer, 'default');
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
if (!attributes.rows || !attributes.columns || !pixelTag) { if (!attributes.rows || !attributes.columns || !pixelTag) {
@@ -1038,10 +1106,16 @@ function isModuleIncludedForExport(style: ModuleStyleRecord, scope: Segmentation
return scope === 'all' || style.visible !== false; return scope === 'all' || style.visible !== false;
} }
function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue, scope: SegmentationExportScope = 'visible') { function createSegmentationData(
project: ProjectRecord,
volume: DicomHuVolume,
pose: ModelPoseValue,
scope: SegmentationExportScope = 'visible',
onlyFileName?: string,
) {
const data = Buffer.alloc(volume.width * volume.height * volume.depth); const data = Buffer.alloc(volume.width * volume.height * volume.depth);
const previews = (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => { const previews = (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
const filePath = path.join(modelDir, fileName); const filePath = getProjectModelFilePath(project, fileName);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord; accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord;
} }
@@ -1071,7 +1145,7 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
const payload = previews[fileName]; const payload = previews[fileName];
const style = getModuleStyle(project, fileName, index); const style = getModuleStyle(project, fileName, index);
if (!payload || !isModuleIncludedForExport(style, scope)) { if (!payload || (onlyFileName && fileName !== onlyFileName) || !isModuleIncludedForExport(style, scope)) {
return; return;
} }
@@ -1087,7 +1161,7 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
return rows; return rows;
}; };
const filePath = path.join(modelDir, fileName); const filePath = getProjectModelFilePath(project, fileName);
forEachBinaryStlTriangle(filePath, fileName, ( forEachBinaryStlTriangle(filePath, fileName, (
ax, ax,
ay, ay,
@@ -1209,6 +1283,10 @@ function parseSegmentationScope(raw: unknown): SegmentationExportScope {
return raw === 'all' ? 'all' : 'visible'; return raw === 'all' ? 'all' : 'visible';
} }
function parseSegmentationExportMode(raw: unknown): SegmentationExportMode {
return raw === 'separate' ? 'separate' : 'combined';
}
function latestSegmentationResult(project: ProjectRecord) { function latestSegmentationResult(project: ProjectRecord) {
return project.segmentationResults?.[project.segmentationResults.length - 1]; return project.segmentationResults?.[project.segmentationResults.length - 1];
} }
@@ -1233,6 +1311,34 @@ function parseExportTargets(raw: unknown): ProjectExportTarget[] {
return [...new Set(targets)]; return [...new Set(targets)];
} }
function sanitizeUploadFileName(name: string, fallback: string, extension: string) {
const baseName = path.basename(name || fallback).replace(/[\\/:*?"<>|]+/g, '_').trim();
const withFallback = baseName || fallback;
return withFallback.toLowerCase().endsWith(extension) ? withFallback : `${withFallback}${extension}`;
}
function decodeUploadedAssetData(raw: string) {
const base64 = raw.includes(',') ? raw.slice(raw.indexOf(',') + 1) : raw;
if (!base64.trim()) {
throw new Error('上传文件内容为空');
}
return Buffer.from(base64, 'base64');
}
function parseUploadedAssets(raw: unknown): UploadedAssetPayload[] {
if (!Array.isArray(raw)) {
throw new Error('上传文件列表无效');
}
return raw
.map((item) => item && typeof item === 'object' ? item as Record<string, unknown> : null)
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item, index) => ({
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : `asset-${index + 1}`,
data: typeof item.data === 'string' ? item.data : '',
}))
.filter((item) => item.data);
}
function createNiftiExport( function createNiftiExport(
project: ProjectRecord, project: ProjectRecord,
files: string[], files: string[],
@@ -1241,7 +1347,7 @@ function createNiftiExport(
pose?: ModelPoseValue, pose?: ModelPoseValue,
segmentationScope: SegmentationExportScope = 'visible', segmentationScope: SegmentationExportScope = 'visible',
) { ) {
const volume = readDicomHuVolume(files); const volume = readDicomHuVolume(project, files);
if (target === 'dicom') { if (target === 'dicom') {
return createNiftiBuffer(volume, volume.data, 'dicom', compressed); return createNiftiBuffer(volume, volume.data, 'dicom', compressed);
} }
@@ -1277,6 +1383,7 @@ function createProjectExportBundle({
compressed, compressed,
activePose, activePose,
segmentationScope, segmentationScope,
segmentationExportMode,
exportRoot, exportRoot,
}: { }: {
project: ProjectRecord; project: ProjectRecord;
@@ -1285,11 +1392,12 @@ function createProjectExportBundle({
compressed: boolean; compressed: boolean;
activePose?: ModelPoseValue; activePose?: ModelPoseValue;
segmentationScope: SegmentationExportScope; segmentationScope: SegmentationExportScope;
segmentationExportMode: SegmentationExportMode;
exportRoot: string; exportRoot: string;
}) { }) {
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = []; const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
const needsVolume = targets.includes('dicom') || targets.includes('segmentation'); const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
const volume = needsVolume ? readDicomHuVolume(files) : null; const volume = needsVolume ? readDicomHuVolume(project, files) : null;
const format = compressed ? 'nii.gz' : 'nii'; const format = compressed ? 'nii.gz' : 'nii';
if (targets.includes('dicom') && volume) { if (targets.includes('dicom') && volume) {
@@ -1300,6 +1408,24 @@ function createProjectExportBundle({
} }
if (targets.includes('segmentation') && volume) { if (targets.includes('segmentation') && volume) {
if (segmentationExportMode === 'separate') {
(project.stlFiles ?? []).forEach((fileName, index) => {
const style = getModuleStyle(project, fileName, index);
if (!isModuleIncludedForExport(style, segmentationScope)) {
return;
}
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
entries.push({
name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
data: createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
'segmentation',
compressed,
),
});
});
} else {
entries.push({ entries.push({
name: `${exportRoot}/${project.id}-segmentation-label.${format}`, name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
data: createNiftiBuffer( data: createNiftiBuffer(
@@ -1309,6 +1435,7 @@ function createProjectExportBundle({
compressed, compressed,
), ),
}); });
}
entries.push({ entries.push({
name: `${exportRoot}/${project.id}-segmentation-labels.json`, name: `${exportRoot}/${project.id}-segmentation-labels.json`,
data: createSegmentationLabelMetadata(project, segmentationScope, activePose), data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
@@ -1324,7 +1451,7 @@ function createProjectExportBundle({
if (targets.includes('stl')) { if (targets.includes('stl')) {
(project.stlFiles ?? []).forEach((fileName) => { (project.stlFiles ?? []).forEach((fileName) => {
const filePath = path.join(modelDir, fileName); const filePath = getProjectModelFilePath(project, fileName);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return; return;
} }
@@ -1350,10 +1477,7 @@ function findProject(state: AppState, projectId: string) {
} }
function getProjectDicomFiles(project: ProjectRecord) { function getProjectDicomFiles(project: ProjectRecord) {
if (project.id !== 'head-ct-demo') { return listFiles(getProjectDicomDir(project), '.dcm');
return [];
}
return listFiles(dicomDir, '.dcm');
} }
function readAsciiValue(buffer: Buffer, start: number, length: number) { function readAsciiValue(buffer: Buffer, start: number, length: number) {
@@ -1542,13 +1666,14 @@ function estimateSliceSpacing(parsed: ReturnType<typeof parseDicomPixels>[]) {
?? 1; ?? 1;
} }
function getDicomVolume(files: string[], mode: DicomDisplayMode) { function getDicomVolume(project: ProjectRecord, files: string[], mode: DicomDisplayMode) {
const cached = dicomVolumeCache.get(mode); const cacheKey = `${project.id}:${project.dicomPath}:${mode}:${files.join('|')}`;
const cached = dicomVolumeCache.get(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode)); const parsed = files.map((fileName) => parseDicomPixels(getProjectDicomFilePath(project, fileName), mode));
const sliceSpacing = estimateSliceSpacing(parsed); const sliceSpacing = estimateSliceSpacing(parsed);
const volume = { const volume = {
frames: parsed.map((frame) => frame.pixelBuffer), frames: parsed.map((frame) => frame.pixelBuffer),
@@ -1562,7 +1687,7 @@ function getDicomVolume(files: string[], mode: DicomDisplayMode) {
sliceThickness: parsed[0]?.attributes.sliceThickness ?? null, sliceThickness: parsed[0]?.attributes.sliceThickness ?? null,
spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null, spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null,
}; };
dicomVolumeCache.set(mode, volume); dicomVolumeCache.set(cacheKey, volume);
return volume; return volume;
} }
@@ -1602,19 +1727,19 @@ function resampleToPhysicalAspect(pixels: Buffer, width: number, height: number,
}; };
} }
function warmDicomVolumeCache(files: string[]) { function warmDicomVolumeCache(project: ProjectRecord, files: string[]) {
setTimeout(() => { setTimeout(() => {
try { try {
getDicomVolume(files, 'default'); getDicomVolume(project, files, 'default');
getDicomVolume(files, 'soft'); getDicomVolume(project, files, 'soft');
} catch (error) { } catch (error) {
console.warn('DICOM volume warmup failed:', error); console.warn('DICOM volume warmup failed:', error);
} }
}, 300); }, 300);
} }
function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) { function createReformattedPreview(project: ProjectRecord, files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(files, mode); const volume = getDicomVolume(project, files, mode);
const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1; const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1;
const clampedSlice = Math.max(0, Math.min(maxSlice, slice)); const clampedSlice = Math.max(0, Math.min(maxSlice, slice));
const outputWidth = files.length; const outputWidth = files.length;
@@ -1660,8 +1785,8 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
}; };
} }
function createDicomFusionVolume(files: string[], start: number, end: number, mode: DicomDisplayMode) { function createDicomFusionVolume(project: ProjectRecord, files: string[], start: number, end: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(files, mode); const volume = getDicomVolume(project, files, mode);
const total = volume.frames.length; const total = volume.frames.length;
const safeStart = Math.max(0, Math.min(total - 1, Number.isFinite(start) ? start : 0)); 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 safeEnd = Math.max(safeStart, Math.min(total - 1, Number.isFinite(end) ? end : safeStart + 49));
@@ -1773,7 +1898,7 @@ function cropDicomContent(pixels: Buffer, width: number, height: number) {
} }
function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord { function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord {
const cacheKey = `${fileName}:${limit}`; const cacheKey = `${filePath}:${fileName}:${limit}`;
const cached = modelPreviewCache.get(cacheKey); const cached = modelPreviewCache.get(cacheKey);
if (cached) { if (cached) {
return cached as ModelPreviewRecord; return cached as ModelPreviewRecord;
@@ -1896,12 +2021,13 @@ function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number
return zlib.gzipSync(Buffer.concat(chunks)); return zlib.gzipSync(Buffer.concat(chunks));
} }
function createDicomTarGz(files: string[]) { function createDicomTarGz(project: ProjectRecord, files: string[]) {
const rootName = sanitizeFilenamePart(project.dicomPath || 'DICOM', 'DICOM');
return createTarGz(files.map((fileName) => { return createTarGz(files.map((fileName) => {
const filePath = path.join(dicomDir, fileName); const filePath = getProjectDicomFilePath(project, fileName);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
return { return {
name: `Head_CT_DICOM/${fileName}`, name: `${rootName}/${fileName}`,
data: fs.readFileSync(filePath), data: fs.readFileSync(filePath),
mtime: stat.mtimeMs / 1000, mtime: stat.mtimeMs / 1000,
}; };
@@ -1939,7 +2065,7 @@ function formatNumber(value: number | null | undefined, digits = 3) {
function createDicomInfo(project: ProjectRecord, files: string[]) { function createDicomInfo(project: ProjectRecord, files: string[]) {
const attributes = files.map((fileName) => { const attributes = files.map((fileName) => {
const buffer = fs.readFileSync(path.join(dicomDir, fileName)); const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName));
return parseDicomAttributes(buffer, 'default'); return parseDicomAttributes(buffer, 'default');
}); });
const first = attributes[0]; const first = attributes[0];
@@ -2009,7 +2135,7 @@ async function startServer() {
const port = Number(portArg ?? 4000); const port = Number(portArg ?? 4000);
ensureDir(exportDir); ensureDir(exportDir);
app.use(express.json()); app.use(express.json({ limit: '512mb' }));
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'revoxelseg-dicom', time: now() }); res.json({ ok: true, service: 'revoxelseg-dicom', time: now() });
@@ -2225,6 +2351,65 @@ async function startServer() {
res.json(project); res.json(project);
}); });
app.post('/api/projects/:projectId/import-assets', (req, res) => {
const kind = req.body?.kind === 'stl' ? 'stl' : 'dicom';
let uploadedFiles: UploadedAssetPayload[];
try {
uploadedFiles = parseUploadedAssets(req.body?.files);
} catch (error) {
res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' });
return;
}
if (!uploadedFiles.length) {
res.status(400).json({ message: '请选择需要导入的文件' });
return;
}
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL');
try {
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
uploadedFiles.forEach((file, index) => {
const fileName = sanitizeUploadFileName(
file.name,
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
kind === 'dicom' ? '.dcm' : '.stl',
);
fs.writeFileSync(path.join(targetDir, fileName), decodeUploadedAssetData(file.data));
});
if (kind === 'dicom') {
const dicomFiles = listFiles(targetDir, '.dcm');
project.dicomPath = toRepoRelativePath(targetDir);
project.dicomCount = dicomFiles.length;
project.segmentationResults = [];
} else {
const stlFiles = listFiles(targetDir, '.stl');
project.modelPath = toRepoRelativePath(targetDir);
project.stlFiles = stlFiles;
project.modelCount = stlFiles.length;
project.hasModel = stlFiles.length > 0;
project.moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
project.segmentationResults = [];
}
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
clearProjectRuntimeCaches(project.id);
writeState(state);
res.json(project);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
}
});
app.post('/api/projects/:projectId/segmentation-results', (req, res) => { app.post('/api/projects/:projectId/segmentation-results', (req, res) => {
const state = readState(); const state = readState();
const project = findProject(state, req.params.projectId); const project = findProject(state, req.params.projectId);
@@ -2296,7 +2481,7 @@ async function startServer() {
let payload: unknown; let payload: unknown;
if (plane === 'axial') { if (plane === 'axial') {
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
const preview = parseDicomPreview(path.join(dicomDir, files[slice]), mode); const preview = parseDicomPreview(getProjectDicomFilePath(project, files[slice]), mode);
payload = { payload = {
...preview, ...preview,
plane, plane,
@@ -2306,7 +2491,7 @@ async function startServer() {
}; };
} else { } else {
payload = { payload = {
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode), ...createReformattedPreview(project, files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
plane, plane,
}; };
} }
@@ -2337,7 +2522,7 @@ async function startServer() {
const end = Number.parseInt(String(req.query.end ?? '49'), 10); const end = Number.parseInt(String(req.query.end ?? '49'), 10);
try { try {
res.json(createDicomFusionVolume(files, start, end, mode)); res.json(createDicomFusionVolume(project, files, start, end, mode));
} catch (error) { } catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' }); res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' });
} }
@@ -2357,7 +2542,7 @@ async function startServer() {
} }
try { try {
const archive = createDicomTarGz(files); const archive = createDicomTarGz(project, files);
const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`; const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`;
res.setHeader('Content-Type', 'application/gzip'); res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
@@ -2391,12 +2576,12 @@ async function startServer() {
const project = findProject(readState(), req.params.projectId); const project = findProject(readState(), req.params.projectId);
const fileName = path.basename(req.params.fileName); const fileName = path.basename(req.params.fileName);
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { if (!project || !project.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' }); res.status(404).json({ message: '模型文件不存在' });
return; return;
} }
res.sendFile(path.join(modelDir, fileName)); res.sendFile(getProjectModelFilePath(project, fileName));
}); });
app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => { app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => {
@@ -2404,13 +2589,13 @@ async function startServer() {
const fileName = path.basename(req.params.fileName); const fileName = path.basename(req.params.fileName);
const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10); const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10);
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { if (!project || !project.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' }); res.status(404).json({ message: '模型文件不存在' });
return; return;
} }
try { try {
res.json(createStlPreview(path.join(modelDir, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
} catch (error) { } catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' }); res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
} }
@@ -2514,6 +2699,7 @@ async function startServer() {
const segmentationScope = req.query.segmentationScope === undefined const segmentationScope = req.query.segmentationScope === undefined
? latestResult?.segmentationScope ?? 'visible' ? latestResult?.segmentationScope ?? 'visible'
: parseSegmentationScope(req.query.segmentationScope); : parseSegmentationScope(req.query.segmentationScope);
const segmentationExportMode = parseSegmentationExportMode(req.query.segmentationExportMode);
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
const compressed = format === 'nii.gz'; const compressed = format === 'nii.gz';
@@ -2527,6 +2713,7 @@ async function startServer() {
compressed, compressed,
activePose, activePose,
segmentationScope, segmentationScope,
segmentationExportMode,
exportRoot: exportBase, exportRoot: exportBase,
}); });
const filename = `${exportBase}.tar.gz`; const filename = `${exportBase}.tar.gz`;
@@ -2561,7 +2748,8 @@ async function startServer() {
app.listen(port, host, () => { app.listen(port, host, () => {
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`); console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject())); const defaultProject = buildDefaultProject();
warmDicomVolumeCache(defaultProject, getProjectDicomFiles(defaultProject));
}); });
} }

View File

@@ -22,10 +22,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api'; import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
import { import {
FusionThreeView, FusionThreeView,
VoxelizationMappingView, VoxelizationMappingView,
clearCachedProjectAssets,
getCachedDicomFusionVolume, getCachedDicomFusionVolume,
getCachedDicomPreview, getCachedDicomPreview,
getCachedModelPreview, getCachedModelPreview,
@@ -63,14 +64,18 @@ type ModelPoseKey = keyof ModelPose;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
]; ];
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
{ id: 'all', label: '所有类别', description: '包含隐藏构件' }, { id: 'all', label: '所有类别', description: '包含隐藏构件' },
]; ];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 }, { id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 }, { id: 'fine', label: '精细', limit: 36000 },
@@ -117,6 +122,17 @@ function formatPoseCompactValue(value: number, digits = 2) {
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
} }
async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return window.btoa(binary);
}
function drawFallbackModelPreview( function drawFallbackModelPreview(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>, previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
@@ -676,13 +692,17 @@ export default function ProjectLibrary({
const [actionMessage, setActionMessage] = useState(''); const [actionMessage, setActionMessage] = useState('');
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false); const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({ const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true, dicom: false,
segmentation: true, segmentation: true,
pose: true, pose: true,
stl: false, stl: false,
}); });
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible'); const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
const [maskExporting, setMaskExporting] = useState(false); const [maskExporting, setMaskExporting] = useState(false);
const [assetImporting, setAssetImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
const sliceRepeatRef = useRef<number | null>(null); const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0); const dicomRequestRef = useRef(0);
const preloadedProjectIdsRef = useRef(new Set<string>()); const preloadedProjectIdsRef = useRef(new Set<string>());
@@ -804,6 +824,7 @@ export default function ProjectLibrary({
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', { await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
pose: latestSegmentationResult?.pose ?? modelPose, pose: latestSegmentationResult?.pose ?? modelPose,
segmentationScope: maskSegmentationScope, segmentationScope: maskSegmentationScope,
segmentationExportMode: maskSegmentationExportMode,
}); });
window.setTimeout(() => setMaskExporting(false), 900); window.setTimeout(() => setMaskExporting(false), 900);
setShowMaskExportMenu(false); setShowMaskExportMenu(false);
@@ -813,6 +834,66 @@ export default function ProjectLibrary({
} }
}; };
const triggerProjectAssetImport = () => {
if (!selectedProject || viewMode === 'mask' || assetImporting) {
return;
}
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
const input = importInputRef.current;
if (!input) {
setActionMessage('导入控件尚未就绪,请稍后重试');
return;
}
importKindRef.current = kind;
input.value = '';
input.accept = kind === 'dicom' ? '.dcm,.dicom,application/dicom' : '.stl';
input.multiple = true;
input.click();
};
const handleProjectAssetImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedProject) {
return;
}
const files = Array.from(event.target.files ?? []);
event.target.value = '';
if (!files.length) {
return;
}
const kind = importKindRef.current;
setAssetImporting(true);
setActionMessage(kind === 'dicom' ? '正在导入 DICOM 影像...' : '正在导入 STL 模型...');
try {
const payload = await Promise.all(files.map(async (file) => ({
name: file.name,
data: await fileToBase64(file),
})));
const updated = await api.importProjectAssets(selectedProject.id, kind, payload);
clearCachedProjectAssets(updated.id);
preloadedProjectIdsRef.current.delete(updated.id);
setSelectedProject(updated);
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
const nextStyles: Record<string, ModuleStyle> = {};
(updated.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
setModelPose(latestResult?.pose ?? defaultModelPose);
setResultPose(latestResult?.pose ?? defaultModelPose);
setSliceIndex(0);
setDicomPreview(null);
setDicomError('');
setResultFusionVolume(null);
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
} catch (error) {
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败');
} finally {
setAssetImporting(false);
}
};
useEffect(() => { useEffect(() => {
setViewMode(initialViewMode); setViewMode(initialViewMode);
}, [initialViewMode]); }, [initialViewMode]);
@@ -1204,6 +1285,12 @@ export default function ProjectLibrary({
<div className="flex-1 flex flex-col gap-6 overflow-hidden"> <div className="flex-1 flex flex-col gap-6 overflow-hidden">
{selectedProject ? ( {selectedProject ? (
<> <>
<input
ref={importInputRef}
type="file"
className="hidden"
onChange={handleProjectAssetImport}
/>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex bg-slate-100 p-1 rounded-xl"> <div className="flex bg-slate-100 p-1 rounded-xl">
{tabs.map((tab) => ( {tabs.map((tab) => (
@@ -1226,8 +1313,12 @@ export default function ProjectLibrary({
<RotateCw size={18} /> <RotateCw size={18} />
</button> </button>
{viewMode !== 'mask' && ( {viewMode !== 'mask' && (
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all"> <button
<Upload size={18} /> onClick={triggerProjectAssetImport}
disabled={assetImporting}
className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all disabled:opacity-50"
>
<Upload size={18} /> {assetImporting ? '导入中' : '导入'}
</button> </button>
)} )}
</div> </div>
@@ -1292,7 +1383,9 @@ export default function ProjectLibrary({
{dicomPreview ? ( {dicomPreview ? (
<DicomCanvas preview={dicomPreview} rotation={rotation} /> <DicomCanvas preview={dicomPreview} rotation={rotation} />
) : ( ) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p> <p className="text-white/30 text-xs font-mono uppercase tracking-widest">
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
</p>
)} )}
{isSliceChanging && dicomPreview && ( {isSliceChanging && dicomPreview && (
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm"> <span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
@@ -1302,7 +1395,7 @@ export default function ProjectLibrary({
</div> </div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]"> <div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span> <span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} </span> <span> {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
</div> </div>
</div> </div>
{/* Right: Vertical Progress Bar */} {/* Right: Vertical Progress Bar */}
@@ -1389,6 +1482,13 @@ export default function ProjectLibrary({
pose={modelPose} pose={modelPose}
onPoseChange={setModelPose} onPoseChange={setModelPose}
/> />
{!stlFiles.length && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<p className="rounded-2xl border border-slate-200 bg-white/85 px-5 py-3 text-xs font-bold text-slate-500 shadow-sm">
STL模型
</p>
</div>
)}
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]"> <div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label} MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
</div> </div>
@@ -1724,6 +1824,27 @@ export default function ProjectLibrary({
</button> </button>
))} ))}
</div> </div>
<div className="mt-2 border-t border-emerald-100 pt-2">
<p className="mb-2 text-[10px] font-bold text-emerald-800"></p>
<div className="grid grid-cols-2 gap-1.5">
{segmentationExportModeOptions.map((option) => (
<button
key={option.id}
onClick={() => setMaskSegmentationExportMode(option.id)}
className={`rounded-lg px-2 py-1.5 text-left transition ${
maskSegmentationExportMode === option.id
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-emerald-100'
}`}
>
<span className="block text-[10px] font-bold">{option.label}</span>
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
{option.description}
</span>
</button>
))}
</div>
</div>
</div> </div>
)} )}
<button <button

View File

@@ -16,7 +16,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api'; import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api';
export interface ModelPreviewPayload { export interface ModelPreviewPayload {
fileName: string; fileName: string;
@@ -100,14 +100,18 @@ const defaultSavedPoses: SavedModelPose[] = [
]; ];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
]; ];
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
{ id: 'all', label: '所有类别', description: '包含隐藏构件' }, { id: 'all', label: '所有类别', description: '包含隐藏构件' },
]; ];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
];
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6; const fusionBaseExtent = 4.6;
const axisInsetLength = 17; const axisInsetLength = 17;
@@ -177,6 +181,16 @@ export function getCachedModelPreview(projectId: string, fileName: string, limit
); );
} }
export function clearCachedProjectAssets(projectId: string) {
[dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => {
[...cache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
cache.delete(key);
}
});
});
}
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
@@ -2056,7 +2070,7 @@ export function VoxelizationMappingView({
const renderOverlaySummary = (placement: 'bottom' | 'side') => ( const renderOverlaySummary = (placement: 'bottom' | 'side') => (
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}> <div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}> <div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
<span className="truncate">Overlay Label Map · {overlayStatus}</span> <span className="truncate">Overlay Label Map</span>
<span className="font-mono text-cyan-100"> <span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px {overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span> </span>
@@ -2219,7 +2233,7 @@ export function VoxelizationMappingView({
<div className="border-t border-slate-100 bg-white px-4 py-3"> <div className="border-t border-slate-100 bg-white px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600"> <div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
<span className="truncate">Overlay Label Map · {overlayStatus}</span> <span className="truncate">Overlay Label Map</span>
<span className="font-mono text-cyan-700"> <span className="font-mono text-cyan-700">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px {overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span> </span>
@@ -2321,12 +2335,13 @@ export default function ReverseWorkspace({
const [selectedPoseId, setSelectedPoseId] = useState('default'); const [selectedPoseId, setSelectedPoseId] = useState('default');
const [showExportMenu, setShowExportMenu] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false);
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({ const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true, dicom: false,
segmentation: true, segmentation: true,
pose: true, pose: true,
stl: false, stl: false,
}); });
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible'); const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null); const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState(''); const [fusionError, setFusionError] = useState('');
@@ -2347,6 +2362,7 @@ export default function ReverseWorkspace({
const poseImportInputRef = useRef<HTMLInputElement | null>(null); const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null); const saveToastTimerRef = useRef<number | null>(null);
const savedWorkspaceSnapshotRef = useRef(''); const savedWorkspaceSnapshotRef = useRef('');
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
const handleExportSelected = async () => { const handleExportSelected = async () => {
const selectedItems = exportOptions const selectedItems = exportOptions
@@ -2363,6 +2379,7 @@ export default function ReverseWorkspace({
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', { await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
pose: modelPose, pose: modelPose,
segmentationScope: segmentationExportScope, segmentationScope: segmentationExportScope,
segmentationExportMode,
}); });
window.setTimeout(() => setExporting(false), 900); window.setTimeout(() => setExporting(false), 900);
setShowExportMenu(false); setShowExportMenu(false);
@@ -2552,7 +2569,7 @@ export default function ReverseWorkspace({
return bounds; return bounds;
}; };
const applyModelStretchByAxis = async (axis: AxisKey) => { const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
if (!project || !fusionVolume) { if (!project || !fusionVolume) {
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型'); setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
return; return;
@@ -2588,8 +2605,23 @@ export default function ReverseWorkspace({
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92; const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6); const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale)); const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale));
updateModelPose({ scale: nextScale }); const nextPose = { ...modelPose, scale: nextScale };
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`); setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`);
if (options.silentInitial) {
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
modelPose: nextPose,
segmentationExportScope,
moduleStyles,
sliceStart,
sliceEnd,
mappingSlice,
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
});
}
} catch (error) { } catch (error) {
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败'); setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
} finally { } finally {
@@ -2620,6 +2652,7 @@ export default function ReverseWorkspace({
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses; const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0]; const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose; const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
setModelPose(restoredPose); setModelPose(restoredPose);
setPoseValueDrafts(formatPoseDraftValues(restoredPose)); setPoseValueDrafts(formatPoseDraftValues(restoredPose));
const nextStyles: Record<string, ModuleStyle> = {}; const nextStyles: Record<string, ModuleStyle> = {};
@@ -2715,7 +2748,7 @@ export default function ReverseWorkspace({
return clamp(value, limit.min, limit.max); return clamp(value, limit.min, limit.max);
}; };
const updateModelPose = (partial: Partial<ModelPose>) => { const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
setModelPose((current) => { setModelPose((current) => {
const next = { ...current }; const next = { ...current };
modelPoseKeys.forEach((key) => { modelPoseKeys.forEach((key) => {
@@ -2726,8 +2759,12 @@ export default function ReverseWorkspace({
}); });
return next; return next;
}); });
if (options.markCustom !== false) {
setSelectedPoseId('custom'); setSelectedPoseId('custom');
}
if (!options.keepStatus) {
setPoseImportStatus(''); setPoseImportStatus('');
}
}; };
const nudgeModelPose = (key: ModelPoseKey, delta: number) => { const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
@@ -3021,6 +3058,25 @@ export default function ReverseWorkspace({
mappingDisplayMode, mappingDisplayMode,
]); ]);
useEffect(() => {
if (!project || !fusionVolume || !workspaceLoadState.ready) {
return;
}
const stretchState = initialZStretchRef.current;
if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) {
return;
}
initialZStretchRef.current = { projectId: project.id, pending: false };
void applyModelStretchByAxis('z', { silentInitial: true });
}, [
project?.id,
fusionVolume,
workspaceLoadState.ready,
modelPose.rotateX,
modelPose.rotateY,
modelPose.rotateZ,
]);
if (!workspaceLoadState.ready) { if (!workspaceLoadState.ready) {
return ( return (
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2"> <div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
@@ -3166,6 +3222,27 @@ export default function ReverseWorkspace({
</button> </button>
))} ))}
</div> </div>
<div className="mt-2 border-t border-emerald-100 pt-2">
<p className="mb-2 text-[10px] font-bold text-emerald-800"></p>
<div className="grid grid-cols-2 gap-1.5">
{segmentationExportModeOptions.map((option) => (
<button
key={option.id}
onClick={() => setSegmentationExportMode(option.id)}
className={`rounded-lg px-2 py-1.5 text-left transition ${
segmentationExportMode === option.id
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-emerald-100'
}`}
>
<span className="block text-[10px] font-bold">{option.label}</span>
<span className={`block text-[9px] ${segmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
{option.description}
</span>
</button>
))}
</div>
</div>
</div> </div>
)} )}
<button <button

View File

@@ -132,6 +132,10 @@ export default function UserManagement() {
setMessage('两次输入的密码不一致'); setMessage('两次输入的密码不一致');
return; return;
} }
if ((formMode === 'create' || formMode === 'edit') && users.some((user) => user.id !== form.id && user.account === account)) {
setMessage('账号已存在,请更换账号');
return;
}
setSaving(true); setSaving(true);
try { try {
@@ -150,7 +154,8 @@ export default function UserManagement() {
closeForm(); closeForm();
await refreshUsers(); await refreshUsers();
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : '用户保存失败'); const message = error instanceof Error ? error.message : '用户保存失败';
setMessage(message === '账号已存在' ? '账号已存在,请更换账号' : message);
setSaving(false); setSaving(false);
} }
}; };

View File

@@ -1,6 +1,8 @@
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types'; import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl'; export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
export type SegmentationExportMode = 'combined' | 'separate';
export type ProjectAssetImportKind = 'dicom' | 'stl';
export type { SegmentationExportScope } from '../types'; export type { SegmentationExportScope } from '../types';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
@@ -63,6 +65,11 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ modelPoses }), body: JSON.stringify({ modelPoses }),
}), }),
importProjectAssets: (projectId: string, kind: ProjectAssetImportKind, files: Array<{ name: string; data: string }>) =>
request<Project>(`/api/projects/${projectId}/import-assets`, {
method: 'POST',
body: JSON.stringify({ kind, files }),
}),
saveProjectSegmentationResult: ( saveProjectSegmentationResult: (
projectId: string, projectId: string,
payload: { payload: {
@@ -131,22 +138,24 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`); triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`);
} }
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) { export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
const params = new URLSearchParams({ target, format }); const params = new URLSearchParams({ target, format });
if (target === 'segmentation' || target === 'pose') { if (target === 'segmentation' || target === 'pose') {
appendPose(params, options.pose); appendPose(params, options.pose);
} }
if (target === 'segmentation') { if (target === 'segmentation') {
params.set('segmentationScope', options.segmentationScope ?? 'visible'); params.set('segmentationScope', options.segmentationScope ?? 'visible');
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
} }
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
} }
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) { export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
const params = new URLSearchParams({ const params = new URLSearchParams({
targets: targets.join(','), targets: targets.join(','),
format, format,
segmentationScope: options.segmentationScope ?? 'visible', segmentationScope: options.segmentationScope ?? 'visible',
segmentationExportMode: options.segmentationExportMode ?? 'combined',
}); });
appendPose(params, options.pose); appendPose(params, options.pose);
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`); triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);

View File

@@ -0,0 +1,61 @@
# 实现方案-2026-05-20-22-35-42
## 实现方案文档路径
`工程分析/实现方案-2026-05-20-22-35-42.md`
## 修改目标
- 精简 Overlay Label Map 状态文案。
- 导出项目及结果支持分割整体/分构件导出,并调整默认勾选与排序。
- 为项目库 DICOM/STL 导入按钮接入真实上传流程,空项目展示导入提示。
- 项目级上传资产独立存储,恢复演示环境仍使用默认数据。
- 用户账号重名冲突给出可读反馈。
- 逆向工作区无保存结果时初次加载默认执行 Z 轴等比例拉伸。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- `工程分析/经验记录.md`
## 技术路线
1. 后端新增项目上传资产目录 `WebSite/data/uploads/<projectId>/dicom|stl`,导入接口接收 base64 文件并写入项目专属目录。
2. 后端统一项目 DICOM/STL 路径解析,预览、三维融合、模型文件、导出和分割生成均读取项目路径。
3. 导出包接口增加 `segmentationExportMode=combined|separate` 参数;整体模式沿用现有 label map分构件模式逐个构件生成独立 NII.GZ。
4. 前端导出面板增加分割导出方式单选,调整默认选择和选项顺序。
5. 项目库导入按钮使用隐藏文件输入,按当前视图选择 DICOM 或 STL 上传,并刷新项目状态。
6. 用户管理编辑接口 409 时展示“账号已存在,请更换账号”等提示。
7. 逆向工作区加载项目后,若没有保存结果,则在 fusion volume 和模型边界都就绪后触发一次 Z 拉伸。
## 执行步骤
1. 编写本次需求分析、实现方案、测试方案。
2. 阅读并定位导出、导入、项目路径、用户管理和逆向工作区相关源码。
3. 修改后端资产导入与导出逻辑。
4. 修改前端项目库、逆向工作区和用户管理交互。
5. 执行 `npm run lint``npm run build`
6. 使用 tmux 重启 `revoxelseg-dicom` 服务并验证 `/api/health` 与首页。
7. 追加经验记录,提交并推送本次相关文件。
## 兼容性与回滚方案
- 未导入资产的默认项目继续读取 `Head_CT_DICOM/``Head_CT_ReConstruct/`
- 已保存的项目路径由状态文件保存,恢复演示环境可通过现有重置接口回到默认路径。
- 若分构件导出失败,可回退为 `segmentationExportMode=combined` 的原有整体导出。
## 预计文件变更
- 前端3 个组件与 API 封装。
- 后端1 个 Express 服务文件。
- 工程文档:本次三件套与经验记录。
## 提交与部署策略
- 仅暂存本次修改的源码与工程分析文档。
- commit message 包含 `2026-05-20-22-35-42` 与简要描述。
- 提交后推送到既有 Gitea 远端,并重启 4000 端口服务。

View File

@@ -0,0 +1,50 @@
# 测试方案-2026-05-20-22-35-42
## 测试方案文档路径
`工程分析/测试方案-2026-05-20-22-35-42.md`
## 静态检查
- 执行 `cd WebSite && npm run lint`,确认 TypeScript 类型检查通过。
- 使用 `rg` 检查旧文案 `Overlay Label Map ·` 是否仍残留在可见 UI。
## 构建检查
- 执行 `cd WebSite && npm run build`,确认生产构建通过。
## 关键业务场景验证
- 导出面板初始状态:`DICOM 原始影像``STL 原始模型` 不勾选,二者排在上方,`分割影像`在下方。
- 勾选分割影像时显示“构件整体导出 / 构件分别导出”选项,并将选择传入后端。
- 系统管理编辑用户账号为已有账号时,页面展示友好错误提示。
- 新建无 DICOM 项目时DICOM 预览区域显示“请导入DICOM影像”。
- DICOM 与 STL 导入按钮可以唤起文件选择并调用导入接口。
- 逆向工作区无保存结果时初次加载默认执行 Z 轴拉伸;已有保存结果不被覆盖。
## 医学影像数据相关边界验证
- 上传导入写入 `WebSite/data/uploads/<projectId>/`,不写入默认 `Head_CT_DICOM/``Head_CT_ReConstruct/`
- 默认演示项目重置后仍回到 300 张 DICOM 与默认 9 个 STL 构件。
- 分构件导出时保留构件 ID 与标签 JSON避免重叠构件在单一 label map 中互相覆盖。
## 部署验证
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启服务:
- `cd WebSite`
- `npm run serve -- --host 0.0.0.0 --port 4000`
- 验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- `git status --short` 检查仅暂存本次相关源码和文档。
- commit message 包含 `2026-05-20-22-35-42`
- `git push` 推送到 Gitea 后确认成功。
## 风险与回归关注点
- 避免上传文件体积过大导致请求失败时没有用户提示。
- 避免项目级 STL 路径解析遗漏,导致预览可用但导出失败。
- 避免默认 Z 拉伸重复触发,引起位姿越拉越大。

View File

@@ -1369,3 +1369,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
涉及中文下载名时应同时设置 ASCII fallback 和 `filename*`,并用 `curl -D -` 抽查响应头。复用医学影像组件时,布局差异应继续沉到小粒度参数,例如 `overlayPlacement``viewPreset`,避免为了位置差异复制整套视图;同时确认 Overlay 摘要不遮挡 DICOM 主画布。 涉及中文下载名时应同时设置 ASCII fallback 和 `filename*`,并用 `curl -D -` 抽查响应头。复用医学影像组件时,布局差异应继续沉到小粒度参数,例如 `overlayPlacement``viewPreset`,避免为了位置差异复制整套视图;同时确认 Overlay 摘要不遮挡 DICOM 主画布。
## 2026-05-20-22-35-42 项目导入不能覆盖默认医学数据
A. 具体问题
用户要求项目库 DICOM 与 STL 导入按钮真正可用同时“头部CT 模型逆向体素化演示”仍作为测试数据保留,导入其他影像或模型不能污染默认 `Head_CT_DICOM/``Head_CT_ReConstruct/`
B. 产生问题原因
后端此前所有 DICOM/STL 预览、导出和分割生成都硬编码读取默认数据目录,项目库导入按钮也没有对应上传接口。若直接把上传文件写入默认目录,会破坏演示环境重置语义,并让默认项目资产与用户项目资产混在一起。
C. 解决问题方案
新增项目级上传接口,将导入文件写入 `WebSite/data/uploads/<projectId>/DICOM|STL`项目状态只保存相对路径预览、三维融合、STL 模型、导出包和分割生成统一改为根据项目路径解析文件。恢复演示环境仍通过默认状态回到原始 `Head_CT_*` 数据。前端导入按钮接入隐藏文件选择器,并在无 DICOM/STL 时显示明确导入提示。
D. 后续如何避免问题
凡是新增医学影像或模型导入能力,都必须区分“默认演示资产”和“项目用户资产”。后端路径解析不能继续硬编码单一目录;导入后要清理前后端缓存并清空旧分割结果,避免新数据套用旧预览或旧位姿结果。

View File

@@ -0,0 +1,54 @@
# 需求分析-2026-05-20-22-35-42
## 开始时间
2026-05-20-22-35-42
## 原始需求摘要
1. 将映射视图下方文案 `Overlay Label Map · Overlay Label Map 已就绪` 精简为 `Overlay Label Map`
2. 系统管理工作区修改账号为同名账号时,需要给出清晰反馈,避免只暴露浏览器 409 报错。
3. “导出项目及结果”中,导出分割影像时新增“构件分别导出 / 构件整体导出”选项,用于处理构件重叠场景。
4. 导出面板初始默认不选择 `DICOM 原始影像``STL 原始模型`,且二者位于选项最上方,分割影像放到最下方。
5. 新建项目没有 DICOM 影像时显示“请导入DICOM影像”同时修复 DICOM 与 3D 模型导入按钮无响应问题。
6. “头部CT 模型逆向体素化演示”作为默认测试数据,用户导入新影像或模型不得覆盖原始数据;恢复演示环境后仍回到初始 DICOM 与 STL。
7. 逆向工作区初始状态默认对模型做一次 Z 轴拉伸。
## 业务目标
- 让项目导入、导出和逆向工作区初始对齐流程更符合实际使用习惯。
- 对重叠构件分割导出提供整体标签图和按构件独立导出两种策略。
- 保护默认演示数据资产,支持用户项目独立导入。
- 用明确的界面反馈替代后台管理接口冲突时的裸错误。
## 输入与输出
- 输入:用户在项目库中导入 DICOM/STL 文件、在导出面板选择导出目标与分割导出方式、在系统管理中编辑账号。
- 输出:项目级独立资产目录、导出 tar.gz 包、分割整体或分构件 NII.GZ、清晰错误提示、更新后的逆向工作区初始位姿。
## 影响范围
- `WebSite/server.ts`:导入资产接口、项目资产路径解析、导出包分割策略、用户冲突响应。
- `WebSite/src/lib/api.ts`:导出参数与导入接口封装。
- `WebSite/src/components/ProjectLibrary.tsx`:导入按钮、空项目提示、导出选项顺序和分割导出方式。
- `WebSite/src/components/ReverseWorkspace.tsx`Overlay 文案、导出选项、默认 Z 拉伸。
- `WebSite/src/components/UserManagement.tsx`409 冲突友好提示。
## 关键约束
- 必须保留默认 `Head_CT_DICOM/``Head_CT_ReConstruct/` 原始数据,不允许导入时覆盖。
- 导出面板的项目库与逆向工作区入口保持一致。
- 默认 Z 拉伸不能覆盖项目库已有保存结果的位姿。
- 只提交本次相关文件,不混入历史删除文档和软著材料。
## 风险点
- DICOM 文件可能较大,前端转 base64 上传会增加内存与请求体体积,需要设置后端请求体限制并控制失败反馈。
- 分构件导出会增加生成时间与压缩包体积,需要沿用当前可见/全部构件过滤逻辑。
- 修改模型路径解析后,所有 STL 预览、导出、分割生成都要使用项目级路径,避免只在部分接口生效。
## 默认假设
- 本次“导入按钮”先支持浏览器本地文件选择上传DICOM 支持多文件STL 支持多文件。
- “构件整体导出”为现有单个 label map“构件分别导出”为每个构件生成一个独立 label map并附带统一标签 JSON。
- 默认 Z 轴拉伸只在没有已保存逆向分割结果的新进入工作区中执行一次。