2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user