From 3dc461e5df9ca1a60256cf338065c54ff31528df Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Tue, 14 May 2024 22:12:11 +0300 Subject: [PATCH] add Annotator --- .gitignore | 6 + .../Azaion.Annotator.Test.csproj | 18 + .../Azaion.Annotator.Test/DictTest.cs | 20 ++ Azaion.Annotator/Azaion.Annotator.sln | 22 ++ Azaion.Annotator/Azaion.Annotator/App.xaml | 7 + Azaion.Annotator/Azaion.Annotator/App.xaml.cs | 46 +++ .../Azaion.Annotator/AssemblyInfo.cs | 10 + .../Azaion.Annotator/Azaion.Annotator.csproj | 22 ++ .../Controls/AnnotationControl.cs | 107 ++++++ .../Azaion.Annotator/Controls/CanvasEditor.cs | 319 ++++++++++++++++++ .../Controls/UpdatableProgressBar.cs | 37 ++ .../DTO/AnnClassSelectedEvent.cs | 8 + .../Azaion.Annotator/DTO/AnnotationClass.cs | 15 + .../Azaion.Annotator/DTO/AnnotationInfo.cs | 119 +++++++ .../Azaion.Annotator/DTO/Config.cs | 58 ++++ .../Azaion.Annotator/DTO/FormState.cs | 14 + .../Azaion.Annotator/DTO/KeyEvent.cs | 10 + .../Azaion.Annotator/DTO/SelectionState.cs | 9 + .../Azaion.Annotator/DTO/VideoFileInfo.cs | 8 + .../Extensions/CanvasExtensions.cs | 38 +++ .../Extensions/ColorExtensions.cs | 32 ++ .../Extensions/DataGridExtensions.cs | 52 +++ .../Extensions/DirectoryInfoExtensions.cs | 9 + .../Extensions/SynchronizeInvokeExtensions.cs | 14 + .../Azaion.Annotator/MainWindow.xaml | 216 ++++++++++++ .../Azaion.Annotator/MainWindow.xaml.cs | 224 ++++++++++++ .../Azaion.Annotator/PlayerControlHandler.cs | 95 ++++++ Azaion.Annotator/Azaion.Annotator/config.json | 69 ++++ .../Azaion.Annotator/translations.json | 16 + 29 files changed, 1620 insertions(+) create mode 100644 .gitignore create mode 100644 Azaion.Annotator/Azaion.Annotator.Test/Azaion.Annotator.Test.csproj create mode 100644 Azaion.Annotator/Azaion.Annotator.Test/DictTest.cs create mode 100644 Azaion.Annotator/Azaion.Annotator.sln create mode 100644 Azaion.Annotator/Azaion.Annotator/App.xaml create mode 100644 Azaion.Annotator/Azaion.Annotator/App.xaml.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/AssemblyInfo.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Azaion.Annotator.csproj create mode 100644 Azaion.Annotator/Azaion.Annotator/Controls/AnnotationControl.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Controls/CanvasEditor.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Controls/UpdatableProgressBar.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/AnnClassSelectedEvent.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/AnnotationClass.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/AnnotationInfo.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/Config.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/FormState.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/KeyEvent.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/SelectionState.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/DTO/VideoFileInfo.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Extensions/CanvasExtensions.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Extensions/ColorExtensions.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Extensions/DataGridExtensions.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Extensions/DirectoryInfoExtensions.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/Extensions/SynchronizeInvokeExtensions.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/MainWindow.xaml create mode 100644 Azaion.Annotator/Azaion.Annotator/MainWindow.xaml.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/PlayerControlHandler.cs create mode 100644 Azaion.Annotator/Azaion.Annotator/config.json create mode 100644 Azaion.Annotator/Azaion.Annotator/translations.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d55939e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +bin +obj +.vs +*.DotSettings* +*.user \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator.Test/Azaion.Annotator.Test.csproj b/Azaion.Annotator/Azaion.Annotator.Test/Azaion.Annotator.Test.csproj new file mode 100644 index 0000000..ac0cca5 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator.Test/Azaion.Annotator.Test.csproj @@ -0,0 +1,18 @@ + + + + enable + enable + net8.0-windows + + + + + + + + + + + + diff --git a/Azaion.Annotator/Azaion.Annotator.Test/DictTest.cs b/Azaion.Annotator/Azaion.Annotator.Test/DictTest.cs new file mode 100644 index 0000000..02c4023 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator.Test/DictTest.cs @@ -0,0 +1,20 @@ +using Azaion.Annotator.DTO; +using Xunit; + +namespace Azaion.Annotator.Test; + +public class DictTest +{ + public Dictionary> Annotations = new(); + + [Fact] + public void DictAddTest() + { + Annotations["sd"] = [new AnnotationInfo(1, 2, 2, 2, 2)]; + Annotations["sd"] = + [ + new AnnotationInfo(1, 2, 2, 2, 2), + new AnnotationInfo(0, 1, 3, 2, 1) + ]; + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator.sln b/Azaion.Annotator/Azaion.Annotator.sln new file mode 100644 index 0000000..e62740f --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Annotator", "Azaion.Annotator\Azaion.Annotator.csproj", "{8E0809AF-2920-4267-B14D-84BAB334A46F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Annotator.Test", "Azaion.Annotator.Test\Azaion.Annotator.Test.csproj", "{85359558-FB59-4542-A597-FD9E1B04C8E7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8E0809AF-2920-4267-B14D-84BAB334A46F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E0809AF-2920-4267-B14D-84BAB334A46F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E0809AF-2920-4267-B14D-84BAB334A46F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E0809AF-2920-4267-B14D-84BAB334A46F}.Release|Any CPU.Build.0 = Release|Any CPU + {85359558-FB59-4542-A597-FD9E1B04C8E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85359558-FB59-4542-A597-FD9E1B04C8E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85359558-FB59-4542-A597-FD9E1B04C8E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85359558-FB59-4542-A597-FD9E1B04C8E7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Azaion.Annotator/Azaion.Annotator/App.xaml b/Azaion.Annotator/Azaion.Annotator/App.xaml new file mode 100644 index 0000000..7e22954 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/App.xaml @@ -0,0 +1,7 @@ + + + + + diff --git a/Azaion.Annotator/Azaion.Annotator/App.xaml.cs b/Azaion.Annotator/Azaion.Annotator/App.xaml.cs new file mode 100644 index 0000000..73a228b --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/App.xaml.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using System.Windows; +using System.Windows.Input; +using Azaion.Annotator.DTO; +using LibVLCSharp.Shared; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Azaion.Annotator; + +public partial class App : Application +{ + private readonly ServiceProvider _serviceProvider; + private IMediator _mediator; + + public App() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddMediatR(c => c.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + services.AddSingleton(_ => new LibVLC()); + services.AddSingleton(); + services.AddSingleton(sp => + { + var libVLC = sp.GetRequiredService(); + return new MediaPlayer(libVLC); + }); + services.AddSingleton(_ => Config.Read()); + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); + _mediator = _serviceProvider.GetService()!; + } + + protected override void OnStartup(StartupEventArgs e) + { + EventManager.RegisterClassHandler(typeof(UIElement), UIElement.KeyDownEvent, new RoutedEventHandler(GlobalClick)); + _serviceProvider.GetRequiredService().Show(); + } + + private void GlobalClick(object sender, RoutedEventArgs e) + { + var args = (KeyEventArgs)e; + + _mediator.Publish(new KeyEvent(sender, args)); + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/AssemblyInfo.cs b/Azaion.Annotator/Azaion.Annotator/AssemblyInfo.cs new file mode 100644 index 0000000..4a05c7d --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Azaion.Annotator.csproj b/Azaion.Annotator/Azaion.Annotator/Azaion.Annotator.csproj new file mode 100644 index 0000000..858bf21 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Azaion.Annotator.csproj @@ -0,0 +1,22 @@ + + + + WinExe + enable + enable + true + net8.0-windows + + + + + + + + + + + + + + diff --git a/Azaion.Annotator/Azaion.Annotator/Controls/AnnotationControl.cs b/Azaion.Annotator/Azaion.Annotator/Controls/AnnotationControl.cs new file mode 100644 index 0000000..4c15a21 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Controls/AnnotationControl.cs @@ -0,0 +1,107 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using Azaion.Annotator.DTO; + +namespace Azaion.Annotator.Controls; + +public class AnnotationControl : Border +{ + private readonly Action _resizeStart; + private const double RESIZE_RECT_SIZE = 10; + + private readonly Grid _grid; + private readonly TextBlock _classNameLabel; + + private AnnotationClass _annotationClass; + public AnnotationClass AnnotationClass + { + get => _annotationClass; + set + { + _grid.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, Action resizeStart) + { + _resizeStart = resizeStart; + _classNameLabel = new TextBlock + { + Text = annotationClass.Name, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 15, 0, 0), + Cursor = Cursors.Arrow + }; + _selectionFrame = new Rectangle + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + Stroke = new SolidColorBrush(Colors.Black), + StrokeThickness = 3, + 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) + }, + + Background = new SolidColorBrush(annotationClass.Color) + }; + 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.FromRgb(10, 10, 10)), // 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 AnnotationInfo Info => new(AnnotationClass.Id, Canvas.GetLeft(this), Canvas.GetTop(this), Width, Height); +} diff --git a/Azaion.Annotator/Azaion.Annotator/Controls/CanvasEditor.cs b/Azaion.Annotator/Azaion.Annotator/Controls/CanvasEditor.cs new file mode 100644 index 0000000..1ffe950 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Controls/CanvasEditor.cs @@ -0,0 +1,319 @@ +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; + + private readonly Rectangle _newAnnotationRect; + private Point _newAnnotationStartPos; + + private readonly Line _horizontalLine; + private readonly Line _verticalLine; + + private Rectangle _curRec; + private AnnotationControl _curAnn; + + private const int MIN_SIZE = 20; + + public FormState FormState { get; set; } + public IMediator Mediator { get; set; } + + private AnnotationClass _currentAnnClass; + public AnnotationClass CurrentAnnClass + { + get => _currentAnnClass; + set + { + _verticalLine.Stroke = value.ColorBrush; + _verticalLine.Fill = value.ColorBrush; + _horizontalLine.Stroke = value.ColorBrush; + _horizontalLine.Fill = value.ColorBrush; + _newAnnotationRect.Stroke = value.ColorBrush; + _newAnnotationRect.Fill = value.ColorBrush; + _currentAnnClass = value; + } + } + + public readonly List 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 + }; + _newAnnotationRect = new Rectangle + { + Name = "selector", + Height = 0, + Width = 0, + Stroke = new SolidColorBrush(Colors.Gray), + Fill = new SolidColorBrush(Color.FromArgb(128, 128, 128, 128)), + }; + Loaded += Init; + } + + private void Init(object sender, RoutedEventArgs e) + { + KeyDown += (_, args) => + { + Console.WriteLine($"pressed {args.Key}"); + }; + MouseDown += CanvasMouseDown; + MouseMove += CanvasMouseMove; + MouseUp += CanvasMouseUp; + SizeChanged += CanvasResized; + Cursor = Cursors.Cross; + _horizontalLine.X1 = 0; + _horizontalLine.X2 = ActualWidth; + _verticalLine.Y1 = 0; + _verticalLine.Y2 = ActualHeight; + + Children.Add(_newAnnotationRect); + Children.Add(_horizontalLine); + Children.Add(_verticalLine); + } + + 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; + + if (e.LeftButton != MouseButtonState.Pressed) + return; + if (FormState.SelectionState == SelectionState.NewAnnCreating) + NewAnnotationCreatingMove(sender, e); + + if (FormState.SelectionState == SelectionState.AnnResizing) + AnnotationResizeMove(sender, e); + + if (FormState.SelectionState == SelectionState.AnnMoving) + AnnotationPositionMove(sender, e); + } + + private void CanvasMouseUp(object sender, MouseButtonEventArgs e) + { + if (FormState.SelectionState == SelectionState.NewAnnCreating) + CreateAnnotation(e.GetPosition(this)); + + FormState.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) + { + FormState.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 (FormState.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; + + FormState.SelectionState = SelectionState.AnnMoving; + e.Handled = true; + } + + private void AnnotationPositionMove(object sender, MouseEventArgs e) + { + if (FormState.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; + + FormState.SelectionState = SelectionState.NewAnnCreating; + } + + private void NewAnnotationCreatingMove(object sender, MouseEventArgs e) + { + if (FormState.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 annotationControl = new AnnotationControl(CurrentAnnClass, AnnotationResizeStart) + { + Width = width, + Height = height + }; + annotationControl.MouseDown += AnnotationPositionStart; + SetLeft(annotationControl, Math.Min(endPos.X, _newAnnotationStartPos.X)); + SetTop(annotationControl, Math.Min(endPos.Y, _newAnnotationStartPos.Y)); + Children.Add(annotationControl); + CurrentAnns.Add(annotationControl); + _newAnnotationRect.Fill = new SolidColorBrush(CurrentAnnClass.Color); + } + + public AnnotationControl CreateAnnotation(AnnotationClass annClass, AnnotationInfo info) + { + var annotationControl = new AnnotationControl(annClass, AnnotationResizeStart) + { + Width = info.Width, + Height = info.Height + }; + annotationControl.MouseDown += AnnotationPositionStart; + SetLeft(annotationControl, info.X ); + SetTop(annotationControl, info.Y); + Children.Add(annotationControl); + CurrentAnns.Add(annotationControl); + _newAnnotationRect.Fill = new SolidColorBrush(annClass.Color); + return annotationControl; + } + + #endregion + + public void RemoveAnnotations(IEnumerable listToRemove) + { + foreach (var ann in listToRemove) + { + Children.Remove(ann); + CurrentAnns.Remove(ann); + } + } + public void RemoveAllAnns() => RemoveAnnotations(CurrentAnns.ToList()); + public void RemoveSelectedAnns() => RemoveAnnotations(CurrentAnns.Where(x => x.IsSelected).ToList()); + + private void ClearSelections() + { + foreach (var ann in CurrentAnns) + ann.IsSelected = false; + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Controls/UpdatableProgressBar.cs b/Azaion.Annotator/Azaion.Annotator/Controls/UpdatableProgressBar.cs new file mode 100644 index 0000000..d017842 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Controls/UpdatableProgressBar.cs @@ -0,0 +1,37 @@ +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 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); + } + } +} diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/AnnClassSelectedEvent.cs b/Azaion.Annotator/Azaion.Annotator/DTO/AnnClassSelectedEvent.cs new file mode 100644 index 0000000..7f23ca7 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/AnnClassSelectedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Azaion.Annotator.DTO; + +public class AnnClassSelectedEvent(AnnotationClass annotationClass) : INotification +{ + public AnnotationClass AnnotationClass { get; } = annotationClass; +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationClass.cs b/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationClass.cs new file mode 100644 index 0000000..0850e66 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationClass.cs @@ -0,0 +1,15 @@ +using System.Windows.Media; +using Azaion.Annotator.Extensions; + +namespace Azaion.Annotator.DTO; + +public class AnnotationClass(int id, string name = "") +{ + public int Id { get; set; } = id; + + public string Name { get; set; } = name; + public Color Color { get; set; } = id.ToColor(); + + public int ClassNumber => Id + 1; + public SolidColorBrush ColorBrush => new(Color); +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationInfo.cs b/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationInfo.cs new file mode 100644 index 0000000..e253023 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/AnnotationInfo.cs @@ -0,0 +1,119 @@ +using System.Windows; + +namespace Azaion.Annotator.DTO; + +public class AnnotationInfo +{ + public int ClassNumber { get; set; } + public double X { get; set; } + public double Y { get; set; } + public double Width { get; set; } + public double Height { get; set; } + + public AnnotationInfo() { } + public AnnotationInfo(int classNumber, double x, double y, double width, double height) + { + ClassNumber = classNumber; + X = x; + Y = y; + Width = width; + Height = height; + } + + public override string ToString() => $"{ClassNumber} {X:F5} {Y:F5} {Width:F5} {Height:F5}"; + + public AnnotationInfo ToLabelCoordinates(Size canvasSize, Size videoSize) + { + var cw = canvasSize.Width; + var ch = canvasSize.Height; + var canvasAR = cw / ch; + var videoAR = videoSize.Width / videoSize.Height; + + var annInfo = new AnnotationInfo { ClassNumber = this.ClassNumber }; + double left, top; + if (videoAR > canvasAR) //100% width + { + left = X / cw; + annInfo.Width = 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 = (Y - blackStripHeight) / realHeight; + annInfo.Height = Height / realHeight; + } + else //100% height + { + top = Y / ch; + annInfo.Height = 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 = (X - blackStripWidth) / realWidth; + annInfo.Width = Width / realWidth; + } + + annInfo.X = left + annInfo.Width / 2.0; + annInfo.Y = top + annInfo.Height / 2.0; + + return annInfo; + } + + public AnnotationInfo ToCanvasCoordinates(Size canvasSize, Size videoSize) + { + var cw = canvasSize.Width; + var ch = canvasSize.Height; + var canvasAR = cw / ch; + var videoAR = videoSize.Width / videoSize.Height; + + var annInfo = new AnnotationInfo { ClassNumber = this.ClassNumber }; + + double left = annInfo.X - annInfo.Width * 2; + double top = annInfo.Y - annInfo.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 + + annInfo.X = left * cw; + annInfo.Y = top * realHeight + blackStripHeight; + annInfo.Width = Width * cw; + annInfo.Height = 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 + + annInfo.X = left * realWidth + blackStripWidth; + annInfo.Y = top * ch; + annInfo.Width = Width * realWidth; + annInfo.Height = Height * ch; + } + return annInfo; + } + + public static AnnotationInfo? Parse(string? s) + { + if (s == null || string.IsNullOrEmpty(s)) + return null; + + var strs = s.Split(' '); + if (strs.Length != 5) + return null; + + try + { + return new AnnotationInfo + { + ClassNumber = int.Parse(strs[0]), + X = double.Parse(strs[1]), + Y = double.Parse(strs[2]), + Width = double.Parse(strs[3]), + Height = double.Parse(strs[4]) + }; + } + catch (Exception) + { + return null; + } + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/Config.cs b/Azaion.Annotator/Azaion.Annotator/DTO/Config.cs new file mode 100644 index 0000000..ee2d0d7 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/Config.cs @@ -0,0 +1,58 @@ +using System.Drawing; +using System.IO; +using System.Reflection; +using System.Text; +using Newtonsoft.Json; +using Size = System.Windows.Size; +using Point = System.Windows.Point; + +namespace Azaion.Annotator.DTO; + +public class Config +{ + 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 static readonly Size DefaultWindowSize = new(1280, 720); + private static readonly Point DefaultWindowLocation = new(100, 100); + + + public string VideosDirectory { get; set; } = DEFAULT_VIDEO_DIR; + public string LabelsDirectory { get; set; } = DEFAULT_LABELS_DIR; + public string ImagesDirectory { get; set; } = DEFAULT_IMAGES_DIR; + + public List AnnotationClasses { get; set; } = []; + public Size WindowSize { get; set; } + public Point WindowLocation { get; set; } + + public void Save() + { + File.WriteAllText(CONFIG_PATH, JsonConvert.SerializeObject(this, Formatting.Indented), Encoding.UTF8); + } + + public static Config Read() + { + if (!File.Exists(CONFIG_PATH)) + { + var exePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + return new Config + { + VideosDirectory = Path.Combine(exePath, DEFAULT_VIDEO_DIR), + LabelsDirectory = Path.Combine(exePath, DEFAULT_LABELS_DIR), + ImagesDirectory = Path.Combine(exePath, DEFAULT_IMAGES_DIR), + WindowLocation = DefaultWindowLocation, + WindowSize = DefaultWindowSize + }; + } + try + { + var str = File.ReadAllText(CONFIG_PATH); + return JsonConvert.DeserializeObject(str) ?? new Config(); + } + catch (Exception) + { + return new Config(); + } + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/FormState.cs b/Azaion.Annotator/Azaion.Annotator/DTO/FormState.cs new file mode 100644 index 0000000..a754936 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/FormState.cs @@ -0,0 +1,14 @@ +using System.IO; +using System.Windows; + +namespace Azaion.Annotator.DTO; + +public class FormState +{ + public SelectionState SelectionState { get; set; } = SelectionState.None; + public string CurrentFile { get; set; } = null!; + public Size CurrentVideoSize { get; set; } + + public string VideoName => Path.GetFileNameWithoutExtension(CurrentFile).Replace(" ", ""); + public string GetTimeName(TimeSpan ts) => $"{VideoName}_{ts:hmmssf}"; +} diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/KeyEvent.cs b/Azaion.Annotator/Azaion.Annotator/DTO/KeyEvent.cs new file mode 100644 index 0000000..54dfad1 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/KeyEvent.cs @@ -0,0 +1,10 @@ +using System.Windows.Input; +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; +} diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/SelectionState.cs b/Azaion.Annotator/Azaion.Annotator/DTO/SelectionState.cs new file mode 100644 index 0000000..a892f27 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/SelectionState.cs @@ -0,0 +1,9 @@ +namespace Azaion.Annotator.DTO; + +public enum SelectionState +{ + None = 0, + NewAnnCreating = 1, + AnnResizing = 2, + AnnMoving = 3 +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/DTO/VideoFileInfo.cs b/Azaion.Annotator/Azaion.Annotator/DTO/VideoFileInfo.cs new file mode 100644 index 0000000..da1f934 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/DTO/VideoFileInfo.cs @@ -0,0 +1,8 @@ +namespace Azaion.Annotator.DTO; + +public class VideoFileInfo +{ + public string Name { get; set; } = null!; + public string Path { get; set; } = null!; + public TimeSpan Duration { get; set; } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Extensions/CanvasExtensions.cs b/Azaion.Annotator/Azaion.Annotator/Extensions/CanvasExtensions.cs new file mode 100644 index 0000000..aae2eea --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Extensions/CanvasExtensions.cs @@ -0,0 +1,38 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace Azaion.Annotator.Extensions; + +public static class CanvasExtensions +{ + public static readonly DependencyProperty PercentPositionProperty = + DependencyProperty.RegisterAttached("PercentPosition", typeof(Point), typeof(CanvasExtensions), + new PropertyMetadata(new Point(0, 0), OnPercentPositionChanged)); + + public static readonly DependencyProperty PercentSizeProperty = + DependencyProperty.RegisterAttached("PercentSize", typeof(Point), typeof(CanvasExtensions), + new PropertyMetadata(new Point(0, 0), OnPercentSizeChanged)); + + private static void OnPercentSizeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + + } + + public static void SetPercentPosition(DependencyObject obj, Point value) => obj.SetValue(PercentPositionProperty, value); + + private static void OnPercentPositionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (!(d is FrameworkElement element)) return; + if (!(VisualTreeHelper.GetParent(element) is Canvas canvas)) return; + + canvas.SizeChanged += (s, arg) => + { + var percentPosition = (Point)element.GetValue(PercentPositionProperty); + var xPosition = percentPosition.X * canvas.ActualWidth - element.ActualWidth / 2; + var yPosition = percentPosition.Y * canvas.ActualHeight - element.ActualHeight / 2; + Canvas.SetLeft(element, xPosition); + Canvas.SetTop(element, yPosition); + }; + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Extensions/ColorExtensions.cs b/Azaion.Annotator/Azaion.Annotator/Extensions/ColorExtensions.cs new file mode 100644 index 0000000..e0dcafe --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Extensions/ColorExtensions.cs @@ -0,0 +1,32 @@ +using System.Windows.Media; + +namespace Azaion.Annotator.Extensions; + +public static class ColorExtensions +{ + public static Color ToColor(this int id) + { + var index = id % ColorValues.Length; + return ToColor($"#{ColorValues[index]}"); + } + + public static Color ToColor(string hex) + { + var color = (Color)ColorConverter.ConvertFromString(hex); + color.A = 128; + 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" + ]; +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Extensions/DataGridExtensions.cs b/Azaion.Annotator/Azaion.Annotator/Extensions/DataGridExtensions.cs new file mode 100644 index 0000000..4eb6e73 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Extensions/DataGridExtensions.cs @@ -0,0 +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(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 FindVisualChildren(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(child)) + { + yield return childOfChild; + } + } + } + + public static TChildItem? FindVisualChild(DependencyObject? obj) where TChildItem : DependencyObject => + FindVisualChildren(obj).FirstOrDefault(); +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Extensions/DirectoryInfoExtensions.cs b/Azaion.Annotator/Azaion.Annotator/Extensions/DirectoryInfoExtensions.cs new file mode 100644 index 0000000..f8b7b86 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Extensions/DirectoryInfoExtensions.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace Azaion.Annotator.Extensions; + +public static class DirectoryInfoExtensions +{ + public static IEnumerable GetFiles(this DirectoryInfo dir, params string[] searchExtensions) => + dir.GetFiles().Where(f => searchExtensions.Any(s => f.Name.Contains(s, StringComparison.CurrentCultureIgnoreCase))).ToList(); +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/Extensions/SynchronizeInvokeExtensions.cs b/Azaion.Annotator/Azaion.Annotator/Extensions/SynchronizeInvokeExtensions.cs new file mode 100644 index 0000000..a9937c8 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/Extensions/SynchronizeInvokeExtensions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace Azaion.Annotator; + +public static class SynchronizeInvokeExtensions +{ + public static void InvokeEx(this T t, Action action) where T : ISynchronizeInvoke + { + if (t.InvokeRequired) + t.Invoke(action, [t]); + else + action(t); + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml b/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml new file mode 100644 index 0000000..262564a --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml.cs b/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml.cs new file mode 100644 index 0000000..d96079c --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/MainWindow.xaml.cs @@ -0,0 +1,224 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Windows; +using Azaion.Annotator.DTO; +using Azaion.Annotator.Extensions; +using LibVLCSharp.Shared; +using MediatR; +using Microsoft.WindowsAPICodePack.Dialogs; + +namespace Azaion.Annotator; + +public partial class MainWindow +{ + private readonly LibVLC _libVLC; + private readonly MediaPlayer _mediaPlayer; + private readonly IMediator _mediator; + private readonly FormState _formState; + + private readonly Config _config; + private readonly TimeSpan _annotationTime = TimeSpan.FromSeconds(1); + + public ObservableCollection AnnotationClasses { get; set; } + private bool _suspendLayout; + + public Dictionary> Annotations { get; set; } = new(); + + public string CurrentHelp { get; set; } + + public MainWindow(LibVLC libVLC, MediaPlayer mediaPlayer, + IMediator mediator, + FormState formState, + Config config) + { + InitializeComponent(); + _libVLC = libVLC; + _mediaPlayer = mediaPlayer; + _mediator = mediator; + _formState = formState; + _config = config; + + VideoView.Loaded += VideoView_Loaded; + Closed += OnFormClosed; + } + + private void VideoView_Loaded(object sender, RoutedEventArgs e) + { + Core.Initialize(); + InitControls(); + + _suspendLayout = true; + Left = _config.WindowLocation.X; + Top = _config.WindowLocation.Y; + Width = _config.WindowSize.Width; + Height = _config.WindowSize.Height; + _suspendLayout = false; + + ReloadFiles(); + if (_config.AnnotationClasses.Count == 0) + _config.AnnotationClasses.Add(new AnnotationClass(0)); + + AnnotationClasses = new ObservableCollection(_config.AnnotationClasses); + LvClasses.ItemsSource = AnnotationClasses; + LvClasses.SelectedIndex = 0; + } + + private void InitControls() + { + VideoView.MediaPlayer = _mediaPlayer; + + _mediaPlayer.Playing += (sender, args) => + { + uint vw = 0, vh = 0; + _mediaPlayer.Size(0, ref vw, ref vh); + _formState.CurrentVideoSize = new Size(vw, vh); + }; + + LvFiles.MouseDoubleClick += async (_, _) => + { + Play((VideoFileInfo)LvFiles.SelectedItem); + }; + + LvClasses.SelectionChanged += (_, _) => + { + var selectedClass = (AnnotationClass)LvClasses.SelectedItem; + Editor.CurrentAnnClass = selectedClass; + _mediator.Publish(new AnnClassSelectedEvent(selectedClass)); + }; + + _mediaPlayer.PositionChanged += (o, args) => + { + Dispatcher.Invoke(() => videoSlider.Value = _mediaPlayer.Position * videoSlider.Maximum); + var curTime = _formState.GetTimeName(TimeSpan.FromMilliseconds(_mediaPlayer.Time)); + if (!Annotations.TryGetValue(curTime, out var annotationInfos)) + return; + + var annotations = annotationInfos.Select(info => + { + var annClass = _config.AnnotationClasses[info.ClassNumber]; + var annInfo = info.ToCanvasCoordinates(Editor.RenderSize, _formState.CurrentVideoSize); + return Editor.CreateAnnotation(annClass, annInfo); + }).ToList(); + + //remove annotations: either in 1 sec, either earlier if there is next annotation in a dictionary + var strs = curTime.Split("_"); + var timeStr = strs.LastOrDefault(); + var ts = TimeSpan.ParseExact(timeStr, "hmmssf", CultureInfo.InvariantCulture); + var timeSpanRemove = Enumerable.Range(0, (int)_annotationTime.TotalMilliseconds / 100) + .Select(x => + { + var time = TimeSpan.FromMilliseconds(x * 100); + var fName = _formState.GetTimeName(ts.Add(time)); + return Annotations.ContainsKey(fName) ? time : (TimeSpan?)null; + }).FirstOrDefault(x => x != null) ?? _annotationTime; + + _ = Task.Run(async () => + { + await Task.Delay(timeSpanRemove); + Dispatcher.Invoke(() => Editor.RemoveAnnotations(annotations)); + }); + }; + + videoSlider.ValueChanged += (value, newValue) => + _mediaPlayer.Position = (float)(newValue / videoSlider.Maximum); + + videoSlider.KeyDown += (sender, args) => _mediator.Publish(new KeyEvent(sender, args)); + + SizeChanged += (sender, args) => + { + if (!_suspendLayout) + _config.WindowSize = args.NewSize; + }; + LocationChanged += (_, _) => + { + if (!_suspendLayout) + _config.WindowLocation = new Point(Left, Top); + }; + + Editor.FormState = _formState; + Editor.Mediator = _mediator; + } + + private void Play(VideoFileInfo videoFileInfo) + { + if (LvFiles.SelectedItem == null) + return; + var fileInfo = (VideoFileInfo)LvFiles.SelectedItem; + + _formState.CurrentFile = fileInfo.Name; + LoadExistingAnnotations(); + + _mediaPlayer.Stop(); + _mediaPlayer.Play(new Media(_libVLC, fileInfo.Path)); + } + + private void LoadExistingAnnotations() + { + var dirInfo = new DirectoryInfo(_config.LabelsDirectory); + if (!dirInfo.Exists) + return; + + var files = dirInfo.GetFiles($"{_formState.VideoName}_*"); + Annotations = files.ToDictionary(f => Path.GetFileNameWithoutExtension(f.Name), f => + { + var str = File.ReadAllText(f.FullName); + return str.Split(Environment.NewLine).Select(AnnotationInfo.Parse).ToList(); + })!; + } + + private void ReloadFiles() + { + var dir = new DirectoryInfo(_config.VideosDirectory); + if (!dir.Exists) + return; + + var files = dir.GetFiles("mp4", "mov").Select(x => + { + _mediaPlayer.Media = new Media(_libVLC, x.FullName); + return new VideoFileInfo + { + Name = x.Name, + Path = x.FullName, + Duration = TimeSpan.FromMilliseconds(_mediaPlayer.Media.Duration) + }; + }); + + LvFiles.ItemsSource = new ObservableCollection(files); + TbFolder.Text = _config.VideosDirectory; + } + + private void OnFormClosed(object? sender, EventArgs e) + { + _mediaPlayer.Stop(); + _mediaPlayer.Dispose(); + _libVLC.Dispose(); + _config.AnnotationClasses = AnnotationClasses.ToList(); + _config.Save(); + } + + // private void AddClassBtnClick(object sender, RoutedEventArgs e) + // { + // LvClasses.IsReadOnly = false; + // AnnotationClasses.Add(new AnnotationClass(AnnotationClasses.Count)); + // LvClasses.SelectedIndex = AnnotationClasses.Count - 1; + // } + private void MenuItem_OnClick(object sender, RoutedEventArgs e) => OpenFolder(); + private void OpenFolderButtonClick(object sender, RoutedEventArgs e) => OpenFolder(); + private void OpenFolder() + { + var dlg = new CommonOpenFileDialog + { + Title = "Open Video folder", + IsFolderPicker = true, + InitialDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + }; + if (dlg.ShowDialog() != CommonFileDialogResult.Ok) + return; + + _config.VideosDirectory = dlg.FileName; + ReloadFiles(); + } + +} diff --git a/Azaion.Annotator/Azaion.Annotator/PlayerControlHandler.cs b/Azaion.Annotator/Azaion.Annotator/PlayerControlHandler.cs new file mode 100644 index 0000000..14f6ef8 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/PlayerControlHandler.cs @@ -0,0 +1,95 @@ +using System.IO; +using System.Windows.Input; +using Azaion.Annotator.DTO; +using LibVLCSharp.Shared; +using MediatR; + +namespace Azaion.Annotator; + +public class PlayerControlHandler: + INotificationHandler, + INotificationHandler +{ + private const int STEP = 20; + private const int LARGE_STEP = 2000; + + private static readonly string[] CatchSenders = ["ForegroundWindow", "ScrollViewer"]; + private MediaPlayer mediaPlayer; + private readonly MainWindow _mainWindow; + private readonly FormState _formState; + private readonly Config _config; + + public PlayerControlHandler(MediaPlayer mediaPlayer1, MainWindow mainWindow, FormState formState, Config config) + { + mediaPlayer = mediaPlayer1; + _mainWindow = mainWindow; + _formState = formState; + _config = config; + } + + public async Task Handle(AnnClassSelectedEvent notification, CancellationToken cancellationToken) => + SelectClass(notification.AnnotationClass); + + 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) + { + //Console.WriteLine($"Time: {DateTime.UtcNow:hh:mm:ss.fff}. Sender {notification.Sender.GetType().Name} Key {notification.Args.Key}"); + if (!CatchSenders.Contains(notification.Sender.GetType().Name)) + return; + + var isCtrlPressed = notification.Args.KeyboardDevice.IsKeyDown(Key.LeftCtrl) || + notification.Args.KeyboardDevice.IsKeyDown(Key.RightCtrl); + var step = isCtrlPressed ? STEP : LARGE_STEP; + 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(_mainWindow.AnnotationClasses[keyNumber.Value]); + + switch (key) + { + case Key.Space: + mediaPlayer.Pause(); + break; + case Key.Left: + mediaPlayer.SetPause(true); + mediaPlayer.Time -= step; + break; + case Key.Right: + mediaPlayer.SetPause(true); + mediaPlayer.Time += step; + break; + case Key.Enter: + if (string.IsNullOrEmpty(_formState.CurrentFile)) + return; + + var fName = _formState.GetTimeName(TimeSpan.FromMilliseconds(mediaPlayer.Time)); + var currentAnns = _mainWindow.Editor.CurrentAnns + .Select(x => x.Info.ToLabelCoordinates(_mainWindow.Editor.RenderSize, _formState.CurrentVideoSize)) + .ToList(); + var labels = string.Join(Environment.NewLine, currentAnns.Select(x => x.ToString())); + + await File.WriteAllTextAsync($"{_config.LabelsDirectory}/{fName}.txt", labels, cancellationToken); + mediaPlayer.TakeSnapshot(0, $"{_config.ImagesDirectory}/{fName}.jpg", 0, 0); + + _mainWindow.Annotations[fName] = currentAnns; + _mainWindow.Editor.RemoveAllAnns(); + break; + + case Key.Delete: + _mainWindow.Editor.RemoveSelectedAnns(); + break; + } + } +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/config.json b/Azaion.Annotator/Azaion.Annotator/config.json new file mode 100644 index 0000000..0081666 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/config.json @@ -0,0 +1,69 @@ +{ + "LookupDirectory": "video", + "LabelsDirectory": "labels", + "ImagesDirectory": "images", + "AnnotationClasses": [ + { + "Id": 0, + "Name": "Броньована техніка", + "Color": "#80FF0000", + "ColorBrush": "#80FF0000" + }, + { + "Id": 1, + "Name": "Вантажівка", + "Color": "#8000FF00", + "ColorBrush": "#8000FF00" + }, + { + "Id": 2, + "Name": "Машина легкова", + "Color": "#800000FF", + "ColorBrush": "#800000FF" + }, + { + "Id": 3, + "Name": "Артилерія", + "Color": "#80FFFF00", + "ColorBrush": "#80FFFF00" + }, + { + "Id": 4, + "Name": "Тінь від техніки", + "Color": "#80FF00FF", + "ColorBrush": "#80FF00FF" + }, + { + "Id": 5, + "Name": "Окопи", + "Color": "#8000FFFF", + "ColorBrush": "#8000FFFF" + }, + { + "Id": 6, + "Name": "Військовий", + "Color": "#80000000", + "ColorBrush": "#80000000" + }, + { + "Id": 7, + "Name": "Накати", + "Color": "#80800000", + "ColorBrush": "#80800000" + }, + { + "Id": 8, + "Name": "Саркофаг", + "Color": "#80008000", + "ColorBrush": "#80008000" + }, + { + "Id": 9, + "Name": "Дим", + "Color": "#80000080", + "ColorBrush": "#80000080" + } + ], + "WindowSize": "1920,1080", + "WindowLocation": "200,121" +} \ No newline at end of file diff --git a/Azaion.Annotator/Azaion.Annotator/translations.json b/Azaion.Annotator/Azaion.Annotator/translations.json new file mode 100644 index 0000000..e180416 --- /dev/null +++ b/Azaion.Annotator/Azaion.Annotator/translations.json @@ -0,0 +1,16 @@ +{ + "en": { + "File": "File", + "Duration": "Duration", + "Key": "Key", + "ClassName": "Class Name", + "HelpOpen": "Open file from the list", + "HelpPause": "Press Space to pause video and " + }, + "ua": { + "File": "Файл", + "Duration": "Тривалість", + "Key": "Клавіша", + "ClassName": "Назва класу" + } +} \ No newline at end of file