1157 lines
41 KiB
C#
1157 lines
41 KiB
C#
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Lib.io.video.swiftcam;
|
||
using SharpDX;
|
||
using SharpDX.DXGI;
|
||
using Device = SharpDX.Direct3D11.Device;
|
||
using StatusLevel = T3.Core.Operator.Interfaces.IStatusProvider.StatusLevel;
|
||
|
||
namespace Lib.io.video;
|
||
|
||
/// <summary>
|
||
/// Captures live video from a Swift Imaging scientific USB camera via the vendor SDK.
|
||
/// Delivers BGRA8 frames into a Texture2D using the SDK's pull-mode callback.
|
||
/// All SDK lifecycle calls (Open, Stop, Close, StartPullModeWithCallback) run on a background
|
||
/// thread so the editor's render thread is never blocked by camera I/O.
|
||
/// Requires swiftcam.dll from the Swift Imaging SDK at %LOCALAPPDATA%\TiXL\NativeDeps\swiftcam.dll
|
||
/// (or pointed at via the TIXL_SWIFTCAM_DLL environment variable).
|
||
/// </summary>
|
||
[Guid("5ea3d627-e44b-4531-9f06-4503a1a4dd0e")]
|
||
public class SwiftCamDevice : Instance<SwiftCamDevice>, ICustomDropdownHolder, IStatusProvider
|
||
{
|
||
[Output(Guid = "5f1552ab-e9e5-49ac-b2f1-3019becf6f96", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
|
||
public readonly Slot<Texture2D> Texture = new();
|
||
|
||
[Output(Guid = "c5d201f4-020c-4f84-9ba2-1a3e59423f22", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
|
||
public readonly Slot<Int2> Resolution = new();
|
||
|
||
[Output(Guid = "d6862579-03df-48e8-a495-070fb6614497", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
|
||
public new readonly Slot<string> Status = new();
|
||
|
||
[Output(Guid = "083525a9-1c9d-4ac4-96dc-915a4c0b6aaa", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
|
||
public readonly Slot<int> UpdateCount = new();
|
||
|
||
[Input(Guid = "d73af73f-e71c-4a6b-a4cf-f8069c1db30d")]
|
||
public readonly InputSlot<bool> Active = new();
|
||
|
||
[Input(Guid = "c44f7b21-0418-490f-a2c1-4d57b70bfa32")]
|
||
public readonly InputSlot<string> DeviceName = new();
|
||
|
||
[Input(Guid = "df2f5f6b-2b27-44b7-989d-6c8225113234")]
|
||
public readonly InputSlot<int> ResolutionIndex = new();
|
||
|
||
[Input(Guid = "e9bc114b-75ac-4bec-a337-5b16404da4de")]
|
||
public readonly InputSlot<bool> AutoExposure = new();
|
||
|
||
[Input(Guid = "48e138e2-c31f-45fe-81f2-cf3789fe7d10")]
|
||
public readonly InputSlot<float> Exposure = new();
|
||
|
||
[Input(Guid = "370fc7fa-29f8-492e-b443-cce968ece722")]
|
||
public readonly InputSlot<float> AnalogGainFactor = new();
|
||
|
||
[Input(Guid = "ea6b3503-9a68-405e-9b34-7f44a74d5b31")]
|
||
public readonly InputSlot<Int2> RoiResolution = new();
|
||
|
||
[Input(Guid = "3e021070-038f-453d-96ca-33c27bc8ddc4")]
|
||
public readonly InputSlot<Vector2> RoiAlignment = new();
|
||
|
||
[Input(Guid = "7965cea6-2fe4-4240-9f9c-ab2af0aedea8")]
|
||
public readonly InputSlot<bool> LogMessages = new();
|
||
|
||
[Input(Guid = "7cd0acca-d843-472e-9fcc-e8deaf129336")]
|
||
public readonly InputSlot<bool> Reconnect = new();
|
||
|
||
[Input(Guid = "caa5d946-060d-45eb-907b-601c4f0a677c")]
|
||
public readonly InputSlot<bool> HardReset = new();
|
||
|
||
static SwiftCamDevice()
|
||
{
|
||
// Pre-load swiftcam.dll into the process by full path. Later [DllImport("swiftcam.dll")] calls
|
||
// resolve to the already-loaded module via the per-process module table, sidestepping TiXL's
|
||
// load-context resolver (which only probes the assembly's directory + the application directory).
|
||
TryPreloadSwiftcamDll();
|
||
}
|
||
|
||
public SwiftCamDevice()
|
||
{
|
||
Texture.UpdateAction = Update;
|
||
Resolution.UpdateAction = Update;
|
||
Status.UpdateAction = Update;
|
||
UpdateCount.UpdateAction = Update;
|
||
}
|
||
|
||
public StatusLevel GetStatusLevel() => _statusLevel;
|
||
|
||
public string GetStatusMessage() => _statusMessage;
|
||
|
||
private static void TryPreloadSwiftcamDll()
|
||
{
|
||
try
|
||
{
|
||
var envPath = Environment.GetEnvironmentVariable("TIXL_SWIFTCAM_DLL");
|
||
if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath)
|
||
&& NativeLibrary.TryLoad(envPath, out _))
|
||
{
|
||
Log.Debug($"SwiftCamDevice: pre-loaded swiftcam.dll from TIXL_SWIFTCAM_DLL='{envPath}'");
|
||
return;
|
||
}
|
||
|
||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||
var userPath = Path.Combine(localAppData, "TiXL", "NativeDeps", "swiftcam.dll");
|
||
if (File.Exists(userPath))
|
||
{
|
||
if (NativeLibrary.TryLoad(userPath, out _))
|
||
Log.Debug($"SwiftCamDevice: pre-loaded swiftcam.dll from '{userPath}'");
|
||
else
|
||
Log.Warning($"SwiftCamDevice: file at '{userPath}' exists but NativeLibrary.TryLoad failed (likely bitness mismatch)");
|
||
}
|
||
else
|
||
{
|
||
Log.Debug($"SwiftCamDevice: '{userPath}' not found - SDK probe will fail unless DLL is on default search path");
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: pre-load threw - {e.Message}");
|
||
}
|
||
}
|
||
|
||
string ICustomDropdownHolder.GetValueForInput(Guid inputId)
|
||
{
|
||
if (inputId == DeviceName.Id)
|
||
return DeviceName.Value;
|
||
|
||
if (inputId == ResolutionIndex.Id)
|
||
{
|
||
var idx = ResolutionIndex.Value;
|
||
if (TryFindDevice(DeviceName.Value, out var device)
|
||
&& device.model.res is { } res
|
||
&& idx >= 0 && idx < res.Length)
|
||
{
|
||
var w = res[idx].width;
|
||
var h = res[idx].height;
|
||
return idx == 0 ? $"{idx}: {w}x{h} (full)" : $"{idx}: {w}x{h}";
|
||
}
|
||
return idx.ToString();
|
||
}
|
||
|
||
return string.Empty;
|
||
}
|
||
|
||
IEnumerable<string> ICustomDropdownHolder.GetOptionsForInput(Guid inputId)
|
||
{
|
||
if (inputId == DeviceName.Id)
|
||
return EnumerateDeviceOptions();
|
||
|
||
if (inputId == ResolutionIndex.Id)
|
||
return EnumerateResolutionOptions();
|
||
|
||
return Array.Empty<string>();
|
||
}
|
||
|
||
private IEnumerable<string> EnumerateDeviceOptions()
|
||
{
|
||
if (_sdkAvailable == null)
|
||
_sdkAvailable = TryProbeSdk();
|
||
|
||
if (_sdkAvailable == false)
|
||
{
|
||
yield return SdkMissingMessage;
|
||
yield break;
|
||
}
|
||
|
||
ScanDevices();
|
||
|
||
if (_devices == null || _devices.Length == 0)
|
||
{
|
||
yield return "No Swift cameras found";
|
||
yield break;
|
||
}
|
||
|
||
for (var i = 0; i < _devices.Length; i++)
|
||
yield return _devices[i].displayname;
|
||
}
|
||
|
||
private IEnumerable<string> EnumerateResolutionOptions()
|
||
{
|
||
if (_sdkAvailable != true)
|
||
{
|
||
yield return "(SDK not available)";
|
||
yield break;
|
||
}
|
||
|
||
ScanDevices();
|
||
|
||
if (!TryFindDevice(DeviceName.Value, out var device))
|
||
{
|
||
yield return "(Pick a device first)";
|
||
yield break;
|
||
}
|
||
|
||
var count = (int)device.model.preview;
|
||
var resArray = device.model.res;
|
||
if (resArray == null || count <= 0)
|
||
{
|
||
yield return "(No resolutions reported)";
|
||
yield break;
|
||
}
|
||
|
||
for (var i = 0; i < count && i < resArray.Length; i++)
|
||
{
|
||
var w = resArray[i].width;
|
||
var h = resArray[i].height;
|
||
var label = i == 0
|
||
? $"{i}: {w}x{h} (full)"
|
||
: $"{i}: {w}x{h}";
|
||
yield return label;
|
||
}
|
||
}
|
||
|
||
void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string selected, bool isAListItem)
|
||
{
|
||
if (inputId == DeviceName.Id)
|
||
{
|
||
DeviceName.SetTypedInputValue(selected);
|
||
return;
|
||
}
|
||
|
||
if (inputId == ResolutionIndex.Id && !string.IsNullOrEmpty(selected))
|
||
{
|
||
// Labels are formatted as "{index}: WxH ...". Parse the leading integer.
|
||
var colon = selected.IndexOf(':');
|
||
var head = colon > 0 ? selected[..colon] : selected;
|
||
if (int.TryParse(head, out var index))
|
||
ResolutionIndex.SetTypedInputValue(index);
|
||
}
|
||
}
|
||
|
||
private void Update(EvaluationContext context)
|
||
{
|
||
// Cache verbose flag for the SDK callback thread.
|
||
_logVerbose = LogMessages.GetValue(context);
|
||
|
||
if (_sdkAvailable == null)
|
||
_sdkAvailable = TryProbeSdk();
|
||
|
||
if (Reconnect.GetValue(context))
|
||
{
|
||
Reconnect.SetTypedInputValue(false);
|
||
Log.Debug("SwiftCamDevice: Reconnect triggered", this);
|
||
BeginStop();
|
||
_devices = null;
|
||
_sdkAvailable = null;
|
||
_backoffUntilTickMs = 0;
|
||
_consecutiveSilentFailures = 0;
|
||
SetStatus("Reconnecting…", StatusLevel.Notice);
|
||
Status.Value = _statusMessage;
|
||
return;
|
||
}
|
||
|
||
if (HardReset.GetValue(context))
|
||
{
|
||
HardReset.SetTypedInputValue(false);
|
||
Log.Warning("SwiftCamDevice: HardReset triggered — sensor reset + flush + reopen", this);
|
||
|
||
// Try a sensor-side reset and buffer flush first. These are fire-and-forget; the
|
||
// camera may or may not actually be in a state to receive them, but it's worth
|
||
// trying before the more disruptive Close/Open cycle.
|
||
if (_camera != null)
|
||
{
|
||
try { _camera.put_Option(Swiftcam.eOPTION.OPTION_RESET_SENSOR, 1); }
|
||
catch (Exception e) { Log.Debug($"SwiftCamDevice: OPTION_RESET_SENSOR failed - {e.Message}", this); }
|
||
try { _camera.put_Option(Swiftcam.eOPTION.OPTION_FLUSH, 3); }
|
||
catch (Exception e) { Log.Debug($"SwiftCamDevice: OPTION_FLUSH failed - {e.Message}", this); }
|
||
}
|
||
|
||
BeginStop();
|
||
_devices = null;
|
||
_sdkAvailable = null;
|
||
_backoffUntilTickMs = 0;
|
||
_consecutiveSilentFailures = 0;
|
||
SetStatus("Hard reset…", StatusLevel.Notice);
|
||
Status.Value = _statusMessage;
|
||
return;
|
||
}
|
||
|
||
if (_sdkAvailable == false)
|
||
{
|
||
SetStatus(SdkMissingMessage, StatusLevel.Error);
|
||
Status.Value = _statusMessage;
|
||
return;
|
||
}
|
||
|
||
PollPendingTasks();
|
||
HandleErrorFlags();
|
||
|
||
ScanDevices();
|
||
|
||
var deviceName = DeviceName.GetValue(context);
|
||
if (string.IsNullOrEmpty(deviceName) && _devices != null && _devices.Length > 0)
|
||
deviceName = _devices[0].displayname;
|
||
|
||
var deviceFound = TryFindDeviceId(deviceName, out var deviceId);
|
||
var active = Active.GetValue(context);
|
||
|
||
if (!deviceFound)
|
||
{
|
||
if (_state == CamState.Streaming || _state == CamState.Starting)
|
||
BeginStop();
|
||
if (_state == CamState.Idle)
|
||
SetStatus($"Camera '{deviceName}' not found.", StatusLevel.Warning);
|
||
}
|
||
else if (active)
|
||
{
|
||
var snap = SnapshotStartConfig(context, deviceId);
|
||
|
||
if (_state == CamState.Streaming && snap.Config.RequiresRestart(_openedConfig))
|
||
{
|
||
Log.Debug("SwiftCamDevice: config changed during streaming - restarting", this);
|
||
BeginStop();
|
||
_backoffUntilTickMs = 0; // user-driven change — restart promptly
|
||
_consecutiveSilentFailures = 0;
|
||
}
|
||
else if (_state == CamState.Idle && snap.Config.RequiresRestart(_openedConfig))
|
||
{
|
||
// User changed config while we were idle (1 s backoff or suspended after repeated
|
||
// failures). Clear the suspension so we retry promptly with the new config.
|
||
_backoffUntilTickMs = 0;
|
||
_consecutiveSilentFailures = 0;
|
||
}
|
||
|
||
if (_state == CamState.Idle && Environment.TickCount64 >= _backoffUntilTickMs)
|
||
{
|
||
BeginStart(snap);
|
||
}
|
||
else if (_state == CamState.Idle)
|
||
{
|
||
// In backoff window — keep last status (set when error fired).
|
||
}
|
||
|
||
if (_state == CamState.Streaming)
|
||
{
|
||
ApplyAutoExposureAndExposure(context);
|
||
ApplyGain(context);
|
||
}
|
||
}
|
||
else // not active
|
||
{
|
||
if (_state == CamState.Streaming || _state == CamState.Starting)
|
||
BeginStop();
|
||
if (_state == CamState.Idle)
|
||
SetStatus("Inactive.", StatusLevel.Notice);
|
||
}
|
||
|
||
UploadLatestFrame();
|
||
Status.Value = _statusMessage;
|
||
}
|
||
|
||
private static bool TryProbeSdk()
|
||
{
|
||
try
|
||
{
|
||
var devices = Swiftcam.EnumV2();
|
||
Log.Debug($"SwiftCamDevice: SDK probe succeeded, EnumV2 returned {devices?.Length ?? 0} device(s)");
|
||
return true;
|
||
}
|
||
catch (DllNotFoundException e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: SDK probe failed - DllNotFoundException: {e.Message}");
|
||
return false;
|
||
}
|
||
catch (BadImageFormatException e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: SDK probe failed - BadImageFormatException: {e.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void ScanDevices()
|
||
{
|
||
if (_devices != null)
|
||
return;
|
||
|
||
try
|
||
{
|
||
_devices = Swiftcam.EnumV2() ?? Array.Empty<Swiftcam.DeviceV2>();
|
||
if (_logVerbose)
|
||
{
|
||
Log.Debug($"SwiftCamDevice: enumerated {_devices.Length} device(s)", this);
|
||
for (var i = 0; i < _devices.Length; i++)
|
||
Log.Debug($"SwiftCamDevice: [{i}] '{_devices[i].displayname}' id='{_devices[i].id}'", this);
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: enumeration failed - {e.Message}", this);
|
||
_devices = Array.Empty<Swiftcam.DeviceV2>();
|
||
}
|
||
}
|
||
|
||
private bool TryFindDeviceId(string displayName, out string id)
|
||
{
|
||
if (TryFindDevice(displayName, out var device))
|
||
{
|
||
id = device.id;
|
||
return true;
|
||
}
|
||
|
||
id = null;
|
||
return false;
|
||
}
|
||
|
||
private bool TryFindDevice(string displayName, out Swiftcam.DeviceV2 device)
|
||
{
|
||
device = default;
|
||
if (_devices == null || string.IsNullOrEmpty(displayName))
|
||
return false;
|
||
|
||
for (var i = 0; i < _devices.Length; i++)
|
||
{
|
||
if (_devices[i].displayname == displayName)
|
||
{
|
||
device = _devices[i];
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private StartSnapshot SnapshotStartConfig(EvaluationContext context, string deviceId)
|
||
{
|
||
return new StartSnapshot
|
||
{
|
||
Config = new StreamConfig
|
||
{
|
||
DeviceId = deviceId,
|
||
ResolutionIndex = (uint)Math.Max(0, ResolutionIndex.GetValue(context)),
|
||
RoiResolution = RoiResolution.GetValue(context),
|
||
RoiAlignment = RoiAlignment.GetValue(context),
|
||
},
|
||
InitialAutoExposure = AutoExposure.GetValue(context),
|
||
InitialExposureMs = Exposure.GetValue(context),
|
||
InitialGainFactor = AnalogGainFactor.GetValue(context),
|
||
};
|
||
}
|
||
|
||
private void BeginStart(StartSnapshot snap)
|
||
{
|
||
Log.Debug($"SwiftCamDevice: BeginStart resIndex={snap.Config.ResolutionIndex} "
|
||
+ $"autoExpo={snap.InitialAutoExposure} exposureMs={snap.InitialExposureMs:F1} "
|
||
+ $"gain={snap.InitialGainFactor:F2}x (ROI applied mid-stream after warm-up)", this);
|
||
SetStatus("Starting…", StatusLevel.Notice);
|
||
_state = CamState.Starting;
|
||
_openedConfig = snap.Config;
|
||
_loggedFirstFrame = false;
|
||
_framesReceivedSinceStart = 0;
|
||
|
||
var verbose = _logVerbose;
|
||
// DoStart runs on a background thread so the render thread isn't blocked by Swiftcam.Open
|
||
// (~1.3 s) or StartPullModeWithCallback (also slow on this SDK).
|
||
_startTask = Task.Run(() => DoStart(snap, verbose));
|
||
}
|
||
|
||
private StartResult DoStart(StartSnapshot snap, bool verbose)
|
||
{
|
||
Swiftcam cam = null;
|
||
try
|
||
{
|
||
cam = Swiftcam.Open(snap.Config.DeviceId);
|
||
if (cam == null)
|
||
return new StartResult { ErrorMessage = "Failed to open Swift camera." };
|
||
|
||
// Real-time delivery — vendor sample calls this right after Open.
|
||
var rtOk = cam.put_RealTime(1);
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_RealTime(1) = {rtOk}", this);
|
||
|
||
// Note: deliberately NOT calling put_Option(OPTION_RGB, RGB32). The vendor's samples
|
||
// never set this, and combining native RGB32 with non-zero ROI offsets seems to
|
||
// trigger EVENT_ERROR shortly after streaming starts on this SDK family. We accept
|
||
// a per-frame RGB24→RGB32 conversion inside the SDK (PullImage with bits=32) in
|
||
// exchange for ROI working reliably.
|
||
|
||
var sizeOk = cam.put_eSize(snap.Config.ResolutionIndex);
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_eSize({snap.Config.ResolutionIndex}) = {sizeOk}", this);
|
||
|
||
// Reset any persisted ROI before measuring sensor dimensions. ROI bounds in this SDK
|
||
// are relative to the *current* effective size, not the original sensor — if a prior
|
||
// ROI carried over, get_Size returns the cropped dimensions and our offset math
|
||
// computes against the wrong base.
|
||
var resetOk = cam.put_Roi(0, 0, 0, 0);
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_Roi(0,0,0,0) reset = {resetOk}", this);
|
||
|
||
if (!cam.get_Size(out var sensorW, out var sensorH) || sensorW <= 0 || sensorH <= 0)
|
||
{
|
||
cam.Close();
|
||
return new StartResult { ErrorMessage = "Camera reported an invalid resolution." };
|
||
}
|
||
if (verbose) Log.Debug($"SwiftCamDevice: get_Size = {sensorW}x{sensorH}", this);
|
||
|
||
// Apply ROI before starting streaming. (Mid-stream ROI changes on the SC1003 cause
|
||
// EVENT_ERROR — neither pre-start nor post-start works reliably, but pre-start is the
|
||
// pattern the vendor's WinForms sample follows for resolution changes.)
|
||
ComputeRoi(snap.Config.RoiResolution, snap.Config.RoiAlignment, sensorW, sensorH,
|
||
out var roiX, out var roiY, out var roiW, out var roiH);
|
||
var roiOk = cam.put_Roi(roiX, roiY, roiW, roiH);
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_Roi({roiX},{roiY},{roiW},{roiH}) = {roiOk}", this);
|
||
|
||
var aeOk = cam.put_AutoExpoEnable(snap.InitialAutoExposure);
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_AutoExpoEnable({snap.InitialAutoExposure}) = {aeOk}", this);
|
||
|
||
int appliedExposureUs = -1;
|
||
if (!snap.InitialAutoExposure && snap.InitialExposureMs > 0)
|
||
{
|
||
var us = (uint)Math.Max(1, (int)Math.Round(snap.InitialExposureMs * 1000f));
|
||
cam.put_ExpoTime(us);
|
||
appliedExposureUs = (int)us;
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_ExpoTime({us})", this);
|
||
}
|
||
|
||
// AnalogGainFactor: 1.0 = unity (= 100% in the SDK). Camera's EXPOGAIN_MIN is 100.
|
||
var gainPercent = (int)Math.Round(snap.InitialGainFactor * 100f);
|
||
int appliedGainPercent = -1;
|
||
if (gainPercent >= Swiftcam.EXPOGAIN_MIN)
|
||
{
|
||
cam.put_ExpoAGain((ushort)gainPercent);
|
||
appliedGainPercent = gainPercent;
|
||
if (verbose) Log.Debug($"SwiftCamDevice: put_ExpoAGain({gainPercent}%)", this);
|
||
}
|
||
|
||
cam.get_Size(out var effW, out var effH);
|
||
if (effW <= 0) effW = sensorW;
|
||
if (effH <= 0) effH = sensorH;
|
||
|
||
// Start streaming on this background thread. StartPullModeWithCallback can take
|
||
// 1–3 seconds on this SDK family — keeping it off the render thread is critical.
|
||
var cb = new Swiftcam.DelegateEventCallback(OnEventCallback);
|
||
if (!cam.StartPullModeWithCallback(cb))
|
||
{
|
||
cam.Close();
|
||
return new StartResult { ErrorMessage = "Swift camera failed to start streaming." };
|
||
}
|
||
|
||
return new StartResult
|
||
{
|
||
Camera = cam,
|
||
EventCallback = cb,
|
||
SensorWidth = sensorW,
|
||
SensorHeight = sensorH,
|
||
EffectiveWidth = effW,
|
||
EffectiveHeight = effH,
|
||
AppliedAutoExposure = snap.InitialAutoExposure,
|
||
AppliedExposureUs = appliedExposureUs,
|
||
AppliedGainPercent = appliedGainPercent,
|
||
AppliedRoi = (roiX, roiY, roiW, roiH),
|
||
};
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
try { cam?.Close(); } catch { /* best effort */ }
|
||
return new StartResult { ErrorMessage = $"Start failed: {e.Message}" };
|
||
}
|
||
}
|
||
|
||
private void PollPendingTasks()
|
||
{
|
||
if (_state == CamState.Starting && _startTask != null && _startTask.IsCompleted)
|
||
{
|
||
var r = _startTask.Result;
|
||
_startTask = null;
|
||
|
||
if (r.ErrorMessage != null)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: {r.ErrorMessage}", this);
|
||
SetStatus(r.ErrorMessage, StatusLevel.Error);
|
||
_state = CamState.Idle;
|
||
ScheduleStartRetry();
|
||
}
|
||
else
|
||
{
|
||
_camera = r.Camera;
|
||
_eventCallback = r.EventCallback;
|
||
_sensorWidth = r.SensorWidth;
|
||
_sensorHeight = r.SensorHeight;
|
||
_appliedAutoExposure = r.AppliedAutoExposure;
|
||
_appliedExposureUs = r.AppliedExposureUs;
|
||
_appliedGainPercent = r.AppliedGainPercent;
|
||
_appliedRoi = r.AppliedRoi;
|
||
EnsurePullBuffer(r.EffectiveWidth, r.EffectiveHeight);
|
||
|
||
_state = CamState.Streaming;
|
||
SetStatus("Streaming.", StatusLevel.Success);
|
||
Log.Debug("SwiftCamDevice: streaming started", this);
|
||
|
||
if (_stopRequestedDuringStart)
|
||
{
|
||
_stopRequestedDuringStart = false;
|
||
BeginStop();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (_state == CamState.Stopping && _stopTask != null && _stopTask.IsCompleted)
|
||
{
|
||
_stopTask = null;
|
||
_state = CamState.Idle;
|
||
Log.Debug("SwiftCamDevice: stopped", this);
|
||
|
||
// Force a settling period before the next Open. Stop/Open in rapid succession is
|
||
// what wedges the SC1003 camera firmware most reliably.
|
||
var settleUntil = Environment.TickCount64 + PostStopCooldownMs;
|
||
if (settleUntil > _backoffUntilTickMs)
|
||
_backoffUntilTickMs = settleUntil;
|
||
}
|
||
}
|
||
|
||
private void HandleErrorFlags()
|
||
{
|
||
if (_disconnected)
|
||
{
|
||
_disconnected = false;
|
||
Log.Warning("SwiftCamDevice: disconnect detected, auto-retrying", this);
|
||
SetStatus("Camera disconnected. Auto-retrying…", StatusLevel.Warning);
|
||
BeginStop();
|
||
ScheduleStartRetry();
|
||
return;
|
||
}
|
||
|
||
if (_hasError)
|
||
{
|
||
_hasError = false;
|
||
|
||
// If the start succeeded but no frame ever arrived before EVENT_ERROR fired, the
|
||
// current config (most often a non-zero RoiResolution) is unsupported by this
|
||
// camera/SDK. Bail after a few attempts rather than retry-looping forever.
|
||
if (_framesReceivedSinceStart == 0)
|
||
{
|
||
_consecutiveSilentFailures++;
|
||
Log.Warning($"SwiftCamDevice: SDK error before first frame "
|
||
+ $"(attempt {_consecutiveSilentFailures}/{MaxSilentFailures})", this);
|
||
|
||
if (_consecutiveSilentFailures >= MaxSilentFailures)
|
||
{
|
||
SetStatus("Camera configuration not supported (likely a non-zero RoiResolution "
|
||
+ "on a camera whose SDK rejects mid-stream ROI). Set RoiResolution "
|
||
+ "to (0, 0) and try Reconnect.",
|
||
StatusLevel.Error);
|
||
BeginStop();
|
||
_backoffUntilTickMs = long.MaxValue; // suspend retries until config changes
|
||
return;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Log.Warning("SwiftCamDevice: SDK error after streaming, auto-retrying", this);
|
||
}
|
||
|
||
SetStatus("Camera reported an error. Auto-retrying…", StatusLevel.Error);
|
||
BeginStop();
|
||
ScheduleStartRetry();
|
||
}
|
||
}
|
||
|
||
private void BeginStop()
|
||
{
|
||
switch (_state)
|
||
{
|
||
case CamState.Idle:
|
||
case CamState.Stopping:
|
||
return;
|
||
case CamState.Starting:
|
||
// Open/Start is in-flight on a background thread; can't cancel cleanly.
|
||
// Defer the stop to when the start task completes.
|
||
_stopRequestedDuringStart = true;
|
||
return;
|
||
case CamState.Streaming:
|
||
DoBeginStop();
|
||
return;
|
||
}
|
||
}
|
||
|
||
private void DoBeginStop()
|
||
{
|
||
var cam = _camera;
|
||
_camera = null;
|
||
_eventCallback = null;
|
||
_hasNewFrame = false;
|
||
_state = CamState.Stopping;
|
||
Log.Debug("SwiftCamDevice: BeginStop", this);
|
||
|
||
_stopTask = Task.Run(() => SafeStopAndClose(cam));
|
||
}
|
||
|
||
private static void SafeStopAndClose(Swiftcam cam)
|
||
{
|
||
if (cam == null)
|
||
return;
|
||
try
|
||
{
|
||
cam.Stop();
|
||
cam.Close();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: Stop/Close failed - {e.Message}");
|
||
}
|
||
}
|
||
|
||
private void ScheduleStartRetry()
|
||
{
|
||
// Force re-enumeration on next attempt — picks up "camera unplugged" rather than
|
||
// blindly re-trying the cached id.
|
||
_devices = null;
|
||
_backoffUntilTickMs = Environment.TickCount64 + StartBackoffMs;
|
||
}
|
||
|
||
private void EnsurePullBuffer(int width, int height)
|
||
{
|
||
var stride = width * BytesPerPixel;
|
||
var totalBytes = stride * height;
|
||
lock (_frameLock)
|
||
{
|
||
if (_frameBuffer == IntPtr.Zero || _frameBufferCapacity < totalBytes)
|
||
{
|
||
if (_frameBuffer != IntPtr.Zero)
|
||
Marshal.FreeHGlobal(_frameBuffer);
|
||
_frameBuffer = Marshal.AllocHGlobal(totalBytes);
|
||
_frameBufferCapacity = totalBytes;
|
||
}
|
||
_expectedWidth = width;
|
||
_expectedHeight = height;
|
||
_expectedStride = stride;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Maps user-facing (RoiResolution, RoiAlignment) into hardware (x, y, w, h) snapped to
|
||
/// 2-pixel boundaries (Bayer-pattern alignment). RoiResolution X or Y <= 0, or a crop
|
||
/// equal/greater than the sensor in both axes, disables ROI (returns 0,0,0,0).
|
||
///
|
||
/// The camera ultimately decides which sizes are valid. The vendor's own app surfaces
|
||
/// "Out of range" errors for many seemingly-reasonable sizes (e.g. 1920×1080 isn't
|
||
/// accepted on the SC1003 — try 1954×1062 instead). When put_Roi succeeds at the SDK
|
||
/// level but the camera silently rejects the size, the operator's silent-failure
|
||
/// detection kicks in and surfaces a clear error to the user.
|
||
/// </summary>
|
||
private static void ComputeRoi(Int2 roiRes, Vector2 alignment, int sensorW, int sensorH,
|
||
out uint x, out uint y, out uint w, out uint h)
|
||
{
|
||
if (roiRes.X <= 0 || roiRes.Y <= 0
|
||
|| (roiRes.X >= sensorW && roiRes.Y >= sensorH))
|
||
{
|
||
x = 0;
|
||
y = 0;
|
||
w = 0;
|
||
h = 0;
|
||
return;
|
||
}
|
||
|
||
var snapW = SnapDownTo2(Math.Min(sensorW, roiRes.X));
|
||
var snapH = SnapDownTo2(Math.Min(sensorH, roiRes.Y));
|
||
var slackX = (sensorW - snapW) / 2;
|
||
var slackY = (sensorH - snapH) / 2;
|
||
|
||
var ax = Math.Clamp(alignment.X, -1f, 1f);
|
||
var ay = Math.Clamp(alignment.Y, -1f, 1f);
|
||
|
||
var offsetX = slackX + (int)Math.Round(ax * slackX);
|
||
var offsetY = slackY + (int)Math.Round(ay * slackY);
|
||
|
||
offsetX = SnapDownTo2(Math.Clamp(offsetX, 0, sensorW - snapW));
|
||
offsetY = SnapDownTo2(Math.Clamp(offsetY, 0, sensorH - snapH));
|
||
|
||
x = (uint)offsetX;
|
||
y = (uint)offsetY;
|
||
w = (uint)snapW;
|
||
h = (uint)snapH;
|
||
}
|
||
|
||
private static int SnapDownTo2(int v) => Math.Max(0, (v / 2) * 2);
|
||
|
||
private void ApplyAutoExposureAndExposure(EvaluationContext context)
|
||
{
|
||
if (_camera == null)
|
||
return;
|
||
|
||
var auto = AutoExposure.GetValue(context);
|
||
if (_appliedAutoExposure != auto)
|
||
{
|
||
try
|
||
{
|
||
_camera.put_AutoExpoEnable(auto);
|
||
_appliedAutoExposure = auto;
|
||
LogVerbose($"put_AutoExpoEnable({auto})");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: put_AutoExpoEnable failed - {e.Message}", this);
|
||
}
|
||
}
|
||
|
||
if (auto)
|
||
return; // SDK manages exposure; ignore the slider while AE is on.
|
||
|
||
var ms = Exposure.GetValue(context);
|
||
if (!(ms > 0f))
|
||
return;
|
||
|
||
var us = (int)Math.Round(ms * 1000f);
|
||
if (us == _appliedExposureUs)
|
||
return;
|
||
|
||
try
|
||
{
|
||
_camera.put_ExpoTime((uint)Math.Max(1, us));
|
||
_appliedExposureUs = us;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: put_ExpoTime failed - {e.Message}", this);
|
||
}
|
||
}
|
||
|
||
private void ApplyGain(EvaluationContext context)
|
||
{
|
||
if (_camera == null)
|
||
return;
|
||
|
||
// AnalogGainFactor: 1.0 = unity, 5.0 = 5x. SDK takes percent (100 = unity).
|
||
var gainPercent = (int)Math.Round(AnalogGainFactor.GetValue(context) * 100f);
|
||
if (gainPercent < Swiftcam.EXPOGAIN_MIN || gainPercent == _appliedGainPercent)
|
||
return;
|
||
|
||
try
|
||
{
|
||
_camera.put_ExpoAGain((ushort)gainPercent);
|
||
_appliedGainPercent = gainPercent;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: put_ExpoAGain failed - {e.Message}", this);
|
||
}
|
||
}
|
||
|
||
private void OnEventCallback(Swiftcam.eEVENT nEvent)
|
||
{
|
||
// Runs on SDK thread.
|
||
switch (nEvent)
|
||
{
|
||
case Swiftcam.eEVENT.EVENT_IMAGE:
|
||
if (_logVerbose)
|
||
Log.Debug($"SwiftCamDevice: event {nEvent}", this);
|
||
PullFrame();
|
||
break;
|
||
case Swiftcam.eEVENT.EVENT_DISCONNECTED:
|
||
Log.Warning("SwiftCamDevice: event EVENT_DISCONNECTED", this);
|
||
_disconnected = true;
|
||
break;
|
||
case Swiftcam.eEVENT.EVENT_ERROR:
|
||
Log.Warning("SwiftCamDevice: event EVENT_ERROR", this);
|
||
_hasError = true;
|
||
break;
|
||
case Swiftcam.eEVENT.EVENT_NOFRAMETIMEOUT:
|
||
Log.Warning("SwiftCamDevice: event EVENT_NOFRAMETIMEOUT (no frame received within timeout)", this);
|
||
_hasError = true;
|
||
break;
|
||
case Swiftcam.eEVENT.EVENT_NOPACKETTIMEOUT:
|
||
Log.Warning("SwiftCamDevice: event EVENT_NOPACKETTIMEOUT (no USB packet within timeout)", this);
|
||
_hasError = true;
|
||
break;
|
||
default:
|
||
if (_logVerbose)
|
||
Log.Debug($"SwiftCamDevice: event {nEvent}", this);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void PullFrame()
|
||
{
|
||
var cam = _camera;
|
||
if (cam == null)
|
||
return;
|
||
|
||
IntPtr buffer;
|
||
int width, height, stride, capacity;
|
||
lock (_frameLock)
|
||
{
|
||
buffer = _frameBuffer;
|
||
width = _expectedWidth;
|
||
height = _expectedHeight;
|
||
stride = _expectedStride;
|
||
capacity = _frameBufferCapacity;
|
||
}
|
||
|
||
if (buffer == IntPtr.Zero || width <= 0 || height <= 0 || capacity < stride * height)
|
||
{
|
||
LogVerbose($"PullFrame skipped - buffer not ready (capacity={capacity}, want={stride * height})");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (!cam.PullImage(buffer, 0, BitsPerPixel, stride, out var info))
|
||
{
|
||
LogVerbose("PullImage returned false");
|
||
return;
|
||
}
|
||
|
||
if (!_loggedFirstFrame)
|
||
{
|
||
Log.Debug($"SwiftCamDevice: first frame received - {info.v3.width}x{info.v3.height}, "
|
||
+ $"seq={info.v3.seq}, flag=0x{info.v3.flag:X}, expotime={info.v3.expotime}us, "
|
||
+ $"expogain={info.v3.expogain}", this);
|
||
_loggedFirstFrame = true;
|
||
_consecutiveSilentFailures = 0; // a successful frame breaks the give-up loop
|
||
}
|
||
_framesReceivedSinceStart++;
|
||
|
||
lock (_frameLock)
|
||
{
|
||
_frameWidth = (int)info.v3.width;
|
||
_frameHeight = (int)info.v3.height;
|
||
_frameStride = stride;
|
||
_frameSeq = info.v3.seq;
|
||
_hasNewFrame = true;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning($"SwiftCamDevice: PullImage threw - {e.Message}", this);
|
||
}
|
||
}
|
||
|
||
private void UploadLatestFrame()
|
||
{
|
||
IntPtr buffer;
|
||
int width, height, stride;
|
||
lock (_frameLock)
|
||
{
|
||
if (!_hasNewFrame)
|
||
return;
|
||
|
||
buffer = _frameBuffer;
|
||
width = _frameWidth;
|
||
height = _frameHeight;
|
||
stride = _frameStride;
|
||
_hasNewFrame = false;
|
||
}
|
||
|
||
EnsureGpuTexture(width, height, Format.B8G8R8A8_UNorm);
|
||
|
||
var dataBox = new DataBox(buffer, stride, 0);
|
||
ResourceManager.Device.ImmediateContext.UpdateSubresource(dataBox, _gpuTexture);
|
||
|
||
Texture.Value = _gpuTexture;
|
||
Resolution.Value = new Int2(width, height);
|
||
UpdateCount.Value++;
|
||
}
|
||
|
||
private void EnsureGpuTexture(int width, int height, Format format)
|
||
{
|
||
if (_gpuTexture != null && _gpuWidth == width && _gpuHeight == height && _gpuFormat == format)
|
||
return;
|
||
|
||
Log.Debug($"SwiftCamDevice: allocating GPU texture {width}x{height} {format}", this);
|
||
_gpuTexture?.Dispose();
|
||
|
||
var desc = new Texture2DDescription
|
||
{
|
||
Width = width,
|
||
Height = height,
|
||
MipLevels = 1,
|
||
ArraySize = 1,
|
||
Format = format,
|
||
SampleDescription = new SampleDescription(1, 0),
|
||
Usage = ResourceUsage.Default,
|
||
BindFlags = BindFlags.ShaderResource,
|
||
CpuAccessFlags = CpuAccessFlags.None,
|
||
OptionFlags = ResourceOptionFlags.None,
|
||
};
|
||
_gpuTexture = new Texture2D(new SharpDX.Direct3D11.Texture2D(ResourceManager.Device, desc));
|
||
_gpuWidth = width;
|
||
_gpuHeight = height;
|
||
_gpuFormat = format;
|
||
}
|
||
|
||
private void SetStatus(string message, StatusLevel level)
|
||
{
|
||
_statusMessage = message;
|
||
_statusLevel = level;
|
||
}
|
||
|
||
private void LogVerbose(string msg)
|
||
{
|
||
if (_logVerbose)
|
||
Log.Debug($"SwiftCamDevice: {msg}", this);
|
||
}
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (!disposing)
|
||
return;
|
||
|
||
BeginStop();
|
||
|
||
// Wait for any pending lifecycle task so we don't leak the camera handle.
|
||
var pending = new List<Task>();
|
||
if (_startTask != null)
|
||
pending.Add(_startTask);
|
||
if (_stopTask != null)
|
||
pending.Add(_stopTask);
|
||
if (pending.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
Task.WaitAll(pending.ToArray(), TimeSpan.FromSeconds(3));
|
||
}
|
||
catch
|
||
{
|
||
// Best effort; if a task fails or times out, fall through and free what we own.
|
||
}
|
||
}
|
||
|
||
// If the start task succeeded but we didn't pick up its result, the camera handle is in
|
||
// the result. Drain it.
|
||
if (_startTask != null && _startTask.IsCompletedSuccessfully)
|
||
{
|
||
try { _startTask.Result?.Camera?.Close(); } catch { /* best effort */ }
|
||
}
|
||
|
||
lock (_frameLock)
|
||
{
|
||
if (_frameBuffer != IntPtr.Zero)
|
||
{
|
||
Marshal.FreeHGlobal(_frameBuffer);
|
||
_frameBuffer = IntPtr.Zero;
|
||
_frameBufferCapacity = 0;
|
||
}
|
||
}
|
||
|
||
_gpuTexture?.Dispose();
|
||
_gpuTexture = null;
|
||
}
|
||
|
||
private const string SdkMissingMessage =
|
||
"swiftcam.dll not found. Copy the x64 DLL from the Swift Imaging SDK to "
|
||
+ "%LOCALAPPDATA%\\TiXL\\NativeDeps\\swiftcam.dll, then trigger Reconnect.";
|
||
|
||
private const int BitsPerPixel = 32;
|
||
private const int BytesPerPixel = 4;
|
||
|
||
// Cooldown after a failed start or runtime error so we don't hammer Swiftcam.Open.
|
||
// Short enough that a transient USB hiccup auto-recovers within a second or two.
|
||
private const long StartBackoffMs = 1000;
|
||
|
||
// After this many "started but no frame before EVENT_ERROR" cycles, suspend retries until
|
||
// the user changes config or triggers Reconnect. Indicates the current config is unsupported
|
||
// (typically a non-zero RoiResolution on a camera whose SDK rejects mid-stream ROI changes).
|
||
private const int MaxSilentFailures = 3;
|
||
|
||
// After a Stop/Close completes, wait this long before the next Open. The Swift firmware seems
|
||
// to need a settling period — quick Stop+Open cycles tend to wedge the camera into a state
|
||
// only physical USB unplug can recover. This is conservative; faster cameras may not need it.
|
||
private const long PostStopCooldownMs = 3000;
|
||
|
||
private enum CamState
|
||
{
|
||
Idle,
|
||
Starting,
|
||
Streaming,
|
||
Stopping,
|
||
}
|
||
|
||
private struct StreamConfig
|
||
{
|
||
public string DeviceId;
|
||
public uint ResolutionIndex;
|
||
public Int2 RoiResolution;
|
||
public Vector2 RoiAlignment;
|
||
|
||
// Any change here triggers a clean Stop + cooldown + Open cycle. Mid-stream ROI changes
|
||
// proved unreliable on the SC1003 (EVENT_ERROR ~1s after EVENT_ROI), so we treat ROI like
|
||
// any other restart-required setting.
|
||
|
||
public bool RequiresRestart(StreamConfig other)
|
||
{
|
||
return DeviceId != other.DeviceId
|
||
|| ResolutionIndex != other.ResolutionIndex
|
||
|| RoiResolution != other.RoiResolution
|
||
|| RoiAlignment != other.RoiAlignment;
|
||
}
|
||
}
|
||
|
||
private struct StartSnapshot
|
||
{
|
||
public StreamConfig Config;
|
||
public bool InitialAutoExposure;
|
||
public float InitialExposureMs;
|
||
public float InitialGainFactor;
|
||
}
|
||
|
||
private class StartResult
|
||
{
|
||
public Swiftcam Camera;
|
||
public Swiftcam.DelegateEventCallback EventCallback;
|
||
public int SensorWidth;
|
||
public int SensorHeight;
|
||
public int EffectiveWidth;
|
||
public int EffectiveHeight;
|
||
public bool AppliedAutoExposure;
|
||
public int AppliedExposureUs;
|
||
public int AppliedGainPercent;
|
||
public (uint X, uint Y, uint W, uint H) AppliedRoi;
|
||
public string ErrorMessage;
|
||
}
|
||
|
||
// State machine + lifecycle tasks
|
||
private CamState _state = CamState.Idle;
|
||
private Task<StartResult> _startTask;
|
||
private Task _stopTask;
|
||
private bool _stopRequestedDuringStart;
|
||
private long _backoffUntilTickMs;
|
||
|
||
// SDK + device cache
|
||
private bool? _sdkAvailable;
|
||
private Swiftcam.DeviceV2[] _devices;
|
||
private volatile bool _logVerbose;
|
||
|
||
// Streaming-state fields (only valid while _state == CamState.Streaming)
|
||
private Swiftcam _camera;
|
||
private Swiftcam.DelegateEventCallback _eventCallback;
|
||
private StreamConfig _openedConfig;
|
||
private int _sensorWidth;
|
||
private int _sensorHeight;
|
||
private (uint X, uint Y, uint W, uint H) _appliedRoi;
|
||
private bool? _appliedAutoExposure;
|
||
private int _appliedExposureUs = -1;
|
||
private int _appliedGainPercent = -1;
|
||
private volatile bool _disconnected;
|
||
private volatile bool _hasError;
|
||
private volatile bool _loggedFirstFrame;
|
||
private volatile int _framesReceivedSinceStart;
|
||
private int _consecutiveSilentFailures;
|
||
|
||
// Frame buffer (filled by PullFrame on SDK thread, read by UploadLatestFrame on render thread)
|
||
private readonly Lock _frameLock = new();
|
||
private IntPtr _frameBuffer;
|
||
private int _frameBufferCapacity;
|
||
private int _expectedWidth;
|
||
private int _expectedHeight;
|
||
private int _expectedStride;
|
||
private int _frameWidth;
|
||
private int _frameHeight;
|
||
private int _frameStride;
|
||
private uint _frameSeq;
|
||
private bool _hasNewFrame;
|
||
|
||
// GPU texture (recreated when dimensions change)
|
||
private Texture2D _gpuTexture;
|
||
private int _gpuWidth;
|
||
private int _gpuHeight;
|
||
private Format _gpuFormat;
|
||
|
||
// Status (read by IStatusProvider)
|
||
private string _statusMessage = "";
|
||
private StatusLevel _statusLevel = StatusLevel.Undefined;
|
||
}
|