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; } } /// /// Логгер /// 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; /// /// Данные выбранного элемента /// private string? _selectedImageData = null; /// /// Данные выбранного элемента /// private List _selectedFeaturesData = new(); /// /// Меню открыто /// [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 int SelectedId { get; set; } = 0; /// /// выбранного элемента /// [ObservableProperty] public partial string SelectedUuid { get; set; } = Guid.NewGuid().ToString(); /// /// выбранного элемента /// [ObservableProperty] public partial string SelectedType { get; set; } = ""; /// /// Представление выбранного элемента /// [ObservableProperty] public partial AsyncImageRecordViewModel SelectedImage { 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 bool ImagesIsCapturing { get; set; } = false; /// /// Прогресс съёмки изображений /// [ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = ""; /// /// Можно ли производить перемещение между экранами /// [ObservableProperty] public partial bool CanScroll { get; set; } 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)) UpdateCanScroll(); }; _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 not null) { // Заполняем простые поля SelectedId = selectedRecord.Id; SelectedUuid = selectedRecord.Uuid; SelectedType = selectedRecord.Type; // Ищем изображение в базе данных var imageRecord = _context.ImageRecords.Find(selectedRecord.ImageId); // Если изображение не найдено в базе данных, то логгируем как ошибку if (imageRecord is null) _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", selectedRecord.ImageId, nameof(_context.ImageRecords)); // В любом случае заполняем изображение, оно заполнится null если запись не найдена _selectedImageData = imageRecord?.Base64; SelectedImage.Data = _selectedImageData; // Ищем все признаки в базе данных _selectedFeaturesData = _context.FeatureRecords .Where(r => r.SeparatorRecordId == SelectedId) .Select(r => r.Values.ToArray()) .ToList(); return; } // Если значение не найдено в базе данных else { // Логгируем как ошибку _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", MenuItems[value].Id, nameof(_context.BrandRecords)); var nonExistingIndex = MenuSelectedIndex; // Сохраняем несуществующий индекс // Устанавливаем, что запись не выбрана, этот метод вызовется повторно и заполнит поля пустыми значениями MenuSelectedIndex = -1; // Удаляем несуществующий элемент из списка MenuItems.RemoveAt(nonExistingIndex); return; } } // Если элемент не выбран, заполняем поля пустыми значениями _logger.LogTrace("Выбран новый элемент"); SelectedId = 0; SelectedUuid = Guid.NewGuid().ToString(); SelectedType = ""; SelectedImage.Data = null; } /// /// Обновить значение /// private void UpdateCanScroll() { CanScroll = !MenuOpened; CanScroll &= !MainSectionIsScrolling; CanScroll &= !ImagesIsCapturing; } // Команды управления меню /// /// Открыть меню /// [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 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), SelectedId, nameof(SeparatorRecord.Type), SelectedType); // Если запись не выбрана, то просто создаём новую запись 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 = SelectedId; var deletedRecord = _context.SeparatorRecords.Find(deletedRecordId); // Создаём новую запись CreateRecord(); // Удаляем запись из списка MenuItems.RemoveAt(deletedMenuIndex); // Если удалённая запись не найдена в базе данных, то логгируем как ошибку if (deletedRecord is null) { _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", deletedRecordId, nameof(_context.BrandRecords)); 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), SelectedId, nameof(SeparatorRecord.Type), SelectedType); // Если запись не выбрана if (MenuSelectedIndex < 0) { // Создаём запись изображения _logger.LogTrace("Создаём новую запись изображения"); // Заполняем запись изображения var imageRecord = new ImageRecord(base64: _selectedImageData ?? ""); // Добавляем запись изображения в базу данных var attachedImageRecord = _context.ImageRecords.Attach(imageRecord); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись изображения сохранена"); _logger.LogTrace("Создаём новую запись"); // Заполняем запись var newRecord = new SeparatorRecord( uuid: SelectedUuid, type: SelectedType, imageId: attachedImageRecord.Entity.Id, featureIds: [] ); // Добавляем запись в базу данных var attachedRecord = _context.SeparatorRecords.Add(newRecord); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись сохранена"); _logger.LogTrace("Создаём новые записи признаков"); // Заполняем записи признаков и добавляем их в базу данных var attachedFeatureRecords = new List(); foreach (var feature in _selectedFeaturesData) { var featureRecord = new FeatureRecord( values: feature, sampleRecordId: attachedRecord.Entity.Id ); var attachedFeatureRecord = _context.FeatureRecords.Attach(featureRecord); attachedFeatureRecords.Add(attachedFeatureRecord.Entity); } // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Записи признаков сохранены"); _logger.LogTrace("Заполняем признаки в сохранённой записи"); // Добавляем идентификаторы признаков в запись attachedRecord.Entity.FeatureIds.AddRange(attachedFeatureRecords.Select(r => r.Id)); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Сохранение записи завершено"); // Добавляем новый элемент в меню MenuItems.Add(new MenuItemViewModel(attachedRecord.Entity.Id, attachedRecord.Entity.Type)); // Выбираем последний элемент из меню MenuSelectedIndex = MenuItems.Count() - 1; } // Если запись выбрана else { // Находим запись в базе данных var attachedRecord = _context.SeparatorRecords.Find(SelectedId); // Если запись не найдена, то логгируем как ошибку if (attachedRecord is null) { _logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", SelectedId, 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)); _logger.LogTrace("Создаём новую запись изображения"); // Заполняем запись изображения var imageRecord = new ImageRecord(base64: _selectedImageData ?? ""); // Добавляем запись изображения в базу данных attachedImageRecord = _context.ImageRecords.Attach(imageRecord).Entity; // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись изображения сохранена"); } // Ищем записи признаков в базе данных var attachedFeatureRecords = _context.FeatureRecords.Where(r => r.SeparatorRecordId == SelectedId); // Если количество признаков в базе данных больше чем текущее if (attachedFeatureRecords.Count() > _selectedFeaturesData.Count()) { // Находим количество признаков которое нужно удалить var featuresRecordsToRemove = attachedFeatureRecords.Count() - _selectedFeaturesData.Count(); // Логгируем _logger.LogTrace("Количество записей признаков в базе данных больше чем текущее, удаление лишних записей: {} ({} - {})", featuresRecordsToRemove, attachedFeatureRecords.Count(), _selectedFeaturesData.Count()); // Удаляем необходимое количество признаков из конца attachedFeatureRecords.TakeLast(featuresRecordsToRemove); } // Заполняем все имеющиеся признаки в базе данных новыми значениями foreach (var (i, attachedFeatureRecord) in attachedFeatureRecords.Enumerate()) attachedFeatureRecord.Values = _selectedFeaturesData[i]; // Если количество признаков в безе данных меньше чем текущее if (attachedFeatureRecords.Count() < _selectedFeaturesData.Count()) { // Находим количество признаков которое нужно добавить var featuresRecordsToAdd = _selectedFeaturesData.Count() - attachedFeatureRecords.Count(); // Логгируем _logger.LogTrace("Количество записей признаков в базе данных меньше чем текущее, добавление недостающих записей: {} ({} - {})", featuresRecordsToAdd, _selectedFeaturesData.Count(), attachedFeatureRecords.Count()); // Заполняем записи недостающих признаков и добавляем их в базу данных for (var i = attachedFeatureRecords.Count(); i < _selectedFeaturesData.Count(); i++) { var featureRecord = new FeatureRecord( values: _selectedFeaturesData[i], sampleRecordId: attachedRecord.Id ); var attachedFeatureRecord = _context.FeatureRecords.Attach(featureRecord); attachedFeatureRecords.Append(attachedFeatureRecord.Entity); } } _context.SaveChanges(); _logger.LogTrace("Записи признаков сохранена"); // Если в данных есть изменения, то записываем их if (attachedRecord.Type != SelectedType) attachedRecord.Type = SelectedType; if (attachedRecord.ImageId != attachedImageRecord.Id) attachedRecord.ImageId = attachedImageRecord.Id; // Для признаков особый случай, они в любом случае будут перезаписаны, потому что так быстрее attachedRecord.FeatureIds = attachedFeatureRecords.Select(r => r.Id).ToList(); // Сохраняем изменения в базе данных _context.SaveChanges(); _logger.LogTrace("Запись сохранена"); // Записываем изменения в меню _logger.LogTrace("Записываем изменения в меню"); MenuItems[MenuSelectedIndex].Type = SelectedType; } } 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; // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => 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; // Сохраняем вектор признаков _selectedFeaturesData = 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); _selectedImageData = Convert.ToBase64String(avgData); // Очищаем память среднего изображения avgImage.Dispose(); // Обновляем данные в интерфейсе // Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке, // а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан _ = Dispatcher.UIThread.InvokeAsync(() => SelectedImage.Data = _selectedImageData); }, 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(); }