diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 4ec68c5b..b894b150 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -1127,6 +1127,7 @@ namespace XplorePlane // ── 检测任务向导服务 (NEW) ── containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); @@ -1168,4 +1169,4 @@ namespace XplorePlane base.ConfigureModuleCatalog(moduleCatalog); } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs b/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs index 0fa1e803..69a70219 100644 --- a/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs +++ b/XplorePlane/Services/Inspection/AdvancedInspectionExecutors.cs @@ -6,18 +6,29 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media.Imaging; +using XplorePlane.Models.Inspection; +using XplorePlane.Models.Inspection.Tasks; +using XplorePlane.Services.Inspection.Tasks; namespace XplorePlane.Services.Inspection { /// Bga Inspection Executor 服务 - public sealed class BgaInspectionExecutor : IInspectionExecutor - { - public InspectionKind Kind => InspectionKind.Bga; + public sealed class BgaInspectionExecutor : IInspectionExecutor + { + private readonly IPoolBallAnalysisService? _ballAnalysis; + + public BgaInspectionExecutor(IPoolBallAnalysisService? ballAnalysis = null) + { + _ballAnalysis = ballAnalysis; + } + + public InspectionKind Kind => InspectionKind.Bga; - public Task ExecuteAsync( + public async Task ExecuteAsync( InspectionExecutionContext context, CancellationToken cancellationToken) { @@ -25,9 +36,20 @@ namespace XplorePlane.Services.Inspection var definition = context.Definition; var grid = definition.Geometry?.BgaGrid ?? throw new InvalidOperationException("BGA 检测缺少阵列几何。"); - var sourceRegion = definition.Geometry.Regions.First(); - var bounds = InspectionPixelAnalysis.GetBounds(sourceRegion, context.Acquisition.Image); - var excluded = new HashSet(grid.ExcludedTargetIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + var sourceRegion = definition.Geometry.Regions.First(); + var bounds = InspectionPixelAnalysis.GetBounds(sourceRegion, context.Acquisition.Image); + if (definition.Analysis?.PipelineSnapshot != null) + { + if (_ballAnalysis == null) + throw new InvalidOperationException("Pipeline BGA 执行缺少统一 Ball 分析服务。"); + var analysis = await _ballAnalysis.AnalyzeAsync( + context.Acquisition.Image, + definition.Analysis.PipelineSnapshot, + ReadDetectionOptions(definition.Analysis.Parameters), + cancellationToken); + return BuildPipelineOutcome(context, grid, bounds, analysis.Balls, cancellationToken); + } + var excluded = new HashSet(grid.ExcludedTargetIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); var targetIds = BgaTargetIdGenerator.Generate(grid); var regions = new List(); for (var row = 0; row < grid.Rows; row++) @@ -45,8 +67,91 @@ namespace XplorePlane.Services.Inspection new[] { new RoiPoint(x0, y0), new RoiPoint(x1, y1) })); } - return Task.FromResult(InspectionPixelAnalysis.AnalyzeRegions(context, regions, cancellationToken)); - } + return InspectionPixelAnalysis.AnalyzeRegions(context, regions, cancellationToken); + } + + private static InspectionExecutionOutcome BuildPipelineOutcome( + InspectionExecutionContext context, + BgaGridGeometry grid, + Rect bounds, + IReadOnlyList detected, + CancellationToken cancellationToken) + { + var ids = BgaTargetIdGenerator.Generate(grid); + var excluded = new HashSet(grid.ExcludedTargetIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + var rows = new List(); + var used = new HashSet(); + var maxVoid = ReadDouble(context.Definition.Analysis.Parameters, "BubbleMaxVoidRate", 25); + for (var index = 0; index < ids.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var id = ids[index]; + if (excluded.Contains(id)) continue; + var row = index / grid.Columns; + var column = index % grid.Columns; + var expectedX = bounds.Left + bounds.Width * (column + .5) / grid.Columns; + var expectedY = bounds.Top + bounds.Height * (row + .5) / grid.Rows; + var candidate = detected.Select((ball, candidateIndex) => new { ball, candidateIndex }) + .Where(x => !used.Contains(x.candidateIndex)) + .OrderBy(x => Math.Pow(x.ball.X - expectedX, 2) + Math.Pow(x.ball.Y - expectedY, 2)) + .FirstOrDefault(); + var maxDistance = Math.Max(bounds.Width / grid.Columns, bounds.Height / grid.Rows) * .6; + if (candidate == null || Math.Sqrt(Math.Pow(candidate.ball.X - expectedX, 2) + Math.Pow(candidate.ball.Y - expectedY, 2)) > maxDistance) + { + rows.Add(new InspectionResultRow { Id = id, RegionId = context.Definition.Geometry.Regions.First().Id, + Status = InspectionStatus.MISS, FailureReason = "流水线未检测到对应焊球" }); + continue; + } + used.Add(candidate.candidateIndex); + var status = candidate.ball.VoidRate > maxVoid ? InspectionStatus.FAIL : InspectionStatus.PASS; + rows.Add(new InspectionResultRow + { + Id = id, + RegionId = context.Definition.Geometry.Regions.First().Id, + Status = status, + Values = new Dictionary + { + ["VoidRate"] = candidate.ball.VoidRate, + ["Confidence"] = candidate.ball.Confidence, + ["Diameter"] = candidate.ball.Diameter + }, + FailureReason = status == InspectionStatus.FAIL ? $"VoidRate {candidate.ball.VoidRate:F2}% > {maxVoid:F2}%" : string.Empty + }); + } + var result = new InspectionExecutionResult + { + DefinitionId = context.Definition.Id, + Status = rows.Any(r => r.Status is InspectionStatus.FAIL or InspectionStatus.MISS) ? InspectionStatus.FAIL : InspectionStatus.PASS, + Rows = rows, + Metrics = new Dictionary + { + ["TargetCount"] = rows.Count, + ["MeasuredCount"] = rows.Count(r => r.Status is InspectionStatus.PASS or InspectionStatus.FAIL), + ["MissCount"] = rows.Count(r => r.Status == InspectionStatus.MISS), + ["MaxVoidRatio"] = rows.Where(r => r.Values.ContainsKey("VoidRate")).Select(r => r.Values["VoidRate"]).Where(v => v.HasValue).Select(v => v!.Value).DefaultIfEmpty().Max() + } + }; + return new InspectionExecutionOutcome(result, context.Acquisition.Image, + InspectionPixelAnalysis.RenderOverlay(context.Acquisition.Image, context.Definition.Geometry.Regions, rows)); + } + + private static BgaDetectionOptions ReadDetectionOptions(IReadOnlyDictionary values) + => new() + { + BlurSize = ReadInt(values, "DetectionBlurSize", 3), + MinArea = ReadInt(values, "DetectionMinArea", 30), + MaxArea = ReadInt(values, "DetectionMaxArea", 5000), + MinCircularity = ReadDouble(values, "DetectionMinCircularity", .5), + AreaConsistency = ReadDouble(values, "DetectionAreaConsistency", .3), + ThresholdLow = ReadInt(values, "BubbleThresholdGrayMin", 1000), + ThresholdHigh = ReadInt(values, "BubbleThresholdGrayMax", 65535) + }; + + private static int ReadInt(IReadOnlyDictionary values, string key, int fallback) + => values.TryGetValue(key, out var value) && double.IsFinite(value) ? (int)Math.Round(value) : fallback; + + private static double ReadDouble(IReadOnlyDictionary values, string key, double fallback) + => values.TryGetValue(key, out var value) && double.IsFinite(value) ? value : fallback; } /// Multi Area Void Inspection Executor 服务 @@ -142,4 +247,4 @@ namespace XplorePlane.Services.Inspection return count == 0 ? 1 : Math.Clamp(1 - difference / (count * 255d), 0, 1); } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/Inspection/Task/BgaDetectionEngineService.cs b/XplorePlane/Services/Inspection/Task/BgaDetectionEngineService.cs index d54890e1..3b8c795a 100644 --- a/XplorePlane/Services/Inspection/Task/BgaDetectionEngineService.cs +++ b/XplorePlane/Services/Inspection/Task/BgaDetectionEngineService.cs @@ -69,7 +69,11 @@ namespace XplorePlane.Services.Inspection.Tasks } /// 将算子内部的 BgaBallInfo 转换为新模型 BGABall。 - private static BGABall ToBGABall(BgaBallInfo src) + /// + /// 将流水线 BgaVoidRateProcessor 的原始输出转换为统一的 BGABall 模型。 + /// 向导、检测池和 CNC 必须共用此映射,避免三处字段语义漂移。 + /// + public static BGABall ToBGABall(BgaBallInfo src) { double diameter = src.FittedEllipse.Size.Width > 0 && src.FittedEllipse.Size.Height > 0 ? (src.FittedEllipse.Size.Width + src.FittedEllipse.Size.Height) / 2.0 @@ -97,4 +101,4 @@ namespace XplorePlane.Services.Inspection.Tasks }; } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs b/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs new file mode 100644 index 00000000..0cd4e38e --- /dev/null +++ b/XplorePlane/Services/Inspection/Task/PoolBallAnalysisService.cs @@ -0,0 +1,179 @@ +using Emgu.CV; +using Emgu.CV.Structure; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using XP.ImageProcessing.Processors; +using XplorePlane.Models.ImageProcessing; +using XplorePlane.Models.Inspection.Tasks; +using XplorePlane.Services.ImageProcessing; +using XplorePlane.Services.Pipeline; + +namespace XplorePlane.Services.Inspection.Tasks +{ + public sealed record PoolBallAnalysisResult( + IReadOnlyList Balls, + BitmapSource ProcessedImage, + string Engine); + + /// + /// 检测池/CNC 共用的 Ball 分析入口。 + /// PipelineSnapshot 存在时,流水线是唯一参数来源;没有流水线输出 Ball 时才回退到独立引擎。 + /// + public interface IPoolBallAnalysisService + { + Task AnalyzeAsync( + BitmapSource image, + PipelineModel? pipeline, + BgaDetectionOptions fallbackOptions, + CancellationToken cancellationToken = default); + } + + public sealed class PoolBallAnalysisService : IPoolBallAnalysisService + { + private readonly IPipelineExecutionService _pipeline; + private readonly IImageProcessingService _imageProcessing; + private readonly IBgaDetectionEngineService _fallback; + + public PoolBallAnalysisService( + IPipelineExecutionService pipeline, + IImageProcessingService imageProcessing, + IBgaDetectionEngineService fallback) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + _imageProcessing = imageProcessing ?? throw new ArgumentNullException(nameof(imageProcessing)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + public async Task AnalyzeAsync( + BitmapSource image, + PipelineModel? pipeline, + BgaDetectionOptions fallbackOptions, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(image); + fallbackOptions ??= new BgaDetectionOptions(); + + using var source16 = ImageConverter.ToEmguCV16(image); + if (pipeline != null) + { + var result = await _pipeline.ExecutePipelineAsync16( + BuildNodes(pipeline), source16, cancellationToken: cancellationToken); + var balls = ReadPipelineBalls(result.LastStepOutputData); + if (balls.Count > 0) + return new PoolBallAnalysisResult(balls, result.Image, "Pipeline"); + + if (result.ResultImage16 != null) + { + using var processed16 = ImageConverter.ToEmguCV16(result.ResultImage16); + return new PoolBallAnalysisResult( + _fallback.DetectBalls(processed16, fallbackOptions), + result.Image, + "PipelineImage+FallbackBall"); + } + } + + return new PoolBallAnalysisResult( + _fallback.DetectBalls(source16, fallbackOptions), + image, + "FallbackBall"); + } + + private IReadOnlyList BuildNodes(PipelineModel model) + { + var nodes = new List(); + foreach (var item in model.Nodes?.OrderBy(n => n.Order) ?? Enumerable.Empty()) + { + var displayName = _imageProcessing.GetProcessorDisplayName(item.OperatorKey) ?? item.OperatorKey; + var node = new PipelineNodeViewModel(item.OperatorKey, displayName) + { + Order = item.Order, + IsEnabled = item.IsEnabled + }; + var definitions = _imageProcessing.GetProcessorParameters(item.OperatorKey); + if (definitions == null) continue; + foreach (var definition in definitions) + { + var parameter = new ProcessorParameterVM(definition); + if (item.Parameters != null && item.Parameters.TryGetValue(definition.Name, out var saved)) + parameter.Value = ConvertSavedValue(saved, definition.ValueType); + node.Parameters.Add(parameter); + } + nodes.Add(node); + } + return nodes; + } + + private static IReadOnlyList ReadPipelineBalls(IReadOnlyDictionary? output) + { + if (output == null || !output.TryGetValue("BgaBalls", out var value) || value is not IEnumerable items) + return Array.Empty(); + + var balls = new List(); + foreach (var item in items) + { + if (item is BgaBallInfo raw) + balls.Add(BgaDetectionEngineService.ToBGABall(raw)); + else if (item is BGABall ball) + balls.Add(ball); + } + return balls; + } + + private static object ConvertSavedValue(object savedValue, Type targetType) + { + if (savedValue is not JsonElement json) return savedValue; + if (targetType == typeof(int) && json.TryGetInt32(out var intValue)) return intValue; + if (targetType == typeof(double) && json.TryGetDouble(out var doubleValue)) return doubleValue; + if (targetType == typeof(bool) && (json.ValueKind == JsonValueKind.True || json.ValueKind == JsonValueKind.False)) + return json.GetBoolean(); + if (targetType == typeof(string)) return json.GetString() ?? string.Empty; + return json.ToString(); + } + } + + public static class PoolBallMatcher + { + /// 把 Block 图像内检测结果绑定到向导生成的全局 Ball 模板。 + public static IReadOnlyList MatchBlock( + InspectionBlock block, + IReadOnlyList detected, + int imageWidth, + int imageHeight) + { + if (block.Balls.Count == 0) return detected; + var used = new HashSet(); + var result = new List(block.Balls.Count); + foreach (var expected in block.Balls.OrderBy(b => b.Y).ThenBy(b => b.X)) + { + var ex = block.Width <= 0 ? 0.5 : (expected.X - (block.CenterX - block.Width / 2)) / block.Width; + var ey = block.Height <= 0 ? 0.5 : (expected.Y - (block.CenterY - block.Height / 2)) / block.Height; + var candidate = detected + .Select((ball, index) => new { ball, index }) + .Where(x => !used.Contains(x.index)) + .OrderBy(x => Distance(ex, ey, imageWidth <= 0 ? 0.5 : x.ball.X / imageWidth, + imageHeight <= 0 ? 0.5 : x.ball.Y / imageHeight)) + .FirstOrDefault(); + + if (candidate == null || Distance(ex, ey, imageWidth <= 0 ? 0.5 : candidate.ball.X / imageWidth, + imageHeight <= 0 ? 0.5 : candidate.ball.Y / imageHeight) > 0.25) + { + result.Add(expected with { Judgement = BallJudgement.Missing, Reason = "未检测到对应焊球" }); + continue; + } + + used.Add(candidate.index); + result.Add(candidate.ball with { Id = expected.Id, X = expected.X, Y = expected.Y }); + } + return result; + } + + private static double Distance(double x1, double y1, double x2, double y2) + => Math.Sqrt(Math.Pow(x1 - x2, 2) + Math.Pow(y1 - y2, 2)); + } +} diff --git a/XplorePlane/Services/Inspection/Task/PoolTaskDefinitionMapper.cs b/XplorePlane/Services/Inspection/Task/PoolTaskDefinitionMapper.cs index d919b545..da04e199 100644 --- a/XplorePlane/Services/Inspection/Task/PoolTaskDefinitionMapper.cs +++ b/XplorePlane/Services/Inspection/Task/PoolTaskDefinitionMapper.cs @@ -414,6 +414,7 @@ namespace XplorePlane.Services.Inspection.Tasks ["BubbleThresholdGrayMax"] = bubble.ThresholdGrayMax, ["BubbleMinVoidArea"] = bubble.MinVoidArea, ["BubbleMinCircularity"] = bubble.MinCircularity, + ["BubbleMaxVoidRate"] = bubble.MaxVoidRatePercent, ["BubbleDiameterTolerancePercent"] = bubble.DiameterTolerancePercent, ["DetectionBlurSize"] = detection.BlurSize, ["DetectionMinArea"] = detection.MinArea, diff --git a/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs b/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs index 5dbba89e..0fb3c4bc 100644 --- a/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs +++ b/XplorePlane/Services/Inspection/Task/ScanSchedulerService.cs @@ -63,7 +63,7 @@ namespace XplorePlane.Services.Inspection.Tasks public sealed class ScanSchedulerService : IScanSchedulerService { private readonly IInspectionAcquisitionService _acquisitionService; - private readonly IBgaDetectionEngineService _bgaEngine; + private readonly IPoolBallAnalysisService _ballAnalysis; private readonly IPoolResultAnalyzer _resultAnalyzer; private readonly ILogger _logger; private volatile bool _pauseRequested; @@ -73,12 +73,12 @@ namespace XplorePlane.Services.Inspection.Tasks public ScanSchedulerService( IInspectionAcquisitionService acquisitionService, - IBgaDetectionEngineService bgaEngine, + IPoolBallAnalysisService ballAnalysis, IPoolResultAnalyzer resultAnalyzer, ILogger logger) { _acquisitionService = acquisitionService; - _bgaEngine = bgaEngine; + _ballAnalysis = ballAnalysis; _resultAnalyzer = resultAnalyzer; _logger = logger; } @@ -99,7 +99,7 @@ namespace XplorePlane.Services.Inspection.Tasks var pool = task.Pool; var scanPositions = task.Calibration.ScanPositions; var acquisitionParameters = PoolTaskDefinitionMapper.BuildAcquisition(task.AcquisitionSnapshot); - var detectionOptions = MapToDetectionOptions(task.BubbleParameters); + var detectionOptions = task.BgaDetectionOptions ?? MapToDetectionOptions(task.BubbleParameters); // 逐 Block 结果先写入本地副本,循环结束后一次性回写检测池, // 避免 AnalyzeTask() 汇总时读取到未更新的原始球状态。 @@ -146,8 +146,12 @@ namespace XplorePlane.Services.Inspection.Tasks machine, acquisitionParameters, _internalCts.Token); // 4. RunBGA — 在采集图上调用真实检测引擎 - using var gray16 = ImageConverter.ToEmguCV16(acquired.Image); - var detectedBalls = _bgaEngine.DetectBalls(gray16, detectionOptions); + var analysis = await _ballAnalysis.AnalyzeAsync( + acquired.Image, task.PipelineSnapshot, detectionOptions, _internalCts.Token); + var detectedBalls = PoolBallMatcher.MatchBlock( + block, analysis.Balls, acquired.Image.PixelWidth, acquired.Image.PixelHeight); + _logger.LogInformation("Block {Id}: Ball engine={Engine}, detected={Count}", + block.Id, analysis.Engine, analysis.Balls.Count); // 5. Judge — 判定各球 var blockResult = _resultAnalyzer.AnalyzeBlock(