Improve segmentation mapping render
This commit is contained in:
@@ -104,6 +104,7 @@ const state = {
|
|||||||
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
|
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
|
||||||
mappingDrag: null,
|
mappingDrag: null,
|
||||||
mappingDrawFrame: 0,
|
mappingDrawFrame: 0,
|
||||||
|
mappingVersion: 0,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
resizeScene: null,
|
resizeScene: null,
|
||||||
nudgeTimer: null,
|
nudgeTimer: null,
|
||||||
@@ -122,6 +123,10 @@ function cls(value) {
|
|||||||
return value ? "active" : "";
|
return value ? "active" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
function setTopLoading(visible) {
|
function setTopLoading(visible) {
|
||||||
$("topLoading").classList.toggle("hidden", !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 = "") {
|
function setSaveState(text, tone = "") {
|
||||||
const el = $("saveState");
|
const el = $("saveState");
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
@@ -969,7 +986,7 @@ function updateDicomPreview() {
|
|||||||
window: state.windowMode,
|
window: state.windowMode,
|
||||||
access_token: state.token,
|
access_token: state.token,
|
||||||
});
|
});
|
||||||
$("dicomLoading").classList.remove("hidden");
|
setDicomLoading(true, "正在加载 DICOM Base Layer", 16);
|
||||||
resetMappingCanvas("正在加载 DICOM Base Layer");
|
resetMappingCanvas("正在加载 DICOM Base Layer");
|
||||||
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
@@ -1370,7 +1387,8 @@ function resetMappingCanvas(message = "当前切片暂无可见构件") {
|
|||||||
|
|
||||||
function scheduleMappingDraw() {
|
function scheduleMappingDraw() {
|
||||||
window.cancelAnimationFrame(state.mappingDrawFrame);
|
window.cancelAnimationFrame(state.mappingDrawFrame);
|
||||||
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
|
const version = ++state.mappingVersion;
|
||||||
|
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMappingTransform() {
|
function applyMappingTransform() {
|
||||||
@@ -1417,82 +1435,209 @@ function addSegmentIntersectionsToRows(rows, width, height, segment) {
|
|||||||
const a = segment.a;
|
const a = segment.a;
|
||||||
const b = segment.b;
|
const b = segment.b;
|
||||||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
|
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;
|
const deltaY = b.y - a.y;
|
||||||
if (Math.abs(deltaY) < 1e-6) {
|
if (Math.abs(deltaY) < 0.01) {
|
||||||
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)));
|
|
||||||
return;
|
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) {
|
for (let row = minY; row <= maxY; row += 1) {
|
||||||
const sampleY = row + 0.5;
|
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 t = (sampleY - a.y) / deltaY;
|
||||||
if (t < 0 || t > 1) continue;
|
|
||||||
const x = a.x + (b.x - a.x) * t;
|
const x = a.x + (b.x - a.x) * t;
|
||||||
if (Number.isFinite(x)) rows[row].push(x);
|
if (Number.isFinite(x)) rows[row].push(x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
|
function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) {
|
||||||
if (!segments.length) return 0;
|
if (segments.length <= 1) return segments.length ? [segments] : [];
|
||||||
const rgb = parseCssColor(color);
|
const parents = segments.map((_, index) => index);
|
||||||
const maskCanvas = document.createElement("canvas");
|
const find = (index) => {
|
||||||
maskCanvas.width = width;
|
if (parents[index] !== index) parents[index] = find(parents[index]);
|
||||||
maskCanvas.height = height;
|
return parents[index];
|
||||||
const maskContext = maskCanvas.getContext("2d");
|
};
|
||||||
const imageData = maskContext.createImageData(width, height);
|
const union = (left, right) => {
|
||||||
const rows = Array.from({ length: height }, () => []);
|
const leftRoot = find(left);
|
||||||
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
|
const rightRoot = find(right);
|
||||||
let filled = 0;
|
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
|
||||||
|
};
|
||||||
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
const buckets = new Map();
|
||||||
rows.forEach((intersections, y) => {
|
const cellSize = Math.max(tolerance, 0.1);
|
||||||
if (intersections.length < 2) return;
|
const toleranceSquared = tolerance * tolerance;
|
||||||
intersections.sort((a, b) => a - b);
|
const cellKey = (x, y) => `${x},${y}`;
|
||||||
const cleaned = [];
|
segments.forEach((segment, index) => {
|
||||||
intersections.forEach((x) => {
|
[segment.a, segment.b].forEach((point) => {
|
||||||
const previous = cleaned[cleaned.length - 1];
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
||||||
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
|
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);
|
||||||
});
|
});
|
||||||
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 key = cellKey(cellX, cellY);
|
||||||
|
const bucket = buckets.get(key) ?? [];
|
||||||
|
bucket.push({ x: point.x, y: point.y, index });
|
||||||
|
buckets.set(key, bucket);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity);
|
const groups = new Map();
|
||||||
maskContext.putImageData(imageData, 0, 0);
|
segments.forEach((segment, index) => {
|
||||||
context.globalAlpha = 0.45;
|
const root = find(index);
|
||||||
context.drawImage(maskCanvas, 0, 0);
|
const group = groups.get(root) ?? [];
|
||||||
context.globalAlpha = 1;
|
group.push(segment);
|
||||||
return filled + hullPixels;
|
groups.set(root, group);
|
||||||
|
});
|
||||||
|
return [...groups.values()].sort((left, right) => right.length - left.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
|
function paintMaskPixel(maskData, width, height, x, y, rgb, alpha) {
|
||||||
const points = [];
|
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
|
||||||
segments.forEach((segment) => {
|
const offset = (y * width + x) * 4;
|
||||||
[segment.a, segment.b].forEach((point) => {
|
if (maskData.data[offset + 3] > 0) return 0;
|
||||||
if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) {
|
maskData.data[offset] = rgb.r;
|
||||||
points.push({ x: point.x, y: point.y });
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return paintedPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
if (points.length < 3) return 0;
|
||||||
const sorted = points
|
const uniquePoints = [];
|
||||||
.filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index)
|
points.forEach((point) => {
|
||||||
.sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y));
|
if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 1e-6)) uniquePoints.push(point);
|
||||||
if (sorted.length < 3) return 0;
|
});
|
||||||
|
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 cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
|
||||||
const lower = [];
|
const lower = [];
|
||||||
sorted.forEach((point) => {
|
sorted.forEach((point) => {
|
||||||
@@ -1505,39 +1650,117 @@ function fillConvexHullMask(context, width, height, segments, color, opacity = 0
|
|||||||
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)];
|
||||||
if (hull.length < 3) return 0;
|
const ordered = hull.length >= 3 ? hull : uniquePoints;
|
||||||
context.save();
|
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.fillStyle = color;
|
||||||
context.strokeStyle = color;
|
|
||||||
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002);
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
hull.forEach((point, index) => {
|
ordered.forEach((point, index) => {
|
||||||
if (index === 0) context.moveTo(point.x, point.y);
|
if (index === 0) context.moveTo(point.x, point.y);
|
||||||
else context.lineTo(point.x, point.y);
|
else context.lineTo(point.x, point.y);
|
||||||
});
|
});
|
||||||
context.closePath();
|
context.closePath();
|
||||||
context.fill();
|
context.fill();
|
||||||
context.globalAlpha = Math.max(0.22, Math.min(opacity, 1));
|
|
||||||
context.stroke();
|
|
||||||
context.restore();
|
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 image = $("dicomPreview");
|
||||||
const canvas = $("mappingCanvas");
|
const canvas = $("mappingCanvas");
|
||||||
const volumeScene = state.volumeScene;
|
const volumeScene = state.volumeScene;
|
||||||
canvas.style.display = state.mappingMode === "image" ? "none" : "";
|
canvas.style.display = state.mappingMode === "image" ? "none" : "";
|
||||||
if (state.mappingMode === "image") {
|
if (state.mappingMode === "image") {
|
||||||
|
setDicomLoading(false);
|
||||||
resetMappingCanvas("单独影像模式未叠加 STL 分割");
|
resetMappingCanvas("单独影像模式未叠加 STL 分割");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const visibleRecords = visibleModelRecords();
|
const visibleRecords = visibleModelRecords();
|
||||||
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
|
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 构件");
|
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setDicomLoading(true, "正在计算 STL 分割映射", 48);
|
||||||
|
|
||||||
const width = image.naturalWidth;
|
const width = image.naturalWidth;
|
||||||
const height = image.naturalHeight;
|
const height = image.naturalHeight;
|
||||||
@@ -1561,9 +1784,10 @@ function drawMappingView() {
|
|||||||
const tempB = new THREE.Vector3();
|
const tempB = new THREE.Vector3();
|
||||||
const tempC = 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;
|
const position = record.mesh.geometry.attributes.position;
|
||||||
if (!position) return;
|
if (!position) continue;
|
||||||
const triangleCount = Math.floor(position.count / 3);
|
const triangleCount = Math.floor(position.count / 3);
|
||||||
const step = Math.max(1, Math.ceil(triangleCount / 70000));
|
const step = Math.max(1, Math.ceil(triangleCount / 70000));
|
||||||
const segments = [];
|
const segments = [];
|
||||||
@@ -1599,7 +1823,12 @@ function drawMappingView() {
|
|||||||
pixels,
|
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`;
|
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
|
||||||
$("mappingLegend").innerHTML = modules.length
|
$("mappingLegend").innerHTML = modules.length
|
||||||
@@ -1614,6 +1843,7 @@ function drawMappingView() {
|
|||||||
</div>
|
</div>
|
||||||
`).join("")
|
`).join("")
|
||||||
: `<div class="empty-state">当前切片暂无可见构件</div>`;
|
: `<div class="empty-state">当前切片暂无可见构件</div>`;
|
||||||
|
setDicomLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildDicomVolume(volume) {
|
async function buildDicomVolume(volume) {
|
||||||
@@ -2314,11 +2544,11 @@ function wireEvents() {
|
|||||||
$("autoIterations").value = 30;
|
$("autoIterations").value = 30;
|
||||||
});
|
});
|
||||||
$("dicomPreview").addEventListener("load", () => {
|
$("dicomPreview").addEventListener("load", () => {
|
||||||
$("dicomLoading").classList.add("hidden");
|
setDicomLoading(true, "正在计算 STL 分割映射", 42);
|
||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
});
|
});
|
||||||
$("dicomPreview").addEventListener("error", () => {
|
$("dicomPreview").addEventListener("error", () => {
|
||||||
$("dicomLoading").classList.add("hidden");
|
setDicomLoading(false);
|
||||||
resetMappingCanvas("DICOM Base Layer 加载失败");
|
resetMappingCanvas("DICOM Base Layer 加载失败");
|
||||||
});
|
});
|
||||||
$("mappingResetBtn").addEventListener("click", () => {
|
$("mappingResetBtn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -353,7 +353,8 @@
|
|||||||
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||||
<div id="dicomLoading" class="fusion-loading hidden">
|
<div id="dicomLoading" class="fusion-loading hidden">
|
||||||
<span>正在加载分割结果</span>
|
<span>正在加载分割结果</span>
|
||||||
<div><i></i></div>
|
<div><i id="dicomProgressFill"></i></div>
|
||||||
|
<em id="dicomProgressText">0%</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mapping-summary">
|
<div class="mapping-summary">
|
||||||
|
|||||||
Reference in New Issue
Block a user