2026-05-20-01-38-33 完善NII导出与位姿持久化
This commit is contained in:
@@ -17,6 +17,22 @@ interface ModuleStyleRecord {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -41,6 +57,7 @@ interface ProjectRecord {
|
||||
exportedMaskCount: number;
|
||||
isDefault?: boolean;
|
||||
moduleStyles: Record<string, ModuleStyleRecord>;
|
||||
modelPoses: ModelPoseRecord[];
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
@@ -79,6 +96,15 @@ const dicomVolumeCache = new Map<DicomDisplayMode, {
|
||||
}>();
|
||||
const modelPreviewCache = new Map<string, unknown>();
|
||||
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 {
|
||||
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 {
|
||||
const stlFiles = listFiles(modelDir, '.stl');
|
||||
|
||||
@@ -204,6 +286,7 @@ function buildDefaultProject(): ProjectRecord {
|
||||
exportedMaskCount: 0,
|
||||
isDefault: true,
|
||||
moduleStyles: buildModuleStyles(stlFiles),
|
||||
modelPoses: defaultModelPoses(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,6 +305,7 @@ function buildEmptyProject(name: string): ProjectRecord {
|
||||
maskFormats: ['nii', 'nii.gz'],
|
||||
exportedMaskCount: 0,
|
||||
moduleStyles: {},
|
||||
modelPoses: defaultModelPoses(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +333,7 @@ function normalizeState(state: AppState): AppState {
|
||||
exportedMaskCount: project.exportedMaskCount ?? 0,
|
||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||
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,
|
||||
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
|
||||
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
|
||||
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
|
||||
},
|
||||
...customProjects,
|
||||
],
|
||||
@@ -290,41 +376,141 @@ function writeState(state: AppState) {
|
||||
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
|
||||
}
|
||||
|
||||
function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
||||
const width = 64;
|
||||
const height = 64;
|
||||
const depth = 64;
|
||||
const headerSize = 348;
|
||||
const voxOffset = 352;
|
||||
const voxelCount = width * height * depth;
|
||||
const data = Buffer.alloc(voxelCount);
|
||||
const center = [width / 2, height / 2, depth / 2];
|
||||
interface DicomHuVolume {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
columnSpacing: number;
|
||||
rowSpacing: number;
|
||||
sliceSpacing: number;
|
||||
data: Buffer;
|
||||
minHu: number;
|
||||
maxHu: number;
|
||||
}
|
||||
|
||||
for (let z = 0; z < depth; z += 1) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
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;
|
||||
interface Point2DRecord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
if (radius < 1) {
|
||||
data[index] = 1;
|
||||
}
|
||||
interface Point3DRecord {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const tumorDx = (x - 42) / 8;
|
||||
const tumorDy = (y - 30) / 7;
|
||||
const tumorDz = (z - 34) / 7;
|
||||
if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) {
|
||||
data[index] = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
interface PlaneSegmentRecord {
|
||||
a: Point2DRecord;
|
||||
b: Point2DRecord;
|
||||
}
|
||||
|
||||
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);
|
||||
header.writeInt32LE(headerSize, 0);
|
||||
header.writeInt32LE(348, 0);
|
||||
header.writeInt16LE(3, 40);
|
||||
header.writeInt16LE(width, 42);
|
||||
header.writeInt16LE(height, 44);
|
||||
@@ -333,22 +519,396 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
||||
header.writeInt16LE(1, 50);
|
||||
header.writeInt16LE(1, 52);
|
||||
header.writeInt16LE(1, 54);
|
||||
header.writeInt16LE(2, 70);
|
||||
header.writeInt16LE(8, 72);
|
||||
header.writeInt16LE(datatype, 70);
|
||||
header.writeInt16LE(bitpix, 72);
|
||||
header.writeFloatLE(1, 76);
|
||||
header.writeFloatLE(1, 80);
|
||||
header.writeFloatLE(1, 84);
|
||||
header.writeFloatLE(1, 88);
|
||||
header.writeFloatLE(columnSpacing, 80);
|
||||
header.writeFloatLE(rowSpacing, 84);
|
||||
header.writeFloatLE(sliceSpacing, 88);
|
||||
header.writeFloatLE(voxOffset, 108);
|
||||
header.writeFloatLE(1, 112);
|
||||
header.write('ReVoxelSeg demo mask', 148, 'ascii');
|
||||
header.write(`Project ${project.id}`, 228, 'ascii');
|
||||
header.writeUInt8(2, 123);
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
||||
function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord {
|
||||
const cacheKey = `${fileName}:${limit}`;
|
||||
const cached = modelPreviewCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
return cached as ModelPreviewRecord;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
@@ -834,7 +1394,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: ModelPreviewRecord = {
|
||||
fileName,
|
||||
triangleCount,
|
||||
sampledTriangles,
|
||||
@@ -1120,6 +1680,25 @@ async function startServer() {
|
||||
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) => {
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
if (!project) {
|
||||
@@ -1298,7 +1877,7 @@ async function startServer() {
|
||||
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 project = state.projects.find((candidate) => candidate.id === req.params.projectId);
|
||||
|
||||
@@ -1307,19 +1886,42 @@ async function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
const mask = createNiftiMask(project, compressed);
|
||||
const filename = `${project.id}-segmentation-mask.${format}`;
|
||||
const outputPath = path.join(exportDir, filename);
|
||||
fs.writeFileSync(outputPath, mask);
|
||||
project.exportedMaskCount += 1;
|
||||
writeState(state);
|
||||
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
|
||||
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
|
||||
const activePose = parseModelPoseQuery(req.query.pose);
|
||||
|
||||
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(mask);
|
||||
});
|
||||
try {
|
||||
if (target === 'pose') {
|
||||
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') {
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
Reference in New Issue
Block a user