合并 TURBO-615-RecognitionAndPositioning 到 ResolveConflicts,保留双方冲突内容
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Language": "zh-CN",
|
||||
"LogLevel": "Debug"
|
||||
"LogLevel": "Debug",
|
||||
"CameraType": "Hikvision"
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public static class PixelConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 将原始像素数据转换为 WPF 的 BitmapSource 对象。
|
||||
/// 支持 Mono8、BGR8、RGB8、BGRA8 以及 Bayer 8-bit 格式(自动解码为 BGR24)。
|
||||
/// 返回的 BitmapSource 已调用 Freeze(),可跨线程访问。
|
||||
/// </summary>
|
||||
public static BitmapSource ToBitmapSource(byte[] pixelData, int width, int height, string pixelFormat)
|
||||
@@ -19,11 +20,23 @@ public static class PixelConverter
|
||||
if (height <= 0) throw new ArgumentException("Height must be a positive integer.", nameof(height));
|
||||
ArgumentNullException.ThrowIfNull(pixelFormat);
|
||||
|
||||
var (format, stride) = pixelFormat switch
|
||||
string normalized = NormalizePixelFormat(pixelFormat);
|
||||
|
||||
// Bayer 格式需要解码
|
||||
if (normalized.StartsWith("Bayer"))
|
||||
{
|
||||
byte[] bgrData = DemosaicBayer(pixelData, width, height, normalized);
|
||||
var bmp = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgr24, null, bgrData, width * 3);
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
|
||||
var (format, stride) = normalized switch
|
||||
{
|
||||
"Mono8" => (PixelFormats.Gray8, width),
|
||||
"BGR8" => (PixelFormats.Bgr24, width * 3),
|
||||
"BGRA8" => (PixelFormats.Bgra32, width * 4),
|
||||
"RGB8" => (PixelFormats.Rgb24, width * 3),
|
||||
_ => throw new NotSupportedException($"Pixel format '{pixelFormat}' is not supported.")
|
||||
};
|
||||
|
||||
@@ -31,4 +44,136 @@ public static class PixelConverter
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将不同 SDK 的像素格式名称统一为标准名称。
|
||||
/// </summary>
|
||||
private static string NormalizePixelFormat(string pixelFormat)
|
||||
{
|
||||
if (pixelFormat is "Mono8" or "BGR8" or "BGRA8" or "RGB8")
|
||||
return pixelFormat;
|
||||
|
||||
var upper = pixelFormat.ToUpperInvariant();
|
||||
|
||||
if (upper.Contains("MONO8")) return "Mono8";
|
||||
if (upper.Contains("BGR8")) return "BGR8";
|
||||
if (upper.Contains("BGRA8")) return "BGRA8";
|
||||
if (upper.Contains("RGB8") && !upper.Contains("BAYER")) return "RGB8";
|
||||
|
||||
// Bayer 格式
|
||||
if (upper.Contains("BAYERRG8") || upper.Contains("BAYER_RG8")) return "BayerRG8";
|
||||
if (upper.Contains("BAYERGR8") || upper.Contains("BAYER_GR8")) return "BayerGR8";
|
||||
if (upper.Contains("BAYERGB8") || upper.Contains("BAYER_GB8")) return "BayerGB8";
|
||||
if (upper.Contains("BAYERBG8") || upper.Contains("BAYER_BG8")) return "BayerBG8";
|
||||
|
||||
return pixelFormat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 简单 Bayer 解码(双线性插值),输出 BGR24。
|
||||
/// </summary>
|
||||
private static byte[] DemosaicBayer(byte[] bayer, int width, int height, string pattern)
|
||||
{
|
||||
// pattern: BayerRG8, BayerGR8, BayerGB8, BayerBG8
|
||||
// RG: R G GR: G R GB: G B BG: B G
|
||||
// G B B G R G G R
|
||||
|
||||
int rRow, rCol; // 红色像素在2x2块中的位置
|
||||
switch (pattern)
|
||||
{
|
||||
case "BayerRG8": rRow = 0; rCol = 0; break;
|
||||
case "BayerGR8": rRow = 0; rCol = 1; break;
|
||||
case "BayerGB8": rRow = 1; rCol = 0; break;
|
||||
case "BayerBG8": rRow = 1; rCol = 1; break;
|
||||
default: rRow = 0; rCol = 0; break;
|
||||
}
|
||||
|
||||
byte[] bgr = new byte[width * height * 3];
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int srcIdx = y * width + x;
|
||||
int dstIdx = (y * width + x) * 3;
|
||||
|
||||
// 确定当前像素在 Bayer 模式中的角色
|
||||
int py = (y + rRow) % 2; // 0=红行, 1=蓝行
|
||||
int px = (x + rCol) % 2; // 0=红列/蓝列, 1=绿列
|
||||
|
||||
byte r, g, b;
|
||||
|
||||
if (py == 0 && px == 0)
|
||||
{
|
||||
// 红色像素位置
|
||||
r = bayer[srcIdx];
|
||||
g = AvgNeighbors4(bayer, width, height, x, y);
|
||||
b = AvgDiagonal(bayer, width, height, x, y);
|
||||
}
|
||||
else if (py == 1 && px == 1)
|
||||
{
|
||||
// 蓝色像素位置
|
||||
b = bayer[srcIdx];
|
||||
g = AvgNeighbors4(bayer, width, height, x, y);
|
||||
r = AvgDiagonal(bayer, width, height, x, y);
|
||||
}
|
||||
else if (py == 0 && px == 1)
|
||||
{
|
||||
// 绿色像素(红行)
|
||||
g = bayer[srcIdx];
|
||||
r = AvgHorizontal(bayer, width, x, y);
|
||||
b = AvgVertical(bayer, width, height, x, y);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 绿色像素(蓝行)
|
||||
g = bayer[srcIdx];
|
||||
b = AvgHorizontal(bayer, width, x, y);
|
||||
r = AvgVertical(bayer, width, height, x, y);
|
||||
}
|
||||
|
||||
bgr[dstIdx] = b;
|
||||
bgr[dstIdx + 1] = g;
|
||||
bgr[dstIdx + 2] = r;
|
||||
}
|
||||
}
|
||||
|
||||
return bgr;
|
||||
}
|
||||
|
||||
private static byte AvgNeighbors4(byte[] data, int w, int h, int x, int y)
|
||||
{
|
||||
int sum = 0, count = 0;
|
||||
if (x > 0) { sum += data[y * w + x - 1]; count++; }
|
||||
if (x < w - 1) { sum += data[y * w + x + 1]; count++; }
|
||||
if (y > 0) { sum += data[(y - 1) * w + x]; count++; }
|
||||
if (y < h - 1) { sum += data[(y + 1) * w + x]; count++; }
|
||||
return count > 0 ? (byte)(sum / count) : (byte)0;
|
||||
}
|
||||
|
||||
private static byte AvgDiagonal(byte[] data, int w, int h, int x, int y)
|
||||
{
|
||||
int sum = 0, count = 0;
|
||||
if (x > 0 && y > 0) { sum += data[(y - 1) * w + x - 1]; count++; }
|
||||
if (x < w - 1 && y > 0) { sum += data[(y - 1) * w + x + 1]; count++; }
|
||||
if (x > 0 && y < h - 1) { sum += data[(y + 1) * w + x - 1]; count++; }
|
||||
if (x < w - 1 && y < h - 1) { sum += data[(y + 1) * w + x + 1]; count++; }
|
||||
return count > 0 ? (byte)(sum / count) : (byte)0;
|
||||
}
|
||||
|
||||
private static byte AvgHorizontal(byte[] data, int w, int x, int y)
|
||||
{
|
||||
int sum = 0, count = 0;
|
||||
if (x > 0) { sum += data[y * w + x - 1]; count++; }
|
||||
if (x < w - 1) { sum += data[y * w + x + 1]; count++; }
|
||||
return count > 0 ? (byte)(sum / count) : (byte)0;
|
||||
}
|
||||
|
||||
private static byte AvgVertical(byte[] data, int w, int h, int x, int y)
|
||||
{
|
||||
int sum = 0, count = 0;
|
||||
if (y > 0) { sum += data[(y - 1) * w + x]; count++; }
|
||||
if (y < h - 1) { sum += data[(y + 1) * w + x]; count++; }
|
||||
return count > 0 ? (byte)(sum / count) : (byte)0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ public class CameraFactory : ICameraFactory
|
||||
return cameraType switch
|
||||
{
|
||||
"Basler" => new BaslerCameraController(),
|
||||
// "Hikvision" => new HikvisionCameraController(),
|
||||
_ => throw new NotSupportedException($"不支持的相机品牌: {cameraType}")
|
||||
"Hikvision" => new HikvisionCameraController(),
|
||||
_ => throw new NotSupportedException($"Unsupported Camera Type: {cameraType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using MvCameraControl;
|
||||
using Serilog;
|
||||
|
||||
namespace XP.Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 海康威视相机控制器,封装 MvCameraControl.Net SDK 实现 <see cref="ICameraController"/>。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>所有公共方法通过内部 <c>_syncLock</c> 对象进行 lock 同步,保证线程安全。</para>
|
||||
/// <para>事件回调(ImageGrabbed、GrabError)在 SDK 回调线程上触发,不持有 _syncLock,避免死锁。</para>
|
||||
/// </remarks>
|
||||
public class HikvisionCameraController : ICameraController
|
||||
{
|
||||
private static readonly ILogger _logger = Log.ForContext<HikvisionCameraController>();
|
||||
private static bool _sdkInitialized;
|
||||
private static readonly object _sdkInitLock = new();
|
||||
|
||||
private readonly object _syncLock = new();
|
||||
private IDevice? _device;
|
||||
private CameraInfo? _cachedCameraInfo;
|
||||
private bool _isConnected;
|
||||
private bool _isGrabbing;
|
||||
|
||||
public HikvisionCameraController()
|
||||
{
|
||||
// SDK 初始化延迟到 Open() 中执行
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected
|
||||
{
|
||||
get { lock (_syncLock) { return _isConnected; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsGrabbing
|
||||
{
|
||||
get { lock (_syncLock) { return _isGrabbing; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<ImageGrabbedEventArgs>? ImageGrabbed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<GrabErrorEventArgs>? GrabError;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler? ConnectionLost;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CameraInfo Open()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (_isConnected && _cachedCameraInfo != null)
|
||||
{
|
||||
_logger.Information("Hikvision camera already connected, returning cached info.");
|
||||
return _cachedCameraInfo;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Information("Opening Hikvision camera connection...");
|
||||
|
||||
// 确保 SDK 初始化
|
||||
EnsureSdkInitialized();
|
||||
|
||||
// 枚举设备
|
||||
DeviceTLayerType layerType = DeviceTLayerType.MvGigEDevice
|
||||
| DeviceTLayerType.MvUsbDevice;
|
||||
|
||||
List<IDeviceInfo> deviceInfoList;
|
||||
int ret = DeviceEnumerator.EnumDevices(layerType, out deviceInfoList);
|
||||
_logger.Information("EnumDevices(GigE|USB) returned: 0x{RetCode:X8}, device count: {Count}",
|
||||
ret, deviceInfoList?.Count ?? 0);
|
||||
|
||||
// 如果没找到,分别尝试
|
||||
if (ret == MvError.MV_OK && (deviceInfoList == null || deviceInfoList.Count == 0))
|
||||
{
|
||||
// 单独尝试 GigE
|
||||
List<IDeviceInfo> gigeList;
|
||||
int retGige = DeviceEnumerator.EnumDevices(DeviceTLayerType.MvGigEDevice, out gigeList);
|
||||
_logger.Information("EnumDevices(GigE only) returned: 0x{RetCode:X8}, count: {Count}",
|
||||
retGige, gigeList?.Count ?? 0);
|
||||
|
||||
// 单独尝试 USB
|
||||
List<IDeviceInfo> usbList;
|
||||
int retUsb = DeviceEnumerator.EnumDevices(DeviceTLayerType.MvUsbDevice, out usbList);
|
||||
_logger.Information("EnumDevices(USB only) returned: 0x{RetCode:X8}, count: {Count}",
|
||||
retUsb, usbList?.Count ?? 0);
|
||||
|
||||
// 合并结果
|
||||
deviceInfoList = new List<IDeviceInfo>();
|
||||
if (gigeList != null) deviceInfoList.AddRange(gigeList);
|
||||
if (usbList != null) deviceInfoList.AddRange(usbList);
|
||||
}
|
||||
|
||||
if (ret != MvError.MV_OK)
|
||||
{
|
||||
throw new CameraException($"Enumerate Hikvision devices failed: 0x{ret:X8}");
|
||||
}
|
||||
|
||||
if (deviceInfoList == null || deviceInfoList.Count == 0)
|
||||
{
|
||||
throw new DeviceNotFoundException("No Hikvision camera device found.");
|
||||
}
|
||||
|
||||
// 选择第一个设备
|
||||
IDeviceInfo deviceInfo = deviceInfoList[0];
|
||||
_logger.Information("Found Hikvision device: {Model} (SN: {Serial})",
|
||||
deviceInfo.ModelName, deviceInfo.SerialNumber);
|
||||
|
||||
// 创建设备
|
||||
_device = DeviceFactory.CreateDevice(deviceInfo);
|
||||
|
||||
// 打开设备
|
||||
ret = _device.Open();
|
||||
if (ret != MvError.MV_OK)
|
||||
{
|
||||
_device.Dispose();
|
||||
_device = null;
|
||||
throw new CameraException($"Open Hikvision device failed: 0x{ret:X8}");
|
||||
}
|
||||
|
||||
// GigE 设备优化包大小
|
||||
if (_device is IGigEDevice gigEDevice)
|
||||
{
|
||||
int packetSize;
|
||||
ret = gigEDevice.GetOptimalPacketSize(out packetSize);
|
||||
if (ret == MvError.MV_OK && packetSize > 0)
|
||||
{
|
||||
_device.Parameters.SetIntValue("GevSCPSPacketSize", packetSize);
|
||||
_logger.Debug("Set GigE packet size to {PacketSize}", packetSize);
|
||||
}
|
||||
}
|
||||
|
||||
// 配置软件触发模式
|
||||
_device.Parameters.SetEnumValueByString("TriggerMode", "On");
|
||||
_device.Parameters.SetEnumValueByString("TriggerSource", "Software");
|
||||
|
||||
// 彩色相机:尝试设置输出为 BGR8 以便直接显示
|
||||
// 如果相机不支持 BGR8(如只支持 Bayer),则保持默认
|
||||
int fmtRet = _device.Parameters.SetEnumValueByString("PixelFormat", "BGR8Packed");
|
||||
if (fmtRet != MvError.MV_OK)
|
||||
{
|
||||
// 尝试 Mono8(黑白相机)
|
||||
fmtRet = _device.Parameters.SetEnumValueByString("PixelFormat", "Mono8");
|
||||
}
|
||||
_logger.Debug("Set PixelFormat result: 0x{Ret:X8}", fmtRet);
|
||||
|
||||
_cachedCameraInfo = new CameraInfo(
|
||||
ModelName: deviceInfo.ModelName ?? "",
|
||||
SerialNumber: deviceInfo.SerialNumber ?? "",
|
||||
VendorName: deviceInfo.ManufacturerName ?? "",
|
||||
DeviceType: deviceInfo.TLayerType.ToString()
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
_logger.Information("Hikvision camera connected: {ModelName} (SN: {SerialNumber})",
|
||||
_cachedCameraInfo.ModelName, _cachedCameraInfo.SerialNumber);
|
||||
|
||||
return _cachedCameraInfo;
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException)
|
||||
{
|
||||
_device?.Dispose();
|
||||
_device = null;
|
||||
_logger.Error(ex, "Failed to open Hikvision camera.");
|
||||
throw new CameraException("Failed to open Hikvision camera device.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Close()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (!_isConnected)
|
||||
{
|
||||
_logger.Information("Hikvision camera not connected, Close() ignored.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_isGrabbing)
|
||||
{
|
||||
StopGrabbingInternal();
|
||||
}
|
||||
|
||||
_logger.Information("Closing Hikvision camera connection...");
|
||||
_device?.Close();
|
||||
_device?.Dispose();
|
||||
_device = null;
|
||||
_isConnected = false;
|
||||
_cachedCameraInfo = null;
|
||||
_logger.Information("Hikvision camera connection closed.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException)
|
||||
{
|
||||
_device = null;
|
||||
_isConnected = false;
|
||||
_isGrabbing = false;
|
||||
_cachedCameraInfo = null;
|
||||
_logger.Error(ex, "Error while closing Hikvision camera.");
|
||||
throw new CameraException("Failed to close Hikvision camera device.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void StartGrabbing()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
if (_isGrabbing)
|
||||
{
|
||||
_logger.Information("Already grabbing, StartGrabbing() ignored.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Information("Starting Hikvision grabbing with software trigger...");
|
||||
|
||||
// 设置缓存节点数
|
||||
_device!.StreamGrabber.SetImageNodeNum(5);
|
||||
|
||||
// 注册回调
|
||||
_device.StreamGrabber.FrameGrabedEvent += OnFrameGrabbed;
|
||||
|
||||
// 开始采集
|
||||
int ret = _device.StreamGrabber.StartGrabbing();
|
||||
if (ret != MvError.MV_OK)
|
||||
{
|
||||
_device.StreamGrabber.FrameGrabedEvent -= OnFrameGrabbed;
|
||||
throw new CameraException($"Start grabbing failed: 0x{ret:X8}");
|
||||
}
|
||||
|
||||
_isGrabbing = true;
|
||||
_logger.Information("Hikvision grabbing started.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException)
|
||||
{
|
||||
_logger.Error(ex, "Failed to start Hikvision grabbing.");
|
||||
throw new CameraException("Failed to start grabbing.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExecuteSoftwareTrigger()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (!_isGrabbing)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot execute software trigger: camera is not grabbing.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int ret = _device!.Parameters.SetCommandValue("TriggerSoftware");
|
||||
if (ret != MvError.MV_OK)
|
||||
{
|
||||
throw new CameraException($"Execute software trigger failed: 0x{ret:X8}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException and not InvalidOperationException)
|
||||
{
|
||||
_logger.Error(ex, "Failed to execute software trigger.");
|
||||
throw new CameraException("Failed to execute software trigger.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void StopGrabbing()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (!_isGrabbing) return;
|
||||
StopGrabbingInternal();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public double GetExposureTime()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
IFloatValue floatValue;
|
||||
int ret = _device!.Parameters.GetFloatValue("ExposureTime", out floatValue);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Get ExposureTime failed: 0x{ret:X8}");
|
||||
return floatValue.CurValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetExposureTime(double microseconds)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
// 关闭自动曝光
|
||||
_device!.Parameters.SetEnumValueByString("ExposureAuto", "Off");
|
||||
int ret = _device.Parameters.SetFloatValue("ExposureTime", (float)microseconds);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Set ExposureTime failed: 0x{ret:X8}");
|
||||
_logger.Information("Hikvision exposure time set to {Microseconds} µs.", microseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public double GetGain()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
IFloatValue floatValue;
|
||||
int ret = _device!.Parameters.GetFloatValue("Gain", out floatValue);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Get Gain failed: 0x{ret:X8}");
|
||||
return floatValue.CurValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetGain(double value)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
_device!.Parameters.SetEnumValueByString("GainAuto", "Off");
|
||||
int ret = _device.Parameters.SetFloatValue("Gain", (float)value);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Set Gain failed: 0x{ret:X8}");
|
||||
_logger.Information("Hikvision gain set to {Value}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetWidth()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
IIntValue intValue;
|
||||
int ret = _device!.Parameters.GetIntValue("Width", out intValue);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Get Width failed: 0x{ret:X8}");
|
||||
return (int)intValue.CurValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetWidth(int value)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
int ret = _device!.Parameters.SetIntValue("Width", value);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Set Width failed: 0x{ret:X8}");
|
||||
_logger.Information("Hikvision width set to {Value}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetHeight()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
IIntValue intValue;
|
||||
int ret = _device!.Parameters.GetIntValue("Height", out intValue);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Get Height failed: 0x{ret:X8}");
|
||||
return (int)intValue.CurValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetHeight(int value)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
int ret = _device!.Parameters.SetIntValue("Height", value);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Set Height failed: 0x{ret:X8}");
|
||||
_logger.Information("Hikvision height set to {Value}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetPixelFormat()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
IEnumValue enumValue;
|
||||
int ret = _device!.Parameters.GetEnumValue("PixelFormat", out enumValue);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Get PixelFormat failed: 0x{ret:X8}");
|
||||
return enumValue.CurEnumEntry.Symbolic;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPixelFormat(string format)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
int ret = _device!.Parameters.SetEnumValueByString("PixelFormat", format);
|
||||
if (ret != MvError.MV_OK)
|
||||
throw new CameraException($"Set PixelFormat failed: 0x{ret:X8}");
|
||||
_logger.Information("Hikvision pixel format set to {Format}.", format);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Close();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 私有方法
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// SDK 回调:图像采集完成
|
||||
/// </summary>
|
||||
private void OnFrameGrabbed(object? sender, FrameGrabbedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frameOut = e.FrameOut;
|
||||
if (frameOut == null || frameOut.Image == null)
|
||||
{
|
||||
_logger.Warning("Hikvision OnFrameGrabbed: FrameOut or Image is null");
|
||||
GrabError?.Invoke(this, new GrabErrorEventArgs(-1, "FrameOut or Image is null."));
|
||||
return;
|
||||
}
|
||||
|
||||
var image = frameOut.Image;
|
||||
int width = (int)image.Width;
|
||||
int height = (int)image.Height;
|
||||
int imageSize = (int)image.ImageSize;
|
||||
string pixelFormat = image.PixelType.ToString();
|
||||
|
||||
// 提取像素数据
|
||||
byte[] pixelData = image.PixelData ?? Array.Empty<byte>();
|
||||
|
||||
_logger.Debug("Hikvision frame: {Width}x{Height}, format={Format}, dataLen={Len}",
|
||||
width, height, pixelFormat, pixelData.Length);
|
||||
|
||||
if (pixelData.Length == 0)
|
||||
{
|
||||
_logger.Warning("Hikvision OnFrameGrabbed: PixelData is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
var args = new ImageGrabbedEventArgs(pixelData, width, height, pixelFormat);
|
||||
ImageGrabbed?.Invoke(this, args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Exception in Hikvision OnFrameGrabbed handler.");
|
||||
}
|
||||
}
|
||||
|
||||
private void StopGrabbingInternal()
|
||||
{
|
||||
if (!_isGrabbing) return;
|
||||
|
||||
try
|
||||
{
|
||||
_device?.StreamGrabber.StopGrabbing();
|
||||
if (_device != null)
|
||||
_device.StreamGrabber.FrameGrabedEvent -= OnFrameGrabbed;
|
||||
_isGrabbing = false;
|
||||
_logger.Information("Hikvision grabbing stopped.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException)
|
||||
{
|
||||
_isGrabbing = false;
|
||||
_logger.Error(ex, "Error while stopping Hikvision grabbing.");
|
||||
throw new CameraException("Failed to stop grabbing.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (!_isConnected)
|
||||
throw new InvalidOperationException("Hikvision camera is not connected. Call Open() first.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保 SDK 全局初始化(只调用一次)
|
||||
/// </summary>
|
||||
private static void EnsureSdkInitialized()
|
||||
{
|
||||
if (_sdkInitialized) return;
|
||||
lock (_sdkInitLock)
|
||||
{
|
||||
if (_sdkInitialized) return;
|
||||
try
|
||||
{
|
||||
int ret = SDKSystem.Initialize();
|
||||
if (ret != MvError.MV_OK)
|
||||
{
|
||||
_logger.Error("Hikvision SDK Initialize failed: 0x{ErrorCode:X8}", ret);
|
||||
throw new CameraException($"Hikvision SDK Initialize failed: 0x{ret:X8}");
|
||||
}
|
||||
_sdkInitialized = true;
|
||||
_logger.Information("Hikvision SDK initialized successfully.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not CameraException)
|
||||
{
|
||||
_logger.Error(ex, "Failed to initialize Hikvision SDK.");
|
||||
throw new CameraException("Failed to initialize Hikvision SDK.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,19 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>XP.Camera</RootNamespace>
|
||||
<AssemblyName>XP.Camera</AssemblyName>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Basler.Pylon">
|
||||
<HintPath>..\ExternalLibraries\Basler.Pylon.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="MvCameraControl.Net">
|
||||
<HintPath>..\ExternalLibraries\MvCameraControl.Net.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<CopyLocal>true</CopyLocal>
|
||||
</Reference>
|
||||
<PackageReference Include="Emgu.CV" Version="4.10.0.5680" />
|
||||
<PackageReference Include="Emgu.CV.Bitmap" Version="4.10.0.5680" />
|
||||
<PackageReference Include="Prism.DryIoc" Version="9.0.537" />
|
||||
|
||||
@@ -1887,4 +1887,122 @@ Reprojection error: {1:F4} pixels</value>
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>Image{0}: {1:F4} pixels</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>Edge Find Line Fit</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>Place calipers along a search line to detect edge points and fit a line (supports Least Squares and RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>Caliper Count</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>Number of calipers placed evenly along the search line</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>Caliper Width</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>Search length of each caliper (pixels), perpendicular to the search line</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>Edge Polarity</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>Edge direction: BrightToDark, DarkToBright, or Both</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>Edge Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>Gradient strength threshold; edges below this value are ignored</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>Smoothing Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>Gaussian smoothing standard deviation for noise suppression (larger = smoother)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>Fit Method</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>Line fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC inlier distance threshold (pixels); points closer than this to the line are inliers</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>Line Thickness</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>Drawing thickness for result visualization</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeCircleFitProcessor -->
|
||||
<data name="EdgeCircleFitProcessor_Name" xml:space="preserve">
|
||||
<value>Edge Find Circle Fit</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Description" xml:space="preserve">
|
||||
<value>Place calipers along estimated circle to detect edge points and fit a circle (supports Least Squares and RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>Caliper Count</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>Number of calipers placed evenly around the circle</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>Caliper Width</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>Search length of each caliper along radial direction (pixels)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>Edge Polarity</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>Edge direction: BrightToDark, DarkToBright, or Both</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>Edge Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>Gradient strength threshold; edges below this value are ignored</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>Smoothing Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>Gaussian smoothing standard deviation for noise suppression</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection" xml:space="preserve">
|
||||
<value>Search Direction</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection_Desc" xml:space="preserve">
|
||||
<value>Caliper search direction: Inward (toward center), Outward (away from center), Both</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>Fit Method</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>Circle fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC inlier distance threshold (pixels); points closer than this to the circle are inliers</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>Line Thickness</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>Drawing thickness for result visualization</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1920,4 +1920,122 @@
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>图像{0}: {1:F4} 像素</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合直线</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿搜索线等间距放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺的搜索长度(像素),沿垂直于搜索线方向</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声(越大越平滑)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeCircleFitProcessor -->
|
||||
<data name="EdgeCircleFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合圆</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿预估圆周放置卡尺检测边缘点,拟合圆(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿圆周等角度放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺沿径向的搜索长度(像素)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection" xml:space="preserve">
|
||||
<value>搜索方向</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection_Desc" xml:space="preserve">
|
||||
<value>卡尺搜索方向:Inward(向圆心)、Outward(背离圆心)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>圆拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到圆周距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1881,4 +1881,122 @@
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>图像{0}: {1:F4} 像素</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合直线</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿搜索线等间距放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺的搜索长度(像素),沿垂直于搜索线方向</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声(越大越平滑)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeCircleFitProcessor -->
|
||||
<data name="EdgeCircleFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合圆</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿预估圆周放置卡尺检测边缘点,拟合圆(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿圆周等角度放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺沿径向的搜索长度(像素)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection" xml:space="preserve">
|
||||
<value>搜索方向</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_SearchDirection_Desc" xml:space="preserve">
|
||||
<value>卡尺搜索方向:Inward(向圆心)、Outward(背离圆心)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>圆拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合)</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到圆周距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeCircleFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace XP.ImageProcessing.Core.Alignment;
|
||||
|
||||
/// <summary>
|
||||
/// 示教阶段保存的对齐配方:基准位姿 + 示教图像素坐标下的检测 ROI。
|
||||
/// </summary>
|
||||
public sealed class AlignmentRecipe
|
||||
{
|
||||
/// <summary>示教图上的基准位姿(建议示教图自匹配得到,或与模板 ROI 中心 + 角度 0 一致)。</summary>
|
||||
public Pose2D ReferencePose { get; set; }
|
||||
|
||||
/// <summary>示教图上的 ROI 多边形顶点(至少 3 点)。</summary>
|
||||
public List<Point2D> RoiPoints { get; set; } = new();
|
||||
|
||||
/// <summary>将示教 ROI 变换到运行图坐标。</summary>
|
||||
public Point2D[] TransformRoi(Pose2D measuredPose)
|
||||
=> RoiAlignment.TransformPolygon(RoiPoints, ReferencePose, measuredPose);
|
||||
|
||||
/// <summary>变换为整型顶点,供检测算子注入。</summary>
|
||||
public (int X, int Y)[] TransformRoiToInt(Pose2D measuredPose)
|
||||
=> RoiAlignment.TransformPolygonToInt(RoiPoints, ReferencePose, measuredPose);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace XP.ImageProcessing.Core.Alignment;
|
||||
|
||||
/// <summary>图像像素平面上的点(与 WPF/Emgu 解耦)。</summary>
|
||||
public readonly record struct Point2D(double X, double Y);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace XP.ImageProcessing.Core.Alignment;
|
||||
|
||||
/// <summary>
|
||||
/// 图像平面上的刚体位姿:绕 <see cref="X"/>/<see cref="Y"/> 旋转 <see cref="AngleDegrees"/>(度)。
|
||||
/// 与 TemplateMatchLib 的 CenterX/CenterY/Angle 约定一致。
|
||||
/// </summary>
|
||||
public readonly record struct Pose2D(double X, double Y, double AngleDegrees)
|
||||
{
|
||||
/// <summary>示教/标准姿态(角度 0,中心由调用方指定)。</summary>
|
||||
public static Pose2D IdentityAt(double x, double y) => new(x, y, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 由模板学习 ROI 矩形估计示教位姿中心(pattern 几何中心),角度默认 0。
|
||||
/// 更稳妥的做法是在示教图上自匹配得到 <see cref="Pose2D"/>。
|
||||
/// </summary>
|
||||
public static Pose2D FromTemplateRoiCenter(int roiX, int roiY, int roiWidth, int roiHeight, double angleDegrees = 0)
|
||||
=> new(roiX + roiWidth * 0.5, roiY + roiHeight * 0.5, angleDegrees);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace XP.ImageProcessing.Core.Alignment;
|
||||
|
||||
/// <summary>
|
||||
/// 将示教图(模板坐标系)上的 ROI 点变换到运行图坐标。
|
||||
/// 旋转中心与模板匹配一致:绕 <see cref="Pose2D.X"/>/<see cref="Pose2D.Y"/>(pattern 中心)。
|
||||
/// </summary>
|
||||
public static class RoiAlignment
|
||||
{
|
||||
/// <summary>
|
||||
/// 刚体变换:示教图点 → 运行图点。
|
||||
/// <paramref name="reference"/>:示教图上的基准位姿;
|
||||
/// <paramref name="measured"/>:运行图匹配位姿。
|
||||
/// </summary>
|
||||
public static Point2D TransformPoint(Point2D point, Pose2D reference, Pose2D measured)
|
||||
{
|
||||
double dTheta = DegreesToRadians(measured.AngleDegrees - reference.AngleDegrees);
|
||||
double cos = Math.Cos(dTheta);
|
||||
double sin = Math.Sin(dTheta);
|
||||
double dx = point.X - reference.X;
|
||||
double dy = point.Y - reference.Y;
|
||||
return new Point2D(
|
||||
measured.X + cos * dx - sin * dy,
|
||||
measured.Y + sin * dx + cos * dy);
|
||||
}
|
||||
|
||||
public static Point2D TransformPoint(double x, double y, Pose2D reference, Pose2D measured)
|
||||
=> TransformPoint(new Point2D(x, y), reference, measured);
|
||||
|
||||
/// <summary>变换多边形顶点(顺序不变)。</summary>
|
||||
public static Point2D[] TransformPolygon(IReadOnlyList<Point2D> templatePoints, Pose2D reference, Pose2D measured)
|
||||
{
|
||||
if (templatePoints == null || templatePoints.Count == 0)
|
||||
return Array.Empty<Point2D>();
|
||||
|
||||
var result = new Point2D[templatePoints.Count];
|
||||
for (int i = 0; i < templatePoints.Count; i++)
|
||||
result[i] = TransformPoint(templatePoints[i], reference, measured);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>变换后四舍五入为整型顶点,供 BGA 等算子 PolyX/PolyY 注入。</summary>
|
||||
public static (int X, int Y)[] TransformPolygonToInt(
|
||||
IReadOnlyList<Point2D> templatePoints,
|
||||
Pose2D reference,
|
||||
Pose2D measured)
|
||||
{
|
||||
var transformed = TransformPolygon(templatePoints, reference, measured);
|
||||
var result = new (int X, int Y)[transformed.Length];
|
||||
for (int i = 0; i < transformed.Length; i++)
|
||||
{
|
||||
result[i] = (
|
||||
(int)Math.Round(transformed[i].X, MidpointRounding.AwayFromZero),
|
||||
(int)Math.Round(transformed[i].Y, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>变换轴对齐矩形为四个顶点(左上、右上、右下、左下)。</summary>
|
||||
public static Point2D[] TransformRect(double x, double y, double width, double height, Pose2D reference, Pose2D measured)
|
||||
{
|
||||
var corners = new[]
|
||||
{
|
||||
new Point2D(x, y),
|
||||
new Point2D(x + width, y),
|
||||
new Point2D(x + width, y + height),
|
||||
new Point2D(x, y + height)
|
||||
};
|
||||
return TransformPolygon(corners, reference, measured);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验匹配结果四角质心是否与 Center 一致(用于确认库的中心/角度约定)。
|
||||
/// </summary>
|
||||
public static bool IsMatchCenterConsistentWithCorners(
|
||||
double centerX,
|
||||
double centerY,
|
||||
double ltX,
|
||||
double ltY,
|
||||
double rtX,
|
||||
double rtY,
|
||||
double rbX,
|
||||
double rbY,
|
||||
double lbX,
|
||||
double lbY,
|
||||
double tolerancePixels = 1.0)
|
||||
{
|
||||
double cx = (ltX + rtX + rbX + lbX) * 0.25;
|
||||
double cy = (ltY + rtY + rbY + lbY) * 0.25;
|
||||
double dx = cx - centerX;
|
||||
double dy = cy - centerY;
|
||||
return dx * dx + dy * dy <= tolerancePixels * tolerancePixels;
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180.0);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using XP.ImageProcessing.Core.Alignment;
|
||||
|
||||
namespace XP.ImageProcessing.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// 将 TemplateMatchLib 匹配结果转换为对齐工具使用的 <see cref="Pose2D"/>。
|
||||
/// </summary>
|
||||
public static class TemplateMatchAlignmentExtensions
|
||||
{
|
||||
public static Pose2D ToPose2D(this TM_Result result)
|
||||
=> new(result.CenterX, result.CenterY, result.Angle);
|
||||
|
||||
/// <summary>四角质心是否与 Center 一致(容差默认 1 像素)。</summary>
|
||||
public static bool IsCenterConsistentWithCorners(this TM_Result result, double tolerancePixels = 1.0)
|
||||
=> RoiAlignment.IsMatchCenterConsistentWithCorners(
|
||||
result.CenterX,
|
||||
result.CenterY,
|
||||
result.LtX,
|
||||
result.LtY,
|
||||
result.RtX,
|
||||
result.RtY,
|
||||
result.RbX,
|
||||
result.RbY,
|
||||
result.LbX,
|
||||
result.LbY,
|
||||
tolerancePixels);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ public struct TM_Params
|
||||
/// <summary>是否亚像素估计 (1=是, 0=否)</summary>
|
||||
public int UseSubPixel;
|
||||
|
||||
/// <summary>
|
||||
/// 开启亚像素且角度容差绝对值超过该值时,托管封装会在调用原生库前关闭亚像素,
|
||||
/// 以避免部分版本 TemplateMatchLib 在 Debug 下出现 vector 越界断言。
|
||||
/// </summary>
|
||||
public const double SubPixelAngleSafetyLimitDegrees = 90.0;
|
||||
|
||||
/// <summary>
|
||||
/// 创建默认参数
|
||||
/// </summary>
|
||||
@@ -168,9 +174,33 @@ public sealed class TemplateMatcherHandle : IDisposable
|
||||
public TM_Result[] Match(IntPtr srcData, int srcWidth, int srcHeight, int srcStep, TM_Params param)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var results = new TM_Result[param.MaxCount];
|
||||
|
||||
// 与库默认一致并对齐已知崩溃组合:Debug 下亚像素 + 大角度容差易触发 vector 越界断言;
|
||||
// 金字塔最小面积过小也可能与内部层级假设不一致。
|
||||
int tw = 0, th = 0, _pyramidLayers = 0;
|
||||
_ = GetTemplateInfo(out tw, out th, out _pyramidLayers);
|
||||
int templatePixels = Math.Max(0, tw) * Math.Max(0, th);
|
||||
|
||||
int maxCount = Math.Clamp(param.MaxCount, 1, 100);
|
||||
int minReduce = (int)Math.Clamp(param.MinReduceArea, 64, 4096);
|
||||
if (templatePixels >= 512)
|
||||
minReduce = Math.Max(256, minReduce);
|
||||
if (templatePixels > 0)
|
||||
minReduce = Math.Min(minReduce, templatePixels);
|
||||
minReduce = Math.Max(64, minReduce);
|
||||
|
||||
int useSubPixel = param.UseSubPixel;
|
||||
if (useSubPixel != 0 && Math.Abs(param.ToleranceAngle) > TM_Params.SubPixelAngleSafetyLimitDegrees)
|
||||
useSubPixel = 0;
|
||||
|
||||
var p = param;
|
||||
p.MaxCount = maxCount;
|
||||
p.MinReduceArea = minReduce;
|
||||
p.UseSubPixel = useSubPixel;
|
||||
|
||||
var results = new TM_Result[p.MaxCount];
|
||||
int count = TemplateMatchNative.TM_Match(_handle, srcData, srcWidth, srcHeight, srcStep,
|
||||
ref param, results, param.MaxCount);
|
||||
ref p, results, p.MaxCount);
|
||||
|
||||
if (count <= 0)
|
||||
return Array.Empty<TM_Result>();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// ============================================================================
|
||||
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
|
||||
// 文件名: BackgroundDefectAnalyzer.cs
|
||||
// 描述: 白底/黑底对比下的缺陷斑点分析(仅 ROI 内计算,不接入流水线算子)
|
||||
// 算法: Otsu 二值化 → 形态学开运算 → 外轮廓 → 面积过滤 → 轮廓顶点最远弦(物理长度与历史等效直径同一标定:mm/px → μm)
|
||||
// 作者: 李伟 wei.lw.li@hexagon.com
|
||||
// ============================================================================
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.CvEnum;
|
||||
using Emgu.CV.Structure;
|
||||
using Emgu.CV.Util;
|
||||
|
||||
namespace XP.ImageProcessing.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// 底色类型:决定 Otsu 后保留的前景是暗区还是亮区。
|
||||
/// </summary>
|
||||
public enum BackgroundDefectMode
|
||||
{
|
||||
/// <summary>白底图像上检测偏暗区域(BinaryInv + Otsu)。</summary>
|
||||
WhiteBackground,
|
||||
|
||||
/// <summary>黑底图像上检测偏亮区域(Binary + Otsu)。</summary>
|
||||
BlackBackground
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个斑点:轮廓顶点相对于 ROI 左上角;<see cref="MaxChordMicrometers"/> 为轮廓顶点间欧氏距离最大值(微米)。
|
||||
/// </summary>
|
||||
public sealed class BackgroundDefectBlob
|
||||
{
|
||||
public Point[] ContourInRoi { get; init; } = Array.Empty<Point>();
|
||||
public double MaxChordMicrometers { get; init; }
|
||||
public Point MaxChordEndAInRoi { get; init; }
|
||||
public Point MaxChordEndBInRoi { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在灰度 ROI 上执行底色缺陷斑点检测。调用方负责构造与释放 <paramref name="roiGray"/>。
|
||||
/// </summary>
|
||||
public static class BackgroundDefectAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// 在 ROI 灰度图上检测斑点。
|
||||
/// </summary>
|
||||
/// <param name="roiGray">ROI 灰度图(单通道 8 位)。</param>
|
||||
/// <param name="mode">白底或黑底模式。</param>
|
||||
/// <param name="minAreaPixels">轮廓最小面积(像素²),小于此值的轮廓丢弃。</param>
|
||||
/// <param name="mmPerPixel">像素物理尺寸(毫米/像素),用于轮廓最远弦换算为微米。</param>
|
||||
/// <param name="morphKernelSize">形态学开运算核尺寸(奇数,默认 3)。</param>
|
||||
public static List<BackgroundDefectBlob> DetectBlobs(
|
||||
Image<Gray, byte> roiGray,
|
||||
BackgroundDefectMode mode,
|
||||
int minAreaPixels = 50,
|
||||
double mmPerPixel = 0.139,
|
||||
int morphKernelSize = 3)
|
||||
{
|
||||
if (roiGray == null) throw new ArgumentNullException(nameof(roiGray));
|
||||
if (minAreaPixels < 1) minAreaPixels = 1;
|
||||
if (mmPerPixel <= 0) mmPerPixel = 0.139;
|
||||
if (morphKernelSize < 1) morphKernelSize = 1;
|
||||
if ((morphKernelSize & 1) == 0) morphKernelSize++;
|
||||
|
||||
int rw = roiGray.Width;
|
||||
int rh = roiGray.Height;
|
||||
if (rw < 1 || rh < 1) return new List<BackgroundDefectBlob>();
|
||||
|
||||
var thresholdType = mode == BackgroundDefectMode.WhiteBackground
|
||||
? ThresholdType.BinaryInv | ThresholdType.Otsu
|
||||
: ThresholdType.Binary | ThresholdType.Otsu;
|
||||
|
||||
using var binary = new Image<Gray, byte>(rw, rh);
|
||||
CvInvoke.Threshold(roiGray, binary, 0, 255, thresholdType);
|
||||
|
||||
using var kernel = CvInvoke.GetStructuringElement(
|
||||
ElementShape.Ellipse, new Size(morphKernelSize, morphKernelSize), new Point(-1, -1));
|
||||
CvInvoke.MorphologyEx(binary, binary, MorphOp.Open, kernel, new Point(-1, -1), 1,
|
||||
BorderType.Default, new MCvScalar(0));
|
||||
|
||||
using var contours = new VectorOfVectorOfPoint();
|
||||
using var hierarchy = new Mat();
|
||||
CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
|
||||
|
||||
var result = new List<BackgroundDefectBlob>();
|
||||
|
||||
for (int i = 0; i < contours.Size; i++)
|
||||
{
|
||||
double area = CvInvoke.ContourArea(contours[i]);
|
||||
if (area < minAreaPixels) continue;
|
||||
|
||||
int n = contours[i].Size;
|
||||
if (n < 2) continue;
|
||||
|
||||
var pts = new Point[n];
|
||||
for (int j = 0; j < n; j++)
|
||||
pts[j] = contours[i][j];
|
||||
|
||||
MaxChordInPixelSpace(pts, out double maxChordPx, out Point pa, out Point pb);
|
||||
double maxChordMicrometers = maxChordPx * mmPerPixel * 1000.0;
|
||||
|
||||
result.Add(new BackgroundDefectBlob
|
||||
{
|
||||
ContourInRoi = pts,
|
||||
MaxChordMicrometers = maxChordMicrometers,
|
||||
MaxChordEndAInRoi = pa,
|
||||
MaxChordEndBInRoi = pb
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>轮廓顶点集合上的最远点对(欧氏距离,像素)。</summary>
|
||||
private static void MaxChordInPixelSpace(Point[] pts, out double maxChordPx, out Point a, out Point b)
|
||||
{
|
||||
maxChordPx = 0;
|
||||
a = pts[0];
|
||||
b = pts.Length > 1 ? pts[1] : pts[0];
|
||||
long bestSq = 0;
|
||||
int bestI = 0, bestJ = 1;
|
||||
int n = pts.Length;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int iX = pts[i].X, iY = pts[i].Y;
|
||||
for (int j = i + 1; j < n; j++)
|
||||
{
|
||||
long dx = iX - pts[j].X;
|
||||
long dy = iY - pts[j].Y;
|
||||
long sq = dx * dx + dy * dy;
|
||||
if (sq > bestSq)
|
||||
{
|
||||
bestSq = sq;
|
||||
bestI = i;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a = pts[bestI];
|
||||
b = pts[bestJ];
|
||||
maxChordPx = Math.Sqrt(bestSq);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// ============================================================================
|
||||
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
|
||||
// 文件名: EdgeCircleFitProcessor.cs
|
||||
// 描述: 边缘查找拟合圆算子
|
||||
// 功能:
|
||||
// - 沿预估圆周等角度放置卡尺,每个卡尺沿径向搜索边缘点
|
||||
// - 支持亚像素精度(抛物线插值)
|
||||
// - 支持边缘极性选择和搜索方向(向内/向外)
|
||||
// - 使用最小二乘或RANSAC算法拟合圆
|
||||
// - 输出拟合圆参数、边缘点、内点/外点、拟合误差
|
||||
// 算法: 卡尺边缘检测 + 最小二乘/RANSAC圆拟合
|
||||
// 作者: 李伟 wei.lw.li@hexagon.com
|
||||
// ============================================================================
|
||||
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.Structure;
|
||||
using XP.ImageProcessing.Core;
|
||||
using Serilog;
|
||||
using System.Drawing;
|
||||
|
||||
namespace XP.ImageProcessing.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// 圆拟合结果
|
||||
/// </summary>
|
||||
public class CircleFitResult
|
||||
{
|
||||
/// <summary>拟合是否成功</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>拟合圆心X</summary>
|
||||
public double CenterX { get; set; }
|
||||
|
||||
/// <summary>拟合圆心Y</summary>
|
||||
public double CenterY { get; set; }
|
||||
|
||||
/// <summary>拟合半径</summary>
|
||||
public double Radius { get; set; }
|
||||
|
||||
/// <summary>所有检测到的边缘点</summary>
|
||||
public List<EdgePointInfo> EdgePoints { get; set; } = new();
|
||||
|
||||
/// <summary>内点列表</summary>
|
||||
public List<PointF> Inliers { get; set; } = new();
|
||||
|
||||
/// <summary>外点列表</summary>
|
||||
public List<PointF> Outliers { get; set; } = new();
|
||||
|
||||
/// <summary>平均拟合误差(像素)</summary>
|
||||
public double FitError { get; set; }
|
||||
|
||||
/// <summary>有效边缘点数</summary>
|
||||
public int EdgePointCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 边缘查找拟合圆算子 - 沿预估圆周放置卡尺检测边缘点并拟合圆
|
||||
/// </summary>
|
||||
public class EdgeCircleFitProcessor : ImageProcessorBase
|
||||
{
|
||||
private static readonly ILogger _logger = Log.ForContext<EdgeCircleFitProcessor>();
|
||||
private static readonly Random _random = new();
|
||||
|
||||
public EdgeCircleFitProcessor()
|
||||
{
|
||||
Name = LocalizationHelper.GetString("EdgeCircleFitProcessor_Name");
|
||||
Description = LocalizationHelper.GetString("EdgeCircleFitProcessor_Description");
|
||||
}
|
||||
|
||||
protected override void InitializeParameters()
|
||||
{
|
||||
// ── 预估圆参数(由UI交互注入,不可见) ──
|
||||
Parameters.Add("CenterX", new ProcessorParameter(
|
||||
"CenterX", "CenterX", typeof(int), 200, null, null, "") { IsVisible = false });
|
||||
Parameters.Add("CenterY", new ProcessorParameter(
|
||||
"CenterY", "CenterY", typeof(int), 200, null, null, "") { IsVisible = false });
|
||||
Parameters.Add("Radius", new ProcessorParameter(
|
||||
"Radius", "Radius", typeof(int), 100, null, null, "") { IsVisible = false });
|
||||
|
||||
// ── 卡尺参数 ──
|
||||
Parameters.Add("CaliperCount", new ProcessorParameter(
|
||||
"CaliperCount",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_CaliperCount"),
|
||||
typeof(int), 36, 3, 360,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_CaliperCount_Desc")));
|
||||
|
||||
Parameters.Add("CaliperWidth", new ProcessorParameter(
|
||||
"CaliperWidth",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_CaliperWidth"),
|
||||
typeof(int), 40, 5, 500,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_CaliperWidth_Desc")));
|
||||
|
||||
// ── 边缘检测参数 ──
|
||||
Parameters.Add("EdgePolarity", new ProcessorParameter(
|
||||
"EdgePolarity",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_EdgePolarity"),
|
||||
typeof(string), "Both", null, null,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_EdgePolarity_Desc"),
|
||||
new string[] { "BrightToDark", "DarkToBright", "Both" }));
|
||||
|
||||
Parameters.Add("EdgeThreshold", new ProcessorParameter(
|
||||
"EdgeThreshold",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_EdgeThreshold"),
|
||||
typeof(int), 20, 1, 255,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_EdgeThreshold_Desc")));
|
||||
|
||||
Parameters.Add("Sigma", new ProcessorParameter(
|
||||
"Sigma",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_Sigma"),
|
||||
typeof(double), 1.0, 0.1, 10.0,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_Sigma_Desc")));
|
||||
|
||||
Parameters.Add("SearchDirection", new ProcessorParameter(
|
||||
"SearchDirection",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_SearchDirection"),
|
||||
typeof(string), "Both", null, null,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_SearchDirection_Desc"),
|
||||
new string[] { "Inward", "Outward", "Both" }));
|
||||
|
||||
// ── 拟合参数 ──
|
||||
Parameters.Add("FitMethod", new ProcessorParameter(
|
||||
"FitMethod",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_FitMethod"),
|
||||
typeof(string), "RANSAC", null, null,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_FitMethod_Desc"),
|
||||
new string[] { "LeastSquares", "RANSAC" }));
|
||||
|
||||
Parameters.Add("RansacThreshold", new ProcessorParameter(
|
||||
"RansacThreshold",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_RansacThreshold"),
|
||||
typeof(double), 2.0, 0.5, 20.0,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_RansacThreshold_Desc")));
|
||||
|
||||
Parameters.Add("Thickness", new ProcessorParameter(
|
||||
"Thickness",
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_Thickness"),
|
||||
typeof(int), 2, 1, 10,
|
||||
LocalizationHelper.GetString("EdgeCircleFitProcessor_Thickness_Desc")));
|
||||
}
|
||||
|
||||
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
|
||||
{
|
||||
int centerX = GetParameter<int>("CenterX");
|
||||
int centerY = GetParameter<int>("CenterY");
|
||||
int radius = GetParameter<int>("Radius");
|
||||
int caliperCount = GetParameter<int>("CaliperCount");
|
||||
int caliperWidth = GetParameter<int>("CaliperWidth");
|
||||
string edgePolarity = GetParameter<string>("EdgePolarity");
|
||||
int edgeThreshold = GetParameter<int>("EdgeThreshold");
|
||||
double sigma = GetParameter<double>("Sigma");
|
||||
string searchDirection = GetParameter<string>("SearchDirection");
|
||||
string fitMethod = GetParameter<string>("FitMethod");
|
||||
double ransacThreshold = GetParameter<double>("RansacThreshold");
|
||||
|
||||
OutputData.Clear();
|
||||
|
||||
_logger.Debug(
|
||||
"EdgeCircleFit started: Center=({CX},{CY}), R={R}, Calipers={Count}, Width={Width}",
|
||||
centerX, centerY, radius, caliperCount, caliperWidth);
|
||||
|
||||
if (radius < 5)
|
||||
{
|
||||
_logger.Warning("Radius too small for circle fitting");
|
||||
OutputData["CircleFitResult"] = new CircleFitResult { Success = false };
|
||||
return inputImage.Clone();
|
||||
}
|
||||
|
||||
// 沿圆周等角度放置卡尺
|
||||
var edgePoints = new List<EdgePointInfo>();
|
||||
double angleStep = 2.0 * Math.PI / caliperCount;
|
||||
|
||||
for (int i = 0; i < caliperCount; i++)
|
||||
{
|
||||
double angle = angleStep * i;
|
||||
// 圆周上的采样点
|
||||
double sampleX = centerX + radius * Math.Cos(angle);
|
||||
double sampleY = centerY + radius * Math.Sin(angle);
|
||||
|
||||
// 径向方向(从圆心指向外)
|
||||
double dirX = Math.Cos(angle);
|
||||
double dirY = Math.Sin(angle);
|
||||
|
||||
// 根据搜索方向确定卡尺搜索方向
|
||||
double searchDirX, searchDirY;
|
||||
if (searchDirection == "Inward")
|
||||
{
|
||||
searchDirX = -dirX;
|
||||
searchDirY = -dirY;
|
||||
}
|
||||
else if (searchDirection == "Outward")
|
||||
{
|
||||
searchDirX = dirX;
|
||||
searchDirY = dirY;
|
||||
}
|
||||
else // Both: 搜索方向为径向(从内到外),卡尺中心在圆周上
|
||||
{
|
||||
searchDirX = dirX;
|
||||
searchDirY = dirY;
|
||||
}
|
||||
|
||||
var edgePoint = FindEdgeInCaliper(
|
||||
inputImage, sampleX, sampleY, searchDirX, searchDirY,
|
||||
caliperWidth, edgePolarity, edgeThreshold, sigma, i);
|
||||
|
||||
if (edgePoint != null)
|
||||
{
|
||||
edgePoints.Add(edgePoint);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug("Found {Count} edge points from {Total} calipers", edgePoints.Count, caliperCount);
|
||||
|
||||
// 拟合圆
|
||||
var result = FitCircle(edgePoints, fitMethod, ransacThreshold);
|
||||
|
||||
// 存储输出
|
||||
OutputData["CircleFitResult"] = result;
|
||||
OutputData["EdgePoints"] = edgePoints.Select(p => p.Position).ToArray();
|
||||
OutputData["EdgePointCount"] = edgePoints.Count;
|
||||
OutputData["Thickness"] = GetParameter<int>("Thickness");
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
OutputData["FittedCenterX"] = result.CenterX;
|
||||
OutputData["FittedCenterY"] = result.CenterY;
|
||||
OutputData["FittedRadius"] = result.Radius;
|
||||
OutputData["InlierPoints"] = result.Inliers.ToArray();
|
||||
OutputData["OutlierPoints"] = result.Outliers.ToArray();
|
||||
OutputData["FitError"] = result.FitError;
|
||||
|
||||
_logger.Information(
|
||||
"EdgeCircleFit completed: Center=({CX:F2},{CY:F2}), R={R:F2}, Inliers={Inliers}/{Total}, Error={Error:F3}px",
|
||||
result.CenterX, result.CenterY, result.Radius,
|
||||
result.Inliers.Count, edgePoints.Count, result.FitError);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("EdgeCircleFit failed: insufficient edge points");
|
||||
}
|
||||
|
||||
return inputImage.Clone();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 卡尺边缘检测(复用直线拟合中的逻辑)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private EdgePointInfo? FindEdgeInCaliper(
|
||||
Image<Gray, byte> image,
|
||||
double centerX, double centerY,
|
||||
double dirX, double dirY,
|
||||
int caliperWidth, string polarity,
|
||||
int threshold, double sigma, int caliperIndex)
|
||||
{
|
||||
int halfWidth = caliperWidth / 2;
|
||||
int profileLength = caliperWidth;
|
||||
|
||||
var profile = new double[profileLength];
|
||||
int validCount = 0;
|
||||
|
||||
for (int i = 0; i < profileLength; i++)
|
||||
{
|
||||
double offset = i - halfWidth;
|
||||
double px = centerX + dirX * offset;
|
||||
double py = centerY + dirY * offset;
|
||||
|
||||
int ix = (int)Math.Round(px);
|
||||
int iy = (int)Math.Round(py);
|
||||
|
||||
if (ix >= 0 && ix < image.Width && iy >= 0 && iy < image.Height)
|
||||
{
|
||||
profile[i] = image.Data[iy, ix, 0];
|
||||
validCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
profile[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (validCount < profileLength * 0.5)
|
||||
return null;
|
||||
|
||||
if (sigma > 0.1)
|
||||
profile = GaussianSmooth1D(profile, sigma);
|
||||
|
||||
var derivative = new double[profileLength];
|
||||
for (int i = 1; i < profileLength - 1; i++)
|
||||
derivative[i] = (profile[i + 1] - profile[i - 1]) / 2.0;
|
||||
|
||||
int bestIdx = -1;
|
||||
double bestStrength = 0;
|
||||
|
||||
for (int i = 2; i < profileLength - 2; i++)
|
||||
{
|
||||
double strength = derivative[i];
|
||||
bool validPolarity = polarity switch
|
||||
{
|
||||
"BrightToDark" => strength < 0,
|
||||
"DarkToBright" => strength > 0,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!validPolarity) continue;
|
||||
|
||||
double absStrength = Math.Abs(strength);
|
||||
if (absStrength >= threshold && absStrength > bestStrength)
|
||||
{
|
||||
bestStrength = absStrength;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0)
|
||||
return null;
|
||||
|
||||
// 亚像素插值
|
||||
double subPixelOffset = 0;
|
||||
if (bestIdx > 0 && bestIdx < profileLength - 1)
|
||||
{
|
||||
double left = Math.Abs(derivative[bestIdx - 1]);
|
||||
double center = Math.Abs(derivative[bestIdx]);
|
||||
double right = Math.Abs(derivative[bestIdx + 1]);
|
||||
double denom = 2.0 * (2.0 * center - left - right);
|
||||
if (Math.Abs(denom) > 1e-6)
|
||||
{
|
||||
subPixelOffset = (left - right) / denom;
|
||||
subPixelOffset = Math.Clamp(subPixelOffset, -0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
double edgeOffset = (bestIdx + subPixelOffset) - halfWidth;
|
||||
float edgeX = (float)(centerX + dirX * edgeOffset);
|
||||
float edgeY = (float)(centerY + dirY * edgeOffset);
|
||||
|
||||
return new EdgePointInfo
|
||||
{
|
||||
Position = new PointF(edgeX, edgeY),
|
||||
Strength = bestStrength,
|
||||
CaliperIndex = caliperIndex,
|
||||
IsInlier = true
|
||||
};
|
||||
}
|
||||
|
||||
private static double[] GaussianSmooth1D(double[] data, double sigma)
|
||||
{
|
||||
int kernelRadius = (int)Math.Ceiling(sigma * 3);
|
||||
int kernelSize = kernelRadius * 2 + 1;
|
||||
var kernel = new double[kernelSize];
|
||||
double sum = 0;
|
||||
|
||||
for (int i = 0; i < kernelSize; i++)
|
||||
{
|
||||
double x = i - kernelRadius;
|
||||
kernel[i] = Math.Exp(-x * x / (2.0 * sigma * sigma));
|
||||
sum += kernel[i];
|
||||
}
|
||||
for (int i = 0; i < kernelSize; i++)
|
||||
kernel[i] /= sum;
|
||||
|
||||
var result = new double[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
double val = 0, wSum = 0;
|
||||
for (int k = 0; k < kernelSize; k++)
|
||||
{
|
||||
int idx = i + k - kernelRadius;
|
||||
if (idx >= 0 && idx < data.Length)
|
||||
{
|
||||
val += data[idx] * kernel[k];
|
||||
wSum += kernel[k];
|
||||
}
|
||||
}
|
||||
result[i] = wSum > 0 ? val / wSum : data[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 圆拟合
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private CircleFitResult FitCircle(List<EdgePointInfo> edgePoints, string method, double ransacThreshold)
|
||||
{
|
||||
var result = new CircleFitResult();
|
||||
|
||||
if (edgePoints.Count < 3)
|
||||
{
|
||||
result.Success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (method == "RANSAC" && edgePoints.Count >= 4)
|
||||
return FitCircleRANSAC(edgePoints, ransacThreshold);
|
||||
else
|
||||
return FitCircleLeastSquares(edgePoints);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最小二乘拟合圆(Kasa方法)
|
||||
/// 将 (x-a)² + (y-b)² = r² 展开为: x² + y² = 2ax + 2by + (r²-a²-b²)
|
||||
/// 令 c = r²-a²-b², 线性方程: 2ax + 2by + c = x² + y²
|
||||
/// </summary>
|
||||
private CircleFitResult FitCircleLeastSquares(List<EdgePointInfo> edgePoints)
|
||||
{
|
||||
var points = edgePoints.Select(p => p.Position).ToArray();
|
||||
var (cx, cy, r) = KasaFit(points);
|
||||
|
||||
var result = new CircleFitResult
|
||||
{
|
||||
Success = true,
|
||||
CenterX = cx,
|
||||
CenterY = cy,
|
||||
Radius = r,
|
||||
Inliers = points.ToList(),
|
||||
Outliers = new List<PointF>(),
|
||||
EdgePointCount = edgePoints.Count,
|
||||
EdgePoints = edgePoints
|
||||
};
|
||||
|
||||
foreach (var ep in edgePoints)
|
||||
ep.IsInlier = true;
|
||||
|
||||
result.FitError = ComputeCircleFitError(points, cx, cy, r);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RANSAC 圆拟合
|
||||
/// </summary>
|
||||
private CircleFitResult FitCircleRANSAC(List<EdgePointInfo> edgePoints, double threshold)
|
||||
{
|
||||
var result = new CircleFitResult();
|
||||
var points = edgePoints.Select(p => p.Position).ToArray();
|
||||
int n = points.Length;
|
||||
|
||||
int maxIterations = Math.Min(2000, n * (n - 1) * (n - 2) / 6);
|
||||
int bestInlierCount = 0;
|
||||
double bestCx = 0, bestCy = 0, bestR = 0;
|
||||
List<int> bestInlierIndices = new();
|
||||
|
||||
for (int iter = 0; iter < maxIterations; iter++)
|
||||
{
|
||||
// 随机选3个点
|
||||
int i1 = _random.Next(n), i2 = _random.Next(n), i3 = _random.Next(n);
|
||||
if (i1 == i2 || i1 == i3 || i2 == i3) continue;
|
||||
|
||||
var (cx, cy, r) = FitCircleFrom3Points(points[i1], points[i2], points[i3]);
|
||||
if (r <= 0 || double.IsNaN(r)) continue;
|
||||
|
||||
// 统计内点
|
||||
var inlierIndices = new List<int>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dist = Math.Abs(Distance(points[i], cx, cy) - r);
|
||||
if (dist <= threshold)
|
||||
inlierIndices.Add(i);
|
||||
}
|
||||
|
||||
if (inlierIndices.Count > bestInlierCount)
|
||||
{
|
||||
bestInlierCount = inlierIndices.Count;
|
||||
bestInlierIndices = inlierIndices;
|
||||
|
||||
// 用所有内点重新拟合
|
||||
var inlierPoints = inlierIndices.Select(i => points[i]).ToArray();
|
||||
(bestCx, bestCy, bestR) = KasaFit(inlierPoints);
|
||||
}
|
||||
|
||||
if (bestInlierCount > n * 0.95)
|
||||
break;
|
||||
}
|
||||
|
||||
if (bestInlierCount < 3)
|
||||
{
|
||||
result.Success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
result.CenterX = bestCx;
|
||||
result.CenterY = bestCy;
|
||||
result.Radius = bestR;
|
||||
|
||||
var inlierSet = new HashSet<int>(bestInlierIndices);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (inlierSet.Contains(i))
|
||||
{
|
||||
result.Inliers.Add(points[i]);
|
||||
edgePoints[i].IsInlier = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Outliers.Add(points[i]);
|
||||
edgePoints[i].IsInlier = false;
|
||||
}
|
||||
}
|
||||
|
||||
result.FitError = ComputeCircleFitError(result.Inliers.ToArray(), bestCx, bestCy, bestR);
|
||||
result.EdgePointCount = edgePoints.Count;
|
||||
result.EdgePoints = edgePoints;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kasa 最小二乘圆拟合
|
||||
/// </summary>
|
||||
private static (double cx, double cy, double r) KasaFit(PointF[] points)
|
||||
{
|
||||
int n = points.Length;
|
||||
if (n < 3) return (0, 0, 0);
|
||||
|
||||
// 构建线性方程组: A * [a, b, c]^T = B
|
||||
// 其中 2*a*xi + 2*b*yi + c = xi² + yi²
|
||||
double sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
|
||||
double sumXY = 0, sumX3 = 0, sumY3 = 0, sumX2Y = 0, sumXY2 = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double x = points[i].X, y = points[i].Y;
|
||||
double x2 = x * x, y2 = y * y;
|
||||
sumX += x; sumY += y;
|
||||
sumX2 += x2; sumY2 += y2;
|
||||
sumXY += x * y;
|
||||
sumX3 += x2 * x; sumY3 += y2 * y;
|
||||
sumX2Y += x2 * y; sumXY2 += x * y2;
|
||||
}
|
||||
|
||||
double A = n * sumX2 - sumX * sumX;
|
||||
double B = n * sumXY - sumX * sumY;
|
||||
double C = n * sumY2 - sumY * sumY;
|
||||
double D = 0.5 * (n * (sumX3 + sumXY2) - sumX * (sumX2 + sumY2));
|
||||
double E = 0.5 * (n * (sumX2Y + sumY3) - sumY * (sumX2 + sumY2));
|
||||
|
||||
double denom = A * C - B * B;
|
||||
if (Math.Abs(denom) < 1e-10)
|
||||
return (0, 0, 0);
|
||||
|
||||
double cx = (D * C - B * E) / denom;
|
||||
double cy = (A * E - B * D) / denom;
|
||||
double r = Math.Sqrt((sumX2 + sumY2 - 2 * cx * sumX - 2 * cy * sumY) / n + cx * cx + cy * cy);
|
||||
|
||||
return (cx, cy, r);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3点拟合圆
|
||||
/// </summary>
|
||||
private static (double cx, double cy, double r) FitCircleFrom3Points(PointF p1, PointF p2, PointF p3)
|
||||
{
|
||||
double ax = p1.X, ay = p1.Y;
|
||||
double bx = p2.X, by = p2.Y;
|
||||
double cx = p3.X, cy = p3.Y;
|
||||
|
||||
double d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||
if (Math.Abs(d) < 1e-10)
|
||||
return (0, 0, -1);
|
||||
|
||||
double ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
|
||||
double uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
|
||||
double r = Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
|
||||
|
||||
return (ux, uy, r);
|
||||
}
|
||||
|
||||
private static double Distance(PointF p, double cx, double cy)
|
||||
{
|
||||
double dx = p.X - cx, dy = p.Y - cy;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static double ComputeCircleFitError(PointF[] points, double cx, double cy, double r)
|
||||
{
|
||||
if (points.Length == 0) return 0;
|
||||
double total = 0;
|
||||
foreach (var p in points)
|
||||
total += Math.Abs(Distance(p, cx, cy) - r);
|
||||
return total / points.Length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
// ============================================================================
|
||||
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
|
||||
// 文件名: EdgeLineFitProcessor.cs
|
||||
// 描述: 边缘查找拟合直线算子
|
||||
// 功能:
|
||||
// - 沿用户定义的搜索线等间距放置多个卡尺(Caliper)
|
||||
// - 在每个卡尺内沿垂直方向提取灰度投影并求导,定位边缘点
|
||||
// - 支持亚像素精度(抛物线插值)
|
||||
// - 支持边缘极性选择(亮到暗/暗到亮/双向)
|
||||
// - 使用最小二乘或RANSAC算法拟合直线
|
||||
// - 输出拟合直线参数、边缘点、内点/外点、拟合误差
|
||||
// 算法: 卡尺边缘检测 + 最小二乘/RANSAC直线拟合
|
||||
// 作者: 李伟 wei.lw.li@hexagon.com
|
||||
// ============================================================================
|
||||
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.CvEnum;
|
||||
using Emgu.CV.Structure;
|
||||
using Emgu.CV.Util;
|
||||
using XP.ImageProcessing.Core;
|
||||
using Serilog;
|
||||
using System.Drawing;
|
||||
|
||||
namespace XP.ImageProcessing.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// 边缘点信息
|
||||
/// </summary>
|
||||
public class EdgePointInfo
|
||||
{
|
||||
/// <summary>边缘点坐标(亚像素)</summary>
|
||||
public PointF Position { get; set; }
|
||||
|
||||
/// <summary>边缘强度(梯度绝对值)</summary>
|
||||
public double Strength { get; set; }
|
||||
|
||||
/// <summary>卡尺索引</summary>
|
||||
public int CaliperIndex { get; set; }
|
||||
|
||||
/// <summary>是否为拟合内点</summary>
|
||||
public bool IsInlier { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直线拟合结果
|
||||
/// </summary>
|
||||
public class LineFitResult
|
||||
{
|
||||
/// <summary>拟合是否成功</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>直线方向向量 (vx, vy)</summary>
|
||||
public PointF Direction { get; set; }
|
||||
|
||||
/// <summary>直线上一点 (x0, y0)</summary>
|
||||
public PointF PointOnLine { get; set; }
|
||||
|
||||
/// <summary>直线角度(度,相对于X轴)</summary>
|
||||
public double AngleDegrees { get; set; }
|
||||
|
||||
/// <summary>直线端点1(用于绘制)</summary>
|
||||
public PointF Endpoint1 { get; set; }
|
||||
|
||||
/// <summary>直线端点2(用于绘制)</summary>
|
||||
public PointF Endpoint2 { get; set; }
|
||||
|
||||
/// <summary>所有检测到的边缘点</summary>
|
||||
public List<EdgePointInfo> EdgePoints { get; set; } = new();
|
||||
|
||||
/// <summary>内点列表</summary>
|
||||
public List<PointF> Inliers { get; set; } = new();
|
||||
|
||||
/// <summary>外点列表</summary>
|
||||
public List<PointF> Outliers { get; set; } = new();
|
||||
|
||||
/// <summary>平均拟合误差(像素)</summary>
|
||||
public double FitError { get; set; }
|
||||
|
||||
/// <summary>有效边缘点数</summary>
|
||||
public int EdgePointCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 边缘查找拟合直线算子 - 使用卡尺法检测边缘点并拟合直线
|
||||
/// </summary>
|
||||
public class EdgeLineFitProcessor : ImageProcessorBase
|
||||
{
|
||||
private static readonly ILogger _logger = Log.ForContext<EdgeLineFitProcessor>();
|
||||
private static readonly Random _random = new();
|
||||
|
||||
public EdgeLineFitProcessor()
|
||||
{
|
||||
Name = LocalizationHelper.GetString("EdgeLineFitProcessor_Name");
|
||||
Description = LocalizationHelper.GetString("EdgeLineFitProcessor_Description");
|
||||
}
|
||||
|
||||
protected override void InitializeParameters()
|
||||
{
|
||||
// ── 搜索线起止点(由UI交互控件注入,不可见) ──
|
||||
Parameters.Add("StartX", new ProcessorParameter(
|
||||
"StartX", "StartX", typeof(int), 100, null, null, "") { IsVisible = false });
|
||||
Parameters.Add("StartY", new ProcessorParameter(
|
||||
"StartY", "StartY", typeof(int), 200, null, null, "") { IsVisible = false });
|
||||
Parameters.Add("EndX", new ProcessorParameter(
|
||||
"EndX", "EndX", typeof(int), 400, null, null, "") { IsVisible = false });
|
||||
Parameters.Add("EndY", new ProcessorParameter(
|
||||
"EndY", "EndY", typeof(int), 200, null, null, "") { IsVisible = false });
|
||||
|
||||
// ── 卡尺参数 ──
|
||||
Parameters.Add("CaliperCount", new ProcessorParameter(
|
||||
"CaliperCount",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperCount"),
|
||||
typeof(int), 20, 3, 200,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperCount_Desc")));
|
||||
|
||||
Parameters.Add("CaliperWidth", new ProcessorParameter(
|
||||
"CaliperWidth",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperWidth"),
|
||||
typeof(int), 40, 5, 500,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperWidth_Desc")));
|
||||
|
||||
// ── 边缘检测参数 ──
|
||||
Parameters.Add("EdgePolarity", new ProcessorParameter(
|
||||
"EdgePolarity",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_EdgePolarity"),
|
||||
typeof(string), "Both", null, null,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_EdgePolarity_Desc"),
|
||||
new string[] { "BrightToDark", "DarkToBright", "Both" }));
|
||||
|
||||
Parameters.Add("EdgeThreshold", new ProcessorParameter(
|
||||
"EdgeThreshold",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_EdgeThreshold"),
|
||||
typeof(int), 30, 1, 255,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_EdgeThreshold_Desc")));
|
||||
|
||||
Parameters.Add("Sigma", new ProcessorParameter(
|
||||
"Sigma",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_Sigma"),
|
||||
typeof(double), 1.0, 0.1, 10.0,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_Sigma_Desc")));
|
||||
|
||||
// ── 拟合参数 ──
|
||||
Parameters.Add("FitMethod", new ProcessorParameter(
|
||||
"FitMethod",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_FitMethod"),
|
||||
typeof(string), "RANSAC", null, null,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_FitMethod_Desc"),
|
||||
new string[] { "LeastSquares", "RANSAC" }));
|
||||
|
||||
Parameters.Add("RansacThreshold", new ProcessorParameter(
|
||||
"RansacThreshold",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_RansacThreshold"),
|
||||
typeof(double), 2.0, 0.5, 20.0,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_RansacThreshold_Desc")));
|
||||
|
||||
Parameters.Add("Thickness", new ProcessorParameter(
|
||||
"Thickness",
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_Thickness"),
|
||||
typeof(int), 2, 1, 10,
|
||||
LocalizationHelper.GetString("EdgeLineFitProcessor_Thickness_Desc")));
|
||||
}
|
||||
|
||||
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
|
||||
{
|
||||
// 读取参数
|
||||
int startX = GetParameter<int>("StartX");
|
||||
int startY = GetParameter<int>("StartY");
|
||||
int endX = GetParameter<int>("EndX");
|
||||
int endY = GetParameter<int>("EndY");
|
||||
int caliperCount = GetParameter<int>("CaliperCount");
|
||||
int caliperWidth = GetParameter<int>("CaliperWidth");
|
||||
string edgePolarity = GetParameter<string>("EdgePolarity");
|
||||
int edgeThreshold = GetParameter<int>("EdgeThreshold");
|
||||
double sigma = GetParameter<double>("Sigma");
|
||||
string fitMethod = GetParameter<string>("FitMethod");
|
||||
double ransacThreshold = GetParameter<double>("RansacThreshold");
|
||||
int thickness = GetParameter<int>("Thickness");
|
||||
|
||||
OutputData.Clear();
|
||||
|
||||
_logger.Debug(
|
||||
"EdgeLineFit started: Search({StartX},{StartY})->({EndX},{EndY}), Calipers={Count}, Width={Width}, Polarity={Polarity}",
|
||||
startX, startY, endX, endY, caliperCount, caliperWidth, edgePolarity);
|
||||
|
||||
// 计算搜索线方向和垂直方向
|
||||
double searchDx = endX - startX;
|
||||
double searchDy = endY - startY;
|
||||
double searchLen = Math.Sqrt(searchDx * searchDx + searchDy * searchDy);
|
||||
|
||||
if (searchLen < 1.0)
|
||||
{
|
||||
_logger.Warning("Search line too short, cannot perform edge detection");
|
||||
OutputData["LineFitResult"] = new LineFitResult { Success = false };
|
||||
return inputImage.Clone();
|
||||
}
|
||||
|
||||
// 搜索线单位方向
|
||||
double ux = searchDx / searchLen;
|
||||
double uy = searchDy / searchLen;
|
||||
|
||||
// 垂直于搜索线的方向(卡尺搜索方向)
|
||||
double perpX = -uy;
|
||||
double perpY = ux;
|
||||
|
||||
// 沿搜索线等间距放置卡尺
|
||||
var edgePoints = new List<EdgePointInfo>();
|
||||
double step = searchLen / (caliperCount + 1);
|
||||
|
||||
for (int i = 0; i < caliperCount; i++)
|
||||
{
|
||||
// 卡尺中心点
|
||||
double cx = startX + ux * step * (i + 1);
|
||||
double cy = startY + uy * step * (i + 1);
|
||||
|
||||
// 在卡尺内沿垂直方向提取灰度剖面
|
||||
var edgePoint = FindEdgeInCaliper(
|
||||
inputImage, cx, cy, perpX, perpY,
|
||||
caliperWidth, edgePolarity, edgeThreshold, sigma, i);
|
||||
|
||||
if (edgePoint != null)
|
||||
{
|
||||
edgePoints.Add(edgePoint);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug("Found {Count} edge points from {Total} calipers", edgePoints.Count, caliperCount);
|
||||
|
||||
// 拟合直线
|
||||
var result = FitLine(edgePoints, fitMethod, ransacThreshold, inputImage.Size);
|
||||
|
||||
// 存储输出数据
|
||||
OutputData["LineFitResult"] = result;
|
||||
OutputData["EdgePoints"] = edgePoints.Select(p => p.Position).ToArray();
|
||||
OutputData["EdgePointCount"] = edgePoints.Count;
|
||||
OutputData["Thickness"] = thickness;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
OutputData["FittedLineDirection"] = result.Direction;
|
||||
OutputData["FittedLinePoint"] = result.PointOnLine;
|
||||
OutputData["LineAngle"] = result.AngleDegrees;
|
||||
OutputData["LineEndpoint1"] = result.Endpoint1;
|
||||
OutputData["LineEndpoint2"] = result.Endpoint2;
|
||||
OutputData["InlierPoints"] = result.Inliers.ToArray();
|
||||
OutputData["OutlierPoints"] = result.Outliers.ToArray();
|
||||
OutputData["FitError"] = result.FitError;
|
||||
|
||||
_logger.Information(
|
||||
"EdgeLineFit completed: Angle={Angle:F2}°, Inliers={Inliers}/{Total}, Error={Error:F3}px",
|
||||
result.AngleDegrees, result.Inliers.Count, edgePoints.Count, result.FitError);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("EdgeLineFit failed: insufficient edge points for line fitting");
|
||||
}
|
||||
|
||||
// 搜索区域信息(供UI绘制)
|
||||
OutputData["SearchStart"] = new PointF(startX, startY);
|
||||
OutputData["SearchEnd"] = new PointF(endX, endY);
|
||||
OutputData["CaliperWidth"] = caliperWidth;
|
||||
OutputData["CaliperCount"] = caliperCount;
|
||||
OutputData["PerpDirection"] = new PointF((float)perpX, (float)perpY);
|
||||
|
||||
return inputImage.Clone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在单个卡尺内查找边缘点
|
||||
/// </summary>
|
||||
private EdgePointInfo? FindEdgeInCaliper(
|
||||
Image<Gray, byte> image,
|
||||
double centerX, double centerY,
|
||||
double perpX, double perpY,
|
||||
int caliperWidth, string polarity,
|
||||
int threshold, double sigma, int caliperIndex)
|
||||
{
|
||||
int halfWidth = caliperWidth / 2;
|
||||
int profileLength = caliperWidth;
|
||||
|
||||
// 提取灰度剖面
|
||||
var profile = new double[profileLength];
|
||||
int validCount = 0;
|
||||
|
||||
for (int i = 0; i < profileLength; i++)
|
||||
{
|
||||
double offset = i - halfWidth;
|
||||
double px = centerX + perpX * offset;
|
||||
double py = centerY + perpY * offset;
|
||||
|
||||
int ix = (int)Math.Round(px);
|
||||
int iy = (int)Math.Round(py);
|
||||
|
||||
if (ix >= 0 && ix < image.Width && iy >= 0 && iy < image.Height)
|
||||
{
|
||||
profile[i] = image.Data[iy, ix, 0];
|
||||
validCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
profile[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (validCount < profileLength * 0.5)
|
||||
return null;
|
||||
|
||||
// 高斯平滑
|
||||
if (sigma > 0.1)
|
||||
{
|
||||
profile = GaussianSmooth1D(profile, sigma);
|
||||
}
|
||||
|
||||
// 求一阶导数
|
||||
var derivative = new double[profileLength];
|
||||
for (int i = 1; i < profileLength - 1; i++)
|
||||
{
|
||||
derivative[i] = (profile[i + 1] - profile[i - 1]) / 2.0;
|
||||
}
|
||||
|
||||
// 根据极性查找最强边缘
|
||||
int bestIdx = -1;
|
||||
double bestStrength = 0;
|
||||
|
||||
for (int i = 2; i < profileLength - 2; i++)
|
||||
{
|
||||
double strength = derivative[i];
|
||||
bool validPolarity = polarity switch
|
||||
{
|
||||
"BrightToDark" => strength < 0, // 亮到暗:导数为负
|
||||
"DarkToBright" => strength > 0, // 暗到亮:导数为正
|
||||
_ => true // Both:任意方向
|
||||
};
|
||||
|
||||
if (!validPolarity) continue;
|
||||
|
||||
double absStrength = Math.Abs(strength);
|
||||
if (absStrength >= threshold && absStrength > bestStrength)
|
||||
{
|
||||
bestStrength = absStrength;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0)
|
||||
return null;
|
||||
|
||||
// 亚像素精度:抛物线插值
|
||||
double subPixelOffset = 0;
|
||||
if (bestIdx > 0 && bestIdx < profileLength - 1)
|
||||
{
|
||||
double left = Math.Abs(derivative[bestIdx - 1]);
|
||||
double center = Math.Abs(derivative[bestIdx]);
|
||||
double right = Math.Abs(derivative[bestIdx + 1]);
|
||||
double denom = 2.0 * (2.0 * center - left - right);
|
||||
if (Math.Abs(denom) > 1e-6)
|
||||
{
|
||||
subPixelOffset = (left - right) / denom;
|
||||
subPixelOffset = Math.Clamp(subPixelOffset, -0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
double edgeOffset = (bestIdx + subPixelOffset) - halfWidth;
|
||||
float edgeX = (float)(centerX + perpX * edgeOffset);
|
||||
float edgeY = (float)(centerY + perpY * edgeOffset);
|
||||
|
||||
return new EdgePointInfo
|
||||
{
|
||||
Position = new PointF(edgeX, edgeY),
|
||||
Strength = bestStrength,
|
||||
CaliperIndex = caliperIndex,
|
||||
IsInlier = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一维高斯平滑
|
||||
/// </summary>
|
||||
private static double[] GaussianSmooth1D(double[] data, double sigma)
|
||||
{
|
||||
int kernelRadius = (int)Math.Ceiling(sigma * 3);
|
||||
int kernelSize = kernelRadius * 2 + 1;
|
||||
var kernel = new double[kernelSize];
|
||||
double sum = 0;
|
||||
|
||||
for (int i = 0; i < kernelSize; i++)
|
||||
{
|
||||
double x = i - kernelRadius;
|
||||
kernel[i] = Math.Exp(-x * x / (2.0 * sigma * sigma));
|
||||
sum += kernel[i];
|
||||
}
|
||||
for (int i = 0; i < kernelSize; i++)
|
||||
kernel[i] /= sum;
|
||||
|
||||
var result = new double[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
double val = 0;
|
||||
double wSum = 0;
|
||||
for (int k = 0; k < kernelSize; k++)
|
||||
{
|
||||
int idx = i + k - kernelRadius;
|
||||
if (idx >= 0 && idx < data.Length)
|
||||
{
|
||||
val += data[idx] * kernel[k];
|
||||
wSum += kernel[k];
|
||||
}
|
||||
}
|
||||
result[i] = wSum > 0 ? val / wSum : data[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拟合直线
|
||||
/// </summary>
|
||||
private LineFitResult FitLine(List<EdgePointInfo> edgePoints, string method,
|
||||
double ransacThreshold, Size imageSize)
|
||||
{
|
||||
var result = new LineFitResult();
|
||||
|
||||
if (edgePoints.Count < 2)
|
||||
{
|
||||
result.Success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (method == "RANSAC" && edgePoints.Count >= 3)
|
||||
{
|
||||
return FitLineRANSAC(edgePoints, ransacThreshold, imageSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
return FitLineLeastSquares(edgePoints, imageSize);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最小二乘直线拟合(使用OpenCV FitLine)
|
||||
/// </summary>
|
||||
private LineFitResult FitLineLeastSquares(List<EdgePointInfo> edgePoints, Size imageSize)
|
||||
{
|
||||
var result = new LineFitResult();
|
||||
var points = edgePoints.Select(p => p.Position).ToArray();
|
||||
|
||||
using var pointVector = new VectorOfPointF(points);
|
||||
using var lineMat = new Mat();
|
||||
CvInvoke.FitLine(pointVector, lineMat, DistType.L2, 0, 0.01, 0.01);
|
||||
var lineParams = new float[4];
|
||||
System.Runtime.InteropServices.Marshal.Copy(lineMat.DataPointer, lineParams, 0, 4);
|
||||
|
||||
float vx = lineParams[0], vy = lineParams[1];
|
||||
float x0 = lineParams[2], y0 = lineParams[3];
|
||||
|
||||
result.Success = true;
|
||||
result.Direction = new PointF(vx, vy);
|
||||
result.PointOnLine = new PointF(x0, y0);
|
||||
result.AngleDegrees = Math.Atan2(vy, vx) * 180.0 / Math.PI;
|
||||
|
||||
// 计算端点(延伸到图像边界或搜索范围)
|
||||
ComputeLineEndpoints(result, points, imageSize);
|
||||
|
||||
// 所有点都是内点
|
||||
result.Inliers = points.ToList();
|
||||
result.Outliers = new List<PointF>();
|
||||
foreach (var ep in edgePoints)
|
||||
ep.IsInlier = true;
|
||||
|
||||
// 计算拟合误差
|
||||
result.FitError = ComputeFitError(points, vx, vy, x0, y0);
|
||||
result.EdgePointCount = edgePoints.Count;
|
||||
result.EdgePoints = edgePoints;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RANSAC直线拟合
|
||||
/// </summary>
|
||||
private LineFitResult FitLineRANSAC(List<EdgePointInfo> edgePoints, double threshold, Size imageSize)
|
||||
{
|
||||
var result = new LineFitResult();
|
||||
var points = edgePoints.Select(p => p.Position).ToArray();
|
||||
int n = points.Length;
|
||||
|
||||
// RANSAC参数
|
||||
int maxIterations = Math.Min(1000, n * (n - 1) / 2);
|
||||
int bestInlierCount = 0;
|
||||
float bestVx = 0, bestVy = 0, bestX0 = 0, bestY0 = 0;
|
||||
List<int> bestInlierIndices = new();
|
||||
|
||||
for (int iter = 0; iter < maxIterations; iter++)
|
||||
{
|
||||
// 随机选择2个点
|
||||
int idx1 = _random.Next(n);
|
||||
int idx2 = _random.Next(n);
|
||||
if (idx1 == idx2) continue;
|
||||
|
||||
PointF p1 = points[idx1], p2 = points[idx2];
|
||||
float dx = p2.X - p1.X, dy = p2.Y - p1.Y;
|
||||
float len = (float)Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < 1e-6f) continue;
|
||||
|
||||
float vx = dx / len, vy = dy / len;
|
||||
|
||||
// 统计内点
|
||||
var inlierIndices = new List<int>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dist = PointToLineDistance(points[i], p1, vx, vy);
|
||||
if (dist <= threshold)
|
||||
{
|
||||
inlierIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (inlierIndices.Count > bestInlierCount)
|
||||
{
|
||||
bestInlierCount = inlierIndices.Count;
|
||||
bestInlierIndices = inlierIndices;
|
||||
|
||||
// 用所有内点重新拟合
|
||||
var inlierPoints = inlierIndices.Select(i => points[i]).ToArray();
|
||||
using var pv = new VectorOfPointF(inlierPoints);
|
||||
using var lpMat = new Mat();
|
||||
CvInvoke.FitLine(pv, lpMat, DistType.L2, 0, 0.01, 0.01);
|
||||
var lp = new float[4];
|
||||
System.Runtime.InteropServices.Marshal.Copy(lpMat.DataPointer, lp, 0, 4);
|
||||
bestVx = lp[0]; bestVy = lp[1]; bestX0 = lp[2]; bestY0 = lp[3];
|
||||
}
|
||||
|
||||
// 如果内点比例已经很高,提前退出
|
||||
if (bestInlierCount > n * 0.95)
|
||||
break;
|
||||
}
|
||||
|
||||
if (bestInlierCount < 2)
|
||||
{
|
||||
result.Success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
result.Direction = new PointF(bestVx, bestVy);
|
||||
result.PointOnLine = new PointF(bestX0, bestY0);
|
||||
result.AngleDegrees = Math.Atan2(bestVy, bestVx) * 180.0 / Math.PI;
|
||||
|
||||
// 分类内点/外点
|
||||
var inliers = new List<PointF>();
|
||||
var outliers = new List<PointF>();
|
||||
var inlierSet = new HashSet<int>(bestInlierIndices);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (inlierSet.Contains(i))
|
||||
{
|
||||
inliers.Add(points[i]);
|
||||
edgePoints[i].IsInlier = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
outliers.Add(points[i]);
|
||||
edgePoints[i].IsInlier = false;
|
||||
}
|
||||
}
|
||||
|
||||
result.Inliers = inliers;
|
||||
result.Outliers = outliers;
|
||||
|
||||
// 计算端点
|
||||
ComputeLineEndpoints(result, inliers.ToArray(), imageSize);
|
||||
|
||||
// 计算拟合误差(仅内点)
|
||||
result.FitError = ComputeFitError(inliers.ToArray(), bestVx, bestVy, bestX0, bestY0);
|
||||
result.EdgePointCount = edgePoints.Count;
|
||||
result.EdgePoints = edgePoints;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算点到直线的距离
|
||||
/// </summary>
|
||||
private static double PointToLineDistance(PointF point, PointF linePoint, float vx, float vy)
|
||||
{
|
||||
// 直线法向量 (-vy, vx)
|
||||
double dx = point.X - linePoint.X;
|
||||
double dy = point.Y - linePoint.Y;
|
||||
return Math.Abs(-vy * dx + vx * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算直线端点(基于边缘点的投影范围)
|
||||
/// </summary>
|
||||
private static void ComputeLineEndpoints(LineFitResult result, PointF[] points, Size imageSize)
|
||||
{
|
||||
float vx = result.Direction.X, vy = result.Direction.Y;
|
||||
float x0 = result.PointOnLine.X, y0 = result.PointOnLine.Y;
|
||||
|
||||
// 将所有点投影到直线方向上,找最小和最大投影值
|
||||
double minT = double.MaxValue, maxT = double.MinValue;
|
||||
foreach (var p in points)
|
||||
{
|
||||
double t = (p.X - x0) * vx + (p.Y - y0) * vy;
|
||||
if (t < minT) minT = t;
|
||||
if (t > maxT) maxT = t;
|
||||
}
|
||||
|
||||
// 稍微延伸一点
|
||||
double extend = (maxT - minT) * 0.05;
|
||||
minT -= extend;
|
||||
maxT += extend;
|
||||
|
||||
result.Endpoint1 = new PointF(
|
||||
(float)(x0 + vx * minT),
|
||||
(float)(y0 + vy * minT));
|
||||
result.Endpoint2 = new PointF(
|
||||
(float)(x0 + vx * maxT),
|
||||
(float)(y0 + vy * maxT));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算平均拟合误差
|
||||
/// </summary>
|
||||
private static double ComputeFitError(PointF[] points, float vx, float vy, float x0, float y0)
|
||||
{
|
||||
if (points.Length == 0) return 0;
|
||||
|
||||
double totalError = 0;
|
||||
foreach (var p in points)
|
||||
{
|
||||
double dx = p.X - x0;
|
||||
double dy = p.Y - y0;
|
||||
double dist = Math.Abs(-vy * dx + vx * dy);
|
||||
totalError += dist;
|
||||
}
|
||||
return totalError / points.Length;
|
||||
}
|
||||
}
|
||||
@@ -504,6 +504,12 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
||||
private Point? _bgaPendingCenter; // 等待第二次点击定半径
|
||||
private Ellipse _bgaPendingDot;
|
||||
|
||||
// 边缘查找拟合直线临时状态
|
||||
private int _elfClickCount;
|
||||
private Ellipse _elfTempDot1;
|
||||
private Line _elfTempLine;
|
||||
private Point? _elfTempStart;
|
||||
|
||||
// 气泡测量状态
|
||||
public enum BubbleSubTool { Roi, RoiCircle, RoiPolygon, Wand, Brush, Eraser }
|
||||
private BubbleSubTool _bubbleTool = BubbleSubTool.Roi;
|
||||
@@ -690,6 +696,8 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
||||
HandleFillRateClick(pos);
|
||||
else if (CurrentMeasureMode == Models.MeasureMode.BgaVoid)
|
||||
HandleBgaVoidClick(pos);
|
||||
else if (CurrentMeasureMode == Models.MeasureMode.EdgeLineFit)
|
||||
HandleEdgeLineFitClick(pos);
|
||||
// BubbleMeasure 的点击在 MouseDown/Move/Up 中处理(拖拽画 ROI 和画笔)
|
||||
}
|
||||
|
||||
@@ -870,6 +878,49 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
||||
return g;
|
||||
}
|
||||
|
||||
// ── 边缘查找拟合直线 ──
|
||||
|
||||
private void HandleEdgeLineFitClick(Point pos)
|
||||
{
|
||||
_elfClickCount++;
|
||||
|
||||
if (_elfClickCount == 1)
|
||||
{
|
||||
_elfTempStart = pos;
|
||||
_elfTempDot1 = CreateMDot(Brushes.Cyan);
|
||||
_measureOverlay.Children.Add(_elfTempDot1);
|
||||
SetDotPos(_elfTempDot1, pos);
|
||||
RaiseMeasureStatusChanged($"直线拟合 - 搜索线起点: ({pos.X:F0}, {pos.Y:F0}),请点击搜索线终点");
|
||||
}
|
||||
else if (_elfClickCount == 2)
|
||||
{
|
||||
// 绘制搜索线
|
||||
_elfTempLine = new Line
|
||||
{
|
||||
Stroke = Brushes.Cyan,
|
||||
StrokeThickness = 1,
|
||||
StrokeDashArray = new DoubleCollection { 4, 2 },
|
||||
IsHitTestVisible = false,
|
||||
X1 = _elfTempStart.Value.X,
|
||||
Y1 = _elfTempStart.Value.Y,
|
||||
X2 = pos.X,
|
||||
Y2 = pos.Y
|
||||
};
|
||||
_measureOverlay.Children.Add(_elfTempLine);
|
||||
|
||||
// 触发完成事件,传递搜索线起止点
|
||||
RaiseMeasureCompleted(_elfTempStart.Value, pos, 0, MeasureCount, "EdgeLineFit");
|
||||
RaiseMeasureStatusChanged($"直线拟合 - 搜索线已定义: ({_elfTempStart.Value.X:F0},{_elfTempStart.Value.Y:F0}) → ({pos.X:F0},{pos.Y:F0})");
|
||||
|
||||
// 清理临时状态
|
||||
if (_elfTempDot1 != null) _measureOverlay.Children.Remove(_elfTempDot1);
|
||||
_elfTempDot1 = null;
|
||||
_elfTempStart = null;
|
||||
_elfClickCount = 0;
|
||||
CurrentMeasureMode = Models.MeasureMode.None;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 角度测量 ──
|
||||
|
||||
private void HandleAngleClick(Point pos)
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace XP.ImageProcessing.RoiControl.Models
|
||||
Angle,
|
||||
FillRate,
|
||||
BgaVoid,
|
||||
BubbleMeasure
|
||||
BubbleMeasure,
|
||||
EdgeLineFit
|
||||
}
|
||||
}
|
||||
|
||||
+47
-19
@@ -55,6 +55,7 @@ using XplorePlane.Services.Storage;
|
||||
using XplorePlane.ViewModels;
|
||||
using XplorePlane.ViewModels.Cnc;
|
||||
using XplorePlane.ViewModels.Debug;
|
||||
using XplorePlane.ViewModels.ImageProcessing;
|
||||
using XplorePlane.Views;
|
||||
using XplorePlane.Views.Cnc;
|
||||
using XplorePlane.Views.Debug;
|
||||
@@ -174,12 +175,12 @@ namespace XplorePlane
|
||||
{
|
||||
var cameraVm = bootstrapper.Container.Resolve<NavigationPropertyPanelViewModel>();
|
||||
cameraVm?.Dispose();
|
||||
Log.Information("导航相机 ViewModel 已释放");
|
||||
Log.Information("Navigation camera ViewModel has been released");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "导航相机 ViewModel 释放失败");
|
||||
Log.Error(ex, "Navigation camera ViewModel release failed");
|
||||
}
|
||||
|
||||
// 释放导航相机服务资源
|
||||
@@ -190,12 +191,12 @@ namespace XplorePlane
|
||||
{
|
||||
var cameraService = bootstrapper.Container.Resolve<ICameraService>();
|
||||
cameraService?.Dispose();
|
||||
Log.Information("导航相机服务资源已释放");
|
||||
Log.Information("Navigation camera service resources have been released");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "导航相机服务资源释放失败");
|
||||
Log.Error(ex, "Navigation camera service resource release failed");
|
||||
}
|
||||
|
||||
// 释放主界面探测器帧流水线资源
|
||||
@@ -350,9 +351,6 @@ namespace XplorePlane
|
||||
// 主窗体加载完成后再连接相机,确保所有模块和原生 DLL 已完成初始化
|
||||
shell.Loaded += async (s, e) =>
|
||||
{
|
||||
// [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度
|
||||
// TryConnectCamera();
|
||||
|
||||
// 初始化主界面探测器帧流水线,开始接收探测器图像事件
|
||||
try
|
||||
{
|
||||
@@ -376,11 +374,22 @@ namespace XplorePlane
|
||||
// {
|
||||
// Log.Error(ex, "通知相机 ViewModel 失败");
|
||||
// }
|
||||
// [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度
|
||||
//TryConnectCamera();
|
||||
//try
|
||||
//{
|
||||
// var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>();
|
||||
// cameraVm.OnCameraReady();
|
||||
//}
|
||||
//catch (Exception ex)
|
||||
//{
|
||||
// Log.Error(ex, "Failed to notify the camera ViewModel");
|
||||
//}
|
||||
|
||||
// if (_cameraError != null)
|
||||
// {
|
||||
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
// }
|
||||
//if (_cameraError != null)
|
||||
//{
|
||||
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
//}
|
||||
};
|
||||
|
||||
return shell;
|
||||
@@ -531,17 +540,17 @@ namespace XplorePlane
|
||||
try
|
||||
{
|
||||
var info = camera.Open();
|
||||
Log.Information("导航相机已连接: {ModelName} (SN: {SerialNumber})", info.ModelName, info.SerialNumber);
|
||||
Log.Information("Navigation camera connected: {ModelName} (SN: {SerialNumber})", info.ModelName, info.SerialNumber);
|
||||
}
|
||||
catch (DeviceNotFoundException)
|
||||
{
|
||||
Log.Warning("未检测到导航相机");
|
||||
_cameraError = "未检测到导航相机,请检查连接后重启软件。";
|
||||
Log.Warning("Navigation camera not detected");
|
||||
_cameraError = "Navigation camera not detected,Please check the connection and restart the software.。";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "导航相机自动连接失败: {Message}", ex.Message);
|
||||
_cameraError = $"导航相机连接失败: {ex.Message}";
|
||||
Log.Warning(ex, "Automatic connection of navigation camera failed: {Message}", ex.Message);
|
||||
_cameraError = $"Navigation camera connection failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +584,6 @@ namespace XplorePlane
|
||||
containerRegistry.RegisterSingleton<IImageProcessingService, ImageProcessingService>();
|
||||
containerRegistry.Register<ImageProcessingViewModel>();
|
||||
|
||||
|
||||
// 注册流水线服务(单例,共享 IImageProcessingService)
|
||||
containerRegistry.RegisterSingleton<IPipelineExecutionService, PipelineExecutionService>();
|
||||
containerRegistry.RegisterSingleton<IPipelinePersistenceService, PipelinePersistenceService>();
|
||||
@@ -593,6 +601,7 @@ namespace XplorePlane
|
||||
// 注册流水线 ViewModel(每次解析创建新实例)
|
||||
containerRegistry.Register<PipelineEditorViewModel>();
|
||||
containerRegistry.Register<OperatorToolboxViewModel>();
|
||||
containerRegistry.Register<TemplateMatchAssistantViewModel>();
|
||||
|
||||
// 注册硬件库的 ViewModel(供 ViewModelLocator 自动装配)
|
||||
containerRegistry.Register<XP.Hardware.RaySource.ViewModels.RaySourceOperateViewModel>();
|
||||
@@ -634,7 +643,26 @@ namespace XplorePlane
|
||||
// ── 导航相机服务(单例)──
|
||||
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
|
||||
containerRegistry.RegisterSingleton<ICameraController>(() =>
|
||||
new CameraFactory().CreateController("Basler"));
|
||||
{
|
||||
string cameraType = "Hikvision"; // 默认值
|
||||
try
|
||||
{
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "config.json");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var match = System.Text.RegularExpressions.Regex.Match(json, "\"CameraType\"\\s*:\\s*\"([^\"]+)\"");
|
||||
if (match.Success)
|
||||
cameraType = match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to read CameraType configuration, using default value Hikvision");
|
||||
}
|
||||
Log.Information("Camera Type: {CameraType}", cameraType);
|
||||
return new CameraFactory().CreateController(cameraType);
|
||||
});
|
||||
containerRegistry.RegisterSingleton<ICameraService, CameraService>();
|
||||
|
||||
// ── 录制服务(单例)──
|
||||
@@ -662,4 +690,4 @@ namespace XplorePlane
|
||||
base.ConfigureModuleCatalog(moduleCatalog);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 778 B |
Binary file not shown.
|
After Width: | Height: | Size: 583 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using Prism.Events;
|
||||
|
||||
namespace XplorePlane.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 白底/黑底检测单条结果:全局图像坐标下的轮廓与最远弦(微米与既有展示规则一致)。
|
||||
/// </summary>
|
||||
public class BackgroundDefectDetectionItem
|
||||
{
|
||||
public List<Point> Contour { get; set; } = new();
|
||||
/// <summary>轮廓顶点间最远距离(微米)。</summary>
|
||||
public double SizeMicrometers { get; set; }
|
||||
public Point ChordP1 { get; set; }
|
||||
public Point ChordP2 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测量工具模式
|
||||
/// </summary>
|
||||
@@ -13,7 +27,8 @@ namespace XplorePlane.Events
|
||||
Angle,
|
||||
ThroughHoleFillRate,
|
||||
BgaVoid,
|
||||
BubbleMeasure
|
||||
BubbleMeasure,
|
||||
EdgeLineFit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -25,4 +40,51 @@ namespace XplorePlane.Events
|
||||
/// 十字辅助线切换事件
|
||||
/// </summary>
|
||||
public class ToggleCrosshairEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 行灰度分布切换事件
|
||||
/// </summary>
|
||||
public class ToggleLineProfileEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 白底检测事件(进入ROI绘制模式)
|
||||
/// </summary>
|
||||
public class WhiteBackgroundDetectionEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 白底检测ROI绘制完成事件
|
||||
/// </summary>
|
||||
public class WhiteBackgroundRoiDrawnEvent : PubSubEvent<System.Windows.Int32Rect> { }
|
||||
|
||||
/// <summary>
|
||||
/// 白底检测结果事件
|
||||
/// </summary>
|
||||
public class WhiteBackgroundResultEvent : PubSubEvent<WhiteBackgroundResultPayload> { }
|
||||
|
||||
public class WhiteBackgroundResultPayload
|
||||
{
|
||||
public System.Drawing.Rectangle RoiRect { get; set; }
|
||||
public List<BackgroundDefectDetectionItem> Detections { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 黑底检测事件(进入ROI绘制模式)
|
||||
/// </summary>
|
||||
public class BlackBackgroundDetectionEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 黑底检测ROI绘制完成事件
|
||||
/// </summary>
|
||||
public class BlackBackgroundRoiDrawnEvent : PubSubEvent<System.Windows.Int32Rect> { }
|
||||
|
||||
/// <summary>
|
||||
/// 黑底检测结果事件
|
||||
/// </summary>
|
||||
public class BlackBackgroundResultEvent : PubSubEvent<BlackBackgroundResultPayload> { }
|
||||
|
||||
public class BlackBackgroundResultPayload
|
||||
{
|
||||
public System.Drawing.Rectangle RoiRect { get; set; }
|
||||
public List<BackgroundDefectDetectionItem> Detections { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using Prism.Events;
|
||||
|
||||
namespace XplorePlane.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 进入「在视口上框选模板 ROI」模式(与主画布 Preview 鼠标逻辑配合)。
|
||||
/// </summary>
|
||||
public class TemplateMatchEnterRoiModeEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ROI 框选完成(图像/画布像素坐标,与白底检测 ROI 约定一致)。仅表示区域已确定,不表示已训练。
|
||||
/// </summary>
|
||||
public class TemplateMatchRoiDrawnEvent : PubSubEvent<Int32Rect> { }
|
||||
|
||||
/// <summary>
|
||||
/// 清除视口上的模板助手持久 ROI 框(例如加载模型后或重置时)。
|
||||
/// </summary>
|
||||
public class TemplateMatchClearRoiOverlayEvent : PubSubEvent { }
|
||||
|
||||
/// <summary>
|
||||
/// 单次模板匹配试跑结果,供主视图叠加层绘制。
|
||||
/// </summary>
|
||||
public class TemplateMatchHitDto
|
||||
{
|
||||
public double CenterX { get; set; }
|
||||
public double CenterY { get; set; }
|
||||
public double Angle { get; set; }
|
||||
public double Score { get; set; }
|
||||
public double LtX { get; set; }
|
||||
public double LtY { get; set; }
|
||||
public double RtX { get; set; }
|
||||
public double RtY { get; set; }
|
||||
public double RbX { get; set; }
|
||||
public double RbY { get; set; }
|
||||
public double LbX { get; set; }
|
||||
public double LbY { get; set; }
|
||||
}
|
||||
|
||||
public class TemplateMatchPreviewPayload
|
||||
{
|
||||
public List<TemplateMatchHitDto> Hits { get; set; } = new();
|
||||
public double MatchTimeMs { get; set; }
|
||||
}
|
||||
|
||||
public class TemplateMatchPreviewResultEvent : PubSubEvent<TemplateMatchPreviewPayload> { }
|
||||
@@ -0,0 +1,497 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.Structure;
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using XP.ImageProcessing.Processors;
|
||||
using XP.ImageProcessing.RoiControl.Controls;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using Brushes = System.Windows.Media.Brushes;
|
||||
using Ellipse = System.Windows.Shapes.Ellipse;
|
||||
using Point = System.Windows.Point;
|
||||
|
||||
namespace XplorePlane.ViewModels.ImageProcessing
|
||||
{
|
||||
/// <summary>
|
||||
/// 边缘查找拟合圆 ViewModel
|
||||
/// 交互:3点定义预估圆,手柄可调整圆心和半径,点击拟合执行
|
||||
/// </summary>
|
||||
public class EdgeCircleFitViewModel : BindableBase
|
||||
{
|
||||
private readonly IMainViewportService _viewportService;
|
||||
private PolygonRoiCanvas _canvas;
|
||||
private Canvas _mainCanvas;
|
||||
|
||||
// 预估圆
|
||||
private Point _center;
|
||||
private double _radius;
|
||||
private bool _circleDefined;
|
||||
|
||||
// 可视化
|
||||
private readonly List<UIElement> _tempOverlays = new();
|
||||
private readonly List<UIElement> _committedOverlays = new();
|
||||
|
||||
// 手柄位置
|
||||
private Point _handleCenterPos;
|
||||
private Point _handleRadiusPos; // 圆周上0°位置
|
||||
|
||||
// 交互
|
||||
private enum DragTarget { None, Center, Radius }
|
||||
private DragTarget _dragging = DragTarget.None;
|
||||
private bool _isDrawing;
|
||||
private int _fitCount;
|
||||
|
||||
private const double HandleSize = 12;
|
||||
private const double HitRadius = 10;
|
||||
private static readonly SolidColorBrush CaliperStroke;
|
||||
private static readonly SolidColorBrush CaliperFill;
|
||||
private static readonly SolidColorBrush FitCircleBrush;
|
||||
private static readonly SolidColorBrush HandleFill;
|
||||
|
||||
static EdgeCircleFitViewModel()
|
||||
{
|
||||
CaliperStroke = new SolidColorBrush(Color.FromRgb(0, 255, 0));
|
||||
CaliperStroke.Freeze();
|
||||
CaliperFill = new SolidColorBrush(Color.FromArgb(15, 0, 255, 0));
|
||||
CaliperFill.Freeze();
|
||||
FitCircleBrush = new SolidColorBrush(Color.FromRgb(30, 144, 255));
|
||||
FitCircleBrush.Freeze();
|
||||
HandleFill = new SolidColorBrush(Color.FromArgb(220, 255, 255, 255));
|
||||
HandleFill.Freeze();
|
||||
}
|
||||
|
||||
public EdgeCircleFitViewModel(IMainViewportService viewportService)
|
||||
{
|
||||
_viewportService = viewportService;
|
||||
FitCommand = new DelegateCommand(ExecuteFit, () => _circleDefined);
|
||||
ClearAllCommand = new DelegateCommand(ExecuteClearAll);
|
||||
DrawCircleCommand = new DelegateCommand(ExecuteDrawCircle);
|
||||
}
|
||||
|
||||
// ── 命令 ──
|
||||
public DelegateCommand FitCommand { get; }
|
||||
public DelegateCommand ClearAllCommand { get; }
|
||||
public DelegateCommand DrawCircleCommand { get; }
|
||||
|
||||
// ── 参数 ──
|
||||
private int _caliperCount = 36;
|
||||
public int CaliperCount { get => _caliperCount; set { if (SetProperty(ref _caliperCount, value)) RedrawTemp(); } }
|
||||
|
||||
private int _caliperWidth = 40;
|
||||
public int CaliperWidth { get => _caliperWidth; set { if (SetProperty(ref _caliperWidth, value)) RedrawTemp(); } }
|
||||
|
||||
private string _edgePolarity = "Both";
|
||||
public string EdgePolarity { get => _edgePolarity; set { if (SetProperty(ref _edgePolarity, value)) RedrawTemp(); } }
|
||||
|
||||
private int _edgeThreshold = 20;
|
||||
public int EdgeThreshold { get => _edgeThreshold; set => SetProperty(ref _edgeThreshold, value); }
|
||||
|
||||
private double _sigma = 1.0;
|
||||
public double Sigma { get => _sigma; set => SetProperty(ref _sigma, value); }
|
||||
|
||||
private string _searchDirection = "Both";
|
||||
public string SearchDirection { get => _searchDirection; set => SetProperty(ref _searchDirection, value); }
|
||||
|
||||
private string _fitMethod = "RANSAC";
|
||||
public string FitMethod { get => _fitMethod; set => SetProperty(ref _fitMethod, value); }
|
||||
|
||||
private double _ransacThreshold = 2.0;
|
||||
public double RansacThreshold { get => _ransacThreshold; set => SetProperty(ref _ransacThreshold, value); }
|
||||
|
||||
private string _resultText = "Ready - click Draw Circle";
|
||||
public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); }
|
||||
|
||||
// ── 初始化 ──
|
||||
public void SetCanvas(PolygonRoiCanvas canvas)
|
||||
{
|
||||
_canvas = canvas;
|
||||
_mainCanvas = FindChild<Canvas>(canvas, "mainCanvas");
|
||||
}
|
||||
|
||||
public void OnPanelClosed()
|
||||
{
|
||||
UnregisterAll();
|
||||
ClearTempOverlays();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 命令
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void ExecuteDrawCircle()
|
||||
{
|
||||
ClearTempOverlays();
|
||||
UnregisterAll();
|
||||
_circleDefined = false;
|
||||
_dragging = DragTarget.None;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
_isDrawing = true;
|
||||
ResultText = "Press and drag to define circle (center → radius)";
|
||||
RegisterInteraction();
|
||||
}
|
||||
|
||||
private void ExecuteFit()
|
||||
{
|
||||
if (!_circleDefined) return;
|
||||
|
||||
// 清除上一次拟合结果
|
||||
ClearCommitted();
|
||||
|
||||
var imageSource = _viewportService?.CurrentDisplayImage as BitmapSource;
|
||||
if (imageSource == null) { ResultText = "Error: no image"; return; }
|
||||
|
||||
try
|
||||
{
|
||||
BitmapSource source = imageSource;
|
||||
if (imageSource.Format != PixelFormats.Gray8)
|
||||
source = new FormatConvertedBitmap(imageSource, PixelFormats.Gray8, null, 0);
|
||||
|
||||
int w = source.PixelWidth, h = source.PixelHeight;
|
||||
int stride = w;
|
||||
byte[] px = new byte[h * stride];
|
||||
source.CopyPixels(px, stride, 0);
|
||||
|
||||
using var img = new Image<Gray, byte>(w, h);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
img.Data[y, x, 0] = px[y * stride + x];
|
||||
|
||||
var proc = new EdgeCircleFitProcessor();
|
||||
proc.SetParameter("CenterX", (int)_center.X);
|
||||
proc.SetParameter("CenterY", (int)_center.Y);
|
||||
proc.SetParameter("Radius", (int)_radius);
|
||||
proc.SetParameter("CaliperCount", CaliperCount);
|
||||
proc.SetParameter("CaliperWidth", CaliperWidth);
|
||||
proc.SetParameter("EdgePolarity", EdgePolarity);
|
||||
proc.SetParameter("EdgeThreshold", EdgeThreshold);
|
||||
proc.SetParameter("Sigma", Sigma);
|
||||
proc.SetParameter("SearchDirection", SearchDirection);
|
||||
proc.SetParameter("FitMethod", FitMethod);
|
||||
proc.SetParameter("RansacThreshold", RansacThreshold);
|
||||
|
||||
var result = proc.Process(img);
|
||||
var od = proc.OutputData;
|
||||
|
||||
if (od.ContainsKey("CircleFitResult"))
|
||||
{
|
||||
var fr = od["CircleFitResult"] as CircleFitResult;
|
||||
if (fr != null && fr.Success)
|
||||
{
|
||||
_fitCount++;
|
||||
DrawFitResult(fr);
|
||||
ResultText = $"[#{_fitCount}] Fit OK\nCenter: ({fr.CenterX:F1}, {fr.CenterY:F1})\n" +
|
||||
$"Radius: {fr.Radius:F2} px\n" +
|
||||
$"Inliers: {fr.Inliers.Count}/{fr.EdgePointCount}\n" +
|
||||
$"Error: {fr.FitError:F3} px\n\nAdjust and fit again, or draw new";
|
||||
}
|
||||
else
|
||||
{
|
||||
int ec = od.ContainsKey("EdgePointCount") ? (int)od["EdgePointCount"] : 0;
|
||||
ResultText = $"Fit failed\nEdge points: {ec}\nAdjust params or circle";
|
||||
}
|
||||
}
|
||||
result.Dispose();
|
||||
}
|
||||
catch (Exception ex) { ResultText = $"Exception: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private void ExecuteClearAll()
|
||||
{
|
||||
ClearTempOverlays();
|
||||
if (_mainCanvas != null)
|
||||
foreach (var el in _committedOverlays) _mainCanvas.Children.Remove(el);
|
||||
_committedOverlays.Clear();
|
||||
_fitCount = 0;
|
||||
UnregisterAll();
|
||||
_circleDefined = false;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
ResultText = "Cleared";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 拟合结果绘制(永久)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void DrawFitResult(CircleFitResult fr)
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
|
||||
// 拟合圆(蓝色)
|
||||
var circle = new Ellipse
|
||||
{
|
||||
Width = fr.Radius * 2, Height = fr.Radius * 2,
|
||||
Stroke = FitCircleBrush, StrokeThickness = 2, Fill = Brushes.Transparent,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(circle, fr.CenterX - fr.Radius);
|
||||
Canvas.SetTop(circle, fr.CenterY - fr.Radius);
|
||||
AddCommitted(circle);
|
||||
|
||||
// 圆心十字
|
||||
double cs = 6;
|
||||
AddCommitted(new Line { X1 = fr.CenterX - cs, Y1 = fr.CenterY, X2 = fr.CenterX + cs, Y2 = fr.CenterY, Stroke = FitCircleBrush, StrokeThickness = 1.5, IsHitTestVisible = false });
|
||||
AddCommitted(new Line { X1 = fr.CenterX, Y1 = fr.CenterY - cs, X2 = fr.CenterX, Y2 = fr.CenterY + cs, Stroke = FitCircleBrush, StrokeThickness = 1.5, IsHitTestVisible = false });
|
||||
|
||||
// 标注
|
||||
var lbl = new TextBlock
|
||||
{
|
||||
Text = $"R:{fr.Radius:F1} C:({fr.CenterX:F1},{fr.CenterY:F1})",
|
||||
Foreground = FitCircleBrush, FontSize = 11, FontWeight = FontWeights.Bold, IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(lbl, fr.CenterX + 5); Canvas.SetTop(lbl, fr.CenterY - fr.Radius - 18);
|
||||
AddCommitted(lbl);
|
||||
}
|
||||
|
||||
private void AddCommitted(UIElement el) { _mainCanvas.Children.Add(el); _committedOverlays.Add(el); }
|
||||
|
||||
private void ClearCommitted()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
foreach (var el in _committedOverlays) _mainCanvas.Children.Remove(el);
|
||||
_committedOverlays.Clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 临时卡尺可视化
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void RedrawTemp()
|
||||
{
|
||||
if (!_circleDefined || _mainCanvas == null) return;
|
||||
ClearTempOverlays();
|
||||
DrawTempCaliper();
|
||||
}
|
||||
|
||||
private void DrawTempCaliper()
|
||||
{
|
||||
if (_mainCanvas == null || _radius < 5) return;
|
||||
|
||||
// 预估圆(虚线)
|
||||
var previewCircle = new Ellipse
|
||||
{
|
||||
Width = _radius * 2, Height = _radius * 2,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1,
|
||||
StrokeDashArray = new DoubleCollection { 4, 3 },
|
||||
Fill = CaliperFill, IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(previewCircle, _center.X - _radius);
|
||||
Canvas.SetTop(previewCircle, _center.Y - _radius);
|
||||
AddTemp(previewCircle);
|
||||
|
||||
// 卡尺径向线
|
||||
int count = CaliperCount;
|
||||
double halfW = CaliperWidth / 2.0;
|
||||
double angleStep = 2.0 * Math.PI / count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
double angle = angleStep * i;
|
||||
double dirX = Math.Cos(angle), dirY = Math.Sin(angle);
|
||||
double cx = _center.X + _radius * dirX;
|
||||
double cy = _center.Y + _radius * dirY;
|
||||
|
||||
AddTemp(new Line
|
||||
{
|
||||
X1 = cx - dirX * halfW, Y1 = cy - dirY * halfW,
|
||||
X2 = cx + dirX * halfW, Y2 = cy + dirY * halfW,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.4, IsHitTestVisible = false
|
||||
});
|
||||
}
|
||||
|
||||
// 手柄
|
||||
_handleCenterPos = _center;
|
||||
_handleRadiusPos = new Point(_center.X + _radius, _center.Y);
|
||||
|
||||
AddTemp(MakeHandle(_handleCenterPos));
|
||||
AddTemp(MakeHandle(_handleRadiusPos));
|
||||
}
|
||||
|
||||
private void CommitCurrentCaliper()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
foreach (var el in _tempOverlays) _mainCanvas.Children.Remove(el);
|
||||
_tempOverlays.Clear();
|
||||
|
||||
// 绘制永久卡尺(半透明)
|
||||
var circle = new Ellipse
|
||||
{
|
||||
Width = _radius * 2, Height = _radius * 2,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.4,
|
||||
Fill = Brushes.Transparent, IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(circle, _center.X - _radius);
|
||||
Canvas.SetTop(circle, _center.Y - _radius);
|
||||
AddCommitted(circle);
|
||||
}
|
||||
|
||||
private Ellipse MakeHandle(Point pos)
|
||||
{
|
||||
var h = new Ellipse
|
||||
{
|
||||
Width = HandleSize, Height = HandleSize,
|
||||
Fill = HandleFill, Stroke = CaliperStroke, StrokeThickness = 2,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(h, pos.X - HandleSize / 2);
|
||||
Canvas.SetTop(h, pos.Y - HandleSize / 2);
|
||||
return h;
|
||||
}
|
||||
|
||||
private void AddTemp(UIElement el) { _mainCanvas.Children.Add(el); _tempOverlays.Add(el); }
|
||||
private void ClearTempOverlays()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
foreach (var el in _tempOverlays) _mainCanvas.Children.Remove(el);
|
||||
_tempOverlays.Clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 鼠标交互
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private bool _interactionRegistered;
|
||||
|
||||
private void RegisterInteraction()
|
||||
{
|
||||
if (_canvas == null || _interactionRegistered) return;
|
||||
_canvas.PreviewMouseLeftButtonDown += OnMouseDown;
|
||||
_canvas.PreviewMouseMove += OnMouseMove;
|
||||
_canvas.PreviewMouseLeftButtonUp += OnMouseUp;
|
||||
_interactionRegistered = true;
|
||||
}
|
||||
|
||||
private void UnregisterAll()
|
||||
{
|
||||
if (_canvas == null || !_interactionRegistered) return;
|
||||
_canvas.PreviewMouseLeftButtonDown -= OnMouseDown;
|
||||
_canvas.PreviewMouseMove -= OnMouseMove;
|
||||
_canvas.PreviewMouseLeftButtonUp -= OnMouseUp;
|
||||
_interactionRegistered = false;
|
||||
_isDrawing = false;
|
||||
_dragging = DragTarget.None;
|
||||
}
|
||||
|
||||
private void OnMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
var pos = e.GetPosition(_mainCanvas);
|
||||
|
||||
// 绘制模式:按下鼠标确定圆心,拖拽确定半径
|
||||
if (_isDrawing)
|
||||
{
|
||||
_center = pos;
|
||||
_radius = 0;
|
||||
_dragging = DragTarget.Radius; // 复用 Radius 拖拽逻辑
|
||||
_canvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 拖拽手柄
|
||||
if (_circleDefined)
|
||||
{
|
||||
var target = HitTest(pos);
|
||||
if (target != DragTarget.None)
|
||||
{
|
||||
_dragging = target;
|
||||
_canvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (_dragging == DragTarget.None || _mainCanvas == null) return;
|
||||
var pos = e.GetPosition(_mainCanvas);
|
||||
|
||||
if (_dragging == DragTarget.Center)
|
||||
{
|
||||
_center = pos;
|
||||
}
|
||||
else if (_dragging == DragTarget.Radius)
|
||||
{
|
||||
_radius = Math.Max(5, Dist(pos, _center));
|
||||
}
|
||||
|
||||
// 实时预览
|
||||
if (_radius >= 5)
|
||||
{
|
||||
_circleDefined = true;
|
||||
ClearTempOverlays();
|
||||
DrawTempCaliper();
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (_dragging == DragTarget.None) return;
|
||||
|
||||
// 绘制模式完成
|
||||
if (_isDrawing)
|
||||
{
|
||||
_isDrawing = false;
|
||||
_dragging = DragTarget.None;
|
||||
_canvas.ReleaseMouseCapture();
|
||||
|
||||
if (_radius >= 5)
|
||||
{
|
||||
_circleDefined = true;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
RedrawTemp();
|
||||
ResultText = $"Circle defined: R={_radius:F0}px\nDrag handles to adjust\nClick Fit to execute";
|
||||
}
|
||||
else
|
||||
{
|
||||
_circleDefined = false;
|
||||
ResultText = "Circle too small, try again";
|
||||
}
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_dragging = DragTarget.None;
|
||||
_canvas.ReleaseMouseCapture();
|
||||
ResultText = $"Circle: R={_radius:F0}px\nClick Fit to execute";
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private DragTarget HitTest(Point pos)
|
||||
{
|
||||
if (Dist(pos, _handleCenterPos) <= HitRadius) return DragTarget.Center;
|
||||
if (Dist(pos, _handleRadiusPos) <= HitRadius) return DragTarget.Radius;
|
||||
return DragTarget.None;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 辅助
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private static double Dist(Point a, Point b)
|
||||
{
|
||||
double dx = a.X - b.X, dy = a.Y - b.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static T FindChild<T>(DependencyObject parent, string name) where T : FrameworkElement
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T t && t.Name == name) return t;
|
||||
var r = FindChild<T>(child, name);
|
||||
if (r != null) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.Structure;
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using XP.ImageProcessing.Processors;
|
||||
using XP.ImageProcessing.RoiControl.Controls;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using Brushes = System.Windows.Media.Brushes;
|
||||
using Ellipse = System.Windows.Shapes.Ellipse;
|
||||
using Point = System.Windows.Point;
|
||||
|
||||
namespace XplorePlane.ViewModels.ImageProcessing
|
||||
{
|
||||
/// <summary>
|
||||
/// 边缘查找拟合直线 ViewModel
|
||||
/// 支持多次拟合,每次点击"画卡尺"开始一次新的测量,结果累积保留
|
||||
/// 关闭面板时保留所有结果,仅清除当前正在编辑的临时卡尺
|
||||
/// </summary>
|
||||
public class EdgeLineFitViewModel : BindableBase
|
||||
{
|
||||
private readonly IMainViewportService _viewportService;
|
||||
private PolygonRoiCanvas _canvas;
|
||||
private Canvas _mainCanvas;
|
||||
|
||||
// 当前正在编辑的搜索线
|
||||
private Point _lineStart;
|
||||
private Point _lineEnd;
|
||||
private double _halfWidth = 30;
|
||||
private bool _lineDefined;
|
||||
|
||||
// 当前编辑中的临时可视化(卡尺框+手柄,拟合前可调整)
|
||||
private readonly List<UIElement> _tempOverlays = new();
|
||||
|
||||
// 已完成的拟合结果(永久保留在画布上)
|
||||
// 不由本类管理生命周期,关闭面板后仍保留
|
||||
private readonly List<UIElement> _committedOverlays = new();
|
||||
|
||||
// 手柄位置
|
||||
private Point _handleStartPos, _handleEndPos, _handleTopPos, _handleBottomPos;
|
||||
|
||||
// 交互状态
|
||||
private enum DragTarget { None, Start, End, Top, Bottom }
|
||||
private DragTarget _dragging = DragTarget.None;
|
||||
private bool _isDrawingLine;
|
||||
private int _drawClickCount;
|
||||
private int _fitCount;
|
||||
|
||||
private const double HandleSize = 12;
|
||||
private const double HitRadius = 10;
|
||||
private static readonly SolidColorBrush CaliperStroke;
|
||||
private static readonly SolidColorBrush CaliperFill;
|
||||
private static readonly SolidColorBrush FitLineBrush;
|
||||
private static readonly SolidColorBrush HandleFill;
|
||||
|
||||
static EdgeLineFitViewModel()
|
||||
{
|
||||
CaliperStroke = new SolidColorBrush(Color.FromRgb(0, 255, 0));
|
||||
CaliperStroke.Freeze();
|
||||
CaliperFill = new SolidColorBrush(Color.FromArgb(20, 0, 255, 0));
|
||||
CaliperFill.Freeze();
|
||||
FitLineBrush = new SolidColorBrush(Color.FromRgb(30, 144, 255));
|
||||
FitLineBrush.Freeze();
|
||||
HandleFill = new SolidColorBrush(Color.FromArgb(220, 255, 255, 255));
|
||||
HandleFill.Freeze();
|
||||
}
|
||||
|
||||
public EdgeLineFitViewModel(IMainViewportService viewportService)
|
||||
{
|
||||
_viewportService = viewportService;
|
||||
FitCommand = new DelegateCommand(ExecuteFit, () => _lineDefined);
|
||||
ClearAllCommand = new DelegateCommand(ExecuteClearAll);
|
||||
DrawCaliperCommand = new DelegateCommand(ExecuteDrawCaliper);
|
||||
}
|
||||
|
||||
// ── 命令 ──
|
||||
public DelegateCommand FitCommand { get; }
|
||||
public DelegateCommand ClearAllCommand { get; }
|
||||
public DelegateCommand DrawCaliperCommand { get; }
|
||||
|
||||
// ── 参数 ──
|
||||
private int _caliperCount = 20;
|
||||
public int CaliperCount
|
||||
{
|
||||
get => _caliperCount;
|
||||
set { if (SetProperty(ref _caliperCount, value)) RedrawTemp(); }
|
||||
}
|
||||
|
||||
private int _displayWidth = 60;
|
||||
public int DisplayWidth
|
||||
{
|
||||
get => _displayWidth;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _displayWidth, Math.Max(10, value)))
|
||||
{
|
||||
_halfWidth = _displayWidth / 2.0;
|
||||
RedrawTemp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _edgePolarity = "Both";
|
||||
public string EdgePolarity
|
||||
{
|
||||
get => _edgePolarity;
|
||||
set { if (SetProperty(ref _edgePolarity, value)) RedrawTemp(); }
|
||||
}
|
||||
|
||||
private int _edgeThreshold = 20;
|
||||
public int EdgeThreshold { get => _edgeThreshold; set => SetProperty(ref _edgeThreshold, value); }
|
||||
|
||||
private double _sigma = 1.0;
|
||||
public double Sigma { get => _sigma; set => SetProperty(ref _sigma, value); }
|
||||
|
||||
private string _fitMethod = "RANSAC";
|
||||
public string FitMethod { get => _fitMethod; set => SetProperty(ref _fitMethod, value); }
|
||||
|
||||
private double _ransacThreshold = 2.0;
|
||||
public double RansacThreshold { get => _ransacThreshold; set => SetProperty(ref _ransacThreshold, value); }
|
||||
|
||||
private string _resultText = "就绪 - 点击「画卡尺」开始";
|
||||
public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); }
|
||||
|
||||
// ── 初始化 ──
|
||||
public void SetCanvas(PolygonRoiCanvas canvas)
|
||||
{
|
||||
_canvas = canvas;
|
||||
_mainCanvas = FindChild<Canvas>(canvas, "mainCanvas");
|
||||
}
|
||||
|
||||
/// <summary>面板关闭时调用:仅清除临时编辑状态,保留已拟合结果</summary>
|
||||
public void OnPanelClosed()
|
||||
{
|
||||
UnregisterAll();
|
||||
ClearTempOverlays(); // 清除正在编辑的卡尺手柄
|
||||
// _committedOverlays 保留在画布上不清除
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 命令实现
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>开始一次新的卡尺绘制(不影响已有结果)</summary>
|
||||
private void ExecuteDrawCaliper()
|
||||
{
|
||||
// 清除当前临时编辑
|
||||
ClearTempOverlays();
|
||||
UnregisterAll();
|
||||
_lineDefined = false;
|
||||
_dragging = DragTarget.None;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
_drawClickCount = 0;
|
||||
_isDrawingLine = true;
|
||||
ResultText = "请在图像上点击搜索线起点";
|
||||
RegisterInteraction();
|
||||
}
|
||||
|
||||
/// <summary>执行拟合,将结果提交为永久显示</summary>
|
||||
private void ExecuteFit()
|
||||
{
|
||||
if (!_lineDefined) return;
|
||||
|
||||
// 清除上一次拟合结果
|
||||
ClearCommitted();
|
||||
|
||||
var imageSource = _viewportService?.CurrentDisplayImage as BitmapSource;
|
||||
if (imageSource == null) { ResultText = "错误:无可用图像"; return; }
|
||||
|
||||
try
|
||||
{
|
||||
BitmapSource source = imageSource;
|
||||
if (imageSource.Format != PixelFormats.Gray8)
|
||||
source = new FormatConvertedBitmap(imageSource, PixelFormats.Gray8, null, 0);
|
||||
|
||||
int w = source.PixelWidth, h = source.PixelHeight;
|
||||
int stride = w;
|
||||
byte[] px = new byte[h * stride];
|
||||
source.CopyPixels(px, stride, 0);
|
||||
|
||||
using var img = new Image<Gray, byte>(w, h);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
img.Data[y, x, 0] = px[y * stride + x];
|
||||
|
||||
var proc = new EdgeLineFitProcessor();
|
||||
proc.SetParameter("StartX", (int)_lineStart.X);
|
||||
proc.SetParameter("StartY", (int)_lineStart.Y);
|
||||
proc.SetParameter("EndX", (int)_lineEnd.X);
|
||||
proc.SetParameter("EndY", (int)_lineEnd.Y);
|
||||
proc.SetParameter("CaliperCount", CaliperCount);
|
||||
proc.SetParameter("CaliperWidth", (int)(_halfWidth * 2));
|
||||
proc.SetParameter("EdgePolarity", EdgePolarity);
|
||||
proc.SetParameter("EdgeThreshold", EdgeThreshold);
|
||||
proc.SetParameter("Sigma", Sigma);
|
||||
proc.SetParameter("FitMethod", FitMethod);
|
||||
proc.SetParameter("RansacThreshold", RansacThreshold);
|
||||
|
||||
var result = proc.Process(img);
|
||||
var od = proc.OutputData;
|
||||
|
||||
if (od.ContainsKey("LineFitResult"))
|
||||
{
|
||||
var fr = od["LineFitResult"] as LineFitResult;
|
||||
if (fr != null && fr.Success)
|
||||
{
|
||||
_fitCount++;
|
||||
DrawFitResult(fr);
|
||||
ResultText = $"[#{_fitCount}] 拟合成功\n角度: {fr.AngleDegrees:F2}°\n" +
|
||||
$"内点: {fr.Inliers.Count}/{fr.EdgePointCount}\n" +
|
||||
$"误差: {fr.FitError:F3} px\n\n可继续调整后再次拟合";
|
||||
}
|
||||
else
|
||||
{
|
||||
int ec = od.ContainsKey("EdgePointCount") ? (int)od["EdgePointCount"] : 0;
|
||||
ResultText = $"拟合失败\n边缘点: {ec}\n请调整参数或拖拽手柄";
|
||||
}
|
||||
}
|
||||
result.Dispose();
|
||||
}
|
||||
catch (Exception ex) { ResultText = $"异常: {ex.Message}"; }
|
||||
}
|
||||
|
||||
/// <summary>清除所有(包括已拟合的结果)</summary>
|
||||
private void ExecuteClearAll()
|
||||
{
|
||||
ClearTempOverlays();
|
||||
// 清除所有已提交的结果
|
||||
if (_mainCanvas != null)
|
||||
{
|
||||
foreach (var el in _committedOverlays)
|
||||
_mainCanvas.Children.Remove(el);
|
||||
}
|
||||
_committedOverlays.Clear();
|
||||
_fitCount = 0;
|
||||
UnregisterAll();
|
||||
_lineDefined = false;
|
||||
_dragging = DragTarget.None;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
ResultText = "已清除所有结果";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 提交当前卡尺为永久显示
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>将当前临时卡尺可视化转为永久(去掉手柄,保留边框和等分线)</summary>
|
||||
private void CommitCurrentCaliper()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
|
||||
// 移除临时元素
|
||||
foreach (var el in _tempOverlays)
|
||||
_mainCanvas.Children.Remove(el);
|
||||
_tempOverlays.Clear();
|
||||
|
||||
// 重新绘制卡尺(无手柄,作为永久元素)
|
||||
double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y;
|
||||
double len = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < 2) return;
|
||||
|
||||
double ux = dx / len, uy = dy / len;
|
||||
double px = -uy, py = ux;
|
||||
double hw = _halfWidth;
|
||||
|
||||
var c1 = new Point(_lineStart.X + px * hw, _lineStart.Y + py * hw);
|
||||
var c2 = new Point(_lineEnd.X + px * hw, _lineEnd.Y + py * hw);
|
||||
var c3 = new Point(_lineEnd.X - px * hw, _lineEnd.Y - py * hw);
|
||||
var c4 = new Point(_lineStart.X - px * hw, _lineStart.Y - py * hw);
|
||||
|
||||
// 矩形边框(半透明,不抢眼)
|
||||
var border = new Polygon
|
||||
{
|
||||
Points = new System.Windows.Media.PointCollection { c1, c2, c3, c4 },
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.5,
|
||||
Fill = System.Windows.Media.Brushes.Transparent, IsHitTestVisible = false
|
||||
};
|
||||
_mainCanvas.Children.Add(border);
|
||||
_committedOverlays.Add(border);
|
||||
|
||||
// 等分线
|
||||
int count = CaliperCount;
|
||||
double step = len / (count + 1);
|
||||
for (int i = 1; i <= count; i++)
|
||||
{
|
||||
double cx = _lineStart.X + ux * step * i, cy = _lineStart.Y + uy * step * i;
|
||||
var line = new Line
|
||||
{
|
||||
X1 = cx + px * hw, Y1 = cy + py * hw,
|
||||
X2 = cx - px * hw, Y2 = cy - py * hw,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.3, IsHitTestVisible = false
|
||||
};
|
||||
_mainCanvas.Children.Add(line);
|
||||
_committedOverlays.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 绘制拟合结果(永久)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void DrawFitResult(LineFitResult fr)
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
|
||||
// 拟合直线(蓝色)
|
||||
AddCommitted(new Line
|
||||
{
|
||||
X1 = fr.Endpoint1.X, Y1 = fr.Endpoint1.Y,
|
||||
X2 = fr.Endpoint2.X, Y2 = fr.Endpoint2.Y,
|
||||
Stroke = FitLineBrush, StrokeThickness = 2, IsHitTestVisible = false
|
||||
});
|
||||
|
||||
// 标注
|
||||
var lbl = new TextBlock
|
||||
{
|
||||
Text = $"∠{fr.AngleDegrees:F2}°",
|
||||
Foreground = FitLineBrush, FontSize = 11, FontWeight = FontWeights.Bold, IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(lbl, (fr.Endpoint1.X + fr.Endpoint2.X) / 2 + 5);
|
||||
Canvas.SetTop(lbl, (fr.Endpoint1.Y + fr.Endpoint2.Y) / 2 - 18);
|
||||
AddCommitted(lbl);
|
||||
}
|
||||
|
||||
private void AddCommitted(UIElement el)
|
||||
{
|
||||
_mainCanvas.Children.Add(el);
|
||||
_committedOverlays.Add(el);
|
||||
}
|
||||
|
||||
private void ClearCommitted()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
foreach (var el in _committedOverlays) _mainCanvas.Children.Remove(el);
|
||||
_committedOverlays.Clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 临时卡尺可视化(编辑中,带手柄)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void RedrawTemp()
|
||||
{
|
||||
if (!_lineDefined || _mainCanvas == null) return;
|
||||
ClearTempOverlays();
|
||||
DrawTempCaliper();
|
||||
}
|
||||
|
||||
private void DrawTempCaliper()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
|
||||
double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y;
|
||||
double len = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < 2) return;
|
||||
|
||||
double ux = dx / len, uy = dy / len;
|
||||
double px = -uy, py = ux;
|
||||
double hw = _halfWidth;
|
||||
|
||||
var c1 = new Point(_lineStart.X + px * hw, _lineStart.Y + py * hw);
|
||||
var c2 = new Point(_lineEnd.X + px * hw, _lineEnd.Y + py * hw);
|
||||
var c3 = new Point(_lineEnd.X - px * hw, _lineEnd.Y - py * hw);
|
||||
var c4 = new Point(_lineStart.X - px * hw, _lineStart.Y - py * hw);
|
||||
|
||||
// 矩形
|
||||
AddTemp(new Polygon
|
||||
{
|
||||
Points = new System.Windows.Media.PointCollection { c1, c2, c3, c4 },
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Fill = CaliperFill, IsHitTestVisible = false
|
||||
});
|
||||
|
||||
// 搜索线虚线
|
||||
AddTemp(new Line
|
||||
{
|
||||
X1 = _lineStart.X, Y1 = _lineStart.Y, X2 = _lineEnd.X, Y2 = _lineEnd.Y,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1,
|
||||
StrokeDashArray = new DoubleCollection { 4, 3 }, IsHitTestVisible = false
|
||||
});
|
||||
|
||||
// 等分线
|
||||
int count = CaliperCount;
|
||||
double step = len / (count + 1);
|
||||
for (int i = 1; i <= count; i++)
|
||||
{
|
||||
double cx = _lineStart.X + ux * step * i, cy = _lineStart.Y + uy * step * i;
|
||||
AddTemp(new Line
|
||||
{
|
||||
X1 = cx + px * hw, Y1 = cy + py * hw,
|
||||
X2 = cx - px * hw, Y2 = cy - py * hw,
|
||||
Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.4, IsHitTestVisible = false
|
||||
});
|
||||
}
|
||||
|
||||
// 极性箭头
|
||||
DrawPolarityArrow(px, py);
|
||||
|
||||
// 手柄位置
|
||||
double midX = (_lineStart.X + _lineEnd.X) / 2, midY = (_lineStart.Y + _lineEnd.Y) / 2;
|
||||
_handleStartPos = _lineStart;
|
||||
_handleEndPos = _lineEnd;
|
||||
_handleTopPos = new Point(midX + px * hw, midY + py * hw);
|
||||
_handleBottomPos = new Point(midX - px * hw, midY - py * hw);
|
||||
|
||||
// 绘制手柄
|
||||
AddTemp(MakeHandleVisual(_handleStartPos));
|
||||
AddTemp(MakeHandleVisual(_handleEndPos));
|
||||
AddTemp(MakeHandleVisual(_handleTopPos));
|
||||
AddTemp(MakeHandleVisual(_handleBottomPos));
|
||||
}
|
||||
|
||||
private void DrawPolarityArrow(double px, double py)
|
||||
{
|
||||
double midX = (_lineStart.X + _lineEnd.X) / 2, midY = (_lineStart.Y + _lineEnd.Y) / 2;
|
||||
double arrowLen = Math.Min(_halfWidth * 0.6, 16);
|
||||
|
||||
if (EdgePolarity == "Both")
|
||||
{
|
||||
DrawArrow(midX, midY, px, py, arrowLen);
|
||||
DrawArrow(midX, midY, -px, -py, arrowLen);
|
||||
}
|
||||
else if (EdgePolarity == "DarkToBright")
|
||||
DrawArrow(midX, midY, px, py, arrowLen);
|
||||
else
|
||||
DrawArrow(midX, midY, -px, -py, arrowLen);
|
||||
|
||||
string txt = EdgePolarity switch { "BrightToDark" => "B→D", "DarkToBright" => "D→B", _ => "↔" };
|
||||
var tb = new TextBlock { Text = txt, Foreground = CaliperStroke, FontSize = 10, IsHitTestVisible = false };
|
||||
Canvas.SetLeft(tb, midX + px * (_halfWidth + 12));
|
||||
Canvas.SetTop(tb, midY + py * (_halfWidth + 12) - 7);
|
||||
AddTemp(tb);
|
||||
}
|
||||
|
||||
private void DrawArrow(double fx, double fy, double dx, double dy, double length)
|
||||
{
|
||||
double tx = fx + dx * length, ty = fy + dy * length;
|
||||
AddTemp(new Line { X1 = fx, Y1 = fy, X2 = tx, Y2 = ty, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false });
|
||||
double ang = Math.Atan2(dy, dx), hl = 5;
|
||||
double a1 = ang + 2.5, a2 = ang - 2.5;
|
||||
AddTemp(new Line { X1 = tx, Y1 = ty, X2 = tx + Math.Cos(a1) * hl, Y2 = ty + Math.Sin(a1) * hl, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false });
|
||||
AddTemp(new Line { X1 = tx, Y1 = ty, X2 = tx + Math.Cos(a2) * hl, Y2 = ty + Math.Sin(a2) * hl, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false });
|
||||
}
|
||||
|
||||
private Ellipse MakeHandleVisual(Point pos)
|
||||
{
|
||||
var h = new Ellipse
|
||||
{
|
||||
Width = HandleSize, Height = HandleSize,
|
||||
Fill = HandleFill, Stroke = CaliperStroke, StrokeThickness = 2,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(h, pos.X - HandleSize / 2);
|
||||
Canvas.SetTop(h, pos.Y - HandleSize / 2);
|
||||
return h;
|
||||
}
|
||||
|
||||
private void AddTemp(UIElement el) { _mainCanvas.Children.Add(el); _tempOverlays.Add(el); }
|
||||
private void ClearTempOverlays()
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
foreach (var el in _tempOverlays) _mainCanvas.Children.Remove(el);
|
||||
_tempOverlays.Clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 统一鼠标交互
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private bool _interactionRegistered;
|
||||
|
||||
private void RegisterInteraction()
|
||||
{
|
||||
if (_canvas == null || _interactionRegistered) return;
|
||||
_canvas.PreviewMouseLeftButtonDown += OnMouseDown;
|
||||
_canvas.PreviewMouseMove += OnMouseMove;
|
||||
_canvas.PreviewMouseLeftButtonUp += OnMouseUp;
|
||||
_interactionRegistered = true;
|
||||
}
|
||||
|
||||
private void UnregisterAll()
|
||||
{
|
||||
if (_canvas == null || !_interactionRegistered) return;
|
||||
_canvas.PreviewMouseLeftButtonDown -= OnMouseDown;
|
||||
_canvas.PreviewMouseMove -= OnMouseMove;
|
||||
_canvas.PreviewMouseLeftButtonUp -= OnMouseUp;
|
||||
_interactionRegistered = false;
|
||||
_isDrawingLine = false;
|
||||
_dragging = DragTarget.None;
|
||||
}
|
||||
|
||||
private void OnMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (_mainCanvas == null) return;
|
||||
var pos = e.GetPosition(_mainCanvas);
|
||||
|
||||
// 绘制模式
|
||||
if (_isDrawingLine)
|
||||
{
|
||||
_drawClickCount++;
|
||||
if (_drawClickCount == 1)
|
||||
{
|
||||
_lineStart = pos;
|
||||
ResultText = "请点击搜索线终点";
|
||||
}
|
||||
else if (_drawClickCount == 2)
|
||||
{
|
||||
_lineEnd = pos;
|
||||
_isDrawingLine = false;
|
||||
_lineDefined = true;
|
||||
_halfWidth = DisplayWidth / 2.0;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
RedrawTemp();
|
||||
ResultText = $"搜索线已定义 ({Len():F0}px)\n拖拽手柄调整,点击「拟合」执行";
|
||||
}
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 拖拽模式
|
||||
if (_lineDefined)
|
||||
{
|
||||
var target = HitTestHandle(pos);
|
||||
if (target != DragTarget.None)
|
||||
{
|
||||
_dragging = target;
|
||||
_canvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (_dragging == DragTarget.None || _mainCanvas == null) return;
|
||||
var pos = e.GetPosition(_mainCanvas);
|
||||
|
||||
switch (_dragging)
|
||||
{
|
||||
case DragTarget.Start:
|
||||
_lineStart = pos;
|
||||
break;
|
||||
case DragTarget.End:
|
||||
_lineEnd = pos;
|
||||
break;
|
||||
case DragTarget.Top:
|
||||
case DragTarget.Bottom:
|
||||
double dist = PointToLineDist(pos, _lineStart, _lineEnd);
|
||||
_halfWidth = Math.Max(5, dist);
|
||||
SetProperty(ref _displayWidth, (int)(_halfWidth * 2), nameof(DisplayWidth));
|
||||
break;
|
||||
}
|
||||
|
||||
ClearTempOverlays();
|
||||
DrawTempCaliper();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (_dragging == DragTarget.None) return;
|
||||
_dragging = DragTarget.None;
|
||||
_canvas.ReleaseMouseCapture();
|
||||
ResultText = $"搜索线: {Len():F0}px, 宽度: {(int)(_halfWidth * 2)}px\n点击「拟合」执行";
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private DragTarget HitTestHandle(Point pos)
|
||||
{
|
||||
if (Dist(pos, _handleStartPos) <= HitRadius) return DragTarget.Start;
|
||||
if (Dist(pos, _handleEndPos) <= HitRadius) return DragTarget.End;
|
||||
if (Dist(pos, _handleTopPos) <= HitRadius) return DragTarget.Top;
|
||||
if (Dist(pos, _handleBottomPos) <= HitRadius) return DragTarget.Bottom;
|
||||
return DragTarget.None;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 辅助
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private double Len()
|
||||
{
|
||||
double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static double Dist(Point a, Point b)
|
||||
{
|
||||
double dx = a.X - b.X, dy = a.Y - b.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static double PointToLineDist(Point p, Point a, Point b)
|
||||
{
|
||||
double abx = b.X - a.X, aby = b.Y - a.Y;
|
||||
double len2 = abx * abx + aby * aby;
|
||||
if (len2 < 1e-6) return Dist(p, a);
|
||||
return Math.Abs(abx * (a.Y - p.Y) - aby * (a.X - p.X)) / Math.Sqrt(len2);
|
||||
}
|
||||
|
||||
private static T FindChild<T>(DependencyObject parent, string name) where T : FrameworkElement
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T t && t.Name == name) return t;
|
||||
var r = FindChild<T>(child, name);
|
||||
if (r != null) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.Structure;
|
||||
using Microsoft.Win32;
|
||||
using Prism.Commands;
|
||||
using Prism.Events;
|
||||
using Prism.Ioc;
|
||||
using Prism.Mvvm;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XP.ImageProcessing.Processors;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using XplorePlane.ViewModels;
|
||||
using Serilog;
|
||||
|
||||
namespace XplorePlane.ViewModels.ImageProcessing;
|
||||
|
||||
/// <summary>
|
||||
/// 旋转模板匹配助手:框选 ROI、从 ROI 训练、参数、加载/保存模型、在当页试匹配;批量测试见 <see cref="Batch"/>。
|
||||
/// </summary>
|
||||
public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TemplateMatchAssistantViewModel>();
|
||||
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IContainerProvider _containerProvider;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly object _matcherLock = new();
|
||||
private readonly TemplateMatchBatchViewModel _batch;
|
||||
private SubscriptionToken? _roiDrawnToken;
|
||||
private TemplateMatcherHandle? _matcher;
|
||||
private bool _disposed;
|
||||
private Int32Rect _pendingTemplateRoi;
|
||||
private bool _hasPendingTemplateRoi;
|
||||
|
||||
private string _statusMessage = "可先加载模型,或点击「框选模板 ROI」后在主视图框选,再点「从 ROI 训练模板」。批量测试请切换到「批量测试」选项卡。";
|
||||
private double _matchThreshold = 0.75;
|
||||
private double _toleranceAngle;
|
||||
private double _maxMatchCount = 5;
|
||||
private double _maxOverlap = 0.3;
|
||||
private double _minReduceArea = 256;
|
||||
private bool _useSimd = true;
|
||||
private bool _useSubPixel;
|
||||
private bool _isModelReady;
|
||||
|
||||
public TemplateMatchAssistantViewModel(
|
||||
IEventAggregator eventAggregator,
|
||||
IContainerProvider containerProvider,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
_containerProvider = containerProvider;
|
||||
_logger = logger;
|
||||
|
||||
SelectTemplateRoiCommand = new DelegateCommand(ExecuteSelectTemplateRoi);
|
||||
LearnFromRoiCommand = new DelegateCommand(ExecuteLearnFromRoi, () => _hasPendingTemplateRoi);
|
||||
LoadModelCommand = new DelegateCommand(ExecuteLoadModel);
|
||||
SaveModelCommand = new DelegateCommand(ExecuteSaveModel, () => _isModelReady && _matcher != null);
|
||||
RunMatchCommand = new DelegateCommand(ExecuteRunMatch, () => _isModelReady && _matcher != null);
|
||||
|
||||
_batch = new TemplateMatchBatchViewModel(
|
||||
this,
|
||||
_eventAggregator,
|
||||
_containerProvider.Resolve<IMainViewportService>());
|
||||
|
||||
_roiDrawnToken = _eventAggregator.GetEvent<TemplateMatchRoiDrawnEvent>()
|
||||
.Subscribe(OnTemplateRoiDrawn, ThreadOption.UIThread);
|
||||
}
|
||||
|
||||
/// <summary>批量测试子页(与助手共用同一模型与参数)。</summary>
|
||||
public TemplateMatchBatchViewModel Batch => _batch;
|
||||
|
||||
public DelegateCommand SelectTemplateRoiCommand { get; }
|
||||
public DelegateCommand LearnFromRoiCommand { get; }
|
||||
public DelegateCommand LoadModelCommand { get; }
|
||||
public DelegateCommand SaveModelCommand { get; }
|
||||
public DelegateCommand RunMatchCommand { get; }
|
||||
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
public double MatchThreshold
|
||||
{
|
||||
get => _matchThreshold;
|
||||
set => SetProperty(ref _matchThreshold, Math.Clamp(value, 0, 1));
|
||||
}
|
||||
|
||||
public double ToleranceAngle
|
||||
{
|
||||
get => _toleranceAngle;
|
||||
set => SetProperty(ref _toleranceAngle, Math.Clamp(value, 0, 180));
|
||||
}
|
||||
|
||||
/// <summary>最大匹配数(滑块 1~100,运行匹配时取整)。</summary>
|
||||
public double MaxMatchCount
|
||||
{
|
||||
get => _maxMatchCount;
|
||||
set => SetProperty(ref _maxMatchCount, Math.Clamp(value, 1, 100));
|
||||
}
|
||||
|
||||
public double MaxOverlap
|
||||
{
|
||||
get => _maxOverlap;
|
||||
set => SetProperty(ref _maxOverlap, Math.Clamp(value, 0, 1));
|
||||
}
|
||||
|
||||
/// <summary>金字塔最小面积(滑块 64~4096,运行匹配时取整)。</summary>
|
||||
public double MinReduceArea
|
||||
{
|
||||
get => _minReduceArea;
|
||||
set => SetProperty(ref _minReduceArea, Math.Clamp(value, 64, 4096));
|
||||
}
|
||||
|
||||
public bool UseSimd
|
||||
{
|
||||
get => _useSimd;
|
||||
set => SetProperty(ref _useSimd, value);
|
||||
}
|
||||
|
||||
public bool UseSubPixel
|
||||
{
|
||||
get => _useSubPixel;
|
||||
set => SetProperty(ref _useSubPixel, value);
|
||||
}
|
||||
|
||||
public bool IsModelReady
|
||||
{
|
||||
get => _isModelReady;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isModelReady, value))
|
||||
{
|
||||
SaveModelCommand.RaiseCanExecuteChanged();
|
||||
RunMatchCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>是否已有框选完成的模板 ROI(与是否已训练无关)。</summary>
|
||||
public bool HasPendingTemplateRoi
|
||||
{
|
||||
get => _hasPendingTemplateRoi;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasPendingTemplateRoi, value))
|
||||
LearnFromRoiCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除主视图上框选的模板学习 ROI 叠加,并重置待训练 ROI 状态(运行匹配或开始批量测试前调用)。
|
||||
/// </summary>
|
||||
public void ClearTemplateLearningRoiOnViewport()
|
||||
{
|
||||
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
|
||||
HasPendingTemplateRoi = false;
|
||||
}
|
||||
|
||||
private void ExecuteSelectTemplateRoi()
|
||||
{
|
||||
_eventAggregator.GetEvent<TemplateMatchEnterRoiModeEvent>().Publish();
|
||||
StatusMessage = "请在主视图图像上拖拽框选模板区域;框选完成后 ROI 会保留在图上,再点击「从 ROI 训练模板」。";
|
||||
}
|
||||
|
||||
private void OnTemplateRoiDrawn(Int32Rect roi)
|
||||
{
|
||||
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
|
||||
var imageSource = viewportVm?.ImageSource as BitmapSource;
|
||||
if (imageSource == null)
|
||||
{
|
||||
StatusMessage = "当前无主视图图像,无法记录模板 ROI。";
|
||||
HasPendingTemplateRoi = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int imgW = imageSource.PixelWidth;
|
||||
int imgH = imageSource.PixelHeight;
|
||||
int rx = Math.Clamp(roi.X, 0, Math.Max(0, imgW - 1));
|
||||
int ry = Math.Clamp(roi.Y, 0, Math.Max(0, imgH - 1));
|
||||
int rw = Math.Clamp(roi.Width, 1, Math.Max(1, imgW - rx));
|
||||
int rh = Math.Clamp(roi.Height, 1, Math.Max(1, imgH - ry));
|
||||
|
||||
_pendingTemplateRoi = new Int32Rect(rx, ry, rw, rh);
|
||||
HasPendingTemplateRoi = true;
|
||||
StatusMessage = $"已框选模板区域 {rw}×{rh} 像素。请点击「从 ROI 训练模板」。";
|
||||
}
|
||||
|
||||
private void ExecuteLearnFromRoi()
|
||||
{
|
||||
if (!HasPendingTemplateRoi) return;
|
||||
|
||||
try
|
||||
{
|
||||
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
|
||||
var imageSource = viewportVm?.ImageSource as BitmapSource;
|
||||
if (imageSource == null)
|
||||
{
|
||||
StatusMessage = "当前无主视图图像,无法学习模板。";
|
||||
return;
|
||||
}
|
||||
|
||||
BitmapSource gray = imageSource.Format == PixelFormats.Gray8
|
||||
? imageSource
|
||||
: new FormatConvertedBitmap(imageSource, PixelFormats.Gray8, null, 0);
|
||||
|
||||
int imgW = gray.PixelWidth;
|
||||
int imgH = gray.PixelHeight;
|
||||
int rx = Math.Clamp(_pendingTemplateRoi.X, 0, imgW - 1);
|
||||
int ry = Math.Clamp(_pendingTemplateRoi.Y, 0, imgH - 1);
|
||||
int rw = Math.Clamp(_pendingTemplateRoi.Width, 1, imgW - rx);
|
||||
int rh = Math.Clamp(_pendingTemplateRoi.Height, 1, imgH - ry);
|
||||
|
||||
var pixels = new byte[rw * rh];
|
||||
gray.CopyPixels(new Int32Rect(rx, ry, rw, rh), pixels, rw, 0);
|
||||
|
||||
using var roiImage = new Image<Gray, byte>(rw, rh);
|
||||
for (int y = 0; y < rh; y++)
|
||||
for (int x = 0; x < rw; x++)
|
||||
roiImage.Data[y, x, 0] = pixels[y * rw + x];
|
||||
|
||||
lock (_matcherLock)
|
||||
{
|
||||
_matcher?.Dispose();
|
||||
_matcher = new TemplateMatcherHandle();
|
||||
IntPtr p = roiImage.Mat.DataPointer;
|
||||
int step = (int)roiImage.Mat.Step;
|
||||
if (!_matcher.LearnPattern(p, rw, rh, step))
|
||||
{
|
||||
_matcher.Dispose();
|
||||
_matcher = null;
|
||||
IsModelReady = false;
|
||||
StatusMessage = "模板学习失败。";
|
||||
_logger.Warn("Template assistant: LearnPattern failed for ROI {0},{1},{2},{3}", rx, ry, rw, rh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
IsModelReady = true;
|
||||
StatusMessage = $"模板已从 ROI 学习成功({rw}×{rh} 像素)。可保存模型或运行匹配。";
|
||||
_logger.Info("Template assistant: learned from ROI {0},{1},{2},{3}", rx, ry, rw, rh);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Template assistant ROI learn failed");
|
||||
StatusMessage = $"学习失败: {ex.Message}";
|
||||
IsModelReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteLoadModel()
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
{
|
||||
Title = "加载模板模型",
|
||||
Filter = "模板模型|*.tmmodel;*.tm|所有文件|*.*"
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_matcherLock)
|
||||
{
|
||||
_matcher?.Dispose();
|
||||
_matcher = new TemplateMatcherHandle();
|
||||
if (!_matcher.LoadModel(dlg.FileName))
|
||||
{
|
||||
_matcher.Dispose();
|
||||
_matcher = null;
|
||||
IsModelReady = false;
|
||||
StatusMessage = "模型加载失败。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
HasPendingTemplateRoi = false;
|
||||
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
|
||||
|
||||
IsModelReady = true;
|
||||
StatusMessage = $"已加载模型: {Path.GetFileName(dlg.FileName)}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "LoadModel failed");
|
||||
StatusMessage = $"加载失败: {ex.Message}";
|
||||
IsModelReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteSaveModel()
|
||||
{
|
||||
if (_matcher == null || !IsModelReady) return;
|
||||
|
||||
var dlg = new SaveFileDialog
|
||||
{
|
||||
Title = "保存模板模型",
|
||||
Filter = "模板模型|*.tmmodel|所有文件|*.*",
|
||||
DefaultExt = ".tmmodel"
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
bool ok;
|
||||
lock (_matcherLock)
|
||||
ok = _matcher != null && _matcher.SaveModel(dlg.FileName);
|
||||
if (ok)
|
||||
StatusMessage = $"模型已保存: {dlg.FileName}";
|
||||
else
|
||||
StatusMessage = "模型保存失败。";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "SaveModel failed");
|
||||
StatusMessage = $"保存失败: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteRunMatch()
|
||||
{
|
||||
if (_matcher == null || !IsModelReady) return;
|
||||
|
||||
ClearTemplateLearningRoiOnViewport();
|
||||
|
||||
try
|
||||
{
|
||||
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
|
||||
var imageSource = viewportVm?.ImageSource as BitmapSource;
|
||||
if (imageSource == null)
|
||||
{
|
||||
StatusMessage = "当前无主视图图像。";
|
||||
return;
|
||||
}
|
||||
|
||||
using Image<Gray, byte>? full = BitmapSourceToGrayImage(imageSource);
|
||||
if (full == null) return;
|
||||
|
||||
bool forcedSubPixelOff = UseSubPixel &&
|
||||
Math.Abs(ToleranceAngle) > TM_Params.SubPixelAngleSafetyLimitDegrees;
|
||||
int templatePixels = 0;
|
||||
lock (_matcherLock)
|
||||
{
|
||||
if (_matcher != null && _matcher.GetTemplateInfo(out int tw, out int th, out _))
|
||||
templatePixels = Math.Max(0, tw) * Math.Max(0, th);
|
||||
}
|
||||
|
||||
bool bumpedMinReduce = templatePixels >= 512 &&
|
||||
(int)Math.Clamp(Math.Round(MinReduceArea), 64, 4096) < 256;
|
||||
|
||||
if (!TryMatchGrayImage(full, out IReadOnlyList<TemplateMatchHitDto> hitsReadonly, out double t, out string? matchErr))
|
||||
{
|
||||
if (matchErr != null && matchErr.Contains("TemplateMatchLib", StringComparison.OrdinalIgnoreCase))
|
||||
StatusMessage = "未找到 TemplateMatchLib.dll,请确认已复制到输出目录。";
|
||||
else
|
||||
StatusMessage = string.IsNullOrEmpty(matchErr) ? "匹配失败。" : matchErr;
|
||||
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(new TemplateMatchPreviewPayload());
|
||||
return;
|
||||
}
|
||||
|
||||
var hits = new List<TemplateMatchHitDto>(hitsReadonly);
|
||||
|
||||
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(
|
||||
new TemplateMatchPreviewPayload { Hits = hits, MatchTimeMs = t });
|
||||
|
||||
StatusMessage = hits.Count == 0
|
||||
? $"未找到匹配(耗时 {t:F1} ms)。可调低阈值或角度范围。"
|
||||
: $"匹配到 {hits.Count} 个目标,耗时 {t:F1} ms。";
|
||||
if (forcedSubPixelOff)
|
||||
StatusMessage += $" 已自动关闭亚像素(角度容差>{TM_Params.SubPixelAngleSafetyLimitDegrees:F0}° 时匹配库易崩溃)。";
|
||||
if (bumpedMinReduce)
|
||||
StatusMessage += " 已将金字塔最小面积提升至不低于 256(与库默认一致)。";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "RunMatch failed");
|
||||
StatusMessage = $"匹配失败: {ex.Message}";
|
||||
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(new TemplateMatchPreviewPayload());
|
||||
}
|
||||
}
|
||||
|
||||
private TM_Params BuildCurrentMatchParams() => new TM_Params
|
||||
{
|
||||
Score = MatchThreshold,
|
||||
ToleranceAngle = ToleranceAngle,
|
||||
MaxOverlap = MaxOverlap,
|
||||
MaxCount = (int)Math.Clamp(Math.Round(MaxMatchCount), 1, 100),
|
||||
MinReduceArea = (int)Math.Clamp(Math.Round(MinReduceArea), 64, 4096),
|
||||
UseSIMD = UseSimd ? 1 : 0,
|
||||
UseSubPixel = UseSubPixel ? 1 : 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 使用当前助手参数对灰度图做模板匹配(与单张「运行匹配」一致)。供批量测试在后台线程调用,内部已加锁。
|
||||
/// </summary>
|
||||
public bool TryMatchGrayImage(Image<Gray, byte> fullImage, out IReadOnlyList<TemplateMatchHitDto> hits,
|
||||
out double matchTimeMs, out string? errorMessage)
|
||||
{
|
||||
hits = Array.Empty<TemplateMatchHitDto>();
|
||||
matchTimeMs = 0;
|
||||
errorMessage = null;
|
||||
if (fullImage == null)
|
||||
{
|
||||
errorMessage = "图像为空";
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_matcherLock)
|
||||
{
|
||||
if (_matcher == null || !IsModelReady)
|
||||
{
|
||||
errorMessage = "模型未就绪";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var param = BuildCurrentMatchParams();
|
||||
var results = _matcher.Match(
|
||||
fullImage.Mat.DataPointer,
|
||||
fullImage.Width,
|
||||
fullImage.Height,
|
||||
(int)fullImage.Mat.Step,
|
||||
param);
|
||||
|
||||
matchTimeMs = _matcher.LastMatchTime;
|
||||
var list = new List<TemplateMatchHitDto>();
|
||||
foreach (var r in results)
|
||||
{
|
||||
list.Add(new TemplateMatchHitDto
|
||||
{
|
||||
CenterX = r.CenterX,
|
||||
CenterY = r.CenterY,
|
||||
Angle = r.Angle,
|
||||
Score = r.Score,
|
||||
LtX = r.LtX, LtY = r.LtY,
|
||||
RtX = r.RtX, RtY = r.RtY,
|
||||
RbX = r.RbX, RbY = r.RbY,
|
||||
LbX = r.LbX, LbY = r.LbY
|
||||
});
|
||||
}
|
||||
|
||||
hits = list;
|
||||
return true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
errorMessage = "TemplateMatchLib.dll 未找到";
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "TryMatchGrayImage failed");
|
||||
errorMessage = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static Image<Gray, byte>? BitmapSourceToGrayImage(BitmapSource bitmapSource)
|
||||
{
|
||||
BitmapSource source = bitmapSource.Format == PixelFormats.Gray8
|
||||
? bitmapSource
|
||||
: new FormatConvertedBitmap(bitmapSource, PixelFormats.Gray8, null, 0);
|
||||
|
||||
int width = source.PixelWidth;
|
||||
int height = source.PixelHeight;
|
||||
if (width < 1 || height < 1) return null;
|
||||
|
||||
int stride = width;
|
||||
var pixels = new byte[width * height];
|
||||
source.CopyPixels(pixels, stride, 0);
|
||||
|
||||
var image = new Image<Gray, byte>(width, height);
|
||||
for (int y = 0; y < height; y++)
|
||||
for (int x = 0; x < width; x++)
|
||||
image.Data[y, x, 0] = pixels[y * stride + x];
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_roiDrawnToken != null)
|
||||
{
|
||||
_eventAggregator.GetEvent<TemplateMatchRoiDrawnEvent>().Unsubscribe(_roiDrawnToken);
|
||||
_roiDrawnToken = null;
|
||||
}
|
||||
|
||||
_batch.Dispose();
|
||||
|
||||
lock (_matcherLock)
|
||||
{
|
||||
_matcher?.Dispose();
|
||||
_matcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Emgu.CV;
|
||||
using Emgu.CV.CvEnum;
|
||||
using Emgu.CV.Structure;
|
||||
using Microsoft.Win32;
|
||||
using Prism.Commands;
|
||||
using Prism.Events;
|
||||
using Prism.Mvvm;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
|
||||
namespace XplorePlane.ViewModels.ImageProcessing;
|
||||
|
||||
/// <summary>单张批量匹配结果行(绑定到 DataGrid)。</summary>
|
||||
public sealed class TemplateMatchBatchRow
|
||||
{
|
||||
public string FileName { get; init; } = "";
|
||||
public string FullPath { get; init; } = "";
|
||||
/// <summary>简要结果:命中 / 未找到 / 失败原因摘要。</summary>
|
||||
public string Result { get; init; } = "";
|
||||
public int MatchCount { get; init; }
|
||||
public double BestScore { get; init; }
|
||||
public double TimeMs { get; init; }
|
||||
public string? ErrorDetail { get; init; }
|
||||
public IReadOnlyList<TemplateMatchHitDto> Hits { get; init; } = Array.Empty<TemplateMatchHitDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板匹配批量测试:扫描文件夹、逐张匹配(与助手当前参数一致)、在主视口打开选中结果。
|
||||
/// </summary>
|
||||
public class TemplateMatchBatchViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TemplateMatchBatchViewModel>();
|
||||
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".bmp", ".png", ".jpg", ".jpeg", ".tif", ".tiff"
|
||||
};
|
||||
|
||||
private readonly TemplateMatchAssistantViewModel _assistant;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IMainViewportService _mainViewportService;
|
||||
private readonly List<string> _imagePaths = new();
|
||||
private CancellationTokenSource? _batchCts;
|
||||
private bool _disposed;
|
||||
private bool _isRunning;
|
||||
private string _folderPath = "";
|
||||
private string _batchStatusText = "请选择文件夹后点击「开始批量匹配」。";
|
||||
private TemplateMatchBatchRow? _selectedRow;
|
||||
private int _imageFileCount;
|
||||
|
||||
public TemplateMatchBatchViewModel(
|
||||
TemplateMatchAssistantViewModel assistant,
|
||||
IEventAggregator eventAggregator,
|
||||
IMainViewportService mainViewportService)
|
||||
{
|
||||
_assistant = assistant ?? throw new ArgumentNullException(nameof(assistant));
|
||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||
|
||||
_assistant.PropertyChanged += OnAssistantPropertyChanged;
|
||||
|
||||
PickFolderCommand = new DelegateCommand(ExecutePickFolder);
|
||||
StartBatchCommand = new DelegateCommand(ExecuteStartBatch, CanStartBatch);
|
||||
StopBatchCommand = new DelegateCommand(ExecuteStopBatch, () => _isRunning);
|
||||
OpenSelectedInMainViewportCommand = new DelegateCommand(ExecuteOpenSelectedInMainViewport, () => SelectedRow != null);
|
||||
}
|
||||
|
||||
private void OnAssistantPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(TemplateMatchAssistantViewModel.IsModelReady))
|
||||
StartBatchCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
public ObservableCollection<TemplateMatchBatchRow> Rows { get; } = new();
|
||||
|
||||
public DelegateCommand PickFolderCommand { get; }
|
||||
public DelegateCommand StartBatchCommand { get; }
|
||||
public DelegateCommand StopBatchCommand { get; }
|
||||
public DelegateCommand OpenSelectedInMainViewportCommand { get; }
|
||||
|
||||
public string FolderPath
|
||||
{
|
||||
get => _folderPath;
|
||||
private set => SetProperty(ref _folderPath, value);
|
||||
}
|
||||
|
||||
public string BatchStatusText
|
||||
{
|
||||
get => _batchStatusText;
|
||||
private set => SetProperty(ref _batchStatusText, value);
|
||||
}
|
||||
|
||||
public int ImageFileCount
|
||||
{
|
||||
get => _imageFileCount;
|
||||
private set => SetProperty(ref _imageFileCount, value);
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get => _isRunning;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isRunning, value))
|
||||
{
|
||||
StartBatchCommand.RaiseCanExecuteChanged();
|
||||
StopBatchCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public TemplateMatchBatchRow? SelectedRow
|
||||
{
|
||||
get => _selectedRow;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedRow, value))
|
||||
OpenSelectedInMainViewportCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>供宿主窗口 DataGrid 双击调用。</summary>
|
||||
public void OpenSelectedFromDoubleClick() => OpenSelectedInMainViewportCommand.Execute();
|
||||
|
||||
private bool CanStartBatch() =>
|
||||
!_isRunning && _imagePaths.Count > 0 && _assistant.IsModelReady;
|
||||
|
||||
private void ExecutePickFolder()
|
||||
{
|
||||
var dlg = new OpenFolderDialog
|
||||
{
|
||||
Title = "选择待测试图像所在文件夹(仅当前目录,不含子文件夹)",
|
||||
InitialDirectory = Directory.Exists(_folderPath) ? _folderPath : Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)
|
||||
};
|
||||
|
||||
if (dlg.ShowDialog() != true)
|
||||
return;
|
||||
|
||||
ScanFolder(dlg.FolderName);
|
||||
}
|
||||
|
||||
private void ScanFolder(string folder)
|
||||
{
|
||||
_imagePaths.Clear();
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
FolderPath = "";
|
||||
ImageFileCount = 0;
|
||||
BatchStatusText = "文件夹不存在。";
|
||||
StartBatchCommand.RaiseCanExecuteChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(folder, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (ImageExtensions.Contains(Path.GetExtension(path)))
|
||||
_imagePaths.Add(path);
|
||||
}
|
||||
|
||||
FolderPath = folder;
|
||||
ImageFileCount = _imagePaths.Count;
|
||||
BatchStatusText = ImageFileCount == 0
|
||||
? "该文件夹下没有支持的图像文件(bmp/png/jpg/tif…)。"
|
||||
: $"已扫描 {ImageFileCount} 个图像文件,可开始批量匹配。";
|
||||
StartBatchCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private async void ExecuteStartBatch()
|
||||
{
|
||||
if (!CanStartBatch()) return;
|
||||
|
||||
_assistant.ClearTemplateLearningRoiOnViewport();
|
||||
|
||||
Rows.Clear();
|
||||
IsRunning = true;
|
||||
_batchCts = new CancellationTokenSource();
|
||||
var token = _batchCts.Token;
|
||||
int total = _imagePaths.Count;
|
||||
int index = 0;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var path in _imagePaths)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
index++;
|
||||
BatchStatusText = $"正在处理 {index}/{total}:{Path.GetFileName(path)}";
|
||||
|
||||
var row = await Task.Run(() => ProcessOneFile(path), token).ConfigureAwait(true);
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
BatchStatusText = $"完成,共处理 {Rows.Count} 张。";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
BatchStatusText = $"已停止(已处理 {Rows.Count}/{total} 张)。";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Batch template match failed");
|
||||
BatchStatusText = $"批量过程异常:{ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRunning = false;
|
||||
_batchCts?.Dispose();
|
||||
_batchCts = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteStopBatch() => _batchCts?.Cancel();
|
||||
|
||||
private TemplateMatchBatchRow ProcessOneFile(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
try
|
||||
{
|
||||
using var mat = CvInvoke.Imread(path, ImreadModes.Grayscale);
|
||||
if (mat == null || mat.IsEmpty)
|
||||
{
|
||||
return new TemplateMatchBatchRow
|
||||
{
|
||||
FileName = fileName,
|
||||
FullPath = path,
|
||||
Result = "无法读取",
|
||||
ErrorDetail = "Imread 为空或失败"
|
||||
};
|
||||
}
|
||||
|
||||
using var gray = mat.ToImage<Gray, byte>();
|
||||
if (!_assistant.TryMatchGrayImage(gray, out var hits, out var t, out var err))
|
||||
{
|
||||
return new TemplateMatchBatchRow
|
||||
{
|
||||
FileName = fileName,
|
||||
FullPath = path,
|
||||
Result = "失败",
|
||||
ErrorDetail = err,
|
||||
TimeMs = t
|
||||
};
|
||||
}
|
||||
|
||||
int c = hits.Count;
|
||||
double best = c == 0 ? 0 : hits.Max(h => h.Score);
|
||||
return new TemplateMatchBatchRow
|
||||
{
|
||||
FileName = fileName,
|
||||
FullPath = path,
|
||||
Result = c > 0 ? "命中" : "未找到",
|
||||
MatchCount = c,
|
||||
BestScore = best,
|
||||
TimeMs = t,
|
||||
Hits = CloneHits(hits)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "ProcessOneFile: {Path}", path);
|
||||
return new TemplateMatchBatchRow
|
||||
{
|
||||
FileName = fileName,
|
||||
FullPath = path,
|
||||
Result = "异常",
|
||||
ErrorDetail = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TemplateMatchHitDto> CloneHits(IReadOnlyList<TemplateMatchHitDto> src)
|
||||
{
|
||||
var list = new List<TemplateMatchHitDto>(src.Count);
|
||||
foreach (var h in src)
|
||||
{
|
||||
list.Add(new TemplateMatchHitDto
|
||||
{
|
||||
CenterX = h.CenterX,
|
||||
CenterY = h.CenterY,
|
||||
Angle = h.Angle,
|
||||
Score = h.Score,
|
||||
LtX = h.LtX, LtY = h.LtY,
|
||||
RtX = h.RtX, RtY = h.RtY,
|
||||
RbX = h.RbX, RbY = h.RbY,
|
||||
LbX = h.LbX, LbY = h.LbY
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private void ExecuteOpenSelectedInMainViewport()
|
||||
{
|
||||
if (SelectedRow == null || string.IsNullOrWhiteSpace(SelectedRow.FullPath) || !File.Exists(SelectedRow.FullPath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.UriSource = new Uri(SelectedRow.FullPath, UriKind.Absolute);
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
|
||||
_mainViewportService.SetManualImage(bitmap, SelectedRow.FullPath);
|
||||
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
|
||||
.Publish(new ManualImageLoadedPayload(bitmap, SelectedRow.FullPath));
|
||||
|
||||
var hits = CloneHits(SelectedRow.Hits);
|
||||
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(
|
||||
new TemplateMatchPreviewPayload { Hits = hits, MatchTimeMs = SelectedRow.TimeMs });
|
||||
|
||||
BatchStatusText = $"已在主视图打开:{SelectedRow.FileName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "OpenSelectedInMainViewport");
|
||||
BatchStatusText = $"打开失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_assistant.PropertyChanged -= OnAssistantPropertyChanged;
|
||||
_batchCts?.Cancel();
|
||||
_batchCts?.Dispose();
|
||||
_batchCts = null;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ using XP.Common.PdfViewer.Interfaces;
|
||||
using XP.Hardware.MotionControl.Abstractions;
|
||||
using XP.Hardware.MotionControl.Services;
|
||||
using System.Windows.Threading;
|
||||
using XP.ImageProcessing.Processors;
|
||||
using XplorePlane.Services.Storage;
|
||||
using XplorePlane.ViewModels.Cnc;
|
||||
using XplorePlane.ViewModels.ImageProcessing;
|
||||
using XplorePlane.Views;
|
||||
using XplorePlane.Views.Cnc;
|
||||
using XplorePlane.Views.ImageProcessing;
|
||||
|
||||
namespace XplorePlane.ViewModels
|
||||
{
|
||||
@@ -180,9 +187,12 @@ namespace XplorePlane.ViewModels
|
||||
public DelegateCommand WhiteBackgroundDetectionCommand { get; }
|
||||
|
||||
public DelegateCommand BlackBackgroundDetectionCommand { get; }
|
||||
public DelegateCommand OpenTemplateMatchAssistantCommand { get; }
|
||||
public DelegateCommand GrayscaleCommand { get; }
|
||||
public DelegateCommand SharpenCommand { get; }
|
||||
public DelegateCommand EnhanceCommand { get; }
|
||||
public DelegateCommand EdgeLineFitCommand { get; }
|
||||
public DelegateCommand EdgeCircleFitCommand { get; }
|
||||
|
||||
// 设置命令
|
||||
public DelegateCommand OpenLanguageSwitcherCommand { get; }
|
||||
@@ -277,6 +287,7 @@ namespace XplorePlane.ViewModels
|
||||
private Window _reportConfigWindow;
|
||||
private Window _inspectionReportViewerWindow;
|
||||
private Window _debugPanelWindow;
|
||||
private Window _templateMatchAssistantWindow;
|
||||
private object _imagePanelContent;
|
||||
private GridLength _viewportPanelWidth = new(1, GridUnitType.Star);
|
||||
private GridLength _imagePanelWidth = new(320);
|
||||
@@ -332,6 +343,12 @@ namespace XplorePlane.ViewModels
|
||||
_eventAggregator.GetEvent<PipelinePreviewUpdatedEvent>()
|
||||
.Subscribe(OnPipelinePreviewUpdated, ThreadOption.UIThread);
|
||||
|
||||
_eventAggregator.GetEvent<WhiteBackgroundRoiDrawnEvent>()
|
||||
.Subscribe(OnWhiteBackgroundRoiDrawn, ThreadOption.UIThread);
|
||||
|
||||
_eventAggregator.GetEvent<BlackBackgroundRoiDrawnEvent>()
|
||||
.Subscribe(OnBlackBackgroundRoiDrawn, ThreadOption.UIThread);
|
||||
|
||||
NavigationTree = new ObservableCollection<object>();
|
||||
|
||||
NavigateHomeCommand = new DelegateCommand(OnNavigateHome);
|
||||
@@ -394,9 +411,12 @@ namespace XplorePlane.ViewModels
|
||||
// 图像处理命令
|
||||
WhiteBackgroundDetectionCommand = new DelegateCommand(ExecuteWhiteBackgroundDetection);
|
||||
BlackBackgroundDetectionCommand = new DelegateCommand(ExecuteBlackBackgroundDetection);
|
||||
OpenTemplateMatchAssistantCommand = new DelegateCommand(ExecuteOpenTemplateMatchAssistant);
|
||||
GrayscaleCommand = new DelegateCommand(ExecuteGrayscale);
|
||||
SharpenCommand = new DelegateCommand(ExecuteSharpen);
|
||||
EnhanceCommand = new DelegateCommand(ExecuteEnhance);
|
||||
EdgeLineFitCommand = new DelegateCommand(ExecuteEdgeLineFit);
|
||||
EdgeCircleFitCommand = new DelegateCommand(ExecuteEdgeCircleFit);
|
||||
|
||||
AxisResetCommand = new DelegateCommand(ExecuteAxisReset);
|
||||
OpenDoorCommand = new DelegateCommand(ExecuteOpenDoor);
|
||||
@@ -1045,6 +1065,8 @@ namespace XplorePlane.ViewModels
|
||||
}
|
||||
|
||||
private Window _bgaDetectionPanel;
|
||||
private Window _edgeLineFitPanel;
|
||||
private Window _edgeCircleFitPanel;
|
||||
|
||||
private void ExecuteBgaDetection()
|
||||
{
|
||||
@@ -1116,22 +1138,148 @@ namespace XplorePlane.ViewModels
|
||||
private void ExecuteWhiteBackgroundDetection()
|
||||
{
|
||||
if (!CheckImageLoaded()) return;
|
||||
_logger.Info("White background detection triggered.");
|
||||
// TODO: 实现白底检测逻辑
|
||||
_logger.Info("White background detection: entering ROI draw mode.");
|
||||
_eventAggregator.GetEvent<WhiteBackgroundDetectionEvent>().Publish();
|
||||
StatusMessage = "白底检测:请在图像上拖拽绘制矩形ROI";
|
||||
}
|
||||
|
||||
private void OnWhiteBackgroundRoiDrawn(System.Windows.Int32Rect roi) =>
|
||||
RunBackgroundRoiDetection(roi, BackgroundDefectMode.WhiteBackground);
|
||||
|
||||
private void ExecuteBlackBackgroundDetection()
|
||||
{
|
||||
if (!CheckImageLoaded()) return;
|
||||
_logger.Info("Black background detection triggered.");
|
||||
// TODO: 实现黑底检测逻辑
|
||||
_logger.Info("Black background detection: entering ROI draw mode.");
|
||||
_eventAggregator.GetEvent<BlackBackgroundDetectionEvent>().Publish();
|
||||
StatusMessage = "黑底检测:请在图像上拖拽绘制矩形ROI";
|
||||
}
|
||||
|
||||
private void ExecuteOpenTemplateMatchAssistant()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CheckImageLoaded())
|
||||
{
|
||||
StatusMessage = "请先加载图像再使用模板助手。";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_templateMatchAssistantWindow != null)
|
||||
{
|
||||
if (_templateMatchAssistantWindow.IsLoaded)
|
||||
{
|
||||
_templateMatchAssistantWindow.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
_templateMatchAssistantWindow = null;
|
||||
}
|
||||
|
||||
var vm = _containerProvider.Resolve<TemplateMatchAssistantViewModel>();
|
||||
var w = new TemplateMatchAssistantWindow
|
||||
{
|
||||
DataContext = vm,
|
||||
Owner = Application.Current?.MainWindow
|
||||
};
|
||||
w.Closed += (_, _) => { _templateMatchAssistantWindow = null; };
|
||||
_templateMatchAssistantWindow = w;
|
||||
w.Show();
|
||||
_logger.Info("Template match assistant opened.");
|
||||
StatusMessage = "已打开模板匹配助手";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to open template match assistant");
|
||||
StatusMessage = $"打开模板助手失败: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBlackBackgroundRoiDrawn(System.Windows.Int32Rect roi) =>
|
||||
RunBackgroundRoiDetection(roi, BackgroundDefectMode.BlackBackground);
|
||||
|
||||
/// <summary>
|
||||
/// 从视口灰度图取 ROI,调用 <see cref="BackgroundDefectAnalyzer"/>,再发布结果事件(全局坐标)。
|
||||
/// </summary>
|
||||
private void RunBackgroundRoiDetection(System.Windows.Int32Rect roi, BackgroundDefectMode mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
|
||||
var imageSource = viewportVm?.ImageSource as System.Windows.Media.Imaging.BitmapSource;
|
||||
if (imageSource == null) return;
|
||||
|
||||
System.Windows.Media.Imaging.BitmapSource gray8;
|
||||
if (imageSource.Format != System.Windows.Media.PixelFormats.Gray8)
|
||||
gray8 = new System.Windows.Media.Imaging.FormatConvertedBitmap(
|
||||
imageSource, System.Windows.Media.PixelFormats.Gray8, null, 0);
|
||||
else
|
||||
gray8 = imageSource;
|
||||
|
||||
int imgW = gray8.PixelWidth;
|
||||
int imgH = gray8.PixelHeight;
|
||||
|
||||
int rx = Math.Clamp(roi.X, 0, imgW - 1);
|
||||
int ry = Math.Clamp(roi.Y, 0, imgH - 1);
|
||||
int rw = Math.Clamp(roi.Width, 1, imgW - rx);
|
||||
int rh = Math.Clamp(roi.Height, 1, imgH - ry);
|
||||
|
||||
byte[] roiPixels = new byte[rw * rh];
|
||||
gray8.CopyPixels(new System.Windows.Int32Rect(rx, ry, rw, rh), roiPixels, rw, 0);
|
||||
|
||||
using var roiImage = new Image<Gray, byte>(rw, rh);
|
||||
for (int y = 0; y < rh; y++)
|
||||
for (int x = 0; x < rw; x++)
|
||||
roiImage.Data[y, x, 0] = roiPixels[y * rw + x];
|
||||
|
||||
const int minArea = 50;
|
||||
const double mmPerPixel = 0.139;
|
||||
var blobs = BackgroundDefectAnalyzer.DetectBlobs(roiImage, mode, minArea, mmPerPixel);
|
||||
|
||||
var detections = new System.Collections.Generic.List<BackgroundDefectDetectionItem>(blobs.Count);
|
||||
foreach (var b in blobs)
|
||||
{
|
||||
var item = new BackgroundDefectDetectionItem
|
||||
{
|
||||
SizeMicrometers = b.MaxChordMicrometers,
|
||||
ChordP1 = new System.Drawing.Point(b.MaxChordEndAInRoi.X + rx, b.MaxChordEndAInRoi.Y + ry),
|
||||
ChordP2 = new System.Drawing.Point(b.MaxChordEndBInRoi.X + rx, b.MaxChordEndBInRoi.Y + ry)
|
||||
};
|
||||
foreach (var p in b.ContourInRoi)
|
||||
item.Contour.Add(new System.Drawing.Point(p.X + rx, p.Y + ry));
|
||||
detections.Add(item);
|
||||
}
|
||||
|
||||
var roiRect = new System.Drawing.Rectangle(rx, ry, rw, rh);
|
||||
if (mode == BackgroundDefectMode.WhiteBackground)
|
||||
{
|
||||
_eventAggregator.GetEvent<WhiteBackgroundResultEvent>().Publish(
|
||||
new WhiteBackgroundResultPayload { RoiRect = roiRect, Detections = detections });
|
||||
StatusMessage = $"白底检测完成:检测到 {detections.Count} 个黑色区域";
|
||||
_logger.Info("White background detection: found {Count} dark regions in ROI ({X},{Y},{W},{H})",
|
||||
detections.Count, rx, ry, rw, rh);
|
||||
}
|
||||
else
|
||||
{
|
||||
_eventAggregator.GetEvent<BlackBackgroundResultEvent>().Publish(
|
||||
new BlackBackgroundResultPayload { RoiRect = roiRect, Detections = detections });
|
||||
StatusMessage = $"黑底检测完成:检测到 {detections.Count} 个亮色区域";
|
||||
_logger.Info("Black background detection: found {Count} bright regions in ROI ({X},{Y},{W},{H})",
|
||||
detections.Count, rx, ry, rw, rh);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string label = mode == BackgroundDefectMode.WhiteBackground ? "白底" : "黑底";
|
||||
_logger.Error(ex, "{Label} background detection failed", label);
|
||||
StatusMessage = $"{label}检测失败: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteGrayscale()
|
||||
{
|
||||
if (!CheckImageLoaded()) return;
|
||||
_logger.Info("Grayscale conversion triggered.");
|
||||
// TODO: 实现灰度转换逻辑
|
||||
_logger.Info("Line profile toggled.");
|
||||
_eventAggregator.GetEvent<ToggleLineProfileEvent>().Publish();
|
||||
}
|
||||
|
||||
private void ExecuteSharpen()
|
||||
@@ -1203,6 +1351,44 @@ namespace XplorePlane.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteEdgeLineFit()
|
||||
{
|
||||
if (!CheckImageLoaded()) return;
|
||||
_logger.Info("边缘查找拟合直线功能已触发");
|
||||
|
||||
if (_edgeLineFitPanel != null && _edgeLineFitPanel.IsVisible)
|
||||
{
|
||||
_edgeLineFitPanel.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
_edgeLineFitPanel = new Views.ImageProcessing.EdgeLineFitPanel
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow
|
||||
};
|
||||
_edgeLineFitPanel.Closed += (_, _) => { _edgeLineFitPanel = null; };
|
||||
_edgeLineFitPanel.Show();
|
||||
}
|
||||
|
||||
private void ExecuteEdgeCircleFit()
|
||||
{
|
||||
if (!CheckImageLoaded()) return;
|
||||
_logger.Info("边缘查找拟合圆功能已触发");
|
||||
|
||||
if (_edgeCircleFitPanel != null && _edgeCircleFitPanel.IsVisible)
|
||||
{
|
||||
_edgeCircleFitPanel.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
_edgeCircleFitPanel = new Views.ImageProcessing.EdgeCircleFitPanel
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow
|
||||
};
|
||||
_edgeCircleFitPanel.Closed += (_, _) => { _edgeCircleFitPanel = null; };
|
||||
_edgeCircleFitPanel.Show();
|
||||
}
|
||||
|
||||
private Image<Gray, byte>? BitmapSourceToImage(BitmapSource bitmapSource)
|
||||
{
|
||||
// 转换为可用的图像格式
|
||||
|
||||
@@ -55,9 +55,6 @@ namespace XplorePlane.ViewModels
|
||||
StopGrabCommand.RaiseCanExecuteChanged();
|
||||
ApplyExposureCommand.RaiseCanExecuteChanged();
|
||||
ApplyGainCommand.RaiseCanExecuteChanged();
|
||||
ApplyWidthCommand.RaiseCanExecuteChanged();
|
||||
ApplyHeightCommand.RaiseCanExecuteChanged();
|
||||
ApplyPixelFormatCommand.RaiseCanExecuteChanged();
|
||||
RefreshCameraParamsCommand.RaiseCanExecuteChanged();
|
||||
OpenCameraSettingsCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
@@ -79,7 +76,7 @@ namespace XplorePlane.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private string _cameraStatusText = "未连接";
|
||||
private string _cameraStatusText = "Disconnected";
|
||||
|
||||
public string CameraStatusText
|
||||
{
|
||||
@@ -152,8 +149,6 @@ namespace XplorePlane.ViewModels
|
||||
set => SetProperty(ref _selectedPixelFormat, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<string> PixelFormatOptions { get; } = new() { "Mono8", "BGR8", "BGRA8" };
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Commands
|
||||
@@ -164,9 +159,6 @@ namespace XplorePlane.ViewModels
|
||||
public DelegateCommand StopGrabCommand { get; }
|
||||
public DelegateCommand ApplyExposureCommand { get; }
|
||||
public DelegateCommand ApplyGainCommand { get; }
|
||||
public DelegateCommand ApplyWidthCommand { get; }
|
||||
public DelegateCommand ApplyHeightCommand { get; }
|
||||
public DelegateCommand ApplyPixelFormatCommand { get; }
|
||||
public DelegateCommand RefreshCameraParamsCommand { get; }
|
||||
public DelegateCommand OpenCameraSettingsCommand { get; }
|
||||
|
||||
@@ -183,9 +175,6 @@ namespace XplorePlane.ViewModels
|
||||
StopGrabCommand = new DelegateCommand(StopGrab, () => IsCameraGrabbing);
|
||||
ApplyExposureCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetExposureTime(ExposureTime)), () => IsCameraConnected);
|
||||
ApplyGainCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetGain(GainValue)), () => IsCameraConnected);
|
||||
ApplyWidthCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetWidth(ImageWidth)), () => IsCameraConnected);
|
||||
ApplyHeightCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetHeight(ImageHeight)), () => IsCameraConnected);
|
||||
ApplyPixelFormatCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetPixelFormat(SelectedPixelFormat)), () => IsCameraConnected);
|
||||
RefreshCameraParamsCommand = new DelegateCommand(RefreshCameraParams, () => IsCameraConnected);
|
||||
OpenCameraSettingsCommand = new DelegateCommand(OpenCameraSettings, () => IsCameraConnected);
|
||||
|
||||
@@ -193,7 +182,7 @@ namespace XplorePlane.ViewModels
|
||||
_defaultImageSource = new BitmapImage(new Uri("pack://application:,,,/Assets/Icons/NoCamera.png"));
|
||||
_cameraImageSource = _defaultImageSource;
|
||||
|
||||
CameraStatusText = "正在检索相机...";
|
||||
CameraStatusText = "Searching camera...";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,7 +192,7 @@ namespace XplorePlane.ViewModels
|
||||
{
|
||||
if (!_camera.IsConnected)
|
||||
{
|
||||
CameraStatusText = "未检测到相机";
|
||||
CameraStatusText = "No camera detected";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,7 +201,7 @@ namespace XplorePlane.ViewModels
|
||||
_camera.ConnectionLost += OnCameraConnectionLost;
|
||||
|
||||
IsCameraConnected = true;
|
||||
CameraStatusText = "已连接";
|
||||
CameraStatusText = "Connected";
|
||||
RefreshCameraParams();
|
||||
SyncCameraStateToAppState();
|
||||
StartGrab();
|
||||
@@ -231,7 +220,7 @@ namespace XplorePlane.ViewModels
|
||||
|
||||
var info = _camera.Open();
|
||||
IsCameraConnected = true;
|
||||
CameraStatusText = $"已连接: {info.ModelName} (SN: {info.SerialNumber})";
|
||||
CameraStatusText = $"Connected: {info.ModelName} (SN: {info.SerialNumber})";
|
||||
_logger.Information("Camera connected: {ModelName}", info.ModelName);
|
||||
RefreshCameraParams();
|
||||
SyncCameraStateToAppState();
|
||||
@@ -239,7 +228,7 @@ namespace XplorePlane.ViewModels
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to connect camera");
|
||||
CameraStatusText = $"连接失败: {ex.Message}";
|
||||
CameraStatusText = $"Connection failed: {ex.Message}";
|
||||
IsCameraConnected = false;
|
||||
SyncCameraStateToAppState();
|
||||
}
|
||||
@@ -263,7 +252,7 @@ namespace XplorePlane.ViewModels
|
||||
_camera.ConnectionLost -= OnCameraConnectionLost;
|
||||
IsCameraConnected = false;
|
||||
IsCameraGrabbing = false;
|
||||
CameraStatusText = "未连接";
|
||||
CameraStatusText = "Disconnected";
|
||||
CameraImageSource = null;
|
||||
SyncCameraStateToAppState();
|
||||
_logger.Information("Camera disconnected");
|
||||
@@ -276,7 +265,7 @@ namespace XplorePlane.ViewModels
|
||||
{
|
||||
_camera.StartGrabbing();
|
||||
IsCameraGrabbing = true;
|
||||
CameraStatusText = "采集中...";
|
||||
CameraStatusText = "Grabbing...";
|
||||
SyncCameraStateToAppState();
|
||||
|
||||
// 如果已勾选实时,自动启动 Live View
|
||||
@@ -288,7 +277,7 @@ namespace XplorePlane.ViewModels
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to start grabbing");
|
||||
CameraStatusText = $"采集失败: {ex.Message}";
|
||||
CameraStatusText = $"Grab failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +288,7 @@ namespace XplorePlane.ViewModels
|
||||
IsLiveViewEnabled = false;
|
||||
_camera.StopGrabbing();
|
||||
IsCameraGrabbing = false;
|
||||
CameraStatusText = "已停止采集";
|
||||
CameraStatusText = "Grab stopped";
|
||||
SyncCameraStateToAppState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -313,7 +302,7 @@ namespace XplorePlane.ViewModels
|
||||
if (!IsCameraGrabbing) return;
|
||||
|
||||
_liveViewRunning = true;
|
||||
CameraStatusText = "实时采集中...";
|
||||
CameraStatusText = "Live...";
|
||||
|
||||
try { _camera.ExecuteSoftwareTrigger(); }
|
||||
catch (Exception ex) { _logger.Error(ex, "Live view trigger failed"); }
|
||||
@@ -323,7 +312,7 @@ namespace XplorePlane.ViewModels
|
||||
{
|
||||
_liveViewRunning = false;
|
||||
if (IsCameraGrabbing)
|
||||
CameraStatusText = "采集中...";
|
||||
CameraStatusText = "Grabbing...";
|
||||
}
|
||||
|
||||
private void RefreshCameraParams()
|
||||
@@ -334,13 +323,16 @@ namespace XplorePlane.ViewModels
|
||||
GainValue = _camera.GetGain();
|
||||
ImageWidth = _camera.GetWidth();
|
||||
ImageHeight = _camera.GetHeight();
|
||||
SelectedPixelFormat = _camera.GetPixelFormat();
|
||||
|
||||
var currentFormat = _camera.GetPixelFormat();
|
||||
SelectedPixelFormat = currentFormat;
|
||||
|
||||
_logger.Information("Camera parameters refreshed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to read camera parameters");
|
||||
CameraStatusText = $"读取参数失败: {ex.Message}";
|
||||
CameraStatusText = $"Read params failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +346,7 @@ namespace XplorePlane.ViewModels
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to apply camera parameter");
|
||||
CameraStatusText = $"设置参数失败: {ex.Message}";
|
||||
CameraStatusText = $"Set param failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,17 +377,21 @@ namespace XplorePlane.ViewModels
|
||||
if (!_disposed)
|
||||
CameraImageSource = bitmap;
|
||||
});
|
||||
|
||||
if (_liveViewRunning)
|
||||
{
|
||||
_camera.ExecuteSoftwareTrigger();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_disposed)
|
||||
_logger.Error(ex, "Failed to process camera image");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 无论图像处理是否成功,都继续触发下一帧,保持采集链不断
|
||||
if (_liveViewRunning && !_disposed)
|
||||
{
|
||||
try { _camera.ExecuteSoftwareTrigger(); }
|
||||
catch { /* 忽略触发失败 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCameraGrabError(object? sender, GrabErrorEventArgs e)
|
||||
@@ -407,7 +403,7 @@ namespace XplorePlane.ViewModels
|
||||
app.Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
CameraStatusText = $"采集错误: {e.ErrorDescription}";
|
||||
CameraStatusText = $"Grab error: {e.ErrorDescription}";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -422,7 +418,7 @@ namespace XplorePlane.ViewModels
|
||||
if (_disposed) return;
|
||||
IsCameraConnected = false;
|
||||
IsCameraGrabbing = false;
|
||||
CameraStatusText = "连接已断开";
|
||||
CameraStatusText = "Connection lost";
|
||||
CameraImageSource = null;
|
||||
SyncCameraStateToAppState();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<Window
|
||||
x:Class="XplorePlane.Views.ImageProcessing.EdgeCircleFitPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="边缘查找拟合圆" Width="300" Height="600"
|
||||
ResizeMode="CanResize" WindowStartupLocation="CenterOwner"
|
||||
Topmost="True" ShowInTaskbar="False"
|
||||
Background="#F5F5F5" FontFamily="Microsoft YaHei UI">
|
||||
<Window.Resources>
|
||||
<Style x:Key="IconBtnStyle" TargetType="ButtonBase">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ButtonBase">
|
||||
<Border x:Name="Bd" Background="#FFFFFF" BorderBrush="#E0E0E0"
|
||||
BorderThickness="1" CornerRadius="6" Padding="8,6">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#EAF2FB" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#B0D4F1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="CardStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderBrush" Value="#E8E8E8" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style x:Key="ParamLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#555" />
|
||||
<Setter Property="Margin" Value="0,0,0,3" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<!-- 工具栏 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding DrawCircleCommand}" ToolTip="画圆(3点)" Margin="0,0,6,0">
|
||||
<TextBlock Text="⊙ 画圆" FontSize="11" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding FitCommand}" ToolTip="执行拟合" Margin="0,0,6,0">
|
||||
<TextBlock Text="▶ 拟合" FontSize="11" FontWeight="SemiBold" Foreground="#005FB8" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding ClearAllCommand}" ToolTip="清除全部">
|
||||
<TextBlock Text="✕ 清除全部" FontSize="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 卡尺参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="卡尺参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="卡尺数量" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding CaliperCount, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="3" Maximum="180" Value="{Binding CaliperCount}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="卡尺宽度 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding CaliperWidth, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="10" Maximum="200" Value="{Binding CaliperWidth}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 边缘检测参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="边缘检测参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="边缘极性" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedValue="{Binding EdgePolarity}" Margin="0,0,0,6">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Both</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">BrightToDark</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">DarkToBright</sys:String>
|
||||
</ComboBox>
|
||||
<TextBlock Text="搜索方向" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedValue="{Binding SearchDirection}" Margin="0,0,0,6">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Both</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Inward</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Outward</sys:String>
|
||||
</ComboBox>
|
||||
<TextBlock Text="边缘阈值" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding EdgeThreshold, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="1" Maximum="200" Value="{Binding EdgeThreshold}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="平滑 Sigma" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding Sigma, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.1" Maximum="5.0" Value="{Binding Sigma}" SmallChange="0.1" LargeChange="0.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 拟合参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="拟合参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="RANSAC 阈值 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding RansacThreshold, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.5" Maximum="20" Value="{Binding RansacThreshold}" SmallChange="0.5" LargeChange="2" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 结果 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="结果" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,6" />
|
||||
<TextBlock Text="{Binding ResultText}" FontSize="11.5" Foreground="#333" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Windows;
|
||||
using Prism.Ioc;
|
||||
using XP.ImageProcessing.RoiControl.Controls;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using XplorePlane.ViewModels.ImageProcessing;
|
||||
|
||||
namespace XplorePlane.Views.ImageProcessing
|
||||
{
|
||||
public partial class EdgeCircleFitPanel : Window
|
||||
{
|
||||
public EdgeCircleFitPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var viewportService = ContainerLocator.Current?.Resolve<IMainViewportService>();
|
||||
DataContext = new EdgeCircleFitViewModel(viewportService);
|
||||
|
||||
Loaded += (s, e) =>
|
||||
{
|
||||
var mainWin = Owner as MainWindow;
|
||||
if (mainWin != null)
|
||||
{
|
||||
var canvas = FindChild<PolygonRoiCanvas>(mainWin);
|
||||
if (DataContext is EdgeCircleFitViewModel vm)
|
||||
{
|
||||
vm.SetCanvas(canvas);
|
||||
vm.DrawCircleCommand.Execute();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Closed += (s, e) =>
|
||||
{
|
||||
if (DataContext is EdgeCircleFitViewModel vm)
|
||||
vm.OnPanelClosed();
|
||||
};
|
||||
}
|
||||
|
||||
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
|
||||
{
|
||||
int count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T t) return t;
|
||||
var result = FindChild<T>(child);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<Window
|
||||
x:Class="XplorePlane.Views.ImageProcessing.EdgeLineFitPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="边缘查找拟合直线" Width="300" Height="560"
|
||||
ResizeMode="CanResize" WindowStartupLocation="CenterOwner"
|
||||
Topmost="True" ShowInTaskbar="False"
|
||||
Background="#F5F5F5" FontFamily="Microsoft YaHei UI">
|
||||
<Window.Resources>
|
||||
<Style x:Key="IconBtnStyle" TargetType="ButtonBase">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ButtonBase">
|
||||
<Border x:Name="Bd" Background="#FFFFFF" BorderBrush="#E0E0E0"
|
||||
BorderThickness="1" CornerRadius="6" Padding="8,6">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#EAF2FB" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#B0D4F1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="CardStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderBrush" Value="#E8E8E8" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style x:Key="ParamLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#555" />
|
||||
<Setter Property="Margin" Value="0,0,0,3" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<!-- 工具栏 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding DrawCaliperCommand}" ToolTip="绘制卡尺" Margin="0,0,6,0">
|
||||
<TextBlock Text="✏ 画卡尺" FontSize="11" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding FitCommand}" ToolTip="执行拟合" Margin="0,0,6,0">
|
||||
<TextBlock Text="▶ 拟合" FontSize="11" FontWeight="SemiBold" Foreground="#005FB8" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding ClearAllCommand}" ToolTip="清除全部">
|
||||
<TextBlock Text="✕ 清除全部" FontSize="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 卡尺参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="卡尺参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="卡尺数量" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding CaliperCount, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="3" Maximum="100" Value="{Binding CaliperCount}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="卡尺宽度 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding DisplayWidth, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="10" Maximum="300" Value="{Binding DisplayWidth}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 边缘检测参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="边缘检测参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="边缘极性" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedValue="{Binding EdgePolarity}" Margin="0,0,0,6">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Both</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">BrightToDark</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">DarkToBright</sys:String>
|
||||
</ComboBox>
|
||||
<TextBlock Text="边缘阈值" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding EdgeThreshold, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="1" Maximum="200" Value="{Binding EdgeThreshold}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="平滑 Sigma" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding Sigma, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.1" Maximum="5.0" Value="{Binding Sigma}" SmallChange="0.1" LargeChange="0.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 拟合参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="拟合参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="RANSAC 阈值 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding RansacThreshold, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.5" Maximum="20" Value="{Binding RansacThreshold}" SmallChange="0.5" LargeChange="2" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 结果 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="结果" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,6" />
|
||||
<TextBlock Text="{Binding ResultText}" FontSize="11.5" Foreground="#333" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Windows;
|
||||
using Prism.Ioc;
|
||||
using XP.ImageProcessing.RoiControl.Controls;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using XplorePlane.ViewModels.ImageProcessing;
|
||||
|
||||
namespace XplorePlane.Views.ImageProcessing
|
||||
{
|
||||
public partial class EdgeLineFitPanel : Window
|
||||
{
|
||||
public EdgeLineFitPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var viewportService = ContainerLocator.Current?.Resolve<IMainViewportService>();
|
||||
DataContext = new EdgeLineFitViewModel(viewportService);
|
||||
|
||||
Loaded += (s, e) =>
|
||||
{
|
||||
var mainWin = Owner as MainWindow;
|
||||
if (mainWin != null)
|
||||
{
|
||||
var canvas = FindChild<PolygonRoiCanvas>(mainWin);
|
||||
if (DataContext is EdgeLineFitViewModel vm)
|
||||
{
|
||||
vm.SetCanvas(canvas);
|
||||
// 自动进入绘制模式
|
||||
vm.DrawCaliperCommand.Execute();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Closed += (s, e) =>
|
||||
{
|
||||
if (DataContext is EdgeLineFitViewModel vm)
|
||||
vm.OnPanelClosed();
|
||||
};
|
||||
}
|
||||
|
||||
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
|
||||
{
|
||||
int count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T t) return t;
|
||||
var result = FindChild<T>(child);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
<Window x:Class="XplorePlane.Views.ImageProcessing.TemplateMatchAssistantWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="模板匹配助手"
|
||||
Height="560"
|
||||
Width="560"
|
||||
FontFamily="Microsoft YaHei UI"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="CanResizeWithGrip">
|
||||
<Window.Resources>
|
||||
<Style x:Key="TmMdl2Glyph" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,0,0,10"
|
||||
FontSize="12" />
|
||||
|
||||
<TabControl Grid.Row="1">
|
||||
<TabItem Header="单张与参数">
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Orientation="Horizontal"
|
||||
Margin="0,0,0,8">
|
||||
<Button
|
||||
Margin="0,0,6,0"
|
||||
Padding="8,4"
|
||||
Command="{Binding SelectTemplateRoiCommand}"
|
||||
ToolTip="在主视图上拖拽框选模板区域">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="框选模板 ROI" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Margin="0,0,6,0"
|
||||
Padding="8,4"
|
||||
Command="{Binding LearnFromRoiCommand}"
|
||||
ToolTip="根据已框选的 ROI 学习模板">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="从 ROI 训练模板" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Margin="0,0,6,0"
|
||||
Padding="8,4"
|
||||
Command="{Binding LoadModelCommand}"
|
||||
ToolTip="从磁盘加载已保存的模板模型">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="加载模型" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Padding="8,4"
|
||||
Command="{Binding SaveModelCommand}"
|
||||
ToolTip="将当前模板保存为模型文件">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="保存模型" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="132" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="52" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Text="匹配阈值" />
|
||||
<Slider
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="6,6,6,6"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
SmallChange="0.01"
|
||||
LargeChange="0.05"
|
||||
TickFrequency="0.05"
|
||||
IsSnapToTickEnabled="False"
|
||||
AutoToolTipPlacement="TopLeft"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding MatchThreshold, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding MatchThreshold, StringFormat={}{0:F2}}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Text="角度容差 (°)" />
|
||||
<Slider
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="6,6,6,6"
|
||||
Minimum="0"
|
||||
Maximum="180"
|
||||
SmallChange="1"
|
||||
LargeChange="5"
|
||||
TickFrequency="5"
|
||||
IsSnapToTickEnabled="True"
|
||||
AutoToolTipPlacement="TopLeft"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding ToleranceAngle, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding ToleranceAngle, StringFormat={}{0:F0}}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Text="最大匹配数" />
|
||||
<Slider
|
||||
Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Margin="6,6,6,6"
|
||||
Minimum="1"
|
||||
Maximum="100"
|
||||
SmallChange="1"
|
||||
LargeChange="5"
|
||||
TickFrequency="1"
|
||||
IsSnapToTickEnabled="True"
|
||||
AutoToolTipPlacement="TopLeft"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding MaxMatchCount, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding MaxMatchCount, StringFormat={}{0:F0}}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="最大重叠" />
|
||||
<Slider
|
||||
Grid.Row="3"
|
||||
Grid.Column="1"
|
||||
Margin="6,6,6,6"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
SmallChange="0.05"
|
||||
LargeChange="0.1"
|
||||
TickFrequency="0.05"
|
||||
IsSnapToTickEnabled="True"
|
||||
AutoToolTipPlacement="TopLeft"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding MaxOverlap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding MaxOverlap, StringFormat={}{0:F2}}" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" VerticalAlignment="Center" TextWrapping="Wrap" Text="金字塔最小面积" />
|
||||
<Slider
|
||||
Grid.Row="4"
|
||||
Grid.Column="1"
|
||||
Margin="6,6,6,6"
|
||||
Minimum="64"
|
||||
Maximum="4096"
|
||||
SmallChange="32"
|
||||
LargeChange="128"
|
||||
TickFrequency="32"
|
||||
IsSnapToTickEnabled="True"
|
||||
AutoToolTipPlacement="TopLeft"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding MinReduceArea, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding MinReduceArea, StringFormat={}{0:F0}}" />
|
||||
|
||||
<CheckBox
|
||||
Grid.Row="5"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,8,0,0"
|
||||
Content="使用 SIMD"
|
||||
IsChecked="{Binding UseSimd}" />
|
||||
<CheckBox
|
||||
Grid.Row="6"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,4,0,0"
|
||||
Content="亚像素"
|
||||
IsChecked="{Binding UseSubPixel}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,10,0,0">
|
||||
<Button
|
||||
Padding="10,4"
|
||||
Command="{Binding RunMatchCommand}"
|
||||
ToolTip="对主视图当前图像执行模板匹配">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="运行匹配" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="批量测试">
|
||||
<Grid Margin="0,8,0,0" DataContext="{Binding Batch}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Text="{Binding BatchStatusText}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,0,0,8"
|
||||
FontSize="12" />
|
||||
<Grid Grid.Row="1" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<Button
|
||||
Padding="10,4"
|
||||
Margin="0,0,8,0"
|
||||
Command="{Binding PickFolderCommand}"
|
||||
ToolTip="选择包含待测图像的文件夹(仅当前层级)">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="选择文件夹…" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Padding="10,4"
|
||||
Margin="0,0,8,0"
|
||||
Command="{Binding StartBatchCommand}"
|
||||
ToolTip="对文件夹内图像依次执行模板匹配">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="开始批量匹配" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Padding="10,4"
|
||||
Margin="0,0,8,0"
|
||||
Command="{Binding StopBatchCommand}"
|
||||
ToolTip="停止当前批量任务">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="停止" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Padding="10,4"
|
||||
Command="{Binding OpenSelectedInMainViewportCommand}"
|
||||
ToolTip="将表格当前选中行对应图像在主视图中打开并显示匹配结果">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource TmMdl2Glyph}" Text="" />
|
||||
<TextBlock VerticalAlignment="Center" Text="在主视图打开所选" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="8,0,0,0">
|
||||
<TextBlock Foreground="Gray" FontSize="11" Text="{Binding ImageFileCount, StringFormat={}{0} 个图像文件}" />
|
||||
<TextBlock
|
||||
Foreground="Gray"
|
||||
FontSize="11"
|
||||
Text="{Binding FolderPath}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="220" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<DataGrid
|
||||
x:Name="BatchResultGrid"
|
||||
Grid.Row="2"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
SelectionMode="Single"
|
||||
ItemsSource="{Binding Rows}"
|
||||
SelectedItem="{Binding SelectedRow, Mode=TwoWay}"
|
||||
MouseDoubleClick="BatchResultGrid_OnMouseDoubleClick">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*" MinWidth="120" />
|
||||
<DataGridTextColumn Header="结果" Binding="{Binding Result}" Width="72" />
|
||||
<DataGridTextColumn Header="匹配数" Binding="{Binding MatchCount}" Width="64" />
|
||||
<DataGridTextColumn Header="最佳分数" Binding="{Binding BestScore, StringFormat=F3}" Width="80" />
|
||||
<DataGridTextColumn Header="耗时(ms)" Binding="{Binding TimeMs, StringFormat=F1}" Width="80" />
|
||||
<DataGridTextColumn Header="备注" Binding="{Binding ErrorDetail}" Width="2*" MinWidth="100" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Margin="0,8,0,0"
|
||||
FontSize="11"
|
||||
Foreground="Gray"
|
||||
TextWrapping="Wrap"
|
||||
Text="参数与「单张与参数」选项卡一致;仅扫描所选文件夹当前层级。双击一行可在主视图打开该图并显示匹配框。" />
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using XplorePlane.ViewModels.ImageProcessing;
|
||||
|
||||
namespace XplorePlane.Views.ImageProcessing;
|
||||
|
||||
public partial class TemplateMatchAssistantWindow : Window
|
||||
{
|
||||
public TemplateMatchAssistantWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void BatchResultGrid_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is DataGrid g && g.DataContext is TemplateMatchBatchViewModel vm)
|
||||
vm.OpenSelectedFromDoubleClick();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (DataContext is IDisposable d)
|
||||
d.Dispose();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/crosshair.png"
|
||||
Text="辅助线" />
|
||||
<telerik:RadRibbonToggleButton
|
||||
<telerik:RadRibbonToggleButton
|
||||
telerik:ScreenTip.Description="显示/隐藏图像比例尺"
|
||||
telerik:ScreenTip.Title="比例尺"
|
||||
IsChecked="{Binding IsScaleBarVisible, Mode=TwoWay}"
|
||||
@@ -537,9 +537,53 @@
|
||||
Text="坐标标定" />
|
||||
</StackPanel>
|
||||
</telerik:RadRibbonGroup>
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
|
||||
=======
|
||||
<telerik:RadRibbonGroup
|
||||
telerik:ScreenTip.Description="Show the Alignment tab of the Format Cells dialog box."
|
||||
telerik:ScreenTip.Title="Format Cells: Alignment"
|
||||
DialogLauncherCommand="{Binding Path=ShowFormatCellsDialog.Command}"
|
||||
DialogLauncherCommandParameter="Alignment"
|
||||
DialogLauncherVisibility="{Binding Path=ShowFormatCellsDialog.IsEnabled, Converter={StaticResource BoolToVisibilityValueConverter}}"
|
||||
Header="识别定位"
|
||||
IsEnabled="{Binding Path=AlignmentGroup.IsEnabled}">
|
||||
<telerik:RadRibbonGroup.Variants>
|
||||
<telerik:GroupVariant Priority="0" Variant="Large" />
|
||||
</telerik:RadRibbonGroup.Variants>
|
||||
<telerik:RadRibbonGroup.Resources>
|
||||
<spreadsheetControls:RadHorizontalAlignmentToBooleanConverter x:Key="horizontalAlignmentToBooleanConverter" />
|
||||
<spreadsheetControls:RadVerticalAlignmentToBooleanConverter x:Key="verticalAlignmentToBooleanConverter" />
|
||||
</telerik:RadRibbonGroup.Resources>
|
||||
<StackPanel />
|
||||
<StackPanel>
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Description="框选模板、调参并在当前图像上试跑旋转模板匹配"
|
||||
telerik:ScreenTip.Title="模板匹配助手"
|
||||
Command="{Binding OpenTemplateMatchAssistantCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/Matching.png"
|
||||
Text="模板助手" />
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Description="沿搜索线放置卡尺检测边缘点并拟合直线"
|
||||
telerik:ScreenTip.Title="拟合直线"
|
||||
Command="{Binding EdgeLineFitCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/FittedLine.png"
|
||||
Text="拟合直线" />
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Description="沿搜索线放置卡尺检测边缘点并拟合圆"
|
||||
telerik:ScreenTip.Title="拟合圆"
|
||||
Command="{Binding EdgeCircleFitCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/FittedCircle.png"
|
||||
Text="拟合圆" />
|
||||
</StackPanel>
|
||||
</telerik:RadRibbonGroup>
|
||||
|
||||
>>>>>>> TURBO-615-RecognitionAndPositioning
|
||||
<telerik:RadRibbonGroup Header="多语言">
|
||||
<telerik:RadRibbonGroup.Variants>
|
||||
<telerik:GroupVariant Priority="0" Variant="Large" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
@@ -75,6 +76,7 @@ namespace XplorePlane.Views
|
||||
"FillRate" => "填锡率",
|
||||
"BgaVoid" => "BGA空隙",
|
||||
"BubbleVoid" => "气泡空隙",
|
||||
"EdgeLineFit" => "直线拟合",
|
||||
_ => "点点距"
|
||||
};
|
||||
string valueText = args.MeasureType switch
|
||||
@@ -83,9 +85,16 @@ namespace XplorePlane.Views
|
||||
"FillRate" => $"{args.Distance:F1}%",
|
||||
"BgaVoid" => $"{args.Distance:F1}%",
|
||||
"BubbleVoid" => $"{args.Distance:F1}%",
|
||||
"EdgeLineFit" => "处理中...",
|
||||
_ => $"{args.Distance:F2} px"
|
||||
};
|
||||
SetStatus($"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量");
|
||||
|
||||
// 边缘查找拟合直线:获取搜索线后执行算子
|
||||
if (args.MeasureType == "EdgeLineFit")
|
||||
{
|
||||
ExecuteEdgeLineFitProcessor(args.P1, args.P2);
|
||||
}
|
||||
}
|
||||
};
|
||||
RoiCanvas.MeasureStatusChanged += (s, e) =>
|
||||
@@ -114,6 +123,7 @@ namespace XplorePlane.Views
|
||||
MeasurementToolMode.ThroughHoleFillRate => XP.ImageProcessing.RoiControl.Models.MeasureMode.FillRate,
|
||||
MeasurementToolMode.BgaVoid => XP.ImageProcessing.RoiControl.Models.MeasureMode.BgaVoid,
|
||||
MeasurementToolMode.BubbleMeasure => XP.ImageProcessing.RoiControl.Models.MeasureMode.BubbleMeasure,
|
||||
MeasurementToolMode.EdgeLineFit => XP.ImageProcessing.RoiControl.Models.MeasureMode.EdgeLineFit,
|
||||
_ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None
|
||||
};
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
@@ -134,8 +144,224 @@ namespace XplorePlane.Views
|
||||
var vm = GetMainVm();
|
||||
if (vm != null) vm.CursorInfoText = RoiCanvas.CursorInfo;
|
||||
});
|
||||
|
||||
// 行灰度分布
|
||||
try
|
||||
{
|
||||
var ea2 = ContainerLocator.Current?.Resolve<Prism.Events.IEventAggregator>();
|
||||
ea2?.GetEvent<ToggleLineProfileEvent>().Subscribe(() =>
|
||||
{
|
||||
ToggleLineProfile();
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
// 白底检测:进入ROI绘制模式
|
||||
ea2?.GetEvent<WhiteBackgroundDetectionEvent>().Subscribe(() =>
|
||||
{
|
||||
_bgDefectDrawing = false;
|
||||
_bgDefectRoiMode = BackgroundDefectRoiMode.WhiteBackground;
|
||||
RegisterBackgroundDefectRoiMouseHandlers();
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
// 黑底检测:进入ROI绘制模式
|
||||
ea2?.GetEvent<BlackBackgroundDetectionEvent>().Subscribe(() =>
|
||||
{
|
||||
_bgDefectDrawing = false;
|
||||
_bgDefectRoiMode = BackgroundDefectRoiMode.BlackBackground;
|
||||
RegisterBackgroundDefectRoiMouseHandlers();
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
// 白底检测:渲染结果(红色标识)
|
||||
ea2?.GetEvent<WhiteBackgroundResultEvent>().Subscribe(payload =>
|
||||
{
|
||||
RenderBackgroundDefectResult(payload.RoiRect, payload.Detections, isBlackBackground: false);
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
// 黑底检测:渲染结果(绿色标识)
|
||||
ea2?.GetEvent<BlackBackgroundResultEvent>().Subscribe(payload =>
|
||||
{
|
||||
RenderBackgroundDefectResult(payload.RoiRect, payload.Detections, isBlackBackground: true);
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
ea2?.GetEvent<TemplateMatchEnterRoiModeEvent>().Subscribe(() =>
|
||||
{
|
||||
_bgDefectDrawing = false;
|
||||
_bgDefectRoiMode = BackgroundDefectRoiMode.TemplateAssistant;
|
||||
RegisterBackgroundDefectRoiMouseHandlers();
|
||||
SetStatus("模板助手:请在图像上拖拽框选模板区域");
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
ea2?.GetEvent<TemplateMatchPreviewResultEvent>().Subscribe(payload =>
|
||||
{
|
||||
RenderTemplateMatchPreview(payload);
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
|
||||
ea2?.GetEvent<TemplateMatchClearRoiOverlayEvent>().Subscribe(() =>
|
||||
{
|
||||
RemoveTemplateAssistantPersistRoi();
|
||||
}, Prism.Events.ThreadOption.UIThread);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
#region 行灰度分布
|
||||
|
||||
private bool _lineProfileEnabled;
|
||||
private System.Windows.Shapes.Line _profileRefLine; // 透明命中区域
|
||||
private System.Windows.Shapes.Line _profileRefLineVisible; // 1px红线显示
|
||||
private System.Windows.Shapes.Polyline _profileCurve;
|
||||
private double _profileLineY;
|
||||
private bool _profileDragging;
|
||||
|
||||
private void ToggleLineProfile()
|
||||
{
|
||||
_lineProfileEnabled = !_lineProfileEnabled;
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
if (_lineProfileEnabled)
|
||||
{
|
||||
// 参考线默认在图像中间
|
||||
_profileLineY = RoiCanvas.CanvasHeight / 2;
|
||||
|
||||
// 创建参考线(红色水平线,可拖动)
|
||||
// 用透明粗线作为命中区域,叠加1px红线显示
|
||||
_profileRefLine = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = 0,
|
||||
Y1 = _profileLineY,
|
||||
X2 = RoiCanvas.CanvasWidth,
|
||||
Y2 = _profileLineY,
|
||||
Stroke = System.Windows.Media.Brushes.Transparent,
|
||||
StrokeThickness = 7, // 上下3px命中区域
|
||||
IsHitTestVisible = true,
|
||||
Cursor = System.Windows.Input.Cursors.SizeNS
|
||||
};
|
||||
_profileRefLineVisible = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = 0,
|
||||
Y1 = _profileLineY,
|
||||
X2 = RoiCanvas.CanvasWidth,
|
||||
Y2 = _profileLineY,
|
||||
Stroke = System.Windows.Media.Brushes.Red,
|
||||
StrokeThickness = 1,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
_profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown;
|
||||
_profileRefLine.MouseMove += ProfileLine_MouseMove;
|
||||
_profileRefLine.MouseLeftButtonUp += ProfileLine_MouseUp;
|
||||
canvas.Children.Add(_profileRefLineVisible);
|
||||
canvas.Children.Add(_profileRefLine);
|
||||
|
||||
// 创建灰度折线(固定显示在图像中间位置)
|
||||
_profileCurve = new System.Windows.Shapes.Polyline
|
||||
{
|
||||
Stroke = System.Windows.Media.Brushes.Red,
|
||||
StrokeThickness = 1,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
canvas.Children.Add(_profileCurve);
|
||||
|
||||
UpdateLineProfile();
|
||||
SetStatus("行灰度分布:拖动红线改变采样行,再次点击按钮关闭");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_profileRefLine != null)
|
||||
{
|
||||
canvas.Children.Remove(_profileRefLine);
|
||||
_profileRefLine = null;
|
||||
}
|
||||
if (_profileRefLineVisible != null)
|
||||
{
|
||||
canvas.Children.Remove(_profileRefLineVisible);
|
||||
_profileRefLineVisible = null;
|
||||
}
|
||||
if (_profileCurve != null)
|
||||
{
|
||||
canvas.Children.Remove(_profileCurve);
|
||||
_profileCurve = null;
|
||||
}
|
||||
SetStatus("行灰度分布已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProfileLine_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
_profileDragging = true;
|
||||
_profileRefLine?.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ProfileLine_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (!_profileDragging || _profileRefLine == null) return;
|
||||
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
var pos = e.GetPosition(canvas);
|
||||
_profileLineY = Math.Clamp(pos.Y, 0, RoiCanvas.CanvasHeight - 1);
|
||||
|
||||
_profileRefLine.Y1 = _profileLineY;
|
||||
_profileRefLine.Y2 = _profileLineY;
|
||||
_profileRefLineVisible.Y1 = _profileLineY;
|
||||
_profileRefLineVisible.Y2 = _profileLineY;
|
||||
|
||||
UpdateLineProfile();
|
||||
}
|
||||
|
||||
private void ProfileLine_MouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
_profileDragging = false;
|
||||
_profileRefLine?.ReleaseMouseCapture();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateLineProfile()
|
||||
{
|
||||
if (_profileCurve == null) return;
|
||||
|
||||
// 从当前显示图像获取像素数据
|
||||
var viewportVm = DataContext as ViewportPanelViewModel;
|
||||
var imageSource = viewportVm?.ImageSource as System.Windows.Media.Imaging.BitmapSource;
|
||||
if (imageSource == null) return;
|
||||
|
||||
int imgWidth = imageSource.PixelWidth;
|
||||
int imgHeight = imageSource.PixelHeight;
|
||||
int row = (int)Math.Clamp(_profileLineY, 0, imgHeight - 1);
|
||||
|
||||
// 转为 Gray8 获取行像素
|
||||
System.Windows.Media.Imaging.BitmapSource gray8;
|
||||
if (imageSource.Format != System.Windows.Media.PixelFormats.Gray8)
|
||||
gray8 = new System.Windows.Media.Imaging.FormatConvertedBitmap(
|
||||
imageSource, System.Windows.Media.PixelFormats.Gray8, null, 0);
|
||||
else
|
||||
gray8 = imageSource;
|
||||
|
||||
byte[] rowPixels = new byte[imgWidth];
|
||||
int stride = imgWidth;
|
||||
gray8.CopyPixels(new System.Windows.Int32Rect(0, row, imgWidth, 1), rowPixels, stride, 0);
|
||||
|
||||
// 构建折线点集:折线固定显示在图像垂直中间位置
|
||||
// 参考线位置决定采样哪一行,折线位置固定在画布中间
|
||||
double canvasH = RoiCanvas.CanvasHeight;
|
||||
double curveCenter = canvasH / 2.0; // 折线基线固定在图像中间
|
||||
double displayHeight = canvasH * 0.25; // 折线振幅为画布高度的25%
|
||||
|
||||
var points = new System.Windows.Media.PointCollection(imgWidth);
|
||||
for (int x = 0; x < imgWidth; x++)
|
||||
{
|
||||
double normalizedGray = rowPixels[x] / 255.0;
|
||||
double y = curveCenter - normalizedGray * displayHeight;
|
||||
points.Add(new System.Windows.Point(x, y));
|
||||
}
|
||||
_profileCurve.Points = points;
|
||||
|
||||
SetStatus($"行灰度分布 | Y={row} | 均值={rowPixels.Select(b => (double)b).Average():F1} | 最大={rowPixels.Max()} | 最小={rowPixels.Min()}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is INotifyPropertyChanged oldVm)
|
||||
@@ -162,7 +388,31 @@ namespace XplorePlane.Views
|
||||
RoiCanvas.SelectedROI = null;
|
||||
if (DataContext is ViewportPanelViewModel vm)
|
||||
vm.ResetMeasurementState();
|
||||
SetStatus("已清除所有测量");
|
||||
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas != null)
|
||||
{
|
||||
if (_bgDefectPreview != null)
|
||||
{
|
||||
canvas.Children.Remove(_bgDefectPreview);
|
||||
_bgDefectPreview = null;
|
||||
}
|
||||
ClearBackgroundDefectOverlays(canvas);
|
||||
ClearTemplateMatchOverlays(canvas);
|
||||
RemoveTemplateAssistantPersistRoi();
|
||||
}
|
||||
else
|
||||
{
|
||||
_bgDefectOverlays.Clear();
|
||||
_tmMatchOverlays.Clear();
|
||||
RemoveTemplateAssistantPersistRoi();
|
||||
}
|
||||
|
||||
_bgDefectDrawing = false;
|
||||
_bgDefectRoiMode = BackgroundDefectRoiMode.None;
|
||||
try { RoiCanvas.ReleaseMouseCapture(); } catch { /* 未捕获则无影响 */ }
|
||||
|
||||
SetStatus("已清除所有测量、白底/黑底检测、模板匹配试跑叠加及模板助手 ROI");
|
||||
}
|
||||
|
||||
private void SaveOriginalImage_Click(object sender, RoutedEventArgs e)
|
||||
@@ -215,6 +465,486 @@ namespace XplorePlane.Views
|
||||
|
||||
#endregion
|
||||
|
||||
#region 白底/黑底检测
|
||||
|
||||
private enum BackgroundDefectRoiMode
|
||||
{
|
||||
None,
|
||||
WhiteBackground,
|
||||
BlackBackground,
|
||||
TemplateAssistant
|
||||
}
|
||||
|
||||
private BackgroundDefectRoiMode _bgDefectRoiMode;
|
||||
private bool _bgDefectDrawing;
|
||||
private System.Windows.Point _bgDefectStart;
|
||||
private System.Windows.Shapes.Rectangle _bgDefectPreview;
|
||||
private readonly System.Collections.Generic.List<System.Windows.UIElement> _bgDefectOverlays = new();
|
||||
private readonly System.Collections.Generic.List<System.Windows.UIElement> _tmMatchOverlays = new();
|
||||
private System.Windows.Shapes.Rectangle _templateAssistantRoiPersist;
|
||||
private bool _bgDefectMouseHandlersRegistered;
|
||||
|
||||
private void RegisterBackgroundDefectRoiMouseHandlers()
|
||||
{
|
||||
if (_bgDefectMouseHandlersRegistered) return;
|
||||
RoiCanvas.PreviewMouseLeftButtonDown += OnMainCanvasPreviewMouseDown;
|
||||
RoiCanvas.PreviewMouseMove += OnMainCanvasPreviewMouseMove;
|
||||
RoiCanvas.PreviewMouseLeftButtonUp += OnMainCanvasPreviewMouseUp;
|
||||
_bgDefectMouseHandlersRegistered = true;
|
||||
}
|
||||
|
||||
// 需要在 mainCanvas 的 MouseDown/Move/Up 中处理
|
||||
// 由于 PolygonRoiCanvas 内部已经处理了鼠标事件,我们通过 PreviewMouse 事件来拦截
|
||||
|
||||
private void OnMainCanvasPreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (_bgDefectRoiMode == BackgroundDefectRoiMode.None || e.LeftButton != System.Windows.Input.MouseButtonState.Pressed) return;
|
||||
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
_bgDefectStart = e.GetPosition(canvas);
|
||||
_bgDefectDrawing = true;
|
||||
|
||||
// 创建预览矩形(不清除之前的检测结果)
|
||||
_bgDefectPreview = new System.Windows.Shapes.Rectangle
|
||||
{
|
||||
Stroke = System.Windows.Media.Brushes.Blue,
|
||||
StrokeThickness = 1,
|
||||
StrokeDashArray = new System.Windows.Media.DoubleCollection { 4, 2 }
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(_bgDefectPreview, _bgDefectStart.X);
|
||||
System.Windows.Controls.Canvas.SetTop(_bgDefectPreview, _bgDefectStart.Y);
|
||||
canvas.Children.Add(_bgDefectPreview);
|
||||
|
||||
RoiCanvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMainCanvasPreviewMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (!_bgDefectDrawing || _bgDefectPreview == null) return;
|
||||
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
var current = e.GetPosition(canvas);
|
||||
double x = Math.Min(_bgDefectStart.X, current.X);
|
||||
double y = Math.Min(_bgDefectStart.Y, current.Y);
|
||||
double w = Math.Abs(current.X - _bgDefectStart.X);
|
||||
double h = Math.Abs(current.Y - _bgDefectStart.Y);
|
||||
|
||||
System.Windows.Controls.Canvas.SetLeft(_bgDefectPreview, x);
|
||||
System.Windows.Controls.Canvas.SetTop(_bgDefectPreview, y);
|
||||
_bgDefectPreview.Width = Math.Max(1, w);
|
||||
_bgDefectPreview.Height = Math.Max(1, h);
|
||||
}
|
||||
|
||||
private void OnMainCanvasPreviewMouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_bgDefectDrawing) return;
|
||||
|
||||
_bgDefectDrawing = false;
|
||||
var completedMode = _bgDefectRoiMode;
|
||||
_bgDefectRoiMode = BackgroundDefectRoiMode.None;
|
||||
RoiCanvas.ReleaseMouseCapture();
|
||||
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
var end = e.GetPosition(canvas);
|
||||
int x = (int)Math.Min(_bgDefectStart.X, end.X);
|
||||
int y = (int)Math.Min(_bgDefectStart.Y, end.Y);
|
||||
int w = (int)Math.Abs(end.X - _bgDefectStart.X);
|
||||
int h = (int)Math.Abs(end.Y - _bgDefectStart.Y);
|
||||
|
||||
// 移除预览矩形
|
||||
if (_bgDefectPreview != null)
|
||||
{
|
||||
canvas.Children.Remove(_bgDefectPreview);
|
||||
_bgDefectPreview = null;
|
||||
}
|
||||
|
||||
if (w < 10 || h < 10) return; // 太小忽略
|
||||
|
||||
// 模板助手:在画布上保留 ROI 矩形(与试跑匹配叠加分开管理)
|
||||
if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
|
||||
{
|
||||
RemoveTemplateAssistantPersistRoi();
|
||||
_templateAssistantRoiPersist = new System.Windows.Shapes.Rectangle
|
||||
{
|
||||
Stroke = System.Windows.Media.Brushes.DeepSkyBlue,
|
||||
StrokeThickness = 1.5,
|
||||
StrokeDashArray = new System.Windows.Media.DoubleCollection { 4, 2 },
|
||||
Fill = System.Windows.Media.Brushes.Transparent,
|
||||
Width = Math.Max(1, w),
|
||||
Height = Math.Max(1, h),
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(_templateAssistantRoiPersist, x);
|
||||
System.Windows.Controls.Canvas.SetTop(_templateAssistantRoiPersist, y);
|
||||
canvas.Children.Add(_templateAssistantRoiPersist);
|
||||
}
|
||||
|
||||
// 发布ROI绘制完成事件
|
||||
try
|
||||
{
|
||||
var ea = ContainerLocator.Current?.Resolve<Prism.Events.IEventAggregator>();
|
||||
var rect = new System.Windows.Int32Rect(x, y, w, h);
|
||||
if (completedMode == BackgroundDefectRoiMode.WhiteBackground)
|
||||
ea?.GetEvent<WhiteBackgroundRoiDrawnEvent>().Publish(rect);
|
||||
else if (completedMode == BackgroundDefectRoiMode.BlackBackground)
|
||||
ea?.GetEvent<BlackBackgroundRoiDrawnEvent>().Publish(rect);
|
||||
else if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
|
||||
ea?.GetEvent<TemplateMatchRoiDrawnEvent>().Publish(rect);
|
||||
}
|
||||
catch { }
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ClearTemplateMatchOverlays(System.Windows.Controls.Canvas canvas)
|
||||
{
|
||||
if (canvas != null)
|
||||
{
|
||||
foreach (var el in _tmMatchOverlays)
|
||||
canvas.Children.Remove(el);
|
||||
}
|
||||
_tmMatchOverlays.Clear();
|
||||
}
|
||||
|
||||
private void RemoveTemplateAssistantPersistRoi()
|
||||
{
|
||||
if (_templateAssistantRoiPersist == null) return;
|
||||
var rect = _templateAssistantRoiPersist;
|
||||
_templateAssistantRoiPersist = null;
|
||||
if (VisualTreeHelper.GetParent(rect) is Panel p)
|
||||
p.Children.Remove(rect);
|
||||
}
|
||||
|
||||
private void RenderTemplateMatchPreview(TemplateMatchPreviewPayload payload)
|
||||
{
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
ClearTemplateMatchOverlays(canvas);
|
||||
if (payload?.Hits == null || payload.Hits.Count == 0)
|
||||
return;
|
||||
|
||||
var stroke = new SolidColorBrush(Color.FromRgb(255, 140, 0));
|
||||
stroke.Freeze();
|
||||
const int crossHalf = 8;
|
||||
|
||||
foreach (var h in payload.Hits)
|
||||
{
|
||||
var poly = new System.Windows.Shapes.Polygon
|
||||
{
|
||||
Stroke = stroke,
|
||||
StrokeThickness = 2,
|
||||
Fill = Brushes.Transparent,
|
||||
IsHitTestVisible = false,
|
||||
Points = new PointCollection
|
||||
{
|
||||
new System.Windows.Point(h.LtX, h.LtY),
|
||||
new System.Windows.Point(h.RtX, h.RtY),
|
||||
new System.Windows.Point(h.RbX, h.RbY),
|
||||
new System.Windows.Point(h.LbX, h.LbY)
|
||||
}
|
||||
};
|
||||
canvas.Children.Add(poly);
|
||||
_tmMatchOverlays.Add(poly);
|
||||
|
||||
var cx = h.CenterX;
|
||||
var cy = h.CenterY;
|
||||
var hLine = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = cx - crossHalf,
|
||||
Y1 = cy,
|
||||
X2 = cx + crossHalf,
|
||||
Y2 = cy,
|
||||
Stroke = stroke,
|
||||
StrokeThickness = 1.5,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
var vLine = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = cx,
|
||||
Y1 = cy - crossHalf,
|
||||
X2 = cx,
|
||||
Y2 = cy + crossHalf,
|
||||
Stroke = stroke,
|
||||
StrokeThickness = 1.5,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
canvas.Children.Add(hLine);
|
||||
canvas.Children.Add(vLine);
|
||||
_tmMatchOverlays.Add(hLine);
|
||||
_tmMatchOverlays.Add(vLine);
|
||||
|
||||
var tb = new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = $"{h.Score:F2}",
|
||||
Foreground = stroke,
|
||||
FontSize = 10,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(tb, cx + crossHalf + 2);
|
||||
System.Windows.Controls.Canvas.SetTop(tb, cy - 8);
|
||||
canvas.Children.Add(tb);
|
||||
_tmMatchOverlays.Add(tb);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderBackgroundDefectResult(
|
||||
System.Drawing.Rectangle roiRect,
|
||||
System.Collections.Generic.IReadOnlyList<BackgroundDefectDetectionItem> detections,
|
||||
bool isBlackBackground)
|
||||
{
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null || detections == null) return;
|
||||
|
||||
// 绘制ROI矩形(蓝色实线,两种模式一致)
|
||||
var roiShape = new System.Windows.Shapes.Rectangle
|
||||
{
|
||||
Stroke = System.Windows.Media.Brushes.Blue,
|
||||
StrokeThickness = 1,
|
||||
Width = roiRect.Width,
|
||||
Height = roiRect.Height,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(roiShape, roiRect.X);
|
||||
System.Windows.Controls.Canvas.SetTop(roiShape, roiRect.Y);
|
||||
canvas.Children.Add(roiShape);
|
||||
_bgDefectOverlays.Add(roiShape);
|
||||
|
||||
var defectBrush = isBlackBackground
|
||||
? System.Windows.Media.Brushes.LimeGreen
|
||||
: System.Windows.Media.Brushes.Red;
|
||||
|
||||
const int labelPadRightOfRoi = 4;
|
||||
const double labelLineHeight = 15;
|
||||
int validCount = detections.Count(d => d.Contour != null && d.Contour.Count >= 2);
|
||||
double roiMidY = roiRect.Y + roiRect.Height * 0.5;
|
||||
double labelLeft = roiRect.X + roiRect.Width + labelPadRightOfRoi;
|
||||
double labelStartY = roiMidY - validCount * labelLineHeight * 0.5;
|
||||
int labelRow = 0;
|
||||
|
||||
foreach (var d in detections)
|
||||
{
|
||||
if (d.Contour == null || d.Contour.Count < 2) continue;
|
||||
|
||||
var fig = new PathFigure
|
||||
{
|
||||
StartPoint = new System.Windows.Point(d.Contour[0].X, d.Contour[0].Y),
|
||||
IsClosed = true
|
||||
};
|
||||
if (d.Contour.Count > 1)
|
||||
{
|
||||
fig.Segments.Add(new PolyLineSegment(
|
||||
d.Contour.Skip(1).Select(p => new System.Windows.Point(p.X, p.Y)), true));
|
||||
}
|
||||
|
||||
var geom = new PathGeometry();
|
||||
geom.Figures.Add(fig);
|
||||
var contourPath = new System.Windows.Shapes.Path
|
||||
{
|
||||
Data = geom,
|
||||
Stroke = defectBrush,
|
||||
StrokeThickness = 1,
|
||||
Fill = System.Windows.Media.Brushes.Transparent,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
canvas.Children.Add(contourPath);
|
||||
_bgDefectOverlays.Add(contourPath);
|
||||
|
||||
var chordLine = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = d.ChordP1.X,
|
||||
Y1 = d.ChordP1.Y,
|
||||
X2 = d.ChordP2.X,
|
||||
Y2 = d.ChordP2.Y,
|
||||
Stroke = defectBrush,
|
||||
StrokeThickness = 1.5,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
canvas.Children.Add(chordLine);
|
||||
_bgDefectOverlays.Add(chordLine);
|
||||
|
||||
double um = d.SizeMicrometers;
|
||||
string label = um >= 1000 ? $"{um / 1000:F2} mm" : $"{um:F0} μm";
|
||||
|
||||
var text = new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = label,
|
||||
Foreground = defectBrush,
|
||||
FontSize = 11,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(text, labelLeft);
|
||||
System.Windows.Controls.Canvas.SetTop(text, labelStartY + labelRow * labelLineHeight);
|
||||
canvas.Children.Add(text);
|
||||
_bgDefectOverlays.Add(text);
|
||||
labelRow++;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearBackgroundDefectOverlays(System.Windows.Controls.Canvas canvas)
|
||||
{
|
||||
foreach (var el in _bgDefectOverlays)
|
||||
canvas.Children.Remove(el);
|
||||
_bgDefectOverlays.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 边缘查找拟合直线
|
||||
|
||||
private void ExecuteEdgeLineFitProcessor(Point startPoint, Point endPoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vm = GetMainVm();
|
||||
if (vm == null) return;
|
||||
|
||||
// 获取当前图像
|
||||
var viewportVm = ContainerLocator.Current?.Resolve<ViewportPanelViewModel>();
|
||||
var imageSource = viewportVm?.ImageSource as BitmapSource;
|
||||
if (imageSource == null)
|
||||
{
|
||||
SetStatus("直线拟合失败:无可用图像");
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换为 Emgu.CV Image
|
||||
BitmapSource source = imageSource;
|
||||
if (imageSource.Format != System.Windows.Media.PixelFormats.Gray8)
|
||||
source = new FormatConvertedBitmap(imageSource, System.Windows.Media.PixelFormats.Gray8, null, 0);
|
||||
|
||||
int width = source.PixelWidth;
|
||||
int height = source.PixelHeight;
|
||||
int stride = width;
|
||||
byte[] pixels = new byte[height * stride];
|
||||
source.CopyPixels(pixels, stride, 0);
|
||||
|
||||
using var inputImage = new Emgu.CV.Image<Emgu.CV.Structure.Gray, byte>(width, height);
|
||||
for (int y = 0; y < height; y++)
|
||||
for (int x = 0; x < width; x++)
|
||||
inputImage.Data[y, x, 0] = pixels[y * stride + x];
|
||||
|
||||
// 创建并配置算子
|
||||
var processor = new XP.ImageProcessing.Processors.EdgeLineFitProcessor();
|
||||
processor.SetParameter("StartX", (int)startPoint.X);
|
||||
processor.SetParameter("StartY", (int)startPoint.Y);
|
||||
processor.SetParameter("EndX", (int)endPoint.X);
|
||||
processor.SetParameter("EndY", (int)endPoint.Y);
|
||||
|
||||
// 执行处理
|
||||
var result = processor.Process(inputImage);
|
||||
|
||||
// 获取输出数据并在画布上绘制结果
|
||||
var outputData = processor.OutputData;
|
||||
if (outputData.ContainsKey("LineFitResult"))
|
||||
{
|
||||
var fitResult = outputData["LineFitResult"] as XP.ImageProcessing.Processors.LineFitResult;
|
||||
if (fitResult != null && fitResult.Success)
|
||||
{
|
||||
DrawEdgeLineFitResult(fitResult, outputData);
|
||||
SetStatus($"直线拟合完成: 角度={fitResult.AngleDegrees:F2}°, 内点={fitResult.Inliers.Count}/{fitResult.EdgePointCount}, 误差={fitResult.FitError:F3}px");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus("直线拟合失败:未找到足够的边缘点");
|
||||
}
|
||||
}
|
||||
|
||||
result.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"直线拟合异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private readonly System.Collections.Generic.List<System.Windows.UIElement> _elfOverlays = new();
|
||||
|
||||
private void DrawEdgeLineFitResult(
|
||||
XP.ImageProcessing.Processors.LineFitResult fitResult,
|
||||
System.Collections.Generic.Dictionary<string, object> outputData)
|
||||
{
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
|
||||
if (canvas == null) return;
|
||||
|
||||
// 清除之前的拟合结果
|
||||
foreach (var el in _elfOverlays)
|
||||
canvas.Children.Remove(el);
|
||||
_elfOverlays.Clear();
|
||||
|
||||
// 绘制拟合直线(绿色)
|
||||
var fitLine = new System.Windows.Shapes.Line
|
||||
{
|
||||
X1 = fitResult.Endpoint1.X,
|
||||
Y1 = fitResult.Endpoint1.Y,
|
||||
X2 = fitResult.Endpoint2.X,
|
||||
Y2 = fitResult.Endpoint2.Y,
|
||||
Stroke = System.Windows.Media.Brushes.Lime,
|
||||
StrokeThickness = 2,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
canvas.Children.Add(fitLine);
|
||||
_elfOverlays.Add(fitLine);
|
||||
|
||||
// 绘制内点(绿色小圆点)
|
||||
foreach (var pt in fitResult.Inliers)
|
||||
{
|
||||
var dot = new System.Windows.Shapes.Ellipse
|
||||
{
|
||||
Width = 6,
|
||||
Height = 6,
|
||||
Fill = System.Windows.Media.Brushes.Lime,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(dot, pt.X - 3);
|
||||
System.Windows.Controls.Canvas.SetTop(dot, pt.Y - 3);
|
||||
canvas.Children.Add(dot);
|
||||
_elfOverlays.Add(dot);
|
||||
}
|
||||
|
||||
// 绘制外点(红色小圆点)
|
||||
foreach (var pt in fitResult.Outliers)
|
||||
{
|
||||
var dot = new System.Windows.Shapes.Ellipse
|
||||
{
|
||||
Width = 6,
|
||||
Height = 6,
|
||||
Fill = System.Windows.Media.Brushes.Red,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(dot, pt.X - 3);
|
||||
System.Windows.Controls.Canvas.SetTop(dot, pt.Y - 3);
|
||||
canvas.Children.Add(dot);
|
||||
_elfOverlays.Add(dot);
|
||||
}
|
||||
|
||||
// 绘制角度标注
|
||||
var labelText = $"∠{fitResult.AngleDegrees:F2}° | Err:{fitResult.FitError:F2}px";
|
||||
var label = new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = labelText,
|
||||
Foreground = System.Windows.Media.Brushes.Yellow,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.Bold,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
double labelX = (fitResult.Endpoint1.X + fitResult.Endpoint2.X) / 2 + 5;
|
||||
double labelY = (fitResult.Endpoint1.Y + fitResult.Endpoint2.Y) / 2 - 20;
|
||||
System.Windows.Controls.Canvas.SetLeft(label, labelX);
|
||||
System.Windows.Controls.Canvas.SetTop(label, labelY);
|
||||
canvas.Children.Add(label);
|
||||
_elfOverlays.Add(label);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static T FindChildByName<T>(DependencyObject parent, string name) where T : FrameworkElement
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
|
||||
@@ -59,21 +59,11 @@
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="图像宽度 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<TextBox DockPanel.Dock="Right" Width="65"
|
||||
Text="{Binding ImageWidth, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="64" Maximum="8192" Value="{Binding ImageWidth}"
|
||||
SmallChange="1" LargeChange="100" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<TextBox Text="{Binding ImageWidth, Mode=OneWay}" IsReadOnly="True" Background="#F0F0F0"
|
||||
Height="28" FontSize="11.5" VerticalContentAlignment="Center" Margin="0,0,0,8" />
|
||||
<TextBlock Text="图像高度 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="65"
|
||||
Text="{Binding ImageHeight, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="64" Maximum="8192" Value="{Binding ImageHeight}"
|
||||
SmallChange="1" LargeChange="100" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<TextBox Text="{Binding ImageHeight, Mode=OneWay}" IsReadOnly="True" Background="#F0F0F0"
|
||||
Height="28" FontSize="11.5" VerticalContentAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -81,9 +71,9 @@
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="像素格式" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedItem="{Binding SelectedPixelFormat}"
|
||||
ItemsSource="{Binding PixelFormatOptions}"
|
||||
Height="28" FontSize="11.5" VerticalContentAlignment="Center" />
|
||||
<TextBox Text="{Binding SelectedPixelFormat, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#F0F0F0"
|
||||
Height="28" FontSize="11.5" VerticalContentAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ namespace XplorePlane.Views
|
||||
var type = dc.GetType();
|
||||
ExecuteCommand(type, dc, "ApplyExposureCommand");
|
||||
ExecuteCommand(type, dc, "ApplyGainCommand");
|
||||
ExecuteCommand(type, dc, "ApplyWidthCommand");
|
||||
ExecuteCommand(type, dc, "ApplyHeightCommand");
|
||||
ExecuteCommand(type, dc, "ApplyPixelFormatCommand");
|
||||
}
|
||||
|
||||
private static void ExecuteCommand(System.Type type, object dc, string cmdName)
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
<HintPath>Libs\Native\BR.AN.PviServices.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
|
||||
<!-- 海康威视相机 SDK (.NET Framework 4.0) -->
|
||||
<Reference Include="MvCameraControl.Net">
|
||||
<HintPath>..\ExternalLibraries\MvCameraControl.Net.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user