forked from amkovkov/GranuSightSoftware2
1) GSS2.Core: - strip prefixes in GSS2.Core.Analysis.AmineContent namespace class names - add IEnumerable<double>.Variance extension - remake analysis + update database so not needed to store all every file, calibration data also stored in database, also currently capturing images stored in system temporary directory and rewriting every time 2) GSS.UI.Core: AsyncImageRecordViewModel make zoom and pans public 3) GSS: - remove image-storage-clear command - remove ResultsView and ResultsViewModel, results presented in analysis - rework calibration views - add analysis views - add text and number fields editors view - add confirmation view on danger buttons
491 lines
26 KiB
C#
491 lines
26 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 SeparatorCalibrationViewModel : ViewModelBase
|
|
{
|
|
private readonly ILogger<SeparatorCalibrationViewModel> _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<SeparatorRecord> 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<SeparatorCalibrationViewModel> 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<SeparatorRecord>(_context.SeparatorRecords);
|
|
|
|
// Берём первую запись
|
|
SelectedRecord = Records.FirstOrDefault();
|
|
// Если записей нет, то создаём пустую
|
|
// на самом деле это излишне, потому что при изменении SelectedRecord
|
|
// вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений
|
|
if (SelectedRecord is null)
|
|
EditingRecord = new()
|
|
{
|
|
Type = "",
|
|
Batch = "",
|
|
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(SeparatorRecord? value)
|
|
{
|
|
// Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись)
|
|
// то создаём пустую запись
|
|
if (SelectedRecord is null)
|
|
{
|
|
EditingRecord = new()
|
|
{
|
|
Type = "",
|
|
Batch = "",
|
|
Image = "",
|
|
VectorRecords = new()
|
|
};
|
|
}
|
|
else
|
|
{
|
|
// Иначе копируем запись, отвязывая её от базы данных
|
|
EditingRecord = CopyRecord(SelectedRecord);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Вызывается при изменении <see cref="EditingRecord"/>.<br/>
|
|
/// Изменяет <see cref="Image"/> изображением из <see cref="EditingRecord"/>.
|
|
/// </summary>
|
|
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];
|
|
// Если вектор есть в базе данных, то пропускаем его
|
|
if (_context.Entry(vector).State is not EntityState.Detached)
|
|
continue;
|
|
|
|
// Если вектора нет в базе данных, то добавляем его в базу
|
|
var attachedRecord = _context.VectorRecords.Add(vector);
|
|
// Удаляем отвязанное от базы данных изображение из записи
|
|
newRecord.VectorRecords.RemoveAt(i);
|
|
// Добавляем привязанное изображение к записи
|
|
newRecord.VectorRecords.Insert(i, attachedRecord.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;
|
|
SelectedRecord.Image = EditingRecord.Image;
|
|
|
|
// Удаляем старые векторы в записи
|
|
{
|
|
// Находим векторы которые нужно удалить -
|
|
// те которые есть в выбранной записи, но нет в редактируемой
|
|
var vectorsToRemove = SelectedRecord.VectorRecords
|
|
.Where(e => EditingRecord.VectorRecords.All(i => i.Id != e.Id))
|
|
.ToList();
|
|
|
|
// Удаляем старые векторы
|
|
foreach (var image in vectorsToRemove)
|
|
SelectedRecord.VectorRecords.Remove(image);
|
|
|
|
// Для каждого вектора в редактируемой записи
|
|
foreach (var editedImage in EditingRecord.VectorRecords)
|
|
{
|
|
// Находим изображение в выбранной записи (оно будет привязано к базе данных)
|
|
var existing = SelectedRecord.VectorRecords
|
|
.FirstOrDefault(i => i.Id == editedImage.Id);
|
|
|
|
// Если вектор не найден
|
|
// Копируем вектор в выбранную запись
|
|
if (existing is null)
|
|
SelectedRecord.VectorRecords.Add(CopyRecord(editedImage));
|
|
// Если вектор найден
|
|
// Изменяем данные вектора на данные вектора из редактируемой записи
|
|
else
|
|
existing.Values = editedImage.Values;
|
|
}
|
|
|
|
// Для каждого вектора в выбранной записи
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Если записи нет в базе данных
|
|
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() => 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;
|
|
// Обрабатываем среднее изображение
|
|
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="SeparatorRecord"/>, отвязывая её от базы данных
|
|
/// </summary>
|
|
/// <returns>Скопированная <see cref="SeparatorRecord"/></returns>
|
|
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()
|
|
};
|
|
}
|
|
/// <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()
|
|
};
|
|
}
|
|
}
|