feat: 集成海康威视相机接口

- 新增 HikvisionCameraController 实现 ICameraController
- CameraFactory 支持 Basler/Hikvision 动态切换(config.json 配置)
- PixelConverter 支持 Bayer RG/GR/GB/BG 8-bit 解码
- 修复采集链断裂问题(finally 中触发下一帧)
- 相机设置面板:宽高和像素格式改为只读显示
- NavigationPropertyPanelViewModel 日志和状态文本改为英文
- 添加 MvCameraControl.Net.dll 到 ExternalLibraries
This commit is contained in:
李伟
2026-05-18 13:11:26 +08:00
parent 12938764b1
commit 843c4d67a6
12 changed files with 19524 additions and 73 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"Language": "zh-CN", "Language": "zh-CN",
"LogLevel": "Debug" "LogLevel": "Debug",
"CameraType": "Hikvision"
} }
+146 -1
View File
@@ -10,6 +10,7 @@ public static class PixelConverter
{ {
/// <summary> /// <summary>
/// 将原始像素数据转换为 WPF 的 BitmapSource 对象。 /// 将原始像素数据转换为 WPF 的 BitmapSource 对象。
/// 支持 Mono8、BGR8、RGB8、BGRA8 以及 Bayer 8-bit 格式(自动解码为 BGR24)。
/// 返回的 BitmapSource 已调用 Freeze(),可跨线程访问。 /// 返回的 BitmapSource 已调用 Freeze(),可跨线程访问。
/// </summary> /// </summary>
public static BitmapSource ToBitmapSource(byte[] pixelData, int width, int height, string pixelFormat) 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)); if (height <= 0) throw new ArgumentException("Height must be a positive integer.", nameof(height));
ArgumentNullException.ThrowIfNull(pixelFormat); 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), "Mono8" => (PixelFormats.Gray8, width),
"BGR8" => (PixelFormats.Bgr24, width * 3), "BGR8" => (PixelFormats.Bgr24, width * 3),
"BGRA8" => (PixelFormats.Bgra32, width * 4), "BGRA8" => (PixelFormats.Bgra32, width * 4),
"RGB8" => (PixelFormats.Rgb24, width * 3),
_ => throw new NotSupportedException($"Pixel format '{pixelFormat}' is not supported.") _ => throw new NotSupportedException($"Pixel format '{pixelFormat}' is not supported.")
}; };
@@ -31,4 +44,136 @@ public static class PixelConverter
bitmap.Freeze(); bitmap.Freeze();
return bitmap; 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;
}
} }
+2 -2
View File
@@ -11,8 +11,8 @@ public class CameraFactory : ICameraFactory
return cameraType switch return cameraType switch
{ {
"Basler" => new BaslerCameraController(), "Basler" => new BaslerCameraController(),
// "Hikvision" => new HikvisionCameraController(), "Hikvision" => new HikvisionCameraController(),
_ => throw new NotSupportedException($"不支持的相机品牌: {cameraType}") _ => 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
View File
@@ -7,12 +7,19 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>XP.Camera</RootNamespace> <RootNamespace>XP.Camera</RootNamespace>
<AssemblyName>XP.Camera</AssemblyName> <AssemblyName>XP.Camera</AssemblyName>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Reference Include="Basler.Pylon"> <Reference Include="Basler.Pylon">
<HintPath>..\ExternalLibraries\Basler.Pylon.dll</HintPath> <HintPath>..\ExternalLibraries\Basler.Pylon.dll</HintPath>
</Reference> </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" Version="4.10.0.5680" />
<PackageReference Include="Emgu.CV.Bitmap" Version="4.10.0.5680" /> <PackageReference Include="Emgu.CV.Bitmap" Version="4.10.0.5680" />
<PackageReference Include="Prism.DryIoc" Version="9.0.537" /> <PackageReference Include="Prism.DryIoc" Version="9.0.537" />
+34 -15
View File
@@ -321,7 +321,7 @@ namespace XplorePlane
shell.Loaded += (s, e) => shell.Loaded += (s, e) =>
{ {
// [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度 // [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度
// TryConnectCamera(); TryConnectCamera();
// 初始化主界面探测器帧流水线,开始接收探测器图像事件 // 初始化主界面探测器帧流水线,开始接收探测器图像事件
try try
@@ -334,20 +334,20 @@ namespace XplorePlane
} }
// [DEV] 相机状态通知已屏蔽 // [DEV] 相机状态通知已屏蔽
// try try
// { {
// var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>(); var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>();
// cameraVm.OnCameraReady(); cameraVm.OnCameraReady();
// } }
// catch (Exception ex) catch (Exception ex)
// { {
// Log.Error(ex, "通知相机 ViewModel 失败"); Log.Error(ex, "通知相机 ViewModel 失败");
// } }
// if (_cameraError != null) if (_cameraError != null)
// { {
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error); HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
// } }
}; };
return shell; return shell;
@@ -462,7 +462,26 @@ namespace XplorePlane
// ── 导航相机服务(单例)── // ── 导航相机服务(单例)──
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>(); containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
containerRegistry.RegisterSingleton<ICameraController>(() => 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, "读取 CameraType 配置失败,使用默认值 Hikvision");
}
Log.Information("相机类型: {CameraType}", cameraType);
return new CameraFactory().CreateController(cameraType);
});
containerRegistry.RegisterSingleton<ICameraService, CameraService>(); containerRegistry.RegisterSingleton<ICameraService, CameraService>();
Log.Information("依赖注入容器配置完成"); Log.Information("依赖注入容器配置完成");
@@ -55,9 +55,6 @@ namespace XplorePlane.ViewModels
StopGrabCommand.RaiseCanExecuteChanged(); StopGrabCommand.RaiseCanExecuteChanged();
ApplyExposureCommand.RaiseCanExecuteChanged(); ApplyExposureCommand.RaiseCanExecuteChanged();
ApplyGainCommand.RaiseCanExecuteChanged(); ApplyGainCommand.RaiseCanExecuteChanged();
ApplyWidthCommand.RaiseCanExecuteChanged();
ApplyHeightCommand.RaiseCanExecuteChanged();
ApplyPixelFormatCommand.RaiseCanExecuteChanged();
RefreshCameraParamsCommand.RaiseCanExecuteChanged(); RefreshCameraParamsCommand.RaiseCanExecuteChanged();
OpenCameraSettingsCommand.RaiseCanExecuteChanged(); OpenCameraSettingsCommand.RaiseCanExecuteChanged();
} }
@@ -79,7 +76,7 @@ namespace XplorePlane.ViewModels
} }
} }
private string _cameraStatusText = "未连接"; private string _cameraStatusText = "Disconnected";
public string CameraStatusText public string CameraStatusText
{ {
@@ -152,8 +149,6 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _selectedPixelFormat, value); set => SetProperty(ref _selectedPixelFormat, value);
} }
public ObservableCollection<string> PixelFormatOptions { get; } = new() { "Mono8", "BGR8", "BGRA8" };
#endregion Properties #endregion Properties
#region Commands #region Commands
@@ -164,9 +159,6 @@ namespace XplorePlane.ViewModels
public DelegateCommand StopGrabCommand { get; } public DelegateCommand StopGrabCommand { get; }
public DelegateCommand ApplyExposureCommand { get; } public DelegateCommand ApplyExposureCommand { get; }
public DelegateCommand ApplyGainCommand { get; } public DelegateCommand ApplyGainCommand { get; }
public DelegateCommand ApplyWidthCommand { get; }
public DelegateCommand ApplyHeightCommand { get; }
public DelegateCommand ApplyPixelFormatCommand { get; }
public DelegateCommand RefreshCameraParamsCommand { get; } public DelegateCommand RefreshCameraParamsCommand { get; }
public DelegateCommand OpenCameraSettingsCommand { get; } public DelegateCommand OpenCameraSettingsCommand { get; }
@@ -183,9 +175,6 @@ namespace XplorePlane.ViewModels
StopGrabCommand = new DelegateCommand(StopGrab, () => IsCameraGrabbing); StopGrabCommand = new DelegateCommand(StopGrab, () => IsCameraGrabbing);
ApplyExposureCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetExposureTime(ExposureTime)), () => IsCameraConnected); ApplyExposureCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetExposureTime(ExposureTime)), () => IsCameraConnected);
ApplyGainCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetGain(GainValue)), () => 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); RefreshCameraParamsCommand = new DelegateCommand(RefreshCameraParams, () => IsCameraConnected);
OpenCameraSettingsCommand = new DelegateCommand(OpenCameraSettings, () => IsCameraConnected); OpenCameraSettingsCommand = new DelegateCommand(OpenCameraSettings, () => IsCameraConnected);
@@ -193,7 +182,7 @@ namespace XplorePlane.ViewModels
_defaultImageSource = new BitmapImage(new Uri("pack://application:,,,/Assets/Icons/NoCamera.png")); _defaultImageSource = new BitmapImage(new Uri("pack://application:,,,/Assets/Icons/NoCamera.png"));
_cameraImageSource = _defaultImageSource; _cameraImageSource = _defaultImageSource;
CameraStatusText = "正在检索相机..."; CameraStatusText = "Searching camera...";
} }
/// <summary> /// <summary>
@@ -203,7 +192,7 @@ namespace XplorePlane.ViewModels
{ {
if (!_camera.IsConnected) if (!_camera.IsConnected)
{ {
CameraStatusText = "未检测到相机"; CameraStatusText = "No camera detected";
return; return;
} }
@@ -212,7 +201,7 @@ namespace XplorePlane.ViewModels
_camera.ConnectionLost += OnCameraConnectionLost; _camera.ConnectionLost += OnCameraConnectionLost;
IsCameraConnected = true; IsCameraConnected = true;
CameraStatusText = "已连接"; CameraStatusText = "Connected";
RefreshCameraParams(); RefreshCameraParams();
SyncCameraStateToAppState(); SyncCameraStateToAppState();
StartGrab(); StartGrab();
@@ -231,7 +220,7 @@ namespace XplorePlane.ViewModels
var info = _camera.Open(); var info = _camera.Open();
IsCameraConnected = true; IsCameraConnected = true;
CameraStatusText = $"已连接: {info.ModelName} (SN: {info.SerialNumber})"; CameraStatusText = $"Connected: {info.ModelName} (SN: {info.SerialNumber})";
_logger.Information("Camera connected: {ModelName}", info.ModelName); _logger.Information("Camera connected: {ModelName}", info.ModelName);
RefreshCameraParams(); RefreshCameraParams();
SyncCameraStateToAppState(); SyncCameraStateToAppState();
@@ -239,7 +228,7 @@ namespace XplorePlane.ViewModels
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, "Failed to connect camera"); _logger.Error(ex, "Failed to connect camera");
CameraStatusText = $"连接失败: {ex.Message}"; CameraStatusText = $"Connection failed: {ex.Message}";
IsCameraConnected = false; IsCameraConnected = false;
SyncCameraStateToAppState(); SyncCameraStateToAppState();
} }
@@ -263,7 +252,7 @@ namespace XplorePlane.ViewModels
_camera.ConnectionLost -= OnCameraConnectionLost; _camera.ConnectionLost -= OnCameraConnectionLost;
IsCameraConnected = false; IsCameraConnected = false;
IsCameraGrabbing = false; IsCameraGrabbing = false;
CameraStatusText = "未连接"; CameraStatusText = "Disconnected";
CameraImageSource = null; CameraImageSource = null;
SyncCameraStateToAppState(); SyncCameraStateToAppState();
_logger.Information("Camera disconnected"); _logger.Information("Camera disconnected");
@@ -276,7 +265,7 @@ namespace XplorePlane.ViewModels
{ {
_camera.StartGrabbing(); _camera.StartGrabbing();
IsCameraGrabbing = true; IsCameraGrabbing = true;
CameraStatusText = "采集中..."; CameraStatusText = "Grabbing...";
SyncCameraStateToAppState(); SyncCameraStateToAppState();
// 如果已勾选实时,自动启动 Live View // 如果已勾选实时,自动启动 Live View
@@ -288,7 +277,7 @@ namespace XplorePlane.ViewModels
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, "Failed to start grabbing"); _logger.Error(ex, "Failed to start grabbing");
CameraStatusText = $"采集失败: {ex.Message}"; CameraStatusText = $"Grab failed: {ex.Message}";
} }
} }
@@ -299,7 +288,7 @@ namespace XplorePlane.ViewModels
IsLiveViewEnabled = false; IsLiveViewEnabled = false;
_camera.StopGrabbing(); _camera.StopGrabbing();
IsCameraGrabbing = false; IsCameraGrabbing = false;
CameraStatusText = "已停止采集"; CameraStatusText = "Grab stopped";
SyncCameraStateToAppState(); SyncCameraStateToAppState();
} }
catch (Exception ex) catch (Exception ex)
@@ -313,7 +302,7 @@ namespace XplorePlane.ViewModels
if (!IsCameraGrabbing) return; if (!IsCameraGrabbing) return;
_liveViewRunning = true; _liveViewRunning = true;
CameraStatusText = "实时采集中..."; CameraStatusText = "Live...";
try { _camera.ExecuteSoftwareTrigger(); } try { _camera.ExecuteSoftwareTrigger(); }
catch (Exception ex) { _logger.Error(ex, "Live view trigger failed"); } catch (Exception ex) { _logger.Error(ex, "Live view trigger failed"); }
@@ -323,7 +312,7 @@ namespace XplorePlane.ViewModels
{ {
_liveViewRunning = false; _liveViewRunning = false;
if (IsCameraGrabbing) if (IsCameraGrabbing)
CameraStatusText = "采集中..."; CameraStatusText = "Grabbing...";
} }
private void RefreshCameraParams() private void RefreshCameraParams()
@@ -334,13 +323,16 @@ namespace XplorePlane.ViewModels
GainValue = _camera.GetGain(); GainValue = _camera.GetGain();
ImageWidth = _camera.GetWidth(); ImageWidth = _camera.GetWidth();
ImageHeight = _camera.GetHeight(); ImageHeight = _camera.GetHeight();
SelectedPixelFormat = _camera.GetPixelFormat();
var currentFormat = _camera.GetPixelFormat();
SelectedPixelFormat = currentFormat;
_logger.Information("Camera parameters refreshed"); _logger.Information("Camera parameters refreshed");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, "Failed to read camera parameters"); _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) catch (Exception ex)
{ {
_logger.Error(ex, "Failed to apply camera parameter"); _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) if (!_disposed)
CameraImageSource = bitmap; CameraImageSource = bitmap;
}); });
if (_liveViewRunning)
{
_camera.ExecuteSoftwareTrigger();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
if (!_disposed) if (!_disposed)
_logger.Error(ex, "Failed to process camera image"); _logger.Error(ex, "Failed to process camera image");
} }
finally
{
// 无论图像处理是否成功,都继续触发下一帧,保持采集链不断
if (_liveViewRunning && !_disposed)
{
try { _camera.ExecuteSoftwareTrigger(); }
catch { /* 忽略触发失败 */ }
}
}
} }
private void OnCameraGrabError(object? sender, GrabErrorEventArgs e) private void OnCameraGrabError(object? sender, GrabErrorEventArgs e)
@@ -407,7 +403,7 @@ namespace XplorePlane.ViewModels
app.Dispatcher.BeginInvoke(() => app.Dispatcher.BeginInvoke(() =>
{ {
if (!_disposed) if (!_disposed)
CameraStatusText = $"采集错误: {e.ErrorDescription}"; CameraStatusText = $"Grab error: {e.ErrorDescription}";
}); });
} }
@@ -422,7 +418,7 @@ namespace XplorePlane.ViewModels
if (_disposed) return; if (_disposed) return;
IsCameraConnected = false; IsCameraConnected = false;
IsCameraGrabbing = false; IsCameraGrabbing = false;
CameraStatusText = "连接已断开"; CameraStatusText = "Connection lost";
CameraImageSource = null; CameraImageSource = null;
SyncCameraStateToAppState(); SyncCameraStateToAppState();
}); });
@@ -59,21 +59,11 @@
<Border Style="{StaticResource CardStyle}"> <Border Style="{StaticResource CardStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="图像宽度 (px)" Style="{StaticResource ParamLabel}" /> <TextBlock Text="图像宽度 (px)" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,8"> <TextBox Text="{Binding ImageWidth, Mode=OneWay}" IsReadOnly="True" Background="#F0F0F0"
<TextBox DockPanel.Dock="Right" Width="65" Height="28" FontSize="11.5" VerticalContentAlignment="Center" Margin="0,0,0,8" />
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>
<TextBlock Text="图像高度 (px)" Style="{StaticResource ParamLabel}" /> <TextBlock Text="图像高度 (px)" Style="{StaticResource ParamLabel}" />
<DockPanel> <TextBox Text="{Binding ImageHeight, Mode=OneWay}" IsReadOnly="True" Background="#F0F0F0"
<TextBox DockPanel.Dock="Right" Width="65" Height="28" FontSize="11.5" VerticalContentAlignment="Center" />
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>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -81,9 +71,9 @@
<Border Style="{StaticResource CardStyle}"> <Border Style="{StaticResource CardStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="像素格式" Style="{StaticResource ParamLabel}" /> <TextBlock Text="像素格式" Style="{StaticResource ParamLabel}" />
<ComboBox SelectedItem="{Binding SelectedPixelFormat}" <TextBox Text="{Binding SelectedPixelFormat, Mode=OneWay}"
ItemsSource="{Binding PixelFormatOptions}" IsReadOnly="True" Background="#F0F0F0"
Height="28" FontSize="11.5" VerticalContentAlignment="Center" /> Height="28" FontSize="11.5" VerticalContentAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -18,9 +18,6 @@ namespace XplorePlane.Views
var type = dc.GetType(); var type = dc.GetType();
ExecuteCommand(type, dc, "ApplyExposureCommand"); ExecuteCommand(type, dc, "ApplyExposureCommand");
ExecuteCommand(type, dc, "ApplyGainCommand"); 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) private static void ExecuteCommand(System.Type type, object dc, string cmdName)
+6
View File
@@ -50,6 +50,12 @@
<Private>True</Private> <Private>True</Private>
</Reference> </Reference>
<!-- 海康威视相机 SDK (.NET Framework 4.0) -->
<Reference Include="MvCameraControl.Net">
<HintPath>..\ExternalLibraries\MvCameraControl.Net.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup> </ItemGroup>
<!-- Telerik UI for WPF --> <!-- Telerik UI for WPF -->