feat: unify pool and cnc ball inspection pipeline

This commit is contained in:
zhengxuan.zhang
2026-08-04 11:26:19 +08:00
parent 84d5b11445
commit f6f8c40c63
6 changed files with 315 additions and 21 deletions
+2 -1
View File
@@ -1127,6 +1127,7 @@ namespace XplorePlane
// ── 检测任务向导服务 (NEW) ──
containerRegistry.RegisterSingleton<IBgaDetectionEngineService, BgaDetectionEngineService>();
containerRegistry.RegisterSingleton<IPoolBallAnalysisService, PoolBallAnalysisService>();
containerRegistry.RegisterSingleton<IBlockGeneratorService, BlockGeneratorService>();
containerRegistry.RegisterSingleton<IPoolCalibrationService, PoolCalibrationService>();
containerRegistry.RegisterSingleton<IScanSchedulerService, ScanSchedulerService>();
@@ -1168,4 +1169,4 @@ namespace XplorePlane
base.ConfigureModuleCatalog(moduleCatalog);
}
}
}
}
@@ -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
{
/// <summary>Bga Inspection Executor 服务</summary>
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<InspectionExecutionOutcome> ExecuteAsync(
public async Task<InspectionExecutionOutcome> 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<string>(grid.ExcludedTargetIds ?? Array.Empty<string>(), 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<string>(grid.ExcludedTargetIds ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
var targetIds = BgaTargetIdGenerator.Generate(grid);
var regions = new List<InspectionRegion>();
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<BGABall> detected,
CancellationToken cancellationToken)
{
var ids = BgaTargetIdGenerator.Generate(grid);
var excluded = new HashSet<string>(grid.ExcludedTargetIds ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
var rows = new List<InspectionResultRow>();
var used = new HashSet<int>();
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<string, double?>
{
["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<string, double?>
{
["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<string, double> 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<string, double> values, string key, int fallback)
=> values.TryGetValue(key, out var value) && double.IsFinite(value) ? (int)Math.Round(value) : fallback;
private static double ReadDouble(IReadOnlyDictionary<string, double> values, string key, double fallback)
=> values.TryGetValue(key, out var value) && double.IsFinite(value) ? value : fallback;
}
/// <summary>Multi Area Void Inspection Executor 服务</summary>
@@ -142,4 +247,4 @@ namespace XplorePlane.Services.Inspection
return count == 0 ? 1 : Math.Clamp(1 - difference / (count * 255d), 0, 1);
}
}
}
}
@@ -69,7 +69,11 @@ namespace XplorePlane.Services.Inspection.Tasks
}
/// <summary>将算子内部的 BgaBallInfo 转换为新模型 BGABall。</summary>
private static BGABall ToBGABall(BgaBallInfo src)
/// <summary>
/// 将流水线 BgaVoidRateProcessor 的原始输出转换为统一的 BGABall 模型。
/// 向导、检测池和 CNC 必须共用此映射,避免三处字段语义漂移。
/// </summary>
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
};
}
}
}
}
@@ -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<BGABall> Balls,
BitmapSource ProcessedImage,
string Engine);
/// <summary>
/// 检测池/CNC 共用的 Ball 分析入口。
/// PipelineSnapshot 存在时,流水线是唯一参数来源;没有流水线输出 Ball 时才回退到独立引擎。
/// </summary>
public interface IPoolBallAnalysisService
{
Task<PoolBallAnalysisResult> 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<PoolBallAnalysisResult> 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<PipelineNodeViewModel> BuildNodes(PipelineModel model)
{
var nodes = new List<PipelineNodeViewModel>();
foreach (var item in model.Nodes?.OrderBy(n => n.Order) ?? Enumerable.Empty<PipelineNodeModel>())
{
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<BGABall> ReadPipelineBalls(IReadOnlyDictionary<string, object>? output)
{
if (output == null || !output.TryGetValue("BgaBalls", out var value) || value is not IEnumerable items)
return Array.Empty<BGABall>();
var balls = new List<BGABall>();
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
{
/// <summary>把 Block 图像内检测结果绑定到向导生成的全局 Ball 模板。</summary>
public static IReadOnlyList<BGABall> MatchBlock(
InspectionBlock block,
IReadOnlyList<BGABall> detected,
int imageWidth,
int imageHeight)
{
if (block.Balls.Count == 0) return detected;
var used = new HashSet<int>();
var result = new List<BGABall>(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));
}
}
@@ -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,
@@ -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<ScanSchedulerService> _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<ScanSchedulerService> 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(