2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互

This commit is contained in:
2026-05-24 11:11:07 +08:00
parent f54dafb83d
commit f279770a0e
12 changed files with 799 additions and 147 deletions

View File

@@ -32,6 +32,9 @@ interface ModelPoseValue {
translateY: number;
translateZ: number;
scale: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
}
interface ModelPoseRecord {
@@ -141,6 +144,9 @@ const defaultModelPose: ModelPoseValue = {
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
interface DicomAttributes {
@@ -379,6 +385,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
? clampNumber(nextValue, min, max)
: fallback;
};
const readBoolean = (key: keyof ModelPoseValue, fallback: boolean) => (
typeof value?.[key] === 'boolean' ? Boolean(value?.[key]) : fallback
);
return {
rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180),
@@ -388,6 +397,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
scale: read('scale', defaultModelPose.scale, 0.5, 2),
flipX: readBoolean('flipX', defaultModelPose.flipX),
flipY: readBoolean('flipY', defaultModelPose.flipY),
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
};
}
@@ -859,9 +871,10 @@ function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, preview
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;
let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
pz += metrics.modelPivotOffsetZ * scalar;
const rotateX = (pose.rotateX * Math.PI) / 180;
const rotateY = (pose.rotateY * Math.PI) / 180;
const rotateZ = (pose.rotateZ * Math.PI) / 180;
@@ -1135,6 +1148,70 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
return filledPixels;
}
function fillExportFallbackClosedRegion(
data: Buffer,
width: number,
height: number,
slice: number,
segments: PlaneSegmentRecord[],
label: number,
) {
const points = segments.flatMap((segment) => [segment.a, segment.b])
.filter((point) => (
Number.isFinite(point.x)
&& Number.isFinite(point.y)
&& point.x >= -width
&& point.x <= width * 2
&& point.y >= -height
&& point.y <= height * 2
));
if (points.length < 3) {
return 0;
}
const uniquePoints: Point2DRecord[] = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-6)) {
uniquePoints.push(point);
}
});
if (uniquePoints.length < 3) {
return 0;
}
const sorted = [...uniquePoints].sort((left, right) => (
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
));
const cross = (origin: Point2DRecord, a: Point2DRecord, b: Point2DRecord) => (
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
);
const lower: Point2DRecord[] = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
lower.pop();
}
lower.push(point);
});
const upper: Point2DRecord[] = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
upper.pop();
}
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const polygon = hull.length >= 3 ? hull : uniquePoints;
const rows = Array.from({ length: height }, () => [] as number[]);
polygon.forEach((point, index) => {
const nextPoint = polygon[(index + 1) % polygon.length];
addExportSegmentToRows(rows, width, height, { a: point, b: nextPoint });
});
return fillExportRows(data, width, height, slice, rows, label);
}
function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord {
return project.moduleStyles[fileName] ?? {
visible: true,
@@ -1192,15 +1269,18 @@ function createSegmentationData(
}
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);
const slicesByIndex = new Map<number, { rows: number[][]; segments: PlaneSegmentRecord[] }>();
const entryForSlice = (slice: number) => {
const existing = slicesByIndex.get(slice);
if (existing) {
return existing;
}
const rows = Array.from({ length: volume.height }, () => [] as number[]);
rowsBySlice.set(slice, rows);
return rows;
const entry = {
rows: Array.from({ length: volume.height }, () => [] as number[]),
segments: [] as PlaneSegmentRecord[],
};
slicesByIndex.set(slice, entry);
return entry;
};
const filePath = getProjectModelFilePath(project, fileName);
@@ -1245,15 +1325,21 @@ function createSegmentationData(
continue;
}
addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, {
const mappedSegment = {
a: mapPoint(segment.a),
b: mapPoint(segment.b),
});
};
const entry = entryForSlice(slice);
addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment);
entry.segments.push(mappedSegment);
}
});
rowsBySlice.forEach((rows, slice) => {
fillExportRows(data, volume.width, volume.height, slice, rows, label);
slicesByIndex.forEach(({ rows, segments }, slice) => {
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label);
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, segments, label);
}
});
});
@@ -1568,7 +1654,7 @@ function createProjectExportBundle({
}
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
entries.push({
name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
data: createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
@@ -2134,17 +2220,35 @@ function writeOctal(buffer: Buffer, value: number, offset: number, length: numbe
buffer.write(`${text}\0`, offset, length, 'ascii');
}
function createTarEntryHeader(name: string, size: number, mtime: number) {
function writeTarText(buffer: Buffer, value: string, offset: number, length: number) {
const source = Buffer.from(value, 'utf8');
source.copy(buffer, offset, 0, Math.min(length, source.length));
}
function createPaxRecord(key: string, value: string) {
const payload = `${key}=${value}\n`;
let length = Buffer.byteLength(payload, 'utf8') + 3;
while (true) {
const record = `${length} ${payload}`;
const nextLength = Buffer.byteLength(record, 'utf8');
if (nextLength === length) {
return record;
}
length = nextLength;
}
}
function createTarEntryHeader(name: string, size: number, mtime: number, typeFlag: '0' | 'x' = '0') {
const header = Buffer.alloc(512);
const safeName = name.slice(0, 100);
header.write(safeName, 0, 100, 'utf8');
writeTarText(header, name, 0, 100);
writeOctal(header, 0o644, 100, 8);
writeOctal(header, 0, 108, 8);
writeOctal(header, 0, 116, 8);
writeOctal(header, size, 124, 12);
writeOctal(header, Math.floor(mtime), 136, 12);
header.fill(' ', 148, 156);
header.write('0', 156, 1, 'ascii');
header.write(typeFlag, 156, 1, 'ascii');
header.write('ustar', 257, 6, 'ascii');
header.write('00', 263, 2, 'ascii');
@@ -2159,14 +2263,29 @@ function createTarEntryHeader(name: string, size: number, mtime: number) {
function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) {
const chunks: Buffer[] = [];
entries.forEach((entry) => {
const data = entry.data;
chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000));
const pushEntry = (name: string, data: Buffer, mtime: number, typeFlag: '0' | 'x' = '0') => {
chunks.push(createTarEntryHeader(name, data.length, mtime, typeFlag));
chunks.push(data);
const remainder = data.length % 512;
if (remainder > 0) {
chunks.push(Buffer.alloc(512 - remainder));
}
};
entries.forEach((entry, index) => {
const data = entry.data;
const mtime = entry.mtime ?? Date.now() / 1000;
const needsPaxPath = Buffer.byteLength(entry.name, 'utf8') > 100 || /[^\x20-\x7e]/.test(entry.name);
let headerName = entry.name;
if (needsPaxPath) {
const paxData = Buffer.from(createPaxRecord('path', entry.name), 'utf8');
const paxName = `PaxHeaders/${String(index + 1).padStart(6, '0')}`;
headerName = `entries/${String(index + 1).padStart(6, '0')}`;
pushEntry(paxName, paxData, mtime, 'x');
}
pushEntry(headerName, data, mtime);
});
chunks.push(Buffer.alloc(1024));