Files
GSS2Rework/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs
T

482 lines
24 KiB
C#

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 SampleCalibrationViewModel : ViewModelBase
{
private readonly ILogger<SampleCalibrationViewModel> _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<SampleRecord> Records { get; set; }
[ObservableProperty] public partial SampleRecord? SelectedRecord { get; set; }
[ObservableProperty] public partial SampleRecord 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 SampleCalibrationViewModel(
ILogger<SampleCalibrationViewModel> 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<SampleRecord>(_context.SampleRecords);
// Берём первую запись
SelectedRecord = Records.FirstOrDefault();
// Если записей нет, то создаём пустую
// на самом деле это излишне, потому что при изменении SelectedRecord
// вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений
if (SelectedRecord is null)
EditingRecord = new()
{
SampleName = "",
SampleComment = "",
SampleBrand = "",
SampleMass = -1.0,
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
MixtureName = "",
MixtureAmineContent = -1.0,
MixtureNormalRate = -1.0,
MixtureActualRate = -1.0,
MeasuredContent = -1.0,
Image = "",
VectorRecords = new()
};
else
EditingRecord = CopyRecord(SelectedRecord);
_logger.LogInformation("Инициализировано");
}
/// <summary>
/// Вызывается при изменении <see cref="SelectedRecord"/>.<br/>
/// Копирует <see cref="SelectedRecord"/> в <see cref="EditingRecord"/> или издаёт пустую <see cref="EditingRecord"/>.
/// </summary>
partial void OnSelectedRecordChanged(SampleRecord? value)
{
// Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись)
// то создаём пустую запись
if (SelectedRecord is null)
{
EditingRecord = new()
{
SampleName = "",
SampleComment = "",
SampleBrand = "",
SampleMass = -1.0,
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
MixtureName = "",
MixtureAmineContent = -1.0,
MixtureNormalRate = -1.0,
MixtureActualRate = -1.0,
MeasuredContent = -1.0,
Image = "",
VectorRecords = new()
};
}
else
{
// Иначе копируем запись, отвязывая её от базы данных
EditingRecord = CopyRecord(SelectedRecord);
}
}
/// <summary>
/// Вызывается при изменении <see cref="EditingRecord"/>.<br/>
/// Изменяет <see cref="Image"/> изображением из <see cref="EditingRecord"/>.
/// </summary>
partial void OnEditingRecordChanged(SampleRecord 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.SampleRecords.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.SampleRecords.Add(newRecord);
// Заменяем текущую запись на привязанную к базе данных
newRecord = attachedRecord.Entity;
}
// Сохраняем изменения в базе данных
_context.SaveChanges();
// Добавляем запись в список
Records.Add(newRecord);
// Выбираем запись
SelectedRecord = newRecord;
}
// Если эта запись есть в базе
else
{
// Переносим данные из редактируемой записи в выбранную
SelectedRecord.SampleName = EditingRecord.SampleName;
SelectedRecord.SampleComment = EditingRecord.SampleComment;
SelectedRecord.SampleBrand = EditingRecord.SampleBrand;
SelectedRecord.SampleMass = EditingRecord.SampleMass;
SelectedRecord.SamplingDateTime = EditingRecord.SamplingDateTime;
SelectedRecord.SamplingPlace = EditingRecord.SamplingPlace;
SelectedRecord.MixtureName = EditingRecord.MixtureName;
SelectedRecord.MixtureAmineContent = EditingRecord.MixtureAmineContent;
SelectedRecord.MixtureNormalRate = EditingRecord.MixtureNormalRate;
SelectedRecord.MixtureActualRate = EditingRecord.MixtureActualRate;
SelectedRecord.MeasuredContent = EditingRecord.MeasuredContent;
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<Task<ImageData>>();
// Обновляем данные в интерфейсе
// 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();
/// <summary>
/// Копирует <see cref="SampleRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="SampleRecord"/></returns>
private static SampleRecord CopyRecord(SampleRecord source)
{
return new SampleRecord
{
Id = source.Id,
SampleName = source.SampleName,
SampleComment = source.SampleComment,
SampleBrand = source.SampleBrand,
SampleMass = source.SampleMass,
SamplingDateTime = source.SamplingDateTime,
SamplingPlace = source.SamplingPlace,
MixtureName = source.MixtureName,
MixtureAmineContent = source.MixtureAmineContent,
MixtureNormalRate = source.MixtureNormalRate,
MixtureActualRate = source.MixtureActualRate,
MeasuredContent = source.MeasuredContent,
Image = source.Image,
VectorRecords = source.VectorRecords
.Select(CopyRecord)
.ToList()
};
}
/// <summary>
/// Копирует <see cref="VectorRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="VectorRecord"/></returns>
private static VectorRecord CopyRecord(VectorRecord source)
{
return new VectorRecord
{
Id = source.Id,
Values = (float[])source.Values.Clone()
};
}
}