diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 4d24707..51f6638 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -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() { `).join("") : `
当前切片暂无可见构件
`; + 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", () => { diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index 5194f51..eeb3953 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -353,7 +353,8 @@
Img: -