feat: unify pool and cnc ball inspection pipeline
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user