2026-05-25-14-00-24 修正项目导出结构与新增批处理API
This commit is contained in:
@@ -2310,6 +2310,22 @@ function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express
|
||||
return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data));
|
||||
}
|
||||
|
||||
function writePreparedAssetFiles(kind: 'dicom' | 'stl', targetDir: string, preparedFiles: PreparedAssetFile[]) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
ensureDir(targetDir);
|
||||
const usedNames = new Set<string>();
|
||||
preparedFiles.forEach((file, index) => {
|
||||
const fileName = safeImportedFileName(
|
||||
file.name,
|
||||
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
|
||||
kind === 'dicom' ? '.dcm' : '.stl',
|
||||
usedNames,
|
||||
);
|
||||
fs.writeFileSync(path.join(targetDir, fileName), file.data);
|
||||
});
|
||||
return listFiles(targetDir, kind === 'dicom' ? '.dcm' : '.stl');
|
||||
}
|
||||
|
||||
function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
|
||||
files.forEach((file) => {
|
||||
if (file.path) {
|
||||
@@ -2318,6 +2334,289 @@ function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonObjectField(raw: unknown) {
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('metadata 必须是 JSON 对象');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parsePipelineMetadata(body: unknown) {
|
||||
const source = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
||||
const metadata = parseJsonObjectField(source.metadata ?? source.payload ?? source.options);
|
||||
return {
|
||||
...source,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFlexibleUploadedAssets(raw: unknown) {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const value = Array.isArray(raw) && raw.length === 1 && typeof raw[0] === 'string' ? raw[0] : raw;
|
||||
try {
|
||||
return parseUploadedAssets(typeof value === 'string' ? JSON.parse(value) as unknown : value);
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : '上传文件列表无效');
|
||||
}
|
||||
}
|
||||
|
||||
function requestFilesFromFields(files: unknown, fieldNames: string[]) {
|
||||
if (!files || Array.isArray(files) || typeof files !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const groups = files as Record<string, Express.Multer.File[]>;
|
||||
return fieldNames.flatMap((name) => Array.isArray(groups[name]) ? groups[name] : []);
|
||||
}
|
||||
|
||||
function allRequestMulterFiles(files: unknown) {
|
||||
if (Array.isArray(files)) {
|
||||
return files as Express.Multer.File[];
|
||||
}
|
||||
if (!files || typeof files !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.values(files as Record<string, Express.Multer.File[]>).flatMap((group) => (
|
||||
Array.isArray(group) ? group : []
|
||||
));
|
||||
}
|
||||
|
||||
function readBooleanOption(value: unknown, fallback = false) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readNumberOption(value: unknown) {
|
||||
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : NaN;
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function readNestedObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function normalizePipelinePose(metadata: Record<string, unknown>) {
|
||||
const poseSource = readNestedObject(metadata.pose);
|
||||
const rotation = readNestedObject(metadata.rotation ?? metadata.rotate);
|
||||
const translation = readNestedObject(metadata.translation ?? metadata.translate);
|
||||
const mirror = readNestedObject(metadata.mirror ?? metadata.flip);
|
||||
const pose: Partial<ModelPoseValue> = {
|
||||
...poseSource,
|
||||
};
|
||||
const rotationX = readNumberOption(rotation.x ?? rotation.rotateX ?? metadata.rotateX);
|
||||
const rotationY = readNumberOption(rotation.y ?? rotation.rotateY ?? metadata.rotateY);
|
||||
const rotationZ = readNumberOption(rotation.z ?? rotation.rotateZ ?? metadata.rotateZ);
|
||||
const translateX = readNumberOption(translation.x ?? translation.translateX ?? metadata.translateX);
|
||||
const translateY = readNumberOption(translation.y ?? translation.translateY ?? metadata.translateY);
|
||||
const translateZ = readNumberOption(translation.z ?? translation.translateZ ?? metadata.translateZ);
|
||||
const scale = readNumberOption(metadata.scale ?? poseSource.scale);
|
||||
|
||||
if (rotationX !== undefined) pose.rotateX = rotationX;
|
||||
if (rotationY !== undefined) pose.rotateY = rotationY;
|
||||
if (rotationZ !== undefined) pose.rotateZ = rotationZ;
|
||||
if (translateX !== undefined) pose.translateX = translateX;
|
||||
if (translateY !== undefined) pose.translateY = translateY;
|
||||
if (translateZ !== undefined) pose.translateZ = translateZ;
|
||||
if (scale !== undefined) pose.scale = scale;
|
||||
|
||||
(['X', 'Y', 'Z'] as const).forEach((axis) => {
|
||||
const lowerAxis = axis.toLowerCase();
|
||||
const key = `flip${axis}` as 'flipX' | 'flipY' | 'flipZ';
|
||||
const value = mirror[lowerAxis] ?? mirror[key] ?? metadata[key] ?? metadata[`mirror${axis}`];
|
||||
if (value !== undefined) {
|
||||
pose[key] = readBooleanOption(value);
|
||||
}
|
||||
});
|
||||
|
||||
return normalizeModelPoseValue(pose);
|
||||
}
|
||||
|
||||
function normalizePipelineAxis(value: unknown) {
|
||||
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
return raw === 'x' || raw === 'y' || raw === 'z' ? raw as 'x' | 'y' | 'z' : null;
|
||||
}
|
||||
|
||||
function collectProjectModelBounds(project: ProjectRecord, previews: Record<string, ModelPreviewRecord>) {
|
||||
const bounds = (project.stlFiles ?? []).reduce<ModelBoundsRecord>((accumulator, fileName) => {
|
||||
const payload = previews[fileName];
|
||||
if (!payload) {
|
||||
return accumulator;
|
||||
}
|
||||
accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x);
|
||||
accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y);
|
||||
accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z);
|
||||
accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x);
|
||||
accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y);
|
||||
accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z);
|
||||
return accumulator;
|
||||
}, {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
});
|
||||
return Number.isFinite(bounds.min.x) ? bounds : null;
|
||||
}
|
||||
|
||||
function rotatedModelSizeForPose(bounds: ModelBoundsRecord, pose: ModelPoseValue) {
|
||||
const center = {
|
||||
x: (bounds.min.x + bounds.max.x) / 2,
|
||||
y: (bounds.min.y + bounds.max.y) / 2,
|
||||
z: (bounds.min.z + bounds.max.z) / 2,
|
||||
};
|
||||
const rotateX = (pose.rotateX * Math.PI) / 180;
|
||||
const rotateY = (pose.rotateY * Math.PI) / 180;
|
||||
const rotateZ = (pose.rotateZ * Math.PI) / 180;
|
||||
const cosX = Math.cos(rotateX);
|
||||
const sinX = Math.sin(rotateX);
|
||||
const cosY = Math.cos(rotateY);
|
||||
const sinY = Math.sin(rotateY);
|
||||
const cosZ = Math.cos(rotateZ);
|
||||
const sinZ = Math.sin(rotateZ);
|
||||
const rotatedBounds = {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
};
|
||||
[bounds.min.x, bounds.max.x].forEach((x) => {
|
||||
[bounds.min.y, bounds.max.y].forEach((y) => {
|
||||
[bounds.min.z, bounds.max.z].forEach((z) => {
|
||||
let px = (x - center.x) * (pose.flipX ? -1 : 1);
|
||||
let py = (y - center.y) * (pose.flipY ? -1 : 1);
|
||||
let pz = (z - center.z) * (pose.flipZ ? -1 : 1);
|
||||
const afterX = {
|
||||
x: px,
|
||||
y: py * cosX - pz * sinX,
|
||||
z: py * sinX + pz * cosX,
|
||||
};
|
||||
const afterY = {
|
||||
x: afterX.x * cosY + afterX.z * sinY,
|
||||
y: afterX.y,
|
||||
z: -afterX.x * sinY + afterX.z * cosY,
|
||||
};
|
||||
px = afterY.x * cosZ - afterY.y * sinZ;
|
||||
py = afterY.x * sinZ + afterY.y * cosZ;
|
||||
pz = afterY.z;
|
||||
rotatedBounds.min.x = Math.min(rotatedBounds.min.x, px);
|
||||
rotatedBounds.min.y = Math.min(rotatedBounds.min.y, py);
|
||||
rotatedBounds.min.z = Math.min(rotatedBounds.min.z, pz);
|
||||
rotatedBounds.max.x = Math.max(rotatedBounds.max.x, px);
|
||||
rotatedBounds.max.y = Math.max(rotatedBounds.max.y, py);
|
||||
rotatedBounds.max.z = Math.max(rotatedBounds.max.z, pz);
|
||||
});
|
||||
});
|
||||
});
|
||||
return {
|
||||
x: Math.max(rotatedBounds.max.x - rotatedBounds.min.x, 1e-6),
|
||||
y: Math.max(rotatedBounds.max.y - rotatedBounds.min.y, 1e-6),
|
||||
z: Math.max(rotatedBounds.max.z - rotatedBounds.min.z, 1e-6),
|
||||
};
|
||||
}
|
||||
|
||||
function applyAxisStretchPose(project: ProjectRecord, files: string[], pose: ModelPoseValue, axis: 'x' | 'y' | 'z') {
|
||||
const volume = readDicomHuVolume(project, files);
|
||||
const previews = collectAutoMatchPreviews(project);
|
||||
const metrics = getExportMetrics(project, volume, previews);
|
||||
const bounds = collectProjectModelBounds(project, previews);
|
||||
if (!metrics || !bounds) {
|
||||
throw new Error('无法读取 STL 全局边界,不能执行轴向拉伸');
|
||||
}
|
||||
const rotatedSize = rotatedModelSizeForPose(bounds, pose);
|
||||
const dicomSize = {
|
||||
x: metrics.dicomWidth,
|
||||
y: metrics.dicomHeight,
|
||||
z: metrics.dicomDepth,
|
||||
};
|
||||
const axisFitScale = dicomSize[axis] / (rotatedSize[axis] * metrics.modelBaseScale);
|
||||
const containmentScale = Math.min(
|
||||
dicomSize.x / (rotatedSize.x * metrics.modelBaseScale),
|
||||
dicomSize.y / (rotatedSize.y * metrics.modelBaseScale),
|
||||
dicomSize.z / (rotatedSize.z * metrics.modelBaseScale),
|
||||
);
|
||||
return normalizeModelPoseValue({
|
||||
...pose,
|
||||
scale: Math.min(axisFitScale, containmentScale),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePipelineExportConfig(metadata: Record<string, unknown>) {
|
||||
const exportConfig = readNestedObject(metadata.export);
|
||||
const targets = Array.isArray(exportConfig.targets)
|
||||
? parseExportTargets(exportConfig.targets.join(','))
|
||||
: parseExportTargets(exportConfig.targets ?? metadata.exportTargets ?? metadata.targets ?? '');
|
||||
const format = exportConfig.format === 'nii' || metadata.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
return {
|
||||
targets,
|
||||
format: format as 'nii' | 'nii.gz',
|
||||
compressed: format === 'nii.gz',
|
||||
segmentationScope: parseSegmentationScope(exportConfig.segmentationScope ?? metadata.segmentationScope),
|
||||
segmentationExportMode: parseSegmentationExportMode(exportConfig.segmentationExportMode ?? metadata.segmentationExportMode),
|
||||
};
|
||||
}
|
||||
|
||||
function createPipelineProject({
|
||||
name,
|
||||
dicomFiles,
|
||||
stlFiles,
|
||||
moduleStyles,
|
||||
}: {
|
||||
name: string;
|
||||
dicomFiles: PreparedAssetFile[];
|
||||
stlFiles: PreparedAssetFile[];
|
||||
moduleStyles?: Record<string, Partial<ModuleStyleRecord>>;
|
||||
}) {
|
||||
const project = buildEmptyProject(name);
|
||||
const projectRoot = path.join(uploadDir, project.id);
|
||||
const dicomTargetDir = path.join(projectRoot, 'DICOM');
|
||||
const stlTargetDir = path.join(projectRoot, 'STL');
|
||||
const dicomFileNames = writePreparedAssetFiles('dicom', dicomTargetDir, dicomFiles);
|
||||
const stlFileNames = writePreparedAssetFiles('stl', stlTargetDir, stlFiles);
|
||||
|
||||
project.dicomPath = toRepoRelativePath(dicomTargetDir);
|
||||
project.modelPath = toRepoRelativePath(stlTargetDir);
|
||||
project.dicomCount = dicomFileNames.length;
|
||||
project.stlFiles = stlFileNames;
|
||||
project.modelCount = stlFileNames.length;
|
||||
project.hasModel = stlFileNames.length > 0;
|
||||
project.moduleStyles = buildModuleStyles(stlFileNames, moduleStyles);
|
||||
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
|
||||
touchProject(project);
|
||||
|
||||
if (project.dicomCount > 0) {
|
||||
try {
|
||||
writeCachedDicomInfo(project, dicomFileNames, createDicomInfo(project, dicomFileNames));
|
||||
} catch {
|
||||
// DICOM info is an optimization cache; export/registration will report hard parse failures later.
|
||||
}
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
function createNiftiExport(
|
||||
project: ProjectRecord,
|
||||
files: string[],
|
||||
@@ -2355,6 +2654,90 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createZipArchive(entries: Array<{ name: string; data: Buffer }>) {
|
||||
const zip = new AdmZip();
|
||||
entries.forEach((entry) => {
|
||||
const normalizedName = entry.name
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((part) => sanitizeFilenamePart(part, 'entry'))
|
||||
.filter((part) => part && part !== '.' && part !== '..')
|
||||
.join('/');
|
||||
zip.addFile(normalizedName || 'entry', entry.data);
|
||||
});
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
function splitArchiveFileName(fileName: string) {
|
||||
if (fileName.toLowerCase().endsWith('.nii.gz')) {
|
||||
return {
|
||||
stem: fileName.slice(0, -'.nii.gz'.length),
|
||||
extension: '.nii.gz',
|
||||
};
|
||||
}
|
||||
const parsed = path.posix.parse(fileName);
|
||||
return {
|
||||
stem: path.posix.join(parsed.dir, parsed.name),
|
||||
extension: parsed.ext,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueArchiveEntryName(name: string, usedNames: Set<string>) {
|
||||
const normalized = name.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
const { stem, extension } = splitArchiveFileName(normalized);
|
||||
let candidate = normalized;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${stem}-${suffix}${extension}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function createProjectExportManifest({
|
||||
project,
|
||||
targets,
|
||||
format,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
targets: ProjectExportTarget[];
|
||||
format: 'nii' | 'nii.gz';
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
segmentationExportMode: SegmentationExportMode;
|
||||
}) {
|
||||
return Buffer.from(JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
generatedAt: now(),
|
||||
archiveFormat: 'zip',
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
createTime: project.createTime,
|
||||
dicomCount: project.dicomCount,
|
||||
modelCount: project.modelCount,
|
||||
dicomPath: project.dicomPath,
|
||||
modelPath: project.modelPath,
|
||||
},
|
||||
export: {
|
||||
targets,
|
||||
format,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
},
|
||||
activePose: activePose ?? null,
|
||||
notes: [
|
||||
'Archive entries are stored without a project-name root directory because the archive filename already carries the project name.',
|
||||
'segmentation/labels.json maps label values to STL component categories.',
|
||||
'When segmentationExportMode is separate, segmentation-parts/*.nii.gz files are single-category masks named by category.',
|
||||
],
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
|
||||
const latestResult = latestSegmentationResult(project);
|
||||
const activePose = latestResult?.pose
|
||||
@@ -2401,7 +2784,6 @@ function createProjectExportBundle({
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
files: string[];
|
||||
@@ -2410,18 +2792,32 @@ function createProjectExportBundle({
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
segmentationExportMode: SegmentationExportMode;
|
||||
exportRoot: string;
|
||||
}) {
|
||||
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
|
||||
const entries: Array<{ name: string; data: Buffer }> = [];
|
||||
const usedEntryNames = new Set<string>();
|
||||
const format = compressed ? 'nii.gz' : 'nii';
|
||||
let contentEntryCount = 0;
|
||||
const pushEntry = (name: string, data: Buffer) => {
|
||||
entries.push({ name: uniqueArchiveEntryName(name, usedEntryNames), data });
|
||||
};
|
||||
const pushContentEntry = (name: string, data: Buffer) => {
|
||||
contentEntryCount += 1;
|
||||
pushEntry(name, data);
|
||||
};
|
||||
const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
|
||||
const volume = needsVolume ? readDicomHuVolume(project, files) : null;
|
||||
const format = compressed ? 'nii.gz' : 'nii';
|
||||
|
||||
pushEntry('manifest.json', createProjectExportManifest({
|
||||
project,
|
||||
targets,
|
||||
format,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
}));
|
||||
|
||||
if (targets.includes('dicom') && volume) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-dicom-image.${format}`,
|
||||
data: createNiftiBuffer(volume, volume.data, 'dicom', compressed),
|
||||
});
|
||||
pushContentEntry(`dicom/image.${format}`, createNiftiBuffer(volume, volume.data, 'dicom', compressed));
|
||||
}
|
||||
|
||||
if (targets.includes('segmentation') && volume) {
|
||||
@@ -2432,38 +2828,32 @@ function createProjectExportBundle({
|
||||
return;
|
||||
}
|
||||
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
|
||||
entries.push({
|
||||
name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
pushContentEntry(
|
||||
`segmentation-parts/${moduleName}.${format}`,
|
||||
createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
);
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
pushContentEntry(
|
||||
`segmentation/label.${format}`,
|
||||
createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
|
||||
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
|
||||
});
|
||||
pushContentEntry('segmentation/labels.json', createSegmentationLabelMetadata(project, segmentationScope, activePose));
|
||||
}
|
||||
|
||||
if (targets.includes('pose')) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-pose-data.json`,
|
||||
data: createPoseExport(project, activePose),
|
||||
});
|
||||
pushContentEntry('pose/pose.json', createPoseExport(project, activePose));
|
||||
}
|
||||
|
||||
if (targets.includes('stl')) {
|
||||
@@ -2473,20 +2863,15 @@ function createProjectExportBundle({
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
entries.push({
|
||||
name: `${exportRoot}/STL/${fileName}`,
|
||||
data: fs.readFileSync(filePath),
|
||||
mtime: stat.mtimeMs / 1000,
|
||||
});
|
||||
pushContentEntry(`stl/${fileName}`, fs.readFileSync(filePath));
|
||||
});
|
||||
}
|
||||
|
||||
if (!entries.length) {
|
||||
if (contentEntryCount === 0) {
|
||||
throw new Error('未选择可导出的内容');
|
||||
}
|
||||
|
||||
return createTarGz(entries);
|
||||
return createZipArchive(entries);
|
||||
}
|
||||
|
||||
function findProject(state: AppState, projectId: string) {
|
||||
@@ -3234,6 +3619,166 @@ async function startServer() {
|
||||
res.json(publicSession(state));
|
||||
});
|
||||
|
||||
app.post('/api/reverse-pipeline', (req, res) => {
|
||||
assetUpload.fields([
|
||||
{ name: 'dicomFiles', maxCount: 5000 },
|
||||
{ name: 'dicom', maxCount: 5000 },
|
||||
{ name: 'stlFiles', maxCount: 2000 },
|
||||
{ name: 'stl', maxCount: 2000 },
|
||||
{ name: 'models', maxCount: 2000 },
|
||||
{ name: 'files', maxCount: 5000 },
|
||||
])(req, res, (uploadError) => {
|
||||
const multerFiles = allRequestMulterFiles(req.files);
|
||||
let project: ProjectRecord | null = null;
|
||||
let persisted = false;
|
||||
|
||||
try {
|
||||
if (uploadError) {
|
||||
res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' });
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = parsePipelineMetadata(req.body);
|
||||
const fallbackFiles = requestFilesFromFields(req.files, ['files']);
|
||||
const dicomMulterFiles = [
|
||||
...requestFilesFromFields(req.files, ['dicomFiles', 'dicom']),
|
||||
...fallbackFiles,
|
||||
];
|
||||
const stlMulterFiles = [
|
||||
...requestFilesFromFields(req.files, ['stlFiles', 'stl', 'models']),
|
||||
...fallbackFiles,
|
||||
];
|
||||
const dicomLegacyFiles = parseFlexibleUploadedAssets(metadata.dicomAssets ?? metadata.dicomFilesJson);
|
||||
const stlLegacyFiles = parseFlexibleUploadedAssets(metadata.stlAssets ?? metadata.stlFilesJson ?? metadata.modelAssets);
|
||||
const preparedDicomFiles = collectPreparedAssetFiles('dicom', dicomMulterFiles, dicomLegacyFiles);
|
||||
const preparedStlFiles = collectPreparedAssetFiles('stl', stlMulterFiles, stlLegacyFiles);
|
||||
|
||||
if (!preparedDicomFiles.length) {
|
||||
res.status(400).json({ message: '请上传 DICOM 文件、DICOM 文件夹压缩包或 dicomAssets' });
|
||||
return;
|
||||
}
|
||||
if (!preparedStlFiles.length) {
|
||||
res.status(400).json({ message: '请上传 STL 文件、STL 压缩包或 stlAssets' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawProjectName = typeof metadata.name === 'string' && metadata.name.trim()
|
||||
? metadata.name.trim()
|
||||
: typeof metadata.projectName === 'string' && metadata.projectName.trim()
|
||||
? metadata.projectName.trim()
|
||||
: `API逆向项目-${timestampForFilename()}`;
|
||||
project = createPipelineProject({
|
||||
name: rawProjectName,
|
||||
dicomFiles: preparedDicomFiles,
|
||||
stlFiles: preparedStlFiles,
|
||||
moduleStyles: readNestedObject(metadata.moduleStyles) as Record<string, Partial<ModuleStyleRecord>>,
|
||||
});
|
||||
|
||||
const files = getProjectDicomFiles(project);
|
||||
let activePose = normalizePipelinePose(metadata);
|
||||
const stretchConfig = readNestedObject(metadata.stretch ?? metadata.axisStretch);
|
||||
const stretchAxis = normalizePipelineAxis(
|
||||
stretchConfig.axis
|
||||
?? metadata.stretch
|
||||
?? metadata.axisStretch
|
||||
?? metadata.stretchAxis,
|
||||
);
|
||||
if (stretchAxis) {
|
||||
activePose = applyAxisStretchPose(project, files, activePose, stretchAxis);
|
||||
}
|
||||
project.modelPoses = normalizeModelPoses([{ id: 'default', name: 'API 位姿', pose: activePose }]);
|
||||
|
||||
const autoMatchConfig = readNestedObject(metadata.autoMatch);
|
||||
const autoMatchEnabled = readBooleanOption(autoMatchConfig.enabled ?? metadata.autoMatch, false);
|
||||
let autoMatchResult: ReturnType<typeof runProjectAutoMatch> | null = null;
|
||||
if (autoMatchEnabled) {
|
||||
autoMatchResult = runProjectAutoMatch(project, {
|
||||
...autoMatchConfig,
|
||||
pose: activePose,
|
||||
});
|
||||
activePose = autoMatchResult.bestPose;
|
||||
applyAutoMatchedPose(project, activePose);
|
||||
}
|
||||
|
||||
const shouldLock = readBooleanOption(metadata.lock ?? metadata.locked, false);
|
||||
const shouldRecord = readBooleanOption(metadata.recordProject ?? metadata.saveToProjectLibrary, false) || shouldLock;
|
||||
if (shouldLock) {
|
||||
const lockedAt = now();
|
||||
project.locked = true;
|
||||
project.lockedAt = lockedAt;
|
||||
project.unlockedAt = null;
|
||||
project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, lockedAt);
|
||||
touchProject(project, lockedAt);
|
||||
}
|
||||
|
||||
const exportConfig = parsePipelineExportConfig(metadata);
|
||||
let exportPayload: Buffer | null = null;
|
||||
let exportFilename = '';
|
||||
if (exportConfig.targets.length) {
|
||||
const exportProject = projectWithSegmentationResultStyles(project);
|
||||
const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`;
|
||||
exportPayload = createProjectExportBundle({
|
||||
project: exportProject,
|
||||
files,
|
||||
targets: exportConfig.targets,
|
||||
compressed: exportConfig.compressed,
|
||||
activePose,
|
||||
segmentationScope: exportConfig.segmentationScope,
|
||||
segmentationExportMode: exportConfig.segmentationExportMode,
|
||||
});
|
||||
exportFilename = `${exportBase}.zip`;
|
||||
fs.writeFileSync(path.join(exportDir, exportFilename), exportPayload);
|
||||
project.exportedMaskCount += exportConfig.targets.includes('segmentation') ? 1 : 0;
|
||||
touchProject(project);
|
||||
}
|
||||
|
||||
if (shouldRecord) {
|
||||
const state = readState();
|
||||
state.projects = state.projects.filter((candidate) => candidate.id !== project?.id);
|
||||
state.projects.push(project);
|
||||
writeState(state);
|
||||
persisted = true;
|
||||
}
|
||||
|
||||
if (exportPayload) {
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', contentDispositionAttachment(exportFilename));
|
||||
res.send(exportPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
recorded: shouldRecord,
|
||||
locked: project.locked,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
dicomCount: project.dicomCount,
|
||||
modelCount: project.modelCount,
|
||||
dicomPath: persisted ? project.dicomPath : null,
|
||||
modelPath: persisted ? project.modelPath : null,
|
||||
},
|
||||
activePose,
|
||||
autoMatch: autoMatchResult,
|
||||
export: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[reverse-pipeline] failed', {
|
||||
message: error instanceof Error ? error.message : error,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : '自动逆向处理失败' });
|
||||
}
|
||||
} finally {
|
||||
cleanupUploadedTempFiles(multerFiles);
|
||||
if (project && !persisted) {
|
||||
fs.rmSync(path.join(uploadDir, project.id), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/users', (_req, res) => {
|
||||
res.json(readState().users.map(publicUser));
|
||||
});
|
||||
@@ -3890,15 +4435,14 @@ async function startServer() {
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot: exportBase,
|
||||
});
|
||||
const filename = `${exportBase}.tar.gz`;
|
||||
const filename = `${exportBase}.zip`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
|
||||
touchProject(project);
|
||||
writeState(state);
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', contentDispositionAttachment(filename));
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2825,6 +2825,7 @@ export default function ReverseWorkspace({
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState({ active: false, percent: 0, phase: '' });
|
||||
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
||||
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
|
||||
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
|
||||
@@ -2840,9 +2841,17 @@ export default function ReverseWorkspace({
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const exportProgressTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||||
|
||||
const clearExportProgressTimer = () => {
|
||||
if (exportProgressTimerRef.current !== null) {
|
||||
window.clearInterval(exportProgressTimerRef.current);
|
||||
exportProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
const selectedItems = exportOptions
|
||||
.filter((option) => exportSelection[option.id])
|
||||
@@ -2854,18 +2863,46 @@ export default function ReverseWorkspace({
|
||||
|
||||
setExporting(true);
|
||||
setFusionError('');
|
||||
clearExportProgressTimer();
|
||||
setExportProgress({ active: true, percent: 8, phase: '准备导出压缩包' });
|
||||
exportProgressTimerRef.current = window.setInterval(() => {
|
||||
setExportProgress((current) => {
|
||||
if (!current.active || current.percent >= 88) {
|
||||
return current;
|
||||
}
|
||||
const step = current.percent < 50 ? 5 : 2;
|
||||
return { ...current, percent: Math.min(88, current.percent + step), phase: '正在生成导出内容' };
|
||||
});
|
||||
}, 450);
|
||||
try {
|
||||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
segmentationExportMode,
|
||||
moduleStyles,
|
||||
}, (progress) => {
|
||||
if (progress.percent <= 0) {
|
||||
return;
|
||||
}
|
||||
setExportProgress((current) => ({
|
||||
active: true,
|
||||
percent: Math.max(current.percent, Math.min(99, progress.percent)),
|
||||
phase: '正在下载压缩包',
|
||||
}));
|
||||
});
|
||||
window.setTimeout(() => setExporting(false), 900);
|
||||
clearExportProgressTimer();
|
||||
setExportProgress({ active: true, percent: 100, phase: '导出完成' });
|
||||
window.setTimeout(() => {
|
||||
setExporting(false);
|
||||
setExportProgress({ active: false, percent: 0, phase: '' });
|
||||
}, 900);
|
||||
setShowExportMenu(false);
|
||||
} catch (error) {
|
||||
clearExportProgressTimer();
|
||||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||
setExporting(false);
|
||||
setExportProgress({ active: true, percent: 100, phase: '导出失败' });
|
||||
window.setTimeout(() => setExportProgress({ active: false, percent: 0, phase: '' }), 1200);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2893,6 +2930,13 @@ export default function ReverseWorkspace({
|
||||
cutEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (exportProgressTimerRef.current !== null) {
|
||||
window.clearInterval(exportProgressTimerRef.current);
|
||||
exportProgressTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
|
||||
if (!project) {
|
||||
return false;
|
||||
@@ -3716,6 +3760,19 @@ export default function ReverseWorkspace({
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||||
{exportProgress.active && (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-[80]">
|
||||
<div className="h-1 w-full bg-slate-200/70">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${exportProgress.phase === '导出失败' ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${Math.max(3, Math.min(100, exportProgress.percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-6 top-2 rounded-full border border-emerald-100 bg-white/95 px-3 py-1 text-[11px] font-bold text-emerald-700 shadow-lg shadow-slate-950/10">
|
||||
{exportProgress.phase} {Math.round(exportProgress.percent)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus && (
|
||||
<>
|
||||
<style>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ProjectAssetImportProgress {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export type ProjectExportDownloadProgress = ProjectAssetImportProgress;
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
@@ -200,6 +202,83 @@ function triggerFileDownload(url: string) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null, fallback: string) {
|
||||
if (!header) {
|
||||
return fallback;
|
||||
}
|
||||
const encodedMatch = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
const plainMatch = header.match(/filename="([^"]+)"/i) ?? header.match(/filename=([^;]+)/i);
|
||||
return plainMatch?.[1]?.trim() || fallback;
|
||||
}
|
||||
|
||||
function downloadFileWithProgress(
|
||||
url: string,
|
||||
fallbackFilename: string,
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onprogress = (event) => {
|
||||
const total = event.lengthComputable ? event.total : 0;
|
||||
const loaded = event.loaded;
|
||||
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
onProgress?.({ loaded, total, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const filename = filenameFromContentDisposition(xhr.getResponseHeader('Content-Disposition'), fallbackFilename);
|
||||
triggerBlobDownload(xhr.response, filename);
|
||||
onProgress?.({ loaded: xhr.response?.size ?? 0, total: xhr.response?.size ?? 0, percent: 100 });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
let message = `请求失败:${xhr.status}`;
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result ?? ''));
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
}
|
||||
} catch {
|
||||
const text = String(reader.result ?? '').trim();
|
||||
if (text) {
|
||||
message = text.slice(0, 240);
|
||||
}
|
||||
}
|
||||
reject(new Error(message));
|
||||
};
|
||||
reader.onerror = () => reject(new Error(`请求失败:${xhr.status}`));
|
||||
reader.readAsText(xhr.response);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络连接中断,导出失败'));
|
||||
xhr.onabort = () => reject(new Error('导出已取消'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||
if (pose) {
|
||||
params.set('pose', JSON.stringify(pose));
|
||||
@@ -232,7 +311,13 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
|
||||
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; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
|
||||
export async function downloadProjectExportBundle(
|
||||
projectId: string,
|
||||
targets: ProjectExportTarget[],
|
||||
format: 'nii' | 'nii.gz' = 'nii.gz',
|
||||
options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {},
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
@@ -241,7 +326,11 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
appendModuleStyles(params, options.moduleStyles);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
await downloadFileWithProgress(
|
||||
`/api/projects/${projectId}/export-bundle?${params.toString()}`,
|
||||
`${projectId}-export.zip`,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadDicomArchive(projectId: string) {
|
||||
|
||||
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 实现方案-2026-05-25-14-00-24
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 将“导出项目及结果”从 tar.gz 改为 ZIP,消除 `PaxHeaders` 暴露问题。
|
||||
2. 统一 ZIP 内部结构:根目录直接包含 `manifest.json`、`pose/`、`segmentation/`、`segmentation-parts/`、`dicom/`、`stl/` 等目录。
|
||||
3. 分类分割导出时使用构件类别名生成 `.nii.gz` 文件名。
|
||||
4. 前端导出时显示顶部细进度条,并通过下载进度回调更新。
|
||||
5. 新增自动化 API,支持 DICOM/STL 上传、位姿参数、轴向自动拉伸、可选自动匹配、可选导出、可选记录项目库和锁定。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-14-00-24.md`
|
||||
- `工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/测试方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 使用项目现有 `adm-zip` 依赖生成 ZIP,而不是继续维护手写 tar.gz 的 PAX 扩展路径。
|
||||
- 在后端导出包中新增根级 `manifest.json`,记录项目、导出目标、格式、分割范围、分类导出模式、位姿等信息。
|
||||
- 后端分类导出命名为 `segmentation-parts/{类别名}.nii.gz`,若重名则自动追加序号。
|
||||
- 前端下载函数改为 `XMLHttpRequest`,支持 `onprogress` 并从 `Content-Disposition` 解析文件名。
|
||||
- `ReverseWorkspace` 增加 `exportProgress` 状态和顶部 fixed 细进度条;服务端生成阶段使用平滑进度,下载阶段使用真实进度。
|
||||
- 新增 `POST /api/reverse-pipeline`,接收 multipart 的 `dicomFiles`、`stlFiles`、可选 `metadata` JSON;复用现有资产解包、NIfTI 导出和自动匹配函数。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 调整服务端项目导出包创建逻辑,返回 ZIP buffer 并改为 `.zip` 下载。
|
||||
2. 更新导出包内部文件命名与 manifest 内容。
|
||||
3. 更新前端下载 API,加入进度回调。
|
||||
4. 在逆向工作区渲染导出进度条并接入进度回调。
|
||||
5. 增加批处理 API 的参数解析、项目创建、资产写入、位姿归一化、自动拉伸、自动匹配、导出、记录与锁定逻辑。
|
||||
6. 执行 TypeScript 构建和必要接口验证。
|
||||
7. 重新部署到 `tmux` 会话 `revoxelseg-dicom` 并验证本地与公网。
|
||||
8. 追加经验记录,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 单文件 `export-nifti` 和 `dicom-archive` 接口保持原行为。
|
||||
- 若 ZIP 导出出现问题,可回滚 `createProjectExportBundle` 和 `/export-bundle` 响应相关修改。
|
||||
- 新增 API 为独立路径,不影响现有页面流程。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端:导出包结构、批处理 API、辅助函数。
|
||||
- 前端:下载 helper 和导出进度 UI。
|
||||
- 工程分析文档:三件套与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 修改完成后运行 `npm run build`。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 验证 `http://127.0.0.1:4000/api/health`、`http://127.0.0.1:4000/`、`https://revoxel.huijutec.cn/`。
|
||||
- commit message 包含 `2026-05-25-14-00-24` 和简要描述,并推送到 Gitea。
|
||||
50
工程分析/测试方案-2026-05-25-14-00-24.md
Normal file
50
工程分析/测试方案-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 测试方案-2026-05-25-14-00-24
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-14-00-24.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 TypeScript 类型是否通过 Vite 构建。
|
||||
- 检查新增导出和 API 函数没有破坏现有 `ProjectExportTarget`、`ModelPoseValue`、`AutoMatchRequest` 使用。
|
||||
- 检查 ZIP 内路径不包含项目名根目录。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行:
|
||||
- `npm run build`
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 请求 `/api/projects/:projectId/export-bundle` 导出分割、位姿等内容,确认响应为 `.zip`。
|
||||
- 使用 `unzip -l` 查看 ZIP,确认存在 `manifest.json`、`segmentation/labels.json`,分类模式下存在 `segmentation-parts/{类别名}.nii.gz`。
|
||||
- 前端导出按钮点击后确认顶部进度条出现并完成后自动消失。
|
||||
- 新增 `/api/reverse-pipeline` 在默认不导出时返回 JSON;在指定导出内容时返回 ZIP。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 分割导出应继续使用当前 DICOM 体数据和当前位姿。
|
||||
- 可见类别导出时仅导出可见构件;所有类别导出时导出全部构件。
|
||||
- 分类导出的单构件 `.nii.gz` 文件命名可直接对应类别,类别映射 JSON 同包提供。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom` 中的服务。
|
||||
- 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `https://revoxel.huijutec.cn/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- `git status --short` 确认本次改动范围。
|
||||
- commit message 包含 `2026-05-25-14-00-24`。
|
||||
- 推送至 Gitea 后确认 `origin/main` 更新。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- ZIP 下载文件名需要兼容中文项目名。
|
||||
- 批处理 API 的临时项目目录需要在不入库时清理,避免磁盘堆积。
|
||||
- 自动匹配失败不能导致已上传临时文件残留或项目状态异常。
|
||||
- 导出进度条不能遮挡和阻塞其他功能。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1977,3 +1977,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续调整自动匹配参数上限时,要同时检查前端控件、API 类型、服务端归一化、默认值和运行耗时提示。对可能显著增加耗时的参数,只提高上限不改变默认值;若用户常用高上限,应补充进度、取消和超时保护。
|
||||
|
||||
## 2026-05-25-14-00-24 项目导出包不应使用会暴露 PAX 头的 tar.gz
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户在 Windows 解压“导出项目及结果”压缩包时,只能看到 `entries`、`PaxHeaders` 和无扩展名编号文件,无法直接看到 JSON、分割类别映射和 `.nii.gz` 分割结果。同时分类分割导出的文件名带编号和 `-label` 后缀,不利于后续按类别处理。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
旧导出包使用手写 tar.gz。tar 路径一旦包含中文项目名或较长路径,服务端会写入 PAX 扩展头;部分 Windows 解压工具会把 PAX 扩展头当成普通目录展示,并把真实文件显示成 `entries/000001` 之类的中转名。压缩包内部还额外套了项目名根目录,而下载文件名本身已经包含项目名。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
将“项目及结果导出”改为 ZIP,包内不再套项目名根目录,固定输出 `manifest.json`、`pose/pose.json`、`segmentation/labels.json`、`segmentation/label.nii.gz` 或 `segmentation-parts/{类别名}.nii.gz`、`dicom/image.nii.gz`、`stl/{原文件名}`。前端下载改为 XHR blob 下载,接入顶部导出进度条。新增 `POST /api/reverse-pipeline`,支持上传 DICOM/STL、应用旋转/平移/缩放/镜像/轴向自动拉伸、可选自动匹配、可选导出、可选记录项目库和锁定。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
面向 Windows 用户直接查看的复杂导出包优先使用 ZIP,不要用需要 PAX 扩展的 tar.gz;如果必须使用 tar.gz,必须避免非 ASCII 和长路径,或确认目标解压器正确隐藏 PAX。导出包内部结构应以机器可读和人工可读为准,分类分割文件名保持类别名,映射关系单独放入 `labels.json`。
|
||||
|
||||
54
工程分析/需求分析-2026-05-25-14-00-24.md
Normal file
54
工程分析/需求分析-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 需求分析-2026-05-25-14-00-24
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-14-00-24
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户反馈“导出项目及结果”的压缩包内容异常,只能看到 `entries`、`PaxHeaders` 和无扩展名编号文件,缺少可直接识别的 JSON、分割类别映射和 `.nii.gz` 分割结果。用户希望压缩包内不再套项目名目录,内容格式统一;分类导出的 `.nii.gz` 直接使用类别名作为文件名。同时,点击导出后需要在页面最上方显示不影响其他操作的导出进度条。最后需要提供一个可调用 API:给定 DICOM 和 STL,能够指定 X/Y/Z 轴拉伸、旋转、平移、缩放、镜像、是否自动配准、导出内容、是否记录项目库、是否上锁。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 让项目导出包在 Windows 常见解压工具中可直接识别文件结构,不出现 PAX 头目录。
|
||||
- 让导出包内的分割结果、类别映射、位姿和模型文件按稳定目录命名,便于后续处理脚本读取。
|
||||
- 让用户点击导出后能获得明确进度反馈,同时不阻塞逆向工作区其他功能。
|
||||
- 为外部系统或脚本提供一条“上传 DICOM/STL -> 应用位姿/自动配准 -> 可选记录/锁定 -> 可选导出”的自动化接口。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- 当前项目库中的 DICOM、STL、构件可见性、构件 ID、位姿和导出选项。
|
||||
- 批处理 API 的 multipart 文件或 JSON 资产、位姿参数、自动匹配参数、导出目标、项目记录与锁定开关。
|
||||
- 输出:
|
||||
- ZIP 格式项目导出包。
|
||||
- 顶部导出进度条。
|
||||
- 新增批处理 API 的 JSON 响应或 ZIP 下载响应。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/server.ts`:项目导出包生成、批处理 API、导出响应类型与文件名。
|
||||
- `WebSite/src/lib/api.ts`:项目导出包下载进度回调。
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`:顶部导出进度条和导出调用。
|
||||
- `工程分析/经验记录.md`:完成后追加本次经验。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 继续保持单独 DICOM 原始影像下载接口兼容,不主动改动其 tar.gz 行为。
|
||||
- 项目导出包内不再以项目名作为根目录,因为下载文件名已经包含项目标识。
|
||||
- 分类分割导出时,`.nii.gz` 文件名优先使用构件类别名。
|
||||
- API 默认不导出任何内容,避免未明确指定时产生大文件下载。
|
||||
- 当前系统的位姿模型为统一缩放;X/Y/Z 轴拉伸沿用现有工作区语义,即选择轴向后计算统一缩放以适配 DICOM 尺寸,而不是引入三轴非等比例缩放字段。
|
||||
|
||||
## 风险点
|
||||
|
||||
- ZIP 替代 tar.gz 会改变导出包扩展名和 MIME 类型,需要前端下载逻辑正确读取服务端文件名。
|
||||
- 导出进度条如果只依赖响应下载进度,在服务端生成阶段可能没有真实百分比,需要结合平滑进度和下载事件。
|
||||
- 批处理 API 上传文件可能较大,需要复用现有导入解包逻辑并在临时项目不入库时清理文件。
|
||||
- 自动配准可能因未指定骨骼构件或 DICOM/STL 不完整而失败,应返回可读错误。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 用户当前要修复的是“导出项目及结果”按钮生成的压缩包,不包括“下载原始 DICOM 归档”。
|
||||
- 外部调用 API 可接受 multipart/form-data,并可通过 `metadata` JSON 字段传入复杂参数。
|
||||
- 当 `recordProject=false` 且 `lock=true` 时,锁定没有项目库持久对象可附着,因此仅在 `recordProject=true` 或 `lock=true` 时保留项目记录。
|
||||
Reference in New Issue
Block a user