diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs index 0bd0561..c5a76e9 100644 --- a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs +++ b/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs @@ -775,7 +775,7 @@ public class AmineContentAnalyzerService p.First.VisibleIntensity != p.Second.VisibleIntensity || p.First.Uv365Intensity != p.Second.Uv365Intensity || p.First.Uv254Intensity != p.Second.Uv254Intensity)) - throw new Exception("Image records indencities to correlate with separator images intencities"); + throw new Exception("Image records intensities to correlate with separator images intensities"); var imagePaths = GetImageRecordsFilePaths(imageRecords); var roi = CalculateSeparatorRoi(imagePaths, 5, cancellationToken); @@ -867,7 +867,7 @@ public class AmineContentAnalyzerService return calibrationRecord; } - public async Task<(Mat result, Mat mask)> CalcaulateAmineContent(IEnumerable sampleImageRecords, IEnumerable separatorImageRecords, CalibrationRecord calibrationRecord, CancellationToken cancellationToken = default) + public async Task<(Mat result, Mat mask)> CalculateAmineContent(IEnumerable sampleImageRecords, IEnumerable separatorImageRecords, CalibrationRecord calibrationRecord, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); @@ -986,10 +986,10 @@ public class AmineContentAnalyzerService } } - // Срезаем 99 проценталь (1% самых горячих пикселей), которые скорее всего являются шумом или загрязенением + // Срезаем 99.9 процентиль (0.1% самых горячих пикселей), которые скорее всего являются шумом или загрязнением var oldMean = Cv2.Mean(result, sampleMask).Val0; values.Sort(); - float p99 = values[(int)(values.Length * 0.99)]; + float p99 = values[(int)(values.Length * 0.999)]; Cv2.Threshold(result, result, p99, p99, ThresholdTypes.Trunc); var newMean = Cv2.Mean(result, sampleMask).Val0; var scale = oldMean / newMean; diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs new file mode 100644 index 0000000..5671a2f --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs @@ -0,0 +1,123 @@ + +using System.Collections.ObjectModel; +using System.Drawing; + +using GSS2.Core.Extensions; +using GSS2.Core.Hardware; + +using Microsoft.Extensions.Logging; + +using OpenCvSharp; + +namespace GSS2.Core.Analysis.AmineContent; + +public class AmineContentImageCapturerService +{ + // Список интенсивностей освещения для каждого кадра + public static readonly ReadOnlyCollection<(double visible, double uv365, double uv254)> IlluminatorIntensities = + [ + (0.02500, 0.0, 0.0), + (0.01250, 0.0, 0.0), + (0.00625, 0.0, 0.0), + (0.00000, 1.0, 0.0), + (0.00000, 0.0, 1.0), + (0.00000, 1.0, 1.0), + (0.01250, 1.0, 0.0), + (0.01250, 0.0, 1.0), + (0.01250, 1.0, 1.0), + (0.00625, 1.0, 0.0), + (0.00625, 0.0, 1.0), + (0.00625, 1.0, 1.0) + ]; + + private readonly ILogger _logger; + private readonly CameraService _camera; + private readonly IlluminatorService _illuminator; + private readonly LightsService _lights; + + public AmineContentImageCapturerService( + ILogger logger, + CameraService camera, + IlluminatorService illuminator, + LightsService lights + ) + { + _logger = logger; + _camera = camera; + _illuminator = illuminator; + _lights = lights; + } + + public async Task CaptureImages(Action<(int i, double visible, double uv365, double uv254, Mat image)> callback, CancellationToken cancellationToken) => + await CaptureImages(async (data) => callback(data), cancellationToken); + + public async Task CaptureImages(Func<(int i, double visible, double uv365, double uv254, Mat image), Task> callback, CancellationToken cancellationToken) + { + // Включаем осветитель + _illuminator.TurnOn(); + + // Если ViewFinder запущен, то для съёмки изображений его нужно остановить + // потом после съёмки снова запустить + var needRestartViewFinder = _camera.ViewFinderStarted; + if (needRestartViewFinder) + _camera.StopViewFinder(); + + try + { + // Для каждой интенсивности + foreach (var (i, (visible, uv365, uv254)) in IlluminatorIntensities.Enumerate()) + { + // Выбрасываем исключение если задача отменена + cancellationToken.ThrowIfCancellationRequested(); + + // Логгируем прогресс + _logger.LogInformation("Capturing images: {} / {} ({};{};{})", i + 1, IlluminatorIntensities.Count(), visible, uv365, uv254); + // Отображаем прогресс на светодиодах + _lights.Load(0.3, 1, (double)(i + 1) / IlluminatorIntensities.Count * 100, (double)i / IlluminatorIntensities.Count * 100, Color.Yellow); + + // Устанавливаем интенсивность + _illuminator.SetIntensity(visible, uv365, uv254); + + // Получаем изображение + Mat? image; + image = await _camera.CaptureImage(cancellationToken, 2); + if (image is null) + throw new Exception("Image capture failed"); + + // Вызываем коллбек + await callback.Invoke((i, visible, uv365, uv254, image)); + } + + // Если ошибок не было, то мигаем светодиодами зелёным + _ = Task.Run(async () => + { + await Task.Delay(1000); + _lights.Flash(1, 1, Color.Green); + await Task.Delay(3000); + _lights.Disconnect(); + }); + } + catch (OperationCanceledException) + { } + catch + { + // Если была ошибка, то мигаем светодиодами красным + _ = Task.Run(async () => + { + await Task.Delay(1000); + _lights.Flash(1, 1, Color.Red); + await Task.Delay(3000); + _lights.Disconnect(); + }); + throw; + } + finally + { + // В любом случае отключаем осветитель + _illuminator.TurnOff(); + // Запускаем обратно ViewFinder, если он был запущен и ещё не запущен обратно + if (needRestartViewFinder && !_camera.ViewFinderStarted) + _camera.StartViewFinder(); + } + } +} diff --git a/GSS2.Core/DependencyInjectionHelper.cs b/GSS2.Core/DependencyInjectionHelper.cs index 5e03e9f..3470321 100644 --- a/GSS2.Core/DependencyInjectionHelper.cs +++ b/GSS2.Core/DependencyInjectionHelper.cs @@ -3,16 +3,32 @@ using Microsoft.IdentityModel.Protocols.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; +using GSS2.Core.Logging; using GSS2.Core.Hardware; using GSS2.Core.Analysis.AmineContent.Database.Calibration; using GSS2.Core.Analysis.AmineContent.Database.Results; using GSS2.Core.Analysis.AmineContent; +using ConsoleFormatter = GSS2.Core.Logging.ConsoleFormatter; + namespace GSS2.Core; public static class DependencyInjectionHelper { + public static IConfigurationBuilder AddAppSettingsJson(this IConfigurationBuilder configuration) => + configuration.AddJsonFile(Path.Join(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json")); + + public static ILoggingBuilder ConfigureLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddConsole(options => options.FormatterName = nameof(ConsoleFormatter)); + logging.AddConsoleFormatter(); + logging.AddProvider(new FileLoggerProvider("full.log", true)); + logging.AddProvider(new FileLoggerProvider("last_run.log", false)); + return logging; + } public static IServiceCollection AddTemperatureHumidityService(this IServiceCollection services, string section) => services .AddSingleton( serviceProvider => diff --git a/GSS2.Core/Extensions/MatExtensions.cs b/GSS2.Core/Extensions/MatExtensions.cs new file mode 100644 index 0000000..d52fc89 --- /dev/null +++ b/GSS2.Core/Extensions/MatExtensions.cs @@ -0,0 +1,14 @@ +using OpenCvSharp; + +namespace GSS2.Core.Extensions; + +public static class MatExtensions +{ + public static void ImWrite(this Mat mat, string path) + { + var directory = Path.GetDirectoryName(path); + if (directory is not null && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + mat.ImWrite(path); + } +} diff --git a/GSS2.Core/Hardware/CameraService.cs b/GSS2.Core/Hardware/CameraService.cs index 6b5aeec..a2ceb5a 100644 --- a/GSS2.Core/Hardware/CameraService.cs +++ b/GSS2.Core/Hardware/CameraService.cs @@ -103,6 +103,7 @@ public class CameraService : IDisposable public bool IsImageCapturing { get; private set; } = false; public List ViewFinderBufferQueue { get; private set; } = new List(); public CameraServiceConfiguration Configuration => _configuration; + public bool ViewFinderStarted { get; private set; } = false; public CameraService(ILogger logger, CameraServiceConfiguration? configuration = null, bool autoInitialize = false) { @@ -315,7 +316,7 @@ public class CameraService : IDisposable throw new Exception($"Error while camera start {result}"); } - _logger.LogInformation("Camera settings: \n{}", GetSettings(4)); + _logger.LogDebug("Camera settings: \n{}", GetSettings(4)); _state |= State.CameraStarted; } @@ -840,6 +841,8 @@ public class CameraService : IDisposable _viewFinderRequestCompletedHandler is not null) throw new InvalidOperationException(); + ViewFinderStarted = true; + _viewFinderTimer = new System.Timers.Timer() { Interval = 1000 / _configuration.ViewFinderFps, @@ -919,9 +922,11 @@ public class CameraService : IDisposable } public void StopViewFinder() { + ViewFinderStarted = false; _viewFinderTimer?.Stop(); _viewFinderTimer?.Dispose(); _viewFinderTimer = null; + _viewFinderTimerElapsed = null; _camera?.RequestCompleted -= _viewFinderRequestCompletedHandler; _viewFinderRequestCompletedHandler = null; lock (ViewFinderBufferQueue) diff --git a/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs b/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs index 8199745..0543696 100644 --- a/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs +++ b/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs @@ -1,9 +1,9 @@ -using CommunityToolkit.Mvvm.ComponentModel; - using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + using GSS2.Core.Analysis.AmineContent.Database.Calibration; namespace GSS2.UI.Core.ViewModels; @@ -14,14 +14,14 @@ public partial class AsyncImageRecordViewModel : ViewModelBase private readonly string _path; - [ObservableProperty] private ImageRecord _imageRecord; - [ObservableProperty] private IImage? _image; - [ObservableProperty] private bool _isLoading = true; + [ObservableProperty] public partial ImageRecord ImageRecord { get; set; } + [ObservableProperty] public partial IImage? Image { get; set; } + [ObservableProperty] public partial bool IsLoading { get; set; } = true; public AsyncImageRecordViewModel(string path, ImageRecord imageRecord) { _path = path; - _imageRecord = imageRecord; + ImageRecord = imageRecord; _ = LoadAsync(); } @@ -48,4 +48,4 @@ public partial class AsyncImageRecordViewModel : ViewModelBase _semaphore.Release(); } } -} \ No newline at end of file +} diff --git a/GSS2.UI.Core/ViewModels/CameraViewModel.cs b/GSS2.UI.Core/ViewModels/CameraViewModel.cs index 3056bd3..6ad9983 100644 --- a/GSS2.UI.Core/ViewModels/CameraViewModel.cs +++ b/GSS2.UI.Core/ViewModels/CameraViewModel.cs @@ -13,7 +13,7 @@ namespace GSS2.UI.Core.ViewModels; public partial class CameraViewModel : ViewModelBase, IDisposable { private bool _disposedValue; - + private readonly ILogger _logger; private readonly CameraService _cameraService; private readonly System.Timers.Timer _renderTimer; @@ -71,4 +71,4 @@ public partial class CameraViewModel : ViewModelBase, IDisposable Dispose(disposing: true); GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/GSS2.UI.Core/Views/IlluminatorControllerView.axaml b/GSS2.UI.Core/Views/IlluminatorControllerView.axaml index 7d95430..48f783b 100644 --- a/GSS2.UI.Core/Views/IlluminatorControllerView.axaml +++ b/GSS2.UI.Core/Views/IlluminatorControllerView.axaml @@ -42,7 +42,7 @@ Orientation="Horizontal" Spacing="20"> - + @@ -53,8 +53,9 @@ Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - Content="Включить" - IsChecked="{Binding Enabled}" /> + IsChecked="{Binding Enabled}"> + + @@ -71,7 +72,7 @@ Orientation="Vertical" Spacing="20"> - + @@ -82,8 +83,9 @@ Margin="0,0,10,0" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" - Content="Включить" - IsChecked="{Binding Enabled}" /> + IsChecked="{Binding Enabled}"> + + diff --git a/GSS2/Commands/UI.cs b/GSS2/Commands/UI.cs index d5b4b94..aade487 100644 --- a/GSS2/Commands/UI.cs +++ b/GSS2/Commands/UI.cs @@ -3,12 +3,13 @@ using System.CommandLine; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Avalonia; using Avalonia.OpenGL.Egl; -using GSS2.Core.Logging; using GSS2.Core; +using GSS2.Core.Logging; using GSS2.Core.Analysis.AmineContent.Database.Calibration; using GSS2.Core.Analysis.AmineContent.Database.Results; @@ -157,7 +158,8 @@ public static class UI builder.Services.AddIlluminatorService("Hardware:Illuminator"); builder.Services.AddCameraService("Hardware:Camera", true); builder.Services.AddComputeResourcesService(); - builder.Services.AddImageStorageService(new DirectoryInfo("images")); + builder.Services.AddImageStorageService(new DirectoryInfo(builder.Configuration.GetValue("ImageStorage") ?? "images")); + builder.Services.AddAmineContentImageCapturerService(); // Добавляем элементы интерфейса как сервисы builder.Services.AddViewsAndModels(); @@ -191,7 +193,7 @@ public static class UI .LogToTrace() .With(new X11PlatformOptions { - RenderingMode = [X11RenderingMode.Egl] + RenderingMode = [X11RenderingMode.Egl], }) .With(new EglDisplayOptions { diff --git a/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs b/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs index 9aff616..17801ad 100644 --- a/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs +++ b/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs @@ -1,11 +1,446 @@ +using System.Collections.ObjectModel; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using GSS2.Core.Analysis.AmineContent; +using GSS2.Core.Analysis.AmineContent.Database.Calibration; +using GSS2.Core.Hardware; using GSS2.UI.Core.ViewModels; namespace GSS2.ViewModels.AmineContent; public partial class SeparatorCalibrationViewModel : ViewModelBase { - public SeparatorCalibrationViewModel() - { + private readonly ILogger _logger; + private readonly AmineContentCalibrationContext _context; + private readonly AmineContentImageCapturerService _imageCapturer; + private readonly ImageStorageService _imageStorage; + private CancellationTokenSource? _captureImagesTaskCts = null; + + [ObservableProperty] public partial bool MenuOpened { get; set; } = false; + [ObservableProperty] public partial ObservableCollection Records { get; set; } + + [ObservableProperty] public partial SeparatorRecord? SelectedRecord { get; set; } + [ObservableProperty] public partial SeparatorRecord EditingRecord { get; set; } + + [ObservableProperty] public partial int MainSectionsCurrentIndex { get; set; } = 0; + [ObservableProperty] public partial bool MainSectionCanScrollBackward { get; set; } = false; + [ObservableProperty] public partial bool MainSectionCanScrollForward { get; set; } = true; + [ObservableProperty] public partial bool MainSectionIsScrolling { get; set; } = true; + + [ObservableProperty] public partial Task? CaptureImagesTask { get; set; } = null; + + [ObservableProperty] public partial ObservableCollection Images { get; set; } = new(); + [ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false; + [ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = ""; + [ObservableProperty] public partial int ImagesSectionCurrentIndex { get; set; } = 0; + [ObservableProperty] public partial bool ImagesSectionCanScrollForward { get; set; } = false; + [ObservableProperty] public partial bool ImagesSectionCanScrollBackward { get; set; } = false; + [ObservableProperty] public partial bool ImagesSectionIsScrolling { get; set; } = true; + + public SeparatorCalibrationViewModel( + ILogger logger, + AmineContentCalibrationContext context, + AmineContentImageCapturerService imageCapturer, + ImageStorageService imageStorage + ) + { + _logger = logger; + _context = context; + _imageCapturer = imageCapturer; + _imageStorage = imageStorage; + _logger.LogInformation("Инициализация"); + + // Получаем записи из базы данных + Records = new ObservableCollection(_context.SeparatorRecords.Include(r => r.Images)); + + // Берём первую запись + SelectedRecord = Records.FirstOrDefault(); + // Если записей нет, то создаём пустую + // на самом деле это излишне, потому что при изменении SelectedRecord + // вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений + if (SelectedRecord is null) + EditingRecord = new() + { + Type = "", + Batch = "", + Images = new() + }; + else + EditingRecord = CopyRecord(SelectedRecord); + + _logger.LogInformation("Инициализировано"); + } + + /// + /// Вызывается при изменении .
+ /// Копирует в или издаёт пустую . + ///
+ partial void OnSelectedRecordChanged(SeparatorRecord? value) + { + // Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись) + // то создаём пустую запись + // TODO: Подумать над тем, чтобы сразу удалять несохранённые изображения из хранилища + if (SelectedRecord is null) + { + EditingRecord = new() + { + Type = "", + Batch = "", + Images = new() + }; + } + else + { + // Иначе копируем запись, отвязывая её от базы данных + EditingRecord = CopyRecord(SelectedRecord); + } + } + + /// + /// Вызывается при изменении .
+ /// Заполняет изображениями из . + ///
+ partial void OnEditingRecordChanged(SeparatorRecord value) + { + var images = value.Images + // Для каждого изображения + .Select(i => + { + // Находим путь через ImageStorageService + var path = _imageStorage.GetFullPath(i.ImagePath, "separator"); + // Если изображения не найдено, возвращаем null + if (path is null) + return null; + // Создаём AsyncImageRecordViewModel + return new AsyncImageRecordViewModel(path, i); + }) + // Оставляем только не null значения + .OfType(); + + // Создаём новую коллекцию изображений + // TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой + Images.Clear(); + Images = new(images); + } + + // Просто команды управления меню + [RelayCommand] private void OpenMenu() => MenuOpened = true; + [RelayCommand] private void CloseMenu() => MenuOpened = false; + [RelayCommand] private void ToggleMenu() => MenuOpened = !MenuOpened; + + // Команда создания новой записи + // вызывает цепочку действий по изменению EditingRecord и Images + [RelayCommand] + private void CreateRecord() + { + // Из-за того что, если создана новая запись, то SelectedRecord == null + // при простом присвоении SelectedRecord = null событие SelectedRecordChanged не вызовется, + // потому что событие срабатывает только при изменении значения, а значение было null и присвоилось ему null, + // поэтому если SelectedRecord == null, то нужно явно вызвать метод OnSelectedRecordChanged + if (SelectedRecord is not null) + SelectedRecord = null; + else + OnSelectedRecordChanged(null); + } + + // Команда удаления записи, создаёт новую запись и удаляет старую из списка и базы данных + [RelayCommand] + private void DeleteRecord() + { + // Сохраняем текущую запись + var deletedRecord = SelectedRecord; + // Создаём новую запись + CreateRecord(); + + // Если запись на была сохранена в базу данных, то ничего не делаем + if (deletedRecord is null) + return; + + // Если запись была в без данных, то + // она была и в Records, удаляем её из Records + Records.Remove(deletedRecord); + // Удаляем запись из базы данных и сохраняем изменения + // TODO: Подумать над тем, что возможно стоит не удалять запись полностью, а добавить флаг удаления, + // но тогда нужно будет сохранять и изображения этой записи, как в базе данных, так и на диске, + // тогда удалённые записи будут заполнять диск + // TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища + _context.SeparatorRecords.Remove(deletedRecord); + _context.SaveChanges(); + } + + // Команда сохранения записи, сохраняет новую запись или изменения в отредактированной + [RelayCommand] + private void SaveRecord() + { + // Если это новая запись + if (SelectedRecord is null) + { + // Если это новая запись, то копируем её + var newRecord = CopyRecord(EditingRecord); + + // Для каждого изображения в записи + for (int i = 0; i < newRecord.Images.Count(); i++) + { + var image = newRecord.Images[i]; + // Если изображение есть в базе данных, то пропускаем его + if (_context.Entry(image).State is not EntityState.Detached) + continue; + + // Если изображения нет в базе данных, то добавляем его в базу + var attachedImage = _context.ImageRecords.Add(image); + // Удаляем отвязанное от базы данных изображение из записи + newRecord.Images.RemoveAt(i); + // Добавляем привязанное изображение к записи + newRecord.Images.Insert(i, attachedImage.Entity); + } + // Если записи нет в базе данных + if (_context.Entry(newRecord).State is EntityState.Detached) + { + // То добавляем её в базу + var attachedRecord = _context.SeparatorRecords.Add(newRecord); + // Заменяем текущую запись на привязанную к базе данных + newRecord = attachedRecord.Entity; + } + + // Сохраняем изменения в базе данных + _context.SaveChanges(); + // Добавляем запись в список + Records.Add(newRecord); + // Выбираем запись + SelectedRecord = newRecord; + } + // Если эта запись есть в базе + else + { + // Переносим данные из редактируемой записи в выбранную + SelectedRecord.Type = EditingRecord.Type; + SelectedRecord.Batch = EditingRecord.Batch; + + // Находим изображения которые нужно удалить - + // те которые есть в выбранной записи, но нет в редактируемой + var imagesToRemove = SelectedRecord.Images + .Where(e => EditingRecord.Images.All(i => i.Id != e.Id)) + .ToList(); + + // Удаляем старые изображения + // TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища + foreach (var image in imagesToRemove) + SelectedRecord.Images.Remove(image); + + // Для каждого изображения в редактируемой записи + foreach (var editedImage in EditingRecord.Images) + { + // Находим изображение в выбранной записи (оно будет привязано к базе данных) + var existing = SelectedRecord.Images + .FirstOrDefault(i => i.Id == editedImage.Id); + + // Если изображение не найдено + if (existing is null) + { + // Копируем изображение в выбранную запись + SelectedRecord.Images.Add(CopyRecord(editedImage)); + } + // Если изображение найдено + else + { + // Изменяем данные изображения на данные изображения из редактируемой записи + existing.ImagePath = editedImage.ImagePath; + existing.VisibleIntensity = editedImage.VisibleIntensity; + existing.Uv365Intensity = editedImage.Uv365Intensity; + existing.Uv254Intensity = editedImage.Uv254Intensity; + } + } + + // Для каждого изображения в выбранной записи + for (int i = 0; i < SelectedRecord.Images.Count(); i++) + { + var image = SelectedRecord.Images[i]; + // Если изображение есть в базе данных, то пропускаем его + if (_context.Entry(image).State is not EntityState.Detached) + continue; + + // Если изображения нет в базе данных, то добавляем его в базу + var attachedImage = _context.ImageRecords.Add(image); + // Удаляем отвязанное от базы данных изображение из записи + SelectedRecord.Images.RemoveAt(i); + // Добавляем привязанное изображение к записи + SelectedRecord.Images.Insert(i, attachedImage.Entity); + } + // Если записи нет в базе данных + if (_context.Entry(SelectedRecord).State is EntityState.Detached) + { + // То добавляем её в базу + var attachedRecord = _context.SeparatorRecords.Add(SelectedRecord); + // Находим текущую запись в списке + var index = Records.IndexOf(SelectedRecord); + // Заменяем её в списке на привязанную к базе данных + Records[index] = attachedRecord.Entity; + // Заменяем выбранную запись на привязанную + SelectedRecord = attachedRecord.Entity; + } + + // Сохраняем изменения в базе данных + _context.SaveChanges(); + } + } + + // Команда изменения основной секции (информация <-> изображения) + [RelayCommand] private void SwitchMainSection() => MainSectionsCurrentIndex = MainSectionsCurrentIndex == 0 ? 1 : 0; + + // Команда навигации по изображениям + [RelayCommand] private void ImagesSectionPrevious() => ImagesSectionCurrentIndex--; + [RelayCommand] private void ImagesSectionNext() => ImagesSectionCurrentIndex++; + + // Команда съёмки изображений + [RelayCommand] + private void CaptureImage() + { + // Если съёмка уже начата, то ничего не делаем + if (CaptureImagesTask is not null) + return; + + // Создаём токен отмены задачи + _captureImagesTaskCts = new CancellationTokenSource(); + var cancellationToken = _captureImagesTaskCts.Token; + + // Запускаем получение изображений асинхронно + CaptureImagesTask = Task.Run(async () => + { + // Список полученных изображений + var imageRecords = new List(); + + // Обновляем данные в интерфейсе + // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а + // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан + _ = Dispatcher.UIThread.InvokeAsync(() => ImagesIsCapturing = true); + + // Получаем изображения + var task = _imageCapturer.CaptureImages(async (data) => + { + // Распаковываем данные + var (i, visible, uv365, uv254, image) = data; + // Выбрасываем исключение если задача отменена + cancellationToken.ThrowIfCancellationRequested(); + + // Обновляем данные в интерфейсе + // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а + // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан + _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {AmineContentImageCapturerService.IlluminatorIntensities.Count()}"); + + _ = Task.Run(async () => + { + // Кодируем изображение в png + var bytes = image.ImEncode(".png"); + // Сохраняем изображение в ImageStorageService + string fileName; + using (var stream = new MemoryStream(bytes)) + fileName = await _imageStorage.SaveAsync(stream, ".png", "separator", cancellationToken); + + // Освобождаем память изображения + image.Release(); + image = null; + bytes = null; + + // Добавляем изображение в список полученных + imageRecords.Add( + new ImageRecord() + { + ImagePath = fileName, + VisibleIntensity = visible, + Uv365Intensity = uv365, + Uv254Intensity = uv254, + } + ); + }); + }, cancellationToken); + + _ = task.ContinueWith(t => + { + if (t.Exception is not null) + { + _logger.LogError(t.Exception, "Исключение во время получения изображений"); + foreach (var ex in t.Exception.InnerExceptions ?? []) + _logger.LogError(ex, "Исключение во время получения изображений"); + } + var images = imageRecords + // Для каждого изображения + .Select(i => + { + // Находим путь через ImageStorageService + var path = _imageStorage.GetFullPath(i.ImagePath, "separator"); + // Если изображения не найдено, возвращаем null + if (path is null) + return null; + // Создаём AsyncImageRecordViewModel + return new AsyncImageRecordViewModel(path, i); + }) + // Оставляем только не null значения + .OfType(); + + // Обновляем данные в редактируемой коллекции + // Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а + // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан + _ = Dispatcher.UIThread.InvokeAsync(() => + { + EditingRecord.Images = imageRecords; + // Создаём новую коллекцию изображений + // TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой + Images = new(images); + }); + + // Сбрасываем состояние задачи съёмки изображений + // Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а + // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан + _ = Dispatcher.UIThread.InvokeAsync(() => + { + ImagesIsCapturing = false; + ImagesCapturingProgress = ""; + CaptureImagesTask = null; + }); + }); + }); + } + + // Команда отмены задачи съёмки изображений + [RelayCommand] private void CancelImageCapturing() => _captureImagesTaskCts?.Cancel(); + + /// + /// Копирует , отвязывая её от базы данных + /// + /// Скопированная + private static SeparatorRecord CopyRecord(SeparatorRecord source) + { + return new SeparatorRecord + { + Id = source.Id, + Type = source.Type, + Batch = source.Batch, + Images = source.Images + .Select(CopyRecord) + .ToList() + }; + } + /// + /// Копирует , отвязывая её от базы данных + /// + /// Скопированная + private static ImageRecord CopyRecord(ImageRecord source) + { + return new ImageRecord + { + Id = source.Id, + ImagePath = source.ImagePath, + VisibleIntensity = source.VisibleIntensity, + Uv365Intensity = source.Uv365Intensity, + Uv254Intensity = source.Uv254Intensity + }; } } diff --git a/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml b/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml index 1665db3..48375c8 100644 --- a/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml +++ b/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml @@ -3,11 +3,493 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:GSS2.ViewModels.AmineContent" + xmlns:converters="using:GSS2.UI.Core.Converters" + xmlns:avalonia_converters="using:Avalonia.Data.Converters" + xmlns:i="using:Avalonia.Xaml.Interactivity" + xmlns:ia="using:Avalonia.Xaml.Interactions.Custom" + xmlns:core="using:GSS2.UI.Core" + xmlns:core_vm="using:GSS2.UI.Core.ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" x:DataType="vm:SeparatorCalibrationViewModel"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +