diff --git a/.gitignore b/.gitignore index 972b8d6..528133f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .idea bin obj +*.dll +*.exe +*.log .vs *.DotSettings* *.user diff --git a/Azaion.Annotator/Annotator.xaml b/Azaion.Annotator/Annotator.xaml index 53bd029..f77d1fa 100644 --- a/Azaion.Annotator/Annotator.xaml +++ b/Azaion.Annotator/Annotator.xaml @@ -500,7 +500,7 @@ Padding="2" Width="25" Height="25" ToolTip="Розпізнати за допомогою AI. Клавіша: [R]" Background="Black" BorderBrush="Black" - Click="AutoDetect"> + Click="AIDetectBtn_OnClick"> + { + { "disabled", aiDisabledText }, + { "downloading", "Будь ласка зачекайте, йде завантаження AI для Вашої відеокарти" }, + { "converting", "Будь ласка зачекайте, йде налаштування AI під Ваше залізо. (5-12 хвилин в залежності від моделі відеокарти, до 50 хв на старих GTX1650)" }, + { "uploading", "Будь ласка зачекайте, йде зберігання" }, + { "enabled", "AI готовий для розпізнавання" } + }; + StatusHelp.Text = messagesDict!.GetValueOrDefault(command.Message, aiDisabledText); + if (aiEnabled) + StatusHelp.Foreground = aiEnabled ? Brushes.White : Brushes.Red; + }); + }; + _inferenceClient.Send(RemoteCommand.Create(CommandType.AIAvailabilityCheck)); Editor.GetTimeFunc = () => TimeSpan.FromMilliseconds(_mediaPlayer.Time); MapMatcherComponent.Init(_appConfig, _gpsMatcherService); @@ -126,9 +149,6 @@ public partial class Annotator TbFolder.Text = _appConfig.DirectoriesConfig.VideosDirectory; LvClasses.Init(_appConfig.AnnotationConfig.DetectionClasses); - - if (LvFiles.Items.IsEmpty) - BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.Initial]); } public void BlinkHelp(string helpText, int times = 2) @@ -175,8 +195,6 @@ public partial class Annotator LvFiles.MouseDoubleClick += async (_, _) => { - if (IsInferenceNow) - FollowAI = false; await _mediator.Publish(new AnnotatorControlEvent(PlaybackControlEnum.Play)); }; @@ -225,9 +243,9 @@ public partial class Annotator return; var res = DgAnnotations.SelectedItems.Cast().ToList(); - var annotations = res.Select(x => x.Annotation).ToList(); + var annotationNames = res.Select(x => x.Annotation.Name).ToList(); - await _mediator.Publish(new AnnotationsDeletedEvent(annotations)); + await _mediator.Publish(new AnnotationsDeletedEvent(annotationNames)); break; } }; @@ -238,8 +256,6 @@ public partial class Annotator public void OpenAnnotationResult(AnnotationResult res) { - if (IsInferenceNow) - FollowAI = false; _mediaPlayer.SetPause(true); Editor.RemoveAllAnns(); _mediaPlayer.Time = (long)res.Annotation.Time.TotalMilliseconds; @@ -325,6 +341,10 @@ public partial class Annotator //Add manually public void AddAnnotation(Annotation annotation) { + var mediaInfo = (MediaFileInfo)LvFiles.SelectedItem; + if ((mediaInfo?.FName ?? "") != annotation.OriginalMediaName) + return; + var time = annotation.Time; var previousAnnotations = TimedAnnotations.Query(time); TimedAnnotations.Remove(previousAnnotations); @@ -342,10 +362,8 @@ public partial class Annotator _logger.LogError(e, e.Message); throw; } - } - var dict = _formState.AnnotationResults .Select((x, i) => new { x.Annotation.Time, Index = i }) .ToDictionary(x => x.Time, x => x.Index); @@ -399,11 +417,9 @@ public partial class Annotator mediaFile.HasAnnotations = labelsDict.ContainsKey(mediaFile.FName); AllMediaFiles = new ObservableCollection(allFiles); + MediaFilesDict = AllMediaFiles.GroupBy(x => x.Name) + .ToDictionary(gr => gr.Key, gr => gr.First()); LvFiles.ItemsSource = AllMediaFiles; - - BlinkHelp(AllMediaFiles.Count == 0 - ? HelpTexts.HelpTextsDict[HelpTextEnum.Initial] - : HelpTexts.HelpTextsDict[HelpTextEnum.PlayVideo]); DataContext = this; } @@ -448,7 +464,7 @@ public partial class Annotator { Title = "Open Video folder", IsFolderPicker = true, - InitialDirectory = Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory) + InitialDirectory = Path.GetDirectoryName(_appConfig.DirectoriesConfig.VideosDirectory) }; var dialogResult = dlg.ShowDialog(); @@ -463,14 +479,13 @@ public partial class Annotator private void TbFilter_OnTextChanged(object sender, TextChangedEventArgs e) { FilteredMediaFiles = new ObservableCollection(AllMediaFiles.Where(x => x.Name.ToLower().Contains(TbFilter.Text.ToLower())).ToList()); + MediaFilesDict = FilteredMediaFiles.ToDictionary(x => x.FName); LvFiles.ItemsSource = FilteredMediaFiles; LvFiles.ItemsSource = FilteredMediaFiles; } private void PlayClick(object sender, RoutedEventArgs e) { - if (IsInferenceNow) - FollowAI = false; _mediator.Publish(new AnnotatorControlEvent(_mediaPlayer.CanPause ? PlaybackControlEnum.Pause : PlaybackControlEnum.Play)); } @@ -501,13 +516,22 @@ public partial class Annotator LvFilesContextMenu.DataContext = listItem!.DataContext; } - public void AutoDetect(object sender, RoutedEventArgs e) + private async void AIDetectBtn_OnClick(object sender, RoutedEventArgs e) + { + try + { + await AutoDetect(); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + } + } + + public async Task AutoDetect() { if (IsInferenceNow) - { - FollowAI = true; return; - } if (LvFiles.Items.IsEmpty) return; @@ -517,96 +541,23 @@ public partial class Annotator Dispatcher.Invoke(() => Editor.ResetBackground()); IsInferenceNow = true; - FollowAI = true; + AIDetectBtn.IsEnabled = false; + DetectionCancellationSource = new CancellationTokenSource(); - var detectToken = DetectionCancellationSource.Token; - _ = Task.Run(async () => - { - while (!detectToken.IsCancellationRequested) - { - var files = new List(); - await Dispatcher.Invoke(async () => - { - //Take all medias - files = (LvFiles.ItemsSource as IEnumerable)?.Skip(LvFiles.SelectedIndex) - //.Where(x => !x.HasAnnotations) - .Take(Constants.DETECTION_BATCH_SIZE) - .Select(x => x.Path) - .ToList() ?? []; - if (files.Count != 0) - { - await _mediator.Publish(new AnnotatorControlEvent(PlaybackControlEnum.Play), detectToken); - await ReloadAnnotations(); - } - }); - if (files.Count == 0) - break; - await _inferenceService.RunInference(files, async annotationImage => await ProcessDetection(annotationImage, detectToken), detectToken); + var files = (FilteredMediaFiles.Count == 0 ? AllMediaFiles : FilteredMediaFiles) + .Skip(LvFiles.SelectedIndex) + .Select(x => x.Path) + .ToList(); + if (files.Count == 0) + return; - Dispatcher.Invoke(() => - { - if (LvFiles.SelectedIndex + files.Count >= LvFiles.Items.Count) - DetectionCancellationSource.Cancel(); - LvFiles.SelectedIndex += files.Count; - }); - } - Dispatcher.Invoke(() => - { - LvFiles.Items.Refresh(); - IsInferenceNow = false; - FollowAI = false; - }); - }); - } + await _inferenceService.RunInference(files, DetectionCancellationSource.Token); - private async Task ProcessDetection(AnnotationImage annotationImage, CancellationToken ct = default) - { - await Dispatcher.Invoke(async () => - { - try - { - var annotation = await _annotationService.SaveAnnotation(annotationImage, ct); - if (annotation.OriginalMediaName != _formState.CurrentMedia?.FName) - { - var nextFile = (LvFiles.ItemsSource as IEnumerable)? - .Select((info, i) => new - { - MediaInfo = info, - Index = i - }) - .FirstOrDefault(x => x.MediaInfo.FName == annotation.OriginalMediaName); - if (nextFile != null) - { - LvFiles.SelectedIndex = nextFile.Index; - await _mediator.Publish(new AnnotatorControlEvent(PlaybackControlEnum.Play), ct); - } - } - - AddAnnotation(annotation); - - if (FollowAI) - SeekTo(annotationImage.Milliseconds, false); - - var log = string.Join(Environment.NewLine, annotation.Detections.Select(det => - $"{_appConfig.AnnotationConfig.DetectionClassesDict[det.ClassNumber].Name}: " + - $"xy=({det.CenterX:F2},{det.CenterY:F2}), " + - $"size=({det.Width:F2}, {det.Height:F2}), " + - $"conf: {det.Confidence*100:F0}%")); - - Dispatcher.Invoke(() => - { - if (_formState.CurrentMedia != null) - _formState.CurrentMedia.HasAnnotations = true; - LvFiles.Items.Refresh(); - StatusHelp.Text = log; - }); - } - catch (Exception e) - { - _logger.LogError(e, e.Message); - } - }); + LvFiles.Items.Refresh(); + IsInferenceNow = false; + StatusHelp.Text = "Розпізнавання зваершено"; + AIDetectBtn.IsEnabled = true; } private void SwitchGpsPanel(object sender, RoutedEventArgs e) diff --git a/Azaion.Annotator/AnnotatorEventHandler.cs b/Azaion.Annotator/AnnotatorEventHandler.cs index e0961e8..c2258dc 100644 --- a/Azaion.Annotator/AnnotatorEventHandler.cs +++ b/Azaion.Annotator/AnnotatorEventHandler.cs @@ -1,9 +1,11 @@ using System.IO; using System.Windows; using System.Windows.Input; +using System.Windows.Threading; using Azaion.Annotator.DTO; using Azaion.Common; using Azaion.Common.DTO; +using Azaion.Common.DTO.Config; using Azaion.Common.Events; using Azaion.Common.Extensions; using Azaion.Common.Services; @@ -21,16 +23,18 @@ public class AnnotatorEventHandler( MediaPlayer mediaPlayer, Annotator mainWindow, FormState formState, - AnnotationService annotationService, + IAnnotationService annotationService, ILogger logger, IOptions dirConfig, + IOptions annotationConfig, IInferenceService inferenceService) : INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler { private const int STEP = 20; private const int LARGE_STEP = 5000; @@ -81,7 +85,7 @@ public class AnnotatorEventHandler( await ControlPlayback(value, cancellationToken); if (key == Key.R) - mainWindow.AutoDetect(null!, null!); + await mainWindow.AutoDetect(); #region Volume switch (key) @@ -128,10 +132,6 @@ public class AnnotatorEventHandler( break; case PlaybackControlEnum.Pause: mediaPlayer.Pause(); - if (mainWindow.IsInferenceNow) - mainWindow.FollowAI = false; - if (!mediaPlayer.IsPlaying) - mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.AnnotationHelp]); if (formState.BackgroundTime.HasValue) { @@ -225,7 +225,6 @@ public class AnnotatorEventHandler( await Task.Delay(100, ct); mediaPlayer.Stop(); mainWindow.Title = $"Azaion Annotator - {mediaInfo.Name}"; - mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.PauseForAnnotations]); mediaPlayer.Play(new Media(libVLC, mediaInfo.Path)); if (formState.CurrentMedia.MediaType == MediaTypes.Image) mediaPlayer.SetPause(true); @@ -278,27 +277,65 @@ public class AnnotatorEventHandler( mainWindow.AddAnnotation(annotation); } - public async Task Handle(AnnotationsDeletedEvent notification, CancellationToken cancellationToken) + public Task Handle(AnnotationsDeletedEvent notification, CancellationToken cancellationToken) { - var annResDict = formState.AnnotationResults.ToDictionary(x => x.Annotation.Name, x => x); - foreach (var ann in notification.Annotations) + try { - if (!annResDict.TryGetValue(ann.Name, out var value)) - continue; - - formState.AnnotationResults.Remove(value); - mainWindow.TimedAnnotations.Remove(ann); - } - - if (formState.AnnotationResults.Count == 0) - { - var media = mainWindow.AllMediaFiles.FirstOrDefault(x => x.Name == formState.CurrentMedia?.Name); - if (media != null) + mainWindow.Dispatcher.Invoke(() => { - media.HasAnnotations = false; - mainWindow.LvFiles.Items.Refresh(); - } + var namesSet = notification.AnnotationNames.ToHashSet(); + + var remainAnnotations = formState.AnnotationResults + .Where(x => !namesSet.Contains(x.Annotation?.Name ?? "")).ToList(); + formState.AnnotationResults.Clear(); + foreach (var ann in remainAnnotations) + formState.AnnotationResults.Add(ann); + + var timedAnnsToRemove = mainWindow.TimedAnnotations + .Where(x => namesSet.Contains(x.Value.Name)) + .Select(x => x.Value).ToList(); + mainWindow.TimedAnnotations.Remove(timedAnnsToRemove); + + if (formState.AnnotationResults.Count == 0) + { + var media = mainWindow.AllMediaFiles.FirstOrDefault(x => x.Name == formState.CurrentMedia?.Name); + if (media != null) + { + media.HasAnnotations = false; + mainWindow.LvFiles.Items.Refresh(); + } + } + }); } - await Task.CompletedTask; + catch (Exception e) + { + logger.LogError(e, e.Message); + throw; + } + return Task.CompletedTask; + } + + public Task Handle(AnnotationAddedEvent e, CancellationToken cancellationToken) + { + mainWindow.Dispatcher.Invoke(() => + { + mainWindow.AddAnnotation(e.Annotation); + + var log = string.Join(Environment.NewLine, e.Annotation.Detections.Select(det => + $"Розпізнавання {e.Annotation.OriginalMediaName}: {annotationConfig.Value.DetectionClassesDict[det.ClassNumber].ShortName}: " + + $"xy=({det.CenterX:F2},{det.CenterY:F2}), " + + $"розмір=({det.Width:F2}, {det.Height:F2}), " + + $"conf: {det.Confidence*100:F0}%")); + + mainWindow.LvFiles.Items.Refresh(); + + var media = mainWindow.MediaFilesDict.GetValueOrDefault(e.Annotation.OriginalMediaName); + if (media != null) + media.HasAnnotations = true; + + mainWindow.LvFiles.Items.Refresh(); + mainWindow.StatusHelp.Text = log; + }); + return Task.CompletedTask; } } diff --git a/Azaion.Common/Constants.cs b/Azaion.Common/Constants.cs index 556017b..e59e1d7 100644 --- a/Azaion.Common/Constants.cs +++ b/Azaion.Common/Constants.cs @@ -1,6 +1,4 @@ using System.Windows; -using System.Windows.Media; -using Azaion.Common.Database; using Azaion.Common.DTO; using Azaion.Common.DTO.Config; using Azaion.Common.Extensions; @@ -10,7 +8,7 @@ namespace Azaion.Common; public class Constants { public const string JPG_EXT = ".jpg"; - + public const string TXT_EXT = ".txt"; #region DirectoriesConfig public const string DEFAULT_VIDEO_DIR = "video"; @@ -80,7 +78,6 @@ public class Constants public const double TRACKING_INTERSECTION_THRESHOLD = 0.8; public const int DEFAULT_FRAME_PERIOD_RECOGNITION = 4; - public const int DETECTION_BATCH_SIZE = 4; # endregion AIRecognitionConfig #region Thumbnails @@ -100,12 +97,7 @@ public class Constants #endregion - #region Queue - public const string MQ_ANNOTATIONS_QUEUE = "azaion-annotations"; - public const string MQ_ANNOTATIONS_CONFIRM_QUEUE = "azaion-annotations-confirm"; - - #endregion #region Database diff --git a/Azaion.Common/Controls/DetectionClasses.xaml b/Azaion.Common/Controls/DetectionClasses.xaml index 47951a8..a7b9925 100644 --- a/Azaion.Common/Controls/DetectionClasses.xaml +++ b/Azaion.Common/Controls/DetectionClasses.xaml @@ -52,6 +52,7 @@ CanUserResizeColumns="False" SelectionChanged="DetectionDataGrid_SelectionChanged" x:FieldModifier="public" + PreviewKeyDown="OnKeyBanActivity" > diff --git a/Azaion.Common/Controls/DetectionClasses.xaml.cs b/Azaion.Common/Controls/DetectionClasses.xaml.cs index d704fa5..7b2500a 100644 --- a/Azaion.Common/Controls/DetectionClasses.xaml.cs +++ b/Azaion.Common/Controls/DetectionClasses.xaml.cs @@ -1,8 +1,10 @@ using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; using Azaion.Common.DTO; using Azaion.Common.Extensions; +using Azaion.CommonSecurity.DTO; namespace Azaion.Common.Controls; @@ -86,4 +88,11 @@ public partial class DetectionClasses { DetectionDataGrid.SelectedIndex = keyNumber; } + + private void OnKeyBanActivity(object sender, KeyEventArgs e) + { + if (e.Key.In(Key.Enter, Key.Down, Key.Up, Key.PageDown, Key.PageUp)) + e.Handled = true; + } + } \ No newline at end of file diff --git a/Azaion.Common/DTO/AnnotationThumbnail.cs b/Azaion.Common/DTO/AnnotationThumbnail.cs index 988a267..f3e00e4 100644 --- a/Azaion.Common/DTO/AnnotationThumbnail.cs +++ b/Azaion.Common/DTO/AnnotationThumbnail.cs @@ -4,12 +4,14 @@ using System.Runtime.CompilerServices; using System.Windows.Media.Imaging; using Azaion.Common.Database; using Azaion.Common.Extensions; +using Azaion.CommonSecurity.DTO; namespace Azaion.Common.DTO; -public class AnnotationThumbnail(Annotation annotation) : INotifyPropertyChanged +public class AnnotationThumbnail(Annotation annotation, bool isValidator) : INotifyPropertyChanged { public Annotation Annotation { get; set; } = annotation; + public bool IsValidator { get; set; } = isValidator; private BitmapImage? _thumbnail; public BitmapImage? Thumbnail @@ -28,7 +30,11 @@ public class AnnotationThumbnail(Annotation annotation) : INotifyPropertyChanged } public string ImageName => Path.GetFileName(Annotation.ImagePath); - public bool IsSeed => Annotation.AnnotationStatus == AnnotationStatus.Created; + public string CreatedDate => $"{Annotation.CreatedDate:dd.MM.yyyy HH:mm:ss}"; + public string CreatedEmail => Annotation.CreatedEmail; + public bool IsSeed => IsValidator && + Annotation.AnnotationStatus.In(AnnotationStatus.Created, AnnotationStatus.Edited) && + !Annotation.CreatedRole.IsValidator(); public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) diff --git a/Azaion.Common/DTO/Queue/AnnotationCreatedMessage.cs b/Azaion.Common/DTO/Queue/AnnotationCreatedMessage.cs index 30a5fec..98d3bab 100644 --- a/Azaion.Common/DTO/Queue/AnnotationCreatedMessage.cs +++ b/Azaion.Common/DTO/Queue/AnnotationCreatedMessage.cs @@ -5,23 +5,23 @@ namespace Azaion.Common.DTO.Queue; using MessagePack; [MessagePackObject] -public class AnnotationCreatedMessage +public class AnnotationMessage { - [Key(0)] public DateTime CreatedDate { get; set; } - [Key(1)] public string Name { get; set; } = null!; - [Key(2)] public string OriginalMediaName { get; set; } = null!; - [Key(3)] public TimeSpan Time { get; set; } - [Key(4)] public string ImageExtension { get; set; } = null!; - [Key(5)] public string Detections { get; set; } = null!; - [Key(6)] public byte[] Image { get; set; } = null!; - [Key(7)] public RoleEnum CreatedRole { get; set; } - [Key(8)] public string CreatedEmail { get; set; } = null!; - [Key(9)] public SourceEnum Source { get; set; } + [Key(0)] public DateTime CreatedDate { get; set; } + [Key(1)] public string Name { get; set; } = null!; + [Key(2)] public string OriginalMediaName { get; set; } = null!; + [Key(3)] public TimeSpan Time { get; set; } + [Key(4)] public string ImageExtension { get; set; } = null!; + [Key(5)] public string Detections { get; set; } = null!; + [Key(6)] public byte[]? Image { get; set; } = null!; + [Key(7)] public RoleEnum Role { get; set; } + [Key(8)] public string Email { get; set; } = null!; + [Key(9)] public SourceEnum Source { get; set; } [Key(10)] public AnnotationStatus Status { get; set; } } [MessagePackObject] -public class AnnotationValidatedMessage +public class AnnotationBulkMessage { - [Key(0)] public string Name { get; set; } = null!; + [Key(0)] public string[] AnnotationNames { get; set; } = null!; } \ No newline at end of file diff --git a/Azaion.Common/Database/Annotation.cs b/Azaion.Common/Database/Annotation.cs index 5b5c04c..42c780c 100644 --- a/Azaion.Common/Database/Annotation.cs +++ b/Azaion.Common/Database/Annotation.cs @@ -59,5 +59,7 @@ public enum AnnotationStatus { None = 0, Created = 10, - Validated = 20 + Edited = 20, + Validated = 30, + Deleted = 40 } \ No newline at end of file diff --git a/Azaion.Common/Database/AnnotationName.cs b/Azaion.Common/Database/AnnotationName.cs deleted file mode 100644 index 709066f..0000000 --- a/Azaion.Common/Database/AnnotationName.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Azaion.Common.Database; - -public class AnnotationName -{ - public string Name { get; set; } = null!; -} \ No newline at end of file diff --git a/Azaion.Common/Database/AnnotationQueueRecord.cs b/Azaion.Common/Database/AnnotationQueueRecord.cs new file mode 100644 index 0000000..0e1524b --- /dev/null +++ b/Azaion.Common/Database/AnnotationQueueRecord.cs @@ -0,0 +1,9 @@ +namespace Azaion.Common.Database; + +public class AnnotationQueueRecord +{ + public Guid Id { get; set; } + public DateTime DateTime { get; set; } + public AnnotationStatus Operation { get; set; } + public List AnnotationNames { get; set; } = null!; +} \ No newline at end of file diff --git a/Azaion.Common/Database/AnnotationsDb.cs b/Azaion.Common/Database/AnnotationsDb.cs index 69ed6ae..c8434d9 100644 --- a/Azaion.Common/Database/AnnotationsDb.cs +++ b/Azaion.Common/Database/AnnotationsDb.cs @@ -7,7 +7,6 @@ namespace Azaion.Common.Database; public class AnnotationsDb(DataOptions dataOptions) : DataConnection(dataOptions) { public ITable Annotations => this.GetTable(); - public ITable AnnotationsQueue => this.GetTable(); + public ITable AnnotationsQueueRecords => this.GetTable(); public ITable Detections => this.GetTable(); - public ITable QueueOffsets => this.GetTable(); } \ No newline at end of file diff --git a/Azaion.Common/Database/DbFactory.cs b/Azaion.Common/Database/DbFactory.cs index fdce46d..6b33fbe 100644 --- a/Azaion.Common/Database/DbFactory.cs +++ b/Azaion.Common/Database/DbFactory.cs @@ -9,6 +9,7 @@ using LinqToDB.DataProvider.SQLite; using LinqToDB.Mapping; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Newtonsoft.Json; namespace Azaion.Common.Database; @@ -17,7 +18,6 @@ public interface IDbFactory Task Run(Func> func); Task Run(Func func); void SaveToDisk(); - Task DeleteAnnotations(List annotations, CancellationToken cancellationToken = default); Task DeleteAnnotations(List annotationNames, CancellationToken cancellationToken = default); } @@ -53,32 +53,24 @@ public class DbFactory : IDbFactory .UseMappingSchema(AnnotationsDbSchemaHolder.MappingSchema); if (!File.Exists(_annConfig.AnnotationsDbFile)) - CreateDb(); + SQLiteConnection.CreateFile(_annConfig.AnnotationsDbFile); + RecreateTables(); + _fileConnection.Open(); _fileConnection.BackupDatabase(_memoryConnection, "main", "main", -1, null, -1); } - private void CreateDb() + private void RecreateTables() { - SQLiteConnection.CreateFile(_annConfig.AnnotationsDbFile); using var db = new AnnotationsDb(_fileDataOptions); - db.CreateTable(); - db.CreateTable(); - db.CreateTable(); - db.CreateTable(); - db.QueueOffsets.BulkCopy(new List - { - new() - { - Offset = 0, - QueueName = Constants.MQ_ANNOTATIONS_QUEUE - }, - new() - { - Offset = 0, - QueueName = Constants.MQ_ANNOTATIONS_CONFIRM_QUEUE - } - }); + var schema = db.DataProvider.GetSchemaProvider().GetSchema(db); + var existingTables = schema.Tables.Select(x => x.TableName).ToHashSet(); + if (!existingTables.Contains(Constants.ANNOTATIONS_TABLENAME)) + db.CreateTable(); + if (!existingTables.Contains(Constants.DETECTIONS_TABLENAME)) + db.CreateTable(); + if (!existingTables.Contains(Constants.ANNOTATIONS_QUEUE_TABLENAME)) + db.CreateTable(); } public async Task Run(Func> func) @@ -98,12 +90,6 @@ public class DbFactory : IDbFactory _memoryConnection.BackupDatabase(_fileConnection, "main", "main", -1, null, -1); } - public async Task DeleteAnnotations(List annotations, CancellationToken cancellationToken = default) - { - var names = annotations.Select(x => x.Name).ToList(); - await DeleteAnnotations(names, cancellationToken); - } - public async Task DeleteAnnotations(List annotationNames, CancellationToken cancellationToken = default) { await Run(async db => @@ -142,8 +128,12 @@ public static class AnnotationsDbSchemaHolder builder.Entity() .HasTableName(Constants.DETECTIONS_TABLENAME); - builder.Entity() - .HasTableName(Constants.ANNOTATIONS_QUEUE_TABLENAME); + builder.Entity() + .HasTableName(Constants.ANNOTATIONS_QUEUE_TABLENAME) + .HasPrimaryKey(x => x.Id) + .Property(x => x.AnnotationNames) + .HasDataType(DataType.NVarChar) + .HasConversion(list => JsonConvert.SerializeObject(list), str => JsonConvert.DeserializeObject>(str) ?? new List()); builder.Build(); } diff --git a/Azaion.Common/Database/QueueOffset.cs b/Azaion.Common/Database/QueueOffset.cs deleted file mode 100644 index 1fe3d0f..0000000 --- a/Azaion.Common/Database/QueueOffset.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Azaion.Common.Database; - -public class QueueOffset -{ - public string QueueName { get; set; } = null!; - public ulong Offset { get; set; } -} \ No newline at end of file diff --git a/Azaion.Common/Events/AnnotationsDeletedEvent.cs b/Azaion.Common/Events/AnnotationsDeletedEvent.cs index 1e815f2..c597e2d 100644 --- a/Azaion.Common/Events/AnnotationsDeletedEvent.cs +++ b/Azaion.Common/Events/AnnotationsDeletedEvent.cs @@ -3,7 +3,13 @@ using MediatR; namespace Azaion.Common.Events; -public class AnnotationsDeletedEvent(List annotations) : INotification +public class AnnotationsDeletedEvent(List annotationNames, bool fromQueue = false) : INotification { - public List Annotations { get; set; } = annotations; + public List AnnotationNames { get; set; } = annotationNames; + public bool FromQueue { get; set; } = fromQueue; +} + +public class AnnotationAddedEvent(Annotation annotation) : INotification +{ + public Annotation Annotation { get; set; } = annotation; } \ No newline at end of file diff --git a/Azaion.Common/Events/LoadErrorEvent.cs b/Azaion.Common/Events/LoadErrorEvent.cs new file mode 100644 index 0000000..051827c --- /dev/null +++ b/Azaion.Common/Events/LoadErrorEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Azaion.Common.Events; + +public class LoadErrorEvent(string error) : INotification +{ + public string Error { get; set; } = error; +} \ No newline at end of file diff --git a/Azaion.Common/Extensions/CancellationTokenExtensions.cs b/Azaion.Common/Extensions/CancellationTokenExtensions.cs new file mode 100644 index 0000000..fbde775 --- /dev/null +++ b/Azaion.Common/Extensions/CancellationTokenExtensions.cs @@ -0,0 +1,30 @@ +namespace Azaion.Common.Extensions; + +public static class CancellationTokenExtensions +{ + public static void WaitForCancel(this CancellationToken token, TimeSpan timeout) + { + try + { + Task.Delay(timeout, token).Wait(token); + } + catch (OperationCanceledException) + { + //Don't need to catch exception, need only return from the waiting + } + } + + public static Task AsTask(this CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + return new TaskCompletionSource().Task; + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = cancellationToken.Register(() => tcs.TrySetResult(true)); + tcs.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default); + return tcs.Task; + } +} \ No newline at end of file diff --git a/Azaion.Common/Extensions/ThrottleExtensions.cs b/Azaion.Common/Extensions/ThrottleExtensions.cs index 219374f..b0c46c2 100644 --- a/Azaion.Common/Extensions/ThrottleExtensions.cs +++ b/Azaion.Common/Extensions/ThrottleExtensions.cs @@ -54,7 +54,6 @@ public static class ThrottleExt finally { await Task.Delay(interval); - lock (state.StateLock) { if (state.CallScheduledDuringCooldown) diff --git a/Azaion.Common/Services/AnnotationService.cs b/Azaion.Common/Services/AnnotationService.cs index af769fe..92fc516 100644 --- a/Azaion.Common/Services/AnnotationService.cs +++ b/Azaion.Common/Services/AnnotationService.cs @@ -13,6 +13,7 @@ using LinqToDB; using LinqToDB.Data; using MediatR; using MessagePack; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using RabbitMQ.Stream.Client; @@ -20,39 +21,46 @@ using RabbitMQ.Stream.Client.Reliable; namespace Azaion.Common.Services; -public class AnnotationService : INotificationHandler +public class AnnotationService : IAnnotationService, INotificationHandler { private readonly IDbFactory _dbFactory; private readonly FailsafeAnnotationsProducer _producer; private readonly IGalleryService _galleryService; private readonly IMediator _mediator; private readonly IAzaionApi _api; + private readonly ILogger _logger; private readonly QueueConfig _queueConfig; private Consumer _consumer = null!; private readonly UIConfig _uiConfig; + private readonly DirectoriesConfig _dirConfig; private static readonly Guid SaveTaskId = Guid.NewGuid(); + private static readonly Guid SaveQueueOffsetTaskId = Guid.NewGuid(); public AnnotationService( IDbFactory dbFactory, FailsafeAnnotationsProducer producer, IOptions queueConfig, IOptions uiConfig, + IOptions directoriesConfig, IGalleryService galleryService, IMediator mediator, - IAzaionApi api) + IAzaionApi api, + ILogger logger) { _dbFactory = dbFactory; _producer = producer; _galleryService = galleryService; _mediator = mediator; _api = api; + _logger = logger; _queueConfig = queueConfig.Value; _uiConfig = uiConfig.Value; + _dirConfig = directoriesConfig.Value; - Task.Run(async () => await Init()).Wait(); + Task.Run(async () => await InitQueueConsumer()).Wait(); } - private async Task Init(CancellationToken cancellationToken = default) + private async Task InitQueueConsumer(CancellationToken cancellationToken = default) { if (!_api.CurrentUser.Role.IsValidator()) return; @@ -69,32 +77,53 @@ public class AnnotationService : INotificationHandler _consumer = await Consumer.Create(new ConsumerConfig(consumerSystem, Constants.MQ_ANNOTATIONS_QUEUE) { Reference = _api.CurrentUser.Email, - OffsetSpec = new OffsetTypeOffset(offsets.AnnotationsOffset + 1), + OffsetSpec = new OffsetTypeOffset(offsets.AnnotationsOffset), MessageHandler = async (_, _, context, message) => { - var msg = MessagePackSerializer.Deserialize(message.Data.Contents); - - offsets.AnnotationsOffset = context.Offset; - ThrottleExt.Throttle(() => + try { - _api.UpdateOffsets(offsets); - return Task.CompletedTask; - }, SaveTaskId, TimeSpan.FromSeconds(10), scheduleCallAfterCooldown: true); + var email = (string)message.ApplicationProperties[nameof(User.Email)]!; + if (!Enum.TryParse((string)message.ApplicationProperties[nameof(AnnotationStatus)], out var annotationStatus)) + return; - if (msg.CreatedEmail == _api.CurrentUser.Email) //Don't process messages by yourself - return; - - await SaveAnnotationInner( - msg.CreatedDate, - msg.OriginalMediaName, - msg.Time, - JsonConvert.DeserializeObject>(msg.Detections) ?? [], - msg.Source, - new MemoryStream(msg.Image), - msg.CreatedRole, - msg.CreatedEmail, - fromQueue: true, - token: cancellationToken); + if (email != _api.CurrentUser.Email) //Don't process messages by yourself + { + if (annotationStatus.In(AnnotationStatus.Created, AnnotationStatus.Edited)) + { + var msg = MessagePackSerializer.Deserialize(message.Data.Contents); + await SaveAnnotationInner( + msg.CreatedDate, + msg.OriginalMediaName, + msg.Time, + JsonConvert.DeserializeObject>(msg.Detections) ?? [], + msg.Source, + msg.Image == null ? null : new MemoryStream(msg.Image), + msg.Role, + msg.Email, + fromQueue: true, + token: cancellationToken); + } + else + { + var msg = MessagePackSerializer.Deserialize(message.Data.Contents); + if (annotationStatus == AnnotationStatus.Validated) + await ValidateAnnotations(msg.AnnotationNames.ToList(), true, cancellationToken); + if (annotationStatus == AnnotationStatus.Deleted) + await _mediator.Publish(new AnnotationsDeletedEvent(msg.AnnotationNames.ToList(), fromQueue:true), cancellationToken); + } + } + + offsets.AnnotationsOffset = context.Offset + 1; //to consume on the next launch from the next message + ThrottleExt.Throttle(() => + { + _api.UpdateOffsets(offsets); + return Task.CompletedTask; + }, SaveQueueOffsetTaskId, TimeSpan.FromSeconds(10), scheduleCallAfterCooldown: true); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } } }); } @@ -112,35 +141,42 @@ public class AnnotationService : INotificationHandler await SaveAnnotationInner(DateTime.UtcNow, originalMediaName, time, detections, SourceEnum.Manual, stream, _api.CurrentUser.Role, _api.CurrentUser.Email, token: token); - // Manual save from Validators -> Validated -> stream: azaion-annotations-confirm - // AI, Manual save from Operators -> Created -> stream: azaion-annotations - private async Task SaveAnnotationInner(DateTime createdDate, string originalMediaName, TimeSpan time, List detections, SourceEnum source, Stream? stream, + private async Task SaveAnnotationInner(DateTime createdDate, string originalMediaName, TimeSpan time, + List detections, SourceEnum source, Stream? stream, RoleEnum userRole, string createdEmail, bool fromQueue = false, CancellationToken token = default) { - AnnotationStatus status; + var status = AnnotationStatus.Created; var fName = originalMediaName.ToTimeName(time); var annotation = await _dbFactory.Run(async db => { - var ann = await db.Annotations.FirstOrDefaultAsync(x => x.Name == fName, token: token); - status = userRole.IsValidator() && source == SourceEnum.Manual - ? AnnotationStatus.Validated - : AnnotationStatus.Created; + var ann = await db.Annotations + .LoadWith(x => x.Detections) + .FirstOrDefaultAsync(x => x.Name == fName, token: token); await db.Detections.DeleteAsync(x => x.AnnotationName == fName, token: token); - if (ann != null) + if (ann != null) //Annotation is already exists { - await db.Annotations + status = AnnotationStatus.Edited; + + var annotationUpdatable = db.Annotations .Where(x => x.Name == fName) - .Set(x => x.Source, source) + .Set(x => x.Source, source); + + if (userRole.IsValidator() && source == SourceEnum.Manual) + { + annotationUpdatable = annotationUpdatable + .Set(x => x.ValidateDate, createdDate) + .Set(x => x.ValidateEmail, createdEmail); + } + + await annotationUpdatable .Set(x => x.AnnotationStatus, status) - .Set(x => x.CreatedDate, createdDate) - .Set(x => x.CreatedEmail, createdEmail) - .Set(x => x.CreatedRole, userRole) .UpdateAsync(token: token); + ann.Detections = detections; } else @@ -175,10 +211,11 @@ public class AnnotationService : INotificationHandler if (_uiConfig.GenerateAnnotatedImage) await _galleryService.CreateAnnotatedImage(annotation, token); - if (!fromQueue && !_uiConfig.SilentDetection) //Send to queue only if we're not getting from queue already - await _producer.SendToInnerQueue(annotation, token); - await _mediator.Publish(new AnnotationCreatedEvent(annotation), token); + + if (!fromQueue) //Send to queue only if we're not getting from queue already + await _producer.SendToInnerQueue([annotation.Name], status, token); + ThrottleExt.Throttle(async () => { _dbFactory.SaveToDisk(); @@ -187,12 +224,12 @@ public class AnnotationService : INotificationHandler return annotation; } - public async Task ValidateAnnotations(List annotations, CancellationToken token = default) + public async Task ValidateAnnotations(List annotationNames, bool fromQueue = false, CancellationToken token = default) { if (!_api.CurrentUser.Role.IsValidator()) return; - var annNames = annotations.Select(x => x.Name).ToHashSet(); + var annNames = annotationNames.ToHashSet(); await _dbFactory.Run(async db => { await db.Annotations @@ -202,6 +239,9 @@ public class AnnotationService : INotificationHandler .Set(x => x.ValidateEmail, _api.CurrentUser.Email) .UpdateAsync(token: token); }); + if (!fromQueue) + await _producer.SendToInnerQueue(annotationNames, AnnotationStatus.Validated, token); + ThrottleExt.Throttle(async () => { _dbFactory.SaveToDisk(); @@ -209,14 +249,32 @@ public class AnnotationService : INotificationHandler }, SaveTaskId, TimeSpan.FromSeconds(5), true); } - public async Task Handle(AnnotationsDeletedEvent notification, CancellationToken cancellationToken) + public async Task Handle(AnnotationsDeletedEvent notification, CancellationToken ct) { - await _dbFactory.DeleteAnnotations(notification.Annotations, cancellationToken); - foreach (var annotation in notification.Annotations) + await _dbFactory.DeleteAnnotations(notification.AnnotationNames, ct); + foreach (var name in notification.AnnotationNames) { - File.Delete(annotation.ImagePath); - File.Delete(annotation.LabelPath); - File.Delete(annotation.ThumbPath); + File.Delete(Path.Combine(_dirConfig.ImagesDirectory, $"{name}{Constants.JPG_EXT}")); + File.Delete(Path.Combine(_dirConfig.LabelsDirectory, $"{name}{Constants.TXT_EXT}")); + File.Delete(Path.Combine(_dirConfig.ThumbnailsDirectory, $"{name}{Constants.THUMBNAIL_PREFIX}{Constants.JPG_EXT}")); + File.Delete(Path.Combine(_dirConfig.ResultsDirectory, $"{name}{Constants.RESULT_PREFIX}{Constants.JPG_EXT}")); } + + //Only validators can send Delete to the queue + if (!notification.FromQueue && _api.CurrentUser.Role.IsValidator()) + await _producer.SendToInnerQueue(notification.AnnotationNames, AnnotationStatus.Deleted, ct); + + ThrottleExt.Throttle(async () => + { + _dbFactory.SaveToDisk(); + await Task.CompletedTask; + }, SaveTaskId, TimeSpan.FromSeconds(5), true); } +} + +public interface IAnnotationService +{ + Task SaveAnnotation(AnnotationImage a, CancellationToken ct = default); + Task SaveAnnotation(string originalMediaName, TimeSpan time, List detections, Stream? stream = null, CancellationToken token = default); + Task ValidateAnnotations(List annotationNames, bool fromQueue = false, CancellationToken token = default); } \ No newline at end of file diff --git a/Azaion.Common/Services/FailsafeProducer.cs b/Azaion.Common/Services/FailsafeProducer.cs index c46fcc2..5beafc1 100644 --- a/Azaion.Common/Services/FailsafeProducer.cs +++ b/Azaion.Common/Services/FailsafeProducer.cs @@ -3,12 +3,16 @@ using System.Net; using Azaion.Common.Database; using Azaion.Common.DTO.Config; using Azaion.Common.DTO.Queue; +using Azaion.Common.Extensions; +using Azaion.CommonSecurity.DTO; +using Azaion.CommonSecurity.Services; using LinqToDB; using MessagePack; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using RabbitMQ.Stream.Client; +using RabbitMQ.Stream.Client.AMQP; using RabbitMQ.Stream.Client.Reliable; namespace Azaion.Common.Services; @@ -17,17 +21,24 @@ public class FailsafeAnnotationsProducer { private readonly ILogger _logger; private readonly IDbFactory _dbFactory; + private readonly IAzaionApi _azaionApi; private readonly QueueConfig _queueConfig; + private readonly UIConfig _uiConfig; private Producer _annotationProducer = null!; - private Producer _annotationConfirmProducer = null!; - public FailsafeAnnotationsProducer(ILogger logger, IDbFactory dbFactory, IOptions queueConfig) + public FailsafeAnnotationsProducer(ILogger logger, + IDbFactory dbFactory, + IOptions queueConfig, + IOptions uiConfig, + IAzaionApi azaionApi) { _logger = logger; _dbFactory = dbFactory; + _azaionApi = azaionApi; _queueConfig = queueConfig.Value; + _uiConfig = uiConfig.Value; Task.Run(async () => await ProcessQueue()); } @@ -41,107 +52,111 @@ public class FailsafeAnnotationsProducer }); } - private async Task Init(CancellationToken cancellationToken = default) + private async Task ProcessQueue(CancellationToken ct = default) { _annotationProducer = await Producer.Create(new ProducerConfig(await GetProducerQueueConfig(), Constants.MQ_ANNOTATIONS_QUEUE)); - _annotationConfirmProducer = await Producer.Create(new ProducerConfig(await GetProducerQueueConfig(), Constants.MQ_ANNOTATIONS_CONFIRM_QUEUE)); - } - - private async Task ProcessQueue(CancellationToken cancellationToken = default) - { - await Init(cancellationToken); - while (!cancellationToken.IsCancellationRequested) + while (!ct.IsCancellationRequested) { - var messages = await GetFromInnerQueue(cancellationToken); - foreach (var messagesChunk in messages.Chunk(10)) //Sending by 10 - { - var sent = false; - while (!sent || cancellationToken.IsCancellationRequested) //Waiting for send - { - try - { - var createdMessages = messagesChunk - .Where(x => x.Status == AnnotationStatus.Created) - .Select(x => new Message(MessagePackSerializer.Serialize(x))) - .ToList(); - if (createdMessages.Any()) - await _annotationProducer.Send(createdMessages, CompressionType.Gzip); - - var validatedMessages = messagesChunk - .Where(x => x.Status == AnnotationStatus.Validated) - .Select(x => new Message(MessagePackSerializer.Serialize(x))) - .ToList(); - if (validatedMessages.Any()) - await _annotationConfirmProducer.Send(validatedMessages, CompressionType.Gzip); - - await _dbFactory.Run(async db => - await db.AnnotationsQueue.DeleteAsync(aq => messagesChunk.Any(x => aq.Name == x.Name), token: cancellationToken)); - sent = true; - _dbFactory.SaveToDisk(); - } - catch (Exception e) - { - _logger.LogError(e, e.Message); - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); - } - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); - } - } - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); - } - } - - private async Task> GetFromInnerQueue(CancellationToken cancellationToken = default) - { - return await _dbFactory.Run(async db => - { - var annotations = await db.AnnotationsQueue.Join( - db.Annotations.LoadWith(x => x.Detections), aq => aq.Name, a => a.Name, (aq, a) => a) - .ToListAsync(token: cancellationToken); - - var messages = new List(); - var badImages = new List(); - foreach (var annotation in annotations) + var sent = false; + while (!sent || !ct.IsCancellationRequested) //Waiting for send { try { - var image = await File.ReadAllBytesAsync(annotation.ImagePath, cancellationToken); - var annCreateMessage = new AnnotationCreatedMessage + var result = await _dbFactory.Run(async db => { - Name = annotation.Name, - OriginalMediaName = annotation.OriginalMediaName, - Time = annotation.Time, - CreatedRole = annotation.CreatedRole, - CreatedEmail = annotation.CreatedEmail, - CreatedDate = annotation.CreatedDate, - Status = annotation.AnnotationStatus, + var records = await db.AnnotationsQueueRecords.OrderBy(x => x.DateTime).ToListAsync(token: ct); + var editedCreatedNames = records + .Where(x => x.Operation.In(AnnotationStatus.Created, AnnotationStatus.Edited)) + .Select(x => x.AnnotationNames.FirstOrDefault()) + .ToList(); - ImageExtension = annotation.ImageExtension, - Image = image, - Detections = JsonConvert.SerializeObject(annotation.Detections), - Source = annotation.Source, - }; - messages.Add(annCreateMessage); + var annotationsDict = await db.Annotations.LoadWith(x => x.Detections) + .Where(x => editedCreatedNames.Contains(x.Name)) + .ToDictionaryAsync(a => a.Name, token: ct); + + var messages = new List(); + foreach (var record in records) + { + var appProperties = new ApplicationProperties + { + { nameof(AnnotationStatus), record.Operation.ToString() }, + { nameof(User.Email), _azaionApi.CurrentUser.Email } + }; + + if (record.Operation.In(AnnotationStatus.Validated, AnnotationStatus.Deleted)) + { + var message = new Message(MessagePackSerializer.Serialize(new AnnotationBulkMessage + { + AnnotationNames = record.AnnotationNames.ToArray() + })) { ApplicationProperties = appProperties }; + + messages.Add(message); + } + else + { + var annotation = annotationsDict!.GetValueOrDefault(record.AnnotationNames.FirstOrDefault()); + if (annotation == null) + continue; + + var image = record.Operation == AnnotationStatus.Created + ? await File.ReadAllBytesAsync(annotation.ImagePath, ct) + : null; + + var annMessage = new AnnotationMessage + { + Name = annotation.Name, + OriginalMediaName = annotation.OriginalMediaName, + Time = annotation.Time, + Role = annotation.CreatedRole, + Email = annotation.CreatedEmail, + CreatedDate = annotation.CreatedDate, + Status = annotation.AnnotationStatus, + + ImageExtension = annotation.ImageExtension, + Image = image, + Detections = JsonConvert.SerializeObject(annotation.Detections), + Source = annotation.Source, + }; + var message = new Message(MessagePackSerializer.Serialize(annMessage)) { ApplicationProperties = appProperties }; + + messages.Add(message); + } + } + + return (messages, records); + }); + + if (result.messages.Any()) + { + await _annotationProducer.Send(result.messages, CompressionType.Gzip); + var ids = result.records.Select(x => x.Id).ToList(); + var removed = await _dbFactory.Run(async db => await db.AnnotationsQueueRecords.DeleteAsync(x => ids.Contains(x.Id), token: ct)); + sent = true; + _dbFactory.SaveToDisk(); + } } catch (Exception e) { _logger.LogError(e, e.Message); - badImages.Add(annotation.Name); + await Task.Delay(TimeSpan.FromSeconds(10), ct); } + await Task.Delay(TimeSpan.FromSeconds(10), ct); } - - if (badImages.Any()) - { - await db.AnnotationsQueue.Where(x => badImages.Contains(x.Name)).DeleteAsync(token: cancellationToken); - _dbFactory.SaveToDisk(); - } - return messages; - }); + } + await Task.Delay(TimeSpan.FromSeconds(5), ct); } - public async Task SendToInnerQueue(Annotation annotation, CancellationToken cancellationToken = default) + public async Task SendToInnerQueue(List annotationNames, AnnotationStatus status, CancellationToken cancellationToken = default) { + if (_uiConfig.SilentDetection) + return; await _dbFactory.Run(async db => - await db.InsertAsync(new AnnotationName { Name = annotation.Name }, token: cancellationToken)); + await db.InsertAsync(new AnnotationQueueRecord + { + Id = Guid.NewGuid(), + DateTime = DateTime.UtcNow, + Operation = status, + AnnotationNames = annotationNames + }, token: cancellationToken)); } } \ No newline at end of file diff --git a/Azaion.Common/Services/GPSMatcherService.cs b/Azaion.Common/Services/GPSMatcherService.cs index 9883c5a..03c4370 100644 --- a/Azaion.Common/Services/GPSMatcherService.cs +++ b/Azaion.Common/Services/GPSMatcherService.cs @@ -17,7 +17,7 @@ public interface IGpsMatcherService public class GpsMatcherService(IGpsMatcherClient gpsMatcherClient, ISatelliteDownloader satelliteTileDownloader, IOptions dirConfig) : IGpsMatcherService { private const int ZOOM_LEVEL = 18; - private const int POINTS_COUNT = 5; + private const int POINTS_COUNT = 10; private const int DISTANCE_BETWEEN_POINTS_M = 100; private const double SATELLITE_RADIUS_M = DISTANCE_BETWEEN_POINTS_M * (POINTS_COUNT + 1); diff --git a/Azaion.Common/Services/GpsMatcherClient.cs b/Azaion.Common/Services/GpsMatcherClient.cs index cb8e225..12db99d 100644 --- a/Azaion.Common/Services/GpsMatcherClient.cs +++ b/Azaion.Common/Services/GpsMatcherClient.cs @@ -23,13 +23,12 @@ public class StartMatchingEvent public int ImagesCount { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } - public string ProcessingType { get; set; } = "cuda"; public int Altitude { get; set; } = 400; public double CameraSensorWidth { get; set; } = 23.5; public double CameraFocalLength { get; set; } = 24; public override string ToString() => - $"{RouteDir},{SatelliteImagesDir},{ImagesCount},{Latitude},{Longitude},{ProcessingType},{Altitude},{CameraSensorWidth},{CameraFocalLength}"; + $"{RouteDir},{SatelliteImagesDir},{ImagesCount},{Latitude},{Longitude},{Altitude},{CameraSensorWidth},{CameraFocalLength}"; } public class GpsMatcherClient : IGpsMatcherClient diff --git a/Azaion.Common/Services/InferenceClient.cs b/Azaion.Common/Services/InferenceClient.cs new file mode 100644 index 0000000..0d6771d --- /dev/null +++ b/Azaion.Common/Services/InferenceClient.cs @@ -0,0 +1,150 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using Azaion.Common.Database; +using Azaion.Common.Extensions; +using Azaion.CommonSecurity; +using Azaion.CommonSecurity.DTO; +using Azaion.CommonSecurity.DTO.Commands; +using Azaion.CommonSecurity.Exceptions; +using Azaion.CommonSecurity.Services; +using MessagePack; +using Microsoft.Extensions.Options; +using NetMQ; +using NetMQ.Sockets; + +namespace Azaion.Common.Services; + +public interface IInferenceClient : IDisposable +{ + event EventHandler BytesReceived; + event EventHandler? InferenceDataReceived; + event EventHandler? AIAvailabilityReceived; + void Send(RemoteCommand create); + void Stop(); +} + +public class InferenceClient : IInferenceClient, IResourceLoader +{ + private CancellationTokenSource _waitFileCancelSource = new(); + + public event EventHandler? BytesReceived; + public event EventHandler? InferenceDataReceived; + public event EventHandler? AIAvailabilityReceived; + + private readonly DealerSocket _dealer = new(); + private readonly NetMQPoller _poller = new(); + private readonly Guid _clientId = Guid.NewGuid(); + private readonly InferenceClientConfig _inferenceClientConfig; + + public InferenceClient(IOptions config, CancellationToken ct) + { + _inferenceClientConfig = config.Value; + Start(ct); + } + + private void Start(CancellationToken ct = default) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = SecurityConstants.EXTERNAL_INFERENCE_PATH, + Arguments = $"--port {_inferenceClientConfig.ZeroMqPort} --api {_inferenceClientConfig.ApiUrl}", + //RedirectStandardOutput = true, + //RedirectStandardError = true, + //CreateNoWindow = true + }; + + process.OutputDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine(e.Data); }; + process.Start(); + } + catch (Exception e) + { + Console.WriteLine(e); + //throw; + } + + _dealer.Options.Identity = Encoding.UTF8.GetBytes(_clientId.ToString("N")); + _dealer.Connect($"tcp://{_inferenceClientConfig.ZeroMqHost}:{_inferenceClientConfig.ZeroMqPort}"); + + _dealer.ReceiveReady += (_, e) => ProcessClientCommand(e.Socket, ct); + _poller.Add(_dealer); + _ = Task.Run(() => _poller.RunAsync(), ct); + } + + private void ProcessClientCommand(NetMQSocket socket, CancellationToken ct = default) + { + while (socket.TryReceiveFrameBytes(TimeSpan.Zero, out var bytes)) + { + if (bytes?.Length == 0) + continue; + + var remoteCommand = MessagePackSerializer.Deserialize(bytes, cancellationToken: ct); + switch (remoteCommand.CommandType) + { + case CommandType.DataBytes: + BytesReceived?.Invoke(this, remoteCommand); + break; + case CommandType.InferenceData: + InferenceDataReceived?.Invoke(this, remoteCommand); + break; + case CommandType.AIAvailabilityResult: + AIAvailabilityReceived?.Invoke(this, remoteCommand); + break; + } + + } + } + + public void Stop() + { + + } + + public void Send(RemoteCommand command) + { + _dealer.SendFrame(MessagePackSerializer.Serialize(command)); + } + + public MemoryStream LoadFile(string fileName, string? folder = null, TimeSpan? timeout = null) + { + //TODO: Bad solution, look for better implementation + byte[] bytes = []; + Exception? exception = null; + _waitFileCancelSource = new CancellationTokenSource(); + Send(RemoteCommand.Create(CommandType.Load, new LoadFileData(fileName, folder))); + BytesReceived += OnBytesReceived; + + void OnBytesReceived(object? sender, RemoteCommand command) + { + if (command.Data is null) + { + exception = new BusinessException(command.Message ?? "File is empty"); + _waitFileCancelSource.Cancel(); + } + + bytes = command.Data!; + _waitFileCancelSource.Cancel(); + } + _waitFileCancelSource.Token.WaitForCancel(timeout ?? TimeSpan.FromSeconds(15)); + BytesReceived -= OnBytesReceived; + if (exception != null) + throw exception; + return new MemoryStream(bytes); + } + + public void Dispose() + { + _waitFileCancelSource.Dispose(); + _poller.Stop(); + _poller.Dispose(); + + _dealer.SendFrame(MessagePackSerializer.Serialize(new RemoteCommand(CommandType.Exit))); + _dealer.Disconnect($"tcp://{_inferenceClientConfig.ZeroMqHost}:{_inferenceClientConfig.ZeroMqPort}"); + _dealer.Close(); + _dealer.Dispose(); + } +} \ No newline at end of file diff --git a/Azaion.Common/Services/InferenceService.cs b/Azaion.Common/Services/InferenceService.cs index 3ce4ef6..119353e 100644 --- a/Azaion.Common/Services/InferenceService.cs +++ b/Azaion.Common/Services/InferenceService.cs @@ -1,11 +1,11 @@ -using System.Text; -using Azaion.Common.Database; +using Azaion.Common.Database; using Azaion.Common.DTO.Config; -using Azaion.CommonSecurity; +using Azaion.Common.Events; +using Azaion.Common.Extensions; using Azaion.CommonSecurity.DTO.Commands; using Azaion.CommonSecurity.Services; +using MediatR; using MessagePack; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,45 +13,74 @@ namespace Azaion.Common.Services; public interface IInferenceService { - Task RunInference(List mediaPaths, Func processAnnotation, CancellationToken detectToken = default); + Task RunInference(List mediaPaths, CancellationToken ct = default); void StopInference(); } -public class InferenceService(ILogger logger, IInferenceClient client, IAzaionApi azaionApi, IOptions aiConfigOptions) : IInferenceService +public class InferenceService : IInferenceService { - public async Task RunInference(List mediaPaths, Func processAnnotation, CancellationToken detectToken = default) + private readonly IInferenceClient _client; + private readonly IAzaionApi _azaionApi; + private readonly IOptions _aiConfigOptions; + private readonly IAnnotationService _annotationService; + private readonly IMediator _mediator; + private CancellationTokenSource _inferenceCancelTokenSource = new(); + + public InferenceService( + ILogger logger, + IInferenceClient client, + IAzaionApi azaionApi, + IOptions aiConfigOptions, + IAnnotationService annotationService, + IMediator mediator) { - client.Send(RemoteCommand.Create(CommandType.Login, azaionApi.Credentials)); - var aiConfig = aiConfigOptions.Value; + _client = client; + _azaionApi = azaionApi; + _aiConfigOptions = aiConfigOptions; + _annotationService = annotationService; + _mediator = mediator; - aiConfig.Paths = mediaPaths; - client.Send(RemoteCommand.Create(CommandType.Inference, aiConfig)); - - while (!detectToken.IsCancellationRequested) + client.InferenceDataReceived += async (sender, command) => { try { - var bytes = client.GetBytes(ct: detectToken); - if (bytes == null) - throw new Exception("Can't get bytes from inference client"); - - if (bytes.Length == 4 && Encoding.UTF8.GetString(bytes) == "DONE") + if (command.Message == "DONE") + { + _inferenceCancelTokenSource?.Cancel(); return; + } - var annotationImage = MessagePackSerializer.Deserialize(bytes, cancellationToken: detectToken); - - await processAnnotation(annotationImage); + var annImage = MessagePackSerializer.Deserialize(command.Data); + await ProcessDetection(annImage); } catch (Exception e) { logger.LogError(e, e.Message); - break; } - } + }; + } + + private async Task ProcessDetection(AnnotationImage annotationImage, CancellationToken ct = default) + { + var annotation = await _annotationService.SaveAnnotation(annotationImage, ct); + await _mediator.Publish(new AnnotationAddedEvent(annotation), ct); + } + + public async Task RunInference(List mediaPaths, CancellationToken ct = default) + { + _inferenceCancelTokenSource = new CancellationTokenSource(); + _client.Send(RemoteCommand.Create(CommandType.Login, _azaionApi.Credentials)); + + var aiConfig = _aiConfigOptions.Value; + aiConfig.Paths = mediaPaths; + _client.Send(RemoteCommand.Create(CommandType.Inference, aiConfig)); + + using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct, _inferenceCancelTokenSource.Token); + await combinedTokenSource.Token.AsTask(); } public void StopInference() { - client.Send(RemoteCommand.Create(CommandType.StopInference)); + _client.Send(RemoteCommand.Create(CommandType.StopInference)); } } \ No newline at end of file diff --git a/Azaion.CommonSecurity/Azaion.CommonSecurity.csproj b/Azaion.CommonSecurity/Azaion.CommonSecurity.csproj index 88fbb9f..4bd3564 100644 --- a/Azaion.CommonSecurity/Azaion.CommonSecurity.csproj +++ b/Azaion.CommonSecurity/Azaion.CommonSecurity.csproj @@ -8,6 +8,7 @@ + diff --git a/Azaion.CommonSecurity/DTO/Commands/RemoteCommand.cs b/Azaion.CommonSecurity/DTO/Commands/RemoteCommand.cs index 9fd9f7e..fbeecf6 100644 --- a/Azaion.CommonSecurity/DTO/Commands/RemoteCommand.cs +++ b/Azaion.CommonSecurity/DTO/Commands/RemoteCommand.cs @@ -3,7 +3,7 @@ namespace Azaion.CommonSecurity.DTO.Commands; [MessagePackObject] -public class RemoteCommand(CommandType commandType, byte[]? data = null) +public class RemoteCommand(CommandType commandType, byte[]? data = null, string? message = null) { [Key("CommandType")] public CommandType CommandType { get; set; } = commandType; @@ -11,11 +11,14 @@ public class RemoteCommand(CommandType commandType, byte[]? data = null) [Key("Data")] public byte[]? Data { get; set; } = data; + [Key("Message")] + public string? Message { get; set; } = message; + public static RemoteCommand Create(CommandType commandType) => new(commandType); - public static RemoteCommand Create(CommandType commandType, T data) where T : class => - new(commandType, MessagePackSerializer.Serialize(data)); + public static RemoteCommand Create(CommandType commandType, T data, string? message = null) where T : class => + new(commandType, MessagePackSerializer.Serialize(data), message); } [MessagePackObject] @@ -34,7 +37,12 @@ public enum CommandType None = 0, Login = 10, Load = 20, + DataBytes = 25, Inference = 30, + InferenceData = 35, StopInference = 40, - Exit = 100 + AIAvailabilityCheck = 80, + AIAvailabilityResult = 85, + Error = 90, + Exit = 100, } \ No newline at end of file diff --git a/Azaion.CommonSecurity/DTO/ExternalClientsConfig.cs b/Azaion.CommonSecurity/DTO/ExternalClientsConfig.cs index 33d66fb..25b1276 100644 --- a/Azaion.CommonSecurity/DTO/ExternalClientsConfig.cs +++ b/Azaion.CommonSecurity/DTO/ExternalClientsConfig.cs @@ -10,7 +10,7 @@ public abstract class ExternalClientConfig public class InferenceClientConfig : ExternalClientConfig { - public string ApiUrl { get; set; } + public string ApiUrl { get; set; } = null!; } public class GpsDeniedClientConfig : ExternalClientConfig diff --git a/Azaion.CommonSecurity/DTO/IEnumerableExtensions.cs b/Azaion.CommonSecurity/DTO/IEnumerableExtensions.cs index df3cf07..32157b9 100644 --- a/Azaion.CommonSecurity/DTO/IEnumerableExtensions.cs +++ b/Azaion.CommonSecurity/DTO/IEnumerableExtensions.cs @@ -1,4 +1,4 @@ -namespace Azaion.Common.Extensions; +namespace Azaion.CommonSecurity.DTO; public static class EnumerableExtensions { diff --git a/Azaion.CommonSecurity/DTO/RoleEnum.cs b/Azaion.CommonSecurity/DTO/RoleEnum.cs index 548d5e0..5f65539 100644 --- a/Azaion.CommonSecurity/DTO/RoleEnum.cs +++ b/Azaion.CommonSecurity/DTO/RoleEnum.cs @@ -1,6 +1,4 @@ -using Azaion.Common.Extensions; - -namespace Azaion.CommonSecurity.DTO; +namespace Azaion.CommonSecurity.DTO; public enum RoleEnum { diff --git a/Azaion.CommonSecurity/Exceptions/BusinessException.cs b/Azaion.CommonSecurity/Exceptions/BusinessException.cs new file mode 100644 index 0000000..51a5a9a --- /dev/null +++ b/Azaion.CommonSecurity/Exceptions/BusinessException.cs @@ -0,0 +1,3 @@ +namespace Azaion.CommonSecurity.Exceptions; + +public class BusinessException(string message) : Exception(message); \ No newline at end of file diff --git a/Azaion.CommonSecurity/Services/InferenceClient.cs b/Azaion.CommonSecurity/Services/InferenceClient.cs deleted file mode 100644 index 30a8119..0000000 --- a/Azaion.CommonSecurity/Services/InferenceClient.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Diagnostics; -using System.Text; -using Azaion.CommonSecurity.DTO; -using Azaion.CommonSecurity.DTO.Commands; -using MessagePack; -using Microsoft.Extensions.Options; -using NetMQ; -using NetMQ.Sockets; - -namespace Azaion.CommonSecurity.Services; - -public interface IInferenceClient -{ - void Send(RemoteCommand create); - T? Get(int retries = 24, int tryTimeoutSeconds = 5, CancellationToken ct = default) where T : class; - byte[]? GetBytes(int retries = 24, int tryTimeoutSeconds = 5, CancellationToken ct = default); - void Stop(); -} - -public class InferenceClient : IInferenceClient -{ - private readonly DealerSocket _dealer = new(); - private readonly Guid _clientId = Guid.NewGuid(); - private readonly InferenceClientConfig _inferenceClientConfig; - - public InferenceClient(IOptions config) - { - _inferenceClientConfig = config.Value; - Start(); - _ = Task.Run(ProcessClientCommands); - } - - private void Start() - { - try - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = SecurityConstants.EXTERNAL_INFERENCE_PATH, - Arguments = $"--port {_inferenceClientConfig.ZeroMqPort} --api {_inferenceClientConfig.ApiUrl}", - //RedirectStandardOutput = true, - //RedirectStandardError = true, - //CreateNoWindow = true - }; - - process.OutputDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine(e.Data); }; - process.Start(); - } - catch (Exception e) - { - Console.WriteLine(e); - //throw; - } - - _dealer.Options.Identity = Encoding.UTF8.GetBytes(_clientId.ToString("N")); - _dealer.Connect($"tcp://{_inferenceClientConfig.ZeroMqHost}:{_inferenceClientConfig.ZeroMqPort}"); - } - private async Task ProcessClientCommands() - { - //TODO: implement always on ready to client's requests. Utilize RemoteCommand - - await Task.CompletedTask; - } - - public void Stop() - { - if (!_dealer.IsDisposed) - { - _dealer.SendFrame(MessagePackSerializer.Serialize(new RemoteCommand(CommandType.Exit))); - _dealer.Close(); - } - } - - public void Send(RemoteCommand command) - { - _dealer.SendFrame(MessagePackSerializer.Serialize(command)); - } - - public T? Get(int retries = 24, int tryTimeoutSeconds = 5, CancellationToken ct = default) where T : class - { - var bytes = GetBytes(retries, tryTimeoutSeconds, ct); - return bytes != null ? MessagePackSerializer.Deserialize(bytes, cancellationToken: ct) : null; - } - - public byte[]? GetBytes(int retries = 24, int tryTimeoutSeconds = 5, CancellationToken ct = default) - { - var tryNum = 0; - while (!ct.IsCancellationRequested && tryNum < retries) - { - tryNum++; - if (!_dealer.TryReceiveFrameBytes(TimeSpan.FromSeconds(tryTimeoutSeconds), out var bytes)) - continue; - - return bytes; - } - - if (!ct.IsCancellationRequested) - throw new Exception($"Unable to get bytes after {tryNum - 1} retries, {tryTimeoutSeconds} seconds each"); - - return null; - } -} diff --git a/Azaion.CommonSecurity/Services/ResourceLoader.cs b/Azaion.CommonSecurity/Services/ResourceLoader.cs index 38a4d70..773ac40 100644 --- a/Azaion.CommonSecurity/Services/ResourceLoader.cs +++ b/Azaion.CommonSecurity/Services/ResourceLoader.cs @@ -1,22 +1,8 @@ using Azaion.CommonSecurity.DTO.Commands; -using Microsoft.Extensions.DependencyInjection; namespace Azaion.CommonSecurity.Services; public interface IResourceLoader { - MemoryStream LoadFile(string fileName, string? folder = null); -} - -public class ResourceLoader([FromKeyedServices(SecurityConstants.EXTERNAL_INFERENCE_PATH)] IInferenceClient inferenceClient) : IResourceLoader -{ - public MemoryStream LoadFile(string fileName, string? folder = null) - { - inferenceClient.Send(RemoteCommand.Create(CommandType.Load, new LoadFileData(fileName, folder))); - var bytes = inferenceClient.GetBytes(2, 3); - if (bytes == null) - throw new Exception($"Unable to receive {fileName}"); - - return new MemoryStream(bytes); - } + MemoryStream LoadFile(string fileName, string? folder, TimeSpan? timeout = null); } diff --git a/Azaion.Dataset/DatasetExplorer.xaml b/Azaion.Dataset/DatasetExplorer.xaml index 73eeb63..c15c4ec 100644 --- a/Azaion.Dataset/DatasetExplorer.xaml +++ b/Azaion.Dataset/DatasetExplorer.xaml @@ -30,7 +30,8 @@ - + + + + + + + + + + diff --git a/Azaion.Dataset/DatasetExplorer.xaml.cs b/Azaion.Dataset/DatasetExplorer.xaml.cs index 8989eac..154be41 100644 --- a/Azaion.Dataset/DatasetExplorer.xaml.cs +++ b/Azaion.Dataset/DatasetExplorer.xaml.cs @@ -8,6 +8,7 @@ using Azaion.Common.DTO.Config; using Azaion.Common.Events; using Azaion.Common.Services; using Azaion.CommonSecurity.DTO; +using Azaion.CommonSecurity.Services; using LinqToDB; using MediatR; using Microsoft.Extensions.Logging; @@ -36,6 +37,7 @@ public partial class DatasetExplorer private readonly IMediator _mediator; public readonly List AnnotationsClasses; + private IAzaionApi _azaionApi; public bool ThumbnailLoading { get; set; } @@ -49,7 +51,8 @@ public partial class DatasetExplorer IGalleryService galleryService, FormState formState, IDbFactory dbFactory, - IMediator mediator) + IMediator mediator, + IAzaionApi azaionApi) { InitializeComponent(); @@ -59,6 +62,7 @@ public partial class DatasetExplorer _galleryService = galleryService; _dbFactory = dbFactory; _mediator = mediator; + _azaionApi = azaionApi; var photoModes = Enum.GetValues(typeof(PhotoMode)).Cast().ToList(); _annotationsDict = _annotationConfig.DetectionClasses.SelectMany(cls => photoModes.Select(mode => (int)mode + cls.Id)) @@ -87,6 +91,7 @@ public partial class DatasetExplorer ThumbnailsView.SelectionChanged += (_, _) => { StatusText.Text = $"Обрано: {ThumbnailsView.SelectedItems.Count} | {ThumbnailsView.SelectedIndex} / {SelectedAnnotations.Count}"; + ValidateBtn.Visibility = ThumbnailsView.SelectedItems.Cast().Any(x => x.IsSeed) ? Visibility.Visible : Visibility.Hidden; @@ -273,10 +278,9 @@ public partial class DatasetExplorer if (result != MessageBoxResult.Yes) return; - var annotations = ThumbnailsView.SelectedItems.Cast().Select(x => x.Annotation) - .ToList(); + var annotationNames = ThumbnailsView.SelectedItems.Cast().Select(x => x.Annotation.Name).ToList(); - await _mediator.Publish(new AnnotationsDeletedEvent(annotations)); + await _mediator.Publish(new AnnotationsDeletedEvent(annotationNames)); ThumbnailsView.SelectedIndex = Math.Min(SelectedAnnotations.Count, tempSelected); } @@ -284,14 +288,18 @@ public partial class DatasetExplorer { SelectedAnnotations.Clear(); SelectedAnnotationDict.Clear(); - var annotations = _annotationsDict[ExplorerEditor.CurrentAnnClass.YoloId]; - foreach (var ann in annotations - .OrderBy(x => x.Value.AnnotationStatus) - .ThenByDescending(x => x.Value.CreatedDate)) + var annThumbnails = _annotationsDict[ExplorerEditor.CurrentAnnClass.YoloId] + .Select(x => new AnnotationThumbnail(x.Value, _azaionApi.CurrentUser.Role.IsValidator())) + .OrderBy(x => !x.IsSeed) + .ThenByDescending(x =>x.Annotation.CreatedDate); + + //var dict = annThumbnails.Take(20).ToDictionary(x => x.Annotation.Name, x => x.IsSeed); + + + foreach (var thumb in annThumbnails) { - var annThumb = new AnnotationThumbnail(ann.Value); - SelectedAnnotations.Add(annThumb); - SelectedAnnotationDict.Add(annThumb.Annotation.Name, annThumb); + SelectedAnnotations.Add(thumb); + SelectedAnnotationDict.Add(thumb.Annotation.Name, thumb); } await Task.CompletedTask; } diff --git a/Azaion.Dataset/DatasetExplorerEventHandler.cs b/Azaion.Dataset/DatasetExplorerEventHandler.cs index d2ee77e..f0cd874 100644 --- a/Azaion.Dataset/DatasetExplorerEventHandler.cs +++ b/Azaion.Dataset/DatasetExplorerEventHandler.cs @@ -1,19 +1,20 @@ -using System.IO; -using System.Windows; -using System.Windows.Input; -using System.Windows.Threading; +using System.Windows.Input; using Azaion.Common.Database; using Azaion.Common.DTO; -using Azaion.Common.DTO.Queue; using Azaion.Common.Events; using Azaion.Common.Services; +using Azaion.CommonSecurity.DTO; +using Azaion.CommonSecurity.Services; using MediatR; +using Microsoft.Extensions.Logging; namespace Azaion.Dataset; public class DatasetExplorerEventHandler( + ILogger logger, DatasetExplorer datasetExplorer, - AnnotationService annotationService) : + IAnnotationService annotationService, + IAzaionApi azaionApi) : INotificationHandler, INotificationHandler, INotificationHandler, @@ -26,7 +27,9 @@ public class DatasetExplorerEventHandler( { Key.X, PlaybackControlEnum.RemoveAllAnns }, { Key.Escape, PlaybackControlEnum.Close }, { Key.Down, PlaybackControlEnum.Next }, + { Key.PageDown, PlaybackControlEnum.Next }, { Key.Up, PlaybackControlEnum.Previous }, + { Key.PageUp, PlaybackControlEnum.Previous }, { Key.V, PlaybackControlEnum.ValidateAnnotations}, }; @@ -97,7 +100,7 @@ public class DatasetExplorerEventHandler( var annotations = datasetExplorer.ThumbnailsView.SelectedItems.Cast() .Select(x => x.Annotation) .ToList(); - await annotationService.ValidateAnnotations(annotations, cancellationToken); + await annotationService.ValidateAnnotations(annotations.Select(x => x.Name).ToList(), token: cancellationToken); foreach (var ann in datasetExplorer.SelectedAnnotations.Where(x => annotations.Contains(x.Annotation))) { ann.Annotation.AnnotationStatus = AnnotationStatus.Validated; @@ -116,20 +119,23 @@ public class DatasetExplorerEventHandler( var annotation = notification.Annotation; var selectedClass = datasetExplorer.LvClasses.CurrentClassNumber; - //TODO: For editing existing need to handle updates datasetExplorer.AddAnnotationToDict(annotation); if (annotation.Classes.Contains(selectedClass) || selectedClass == -1) { - var annThumb = new AnnotationThumbnail(annotation); + var index = 0; + var annThumb = new AnnotationThumbnail(annotation, azaionApi.CurrentUser.Role.IsValidator()); if (datasetExplorer.SelectedAnnotationDict.ContainsKey(annThumb.Annotation.Name)) { datasetExplorer.SelectedAnnotationDict.Remove(annThumb.Annotation.Name); var ann = datasetExplorer.SelectedAnnotations.FirstOrDefault(x => x.Annotation.Name == annThumb.Annotation.Name); if (ann != null) + { + index = datasetExplorer.SelectedAnnotations.IndexOf(ann); datasetExplorer.SelectedAnnotations.Remove(ann); + } } - datasetExplorer.SelectedAnnotations.Insert(0, annThumb); + datasetExplorer.SelectedAnnotations.Insert(index, annThumb); datasetExplorer.SelectedAnnotationDict.Add(annThumb.Annotation.Name, annThumb); } }); @@ -138,15 +144,25 @@ public class DatasetExplorerEventHandler( public async Task Handle(AnnotationsDeletedEvent notification, CancellationToken cancellationToken) { - var names = notification.Annotations.Select(x => x.Name).ToList(); - var annThumbs = datasetExplorer.SelectedAnnotationDict - .Where(x => names.Contains(x.Key)) - .Select(x => x.Value) - .ToList(); - foreach (var annThumb in annThumbs) + try { - datasetExplorer.SelectedAnnotations.Remove(annThumb); - datasetExplorer.SelectedAnnotationDict.Remove(annThumb.Annotation.Name); + datasetExplorer.Dispatcher.Invoke(() => + { + var annThumbs = datasetExplorer.SelectedAnnotationDict + .Where(x => notification.AnnotationNames.Contains(x.Key)) + .Select(x => x.Value) + .ToList(); + foreach (var annThumb in annThumbs) + { + datasetExplorer.SelectedAnnotations.Remove(annThumb); + datasetExplorer.SelectedAnnotationDict.Remove(annThumb.Annotation.Name); + } + }); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + throw; } await Task.CompletedTask; } diff --git a/Azaion.Inference/api_client.pyx b/Azaion.Inference/api_client.pyx index 5de25db..a6ed66b 100644 --- a/Azaion.Inference/api_client.pyx +++ b/Azaion.Inference/api_client.pyx @@ -1,4 +1,5 @@ import json +import os from http import HTTPStatus from os import path from uuid import UUID @@ -6,6 +7,7 @@ import jwt import requests cimport constants import yaml +from requests import HTTPError from cdn_manager cimport CDNManager, CDNCredentials from hardware_service cimport HardwareService @@ -23,6 +25,9 @@ cdef class ApiClient: cdef set_credentials(self, Credentials credentials): self.credentials = credentials + if self.cdn_manager is not None: + return + yaml_bytes = self.load_bytes(constants.CDN_CONFIG, '') yaml_config = yaml.safe_load(yaml_bytes) creds = CDNCredentials(yaml_config["host"], @@ -34,11 +39,18 @@ cdef class ApiClient: self.cdn_manager = CDNManager(creds) cdef login(self): - response = requests.post(f"{self.api_url}/login", - json={"email": self.credentials.email, "password": self.credentials.password}) - response.raise_for_status() - token = response.json()["token"] - self.set_token(token) + response = None + try: + response = requests.post(f"{self.api_url}/login", + json={"email": self.credentials.email, "password": self.credentials.password}) + response.raise_for_status() + token = response.json()["token"] + self.set_token(token) + except HTTPError as e: + print(response.json()) + if response.status_code == HTTPStatus.CONFLICT: + res = response.json() + raise Exception(res['Message']) cdef set_token(self, str token): @@ -123,11 +135,21 @@ cdef class ApiClient: return data cdef load_big_small_resource(self, str resource_name, str folder, str key): - cdef str big_part = path.join(folder, f'{resource_name}.big') + cdef str big_part = f'{resource_name}.big' cdef str small_part = f'{resource_name}.small' - with open(big_part, 'rb') as binary_file: - encrypted_bytes_big = binary_file.read() + print(f'checking on existence for {folder}\\{big_part}') + if os.path.exists(os.path.join( folder, big_part)): + with open(path.join( folder, big_part), 'rb') as binary_file: + encrypted_bytes_big = binary_file.read() + print(f'local file {folder}\\{big_part} is found!') + else: + print(f'downloading file {folder}\\{big_part} from cdn...') + if self.cdn_manager.download(folder, big_part): + with open(path.join( folder, big_part), 'rb') as binary_file: + encrypted_bytes_big = binary_file.read() + else: + return None encrypted_bytes_small = self.load_bytes(small_part, folder) @@ -145,7 +167,7 @@ cdef class ApiClient: part_big = resource_encrypted[part_small_size:] - self.cdn_manager.upload(constants.MODELS_FOLDER, big_part_name, part_big) + self.cdn_manager.upload(folder, big_part_name, part_big) with open(path.join(folder, big_part_name), 'wb') as f: f.write(part_big) - self.upload_file(small_part_name, part_small, constants.MODELS_FOLDER) \ No newline at end of file + self.upload_file(small_part_name, part_small, folder) \ No newline at end of file diff --git a/Azaion.Inference/cdn_manager.pyx b/Azaion.Inference/cdn_manager.pyx index 92b4d3f..5c0fc01 100644 --- a/Azaion.Inference/cdn_manager.pyx +++ b/Azaion.Inference/cdn_manager.pyx @@ -31,10 +31,10 @@ cdef class CDNManager: print(e) return False - cdef download(self, str bucket, str filename): + cdef download(self, str folder, str filename): try: - self.download_client.download_file(bucket, filename, filename) - print(f'downloaded {filename} from the {bucket} to current folder') + self.download_client.download_file(folder, filename, f'{folder}\\{filename}') + print(f'downloaded {filename} from the {folder} to current folder') return True except Exception as e: print(e) diff --git a/Azaion.Inference/constants.pxd b/Azaion.Inference/constants.pxd index 1e678c8..6cd4339 100644 --- a/Azaion.Inference/constants.pxd +++ b/Azaion.Inference/constants.pxd @@ -13,7 +13,5 @@ cdef str MODELS_FOLDER cdef int SMALL_SIZE_KB -cdef bytes DONE_SIGNAL - cdef log(str log_message, bytes client_id=*) \ No newline at end of file diff --git a/Azaion.Inference/inference.pxd b/Azaion.Inference/inference.pxd index f0cfc39..7cb602f 100644 --- a/Azaion.Inference/inference.pxd +++ b/Azaion.Inference/inference.pxd @@ -16,7 +16,7 @@ cdef class Inference: cdef int model_width cdef int model_height - cdef build_tensor_engine(self) + cdef build_tensor_engine(self, object updater_callback) cdef init_ai(self) cdef bint is_building_engine cdef bint is_video(self, str filepath) diff --git a/Azaion.Inference/inference.pyx b/Azaion.Inference/inference.pyx index 48925de..ccae2c6 100644 --- a/Azaion.Inference/inference.pyx +++ b/Azaion.Inference/inference.pyx @@ -32,7 +32,7 @@ cdef class Inference: self.engine = None self.is_building_engine = False - cdef build_tensor_engine(self): + cdef build_tensor_engine(self, object updater_callback): is_nvidia = HardwareService.has_nvidia_gpu() if not is_nvidia: return @@ -40,18 +40,22 @@ cdef class Inference: engine_filename = TensorRTEngine.get_engine_filename(0) key = Security.get_model_encryption_key() models_dir = constants.MODELS_FOLDER - if not os.path.exists(os.path.join( models_dir, f'{engine_filename}.big')): - #TODO: Check cdn on engine exists, if there is, download - self.is_building_engine = True - time.sleep(8) # prevent simultaneously loading dll and models - onnx_model = self.api_client.load_big_small_resource(constants.AI_ONNX_MODEL_FILE, models_dir, key) - model_bytes = TensorRTEngine.convert_from_onnx(onnx_model) - self.api_client.upload_big_small_resource(model_bytes, engine_filename, models_dir, key) - print('uploaded ') - self.is_building_engine = False - else: - print('tensor rt engine is here, no need to build') + self.is_building_engine = True + updater_callback('downloading') + if self.api_client.load_big_small_resource(engine_filename, models_dir, key): + print('tensor rt engine is here, no need to build') + self.is_building_engine = False + return + + # time.sleep(8) # prevent simultaneously loading dll and models + updater_callback('converting') + onnx_model = self.api_client.load_big_small_resource(constants.AI_ONNX_MODEL_FILE, models_dir, key) + model_bytes = TensorRTEngine.convert_from_onnx(onnx_model) + updater_callback('uploading') + self.api_client.upload_big_small_resource(model_bytes, engine_filename, models_dir, key) + print(f'uploaded {engine_filename} to CDN and API') + self.is_building_engine = False cdef init_ai(self): if self.engine is not None: @@ -161,7 +165,6 @@ cdef class Inference: self.stop_signal = False self.init_ai() - print(ai_config.paths) for m in ai_config.paths: if self.is_video(m): videos.append(m) diff --git a/Azaion.Inference/main.pyx b/Azaion.Inference/main.pyx index fbb806f..7ed4306 100644 --- a/Azaion.Inference/main.pyx +++ b/Azaion.Inference/main.pyx @@ -34,7 +34,8 @@ cdef class CommandProcessor: try: command = self.inference_queue.get(timeout=0.5) self.inference.run_inference(command) - self.remote_handler.send(command.client_id, 'DONE'.encode('utf-8')) + end_inference_command = RemoteCommand(CommandType.INFERENCE_DATA, None, 'DONE') + self.remote_handler.send(command.client_id, end_inference_command.serialize()) except queue.Empty: continue except Exception as e: @@ -44,11 +45,13 @@ cdef class CommandProcessor: cdef on_command(self, RemoteCommand command): try: if command.command_type == CommandType.LOGIN: - self.login(command) + self.api_client.set_credentials(Credentials.from_msgpack(command.data)) elif command.command_type == CommandType.LOAD: self.load_file(command) elif command.command_type == CommandType.INFERENCE: self.inference_queue.put(command) + elif command.command_type == CommandType.AI_AVAILABILITY_CHECK: + self.build_tensor_engine(command.client_id) elif command.command_type == CommandType.STOP_INFERENCE: self.inference.stop() elif command.command_type == CommandType.EXIT: @@ -59,18 +62,28 @@ cdef class CommandProcessor: except Exception as e: print(f"Error handling client: {e}") - cdef login(self, RemoteCommand command): - self.api_client.set_credentials(Credentials.from_msgpack(command.data)) - Thread(target=self.inference.build_tensor_engine).start() # build AI engine in non-blocking thread + cdef build_tensor_engine(self, client_id): + self.inference.build_tensor_engine(lambda status: self.build_tensor_status_updater(client_id, status)) + self.remote_handler.send(client_id, RemoteCommand(CommandType.AI_AVAILABILITY_RESULT, None, 'enabled').serialize()) + + cdef build_tensor_status_updater(self, bytes client_id, str status): + self.remote_handler.send(client_id, RemoteCommand(CommandType.AI_AVAILABILITY_RESULT, None, status).serialize()) cdef load_file(self, RemoteCommand command): - cdef FileData file_data = FileData.from_msgpack(command.data) - response = self.api_client.load_bytes(file_data.filename, file_data.folder) - self.remote_handler.send(command.client_id, response) + cdef RemoteCommand response + cdef FileData file_data + cdef bytes file_bytes + try: + file_data = FileData.from_msgpack(command.data) + file_bytes = self.api_client.load_bytes(file_data.filename, file_data.folder) + response = RemoteCommand(CommandType.DATA_BYTES, file_bytes) + except Exception as e: + response = RemoteCommand(CommandType.DATA_BYTES, None, str(e)) + self.remote_handler.send(command.client_id, response.serialize()) cdef on_annotation(self, RemoteCommand cmd, Annotation annotation): - data = annotation.serialize() - self.remote_handler.send(cmd.client_id, data) + cdef RemoteCommand response = RemoteCommand(CommandType.INFERENCE_DATA, annotation.serialize()) + self.remote_handler.send(cmd.client_id, response.serialize()) def stop(self): self.inference.stop() diff --git a/Azaion.Inference/remote_command.pxd b/Azaion.Inference/remote_command.pxd index 014ece0..5f5b708 100644 --- a/Azaion.Inference/remote_command.pxd +++ b/Azaion.Inference/remote_command.pxd @@ -1,13 +1,19 @@ cdef enum CommandType: LOGIN = 10 LOAD = 20 + DATA_BYTES = 25 INFERENCE = 30 + INFERENCE_DATA = 35 STOP_INFERENCE = 40 + AI_AVAILABILITY_CHECK = 80 + AI_AVAILABILITY_RESULT = 85 + ERROR = 90 EXIT = 100 cdef class RemoteCommand: cdef public bytes client_id cdef CommandType command_type + cdef str message cdef bytes data @staticmethod diff --git a/Azaion.Inference/remote_command.pyx b/Azaion.Inference/remote_command.pyx index a053685..333b34a 100644 --- a/Azaion.Inference/remote_command.pyx +++ b/Azaion.Inference/remote_command.pyx @@ -1,16 +1,22 @@ import msgpack cdef class RemoteCommand: - def __init__(self, CommandType command_type, bytes data): + def __init__(self, CommandType command_type, bytes data, str message=None): self.command_type = command_type self.data = data + self.message = message def __str__(self): command_type_names = { 10: "LOGIN", 20: "LOAD", + 25: "DATA_BYTES", 30: "INFERENCE", + 35: "INFERENCE_DATA", 40: "STOP_INFERENCE", + 80: "AI_AVAILABILITY_CHECK", + 85: "AI_AVAILABILITY_RESULT", + 90: "ERROR", 100: "EXIT" } data_str = f'{len(self.data)} bytes' if self.data else '' @@ -19,10 +25,11 @@ cdef class RemoteCommand: @staticmethod cdef from_msgpack(bytes data): unpacked = msgpack.unpackb(data, strict_map_key=False) - return RemoteCommand(unpacked.get("CommandType"), unpacked.get("Data")) + return RemoteCommand(unpacked.get("CommandType"), unpacked.get("Data"), unpacked.get("Message")) cdef bytes serialize(self): return msgpack.packb({ "CommandType": self.command_type, - "Data": self.data + "Data": self.data, + "Message": self.message }) diff --git a/Azaion.Inference/remote_command_handler.pyx b/Azaion.Inference/remote_command_handler.pyx index da34d5d..86877ed 100644 --- a/Azaion.Inference/remote_command_handler.pyx +++ b/Azaion.Inference/remote_command_handler.pyx @@ -70,10 +70,12 @@ cdef class RemoteCommandHandler: worker_socket.close() cdef send(self, bytes client_id, bytes data): - with self._context.socket(zmq.DEALER) as socket: - socket.connect("inproc://backend") - socket.send_multipart([client_id, data]) - # constants.log(f'Sent {len(data)} bytes.', client_id) + self._router.send_multipart([client_id, data]) + + # with self._context.socket(zmq.DEALER) as socket: + # socket.connect("inproc://backend") + # socket.send_multipart([client_id, data]) + # # constants.log(f'Sent {len(data)} bytes.', client_id) cdef stop(self): self._shutdown_event.set() diff --git a/Azaion.Suite.sln b/Azaion.Suite.sln index 00324f8..3870dd3 100644 --- a/Azaion.Suite.sln +++ b/Azaion.Suite.sln @@ -35,6 +35,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{CF141A48 build\publish.cmd = build\publish.cmd build\installer.full.iss = build\installer.full.iss build\installer.iterative.iss = build\installer.iterative.iss + build\publish-full.cmd = build\publish-full.cmd EndProjectSection EndProject Global diff --git a/Azaion.Suite/App.xaml.cs b/Azaion.Suite/App.xaml.cs index bd8a258..2d0c1ce 100644 --- a/Azaion.Suite/App.xaml.cs +++ b/Azaion.Suite/App.xaml.cs @@ -2,7 +2,6 @@ using System.Net.Http; using System.Reflection; using System.Text; -using System.Text.Unicode; using System.Windows; using System.Windows.Threading; using Azaion.Annotator; @@ -18,7 +17,6 @@ using Azaion.CommonSecurity.DTO; using Azaion.CommonSecurity.DTO.Commands; using Azaion.CommonSecurity.Services; using Azaion.Dataset; -using LazyCache; using LibVLCSharp.Shared; using MediatR; using Microsoft.Extensions.Configuration; @@ -40,13 +38,15 @@ public partial class App private FormState _formState = null!; private IInferenceClient _inferenceClient = null!; - private IResourceLoader _resourceLoader = null!; private Stream _securedConfig = null!; private Stream _systemConfig = null!; private static readonly Guid KeyPressTaskId = Guid.NewGuid(); + private string _loadErrors = ""; + private readonly ICache _cache = new MemoryCache(); private IAzaionApi _azaionApi = null!; + private CancellationTokenSource _mainCTokenSource = new(); private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { @@ -89,10 +89,10 @@ public partial class App new ConfigUpdater().CheckConfig(); var secureAppConfig = ReadSecureAppConfig(); var apiDir = secureAppConfig.DirectoriesConfig.ApiResourcesDirectory; - _inferenceClient = new InferenceClient(new OptionsWrapper(secureAppConfig.InferenceClientConfig)); - _resourceLoader = new ResourceLoader(_inferenceClient); + _inferenceClient = new InferenceClient(new OptionsWrapper(secureAppConfig.InferenceClientConfig), _mainCTokenSource.Token); var login = new Login(); + var loader = (IResourceLoader)_inferenceClient; login.CredentialsEntered += (_, credentials) => { _inferenceClient.Send(RemoteCommand.Create(CommandType.Login, credentials)); @@ -100,8 +100,8 @@ public partial class App try { - _securedConfig = _resourceLoader.LoadFile("config.secured.json", apiDir); - _systemConfig = _resourceLoader.LoadFile("config.system.json", apiDir); + _securedConfig = loader.LoadFile("config.secured.json", apiDir); + _systemConfig = loader.LoadFile("config.system.json", apiDir); } catch (Exception e) { @@ -123,12 +123,13 @@ public partial class App { try { - var stream = _resourceLoader.LoadFile($"{assemblyName}.dll", apiDir); + var stream = loader.LoadFile($"{assemblyName}.dll", apiDir); return Assembly.Load(stream.ToArray()); } catch (Exception e) { - Console.WriteLine(e); + Log.Logger.Error(e, $"Failed to load assembly {assemblyName}"); + _loadErrors += $"{e.Message}{Environment.NewLine}{Environment.NewLine}"; var currentLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; var dllPath = Path.Combine(currentLocation, SecurityConstants.DUMMY_DIR, $"{assemblyName}.dll"); return Assembly.LoadFile(dllPath); @@ -143,13 +144,13 @@ public partial class App StartMain(); _host.Start(); - EventManager.RegisterClassHandler(typeof(UIElement), UIElement.KeyDownEvent, new RoutedEventHandler(GlobalClick)); + EventManager.RegisterClassHandler(typeof(UIElement), UIElement.PreviewKeyDownEvent, new RoutedEventHandler(GlobalKeyHandler)); _host.Services.GetRequiredService().Show(); }; login.Closed += (sender, args) => { if (!login.MainSuiteOpened) - _inferenceClient.Stop(); + _inferenceClient.Dispose(); }; login.ShowDialog(); } @@ -218,7 +219,7 @@ public partial class App services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -229,18 +230,25 @@ public partial class App .Build(); Annotation.InitializeDirs(_host.Services.GetRequiredService>().Value); + var datasetExplorer = _host.Services.GetRequiredService(); + datasetExplorer.Show(); + datasetExplorer.Hide(); _mediator = _host.Services.GetRequiredService(); + if (!string.IsNullOrEmpty(_loadErrors)) + _mediator.Publish(new LoadErrorEvent(_loadErrors)); + _logger = _host.Services.GetRequiredService>(); _formState = _host.Services.GetRequiredService(); DispatcherUnhandledException += OnDispatcherUnhandledException; } - private void GlobalClick(object sender, RoutedEventArgs e) + private void GlobalKeyHandler(object sender, RoutedEventArgs e) { var args = (KeyEventArgs)e; var keyEvent = new KeyEvent(sender, args, _formState.ActiveWindow); - ThrottleExt.Throttle(() => _mediator.Publish(keyEvent), KeyPressTaskId, TimeSpan.FromMilliseconds(50)); + ThrottleExt.Throttle(() => _mediator.Publish(keyEvent, _mainCTokenSource.Token), KeyPressTaskId, TimeSpan.FromMilliseconds(50)); + //e.Handled = true; } protected override async void OnExit(ExitEventArgs e) @@ -248,4 +256,4 @@ public partial class App base.OnExit(e); await _host.StopAsync(); } -} +} \ No newline at end of file diff --git a/Azaion.Suite/MainSuite.xaml.cs b/Azaion.Suite/MainSuite.xaml.cs index 4b16c7d..9a96a9a 100644 --- a/Azaion.Suite/MainSuite.xaml.cs +++ b/Azaion.Suite/MainSuite.xaml.cs @@ -143,7 +143,7 @@ public partial class MainSuite foreach (var window in _openedWindows) window.Value.Close(); - _inferenceClient.Stop(); + _inferenceClient.Dispose(); _gpsMatcherClient.Stop(); Application.Current.Shutdown(); } diff --git a/Azaion.Suite/config.json b/Azaion.Suite/config.json index f5c76fd..d4652ef 100644 --- a/Azaion.Suite/config.json +++ b/Azaion.Suite/config.json @@ -27,6 +27,6 @@ "LeftPanelWidth": 220.0, "RightPanelWidth": 230.0, "GenerateAnnotatedImage": true, - "SilentDetection": true + "SilentDetection": false } } \ No newline at end of file diff --git a/Azaion.Suite/config.system.json b/Azaion.Suite/config.system.json index a0beb77..e3a280c 100644 --- a/Azaion.Suite/config.system.json +++ b/Azaion.Suite/config.system.json @@ -1,22 +1,23 @@ { "AnnotationConfig": { "DetectionClasses": [ - { "Id": 0, "Name": "ArmorVehicle", "ShortName": "Броня", "Color": "#FF0000" }, - { "Id": 1, "Name": "Truck", "ShortName": "Вантаж.", "Color": "#00FF00" }, - { "Id": 2, "Name": "Vehicle", "ShortName": "Машина", "Color": "#0000FF" }, - { "Id": 3, "Name": "Atillery", "ShortName": "Арта", "Color": "#FFFF00" }, - { "Id": 4, "Name": "Shadow", "ShortName": "Тінь", "Color": "#FF00FF" }, - { "Id": 5, "Name": "Trenches", "ShortName": "Окопи", "Color": "#00FFFF" }, - { "Id": 6, "Name": "MilitaryMan", "ShortName": "Військов", "Color": "#188021" }, - { "Id": 7, "Name": "TyreTracks", "ShortName": "Накати", "Color": "#800000" }, - { "Id": 8, "Name": "AdditArmoredTank", "ShortName": "Танк.захист", "Color": "#008000" }, - { "Id": 9, "Name": "Smoke", "ShortName": "Дим", "Color": "#000080" }, - { "Id": 10, "Name": "Plane", "ShortName": "Літак", "Color": "#000080" }, - { "Id": 11, "Name": "Moto", "ShortName": "Мото", "Color": "#808000" }, - { "Id": 12, "Name": "CamouflageNet", "ShortName": "Сітка", "Color": "#800080" }, - { "Id": 13, "Name": "CamouflageBranches", "ShortName": "Гілки", "Color": "#2f4f4f" }, - { "Id": 14, "Name": "Roof", "ShortName": "Дах", "Color": "#1e90ff" }, - { "Id": 15, "Name": "Building", "ShortName": "Будівля", "Color": "#ffb6c1" } + { "Id": 0, "Name": "ArmorVehicle", "ShortName": "Броня", "Color": "#ff0000" }, + { "Id": 1, "Name": "Truck", "ShortName": "Вантаж.", "Color": "#00ff00" }, + { "Id": 2, "Name": "Vehicle", "ShortName": "Машина", "Color": "#0000ff" }, + { "Id": 3, "Name": "Atillery", "ShortName": "Арта", "Color": "#ffff00" }, + { "Id": 4, "Name": "Shadow", "ShortName": "Тінь", "Color": "#ff00ff" }, + { "Id": 5, "Name": "Trenches", "ShortName": "Окопи", "Color": "#00ffff" }, + { "Id": 6, "Name": "MilitaryMan", "ShortName": "Військов", "Color": "#188021" }, + { "Id": 7, "Name": "TyreTracks", "ShortName": "Накати", "Color": "#800000" }, + { "Id": 8, "Name": "AdditArmoredTank", "ShortName": "Танк.захист", "Color": "#008000" }, + { "Id": 9, "Name": "Smoke", "ShortName": "Дим", "Color": "#000080" }, + { "Id": 10, "Name": "Plane", "ShortName": "Літак", "Color": "#a52a2a" }, + { "Id": 11, "Name": "Moto", "ShortName": "Мото", "Color": "#808000" }, + { "Id": 12, "Name": "CamouflageNet", "ShortName": "Сітка", "Color": "#87ceeb" }, + { "Id": 13, "Name": "CamouflageBranches", "ShortName": "Гілки", "Color": "#2f4f4f" }, + { "Id": 14, "Name": "Roof", "ShortName": "Дах", "Color": "#1e90ff" }, + { "Id": 15, "Name": "Building", "ShortName": "Будівля", "Color": "#ffb6c1" }, + { "Id": 16, "Name": "Caponier", "ShortName": "Капонір", "Color": "#ffa500" } ], "VideoFormats": [ ".mp4", ".mov", ".avi" ], "ImageFormats": [ ".jpg", ".jpeg", ".png", ".bmp" ], diff --git a/Azaion.Test/ThrottleTest.cs b/Azaion.Test/ThrottleTest.cs new file mode 100644 index 0000000..6a5b746 --- /dev/null +++ b/Azaion.Test/ThrottleTest.cs @@ -0,0 +1,57 @@ +using Azaion.Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Azaion.Annotator.Test; + +public class ThrottleTest +{ + private readonly Guid _testTaskId = Guid.NewGuid(); + + [Fact] + public async Task TestScheduleAfterCooldown() + { + var calls = new List(); + + Console.WriteLine($"Start time: {DateTime.Now}"); + for (int i = 0; i < 10; i++) + { + ThrottleExt.Throttle(() => + { + calls.Add(DateTime.Now); + return Task.CompletedTask; + }, _testTaskId, TimeSpan.FromSeconds(1), scheduleCallAfterCooldown: true); + } + + await Task.Delay(TimeSpan.FromSeconds(2)); + Console.WriteLine(string.Join(',', calls)); + + calls.Count.Should().Be(2); + } + + [Fact] + public async Task TestScheduleAfterCooldown2() + { + var calls = new List(); + + Console.WriteLine($"Start time: {DateTime.Now}"); + ThrottleExt.Throttle(() => + { + calls.Add(DateTime.Now); + return Task.CompletedTask; + }, _testTaskId, TimeSpan.FromSeconds(1), scheduleCallAfterCooldown: true); + await Task.Delay(TimeSpan.FromSeconds(2)); + + ThrottleExt.Throttle(() => + { + calls.Add(DateTime.Now); + return Task.CompletedTask; + }, _testTaskId, TimeSpan.FromSeconds(1), scheduleCallAfterCooldown: true); + + + await Task.Delay(TimeSpan.FromSeconds(2)); + Console.WriteLine(string.Join(',', calls)); + + calls.Count.Should().Be(2); + } +} \ No newline at end of file diff --git a/Dummy/Azaion.Annotator/Annotator.xaml b/Dummy/Azaion.Annotator/Annotator.xaml index dae811d..f139f98 100644 --- a/Dummy/Azaion.Annotator/Annotator.xaml +++ b/Dummy/Azaion.Annotator/Annotator.xaml @@ -7,13 +7,39 @@ Title="Azaion Annotator" Height="800" Width="1100" WindowState="Maximized" > - - + + + + + + + + Під час запуску виникла помилка! + + Error happened during the launch! + + + Будь ласка перевірте правильність email чи паролю! Також зауважте, що запуск можливий лише з одного конкретного компьютера, копіювання заборонене! Для подальшого вирішення проблеми ви можете зв'язатися з нами: hi@azaion.com @@ -21,5 +47,6 @@ Please check your email or password! The program is restricted to start only from particular hardware, copying is forbidden! For the further guidance, please feel free to contact us: hi@azaion.com - + + diff --git a/Dummy/Azaion.Annotator/AnnotatorEventHandler.cs b/Dummy/Azaion.Annotator/AnnotatorEventHandler.cs index d27500f..f216f8a 100644 --- a/Dummy/Azaion.Annotator/AnnotatorEventHandler.cs +++ b/Dummy/Azaion.Annotator/AnnotatorEventHandler.cs @@ -1,3 +1,13 @@ -namespace Azaion.Annotator; +using Azaion.Common.Events; +using MediatR; -public class AnnotatorEventHandler; +namespace Azaion.Annotator; + +public class AnnotatorEventHandler(Annotator annotator) : INotificationHandler +{ + public Task Handle(LoadErrorEvent notification, CancellationToken cancellationToken) + { + annotator.Dispatcher.Invoke(() => annotator.TbError.Text = notification.Error); + return Task.CompletedTask; + } +} diff --git a/build/cdn_manager.py b/build/cdn_manager.py index 04fce3b..e3ed75b 100644 --- a/build/cdn_manager.py +++ b/build/cdn_manager.py @@ -34,24 +34,25 @@ class CDNManager: print(e) return False - def download(self, bucket: str, filename: str): + def download(self, folder: str, filename: str): try: if filename is not None: - self.download_client.download_file(bucket, filename, os.path.join(bucket, filename)) - print(f'downloaded {filename} from the {bucket} to current folder') + self.download_client.download_file(folder, filename, f'{folder}\\{filename}') + print(f'downloaded {filename} from the {folder} to current folder') + return True else: - response = self.download_client.list_objects_v2(Bucket=bucket) + response = self.download_client.list_objects_v2(Bucket=folder) if 'Contents' in response: for obj in response['Contents']: object_key = obj['Key'] - local_filepath = os.path.join(bucket, object_key) + local_filepath = os.path.join(folder, object_key) local_dir = os.path.dirname(local_filepath) if local_dir: os.makedirs(local_dir, exist_ok=True) if not object_key.endswith('/'): try: - self.download_client.download_file(bucket, object_key, local_filepath) + self.download_client.download_file(folder, object_key, local_filepath) except Exception as e_file: all_successful = False # Mark as failed if any file fails return True diff --git a/build/installer.full.iss b/build/installer.full.iss index 2e7ba94..edea07d 100644 --- a/build/installer.full.iss +++ b/build/installer.full.iss @@ -1,12 +1,12 @@ [Setup] AppId={{CCFEC8E2-0FCC-4B03-8EEA-00AF20D265E5}} AppName=Azaion Suite -AppVersion=1.4.5 +AppVersion=1.4.6 AppPublisher=Azaion Ukraine DefaultDirName={localappdata}\Azaion\Azaion Suite DefaultGroupName=Azaion Suite OutputDir=..\ -OutputBaseFilename=AzaionSuite.Full.1.4.5 +OutputBaseFilename=AzaionSuite.Full.1.4.6 SetupIconFile=..\dist-azaion\logo.ico UninstallDisplayName=Azaion Suite UninstallDisplayIcon={app}\Azaion.Suite.exe diff --git a/build/installer.iterative.iss b/build/installer.iterative.iss index 6285322..86f9106 100644 --- a/build/installer.iterative.iss +++ b/build/installer.iterative.iss @@ -1,12 +1,12 @@ [Setup] AppId={{CCFEC8E2-0FCC-4B03-8EEA-00AF20D265E5}} AppName=Azaion Suite -AppVersion=1.4.5 +AppVersion=1.4.6 AppPublisher=Azaion Ukraine DefaultDirName={localappdata}\Azaion\Azaion Suite DefaultGroupName=Azaion Suite OutputDir=..\ -OutputBaseFilename=AzaionSuite.Iterative.1.4.5 +OutputBaseFilename=AzaionSuite.Iterative.1.4.6 SetupIconFile=..\dist-azaion\logo.ico UninstallDisplayName=Azaion Suite UninstallDisplayIcon={app}\Azaion.Suite.exe diff --git a/build/publish.cmd b/build/publish.cmd index 48f4fbf..3678741 100644 --- a/build/publish.cmd +++ b/build/publish.cmd @@ -12,7 +12,6 @@ call ..\gps-denied\image-matcher\build_gps call build\download_models echo building installer... -iscc build\installer.full.iss iscc build\installer.iterative.iss popd \ No newline at end of file