Improve segmentation mapping render

This commit is contained in:
Codex
2026-05-29 11:24:48 +08:00
parent adb2f41213
commit 4bb482c4b0
2 changed files with 304 additions and 73 deletions

View File

@@ -104,6 +104,7 @@ const state = {
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
mappingVersion: 0,
resizeObserver: null,
resizeScene: null,
nudgeTimer: null,
@@ -122,6 +123,10 @@ function cls(value) {
return value ? "active" : "";
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
@@ -139,6 +144,18 @@ function setFusionLoading(visible, text = "正在构建融合视图", progress =
}
}
function setDicomLoading(visible, text = "正在加载分割结果", progress = null) {
$("dicomLoading").classList.toggle("hidden", !visible);
$("dicomLoading").querySelector("span").textContent = text;
const fill = $("dicomProgressFill");
const label = $("dicomProgressText");
if (!fill || !label) return;
const value = progress == null ? 0 : clamp(Number(progress) || 0, 0, 100);
fill.style.width = progress == null ? "34%" : `${value}%`;
fill.classList.toggle("indeterminate", progress == null);
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
}
function setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
@@ -969,7 +986,7 @@ function updateDicomPreview() {
window: state.windowMode,
access_token: state.token,
});
$("dicomLoading").classList.remove("hidden");
setDicomLoading(true, "正在加载 DICOM Base Layer", 16);
resetMappingCanvas("正在加载 DICOM Base Layer");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
@@ -1370,7 +1387,8 @@ function resetMappingCanvas(message = "当前切片暂无可见构件") {
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
const version = ++state.mappingVersion;
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version));
}
function applyMappingTransform() {
@@ -1417,82 +1435,209 @@ function addSegmentIntersectionsToRows(rows, width, height, segment) {
const a = segment.a;
const b = segment.b;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) 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)));
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 1e-6) {
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
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;
if (t < 0 || t > 1) continue;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
const imageData = maskContext.createImageData(width, height);
const rows = Array.from({ length: height }, () => []);
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
let filled = 0;
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
rows.forEach((intersections, y) => {
if (intersections.length < 2) return;
intersections.sort((a, b) => a - b);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) {
if (segments.length <= 1) return segments.length ? [segments] : [];
const parents = segments.map((_, index) => index);
const find = (index) => {
if (parents[index] !== index) parents[index] = find(parents[index]);
return parents[index];
};
const union = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
};
const buckets = new Map();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x, y) => `${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);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (y * width + x) * 4;
imageData.data[offset] = rgb.r;
imageData.data[offset + 1] = rgb.g;
imageData.data[offset + 2] = rgb.b;
imageData.data[offset + 3] = alpha;
filled += 1;
});
const groups = new Map();
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 paintMaskPixel(maskData, width, height, x, y, rgb, alpha) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
const offset = (y * width + x) * 4;
if (maskData.data[offset + 3] > 0) return 0;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
return 1;
}
function solidStrokeRadius(width, height) {
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}
function fillSegmentCapsulesIntoMask(maskData, width, height, segments, rgb, alpha, radius) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
if ((px - closestX) ** 2 + (py - closestY) ** 2 <= radiusSquared) {
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
}
}
}
});
const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity);
maskContext.putImageData(imageData, 0, 0);
context.globalAlpha = 0.45;
context.drawImage(maskCanvas, 0, 0);
context.globalAlpha = 1;
return filled + hullPixels;
return paintedPixels;
}
function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
const points = [];
segments.forEach((segment) => {
[segment.a, segment.b].forEach((point) => {
if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) {
points.push({ x: point.x, y: point.y });
function closeSmallMaskGaps(maskData, width, height, rgb, alpha, maxGap = 2) {
const toFill = new Set();
const hasPixel = (x, y) => maskData.data[(y * width + x) * 4 + 3] > 0;
const mark = (x, y) => {
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 fillInternalMaskHoles(maskData, width, height, rgb, alpha) {
const outside = new Uint8Array(width * height);
const stack = [];
const pushIfEmpty = (x, y) => {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const index = y * width + x;
if (outside[index] || maskData.data[index * 4 + 3] > 0) return;
outside[index] = 1;
stack.push(index);
};
for (let x = 0; x < width; x += 1) {
pushIfEmpty(x, 0);
pushIfEmpty(x, height - 1);
}
for (let y = 0; y < height; y += 1) {
pushIfEmpty(0, y);
pushIfEmpty(width - 1, y);
}
while (stack.length) {
const index = stack.pop();
const x = index % width;
const y = Math.floor(index / width);
pushIfEmpty(x + 1, y);
pushIfEmpty(x - 1, y);
pushIfEmpty(x, y + 1);
pushIfEmpty(x, y - 1);
}
let patchedPixels = 0;
for (let index = 0; index < outside.length; index += 1) {
const offset = index * 4;
if (!outside[index] && 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;
patchedPixels += 1;
}
}
return patchedPixels;
}
function drawFallbackClosedRegion(context, width, height, segments, color, opacity = 0.72) {
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 sorted = points
.filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index)
.sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y));
if (sorted.length < 3) return 0;
const uniquePoints = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 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, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
const lower = [];
sorted.forEach((point) => {
@@ -1505,39 +1650,117 @@ function fillConvexHullMask(context, width, height, segments, color, opacity = 0
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
if (hull.length < 3) return 0;
const ordered = hull.length >= 3 ? hull : uniquePoints;
context.save();
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)) * 0.58;
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
context.fillStyle = color;
context.strokeStyle = color;
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002);
context.beginPath();
hull.forEach((point, index) => {
ordered.forEach((point, index) => {
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.closePath();
context.fill();
context.globalAlpha = Math.max(0.22, Math.min(opacity, 1));
context.stroke();
context.restore();
return Math.max(1, Math.round(hull.length * 6));
return Math.max(1, Math.round(ordered.length / 2));
}
function drawMappingView() {
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return 0;
const maskData = maskContext.createImageData(width, height);
const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
const fallbackGroups = [];
let filledPixels = 0;
groups.forEach((group) => {
const rows = Array.from({ length: height }, () => []);
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
let groupPixels = 0;
rows.forEach((intersections, row) => {
if (intersections.length < 2) return;
intersections.sort((left, right) => left - right);
const cleaned = [];
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;
}
}
}
});
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels;
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) fallbackGroups.push(group);
});
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
fallbackGroups.forEach((group) => {
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
});
if (filledPixels === 0) {
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
}
return filledPixels;
}
async function drawMappingView(version = state.mappingVersion) {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
setDicomLoading(false);
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
const visibleRecords = visibleModelRecords();
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
if (!visibleRecords.length) setDicomLoading(false);
else setDicomLoading(true, "等待 DICOM Base Layer", 32);
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
setDicomLoading(true, "正在计算 STL 分割映射", 48);
const width = image.naturalWidth;
const height = image.naturalHeight;
@@ -1561,9 +1784,10 @@ function drawMappingView() {
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
visibleRecords.forEach((record, index) => {
for (const [index, record] of visibleRecords.entries()) {
if (version !== state.mappingVersion) return;
const position = record.mesh.geometry.attributes.position;
if (!position) return;
if (!position) continue;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000));
const segments = [];
@@ -1599,7 +1823,12 @@ function drawMappingView() {
pixels,
});
}
});
const progress = 48 + ((index + 1) / Math.max(visibleRecords.length, 1)) * 48;
const label = record.file.segment_name || record.file.file_name || `STL ${index + 1}`;
setDicomLoading(true, `正在计算 ${label}`, progress);
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
if (version !== state.mappingVersion) return;
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
@@ -1614,6 +1843,7 @@ function drawMappingView() {
</div>
`).join("")
: `<div class="empty-state">当前切片暂无可见构件</div>`;
setDicomLoading(false);
}
async function buildDicomVolume(volume) {
@@ -2314,11 +2544,11 @@ function wireEvents() {
$("autoIterations").value = 30;
});
$("dicomPreview").addEventListener("load", () => {
$("dicomLoading").classList.add("hidden");
setDicomLoading(true, "正在计算 STL 分割映射", 42);
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
$("dicomLoading").classList.add("hidden");
setDicomLoading(false);
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {

View File

@@ -353,7 +353,8 @@
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载分割结果</span>
<div><i></i></div>
<div><i id="dicomProgressFill"></i></div>
<em id="dicomProgressText">0%</em>
</div>
</div>
<div class="mapping-summary">