Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab
This commit is contained in:
commit
7265b10870
|
@ -34,6 +34,7 @@
|
||||||
dotnetCorePackages.sdk_8_0
|
dotnetCorePackages.sdk_8_0
|
||||||
])
|
])
|
||||||
nuget
|
nuget
|
||||||
|
mono
|
||||||
# msbuild
|
# msbuild
|
||||||
omnisharp-roslyn
|
omnisharp-roslyn
|
||||||
csharpier
|
csharpier
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<PackageReference Include="ArpLookup" Version="2.0.3" />
|
<PackageReference Include="ArpLookup" Version="2.0.3" />
|
||||||
<PackageReference Include="DotNext" Version="5.23.0" />
|
<PackageReference Include="DotNext" Version="5.23.0" />
|
||||||
<PackageReference Include="DotNext.Threading" Version="5.23.0" />
|
<PackageReference Include="DotNext.Threading" Version="5.23.0" />
|
||||||
|
<PackageReference Include="FlashCap" Version="1.11.0" />
|
||||||
<PackageReference Include="Honoo.IO.Hashing.Crc" Version="1.3.3" />
|
<PackageReference Include="Honoo.IO.Hashing.Crc" Version="1.3.3" />
|
||||||
<PackageReference Include="linq2db.AspNet" Version="5.4.1" />
|
<PackageReference Include="linq2db.AspNet" Version="5.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
|
||||||
|
@ -29,8 +30,6 @@
|
||||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.4.0" />
|
<PackageReference Include="NLog.Web.AspNetCore" Version="5.4.0" />
|
||||||
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0" />
|
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0" />
|
||||||
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.4.0" />
|
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.4.0" />
|
||||||
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
|
|
||||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
|
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||||
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
|
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
|
||||||
<PackageReference Include="Tapper.Analyzer" Version="1.13.1">
|
<PackageReference Include="Tapper.Analyzer" Version="1.13.1">
|
||||||
|
|
|
@ -3,10 +3,7 @@ using System.Text;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using DotNext;
|
using DotNext;
|
||||||
using DotNext.Threading;
|
using DotNext.Threading;
|
||||||
|
using FlashCap;
|
||||||
#if USB_CAMERA
|
|
||||||
using OpenCvSharp;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
namespace server.Services;
|
namespace server.Services;
|
||||||
|
|
||||||
|
@ -17,17 +14,17 @@ public class VideoStreamClient
|
||||||
public int FrameWidth { get; set; }
|
public int FrameWidth { get; set; }
|
||||||
public int FrameHeight { get; set; }
|
public int FrameHeight { get; set; }
|
||||||
public int FrameRate { get; set; }
|
public int FrameRate { get; set; }
|
||||||
public Peripherals.CameraClient.Camera Camera { get; set; }
|
public AsyncLazy<Peripherals.CameraClient.Camera> Camera { get; set; }
|
||||||
public CancellationTokenSource CTS { get; set; }
|
public CancellationTokenSource CTS { get; set; }
|
||||||
public readonly AsyncReaderWriterLock Lock = new();
|
public readonly AsyncReaderWriterLock Lock = new();
|
||||||
|
|
||||||
public VideoStreamClient(
|
public VideoStreamClient(
|
||||||
string clientId, int width, int height, Peripherals.CameraClient.Camera camera)
|
string clientId, int width, int height, AsyncLazy<Peripherals.CameraClient.Camera> camera)
|
||||||
{
|
{
|
||||||
ClientId = clientId;
|
ClientId = clientId;
|
||||||
FrameWidth = width;
|
FrameWidth = width;
|
||||||
FrameHeight = height;
|
FrameHeight = height;
|
||||||
FrameRate = 0;
|
FrameRate = 30;
|
||||||
Camera = camera;
|
Camera = camera;
|
||||||
CTS = new CancellationTokenSource();
|
CTS = new CancellationTokenSource();
|
||||||
}
|
}
|
||||||
|
@ -101,11 +98,25 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
private readonly ConcurrentDictionary<string, VideoStreamClient> _clientDict = new();
|
private readonly ConcurrentDictionary<string, VideoStreamClient> _clientDict = new();
|
||||||
|
|
||||||
// USB Camera 相关
|
// USB Camera 相关
|
||||||
#if USB_CAMERA
|
private AsyncLazy<UsbCameraCapture> _usbCamera = new(async token => await InitializeUsbCamera(token));
|
||||||
private VideoCapture? _usbCamera;
|
|
||||||
private bool _usbCameraEnable = false;
|
private static async Task<UsbCameraCapture> InitializeUsbCamera(CancellationToken token)
|
||||||
private readonly object _usbCameraLock = new object();
|
{
|
||||||
#endif
|
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<VideoStreamClient> TryGetClient(string boardId)
|
private Optional<VideoStreamClient> TryGetClient(string boardId)
|
||||||
{
|
{
|
||||||
|
@ -116,7 +127,7 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<VideoStreamClient?> GetOrCreateClientAsync(string boardId, int initWidth, int initHeight)
|
private Optional<VideoStreamClient> GetOrCreateClient(string boardId, int initWidth, int initHeight)
|
||||||
{
|
{
|
||||||
if (_clientDict.TryGetValue(boardId, out var client))
|
if (_clientDict.TryGetValue(boardId, out var client))
|
||||||
{
|
{
|
||||||
|
@ -135,13 +146,17 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
|
|
||||||
var board = boardRet.Value.Value;
|
var board = boardRet.Value.Value;
|
||||||
|
|
||||||
var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
|
var camera = new AsyncLazy<Peripherals.CameraClient.Camera>(async (_) =>
|
||||||
var ret = await camera.Init();
|
|
||||||
if (!ret.IsSuccessful || !ret.Value)
|
|
||||||
{
|
{
|
||||||
logger.Error("Camera Init Failed!");
|
var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
|
||||||
return null;
|
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);
|
client = new VideoStreamClient(boardId, initWidth, initHeight, camera);
|
||||||
_clientDict[boardId] = client;
|
_clientDict[boardId] = client;
|
||||||
|
@ -172,7 +187,8 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
client.CTS.Cancel();
|
client.CTS.Cancel();
|
||||||
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
|
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
await client.Camera.EnableHardwareTrans(false);
|
var camera = await client.Camera.WithCancellation(cancellationToken);
|
||||||
|
await camera.EnableHardwareTrans(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_clientDict.Clear();
|
_clientDict.Clear();
|
||||||
|
@ -217,40 +233,40 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = context.Request.Url?.AbsolutePath ?? "/";
|
var path = context.Request.Url?.AbsolutePath ?? "/";
|
||||||
var boardId = context.Request.QueryString["board"];
|
var boardId = context.Request.QueryString["boardId"];
|
||||||
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;
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(boardId))
|
if (string.IsNullOrEmpty(boardId))
|
||||||
{
|
{
|
||||||
await SendErrorAsync(context.Response, "Missing clientId");
|
await SendErrorAsync(context.Response, "Missing clientId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var client = await GetOrCreateClientAsync(boardId, width, height);
|
var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
|
||||||
if (client == null)
|
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");
|
await SendErrorAsync(context.Response, "Invalid clientId or camera not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var client = clientOpt.Value;
|
||||||
|
|
||||||
var clientToken = client.CTS.Token;
|
var clientToken = client.CTS.Token;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.Info("新HTTP客户端连接: {RemoteEndPoint}", context.Request.RemoteEndPoint);
|
logger.Info("新HTTP客户端连接: {RemoteEndPoint}", context.Request.RemoteEndPoint);
|
||||||
|
|
||||||
if (path == "/video-stream")
|
if (path == "/video")
|
||||||
{
|
{
|
||||||
// MJPEG 流请求(FPGA)
|
// MJPEG 流请求(FPGA)
|
||||||
await HandleMjpegStreamAsync(context.Response, client, cancellationToken);
|
await HandleMjpegStreamAsync(context.Response, client, cancellationToken);
|
||||||
}
|
}
|
||||||
#if USB_CAMERA
|
else if (path == "/usbCamera")
|
||||||
else if (requestPath == "/usb-camera")
|
|
||||||
{
|
{
|
||||||
// USB Camera MJPEG流请求
|
// USB Camera MJPEG流请求
|
||||||
await HandleUsbCameraStreamAsync(response, cancellationToken);
|
await HandleUsbCameraStreamAsync(context.Response, client, cancellationToken);
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
else if (path == "/snapshot")
|
else if (path == "/snapshot")
|
||||||
{
|
{
|
||||||
// 单帧图像请求
|
// 单帧图像请求
|
||||||
|
@ -281,24 +297,16 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
}
|
}
|
||||||
|
|
||||||
// USB Camera MJPEG流处理
|
// USB Camera MJPEG流处理
|
||||||
#if USB_CAMERA
|
private async Task HandleUsbCameraStreamAsync(
|
||||||
private async Task HandleUsbCameraStreamAsync(HttpListenerResponse response, CancellationToken cancellationToken)
|
HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
lock (_usbCameraLock)
|
var camera = await _usbCamera.WithCancellation(cancellationToken);
|
||||||
{
|
|
||||||
if (_usbCamera == null)
|
if (!camera.IsCapturing)
|
||||||
{
|
|
||||||
_usbCamera = new VideoCapture(1);
|
|
||||||
_usbCamera.Fps = _frameRate;
|
|
||||||
_usbCamera.FrameWidth = _frameWidth;
|
|
||||||
_usbCamera.FrameHeight = _frameHeight;
|
|
||||||
_usbCameraEnable = _usbCamera.IsOpened();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!_usbCameraEnable || _usbCamera == null || !_usbCamera.IsOpened())
|
|
||||||
{
|
{
|
||||||
|
logger.Error("USB Camera is not capturing");
|
||||||
response.StatusCode = 500;
|
response.StatusCode = 500;
|
||||||
await response.OutputStream.FlushAsync(cancellationToken);
|
await response.OutputStream.FlushAsync(cancellationToken);
|
||||||
response.Close();
|
response.Close();
|
||||||
|
@ -310,61 +318,52 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
response.Headers.Add("Pragma", "no-cache");
|
response.Headers.Add("Pragma", "no-cache");
|
||||||
response.Headers.Add("Expires", "0");
|
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;
|
logger.Warn("USB Camera MJPEG帧获取失败");
|
||||||
lock (_usbCameraLock)
|
await Task.Delay(1000 / client.FrameRate, cancellationToken);
|
||||||
{
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.Error(ex, "USB Camera MJPEG流处理异常");
|
logger.Error(ex, "USB Camera MJPEG流处理异常");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
||||||
try { response.Close(); } catch { }
|
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
|
// 读取 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)
|
if (!frameResult.IsSuccessful || frameResult.Value == null)
|
||||||
{
|
{
|
||||||
response.StatusCode = 500;
|
response.StatusCode = 500;
|
||||||
|
@ -386,16 +385,18 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
response.Close();
|
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.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
|
||||||
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
response.Headers.Add("Pragma", "no-cache");
|
response.Headers.Add("Pragma", "no-cache");
|
||||||
response.Headers.Add("Expires", "0");
|
response.Headers.Add("Expires", "0");
|
||||||
|
|
||||||
|
var camera = await client.Camera.WithCancellation(cancellationToken);
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var frameResult = await client.Camera.ReadFrame();
|
var frameResult = await camera.ReadFrame();
|
||||||
if (!frameResult.IsSuccessful || frameResult.Value == null) continue;
|
if (!frameResult.IsSuccessful || frameResult.Value == null) continue;
|
||||||
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
|
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
|
||||||
if (!jpegResult.IsSuccessful) continue;
|
if (!jpegResult.IsSuccessful) continue;
|
||||||
|
@ -508,7 +509,8 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
{
|
{
|
||||||
// 从摄像头读取帧数据
|
// 从摄像头读取帧数据
|
||||||
var readStartTime = DateTime.UtcNow;
|
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 readEndTime = DateTime.UtcNow;
|
||||||
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
|
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
|
||||||
|
|
||||||
|
@ -568,7 +570,7 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
|
|
||||||
using (await client.Lock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken))
|
using (await client.Lock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken))
|
||||||
{
|
{
|
||||||
var currentCamera = client.Camera;
|
var currentCamera = await client.Camera.WithCancellation(cancellationToken);
|
||||||
if (currentCamera == null)
|
if (currentCamera == null)
|
||||||
{
|
{
|
||||||
var message = $"获取摄像头失败";
|
var message = $"获取摄像头失败";
|
||||||
|
@ -621,7 +623,8 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
using (await client.Lock.AcquireWriteLockAsync(
|
using (await client.Lock.AcquireWriteLockAsync(
|
||||||
TimeSpan.FromMilliseconds(timeout), cancellationToken))
|
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)
|
if (result.IsSuccessful && result.Value)
|
||||||
{
|
{
|
||||||
|
@ -655,7 +658,8 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
|
|
||||||
logger.Info($"Board{boardId}开始执行摄像头自动对焦");
|
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)
|
if (result.IsSuccessful && result.Value)
|
||||||
{
|
{
|
||||||
|
@ -679,16 +683,18 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
/// 配置摄像头连接参数
|
/// 配置摄像头连接参数
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="boardId">板卡ID</param>
|
/// <param name="boardId">板卡ID</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
/// <returns>配置是否成功</returns>
|
/// <returns>配置是否成功</returns>
|
||||||
public async Task<bool> ConfigureCameraAsync(string boardId)
|
public async Task<bool> ConfigureCameraAsync(string boardId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
|
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)
|
if (!ret.IsSuccessful)
|
||||||
{
|
{
|
||||||
logger.Error(ret.Error);
|
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)
|
if (!ret.IsSuccessful)
|
||||||
{
|
{
|
||||||
logger.Error(ret.Error);
|
logger.Error(ret.Error);
|
||||||
|
@ -747,7 +753,7 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
client.CTS.Cancel();
|
client.CTS.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
var camera = client.Camera;
|
var camera = await client.Camera.WithCancellation(client.CTS.Token);
|
||||||
var disableResult = await camera.EnableHardwareTrans(enable);
|
var disableResult = await camera.EnableHardwareTrans(enable);
|
||||||
if (disableResult.IsSuccessful && disableResult.Value)
|
if (disableResult.IsSuccessful && disableResult.Value)
|
||||||
logger.Info($"Successfully disabled camera {boardId} hardware transmission");
|
logger.Info($"Successfully disabled camera {boardId} hardware transmission");
|
||||||
|
@ -782,7 +788,7 @@ public class HttpVideoStreamService : BackgroundService
|
||||||
|
|
||||||
public VideoStreamEndpoint GetVideoEndpoint(string boardId)
|
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
|
return new VideoStreamEndpoint
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,222 @@
|
||||||
|
// using System.Drawing;
|
||||||
|
using FlashCap;
|
||||||
|
|
||||||
|
namespace server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple USB camera capture service following Linus principles:
|
||||||
|
/// - Single responsibility: just capture frames
|
||||||
|
/// - No special cases: uniform error handling
|
||||||
|
/// - Good taste: clean data structures
|
||||||
|
/// </summary>
|
||||||
|
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<byte[]>? FrameReady;
|
||||||
|
public event Action<Exception>? Error;
|
||||||
|
|
||||||
|
public bool IsCapturing => _isCapturing;
|
||||||
|
public VideoCharacteristics? CurrentCharacteristics => _characteristics;
|
||||||
|
public CaptureDeviceDescriptor? CurrentDevice => _descriptor;
|
||||||
|
|
||||||
|
public UsbCameraCapture()
|
||||||
|
{
|
||||||
|
_captureDevices = new CaptureDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all available camera devices
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CaptureDeviceDescriptor> GetDevices()
|
||||||
|
{
|
||||||
|
return _captureDevices.EnumerateDescriptors().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start capturing from specified device with best matching characteristics
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start capturing with exact device and characteristics
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stop capturing and cleanup
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
if (!_isCapturing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isCapturing = false;
|
||||||
|
await CleanupAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the latest captured frame (returns copy for thread safety)
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? GetLatestFrame()
|
||||||
|
{
|
||||||
|
return _latestFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /// <summary>
|
||||||
|
// /// Get latest frame as bitmap
|
||||||
|
// /// </summary>
|
||||||
|
// 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;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get supported video characteristics for current device
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<VideoCharacteristics> GetSupportedCharacteristics()
|
||||||
|
{
|
||||||
|
return _descriptor?.Characteristics.ToArray() ?? Array.Empty<VideoCharacteristics>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue