2026-05-24-15-37-16 修正mask线条桥接与DICOM复位入口
This commit is contained in:
@@ -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) {
|
function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) {
|
||||||
const outside = new Uint8Array(width * height);
|
const outside = new Uint8Array(width * height);
|
||||||
const stack: number[] = [];
|
const stack: number[] = [];
|
||||||
@@ -1096,6 +1158,53 @@ function fillExportInternalHoles(mask: Uint8Array, width: number, height: number
|
|||||||
return patchedPixels;
|
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) {
|
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) {
|
||||||
const mask = new Uint8Array(width * height);
|
const mask = new Uint8Array(width * height);
|
||||||
let filledPixels = 0;
|
let filledPixels = 0;
|
||||||
@@ -1137,6 +1246,7 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filledPixels += closeExportMaskGaps(mask, width, height);
|
||||||
filledPixels += fillExportInternalHoles(mask, width, height);
|
filledPixels += fillExportInternalHoles(mask, width, height);
|
||||||
const sliceOffset = slice * width * height;
|
const sliceOffset = slice * width * height;
|
||||||
for (let index = 0; index < mask.length; index += 1) {
|
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 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 entryForSlice = (slice: number) => {
|
||||||
const existing = slicesByIndex.get(slice);
|
const existing = slicesByIndex.get(slice);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const entry = {
|
const entry = {
|
||||||
rows: Array.from({ length: volume.height }, () => [] as number[]),
|
|
||||||
segments: [] as PlaneSegmentRecord[],
|
segments: [] as PlaneSegmentRecord[],
|
||||||
};
|
};
|
||||||
slicesByIndex.set(slice, entry);
|
slicesByIndex.set(slice, entry);
|
||||||
@@ -1330,18 +1439,21 @@ function createSegmentationData(
|
|||||||
b: mapPoint(segment.b),
|
b: mapPoint(segment.b),
|
||||||
};
|
};
|
||||||
const entry = entryForSlice(slice);
|
const entry = entryForSlice(slice);
|
||||||
addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment);
|
|
||||||
entry.segments.push(mappedSegment);
|
entry.segments.push(mappedSegment);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
slicesByIndex.forEach(({ rows, segments }, slice) => {
|
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);
|
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) {
|
if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) {
|
||||||
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, segments, label);
|
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
|||||||
return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex)));
|
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 canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
@@ -348,9 +348,10 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
|
|||||||
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
||||||
}, [preview, rotation]);
|
}, [preview, rotation]);
|
||||||
|
|
||||||
const resetViewport = () => {
|
useEffect(() => {
|
||||||
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
};
|
}, [resetSignal]);
|
||||||
|
|
||||||
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
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"
|
className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -838,6 +830,7 @@ export default function ProjectLibrary({
|
|||||||
const [plane, setPlane] = useState<Plane>('axial');
|
const [plane, setPlane] = useState<Plane>('axial');
|
||||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [dicomViewportResetSignal, setDicomViewportResetSignal] = useState(0);
|
||||||
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
||||||
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
||||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||||
@@ -1814,6 +1807,13 @@ export default function ProjectLibrary({
|
|||||||
>
|
>
|
||||||
<RotateCw size={12} /> 右转
|
<RotateCw size={12} /> 右转
|
||||||
</button>
|
</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>
|
||||||
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
|
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
|
||||||
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
|
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
|
||||||
@@ -1822,7 +1822,7 @@ export default function ProjectLibrary({
|
|||||||
</div>
|
</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'}`}>
|
<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 ? (
|
{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">
|
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
|
||||||
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
|
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
|
||||||
|
|||||||
@@ -1666,6 +1666,155 @@ function fillInternalMaskHoles(
|
|||||||
return patchedPixels;
|
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(
|
function drawFallbackClosedRegion(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -1688,7 +1837,17 @@ function drawFallbackClosedRegion(
|
|||||||
return 0;
|
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
|
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
|
||||||
));
|
));
|
||||||
const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
|
const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
|
||||||
@@ -1709,7 +1868,7 @@ function drawFallbackClosedRegion(
|
|||||||
upper.push(point);
|
upper.push(point);
|
||||||
});
|
});
|
||||||
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
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.save();
|
||||||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
|
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
|
||||||
@@ -1741,35 +1900,6 @@ function fillSegmentsAsSolidMask(
|
|||||||
return 0;
|
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 rgb = parseHexColor(color);
|
||||||
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
|
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
|
||||||
const maskCanvas = document.createElement('canvas');
|
const maskCanvas = document.createElement('canvas');
|
||||||
@@ -1781,6 +1911,13 @@ function fillSegmentsAsSolidMask(
|
|||||||
}
|
}
|
||||||
const maskData = maskContext.createImageData(width, height);
|
const maskData = maskContext.createImageData(width, height);
|
||||||
let filledPixels = 0;
|
let filledPixels = 0;
|
||||||
|
const groups = groupPlaneSegmentsByConnectivity(segments);
|
||||||
|
const fallbackGroups: PlaneSegment[][] = [];
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const rows: number[][] = Array.from({ length: height }, () => []);
|
||||||
|
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
||||||
|
let groupPixels = 0;
|
||||||
|
|
||||||
rows.forEach((intersections, row) => {
|
rows.forEach((intersections, row) => {
|
||||||
if (intersections.length < 2) {
|
if (intersections.length < 2) {
|
||||||
@@ -1811,21 +1948,30 @@ function fillSegmentsAsSolidMask(
|
|||||||
|
|
||||||
for (let x = startX; x <= endX; x += 1) {
|
for (let x = startX; x <= endX; x += 1) {
|
||||||
const offset = (row * width + x) * 4;
|
const offset = (row * width + x) * 4;
|
||||||
|
if (maskData.data[offset + 3] === 0) {
|
||||||
maskData.data[offset] = rgb.r;
|
maskData.data[offset] = rgb.r;
|
||||||
maskData.data[offset + 1] = rgb.g;
|
maskData.data[offset + 1] = rgb.g;
|
||||||
maskData.data[offset + 2] = rgb.b;
|
maskData.data[offset + 2] = rgb.b;
|
||||||
maskData.data[offset + 3] = alpha;
|
maskData.data[offset + 3] = alpha;
|
||||||
filledPixels += 1;
|
groupPixels += 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);
|
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||||||
maskContext.putImageData(maskData, 0, 0);
|
maskContext.putImageData(maskData, 0, 0);
|
||||||
context.drawImage(maskCanvas, 0, 0);
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
|
fallbackGroups.forEach((group) => {
|
||||||
filledPixels += drawFallbackClosedRegion(context, width, height, segments, color, opacity);
|
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (filledPixels === 0) {
|
if (filledPixels === 0) {
|
||||||
context.save();
|
context.save();
|
||||||
|
|||||||
58
工程分析/实现方案-2026-05-24-15-37-16.md
Normal file
58
工程分析/实现方案-2026-05-24-15-37-16.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 实现方案-2026-05-24-15-37-16
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-24-15-37-16.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 将项目库 DICOM 影像位置重置按钮移动/补充到左转、右转按钮组旁。
|
||||||
|
- 对前端逆向分割映射的切面线段进行连通分组,避免不同组件或孤立碎片被同一行扫描线错误配对。
|
||||||
|
- 对服务端导出 Label Map 使用同样的连通分组策略,减少不规则相连线。
|
||||||
|
- 增加小缝隙闭合处理,缓解实体区域内部横向黑线。
|
||||||
|
- 保留对开口/非流形模型的保守说明。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `工程分析/需求分析-2026-05-24-15-37-16.md`
|
||||||
|
- `工程分析/实现方案-2026-05-24-15-37-16.md`
|
||||||
|
- `工程分析/测试方案-2026-05-24-15-37-16.md`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
1. 项目库 DICOM 画布由父组件下发 reset token,让外部按钮触发内部 viewport 复位。
|
||||||
|
2. 前端 overlay 按切面线段端点近邻关系分组,每个连通组独立扫描线填充。
|
||||||
|
3. 对每个连通组的 mask 做小 gap closing,再做内部孔洞填充。
|
||||||
|
4. 服务端 NIfTI 生成路径复用同样的分组、逐组填充、gap closing 和兜底逻辑。
|
||||||
|
5. 对孤立或开口线段组件减少跨组件配对,避免导出长线桥接。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 创建当次三件套。
|
||||||
|
2. 再次确认已读 `工程分析/经验记录.md`。
|
||||||
|
3. 修改项目库 DICOM 控件。
|
||||||
|
4. 修改前端 overlay 填充算法。
|
||||||
|
5. 修改服务端导出算法。
|
||||||
|
6. 执行 `npm run lint` 和 `npm run build`。
|
||||||
|
7. 重新部署并验证本机、公网、导出包。
|
||||||
|
8. 追加经验记录,提交并推送。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- UI reset token 只影响 DICOM 预览视窗状态,不改变 DICOM 数据。
|
||||||
|
- 连通分组只改变轮廓填充方式,不改变项目状态结构。
|
||||||
|
- 若新填充策略过度保守,可回滚到上一提交 `f279770` 的扫描线逻辑。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 前端组件和后端导出逻辑。
|
||||||
|
- 本次工程分析文档和经验记录。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- commit message:`2026-05-24-15-37-16 修正mask线条桥接与DICOM复位入口`
|
||||||
|
- 部署沿用 `tmux` 会话 `revoxelseg-dicom`,生产模式端口 `4000`。
|
||||||
47
工程分析/测试方案-2026-05-24-15-37-16.md
Normal file
47
工程分析/测试方案-2026-05-24-15-37-16.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 测试方案-2026-05-24-15-37-16
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-24-15-37-16.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- `git status --short --branch`
|
||||||
|
- `git diff --check`
|
||||||
|
- `rg` 检查连通分组、gap closing 和 DICOM reset token 的使用点。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- `cd WebSite && npm run lint`
|
||||||
|
- `cd WebSite && npm run build`
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 项目库 DICOM 影像左转、右转旁可见位置重置按钮。
|
||||||
|
- DICOM 影像滚轮缩放、拖拽平移后,外部位置重置可恢复默认视图。
|
||||||
|
- 逆向分割映射中不同断开的线段不再被大范围水平桥接。
|
||||||
|
- 导出的 `separate` Label Map 文件仍位于 `segmentation-parts/`。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 默认项目仍可导出分割包。
|
||||||
|
- 中文长路径 tar 包仍可完整列出。
|
||||||
|
- 源 STL 开口或非流形时,程序减少错误桥接,但最终说明不承诺能完全恢复真实实体。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||||
|
- 验证 `https://revoxel.huijutec.cn/api/health`。
|
||||||
|
- 验证 `https://revoxel.huijutec.cn/`。
|
||||||
|
|
||||||
|
## Git/Gitea 备份验证
|
||||||
|
|
||||||
|
- commit message 包含 `2026-05-24-15-37-16`。
|
||||||
|
- 推送后 `main` 与 `origin/main` 同步。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 连通分组阈值过大可能连接近邻独立结构,过小则闭合轮廓被拆散。
|
||||||
|
- gap closing 不能跨大距离连接不同器官或构件。
|
||||||
|
- DICOM reset token 不应导致每次切片变化都自动复位用户视图。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1675,3 +1675,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
凡是新增位姿字段,必须同时检查前端类型、默认状态、保存/导入、三维渲染、二维映射、服务端归一化、导出坐标变换和项目库回放,不能只改一个界面。涉及 DICOM 层号时要明确区分“数组索引”和“对用户显示的医学层号”。导出压缩包如果包含中文或深层路径,必须用 `tar -tzf` 验证路径完整,不要依赖 100 字节 tar name 字段。分割显示的视觉修正也应同步确认导出数据路径,避免 UI 看似实体而 NIfTI 仍是轮廓。
|
凡是新增位姿字段,必须同时检查前端类型、默认状态、保存/导入、三维渲染、二维映射、服务端归一化、导出坐标变换和项目库回放,不能只改一个界面。涉及 DICOM 层号时要明确区分“数组索引”和“对用户显示的医学层号”。导出压缩包如果包含中文或深层路径,必须用 `tar -tzf` 验证路径完整,不要依赖 100 字节 tar name 字段。分割显示的视觉修正也应同步确认导出数据路径,避免 UI 看似实体而 NIfTI 仍是轮廓。
|
||||||
|
|
||||||
|
## 2026-05-24-15-37-16 STL 切面填充不能跨断开轮廓全局配对
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户反馈逆向分割映射视图中仍有 mask 呈线条状,导出的 `label.nii` 里 `liver_artery` 等构件出现不规则相连长线和内部黑色线条。项目库 DICOM 影像的“位置重置”也需要放到左转、右转同一工具组,减少查找成本。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
检查 `project-mpea0n3e-akbpn` 的 `liver_artery.stl`,其二进制 STL 约 220920 个三角面、331119 条量化边,其中边界边为 0,非流形边约 27 条,占 0.008%,说明它不是大面积开口模型。主要问题来自旧体素化逻辑:同一切片上把全部相交线段按扫描行收集后全局排序并两两配对。如果同一行存在多个断开的轮廓、孤立碎片或少量非流形小片,就会把不同连通区域的交点配成一条长桥;内部横向黑线则来自相邻扫描行之间的 1-2 像素细缝没有闭合。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
前端 overlay 与服务端 NIfTI 导出均改为先按线段端点近邻关系做连通分组,每个连通组独立扫描线填充和闭合外轮廓兜底,避免跨断开轮廓配对。对已生成的二值 mask 增加小缝隙闭合,再执行内部孔洞填充,用于减少实体区域内部横向黑线。项目库 DICOM 画布增加外部 reset token,把“位置重置”按钮放到左转、右转旁边,直接复位缩放和平移。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
后续做 STL 到 Label Map 的切面填充时,不能只按全局行交点两两配对,必须先按轮廓连通性分组。遇到长线桥接,应同时检查源 STL 的边界边/非流形边和体素化配对策略;如果 STL 是真实中心线或开口片面,只能近似显示,不能承诺恢复真实医学实体。每次修改导出算法后,至少用包含中文构件名和复杂肝脏血管的项目执行一次 `separate + all` 导出,并用 `tar -tzf` 验证文件完整。
|
||||||
|
|||||||
48
工程分析/需求分析-2026-05-24-15-37-16.md
Normal file
48
工程分析/需求分析-2026-05-24-15-37-16.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 需求分析-2026-05-24-15-37-16
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-24-15-37-16
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
- 在项目库 DICOM 影像的左转、右转按钮旁增加位置重置按钮。
|
||||||
|
- 检查逆向工作区的逆向分割映射视图中部分 mask 仍呈线条状的原因,并继续修正。
|
||||||
|
- 检查导出的 `label.nii` 中不规则相连、内部黑线等问题,判断是模型原因还是体素化逻辑原因,并修正可控问题。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- DICOM 预览操作入口更直接,旋转与位置复位放在同一工具组。
|
||||||
|
- 分割映射显示尽量呈现实体区域,减少横向线条、错误桥接和内部空线。
|
||||||
|
- 导出的 NIfTI Label Map 与前端显示逻辑保持一致,避免分离构件之间被扫描线错误连接。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:当前项目库与逆向工作区的 DICOM/STL 数据、用户提供的截图、导出的 Label Map 表现。
|
||||||
|
- 输出:前端交互修正、前后端体素化/映射填充修正、原因说明、构建部署验证、Git/Gitea 提交。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
- 本次三件套文档
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 必须执行 `工程分析/代码编纂工作流.md`。
|
||||||
|
- 修改后重新构建、部署并验证 `https://revoxel.huijutec.cn/`。
|
||||||
|
- 不能把运行态导出文件、医学数据或无关变化混入提交。
|
||||||
|
- 对医学影像结果表述要保守,区分模型源数据问题和程序可修正的体素化问题。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 如果源 STL 是开口面、非流形、中心线或带孤立碎片,无法严格恢复真实实体,只能做显示/导出层面的稳健化处理。
|
||||||
|
- 过度填充可能把本不相连的构件误连,影响 Label Map 后处理。
|
||||||
|
- 过滤线条伪影不能误删真实细小血管或小构件。
|
||||||
|
|
||||||
|
## 默认假设
|
||||||
|
|
||||||
|
- 用户希望优先减少错误线条桥接和内部黑线,而不是保留所有开口 STL 的线状截面。
|
||||||
|
- 对于无法闭合的开口轮廓,可在最终说明中明确源模型质量对结果的影响。
|
||||||
Reference in New Issue
Block a user