From 283bf2a956ea979856bf0921d55e78a8c54e92ef Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Mon, 18 Aug 2025 15:15:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=82=E9=85=8Dusb=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=EF=BC=8C=E5=BD=93=E4=BC=BC=E4=B9=8E=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flake.nix | 1 + server/server.csproj | 3 +- server/src/Services/HttpVideoStreamService.cs | 198 ++++++++-------- server/src/Services/UsbCameraCapture.cs | 222 ++++++++++++++++++ 4 files changed, 326 insertions(+), 98 deletions(-) create mode 100644 server/src/Services/UsbCameraCapture.cs 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; + } +}