fix: remove legacy wizard ball detection and add overlay context

This commit is contained in:
zhengxuan.zhang
2026-08-04 13:02:00 +08:00
parent 53f7d1ae07
commit 7e99da4ff7
7 changed files with 64 additions and 215 deletions
@@ -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
/// <summary>
/// BGA 球识别引擎参数。默认取值与 BgaVoidRateProcessor 保持行为一致。
/// </summary>
[Obsolete("旧格式兼容字段。Ball 识别参数必须配置在 PipelineSnapshot 的 BgaVoidRate 算子中。")]
public record BgaDetectionOptions
{
/// <summary>高斯模糊核大小,奇数;0 表示不模糊。</summary>
@@ -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<string>(grid.ExcludedTargetIds ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
@@ -11,7 +11,7 @@ namespace XplorePlane.Services.Inspection.Tasks
/// </summary>
public interface IBallOverlayService
{
void Publish(IReadOnlyList<BGABall> balls);
void Publish(IReadOnlyList<BGABall> 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<BGABall> balls)
public void Publish(IReadOnlyList<BGABall> 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();
@@ -21,7 +21,7 @@ namespace XplorePlane.Services.Inspection.Tasks
/// <summary>
/// 检测池/CNC 共用的 Ball 分析入口。
/// PipelineSnapshot 存在时,流水线是唯一参数来源;没有流水线输出 Ball 时才回退到独立引擎
/// PipelineSnapshot 是唯一参数来源;没有流水线输出 BgaBalls直接失败,禁止切换到另一套识别算法
/// </summary>
public interface IPoolBallAnalysisService
{
@@ -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);
@@ -36,6 +36,9 @@ namespace XplorePlane.Services.Inspection.Tasks
/// <summary>球列表数据(BgaBalls 类型时有效)。</summary>
public IReadOnlyList<BallOverlayItem>? Balls { get; init; }
/// <summary>Ball 坐标系及其来源图像上下文。</summary>
public BallOverlayContext? BallContext { get; init; }
/// <summary>Block 网格数据(BlockGrid 类型时有效)。</summary>
public IReadOnlyList<BlockOverlayItem>? Blocks { get; init; }
@@ -64,6 +67,25 @@ namespace XplorePlane.Services.Inspection.Tasks
public bool IsFailed { get; init; }
}
public enum BallCoordinateSystem
{
ReferenceImage,
BlockImage,
StitchedImage,
ViewportImage
}
/// <summary>描述 Ball 坐标来自哪张图,以及显示前需要执行的线性变换。</summary>
public sealed record BallOverlayContext(
BallCoordinateSystem CoordinateSystem,
int ImageWidth,
int ImageHeight,
double OriginX = 0,
double OriginY = 0,
double ScaleX = 1,
double ScaleY = 1,
string SourceId = "");
/// <summary>Block 叠加层元素。</summary>
public class BlockOverlayItem
{
@@ -109,7 +131,7 @@ namespace XplorePlane.Services.Inspection.Tasks
event EventHandler<WizardOverlayPayload>? OverlayChanged;
/// <summary>发布 BGA 球叠加层。</summary>
void PublishBgaBalls(IReadOnlyList<BallOverlayItem> balls);
void PublishBgaBalls(IReadOnlyList<BallOverlayItem> balls, BallOverlayContext? context = null);
/// <summary>发布 Block 网格叠加层。</summary>
void PublishBlockGrid(IReadOnlyList<BlockOverlayItem> blocks);
@@ -142,13 +164,14 @@ namespace XplorePlane.Services.Inspection.Tasks
}
/// <inheritdoc />
public void PublishBgaBalls(IReadOnlyList<BallOverlayItem> balls)
public void PublishBgaBalls(IReadOnlyList<BallOverlayItem> 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}",
@@ -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<BgaBallsDetectedEvent>().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<BgaBallsDetectedEvent>().Publish(DetectedBalls);
CurrentStage = InspectionTaskStage.BGADetected;
}
catch (Exception ex)
{
_logger.LogError(ex, "BGA 识别异常");
_logger.LogError(ex, "[BGA-Pipeline] 统一流水线 Ball 识别失败");
}
#endif
}
/// <summary>16 位灰度 BitmapSource → Emgu Image&lt;Gray, ushort&gt;8 位输入升位,零精度损失路径优先)。</summary>
private IReadOnlyList<BGABall>? 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<BgaBallInfo> rawBalls)
return null;
return rawBalls.Select(BgaDetectionEngineService.ToBGABall).ToArray();
}
private static Image<Gray, ushort> 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<Gray, ushort>(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<Gray, ushort>(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;
}
/// <summary>
/// 按行列聚类给焊球分配 A1~D4 风格分组编号,发布到主界面实时预览叠加层(绿色圆 + 编号)。
/// 聚类失败(如球数不足)时回退为按 Id 显示的扁平序号,仍能画圆。
/// </summary>
private void PublishBgaBallsOverlay(IReadOnlyList<BGABall> 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);
}
/// <summary>生成仿真 BGA 球数据(6 行 × 9 列 = 54 个球)</summary>
private static BGABall[] GenerateSimulatedBgaBalls()
{
var rand = new Random(42);
var balls = new List<BGABall>();
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()