splitting python complete

This commit is contained in:
Oleksandr Bezdieniezhnykh
2025-08-12 14:48:56 +03:00
parent fc6e5db795
commit ad782bcbaa
31 changed files with 834 additions and 369 deletions
+85 -97
View File
@@ -29,7 +29,7 @@ namespace Azaion.Annotator;
public partial class Annotator
{
private readonly AppConfig _appConfig;
private readonly LibVLC _libVLC;
private readonly LibVLC _libVlc;
private readonly MediaPlayer _mediaPlayer;
private readonly IMediator _mediator;
private readonly FormState _formState;
@@ -42,17 +42,17 @@ public partial class Annotator
private readonly IInferenceClient _inferenceClient;
private bool _suspendLayout;
private bool _gpsPanelVisible = false;
private bool _gpsPanelVisible;
public readonly CancellationTokenSource MainCancellationSource = new();
private readonly CancellationTokenSource _mainCancellationSource = new();
public CancellationTokenSource DetectionCancellationSource = new();
public bool IsInferenceNow = false;
private bool _isInferenceNow;
private readonly TimeSpan _thresholdBefore = TimeSpan.FromMilliseconds(50);
private readonly TimeSpan _thresholdAfter = TimeSpan.FromMilliseconds(150);
public ObservableCollection<MediaFileInfo> AllMediaFiles { get; set; } = new();
public ObservableCollection<MediaFileInfo> FilteredMediaFiles { get; set; } = new();
private ObservableCollection<MediaFileInfo> FilteredMediaFiles { get; set; } = new();
public Dictionary<string, MediaFileInfo> MediaFilesDict = new();
public IntervalTree<TimeSpan, Annotation> TimedAnnotations { get; set; } = new();
@@ -61,7 +61,7 @@ public partial class Annotator
public Annotator(
IConfigUpdater configUpdater,
IOptions<AppConfig> appConfig,
LibVLC libVLC,
LibVLC libVlc,
MediaPlayer mediaPlayer,
IMediator mediator,
FormState formState,
@@ -78,7 +78,7 @@ public partial class Annotator
Title = MainTitle;
_appConfig = appConfig.Value;
_configUpdater = configUpdater;
_libVLC = libVLC;
_libVlc = libVlc;
_mediaPlayer = mediaPlayer;
_mediator = mediator;
_formState = formState;
@@ -91,7 +91,7 @@ public partial class Annotator
Loaded += OnLoaded;
Closed += OnFormClosed;
Activated += (_, _) => _formState.ActiveWindow = WindowEnum.Annotator;
TbFolder.TextChanged += async (sender, args) =>
TbFolder.TextChanged += async (_, _) =>
{
if (!Path.Exists(TbFolder.Text))
return;
@@ -179,22 +179,8 @@ public partial class Annotator
VideoView.MediaPlayer = _mediaPlayer;
//On start playing media
_mediaPlayer.Playing += async (sender, args) =>
_mediaPlayer.Playing += (_, _) =>
{
if (_formState.CurrentMrl == _mediaPlayer.Media?.Mrl)
return; //already loaded all the info
await Dispatcher.Invoke(async () => await ReloadAnnotations());
//show image
if (_formState.CurrentMedia?.MediaType == MediaTypes.Image)
{
await Task.Delay(100); //wait to load the frame and set on pause
ShowTimeAnnotations(TimeSpan.FromMilliseconds(_mediaPlayer.Time), showImage: true);
return;
}
_formState.CurrentMrl = _mediaPlayer.Media?.Mrl ?? "";
uint vw = 0, vh = 0;
_mediaPlayer.Size(0, ref vw, ref vh);
_formState.CurrentMediaSize = new Size(vw, vh);
@@ -211,12 +197,12 @@ public partial class Annotator
var selectedClass = args.DetectionClass;
Editor.CurrentAnnClass = selectedClass;
_mediator.Publish(new AnnClassSelectedEvent(selectedClass));
};
};
_mediaPlayer.PositionChanged += (o, args) =>
_mediaPlayer.PositionChanged += (_, _) =>
ShowTimeAnnotations(TimeSpan.FromMilliseconds(_mediaPlayer.Time));
VideoSlider.ValueChanged += (value, newValue) =>
VideoSlider.ValueChanged += (_, newValue) =>
_mediaPlayer.Position = (float)(newValue / VideoSlider.Maximum);
VideoSlider.KeyDown += (sender, args) =>
@@ -227,51 +213,49 @@ public partial class Annotator
DgAnnotations.MouseDoubleClick += (sender, args) =>
{
var dgRow = ItemsControl.ContainerFromElement((DataGrid)sender, (args.OriginalSource as DependencyObject)!) as DataGridRow;
if (dgRow != null)
OpenAnnotationResult((AnnotationResult)dgRow!.Item);
if (ItemsControl.ContainerFromElement((DataGrid)sender, (args.OriginalSource as DependencyObject)!) is DataGridRow dgRow)
OpenAnnotationResult((Annotation)dgRow.Item);
};
DgAnnotations.KeyUp += async (sender, args) =>
DgAnnotations.KeyUp += async (_, args) =>
{
switch (args.Key)
{
case Key.Up:
case Key.Down: //cursor is already moved by system behaviour
OpenAnnotationResult((AnnotationResult)DgAnnotations.SelectedItem);
OpenAnnotationResult((Annotation)DgAnnotations.SelectedItem);
break;
case Key.Delete:
var result = MessageBox.Show("Чи дійсно видалити аннотації?","Підтвердження видалення", MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (result != MessageBoxResult.OK)
return;
var res = DgAnnotations.SelectedItems.Cast<AnnotationResult>().ToList();
var annotationNames = res.Select(x => x.Annotation.Name).ToList();
var res = DgAnnotations.SelectedItems.Cast<Annotation>().ToList();
var annotationNames = res.Select(x => x.Name).ToList();
await _mediator.Publish(new AnnotationsDeletedEvent(annotationNames));
break;
}
};
Editor.Mediator = _mediator;
DgAnnotations.ItemsSource = _formState.AnnotationResults;
}
public void OpenAnnotationResult(AnnotationResult res)
private void OpenAnnotationResult(Annotation ann)
{
_mediaPlayer.SetPause(true);
Editor.RemoveAllAnns();
_mediaPlayer.Time = (long)res.Annotation.Time.TotalMilliseconds;
if (!ann.IsSplit)
Editor.RemoveAllAnns();
_mediaPlayer.Time = (long)ann.Time.TotalMilliseconds;
Dispatcher.Invoke(() =>
{
VideoSlider.Value = _mediaPlayer.Position * VideoSlider.Maximum;
StatusClock.Text = $"{TimeSpan.FromMilliseconds(_mediaPlayer.Time):mm\\:ss} / {_formState.CurrentVideoLength:mm\\:ss}";
Editor.ClearExpiredAnnotations(res.Annotation.Time);
Editor.ClearExpiredAnnotations(ann.Time);
});
ShowAnnotation(res.Annotation, showImage: true);
ShowAnnotation(ann, showImage: true, openResult: true);
}
private void SaveUserSettings()
{
@@ -284,7 +268,7 @@ public partial class Annotator
_configUpdater.Save(_appConfig);
}
private void ShowTimeAnnotations(TimeSpan time, bool showImage = false)
public void ShowTimeAnnotations(TimeSpan time, bool showImage = false)
{
Dispatcher.Invoke(() =>
{
@@ -292,60 +276,68 @@ public partial class Annotator
StatusClock.Text = $"{TimeSpan.FromMilliseconds(_mediaPlayer.Time):mm\\:ss} / {_formState.CurrentVideoLength:mm\\:ss}";
Editor.ClearExpiredAnnotations(time);
});
var annotation = TimedAnnotations.Query(time).FirstOrDefault();
if (annotation != null) ShowAnnotation(annotation, showImage);
var annotations = TimedAnnotations.Query(time).ToList();
if (!annotations.Any())
return;
foreach (var ann in annotations)
ShowAnnotation(ann, showImage);
}
private void ShowAnnotation(Annotation annotation, bool showImage = false)
private void ShowAnnotation(Annotation annotation, bool showImage = false, bool openResult = false)
{
Dispatcher.Invoke(async () =>
{
if (showImage)
if (showImage && !annotation.IsSplit && File.Exists(annotation.ImagePath))
{
if (File.Exists(annotation.ImagePath))
{
Editor.SetBackground(await annotation.ImagePath.OpenImage());
_formState.BackgroundTime = annotation.Time;
}
Editor.SetBackground(await annotation.ImagePath.OpenImage());
_formState.BackgroundTime = annotation.Time;
}
Editor.CreateDetections(annotation.Time, annotation.Detections, _appConfig.AnnotationConfig.DetectionClasses, _formState.CurrentMediaSize);
if (annotation.SplitTile != null && openResult)
{
var canvasTileLocation = new CanvasLabel(new YoloLabel(annotation.SplitTile, _formState.CurrentMediaSize),
RenderSize);
Editor.ZoomTo(new Point(canvasTileLocation.CenterX, canvasTileLocation.CenterY));
}
else
Editor.CreateDetections(annotation, _appConfig.AnnotationConfig.DetectionClasses, _formState.CurrentMediaSize);
});
}
private async Task ReloadAnnotations()
public async Task ReloadAnnotations()
{
_formState.AnnotationResults.Clear();
TimedAnnotations.Clear();
Editor.RemoveAllAnns();
var annotations = await _dbFactory.Run(async db =>
await db.Annotations.LoadWith(x => x.Detections)
.Where(x => x.OriginalMediaName == _formState.MediaName)
.OrderBy(x => x.Time)
.ToListAsync(token: MainCancellationSource.Token));
TimedAnnotations.Clear();
_formState.AnnotationResults.Clear();
foreach (var ann in annotations)
await Dispatcher.InvokeAsync(async () =>
{
TimedAnnotations.Add(ann.Time.Subtract(_thresholdBefore), ann.Time.Add(_thresholdAfter), ann);
_formState.AnnotationResults.Add(new AnnotationResult(_appConfig.AnnotationConfig.DetectionClassesDict, ann));
}
_formState.AnnotationResults.Clear();
TimedAnnotations.Clear();
Editor.RemoveAllAnns();
var annotations = await _dbFactory.Run(async db =>
await db.Annotations.LoadWith(x => x.Detections)
.Where(x => x.OriginalMediaName == _formState.MediaName)
.OrderBy(x => x.Time)
.ToListAsync(token: _mainCancellationSource.Token));
TimedAnnotations.Clear();
_formState.AnnotationResults.Clear();
foreach (var ann in annotations)
{
// Duplicate for speed
TimedAnnotations.Add(ann.Time.Subtract(_thresholdBefore), ann.Time.Add(_thresholdAfter), ann);
_formState.AnnotationResults.Add(ann);
}
});
}
//Add manually
public void AddAnnotation(Annotation annotation)
{
var mediaInfo = (MediaFileInfo)LvFiles.SelectedItem;
if ((mediaInfo?.FName ?? "") != annotation.OriginalMediaName)
return;
var time = annotation.Time;
var previousAnnotations = TimedAnnotations.Query(time);
TimedAnnotations.Remove(previousAnnotations);
TimedAnnotations.Add(time.Subtract(_thresholdBefore), time.Add(_thresholdAfter), annotation);
var existingResult = _formState.AnnotationResults.FirstOrDefault(x => x.Annotation.Time == time);
var existingResult = _formState.AnnotationResults.FirstOrDefault(x => x.Time == time);
if (existingResult != null)
{
try
@@ -360,16 +352,14 @@ public partial class Annotator
}
var dict = _formState.AnnotationResults
.Select((x, i) => new { x.Annotation.Time, Index = i })
.Select((x, i) => new { x.Time, Index = i })
.ToDictionary(x => x.Time, x => x.Index);
var index = dict.Where(x => x.Key < time)
.OrderBy(x => time - x.Key)
.Select(x => x.Value + 1)
.FirstOrDefault();
var annRes = new AnnotationResult(_appConfig.AnnotationConfig.DetectionClassesDict, annotation);
_formState.AnnotationResults.Insert(index, annRes);
_formState.AnnotationResults.Insert(index, annotation);
}
private async Task ReloadFiles()
@@ -380,7 +370,7 @@ public partial class Annotator
var videoFiles = dir.GetFiles(_appConfig.AnnotationConfig.VideoFormats.ToArray()).Select(x =>
{
using var media = new Media(_libVLC, x.FullName);
var media = new Media(_libVlc, x.FullName);
media.Parse();
var fInfo = new MediaFileInfo
{
@@ -403,14 +393,16 @@ public partial class Annotator
var allFileNames = allFiles.Select(x => x.FName).ToList();
var labelsDict = await _dbFactory.Run(async db => await db.Annotations
.GroupBy(x => x.Name.Substring(0, x.Name.Length - 7))
var labelsDict = await _dbFactory.Run(async db =>
await db.Annotations
.GroupBy(x => x.OriginalMediaName)
.Where(x => allFileNames.Contains(x.Key))
.ToDictionaryAsync(x => x.Key, x => x.Key));
.Select(x => x.Key)
.ToDictionaryAsync(x => x, x => x));
foreach (var mediaFile in allFiles)
mediaFile.HasAnnotations = labelsDict.ContainsKey(mediaFile.FName);
AllMediaFiles = new ObservableCollection<MediaFileInfo>(allFiles);
MediaFilesDict = AllMediaFiles.GroupBy(x => x.Name)
.ToDictionary(gr => gr.Key, gr => gr.First());
@@ -420,13 +412,13 @@ public partial class Annotator
private void OnFormClosed(object? sender, EventArgs e)
{
MainCancellationSource.Cancel();
_mainCancellationSource.Cancel();
_inferenceService.StopInference();
DetectionCancellationSource.Cancel();
_mediaPlayer.Stop();
_mediaPlayer.Dispose();
_libVLC.Dispose();
_libVlc.Dispose();
}
private void OpenContainingFolder(object sender, RoutedEventArgs e)
@@ -447,13 +439,10 @@ public partial class Annotator
StatusClock.Text = $"{TimeSpan.FromMilliseconds(_mediaPlayer.Time):mm\\:ss} / {_formState.CurrentVideoLength:mm\\:ss}";
}
private void SeekTo(TimeSpan time) =>
SeekTo((long)time.TotalMilliseconds);
private void OpenFolderItemClick(object sender, RoutedEventArgs e) => OpenFolder();
private void OpenFolderButtonClick(object sender, RoutedEventArgs e) => OpenFolder();
private async void OpenFolderItemClick(object sender, RoutedEventArgs e) => await OpenFolder();
private async void OpenFolderButtonClick(object sender, RoutedEventArgs e) => await OpenFolder();
private async Task OpenFolder()
private void OpenFolder()
{
var dlg = new CommonOpenFileDialog
{
@@ -468,7 +457,6 @@ public partial class Annotator
_appConfig.DirectoriesConfig.VideosDirectory = dlg.FileName;
TbFolder.Text = dlg.FileName;
await Task.CompletedTask;
}
private void TbFilter_OnTextChanged(object sender, TextChangedEventArgs e)
@@ -525,7 +513,7 @@ public partial class Annotator
public async Task AutoDetect()
{
if (IsInferenceNow)
if (_isInferenceNow)
return;
if (LvFiles.Items.IsEmpty)
@@ -535,7 +523,7 @@ public partial class Annotator
Dispatcher.Invoke(() => Editor.SetBackground(null));
IsInferenceNow = true;
_isInferenceNow = true;
AIDetectBtn.IsEnabled = false;
DetectionCancellationSource = new CancellationTokenSource();
@@ -550,7 +538,7 @@ public partial class Annotator
await _inferenceService.RunInference(files, DetectionCancellationSource.Token);
LvFiles.Items.Refresh();
IsInferenceNow = false;
_isInferenceNow = false;
StatusHelp.Text = "Розпізнавання зваершено";
AIDetectBtn.IsEnabled = true;
}
@@ -596,7 +584,7 @@ public class GradientStyleSelector : StyleSelector
{
public override Style? SelectStyle(object item, DependencyObject container)
{
if (container is not DataGridRow row || row.DataContext is not AnnotationResult result)
if (container is not DataGridRow row || row.DataContext is not Annotation result)
return null;
var style = new Style(typeof(DataGridRow));
+36 -27
View File
@@ -23,7 +23,7 @@ using MediaPlayer = LibVLCSharp.Shared.MediaPlayer;
namespace Azaion.Annotator;
public class AnnotatorEventHandler(
LibVLC libVLC,
LibVLC libVlc,
MediaPlayer mediaPlayer,
Annotator mainWindow,
FormState formState,
@@ -47,8 +47,7 @@ public class AnnotatorEventHandler(
{
private const int STEP = 20;
private const int LARGE_STEP = 5000;
private const int RESULT_WIDTH = 1280;
private readonly string tempImgPath = Path.Combine(dirConfig.Value.ImagesDirectory, "___temp___.jpg");
private readonly string _tempImgPath = Path.Combine(dirConfig.Value.ImagesDirectory, "___temp___.jpg");
private readonly Dictionary<Key, PlaybackControlEnum> _keysControlEnumDict = new()
{
@@ -144,8 +143,8 @@ public class AnnotatorEventHandler(
if (mediaPlayer.IsPlaying)
{
mediaPlayer.Pause();
mediaPlayer.TakeSnapshot(0, tempImgPath, 0, 0);
mainWindow.Editor.SetBackground(await tempImgPath.OpenImage());
mediaPlayer.TakeSnapshot(0, _tempImgPath, 0, 0);
mainWindow.Editor.SetBackground(await _tempImgPath.OpenImage());
formState.BackgroundTime = TimeSpan.FromMilliseconds(mediaPlayer.Time);
}
else
@@ -238,16 +237,21 @@ public class AnnotatorEventHandler(
return;
var mediaInfo = (MediaFileInfo)mainWindow.LvFiles.SelectedItem;
if (formState.CurrentMedia == mediaInfo)
return; //already loaded
formState.CurrentMedia = mediaInfo;
mainWindow.Title = $"{mainWindow.MainTitle} - {mediaInfo.Name}";
await mainWindow.ReloadAnnotations();
if (mediaInfo.MediaType == MediaTypes.Video)
{
mainWindow.Editor.SetBackground(null);
//need to wait a bit for correct VLC playback event handling
await Task.Delay(100, ct);
mediaPlayer.Stop();
mediaPlayer.Play(new Media(libVLC, mediaInfo.Path));
mediaPlayer.Play(new Media(libVlc, mediaInfo.Path));
}
else
{
@@ -256,6 +260,7 @@ public class AnnotatorEventHandler(
formState.CurrentMediaSize = new Size(image.PixelWidth, image.PixelHeight);
mainWindow.Editor.SetBackground(image);
mediaPlayer.Stop();
mainWindow.ShowTimeAnnotations(TimeSpan.Zero, showImage: true);
}
}
@@ -282,13 +287,14 @@ public class AnnotatorEventHandler(
// var annGrid = mainWindow.DgAnnotations;
// annGrid.SelectedIndex = Math.Min(annGrid.Items.Count, annGrid.SelectedIndex + 1);
// mainWindow.OpenAnnotationResult((AnnotationResult)annGrid.SelectedItem);
mainWindow.Editor.SetBackground(null);
formState.BackgroundTime = null;
}
else
{
await NextMedia(ct: cancellationToken);
}
mainWindow.Editor.SetBackground(null);
formState.BackgroundTime = null;
mainWindow.LvFiles.Items.Refresh();
mainWindow.Editor.RemoveAllAnns();
@@ -301,7 +307,7 @@ public class AnnotatorEventHandler(
if (!File.Exists(imgPath))
{
var source = (mainWindow.Editor.BackgroundImage.Source as BitmapSource)!;
if (source.PixelWidth <= RESULT_WIDTH * 2 && source.PixelHeight <= RESULT_WIDTH * 2) // Allow to be up to 2560*2560 to save to 1280*1280
if (source.PixelWidth <= Constants.AI_TILE_SIZE * 2 && source.PixelHeight <= Constants.AI_TILE_SIZE * 2) // Allow to be up to 2560*2560 to save to 1280*1280
{
//Save image
await using var stream = new FileStream(imgPath, FileMode.Create);
@@ -314,28 +320,28 @@ public class AnnotatorEventHandler(
{
//Tiling
//1. Restore original picture coordinates
var pictureCoordinatesDetections = canvasDetections.Select(x => new CanvasLabel(
//1. Convert from RenderSize to CurrentMediaSize
var detectionCoords = canvasDetections.Select(x => new CanvasLabel(
new YoloLabel(x, mainWindow.Editor.RenderSize, formState.CurrentMediaSize), formState.CurrentMediaSize, null, x.Confidence))
.ToList();
//2. Split to 1280*1280 frames
var results = TileProcessor.Split(formState.CurrentMediaSize, pictureCoordinatesDetections, cancellationToken);
//2. Split to frames
var results = TileProcessor.Split(formState.CurrentMediaSize, detectionCoords, cancellationToken);
//3. Save each frame as a separate annotation
BitmapEncoder tileEncoder = new JpegBitmapEncoder();
foreach (var res in results)
{
var mediaName = $"{formState.MediaName}!split!{res.Tile.X}_{res.Tile.Y}!";
var time = TimeSpan.Zero;
var annotationName = mediaName.ToTimeName(time);
var annotationName = $"{formState.MediaName}{Constants.SPLIT_SUFFIX}{res.Tile.Left:0000}_{res.Tile.Top:0000}!".ToTimeName(time);
var tileImgPath = Path.Combine(dirConfig.Value.ImagesDirectory, $"{annotationName}{Constants.JPG_EXT}");
await using var tileStream = new FileStream(tileImgPath, FileMode.Create);
var bitmap = new CroppedBitmap(source, new Int32Rect((int)res.Tile.X, (int)res.Tile.Y, (int)res.Tile.Width, (int)res.Tile.Height));
tileEncoder.Frames.Add(BitmapFrame.Create(bitmap));
var bitmap = new CroppedBitmap(source, new Int32Rect((int)res.Tile.Left, (int)res.Tile.Top, (int)res.Tile.Width, (int)res.Tile.Height));
var tileEncoder = new JpegBitmapEncoder { Frames = [BitmapFrame.Create(bitmap)] };
tileEncoder.Save(tileStream);
await tileStream.FlushAsync(cancellationToken);
tileStream.Close();
var frameSize = new Size(res.Tile.Width, res.Tile.Height);
var detections = res.Detections
@@ -343,18 +349,18 @@ public class AnnotatorEventHandler(
.Select(x => new Detection(annotationName, new YoloLabel(x, frameSize)))
.ToList();
annotationsResult.Add(await annotationService.SaveAnnotation(mediaName, time, detections, token: cancellationToken));
annotationsResult.Add(await annotationService.SaveAnnotation(formState.MediaName, annotationName, time, detections, token: cancellationToken));
}
return annotationsResult;
}
}
var timeImg = formState.BackgroundTime ?? TimeSpan.FromMilliseconds(mediaPlayer.Time);
var timeName = formState.MediaName.ToTimeName(timeImg);
var annName = formState.MediaName.ToTimeName(timeImg);
var currentDetections = canvasDetections.Select(x =>
new Detection(timeName, new YoloLabel(x, mainWindow.Editor.RenderSize)))
new Detection(annName, new YoloLabel(x, mainWindow.Editor.RenderSize)))
.ToList();
var annotation = await annotationService.SaveAnnotation(formState.MediaName, timeImg, currentDetections, token: cancellationToken);
var annotation = await annotationService.SaveAnnotation(formState.MediaName, annName, timeImg, currentDetections, token: cancellationToken);
return [annotation];
}
@@ -367,15 +373,15 @@ public class AnnotatorEventHandler(
var namesSet = notification.AnnotationNames.ToHashSet();
var remainAnnotations = formState.AnnotationResults
.Where(x => !namesSet.Contains(x.Annotation?.Name ?? "")).ToList();
.Where(x => !namesSet.Contains(x.Name)).ToList();
formState.AnnotationResults.Clear();
foreach (var ann in remainAnnotations)
formState.AnnotationResults.Add(ann);
var timedAnnsToRemove = mainWindow.TimedAnnotations
var timedAnnotationsToRemove = mainWindow.TimedAnnotations
.Where(x => namesSet.Contains(x.Value.Name))
.Select(x => x.Value).ToList();
mainWindow.TimedAnnotations.Remove(timedAnnsToRemove);
mainWindow.TimedAnnotations.Remove(timedAnnotationsToRemove);
if (formState.AnnotationResults.Count == 0)
{
@@ -420,7 +426,10 @@ public class AnnotatorEventHandler(
{
mainWindow.Dispatcher.Invoke(() =>
{
mainWindow.AddAnnotation(e.Annotation);
var mediaInfo = (MediaFileInfo)mainWindow.LvFiles.SelectedItem;
if ((mediaInfo?.FName ?? "") == e.Annotation.OriginalMediaName)
mainWindow.AddAnnotation(e.Annotation);
var log = string.Join(Environment.NewLine, e.Annotation.Detections.Select(det =>
$"Розпізнавання {e.Annotation.OriginalMediaName}: {annotationConfig.Value.DetectionClassesDict[det.ClassNumber].ShortName}: " +