2026-05-20-22-35-42 导入资产与分割导出优化
This commit is contained in:
@@ -11,6 +11,7 @@ type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
||||
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
||||
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||
type SegmentationExportScope = 'all' | 'visible';
|
||||
type SegmentationExportMode = 'combined' | 'separate';
|
||||
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
@@ -95,16 +96,22 @@ interface AppState {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface UploadedAssetPayload {
|
||||
name: string;
|
||||
data: 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 uploadDir = path.join(dataDir, 'uploads');
|
||||
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, {
|
||||
const dicomVolumeCache = new Map<string, {
|
||||
frames: Buffer[];
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -209,6 +216,47 @@ function naturalFileCompare(a: string, b: string) {
|
||||
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) {
|
||||
const { password: _password, ...rest } = user;
|
||||
return rest;
|
||||
@@ -446,20 +494,34 @@ function normalizeState(state: AppState): AppState {
|
||||
? state.projects
|
||||
.filter((project) => project.id !== defaultProject.id)
|
||||
.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);
|
||||
return {
|
||||
...project,
|
||||
dicomPath,
|
||||
modelPath,
|
||||
dicomCount,
|
||||
stlFiles,
|
||||
hasModel: stlFiles.length > 0,
|
||||
modelCount: stlFiles.length,
|
||||
exportedMaskCount: project.exportedMaskCount ?? 0,
|
||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||
moduleStyles,
|
||||
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 {
|
||||
...state,
|
||||
@@ -467,14 +529,20 @@ function normalizeState(state: AppState): AppState {
|
||||
{
|
||||
...defaultProject,
|
||||
name: savedDefaultProject?.name ?? defaultProject.name,
|
||||
dicomPath: defaultDicomPath,
|
||||
modelPath: defaultModelPath,
|
||||
dicomCount: defaultDicomCount,
|
||||
hasModel: defaultStlFiles.length > 0,
|
||||
modelCount: defaultStlFiles.length,
|
||||
stlFiles: defaultStlFiles,
|
||||
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
|
||||
moduleStyles: defaultModuleStyles,
|
||||
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
|
||||
segmentationResults: normalizeSegmentationResults(
|
||||
savedDefaultProject?.segmentationResults,
|
||||
defaultProject.stlFiles,
|
||||
defaultStlFiles,
|
||||
defaultModuleStyles,
|
||||
defaultProject.dicomCount,
|
||||
defaultDicomCount,
|
||||
),
|
||||
},
|
||||
...customProjects,
|
||||
@@ -558,13 +626,13 @@ interface ExportSceneMetrics {
|
||||
|
||||
const exportFusionBaseExtent = 4.6;
|
||||
|
||||
function readDicomHuVolume(files: string[]): DicomHuVolume {
|
||||
function readDicomHuVolume(project: ProjectRecord, files: string[]): DicomHuVolume {
|
||||
if (!files.length) {
|
||||
throw new Error('当前项目没有可导出的 DICOM 序列');
|
||||
}
|
||||
|
||||
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 pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
|
||||
if (!attributes.rows || !attributes.columns || !pixelTag) {
|
||||
@@ -1038,10 +1106,16 @@ function isModuleIncludedForExport(style: ModuleStyleRecord, scope: Segmentation
|
||||
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 previews = (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
|
||||
const filePath = path.join(modelDir, fileName);
|
||||
const filePath = getProjectModelFilePath(project, fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord;
|
||||
}
|
||||
@@ -1071,7 +1145,7 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
|
||||
const payload = previews[fileName];
|
||||
const style = getModuleStyle(project, fileName, index);
|
||||
|
||||
if (!payload || !isModuleIncludedForExport(style, scope)) {
|
||||
if (!payload || (onlyFileName && fileName !== onlyFileName) || !isModuleIncludedForExport(style, scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1087,7 +1161,7 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
|
||||
return rows;
|
||||
};
|
||||
|
||||
const filePath = path.join(modelDir, fileName);
|
||||
const filePath = getProjectModelFilePath(project, fileName);
|
||||
forEachBinaryStlTriangle(filePath, fileName, (
|
||||
ax,
|
||||
ay,
|
||||
@@ -1209,6 +1283,10 @@ function parseSegmentationScope(raw: unknown): SegmentationExportScope {
|
||||
return raw === 'all' ? 'all' : 'visible';
|
||||
}
|
||||
|
||||
function parseSegmentationExportMode(raw: unknown): SegmentationExportMode {
|
||||
return raw === 'separate' ? 'separate' : 'combined';
|
||||
}
|
||||
|
||||
function latestSegmentationResult(project: ProjectRecord) {
|
||||
return project.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
}
|
||||
@@ -1233,6 +1311,34 @@ function parseExportTargets(raw: unknown): ProjectExportTarget[] {
|
||||
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(
|
||||
project: ProjectRecord,
|
||||
files: string[],
|
||||
@@ -1241,7 +1347,7 @@ function createNiftiExport(
|
||||
pose?: ModelPoseValue,
|
||||
segmentationScope: SegmentationExportScope = 'visible',
|
||||
) {
|
||||
const volume = readDicomHuVolume(files);
|
||||
const volume = readDicomHuVolume(project, files);
|
||||
if (target === 'dicom') {
|
||||
return createNiftiBuffer(volume, volume.data, 'dicom', compressed);
|
||||
}
|
||||
@@ -1277,6 +1383,7 @@ function createProjectExportBundle({
|
||||
compressed,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
@@ -1285,11 +1392,12 @@ function createProjectExportBundle({
|
||||
compressed: boolean;
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
segmentationExportMode: SegmentationExportMode;
|
||||
exportRoot: string;
|
||||
}) {
|
||||
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
|
||||
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';
|
||||
|
||||
if (targets.includes('dicom') && volume) {
|
||||
@@ -1300,15 +1408,34 @@ function createProjectExportBundle({
|
||||
}
|
||||
|
||||
if (targets.includes('segmentation') && volume) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
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({
|
||||
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
|
||||
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
|
||||
@@ -1324,7 +1451,7 @@ function createProjectExportBundle({
|
||||
|
||||
if (targets.includes('stl')) {
|
||||
(project.stlFiles ?? []).forEach((fileName) => {
|
||||
const filePath = path.join(modelDir, fileName);
|
||||
const filePath = getProjectModelFilePath(project, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
@@ -1350,10 +1477,7 @@ function findProject(state: AppState, projectId: string) {
|
||||
}
|
||||
|
||||
function getProjectDicomFiles(project: ProjectRecord) {
|
||||
if (project.id !== 'head-ct-demo') {
|
||||
return [];
|
||||
}
|
||||
return listFiles(dicomDir, '.dcm');
|
||||
return listFiles(getProjectDicomDir(project), '.dcm');
|
||||
}
|
||||
|
||||
function readAsciiValue(buffer: Buffer, start: number, length: number) {
|
||||
@@ -1542,13 +1666,14 @@ function estimateSliceSpacing(parsed: ReturnType<typeof parseDicomPixels>[]) {
|
||||
?? 1;
|
||||
}
|
||||
|
||||
function getDicomVolume(files: string[], mode: DicomDisplayMode) {
|
||||
const cached = dicomVolumeCache.get(mode);
|
||||
function getDicomVolume(project: ProjectRecord, files: string[], mode: DicomDisplayMode) {
|
||||
const cacheKey = `${project.id}:${project.dicomPath}:${mode}:${files.join('|')}`;
|
||||
const cached = dicomVolumeCache.get(cacheKey);
|
||||
if (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 volume = {
|
||||
frames: parsed.map((frame) => frame.pixelBuffer),
|
||||
@@ -1562,7 +1687,7 @@ function getDicomVolume(files: string[], mode: DicomDisplayMode) {
|
||||
sliceThickness: parsed[0]?.attributes.sliceThickness ?? null,
|
||||
spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null,
|
||||
};
|
||||
dicomVolumeCache.set(mode, volume);
|
||||
dicomVolumeCache.set(cacheKey, 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(() => {
|
||||
try {
|
||||
getDicomVolume(files, 'default');
|
||||
getDicomVolume(files, 'soft');
|
||||
getDicomVolume(project, files, 'default');
|
||||
getDicomVolume(project, 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);
|
||||
function createReformattedPreview(project: ProjectRecord, files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) {
|
||||
const volume = getDicomVolume(project, 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;
|
||||
@@ -1660,8 +1785,8 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
|
||||
};
|
||||
}
|
||||
|
||||
function createDicomFusionVolume(files: string[], start: number, end: number, mode: DicomDisplayMode) {
|
||||
const volume = getDicomVolume(files, mode);
|
||||
function createDicomFusionVolume(project: ProjectRecord, files: string[], start: number, end: number, mode: DicomDisplayMode) {
|
||||
const volume = getDicomVolume(project, files, mode);
|
||||
const total = volume.frames.length;
|
||||
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));
|
||||
@@ -1773,7 +1898,7 @@ function cropDicomContent(pixels: Buffer, width: number, height: number) {
|
||||
}
|
||||
|
||||
function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord {
|
||||
const cacheKey = `${fileName}:${limit}`;
|
||||
const cacheKey = `${filePath}:${fileName}:${limit}`;
|
||||
const cached = modelPreviewCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached as ModelPreviewRecord;
|
||||
@@ -1896,12 +2021,13 @@ function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number
|
||||
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) => {
|
||||
const filePath = path.join(dicomDir, fileName);
|
||||
const filePath = getProjectDicomFilePath(project, fileName);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
name: `Head_CT_DICOM/${fileName}`,
|
||||
name: `${rootName}/${fileName}`,
|
||||
data: fs.readFileSync(filePath),
|
||||
mtime: stat.mtimeMs / 1000,
|
||||
};
|
||||
@@ -1939,7 +2065,7 @@ function formatNumber(value: number | null | undefined, digits = 3) {
|
||||
|
||||
function createDicomInfo(project: ProjectRecord, files: string[]) {
|
||||
const attributes = files.map((fileName) => {
|
||||
const buffer = fs.readFileSync(path.join(dicomDir, fileName));
|
||||
const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName));
|
||||
return parseDicomAttributes(buffer, 'default');
|
||||
});
|
||||
const first = attributes[0];
|
||||
@@ -2009,7 +2135,7 @@ async function startServer() {
|
||||
const port = Number(portArg ?? 4000);
|
||||
|
||||
ensureDir(exportDir);
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '512mb' }));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'revoxelseg-dicom', time: now() });
|
||||
@@ -2225,6 +2351,65 @@ async function startServer() {
|
||||
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) => {
|
||||
const state = readState();
|
||||
const project = findProject(state, req.params.projectId);
|
||||
@@ -2296,7 +2481,7 @@ async function startServer() {
|
||||
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);
|
||||
const preview = parseDicomPreview(getProjectDicomFilePath(project, files[slice]), mode);
|
||||
payload = {
|
||||
...preview,
|
||||
plane,
|
||||
@@ -2306,7 +2491,7 @@ async function startServer() {
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
|
||||
...createReformattedPreview(project, files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
|
||||
plane,
|
||||
};
|
||||
}
|
||||
@@ -2337,7 +2522,7 @@ async function startServer() {
|
||||
const end = Number.parseInt(String(req.query.end ?? '49'), 10);
|
||||
|
||||
try {
|
||||
res.json(createDicomFusionVolume(files, start, end, mode));
|
||||
res.json(createDicomFusionVolume(project, files, start, end, mode));
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' });
|
||||
}
|
||||
@@ -2357,7 +2542,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const archive = createDicomTarGz(files);
|
||||
const archive = createDicomTarGz(project, files);
|
||||
const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`;
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
@@ -2391,12 +2576,12 @@ async function startServer() {
|
||||
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)) {
|
||||
if (!project || !project.stlFiles.includes(fileName)) {
|
||||
res.status(404).json({ message: '模型文件不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(path.join(modelDir, fileName));
|
||||
res.sendFile(getProjectModelFilePath(project, fileName));
|
||||
});
|
||||
|
||||
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 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: '模型文件不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
|
||||
}
|
||||
@@ -2514,6 +2699,7 @@ async function startServer() {
|
||||
const segmentationScope = req.query.segmentationScope === undefined
|
||||
? latestResult?.segmentationScope ?? 'visible'
|
||||
: parseSegmentationScope(req.query.segmentationScope);
|
||||
const segmentationExportMode = parseSegmentationExportMode(req.query.segmentationExportMode);
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
|
||||
@@ -2527,6 +2713,7 @@ async function startServer() {
|
||||
compressed,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot: exportBase,
|
||||
});
|
||||
const filename = `${exportBase}.tar.gz`;
|
||||
@@ -2561,7 +2748,8 @@ async function startServer() {
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
|
||||
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject()));
|
||||
const defaultProject = buildDefaultProject();
|
||||
warmDicomVolumeCache(defaultProject, getProjectDicomFiles(defaultProject));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
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 {
|
||||
FusionThreeView,
|
||||
VoxelizationMappingView,
|
||||
clearCachedProjectAssets,
|
||||
getCachedDicomFusionVolume,
|
||||
getCachedDicomPreview,
|
||||
getCachedModelPreview,
|
||||
@@ -63,14 +64,18 @@ type ModelPoseKey = keyof ModelPose;
|
||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ 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 }> = [
|
||||
{ id: 'visible', 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 }> = [
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ 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';
|
||||
}
|
||||
|
||||
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(
|
||||
canvas: HTMLCanvasElement,
|
||||
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
||||
@@ -676,13 +692,17 @@ export default function ProjectLibrary({
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
|
||||
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||||
dicom: true,
|
||||
dicom: false,
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
stl: false,
|
||||
});
|
||||
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
|
||||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
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 dicomRequestRef = useRef(0);
|
||||
const preloadedProjectIdsRef = useRef(new Set<string>());
|
||||
@@ -804,6 +824,7 @@ export default function ProjectLibrary({
|
||||
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
|
||||
pose: latestSegmentationResult?.pose ?? modelPose,
|
||||
segmentationScope: maskSegmentationScope,
|
||||
segmentationExportMode: maskSegmentationExportMode,
|
||||
});
|
||||
window.setTimeout(() => setMaskExporting(false), 900);
|
||||
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(() => {
|
||||
setViewMode(initialViewMode);
|
||||
}, [initialViewMode]);
|
||||
@@ -1204,6 +1285,12 @@ export default function ProjectLibrary({
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
{selectedProject ? (
|
||||
<>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleProjectAssetImport}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
{tabs.map((tab) => (
|
||||
@@ -1226,8 +1313,12 @@ export default function ProjectLibrary({
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
{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">
|
||||
<Upload size={18} /> 导入
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -1292,7 +1383,9 @@ export default function ProjectLibrary({
|
||||
{dicomPreview ? (
|
||||
<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 && (
|
||||
<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">
|
||||
@@ -1301,8 +1394,8 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</div>
|
||||
<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>第 {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||
<span>第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Vertical Progress Bar */}
|
||||
@@ -1389,6 +1482,13 @@ export default function ProjectLibrary({
|
||||
pose={modelPose}
|
||||
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]">
|
||||
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
|
||||
</div>
|
||||
@@ -1724,6 +1824,27 @@ export default function ProjectLibrary({
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
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 {
|
||||
fileName: string;
|
||||
@@ -100,14 +100,18 @@ const defaultSavedPoses: SavedModelPose[] = [
|
||||
];
|
||||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ 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 }> = [
|
||||
{ id: 'visible', 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 fusionBaseExtent = 4.6;
|
||||
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) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -2056,7 +2070,7 @@ export function VoxelizationMappingView({
|
||||
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={`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">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</span>
|
||||
@@ -2219,7 +2233,7 @@ export function VoxelizationMappingView({
|
||||
|
||||
<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">
|
||||
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
|
||||
<span className="truncate">Overlay Label Map</span>
|
||||
<span className="font-mono text-cyan-700">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</span>
|
||||
@@ -2321,12 +2335,13 @@ export default function ReverseWorkspace({
|
||||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||||
dicom: true,
|
||||
dicom: false,
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
stl: false,
|
||||
});
|
||||
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
|
||||
const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
@@ -2347,6 +2362,7 @@ export default function ReverseWorkspace({
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
const selectedItems = exportOptions
|
||||
@@ -2363,6 +2379,7 @@ export default function ReverseWorkspace({
|
||||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
segmentationExportMode,
|
||||
});
|
||||
window.setTimeout(() => setExporting(false), 900);
|
||||
setShowExportMenu(false);
|
||||
@@ -2552,7 +2569,7 @@ export default function ReverseWorkspace({
|
||||
return bounds;
|
||||
};
|
||||
|
||||
const applyModelStretchByAxis = async (axis: AxisKey) => {
|
||||
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
|
||||
if (!project || !fusionVolume) {
|
||||
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
|
||||
return;
|
||||
@@ -2588,8 +2605,23 @@ export default function ReverseWorkspace({
|
||||
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
|
||||
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
|
||||
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()} 方向进行三维等比例拉伸`);
|
||||
if (options.silentInitial) {
|
||||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||||
modelPose: nextPose,
|
||||
segmentationExportScope,
|
||||
moduleStyles,
|
||||
sliceStart,
|
||||
sliceEnd,
|
||||
mappingSlice,
|
||||
displayLevel,
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
|
||||
} finally {
|
||||
@@ -2620,6 +2652,7 @@ export default function ReverseWorkspace({
|
||||
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
|
||||
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
|
||||
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
|
||||
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
|
||||
setModelPose(restoredPose);
|
||||
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
@@ -2715,7 +2748,7 @@ export default function ReverseWorkspace({
|
||||
return clamp(value, limit.min, limit.max);
|
||||
};
|
||||
|
||||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||||
const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
|
||||
setModelPose((current) => {
|
||||
const next = { ...current };
|
||||
modelPoseKeys.forEach((key) => {
|
||||
@@ -2726,8 +2759,12 @@ export default function ReverseWorkspace({
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setSelectedPoseId('custom');
|
||||
setPoseImportStatus('');
|
||||
if (options.markCustom !== false) {
|
||||
setSelectedPoseId('custom');
|
||||
}
|
||||
if (!options.keepStatus) {
|
||||
setPoseImportStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||||
@@ -3021,6 +3058,25 @@ export default function ReverseWorkspace({
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -132,6 +132,10 @@ export default function UserManagement() {
|
||||
setMessage('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if ((formMode === 'create' || formMode === 'edit') && users.some((user) => user.id !== form.id && user.account === account)) {
|
||||
setMessage('账号已存在,请更换账号');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -150,7 +154,8 @@ export default function UserManagement() {
|
||||
closeForm();
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '用户保存失败');
|
||||
const message = error instanceof Error ? error.message : '用户保存失败';
|
||||
setMessage(message === '账号已存在' ? '账号已存在,请更换账号' : message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 SegmentationExportMode = 'combined' | 'separate';
|
||||
export type ProjectAssetImportKind = 'dicom' | 'stl';
|
||||
export type { SegmentationExportScope } from '../types';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
@@ -63,6 +65,11 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
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: (
|
||||
projectId: string,
|
||||
payload: {
|
||||
@@ -131,22 +138,24 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
|
||||
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 });
|
||||
if (target === 'segmentation' || target === 'pose') {
|
||||
appendPose(params, options.pose);
|
||||
}
|
||||
if (target === 'segmentation') {
|
||||
params.set('segmentationScope', options.segmentationScope ?? 'visible');
|
||||
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
|
||||
}
|
||||
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({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
segmentationScope: options.segmentationScope ?? 'visible',
|
||||
segmentationExportMode: options.segmentationExportMode ?? 'combined',
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
|
||||
Reference in New Issue
Block a user