2026-05-20-02-32-47 支持NII导出包与分割类别范围
This commit is contained in:
@@ -9,6 +9,8 @@ import { fileURLToPath } from 'node:url';
|
||||
type ProjectStatus = 'pending' | 'completed' | 'processing';
|
||||
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
||||
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
||||
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
|
||||
type SegmentationExportScope = 'all' | 'visible';
|
||||
|
||||
interface ModuleStyleRecord {
|
||||
visible: boolean;
|
||||
@@ -772,7 +774,20 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
|
||||
});
|
||||
}
|
||||
|
||||
function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue) {
|
||||
function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord {
|
||||
return project.moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: defaultModuleColors[index % defaultModuleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function isModuleIncludedForExport(style: ModuleStyleRecord, scope: SegmentationExportScope) {
|
||||
return scope === 'all' || style.visible !== false;
|
||||
}
|
||||
|
||||
function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue, scope: SegmentationExportScope = 'visible') {
|
||||
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);
|
||||
@@ -803,14 +818,9 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
|
||||
|
||||
(project.stlFiles ?? []).forEach((fileName, index) => {
|
||||
const payload = previews[fileName];
|
||||
const style = project.moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: defaultModuleColors[index % defaultModuleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
const style = getModuleStyle(project, fileName, index);
|
||||
|
||||
if (!payload || style.visible === false) {
|
||||
if (!payload || !isModuleIncludedForExport(style, scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -872,6 +882,55 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p
|
||||
return data;
|
||||
}
|
||||
|
||||
function createSegmentationLabelMetadata(project: ProjectRecord, scope: SegmentationExportScope, activePose?: ModelPoseValue) {
|
||||
const labels = (project.stlFiles ?? [])
|
||||
.map((fileName, index) => {
|
||||
const style = getModuleStyle(project, fileName, index);
|
||||
if (!isModuleIncludedForExport(style, scope)) {
|
||||
return null;
|
||||
}
|
||||
const name = fileName.replace(/\.stl$/i, '');
|
||||
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
|
||||
|
||||
return {
|
||||
label,
|
||||
partId: label,
|
||||
name,
|
||||
categoryName: name,
|
||||
className: name,
|
||||
fileName,
|
||||
color: style.color,
|
||||
opacity: style.opacity,
|
||||
visible: style.visible !== false,
|
||||
};
|
||||
})
|
||||
.filter((item): item is {
|
||||
label: number;
|
||||
partId: number;
|
||||
name: string;
|
||||
categoryName: string;
|
||||
className: string;
|
||||
fileName: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
} => Boolean(item));
|
||||
|
||||
return Buffer.from(JSON.stringify({
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
dicomPath: project.dicomPath,
|
||||
modelPath: project.modelPath,
|
||||
},
|
||||
generatedAt: now(),
|
||||
segmentationScope: scope,
|
||||
activePose: activePose ?? null,
|
||||
labels,
|
||||
note: 'Label values correspond to ReVoxelSeg STL component hierarchy partId values.',
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function parseModelPoseQuery(raw: unknown) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return undefined;
|
||||
@@ -884,13 +943,37 @@ function parseModelPoseQuery(raw: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function createNiftiExport(project: ProjectRecord, files: string[], target: 'dicom' | 'segmentation', compressed: boolean, pose?: ModelPoseValue) {
|
||||
function parseSegmentationScope(raw: unknown): SegmentationExportScope {
|
||||
return raw === 'all' ? 'all' : 'visible';
|
||||
}
|
||||
|
||||
function parseExportTargets(raw: unknown): ProjectExportTarget[] {
|
||||
const values = typeof raw === 'string' ? raw.split(',') : [];
|
||||
const targets = values.filter((value): value is ProjectExportTarget => (
|
||||
value === 'dicom' || value === 'segmentation' || value === 'pose'
|
||||
));
|
||||
return [...new Set(targets)];
|
||||
}
|
||||
|
||||
function createNiftiExport(
|
||||
project: ProjectRecord,
|
||||
files: string[],
|
||||
target: 'dicom' | 'segmentation',
|
||||
compressed: boolean,
|
||||
pose?: ModelPoseValue,
|
||||
segmentationScope: SegmentationExportScope = 'visible',
|
||||
) {
|
||||
const volume = readDicomHuVolume(files);
|
||||
if (target === 'dicom') {
|
||||
return createNiftiBuffer(volume, volume.data, 'dicom', compressed);
|
||||
}
|
||||
|
||||
return createNiftiBuffer(volume, createSegmentationData(project, volume, pose ?? defaultModelPose), 'segmentation', compressed);
|
||||
return createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, pose ?? defaultModelPose, segmentationScope),
|
||||
'segmentation',
|
||||
compressed,
|
||||
);
|
||||
}
|
||||
|
||||
function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
|
||||
@@ -909,6 +992,64 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createProjectExportBundle({
|
||||
project,
|
||||
files,
|
||||
targets,
|
||||
compressed,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
files: string[];
|
||||
targets: ProjectExportTarget[];
|
||||
compressed: boolean;
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
}) {
|
||||
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
|
||||
const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
|
||||
const volume = needsVolume ? readDicomHuVolume(files) : null;
|
||||
const format = compressed ? 'nii.gz' : 'nii';
|
||||
const exportRoot = `${project.id}-nifti-export`;
|
||||
|
||||
if (targets.includes('dicom') && volume) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-dicom-image.${format}`,
|
||||
data: createNiftiBuffer(volume, volume.data, 'dicom', compressed),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
});
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
|
||||
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
|
||||
});
|
||||
}
|
||||
|
||||
if (targets.includes('pose')) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-pose-data.json`,
|
||||
data: createPoseExport(project, activePose),
|
||||
});
|
||||
}
|
||||
|
||||
if (!entries.length) {
|
||||
throw new Error('未选择可导出的内容');
|
||||
}
|
||||
|
||||
return createTarGz(entries);
|
||||
}
|
||||
|
||||
function findProject(state: AppState, projectId: string) {
|
||||
return state.projects.find((candidate) => candidate.id === projectId);
|
||||
}
|
||||
@@ -1443,14 +1584,12 @@ function createTarEntryHeader(name: string, size: number, mtime: number) {
|
||||
return header;
|
||||
}
|
||||
|
||||
function createDicomTarGz(files: string[]) {
|
||||
function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
files.forEach((fileName) => {
|
||||
const filePath = path.join(dicomDir, fileName);
|
||||
const stat = fs.statSync(filePath);
|
||||
const data = fs.readFileSync(filePath);
|
||||
chunks.push(createTarEntryHeader(`Head_CT_DICOM/${fileName}`, data.length, stat.mtimeMs / 1000));
|
||||
entries.forEach((entry) => {
|
||||
const data = entry.data;
|
||||
chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000));
|
||||
chunks.push(data);
|
||||
const remainder = data.length % 512;
|
||||
if (remainder > 0) {
|
||||
@@ -1462,6 +1601,18 @@ function createDicomTarGz(files: string[]) {
|
||||
return zlib.gzipSync(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
function createDicomTarGz(files: string[]) {
|
||||
return createTarGz(files.map((fileName) => {
|
||||
const filePath = path.join(dicomDir, fileName);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
name: `Head_CT_DICOM/${fileName}`,
|
||||
data: fs.readFileSync(filePath),
|
||||
mtime: stat.mtimeMs / 1000,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function estimateSliceSpacingFromAttributes(attributes: DicomAttributes[]) {
|
||||
const diffs: number[] = [];
|
||||
for (let index = 1; index < attributes.length; index += 1) {
|
||||
@@ -1889,6 +2040,7 @@ async function startServer() {
|
||||
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
|
||||
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
|
||||
const activePose = parseModelPoseQuery(req.query.pose);
|
||||
const segmentationScope = parseSegmentationScope(req.query.segmentationScope);
|
||||
|
||||
try {
|
||||
if (target === 'pose') {
|
||||
@@ -1904,7 +2056,7 @@ async function startServer() {
|
||||
const files = getProjectDicomFiles(project);
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
const payload = createNiftiExport(project, files, target, compressed, activePose);
|
||||
const payload = createNiftiExport(project, files, target, compressed, activePose, segmentationScope);
|
||||
const suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label';
|
||||
const filename = `${project.id}-${suffix}.${format}`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||
@@ -1919,6 +2071,49 @@ async function startServer() {
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/projects/:projectId/export-bundle', (req, res) => {
|
||||
const state = readState();
|
||||
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = parseExportTargets(req.query.targets);
|
||||
if (!targets.length) {
|
||||
res.status(400).json({ message: '请至少选择一个导出内容' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activePose = parseModelPoseQuery(req.query.pose);
|
||||
const segmentationScope = parseSegmentationScope(req.query.segmentationScope);
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
|
||||
try {
|
||||
const files = getProjectDicomFiles(project);
|
||||
const payload = createProjectExportBundle({
|
||||
project,
|
||||
files,
|
||||
targets,
|
||||
compressed,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
});
|
||||
const filename = `${project.id}-nifti-export.tar.gz`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
|
||||
writeState(state);
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : '导出包生成失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/export-nifti', (req, res) => handleProjectExport(req, res));
|
||||
app.get('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation'));
|
||||
app.post('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation'));
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||
import { api, downloadMask, downloadSelectedProjectExports, ProjectExportTarget } from '../lib/api';
|
||||
import { api, downloadMask, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
|
||||
|
||||
interface ModelPreviewPayload {
|
||||
fileName: string;
|
||||
@@ -83,6 +83,10 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
];
|
||||
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
|
||||
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
|
||||
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
|
||||
];
|
||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const fusionBaseExtent = 4.6;
|
||||
const axisInsetLength = 17;
|
||||
@@ -1925,6 +1929,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
});
|
||||
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
@@ -1936,7 +1941,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadMask(projectId, format, modelPose);
|
||||
await downloadMask(projectId, format, modelPose, segmentationExportScope);
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||
} finally {
|
||||
@@ -1956,8 +1961,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
setExporting(true);
|
||||
setFusionError('');
|
||||
try {
|
||||
await downloadSelectedProjectExports(projectId, selectedItems, 'nii.gz', { pose: modelPose });
|
||||
window.setTimeout(() => setExporting(false), selectedItems.length * 220 + 200);
|
||||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
});
|
||||
window.setTimeout(() => setExporting(false), 900);
|
||||
setShowExportMenu(false);
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||
@@ -2329,12 +2337,38 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{exportSelection.segmentation && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationScopeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setSegmentationExportScope(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
segmentationExportScope === option.id
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${segmentationExportScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleExportSelected}
|
||||
disabled={exporting}
|
||||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
导出所选
|
||||
导出所选压缩包
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types';
|
||||
|
||||
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
|
||||
export type SegmentationExportScope = 'all' | 'visible';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
@@ -89,26 +90,32 @@ function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose) {
|
||||
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose, segmentationScope: SegmentationExportScope = 'visible') {
|
||||
const params = new URLSearchParams({ format });
|
||||
appendPose(params, pose);
|
||||
params.set('segmentationScope', segmentationScope);
|
||||
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 } = {}) {
|
||||
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) {
|
||||
const params = new URLSearchParams({ target, format });
|
||||
if (target !== 'dicom') {
|
||||
appendPose(params, options.pose);
|
||||
}
|
||||
if (target === 'segmentation') {
|
||||
params.set('segmentationScope', options.segmentationScope ?? 'visible');
|
||||
}
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadSelectedProjectExports(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) {
|
||||
targets.forEach((target, index) => {
|
||||
window.setTimeout(() => {
|
||||
void downloadProjectExport(projectId, target, format, options);
|
||||
}, index * 180);
|
||||
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
segmentationScope: options.segmentationScope ?? 'visible',
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadDicomArchive(projectId: string) {
|
||||
|
||||
68
工程分析/实现方案-2026-05-20-02-32-47.md
Normal file
68
工程分析/实现方案-2026-05-20-02-32-47.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 实现方案-2026-05-20-02-32-47
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-02-32-47.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将顶部“导出全部 NII.GZ”改为一次性下载 `.tar.gz` 导出包;分割影像导出时自动附带 label/category 映射 JSON,并支持“所有类别/可见类别”范围选择。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-02-32-47.md`
|
||||
- `工程分析/实现方案-2026-05-20-02-32-47.md`
|
||||
- `工程分析/测试方案-2026-05-20-02-32-47.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端扩展分割生成参数:
|
||||
- 增加 `SegmentationExportScope = all | visible`。
|
||||
- `createSegmentationData` 按 scope 决定是否跳过隐藏构件。
|
||||
2. 后端新增类别映射 JSON:
|
||||
- 根据 `project.stlFiles` 和 `project.moduleStyles` 生成 label/name/fileName/color/opacity/visible。
|
||||
- scope 为 visible 时只包含可见构件。
|
||||
3. 后端新增导出包接口:
|
||||
- `GET /api/projects/:projectId/export-bundle`
|
||||
- 参数:`targets=dicom,segmentation,pose`、`format=nii.gz`、`pose=...`、`segmentationScope=visible|all`
|
||||
- 使用现有 tar header 工具封装为 `.tar.gz`。
|
||||
4. 前端 API:
|
||||
- 新增 `downloadProjectExportBundle`。
|
||||
- 保留单个分割 NII/NII.GZ 下载兼容。
|
||||
5. 前端菜单:
|
||||
- “导出所选”改为下载单个压缩包。
|
||||
- 增加分割类别范围的 segmented control。
|
||||
- 仅选中分割影像时显示类别范围选项。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 新增后端 tar entry 通用函数和 bundle 生成函数。
|
||||
- 修改 NIfTI 分割生成函数支持 scope。
|
||||
- 新增 label map JSON 生成逻辑。
|
||||
- 调整前端 API 和导出菜单。
|
||||
- 执行 `npm run lint`、`npm run build`。
|
||||
- 用临时/正式服务验证 bundle 接口返回 `.tar.gz`,并检查包内文件名。
|
||||
- 追加经验记录、commit、push、部署。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- `/export-nifti` 与 `/export-mask` 保持可用。
|
||||
- 单独分割导出默认沿用 visible scope。
|
||||
- 若 bundle 导出异常,可回滚到多文件直链下载。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:新增 bundle、分割 scope、类别映射 JSON。
|
||||
- `api.ts`:新增 bundle 下载 API。
|
||||
- `ReverseWorkspace.tsx`:导出菜单增加 scope 选择并调用 bundle。
|
||||
- 工程分析文档:新增本次文档,更新经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message:`2026-05-20-02-32-47 支持NII导出包与分割类别范围`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main`,并使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。
|
||||
62
工程分析/测试方案-2026-05-20-02-32-47.md
Normal file
62
工程分析/测试方案-2026-05-20-02-32-47.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 测试方案-2026-05-20-02-32-47
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-20-02-32-47.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run lint`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 顶部“导出全部 NII.GZ”选择多个内容后只触发一个压缩包下载。
|
||||
- 选中分割影像时,菜单显示“导出可见类别/导出所有类别”选项。
|
||||
- bundle 中包含所选 DICOM NIfTI、分割 NIfTI、位姿 JSON。
|
||||
- 若包含分割影像,bundle 中同时包含 `segmentation-labels.json`。
|
||||
- 类别 JSON 中 label/partId/name/fileName/color/opacity/visible 与项目构件层级一致。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- DICOM NIfTI 仍为真实 DICOM 体数据同维同距。
|
||||
- 分割 NIfTI 仍使用当前模型位姿。
|
||||
- visible scope 下隐藏构件不进入分割图和类别 JSON。
|
||||
- all scope 下隐藏构件仍进入分割图和类别 JSON。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证:
|
||||
- `curl http://127.0.0.1:4000/api/health`
|
||||
- `curl -I http://127.0.0.1:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 显式暂存本次相关代码和文档。
|
||||
- 创建包含时间戳和描述的 commit。
|
||||
- 推送到 Gitea `origin/main`。
|
||||
|
||||
## 实测结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;仅保留 Vite chunk size 提醒。
|
||||
- 临时服务 `127.0.0.1:4100` 验证 `targets=segmentation,pose&segmentationScope=visible`:HTTP 200,返回 `.tar.gz`。
|
||||
- visible bundle 包内包含:
|
||||
- `head-ct-demo-segmentation-label.nii.gz`
|
||||
- `head-ct-demo-segmentation-labels.json`
|
||||
- `head-ct-demo-pose-data.json`
|
||||
- `segmentation-labels.json` 验证包含 `segmentationScope=visible`、`label/partId/name/categoryName/className/fileName/color/opacity/visible` 字段。
|
||||
- 临时服务验证 `targets=dicom,segmentation,pose&segmentationScope=all`:HTTP 200,返回约 75.90 MB `.tar.gz`。
|
||||
- all bundle 包内包含 DICOM NIfTI、分割 NIfTI、分割 labels JSON、位姿 JSON。
|
||||
- all labels JSON 验证包含隐藏构件,隐藏项 `visible=false` 仍保留。
|
||||
- bundle 内分割 NIfTI 头验证:`512x512x300`,datatype `2`,bitpix `8`,最大标签 `9`。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 避免重新引入 `URL.createObjectURL(blob)` 下载。
|
||||
- 避免提交历史工程文档删除状态。
|
||||
- 大体数据打包接口要捕获异常并返回明确错误。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1099,3 +1099,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是三维视图中的方向、法向、切面或平移提示,都应从 Three.js 真实对象矩阵或统一坐标变换链路推导,不能手写静态示意。若该提示会随拖拽视角变化,还必须包含场景根节点和相机投影。
|
||||
|
||||
## 2026-05-20-02-32-47 分割影像与类别元数据必须同包同源
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求“导出全部 NII.GZ”改成压缩包形式,同时分割影像必须附带不同类别 ID 与名称的对应 JSON,并且分割范围可选所有类别或可见类别。如果 NIfTI 和 JSON 分开下载,或二者使用不同筛选条件,后续在 ITK-SNAP 查看时容易无法追溯标签语义。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
旧前端对多个导出目标采用连续触发多个直链下载,缺少一个原子化导出包;旧分割生成逻辑默认跳过隐藏构件,也没有把实际参与导出的 label/partId/name/fileName/color 等元数据写入侧车文件。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
新增 `/api/projects/:projectId/export-bundle`,将 DICOM NIfTI、分割 NIfTI、位姿 JSON 和分割 labels JSON 放入同一个 `.tar.gz`。分割生成函数和 labels JSON 生成函数共用 `segmentationScope=visible|all`,确保可见/全量筛选逻辑一致;labels JSON 中记录 label、partId、name、categoryName、className、fileName、color、opacity、visible 和 activePose。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
任何分割影像导出都应同时考虑语义侧车文件,并保证侧车元数据与实际 mask 标签来自同一批样式和筛选条件。多文件导出优先做成一个后端归档包,避免浏览器多下载顺序、丢文件或元数据错配。
|
||||
|
||||
56
工程分析/需求分析-2026-05-20-02-32-47.md
Normal file
56
工程分析/需求分析-2026-05-20-02-32-47.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 需求分析-2026-05-20-02-32-47
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-20-02-32-47
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求优化“导出全部 NII.GZ”:
|
||||
|
||||
- 选择导出全部时,改为压缩包形式一次导出。
|
||||
- 如果选择分割影像,需要同时导出不同类别 ID 与名称/类别名对应的 JSON 文件。
|
||||
- 分割影像导出范围可选择“所有类别”或“可见类别”。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 避免多文件连续触发下载,形成一个可归档、可转移的导出包。
|
||||
- 让分割影像与类别语义元数据成对导出,便于 ITK-SNAP 查看后再对照系统内 STL 构件层级。
|
||||
- 保留“只导出当前可见构件”的轻量检查场景,同时支持“导出全部构件”的完整交付场景。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- 导出内容选择:DICOM 原始影像、分割影像、位姿数据。
|
||||
- 分割类别范围:所有类别或可见类别。
|
||||
- 当前模型位姿。
|
||||
- 输出:
|
||||
- 一个 `.tar.gz` 导出包。
|
||||
- 包内按选择包含 DICOM NIfTI、分割 NIfTI、位姿 JSON。
|
||||
- 若包含分割影像,同时包含类别映射 JSON。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- 本次工程分析文档和经验记录。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- NIfTI 导出仍必须使用真实 DICOM 维度、spacing 和当前模型位姿。
|
||||
- 分割类别范围不能影响 DICOM 原始影像和位姿数据导出。
|
||||
- 类别 JSON 必须与实际导出的分割标签值一致。
|
||||
- 前端仍使用后端直链下载,避免重新引入 blob URL。
|
||||
|
||||
## 风险点
|
||||
|
||||
- DICOM NIfTI 和分割 NIfTI 都可能较大,包生成会有一定耗时。
|
||||
- 当前项目已有 tar.gz 归档工具,若强行实现 zip 可能引入额外复杂度;本轮默认使用 `.tar.gz` 压缩包。
|
||||
- “可见类别”需要读取项目持久化的 `moduleStyles.visible`,不能只看前端临时状态。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “压缩包形式”按项目现有归档方式实现为 `.tar.gz`。
|
||||
- 顶部“导出全部 NII.GZ”使用压缩包;右侧单独 NII/NII.GZ 导出按钮继续导出单个分割 NIfTI。
|
||||
- 分割类别范围默认为“可见类别”,符合当前右侧映射视图的显示语义。
|
||||
Reference in New Issue
Block a user