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()