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" },