2026-05-20-01-38-33 完善NII导出与位姿持久化
This commit is contained in:
@@ -17,6 +17,22 @@ interface ModuleStyleRecord {
|
|||||||
partId: number;
|
partId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelPoseValue {
|
||||||
|
rotateX: number;
|
||||||
|
rotateY: number;
|
||||||
|
rotateZ: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
translateZ: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelPoseRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pose: ModelPoseValue;
|
||||||
|
}
|
||||||
|
|
||||||
interface UserRecord {
|
interface UserRecord {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +57,7 @@ interface ProjectRecord {
|
|||||||
exportedMaskCount: number;
|
exportedMaskCount: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
moduleStyles: Record<string, ModuleStyleRecord>;
|
moduleStyles: Record<string, ModuleStyleRecord>;
|
||||||
|
modelPoses: ModelPoseRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionRecord {
|
interface SessionRecord {
|
||||||
@@ -79,6 +96,15 @@ const dicomVolumeCache = new Map<DicomDisplayMode, {
|
|||||||
}>();
|
}>();
|
||||||
const modelPreviewCache = new Map<string, unknown>();
|
const modelPreviewCache = new Map<string, unknown>();
|
||||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||||
|
const defaultModelPose: ModelPoseValue = {
|
||||||
|
rotateX: 0,
|
||||||
|
rotateY: 0,
|
||||||
|
rotateZ: 0,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
translateZ: 0,
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
interface DicomAttributes {
|
interface DicomAttributes {
|
||||||
patientName: string;
|
patientName: string;
|
||||||
@@ -186,6 +212,62 @@ function buildModuleStyles(
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultModelPoses(): ModelPoseRecord[] {
|
||||||
|
return [
|
||||||
|
{ id: 'default', name: '默认', pose: { ...defaultModelPose } },
|
||||||
|
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||||||
|
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): ModelPoseValue {
|
||||||
|
const read = (key: keyof ModelPoseValue, fallback: number, min: number, max: number) => {
|
||||||
|
const nextValue = value?.[key];
|
||||||
|
return typeof nextValue === 'number' && Number.isFinite(nextValue)
|
||||||
|
? clampNumber(nextValue, min, max)
|
||||||
|
: fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180),
|
||||||
|
rotateY: read('rotateY', defaultModelPose.rotateY, -180, 180),
|
||||||
|
rotateZ: read('rotateZ', defaultModelPose.rotateZ, -180, 180),
|
||||||
|
translateX: read('translateX', defaultModelPose.translateX, -2, 2),
|
||||||
|
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
||||||
|
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
||||||
|
scale: read('scale', defaultModelPose.scale, 0.5, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelPoseRecord(record: Partial<ModelPoseRecord> | undefined, fallback: ModelPoseRecord): ModelPoseRecord {
|
||||||
|
const id = typeof record?.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 80) : fallback.id;
|
||||||
|
const name = typeof record?.name === 'string' && record.name.trim() ? record.name.trim().slice(0, 80) : fallback.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
pose: normalizeModelPoseValue(record?.pose ?? fallback.pose),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelPoses(existing?: Partial<ModelPoseRecord>[]) {
|
||||||
|
const defaults = defaultModelPoses();
|
||||||
|
const incoming = Array.isArray(existing)
|
||||||
|
? existing
|
||||||
|
.map((record, index) => normalizeModelPoseRecord(record, defaults[index] ?? {
|
||||||
|
id: `pose-${index}`,
|
||||||
|
name: `位姿${index + 1}`,
|
||||||
|
pose: defaultModelPose,
|
||||||
|
}))
|
||||||
|
.filter((record) => record.id)
|
||||||
|
: [];
|
||||||
|
const incomingById = new Map(incoming.map((record) => [record.id, record]));
|
||||||
|
const normalizedDefaults = defaults.map((pose) => incomingById.get(pose.id) ?? pose);
|
||||||
|
const custom = incoming.filter((record) => !defaults.some((pose) => pose.id === record.id));
|
||||||
|
|
||||||
|
return [...normalizedDefaults, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultProject(): ProjectRecord {
|
function buildDefaultProject(): ProjectRecord {
|
||||||
const stlFiles = listFiles(modelDir, '.stl');
|
const stlFiles = listFiles(modelDir, '.stl');
|
||||||
|
|
||||||
@@ -204,6 +286,7 @@ function buildDefaultProject(): ProjectRecord {
|
|||||||
exportedMaskCount: 0,
|
exportedMaskCount: 0,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
moduleStyles: buildModuleStyles(stlFiles),
|
moduleStyles: buildModuleStyles(stlFiles),
|
||||||
|
modelPoses: defaultModelPoses(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +305,7 @@ function buildEmptyProject(name: string): ProjectRecord {
|
|||||||
maskFormats: ['nii', 'nii.gz'],
|
maskFormats: ['nii', 'nii.gz'],
|
||||||
exportedMaskCount: 0,
|
exportedMaskCount: 0,
|
||||||
moduleStyles: {},
|
moduleStyles: {},
|
||||||
|
modelPoses: defaultModelPoses(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +333,7 @@ function normalizeState(state: AppState): AppState {
|
|||||||
exportedMaskCount: project.exportedMaskCount ?? 0,
|
exportedMaskCount: project.exportedMaskCount ?? 0,
|
||||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||||
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
|
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
|
||||||
|
modelPoses: normalizeModelPoses(project.modelPoses),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -260,6 +345,7 @@ function normalizeState(state: AppState): AppState {
|
|||||||
name: savedDefaultProject?.name ?? defaultProject.name,
|
name: savedDefaultProject?.name ?? defaultProject.name,
|
||||||
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
|
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
|
||||||
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
|
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
|
||||||
|
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
|
||||||
},
|
},
|
||||||
...customProjects,
|
...customProjects,
|
||||||
],
|
],
|
||||||
@@ -290,41 +376,141 @@ function writeState(state: AppState) {
|
|||||||
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
|
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
interface DicomHuVolume {
|
||||||
const width = 64;
|
width: number;
|
||||||
const height = 64;
|
height: number;
|
||||||
const depth = 64;
|
depth: number;
|
||||||
const headerSize = 348;
|
columnSpacing: number;
|
||||||
const voxOffset = 352;
|
rowSpacing: number;
|
||||||
const voxelCount = width * height * depth;
|
sliceSpacing: number;
|
||||||
const data = Buffer.alloc(voxelCount);
|
data: Buffer;
|
||||||
const center = [width / 2, height / 2, depth / 2];
|
minHu: number;
|
||||||
|
maxHu: number;
|
||||||
|
}
|
||||||
|
|
||||||
for (let z = 0; z < depth; z += 1) {
|
interface Point2DRecord {
|
||||||
for (let y = 0; y < height; y += 1) {
|
x: number;
|
||||||
for (let x = 0; x < width; x += 1) {
|
y: number;
|
||||||
const dx = (x - center[0]) / 18;
|
}
|
||||||
const dy = (y - center[1]) / 15;
|
|
||||||
const dz = (z - center[2]) / 20;
|
|
||||||
const index = z * width * height + y * width + x;
|
|
||||||
const radius = dx * dx + dy * dy + dz * dz;
|
|
||||||
|
|
||||||
if (radius < 1) {
|
interface Point3DRecord {
|
||||||
data[index] = 1;
|
x: number;
|
||||||
}
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
const tumorDx = (x - 42) / 8;
|
interface PlaneSegmentRecord {
|
||||||
const tumorDy = (y - 30) / 7;
|
a: Point2DRecord;
|
||||||
const tumorDz = (z - 34) / 7;
|
b: Point2DRecord;
|
||||||
if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) {
|
}
|
||||||
data[index] = 2;
|
|
||||||
}
|
interface ModelBoundsRecord {
|
||||||
}
|
min: Point3DRecord;
|
||||||
}
|
max: Point3DRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelPreviewRecord {
|
||||||
|
fileName: string;
|
||||||
|
triangleCount: number;
|
||||||
|
sampledTriangles: number;
|
||||||
|
vertices: number[];
|
||||||
|
bounds: ModelBoundsRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportSceneMetrics {
|
||||||
|
center: Point3DRecord;
|
||||||
|
modelBaseScale: number;
|
||||||
|
modelPivotOffsetZ: number;
|
||||||
|
dicomWidth: number;
|
||||||
|
dicomHeight: number;
|
||||||
|
dicomDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFusionBaseExtent = 4.6;
|
||||||
|
|
||||||
|
function readDicomHuVolume(files: string[]): DicomHuVolume {
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error('当前项目没有可导出的 DICOM 序列');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsed = files.map((fileName) => {
|
||||||
|
const buffer = fs.readFileSync(path.join(dicomDir, fileName));
|
||||||
|
const attributes = parseDicomAttributes(buffer, 'default');
|
||||||
|
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
|
||||||
|
if (!attributes.rows || !attributes.columns || !pixelTag) {
|
||||||
|
throw new Error(`无法解析 DICOM 像素数据:${fileName}`);
|
||||||
|
}
|
||||||
|
return { fileName, buffer, attributes, pixelOffset: pixelTag.valueOffset, pixelLength: pixelTag.length };
|
||||||
|
});
|
||||||
|
const first = parsed[0];
|
||||||
|
const width = first.attributes.columns;
|
||||||
|
const height = first.attributes.rows;
|
||||||
|
const depth = parsed.length;
|
||||||
|
const data = Buffer.alloc(width * height * depth * 2);
|
||||||
|
let minHu = Infinity;
|
||||||
|
let maxHu = -Infinity;
|
||||||
|
|
||||||
|
parsed.forEach((slice, z) => {
|
||||||
|
if (slice.attributes.columns !== width || slice.attributes.rows !== height) {
|
||||||
|
throw new Error(`DICOM 尺寸不一致:${slice.fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < width * height; index += 1) {
|
||||||
|
const position = slice.pixelOffset + index * (slice.attributes.bitsAllocated / 8);
|
||||||
|
if (position + 1 >= slice.buffer.length || position >= slice.pixelOffset + slice.pixelLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = slice.attributes.bitsAllocated === 16
|
||||||
|
? (slice.attributes.pixelRepresentation ? slice.buffer.readInt16LE(position) : slice.buffer.readUInt16LE(position))
|
||||||
|
: slice.buffer.readUInt8(position);
|
||||||
|
const hu = clampNumber(Math.round(raw * slice.attributes.rescaleSlope + slice.attributes.rescaleIntercept), -32768, 32767);
|
||||||
|
const outputOffset = (z * width * height + index) * 2;
|
||||||
|
data.writeInt16LE(hu, outputOffset);
|
||||||
|
minHu = Math.min(minHu, hu);
|
||||||
|
maxHu = Math.max(maxHu, hu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliceSpacing = estimateSliceSpacingFromAttributes(parsed.map((item) => item.attributes)).value;
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth,
|
||||||
|
columnSpacing: first.attributes.columnSpacing,
|
||||||
|
rowSpacing: first.attributes.rowSpacing,
|
||||||
|
sliceSpacing,
|
||||||
|
data,
|
||||||
|
minHu: Number.isFinite(minHu) ? minHu : 0,
|
||||||
|
maxHu: Number.isFinite(maxHu) ? maxHu : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeNiftiHeader({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth,
|
||||||
|
columnSpacing,
|
||||||
|
rowSpacing,
|
||||||
|
sliceSpacing,
|
||||||
|
datatype,
|
||||||
|
bitpix,
|
||||||
|
description,
|
||||||
|
auxFile,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
depth: number;
|
||||||
|
columnSpacing: number;
|
||||||
|
rowSpacing: number;
|
||||||
|
sliceSpacing: number;
|
||||||
|
datatype: number;
|
||||||
|
bitpix: number;
|
||||||
|
description: string;
|
||||||
|
auxFile: string;
|
||||||
|
}) {
|
||||||
|
const voxOffset = 352;
|
||||||
const header = Buffer.alloc(voxOffset);
|
const header = Buffer.alloc(voxOffset);
|
||||||
header.writeInt32LE(headerSize, 0);
|
header.writeInt32LE(348, 0);
|
||||||
header.writeInt16LE(3, 40);
|
header.writeInt16LE(3, 40);
|
||||||
header.writeInt16LE(width, 42);
|
header.writeInt16LE(width, 42);
|
||||||
header.writeInt16LE(height, 44);
|
header.writeInt16LE(height, 44);
|
||||||
@@ -333,22 +519,396 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
|||||||
header.writeInt16LE(1, 50);
|
header.writeInt16LE(1, 50);
|
||||||
header.writeInt16LE(1, 52);
|
header.writeInt16LE(1, 52);
|
||||||
header.writeInt16LE(1, 54);
|
header.writeInt16LE(1, 54);
|
||||||
header.writeInt16LE(2, 70);
|
header.writeInt16LE(datatype, 70);
|
||||||
header.writeInt16LE(8, 72);
|
header.writeInt16LE(bitpix, 72);
|
||||||
header.writeFloatLE(1, 76);
|
header.writeFloatLE(1, 76);
|
||||||
header.writeFloatLE(1, 80);
|
header.writeFloatLE(columnSpacing, 80);
|
||||||
header.writeFloatLE(1, 84);
|
header.writeFloatLE(rowSpacing, 84);
|
||||||
header.writeFloatLE(1, 88);
|
header.writeFloatLE(sliceSpacing, 88);
|
||||||
header.writeFloatLE(voxOffset, 108);
|
header.writeFloatLE(voxOffset, 108);
|
||||||
header.writeFloatLE(1, 112);
|
header.writeFloatLE(1, 112);
|
||||||
header.write('ReVoxelSeg demo mask', 148, 'ascii');
|
header.writeUInt8(2, 123);
|
||||||
header.write(`Project ${project.id}`, 228, 'ascii');
|
header.writeInt16LE(1, 252);
|
||||||
|
header.writeInt16LE(1, 254);
|
||||||
|
header.write(description.slice(0, 79), 148, 'ascii');
|
||||||
|
header.write(auxFile.slice(0, 23), 228, 'ascii');
|
||||||
|
header.writeFloatLE(columnSpacing, 280);
|
||||||
|
header.writeFloatLE(0, 284);
|
||||||
|
header.writeFloatLE(0, 288);
|
||||||
|
header.writeFloatLE(0, 292);
|
||||||
|
header.writeFloatLE(0, 296);
|
||||||
|
header.writeFloatLE(rowSpacing, 300);
|
||||||
|
header.writeFloatLE(0, 304);
|
||||||
|
header.writeFloatLE(0, 308);
|
||||||
|
header.writeFloatLE(0, 312);
|
||||||
|
header.writeFloatLE(0, 316);
|
||||||
|
header.writeFloatLE(sliceSpacing, 320);
|
||||||
|
header.writeFloatLE(0, 324);
|
||||||
header.write('n+1\0', 344, 'ascii');
|
header.write('n+1\0', 344, 'ascii');
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
const nifti = Buffer.concat([header, data]);
|
function createNiftiBuffer(volume: DicomHuVolume, data: Buffer, kind: 'dicom' | 'segmentation', compressed: boolean) {
|
||||||
|
const isSegmentation = kind === 'segmentation';
|
||||||
|
const nifti = Buffer.concat([
|
||||||
|
writeNiftiHeader({
|
||||||
|
width: volume.width,
|
||||||
|
height: volume.height,
|
||||||
|
depth: volume.depth,
|
||||||
|
columnSpacing: volume.columnSpacing,
|
||||||
|
rowSpacing: volume.rowSpacing,
|
||||||
|
sliceSpacing: volume.sliceSpacing,
|
||||||
|
datatype: isSegmentation ? 2 : 4,
|
||||||
|
bitpix: isSegmentation ? 8 : 16,
|
||||||
|
description: isSegmentation ? 'ReVoxelSeg label map' : 'ReVoxelSeg DICOM HU volume',
|
||||||
|
auxFile: isSegmentation ? 'segmentation' : 'dicom',
|
||||||
|
}),
|
||||||
|
data,
|
||||||
|
]);
|
||||||
return compressed ? zlib.gzipSync(nifti) : nifti;
|
return compressed ? zlib.gzipSync(nifti) : nifti;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, previews: Record<string, ModelPreviewRecord>): ExportSceneMetrics | null {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Number.isFinite(bounds.min.x)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanX = Math.max(bounds.max.x - bounds.min.x, 0.001);
|
||||||
|
const spanY = Math.max(bounds.max.y - bounds.min.y, 0.001);
|
||||||
|
const spanZ = Math.max(bounds.max.z - bounds.min.z, 0.001);
|
||||||
|
const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
|
||||||
|
const physicalWidth = volume.width * volume.columnSpacing;
|
||||||
|
const physicalHeight = volume.height * volume.rowSpacing;
|
||||||
|
const physicalDepth = Math.max(volume.depth, 1) * volume.sliceSpacing;
|
||||||
|
const maxPhysical = Math.max(physicalWidth, physicalHeight, physicalDepth, 1);
|
||||||
|
const dicomWidth = (physicalWidth / maxPhysical) * exportFusionBaseExtent;
|
||||||
|
const dicomHeight = (physicalHeight / maxPhysical) * exportFusionBaseExtent;
|
||||||
|
const dicomDepth = Math.max((physicalDepth / maxPhysical) * exportFusionBaseExtent, 0.18);
|
||||||
|
const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
modelBaseScale,
|
||||||
|
modelPivotOffsetZ: dicomDepth * 0.08,
|
||||||
|
dicomWidth,
|
||||||
|
dicomHeight,
|
||||||
|
dicomDepth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord {
|
||||||
|
const scalar = metrics.modelBaseScale * pose.scale;
|
||||||
|
let px = (x - metrics.center.x) * scalar;
|
||||||
|
let py = (y - metrics.center.y) * scalar;
|
||||||
|
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: px + pose.translateX,
|
||||||
|
y: py + pose.translateY,
|
||||||
|
z: pz + pose.translateZ,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null {
|
||||||
|
const epsilon = 1e-5;
|
||||||
|
const startDistance = start.z - targetZ;
|
||||||
|
const endDistance = end.z - targetZ;
|
||||||
|
|
||||||
|
if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Math.abs(startDistance) <= epsilon) {
|
||||||
|
return { x: start.x, y: start.y };
|
||||||
|
}
|
||||||
|
if (Math.abs(endDistance) <= epsilon) {
|
||||||
|
return { x: end.x, y: end.y };
|
||||||
|
}
|
||||||
|
if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = startDistance / (startDistance - endDistance);
|
||||||
|
return {
|
||||||
|
x: start.x + (end.x - start.x) * t,
|
||||||
|
y: start.y + (end.y - start.y) * t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPointDistanceSquared(a: Point2DRecord, b: Point2DRecord) {
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectExportTriangleWithPlane(a: Point3DRecord, b: Point3DRecord, c: Point3DRecord, targetZ: number): PlaneSegmentRecord | null {
|
||||||
|
const intersections = [
|
||||||
|
intersectExportEdgeWithPlane(a, b, targetZ),
|
||||||
|
intersectExportEdgeWithPlane(b, c, targetZ),
|
||||||
|
intersectExportEdgeWithPlane(c, a, targetZ),
|
||||||
|
].filter((point): point is Point2DRecord => Boolean(point));
|
||||||
|
const uniquePoints: Point2DRecord[] = [];
|
||||||
|
|
||||||
|
intersections.forEach((point) => {
|
||||||
|
if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-8)) {
|
||||||
|
uniquePoints.push(point);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uniquePoints.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segment: PlaneSegmentRecord = { a: uniquePoints[0], b: uniquePoints[1] };
|
||||||
|
let maxDistance = exportPointDistanceSquared(segment.a, segment.b);
|
||||||
|
for (let first = 0; first < uniquePoints.length; first += 1) {
|
||||||
|
for (let second = first + 1; second < uniquePoints.length; second += 1) {
|
||||||
|
const distance = exportPointDistanceSquared(uniquePoints[first], uniquePoints[second]);
|
||||||
|
if (distance > maxDistance) {
|
||||||
|
maxDistance = distance;
|
||||||
|
segment = { a: uniquePoints[first], b: uniquePoints[second] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxDistance > 1e-8 ? segment : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExportSegmentToRows(rows: number[][], width: number, height: number, segment: PlaneSegmentRecord) {
|
||||||
|
const deltaY = segment.b.y - segment.a.y;
|
||||||
|
if (Math.abs(deltaY) < 0.01) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minY = Math.max(0, Math.floor(Math.min(segment.a.y, segment.b.y)));
|
||||||
|
const maxY = Math.min(height - 1, Math.ceil(Math.max(segment.a.y, segment.b.y)));
|
||||||
|
for (let row = minY; row <= maxY; row += 1) {
|
||||||
|
const sampleY = row + 0.5;
|
||||||
|
const crosses = (sampleY >= segment.a.y && sampleY < segment.b.y)
|
||||||
|
|| (sampleY >= segment.b.y && sampleY < segment.a.y);
|
||||||
|
if (!crosses) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (sampleY - segment.a.y) / deltaY;
|
||||||
|
const x = segment.a.x + (segment.b.x - segment.a.x) * t;
|
||||||
|
if (Number.isFinite(x)) {
|
||||||
|
rows[row].push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) {
|
||||||
|
const sliceOffset = slice * width * height;
|
||||||
|
rows.forEach((intersections, row) => {
|
||||||
|
if (intersections.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intersections.sort((left, right) => left - right);
|
||||||
|
const cleaned: number[] = [];
|
||||||
|
intersections.forEach((x) => {
|
||||||
|
const previous = cleaned[cleaned.length - 1];
|
||||||
|
if (previous === undefined || Math.abs(previous - x) > 0.35) {
|
||||||
|
cleaned.push(x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let index = 0; index + 1 < cleaned.length; index += 2) {
|
||||||
|
const rawStartX = cleaned[index];
|
||||||
|
const rawEndX = cleaned[index + 1];
|
||||||
|
if (rawEndX < 0 || rawStartX > width - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = clampNumber(Math.ceil(rawStartX), 0, width - 1);
|
||||||
|
const endX = clampNumber(Math.floor(rawEndX), 0, width - 1);
|
||||||
|
for (let x = startX; x <= endX; x += 1) {
|
||||||
|
data[sliceOffset + row * width + x] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue) {
|
||||||
|
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);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord;
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
const metrics = getExportMetrics(project, volume, previews);
|
||||||
|
if (!metrics) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliceToZ = (slice: number) => (
|
||||||
|
volume.depth <= 1
|
||||||
|
? 0
|
||||||
|
: -metrics.dicomDepth / 2 + (metrics.dicomDepth * slice) / (volume.depth - 1)
|
||||||
|
);
|
||||||
|
const zToSlice = (z: number) => (
|
||||||
|
volume.depth <= 1
|
||||||
|
? 0
|
||||||
|
: ((z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1)
|
||||||
|
);
|
||||||
|
const mapPoint = (point: Point2DRecord): Point2DRecord => ({
|
||||||
|
x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width,
|
||||||
|
y: volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
(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,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload || style.visible === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
|
||||||
|
const rowsBySlice = new Map<number, number[][]>();
|
||||||
|
const rowsForSlice = (slice: number) => {
|
||||||
|
const existing = rowsBySlice.get(slice);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const rows = Array.from({ length: volume.height }, () => [] as number[]);
|
||||||
|
rowsBySlice.set(slice, rows);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let vertexIndex = 0; vertexIndex + 8 < payload.vertices.length; vertexIndex += 9) {
|
||||||
|
const a = transformPointForExportPose(
|
||||||
|
payload.vertices[vertexIndex],
|
||||||
|
payload.vertices[vertexIndex + 1],
|
||||||
|
payload.vertices[vertexIndex + 2],
|
||||||
|
metrics,
|
||||||
|
pose,
|
||||||
|
);
|
||||||
|
const b = transformPointForExportPose(
|
||||||
|
payload.vertices[vertexIndex + 3],
|
||||||
|
payload.vertices[vertexIndex + 4],
|
||||||
|
payload.vertices[vertexIndex + 5],
|
||||||
|
metrics,
|
||||||
|
pose,
|
||||||
|
);
|
||||||
|
const c = transformPointForExportPose(
|
||||||
|
payload.vertices[vertexIndex + 6],
|
||||||
|
payload.vertices[vertexIndex + 7],
|
||||||
|
payload.vertices[vertexIndex + 8],
|
||||||
|
metrics,
|
||||||
|
pose,
|
||||||
|
);
|
||||||
|
const minSlice = clampNumber(Math.floor(zToSlice(Math.min(a.z, b.z, c.z))) - 1, 0, volume.depth - 1);
|
||||||
|
const maxSlice = clampNumber(Math.ceil(zToSlice(Math.max(a.z, b.z, c.z))) + 1, 0, volume.depth - 1);
|
||||||
|
|
||||||
|
for (let slice = minSlice; slice <= maxSlice; slice += 1) {
|
||||||
|
const segment = intersectExportTriangleWithPlane(a, b, c, sliceToZ(slice));
|
||||||
|
if (!segment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, {
|
||||||
|
a: mapPoint(segment.a),
|
||||||
|
b: mapPoint(segment.b),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsBySlice.forEach((rows, slice) => {
|
||||||
|
fillExportRows(data, volume.width, volume.height, slice, rows, label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelPoseQuery(raw: unknown) {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeModelPoseValue(JSON.parse(raw) as Partial<ModelPoseValue>);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNiftiExport(project: ProjectRecord, files: string[], target: 'dicom' | 'segmentation', compressed: boolean, pose?: ModelPoseValue) {
|
||||||
|
const volume = readDicomHuVolume(files);
|
||||||
|
if (target === 'dicom') {
|
||||||
|
return createNiftiBuffer(volume, volume.data, 'dicom', compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createNiftiBuffer(volume, createSegmentationData(project, volume, pose ?? defaultModelPose), 'segmentation', compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
|
||||||
|
return Buffer.from(JSON.stringify({
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
dicomPath: project.dicomPath,
|
||||||
|
modelPath: project.modelPath,
|
||||||
|
},
|
||||||
|
generatedAt: now(),
|
||||||
|
activePose: activePose ?? null,
|
||||||
|
modelPoses: project.modelPoses,
|
||||||
|
moduleStyles: project.moduleStyles,
|
||||||
|
note: 'Pose values are stored in the ReVoxelSeg fusion view coordinate system.',
|
||||||
|
}, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
function findProject(state: AppState, projectId: string) {
|
function findProject(state: AppState, projectId: string) {
|
||||||
return state.projects.find((candidate) => candidate.id === projectId);
|
return state.projects.find((candidate) => candidate.id === projectId);
|
||||||
}
|
}
|
||||||
@@ -776,11 +1336,11 @@ function cropDicomContent(pixels: Buffer, width: number, height: number) {
|
|||||||
return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight };
|
return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord {
|
||||||
const cacheKey = `${fileName}:${limit}`;
|
const cacheKey = `${fileName}:${limit}`;
|
||||||
const cached = modelPreviewCache.get(cacheKey);
|
const cached = modelPreviewCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached as ModelPreviewRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = fs.readFileSync(filePath);
|
const buffer = fs.readFileSync(filePath);
|
||||||
@@ -834,7 +1394,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload: ModelPreviewRecord = {
|
||||||
fileName,
|
fileName,
|
||||||
triangleCount,
|
triangleCount,
|
||||||
sampledTriangles,
|
sampledTriangles,
|
||||||
@@ -1120,6 +1680,25 @@ async function startServer() {
|
|||||||
res.json(project);
|
res.json(project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:projectId/model-poses', (req, res) => {
|
||||||
|
const incoming = req.body?.modelPoses;
|
||||||
|
if (!Array.isArray(incoming)) {
|
||||||
|
res.status(400).json({ message: '位姿数据无效' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readState();
|
||||||
|
const project = findProject(state, req.params.projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ message: '项目不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
project.modelPoses = normalizeModelPoses(incoming as Partial<ModelPoseRecord>[]);
|
||||||
|
writeState(state);
|
||||||
|
res.json(project);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
|
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
|
||||||
const project = findProject(readState(), req.params.projectId);
|
const project = findProject(readState(), req.params.projectId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -1298,7 +1877,7 @@ async function startServer() {
|
|||||||
res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) });
|
res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/projects/:projectId/export-mask', (req, res) => {
|
const handleProjectExport = (req: express.Request, res: express.Response, targetOverride?: 'segmentation') => {
|
||||||
const state = readState();
|
const state = readState();
|
||||||
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
|
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
|
||||||
|
|
||||||
@@ -1307,19 +1886,42 @@ async function startServer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
|
||||||
const compressed = format === 'nii.gz';
|
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
|
||||||
const mask = createNiftiMask(project, compressed);
|
const activePose = parseModelPoseQuery(req.query.pose);
|
||||||
const filename = `${project.id}-segmentation-mask.${format}`;
|
|
||||||
const outputPath = path.join(exportDir, filename);
|
|
||||||
fs.writeFileSync(outputPath, mask);
|
|
||||||
project.exportedMaskCount += 1;
|
|
||||||
writeState(state);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
|
try {
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
if (target === 'pose') {
|
||||||
res.send(mask);
|
const posePayload = createPoseExport(project, activePose);
|
||||||
});
|
const filename = `${project.id}-pose-data.json`;
|
||||||
|
fs.writeFileSync(path.join(exportDir, filename), posePayload);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(posePayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label';
|
||||||
|
const filename = `${project.id}-${suffix}.${format}`;
|
||||||
|
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||||
|
project.exportedMaskCount += target === 'segmentation' ? 1 : 0;
|
||||||
|
writeState(state);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
|
||||||
|
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'));
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.use(express.static(path.join(__dirname, 'dist')));
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dices,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
Download,
|
Download,
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
@@ -12,18 +11,8 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
|
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||||
import { api, downloadMask } from '../lib/api';
|
import { api, downloadMask, downloadSelectedProjectExports, ProjectExportTarget } from '../lib/api';
|
||||||
|
|
||||||
interface ModelPose {
|
|
||||||
rotateX: number;
|
|
||||||
rotateY: number;
|
|
||||||
rotateZ: number;
|
|
||||||
translateX: number;
|
|
||||||
translateY: number;
|
|
||||||
translateZ: number;
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelPreviewPayload {
|
interface ModelPreviewPayload {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -71,6 +60,16 @@ const defaultModelPose: ModelPose = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultSavedPoses: SavedModelPose[] = [
|
||||||
|
{ id: 'default', name: '默认', pose: defaultModelPose },
|
||||||
|
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||||||
|
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||||||
|
];
|
||||||
|
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 侧车' },
|
||||||
|
];
|
||||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||||
const fusionBaseExtent = 4.6;
|
const fusionBaseExtent = 4.6;
|
||||||
|
|
||||||
@@ -120,6 +119,33 @@ function createDicomTexture(frame: string, width: number, height: number) {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoordinateAxesInset() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-xl border border-white/10 bg-black/65 p-2 shadow-lg backdrop-blur-sm">
|
||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" aria-hidden="true" className="block">
|
||||||
|
<defs>
|
||||||
|
<marker id="axis-arrow-red" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#ef4444" />
|
||||||
|
</marker>
|
||||||
|
<marker id="axis-arrow-green" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#22c55e" />
|
||||||
|
</marker>
|
||||||
|
<marker id="axis-arrow-blue" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#38bdf8" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<circle cx="28" cy="44" r="3" fill="#e5e7eb" />
|
||||||
|
<line x1="28" y1="44" x2="58" y2="44" stroke="#ef4444" strokeWidth="3" markerEnd="url(#axis-arrow-red)" />
|
||||||
|
<line x1="28" y1="44" x2="14" y2="58" stroke="#22c55e" strokeWidth="3" markerEnd="url(#axis-arrow-green)" />
|
||||||
|
<line x1="28" y1="44" x2="28" y2="12" stroke="#38bdf8" strokeWidth="3" markerEnd="url(#axis-arrow-blue)" />
|
||||||
|
<text x="61" y="48" fill="#fecaca" fontSize="10" fontWeight="700">X</text>
|
||||||
|
<text x="5" y="66" fill="#bbf7d0" fontSize="10" fontWeight="700">Y</text>
|
||||||
|
<text x="24" y="10" fill="#bae6fd" fontSize="10" fontWeight="700">Z</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FusionThreeView({
|
function FusionThreeView({
|
||||||
project,
|
project,
|
||||||
volume,
|
volume,
|
||||||
@@ -492,6 +518,7 @@ function FusionThreeView({
|
|||||||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||||||
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
<CoordinateAxesInset />
|
||||||
{loadProgress < 100 && (
|
{loadProgress < 100 && (
|
||||||
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
||||||
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
|
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
|
||||||
@@ -1679,14 +1706,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const [showBounds, setShowBounds] = useState(true);
|
const [showBounds, setShowBounds] = useState(true);
|
||||||
const [cutEnabled, setCutEnabled] = useState(false);
|
const [cutEnabled, setCutEnabled] = useState(false);
|
||||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||||
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
|
const [savedPoses, setSavedPoses] = useState<SavedModelPose[]>(defaultSavedPoses);
|
||||||
{ id: 'default', name: '默认', pose: defaultModelPose },
|
|
||||||
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
|
||||||
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
|
||||||
]);
|
|
||||||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||||||
|
dicom: true,
|
||||||
|
segmentation: true,
|
||||||
|
pose: true,
|
||||||
|
});
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||||
const [fusionError, setFusionError] = useState('');
|
const [fusionError, setFusionError] = useState('');
|
||||||
@@ -1694,15 +1721,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
||||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||||
|
|
||||||
const handleStartRegistration = () => {
|
|
||||||
setIsRegistering(true);
|
|
||||||
setProgress(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
await downloadMask(projectId, format);
|
await downloadMask(projectId, format, modelPose);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1710,6 +1732,27 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportSelected = async () => {
|
||||||
|
const selectedItems = exportOptions
|
||||||
|
.filter((option) => exportSelection[option.id])
|
||||||
|
.map((option) => option.id);
|
||||||
|
if (!selectedItems.length) {
|
||||||
|
setFusionError('请至少选择一个导出内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
setFusionError('');
|
||||||
|
try {
|
||||||
|
await downloadSelectedProjectExports(projectId, selectedItems, 'nii.gz', { pose: modelPose });
|
||||||
|
window.setTimeout(() => setExporting(false), selectedItems.length * 220 + 200);
|
||||||
|
setShowExportMenu(false);
|
||||||
|
} catch (error) {
|
||||||
|
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||||
visible: fallback?.visible ?? true,
|
visible: fallback?.visible ?? true,
|
||||||
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||||||
@@ -1764,6 +1807,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
||||||
});
|
});
|
||||||
setModuleStyles(nextStyles);
|
setModuleStyles(nextStyles);
|
||||||
|
setSavedPoses(item.modelPoses?.length ? item.modelPoses : defaultSavedPoses);
|
||||||
|
setSelectedPoseId('default');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setFusionVolume(null);
|
setFusionVolume(null);
|
||||||
@@ -1796,17 +1841,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRegistering && progress < 100) {
|
|
||||||
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
if (progress >= 100) {
|
|
||||||
setIsRegistering(false);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [isRegistering, progress]);
|
|
||||||
|
|
||||||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||||||
setModelPose((current) => ({
|
setModelPose((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1885,20 +1919,35 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
|
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commitSavedPoses = (next: SavedModelPose[]) => {
|
||||||
|
setSavedPoses(next);
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.updateProjectModelPoses(project.id, next)
|
||||||
|
.then((updated) => {
|
||||||
|
setProject(updated);
|
||||||
|
setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFusionError('位姿保存失败,请稍后重试');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveCurrentPose = () => {
|
const saveCurrentPose = () => {
|
||||||
const nextPose = {
|
const nextPose = {
|
||||||
id: `pose-${Date.now()}`,
|
id: `pose-${Date.now()}`,
|
||||||
name: `位姿${savedPoses.length - 2}`,
|
name: `位姿${savedPoses.length - 2}`,
|
||||||
pose: { ...modelPose },
|
pose: { ...modelPose },
|
||||||
};
|
};
|
||||||
setSavedPoses((current) => [...current, nextPose]);
|
commitSavedPoses([...savedPoses, nextPose]);
|
||||||
setSelectedPoseId(nextPose.id);
|
setSelectedPoseId(nextPose.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renamePose = (poseId: string, name: string) => {
|
const renamePose = (poseId: string, name: string) => {
|
||||||
if (poseId === 'default') return;
|
if (poseId === 'default') return;
|
||||||
const nextName = name.trim();
|
const nextName = name.trim();
|
||||||
setSavedPoses((current) => current.map((item) => (
|
commitSavedPoses(savedPoses.map((item) => (
|
||||||
item.id === poseId ? { ...item, name: nextName || item.name } : item
|
item.id === poseId ? { ...item, name: nextName || item.name } : item
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
@@ -1935,24 +1984,52 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={handleStartRegistration}
|
<button
|
||||||
disabled={isRegistering}
|
onClick={() => setShowExportMenu((value) => !value)}
|
||||||
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
disabled={exporting}
|
||||||
>
|
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||||||
{isRegistering ? (
|
>
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<Download size={18} />
|
||||||
) : <Dices size={18} />}
|
{exporting ? '正在导出' : '导出全部 NII.GZ'}
|
||||||
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
|
</button>
|
||||||
</button>
|
{showExportMenu && (
|
||||||
<button
|
<div className="absolute right-0 top-12 z-30 w-72 rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
|
||||||
onClick={() => handleExport('nii.gz')}
|
<div className="mb-2 flex items-center justify-between">
|
||||||
disabled={exporting}
|
<p className="font-bold text-slate-700">导出内容</p>
|
||||||
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
<button
|
||||||
>
|
onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true })}
|
||||||
<Download size={18} />
|
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||||
{exporting ? '正在导出' : '导出 NII.GZ'}
|
>
|
||||||
</button>
|
全选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{exportOptions.map((option) => (
|
||||||
|
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportSelection[option.id]}
|
||||||
|
onChange={(event) => setExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||||||
|
className="accent-emerald-600"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block">{option.label}</span>
|
||||||
|
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, OverviewSummary, Project, SessionState, UserRecord } from '../types';
|
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types';
|
||||||
|
|
||||||
|
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
@@ -55,6 +57,11 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ moduleStyles }),
|
body: JSON.stringify({ moduleStyles }),
|
||||||
}),
|
}),
|
||||||
|
updateProjectModelPoses: (projectId: string, modelPoses: SavedModelPose[]) =>
|
||||||
|
request<Project>(`/api/projects/${projectId}/model-poses`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ modelPoses }),
|
||||||
|
}),
|
||||||
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
|
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
|
||||||
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
|
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
|
||||||
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
|
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
|
||||||
@@ -67,46 +74,43 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz') {
|
function triggerFileDownload(url: string) {
|
||||||
const response = await fetch(`/api/projects/${projectId}/export-mask?format=${encodeURIComponent(format)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`导出失败:${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const disposition = response.headers.get('Content-Disposition') ?? '';
|
|
||||||
const match = disposition.match(/filename="([^"]+)"/);
|
|
||||||
const filename = match?.[1] ?? `segmentation-mask.${format}`;
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.rel = 'noopener';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
}
|
||||||
|
|
||||||
|
function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||||
|
if (pose) {
|
||||||
|
params.set('pose', JSON.stringify(pose));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose) {
|
||||||
|
const params = new URLSearchParams({ format });
|
||||||
|
appendPose(params, pose);
|
||||||
|
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 } = {}) {
|
||||||
|
const params = new URLSearchParams({ target, format });
|
||||||
|
if (target !== 'dicom') {
|
||||||
|
appendPose(params, options.pose);
|
||||||
|
}
|
||||||
|
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 downloadDicomArchive(projectId: string) {
|
export async function downloadDicomArchive(projectId: string) {
|
||||||
const response = await fetch(`/api/projects/${projectId}/dicom-archive`);
|
triggerFileDownload(`/api/projects/${projectId}/dicom-archive`);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`DICOM 压缩包下载失败:${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const disposition = response.headers.get('Content-Disposition') ?? '';
|
|
||||||
const match = disposition.match(/filename="([^"]+)"/);
|
|
||||||
const filename = match?.[1] ?? `${projectId}-dicom-series.tar.gz`;
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface Project {
|
|||||||
exportedMaskCount?: number;
|
exportedMaskCount?: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
moduleStyles?: Record<string, ModuleStyle>;
|
moduleStyles?: Record<string, ModuleStyle>;
|
||||||
|
modelPoses?: SavedModelPose[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleStyle {
|
export interface ModuleStyle {
|
||||||
@@ -30,6 +31,22 @@ export interface ModuleStyle {
|
|||||||
partId: number;
|
partId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelPose {
|
||||||
|
rotateX: number;
|
||||||
|
rotateY: number;
|
||||||
|
rotateZ: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
translateZ: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedModelPose {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pose: ModelPose;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MaskMapping {
|
export interface MaskMapping {
|
||||||
className: string;
|
className: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
71
工程分析/实现方案-2026-05-20-01-38-33.md
Normal file
71
工程分析/实现方案-2026-05-20-01-38-33.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 实现方案-2026-05-20-01-38-33
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-20-01-38-33.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
完善逆向工作区导出与位姿持久化:移除自动配准演示入口,新增可选导出 DICOM NIfTI、分割 NIfTI 和位姿 JSON,修复 blob 下载警告,增加三维坐标轴示意,并让保存位姿跨项目进入持久保留。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `WebSite/src/lib/api.ts`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `工程分析/需求分析-2026-05-20-01-38-33.md`
|
||||||
|
- `工程分析/实现方案-2026-05-20-01-38-33.md`
|
||||||
|
- `工程分析/测试方案-2026-05-20-01-38-33.md`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
1. 后端扩展项目状态:
|
||||||
|
- 增加 `modelPoses` 字段,保存默认、俯视、侧视与用户自定义位姿。
|
||||||
|
- 新增 `PATCH /api/projects/:projectId/model-poses`。
|
||||||
|
2. 后端重写导出:
|
||||||
|
- 新增通用 NIfTI-1 写入函数。
|
||||||
|
- DICOM 原始影像导出为 int16 HU `.nii.gz`,维度与 spacing 来自 DICOM。
|
||||||
|
- 分割影像导出为 STL 构件采样切面填充后的 uint8 label map `.nii.gz`,维度与 spacing 与 DICOM 完全一致。
|
||||||
|
- 位姿数据导出为 JSON,并记录导出时的当前 activePose。
|
||||||
|
- 保留 `/export-mask` 兼容旧分割导出。
|
||||||
|
3. 前端下载方式:
|
||||||
|
- 用直链 `<a href>` 下载替代 `fetch -> Blob -> URL.createObjectURL`。
|
||||||
|
- 新增导出选项复选框和“导出所选”动作,分割和位姿导出会携带当前模型位姿。
|
||||||
|
4. UI 调整:
|
||||||
|
- 去掉“开始自动配准”按钮和相关演示进度状态。
|
||||||
|
- 顶部按钮改为“导出全部 NII.GZ”。
|
||||||
|
- 三维融合视角右下角增加固定 XYZ 坐标轴示意。
|
||||||
|
5. 位姿持久化:
|
||||||
|
- 项目加载时读取 `project.modelPoses`。
|
||||||
|
- 保存/重命名位姿时写回后端。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
- 更新类型定义和后端状态归一化。
|
||||||
|
- 实现 NIfTI/位姿导出接口。
|
||||||
|
- 更新前端 API 下载方法。
|
||||||
|
- 更新 ReverseWorkspace UI、导出选项、位姿保存逻辑和坐标轴。
|
||||||
|
- 执行 `npm run lint` 与 `npm run build`。
|
||||||
|
- 验证新导出接口返回文件,重新部署并推送。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 旧 `downloadMask` 与 `/export-mask` 保持可用,只把底层分割 NIfTI 改为同维度数据。
|
||||||
|
- 如果完整 DICOM NIfTI 导出耗时过高,可后续增加范围导出或后台任务。
|
||||||
|
- 如果浏览器仍对 HTTP 下载提示安全警告,需要部署 HTTPS;本次先消除 blob URL 警告来源。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- `server.ts`:项目位姿字段、DICOM NIfTI 导出、STL Label Map 导出、位姿导出、兼容旧导出。
|
||||||
|
- `api.ts`:直链下载和导出选项 API。
|
||||||
|
- `types.ts`:项目位姿类型。
|
||||||
|
- `ReverseWorkspace.tsx`:移除自动配准、导出选项、坐标轴、位姿持久化。
|
||||||
|
- 新增本次三份工程文档,更新经验记录。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- 提交信息:`2026-05-20-01-38-33 完善NII导出与位姿持久化`
|
||||||
|
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||||
|
- 推送到 Gitea `origin/main` 并重新部署。
|
||||||
59
工程分析/测试方案-2026-05-20-01-38-33.md
Normal file
59
工程分析/测试方案-2026-05-20-01-38-33.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 测试方案-2026-05-20-01-38-33
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-20-01-38-33.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 在 `WebSite/` 下执行 `npm run lint`。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- 在 `WebSite/` 下执行 `npm run build`。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 顶部不再显示“开始自动配准”。
|
||||||
|
- 顶部显示“导出全部 NII.GZ”,并能展开 DICOM 原始影像、分割影像、位姿数据选项。
|
||||||
|
- 三维融合视角右下角显示小型 XYZ 坐标轴。
|
||||||
|
- 保存自定义位姿后重新进入项目,位姿仍存在。
|
||||||
|
- 重命名自定义位姿后刷新项目,名称仍保留。
|
||||||
|
|
||||||
|
## 导出验证
|
||||||
|
|
||||||
|
- `curl` 验证 DICOM 原始影像导出接口返回 `.nii.gz`。
|
||||||
|
- `curl` 验证分割影像导出接口返回 `.nii.gz`。
|
||||||
|
- `curl` 验证位姿数据导出接口返回 JSON。
|
||||||
|
- 使用 gzip 文件头或响应大小确认导出不是旧 `64x64x64` 演示 Mask。
|
||||||
|
- 确认前端下载不再使用 `URL.createObjectURL(blob)`。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `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` 导出 DICOM 原始影像:HTTP 200,`.nii.gz` 约 75.76 MB。
|
||||||
|
- DICOM NIfTI 头验证:`512x512x300`,datatype `4`,bitpix `16`,vox_offset `352`。
|
||||||
|
- 临时服务导出 STL 分割影像:HTTP 200,`.nii.gz` 约 146 KB。
|
||||||
|
- STL 分割 NIfTI 头验证:`512x512x300`,datatype `2`,bitpix `8`,非零体素 `1131842`,最大标签 `9`。
|
||||||
|
- 临时服务导出位姿数据:HTTP 200,JSON 内包含 `project=head-ct-demo`、当前 `activePose` 和 3 个默认/保存位姿。
|
||||||
|
- 前端下载实现已确认不再使用 `URL.createObjectURL(blob)`,改为后端直链附件下载。
|
||||||
|
|
||||||
|
## 回归关注点
|
||||||
|
|
||||||
|
- 右侧分割 NII/NII.GZ 导出按钮仍可工作。
|
||||||
|
- 项目库中的 Mask 下载入口仍可工作。
|
||||||
|
- DICOM/STL 预览与三维融合视图不受导出改动影响。
|
||||||
54
工程分析/经验记录.md
54
工程分析/经验记录.md
@@ -991,3 +991,57 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
调整任何滑条或步进按钮的 step 时,同步检查数值展示、长按步进和手动输入精度,避免控制精度和显示精度不一致。
|
调整任何滑条或步进按钮的 step 时,同步检查数值展示、长按步进和手动输入精度,避免控制精度和显示精度不一致。
|
||||||
|
|
||||||
|
## 2026-05-20-01-38-33 NIfTI 导出必须与 DICOM 体数据同维同距
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
旧的 NII/NII.GZ 导出生成的是固定 `64x64x64` 演示 Mask,用户在 ITK-SNAP 中打开时无法与真实 DICOM 序列对应,表现为尺度、层数和内容都不可信。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
早期导出接口没有读取真实 DICOM Pixel Data、Pixel Spacing 和 slice spacing,而是直接写入一个合成球体数据;这类演示数据适合验证下载链路,但不能用于医学影像工具复核。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
后端新增真实 NIfTI-1 写入链路:DICOM 原始影像导出为 `512x512x300` int16 HU volume,分割影像导出为同维度 uint8 Label Map,并把 spacing 写入 NIfTI header。分割数据复用 STL 构件采样切面、当前位姿变换和扫描线填充逻辑生成,标签值对应构件 `partId`。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
任何影像导出功能都必须先确认维度、spacing、datatype、vox_offset 和真实数据来源。不能用固定尺寸演示数据替代生产导出;新增导出后至少用脚本读取 NIfTI header,并确认体素数和 DICOM 序列一致。
|
||||||
|
|
||||||
|
## 2026-05-20-01-38-33 浏览器下载不应依赖 blob:http 临时地址
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
浏览器控制台出现 `blob:http://... was loaded over an insecure connection`,用户误以为导出的 NII.GZ 文件本身或服务部署存在安全错误。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
前端先 `fetch` 后端文件,再用 `URL.createObjectURL(blob)` 生成临时 `blob:http` 地址触发下载;在 HTTP 部署环境下,浏览器会对 blob 来源继承非安全上下文并给出警告。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
将 NIfTI、DICOM 压缩包和多文件导出统一改成后端直链附件下载,由 Express 设置 `Content-Disposition`,前端只创建 `<a href="/api/...">` 触发下载,不再生成 blob URL。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
面向大文件、医疗影像和压缩包下载时优先使用后端附件直链。如果必须用 blob,应明确浏览器安全提示的影响;生产环境仍应考虑 HTTPS,但不能把 blob 临时地址当作常规下载路径。
|
||||||
|
|
||||||
|
## 2026-05-20-01-38-33 位姿列表需要进入项目状态而不是组件内存
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户保存的可视化工具栏位姿在重新进入项目后消失,造成调好的 STL/DICOM 对齐姿态无法继续复核或用于导出。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
旧实现把 `savedPoses` 放在 `ReverseWorkspace` 组件本地 state 中,页面重新加载或重新进入项目时只会恢复默认、俯视、侧视三组临时位姿,没有写回后端项目状态。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
为项目状态新增 `modelPoses` 字段,后端归一化默认位姿并提供 `PATCH /api/projects/:projectId/model-poses`;前端加载项目时读取 `project.modelPoses`,保存或重命名位姿时立即写回后端。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
凡是用户明确“保存”的可视化参数都应进入项目级持久状态,不能只存在于组件 state。新增保存类交互时同时设计读取、写入、归一化和刷新后的回显验证。
|
||||||
|
|||||||
55
工程分析/需求分析-2026-05-20-01-38-33.md
Normal file
55
工程分析/需求分析-2026-05-20-01-38-33.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 需求分析-2026-05-20-01-38-33
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-20-01-38-33
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户提出五项修改:去掉“开始自动配准”;将上方导出改为“导出全部 NII.GZ”,并提供 DICOM 原始影像、分割影像、位姿数据选项,方便 ITK-SNAP 打开检查;在三维融合视角右下角增加小型 XYZ 坐标轴;解决重新进入项目后可视化工具栏保存位姿丢失;处理浏览器对 `blob:http` 下载的 insecure connection 报错。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 顶部操作区更聚焦导出,不再展示自动配准演示按钮。
|
||||||
|
- 导出的医学影像文件与 DICOM 原始序列维度、spacing 对齐,能被 ITK-SNAP 作为主影像/分割影像加载。
|
||||||
|
- 位姿数据能随项目保存,并能作为导出侧车 JSON 下载。
|
||||||
|
- 三维融合视角提供固定的 XYZ 空间方向提示。
|
||||||
|
- 前端下载避免 `URL.createObjectURL(blob)`,减少 `blob:http` insecure connection 警告。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:
|
||||||
|
- 当前项目 DICOM 序列、模块样式、已保存位姿。
|
||||||
|
- 用户在导出选项中勾选的 DICOM 原始影像、分割影像、位姿数据。
|
||||||
|
- 输出:
|
||||||
|
- DICOM 原始影像 `.nii.gz`。
|
||||||
|
- 与原始影像同维度/spacing 的分割影像 `.nii.gz`。
|
||||||
|
- 项目位姿与构件样式 JSON。
|
||||||
|
- 重新进入项目仍可读取的已保存位姿。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `WebSite/src/lib/api.ts`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- 本次工程分析文档与 `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 不引入新依赖。
|
||||||
|
- 旧的分割导出入口仍需兼容。
|
||||||
|
- NIfTI 文件需要保持标准 NIfTI-1 单文件布局,设置正确 dim、datatype、pixdim、vox_offset、magic。
|
||||||
|
- 位姿保存不能只停留在 React 组件内存中,需要写入项目状态文件。
|
||||||
|
- 本次提交不能混入历史 `工程分析` 文档删除状态。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 导出完整 512x512x300 体数据会比旧 64x64x64 演示 Mask 大很多,生成和下载耗时增加。
|
||||||
|
- 当前分割影像仍是后端基于 DICOM 像素阈值生成的同维度标签图,不等同于完整 STL 体素化算法。
|
||||||
|
- HTTP 访问环境下浏览器仍可能对非 HTTPS 下载有提示,但去掉 blob URL 可消除当前报错指向的 `blob:http` 链路。
|
||||||
|
|
||||||
|
## 默认假设
|
||||||
|
|
||||||
|
- “DICOM 原始影像”导出优先保证 ITK-SNAP 可打开与空间尺寸匹配,使用当前解析到的 CT HU int16 数据写入 NIfTI。
|
||||||
|
- “位姿数据”作为 JSON 侧车文件导出,供复现实验参数使用,不作为 ITK-SNAP 主影像打开。
|
||||||
Reference in New Issue
Block a user