From e7ea5a8ded3860ec9ea20a665f4a7c52c3224474 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 17 Nov 2025 13:14:05 +0200 Subject: [PATCH] big refactoring. get rid of static properties and coupled architecture. prepare system for integration tests --- Azaion.Annotator/Annotator.xaml | 5 +- Azaion.Annotator/Annotator.xaml.cs | 56 ++++++++++++++-- Azaion.Annotator/AnnotatorEventHandler.cs | 29 ++++---- Azaion.Common/Controls/DetectionControl.cs | 2 +- Azaion.Common/DTO/AnnotationThumbnail.cs | 10 ++- Azaion.Common/DTO/Config/AppConfig.cs | 28 ++++---- Azaion.Common/Database/Annotation.cs | 39 ----------- .../Database/AnnotationRepository.cs | 48 +++++++++++++ .../Database/AnnotationsDbSchemaHolder.cs | 5 -- .../Database/IAnnotationRepository.cs | 11 +++ .../ServiceCollectionExtensions.cs | 45 +++++++++++++ .../Services/AnnotationPathResolver.cs | 29 ++++++++ Azaion.Common/Services/AnnotationService.cs | 25 +++++-- .../Services/DetectionClassProvider.cs | 35 ++++++++++ Azaion.Common/Services/FailsafeProducer.cs | 17 ++++- .../Services/FileConfigurationStore.cs | 49 ++++++++++++++ Azaion.Common/Services/GalleryService.cs | 32 +++++---- .../Services/GpsMatcher/GpsMatcherClient.cs | 17 ++--- .../Services/IAnnotationPathResolver.cs | 11 +++ Azaion.Common/Services/IConfigurationStore.cs | 10 +++ .../Services/IDetectionClassProvider.cs | 13 ++++ Azaion.Common/Services/IFileSystem.cs | 17 +++++ Azaion.Common/Services/IMediaPlayerService.cs | 22 ++++++ Azaion.Common/Services/IProcessLauncher.cs | 7 ++ .../Services/IUICommandDispatcher.cs | 9 +++ .../Services/Inference/InferenceClient.cs | 23 ++++--- .../Services/Inference/InferenceService.cs | 8 ++- Azaion.Common/Services/LoaderClient.cs | 35 ++++++---- Azaion.Common/Services/NoOpProcessLauncher.cs | 9 +++ Azaion.Common/Services/PhysicalFileSystem.cs | 56 ++++++++++++++++ Azaion.Common/Services/ProcessLauncher.cs | 20 ++++++ .../Services/WpfUICommandDispatcher.cs | 29 ++++++++ Azaion.Dataset/DatasetExplorer.xaml.cs | 13 +++- Azaion.Dataset/DatasetExplorerEventHandler.cs | 14 ++-- Azaion.Suite/App.xaml.cs | 32 +++++++-- .../Services/VlcMediaPlayerService.cs | 65 ++++++++++++++++++ Azaion.Test/InMemoryFileSystem.cs | 67 +++++++++++++++++++ Azaion.Test/SynchronousUICommandDispatcher.cs | 23 +++++++ 38 files changed, 808 insertions(+), 157 deletions(-) create mode 100644 Azaion.Common/Database/AnnotationRepository.cs create mode 100644 Azaion.Common/Database/IAnnotationRepository.cs create mode 100644 Azaion.Common/Infrastructure/ServiceCollectionExtensions.cs create mode 100644 Azaion.Common/Services/AnnotationPathResolver.cs create mode 100644 Azaion.Common/Services/DetectionClassProvider.cs create mode 100644 Azaion.Common/Services/FileConfigurationStore.cs create mode 100644 Azaion.Common/Services/IAnnotationPathResolver.cs create mode 100644 Azaion.Common/Services/IConfigurationStore.cs create mode 100644 Azaion.Common/Services/IDetectionClassProvider.cs create mode 100644 Azaion.Common/Services/IFileSystem.cs create mode 100644 Azaion.Common/Services/IMediaPlayerService.cs create mode 100644 Azaion.Common/Services/IProcessLauncher.cs create mode 100644 Azaion.Common/Services/IUICommandDispatcher.cs create mode 100644 Azaion.Common/Services/NoOpProcessLauncher.cs create mode 100644 Azaion.Common/Services/PhysicalFileSystem.cs create mode 100644 Azaion.Common/Services/ProcessLauncher.cs create mode 100644 Azaion.Common/Services/WpfUICommandDispatcher.cs create mode 100644 Azaion.Suite/Services/VlcMediaPlayerService.cs create mode 100644 Azaion.Test/InMemoryFileSystem.cs create mode 100644 Azaion.Test/SynchronousUICommandDispatcher.cs diff --git a/Azaion.Annotator/Annotator.xaml b/Azaion.Annotator/Annotator.xaml index 4989fa8..b8dc717 100644 --- a/Azaion.Annotator/Annotator.xaml +++ b/Azaion.Annotator/Annotator.xaml @@ -1,4 +1,4 @@ - + RowStyleSelector="{StaticResource GradientStyleSelector}" + local:GradientStyleSelector.ClassProvider="{Binding ClassProvider, RelativeSource={RelativeSource AncestorType=local:Annotator}}"> _appConfig?.CameraConfig ?? new CameraConfig(); private static readonly Guid ReloadTaskId = Guid.NewGuid(); + private readonly IAnnotationPathResolver _pathResolver; + private readonly IDetectionClassProvider _classProvider; + + public IDetectionClassProvider ClassProvider => _classProvider; + public Annotator( IConfigUpdater configUpdater, IOptions appConfig, @@ -75,8 +80,12 @@ public partial class Annotator IDbFactory dbFactory, IInferenceService inferenceService, IInferenceClient inferenceClient, - IGpsMatcherService gpsMatcherService) + IGpsMatcherService gpsMatcherService, + IAnnotationPathResolver pathResolver, + IDetectionClassProvider classProvider) { + _pathResolver = pathResolver; + _classProvider = classProvider; // Initialize configuration and services BEFORE InitializeComponent so bindings can see real values _appConfig = appConfig.Value; _configUpdater = configUpdater; @@ -270,9 +279,10 @@ public partial class Annotator { Dispatcher.Invoke(async () => { - if (showImage && !annotation.IsSplit && File.Exists(annotation.ImagePath)) + var imagePath = _pathResolver.GetImagePath(annotation); + if (showImage && !annotation.IsSplit && File.Exists(imagePath)) { - Editor.SetBackground(await annotation.ImagePath.OpenImage()); + Editor.SetBackground(await imagePath.OpenImage()); _formState.BackgroundTime = annotation.Time; } @@ -648,11 +658,30 @@ public partial class Annotator public class GradientStyleSelector : StyleSelector { + public static readonly DependencyProperty ClassProviderProperty = DependencyProperty.RegisterAttached( + "ClassProvider", + typeof(IDetectionClassProvider), + typeof(GradientStyleSelector), + new PropertyMetadata(null)); + + public static void SetClassProvider(DependencyObject element, IDetectionClassProvider value) + { + element.SetValue(ClassProviderProperty, value); + } + + public static IDetectionClassProvider GetClassProvider(DependencyObject element) + { + return (IDetectionClassProvider)element.GetValue(ClassProviderProperty); + } + public override Style? SelectStyle(object item, DependencyObject container) { if (container is not DataGridRow row || row.DataContext is not Annotation result) return null; + var dataGrid = FindParent(row); + var classProvider = dataGrid != null ? GetClassProvider(dataGrid) : null; + var style = new Style(typeof(DataGridRow)); var brush = new LinearGradientBrush { @@ -661,16 +690,17 @@ public class GradientStyleSelector : StyleSelector }; var gradients = new List(); - if (result.Colors.Count == 0) + var colors = classProvider?.GetColors(result) ?? []; + if (colors.Count == 0) { var color = (Color)ColorConverter.ConvertFromString("#40DDDDDD"); gradients = [new GradientStop(color, 0.99)]; } else { - var increment = 1.0 / result.Colors.Count; + var increment = 1.0 / colors.Count; var currentStop = increment; - foreach (var c in result.Colors) + foreach (var c in colors) { var resultColor = c.Color.ToConfidenceColor(c.Confidence); brush.GradientStops.Add(new GradientStop(resultColor, currentStop)); @@ -683,4 +713,16 @@ public class GradientStyleSelector : StyleSelector style.Setters.Add(new Setter(Control.BackgroundProperty, brush)); return style; } + + private static T? FindParent(DependencyObject child) where T : DependencyObject + { + var parent = VisualTreeHelper.GetParent(child); + while (parent != null) + { + if (parent is T typedParent) + return typedParent; + parent = VisualTreeHelper.GetParent(parent); + } + return null; + } } diff --git a/Azaion.Annotator/AnnotatorEventHandler.cs b/Azaion.Annotator/AnnotatorEventHandler.cs index a782df8..3e5408f 100644 --- a/Azaion.Annotator/AnnotatorEventHandler.cs +++ b/Azaion.Annotator/AnnotatorEventHandler.cs @@ -36,7 +36,10 @@ public class AnnotatorEventHandler( IInferenceService inferenceService, IDbFactory dbFactory, IAzaionApi api, - FailsafeAnnotationsProducer producer) + FailsafeAnnotationsProducer producer, + IAnnotationPathResolver pathResolver, + IFileSystem fileSystem, + IUICommandDispatcher uiDispatcher) : INotificationHandler, INotificationHandler, @@ -318,7 +321,7 @@ public class AnnotatorEventHandler( var annotationsResult = new List(); var time = formState.BackgroundTime ?? TimeSpan.FromMilliseconds(mediaPlayer.Time); - if (!File.Exists(imgPath)) + if (!fileSystem.FileExists(imgPath)) { if (mediaSize.FitSizeForAI()) await source.SaveImage(imgPath, ct); @@ -339,7 +342,8 @@ public class AnnotatorEventHandler( { var annotationName = $"{formState.CurrentMedia?.Name ?? ""}{Constants.SPLIT_SUFFIX}{res.Tile.Width}_{res.Tile.Left:0000}_{res.Tile.Top:0000}!".ToTimeName(time); - var tileImgPath = Path.Combine(dirConfig.Value.ImagesDirectory, $"{annotationName}{Constants.JPG_EXT}"); + var tempAnnotation = new Annotation { Name = annotationName, ImageExtension = Constants.JPG_EXT }; + var tileImgPath = pathResolver.GetImagePath(tempAnnotation); var bitmap = new CroppedBitmap(source, new Int32Rect((int)res.Tile.Left, (int)res.Tile.Top, (int)res.Tile.Width, (int)res.Tile.Height)); await bitmap.SaveImage(tileImgPath, ct); @@ -367,7 +371,7 @@ public class AnnotatorEventHandler( { try { - mainWindow.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { var namesSet = notification.AnnotationNames.ToHashSet(); @@ -391,10 +395,11 @@ public class AnnotatorEventHandler( { try { - File.Delete(Path.Combine(dirConfig.Value.ImagesDirectory, $"{name}{Constants.JPG_EXT}")); - File.Delete(Path.Combine(dirConfig.Value.LabelsDirectory, $"{name}{Constants.TXT_EXT}")); - File.Delete(Path.Combine(dirConfig.Value.ThumbnailsDirectory, $"{name}{Constants.THUMBNAIL_PREFIX}{Constants.JPG_EXT}")); - File.Delete(Path.Combine(dirConfig.Value.ResultsDirectory, $"{name}{Constants.RESULT_PREFIX}{Constants.JPG_EXT}")); + var tempAnnotation = new Annotation { Name = name, ImageExtension = Constants.JPG_EXT }; + fileSystem.DeleteFile(pathResolver.GetImagePath(tempAnnotation)); + fileSystem.DeleteFile(pathResolver.GetLabelPath(tempAnnotation)); + fileSystem.DeleteFile(pathResolver.GetThumbPath(tempAnnotation)); + fileSystem.DeleteFile(Path.Combine(dirConfig.Value.ResultsDirectory, $"{name}{Constants.RESULT_PREFIX}{Constants.JPG_EXT}")); } catch (Exception e) { @@ -416,7 +421,7 @@ public class AnnotatorEventHandler( public Task Handle(AnnotationAddedEvent e, CancellationToken ct) { - mainWindow.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { var mediaInfo = (MediaFile)mainWindow.LvFiles.SelectedItem; if ((mediaInfo?.Name ?? "") == e.Annotation.OriginalMediaName) @@ -442,7 +447,7 @@ public class AnnotatorEventHandler( public Task Handle(SetStatusTextEvent e, CancellationToken cancellationToken) { - mainWindow.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { mainWindow.StatusHelp.Text = e.Text; mainWindow.StatusHelp.Foreground = e.IsError ? Brushes.Red : Brushes.White; @@ -452,7 +457,7 @@ public class AnnotatorEventHandler( public Task Handle(GPSMatcherResultProcessedEvent e, CancellationToken cancellationToken) { - mainWindow.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { var ann = mainWindow.MapMatcherComponent.Annotations[e.Index]; AddMarker(e.GeoPoint, e.Image, Brushes.Blue); @@ -478,7 +483,7 @@ public class AnnotatorEventHandler( public async Task Handle(AIAvailabilityStatusEvent e, CancellationToken cancellationToken) { - mainWindow.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { logger.LogInformation(e.ToString()); mainWindow.AIDetectBtn.IsEnabled = e.Status == AIAvailabilityEnum.Enabled; diff --git a/Azaion.Common/Controls/DetectionControl.cs b/Azaion.Common/Controls/DetectionControl.cs index 93e92ca..b30d10b 100644 --- a/Azaion.Common/Controls/DetectionControl.cs +++ b/Azaion.Common/Controls/DetectionControl.cs @@ -94,7 +94,7 @@ public class DetectionControl : Border _detectionLabelPanel = new DetectionLabelPanel { Confidence = canvasLabel.Confidence, - DetectionClass = Annotation.DetectionClassesDict[canvasLabel.ClassNumber] + DetectionClass = detectionClass }; DetectionLabelContainer.Children.Add(_detectionLabelPanel); diff --git a/Azaion.Common/DTO/AnnotationThumbnail.cs b/Azaion.Common/DTO/AnnotationThumbnail.cs index 54a1572..3a3ca75 100644 --- a/Azaion.Common/DTO/AnnotationThumbnail.cs +++ b/Azaion.Common/DTO/AnnotationThumbnail.cs @@ -7,10 +7,12 @@ using Azaion.Common.Extensions; namespace Azaion.Common.DTO; -public class AnnotationThumbnail(Annotation annotation, bool isValidator) : INotifyPropertyChanged +public class AnnotationThumbnail(Annotation annotation, bool isValidator, string imagePath, string thumbPath) : INotifyPropertyChanged { public Annotation Annotation { get; set; } = annotation; public bool IsValidator { get; set; } = isValidator; + private readonly string _imagePath = imagePath; + private readonly string _thumbPath = thumbPath; private BitmapImage? _thumbnail; public BitmapImage? Thumbnail @@ -18,7 +20,9 @@ public class AnnotationThumbnail(Annotation annotation, bool isValidator) : INot get { if (_thumbnail == null) - Task.Run(async () => Thumbnail = await Annotation.ThumbPath.OpenImage()); + { + Task.Run(async () => Thumbnail = await _thumbPath.OpenImage()); + } return _thumbnail; } private set @@ -28,7 +32,7 @@ public class AnnotationThumbnail(Annotation annotation, bool isValidator) : INot } } - public string ImageName => Path.GetFileName(Annotation.ImagePath); + public string ImageName => Path.GetFileName(_imagePath); public string CreatedDate => $"{Annotation.CreatedDate:dd.MM.yyyy HH:mm:ss}"; public string CreatedEmail => Annotation.CreatedEmail; public bool IsSeed => IsValidator && diff --git a/Azaion.Common/DTO/Config/AppConfig.cs b/Azaion.Common/DTO/Config/AppConfig.cs index b4017a5..f1a84ba 100644 --- a/Azaion.Common/DTO/Config/AppConfig.cs +++ b/Azaion.Common/DTO/Config/AppConfig.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text; using Azaion.Common.Extensions; +using Azaion.Common.Services; using Newtonsoft.Json; namespace Azaion.Common.DTO.Config; @@ -40,7 +41,16 @@ public interface IConfigUpdater public class ConfigUpdater : IConfigUpdater { - private static readonly Guid SaveConfigTaskId = Guid.NewGuid(); + private readonly IConfigurationStore _configStore; + + public ConfigUpdater(IConfigurationStore configStore) + { + _configStore = configStore; + } + + public ConfigUpdater() : this(new FileConfigurationStore(Constants.CONFIG_PATH, new PhysicalFileSystem())) + { + } public void CheckConfig() { @@ -55,20 +65,6 @@ public class ConfigUpdater : IConfigUpdater public void Save(AppConfig config) { - ThrottleExt.Throttle(async () => - { - var publicConfig = new - { - config.LoaderClientConfig, - config.InferenceClientConfig, - config.GpsDeniedClientConfig, - config.DirectoriesConfig, - config.UIConfig, - config.CameraConfig - }; - - await File.WriteAllTextAsync(Constants.CONFIG_PATH, JsonConvert.SerializeObject(publicConfig, Formatting.Indented), Encoding.UTF8); - }, SaveConfigTaskId, TimeSpan.FromSeconds(5)); - + _ = _configStore.SaveAsync(config); } } diff --git a/Azaion.Common/Database/Annotation.cs b/Azaion.Common/Database/Annotation.cs index fdf1e8d..e35187f 100644 --- a/Azaion.Common/Database/Annotation.cs +++ b/Azaion.Common/Database/Annotation.cs @@ -9,19 +9,6 @@ namespace Azaion.Common.Database; [MessagePackObject] public class Annotation { - private static string _labelsDir = null!; - private static string _imagesDir = null!; - private static string _thumbDir = null!; - public static Dictionary DetectionClassesDict = null!; - - public static void Init(DirectoriesConfig config, Dictionary detectionClassesDict) - { - _labelsDir = config.LabelsDirectory; - _imagesDir = config.ImagesDirectory; - _thumbDir = config.ThumbnailsDirectory; - DetectionClassesDict = detectionClassesDict; - } - [Key("n")] public string Name { get; set; } = null!; [Key("hash")] public string MediaHash { get; set; } = null!; [Key("mn")] public string OriginalMediaName { get; set; } = null!; @@ -44,9 +31,6 @@ public class Annotation #region Calculated [IgnoreMember] public List Classes => Detections.Select(x => x.ClassNumber).ToList(); - [IgnoreMember] public string ImagePath => Path.Combine(_imagesDir, $"{Name}{ImageExtension}"); - [IgnoreMember] public string LabelPath => Path.Combine(_labelsDir, $"{Name}.txt"); - [IgnoreMember] public string ThumbPath => Path.Combine(_thumbDir, $"{Name}{Constants.THUMBNAIL_PREFIX}.jpg"); [IgnoreMember] public bool IsSplit => Name.Contains(Constants.SPLIT_SUFFIX); private CanvasLabel? _splitTile; @@ -73,30 +57,7 @@ public class Annotation } [IgnoreMember] public string TimeStr => $"{Time:h\\:mm\\:ss}"; - - private List<(Color Color, double Confidence)>? _colors; - [IgnoreMember] public List<(Color Color, double Confidence)> Colors => _colors ??= Detections - .Select(d => (DetectionClassesDict[d.ClassNumber].Color, d.Confidence)) - .ToList(); - - private string? _className; - [IgnoreMember] public string ClassName - { - get - { - if (string.IsNullOrEmpty(_className)) - { - var detectionClasses = Detections.Select(x => x.ClassNumber).Distinct().ToList(); - _className = detectionClasses.Count > 1 - ? string.Join(", ", detectionClasses.Select(x => DetectionClassesDict[x].UIName)) - : DetectionClassesDict[detectionClasses.FirstOrDefault()].UIName; - } - return _className; - } - } #endregion Calculated - - public override string ToString() => $"Annotation: {Name}{TimeStr}: {ClassName}"; } [MessagePackObject] diff --git a/Azaion.Common/Database/AnnotationRepository.cs b/Azaion.Common/Database/AnnotationRepository.cs new file mode 100644 index 0000000..8787989 --- /dev/null +++ b/Azaion.Common/Database/AnnotationRepository.cs @@ -0,0 +1,48 @@ +using LinqToDB; + +namespace Azaion.Common.Database; + +public class AnnotationRepository : IAnnotationRepository +{ + private readonly IDbFactory _dbFactory; + + public AnnotationRepository(IDbFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task> GetByMediaHashAsync(string hash, CancellationToken ct = default) + { + return await _dbFactory.Run(async db => + await db.GetTable() + .Where(x => x.MediaHash == hash) + .ToListAsync(ct)); + } + + public async Task GetByNameAsync(string name, CancellationToken ct = default) + { + return await _dbFactory.Run(async db => + await db.GetTable() + .FirstOrDefaultAsync(x => x.Name == name, ct)); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _dbFactory.Run(async db => + await db.GetTable().ToListAsync(ct)); + } + + public async Task SaveAsync(Annotation annotation, CancellationToken ct = default) + { + await _dbFactory.RunWrite(async db => + { + await db.InsertOrReplaceAsync(annotation, token: ct); + }); + } + + public async Task DeleteAsync(List names, CancellationToken ct = default) + { + await _dbFactory.DeleteAnnotations(names, ct); + } +} + diff --git a/Azaion.Common/Database/AnnotationsDbSchemaHolder.cs b/Azaion.Common/Database/AnnotationsDbSchemaHolder.cs index 2698687..f9d97f6 100644 --- a/Azaion.Common/Database/AnnotationsDbSchemaHolder.cs +++ b/Azaion.Common/Database/AnnotationsDbSchemaHolder.cs @@ -24,11 +24,6 @@ public static class AnnotationsDbSchemaHolder annotationBuilder .Ignore(x => x.Milliseconds) .Ignore(x => x.Classes) - .Ignore(x => x.ImagePath) - .Ignore(x => x.LabelPath) - .Ignore(x => x.ThumbPath) - .Ignore(x => x.ClassName) - .Ignore(x => x.Colors) .Ignore(x => x.SplitTile) .Ignore(x => x.IsSplit) .Ignore(x => x.TimeStr); diff --git a/Azaion.Common/Database/IAnnotationRepository.cs b/Azaion.Common/Database/IAnnotationRepository.cs new file mode 100644 index 0000000..27f7b7a --- /dev/null +++ b/Azaion.Common/Database/IAnnotationRepository.cs @@ -0,0 +1,11 @@ +namespace Azaion.Common.Database; + +public interface IAnnotationRepository +{ + Task> GetByMediaHashAsync(string hash, CancellationToken ct = default); + Task GetByNameAsync(string name, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task SaveAsync(Annotation annotation, CancellationToken ct = default); + Task DeleteAsync(List names, CancellationToken ct = default); +} + diff --git a/Azaion.Common/Infrastructure/ServiceCollectionExtensions.cs b/Azaion.Common/Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0219d2f --- /dev/null +++ b/Azaion.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,45 @@ +using Azaion.Common.Database; +using Azaion.Common.DTO; +using Azaion.Common.DTO.Config; +using Azaion.Common.Services; +using Azaion.Common.Services.Inference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Azaion.Common.Infrastructure; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAzaionInfrastructure(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + new AnnotationPathResolver(sp.GetRequiredService>().Value)); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + public static IServiceCollection AddAzaionConfiguration(this IServiceCollection services) + { + services.AddSingleton(sp => + new FileConfigurationStore(Constants.CONFIG_PATH, sp.GetRequiredService())); + services.AddSingleton(); + + return services; + } + + public static IServiceCollection AddAzaionServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} + diff --git a/Azaion.Common/Services/AnnotationPathResolver.cs b/Azaion.Common/Services/AnnotationPathResolver.cs new file mode 100644 index 0000000..7064ef3 --- /dev/null +++ b/Azaion.Common/Services/AnnotationPathResolver.cs @@ -0,0 +1,29 @@ +using System.IO; +using Azaion.Common.Database; +using Azaion.Common.DTO; + +namespace Azaion.Common.Services; + +public class AnnotationPathResolver : IAnnotationPathResolver +{ + private readonly string _labelsDir; + private readonly string _imagesDir; + private readonly string _thumbDir; + + public AnnotationPathResolver(DirectoriesConfig config) + { + _labelsDir = config.LabelsDirectory; + _imagesDir = config.ImagesDirectory; + _thumbDir = config.ThumbnailsDirectory; + } + + public string GetImagePath(Annotation annotation) => + Path.Combine(_imagesDir, $"{annotation.Name}{annotation.ImageExtension}"); + + public string GetLabelPath(Annotation annotation) => + Path.Combine(_labelsDir, $"{annotation.Name}.txt"); + + public string GetThumbPath(Annotation annotation) => + Path.Combine(_thumbDir, $"{annotation.Name}{Constants.THUMBNAIL_PREFIX}.jpg"); +} + diff --git a/Azaion.Common/Services/AnnotationService.cs b/Azaion.Common/Services/AnnotationService.cs index f89e6d2..402d1bf 100644 --- a/Azaion.Common/Services/AnnotationService.cs +++ b/Azaion.Common/Services/AnnotationService.cs @@ -33,6 +33,8 @@ public class AnnotationService : IAnnotationService private readonly QueueConfig _queueConfig; private Consumer _consumer = null!; private readonly UIConfig _uiConfig; + private readonly IAnnotationPathResolver _pathResolver; + private readonly IFileSystem _fileSystem; private readonly SemaphoreSlim _imageAccessSemaphore = new(1, 1); private readonly SemaphoreSlim _messageProcessingSemaphore = new(1, 1); private static readonly Guid SaveQueueOffsetTaskId = Guid.NewGuid(); @@ -46,7 +48,9 @@ public class AnnotationService : IAnnotationService IGalleryService galleryService, IMediator mediator, IAzaionApi api, - ILogger logger) + ILogger logger, + IAnnotationPathResolver pathResolver, + IFileSystem fileSystem) { _dbFactory = dbFactory; _producer = producer; @@ -56,8 +60,13 @@ public class AnnotationService : IAnnotationService _logger = logger; _queueConfig = queueConfig.Value; _uiConfig = uiConfig.Value; + _pathResolver = pathResolver; + _fileSystem = fileSystem; + } - Task.Run(async () => await InitQueueConsumer()).Wait(); + public async Task StartQueueConsumerAsync(CancellationToken token = default) + { + await InitQueueConsumer(token); } private async Task InitQueueConsumer(CancellationToken token = default) @@ -221,13 +230,14 @@ public class AnnotationService : IAnnotationService Image image = null!; if (stream != null) { + var imagePath = _pathResolver.GetImagePath(annotation); image = Image.FromStream(stream); - if (File.Exists(annotation.ImagePath)) - ResilienceExt.WithRetry(() => File.Delete(annotation.ImagePath)); - image.Save(annotation.ImagePath, ImageFormat.Jpeg); + if (_fileSystem.FileExists(imagePath)) + ResilienceExt.WithRetry(() => _fileSystem.DeleteFile(imagePath)); + image.Save(imagePath, ImageFormat.Jpeg); } - await YoloLabel.WriteToFile(detections, annotation.LabelPath, token); + await YoloLabel.WriteToFile(detections, _pathResolver.GetLabelPath(annotation), token); await _galleryService.CreateThumbnail(annotation, image, token); if (_uiConfig.GenerateAnnotatedImage) @@ -235,7 +245,7 @@ public class AnnotationService : IAnnotationService } catch (Exception e) { - _logger.LogError(e, $"Try to save {annotation.ImagePath}, Error: {e.Message}"); + _logger.LogError(e, $"Try to save {_pathResolver.GetImagePath(annotation)}, Error: {e.Message}"); throw; } finally @@ -274,6 +284,7 @@ public class AnnotationService : IAnnotationService public interface IAnnotationService { + Task StartQueueConsumerAsync(CancellationToken token = default); Task SaveAnnotation(AnnotationImage a, CancellationToken ct = default); Task SaveAnnotation(string mediaHash, string originalMediaName, string annotationName, TimeSpan time, List detections, Stream? stream = null, CancellationToken token = default); Task ValidateAnnotations(List annotationNames, bool fromQueue = false, CancellationToken token = default); diff --git a/Azaion.Common/Services/DetectionClassProvider.cs b/Azaion.Common/Services/DetectionClassProvider.cs new file mode 100644 index 0000000..b5066a1 --- /dev/null +++ b/Azaion.Common/Services/DetectionClassProvider.cs @@ -0,0 +1,35 @@ +using System.Windows.Media; +using Azaion.Common.Database; +using Azaion.Common.DTO; +using Azaion.Common.DTO.Config; +using Microsoft.Extensions.Options; + +namespace Azaion.Common.Services; + +public class DetectionClassProvider : IDetectionClassProvider +{ + private readonly Dictionary _detectionClasses; + + public DetectionClassProvider(IOptions annotationConfig) + { + _detectionClasses = annotationConfig.Value.DetectionClassesDict; + } + + public Dictionary GetDetectionClasses() => _detectionClasses; + + public List<(Color Color, double Confidence)> GetColors(Annotation annotation) + { + return annotation.Detections + .Select(d => (_detectionClasses[d.ClassNumber].Color, d.Confidence)) + .ToList(); + } + + public string GetClassName(Annotation annotation) + { + var detectionClasses = annotation.Detections.Select(x => x.ClassNumber).Distinct().ToList(); + return detectionClasses.Count > 1 + ? string.Join(", ", detectionClasses.Select(x => _detectionClasses[x].UIName)) + : _detectionClasses[detectionClasses.FirstOrDefault()].UIName; + } +} + diff --git a/Azaion.Common/Services/FailsafeProducer.cs b/Azaion.Common/Services/FailsafeProducer.cs index 96f588d..c6c49a2 100644 --- a/Azaion.Common/Services/FailsafeProducer.cs +++ b/Azaion.Common/Services/FailsafeProducer.cs @@ -23,6 +23,8 @@ public class FailsafeAnnotationsProducer private readonly IAzaionApi _azaionApi; private readonly QueueConfig _queueConfig; private readonly UIConfig _uiConfig; + private readonly IAnnotationPathResolver _pathResolver; + private readonly IFileSystem _fileSystem; private Producer _annotationProducer = null!; @@ -31,14 +33,23 @@ public class FailsafeAnnotationsProducer IDbFactory dbFactory, IOptions queueConfig, IOptions uiConfig, - IAzaionApi azaionApi) + IAzaionApi azaionApi, + IAnnotationPathResolver pathResolver, + IFileSystem fileSystem) { _logger = logger; _dbFactory = dbFactory; _azaionApi = azaionApi; _queueConfig = queueConfig.Value; _uiConfig = uiConfig.Value; - Task.Run(async () => await ProcessQueue()); + _pathResolver = pathResolver; + _fileSystem = fileSystem; + } + + public async Task StartAsync(CancellationToken ct = default) + { + _ = Task.Run(async () => await ProcessQueue(ct), ct); + await Task.CompletedTask; } private async Task GetProducerQueueConfig() @@ -104,7 +115,7 @@ public class FailsafeAnnotationsProducer continue; var image = record.Operation == AnnotationStatus.Created - ? await File.ReadAllBytesAsync(annotation.ImagePath, ct) + ? await _fileSystem.ReadAllBytesAsync(_pathResolver.GetImagePath(annotation), ct) : null; var annMessage = new AnnotationMessage diff --git a/Azaion.Common/Services/FileConfigurationStore.cs b/Azaion.Common/Services/FileConfigurationStore.cs new file mode 100644 index 0000000..0eb3248 --- /dev/null +++ b/Azaion.Common/Services/FileConfigurationStore.cs @@ -0,0 +1,49 @@ +using System.Text; +using Azaion.Common.DTO.Config; +using Azaion.Common.Extensions; +using Newtonsoft.Json; + +namespace Azaion.Common.Services; + +public class FileConfigurationStore : IConfigurationStore +{ + private readonly string _configPath; + private readonly IFileSystem _fileSystem; + private static readonly Guid SaveConfigTaskId = Guid.NewGuid(); + + public FileConfigurationStore(string configPath, IFileSystem fileSystem) + { + _configPath = configPath; + _fileSystem = fileSystem; + } + + public async Task LoadAsync(CancellationToken ct = default) + { + if (!_fileSystem.FileExists(_configPath)) + return new AppConfig(); + + var json = Encoding.UTF8.GetString(await _fileSystem.ReadAllBytesAsync(_configPath, ct)); + return JsonConvert.DeserializeObject(json) ?? new AppConfig(); + } + + public Task SaveAsync(AppConfig config, CancellationToken ct = default) + { + ThrottleExt.Throttle(async () => + { + var publicConfig = new + { + config.LoaderClientConfig, + config.InferenceClientConfig, + config.GpsDeniedClientConfig, + config.DirectoriesConfig, + config.UIConfig, + config.CameraConfig + }; + var json = JsonConvert.SerializeObject(publicConfig, Formatting.Indented); + await _fileSystem.WriteAllBytesAsync(_configPath, Encoding.UTF8.GetBytes(json), ct); + }, SaveConfigTaskId, TimeSpan.FromSeconds(5)); + + return Task.CompletedTask; + } +} + diff --git a/Azaion.Common/Services/GalleryService.cs b/Azaion.Common/Services/GalleryService.cs index bb1e611..ce87c9b 100644 --- a/Azaion.Common/Services/GalleryService.cs +++ b/Azaion.Common/Services/GalleryService.cs @@ -26,11 +26,15 @@ public class GalleryService( IOptions thumbnailConfig, IOptions annotationConfig, ILogger logger, - IDbFactory dbFactory) : IGalleryService + IDbFactory dbFactory, + IAnnotationPathResolver pathResolver, + IFileSystem fileSystem) : IGalleryService { private readonly DirectoriesConfig _dirConfig = directoriesConfig.Value; private readonly ThumbnailConfig _thumbnailConfig = thumbnailConfig.Value; private readonly AnnotationConfig _annotationConfig = annotationConfig.Value; + private readonly IAnnotationPathResolver _pathResolver = pathResolver; + private readonly IFileSystem _fileSystem = fileSystem; public event ThumbnailsUpdatedEventHandler? ThumbnailsUpdate; @@ -58,8 +62,9 @@ public class GalleryService( public async Task ClearThumbnails(CancellationToken cancellationToken = default) { - foreach(var file in new DirectoryInfo(_dirConfig.ThumbnailsDirectory).GetFiles()) - file.Delete(); + var thumbDir = _fileSystem.GetDirectoryInfo(_dirConfig.ThumbnailsDirectory); + foreach(var file in thumbDir.GetFiles()) + _fileSystem.DeleteFile(file.FullName); await dbFactory.RunWrite(async db => { await db.Detections.DeleteAsync(x => true, token: cancellationToken); @@ -83,7 +88,8 @@ public class GalleryService( .Select(gr => gr.Key) .ToHashSet(); - var files = new DirectoryInfo(_dirConfig.ImagesDirectory).GetFiles(); + var imagesDir = _fileSystem.GetDirectoryInfo(_dirConfig.ImagesDirectory); + var files = imagesDir.GetFiles(); var imagesCount = files.Length; await ParallelExt.ForEachAsync(files, async (file, cancellationToken) => @@ -92,9 +98,9 @@ public class GalleryService( try { var labelName = Path.Combine(_dirConfig.LabelsDirectory, $"{fName}.txt"); - if (!File.Exists(labelName)) + if (!_fileSystem.FileExists(labelName)) { - File.Delete(file.FullName); + _fileSystem.DeleteFile(file.FullName); logger.LogInformation($"No labels found for image {file.FullName}! Image deleted!"); await dbFactory.DeleteAnnotations([fName], cancellationToken); return; @@ -213,7 +219,7 @@ public class GalleryService( var width = (int)_thumbnailConfig.Size.Width; var height = (int)_thumbnailConfig.Size.Height; - originalImage ??= Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(annotation.ImagePath, cancellationToken))); + originalImage ??= Image.FromStream(new MemoryStream(await _fileSystem.ReadAllBytesAsync(_pathResolver.GetImagePath(annotation), cancellationToken))); var bitmap = new Bitmap(width, height); @@ -273,7 +279,7 @@ public class GalleryService( g.DrawRectangle(new Pen(brush, width: 3), (float)((label.Left - frameX) / scale), (float)((label.Top - frameY) / scale), (float)(label.Width / scale), (float)(label.Height / scale)); } - bitmap.Save(annotation.ThumbPath, ImageFormat.Jpeg); + bitmap.Save(_pathResolver.GetThumbPath(annotation), ImageFormat.Jpeg); } catch (Exception e) { @@ -282,7 +288,7 @@ public class GalleryService( } public async Task CreateAnnotatedImage(Annotation annotation, Image? originalImage = null, CancellationToken token = default) { - originalImage ??= Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(annotation.ImagePath, token))); + originalImage ??= Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(_pathResolver.GetImagePath(annotation), token))); using var g = Graphics.FromImage(originalImage); foreach (var detection in annotation.Detections) @@ -297,11 +303,11 @@ public class GalleryService( g.DrawTextBox(label, new PointF((float)(det.Left + det.Width / 2.0), (float)(det.Top - 24)), brush, Brushes.Black); } - var imagePath = Path.Combine(_dirConfig.ResultsDirectory, $"{annotation.Name}{Constants.RESULT_PREFIX}.jpg"); - if (File.Exists(imagePath)) - ResilienceExt.WithRetry(() => File.Delete(imagePath)); + var resultPath = Path.Combine(_dirConfig.ResultsDirectory, $"{annotation.Name}{Constants.RESULT_PREFIX}.jpg"); + if (_fileSystem.FileExists(resultPath)) + ResilienceExt.WithRetry(() => _fileSystem.DeleteFile(resultPath)); - originalImage.Save(imagePath, ImageFormat.Jpeg); + originalImage.Save(resultPath, ImageFormat.Jpeg); } } diff --git a/Azaion.Common/Services/GpsMatcher/GpsMatcherClient.cs b/Azaion.Common/Services/GpsMatcher/GpsMatcherClient.cs index 25100c4..aa772b9 100644 --- a/Azaion.Common/Services/GpsMatcher/GpsMatcherClient.cs +++ b/Azaion.Common/Services/GpsMatcher/GpsMatcherClient.cs @@ -34,27 +34,24 @@ public class GpsMatcherClient : IGpsMatcherClient { private readonly IMediator _mediator; private readonly ILogger _logger; + private readonly IProcessLauncher _processLauncher; private readonly string _requestAddress; private readonly RequestSocket _requestSocket = new(); private readonly string _subscriberAddress; private readonly SubscriberSocket _subscriberSocket = new(); private readonly NetMQPoller _poller = new(); - public GpsMatcherClient(IMediator mediator, IOptions gpsConfig, ILogger logger) + public GpsMatcherClient(IMediator mediator, IOptions gpsConfig, ILogger logger, IProcessLauncher processLauncher) { _mediator = mediator; _logger = logger; + _processLauncher = processLauncher; try { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = Constants.ExternalGpsDeniedPath, - Arguments = $"zeromq --rep {gpsConfig.Value.ZeroMqPort} --pub {gpsConfig.Value.ZeroMqReceiverPort}", - WorkingDirectory = Constants.EXTERNAL_GPS_DENIED_FOLDER, - CreateNoWindow = true - }; - process.Start(); + _processLauncher.Launch( + Constants.ExternalGpsDeniedPath, + $"zeromq --rep {gpsConfig.Value.ZeroMqPort} --pub {gpsConfig.Value.ZeroMqReceiverPort}", + Constants.EXTERNAL_GPS_DENIED_FOLDER); } catch (Exception e) { diff --git a/Azaion.Common/Services/IAnnotationPathResolver.cs b/Azaion.Common/Services/IAnnotationPathResolver.cs new file mode 100644 index 0000000..8da1e62 --- /dev/null +++ b/Azaion.Common/Services/IAnnotationPathResolver.cs @@ -0,0 +1,11 @@ +using Azaion.Common.Database; + +namespace Azaion.Common.Services; + +public interface IAnnotationPathResolver +{ + string GetImagePath(Annotation annotation); + string GetLabelPath(Annotation annotation); + string GetThumbPath(Annotation annotation); +} + diff --git a/Azaion.Common/Services/IConfigurationStore.cs b/Azaion.Common/Services/IConfigurationStore.cs new file mode 100644 index 0000000..c2dde5f --- /dev/null +++ b/Azaion.Common/Services/IConfigurationStore.cs @@ -0,0 +1,10 @@ +using Azaion.Common.DTO.Config; + +namespace Azaion.Common.Services; + +public interface IConfigurationStore +{ + Task LoadAsync(CancellationToken ct = default); + Task SaveAsync(AppConfig config, CancellationToken ct = default); +} + diff --git a/Azaion.Common/Services/IDetectionClassProvider.cs b/Azaion.Common/Services/IDetectionClassProvider.cs new file mode 100644 index 0000000..51098b6 --- /dev/null +++ b/Azaion.Common/Services/IDetectionClassProvider.cs @@ -0,0 +1,13 @@ +using System.Windows.Media; +using Azaion.Common.Database; +using Azaion.Common.DTO; + +namespace Azaion.Common.Services; + +public interface IDetectionClassProvider +{ + Dictionary GetDetectionClasses(); + List<(Color Color, double Confidence)> GetColors(Annotation annotation); + string GetClassName(Annotation annotation); +} + diff --git a/Azaion.Common/Services/IFileSystem.cs b/Azaion.Common/Services/IFileSystem.cs new file mode 100644 index 0000000..b9f0b3d --- /dev/null +++ b/Azaion.Common/Services/IFileSystem.cs @@ -0,0 +1,17 @@ +using System.IO; + +namespace Azaion.Common.Services; + +public interface IFileSystem +{ + Task ReadAllBytesAsync(string path, CancellationToken ct = default); + Task WriteAllBytesAsync(string path, byte[] content, CancellationToken ct = default); + bool FileExists(string path); + void DeleteFile(string path); + IEnumerable GetFiles(string directory, string searchPattern); + IEnumerable GetFileInfos(string directory, string[] searchPatterns); + DirectoryInfo GetDirectoryInfo(string path); + bool DirectoryExists(string path); + void CreateDirectory(string path); +} + diff --git a/Azaion.Common/Services/IMediaPlayerService.cs b/Azaion.Common/Services/IMediaPlayerService.cs new file mode 100644 index 0000000..1b3b601 --- /dev/null +++ b/Azaion.Common/Services/IMediaPlayerService.cs @@ -0,0 +1,22 @@ +namespace Azaion.Common.Services; + +public interface IMediaPlayerService +{ + void Play(); + void Pause(); + void Stop(); + long Time { get; set; } + float Position { get; set; } + int Volume { get; set; } + bool IsPlaying { get; } + long Length { get; } + void SetMedia(string mediaPath); + void TakeSnapshot(uint num, string path, uint width, uint height); + + event EventHandler? Playing; + event EventHandler? Paused; + event EventHandler? Stopped; + event EventHandler? PositionChanged; + event EventHandler? LengthChanged; +} + diff --git a/Azaion.Common/Services/IProcessLauncher.cs b/Azaion.Common/Services/IProcessLauncher.cs new file mode 100644 index 0000000..a41249f --- /dev/null +++ b/Azaion.Common/Services/IProcessLauncher.cs @@ -0,0 +1,7 @@ +namespace Azaion.Common.Services; + +public interface IProcessLauncher +{ + void Launch(string fileName, string arguments, string? workingDirectory = null); +} + diff --git a/Azaion.Common/Services/IUICommandDispatcher.cs b/Azaion.Common/Services/IUICommandDispatcher.cs new file mode 100644 index 0000000..f22d4ff --- /dev/null +++ b/Azaion.Common/Services/IUICommandDispatcher.cs @@ -0,0 +1,9 @@ +namespace Azaion.Common.Services; + +public interface IUICommandDispatcher +{ + Task ExecuteAsync(Action action); + Task ExecuteAsync(Func func); + void Execute(Action action); +} + diff --git a/Azaion.Common/Services/Inference/InferenceClient.cs b/Azaion.Common/Services/Inference/InferenceClient.cs index 769366e..b383f13 100644 --- a/Azaion.Common/Services/Inference/InferenceClient.cs +++ b/Azaion.Common/Services/Inference/InferenceClient.cs @@ -20,6 +20,8 @@ public interface IInferenceClient : IDisposable public class InferenceClient : IInferenceClient { private readonly ILogger _logger; + private readonly IProcessLauncher _processLauncher; + private readonly IDetectionClassProvider _classProvider; private readonly DealerSocket _dealer = new(); private readonly NetMQPoller _poller = new(); @@ -30,12 +32,16 @@ public class InferenceClient : IInferenceClient public InferenceClient(ILogger logger, IOptions inferenceConfig, IMediator mediator, - IOptions loaderConfig) + IOptions loaderConfig, + IProcessLauncher processLauncher, + IDetectionClassProvider classProvider) { _logger = logger; _inferenceClientConfig = inferenceConfig.Value; _loaderClientConfig = loaderConfig.Value; _mediator = mediator; + _processLauncher = processLauncher; + _classProvider = classProvider; Start(); } @@ -43,14 +49,9 @@ public class InferenceClient : IInferenceClient { try { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = Constants.EXTERNAL_INFERENCE_PATH, - Arguments = $"-p {_inferenceClientConfig.ZeroMqPort} -lp {_loaderClientConfig.ZeroMqPort} -a {_inferenceClientConfig.ApiUrl}", - CreateNoWindow = true - }; - process.Start(); + _processLauncher.Launch( + Constants.EXTERNAL_INFERENCE_PATH, + $"-p {_inferenceClientConfig.ZeroMqPort} -lp {_loaderClientConfig.ZeroMqPort} -a {_inferenceClientConfig.ApiUrl}"); } catch (Exception e) { @@ -77,7 +78,9 @@ public class InferenceClient : IInferenceClient { case CommandType.InferenceData: var annotationImage = MessagePackSerializer.Deserialize(remoteCommand.Data, cancellationToken: ct); - _logger.LogInformation("Received command: {AnnotationImage}", annotationImage.ToString()); + var className = _classProvider.GetClassName(annotationImage); + _logger.LogInformation("Received command: Annotation {Name} {Time} - {ClassName}", + annotationImage.Name, annotationImage.TimeStr, className); await _mediator.Publish(new InferenceDataEvent(annotationImage), ct); break; case CommandType.InferenceStatus: diff --git a/Azaion.Common/Services/Inference/InferenceService.cs b/Azaion.Common/Services/Inference/InferenceService.cs index 542aeda..46a6f12 100644 --- a/Azaion.Common/Services/Inference/InferenceService.cs +++ b/Azaion.Common/Services/Inference/InferenceService.cs @@ -7,6 +7,7 @@ namespace Azaion.Common.Services.Inference; public interface IInferenceService { + Task StartAsync(); Task RunInference(List mediaPaths, CameraConfig cameraConfig, CancellationToken ct = default); CancellationTokenSource InferenceCancelTokenSource { get; set; } CancellationTokenSource CheckAIAvailabilityTokenSource { get; set; } @@ -28,12 +29,17 @@ public class InferenceService : IInferenceService _client = client; _azaionApi = azaionApi; _aiConfigOptions = aiConfigOptions; - _ = Task.Run(async () => await CheckAIAvailabilityStatus()); } public CancellationTokenSource InferenceCancelTokenSource { get; set; } = new(); public CancellationTokenSource CheckAIAvailabilityTokenSource { get; set; } = new(); + public async Task StartAsync() + { + _ = Task.Run(async () => await CheckAIAvailabilityStatus()); + await Task.CompletedTask; + } + private async Task CheckAIAvailabilityStatus() { CheckAIAvailabilityTokenSource = new CancellationTokenSource(); diff --git a/Azaion.Common/Services/LoaderClient.cs b/Azaion.Common/Services/LoaderClient.cs index f6c2ac4..e4bdff6 100644 --- a/Azaion.Common/Services/LoaderClient.cs +++ b/Azaion.Common/Services/LoaderClient.cs @@ -10,27 +10,34 @@ using Exception = System.Exception; namespace Azaion.Common.Services; -public class LoaderClient(LoaderClientConfig config, ILogger logger, CancellationToken ct = default) : IDisposable +public class LoaderClient : IDisposable { + private readonly LoaderClientConfig _config; + private readonly ILogger _logger; + private readonly CancellationToken _ct; + private readonly IProcessLauncher _processLauncher; private readonly DealerSocket _dealer = new(); private readonly Guid _clientId = Guid.NewGuid(); + public LoaderClient(LoaderClientConfig config, ILogger logger, IProcessLauncher processLauncher, CancellationToken ct = default) + { + _config = config; + _logger = logger; + _processLauncher = processLauncher; + _ct = ct; + } + public void StartClient() { try { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = Constants.EXTERNAL_LOADER_PATH, - Arguments = $"--port {config.ZeroMqPort} --api {config.ApiUrl}", - CreateNoWindow = true - }; - process.Start(); + _processLauncher.Launch( + Constants.EXTERNAL_LOADER_PATH, + $"--port {_config.ZeroMqPort} --api {_config.ApiUrl}"); } catch (Exception e) { - logger.Error(e, e.Message); + _logger.Error(e, e.Message); throw; } } @@ -38,7 +45,7 @@ public class LoaderClient(LoaderClientConfig config, ILogger logger, Cancellatio public void Connect() { _dealer.Options.Identity = Encoding.UTF8.GetBytes(_clientId.ToString("N")); - _dealer.Connect($"tcp://{config.ZeroMqHost}:{config.ZeroMqPort}"); + _dealer.Connect($"tcp://{_config.ZeroMqHost}:{_config.ZeroMqPort}"); } public void Login(ApiCredentials credentials) @@ -63,11 +70,11 @@ public class LoaderClient(LoaderClientConfig config, ILogger logger, Cancellatio _dealer.SendFrame(MessagePackSerializer.Serialize(command)); var tryNum = 0; - while (!ct.IsCancellationRequested && tryNum++ < retryCount) + while (!_ct.IsCancellationRequested && tryNum++ < retryCount) { if (!_dealer.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(retryDelayMs), out var bytes)) continue; - var res = MessagePackSerializer.Deserialize(bytes, cancellationToken: ct); + var res = MessagePackSerializer.Deserialize(bytes, cancellationToken: _ct); if (res.CommandType == CommandType.Error) throw new Exception(res.Message); return res; @@ -77,7 +84,7 @@ public class LoaderClient(LoaderClientConfig config, ILogger logger, Cancellatio } catch (Exception e) { - logger.Error(e, e.Message); + _logger.Error(e, e.Message); throw; } } diff --git a/Azaion.Common/Services/NoOpProcessLauncher.cs b/Azaion.Common/Services/NoOpProcessLauncher.cs new file mode 100644 index 0000000..ac3947a --- /dev/null +++ b/Azaion.Common/Services/NoOpProcessLauncher.cs @@ -0,0 +1,9 @@ +namespace Azaion.Common.Services; + +public class NoOpProcessLauncher : IProcessLauncher +{ + public void Launch(string fileName, string arguments, string? workingDirectory = null) + { + } +} + diff --git a/Azaion.Common/Services/PhysicalFileSystem.cs b/Azaion.Common/Services/PhysicalFileSystem.cs new file mode 100644 index 0000000..e96e81e --- /dev/null +++ b/Azaion.Common/Services/PhysicalFileSystem.cs @@ -0,0 +1,56 @@ +using System.IO; + +namespace Azaion.Common.Services; + +public class PhysicalFileSystem : IFileSystem +{ + public Task ReadAllBytesAsync(string path, CancellationToken ct = default) + { + return File.ReadAllBytesAsync(path, ct); + } + + public Task WriteAllBytesAsync(string path, byte[] content, CancellationToken ct = default) + { + return File.WriteAllBytesAsync(path, content, ct); + } + + public bool FileExists(string path) + { + return File.Exists(path); + } + + public void DeleteFile(string path) + { + File.Delete(path); + } + + public IEnumerable GetFiles(string directory, string searchPattern) + { + return Directory.GetFiles(directory, searchPattern); + } + + public IEnumerable GetFileInfos(string directory, string[] searchPatterns) + { + var dir = new DirectoryInfo(directory); + if (!dir.Exists) + return Enumerable.Empty(); + + return searchPatterns.SelectMany(pattern => dir.GetFiles(pattern)); + } + + public DirectoryInfo GetDirectoryInfo(string path) + { + return new DirectoryInfo(path); + } + + public bool DirectoryExists(string path) + { + return Directory.Exists(path); + } + + public void CreateDirectory(string path) + { + Directory.CreateDirectory(path); + } +} + diff --git a/Azaion.Common/Services/ProcessLauncher.cs b/Azaion.Common/Services/ProcessLauncher.cs new file mode 100644 index 0000000..f98bf24 --- /dev/null +++ b/Azaion.Common/Services/ProcessLauncher.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace Azaion.Common.Services; + +public class ProcessLauncher : IProcessLauncher +{ + public void Launch(string fileName, string arguments, string? workingDirectory = null) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory ?? "", + CreateNoWindow = true + }; + process.Start(); + } +} + diff --git a/Azaion.Common/Services/WpfUICommandDispatcher.cs b/Azaion.Common/Services/WpfUICommandDispatcher.cs new file mode 100644 index 0000000..f542463 --- /dev/null +++ b/Azaion.Common/Services/WpfUICommandDispatcher.cs @@ -0,0 +1,29 @@ +using System.Windows.Threading; + +namespace Azaion.Common.Services; + +public class WpfUICommandDispatcher : IUICommandDispatcher +{ + private readonly Dispatcher _dispatcher; + + public WpfUICommandDispatcher(Dispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public Task ExecuteAsync(Action action) + { + return _dispatcher.InvokeAsync(action).Task; + } + + public Task ExecuteAsync(Func func) + { + return _dispatcher.InvokeAsync(func).Task; + } + + public void Execute(Action action) + { + _dispatcher.Invoke(action); + } +} + diff --git a/Azaion.Dataset/DatasetExplorer.xaml.cs b/Azaion.Dataset/DatasetExplorer.xaml.cs index f9456d3..9ea31cd 100644 --- a/Azaion.Dataset/DatasetExplorer.xaml.cs +++ b/Azaion.Dataset/DatasetExplorer.xaml.cs @@ -35,6 +35,7 @@ public partial class DatasetExplorer private readonly IAzaionApi _azaionApi; private readonly IConfigUpdater _configUpdater; + private readonly IAnnotationPathResolver _pathResolver; public bool ThumbnailLoading { get; set; } public string CurrentFilter { get; set; } = ""; @@ -51,7 +52,8 @@ public partial class DatasetExplorer IDbFactory dbFactory, IMediator mediator, IAzaionApi azaionApi, - IConfigUpdater configUpdater) + IConfigUpdater configUpdater, + IAnnotationPathResolver pathResolver) { InitializeComponent(); _appConfig = appConfig.Value; @@ -61,6 +63,7 @@ public partial class DatasetExplorer _mediator = mediator; _azaionApi = azaionApi; _configUpdater = configUpdater; + _pathResolver = pathResolver; ShowWithObjectsOnlyChBox.IsChecked = _appConfig.UIConfig.ShowDatasetWithDetectionsOnly; var photoModes = Enum.GetValues(typeof(PhotoMode)).Cast().ToList(); @@ -196,7 +199,7 @@ public partial class DatasetExplorer ThumbnailsView.SelectedIndex = index; var ann = CurrentAnnotation.Annotation; - var image = await ann.ImagePath.OpenImage(); + var image = await _pathResolver.GetImagePath(ann).OpenImage(); ExplorerEditor.SetBackground(image); SelectedAnnotationName.Text = ann.Name; SwitchTab(toEditor: true); @@ -264,7 +267,11 @@ public partial class DatasetExplorer var annThumbnails = _annotationsDict[ExplorerEditor.CurrentAnnClass!.YoloId] .WhereIf(withDetectionsOnly, x => x.Value.Detections.Any()) .WhereIf(!string.IsNullOrEmpty(CurrentFilter), x => x.Key.Contains(CurrentFilter, StringComparison.CurrentCultureIgnoreCase)) - .Select(x => new AnnotationThumbnail(x.Value, currentUser.Role.IsValidator())) + .Select(x => new AnnotationThumbnail( + x.Value, + currentUser.Role.IsValidator(), + _pathResolver.GetImagePath(x.Value), + _pathResolver.GetThumbPath(x.Value))) .OrderBy(x => !x.IsSeed) .ThenByDescending(x =>x.Annotation.CreatedDate); diff --git a/Azaion.Dataset/DatasetExplorerEventHandler.cs b/Azaion.Dataset/DatasetExplorerEventHandler.cs index abe72a4..22d2f11 100644 --- a/Azaion.Dataset/DatasetExplorerEventHandler.cs +++ b/Azaion.Dataset/DatasetExplorerEventHandler.cs @@ -13,7 +13,9 @@ public class DatasetExplorerEventHandler( ILogger logger, DatasetExplorer datasetExplorer, IAnnotationService annotationService, - IAzaionApi azaionApi) : + IAzaionApi azaionApi, + IUICommandDispatcher uiDispatcher, + IAnnotationPathResolver pathResolver) : INotificationHandler, INotificationHandler, INotificationHandler, @@ -121,7 +123,7 @@ public class DatasetExplorerEventHandler( public async Task Handle(AnnotationCreatedEvent notification, CancellationToken token) { - await datasetExplorer.Dispatcher.Invoke(async () => + await uiDispatcher.ExecuteAsync(async () => { var annotation = notification.Annotation; var selectedClass = datasetExplorer.LvClasses.CurrentClassNumber; @@ -132,7 +134,11 @@ public class DatasetExplorerEventHandler( var index = 0; var currentUser = await azaionApi.GetCurrentUserAsync(); - var annThumb = new AnnotationThumbnail(annotation, currentUser.Role.IsValidator()); + var annThumb = new AnnotationThumbnail( + annotation, + currentUser.Role.IsValidator(), + pathResolver.GetImagePath(annotation), + pathResolver.GetThumbPath(annotation)); if (datasetExplorer.SelectedAnnotationDict.ContainsKey(annThumb.Annotation.Name)) { datasetExplorer.SelectedAnnotationDict.Remove(annThumb.Annotation.Name); @@ -153,7 +159,7 @@ public class DatasetExplorerEventHandler( { try { - datasetExplorer.Dispatcher.Invoke(() => + uiDispatcher.Execute(() => { var annThumbs = datasetExplorer.SelectedAnnotationDict .Where(x => notification.AnnotationNames.Contains(x.Key)) diff --git a/Azaion.Suite/App.xaml.cs b/Azaion.Suite/App.xaml.cs index 555e520..607782b 100644 --- a/Azaion.Suite/App.xaml.cs +++ b/Azaion.Suite/App.xaml.cs @@ -13,6 +13,7 @@ using Azaion.Common.Extensions; using Azaion.Common.Services; using Azaion.Common.Services.Inference; using Azaion.Dataset; +using Azaion.Suite.Services; using CommandLine; using LibVLCSharp.Shared; using MediatR; @@ -98,8 +99,9 @@ public partial class App new ConfigUpdater().CheckConfig(); var initConfig = Constants.ReadInitConfig(Log.Logger); var apiDir = initConfig.DirectoriesConfig.ApiResourcesDirectory; + var processLauncher = new ProcessLauncher(); - _loaderClient = new LoaderClient(initConfig.LoaderClientConfig, Log.Logger, _mainCTokenSource.Token); + _loaderClient = new LoaderClient(initConfig.LoaderClientConfig, Log.Logger, processLauncher, _mainCTokenSource.Token); _loaderClient.StartClient(); _loaderClient.Connect(); _loaderClient.Login(credentials); @@ -145,7 +147,16 @@ public partial class App services.AddSingleton(azaionApi); #endregion + services.AddSingleton(); + services.AddSingleton(sp => + new FileConfigurationStore(Constants.CONFIG_PATH, sp.GetRequiredService())); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => + new WpfUICommandDispatcher(Application.Current.Dispatcher)); + services.AddSingleton(sp => + new AnnotationPathResolver(sp.GetRequiredService>().Value)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -155,16 +166,18 @@ public partial class App typeof(AnnotationService).Assembly)); services.AddSingleton(_ => new LibVLC("--no-osd", "--no-video-title-show", "--no-snapshot-preview")); services.AddSingleton(); - services.AddSingleton(sp => + services.AddSingleton(sp => { var libVlc = sp.GetRequiredService(); - return new MediaPlayer(libVlc); + return new LibVLCSharp.Shared.MediaPlayer(libVlc); }); + services.AddSingleton(sp => + new VlcMediaPlayerService(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -175,9 +188,6 @@ public partial class App }) .Build(); - Annotation.Init(_host.Services.GetRequiredService>().Value, - _host.Services.GetRequiredService>().Value.DetectionClassesDict); - _host.Services.GetRequiredService(); _mediator = _host.Services.GetRequiredService(); @@ -186,6 +196,14 @@ public partial class App DispatcherUnhandledException += OnDispatcherUnhandledException; _host.Start(); + + _ = Task.Run(async () => + { + await _host.Services.GetRequiredService().StartQueueConsumerAsync(); + await _host.Services.GetRequiredService().StartAsync(); + await _host.Services.GetRequiredService().StartAsync(); + }); + EventManager.RegisterClassHandler(typeof(UIElement), UIElement.PreviewKeyDownEvent, new RoutedEventHandler(GlobalKeyHandler)); _host.Services.GetRequiredService().Show(); } diff --git a/Azaion.Suite/Services/VlcMediaPlayerService.cs b/Azaion.Suite/Services/VlcMediaPlayerService.cs new file mode 100644 index 0000000..d9a0820 --- /dev/null +++ b/Azaion.Suite/Services/VlcMediaPlayerService.cs @@ -0,0 +1,65 @@ +using Azaion.Common.Services; +using LibVLCSharp.Shared; + +namespace Azaion.Suite.Services; + +public class VlcMediaPlayerService : IMediaPlayerService +{ + private readonly MediaPlayer _mediaPlayer; + private readonly LibVLC _libVlc; + + public VlcMediaPlayerService(MediaPlayer mediaPlayer, LibVLC libVlc) + { + _mediaPlayer = mediaPlayer; + _libVlc = libVlc; + + _mediaPlayer.Playing += (s, e) => Playing?.Invoke(s, e); + _mediaPlayer.Paused += (s, e) => Paused?.Invoke(s, e); + _mediaPlayer.Stopped += (s, e) => Stopped?.Invoke(s, e); + _mediaPlayer.PositionChanged += (s, e) => PositionChanged?.Invoke(s, e); + _mediaPlayer.LengthChanged += (s, e) => LengthChanged?.Invoke(s, e); + } + + public void Play() => _mediaPlayer.Play(); + public void Pause() => _mediaPlayer.Pause(); + public void Stop() => _mediaPlayer.Stop(); + + public long Time + { + get => _mediaPlayer.Time; + set => _mediaPlayer.Time = value; + } + + public float Position + { + get => _mediaPlayer.Position; + set => _mediaPlayer.Position = value; + } + + public int Volume + { + get => _mediaPlayer.Volume; + set => _mediaPlayer.Volume = value; + } + + public bool IsPlaying => _mediaPlayer.IsPlaying; + public long Length => _mediaPlayer.Length; + + public void SetMedia(string mediaPath) + { + using var media = new Media(_libVlc, mediaPath); + _mediaPlayer.Media = media; + } + + public void TakeSnapshot(uint num, string path, uint width, uint height) + { + _mediaPlayer.TakeSnapshot(num, path, width, height); + } + + public event EventHandler? Playing; + public event EventHandler? Paused; + public event EventHandler? Stopped; + public event EventHandler? PositionChanged; + public event EventHandler? LengthChanged; +} + diff --git a/Azaion.Test/InMemoryFileSystem.cs b/Azaion.Test/InMemoryFileSystem.cs new file mode 100644 index 0000000..ba59e2d --- /dev/null +++ b/Azaion.Test/InMemoryFileSystem.cs @@ -0,0 +1,67 @@ +using System.IO; +using Azaion.Common.Services; + +namespace Azaion.Test; + +public class InMemoryFileSystem : IFileSystem +{ + private readonly Dictionary _files = new(); + private readonly HashSet _directories = new(); + + public Task ReadAllBytesAsync(string path, CancellationToken ct = default) + { + if (!_files.TryGetValue(path, out var content)) + throw new FileNotFoundException($"File not found: {path}"); + return Task.FromResult(content); + } + + public Task WriteAllBytesAsync(string path, byte[] content, CancellationToken ct = default) + { + _files[path] = content; + var directory = Path.GetDirectoryName(path); + if (directory != null) + _directories.Add(directory); + return Task.CompletedTask; + } + + public bool FileExists(string path) + { + return _files.ContainsKey(path); + } + + public void DeleteFile(string path) + { + _files.Remove(path); + } + + public IEnumerable GetFiles(string directory, string searchPattern) + { + var pattern = searchPattern.Replace("*", ".*").Replace("?", "."); + var regex = new System.Text.RegularExpressions.Regex(pattern); + return _files.Keys.Where(f => Path.GetDirectoryName(f) == directory && regex.IsMatch(Path.GetFileName(f))); + } + + public IEnumerable GetFileInfos(string directory, string[] searchPatterns) + { + var files = searchPatterns.SelectMany(pattern => GetFiles(directory, pattern)); + return files.Select(f => new FileInfo(f)); + } + + public DirectoryInfo GetDirectoryInfo(string path) + { + if (!_directories.Contains(path)) + _directories.Add(path); + return new DirectoryInfo(path); + } + + public bool DirectoryExists(string path) + { + return _directories.Contains(path); + } + + public void CreateDirectory(string path) + { + _directories.Add(path); + } +} + diff --git a/Azaion.Test/SynchronousUICommandDispatcher.cs b/Azaion.Test/SynchronousUICommandDispatcher.cs new file mode 100644 index 0000000..6386616 --- /dev/null +++ b/Azaion.Test/SynchronousUICommandDispatcher.cs @@ -0,0 +1,23 @@ +using Azaion.Common.Services; + +namespace Azaion.Test; + +public class SynchronousUICommandDispatcher : IUICommandDispatcher +{ + public Task ExecuteAsync(Action action) + { + action(); + return Task.CompletedTask; + } + + public Task ExecuteAsync(Func func) + { + return Task.FromResult(func()); + } + + public void Execute(Action action) + { + action(); + } +} +