diff --git a/XplorePlane/Models/Inspection/Task/BgaDetectionOptions.cs b/XplorePlane/Models/Inspection/Task/BgaDetectionOptions.cs
index 020087dd..7131b607 100644
--- a/XplorePlane/Models/Inspection/Task/BgaDetectionOptions.cs
+++ b/XplorePlane/Models/Inspection/Task/BgaDetectionOptions.cs
@@ -1,3 +1,4 @@
+using System;
using System.Text.Json.Serialization;
namespace XplorePlane.Models.Inspection.Tasks
@@ -5,6 +6,7 @@ namespace XplorePlane.Models.Inspection.Tasks
///
/// BGA 球识别引擎参数。默认取值与 BgaVoidRateProcessor 保持行为一致。
///
+ [Obsolete("旧格式兼容字段。Ball 识别参数必须配置在 PipelineSnapshot 的 BgaVoidRate 算子中。")]
public record BgaDetectionOptions
{
/// 高斯模糊核大小,奇数;0 表示不模糊。
diff --git a/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs b/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs
index a0ce4a13..b2f0e3e2 100644
--- a/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs
+++ b/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs
@@ -50,7 +50,11 @@ namespace XplorePlane.Services.Inspection
context.Acquisition.Image,
definition.Analysis.PipelineSnapshot,
cancellationToken);
- _ballOverlay?.Publish(analysis.Balls);
+ _ballOverlay?.Publish(analysis.Balls, new BallOverlayContext(
+ BallCoordinateSystem.StitchedImage,
+ context.Acquisition.Image.PixelWidth,
+ context.Acquisition.Image.PixelHeight,
+ SourceId: definition.Id.ToString()));
return BuildPipelineOutcome(context, grid, bounds, analysis.Balls, cancellationToken);
}
var excluded = new HashSet(grid.ExcludedTargetIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
diff --git a/XplorePlane/Services/Inspection/Task/BallOverlayService.cs b/XplorePlane/Services/Inspection/Task/BallOverlayService.cs
index b03a4445..2d796ddf 100644
--- a/XplorePlane/Services/Inspection/Task/BallOverlayService.cs
+++ b/XplorePlane/Services/Inspection/Task/BallOverlayService.cs
@@ -11,7 +11,7 @@ namespace XplorePlane.Services.Inspection.Tasks
///
public interface IBallOverlayService
{
- void Publish(IReadOnlyList balls);
+ void Publish(IReadOnlyList balls, BallOverlayContext context);
void Clear();
}
@@ -24,17 +24,18 @@ namespace XplorePlane.Services.Inspection.Tasks
_overlay = overlay ?? throw new ArgumentNullException(nameof(overlay));
}
- public void Publish(IReadOnlyList balls)
+ public void Publish(IReadOnlyList balls, BallOverlayContext context)
{
ArgumentNullException.ThrowIfNull(balls);
+ ArgumentNullException.ThrowIfNull(context);
_overlay.PublishBgaBalls(balls.Select(ball => new BallOverlayItem
{
Id = ball.Id,
- CenterX = ball.X,
- CenterY = ball.Y,
- Diameter = ball.Diameter,
+ CenterX = (ball.X - context.OriginX) * context.ScaleX,
+ CenterY = (ball.Y - context.OriginY) * context.ScaleY,
+ Diameter = ball.Diameter * ((context.ScaleX + context.ScaleY) / 2),
IsFailed = ball.Judgement is BallJudgement.NG or BallJudgement.Missing
- }).ToArray());
+ }).ToArray(), context);
}
public void Clear() => _overlay.ClearOverlay();
diff --git a/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs b/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs
index 840554a1..c7f87924 100644
--- a/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs
+++ b/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs
@@ -21,7 +21,7 @@ namespace XplorePlane.Services.Inspection.Tasks
///
/// 检测池/CNC 共用的 Ball 分析入口。
- /// PipelineSnapshot 存在时,流水线是唯一参数来源;没有流水线输出 Ball 时才回退到独立引擎。
+ /// PipelineSnapshot 是唯一参数来源;没有流水线输出 BgaBalls 时直接失败,禁止切换到另一套识别算法。
///
public interface IPoolBallAnalysisService
{
diff --git a/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs b/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs
index d7c8233c..82389283 100644
--- a/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs
+++ b/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs
@@ -154,7 +154,11 @@ namespace XplorePlane.Services.Inspection.Tasks
acquired.Image, task.PipelineSnapshot, _internalCts.Token);
var detectedBalls = PoolBallMatcher.MatchBlock(
block, analysis.Balls, acquired.Image.PixelWidth, acquired.Image.PixelHeight);
- _ballOverlay.Publish(detectedBalls);
+ _ballOverlay.Publish(detectedBalls, new BallOverlayContext(
+ BallCoordinateSystem.ReferenceImage,
+ pool.ReferenceImageWidth,
+ pool.ReferenceImageHeight,
+ SourceId: task.Id.ToString()));
_logger.LogInformation("Block {Id}: Ball engine={Engine}, detected={Count}",
block.Id, analysis.Engine, analysis.Balls.Count);
diff --git a/XplorePlane/Services/Inspection/Task/TaskWizardOverlayService.cs b/XplorePlane/Services/Inspection/Task/TaskWizardOverlayService.cs
index 0e71e89f..9122f3a6 100644
--- a/XplorePlane/Services/Inspection/Task/TaskWizardOverlayService.cs
+++ b/XplorePlane/Services/Inspection/Task/TaskWizardOverlayService.cs
@@ -36,6 +36,9 @@ namespace XplorePlane.Services.Inspection.Tasks
/// 球列表数据(BgaBalls 类型时有效)。
public IReadOnlyList? Balls { get; init; }
+ /// Ball 坐标系及其来源图像上下文。
+ public BallOverlayContext? BallContext { get; init; }
+
/// Block 网格数据(BlockGrid 类型时有效)。
public IReadOnlyList? Blocks { get; init; }
@@ -64,6 +67,25 @@ namespace XplorePlane.Services.Inspection.Tasks
public bool IsFailed { get; init; }
}
+ public enum BallCoordinateSystem
+ {
+ ReferenceImage,
+ BlockImage,
+ StitchedImage,
+ ViewportImage
+ }
+
+ /// 描述 Ball 坐标来自哪张图,以及显示前需要执行的线性变换。
+ public sealed record BallOverlayContext(
+ BallCoordinateSystem CoordinateSystem,
+ int ImageWidth,
+ int ImageHeight,
+ double OriginX = 0,
+ double OriginY = 0,
+ double ScaleX = 1,
+ double ScaleY = 1,
+ string SourceId = "");
+
/// Block 叠加层元素。
public class BlockOverlayItem
{
@@ -109,7 +131,7 @@ namespace XplorePlane.Services.Inspection.Tasks
event EventHandler? OverlayChanged;
/// 发布 BGA 球叠加层。
- void PublishBgaBalls(IReadOnlyList balls);
+ void PublishBgaBalls(IReadOnlyList balls, BallOverlayContext? context = null);
/// 发布 Block 网格叠加层。
void PublishBlockGrid(IReadOnlyList blocks);
@@ -142,13 +164,14 @@ namespace XplorePlane.Services.Inspection.Tasks
}
///
- public void PublishBgaBalls(IReadOnlyList balls)
+ public void PublishBgaBalls(IReadOnlyList balls, BallOverlayContext? context = null)
{
_logger.LogDebug("发布 BGA 球叠加层: {Count} 个球", balls.Count);
_currentPayload = new WizardOverlayPayload
{
Type = OverlayType.BgaBalls,
Balls = balls,
+ BallContext = context,
StatusText = $"BGA 球: {balls.Count} 个"
};
_logger.LogInformation("[BGA-Debug] service cached type={Type}, balls={Count}",
diff --git a/XplorePlane/ViewModels/Inspection/TaskWizard/InspectionTaskWizardViewModel.cs b/XplorePlane/ViewModels/Inspection/TaskWizard/InspectionTaskWizardViewModel.cs
index 60d6a523..800c255f 100644
--- a/XplorePlane/ViewModels/Inspection/TaskWizard/InspectionTaskWizardViewModel.cs
+++ b/XplorePlane/ViewModels/Inspection/TaskWizard/InspectionTaskWizardViewModel.cs
@@ -56,6 +56,7 @@ namespace XplorePlane.ViewModels.Inspection.TaskWizard
private readonly IInspectionTaskStore _taskStore;
private readonly ITaskWizardOverlayService _overlayService;
private readonly IBallOverlayService _ballOverlay;
+ private readonly IPoolBallAnalysisService _ballAnalysis;
private readonly IInspectionTeachInService _teachInService;
private readonly IPoolTaskDefinitionMapper _definitionMapper;
private readonly IInspectionDefinitionRepository _definitionRepository;
@@ -383,6 +384,7 @@ namespace XplorePlane.ViewModels.Inspection.TaskWizard
IInspectionTaskStore taskStore,
ITaskWizardOverlayService overlayService,
IBallOverlayService ballOverlay,
+ IPoolBallAnalysisService ballAnalysis,
IInspectionTeachInService teachInService,
IPoolTaskDefinitionMapper definitionMapper,
IInspectionDefinitionRepository definitionRepository,
@@ -397,6 +399,7 @@ namespace XplorePlane.ViewModels.Inspection.TaskWizard
_taskStore = taskStore;
_overlayService = overlayService;
_ballOverlay = ballOverlay;
+ _ballAnalysis = ballAnalysis;
_teachInService = teachInService;
_definitionMapper = definitionMapper;
_definitionRepository = definitionRepository;
@@ -405,7 +408,7 @@ namespace XplorePlane.ViewModels.Inspection.TaskWizard
// 注册命令
LoadWorkpieceCommand = new DelegateCommand(ExecuteLoadWorkpiece);
ApplyPipelineCommand = new DelegateCommand(ExecuteApplyPipeline);
- RunBgaDetectionCommand = new DelegateCommand(async () => await ExecuteRunBgaDetection());
+ RunBgaDetectionCommand = new DelegateCommand(async () => await ExecuteRunBgaDetectionUnified());
GeneratePoolCommand = new DelegateCommand(ExecuteGeneratePool);
ApplyBubbleParametersCommand = new DelegateCommand(ExecuteApplyBubbleParameters);
RunCalibrationCommand = new DelegateCommand(ExecuteRunCalibration);
@@ -608,221 +611,33 @@ namespace XplorePlane.ViewModels.Inspection.TaskWizard
CurrentStage = InspectionTaskStage.BGADetected;
}
- private Task ExecuteRunBgaDetection()
+ private async Task ExecuteRunBgaDetectionUnified()
{
- var pipelineBalls = TryReadPipelineBgaBalls();
- if (pipelineBalls != null)
+ var pipeline = InspectionTask?.PipelineSnapshot;
+ var sourceImage = _viewportService?.LatestDetectorImage as BitmapSource
+ ?? _viewportService?.CurrentDisplayImage as BitmapSource;
+ if (pipeline == null || sourceImage == null)
{
- DetectedBalls = pipelineBalls;
- _logger.LogInformation("[BGA-Pipeline] 复用流水线 BgaVoidRate 输出: {Count} 个球", DetectedBalls.Count);
- _ballOverlay.Publish(DetectedBalls);
- _eventAggregator.GetEvent().Publish(DetectedBalls);
- CurrentStage = InspectionTaskStage.BGADetected;
- return Task.CompletedTask;
+ _logger.LogError("[BGA-Pipeline] 缺少 PipelineSnapshot 或可执行的原始图像");
+ return;
}
- _logger.LogWarning(
- "[BGA-Pipeline] 未找到流水线 BgaVoidRate 输出,operator={Operator}, keys={Keys},回退到真实检测路径",
- _viewportService?.LatestPipelineOperatorKey ?? string.Empty,
- _viewportService?.LatestPipelineOutputData == null
- ? "none"
- : string.Join(",", _viewportService.LatestPipelineOutputData.Keys));
-
- _logger.LogError(
- "[BGA-Pipeline] 流水线未输出 BgaBalls,已拒绝使用独立 Ball 引擎。请在流水线中启用 BgaVoidRate 算子。"
- );
- return Task.CompletedTask;
-
-#if false
- _logger.LogInformation("▶ ExecuteRunBgaDetection 开始");
-
try
{
- // 阈值针对 16 位值域(0~65535)设定,对应 8 位约 5~40 的灰度范围
- var options = new BgaDetectionOptions
- {
- // 参考图中的焊球是大圆目标:当前 2512x2512 图像中预计直径约 250~500px,
- // 面积约 5 万~20 万像素。旧 MaxArea=5000 会把所有真实焊球直接过滤掉,
- // 最终只留下少量小圆噪声。
- MinArea = 15000,
- MaxArea = 300000,
- MinCircularity = 0.65,
- // 16 位图中焊球为暗目标,允许从 0 开始,避免切掉最暗的焊球区域。
- ThresholdLow = 0,
- ThresholdHigh = 12000,
- BlurSize = 3,
- AreaConsistency = 0.75
- };
-
- _logger.LogInformation(
- "[BGA-Debug] options area=[{MinArea}..{MaxArea}], circularity={Circularity:F2}, threshold=[{Low}..{High}], consistency={Consistency:F2}",
- options.MinArea, options.MaxArea, options.MinCircularity,
- options.ThresholdLow, options.ThresholdHigh, options.AreaConsistency);
-
- BGABall[] balls;
-
- // 优先使用流水线输出的 16 位原始结果图(精度无损),
- // 回退到主视口当前显示图(可能是 8 位)
- var image16 = _viewportService?.LatestResultImage16 as BitmapSource;
- var imageFallback = _viewportService?.CurrentDisplayImage as BitmapSource;
-
- if (image16 != null)
- {
- _logger.LogInformation("BGA 识别: 使用 LatestResultImage16 ({W}x{H}, {Format})",
- image16.PixelWidth, image16.PixelHeight, image16.Format);
- }
- else if (imageFallback != null)
- {
- _logger.LogInformation("BGA 识别: LatestResultImage16 不可用, 回退到 CurrentDisplayImage ({W}x{H}, {Format})",
- imageFallback.PixelWidth, imageFallback.PixelHeight, imageFallback.Format);
- }
-
- var sourceImage = image16 ?? imageFallback;
-
- if (sourceImage == null)
- {
- _logger.LogWarning("BGA 识别: 主视口无可用图像, 回退到仿真数据");
- balls = GenerateSimulatedBgaBalls();
- }
- else
- {
- try
- {
- using var gray16 = BitmapSourceToGray16(sourceImage);
- balls = _bgaEngine.DetectBalls(gray16, options).ToArray();
- _logger.LogInformation("BGA 识别: 真实检测完成, {Count} 个球", balls.Length);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "BGA 引擎检测失败, 回退到仿真数据");
- balls = GenerateSimulatedBgaBalls();
- }
- }
-
- DetectedBalls = balls;
- InspectionTask = InspectionTask with { BgaDetectionOptions = options };
-
- _logger.LogInformation(
- "[BGA-Debug] detected count={Count}, image={Width}x{Height}, balls={Balls}",
- DetectedBalls.Count,
- sourceImage?.PixelWidth ?? 0,
- sourceImage?.PixelHeight ?? 0,
- string.Join("; ", DetectedBalls.Take(8).Select(ball =>
- $"id={ball.Id},xy=({ball.X:F1},{ball.Y:F1}),d={ball.Diameter:F1},judge={ball.Judgement}")));
-
- _logger.LogInformation("✓ BGA 识别完成: {Count} 个球", DetectedBalls.Count);
-
- _ballOverlay.Publish(DetectedBalls);
+ var analysis = await _ballAnalysis.AnalyzeAsync(sourceImage, pipeline);
+ DetectedBalls = analysis.Balls;
+ _ballOverlay.Publish(DetectedBalls, new BallOverlayContext(
+ BallCoordinateSystem.ViewportImage,
+ sourceImage.PixelWidth,
+ sourceImage.PixelHeight,
+ SourceId: InspectionTask.Id.ToString()));
_eventAggregator.GetEvent().Publish(DetectedBalls);
CurrentStage = InspectionTaskStage.BGADetected;
}
catch (Exception ex)
{
- _logger.LogError(ex, "BGA 识别异常");
+ _logger.LogError(ex, "[BGA-Pipeline] 统一流水线 Ball 识别失败");
}
-#endif
- }
-
- /// 16 位灰度 BitmapSource → Emgu Image<Gray, ushort>(8 位输入升位,零精度损失路径优先)。
- private IReadOnlyList? TryReadPipelineBgaBalls()
- {
- if (!string.Equals(_viewportService?.LatestPipelineOperatorKey, "BgaVoidRate", StringComparison.OrdinalIgnoreCase))
- return null;
-
- var output = _viewportService?.LatestPipelineOutputData;
- if (output == null || !output.TryGetValue("BgaBalls", out var value))
- return null;
-
- if (value is not IEnumerable rawBalls)
- return null;
-
- return rawBalls.Select(BgaDetectionEngineService.ToBGABall).ToArray();
- }
-
- private static Image BitmapSourceToGray16(BitmapSource bmp)
- {
- if (bmp.Format == PixelFormats.Gray16)
- {
- int w = bmp.PixelWidth, h = bmp.PixelHeight;
- int stride = w * 2;
- var pixels16 = new ushort[w * h];
- bmp.CopyPixels(pixels16, stride, 0);
-
- var gray = new Image(w, h);
- for (int y = 0; y < h; y++)
- for (int x = 0; x < w; x++)
- gray.Data[y, x, 0] = pixels16[y * w + x];
- return gray;
- }
-
- var formatted = new FormatConvertedBitmap(bmp, PixelFormats.Gray8, null, 0);
- int width = formatted.PixelWidth, height = formatted.PixelHeight;
- int stride8 = width;
- var pixels8 = new byte[height * stride8];
- formatted.CopyPixels(pixels8, stride8, 0);
-
- var result = new Image(width, height);
- for (int y = 0; y < height; y++)
- for (int x = 0; x < width; x++)
- result.Data[y, x, 0] = (ushort)Math.Round(pixels8[y * stride8 + x] / 255.0 * 65535.0);
- return result;
- }
-
- ///
- /// 按行列聚类给焊球分配 A1~D4 风格分组编号,发布到主界面实时预览叠加层(绿色圆 + 编号)。
- /// 聚类失败(如球数不足)时回退为按 Id 显示的扁平序号,仍能画圆。
- ///
- private void PublishBgaBallsOverlay(IReadOnlyList balls)
- {
- var labels = BgaBallGroupLabeler.AssignLabels(balls);
- var items = balls.Select(ball => new BallOverlayItem
- {
- Id = ball.Id,
- CenterX = ball.X,
- CenterY = ball.Y,
- Diameter = ball.Diameter,
- Label = labels.TryGetValue(ball.Id, out var label) ? label : $"#{ball.Id}",
- IsFailed = ball.Judgement == BallJudgement.NG
- }).ToArray();
-
- _logger.LogInformation(
- "[BGA-Debug] publish overlay count={Count}, first={First}",
- items.Length,
- items.Length == 0
- ? "none"
- : $"id={items[0].Id},xy=({items[0].CenterX:F1},{items[0].CenterY:F1}),d={items[0].Diameter:F1}");
-
- _overlayService.PublishBgaBalls(items);
- }
-
- /// 生成仿真 BGA 球数据(6 行 × 9 列 = 54 个球)
- private static BGABall[] GenerateSimulatedBgaBalls()
- {
- var rand = new Random(42);
- var balls = new List();
-
- int cols = 9, rows = 6;
- double spacingX = 1280.0 / (cols + 1);
- double spacingY = 1024.0 / (rows + 1);
-
- for (int r = 0; r < rows; r++)
- for (int c = 0; c < cols; c++)
- {
- double baseDiam = 60 + rand.NextDouble() * 40;
- balls.Add(new BGABall
- {
- Id = r * cols + c,
- X = (c + 1) * spacingX + rand.NextDouble() * 10 - 5,
- Y = (r + 1) * spacingY + rand.NextDouble() * 10 - 5,
- Diameter = Math.Round(baseDiam, 1),
- Gray = (ushort)(30000 + rand.NextDouble() * 20000),
- Confidence = 0.7 + rand.NextDouble() * 0.28,
- Judgement = BallJudgement.Pending,
- Reason = string.Empty
- });
- }
-
- return balls.ToArray();
}
private void ExecuteGeneratePool()