diff --git a/flake.nix b/flake.nix
index e37066a..16d7fcf 100644
--- a/flake.nix
+++ b/flake.nix
@@ -34,6 +34,7 @@
dotnetCorePackages.sdk_8_0
])
nuget
+ mono
# msbuild
omnisharp-roslyn
csharpier
diff --git a/server/server.csproj b/server/server.csproj
index 6c501a1..df81424 100644
--- a/server/server.csproj
+++ b/server/server.csproj
@@ -18,6 +18,7 @@
+
@@ -29,8 +30,6 @@
-
-
diff --git a/server/src/Services/HttpVideoStreamService.cs b/server/src/Services/HttpVideoStreamService.cs
index fbaa37b..0a08552 100644
--- a/server/src/Services/HttpVideoStreamService.cs
+++ b/server/src/Services/HttpVideoStreamService.cs
@@ -3,10 +3,7 @@ using System.Text;
using System.Collections.Concurrent;
using DotNext;
using DotNext.Threading;
-
-#if USB_CAMERA
-using OpenCvSharp;
-#endif
+using FlashCap;
namespace server.Services;
@@ -17,17 +14,17 @@ public class VideoStreamClient
public int FrameWidth { get; set; }
public int FrameHeight { get; set; }
public int FrameRate { get; set; }
- public Peripherals.CameraClient.Camera Camera { get; set; }
+ public AsyncLazy Camera { get; set; }
public CancellationTokenSource CTS { get; set; }
public readonly AsyncReaderWriterLock Lock = new();
public VideoStreamClient(
- string clientId, int width, int height, Peripherals.CameraClient.Camera camera)
+ string clientId, int width, int height, AsyncLazy camera)
{
ClientId = clientId;
FrameWidth = width;
FrameHeight = height;
- FrameRate = 0;
+ FrameRate = 30;
Camera = camera;
CTS = new CancellationTokenSource();
}
@@ -101,11 +98,25 @@ public class HttpVideoStreamService : BackgroundService
private readonly ConcurrentDictionary _clientDict = new();
// USB Camera 相关
-#if USB_CAMERA
- private VideoCapture? _usbCamera;
- private bool _usbCameraEnable = false;
- private readonly object _usbCameraLock = new object();
-#endif
+ private AsyncLazy _usbCamera = new(async token => await InitializeUsbCamera(token));
+
+ private static async Task InitializeUsbCamera(CancellationToken token)
+ {
+ try
+ {
+ var camera = new UsbCameraCapture();
+ var devices = camera.GetDevices();
+ for (int i = 0; i < devices.Count; i++)
+ logger.Info($"Device[{i}]: {devices[i].Name}");
+ await camera.StartAsync(1, 3840, 2160, 30);
+ return camera;
+ }
+ catch (Exception ex)
+ {
+ logger.Error(ex, "Failed to start USB camera");
+ throw;
+ }
+ }
private Optional TryGetClient(string boardId)
{
@@ -116,7 +127,7 @@ public class HttpVideoStreamService : BackgroundService
return null;
}
- private async Task GetOrCreateClientAsync(string boardId, int initWidth, int initHeight)
+ private Optional GetOrCreateClient(string boardId, int initWidth, int initHeight)
{
if (_clientDict.TryGetValue(boardId, out var client))
{
@@ -135,13 +146,17 @@ public class HttpVideoStreamService : BackgroundService
var board = boardRet.Value.Value;
- var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
- var ret = await camera.Init();
- if (!ret.IsSuccessful || !ret.Value)
+ var camera = new AsyncLazy(async (_) =>
{
- logger.Error("Camera Init Failed!");
- return null;
- }
+ var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
+ var ret = await camera.Init();
+ if (!ret.IsSuccessful || !ret.Value)
+ {
+ logger.Error("Camera Init Failed!");
+ throw new Exception("Camera Init Failed!");
+ }
+ return camera;
+ });
client = new VideoStreamClient(boardId, initWidth, initHeight, camera);
_clientDict[boardId] = client;
@@ -172,7 +187,8 @@ public class HttpVideoStreamService : BackgroundService
client.CTS.Cancel();
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
- await client.Camera.EnableHardwareTrans(false);
+ var camera = await client.Camera.WithCancellation(cancellationToken);
+ await camera.EnableHardwareTrans(false);
}
}
_clientDict.Clear();
@@ -217,40 +233,40 @@ public class HttpVideoStreamService : BackgroundService
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
- var boardId = context.Request.QueryString["board"];
- var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
- var height = int.TryParse(context.Request.QueryString["height"], out var h) ? h : 480;
-
+ var boardId = context.Request.QueryString["boardId"];
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing clientId");
return;
}
- var client = await GetOrCreateClientAsync(boardId, width, height);
- if (client == null)
+ var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
+ var height = int.TryParse(context.Request.QueryString["height"], out var h) ? h : 480;
+
+ var clientOpt = GetOrCreateClient(boardId, width, height);
+ if (!clientOpt.HasValue)
{
await SendErrorAsync(context.Response, "Invalid clientId or camera not available");
return;
}
+ var client = clientOpt.Value;
+
var clientToken = client.CTS.Token;
try
{
logger.Info("新HTTP客户端连接: {RemoteEndPoint}", context.Request.RemoteEndPoint);
- if (path == "/video-stream")
+ if (path == "/video")
{
// MJPEG 流请求(FPGA)
await HandleMjpegStreamAsync(context.Response, client, cancellationToken);
}
-#if USB_CAMERA
- else if (requestPath == "/usb-camera")
+ else if (path == "/usbCamera")
{
// USB Camera MJPEG流请求
- await HandleUsbCameraStreamAsync(response, cancellationToken);
+ await HandleUsbCameraStreamAsync(context.Response, client, cancellationToken);
}
-#endif
else if (path == "/snapshot")
{
// 单帧图像请求
@@ -281,24 +297,16 @@ public class HttpVideoStreamService : BackgroundService
}
// USB Camera MJPEG流处理
-#if USB_CAMERA
- private async Task HandleUsbCameraStreamAsync(HttpListenerResponse response, CancellationToken cancellationToken)
+ private async Task HandleUsbCameraStreamAsync(
+ HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
try
{
- lock (_usbCameraLock)
- {
- if (_usbCamera == null)
- {
- _usbCamera = new VideoCapture(1);
- _usbCamera.Fps = _frameRate;
- _usbCamera.FrameWidth = _frameWidth;
- _usbCamera.FrameHeight = _frameHeight;
- _usbCameraEnable = _usbCamera.IsOpened();
- }
- }
- if (!_usbCameraEnable || _usbCamera == null || !_usbCamera.IsOpened())
+ var camera = await _usbCamera.WithCancellation(cancellationToken);
+
+ if (!camera.IsCapturing)
{
+ logger.Error("USB Camera is not capturing");
response.StatusCode = 500;
await response.OutputStream.FlushAsync(cancellationToken);
response.Close();
@@ -310,61 +318,52 @@ public class HttpVideoStreamService : BackgroundService
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
- using (var mat = new Mat())
+ logger.Info("Start USB Camera MJPEG Stream");
+
+ while (true)
{
- while (!cancellationToken.IsCancellationRequested)
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var jpegData = camera.GetLatestFrame();
+ if (jpegData == null)
{
- bool grabbed;
- lock (_usbCameraLock)
- {
- grabbed = _usbCamera.Read(mat);
- }
- if (!grabbed || mat.Empty())
- {
- await Task.Delay(50, cancellationToken);
- continue;
- }
-
- // 编码为JPEG
- byte[]? jpegData = null;
- try
- {
- jpegData = mat.ToBytes(".jpg", new int[] { (int)ImwriteFlags.JpegQuality, 80 });
- }
- catch (Exception ex)
- {
- logger.Error(ex, "USB Camera帧编码JPEG失败");
- continue;
- }
- if (jpegData == null)
- continue;
-
- // MJPEG帧头
- var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegData.Length + "\r\n\r\n");
- await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
- await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
- await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken); // \r\n
- await response.OutputStream.FlushAsync(cancellationToken);
-
- await Task.Delay(1000 / _frameRate, cancellationToken);
+ logger.Warn("USB Camera MJPEG帧获取失败");
+ await Task.Delay(1000 / client.FrameRate, cancellationToken);
+ continue;
}
+
+ // MJPEG帧头
+ var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegData.Length + "\r\n\r\n");
+ await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
+ await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
+ await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken); // \r\n
+ await response.OutputStream.FlushAsync(cancellationToken);
+
+ await Task.Delay(1000 / client.FrameRate, cancellationToken);
+ logger.Info("USB Camera MJPEG帧发送成功");
}
}
+ catch (OperationCanceledException ex)
+ {
+ logger.Info(ex, "USB Camera MJPEG 串流取消");
+ }
catch (Exception ex)
{
logger.Error(ex, "USB Camera MJPEG流处理异常");
}
finally
{
+
try { response.Close(); } catch { }
}
}
-#endif
- private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
+ private async Task HandleSnapshotRequestAsync(
+ HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
// 读取 Camera 快照,返回 JPEG
- var frameResult = await client.Camera.ReadFrame();
+ var camera = await client.Camera.WithCancellation(cancellationToken);
+ var frameResult = await camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
response.StatusCode = 500;
@@ -386,16 +385,18 @@ public class HttpVideoStreamService : BackgroundService
response.Close();
}
- private async Task HandleMjpegStreamAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
+ private async Task HandleMjpegStreamAsync(
+ HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
response.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
+ var camera = await client.Camera.WithCancellation(cancellationToken);
while (!cancellationToken.IsCancellationRequested)
{
- var frameResult = await client.Camera.ReadFrame();
+ var frameResult = await camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null) continue;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
if (!jpegResult.IsSuccessful) continue;
@@ -508,7 +509,8 @@ public class HttpVideoStreamService : BackgroundService
{
// 从摄像头读取帧数据
var readStartTime = DateTime.UtcNow;
- var result = await client.Camera.ReadFrame();
+ var camera = await client.Camera.WithCancellation(cancellationToken);
+ var result = await camera.ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
@@ -568,7 +570,7 @@ public class HttpVideoStreamService : BackgroundService
using (await client.Lock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
- var currentCamera = client.Camera;
+ var currentCamera = await client.Camera.WithCancellation(cancellationToken);
if (currentCamera == null)
{
var message = $"获取摄像头失败";
@@ -621,7 +623,8 @@ public class HttpVideoStreamService : BackgroundService
using (await client.Lock.AcquireWriteLockAsync(
TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
- var result = await client.Camera.InitAutoFocus();
+ var camera = await client.Camera.WithCancellation(cancellationToken);
+ var result = await camera.InitAutoFocus();
if (result.IsSuccessful && result.Value)
{
@@ -655,7 +658,8 @@ public class HttpVideoStreamService : BackgroundService
logger.Info($"Board{boardId}开始执行摄像头自动对焦");
- var result = await client.Camera.PerformAutoFocus();
+ var camera = await client.Camera.WithCancellation(cancellationToken);
+ var result = await camera.PerformAutoFocus();
if (result.IsSuccessful && result.Value)
{
@@ -679,16 +683,18 @@ public class HttpVideoStreamService : BackgroundService
/// 配置摄像头连接参数
///
/// 板卡ID
+ /// 取消令牌
/// 配置是否成功
- public async Task ConfigureCameraAsync(string boardId)
+ public async Task ConfigureCameraAsync(string boardId, CancellationToken cancellationToken = default)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
+ var camera = await client.Camera.WithCancellation(cancellationToken);
- using (await client.Lock.AcquireWriteLockAsync())
+ using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
- var ret = await client.Camera.Init();
+ var ret = await camera.Init();
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
@@ -702,9 +708,9 @@ public class HttpVideoStreamService : BackgroundService
}
}
- using (await client.Lock.AcquireWriteLockAsync())
+ using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
- var ret = await client.Camera.ChangeResolution(client.FrameWidth, client.FrameHeight);
+ var ret = await camera.ChangeResolution(client.FrameWidth, client.FrameHeight);
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
@@ -747,7 +753,7 @@ public class HttpVideoStreamService : BackgroundService
client.CTS.Cancel();
}
- var camera = client.Camera;
+ var camera = await client.Camera.WithCancellation(client.CTS.Token);
var disableResult = await camera.EnableHardwareTrans(enable);
if (disableResult.IsSuccessful && disableResult.Value)
logger.Info($"Successfully disabled camera {boardId} hardware transmission");
@@ -782,7 +788,7 @@ public class HttpVideoStreamService : BackgroundService
public VideoStreamEndpoint GetVideoEndpoint(string boardId)
{
- var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
+ var client = GetOrCreateClient(boardId, 640, 480).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
return new VideoStreamEndpoint
{
diff --git a/server/src/Services/UsbCameraCapture.cs b/server/src/Services/UsbCameraCapture.cs
new file mode 100644
index 0000000..85e1748
--- /dev/null
+++ b/server/src/Services/UsbCameraCapture.cs
@@ -0,0 +1,222 @@
+// using System.Drawing;
+using FlashCap;
+
+namespace server.Services;
+
+///
+/// Simple USB camera capture service following Linus principles:
+/// - Single responsibility: just capture frames
+/// - No special cases: uniform error handling
+/// - Good taste: clean data structures
+///
+public class UsbCameraCapture : IDisposable
+{
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private readonly CaptureDevices _captureDevices;
+ private CaptureDevice? _device;
+ private CaptureDeviceDescriptor? _descriptor;
+ private VideoCharacteristics? _characteristics;
+
+ // Single source of truth for latest frame - no redundant buffering
+ private volatile byte[]? _latestFrame;
+ private volatile bool _isCapturing;
+ private bool _disposed;
+
+ public event Action? FrameReady;
+ public event Action? Error;
+
+ public bool IsCapturing => _isCapturing;
+ public VideoCharacteristics? CurrentCharacteristics => _characteristics;
+ public CaptureDeviceDescriptor? CurrentDevice => _descriptor;
+
+ public UsbCameraCapture()
+ {
+ _captureDevices = new CaptureDevices();
+ }
+
+ ///
+ /// Get all available camera devices
+ ///
+ public IReadOnlyList GetDevices()
+ {
+ return _captureDevices.EnumerateDescriptors().ToArray();
+ }
+
+ ///
+ /// Start capturing from specified device with best matching characteristics
+ ///
+ public async Task StartAsync(int deviceIndex, int width = 640, int height = 480, int frameRate = 30)
+ {
+ var devices = GetDevices();
+ if (deviceIndex >= devices.Count)
+ throw new ArgumentOutOfRangeException(nameof(deviceIndex));
+
+ var descriptor = devices[deviceIndex];
+ var characteristics = FindBestMatch(descriptor, width, height, frameRate);
+
+ await StartAsync(descriptor, characteristics);
+ }
+
+ ///
+ /// Start capturing with exact device and characteristics
+ ///
+ public async Task StartAsync(CaptureDeviceDescriptor descriptor, VideoCharacteristics characteristics)
+ {
+ if (_isCapturing)
+ await StopAsync();
+
+ try
+ {
+ _descriptor = descriptor;
+ _characteristics = characteristics;
+ _device = await descriptor.OpenAsync(characteristics, OnFrameCaptured);
+
+ await _device.StartAsync();
+ _isCapturing = true;
+ logger.Debug("Started capturing");
+ }
+ catch (Exception ex)
+ {
+ await CleanupAsync();
+ Error?.Invoke(ex);
+ throw;
+ }
+ }
+
+ ///
+ /// Stop capturing and cleanup
+ ///
+ public async Task StopAsync()
+ {
+ if (!_isCapturing)
+ return;
+
+ _isCapturing = false;
+ await CleanupAsync();
+ }
+
+ ///
+ /// Get the latest captured frame (returns copy for thread safety)
+ ///
+ public byte[]? GetLatestFrame()
+ {
+ return _latestFrame;
+ }
+
+ // ///
+ // /// Get latest frame as bitmap
+ // ///
+ // public Bitmap? GetLatestFrameAsBitmap()
+ // {
+ // var frameData = _latestFrame;
+ // if (frameData == null)
+ // return null;
+
+ // try
+ // {
+ // using var ms = new MemoryStream(frameData);
+ // return new Bitmap(ms);
+ // }
+ // catch
+ // {
+ // return null;
+ // }
+ // }
+
+ ///
+ /// Get supported video characteristics for current device
+ ///
+ public IReadOnlyList GetSupportedCharacteristics()
+ {
+ return _descriptor?.Characteristics.ToArray() ?? Array.Empty();
+ }
+
+ private VideoCharacteristics FindBestMatch(CaptureDeviceDescriptor descriptor, int width, int height, int frameRate)
+ {
+ var characteristics = descriptor.Characteristics;
+
+ // Exact match first
+ var exact = characteristics.FirstOrDefault(c =>
+ c.Width == width && c.Height == height && Math.Abs(c.FramesPerSecond - frameRate) < 1);
+ if (exact != null)
+ return exact;
+
+ // Resolution match with best framerate
+ var resolution = characteristics
+ .Where(c => c.Width == width && c.Height == height)
+ .OrderByDescending(c => c.FramesPerSecond)
+ .FirstOrDefault();
+ if (resolution != null)
+ return resolution;
+
+ // Closest resolution
+ try
+ {
+ var closest = characteristics
+ .OrderBy(c => Math.Abs(c.Width - width) + Math.Abs(c.Height - height))
+ .ThenByDescending(c => c.FramesPerSecond)
+ .First();
+
+ return closest;
+ }
+ catch
+ {
+ for (int i = 0; i < characteristics.Length; i++)
+ logger.Error($"Characteristics[{i}]: {characteristics[i].Width}x{characteristics[i].Height} @ {characteristics[i].FramesPerSecond}fps");
+ throw;
+ }
+ }
+
+ private void OnFrameCaptured(PixelBufferScope bufferScope)
+ {
+ logger.Info("Frame captured");
+ if (!_isCapturing)
+ return;
+
+ try
+ {
+ // Simple: extract and store. No queues, no locks, no complexity.
+ var imageData = bufferScope.Buffer.CopyImage();
+ _latestFrame = imageData;
+ FrameReady?.Invoke(imageData);
+ }
+ catch (Exception ex)
+ {
+ Error?.Invoke(ex);
+ }
+ }
+
+ private async Task CleanupAsync()
+ {
+ try
+ {
+ if (_device != null)
+ {
+ await _device.StopAsync();
+ _device.Dispose();
+ _device = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Error?.Invoke(ex);
+ }
+ finally
+ {
+ _latestFrame = null;
+ _descriptor = null;
+ _characteristics = null;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ if (_isCapturing) StopAsync().Wait();
+
+ _device?.Dispose();
+ _disposed = true;
+ }
+}