2026-05-24-15-37-16 修正mask线条桥接与DICOM复位入口

This commit is contained in:
2026-05-24 15:47:59 +08:00
parent f279770a0e
commit e9f0823281
7 changed files with 515 additions and 86 deletions

View File

@@ -1045,6 +1045,68 @@ function addExportSegmentToRows(rows: number[][], width: number, height: number,
}
}
function groupExportSegmentsByConnectivity(segments: PlaneSegmentRecord[], tolerance = 1.35) {
if (segments.length <= 1) {
return segments.length ? [segments] : [];
}
const parents = segments.map((_, index) => index);
const find = (index: number): number => {
if (parents[index] !== index) {
parents[index] = find(parents[index]);
}
return parents[index];
};
const union = (left: number, right: number) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) {
parents[rightRoot] = leftRoot;
}
};
const buckets = new Map<string, Array<{ x: number; y: number; index: number }>>();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x: number, y: number) => `${x},${y}`;
segments.forEach((segment, index) => {
[segment.a, segment.b].forEach((point) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
return;
}
const cellX = Math.floor(point.x / cellSize);
const cellY = Math.floor(point.y / cellSize);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
candidates?.forEach((candidate) => {
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
if (distanceSquared <= toleranceSquared) {
union(index, candidate.index);
}
});
}
}
const key = cellKey(cellX, cellY);
const bucket = buckets.get(key) ?? [];
bucket.push({ x: point.x, y: point.y, index });
buckets.set(key, bucket);
});
});
const groups = new Map<number, PlaneSegmentRecord[]>();
segments.forEach((segment, index) => {
const root = find(index);
const group = groups.get(root) ?? [];
group.push(segment);
groups.set(root, group);
});
return [...groups.values()].sort((left, right) => right.length - left.length);
}
function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) {
const outside = new Uint8Array(width * height);
const stack: number[] = [];
@@ -1096,6 +1158,53 @@ function fillExportInternalHoles(mask: Uint8Array, width: number, height: number
return patchedPixels;
}
function closeExportMaskGaps(mask: Uint8Array, width: number, height: number, maxGap = 2) {
const toFill = new Set<number>();
const hasPixel = (x: number, y: number) => mask[y * width + x] > 0;
const mark = (x: number, y: number) => {
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) {
toFill.add(y * width + x);
}
};
for (let y = 0; y < height; y += 1) {
let lastFilled = -1;
for (let x = 0; x < width; x += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = x - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) {
mark(fillX, y);
}
}
lastFilled = x;
}
}
for (let x = 0; x < width; x += 1) {
let lastFilled = -1;
for (let y = 0; y < height; y += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = y - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) {
mark(x, fillY);
}
}
lastFilled = y;
}
}
toFill.forEach((index) => {
mask[index] = 1;
});
return toFill.size;
}
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) {
const mask = new Uint8Array(width * height);
let filledPixels = 0;
@@ -1137,6 +1246,7 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
return 0;
}
filledPixels += closeExportMaskGaps(mask, width, height);
filledPixels += fillExportInternalHoles(mask, width, height);
const sliceOffset = slice * width * height;
for (let index = 0; index < mask.length; index += 1) {
@@ -1269,14 +1379,13 @@ function createSegmentationData(
}
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
const slicesByIndex = new Map<number, { rows: number[][]; segments: PlaneSegmentRecord[] }>();
const slicesByIndex = new Map<number, { segments: PlaneSegmentRecord[] }>();
const entryForSlice = (slice: number) => {
const existing = slicesByIndex.get(slice);
if (existing) {
return existing;
}
const entry = {
rows: Array.from({ length: volume.height }, () => [] as number[]),
segments: [] as PlaneSegmentRecord[],
};
slicesByIndex.set(slice, entry);
@@ -1330,16 +1439,19 @@ function createSegmentationData(
b: mapPoint(segment.b),
};
const entry = entryForSlice(slice);
addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment);
entry.segments.push(mappedSegment);
}
});
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);
}
slicesByIndex.forEach(({ segments }, slice) => {
groupExportSegmentsByConnectivity(segments).forEach((group) => {
const rows = Array.from({ length: volume.height }, () => [] as number[]);
group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment));
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label);
if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) {
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label);
}
});
});
});

View File

@@ -327,7 +327,7 @@ function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex)));
}
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
const [isPanning, setIsPanning] = useState(false);
@@ -348,9 +348,10 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
drawDicomPreviewToCanvas(canvas, preview, rotation);
}, [preview, rotation]);
const resetViewport = () => {
useEffect(() => {
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
};
}, [resetSignal]);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
@@ -418,15 +419,6 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
/>
</div>
<button
onClick={resetViewport}
onPointerDown={(event) => event.stopPropagation()}
className="absolute right-3 top-3 z-10 flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-black/65 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
title="重置 DICOM 图片位置"
>
<RefreshCcw size={13} />
</button>
</div>
);
}
@@ -838,6 +830,7 @@ export default function ProjectLibrary({
const [plane, setPlane] = useState<Plane>('axial');
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
const [rotation, setRotation] = useState(0);
const [dicomViewportResetSignal, setDicomViewportResetSignal] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
@@ -1814,6 +1807,13 @@ export default function ProjectLibrary({
>
<RotateCw size={12} />
</button>
<button
onClick={() => setDicomViewportResetSignal((value) => value + 1)}
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
title="重置 DICOM 图片位置"
>
<RefreshCcw size={12} />
</button>
</div>
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
@@ -1822,7 +1822,7 @@ export default function ProjectLibrary({
</div>
<div className={`relative w-full h-full flex items-center justify-center transition-all duration-150 ${isSliceChanging ? 'scale-[1.01] opacity-85 brightness-110' : 'scale-100 opacity-100 brightness-100'}`}>
{dicomPreview ? (
<DicomCanvas preview={dicomPreview} rotation={rotation} />
<DicomCanvas preview={dicomPreview} rotation={rotation} resetSignal={dicomViewportResetSignal} />
) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}

View File

@@ -1666,6 +1666,155 @@ function fillInternalMaskHoles(
return patchedPixels;
}
function addSegmentIntersectionsToRows(rows: number[][], width: number, height: number, segment: PlaneSegment) {
const { a, b } = segment;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
return;
}
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 0.01) {
return;
}
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
if (!crosses) {
continue;
}
const t = (sampleY - a.y) / deltaY;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) {
rows[row].push(x);
}
}
}
function groupPlaneSegmentsByConnectivity(segments: PlaneSegment[], tolerance = 1.35) {
if (segments.length <= 1) {
return segments.length ? [segments] : [];
}
const parents = segments.map((_, index) => index);
const find = (index: number): number => {
if (parents[index] !== index) {
parents[index] = find(parents[index]);
}
return parents[index];
};
const union = (left: number, right: number) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) {
parents[rightRoot] = leftRoot;
}
};
const buckets = new Map<string, Array<{ x: number; y: number; index: number }>>();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x: number, y: number) => `${x},${y}`;
segments.forEach((segment, index) => {
[segment.a, segment.b].forEach((point) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
return;
}
const cellX = Math.floor(point.x / cellSize);
const cellY = Math.floor(point.y / cellSize);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
candidates?.forEach((candidate) => {
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
if (distanceSquared <= toleranceSquared) {
union(index, candidate.index);
}
});
}
}
const key = cellKey(cellX, cellY);
const bucket = buckets.get(key) ?? [];
bucket.push({ x: point.x, y: point.y, index });
buckets.set(key, bucket);
});
});
const groups = new Map<number, PlaneSegment[]>();
segments.forEach((segment, index) => {
const root = find(index);
const group = groups.get(root) ?? [];
group.push(segment);
groups.set(root, group);
});
return [...groups.values()].sort((left, right) => right.length - left.length);
}
function closeSmallMaskGaps(
maskData: ImageData,
width: number,
height: number,
rgb: { r: number; g: number; b: number },
alpha: number,
maxGap = 2,
) {
const toFill = new Set<number>();
const hasPixel = (x: number, y: number) => maskData.data[(y * width + x) * 4 + 3] > 0;
const mark = (x: number, y: number) => {
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) {
toFill.add(y * width + x);
}
};
for (let y = 0; y < height; y += 1) {
let lastFilled = -1;
for (let x = 0; x < width; x += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = x - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) {
mark(fillX, y);
}
}
lastFilled = x;
}
}
for (let x = 0; x < width; x += 1) {
let lastFilled = -1;
for (let y = 0; y < height; y += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = y - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) {
mark(x, fillY);
}
}
lastFilled = y;
}
}
toFill.forEach((index) => {
const offset = index * 4;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
});
return toFill.size;
}
function drawFallbackClosedRegion(
context: CanvasRenderingContext2D,
width: number,
@@ -1688,7 +1837,17 @@ function drawFallbackClosedRegion(
return 0;
}
const sorted = [...points].sort((left, right) => (
const uniquePoints: Point2D[] = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => pointDistanceSquared(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: Point2D, a: Point2D, b: Point2D) => (
@@ -1709,7 +1868,7 @@ function drawFallbackClosedRegion(
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const ordered = hull.length >= 3 ? hull : points;
const ordered = hull.length >= 3 ? hull : uniquePoints;
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
@@ -1741,35 +1900,6 @@ function fillSegmentsAsSolidMask(
return 0;
}
const rows: number[][] = Array.from({ length: height }, () => []);
segments.forEach((segment) => {
const { a, b } = segment;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
return;
}
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 0.01) {
return;
}
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
if (!crosses) {
continue;
}
const t = (sampleY - a.y) / deltaY;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) {
rows[row].push(x);
}
}
});
const rgb = parseHexColor(color);
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
const maskCanvas = document.createElement('canvas');
@@ -1781,51 +1911,67 @@ function fillSegmentsAsSolidMask(
}
const maskData = maskContext.createImageData(width, height);
let filledPixels = 0;
const groups = groupPlaneSegmentsByConnectivity(segments);
const fallbackGroups: PlaneSegment[][] = [];
rows.forEach((intersections, row) => {
if (intersections.length < 2) {
return;
}
groups.forEach((group) => {
const rows: number[][] = Array.from({ length: height }, () => []);
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
let groupPixels = 0;
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);
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 = clamp(Math.ceil(rawStartX), 0, width - 1);
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
if (endX < startX) {
continue;
}
for (let x = startX; x <= endX; x += 1) {
const offset = (row * width + x) * 4;
if (maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
groupPixels += 1;
}
}
}
});
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 = clamp(Math.ceil(rawStartX), 0, width - 1);
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
if (endX < startX) {
continue;
}
for (let x = startX; x <= endX; x += 1) {
const offset = (row * width + x) * 4;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
filledPixels += 1;
}
filledPixels += groupPixels;
if (groupPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) {
fallbackGroups.push(group);
}
});
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
filledPixels += drawFallbackClosedRegion(context, width, height, segments, color, opacity);
}
fallbackGroups.forEach((group) => {
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
});
if (filledPixels === 0) {
context.save();