diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
index 7c9581b..816da59 100644
--- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
+++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
@@ -1183,12 +1183,131 @@ namespace XP.ImageProcessing.RoiControl.Controls
private void ApplyBrushAt(Point pos)
{
- // 画笔/橡皮:暂时空实现,下一步实现
+ // 画笔/橡皮:下一步实现
}
private void UpdateBubbleResult()
{
- // 计算空隙率:暂时空实现,下一步实现
+ if (_bubbleMask == null || !_bubbleRoi.HasValue) return;
+ var roi = _bubbleRoi.Value;
+ int w = _bubbleMask.PixelWidth, h = _bubbleMask.PixelHeight;
+ int stride = w * 4;
+ var pixels = new byte[stride * h];
+ _bubbleMask.CopyPixels(pixels, stride, 0);
+
+ int roiX0 = Math.Max(0, (int)roi.X), roiY0 = Math.Max(0, (int)roi.Y);
+ int roiX1 = Math.Min(w, (int)roi.Right), roiY1 = Math.Min(h, (int)roi.Bottom);
+
+ int roiArea = 0, voidArea = 0;
+ for (int y = roiY0; y < roiY1; y++)
+ for (int x = roiX0; x < roiX1; x++)
+ {
+ roiArea++;
+ int idx = (y * w + x) * 4;
+ if (pixels[idx + 3] > 0) voidArea++; // alpha > 0 表示已标记
+ }
+
+ double voidRate = roiArea > 0 ? voidArea * 100.0 / roiArea : 0;
+ RaiseMeasureCompleted(roi.TopLeft, roi.BottomRight, voidRate, 1, "BubbleVoid");
+ }
+
+ /// 魔棒:在点击位置做 flood fill
+ public void WandFloodFill(Point pos)
+ {
+ if (_bubbleMask == null || !_bubbleRoi.HasValue || ImageSource == null) return;
+
+ var roi = _bubbleRoi.Value;
+ int px = (int)pos.X, py = (int)pos.Y;
+ if (!roi.Contains(pos)) return;
+
+ // 获取灰度像素
+ var gray = GetGrayscalePixels();
+ if (gray == null) return;
+
+ int w = _bubbleMask.PixelWidth, h = _bubbleMask.PixelHeight;
+ if (px < 0 || px >= w || py < 0 || py >= h) return;
+
+ int seedVal = gray[py * w + px];
+ int lo = _bubbleThreshold, hi = _bubbleThreshold;
+
+ // BFS flood fill
+ var visited = new bool[w * h];
+ var queue = new System.Collections.Generic.Queue<(int x, int y)>();
+ queue.Enqueue((px, py));
+ visited[py * w + px] = true;
+
+ int roiX0 = Math.Max(0, (int)roi.X), roiY0 = Math.Max(0, (int)roi.Y);
+ int roiX1 = Math.Min(w, (int)roi.Right), roiY1 = Math.Min(h, (int)roi.Bottom);
+
+ var filled = new System.Collections.Generic.List<(int x, int y)>();
+
+ while (queue.Count > 0)
+ {
+ var (cx, cy) = queue.Dequeue();
+ int val = gray[cy * w + cx];
+
+ // 阈值判断:与种子点灰度差在阈值范围内
+ if (Math.Abs(val - seedVal) > lo) continue;
+ // 必须在 ROI 内
+ if (cx < roiX0 || cx >= roiX1 || cy < roiY0 || cy >= roiY1) continue;
+
+ filled.Add((cx, cy));
+
+ // 四邻域
+ int[] dx = { -1, 1, 0, 0 }, dy = { 0, 0, -1, 1 };
+ for (int d = 0; d < 4; d++)
+ {
+ int nx = cx + dx[d], ny = cy + dy[d];
+ if (nx >= roiX0 && nx < roiX1 && ny >= roiY0 && ny < roiY1 && !visited[ny * w + nx])
+ {
+ visited[ny * w + nx] = true;
+ queue.Enqueue((nx, ny));
+ }
+ }
+ }
+
+ if (filled.Count == 0) return;
+
+ // 写入 mask(橙色半透明)
+ int stride = w * 4;
+ var maskPixels = new byte[stride * h];
+ _bubbleMask.CopyPixels(maskPixels, stride, 0);
+
+ foreach (var (fx, fy) in filled)
+ {
+ int idx = (fy * w + fx) * 4;
+ maskPixels[idx + 0] = 0; // B
+ maskPixels[idx + 1] = 140; // G
+ maskPixels[idx + 2] = 255; // R (橙色)
+ maskPixels[idx + 3] = 180; // A
+ }
+
+ _bubbleMask.WritePixels(new System.Windows.Int32Rect(0, 0, w, h), maskPixels, stride, 0);
+ UpdateBubbleResult();
+ }
+
+ /// 从 ImageSource 提取灰度像素数组
+ private byte[] GetGrayscalePixels()
+ {
+ if (ImageSource is not BitmapSource bmp) return null;
+
+ int w = bmp.PixelWidth, h = bmp.PixelHeight;
+ if (w != (int)CanvasWidth || h != (int)CanvasHeight) return null;
+
+ // 转为 Bgra32
+ var converted = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0);
+ int stride = w * 4;
+ var pixels = new byte[stride * h];
+ converted.CopyPixels(pixels, stride, 0);
+
+ // 提取灰度
+ var gray = new byte[w * h];
+ for (int i = 0; i < w * h; i++)
+ {
+ int idx = i * 4;
+ gray[i] = (byte)(pixels[idx + 2] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx] * 0.114);
+ }
+ return gray;
}
/// 清除气泡测量所有状态
@@ -1668,7 +1787,14 @@ namespace XP.ImageProcessing.RoiControl.Controls
{
Point clickPosition = e.GetPosition(mainCanvas);
if (IsMeasuring)
+ {
HandleMeasureClick(clickPosition);
+ // 魔棒点击
+ if (CurrentMeasureMode == Models.MeasureMode.BubbleMeasure && _bubbleTool == BubbleSubTool.Wand && _bubbleRoi.HasValue)
+ {
+ WandFloodFill(clickPosition);
+ }
+ }
OnCanvasClicked(clickPosition);
}
diff --git a/XplorePlane/Views/ImageProcessing/BubbleMeasurePanel.xaml.cs b/XplorePlane/Views/ImageProcessing/BubbleMeasurePanel.xaml.cs
index 369fd41..c7f9323 100644
--- a/XplorePlane/Views/ImageProcessing/BubbleMeasurePanel.xaml.cs
+++ b/XplorePlane/Views/ImageProcessing/BubbleMeasurePanel.xaml.cs
@@ -53,7 +53,15 @@ namespace XplorePlane.Views.ImageProcessing
{
_canvas.BubbleToolChanged += (s, ev) =>
{
- // 暂不自动切换面板 radio button
+ // 同步面板 radio button
+ };
+
+ _canvas.MeasureCompleted += (s, ev) =>
+ {
+ if (ev is MeasureCompletedEventArgs args && args.MeasureType == "BubbleVoid")
+ {
+ TbResult.Text = $"空隙率: {args.Distance:F1}%";
+ }
};
}
}
diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
index 9e83eb1..743a755 100644
--- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
+++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
@@ -42,6 +42,7 @@ namespace XplorePlane.Views
"Angle" => "角度",
"FillRate" => "填锡率",
"BgaVoid" => "BGA空隙",
+ "BubbleVoid" => "气泡空隙",
_ => "点点距"
};
string valueText = args.MeasureType switch
@@ -49,6 +50,7 @@ namespace XplorePlane.Views
"Angle" => $"{args.Distance:F2}°",
"FillRate" => $"{args.Distance:F1}%",
"BgaVoid" => $"{args.Distance:F1}%",
+ "BubbleVoid" => $"{args.Distance:F1}%",
_ => $"{args.Distance:F2} px"
};
SetStatus($"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量");