mirror of
https://github.com/azaion/annotations.git
synced 2026-04-23 01:16:31 +00:00
rework to Azaion.Suite
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<Window x:Class="Azaion.Annotator.MainWindow"
|
||||
<Window x:Class="Azaion.Annotator.Annotator"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:wpf="clr-namespace:LibVLCSharp.WPF;assembly=LibVLCSharp.WPF" xmlns:controls="clr-namespace:Azaion.Annotator.Controls"
|
||||
xmlns:controls1="clr-namespace:Azaion.Common.Controls;assembly=Azaion.Common"
|
||||
xmlns:controls2="clr-namespace:Azaion.Annotator.Controls;assembly=Azaion.Common"
|
||||
mc:Ignorable="d"
|
||||
Title="Azaion Annotator" Height="450" Width="1100"
|
||||
>
|
||||
@@ -85,12 +87,6 @@
|
||||
<MenuItem x:Name="OpenFolderItem"
|
||||
Foreground="Black"
|
||||
IsEnabled="True" Header="Відкрити папку..." Click="OpenFolderItemClick"/>
|
||||
<MenuItem x:Name="OpenDataExplorerItem"
|
||||
Foreground="Black"
|
||||
IsEnabled="True" Header="Відкрити переглядач анотацій..." Click="OpenDataExplorerItemClick"/>
|
||||
<MenuItem x:Name="ReloadThumbnailsItem"
|
||||
Foreground="Black"
|
||||
IsEnabled="True" Header="Оновити базу іконок" Click="ReloadThumbnailsItemClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Допомога" Foreground="#FFBDBCBC" Margin="0,3,0,0">
|
||||
<MenuItem x:Name="OpenHelpWindow"
|
||||
@@ -179,11 +175,11 @@
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
<controls:AnnotationClasses
|
||||
<controls1:AnnotationClasses
|
||||
x:Name="LvClasses"
|
||||
Grid.Column="0"
|
||||
Grid.Row="4">
|
||||
</controls:AnnotationClasses>
|
||||
</controls1:AnnotationClasses>
|
||||
|
||||
<GridSplitter
|
||||
Background="DarkGray"
|
||||
@@ -201,7 +197,7 @@
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="4"
|
||||
x:Name="VideoView">
|
||||
<controls:CanvasEditor x:Name="Editor"
|
||||
<controls1:CanvasEditor x:Name="Editor"
|
||||
Background="#01000000"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch" />
|
||||
@@ -261,9 +257,9 @@
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0 0 " EndPoint="1 0">
|
||||
<GradientStop Offset="0.3" Color="{Binding Path=ClassColor1}" />
|
||||
<GradientStop Offset="0.5" Color="{Binding Path=ClassColor2}" />
|
||||
<GradientStop Offset="0.8" Color="{Binding Path=ClassColor3}" />
|
||||
<GradientStop Offset="0.3" Color="{Binding Path=ClassColor0}" />
|
||||
<GradientStop Offset="0.5" Color="{Binding Path=ClassColor1}" />
|
||||
<GradientStop Offset="0.8" Color="{Binding Path=ClassColor2}" />
|
||||
<GradientStop Offset="0.99" Color="{Binding Path=ClassColor3}" />
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
@@ -275,12 +271,12 @@
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<controls:UpdatableProgressBar x:Name="VideoSlider"
|
||||
<controls2:UpdatableProgressBar x:Name="VideoSlider"
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Background="#252525"
|
||||
Foreground="LightBlue">
|
||||
</controls:UpdatableProgressBar>
|
||||
</controls2:UpdatableProgressBar>
|
||||
|
||||
<!-- Buttons -->
|
||||
<Grid
|
||||
@@ -469,14 +465,14 @@
|
||||
</Image>
|
||||
</Button>
|
||||
|
||||
<controls:UpdatableProgressBar
|
||||
<controls2:UpdatableProgressBar
|
||||
x:Name="Volume"
|
||||
Grid.Column="9"
|
||||
Width="70" Height="15"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="#252525" BorderBrush="#252525" Foreground="LightBlue"
|
||||
Maximum="100" Minimum="0">
|
||||
</controls:UpdatableProgressBar>
|
||||
</controls2:UpdatableProgressBar>
|
||||
|
||||
<Button
|
||||
x:Name="AIDetectBtn"
|
||||
@@ -7,9 +7,12 @@ using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Annotator.Extensions;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.DTO;
|
||||
using Azaion.Common.DTO.Config;
|
||||
using Azaion.Common.Extensions;
|
||||
using LibVLCSharp.Shared;
|
||||
using MediatR;
|
||||
using Microsoft.WindowsAPICodePack.Dialogs;
|
||||
@@ -17,122 +20,90 @@ using Newtonsoft.Json;
|
||||
using Size = System.Windows.Size;
|
||||
using IntervalTree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTK.Graphics.OpenGL;
|
||||
using ScottPlot.TickGenerators.TimeUnits;
|
||||
using Serilog;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MediaPlayer = LibVLCSharp.Shared.MediaPlayer;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public partial class MainWindow
|
||||
public partial class Annotator
|
||||
{
|
||||
private readonly AppConfig _appConfig;
|
||||
private readonly LibVLC _libVLC;
|
||||
private readonly MediaPlayer _mediaPlayer;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly FormState _formState;
|
||||
|
||||
private readonly IConfigRepository _configRepository;
|
||||
private readonly IConfigUpdater _configUpdater;
|
||||
private readonly HelpWindow _helpWindow;
|
||||
private readonly ILogger<MainWindow> _logger;
|
||||
private readonly IGalleryManager _galleryManager;
|
||||
private readonly ILogger<Annotator> _logger;
|
||||
private readonly VLCFrameExtractor _vlcFrameExtractor;
|
||||
private readonly IAIDetector _aiDetector;
|
||||
private CancellationTokenSource _cancellationTokenSource = new();
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
private ObservableCollection<AnnotationClass> AnnotationClasses { get; set; } = new();
|
||||
private bool _suspendLayout;
|
||||
|
||||
private readonly TimeSpan _thresholdBefore = TimeSpan.FromMilliseconds(100);
|
||||
private readonly TimeSpan _thresholdAfter = TimeSpan.FromMilliseconds(300);
|
||||
private readonly Config _config;
|
||||
private readonly DatasetExplorer _datasetExplorer;
|
||||
|
||||
|
||||
private ObservableCollection<MediaFileInfo> AllMediaFiles { get; set; } = new();
|
||||
private ObservableCollection<MediaFileInfo> FilteredMediaFiles { get; set; } = new();
|
||||
|
||||
public IntervalTree<TimeSpan, List<YoloLabel>> Annotations { get; set; } = new();
|
||||
private AutodetectDialog _autoDetectDialog;
|
||||
private AutodetectDialog _autoDetectDialog = new() { Topmost = true };
|
||||
|
||||
public MainWindow(LibVLC libVLC, MediaPlayer mediaPlayer,
|
||||
public Annotator(
|
||||
IConfigUpdater configUpdater,
|
||||
IOptions<AppConfig> appConfig,
|
||||
LibVLC libVLC, MediaPlayer mediaPlayer,
|
||||
IMediator mediator,
|
||||
FormState formState,
|
||||
IConfigRepository configRepository,
|
||||
HelpWindow helpWindow,
|
||||
DatasetExplorer datasetExplorer,
|
||||
ILogger<MainWindow> logger,
|
||||
IGalleryManager galleryManager,
|
||||
ILogger<Annotator> logger,
|
||||
VLCFrameExtractor vlcFrameExtractor,
|
||||
IAIDetector aiDetector)
|
||||
{
|
||||
InitializeComponent();
|
||||
_appConfig = appConfig.Value;
|
||||
_configUpdater = configUpdater;
|
||||
_libVLC = libVLC;
|
||||
_mediaPlayer = mediaPlayer;
|
||||
_mediator = mediator;
|
||||
_formState = formState;
|
||||
_configRepository = configRepository;
|
||||
_config = _configRepository.Get();
|
||||
_helpWindow = helpWindow;
|
||||
_datasetExplorer = datasetExplorer;
|
||||
_logger = logger;
|
||||
_galleryManager = galleryManager;
|
||||
_vlcFrameExtractor = vlcFrameExtractor;
|
||||
_aiDetector = aiDetector;
|
||||
|
||||
VideoView.Loaded += VideoView_Loaded;
|
||||
Closed += OnFormClosed;
|
||||
|
||||
if (!Directory.Exists(_config.LabelsDirectory))
|
||||
Directory.CreateDirectory(_config.LabelsDirectory);
|
||||
if (!Directory.Exists(_config.ImagesDirectory))
|
||||
Directory.CreateDirectory(_config.ImagesDirectory);
|
||||
if (!Directory.Exists(_config.ResultsDirectory))
|
||||
Directory.CreateDirectory(_config.ResultsDirectory);
|
||||
|
||||
Editor.GetTimeFunc = () => TimeSpan.FromMilliseconds(_mediaPlayer.Time);
|
||||
|
||||
Activated += (_, _) => { _formState.ActiveWindow = WindowsEnum.Main; };
|
||||
}
|
||||
|
||||
private void VideoView_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Core.Initialize();
|
||||
InitControls();
|
||||
_ = Task.Run(async () => await _galleryManager.RefreshThumbnails());
|
||||
|
||||
_suspendLayout = true;
|
||||
|
||||
Left = _config.MainWindowConfig.WindowLocation.X;
|
||||
Top = _config.MainWindowConfig.WindowLocation.Y;
|
||||
Width = _config.MainWindowConfig.WindowSize.Width;
|
||||
Height = _config.MainWindowConfig.WindowSize.Height;
|
||||
|
||||
_datasetExplorer.Left = _config.MainWindowConfig.WindowLocation.X;
|
||||
_datasetExplorer.Top = _config.DatasetExplorerConfig.WindowLocation.Y;
|
||||
_datasetExplorer.Width = _config.DatasetExplorerConfig.WindowSize.Width;
|
||||
_datasetExplorer.Height = _config.DatasetExplorerConfig.WindowSize.Height;
|
||||
if (_config.DatasetExplorerConfig.FullScreen)
|
||||
_datasetExplorer.WindowState = WindowState.Maximized;
|
||||
|
||||
MainGrid.ColumnDefinitions.FirstOrDefault()!.Width = new GridLength(_config.LeftPanelWidth);
|
||||
MainGrid.ColumnDefinitions.LastOrDefault()!.Width = new GridLength(_config.RightPanelWidth);
|
||||
|
||||
if (_config.MainWindowConfig.FullScreen)
|
||||
WindowState = WindowState.Maximized;
|
||||
MainGrid.ColumnDefinitions.FirstOrDefault()!.Width = new GridLength(_appConfig.WindowConfig.LeftPanelWidth);
|
||||
MainGrid.ColumnDefinitions.LastOrDefault()!.Width = new GridLength(_appConfig.WindowConfig.RightPanelWidth);
|
||||
|
||||
_suspendLayout = false;
|
||||
|
||||
ReloadFiles();
|
||||
if (_config.AnnotationClasses.Count == 0)
|
||||
_config.AnnotationClasses.Add(new AnnotationClass{Id = 0});
|
||||
|
||||
AnnotationClasses = new ObservableCollection<AnnotationClass>(_config.AnnotationClasses);
|
||||
AnnotationClasses = new ObservableCollection<AnnotationClass>(_appConfig.AnnotationConfig.AnnotationClasses);
|
||||
LvClasses.ItemsSource = AnnotationClasses;
|
||||
LvClasses.SelectedIndex = 0;
|
||||
|
||||
if (LvFiles.Items.IsEmpty)
|
||||
BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.Initial]);
|
||||
|
||||
if (_config.ShowHelpOnStart)
|
||||
if (_appConfig.WindowConfig.ShowHelpOnStart)
|
||||
_helpWindow.Show();
|
||||
}
|
||||
|
||||
@@ -194,7 +165,7 @@ public partial class MainWindow
|
||||
_mediaPlayer.Position = (float)(newValue / VideoSlider.Maximum);
|
||||
|
||||
VideoSlider.KeyDown += (sender, args) =>
|
||||
_mediator.Publish(new KeyEvent(sender, args));
|
||||
_mediator.Publish(new KeyEvent(sender, args, WindowEnum.Annotator));
|
||||
|
||||
Volume.ValueChanged += (_, newValue) =>
|
||||
_mediator.Publish(new VolumeChangedEvent((int)newValue));
|
||||
@@ -226,9 +197,9 @@ public partial class MainWindow
|
||||
foreach (var annotationResult in res)
|
||||
{
|
||||
var imgName = Path.GetFileNameWithoutExtension(annotationResult.Image);
|
||||
var thumbnailPath = Path.Combine(_config.ThumbnailsDirectory, $"{imgName}{Config.THUMBNAIL_PREFIX}.jpg");
|
||||
var thumbnailPath = Path.Combine(_appConfig.DirectoriesConfig.ThumbnailsDirectory, $"{imgName}{Constants.THUMBNAIL_PREFIX}.jpg");
|
||||
File.Delete(annotationResult.Image);
|
||||
File.Delete(Path.Combine(_config.LabelsDirectory, $"{imgName}.txt"));
|
||||
File.Delete(Path.Combine(_appConfig.DirectoriesConfig.LabelsDirectory, $"{imgName}.txt"));
|
||||
File.Delete(thumbnailPath);
|
||||
_formState.AnnotationResults.Remove(annotationResult);
|
||||
Annotations.Remove(Annotations.Query(annotationResult.Time));
|
||||
@@ -237,7 +208,6 @@ public partial class MainWindow
|
||||
}
|
||||
};
|
||||
|
||||
Editor.FormState = _formState;
|
||||
Editor.Mediator = _mediator;
|
||||
DgAnnotations.ItemsSource = _formState.AnnotationResults;
|
||||
}
|
||||
@@ -262,13 +232,12 @@ public partial class MainWindow
|
||||
if (_suspendLayout)
|
||||
return;
|
||||
|
||||
_config.LeftPanelWidth = MainGrid.ColumnDefinitions.FirstOrDefault()!.Width.Value;
|
||||
_config.RightPanelWidth = MainGrid.ColumnDefinitions.LastOrDefault()!.Width.Value;
|
||||
_appConfig.WindowConfig.LeftPanelWidth = MainGrid.ColumnDefinitions.FirstOrDefault()!.Width.Value;
|
||||
_appConfig.WindowConfig.RightPanelWidth = MainGrid.ColumnDefinitions.LastOrDefault()!.Width.Value;
|
||||
|
||||
_config.MainWindowConfig = this.GetConfig();
|
||||
await ThrottleExt.Throttle(() =>
|
||||
{
|
||||
_configRepository.Save(_config);
|
||||
_configUpdater.Save(_appConfig);
|
||||
return Task.CompletedTask;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
@@ -295,7 +264,7 @@ public partial class MainWindow
|
||||
if (showImage)
|
||||
{
|
||||
var fName = _formState.GetTimeName(time);
|
||||
var imgPath = Path.Combine(_config.ImagesDirectory, $"{fName}.jpg");
|
||||
var imgPath = Path.Combine(_appConfig.DirectoriesConfig.ImagesDirectory, $"{fName}.jpg");
|
||||
if (File.Exists(imgPath))
|
||||
{
|
||||
Editor.Background = new ImageBrush { ImageSource = await imgPath.OpenImage() };
|
||||
@@ -305,7 +274,7 @@ public partial class MainWindow
|
||||
}
|
||||
foreach (var label in labels)
|
||||
{
|
||||
var annClass = _config.AnnotationClasses[label.ClassNumber];
|
||||
var annClass = _appConfig.AnnotationConfig.AnnotationClasses[label.ClassNumber];
|
||||
var canvasLabel = new CanvasLabel(label, canvasSize, videoSize, label.Probability);
|
||||
Editor.CreateAnnotation(annClass, time, canvasLabel);
|
||||
}
|
||||
@@ -319,7 +288,7 @@ public partial class MainWindow
|
||||
Annotations.Clear();
|
||||
Editor.RemoveAllAnns();
|
||||
|
||||
var labelDir = new DirectoryInfo(_config.LabelsDirectory);
|
||||
var labelDir = new DirectoryInfo(_appConfig.DirectoriesConfig.LabelsDirectory);
|
||||
if (!labelDir.Exists)
|
||||
return;
|
||||
|
||||
@@ -327,7 +296,7 @@ public partial class MainWindow
|
||||
foreach (var file in labelFiles)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(file.Name);
|
||||
var time = _formState.GetTime(name);
|
||||
var time = Constants.GetTime(name);
|
||||
await AddAnnotations(time, await YoloLabel.ReadFromFile(file.FullName, ct), ct);
|
||||
}
|
||||
}
|
||||
@@ -335,12 +304,12 @@ public partial class MainWindow
|
||||
public async Task AddAnnotations(TimeSpan? time, List<YoloLabel> annotations, CancellationToken ct = default)
|
||||
=> await AddAnnotations(time, annotations.Select(x => new Detection(x)).ToList(), ct);
|
||||
|
||||
public async Task AddAnnotations(TimeSpan? time, List<Detection> annotations, CancellationToken ct = default)
|
||||
public async Task AddAnnotations(TimeSpan? time, List<Detection> detections, CancellationToken ct = default)
|
||||
{
|
||||
var timeValue = time ?? TimeSpan.FromMinutes(0);
|
||||
var previousAnnotations = Annotations.Query(timeValue);
|
||||
Annotations.Remove(previousAnnotations);
|
||||
Annotations.Add(timeValue.Subtract(_thresholdBefore), timeValue.Add(_thresholdAfter), annotations.Cast<YoloLabel>().ToList());
|
||||
Annotations.Add(timeValue.Subtract(_thresholdBefore), timeValue.Add(_thresholdAfter), detections.Cast<YoloLabel>().ToList());
|
||||
|
||||
var existingResult = _formState.AnnotationResults.FirstOrDefault(x => x.Time == time);
|
||||
if (existingResult != null)
|
||||
@@ -355,17 +324,51 @@ public partial class MainWindow
|
||||
.Select(x => x.Value + 1)
|
||||
.FirstOrDefault();
|
||||
|
||||
_formState.AnnotationResults.Insert(index, new AnnotationResult(timeValue, _formState.GetTimeName(time), annotations, _config));
|
||||
await File.WriteAllTextAsync($"{_config.ResultsDirectory}/{_formState.VideoName}.json", JsonConvert.SerializeObject(_formState.AnnotationResults), ct);
|
||||
_formState.AnnotationResults.Insert(index, CreateAnnotationReult(timeValue, detections));
|
||||
await File.WriteAllTextAsync($"{_appConfig.DirectoriesConfig.ResultsDirectory}/{_formState.VideoName}.json", JsonConvert.SerializeObject(_formState.AnnotationResults), ct);
|
||||
}
|
||||
|
||||
private AnnotationResult CreateAnnotationReult(TimeSpan timeValue, List<Detection> detections)
|
||||
{
|
||||
var annotationResult = new AnnotationResult
|
||||
{
|
||||
Time = timeValue,
|
||||
Image = $"{_formState.GetTimeName(timeValue)}.jpg",
|
||||
Detections = detections,
|
||||
};
|
||||
if (detections.Count <= 0)
|
||||
return annotationResult;
|
||||
|
||||
Color GetAnnotationClass(List<int> detectionClasses, int colorNumber)
|
||||
{
|
||||
if (detections.Count == 0)
|
||||
return (-1).ToColor();
|
||||
|
||||
return colorNumber >= detectionClasses.Count
|
||||
? _appConfig.AnnotationConfig.AnnotationClassesDict[detectionClasses.LastOrDefault()].Color
|
||||
: _appConfig.AnnotationConfig.AnnotationClassesDict[detectionClasses[colorNumber]].Color;
|
||||
}
|
||||
|
||||
var detectionClasses = detections.Select(x => x.ClassNumber).Distinct().ToList();
|
||||
|
||||
annotationResult.ClassName = detectionClasses.Count > 1
|
||||
? string.Join(", ", detectionClasses.Select(x => _appConfig.AnnotationConfig.AnnotationClassesDict[x].ShortName))
|
||||
: _appConfig.AnnotationConfig.AnnotationClassesDict[detectionClasses.FirstOrDefault()].Name;
|
||||
|
||||
annotationResult.ClassColor0 = GetAnnotationClass(detectionClasses, 0);
|
||||
annotationResult.ClassColor1 = GetAnnotationClass(detectionClasses, 1);
|
||||
annotationResult.ClassColor2 = GetAnnotationClass(detectionClasses, 2);
|
||||
annotationResult.ClassColor3 = GetAnnotationClass(detectionClasses, 3);
|
||||
return annotationResult;
|
||||
}
|
||||
|
||||
private void ReloadFiles()
|
||||
{
|
||||
var dir = new DirectoryInfo(_config.VideosDirectory);
|
||||
var dir = new DirectoryInfo(_appConfig.DirectoriesConfig.VideosDirectory);
|
||||
if (!dir.Exists)
|
||||
return;
|
||||
|
||||
var labelNames = new DirectoryInfo(_config.LabelsDirectory).GetFiles()
|
||||
var labelNames = new DirectoryInfo(_appConfig.DirectoriesConfig.LabelsDirectory).GetFiles()
|
||||
.Select(x =>
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(x.Name);
|
||||
@@ -377,7 +380,7 @@ public partial class MainWindow
|
||||
.Select(gr => gr.Key)
|
||||
.ToDictionary(x => x);
|
||||
|
||||
var videoFiles = dir.GetFiles(_config.VideoFormats.ToArray()).Select(x =>
|
||||
var videoFiles = dir.GetFiles(_appConfig.AnnotationConfig.VideoFormats.ToArray()).Select(x =>
|
||||
{
|
||||
using var media = new Media(_libVLC, x.FullName);
|
||||
media.Parse();
|
||||
@@ -392,7 +395,7 @@ public partial class MainWindow
|
||||
return fInfo;
|
||||
}).ToList();
|
||||
|
||||
var imageFiles = dir.GetFiles(_config.ImageFormats.ToArray()).Select(x => new MediaFileInfo
|
||||
var imageFiles = dir.GetFiles(_appConfig.AnnotationConfig.ImageFormats.ToArray()).Select(x => new MediaFileInfo
|
||||
{
|
||||
Name = x.Name,
|
||||
Path = x.FullName,
|
||||
@@ -402,7 +405,7 @@ public partial class MainWindow
|
||||
|
||||
AllMediaFiles = new ObservableCollection<MediaFileInfo>(videoFiles.Concat(imageFiles).ToList());
|
||||
LvFiles.ItemsSource = AllMediaFiles;
|
||||
TbFolder.Text = _config.VideosDirectory;
|
||||
TbFolder.Text = _appConfig.DirectoriesConfig.VideosDirectory;
|
||||
|
||||
BlinkHelp(AllMediaFiles.Count == 0
|
||||
? HelpTexts.HelpTextsDict[HelpTextEnum.Initial]
|
||||
@@ -415,7 +418,7 @@ public partial class MainWindow
|
||||
_mediaPlayer.Stop();
|
||||
_mediaPlayer.Dispose();
|
||||
_libVLC.Dispose();
|
||||
_configRepository.Save(_config);
|
||||
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
|
||||
@@ -460,7 +463,7 @@ public partial class MainWindow
|
||||
|
||||
if (!string.IsNullOrEmpty(dlg.FileName))
|
||||
{
|
||||
_config.VideosDirectory = dlg.FileName;
|
||||
_appConfig.DirectoriesConfig.VideosDirectory = dlg.FileName;
|
||||
await SaveUserSettings();
|
||||
}
|
||||
|
||||
@@ -473,13 +476,6 @@ public partial class MainWindow
|
||||
LvFiles.ItemsSource = FilteredMediaFiles;
|
||||
}
|
||||
|
||||
private void OpenDataExplorerItemClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_datasetExplorer.Show();
|
||||
_datasetExplorer.Activate();
|
||||
}
|
||||
|
||||
|
||||
private void PlayClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_mediator.Publish(new PlaybackControlEvent(_mediaPlayer.CanPause ? PlaybackControlEnum.Pause : PlaybackControlEnum.Play));
|
||||
@@ -506,20 +502,10 @@ public partial class MainWindow
|
||||
|
||||
private void Thumb_OnDragCompleted(object sender, DragCompletedEventArgs e) => _ = SaveUserSettings();
|
||||
|
||||
private void ReloadThumbnailsItemClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var result = MessageBox.Show($"Видалити всі іконки і згенерувати нову базу іконок в {_config.ThumbnailsDirectory}?",
|
||||
"Підтвердження оновлення іконок", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (result != MessageBoxResult.Yes)
|
||||
return;
|
||||
_galleryManager.ClearThumbnails();
|
||||
_galleryManager.RefreshThumbnails();
|
||||
}
|
||||
|
||||
private void LvFilesContextOpening(object sender, ContextMenuEventArgs e)
|
||||
{
|
||||
var listItem = sender as ListViewItem;
|
||||
LvFilesContextMenu.DataContext = listItem.DataContext;
|
||||
LvFilesContextMenu.DataContext = listItem!.DataContext;
|
||||
}
|
||||
|
||||
private (TimeSpan Time, List<Detection> Detections)? _previousDetection;
|
||||
@@ -555,7 +541,7 @@ public partial class MainWindow
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var detector = new YOLODetector(_config);
|
||||
using var detector = new YOLODetector(_appConfig.AIRecognitionConfig);
|
||||
Dispatcher.Invoke(() => _autoDetectDialog.Log("Ініціалізація AI..."));
|
||||
var prevSeekTime = 0.0;
|
||||
|
||||
@@ -601,7 +587,7 @@ public partial class MainWindow
|
||||
var prev = _previousDetection.Value;
|
||||
|
||||
// Time between detections is >= than Frame Recognition Seconds, allow
|
||||
if (time >= prev.Time.Add(TimeSpan.FromSeconds(_config.AIRecognitionConfig.FrameRecognitionSeconds)))
|
||||
if (time >= prev.Time.Add(TimeSpan.FromSeconds(_appConfig.AIRecognitionConfig.FrameRecognitionSeconds)))
|
||||
return true;
|
||||
|
||||
// Detection is earlier than previous + FrameRecognitionSeconds.
|
||||
@@ -624,11 +610,11 @@ public partial class MainWindow
|
||||
.First();
|
||||
|
||||
// Closest object is farther than Tracking distance confidence, hence it's a different object, allow
|
||||
if (closestObject.Distance > _config.AIRecognitionConfig.TrackingDistanceConfidence)
|
||||
if (closestObject.Distance > _appConfig.AIRecognitionConfig.TrackingDistanceConfidence)
|
||||
return true;
|
||||
|
||||
// Since closest object within distance confidence, then it is tracking of the same object. Then if recognition probability for the object > increase from previous
|
||||
if (det.Probability >= closestObject.Point.Probability + _config.AIRecognitionConfig.TrackingProbabilityIncrease)
|
||||
if (det.Probability >= closestObject.Point.Probability + _appConfig.AIRecognitionConfig.TrackingProbabilityIncrease)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -645,10 +631,10 @@ public partial class MainWindow
|
||||
var time = timeframe.Time;
|
||||
|
||||
var fName = _formState.GetTimeName(timeframe.Time);
|
||||
var imgPath = Path.Combine(_config.ImagesDirectory, $"{fName}.jpg");
|
||||
var imgPath = Path.Combine(_appConfig.DirectoriesConfig.ImagesDirectory, $"{fName}.jpg");
|
||||
var img = System.Drawing.Image.FromStream(timeframe.Stream);
|
||||
img.Save(imgPath, ImageFormat.Jpeg);
|
||||
await YoloLabel.WriteToFile(detections, Path.Combine(_config.LabelsDirectory, $"{fName}.txt"), token);
|
||||
await YoloLabel.WriteToFile(detections, Path.Combine(_appConfig.DirectoriesConfig.LabelsDirectory, $"{fName}.txt"), token);
|
||||
|
||||
Editor.Background = new ImageBrush { ImageSource = await imgPath.OpenImage() };
|
||||
Editor.RemoveAllAnns();
|
||||
@@ -656,16 +642,14 @@ public partial class MainWindow
|
||||
await AddAnnotations(timeframe.Time, detections, token);
|
||||
|
||||
var log = string.Join(Environment.NewLine, detections.Select(det =>
|
||||
$"{_config.AnnotationClassesDict[det.ClassNumber].Name}: " +
|
||||
$"{_appConfig.AnnotationConfig.AnnotationClassesDict[det.ClassNumber].Name}: " +
|
||||
$"xy=({det.CenterX:F2},{det.CenterY:F2}), " +
|
||||
$"size=({det.Width:F2}, {det.Height:F2}), " +
|
||||
$"prob: {det.Probability:F1}%"));
|
||||
|
||||
Dispatcher.Invoke(() => _autoDetectDialog.Log(log));
|
||||
|
||||
var thumbnailDto = await _galleryManager.CreateThumbnail(imgPath, token);
|
||||
if (thumbnailDto != null)
|
||||
_datasetExplorer.AddThumbnail(thumbnailDto, detections.Select(x => x.ClassNumber));
|
||||
await _mediator.Publish(new ImageCreatedEvent(imgPath), token);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Common.DTO;
|
||||
using LibVLCSharp.Shared;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MediaPlayer = LibVLCSharp.Shared.MediaPlayer;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public class AnnotatorEventHandler(
|
||||
LibVLC libVLC,
|
||||
MediaPlayer mediaPlayer,
|
||||
Annotator mainWindow,
|
||||
FormState formState,
|
||||
IOptions<DirectoriesConfig> directoriesConfig,
|
||||
IMediator mediator,
|
||||
ILogger<AnnotatorEventHandler> logger)
|
||||
:
|
||||
INotificationHandler<KeyEvent>,
|
||||
INotificationHandler<AnnClassSelectedEvent>,
|
||||
INotificationHandler<PlaybackControlEvent>,
|
||||
INotificationHandler<VolumeChangedEvent>
|
||||
{
|
||||
private readonly DirectoriesConfig _directoriesConfig = directoriesConfig.Value;
|
||||
private const int STEP = 20;
|
||||
private const int LARGE_STEP = 5000;
|
||||
private const int RESULT_WIDTH = 1280;
|
||||
|
||||
private readonly Dictionary<Key, PlaybackControlEnum> _keysControlEnumDict = new()
|
||||
{
|
||||
{ Key.Space, PlaybackControlEnum.Pause },
|
||||
{ Key.Left, PlaybackControlEnum.PreviousFrame },
|
||||
{ Key.Right, PlaybackControlEnum.NextFrame },
|
||||
{ Key.Enter, PlaybackControlEnum.SaveAnnotations },
|
||||
{ Key.Delete, PlaybackControlEnum.RemoveSelectedAnns },
|
||||
{ Key.X, PlaybackControlEnum.RemoveAllAnns },
|
||||
{ Key.PageUp, PlaybackControlEnum.Previous },
|
||||
{ Key.PageDown, PlaybackControlEnum.Next },
|
||||
};
|
||||
|
||||
public async Task Handle(AnnClassSelectedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
SelectClass(notification.AnnotationClass);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void SelectClass(AnnotationClass annClass)
|
||||
{
|
||||
mainWindow.Editor.CurrentAnnClass = annClass;
|
||||
foreach (var ann in mainWindow.Editor.CurrentAnns.Where(x => x.IsSelected))
|
||||
ann.AnnotationClass = annClass;
|
||||
mainWindow.LvClasses.SelectedIndex = annClass.Id;
|
||||
}
|
||||
|
||||
public async Task Handle(KeyEvent keyEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
if (keyEvent.WindowEnum != WindowEnum.Annotator)
|
||||
return;
|
||||
|
||||
var key = keyEvent.Args.Key;
|
||||
var keyNumber = (int?)null;
|
||||
|
||||
if ((int)key >= (int)Key.D1 && (int)key <= (int)Key.D9)
|
||||
keyNumber = key - Key.D1;
|
||||
if ((int)key >= (int)Key.NumPad1 && (int)key <= (int)Key.NumPad9)
|
||||
keyNumber = key - Key.NumPad1;
|
||||
if (keyNumber.HasValue)
|
||||
SelectClass((AnnotationClass)mainWindow.LvClasses.Items[keyNumber.Value]!);
|
||||
|
||||
if (_keysControlEnumDict.TryGetValue(key, out var value))
|
||||
await ControlPlayback(value);
|
||||
|
||||
if (key == Key.A)
|
||||
mainWindow.AutoDetect(null!, null!);
|
||||
|
||||
await VolumeControl(key);
|
||||
}
|
||||
|
||||
private async Task VolumeControl(Key key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case Key.VolumeMute when mediaPlayer.Volume == 0:
|
||||
await ControlPlayback(PlaybackControlEnum.TurnOnVolume);
|
||||
break;
|
||||
case Key.VolumeMute:
|
||||
await ControlPlayback(PlaybackControlEnum.TurnOffVolume);
|
||||
break;
|
||||
case Key.Up:
|
||||
case Key.VolumeUp:
|
||||
var vUp = Math.Min(100, mediaPlayer.Volume + 5);
|
||||
ChangeVolume(vUp);
|
||||
mainWindow.Volume.Value = vUp;
|
||||
break;
|
||||
case Key.Down:
|
||||
case Key.VolumeDown:
|
||||
var vDown = Math.Max(0, mediaPlayer.Volume - 5);
|
||||
ChangeVolume(vDown);
|
||||
mainWindow.Volume.Value = vDown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Handle(PlaybackControlEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await ControlPlayback(notification.PlaybackControl);
|
||||
mainWindow.VideoView.Focus();
|
||||
}
|
||||
|
||||
private async Task ControlPlayback(PlaybackControlEnum controlEnum)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isCtrlPressed = Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl);
|
||||
var step = isCtrlPressed ? LARGE_STEP : STEP;
|
||||
|
||||
switch (controlEnum)
|
||||
{
|
||||
case PlaybackControlEnum.Play:
|
||||
Play();
|
||||
break;
|
||||
case PlaybackControlEnum.Pause:
|
||||
mediaPlayer.Pause();
|
||||
if (!mediaPlayer.IsPlaying)
|
||||
mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.AnnotationHelp]);
|
||||
if (formState.BackgroundTime.HasValue)
|
||||
{
|
||||
mainWindow.Editor.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
formState.BackgroundTime = null;
|
||||
}
|
||||
break;
|
||||
case PlaybackControlEnum.Stop:
|
||||
mediaPlayer.Stop();
|
||||
break;
|
||||
case PlaybackControlEnum.PreviousFrame:
|
||||
mainWindow.SeekTo(mediaPlayer.Time - step);
|
||||
break;
|
||||
case PlaybackControlEnum.NextFrame:
|
||||
mainWindow.SeekTo(mediaPlayer.Time + step);
|
||||
break;
|
||||
case PlaybackControlEnum.SaveAnnotations:
|
||||
await SaveAnnotations();
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveSelectedAnns:
|
||||
|
||||
mainWindow.Editor.RemoveSelectedAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveAllAnns:
|
||||
mainWindow.Editor.RemoveAllAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.TurnOnVolume:
|
||||
mainWindow.TurnOnVolumeBtn.Visibility = Visibility.Collapsed;
|
||||
mainWindow.TurnOffVolumeBtn.Visibility = Visibility.Visible;
|
||||
mediaPlayer.Volume = formState.CurrentVolume;
|
||||
break;
|
||||
case PlaybackControlEnum.TurnOffVolume:
|
||||
mainWindow.TurnOffVolumeBtn.Visibility = Visibility.Collapsed;
|
||||
mainWindow.TurnOnVolumeBtn.Visibility = Visibility.Visible;
|
||||
formState.CurrentVolume = mediaPlayer.Volume;
|
||||
mediaPlayer.Volume = 0;
|
||||
break;
|
||||
case PlaybackControlEnum.Previous:
|
||||
NextMedia(isPrevious: true);
|
||||
break;
|
||||
case PlaybackControlEnum.Next:
|
||||
NextMedia();
|
||||
break;
|
||||
case PlaybackControlEnum.None:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(controlEnum), controlEnum, null);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, e.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void NextMedia(bool isPrevious = false)
|
||||
{
|
||||
var increment = isPrevious ? -1 : 1;
|
||||
var check = isPrevious ? -1 : mainWindow.LvFiles.Items.Count;
|
||||
if (mainWindow.LvFiles.SelectedIndex + increment == check)
|
||||
return;
|
||||
|
||||
mainWindow.LvFiles.SelectedIndex += increment;
|
||||
Play();
|
||||
}
|
||||
|
||||
public async Task Handle(VolumeChangedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
ChangeVolume(notification.Volume);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ChangeVolume(int volume)
|
||||
{
|
||||
formState.CurrentVolume = volume;
|
||||
mediaPlayer.Volume = volume;
|
||||
}
|
||||
|
||||
private void Play()
|
||||
{
|
||||
if (mainWindow.LvFiles.SelectedItem == null)
|
||||
return;
|
||||
var mediaInfo = (MediaFileInfo)mainWindow.LvFiles.SelectedItem;
|
||||
|
||||
formState.CurrentMedia = mediaInfo;
|
||||
mediaPlayer.Stop();
|
||||
mainWindow.Title = $"Azaion Annotator - {mediaInfo.Name}";
|
||||
mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.PauseForAnnotations]);
|
||||
mediaPlayer.Play(new Media(libVLC, mediaInfo.Path));
|
||||
}
|
||||
|
||||
private async Task SaveAnnotations()
|
||||
{
|
||||
var annGridSelectedIndex = mainWindow.DgAnnotations.SelectedIndex;
|
||||
|
||||
if (formState.CurrentMedia == null)
|
||||
return;
|
||||
|
||||
var time = formState.BackgroundTime ?? TimeSpan.FromMilliseconds(mediaPlayer.Time);
|
||||
var fName = formState.GetTimeName(time);
|
||||
|
||||
var currentAnns = mainWindow.Editor.CurrentAnns
|
||||
.Select(x => new YoloLabel(x.Info, mainWindow.Editor.RenderSize, formState.BackgroundTime.HasValue ? mainWindow.Editor.RenderSize : formState.CurrentVideoSize))
|
||||
.ToList();
|
||||
|
||||
await YoloLabel.WriteToFile(currentAnns, Path.Combine(_directoriesConfig.LabelsDirectory, $"{fName}.txt"));
|
||||
await mainWindow.AddAnnotations(time, currentAnns);
|
||||
|
||||
formState.CurrentMedia.HasAnnotations = mainWindow.Annotations.Count != 0;
|
||||
mainWindow.LvFiles.Items.Refresh();
|
||||
|
||||
var isVideo = formState.CurrentMedia.MediaType == MediaTypes.Video;
|
||||
var destinationPath = Path.Combine(_directoriesConfig.ImagesDirectory, $"{fName}{(isVideo ? ".jpg" : Path.GetExtension(formState.CurrentMedia.Path))}");
|
||||
|
||||
mainWindow.Editor.RemoveAllAnns();
|
||||
if (isVideo)
|
||||
{
|
||||
if (formState.BackgroundTime.HasValue)
|
||||
{
|
||||
//no need to save image, it's already there, just remove background
|
||||
mainWindow.Editor.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
formState.BackgroundTime = null;
|
||||
|
||||
//next item
|
||||
var annGrid = mainWindow.DgAnnotations;
|
||||
annGrid.SelectedIndex = Math.Min(annGrid.Items.Count, annGridSelectedIndex + 1);
|
||||
mainWindow.OpenAnnotationResult((AnnotationResult)annGrid.SelectedItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
var resultHeight = (uint)Math.Round(RESULT_WIDTH / formState.CurrentVideoSize.Width * formState.CurrentVideoSize.Height);
|
||||
mediaPlayer.TakeSnapshot(0, destinationPath, RESULT_WIDTH, resultHeight);
|
||||
mediaPlayer.Play();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Copy(formState.CurrentMedia.Path, destinationPath, overwrite: true);
|
||||
NextMedia();
|
||||
}
|
||||
|
||||
await mediator.Publish(new ImageCreatedEvent(destinationPath));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<Application x:Class="Azaion.Annotator.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,82 +0,0 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using Azaion.Annotator.DTO;
|
||||
using LibVLCSharp.Shared;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Azaion.Annotator.Extensions;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly ILogger<App> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public App()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
path: "Logs/log.txt",
|
||||
rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
_host = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddSingleton<MainWindow>();
|
||||
services.AddSingleton<HelpWindow>();
|
||||
services.AddSingleton<DatasetExplorer>();
|
||||
services.AddSingleton<IGalleryManager, GalleryManager>();
|
||||
services.AddSingleton<IAIDetector, YOLODetector>();
|
||||
services.AddMediatR(c => c.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
||||
services.AddSingleton<LibVLC>(_ => new LibVLC());
|
||||
services.AddSingleton<FormState>();
|
||||
services.AddSingleton<MediaPlayer>(sp =>
|
||||
{
|
||||
var libVLC = sp.GetRequiredService<LibVLC>();
|
||||
return new MediaPlayer(libVLC);
|
||||
});
|
||||
services.AddSingleton<IConfigRepository, FileConfigRepository>();
|
||||
services.AddSingleton<Config>(sp => sp.GetRequiredService<IConfigRepository>().Get());
|
||||
services.AddSingleton<MainWindowEventHandler>();
|
||||
services.AddSingleton<VLCFrameExtractor>();
|
||||
})
|
||||
.UseSerilog()
|
||||
.Build();
|
||||
_mediator = _host.Services.GetRequiredService<IMediator>();
|
||||
_logger = _host.Services.GetRequiredService<ILogger<App>>();
|
||||
DispatcherUnhandledException += OnDispatcherUnhandledException;
|
||||
}
|
||||
|
||||
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
_logger.LogError(e.Exception, e.Exception.Message);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
EventManager.RegisterClassHandler(typeof(UIElement), UIElement.KeyDownEvent, new RoutedEventHandler(GlobalClick));
|
||||
_host.Start();
|
||||
_host.Services.GetRequiredService<MainWindow>().Show();
|
||||
|
||||
base.OnStartup(e);
|
||||
}
|
||||
|
||||
private void GlobalClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var args = (KeyEventArgs)e;
|
||||
_ = ThrottleExt.Throttle(() => _mediator.Publish(new KeyEvent(sender, args)), TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,53 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ApplicationIcon>logo.ico</ApplicationIcon>
|
||||
<ApplicationIcon>..\logo.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="libc.translation" Version="7.1.1" />
|
||||
<PackageReference Include="LibVLCSharp" Version="3.8.2" />
|
||||
<PackageReference Include="LibVLCSharp.WPF" Version="3.8.2" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="RangeTree" Version="3.0.1" />
|
||||
<PackageReference Include="ScottPlot.WPF" Version="5.0.39" />
|
||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.9" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.20" />
|
||||
<PackageReference Include="VirtualizingWrapPanel" Version="2.0.10" />
|
||||
<PackageReference Include="WindowsAPICodePack" Version="7.0.4" />
|
||||
<PackageReference Include="YoloV8.Gpu" Version="5.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="logo.ico" />
|
||||
<Resource Include="logo.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Resource>
|
||||
<None Update="config.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Azaion.Common\Azaion.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Page Update="AutodetectDialog.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<XamlRuntime>Wpf</XamlRuntime>
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<DataGrid x:Class="Azaion.Annotator.Controls.AnnotationClasses"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:Azaion.Annotator.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="300" d:DesignWidth="300"
|
||||
Background="Black"
|
||||
RowBackground="#252525"
|
||||
Foreground="White"
|
||||
RowHeaderWidth="0"
|
||||
Padding="2 0 0 0"
|
||||
AutoGenerateColumns="False"
|
||||
SelectionMode="Single"
|
||||
CellStyle="{DynamicResource DataGridCellStyle1}"
|
||||
IsReadOnly="True"
|
||||
CanUserResizeRows="False"
|
||||
CanUserResizeColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn
|
||||
Width="50"
|
||||
Header="Клавіша"
|
||||
CanUserSort="False">
|
||||
<DataGridTemplateColumn.HeaderStyle>
|
||||
<Style TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="#252525"></Setter>
|
||||
</Style>
|
||||
</DataGridTemplateColumn.HeaderStyle>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{Binding Path=ColorBrush}">
|
||||
<TextBlock Text="{Binding Path=ClassNumber}"></TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Header="Назва"
|
||||
Binding="{Binding Path=Name}"
|
||||
CanUserSort="False">
|
||||
<DataGridTextColumn.HeaderStyle>
|
||||
<Style TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="#252525"></Setter>
|
||||
</Style>
|
||||
</DataGridTextColumn.HeaderStyle>
|
||||
</DataGridTextColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Azaion.Annotator.Controls;
|
||||
|
||||
public partial class AnnotationClasses : DataGrid
|
||||
{
|
||||
public AnnotationClasses()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Label = System.Windows.Controls.Label;
|
||||
|
||||
namespace Azaion.Annotator.Controls;
|
||||
|
||||
public class AnnotationControl : Border
|
||||
{
|
||||
private readonly Action<object, MouseButtonEventArgs> _resizeStart;
|
||||
private const double RESIZE_RECT_SIZE = 9;
|
||||
|
||||
private readonly Grid _grid;
|
||||
private readonly TextBlock _classNameLabel;
|
||||
private readonly Label _probabilityLabel;
|
||||
public TimeSpan? Time { get; set; }
|
||||
|
||||
private AnnotationClass _annotationClass = null!;
|
||||
public AnnotationClass AnnotationClass
|
||||
{
|
||||
get => _annotationClass;
|
||||
set
|
||||
{
|
||||
_grid.Background = value.ColorBrush;
|
||||
_probabilityLabel.Background = value.ColorBrush;
|
||||
_classNameLabel.Text = value.Name;
|
||||
_annotationClass = value;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Rectangle _selectionFrame;
|
||||
|
||||
private bool _isSelected;
|
||||
public bool IsSelected
|
||||
{
|
||||
get => _isSelected;
|
||||
set
|
||||
{
|
||||
_selectionFrame.Visibility = value ? Visibility.Visible : Visibility.Collapsed;
|
||||
_isSelected = value;
|
||||
}
|
||||
}
|
||||
|
||||
public AnnotationControl(AnnotationClass annotationClass, TimeSpan? time, Action<object, MouseButtonEventArgs> resizeStart, double? probability = null)
|
||||
{
|
||||
Time = time;
|
||||
_resizeStart = resizeStart;
|
||||
_classNameLabel = new TextBlock
|
||||
{
|
||||
Text = annotationClass.Name,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, 15, 0, 0),
|
||||
FontSize = 14,
|
||||
Cursor = Cursors.SizeAll
|
||||
};
|
||||
_probabilityLabel = new Label
|
||||
{
|
||||
Content = probability.HasValue ? $"{probability.Value:F0}%" : string.Empty,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, -32, 0, 0),
|
||||
FontSize = 16,
|
||||
Visibility = Visibility.Visible
|
||||
};
|
||||
_selectionFrame = new Rectangle
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Stroke = new SolidColorBrush(Colors.Black),
|
||||
StrokeThickness = 2,
|
||||
Visibility = Visibility.Collapsed
|
||||
};
|
||||
|
||||
_grid = new Grid
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Children =
|
||||
{
|
||||
_selectionFrame,
|
||||
_classNameLabel,
|
||||
AddRect("rLT", HorizontalAlignment.Left, VerticalAlignment.Top, Cursors.SizeNWSE),
|
||||
AddRect("rCT", HorizontalAlignment.Center, VerticalAlignment.Top, Cursors.SizeNS),
|
||||
AddRect("rRT", HorizontalAlignment.Right, VerticalAlignment.Top, Cursors.SizeNESW),
|
||||
AddRect("rLC", HorizontalAlignment.Left, VerticalAlignment.Center, Cursors.SizeWE),
|
||||
AddRect("rRC", HorizontalAlignment.Right, VerticalAlignment.Center, Cursors.SizeWE),
|
||||
AddRect("rLB", HorizontalAlignment.Left, VerticalAlignment.Bottom, Cursors.SizeNESW),
|
||||
AddRect("rCB", HorizontalAlignment.Center, VerticalAlignment.Bottom, Cursors.SizeNS),
|
||||
AddRect("rRB", HorizontalAlignment.Right, VerticalAlignment.Bottom, Cursors.SizeNWSE)
|
||||
}
|
||||
};
|
||||
if (probability.HasValue)
|
||||
_grid.Children.Add(_probabilityLabel);
|
||||
Child = _grid;
|
||||
Cursor = Cursors.SizeAll;
|
||||
AnnotationClass = annotationClass;
|
||||
}
|
||||
|
||||
//small corners
|
||||
private Rectangle AddRect(string name, HorizontalAlignment ha, VerticalAlignment va, Cursor crs)
|
||||
{
|
||||
var rect = new Rectangle() // small rectangles at the corners and sides
|
||||
{
|
||||
HorizontalAlignment = ha,
|
||||
VerticalAlignment = va,
|
||||
Width = RESIZE_RECT_SIZE,
|
||||
Height = RESIZE_RECT_SIZE,
|
||||
Stroke = new SolidColorBrush(Color.FromArgb(230, 40, 40, 40)), // small rectangles color
|
||||
Fill = new SolidColorBrush(Color.FromArgb(1, 255, 255, 255)),
|
||||
Cursor = crs,
|
||||
Name = name,
|
||||
};
|
||||
rect.MouseDown += (sender, args) => _resizeStart(sender, args);
|
||||
return rect;
|
||||
}
|
||||
|
||||
public CanvasLabel Info => new(AnnotationClass.Id, Canvas.GetLeft(this), Canvas.GetTop(this), Width, Height);
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using Azaion.Annotator.DTO;
|
||||
using MediatR;
|
||||
using Color = System.Windows.Media.Color;
|
||||
using Rectangle = System.Windows.Shapes.Rectangle;
|
||||
|
||||
namespace Azaion.Annotator.Controls;
|
||||
|
||||
public class CanvasEditor : Canvas
|
||||
{
|
||||
private Point _lastPos;
|
||||
public SelectionState SelectionState { get; set; } = SelectionState.None;
|
||||
|
||||
private readonly Rectangle _newAnnotationRect;
|
||||
private Point _newAnnotationStartPos;
|
||||
|
||||
private readonly Line _horizontalLine;
|
||||
private readonly Line _verticalLine;
|
||||
private readonly TextBlock _classNameHint;
|
||||
|
||||
private Rectangle _curRec = new();
|
||||
private AnnotationControl _curAnn = null!;
|
||||
|
||||
private const int MIN_SIZE = 20;
|
||||
private readonly TimeSpan _viewThreshold = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
public FormState FormState { get; set; } = null!;
|
||||
public IMediator Mediator { get; set; } = null!;
|
||||
|
||||
public static readonly DependencyProperty GetTimeFuncProp =
|
||||
DependencyProperty.Register(
|
||||
nameof(GetTimeFunc),
|
||||
typeof(Func<TimeSpan?>),
|
||||
typeof(CanvasEditor),
|
||||
new PropertyMetadata(null));
|
||||
|
||||
public Func<TimeSpan?> GetTimeFunc
|
||||
{
|
||||
get => (Func<TimeSpan?>)GetValue(GetTimeFuncProp);
|
||||
set => SetValue(GetTimeFuncProp, value);
|
||||
}
|
||||
|
||||
private AnnotationClass _currentAnnClass = null!;
|
||||
public AnnotationClass CurrentAnnClass
|
||||
{
|
||||
get => _currentAnnClass;
|
||||
set
|
||||
{
|
||||
_verticalLine.Stroke = value.ColorBrush;
|
||||
_verticalLine.Fill = value.ColorBrush;
|
||||
_horizontalLine.Stroke = value.ColorBrush;
|
||||
_horizontalLine.Fill = value.ColorBrush;
|
||||
_classNameHint.Text = value.Name;
|
||||
|
||||
_newAnnotationRect.Stroke = value.ColorBrush;
|
||||
_newAnnotationRect.Fill = value.ColorBrush;
|
||||
_currentAnnClass = value;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly List<AnnotationControl> CurrentAnns = new();
|
||||
|
||||
public CanvasEditor()
|
||||
{
|
||||
_horizontalLine = new Line
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
Stroke = new SolidColorBrush(Colors.Blue),
|
||||
Fill = new SolidColorBrush(Colors.Blue),
|
||||
StrokeDashArray = [5],
|
||||
StrokeThickness = 2
|
||||
};
|
||||
_verticalLine = new Line
|
||||
{
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Stroke = new SolidColorBrush(Colors.Blue),
|
||||
Fill = new SolidColorBrush(Colors.Blue),
|
||||
StrokeDashArray = [5],
|
||||
StrokeThickness = 2
|
||||
};
|
||||
_classNameHint = new TextBlock
|
||||
{
|
||||
Text = CurrentAnnClass?.Name ?? "asd",
|
||||
Foreground = new SolidColorBrush(Colors.Black),
|
||||
Cursor = Cursors.Arrow,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeights.Bold
|
||||
};
|
||||
_newAnnotationRect = new Rectangle
|
||||
{
|
||||
Name = "selector",
|
||||
Height = 0,
|
||||
Width = 0,
|
||||
Stroke = new SolidColorBrush(Colors.Gray),
|
||||
Fill = new SolidColorBrush(Color.FromArgb(128, 128, 128, 128)),
|
||||
};
|
||||
|
||||
KeyDown += (_, args) =>
|
||||
{
|
||||
Console.WriteLine($"pressed {args.Key}");
|
||||
};
|
||||
MouseDown += CanvasMouseDown;
|
||||
MouseMove += CanvasMouseMove;
|
||||
MouseUp += CanvasMouseUp;
|
||||
SizeChanged += CanvasResized;
|
||||
Cursor = Cursors.Cross;
|
||||
|
||||
Children.Add(_newAnnotationRect);
|
||||
Children.Add(_horizontalLine);
|
||||
Children.Add(_verticalLine);
|
||||
Children.Add(_classNameHint);
|
||||
|
||||
Loaded += Init;
|
||||
}
|
||||
|
||||
private void Init(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_horizontalLine.X1 = 0;
|
||||
_horizontalLine.X2 = ActualWidth;
|
||||
_verticalLine.Y1 = 0;
|
||||
_verticalLine.Y2 = ActualHeight;
|
||||
}
|
||||
|
||||
private void CanvasMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
ClearSelections();
|
||||
NewAnnotationStart(sender, e);
|
||||
}
|
||||
|
||||
private void CanvasMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var pos = e.GetPosition(this);
|
||||
_horizontalLine.Y1 = _horizontalLine.Y2 = pos.Y;
|
||||
_verticalLine.X1 = _verticalLine.X2 = pos.X;
|
||||
SetLeft(_classNameHint, pos.X + 10);
|
||||
SetTop(_classNameHint, pos.Y - 30);
|
||||
|
||||
if (e.LeftButton != MouseButtonState.Pressed)
|
||||
return;
|
||||
if (SelectionState == SelectionState.NewAnnCreating)
|
||||
NewAnnotationCreatingMove(sender, e);
|
||||
|
||||
if (SelectionState == SelectionState.AnnResizing)
|
||||
AnnotationResizeMove(sender, e);
|
||||
|
||||
if (SelectionState == SelectionState.AnnMoving)
|
||||
AnnotationPositionMove(sender, e);
|
||||
}
|
||||
|
||||
private void CanvasMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (SelectionState == SelectionState.NewAnnCreating)
|
||||
CreateAnnotation(e.GetPosition(this));
|
||||
|
||||
SelectionState = SelectionState.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CanvasResized(object sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_horizontalLine.X2 = e.NewSize.Width;
|
||||
_verticalLine.Y2 = e.NewSize.Height;
|
||||
}
|
||||
|
||||
#region Annotation Resizing & Moving
|
||||
|
||||
private void AnnotationResizeStart(object sender, MouseEventArgs e)
|
||||
{
|
||||
SelectionState = SelectionState.AnnResizing;
|
||||
_lastPos = e.GetPosition(this);
|
||||
_curRec = (Rectangle)sender;
|
||||
_curAnn = (AnnotationControl)((Grid)_curRec.Parent).Parent;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void AnnotationResizeMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SelectionState != SelectionState.AnnResizing)
|
||||
return;
|
||||
|
||||
var currentPos = e.GetPosition(this);
|
||||
|
||||
var x = GetLeft(_curAnn);
|
||||
var y = GetTop(_curAnn);
|
||||
var offsetX = currentPos.X - _lastPos.X;
|
||||
var offsetY = currentPos.Y - _lastPos.Y;
|
||||
switch (_curRec.HorizontalAlignment, _curRec.VerticalAlignment)
|
||||
{
|
||||
case (HorizontalAlignment.Left, VerticalAlignment.Top):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width - offsetX);
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height - offsetY);
|
||||
SetLeft(_curAnn, x + offsetX);
|
||||
SetTop(_curAnn, y + offsetY);
|
||||
break;
|
||||
case (HorizontalAlignment.Center, VerticalAlignment.Top):
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height - offsetY);
|
||||
SetTop(_curAnn, y + offsetY);
|
||||
break;
|
||||
case (HorizontalAlignment.Right, VerticalAlignment.Top):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width + offsetX);
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height - offsetY);
|
||||
SetTop(_curAnn, y + offsetY);
|
||||
break;
|
||||
|
||||
case (HorizontalAlignment.Left, VerticalAlignment.Center):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width - offsetX);
|
||||
SetLeft(_curAnn, x + offsetX);
|
||||
break;
|
||||
case (HorizontalAlignment.Right, VerticalAlignment.Center):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width + offsetX);
|
||||
break;
|
||||
|
||||
case (HorizontalAlignment.Left, VerticalAlignment.Bottom):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width - offsetX);
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height + offsetY);
|
||||
SetLeft(_curAnn, x + offsetX);
|
||||
break;
|
||||
case (HorizontalAlignment.Center, VerticalAlignment.Bottom):
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height + offsetY);
|
||||
break;
|
||||
case (HorizontalAlignment.Right, VerticalAlignment.Bottom):
|
||||
_curAnn.Width = Math.Max(MIN_SIZE, _curAnn.Width + offsetX);
|
||||
_curAnn.Height = Math.Max(MIN_SIZE, _curAnn.Height + offsetY);
|
||||
break;
|
||||
}
|
||||
_lastPos = currentPos;
|
||||
}
|
||||
|
||||
private void AnnotationPositionStart(object sender, MouseEventArgs e)
|
||||
{
|
||||
_lastPos = e.GetPosition(this);
|
||||
_curAnn = (AnnotationControl)sender;
|
||||
|
||||
if (!Keyboard.IsKeyDown(Key.LeftCtrl) && !Keyboard.IsKeyDown(Key.RightCtrl))
|
||||
ClearSelections();
|
||||
|
||||
_curAnn.IsSelected = true;
|
||||
|
||||
SelectionState = SelectionState.AnnMoving;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void AnnotationPositionMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SelectionState != SelectionState.AnnMoving)
|
||||
return;
|
||||
|
||||
var currentPos = e.GetPosition(this);
|
||||
var offsetX = currentPos.X - _lastPos.X;
|
||||
var offsetY = currentPos.Y - _lastPos.Y;
|
||||
|
||||
SetLeft(_curAnn, GetLeft(_curAnn) + offsetX);
|
||||
SetTop(_curAnn, GetTop(_curAnn) + offsetY);
|
||||
_lastPos = currentPos;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NewAnnotation
|
||||
|
||||
private void NewAnnotationStart(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_newAnnotationStartPos = e.GetPosition(this);
|
||||
|
||||
SetLeft(_newAnnotationRect, _newAnnotationStartPos.X);
|
||||
SetTop(_newAnnotationRect, _newAnnotationStartPos.Y);
|
||||
_newAnnotationRect.MouseMove += NewAnnotationCreatingMove;
|
||||
|
||||
SelectionState = SelectionState.NewAnnCreating;
|
||||
}
|
||||
|
||||
private void NewAnnotationCreatingMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SelectionState != SelectionState.NewAnnCreating)
|
||||
return;
|
||||
|
||||
var currentPos = e.GetPosition(this);
|
||||
var diff = currentPos - _newAnnotationStartPos;
|
||||
|
||||
_newAnnotationRect.Height = Math.Abs(diff.Y);
|
||||
_newAnnotationRect.Width = Math.Abs(diff.X);
|
||||
|
||||
if (diff.X < 0)
|
||||
SetLeft(_newAnnotationRect, currentPos.X);
|
||||
if (diff.Y < 0)
|
||||
SetTop(_newAnnotationRect, currentPos.Y);
|
||||
}
|
||||
|
||||
private void CreateAnnotation(Point endPos)
|
||||
{
|
||||
_newAnnotationRect.Width = 0;
|
||||
_newAnnotationRect.Height = 0;
|
||||
var width = Math.Abs(endPos.X - _newAnnotationStartPos.X);
|
||||
var height = Math.Abs(endPos.Y - _newAnnotationStartPos.Y);
|
||||
if (width < MIN_SIZE || height < MIN_SIZE)
|
||||
return;
|
||||
|
||||
var time = GetTimeFunc();
|
||||
CreateAnnotation(CurrentAnnClass, time, new CanvasLabel
|
||||
{
|
||||
Width = width,
|
||||
Height = height,
|
||||
X = Math.Min(endPos.X, _newAnnotationStartPos.X),
|
||||
Y = Math.Min(endPos.Y, _newAnnotationStartPos.Y)
|
||||
});
|
||||
}
|
||||
|
||||
public AnnotationControl CreateAnnotation(AnnotationClass annClass, TimeSpan? time, CanvasLabel canvasLabel)
|
||||
{
|
||||
var annotationControl = new AnnotationControl(annClass, time, AnnotationResizeStart, canvasLabel.Probability)
|
||||
{
|
||||
Width = canvasLabel.Width,
|
||||
Height = canvasLabel.Height
|
||||
};
|
||||
annotationControl.MouseDown += AnnotationPositionStart;
|
||||
SetLeft(annotationControl, canvasLabel.X );
|
||||
SetTop(annotationControl, canvasLabel.Y);
|
||||
Children.Add(annotationControl);
|
||||
CurrentAnns.Add(annotationControl);
|
||||
_newAnnotationRect.Fill = new SolidColorBrush(annClass.Color);
|
||||
return annotationControl;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void RemoveAnnotations(IEnumerable<AnnotationControl> listToRemove)
|
||||
{
|
||||
foreach (var ann in listToRemove)
|
||||
{
|
||||
Children.Remove(ann);
|
||||
CurrentAnns.Remove(ann);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveAllAnns()
|
||||
{
|
||||
foreach (var ann in CurrentAnns)
|
||||
Children.Remove(ann);
|
||||
CurrentAnns.Clear();
|
||||
}
|
||||
|
||||
public void RemoveSelectedAnns() => RemoveAnnotations(CurrentAnns.Where(x => x.IsSelected).ToList());
|
||||
|
||||
private void ClearSelections()
|
||||
{
|
||||
foreach (var ann in CurrentAnns)
|
||||
ann.IsSelected = false;
|
||||
}
|
||||
|
||||
public void ClearExpiredAnnotations(TimeSpan time)
|
||||
{
|
||||
var expiredAnns = CurrentAnns.Where(x =>
|
||||
x.Time.HasValue &&
|
||||
Math.Abs((time - x.Time.Value).TotalMilliseconds) > _viewThreshold.TotalMilliseconds)
|
||||
.ToList();
|
||||
RemoveAnnotations(expiredAnns);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace Azaion.Annotator.Controls
|
||||
{
|
||||
public class UpdatableProgressBar : ProgressBar
|
||||
{
|
||||
public delegate void ValueChange(double oldValue, double newValue);
|
||||
|
||||
public new event ValueChange? ValueChanged;
|
||||
|
||||
public UpdatableProgressBar() : base()
|
||||
{
|
||||
MouseDown += OnMouseDown;
|
||||
MouseMove += OnMouseMove;
|
||||
}
|
||||
|
||||
private double SetProgressBarValue(double mousePos)
|
||||
{
|
||||
Value = Minimum;
|
||||
var pbValue = mousePos / ActualWidth * Maximum;
|
||||
ValueChanged?.Invoke(Value, pbValue);
|
||||
return pbValue;
|
||||
}
|
||||
|
||||
private void OnMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
Value = SetProgressBarValue(e.GetPosition(this).X);
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.LeftButton == MouseButtonState.Pressed)
|
||||
Value = SetProgressBarValue(e.GetPosition(this).X);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MediatR;
|
||||
using Azaion.Common.DTO;
|
||||
using MediatR;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Media;
|
||||
using Azaion.Annotator.Extensions;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class AnnotationClass
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
public string ShortName { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Color Color => Id.ToColor();
|
||||
|
||||
[JsonIgnore]
|
||||
public int ClassNumber => Id + 1;
|
||||
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush ColorBrush => new(Color);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Windows.Media;
|
||||
using Azaion.Annotator.Extensions;
|
||||
using Azaion.Common.DTO;
|
||||
using Azaion.Common.DTO.Config;
|
||||
using Azaion.Common.Extensions;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class AnnotationResult
|
||||
{
|
||||
private readonly Config _config = null!;
|
||||
|
||||
[JsonProperty(PropertyName = "f")]
|
||||
public string Image { get; set; } = null!;
|
||||
|
||||
@@ -18,61 +19,26 @@ public class AnnotationResult
|
||||
public double Lon { get; set; }
|
||||
public List<Detection> Detections { get; set; } = new();
|
||||
|
||||
#region For Display in the grid
|
||||
#region For XAML Form
|
||||
|
||||
[JsonIgnore]
|
||||
//For XAML Form
|
||||
public string TimeStr => $"{Time:h\\:mm\\:ss}";
|
||||
|
||||
private List<int>? _detectionClasses = null!;
|
||||
|
||||
//For Form
|
||||
[JsonIgnore]
|
||||
public string ClassName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Detections.Count == 0)
|
||||
return "";
|
||||
_detectionClasses ??= Detections.Select(x => x.ClassNumber).Distinct().ToList();
|
||||
|
||||
return _detectionClasses.Count > 1
|
||||
? string.Join(", ", _detectionClasses.Select(x => _config.AnnotationClassesDict[x].ShortName))
|
||||
: _config.AnnotationClassesDict[_detectionClasses.FirstOrDefault()].Name;
|
||||
}
|
||||
}
|
||||
|
||||
public string ClassName { get; set; } = null!;
|
||||
|
||||
[JsonIgnore]
|
||||
public Color ClassColor1 => GetAnnotationClass(0);
|
||||
public Color ClassColor0 { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Color ClassColor2 => GetAnnotationClass(1);
|
||||
public Color ClassColor1 { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Color ClassColor3 => GetAnnotationClass(2);
|
||||
public Color ClassColor2 { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Color ClassColor4 => GetAnnotationClass(3);
|
||||
|
||||
private Color GetAnnotationClass(int colorNumber)
|
||||
{
|
||||
if (Detections.Count == 0)
|
||||
return (-1).ToColor();
|
||||
|
||||
_detectionClasses ??= Detections.Select(x => x.ClassNumber).Distinct().ToList();
|
||||
|
||||
return colorNumber >= _detectionClasses.Count
|
||||
? _config.AnnotationClassesDict[_detectionClasses.LastOrDefault()].Color
|
||||
: _config.AnnotationClassesDict[_detectionClasses[colorNumber]].Color;
|
||||
}
|
||||
|
||||
public Color ClassColor3 { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
public AnnotationResult() { }
|
||||
public AnnotationResult(TimeSpan time, string timeName, List<Detection> detections, Config config)
|
||||
{
|
||||
_config = config;
|
||||
Detections = detections;
|
||||
Time = time;
|
||||
Image = $"{timeName}.jpg";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Size = System.Windows.Size;
|
||||
using Point = System.Windows.Point;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class Config
|
||||
{
|
||||
public const string THUMBNAIL_PREFIX = "_thumb";
|
||||
public const string THUMBNAILS_CACHE_FILE = "thumbnails.cache";
|
||||
|
||||
public string VideosDirectory { get; set; } = null!;
|
||||
public string LabelsDirectory { get; set; } = null!;
|
||||
public string ImagesDirectory { get; set; } = null!;
|
||||
public string ResultsDirectory { get; set; } = null!;
|
||||
public string ThumbnailsDirectory { get; set; } = null!;
|
||||
public string UnknownImages { get; set; } = null!;
|
||||
|
||||
public List<AnnotationClass> AnnotationClasses { get; set; } = [];
|
||||
|
||||
private Dictionary<int, AnnotationClass>? _annotationClassesDict;
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<int, AnnotationClass> AnnotationClassesDict => _annotationClassesDict ??= AnnotationClasses.ToDictionary(x => x.Id);
|
||||
|
||||
public WindowConfig MainWindowConfig { get; set; } = null!;
|
||||
public WindowConfig DatasetExplorerConfig { get; set; } = null!;
|
||||
|
||||
public double LeftPanelWidth { get; set; }
|
||||
public double RightPanelWidth { get; set; }
|
||||
|
||||
public bool ShowHelpOnStart { get; set; }
|
||||
|
||||
public List<string> VideoFormats { get; set; } = null!;
|
||||
public List<string> ImageFormats { get; set; } = null!;
|
||||
|
||||
public ThumbnailConfig ThumbnailConfig { get; set; } = null!;
|
||||
public int? LastSelectedExplorerClass { get; set; }
|
||||
public AIRecognitionConfig AIRecognitionConfig { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class AIRecognitionConfig
|
||||
{
|
||||
public string AIModelPath { get; set; } = null!;
|
||||
public double FrameRecognitionSeconds { get; set; }
|
||||
public double TrackingDistanceConfidence { get; set; }
|
||||
public double TrackingProbabilityIncrease { get; set; }
|
||||
public double TrackingIntersectionThreshold { get; set; }
|
||||
}
|
||||
|
||||
public class WindowConfig
|
||||
{
|
||||
public Size WindowSize { get; set; }
|
||||
public Point WindowLocation { get; set; }
|
||||
public bool FullScreen { get; set; }
|
||||
}
|
||||
|
||||
public class ThumbnailConfig
|
||||
{
|
||||
public Size Size { get; set; }
|
||||
public int Border { get; set; }
|
||||
}
|
||||
|
||||
public interface IConfigRepository
|
||||
{
|
||||
public Config Get();
|
||||
public void Save(Config config);
|
||||
}
|
||||
|
||||
public class FileConfigRepository(ILogger<FileConfigRepository> logger) : IConfigRepository
|
||||
{
|
||||
private const string CONFIG_PATH = "config.json";
|
||||
|
||||
private const string DEFAULT_VIDEO_DIR = "video";
|
||||
|
||||
private const string DEFAULT_LABELS_DIR = "labels";
|
||||
private const string DEFAULT_IMAGES_DIR = "images";
|
||||
private const string DEFAULT_RESULTS_DIR = "results";
|
||||
private const string DEFAULT_THUMBNAILS_DIR = "thumbnails";
|
||||
private const string DEFAULT_UNKNOWN_IMG_DIR = "unknown";
|
||||
|
||||
private const int DEFAULT_THUMBNAIL_BORDER = 10;
|
||||
private const double DEFAULT_FRAME_RECOGNITION_SECONDS = 2;
|
||||
private const double TRACKING_DISTANCE_CONFIDENCE = 0.15;
|
||||
private const double TRACKING_PROBABILITY_INCREASE = 15;
|
||||
private const double TRACKING_INTERSECTION_THRESHOLD = 0.8;
|
||||
|
||||
private static readonly Size DefaultWindowSize = new(1280, 720);
|
||||
private static readonly Point DefaultWindowLocation = new(100, 100);
|
||||
private static readonly Size DefaultThumbnailSize = new(240, 135);
|
||||
|
||||
private static readonly List<string> DefaultVideoFormats = ["mp4", "mov", "avi"];
|
||||
private static readonly List<string> DefaultImageFormats = ["jpg", "jpeg", "png", "bmp"];
|
||||
|
||||
public Config Get()
|
||||
{
|
||||
var exePath = Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory)!;
|
||||
var configFilePath = Path.Combine(exePath, CONFIG_PATH);
|
||||
|
||||
if (!File.Exists(configFilePath))
|
||||
{
|
||||
return new Config
|
||||
{
|
||||
VideosDirectory = Path.Combine(exePath, DEFAULT_VIDEO_DIR),
|
||||
LabelsDirectory = Path.Combine(exePath, DEFAULT_LABELS_DIR),
|
||||
ImagesDirectory = Path.Combine(exePath, DEFAULT_IMAGES_DIR),
|
||||
ResultsDirectory = Path.Combine(exePath, DEFAULT_RESULTS_DIR),
|
||||
ThumbnailsDirectory = Path.Combine(exePath, DEFAULT_THUMBNAILS_DIR),
|
||||
UnknownImages = Path.Combine(exePath, DEFAULT_UNKNOWN_IMG_DIR),
|
||||
|
||||
MainWindowConfig = new WindowConfig
|
||||
{
|
||||
WindowSize = DefaultWindowSize,
|
||||
WindowLocation = DefaultWindowLocation
|
||||
},
|
||||
DatasetExplorerConfig = new WindowConfig
|
||||
{
|
||||
WindowSize = DefaultWindowSize,
|
||||
WindowLocation = DefaultWindowLocation
|
||||
},
|
||||
ShowHelpOnStart = true,
|
||||
|
||||
VideoFormats = DefaultVideoFormats,
|
||||
ImageFormats = DefaultImageFormats,
|
||||
ThumbnailConfig = new ThumbnailConfig
|
||||
{
|
||||
Size = DefaultThumbnailSize,
|
||||
Border = DEFAULT_THUMBNAIL_BORDER
|
||||
},
|
||||
AIRecognitionConfig = new AIRecognitionConfig
|
||||
{
|
||||
AIModelPath = "azaion.onnx",
|
||||
FrameRecognitionSeconds = DEFAULT_FRAME_RECOGNITION_SECONDS,
|
||||
TrackingDistanceConfidence = TRACKING_DISTANCE_CONFIDENCE,
|
||||
TrackingProbabilityIncrease = TRACKING_PROBABILITY_INCREASE,
|
||||
TrackingIntersectionThreshold = TRACKING_INTERSECTION_THRESHOLD
|
||||
}
|
||||
};
|
||||
}
|
||||
var str = File.ReadAllText(CONFIG_PATH);
|
||||
return JsonConvert.DeserializeObject<Config>(str) ?? new Config();
|
||||
}
|
||||
|
||||
public void Save(Config config)
|
||||
{
|
||||
File.WriteAllText(CONFIG_PATH, JsonConvert.SerializeObject(config, Formatting.Indented), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
@@ -11,32 +11,13 @@ public class FormState
|
||||
? ""
|
||||
: Path.GetFileNameWithoutExtension(CurrentMedia.Name).Replace(" ", "");
|
||||
|
||||
public string CurrentMrl { get; set; }
|
||||
public string CurrentMrl { get; set; } = null!;
|
||||
public Size CurrentVideoSize { get; set; }
|
||||
public TimeSpan CurrentVideoLength { get; set; }
|
||||
|
||||
public TimeSpan? BackgroundTime { get; set; }
|
||||
public int CurrentVolume { get; set; } = 100;
|
||||
public ObservableCollection<AnnotationResult> AnnotationResults { get; set; } = [];
|
||||
public WindowsEnum ActiveWindow { get; set; }
|
||||
|
||||
public string GetTimeName(TimeSpan? ts) => $"{VideoName}_{ts:hmmssf}";
|
||||
|
||||
public TimeSpan? GetTime(string name)
|
||||
{
|
||||
var timeStr = name.Split("_").LastOrDefault();
|
||||
if (string.IsNullOrEmpty(timeStr) || timeStr.Length < 6)
|
||||
return null;
|
||||
|
||||
//For some reason, TimeSpan.ParseExact doesn't work on every platform.
|
||||
if (!int.TryParse(timeStr[0..1], out var hours))
|
||||
return null;
|
||||
if (!int.TryParse(timeStr[1..3], out var minutes))
|
||||
return null;
|
||||
if (!int.TryParse(timeStr[3..5], out var seconds))
|
||||
return null;
|
||||
if (!int.TryParse(timeStr[5..6], out var milliseconds))
|
||||
return null;
|
||||
return new TimeSpan(0, hours, minutes, seconds, milliseconds * 100);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Size = System.Windows.Size;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public abstract class Label
|
||||
{
|
||||
[JsonProperty(PropertyName = "cl")] public int ClassNumber { get; set; }
|
||||
|
||||
protected Label()
|
||||
{
|
||||
}
|
||||
|
||||
protected Label(int classNumber)
|
||||
{
|
||||
ClassNumber = classNumber;
|
||||
}
|
||||
}
|
||||
|
||||
public class CanvasLabel : Label
|
||||
{
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
public double? Probability { get; }
|
||||
|
||||
public CanvasLabel()
|
||||
{
|
||||
}
|
||||
|
||||
public CanvasLabel(int classNumber, double x, double y, double width, double height, double? probability = null) : base(classNumber)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Width = width;
|
||||
Height = height;
|
||||
Probability = probability;
|
||||
}
|
||||
|
||||
public CanvasLabel(YoloLabel label, Size canvasSize, Size videoSize, double? probability = null)
|
||||
{
|
||||
var cw = canvasSize.Width;
|
||||
var ch = canvasSize.Height;
|
||||
var canvasAr = cw / ch;
|
||||
var videoAr = videoSize.Width / videoSize.Height;
|
||||
|
||||
ClassNumber = label.ClassNumber;
|
||||
|
||||
var left = label.CenterX - label.Width / 2;
|
||||
var top = label.CenterY - label.Height / 2;
|
||||
|
||||
if (videoAr > canvasAr) //100% width
|
||||
{
|
||||
var realHeight = cw / videoAr; //real video height in pixels on canvas
|
||||
var blackStripHeight = (ch - realHeight) / 2.0; //height of black strips at the top and bottom
|
||||
|
||||
X = left * cw;
|
||||
Y = top * realHeight + blackStripHeight;
|
||||
Width = label.Width * cw;
|
||||
Height = label.Height * realHeight;
|
||||
}
|
||||
else //100% height
|
||||
{
|
||||
var realWidth = ch * videoAr; //real video width in pixels on canvas
|
||||
var blackStripWidth = (cw - realWidth) / 2.0; //height of black strips at the top and bottom
|
||||
|
||||
X = left * realWidth + blackStripWidth;
|
||||
Y = top * ch;
|
||||
Width = label.Width * realWidth;
|
||||
Height = label.Height * ch;
|
||||
}
|
||||
Probability = probability;
|
||||
}
|
||||
}
|
||||
|
||||
public class YoloLabel : Label
|
||||
{
|
||||
[JsonProperty(PropertyName = "x")] public double CenterX { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "y")] public double CenterY { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "w")] public double Width { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "h")] public double Height { get; set; }
|
||||
|
||||
public YoloLabel()
|
||||
{
|
||||
}
|
||||
|
||||
public YoloLabel(int classNumber, double centerX, double centerY, double width, double height) : base(classNumber)
|
||||
{
|
||||
CenterX = centerX;
|
||||
CenterY = centerY;
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
public RectangleF ToRectangle() =>
|
||||
new((float)(CenterX - Width / 2.0), (float)(CenterY - Height / 2.0), (float)Width, (float)Height);
|
||||
|
||||
public YoloLabel(CanvasLabel canvasLabel, Size canvasSize, Size videoSize)
|
||||
{
|
||||
var cw = canvasSize.Width;
|
||||
var ch = canvasSize.Height;
|
||||
var canvasAr = cw / ch;
|
||||
var videoAr = videoSize.Width / videoSize.Height;
|
||||
|
||||
ClassNumber = canvasLabel.ClassNumber;
|
||||
|
||||
double left, top;
|
||||
if (videoAr > canvasAr) //100% width
|
||||
{
|
||||
left = canvasLabel.X / cw;
|
||||
Width = canvasLabel.Width / cw;
|
||||
var realHeight = cw / videoAr; //real video height in pixels on canvas
|
||||
var blackStripHeight = (ch - realHeight) / 2.0; //height of black strips at the top and bottom
|
||||
top = (canvasLabel.Y - blackStripHeight) / realHeight;
|
||||
Height = canvasLabel.Height / realHeight;
|
||||
}
|
||||
else //100% height
|
||||
{
|
||||
top = canvasLabel.Y / ch;
|
||||
Height = canvasLabel.Height / ch;
|
||||
var realWidth = ch * videoAr; //real video width in pixels on canvas
|
||||
var blackStripWidth = (cw - realWidth) / 2.0; //height of black strips at the top and bottom
|
||||
left = (canvasLabel.X - blackStripWidth) / realWidth;
|
||||
Width = canvasLabel.Width / realWidth;
|
||||
}
|
||||
|
||||
CenterX = left + Width / 2.0;
|
||||
CenterY = top + Height / 2.0;
|
||||
}
|
||||
|
||||
public static YoloLabel? Parse(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
return null;
|
||||
|
||||
var strings = s.Replace(',', '.').Split(' ');
|
||||
if (strings.Length != 5)
|
||||
throw new Exception("Wrong labels format!");
|
||||
|
||||
var res = new YoloLabel
|
||||
{
|
||||
ClassNumber = int.Parse(strings[0], CultureInfo.InvariantCulture),
|
||||
CenterX = double.Parse(strings[1], CultureInfo.InvariantCulture),
|
||||
CenterY = double.Parse(strings[2], CultureInfo.InvariantCulture),
|
||||
Width = double.Parse(strings[3], CultureInfo.InvariantCulture),
|
||||
Height = double.Parse(strings[4], CultureInfo.InvariantCulture)
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
public static async Task<List<YoloLabel>> ReadFromFile(string filename, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var str = await File.ReadAllTextAsync(filename, cancellationToken);
|
||||
|
||||
return str.Split('\n')
|
||||
.Select(Parse)
|
||||
.Where(ann => ann != null)
|
||||
.ToList()!;
|
||||
}
|
||||
|
||||
public static async Task WriteToFile(IEnumerable<YoloLabel> labels, string filename, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var labelsStr = string.Join(Environment.NewLine, labels.Select(x => x.ToString()));
|
||||
await File.WriteAllTextAsync(filename, labelsStr, cancellationToken);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{ClassNumber} {CenterX:F5} {CenterY:F5} {Width:F5} {Height:F5}".Replace(',', '.');
|
||||
}
|
||||
|
||||
public class Detection : YoloLabel
|
||||
{
|
||||
public Detection(YoloLabel label, double? probability = null)
|
||||
{
|
||||
ClassNumber = label.ClassNumber;
|
||||
CenterX = label.CenterX;
|
||||
CenterY = label.CenterY;
|
||||
Height = label.Height;
|
||||
Width = label.Width;
|
||||
Probability = probability;
|
||||
}
|
||||
public double? Probability { get; set; }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class LabelInfo
|
||||
{
|
||||
[JsonProperty("c")] public List<int> Classes { get; set; } = null!;
|
||||
|
||||
[JsonProperty("d")] public DateTime ImageDateTime { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
using System.Windows.Input;
|
||||
using Azaion.Common.DTO;
|
||||
using MediatR;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class KeyEvent(object sender, KeyEventArgs args) : INotification
|
||||
{
|
||||
public object Sender { get; set; } = sender;
|
||||
public KeyEventArgs Args { get; set; } = args;
|
||||
}
|
||||
|
||||
public class PlaybackControlEvent(PlaybackControlEnum playbackControlEnum) : INotification
|
||||
{
|
||||
public PlaybackControlEnum PlaybackControl { get; set; } = playbackControlEnum;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public enum PlaybackControlEnum
|
||||
{
|
||||
None = 0,
|
||||
Play = 1,
|
||||
Pause = 2,
|
||||
Stop = 3,
|
||||
PreviousFrame = 4,
|
||||
NextFrame = 5,
|
||||
SaveAnnotations = 6,
|
||||
RemoveSelectedAnns = 7,
|
||||
RemoveAllAnns = 8,
|
||||
TurnOffVolume = 9,
|
||||
TurnOnVolume = 10,
|
||||
Previous = 11,
|
||||
Next = 12,
|
||||
Close = 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public enum SelectionState
|
||||
{
|
||||
None = 0,
|
||||
NewAnnCreating = 1,
|
||||
AnnResizing = 2,
|
||||
AnnMoving = 3
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Azaion.Annotator.Extensions;
|
||||
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public class ThumbnailDto : INotifyPropertyChanged
|
||||
{
|
||||
public string ThumbnailPath { get; set; }
|
||||
public string ImagePath { get; set; }
|
||||
public string LabelPath { get; set; }
|
||||
public DateTime ImageDate { get; set; }
|
||||
|
||||
private BitmapImage? _image;
|
||||
public BitmapImage? Image
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_image == null)
|
||||
Task.Run(async () => Image = await ThumbnailPath.OpenImage());
|
||||
return _image;
|
||||
}
|
||||
set
|
||||
{
|
||||
_image = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
public string ImageName => Path.GetFileName(ImagePath);
|
||||
|
||||
public void UpdateImage() => _image = null;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Azaion.Annotator.DTO;
|
||||
|
||||
public enum WindowsEnum
|
||||
{
|
||||
None = 0,
|
||||
Main = 10,
|
||||
DatasetExplorer = 20
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<Window x:Class="Azaion.Annotator.DatasetExplorer"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
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: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">
|
||||
|
||||
<Window.Resources>
|
||||
<DataTemplate x:Key="ThumbnailTemplate" DataType="{x:Type dto:ThumbnailDto}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"></RowDefinition>
|
||||
<RowDefinition Height="32"></RowDefinition>
|
||||
</Grid.RowDefinitions>
|
||||
<Image
|
||||
Grid.Row="0"
|
||||
Source="{Binding Image}"
|
||||
Width="480"
|
||||
Height="270"
|
||||
Margin="2" />
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Foreground="LightGray"
|
||||
Text="{Binding ImageName}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid
|
||||
Name="MainGrid"
|
||||
ShowGridLines="False"
|
||||
Background="Black"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"></RowDefinition>
|
||||
<RowDefinition Height="32"></RowDefinition>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="4"/>
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<controls:AnnotationClasses
|
||||
x:Name="LvClasses"
|
||||
Grid.Column="0"
|
||||
Grid.Row="0">
|
||||
</controls:AnnotationClasses>
|
||||
|
||||
<TabControl
|
||||
Name="Switcher"
|
||||
Grid.Column="2"
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Background="Black">
|
||||
<TabItem Name="AnnotationsTab" Header="Анотації">
|
||||
<vwp:GridView
|
||||
Name="ThumbnailsView"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Background="Black"
|
||||
Margin="2,5,2,2"
|
||||
ItemsSource="{Binding ThumbnailsDtos, Mode=OneWay}"
|
||||
ItemTemplate="{StaticResource ThumbnailTemplate}"
|
||||
>
|
||||
</vwp:GridView>
|
||||
</TabItem>
|
||||
<TabItem Name="EditorTab"
|
||||
Header="Редактор"
|
||||
Visibility="Collapsed">
|
||||
<controls:CanvasEditor x:Name="ExplorerEditor"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch" >
|
||||
</controls:CanvasEditor>
|
||||
</TabItem>
|
||||
<TabItem Name="ClassDistributionTab" Header="Розподіл класів">
|
||||
<ScottPlot:WpfPlot x:Name="ClassDistribution" />
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
<StatusBar
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Background="#252525"
|
||||
Foreground="White"
|
||||
>
|
||||
<StatusBar.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
</Grid>
|
||||
</ItemsPanelTemplate>
|
||||
</StatusBar.ItemsPanel>
|
||||
<StatusBarItem Grid.Column="0" Background="Black">
|
||||
<TextBlock Name="LoadingAnnsCaption">Завантаження:</TextBlock>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem Grid.Column="1" Background="Black">
|
||||
<ProgressBar x:Name="LoadingAnnsBar"
|
||||
Width="150"
|
||||
Height="15"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="#252525"
|
||||
BorderBrush="#252525"
|
||||
Foreground="LightBlue"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Value="0">
|
||||
</ProgressBar>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem Grid.Column="2" Background="Black">
|
||||
<TextBlock Name="RefreshThumbCaption">База іконок:</TextBlock>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem Grid.Column="3" Background="Black">
|
||||
<ProgressBar x:Name="RefreshThumbBar"
|
||||
Width="150"
|
||||
Height="15"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="#252525"
|
||||
BorderBrush="#252525"
|
||||
Foreground="LightBlue"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Value="0">
|
||||
</ProgressBar>
|
||||
</StatusBarItem>
|
||||
<Separator Grid.Column="4"/>
|
||||
<StatusBarItem Grid.Column="5" Background="Black">
|
||||
<TextBlock Name="StatusText" Text=""/>
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,352 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Annotator.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScottPlot;
|
||||
using Color = ScottPlot.Color;
|
||||
using MessageBox = System.Windows.MessageBox;
|
||||
using Orientation = ScottPlot.Orientation;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public partial class DatasetExplorer
|
||||
{
|
||||
private readonly Config _config;
|
||||
private readonly ILogger<DatasetExplorer> _logger;
|
||||
|
||||
public ObservableCollection<ThumbnailDto> ThumbnailsDtos { get; set; } = new();
|
||||
private ObservableCollection<AnnotationClass> AllAnnotationClasses { get; set; } = new();
|
||||
|
||||
private int _tempSelectedClassIdx = 0;
|
||||
private readonly IConfigRepository _configRepository;
|
||||
private readonly FormState _formState;
|
||||
private readonly IGalleryManager _galleryManager;
|
||||
|
||||
public bool ThumbnailLoading { get; set; }
|
||||
|
||||
public ThumbnailDto? CurrentThumbnail { get; set; }
|
||||
|
||||
public DatasetExplorer(
|
||||
Config config,
|
||||
ILogger<DatasetExplorer> logger,
|
||||
IConfigRepository configRepository,
|
||||
FormState formState,
|
||||
IGalleryManager galleryManager)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_configRepository = configRepository;
|
||||
_formState = formState;
|
||||
_galleryManager = galleryManager;
|
||||
|
||||
InitializeComponent();
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
AllAnnotationClasses = new ObservableCollection<AnnotationClass>(
|
||||
new List<AnnotationClass> { new() {Id = -1, Name = "All", ShortName = "All"}}
|
||||
.Concat(_config.AnnotationClasses));
|
||||
LvClasses.ItemsSource = AllAnnotationClasses;
|
||||
|
||||
LvClasses.MouseUp += async (_, _) =>
|
||||
{
|
||||
var selectedClass = (AnnotationClass)LvClasses.SelectedItem;
|
||||
ExplorerEditor.CurrentAnnClass = selectedClass;
|
||||
config.LastSelectedExplorerClass = selectedClass.Id;
|
||||
|
||||
if (Switcher.SelectedIndex == 0)
|
||||
await ReloadThumbnails();
|
||||
else
|
||||
foreach (var ann in ExplorerEditor.CurrentAnns.Where(x => x.IsSelected))
|
||||
ann.AnnotationClass = selectedClass;
|
||||
};
|
||||
|
||||
LvClasses.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (Switcher.SelectedIndex != 1)
|
||||
return;
|
||||
|
||||
var selectedClass = (AnnotationClass)LvClasses.SelectedItem;
|
||||
if (selectedClass == null)
|
||||
return;
|
||||
|
||||
ExplorerEditor.CurrentAnnClass = selectedClass;
|
||||
|
||||
foreach (var ann in ExplorerEditor.CurrentAnns.Where(x => x.IsSelected))
|
||||
ann.AnnotationClass = selectedClass;
|
||||
};
|
||||
|
||||
|
||||
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) =>
|
||||
{
|
||||
args.Cancel = true;
|
||||
Visibility = Visibility.Hidden;
|
||||
};
|
||||
|
||||
ThumbnailsView.KeyDown += async (sender, args) =>
|
||||
{
|
||||
switch (args.Key)
|
||||
{
|
||||
case Key.Delete:
|
||||
DeleteAnnotations();
|
||||
break;
|
||||
case Key.Enter:
|
||||
await EditAnnotation();
|
||||
break;
|
||||
}
|
||||
};
|
||||
ThumbnailsView.MouseDoubleClick += async (_, _) => await EditAnnotation();
|
||||
|
||||
ThumbnailsView.SelectionChanged += (_, _) =>
|
||||
{
|
||||
StatusText.Text = $"Обрано: {ThumbnailsView.SelectedItems.Count} | {ThumbnailsView.SelectedIndex} / {ThumbnailsDtos.Count}";
|
||||
};
|
||||
|
||||
Activated += (_, _) => { _formState.ActiveWindow = WindowsEnum.DatasetExplorer; };
|
||||
|
||||
ExplorerEditor.GetTimeFunc = () => _formState.GetTime(CurrentThumbnail!.ImagePath);
|
||||
galleryManager.ThumbnailsUpdate += thumbnailsPercentage =>
|
||||
{
|
||||
Dispatcher.Invoke(() => RefreshThumbBar.Value = thumbnailsPercentage);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private void LoadClassDistribution()
|
||||
{
|
||||
var data = _galleryManager.LabelsCache
|
||||
.SelectMany(x => x.Value.Classes)
|
||||
.GroupBy(x => x)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Key,
|
||||
_config.AnnotationClassesDict[x.Key].Name,
|
||||
_config.AnnotationClassesDict[x.Key].Color,
|
||||
ClassCount = x.Count()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var foregroundColor = Color.FromColor(System.Drawing.Color.Black);
|
||||
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,
|
||||
LabelOffset = 10
|
||||
}));
|
||||
|
||||
foreach (var x in data)
|
||||
{
|
||||
var label = plot.Add.Text(x.Name, 50, -1.5 * x.Key + 1.1);
|
||||
label.LabelFontColor = foregroundColor;
|
||||
label.LabelFontSize = 18;
|
||||
}
|
||||
|
||||
plot.Axes.AutoScale();
|
||||
plot.HideAxesAndGrid();
|
||||
plot.FigureBackground.Color = new("#888888");
|
||||
|
||||
ClassDistribution.Refresh();
|
||||
}
|
||||
|
||||
private async Task EditAnnotation()
|
||||
{
|
||||
try
|
||||
{
|
||||
ThumbnailLoading = true;
|
||||
|
||||
if (ThumbnailsView.SelectedItem == null)
|
||||
return;
|
||||
|
||||
var dto = (ThumbnailsView.SelectedItem as ThumbnailDto)!;
|
||||
CurrentThumbnail = dto;
|
||||
ExplorerEditor.Background = new ImageBrush
|
||||
{
|
||||
ImageSource = await dto.ImagePath.OpenImage()
|
||||
};
|
||||
SwitchTab(toEditor: true);
|
||||
|
||||
var time = _formState.GetTime(dto.ImagePath);
|
||||
ExplorerEditor.RemoveAllAnns();
|
||||
foreach (var ann in await YoloLabel.ReadFromFile(dto.LabelPath))
|
||||
{
|
||||
var annClass = _config.AnnotationClassesDict[ann.ClassNumber];
|
||||
var canvasLabel = new CanvasLabel(ann, ExplorerEditor.RenderSize, ExplorerEditor.RenderSize);
|
||||
ExplorerEditor.CreateAnnotation(annClass, time, canvasLabel);
|
||||
}
|
||||
|
||||
ThumbnailLoading = false;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ThumbnailLoading = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void SwitchTab(bool toEditor)
|
||||
{
|
||||
if (toEditor)
|
||||
{
|
||||
AnnotationsTab.Visibility = Visibility.Collapsed;
|
||||
EditorTab.Visibility = Visibility.Visible;
|
||||
_tempSelectedClassIdx = LvClasses.SelectedIndex;
|
||||
LvClasses.ItemsSource = _config.AnnotationClasses;
|
||||
|
||||
Switcher.SelectedIndex = 1;
|
||||
LvClasses.SelectedIndex = Math.Max(0, _tempSelectedClassIdx - 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnnotationsTab.Visibility = Visibility.Visible;
|
||||
EditorTab.Visibility = Visibility.Collapsed;
|
||||
LvClasses.ItemsSource = AllAnnotationClasses;
|
||||
LvClasses.SelectedIndex = _tempSelectedClassIdx;
|
||||
Switcher.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveUserSettings()
|
||||
{
|
||||
_config.DatasetExplorerConfig = this.GetConfig();
|
||||
await ThrottleExt.Throttle(() =>
|
||||
{
|
||||
_configRepository.Save(_config);
|
||||
return Task.CompletedTask;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private void DeleteAnnotations()
|
||||
{
|
||||
var tempSelected = ThumbnailsView.SelectedIndex;
|
||||
var result = MessageBox.Show("Чи дійсно видалити аннотації?","Підтвердження видалення", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (result != MessageBoxResult.Yes)
|
||||
return;
|
||||
|
||||
var selected = ThumbnailsView.SelectedItems.Count;
|
||||
for (var i = 0; i < selected; i++)
|
||||
{
|
||||
var dto = (ThumbnailsView.SelectedItems[0] as ThumbnailDto)!;
|
||||
File.Delete(dto.ImagePath);
|
||||
File.Delete(dto.LabelPath);
|
||||
File.Delete(dto.ThumbnailPath);
|
||||
ThumbnailsDtos.Remove(dto);
|
||||
}
|
||||
ThumbnailsView.SelectedIndex = Math.Min(ThumbnailsDtos.Count, tempSelected);
|
||||
}
|
||||
|
||||
private async Task ReloadThumbnails()
|
||||
{
|
||||
LoadingAnnsCaption.Visibility = Visibility.Visible;
|
||||
LoadingAnnsBar.Visibility = Visibility.Visible;
|
||||
|
||||
if (!Directory.Exists(_config.ThumbnailsDirectory))
|
||||
return;
|
||||
|
||||
var thumbnails = Directory.GetFiles(_config.ThumbnailsDirectory, "*.jpg");
|
||||
var thumbnailDtos = new List<ThumbnailDto>();
|
||||
for (int i = 0; i < thumbnails.Length; i++)
|
||||
{
|
||||
var thumbnailDto = await GetThumbnail(thumbnails[i]);
|
||||
if (thumbnailDto != null)
|
||||
thumbnailDtos.Add(thumbnailDto);
|
||||
|
||||
if (i % 1000 == 0)
|
||||
LoadingAnnsBar.Value = i * 100.0 / thumbnails.Length;
|
||||
}
|
||||
|
||||
ThumbnailsDtos.Clear();
|
||||
foreach (var th in thumbnailDtos.OrderByDescending(x => x.ImageDate))
|
||||
ThumbnailsDtos.Add(th);
|
||||
|
||||
LoadingAnnsCaption.Visibility = Visibility.Collapsed;
|
||||
LoadingAnnsBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async Task<ThumbnailDto?> GetThumbnail(string thumbnail)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(thumbnail)[..^Config.THUMBNAIL_PREFIX.Length];
|
||||
var imagePath = Path.Combine(_config.ImagesDirectory, name);
|
||||
var labelPath = Path.Combine(_config.LabelsDirectory, $"{name}.txt");
|
||||
|
||||
foreach (var f in _config.ImageFormats)
|
||||
{
|
||||
var curName = $"{imagePath}.{f}";
|
||||
if (File.Exists(curName))
|
||||
{
|
||||
imagePath = curName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_galleryManager.LabelsCache.TryGetValue(Path.GetFileName(imagePath), out var info))
|
||||
{
|
||||
if (!File.Exists(imagePath) || !File.Exists(labelPath))
|
||||
{
|
||||
File.Delete(thumbnail);
|
||||
_logger.LogError($"No label {labelPath} found ! Image {imagePath} not found, thumbnail {thumbnail} was removed");
|
||||
return null;
|
||||
}
|
||||
|
||||
var classes = (await YoloLabel.ReadFromFile(labelPath))
|
||||
.Select(x => x.ClassNumber)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
info = _galleryManager.AddToCache(imagePath, classes);
|
||||
}
|
||||
|
||||
if (!info.Classes.Contains(ExplorerEditor.CurrentAnnClass.Id) && ExplorerEditor.CurrentAnnClass.Id != -1)
|
||||
return null;
|
||||
|
||||
return new ThumbnailDto
|
||||
{
|
||||
ThumbnailPath = thumbnail,
|
||||
ImagePath = imagePath,
|
||||
LabelPath = labelPath,
|
||||
ImageDate = info.ImageDateTime
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void AddThumbnail(ThumbnailDto thumbnailDto, IEnumerable<int> classes)
|
||||
{
|
||||
var selectedClass = ((AnnotationClass?)LvClasses.SelectedItem)?.Id;
|
||||
|
||||
if (selectedClass != null && (selectedClass == -1 || classes.Any(x => x == selectedClass)))
|
||||
ThumbnailsDtos.Insert(0, thumbnailDto);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Windows.Input;
|
||||
using Azaion.Annotator.DTO;
|
||||
using MediatR;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public class DatasetExplorerEventHandler(DatasetExplorer datasetExplorer,
|
||||
Config config,
|
||||
IGalleryManager galleryManager,
|
||||
FormState formState) : INotificationHandler<KeyEvent>
|
||||
{
|
||||
private readonly Dictionary<Key, PlaybackControlEnum> _keysControlEnumDict = new()
|
||||
{
|
||||
{ Key.Enter, PlaybackControlEnum.SaveAnnotations },
|
||||
{ Key.Delete, PlaybackControlEnum.RemoveSelectedAnns },
|
||||
{ Key.X, PlaybackControlEnum.RemoveAllAnns },
|
||||
{ Key.Escape, PlaybackControlEnum.Close }
|
||||
};
|
||||
|
||||
public async Task Handle(KeyEvent keyEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
if (formState.ActiveWindow != WindowsEnum.DatasetExplorer)
|
||||
return;
|
||||
|
||||
var key = keyEvent.Args.Key;
|
||||
var keyNumber = (int?)null;
|
||||
|
||||
if ((int)key >= (int)Key.D1 && (int)key <= (int)Key.D9) keyNumber = key - Key.D1;
|
||||
if ((int)key >= (int)Key.NumPad1 && (int)key <= (int)Key.NumPad9) keyNumber = key - Key.NumPad1;
|
||||
|
||||
if (keyNumber.HasValue)
|
||||
datasetExplorer.LvClasses.SelectedIndex = keyNumber.Value;
|
||||
else
|
||||
{
|
||||
if (datasetExplorer.Switcher.SelectedIndex == 1 && _keysControlEnumDict.TryGetValue(key, out var value))
|
||||
await HandleControl(value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleControl(PlaybackControlEnum controlEnum)
|
||||
{
|
||||
switch (controlEnum)
|
||||
{
|
||||
case PlaybackControlEnum.SaveAnnotations:
|
||||
if (datasetExplorer.ThumbnailLoading)
|
||||
return;
|
||||
|
||||
var currentAnns = datasetExplorer.ExplorerEditor.CurrentAnns
|
||||
.Select(x => new YoloLabel(x.Info, datasetExplorer.ExplorerEditor.RenderSize, datasetExplorer.ExplorerEditor.RenderSize))
|
||||
.ToList();
|
||||
|
||||
await YoloLabel.WriteToFile(currentAnns, Path.Combine(config.LabelsDirectory, datasetExplorer.CurrentThumbnail!.LabelPath));
|
||||
await galleryManager.CreateThumbnail(datasetExplorer.CurrentThumbnail.ImagePath);
|
||||
await galleryManager.SaveLabelsCache();
|
||||
datasetExplorer.CurrentThumbnail.UpdateImage();
|
||||
datasetExplorer.SwitchTab(toEditor: false);
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveSelectedAnns:
|
||||
datasetExplorer.ExplorerEditor.RemoveSelectedAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveAllAnns:
|
||||
datasetExplorer.ExplorerEditor.RemoveAllAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.Close:
|
||||
datasetExplorer.SwitchTab(toEditor: false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color ToColor(this int id)
|
||||
{
|
||||
var index = id % ColorValues.Length;
|
||||
var hex = index == -1
|
||||
? "#40DDDDDD"
|
||||
: $"#40{ColorValues[index]}";
|
||||
var color =(Color)ColorConverter.ConvertFromString(hex);
|
||||
return color;
|
||||
}
|
||||
|
||||
private static readonly string[] ColorValues =
|
||||
[
|
||||
"FF0000", "00FF00", "0000FF", "FFFF00", "FF00FF", "00FFFF", "000000",
|
||||
"800000", "008000", "000080", "808000", "800080", "008080", "808080",
|
||||
"C00000", "00C000", "0000C0", "C0C000", "C000C0", "00C0C0", "C0C0C0",
|
||||
"400000", "004000", "000040", "404000", "400040", "004040", "404040",
|
||||
"200000", "002000", "000020", "202000", "200020", "002020", "202020",
|
||||
"600000", "006000", "000060", "606000", "600060", "006060", "606060",
|
||||
"A00000", "00A000", "0000A0", "A0A000", "A000A0", "00A0A0", "A0A0A0",
|
||||
"E00000", "00E000", "0000E0", "E0E000", "E000E0", "00E0E0", "E0E0E0"
|
||||
];
|
||||
}
|
||||
@@ -1,52 +1,52 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public static class DataGridExtensions
|
||||
{
|
||||
public static DataGridCell? GetCell(this DataGrid grid, int rowIndex, int columnIndex = 0)
|
||||
{
|
||||
var row = (DataGridRow)grid.ItemContainerGenerator.ContainerFromIndex(rowIndex);
|
||||
if (row == null)
|
||||
return null;
|
||||
|
||||
var presenter = FindVisualChild<DataGridCellsPresenter>(row);
|
||||
if (presenter == null)
|
||||
return null;
|
||||
|
||||
var cell = (DataGridCell)presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex);
|
||||
if (cell != null) return cell;
|
||||
|
||||
// now try to bring into view and retrieve the cell
|
||||
grid.ScrollIntoView(row, grid.Columns[columnIndex]);
|
||||
cell = (DataGridCell)presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static IEnumerable<T> FindVisualChildren<T>(DependencyObject? dependencyObj) where T : DependencyObject
|
||||
{
|
||||
if (dependencyObj == null)
|
||||
yield break;
|
||||
|
||||
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(dependencyObj); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(dependencyObj, i);
|
||||
if (child is T dependencyObject)
|
||||
{
|
||||
yield return dependencyObject;
|
||||
}
|
||||
|
||||
foreach (T childOfChild in FindVisualChildren<T>(child))
|
||||
{
|
||||
yield return childOfChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static TChildItem? FindVisualChild<TChildItem>(DependencyObject? obj) where TChildItem : DependencyObject =>
|
||||
FindVisualChildren<TChildItem>(obj).FirstOrDefault();
|
||||
}
|
||||
// using System.Windows;
|
||||
// using System.Windows.Controls;
|
||||
// using System.Windows.Controls.Primitives;
|
||||
// using System.Windows.Media;
|
||||
//
|
||||
// namespace Azaion.Annotator.Extensions;
|
||||
//
|
||||
// public static class DataGridExtensions
|
||||
// {
|
||||
// public static DataGridCell? GetCell(this DataGrid grid, int rowIndex, int columnIndex = 0)
|
||||
// {
|
||||
// var row = (DataGridRow)grid.ItemContainerGenerator.ContainerFromIndex(rowIndex);
|
||||
// if (row == null)
|
||||
// return null;
|
||||
//
|
||||
// var presenter = FindVisualChild<DataGridCellsPresenter>(row);
|
||||
// if (presenter == null)
|
||||
// return null;
|
||||
//
|
||||
// var cell = (DataGridCell)presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex);
|
||||
// if (cell != null) return cell;
|
||||
//
|
||||
// // now try to bring into view and retrieve the cell
|
||||
// grid.ScrollIntoView(row, grid.Columns[columnIndex]);
|
||||
// cell = (DataGridCell)presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex);
|
||||
//
|
||||
// return cell;
|
||||
// }
|
||||
//
|
||||
// private static IEnumerable<T> FindVisualChildren<T>(DependencyObject? dependencyObj) where T : DependencyObject
|
||||
// {
|
||||
// if (dependencyObj == null)
|
||||
// yield break;
|
||||
//
|
||||
// for (int i = 0; i < VisualTreeHelper.GetChildrenCount(dependencyObj); i++)
|
||||
// {
|
||||
// var child = VisualTreeHelper.GetChild(dependencyObj, i);
|
||||
// if (child is T dependencyObject)
|
||||
// {
|
||||
// yield return dependencyObject;
|
||||
// }
|
||||
//
|
||||
// foreach (T childOfChild in FindVisualChildren<T>(child))
|
||||
// {
|
||||
// yield return childOfChild;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public static TChildItem? FindVisualChild<TChildItem>(DependencyObject? obj) where TChildItem : DependencyObject =>
|
||||
// FindVisualChildren<TChildItem>(obj).FirstOrDefault();
|
||||
// }
|
||||
@@ -1,12 +0,0 @@
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public class DenseDateTimeConverter : IsoDateTimeConverter
|
||||
{
|
||||
public DenseDateTimeConverter()
|
||||
{
|
||||
DateTimeFormat = "yy-MM-dd HH:mm:ss";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.IO;
|
||||
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public static class DirectoryInfoExtensions
|
||||
{
|
||||
public static IEnumerable<FileInfo> GetFiles(this DirectoryInfo dir, params string[] searchExtensions) =>
|
||||
dir.GetFiles("*.*", SearchOption.AllDirectories)
|
||||
.Where(f => searchExtensions.Any(s => f.Name.Contains(s, StringComparison.CurrentCultureIgnoreCase))).ToList();
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public class ParallelOptions
|
||||
{
|
||||
public int ProgressUpdateInterval { get; set; } = 100;
|
||||
public Func<int, Task> ProgressFn { get; set; } = null!;
|
||||
public double CpuUtilPercent { get; set; } = 100;
|
||||
}
|
||||
|
||||
public class ParallelExt
|
||||
{
|
||||
public static async Task ForEachAsync<T>(ICollection<T> source,
|
||||
Func<T, CancellationToken, ValueTask> 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<Task>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
public static class ThrottleExt
|
||||
{
|
||||
private static bool _throttleOn;
|
||||
public static async Task Throttle(Func<Task> func, TimeSpan? throttleTime = null)
|
||||
{
|
||||
if (_throttleOn)
|
||||
return;
|
||||
|
||||
_throttleOn = true;
|
||||
await func();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(throttleTime ?? TimeSpan.FromMilliseconds(500));
|
||||
_throttleOn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Common.DTO.Config;
|
||||
|
||||
namespace Azaion.Annotator.Extensions;
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Annotator.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Color = System.Drawing.Color;
|
||||
using ParallelOptions = Azaion.Annotator.Extensions.ParallelOptions;
|
||||
using Size = System.Windows.Size;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public delegate void ThumbnailsUpdatedEventHandler(double thumbnailsPercentage);
|
||||
|
||||
public class GalleryManager : IGalleryManager
|
||||
{
|
||||
private readonly ILogger<GalleryManager> _logger;
|
||||
|
||||
public event ThumbnailsUpdatedEventHandler ThumbnailsUpdate;
|
||||
private readonly string _thumbnailsCacheFile;
|
||||
|
||||
private readonly SemaphoreSlim _updateLock = new(1);
|
||||
|
||||
public double ThumbnailsPercentage { get; set; }
|
||||
public ConcurrentDictionary<string, LabelInfo> LabelsCache { get; set; } = new();
|
||||
|
||||
private DirectoryInfo? _thumbnailsDirectory;
|
||||
private readonly Config _config;
|
||||
|
||||
private DirectoryInfo ThumbnailsDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_thumbnailsDirectory != null)
|
||||
return _thumbnailsDirectory;
|
||||
|
||||
var dir = new DirectoryInfo(_config.ThumbnailsDirectory);
|
||||
if (!dir.Exists)
|
||||
Directory.CreateDirectory(_config.ThumbnailsDirectory);
|
||||
_thumbnailsDirectory = new DirectoryInfo(_config.ThumbnailsDirectory);
|
||||
return _thumbnailsDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
public GalleryManager(Config config, ILogger<GalleryManager> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_thumbnailsCacheFile = Path.Combine(config.ThumbnailsDirectory, Config.THUMBNAILS_CACHE_FILE);
|
||||
}
|
||||
|
||||
public void ClearThumbnails()
|
||||
{
|
||||
foreach(var file in new DirectoryInfo(_config.ThumbnailsDirectory).GetFiles())
|
||||
file.Delete();
|
||||
}
|
||||
|
||||
public async Task RefreshThumbnails()
|
||||
{
|
||||
await _updateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var prefixLen = Config.THUMBNAIL_PREFIX.Length;
|
||||
|
||||
var thumbnails = ThumbnailsDirectory.GetFiles()
|
||||
.Select(x => Path.GetFileNameWithoutExtension(x.Name)[..^prefixLen])
|
||||
.GroupBy(x => x)
|
||||
.Select(gr => gr.Key)
|
||||
.ToHashSet();
|
||||
|
||||
if (File.Exists(_thumbnailsCacheFile))
|
||||
{
|
||||
var cache = JsonConvert.DeserializeObject<ConcurrentDictionary<string, LabelInfo>>(
|
||||
await File.ReadAllTextAsync(_thumbnailsCacheFile), new DenseDateTimeConverter());
|
||||
LabelsCache = cache ?? new ConcurrentDictionary<string, LabelInfo>();
|
||||
}
|
||||
else
|
||||
LabelsCache = new ConcurrentDictionary<string, LabelInfo>();
|
||||
|
||||
var files = new DirectoryInfo(_config.ImagesDirectory).GetFiles();
|
||||
var imagesCount = files.Length;
|
||||
|
||||
await ParallelExt.ForEachAsync(files, async (file, cancellationToken) =>
|
||||
{
|
||||
var imgName = Path.GetFileNameWithoutExtension(file.Name);
|
||||
if (thumbnails.Contains(imgName))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await CreateThumbnail(file.FullName, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Failed to generate thumbnail for {file.Name}! Error: {e.Message}");
|
||||
}
|
||||
}, 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
|
||||
{
|
||||
await SaveLabelsCache();
|
||||
_updateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveLabelsCache()
|
||||
{
|
||||
var labelsCacheStr = JsonConvert.SerializeObject(LabelsCache, new DenseDateTimeConverter());
|
||||
await File.WriteAllTextAsync(_thumbnailsCacheFile, labelsCacheStr);
|
||||
}
|
||||
|
||||
public async Task<ThumbnailDto?> CreateThumbnail(string imgPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var width = (int)_config.ThumbnailConfig.Size.Width;
|
||||
var height = (int)_config.ThumbnailConfig.Size.Height;
|
||||
|
||||
var imgName = Path.GetFileName(imgPath);
|
||||
var labelName = Path.Combine(_config.LabelsDirectory, $"{Path.GetFileNameWithoutExtension(imgPath)}.txt");
|
||||
|
||||
var originalImage = Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(imgPath, cancellationToken)));
|
||||
|
||||
var bitmap = new Bitmap(width, height);
|
||||
|
||||
using var g = Graphics.FromImage(bitmap);
|
||||
g.CompositingQuality = CompositingQuality.HighSpeed;
|
||||
g.SmoothingMode = SmoothingMode.HighSpeed;
|
||||
g.InterpolationMode = InterpolationMode.Default;
|
||||
|
||||
var size = new Size(originalImage.Width, originalImage.Height);
|
||||
if (!File.Exists(labelName))
|
||||
{
|
||||
File.Delete(imgPath);
|
||||
_logger.LogInformation($"No labels found for image {imgName}! Image deleted!");
|
||||
return null;
|
||||
}
|
||||
|
||||
var labels = (await YoloLabel.ReadFromFile(labelName, cancellationToken))
|
||||
.Select(x => new CanvasLabel(x, size, size))
|
||||
.ToList();
|
||||
var classes = labels.Select(x => x.ClassNumber).Distinct().ToList();
|
||||
|
||||
AddToCache(imgPath, classes);
|
||||
|
||||
var thumbWhRatio = width / (float)height;
|
||||
var border = _config.ThumbnailConfig.Border;
|
||||
|
||||
var frameX = 0.0;
|
||||
var frameY = 0.0;
|
||||
var frameHeight = size.Height;
|
||||
var frameWidth = size.Width;
|
||||
|
||||
if (labels.Any())
|
||||
{
|
||||
var labelsMinX = labels.Min(x => x.X);
|
||||
var labelsMaxX = labels.Max(x => x.X + x.Width);
|
||||
|
||||
var labelsMinY = labels.Min(x => x.Y);
|
||||
var labelsMaxY = labels.Max(x => x.Y + x.Height);
|
||||
|
||||
var labelsHeight = labelsMaxY - labelsMinY + 2 * border;
|
||||
var labelsWidth = labelsMaxX - labelsMinX + 2 * border;
|
||||
|
||||
if (labelsWidth / labelsHeight > thumbWhRatio)
|
||||
{
|
||||
frameWidth = labelsWidth;
|
||||
frameHeight = Math.Min(labelsWidth / thumbWhRatio, size.Height);
|
||||
frameX = Math.Max(0, labelsMinX - border);
|
||||
frameY = Math.Max(0, 0.5 * (labelsMinY + labelsMaxY - frameHeight) - border);
|
||||
}
|
||||
else
|
||||
{
|
||||
frameHeight = labelsHeight;
|
||||
frameWidth = Math.Min(labelsHeight * thumbWhRatio, size.Width);
|
||||
frameY = Math.Max(0, labelsMinY - border);
|
||||
frameX = Math.Max(0, 0.5 * (labelsMinX + labelsMaxX - frameWidth) - border);
|
||||
}
|
||||
}
|
||||
|
||||
var scale = frameHeight / height;
|
||||
g.DrawImage(originalImage, new Rectangle(0, 0, width, height), new RectangleF((float)frameX, (float)frameY, (float)frameWidth, (float)frameHeight), GraphicsUnit.Pixel);
|
||||
|
||||
foreach (var label in labels)
|
||||
{
|
||||
var color = _config.AnnotationClassesDict[label.ClassNumber].Color;
|
||||
var brush = new SolidBrush(Color.FromArgb(color.A, color.R, color.G, color.B));
|
||||
|
||||
var rectangle = new RectangleF((float)((label.X - frameX) / scale), (float)((label.Y - frameY) / scale), (float)(label.Width / scale), (float)(label.Height / scale));
|
||||
g.FillRectangle(brush, rectangle);
|
||||
}
|
||||
|
||||
var thumbnailName = Path.Combine(ThumbnailsDirectory.FullName, $"{Path.GetFileNameWithoutExtension(imgPath)}{Config.THUMBNAIL_PREFIX}.jpg");
|
||||
bitmap.Save(thumbnailName, ImageFormat.Jpeg);
|
||||
|
||||
return new ThumbnailDto
|
||||
{
|
||||
ThumbnailPath = thumbnailName,
|
||||
ImagePath = imgPath,
|
||||
LabelPath = labelName,
|
||||
ImageDate = File.GetCreationTimeUtc(imgPath)
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public LabelInfo AddToCache(string imgPath, List<int> classes)
|
||||
{
|
||||
var labelInfo = new LabelInfo
|
||||
{
|
||||
Classes = classes,
|
||||
ImageDateTime = File.GetCreationTimeUtc(imgPath)
|
||||
};
|
||||
LabelsCache.TryAdd(Path.GetFileName(imgPath), labelInfo);
|
||||
|
||||
//Save to file only each 2 seconds
|
||||
_ = ThrottleExt.Throttle(async () => await SaveLabelsCache(), TimeSpan.FromSeconds(2));
|
||||
return labelInfo;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IGalleryManager
|
||||
{
|
||||
event ThumbnailsUpdatedEventHandler ThumbnailsUpdate;
|
||||
double ThumbnailsPercentage { get; set; }
|
||||
Task SaveLabelsCache();
|
||||
LabelInfo AddToCache(string imgPath, List<int> classes);
|
||||
ConcurrentDictionary<string, LabelInfo> LabelsCache { get; set; }
|
||||
Task<ThumbnailDto?> CreateThumbnail(string imgPath, CancellationToken cancellationToken = default);
|
||||
Task RefreshThumbnails();
|
||||
void ClearThumbnails();
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
using System.Windows;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Common.DTO.Config;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public partial class HelpWindow : Window
|
||||
{
|
||||
private readonly Config _config;
|
||||
private readonly WindowConfig _windowConfig;
|
||||
|
||||
public HelpWindow(Config config)
|
||||
public HelpWindow(WindowConfig windowConfig)
|
||||
{
|
||||
_config = config;
|
||||
Loaded += (_, _) => CbShowHelp.IsChecked = _config.ShowHelpOnStart;
|
||||
_windowConfig = windowConfig;
|
||||
Loaded += (_, _) => CbShowHelp.IsChecked = windowConfig.ShowHelpOnStart;
|
||||
InitializeComponent();
|
||||
}
|
||||
private void Close(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void CbShowHelp_OnChecked(object sender, RoutedEventArgs e) => _config.ShowHelpOnStart = true;
|
||||
private void CbShowHelp_OnUnchecked(object sender, RoutedEventArgs e) => _config.ShowHelpOnStart = false;
|
||||
private void CbShowHelp_OnChecked(object sender, RoutedEventArgs e) => _windowConfig.ShowHelpOnStart = true;
|
||||
private void CbShowHelp_OnUnchecked(object sender, RoutedEventArgs e) => _windowConfig.ShowHelpOnStart = false;
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Azaion.Annotator.DTO;
|
||||
using LibVLCSharp.Shared;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaPlayer = LibVLCSharp.Shared.MediaPlayer;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
public class MainWindowEventHandler :
|
||||
INotificationHandler<KeyEvent>,
|
||||
INotificationHandler<AnnClassSelectedEvent>,
|
||||
INotificationHandler<PlaybackControlEvent>,
|
||||
INotificationHandler<VolumeChangedEvent>
|
||||
{
|
||||
private readonly LibVLC _libVLC;
|
||||
private readonly MediaPlayer _mediaPlayer;
|
||||
private readonly MainWindow _mainWindow;
|
||||
private readonly FormState _formState;
|
||||
private readonly Config _config;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IGalleryManager _galleryManager;
|
||||
private readonly DatasetExplorer _datasetExplorer;
|
||||
private readonly ILogger<MainWindowEventHandler> _logger;
|
||||
|
||||
private const int STEP = 20;
|
||||
private const int LARGE_STEP = 5000;
|
||||
private const int RESULT_WIDTH = 1280;
|
||||
|
||||
private readonly Dictionary<Key, PlaybackControlEnum> _keysControlEnumDict = new()
|
||||
{
|
||||
{ Key.Space, PlaybackControlEnum.Pause },
|
||||
{ Key.Left, PlaybackControlEnum.PreviousFrame },
|
||||
{ Key.Right, PlaybackControlEnum.NextFrame },
|
||||
{ Key.Enter, PlaybackControlEnum.SaveAnnotations },
|
||||
{ Key.Delete, PlaybackControlEnum.RemoveSelectedAnns },
|
||||
{ Key.X, PlaybackControlEnum.RemoveAllAnns },
|
||||
{ Key.PageUp, PlaybackControlEnum.Previous },
|
||||
{ Key.PageDown, PlaybackControlEnum.Next },
|
||||
};
|
||||
|
||||
public MainWindowEventHandler(LibVLC libVLC,
|
||||
MediaPlayer mediaPlayer,
|
||||
MainWindow mainWindow,
|
||||
FormState formState,
|
||||
Config config,
|
||||
IMediator mediator,
|
||||
IGalleryManager galleryManager,
|
||||
DatasetExplorer datasetExplorer,
|
||||
ILogger<MainWindowEventHandler> logger)
|
||||
{
|
||||
_libVLC = libVLC;
|
||||
_mediaPlayer = mediaPlayer;
|
||||
_mainWindow = mainWindow;
|
||||
_formState = formState;
|
||||
_config = config;
|
||||
_mediator = mediator;
|
||||
_galleryManager = galleryManager;
|
||||
_datasetExplorer = datasetExplorer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(AnnClassSelectedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
SelectClass(notification.AnnotationClass);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
private void SelectClass(AnnotationClass annClass)
|
||||
{
|
||||
_mainWindow.Editor.CurrentAnnClass = annClass;
|
||||
foreach (var ann in _mainWindow.Editor.CurrentAnns.Where(x => x.IsSelected))
|
||||
ann.AnnotationClass = annClass;
|
||||
_mainWindow.LvClasses.SelectedIndex = annClass.Id;
|
||||
}
|
||||
|
||||
public async Task Handle(KeyEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_formState.ActiveWindow != WindowsEnum.Main)
|
||||
return;
|
||||
|
||||
var key = notification.Args.Key;
|
||||
var keyNumber = (int?)null;
|
||||
|
||||
if ((int)key >= (int)Key.D1 && (int)key <= (int)Key.D9)
|
||||
keyNumber = key - Key.D1;
|
||||
if ((int)key >= (int)Key.NumPad1 && (int)key <= (int)Key.NumPad9)
|
||||
keyNumber = key - Key.NumPad1;
|
||||
if (keyNumber.HasValue)
|
||||
SelectClass((AnnotationClass)_mainWindow.LvClasses.Items[keyNumber.Value]);
|
||||
|
||||
if (_keysControlEnumDict.TryGetValue(key, out var value))
|
||||
await ControlPlayback(value);
|
||||
|
||||
if (key == Key.A)
|
||||
_mainWindow.AutoDetect(null!, null!);
|
||||
|
||||
await VolumeControl(key);
|
||||
}
|
||||
|
||||
private async Task VolumeControl(Key key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case Key.VolumeMute when _mediaPlayer.Volume == 0:
|
||||
await ControlPlayback(PlaybackControlEnum.TurnOnVolume);
|
||||
break;
|
||||
case Key.VolumeMute:
|
||||
await ControlPlayback(PlaybackControlEnum.TurnOffVolume);
|
||||
break;
|
||||
case Key.Up:
|
||||
case Key.VolumeUp:
|
||||
var vUp = Math.Min(100, _mediaPlayer.Volume + 5);
|
||||
ChangeVolume(vUp);
|
||||
_mainWindow.Volume.Value = vUp;
|
||||
break;
|
||||
case Key.Down:
|
||||
case Key.VolumeDown:
|
||||
var vDown = Math.Max(0, _mediaPlayer.Volume - 5);
|
||||
ChangeVolume(vDown);
|
||||
_mainWindow.Volume.Value = vDown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Handle(PlaybackControlEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await ControlPlayback(notification.PlaybackControl);
|
||||
_mainWindow.VideoView.Focus();
|
||||
}
|
||||
|
||||
private async Task ControlPlayback(PlaybackControlEnum controlEnum)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isCtrlPressed = Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl);
|
||||
var step = isCtrlPressed ? LARGE_STEP : STEP;
|
||||
|
||||
switch (controlEnum)
|
||||
{
|
||||
case PlaybackControlEnum.Play:
|
||||
Play();
|
||||
break;
|
||||
case PlaybackControlEnum.Pause:
|
||||
_mediaPlayer.Pause();
|
||||
if (!_mediaPlayer.IsPlaying)
|
||||
_mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.AnnotationHelp]);
|
||||
if (_formState.BackgroundTime.HasValue)
|
||||
{
|
||||
_mainWindow.Editor.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
_formState.BackgroundTime = null;
|
||||
}
|
||||
break;
|
||||
case PlaybackControlEnum.Stop:
|
||||
_mediaPlayer.Stop();
|
||||
break;
|
||||
case PlaybackControlEnum.PreviousFrame:
|
||||
_mainWindow.SeekTo(_mediaPlayer.Time - step);
|
||||
break;
|
||||
case PlaybackControlEnum.NextFrame:
|
||||
_mainWindow.SeekTo(_mediaPlayer.Time + step);
|
||||
break;
|
||||
case PlaybackControlEnum.SaveAnnotations:
|
||||
await SaveAnnotations();
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveSelectedAnns:
|
||||
|
||||
_mainWindow.Editor.RemoveSelectedAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.RemoveAllAnns:
|
||||
_mainWindow.Editor.RemoveAllAnns();
|
||||
break;
|
||||
case PlaybackControlEnum.TurnOnVolume:
|
||||
_mainWindow.TurnOnVolumeBtn.Visibility = Visibility.Collapsed;
|
||||
_mainWindow.TurnOffVolumeBtn.Visibility = Visibility.Visible;
|
||||
_mediaPlayer.Volume = _formState.CurrentVolume;
|
||||
break;
|
||||
case PlaybackControlEnum.TurnOffVolume:
|
||||
_mainWindow.TurnOffVolumeBtn.Visibility = Visibility.Collapsed;
|
||||
_mainWindow.TurnOnVolumeBtn.Visibility = Visibility.Visible;
|
||||
_formState.CurrentVolume = _mediaPlayer.Volume;
|
||||
_mediaPlayer.Volume = 0;
|
||||
break;
|
||||
case PlaybackControlEnum.Previous:
|
||||
NextMedia(isPrevious: true);
|
||||
break;
|
||||
case PlaybackControlEnum.Next:
|
||||
NextMedia();
|
||||
break;
|
||||
case PlaybackControlEnum.None:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(controlEnum), controlEnum, null);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void NextMedia(bool isPrevious = false)
|
||||
{
|
||||
var increment = isPrevious ? -1 : 1;
|
||||
var check = isPrevious ? -1 : _mainWindow.LvFiles.Items.Count;
|
||||
if (_mainWindow.LvFiles.SelectedIndex + increment == check)
|
||||
return;
|
||||
|
||||
_mainWindow.LvFiles.SelectedIndex += increment;
|
||||
Play();
|
||||
}
|
||||
|
||||
public async Task Handle(VolumeChangedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
ChangeVolume(notification.Volume);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ChangeVolume(int volume)
|
||||
{
|
||||
_formState.CurrentVolume = volume;
|
||||
_mediaPlayer.Volume = volume;
|
||||
}
|
||||
|
||||
private void Play()
|
||||
{
|
||||
if (_mainWindow.LvFiles.SelectedItem == null)
|
||||
return;
|
||||
var mediaInfo = (MediaFileInfo)_mainWindow.LvFiles.SelectedItem;
|
||||
|
||||
_formState.CurrentMedia = mediaInfo;
|
||||
_mediaPlayer.Stop();
|
||||
_mainWindow.Title = $"Azaion Annotator - {mediaInfo.Name}";
|
||||
_mainWindow.BlinkHelp(HelpTexts.HelpTextsDict[HelpTextEnum.PauseForAnnotations]);
|
||||
_mediaPlayer.Play(new Media(_libVLC, mediaInfo.Path));
|
||||
}
|
||||
|
||||
private async Task SaveAnnotations()
|
||||
{
|
||||
var annGridSelectedIndex = _mainWindow.DgAnnotations.SelectedIndex;
|
||||
|
||||
if (_formState.CurrentMedia == null)
|
||||
return;
|
||||
|
||||
var time = _formState.BackgroundTime ?? TimeSpan.FromMilliseconds(_mediaPlayer.Time);
|
||||
var fName = _formState.GetTimeName(time);
|
||||
|
||||
var currentAnns = _mainWindow.Editor.CurrentAnns
|
||||
.Select(x => new YoloLabel(x.Info, _mainWindow.Editor.RenderSize, _formState.BackgroundTime.HasValue ? _mainWindow.Editor.RenderSize : _formState.CurrentVideoSize))
|
||||
.ToList();
|
||||
|
||||
await YoloLabel.WriteToFile(currentAnns, Path.Combine(_config.LabelsDirectory, $"{fName}.txt"));
|
||||
await _mainWindow.AddAnnotations(time, currentAnns);
|
||||
|
||||
_formState.CurrentMedia.HasAnnotations = _mainWindow.Annotations.Count != 0;
|
||||
_mainWindow.LvFiles.Items.Refresh();
|
||||
|
||||
var isVideo = _formState.CurrentMedia.MediaType == MediaTypes.Video;
|
||||
var destinationPath = Path.Combine(_config.ImagesDirectory, $"{fName}{(isVideo ? ".jpg" : Path.GetExtension(_formState.CurrentMedia.Path))}");
|
||||
|
||||
_mainWindow.Editor.RemoveAllAnns();
|
||||
if (isVideo)
|
||||
{
|
||||
if (_formState.BackgroundTime.HasValue)
|
||||
{
|
||||
//no need to save image, it's already there, just remove background
|
||||
_mainWindow.Editor.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
||||
_formState.BackgroundTime = null;
|
||||
|
||||
//next item
|
||||
var annGrid = _mainWindow.DgAnnotations;
|
||||
annGrid.SelectedIndex = Math.Min(annGrid.Items.Count, annGridSelectedIndex + 1);
|
||||
_mainWindow.OpenAnnotationResult((AnnotationResult)annGrid.SelectedItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
var resultHeight = (uint)Math.Round(RESULT_WIDTH / _formState.CurrentVideoSize.Width * _formState.CurrentVideoSize.Height);
|
||||
_mediaPlayer.TakeSnapshot(0, destinationPath, RESULT_WIDTH, resultHeight);
|
||||
_mediaPlayer.Play();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Copy(_formState.CurrentMedia.Path, destinationPath, overwrite: true);
|
||||
NextMedia();
|
||||
}
|
||||
|
||||
var thumbnailDto = await _galleryManager.CreateThumbnail(destinationPath);
|
||||
if (thumbnailDto != null)
|
||||
_datasetExplorer.AddThumbnail(thumbnailDto, currentAnns.Select(x => x.ClassNumber));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.IO;
|
||||
using Azaion.Annotator.DTO;
|
||||
using Azaion.Annotator.Extensions;
|
||||
using Azaion.Common.DTO;
|
||||
using Compunet.YoloV8;
|
||||
using Compunet.YoloV8.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Detection = Azaion.Annotator.DTO.Detection;
|
||||
using Detection = Azaion.Common.DTO.Detection;
|
||||
|
||||
namespace Azaion.Annotator;
|
||||
|
||||
@@ -14,9 +15,9 @@ public interface IAIDetector
|
||||
List<Detection> Detect(Stream stream);
|
||||
}
|
||||
|
||||
public class YOLODetector(Config config) : IAIDetector, IDisposable
|
||||
public class YOLODetector(AIRecognitionConfig recognitionConfig) : IAIDetector, IDisposable
|
||||
{
|
||||
private readonly YoloPredictor _predictor = new(config.AIRecognitionConfig.AIModelPath);
|
||||
private readonly YoloPredictor _predictor = new(recognitionConfig.AIModelPath);
|
||||
|
||||
public List<Detection> Detect(Stream stream)
|
||||
{
|
||||
@@ -37,7 +38,7 @@ public class YOLODetector(Config config) : IAIDetector, IDisposable
|
||||
|
||||
private List<Detection> FilterOverlapping(List<Detection> detections)
|
||||
{
|
||||
var k = config.AIRecognitionConfig.TrackingIntersectionThreshold;
|
||||
var k = recognitionConfig.TrackingIntersectionThreshold;
|
||||
var filteredDetections = new List<Detection>();
|
||||
for (var i = 0; i < detections.Count; i++)
|
||||
{
|
||||
@@ -48,21 +49,21 @@ public class YOLODetector(Config config) : IAIDetector, IDisposable
|
||||
intersect.Intersect(detections[j].ToRectangle());
|
||||
|
||||
var maxArea = Math.Max(detections[i].ToRectangle().Area(), detections[j].ToRectangle().Area());
|
||||
if (intersect.Area() > k * maxArea)
|
||||
if (!(intersect.Area() > k * maxArea))
|
||||
continue;
|
||||
|
||||
if (detections[i].Probability > detections[j].Probability)
|
||||
{
|
||||
if (detections[i].Probability > detections[j].Probability)
|
||||
{
|
||||
filteredDetections.Add(detections[i]);
|
||||
detections.RemoveAt(j);
|
||||
}
|
||||
else
|
||||
{
|
||||
filteredDetections.Add(detections[j]);
|
||||
detections.RemoveAt(i);
|
||||
}
|
||||
detectionSelected = true;
|
||||
break;
|
||||
filteredDetections.Add(detections[i]);
|
||||
detections.RemoveAt(j);
|
||||
}
|
||||
else
|
||||
{
|
||||
filteredDetections.Add(detections[j]);
|
||||
detections.RemoveAt(i);
|
||||
}
|
||||
detectionSelected = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!detectionSelected)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Reference in New Issue
Block a user