diff --git a/src/Baballonia/Contracts/ICalibrationService.cs b/src/Baballonia/Contracts/ICalibrationService.cs index ca6a50bd..d665e083 100644 --- a/src/Baballonia/Contracts/ICalibrationService.cs +++ b/src/Baballonia/Contracts/ICalibrationService.cs @@ -1,9 +1,15 @@ -using Baballonia.Services.Calibration; +using System; +using System.Threading.Tasks; +using Baballonia.Services.Calibration; namespace Baballonia.Contracts; public interface ICalibrationService { + bool AutoCalibrationEnabled { get; set; } + + event Action? AutoCalibrationReset; + void SetExpression(string expression, float value); CalibrationParameter GetExpressionSettings(string parameterName); @@ -12,5 +18,5 @@ public interface ICalibrationService void ResetValues(); void ResetMinimums(); void ResetMaximums(); - + void ResetAutoCalibration(); } diff --git a/src/Baballonia/Services/CalibrationService.cs b/src/Baballonia/Services/CalibrationService.cs index 2695be4b..391ee6bc 100644 --- a/src/Baballonia/Services/CalibrationService.cs +++ b/src/Baballonia/Services/CalibrationService.cs @@ -1,4 +1,8 @@ -using Baballonia.Contracts; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Baballonia.Contracts; using Baballonia.Services.Calibration; using System.Collections.Concurrent; using System.Collections.Generic; @@ -7,6 +11,8 @@ namespace Baballonia.Services; public class CalibrationService : ICalibrationService { + private const float AutoCalSeed = 0.5f; + // Expression parameter names private readonly Dictionary _eyeExpressionMap = new() { @@ -75,17 +81,74 @@ public class CalibrationService : ICalibrationService { "TongueTwistRight", "/tongueTwistRight" } }; + private static readonly string[] FaceExpressionNames = ParameterSenderService.FaceExpressionMap.Keys.ToArray(); + + // Eye lid expression names and their indices in the eye output array + private static readonly (string Name, int Index)[] EyeLidExpressions = + [ + ("LeftEyeLid", 2), + ("RightEyeLid", 5) + ]; + private readonly ConcurrentDictionary _expressionSettings = new(); private readonly ILocalSettingsService _localSettingsService; + private readonly ProcessingLoopService _processingLoopService; - public CalibrationService(ILocalSettingsService localSettingsService) + public bool AutoCalibrationEnabled { get; set; } + + public event Action? AutoCalibrationReset; + + public CalibrationService(ILocalSettingsService localSettingsService, ProcessingLoopService processingLoopService) { _localSettingsService = localSettingsService; + _processingLoopService = processingLoopService; + + _processingLoopService.ExpressionChangeEvent += OnExpressionChanged; Load(); } + private void OnExpressionChanged(ProcessingLoopService.Expressions expressions) + { + if (!AutoCalibrationEnabled) + return; + + // Auto-calibrate face expressions (indexed same as ParameterSenderService.FaceExpressionMap) + if (expressions.FaceExpression != null) + { + for (var i = 0; i < FaceExpressionNames.Length && i < expressions.FaceExpression.Length; i++) + { + var name = FaceExpressionNames[i]; + var rawValue = expressions.FaceExpression[i]; + if (_expressionSettings.TryGetValue(name, out var param)) + { + if (rawValue > param.Upper) param.Upper = rawValue; + if (rawValue < param.Lower) param.Lower = rawValue; + } + } + } + + // Auto-calibrate eye lid expressions + if (expressions.EyeExpression != null) + { + foreach (var (name, index) in EyeLidExpressions) + { + if (index >= expressions.EyeExpression.Length) + continue; + + var rawValue = expressions.EyeExpression[index]; + if (_expressionSettings.TryGetValue(name, out var param)) + { + if (rawValue > param.Upper) param.Upper = rawValue; + if (rawValue < param.Lower) param.Lower = rawValue; + } + } + } + + SaveAsync(); + } + public void SetExpression(string expression, float value) { if (string.IsNullOrEmpty(expression)) @@ -195,4 +258,29 @@ public void ResetMaximums() } SaveAsync(); } + + public void ResetAutoCalibration() + { + // Reset Lower/Upper to seed values for all face expressions and eye lids + foreach (var name in FaceExpressionNames) + { + if (_expressionSettings.TryGetValue(name, out var param)) + { + param.Lower = AutoCalSeed; + param.Upper = AutoCalSeed; + } + } + + foreach (var (name, _) in EyeLidExpressions) + { + if (_expressionSettings.TryGetValue(name, out var param)) + { + param.Lower = AutoCalSeed; + param.Upper = AutoCalSeed; + } + } + + SaveAsync(); + AutoCalibrationReset?.Invoke(); + } } diff --git a/src/Baballonia/Services/ParameterSenderService.cs b/src/Baballonia/Services/ParameterSenderService.cs index ff56038f..c99cd81b 100644 --- a/src/Baballonia/Services/ParameterSenderService.cs +++ b/src/Baballonia/Services/ParameterSenderService.cs @@ -27,7 +27,7 @@ public class ParameterSenderService : BackgroundService private readonly ConcurrentQueue _dfrQueue = new(); // Expression parameter names - private readonly Dictionary _eyeExpressionMap = new() + public static readonly Dictionary EyeExpressionMap = new() { { "LeftEyeX", "/LeftEyeX" }, { "LeftEyeY", "/LeftEyeY" }, @@ -43,7 +43,7 @@ public class ParameterSenderService : BackgroundService //{ "RightEyeBrow", "/RightEyeBrow" }, }; - public readonly Dictionary FaceExpressionMap = new() + public static readonly Dictionary FaceExpressionMap = new() { { "CheekPuffLeft", "/cheekPuffLeft" }, { "CheekPuffRight", "/cheekPuffRight" }, @@ -113,7 +113,7 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) { _logger.LogDebug("Starting Parameter Sender Service..."); _logger.LogDebug("OSC parameter mapping initialized with {EyeCount} eye expressions and {FaceCount} face expressions", - _eyeExpressionMap.Count, FaceExpressionMap.Count); + EyeExpressionMap.Count, FaceExpressionMap.Count); while (!cancellationToken.IsCancellationRequested) { @@ -145,10 +145,10 @@ private void ProcessEyeExpressionData(float[] expressions) if (expressions is null) return; if (expressions.Length == 0) return; - for (var i = 0; i < Math.Min(expressions.Length, _eyeExpressionMap.Count); i++) + for (var i = 0; i < Math.Min(expressions.Length, EyeExpressionMap.Count); i++) { var weight = expressions[i]; - var eyeElement = _eyeExpressionMap.ElementAt(i); + var eyeElement = EyeExpressionMap.ElementAt(i); var settings = _calibrationService.GetExpressionSettings(eyeElement.Key); var msg = new OscMessage(_prefix + eyeElement.Value, diff --git a/src/Baballonia/ViewModels/SplitViewPane/AppSettingsViewModel.cs b/src/Baballonia/ViewModels/SplitViewPane/AppSettingsViewModel.cs index 93add635..ebba0949 100644 --- a/src/Baballonia/ViewModels/SplitViewPane/AppSettingsViewModel.cs +++ b/src/Baballonia/ViewModels/SplitViewPane/AppSettingsViewModel.cs @@ -84,6 +84,10 @@ public partial class AppSettingsViewModel : ViewModelBase [property: SavedSetting("AppSettings_StabilizeEyes", true)] private bool _stabilizeEyes; + [ObservableProperty] + [property: SavedSetting("AppSettings_AutoCalibrationEnabled", false)] + private bool _autoCalibrationEnabled; + public List LowestLogLevel { get; } = [ "Debug", @@ -98,6 +102,7 @@ public partial class AppSettingsViewModel : ViewModelBase private readonly FacePipelineManager _facePipelineManager; private readonly EyePipelineManager _eyePipelineManager; private readonly IIdentityService _identityService; + private readonly ICalibrationService _calibrationService; public AppSettingsViewModel( FacePipelineManager facePipelineManager, @@ -114,8 +119,12 @@ public AppSettingsViewModel( GithubService = Ioc.Default.GetService()!; SettingsService = Ioc.Default.GetService()!; _logger = Ioc.Default.GetService>()!; + _calibrationService = Ioc.Default.GetService()!; SettingsService.Load(this); + // Sync persisted auto-calibration state to service + _calibrationService.AutoCalibrationEnabled = AutoCalibrationEnabled; + // Handle edge case where OSC port is used and the system freaks out if (OscTarget.OutPort == 0) { @@ -195,6 +204,21 @@ partial void OnSteamvrAutoStartChanged(bool value) } } + partial void OnAutoCalibrationEnabledChanged(bool value) + { + if (value) + { + // Reset seeds BEFORE enabling tracking, so the UI can + // refresh to show 0.5 seed values before they get pushed + _calibrationService.ResetAutoCalibration(); + _calibrationService.AutoCalibrationEnabled = true; + } + else + { + _calibrationService.AutoCalibrationEnabled = false; + } + } + async partial void OnUseGPUChanged(bool value) { var prev = SettingsService.ReadSetting("AppSettings_UseGPU", value); diff --git a/src/Baballonia/ViewModels/SplitViewPane/CalibrationViewModel.cs b/src/Baballonia/ViewModels/SplitViewPane/CalibrationViewModel.cs index b7394e49..08b5ccd6 100644 --- a/src/Baballonia/ViewModels/SplitViewPane/CalibrationViewModel.cs +++ b/src/Baballonia/ViewModels/SplitViewPane/CalibrationViewModel.cs @@ -24,7 +24,6 @@ public partial class CalibrationViewModel : ViewModelBase, IDisposable private ILocalSettingsService _settingsService { get; } private readonly ICalibrationService _calibrationService; - private readonly ParameterSenderService _parameterSenderService; private readonly ProcessingLoopService _processingLoopService; private readonly EyePipelineManager _eyePipelineManager; @@ -35,7 +34,6 @@ public CalibrationViewModel(EyePipelineManager eyePipelineManager) _eyePipelineManager = eyePipelineManager; _settingsService = Ioc.Default.GetService()!; _calibrationService = Ioc.Default.GetService()!; - _parameterSenderService = Ioc.Default.GetService()!; _processingLoopService = Ioc.Default.GetService()!; EyeSettings = @@ -138,7 +136,7 @@ public CalibrationViewModel(EyePipelineManager eyePipelineManager) //{ "RightEyeBrow", }, }; - _faceKeyIndexMap = _parameterSenderService.FaceExpressionMap.Keys + _faceKeyIndexMap = ParameterSenderService.FaceExpressionMap.Keys .Select((key, index) => new { key, index }) .ToDictionary(x => x.key, x => x.index); @@ -154,11 +152,23 @@ public CalibrationViewModel(EyePipelineManager eyePipelineManager) }; _processingLoopService.ExpressionChangeEvent += ExpressionUpdateHandler; + _calibrationService.AutoCalibrationReset += OnAutoCalibrationReset; LoadInitialSettings(); _settingsService.Load(this); } + private void OnAutoCalibrationReset() + { + // Called on the UI thread from the toggle handler. + // Must run synchronously so sliders show the 0.5 seed values + // BEFORE auto-cal tracking is enabled and pushes them. + if (Dispatcher.UIThread.CheckAccess()) + LoadInitialSettings(); + else + Dispatcher.UIThread.Post(LoadInitialSettings); + } + private void ExpressionUpdateHandler(ProcessingLoopService.Expressions expressions) { if(expressions.FaceExpression != null) @@ -198,6 +208,8 @@ private void ApplyCurrentEyeExpressionValues(float[] values, IEnumerable 0.0001f; + var lowerChanged = Math.Abs(setting.Lower - calParam.Lower) > 0.0001f; + + if (!upperChanged && !lowerChanged) + return; + + // Temporarily unhook to avoid circular writes back to CalibrationService + setting.PropertyChanged -= OnSettingChanged; + if (upperChanged) setting.Upper = calParam.Upper; + if (lowerChanged) setting.Lower = calParam.Lower; + setting.PropertyChanged += OnSettingChanged; + } + [RelayCommand] public void ResetMinimums() { diff --git a/src/Baballonia/Views/AppSettingsView.axaml b/src/Baballonia/Views/AppSettingsView.axaml index a916214b..0346faf2 100644 --- a/src/Baballonia/Views/AppSettingsView.axaml +++ b/src/Baballonia/Views/AppSettingsView.axaml @@ -323,6 +323,12 @@ + +