From 22d4493d8656caa410c62f162219c5ecba66b03d Mon Sep 17 00:00:00 2001 From: Alex Bezdieniezhnykh Date: Wed, 25 Sep 2024 21:46:07 +0300 Subject: [PATCH] make thumbnail generating multithread fix the bug with open video add class distribution chart --- Azaion.Annotator.Test/ParallelExtTest.cs | 46 +++++++++++++++ Azaion.Annotator/Azaion.Annotator.csproj | 1 + Azaion.Annotator/DTO/FormState.cs | 4 +- Azaion.Annotator/DTO/Label.cs | 4 +- Azaion.Annotator/DatasetExplorer.xaml | 11 ++-- Azaion.Annotator/DatasetExplorer.xaml.cs | 69 +++++++++++++++++----- Azaion.Annotator/Extensions/ParallelExt.cs | 67 +++++++++++++++++++++ Azaion.Annotator/GalleryManager.cs | 39 ++++++------ Azaion.Annotator/MainWindow.xaml | 2 +- Azaion.Annotator/MainWindow.xaml.cs | 20 ++++--- Azaion.Annotator/config.json | 2 +- 11 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 Azaion.Annotator.Test/ParallelExtTest.cs create mode 100644 Azaion.Annotator/Extensions/ParallelExt.cs diff --git a/Azaion.Annotator.Test/ParallelExtTest.cs b/Azaion.Annotator.Test/ParallelExtTest.cs new file mode 100644 index 0000000..e0399de --- /dev/null +++ b/Azaion.Annotator.Test/ParallelExtTest.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using Azaion.Annotator.Extensions; +using FluentAssertions; +using Xunit; +using ParallelOptions = Azaion.Annotator.Extensions.ParallelOptions; + +namespace Azaion.Annotator.Test; + +public class ParallelExtTest +{ + [Fact] + public async Task ParallelExtWorksOkTest() + { + var list = Enumerable.Range(0, 10).ToList(); + + var sw = Stopwatch.StartNew(); + await ParallelExt.ForEachAsync(list, async (i, cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(i), cancellationToken); + }, new ParallelOptions + { + CpuUtilPercent = 100, + ProgressUpdateInterval = 1 + }); + + var elapsed = sw.Elapsed; + elapsed.Should().BeLessThan(TimeSpan.FromSeconds(11)); + } + + [Fact] + public async Task ParallelLibWorksOkTest() + { + var list = Enumerable.Range(0, 10).ToList(); + + var sw = Stopwatch.StartNew(); + await Parallel.ForEachAsync(list, new System.Threading.Tasks.ParallelOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount, + }, async (i, cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(i), cancellationToken); + }); + var elapsed = sw.Elapsed; + elapsed.Should().BeLessThan(TimeSpan.FromSeconds(11)); + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator.csproj b/Azaion.Annotator/Azaion.Annotator.csproj index e657879..326a6f3 100644 --- a/Azaion.Annotator/Azaion.Annotator.csproj +++ b/Azaion.Annotator/Azaion.Annotator.csproj @@ -20,6 +20,7 @@ + diff --git a/Azaion.Annotator/DTO/FormState.cs b/Azaion.Annotator/DTO/FormState.cs index d15850f..b6fed46 100644 --- a/Azaion.Annotator/DTO/FormState.cs +++ b/Azaion.Annotator/DTO/FormState.cs @@ -19,12 +19,12 @@ public class FormState public ObservableCollection AnnotationResults { get; set; } = []; public WindowsEnum ActiveWindow { get; set; } - public string GetTimeName(TimeSpan ts) => $"{VideoName}_{ts:hmmssf}"; + public string GetTimeName(TimeSpan? ts) => $"{VideoName}_{ts:hmmssf}"; public TimeSpan? GetTime(string name) { var timeStr = name.Split("_").LastOrDefault(); - if (string.IsNullOrEmpty(timeStr) || timeStr.Length < 7) + if (string.IsNullOrEmpty(timeStr) || timeStr.Length < 6) return null; //For some reason, TimeSpan.ParseExact doesn't work on every platform. diff --git a/Azaion.Annotator/DTO/Label.cs b/Azaion.Annotator/DTO/Label.cs index cfe16f5..b3bdf8e 100644 --- a/Azaion.Annotator/DTO/Label.cs +++ b/Azaion.Annotator/DTO/Label.cs @@ -148,9 +148,9 @@ public class YoloLabel : Label return res; } - public static async Task> ReadFromFile(string filename) + public static async Task> ReadFromFile(string filename, CancellationToken cancellationToken = default) { - var str = await File.ReadAllTextAsync(filename); + var str = await File.ReadAllTextAsync(filename, cancellationToken); return str.Split('\n') .Select(Parse) diff --git a/Azaion.Annotator/DatasetExplorer.xaml b/Azaion.Annotator/DatasetExplorer.xaml index 1b48431..c1f9556 100644 --- a/Azaion.Annotator/DatasetExplorer.xaml +++ b/Azaion.Annotator/DatasetExplorer.xaml @@ -4,11 +4,11 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:vwp="clr-namespace:WpfToolkit.Controls;assembly=VirtualizingWrapPanel" - xmlns:local="clr-namespace:Azaion.Annotator" xmlns:dto="clr-namespace:Azaion.Annotator.DTO" xmlns:controls="clr-namespace:Azaion.Annotator.Controls" + xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" mc:Ignorable="d" - Title="Браузер анотацій" Height="900" Width="1200"> + Title="Переглядач анотацій" Height="900" Width="1200"> @@ -58,7 +58,7 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Black"> - + - + + + + { AllAnnotationClasses = new ObservableCollection( @@ -88,12 +89,14 @@ public partial class DatasetExplorer LvClasses.SelectedIndex = config.LastSelectedExplorerClass ?? 0; ExplorerEditor.CurrentAnnClass = (AnnotationClass)LvClasses.SelectedItem; await ReloadThumbnails(); - + LoadClassDistribution(); + SizeChanged += async (_, _) => await SaveUserSettings(); LocationChanged += async (_, _) => await SaveUserSettings(); StateChanged += async (_, _) => await SaveUserSettings(); RefreshThumbBar.Value = galleryManager.ThumbnailsPercentage; + DataContext = this; }; Closing += (sender, args) => @@ -125,18 +128,17 @@ public partial class DatasetExplorer Switcher.SelectionChanged += (sender, args) => { - if (Switcher.SelectedIndex == 1) + switch (Switcher.SelectedIndex) { - //Editor - _tempSelectedClassIdx = LvClasses.SelectedIndex; - LvClasses.ItemsSource = _config.AnnotationClasses; - } - else - { - //Explorer - LvClasses.ItemsSource = AllAnnotationClasses; - LvClasses.SelectedIndex = _tempSelectedClassIdx; - ExplorerEditor.Background = null; + case 0: //ListView + LvClasses.ItemsSource = AllAnnotationClasses; + LvClasses.SelectedIndex = _tempSelectedClassIdx; + ExplorerEditor.Background = null; + break; + case 1: //Editor + _tempSelectedClassIdx = LvClasses.SelectedIndex; + LvClasses.ItemsSource = _config.AnnotationClasses; + break; } }; @@ -148,6 +150,41 @@ public partial class DatasetExplorer } + private void LoadClassDistribution() + { + var data = LabelsCache.SelectMany(x => x.Value) + .GroupBy(x => x) + .Select(x => new + { + x.Key, + _config.AnnotationClassesDict[x.Key].Name, + _config.AnnotationClassesDict[x.Key].Color, + ClassCount = x.Count() + }) + .ToList(); + + var plot = ClassDistribution.Plot; + plot.Add.Bars(data.Select(x => new Bar + { + Orientation = Orientation.Horizontal, + Position = -1.5 * x.Key + 1, + Label = x.ClassCount > 200 ? x.ClassCount.ToString() : "", + FillColor = new Color(x.Color.R, x.Color.G, x.Color.B, x.Color.A), + Value = x.ClassCount, + CenterLabel = true + })); + foreach (var x in data) + { + var label = plot.Add.Text(x.Name, 50, -1.5 * x.Key + 1.1); + label.LabelFontSize = 16; + } + + plot.Axes.AutoScale(); + //plot.Axes.SetLimits(-200, data.Max(x => x.ClassCount + 3000), -2 * data.Count + 5, 5); + ClassDistribution.Background = new SolidColorBrush(System.Windows.Media.Colors.Black); + ClassDistribution.Refresh(); + } + private async Task EditAnnotation() { try @@ -171,7 +208,7 @@ public partial class DatasetExplorer ExplorerEditor.RemoveAllAnns(); foreach (var ann in await YoloLabel.ReadFromFile(dto.LabelPath)) { - var annClass = _config.AnnotationClasses[ann.ClassNumber]; + var annClass = _config.AnnotationClassesDict[ann.ClassNumber]; var annInfo = new CanvasLabel(ann, ExplorerEditor.RenderSize, ExplorerEditor.RenderSize); ExplorerEditor.CreateAnnotation(annClass, time, annInfo); } @@ -248,7 +285,7 @@ public partial class DatasetExplorer await File.WriteAllTextAsync(_thumbnailsCacheFile, JsonConvert.SerializeObject(LabelsCache)); } - private async Task AddThumbnail(string thumbnail) + private async Task AddThumbnail(string thumbnail, CancellationToken cancellationToken = default) { try { @@ -284,7 +321,7 @@ public partial class DatasetExplorer return; } - var labels = await YoloLabel.ReadFromFile(labelPath); + var labels = await YoloLabel.ReadFromFile(labelPath, cancellationToken); classes = labels.Select(x => x.ClassNumber).Distinct().ToList(); LabelsCache.Add(name, classes); } diff --git a/Azaion.Annotator/Extensions/ParallelExt.cs b/Azaion.Annotator/Extensions/ParallelExt.cs new file mode 100644 index 0000000..8e8edc9 --- /dev/null +++ b/Azaion.Annotator/Extensions/ParallelExt.cs @@ -0,0 +1,67 @@ +namespace Azaion.Annotator.Extensions; + +public class ParallelOptions +{ + public int ProgressUpdateInterval { get; set; } = 100; + public Func ProgressFn { get; set; } = null!; + public double CpuUtilPercent { get; set; } = 100; +} + +public class ParallelExt +{ + public static async Task ForEachAsync(ICollection source, + Func processFn, + ParallelOptions? parallelOptions = null, + CancellationToken cancellationToken = default) + { + parallelOptions ??= new ParallelOptions + { + CpuUtilPercent = 100, + ProgressUpdateInterval = 100, + ProgressFn = i => + { + Console.WriteLine($"Processed {i} item by Task {Task.CurrentId}%"); + return Task.CompletedTask; + } + }; + var threadsCount = (int)(Environment.ProcessorCount * parallelOptions.CpuUtilPercent / 100.0); + + var processedCount = 0; + var chunkSize = Math.Max(1, (int)(source.Count / (decimal)threadsCount)); + var chunks = source.Chunk(chunkSize).ToList(); + if (chunks.Count > threadsCount) + { + chunks[^2] = chunks[^2].Concat(chunks.Last()).ToArray(); + chunks.RemoveAt(chunks.Count - 1); + } + var progressUpdateLock = new SemaphoreSlim(1); + + var tasks = new List(); + foreach (var chunk in chunks) + { + tasks.Add(await Task.Factory.StartNew(async () => + { + foreach (var item in chunk) + { + await processFn(item, cancellationToken); + Interlocked.Increment(ref processedCount); + if (processedCount % parallelOptions.ProgressUpdateInterval == 0 && parallelOptions.ProgressFn != null) + _ = Task.Run(async () => + { + await progressUpdateLock.WaitAsync(cancellationToken); + try + { + await parallelOptions.ProgressFn(processedCount); + } + finally + { + progressUpdateLock.Release(); + } + }, cancellationToken); + } + + }, TaskCreationOptions.LongRunning)); + } + await Task.WhenAll(tasks); + } +} \ No newline at end of file diff --git a/Azaion.Annotator/GalleryManager.cs b/Azaion.Annotator/GalleryManager.cs index 6e637f6..76e3425 100644 --- a/Azaion.Annotator/GalleryManager.cs +++ b/Azaion.Annotator/GalleryManager.cs @@ -3,8 +3,10 @@ using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.IO; using Azaion.Annotator.DTO; +using Azaion.Annotator.Extensions; using Microsoft.Extensions.Logging; using Color = System.Drawing.Color; +using ParallelOptions = Azaion.Annotator.Extensions.ParallelOptions; using Size = System.Windows.Size; namespace Azaion.Annotator; @@ -14,7 +16,7 @@ public delegate void ThumbnailsUpdatedEventHandler(double thumbnailsPercentage); public class GalleryManager(Config config, ILogger logger) : IGalleryManager { public event ThumbnailsUpdatedEventHandler ThumbnailsUpdate; - private const int UPDATE_STEP = 20; + private readonly SemaphoreSlim _updateLock = new(1); public double ThumbnailsPercentage { get; set; } @@ -53,32 +55,35 @@ public class GalleryManager(Config config, ILogger logger) : IGa .GroupBy(x => x) .Select(gr => gr.Key) .ToHashSet(); - var thumbnailsCount = thumbnails.Count; var files = new DirectoryInfo(config.ImagesDirectory).GetFiles(); var imagesCount = files.Length; - for (int i = 0; i < files.Length; i++) + await ParallelExt.ForEachAsync(files, async (file, cancellationToken) => { - var img = files[i]; - ThumbnailsPercentage = imagesCount == 0 ? 0 : Math.Min(100, i * 100 / (double)imagesCount); - var imgName = Path.GetFileNameWithoutExtension(img.Name); - if (i % UPDATE_STEP == 0) - ThumbnailsUpdate?.Invoke(ThumbnailsPercentage); + var imgName = Path.GetFileNameWithoutExtension(file.Name); if (thumbnails.Contains(imgName)) - continue; + return; try { - await CreateThumbnail(img.FullName); - thumbnailsCount++; + await CreateThumbnail(file.FullName, cancellationToken); } catch (Exception e) { - logger.LogError(e, $"Failed to generate thumbnail for {img.Name}! Error: {e.Message}"); + logger.LogError(e, $"Failed to generate thumbnail for {file.Name}! Error: {e.Message}"); } - } - - await Task.Delay(10000); + }, new ParallelOptions + { + ProgressFn = async num => + { + Console.WriteLine($"Processed {num} item by Thread {Environment.CurrentManagedThreadId}"); + ThumbnailsPercentage = imagesCount == 0 ? 0 : Math.Min(100, num * 100 / (double)imagesCount); + ThumbnailsUpdate?.Invoke(ThumbnailsPercentage); + await Task.CompletedTask; + }, + CpuUtilPercent = 100, + ProgressUpdateInterval = 200 + }); } finally { @@ -86,7 +91,7 @@ public class GalleryManager(Config config, ILogger logger) : IGa } } - public async Task CreateThumbnail(string imgPath) + public async Task CreateThumbnail(string imgPath, CancellationToken cancellationToken = default) { var bitmap = await GenerateThumbnail(imgPath); if (bitmap != null) @@ -178,7 +183,7 @@ public interface IGalleryManager { event ThumbnailsUpdatedEventHandler ThumbnailsUpdate; double ThumbnailsPercentage { get; set; } - Task CreateThumbnail(string imgPath); + Task CreateThumbnail(string imgPath, CancellationToken cancellationToken = default); Task RefreshThumbnails(); void ClearThumbnails(); } \ No newline at end of file diff --git a/Azaion.Annotator/MainWindow.xaml b/Azaion.Annotator/MainWindow.xaml index 2745e3b..0ae2c11 100644 --- a/Azaion.Annotator/MainWindow.xaml +++ b/Azaion.Annotator/MainWindow.xaml @@ -78,7 +78,7 @@ IsEnabled="True" Header="Відкрити папку..." Click="OpenFolderItemClick"/> + IsEnabled="True" Header="Відкрити переглядач анотацій..." Click="OpenDataExplorerItemClick"/> diff --git a/Azaion.Annotator/MainWindow.xaml.cs b/Azaion.Annotator/MainWindow.xaml.cs index 9adf192..4897ff4 100644 --- a/Azaion.Annotator/MainWindow.xaml.cs +++ b/Azaion.Annotator/MainWindow.xaml.cs @@ -248,25 +248,25 @@ public partial class MainWindow foreach (var file in labelFiles) { var name = Path.GetFileNameWithoutExtension(file.Name); - var time = _formState.GetTime(name)!.Value; - + var time = _formState.GetTime(name); await AddAnnotation(time, await YoloLabel.ReadFromFile(file.FullName)); } } - public async Task AddAnnotation(TimeSpan time, List annotations) + public async Task AddAnnotation(TimeSpan? time, List annotations) { var fName = _formState.GetTimeName(time); - var previousAnnotations = Annotations.Query(time); + var timeValue = time ?? TimeSpan.FromMinutes(0); + var previousAnnotations = Annotations.Query(timeValue); Annotations.Remove(previousAnnotations); - Annotations.Add(time.Subtract(_thresholdBefore), time.Add(_thresholdAfter), annotations); + Annotations.Add(timeValue.Subtract(_thresholdBefore), timeValue.Add(_thresholdAfter), annotations); var existingResult = _formState.AnnotationResults.FirstOrDefault(x => x.Time == time); if (existingResult != null) _formState.AnnotationResults.Remove(existingResult); - _formState.AnnotationResults.Add(new AnnotationResult(time, fName, annotations, _config)); + _formState.AnnotationResults.Add(new AnnotationResult(timeValue, fName, annotations, _config)); await File.WriteAllTextAsync($"{_config.ResultsDirectory}/{fName}.json", JsonConvert.SerializeObject(_formState.AnnotationResults)); } @@ -277,7 +277,13 @@ public partial class MainWindow return; var labelNames = new DirectoryInfo(_config.LabelsDirectory).GetFiles() - .Select(x => x.Name[..^11]) + .Select(x => + { + var name = Path.GetFileNameWithoutExtension(x.Name); + return name.Length > 8 + ? name[..^7] + : name; + }) .GroupBy(x => x) .Select(gr => gr.Key) .ToDictionary(x => x); diff --git a/Azaion.Annotator/config.json b/Azaion.Annotator/config.json index a4109d6..5f1260f 100644 --- a/Azaion.Annotator/config.json +++ b/Azaion.Annotator/config.json @@ -2,9 +2,9 @@ "VideosDirectory": "E:\\Azaion3\\Videos", "LabelsDirectory": "E:\\labels", "ImagesDirectory": "E:\\images", + "ThumbnailsDirectory": "E:\\thumbnails", "ResultsDirectory": "E:\\results", "UnknownImages": "E:\\unknown", - "ThumbnailsDirectory": "E:\\thumbnails", "AnnotationClasses": [ { "Id": 0, "Name": "Броньована техніка", "Color": "#40FF0000" }, { "Id": 1, "Name": "Вантажівка", "Color": "#4000FF00" },