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; using GSS2.Core.Analysis.AmineContent; using GSS2.Core.Analysis.AmineContent.Database.Calibration; using GSS2.UI.Core.ViewModels; using GSS2.UI.Core.Services; namespace GSS2.ViewModels.AmineContent; public partial class SeparatorCalibrationViewModel : ViewModelBase { private readonly ILogger _logger; private readonly CalibrationContext _context; private readonly ImageCapturerService _imageCapturer; private readonly ImageStorageService _imageStorage; private readonly DependencyInjectionViewLocator _viewLocator; private readonly NavigationService _navigation; 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 MainSectionCurrentIndex { 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 int InfoSectionCurrentIndex { get; set; } = 0; [ObservableProperty] public partial bool InfoSectionCanScrollBackward { get; set; } = false; [ObservableProperty] public partial bool InfoSectionCanScrollForward { get; set; } = true; [ObservableProperty] public partial bool InfoSectionIsScrolling { get; set; } = true; [ObservableProperty] public partial Task? CaptureImagesTask { get; set; } = null; [ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false; [ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = ""; [ObservableProperty] public partial AsyncImageRecordViewModel? Image { get; set; } = null; public SeparatorCalibrationViewModel( ILogger logger, CalibrationContext context, ImageCapturerService imageCapturer, ImageStorageService imageStorage, DependencyInjectionViewLocator viewLocator, NavigationService navigation ) { _logger = logger; _context = context; _imageCapturer = imageCapturer; _imageStorage = imageStorage; _viewLocator = viewLocator; _navigation = navigation; _logger.LogInformation("Инициализация"); // Получаем записи из базы данных Records = new ObservableCollection(_context.SeparatorRecords); // Берём первую запись SelectedRecord = Records.FirstOrDefault(); // Если записей нет, то создаём пустую // на самом деле это излишне, потому что при изменении SelectedRecord // вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений if (SelectedRecord is null) EditingRecord = new() { Type = "", Batch = "", Image = "", VectorRecords = new() }; else EditingRecord = CopyRecord(SelectedRecord); _logger.LogInformation("Инициализировано"); } /// /// Вызывается при изменении .
/// Копирует в или издаёт пустую . ///
partial void OnSelectedRecordChanged(SeparatorRecord? value) { // Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись) // то создаём пустую запись if (SelectedRecord is null) { EditingRecord = new() { Type = "", Batch = "", Image = "", VectorRecords = new() }; } else { // Иначе копируем запись, отвязывая её от базы данных EditingRecord = CopyRecord(SelectedRecord); } } /// /// Вызывается при изменении .
/// Изменяет изображением из . ///
partial void OnEditingRecordChanged(SeparatorRecord value) { var imagePath = _imageStorage.GetFullPath(value.Image, "calibration-sample"); if (!File.Exists(imagePath)) { Image = null; return; } if (Image is null) Image = new AsyncImageRecordViewModel(imagePath); else Image.Path = imagePath; } // Просто команды управления меню [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 confirmViewModel = new DeleteConfirmViewModel(_navigation) { Question = "Вы уверены, что хотиите удаить запись?" }; if (SelectedRecord is null) { CreateRecord(); return; } confirmViewModel.Closes += (_, ea) => { if (ea != true) return; // Сохраняем текущую запись var deletedRecord = SelectedRecord; // Создаём новую запись CreateRecord(); // Если запись на была сохранена в базу данных, то ничего не делаем if (deletedRecord is null) return; // Если запись была в без данных, то // она была и в Records, удаляем её из Records Records.Remove(deletedRecord); // Удаляем запись из базы данных и сохраняем изменения // TODO: Подумать над тем, что возможно стоит не удалять запись полностью, а добавить флаг удаления, // но тогда нужно будет сохранять и изображения этой записи, как в базе данных, так и на диске, // тогда удалённые записи будут заполнять диск // TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища _context.SeparatorRecords.Remove(deletedRecord); _context.SaveChanges(); }; var convfirmView = _viewLocator.Build(confirmViewModel); if (convfirmView is not null) _navigation.NavigateTo(convfirmView); } // // Команда сохранения записи, сохраняет новую запись или изменения в отредактированной [RelayCommand] private void SaveRecord() { // Если это новая запись if (SelectedRecord is null) { // Копируем её var newRecord = CopyRecord(EditingRecord); // Для каждого вектора for (int i = 0; i < newRecord.VectorRecords.Count(); i++) { var vector = newRecord.VectorRecords[i]; // Добавляем вектор в базу var attachedRecord = _context.VectorRecords.Add(vector); // Удаляем отвязанный от базы данных вектор из записи newRecord.VectorRecords.RemoveAt(i); // Добавляем привязанный к базе данных вектор в запись newRecord.VectorRecords.Insert(i, attachedRecord.Entity); } { // То добавляем запись в базу 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; SelectedRecord.Image = EditingRecord.Image; SelectedRecord.VectorRecords = EditingRecord.VectorRecords; // Для каждого вектора в выбранной записи for (int i = 0; i < SelectedRecord.VectorRecords.Count(); i++) { var vector = SelectedRecord.VectorRecords[i]; // Если вектор есть в базе данных, то пропускаем его if (_context.Entry(vector).State is not EntityState.Detached) continue; // Если вектора нет в базе данных, то добавляем его в базу var attachedRecord = _context.VectorRecords.Add(vector); // Удаляем отвязанный от базы данных вектор из записи SelectedRecord.VectorRecords.RemoveAt(i); // Добавляем привязанный вектор к записи SelectedRecord.VectorRecords.Insert(i, attachedRecord.Entity); } // Сохраняем изменения в базе данных _context.SaveChanges(); } } // Команда изменения основной секции (информация <-> изображения) [RelayCommand] private void SwitchMainSection() => MainSectionCurrentIndex = MainSectionCurrentIndex == 0 ? 1 : 0; // Команда навигации по информации [RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--; [RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++; // Команда съёмки изображений [RelayCommand] private void CaptureImage() { // Если съёмка уже начата, то ничего не делаем if (CaptureImagesTask is not null) return; // Создаём токен отмены задачи _captureImagesTaskCts = new CancellationTokenSource(); var cancellationToken = _captureImagesTaskCts.Token; // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: 0 / {ImageCapturerService.IlluminatorIntensities.Count()}"); // Запускаем получение изображений асинхронно CaptureImagesTask = Task.Run(async () => { // Список полученных изображений var imagesData = new List>(); // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesIsCapturing = true); // Получаем изображения var captureTask = _imageCapturer.CaptureImages(async (data) => { // Распаковываем данные var (i, visible, uv365, uv254, image) = data; // Выбрасываем исключение если задача отменена cancellationToken.ThrowIfCancellationRequested(); // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {ImageCapturerService.IlluminatorIntensities.Count()}"); var imagesDataTask = Task.Run(() => { // Сохраняем png изображение во временную папку var path = Path.Join(Path.GetTempPath(), $"gss2-calibration-sample-{visible}-{uv365}-{uv254}.png"); image.ImWrite(path); // Освобождаем память изображения image.Release(); // Добавляем данные изображения в список return new ImageData(path, visible, uv365, uv254); }); imagesData.Add(imagesDataTask); }, cancellationToken); // Обрабатываем изображения var postTask = Task.WhenAll([captureTask, .. imagesData]).ContinueWith(async t => { // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Обработка изображений"); // Проверяем исключения задачи получения изображений if (t.Exception is not null) { _logger.LogError(t.Exception, "Исключение во время получения изображений"); foreach (var ex in t.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время получения изображений"); return; } // Проверяем исключения задач сохранения изображений foreach (var imageData in imagesData) if (imageData.Exception is not null) { _logger.LogError(imageData.Exception, "Исключение во время получения изображения"); foreach (var ex in imageData.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время получения изображения"); return; } // Вычисляем вектор признаков var result = AnalyzerService.GetFeatureVectors(imagesData.Select(i => i.Result)); // Если вектор признаков вычислен if (result is not null) { // Распаковываем результат var (avgImage, roi, features) = result.Value; _logger.LogInformation($"Длинна вектора признаков: {features.Count()}"); // Обрабатываем среднее изображение avgImage = new OpenCvSharp.Mat(avgImage, roi); OpenCvSharp.Cv2.Resize( avgImage, avgImage, new OpenCvSharp.Size( AnalyzerService.ANALYSIS_IMAGE_SIZE, AnalyzerService.ANALYSIS_IMAGE_SIZE ) ); // Кодируем и сохраняем среднее изображение OpenCvSharp.Cv2.ImEncode(".png", avgImage, out var avgData); string avgPath; using (var stream = new MemoryStream(avgData)) avgPath = await _imageStorage.SaveAsync(stream, ".png", "calibration-sample"); // Заполняем поля в редактируемой записи EditingRecord.Image = avgPath; EditingRecord.VectorRecords = features.Select(f => new VectorRecord { Values = f }).ToList(); // Обновляем данные в редактируемой записи // Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => OnEditingRecordChanged(EditingRecord)); } else { _logger.LogError("Ошибка при вычислении векторов признаков"); } }, cancellationToken); // Сбрасываем состояние задачи съёмки изображений _ = Task.WhenAll([captureTask, .. imagesData, postTask]).ContinueWith(t => { // Обновляем данные в интерфейсе // 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, Image = source.Image, VectorRecords = source.VectorRecords .Select(CopyRecord) .ToList() }; } /// /// Копирует , отвязывая её от базы данных /// /// Скопированная private static VectorRecord CopyRecord(VectorRecord source) { return new VectorRecord { Id = source.Id, Values = (float[])source.Values.Clone() }; } }