using System.Data; 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; using GSS2.Core.Extensions; using GSS2.UI.Core.ViewModels; using GSS2.UI.Core.Services; namespace GSS2.ViewModels.AmineContent; public partial class SeparatorCalibrationViewModel : ViewModelBase { /// /// Внутренняя модель элементов меню /// public partial class MenuItemViewModel : ViewModelBase { [ObservableProperty] public partial int Id { get; set; } [ObservableProperty] public partial string Type { get; set; } public MenuItemViewModel(int id, string type) { Id = id; Type = type; } } /// /// Внутренняя модель записи /// public partial class RecordViewModel : ViewModelBase { /// /// /// [ObservableProperty] public partial int Id { get; set; } = 0; /// /// /// [ObservableProperty] public partial string Uuid { get; set; } = Guid.NewGuid().ToString(); /// /// /// [ObservableProperty] public partial DateTime CreateDateTime { get; set; } = DateTime.UtcNow; /// /// /// [ObservableProperty] public partial DateTime EditDateTime { get; set; } = DateTime.UtcNow; /// /// /// [ObservableProperty] public partial string Type { get; set; } = ""; /// /// /// [ObservableProperty] public partial string Comment { get; set; } = ""; /// /// Представление /// [ObservableProperty] public partial AsyncImageRecordViewModel Image { get; set; } = new(); /// /// Данные /// [ObservableProperty] public partial List Features { get; set; } = new(); } /// /// Логгер /// private readonly ILogger _logger; /// /// Контекст базы данных /// private readonly Context _context; /// /// Сервис для получения изображений с камеры /// private readonly IImageCapturerService _imageCapturer; /// /// ViewLocator для получения других View по их моделям /// private readonly DependencyInjectionViewLocator _viewLocator; /// /// Сервис навигации между экранами /// private readonly NavigationService _navigation; /// /// Задача съёмки изображений /// private Task? _сaptureImagesTask = null; /// /// Токен отмены задачи съёмки изображений /// private CancellationTokenSource? _captureImagesTaskCts = null; /// /// Меню открыто /// [ObservableProperty] public partial bool MenuOpened { get; set; } = false; /// /// Элементы меню /// [ObservableProperty] public partial ObservableCollection MenuItems { get; set; } = new(); /// /// Текущий выбранный элемент меню. Равен -1 если элемент не выбран /// [ObservableProperty] public partial int MenuSelectedIndex { get; set; } = -1; /// /// Запись /// [ObservableProperty] public partial RecordViewModel Record { get; set; } = new(); /// /// Текущий экран главной секции /// [ObservableProperty] public partial int MainSectionCurrentIndex { get; set; } = 0; /// /// Можно ли переходить на следующий экран главной секции /// [ObservableProperty] public partial bool MainSectionCanScrollBackward { get; set; } = false; /// /// Можно ли переходить на предыдущий экран главной секции /// [ObservableProperty] public partial bool MainSectionCanScrollForward { get; set; } = false; /// /// Происходит ли переход между экранами главной секции /// [ObservableProperty] public partial bool MainSectionIsScrolling { get; set; } = false; /// /// Текущий экран информационной секции /// [ObservableProperty] public partial int InfoSectionCurrentIndex { get; set; } = 0; /// /// Можно ли переходить на следующий экран информационной секции /// [ObservableProperty] public partial bool InfoSectionCanScrollBackward { get; set; } = false; /// /// Можно ли переходить на предыдущий экран информационной секции /// [ObservableProperty] public partial bool InfoSectionCanScrollForward { get; set; } = false; /// /// Происходит ли переход между экранами информационной секции /// [ObservableProperty] public partial bool InfoSectionIsScrolling { get; set; } = false; /// /// Проводится ли съёмка изображений /// [ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false; /// /// Прогресс съёмки изображений /// [ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = ""; /// /// Можно ли взаимодействовать с объектом /// [ObservableProperty] public partial bool CanInteract { get; set; } = true; public SeparatorCalibrationViewModel( ILogger logger, Context context, IImageCapturerService imageCapturer, DependencyInjectionViewLocator viewLocator, NavigationService navigation ) { // Устанавливаем readonly поля _logger = logger; _context = context; _imageCapturer = imageCapturer; _viewLocator = viewLocator; _navigation = navigation; _logger.LogInformation("Инициализация"); // Получаем записи меню из базы данных MenuItems = new ObservableCollection(_context.SeparatorRecords.Select(r => new MenuItemViewModel(r.Id, r.Type))); if (MenuItems.Count() > 0) // Если записи есть, MenuSelectedIndex = 0; // Выбираем первую запись else // Если записей нет, MenuSelectedIndex = -1; // Устанавливаем, что запись не выбрана // Подписываемся на изменения свойств для обновления CanScroll PropertyChanged += (_, ea) => { if (ea.PropertyName == nameof(MenuOpened) || ea.PropertyName == nameof(MainSectionIsScrolling) || ea.PropertyName == nameof(InfoSectionIsScrolling)) UpdateCanInteract(); }; _logger.LogInformation("Инициализировано"); } /// /// Вызывается при изменении /// partial void OnMenuSelectedIndexChanged(int value) { // Если значение выходит за верхнюю границу списка элементов меню, то логгируем как ошибку if (value >= MenuItems.Count()) _logger.LogError("Значение {}({}) больше количества элементов {}({})", nameof(MenuSelectedIndex), MenuSelectedIndex, nameof(MenuItems), MenuItems.Count()); // Если значение больше или равно 0, то значит выбран один из элементов меню if (value >= 0 && value < MenuItems.Count()) { _logger.LogTrace("Выбран элемент из списка: {}", value); // Находим запись в базе данных var selectedRecord = _context.SeparatorRecords.Find(MenuItems[value].Id); // Если запись не найдена if (selectedRecord is null) { // Логгируем как ошибку _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", MenuItems[value].Id, nameof(_context.SeparatorRecords)); var nonExistingIndex = MenuSelectedIndex; // Сохраняем несуществующий индекс // Устанавливаем, что запись не выбрана, этот метод вызовется повторно и заполнит поля пустыми значениями MenuSelectedIndex = -1; // Удаляем несуществующий элемент из списка MenuItems.RemoveAt(nonExistingIndex); return; } // Если запись найдена, то заполняем поля из базы данных // Заполняем простые поля Record.Id = selectedRecord.Id; Record.Uuid = selectedRecord.Uuid; Record.CreateDateTime = selectedRecord.CreateDateTime; Record.EditDateTime = selectedRecord.EditDateTime; Record.Type = selectedRecord.Type; Record.Comment = selectedRecord.Comment; if (selectedRecord.Features.Count() > 0) Record.Features = selectedRecord.Features.Chunk(AnalyzerService.FEATURES_LENGTH).Select(v => v.ToArray()).ToList(); else Record.Features = new List(); // Ищем изображение в базе данных var imageRecord = _context.ImageRecords.Find(selectedRecord.ImageId); // Если изображение не найдено в базе данных, то логгируем как ошибку if (imageRecord is null) _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", selectedRecord.ImageId, nameof(_context.ImageRecords)); // В любом случае заполняем изображение Record.Image.Data = imageRecord?.Base64 ?? ""; return; } // Если элемент не выбран, заполняем поля пустыми значениями _logger.LogTrace("Выбран новый элемент"); var dateTime = DateTime.UtcNow; Record.Id = 0; Record.Uuid = Guid.NewGuid().ToString(); Record.CreateDateTime = dateTime; Record.EditDateTime = dateTime; Record.Type = ""; Record.Comment = ""; Record.Image.Data = ""; Record.Features = new List(); } /// /// Обновить значение /// private void UpdateCanInteract() { CanInteract = !MenuOpened; CanInteract &= !MainSectionIsScrolling; CanInteract &= !InfoSectionIsScrolling; } // Команды управления меню /// /// Открыть меню /// [RelayCommand] private void OpenMenu() => MenuOpened = true; /// /// Закрыть меню /// [RelayCommand] private void CloseMenu() => MenuOpened = false; /// /// Переключить видимость меню /// [RelayCommand] private void ToggleMenu() => MenuOpened = !MenuOpened; /// /// Переключить главную секцию информация <-> изображения /// [RelayCommand] private void SwitchMainSection() => MainSectionCurrentIndex = MainSectionCurrentIndex == 0 ? 1 : 0; /// /// Переключить информационную секцию на следующую /// [RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--; /// /// Переключить информационную секцию на предыдущую /// [RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++; /// /// Создать новую запись /// [RelayCommand] private void CreateRecord() { _logger.LogInformation("Создание новой записи"); // Если уже создана новая запись, то MenuSelectedIndex может быть равен -1 и // при простом присвоении MenuSelectedIndex = -1 событие OnMenuSelectedIndexChanged не вызовется, // потому что событие срабатывает только при изменении значения, а значение было -1 и привилось ему -1, // поэтому если SelectedRecord != -1, то нужно явно вызвать метод OnMenuSelectedIndexChanged if (MenuSelectedIndex != -1) MenuSelectedIndex = -1; else OnMenuSelectedIndexChanged(-1); } /// /// Удалить выбранную запись из базы данных или очистить значения, если запись не выбрана /// [RelayCommand] private void DeleteRecord() { // TODO: Добавить определение и отображение количества затрагиваемых, записей которые будут повреждены при удалении этой записи try { _logger.LogTrace("Удаление записи: {}={}, {}={}", nameof(SeparatorRecord.Id), Record.Id, nameof(SeparatorRecord.Type), Record.Type); // Если запись не выбрана, то просто создаём новую запись if (MenuSelectedIndex < 0) { _logger.LogTrace("Запись не выбрана, создаём новую"); CreateRecord(); return; } // Создаём модель для экрана подтверждения var confirmViewModel = new DeleteConfirmViewModel(_navigation) { Question = "Вы уверены, что хотите удалить запись?" }; // Создаём коллбек который вызовется при экрана подтверждения void OnConfirmCloses(object? sender, bool result) { // Если пользователь отменил удаление, то выходим из коллбека if (!result) { _logger.LogTrace("Пользователь отменил удаление"); return; } // Если запись не выбрана, то просто создаём новую запись if (MenuSelectedIndex < 0) { _logger.LogTrace("Запись не выбрана, создаём новую"); CreateRecord(); return; } // Сохраняем индекс удалённый записи и находим её в базе данных var deletedMenuIndex = MenuSelectedIndex; var deletedRecordId = Record.Id; var deletedRecord = _context.SeparatorRecords.Find(deletedRecordId); // Создаём новую запись CreateRecord(); // Удаляем запись из списка MenuItems.RemoveAt(deletedMenuIndex); // Если удалённая запись не найдена в базе данных, то логгируем как ошибку if (deletedRecord is null) { _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", deletedRecordId, nameof(_context.SeparatorRecords)); return; } // Удаляем запись из базы данных и сохраняем изменения в базе данных _context.SeparatorRecords.Remove(deletedRecord); _context.SaveChanges(); _logger.LogTrace("Запись удалена"); // Отписываемся от события закрытия экрана подтверждения confirmViewModel.Closes -= OnConfirmCloses; } // Создаём View экрана подтверждения var confirmView = _viewLocator.Build(confirmViewModel); // Если View не найден, то логгируем как ошибку if (confirmView is null) { _logger.LogError("Не удалось найти View для экрана подтверждения"); return; } _logger.LogTrace("Задаём вопрос"); // Подписываемся на событие закрытия экрана подтверждения confirmViewModel.Closes += OnConfirmCloses; // Преходим на экран подтверждения, при закрытии он сам вернёт экран обратно на текущий _navigation.NavigateTo(confirmView); } catch (Exception ex) { _logger.LogError(ex, "Исключение при удалении записи"); } } /// /// Сохранить текущие значения в новую запись или перезаписать значения в выдранной записи /// [RelayCommand] private void SaveRecord() { try { _logger.LogTrace("Сохранение записи: {}={}, {}={}", nameof(SeparatorRecord.Id), Record.Id, nameof(SeparatorRecord.Type), Record.Type); // Если запись не выбрана if (MenuSelectedIndex < 0) { // Создаём запись изображения var imageId = 0; if (!string.IsNullOrEmpty(Record.Image.Data)) { _logger.LogTrace("Создаём новую запись изображения"); // Заполняем запись изображения var imageRecord = new ImageRecord(base64: Record.Image.Data); // Добавляем запись изображения в базу данных var attachedImageRecord = _context.ImageRecords.Attach(imageRecord); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись изображения сохранена"); imageId = attachedImageRecord.Entity.Id; } _logger.LogTrace("Создаём новую запись"); var dateTime = DateTime.UtcNow; // Заполняем запись var newRecord = new SeparatorRecord( uuid: Record.Uuid, createDateTime: dateTime, editDateTime: dateTime, type: Record.Type, comment: Record.Comment, imageId: imageId, features: Record.Features ); // Добавляем запись в базу данных var attachedRecord = _context.SeparatorRecords.Add(newRecord); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись сохранена"); // Добавляем новый элемент в меню MenuItems.Add(new MenuItemViewModel(attachedRecord.Entity.Id, attachedRecord.Entity.Type)); // Выбираем последний элемент из меню MenuSelectedIndex = MenuItems.Count() - 1; } // Если запись выбрана else { // Находим запись в базе данных var attachedRecord = _context.SeparatorRecords.Find(Record.Id); // Если запись не найдена, то логгируем как ошибку if (attachedRecord is null) { _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", Record.Id, nameof(_context.SeparatorRecords)); return; } _logger.LogTrace("Записываем изменения в базу данных"); // Ищем запись изображения в базе данных var attachedImageRecord = _context.ImageRecords.Find(attachedRecord.ImageId); // Если запись не найдена if (attachedImageRecord is null) { // Логгируем как ошибку _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", attachedRecord.ImageId, nameof(_context.ImageRecords)); // Если изображение есть if (!string.IsNullOrEmpty(Record.Image.Data)) { _logger.LogTrace("Создаём новую запись изображения"); // Заполняем запись изображения var imageRecord = new ImageRecord(base64: Record.Image.Data); // Добавляем запись изображения в базу данных attachedImageRecord = _context.ImageRecords.Attach(imageRecord).Entity; // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись изображения сохранена"); attachedRecord.ImageId = attachedImageRecord.Id; } } // Если запись найдена else { // Если изображение есть if (!string.IsNullOrEmpty(Record.Image.Data)) { _logger.LogTrace("Записываем изменения в запись изображения"); // Вносим изменения attachedImageRecord.Base64 = Record.Image.Data; // Сохраняем изменения в базе данных _context.SaveChanges(); } // Если изображения нет else { // Удаляем изображение из базы данных _context.ImageRecords.Remove(attachedImageRecord); // Сохраняем изменения в базе данных _context.SaveChanges(); attachedRecord.ImageId = 0; } } // Записываем изменения attachedRecord.EditDateTime = DateTime.UtcNow; attachedRecord.Type = Record.Type; attachedRecord.Comment = Record.Comment; attachedRecord.Features = Record.Features.SelectMany(fs => fs).ToList(); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись сохранена"); // Записываем изменения в меню _logger.LogTrace("Записываем изменения в меню"); MenuItems[MenuSelectedIndex].Type = Record.Type; } } catch (Exception ex) { _logger.LogError(ex, "Исключение при сохранении записи"); } } /// /// Снять изображения /// [RelayCommand] private void CaptureImage() { // Если съёмка уже начата, то логгируем как ошибку if (_сaptureImagesTask is not null) { _logger.LogError("Задача съёмки уже запущена"); return; } // Создаём токен отмены задачи _captureImagesTaskCts = new CancellationTokenSource(); var cancellationToken = _captureImagesTaskCts.Token; ImagesCapturingProgress = $"Получение изображений: 0 / {ImageCapturerService.IlluminatorIntensities.Count()}"; // Запускаем получение изображений асинхронно _сaptureImagesTask = 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(() => { // Выбрасываем исключение, если задача отменена cancellationToken.ThrowIfCancellationRequested(); // Сохраняем PNG изображение во временную папку var path = Path.Join(Path.GetTempPath(), $"gss2-calibration-separator-{visible}-{uv365}-{uv254}.png"); image.ImWrite(path); // Освобождаем память изображения image.Dispose(); // Добавляем данные изображения в список return new ImageData(path, visible, uv365, uv254); }, cancellationToken); // Добавляем задачу сохранения изображения в список imagesData.Add(imagesDataTask); }, cancellationToken); // Создаём задачу обработки изображений, // эта задача начнётся только после того как завершится // задача съёмки изображений и все задачи сохранения изображений var processTask = Task.WhenAll([captureTask, .. imagesData]).ContinueWith(async t => { // Выбрасываем исключение, если задача отменена cancellationToken.ThrowIfCancellationRequested(); // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что неизвестно как будет выполнятся эта задача, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Обработка изображений"); // Если во время выполнения задачи съёмки изображений произошло исключение, // то логгируем все исключения и выходим if (captureTask.Exception is not null) { _logger.LogError(captureTask.Exception, "Исключение во время получения изображения"); foreach (var ex in captureTask.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время получения изображения"); return; } // Если во время выполнения задач сохранения изображений произошло исключение, // то логгируем все исключения и выходим bool anyImageDataException = false; 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, "Исключение во время сохранения изображения"); anyImageDataException = true; } } if (anyImageDataException) return; // Если во время выполнения произошло исключение, // то логгируем все исключения и выходим if (t.Exception is not null) { _logger.LogError(t.Exception, "Исключение во время обработки изображений"); foreach (var ex in t.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время обработки изображений"); return; } // Вычисляем вектор признаков var result = AnalyzerService.GetFeatureVectors(imagesData.Select(i => i.Result)); // Если вектор признаков не вычислен, то логгируем как ошибку if (result is null) { _logger.LogError("Не удалось вычислить вектор признаков"); return; } // Распаковываем результат var (avgImage, roi, features) = result.Value; // Сохраняем вектор признаков Record.Features = features; // Обрабатываем среднее изображение 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); // Очищаем память среднего изображения avgImage.Dispose(); // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что неизвестно как будет выполнятся эта задача, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => Record.Image.Data = Convert.ToBase64String(avgData)); }, cancellationToken); // Создаём задачу которая выполнится после завершения всех других _ = Task.WhenAll([captureTask, .. imagesData, processTask]).ContinueWith(t => { // Если во время выполнения задачи съёмки изображений произошло исключение, // то логгируем все исключения и выходим if (captureTask.Exception is not null) { _logger.LogError(captureTask.Exception, "Исключение во время получения изображения"); foreach (var ex in captureTask.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время получения изображения"); return; } // Если во время выполнения задач сохранения изображений произошло исключение, // то логгируем все исключения и выходим bool anyImageDataException = false; 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, "Исключение во время сохранения изображения"); anyImageDataException = true; } } if (anyImageDataException) return; // Если во время выполнения задачи обработки изображений произошло исключение, // то логгируем все исключения и выходим if (processTask.Exception is not null) { _logger.LogError(processTask.Exception, "Исключение во время обработки изображений"); foreach (var ex in processTask.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время обработки изображений"); return; } // Если во время выполнения произошло исключение, // то логгируем все исключения и выходим if (t.Exception is not null) { _logger.LogError(t.Exception, "Исключение во время обработки изображений"); foreach (var ex in t.Exception.InnerExceptions ?? []) _logger.LogError(ex, "Исключение во время обработки изображений"); return; } // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что неизвестно как будет выполнятся эта задача, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => { // Сбрасываем состояние задачи съёмки изображений ImagesIsCapturing = false; ImagesCapturingProgress = ""; _сaptureImagesTask = null; }); }); }); } /// /// Отменить задачу съёмки изображения /// [RelayCommand] private void CancelImageCapturing() => _captureImagesTaskCts?.Cancel(); }