feat: massive rework

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
This commit is contained in:
2026-05-13 12:56:32 +03:00
parent ec95eef375
commit 3cc4d21947
63 changed files with 4359 additions and 3961 deletions
@@ -1,11 +1,787 @@
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.Results;
using GSS2.Core.Extensions;
using GSS2.UI.Core.ViewModels;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
using LiveChartsCore.SkiaSharpView.Painting.Effects;
using GSS2.UI.Core.Services;
namespace GSS2.ViewModels.AmineContent;
public partial class AnalysisViewModel : ViewModelBase
{
public AnalysisViewModel()
public event EventHandler? UpdateCharts;
private readonly ILogger<AnalysisViewModel> _logger;
private readonly ResultsContext _context;
private readonly ImageCapturerService _imageCapturer;
private readonly ImageStorageService _imageStorage;
private readonly AnalyzerService _analyzer;
private readonly DependencyInjectionViewLocator _viewLocator;
private readonly NavigationService _navigation;
private CancellationTokenSource? _captureImagesTaskCts = null;
[ObservableProperty] public partial bool MenuOpened { get; set; } = false;
[ObservableProperty] public partial ObservableCollection<ResultRecord> Records { get; set; }
[ObservableProperty] public partial ResultRecord? SelectedRecord { get; set; }
[ObservableProperty] public partial ResultRecord 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;
[ObservableProperty] public partial AsyncImageRecordViewModel? MaskImage { get; set; } = null;
[ObservableProperty] public partial AsyncImageRecordViewModel? ResultImage { get; set; } = null;
[ObservableProperty] public partial bool ImageIsVisible { get; set; } = true;
[ObservableProperty] public partial bool MaskImageIsVisible { get; set; } = false;
[ObservableProperty] public partial bool ResultImageIsVisible { get; set; } = false;
[ObservableProperty] public partial float ImagesZoom { get; set; } = 1;
[ObservableProperty] public partial float ImagesPanX { get; set; } = 0;
[ObservableProperty] public partial float ImagesPanY { get; set; } = 0;
[ObservableProperty] public partial SolidColorPaint LegendTextPaint { get; set; } = new SolidColorPaint(SKColors.White);
[ObservableProperty] public partial ISeries[] MeanValuesHistogram { get; set; } = [];
[ObservableProperty] public partial Axis[] MeanValuesXAxis { get; set; } = [new Axis()];
[ObservableProperty] public partial Axis[] MeanValuesYAxis { get; set; } = [new Axis()];
[ObservableProperty] public partial ISeries[] VariancesHistogram { get; set; } = [];
[ObservableProperty] public partial Axis[] VariancesXAxis { get; set; } = [new Axis()];
[ObservableProperty] public partial Axis[] VariancesYAxis { get; set; } = [new Axis()];
public AnalysisViewModel(
ILogger<AnalysisViewModel> logger,
ResultsContext context,
ImageCapturerService imageCapturer,
ImageStorageService imageStorage,
AnalyzerService analyzer,
DependencyInjectionViewLocator viewLocator,
NavigationService navigation
)
{
_logger = logger;
_context = context;
_imageCapturer = imageCapturer;
_imageStorage = imageStorage;
_analyzer = analyzer;
_viewLocator = viewLocator;
_navigation = navigation;
_logger.LogInformation("Инициализация");
// Получаем записи из базы данных
Records = new ObservableCollection<ResultRecord>(_context.ResultRecords);
// Создаём пустые записи
SelectedRecord = null;
// На самом деле заполнять EditingRecord излишне, потому что при изменении SelectedRecord
// вызовется OnSelectedRecordChanged который сделает то же самое, но сделано это для избавления от предупреждений
EditingRecord = new()
{
SampleName = "",
SampleComment = "",
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
Image = "",
SeparatorType = "",
SampleBrand = "",
MaskImage = "",
ResultImage = "",
MinValue = -1,
MaxValue = -1,
ParticlesCount = -1,
MeanValue = -1,
MeanVariance = -1,
VarianceOfMean = -1,
MeanValues = [],
Variances = []
};
_logger.LogInformation("Инициализировано");
}
/// <summary>
/// Вызывается при изменении <see cref="SelectedRecord"/>.<br/>
/// Копирует <see cref="SelectedRecord"/> в <see cref="EditingRecord"/> или издаёт пустую <see cref="EditingRecord"/>.
/// </summary>
partial void OnSelectedRecordChanged(ResultRecord? value)
{
// Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись)
// то создаём пустую запись
if (SelectedRecord is null)
{
EditingRecord = new()
{
SampleName = "",
SampleComment = "",
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
Image = "",
SeparatorType = "",
SampleBrand = "",
MaskImage = "",
ResultImage = "",
MinValue = -1,
MaxValue = -1,
ParticlesCount = -1,
MeanValue = -1,
MeanVariance = -1,
VarianceOfMean = -1,
MeanValues = [],
Variances = []
};
}
else
{
// Иначе копируем запись, отвязывая её от базы данных
EditingRecord = CopyRecord(SelectedRecord);
}
}
/// <summary>
/// Вызывается при изменении <see cref="EditingRecord"/>.<br/>
/// Изменяет <see cref="Image"/> изображением из <see cref="EditingRecord"/>.
/// </summary>
partial void OnEditingRecordChanged(ResultRecord value)
{
var avgImagePath = _imageStorage.GetFullPath(value.Image, "result-avg");
if (File.Exists(avgImagePath))
{
if (Image is null)
Image = new AsyncImageRecordViewModel(avgImagePath);
else
Image.Path = avgImagePath;
}
else
Image = null;
var maskImagePath = _imageStorage.GetFullPath(value.MaskImage, "result-mask");
if (File.Exists(maskImagePath))
{
if (MaskImage is null)
MaskImage = new AsyncImageRecordViewModel(maskImagePath);
else
MaskImage.Path = maskImagePath;
}
else
MaskImage = null;
var resultImagePath = _imageStorage.GetFullPath(value.ResultImage, "result");
if (File.Exists(resultImagePath))
{
if (ResultImage is null)
ResultImage = new AsyncImageRecordViewModel(resultImagePath);
else
ResultImage.Path = resultImagePath;
}
else
ResultImage = null;
try
{
(MeanValuesHistogram, MeanValuesXAxis, MeanValuesYAxis) = BuildHistogram(
value.MeanValues,
0,
Math.Max(value.MeanValues.Count() > 0 ? value.MeanValues.Max() : 0, 1),
leftText: "Количество частиц",
bottomText: "Среднее знечение"
);
(VariancesHistogram, VariancesXAxis, VariancesYAxis) = BuildHistogram(
value.Variances,
0,
Math.Max(value.Variances.Count() > 0 ? value.Variances.Max() : 0, 1),
leftText: "Количество частиц",
bottomText: "Дисперсия"
);
}
catch (Exception ex)
{
_logger.LogError(ex, "");
}
}
// Просто команды управления меню
[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.ResultRecords.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);
// Если записи нет в базе данных
if (_context.Entry(newRecord).State is EntityState.Detached)
{
// То добавляем её в базу
var attachedRecord = _context.ResultRecords.Add(newRecord);
// Заменяем текущую запись на привязанную к базе данных
newRecord = attachedRecord.Entity;
}
// Сохраняем изменения в базе данных
_context.SaveChanges();
// Добавляем запись в список
Records.Add(newRecord);
// Выбираем запись
SelectedRecord = newRecord;
}
// Если эта запись есть в базе
else
{
// Переносим данные из редактируемой записи в выбранную
SelectedRecord.SampleName = EditingRecord.SampleName;
SelectedRecord.SampleComment = EditingRecord.SampleComment;
SelectedRecord.SamplingDateTime = EditingRecord.SamplingDateTime;
SelectedRecord.SamplingPlace = EditingRecord.SamplingPlace;
SelectedRecord.Image = EditingRecord.Image;
SelectedRecord.SeparatorType = EditingRecord.SeparatorType;
SelectedRecord.SampleBrand = EditingRecord.SampleBrand;
SelectedRecord.MaskImage = EditingRecord.MaskImage;
SelectedRecord.ResultImage = EditingRecord.ResultImage;
SelectedRecord.MeanValue = EditingRecord.MeanValue;
SelectedRecord.MeanVariance = EditingRecord.MeanVariance;
SelectedRecord.VarianceOfMean = EditingRecord.VarianceOfMean;
// Если записи нет в базе данных
if (_context.Entry(SelectedRecord).State is EntityState.Detached)
{
// То добавляем её в базу
var attachedRecord = _context.ResultRecords.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--;
UpdateCharts?.Invoke(this, new EventArgs());
}
[RelayCommand]
private void InfoSectionNext()
{
InfoSectionCurrentIndex++;
UpdateCharts?.Invoke(this, new EventArgs());
}
// Команда съёмки изображений
[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-result-{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;
}
// Инициализируем анализатор
if (!_analyzer.Initialized)
{
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Инициализация моделей");
await _analyzer.Initialize();
}
// Инициализируем анализатор
if (!_analyzer.Initialized)
{
_logger.LogError("Ошибка при инициализации анализатора");
return;
}
// Обновляем данные в редактируемой записи
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Анализ");
// Проводим анализ
var result = await _analyzer.Analyze(imagesData.Select(i => i.Result));
// Если анализ проведён
if (result is not null)
{
_logger.LogInformation("Анализ завершён");
// Распаковываем результат
var (avgImage, _, mask, contours, resultImage, separatorType, sampleBrand) = result.Value;
try
{
_logger.LogInformation("Анализ завершён [2]");
// Вычисляем минимальное и максимальное значение
OpenCvSharp.Cv2.MinMaxLoc(resultImage, out double minValue, out double maxValue, out _, out _, mask);
// Вычисляем среднее значение
var meanValue = OpenCvSharp.Cv2.Mean(resultImage, mask).Val0;
_logger.LogInformation("Миниальное значение: {}, Максимальное значние: {}", minValue, maxValue);
_logger.LogInformation("Колличество конутров: {}, Среднее значние: {}", contours.Count(), meanValue);
_logger.LogInformation("Анализ завершён [3]");
// Вычисляем среднее и дисперсию каждого контура
var means = new List<double>();
var variances = new List<double>();
// Создаём временную маску для каждого контура
var contourMask = new OpenCvSharp.Mat(
new OpenCvSharp.Size(
AnalyzerService.ANALYSIS_IMAGE_SIZE,
AnalyzerService.ANALYSIS_IMAGE_SIZE
),
OpenCvSharp.MatType.CV_8UC1,
new OpenCvSharp.Scalar(0)
);
// Проходим по каждому контуру
foreach (var contour in contours)
{
// Обнуляем маску
contourMask.SetTo(new OpenCvSharp.Scalar(0));
// Заполняем контур на маске
OpenCvSharp.Cv2.DrawContours(contourMask, [contour], -1, new OpenCvSharp.Scalar(255), -1);
// Вычисляем среднее и среднеквадратическое отклонение
OpenCvSharp.Cv2.MeanStdDev(resultImage, out var mean, out var stdDev, contourMask);
// Добавляем значения в лист
means.Add(mean.Val0);
variances.Add(Math.Pow(stdDev.Val0, 2));
}
contourMask.Release();
_logger.LogInformation("Анализ завершён [4]");
// Вычисляем среднюю дисперсию и дисперсию среднего
double meanVariance = variances.Average();
double varianceOfMean = means.Variance();
_logger.LogInformation("Средняя дивергенция: {}, Дивергенция среднего: {}", meanVariance, varianceOfMean);
_logger.LogInformation("Анализ завершён [5]");
// Кодируем и сохраняем среднее изображение
OpenCvSharp.Cv2.ImEncode(".png", avgImage, out var avgData);
string avgPath;
using (var stream = new MemoryStream(avgData))
avgPath = await _imageStorage.SaveAsync(stream, ".png", "result-avg");
// Освобождаем память среднего изображения
avgImage.Release();
avgData = null;
_logger.LogInformation("Анализ завершён [6]");
// Кодируем и сохраняем маску
OpenCvSharp.Cv2.ImEncode(".png", mask, out var maskData);
string maskPath;
using (var stream = new MemoryStream(maskData))
maskPath = await _imageStorage.SaveAsync(stream, ".png", "result-mask");
// Освобождаем память маски
mask.Release();
maskData = null;
_logger.LogInformation("Анализ завершён [7]");
// Преобразуем изображение (minValue...maxValue) -> (0...255)
resultImage.ConvertTo(resultImage, OpenCvSharp.MatType.CV_8UC1, 255 / (maxValue - minValue), 255 * minValue / (maxValue - minValue));
// Кодируем и сохраняем изображение результата
OpenCvSharp.Cv2.ImEncode(".png", resultImage, out var resultData);
string resultPath;
using (var stream = new MemoryStream(resultData))
resultPath = await _imageStorage.SaveAsync(stream, ".png", "result");
// Освобождаем память изображения результата
resultImage.Release();
resultData = null;
_logger.LogInformation("Анализ завершён [8]");
// Заполняем поля в редактируемой записи
EditingRecord.Image = avgPath;
EditingRecord.SeparatorType = separatorType;
EditingRecord.SampleBrand = sampleBrand;
EditingRecord.MaskImage = maskPath;
EditingRecord.ResultImage = resultPath;
EditingRecord.MinValue = minValue;
EditingRecord.MaxValue = maxValue;
EditingRecord.ParticlesCount = contours.Count();
EditingRecord.MeanValue = meanValue;
EditingRecord.MeanVariance = meanVariance;
EditingRecord.VarianceOfMean = varianceOfMean;
EditingRecord.MeanValues = means.ToArray();
EditingRecord.Variances = variances.ToArray();
// Обновляем данные в редактируемой записи
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => EditingRecord = CopyRecord(EditingRecord));
}
catch (Exception ex)
{
_logger.LogError(ex, "Исключение во время анализа");
}
}
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();
// Команды переключения изображений
[RelayCommand]
private void SwitchToImage()
{
ImageIsVisible = true;
MaskImageIsVisible = false;
ResultImageIsVisible = false;
}
[RelayCommand]
private void SwitchToMaskImage()
{
ImageIsVisible = false;
MaskImageIsVisible = true;
ResultImageIsVisible = false;
}
[RelayCommand]
private void SwitchToResultImage()
{
ImageIsVisible = false;
MaskImageIsVisible = false;
ResultImageIsVisible = true;
}
/// <summary>
/// Копирует <see cref="ResultRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="ResultRecord"/></returns>
private static ResultRecord CopyRecord(ResultRecord source)
{
return new ResultRecord
{
Id = source.Id,
SampleName = source.SampleName,
SampleComment = source.SampleComment,
SamplingDateTime = source.SamplingDateTime,
SamplingPlace = source.SamplingPlace,
Image = source.Image,
SeparatorType = source.SeparatorType,
SampleBrand = source.SampleBrand,
MaskImage = source.MaskImage,
ResultImage = source.ResultImage,
MinValue = source.MinValue,
MaxValue = source.MaxValue,
ParticlesCount = source.ParticlesCount,
MeanValue = source.MeanValue,
MeanVariance = source.MeanVariance,
VarianceOfMean = source.VarianceOfMean,
MeanValues = source.MeanValues,
Variances = source.Variances
};
}
private static (ISeries[] Series, Axis[] XAxes, Axis[] YAxes) EmptyHistogram()
{
return (Array.Empty<ISeries>(), [new Axis()], [new Axis()]);
}
private static (ISeries[] Series, Axis[] XAxes, Axis[] YAxes) BuildHistogram(IEnumerable<float> values, double? min = null, double? max = null, string leftText = "", string rightText = "", string bottomText = "", string barLegend = "", string lineLegend = "")
{
if (values == null || values.Count() == 0)
return EmptyHistogram();
var vals = values.Select(v => (double)v).ToArray();
double minVal = min ?? vals.Min();
double maxVal = max ?? vals.Max();
int binCount = 20;
double binSize = (maxVal - minVal) / binCount;
var binsPDF = new int[binCount];
var binsCDF = new float[binCount];
foreach (var v in vals)
{
int index = (int)((v - minVal) / binSize);
if (index >= binCount)
index = binCount - 1;
if (index < 0)
index = 0;
binsPDF[index]++;
for (int i = index; i < binCount; i++)
binsCDF[i] += 100.0f / values.Count();
}
// Подписи для оси X
var labels = new string[binCount];
for (int i = 0; i < binCount; i++)
{
double from = minVal + i * binSize;
double to = from + binSize;
labels[i] = $"{from:F2}";
}
return
(
new ISeries[]
{
new ColumnSeries<int>
{
Values = binsPDF,
Name = barLegend,
Fill = new SolidColorPaint(SKColors.White),
ScalesYAt = 0
},
new LineSeries<float>
{
Values = binsCDF,
Name = lineLegend,
Fill = new SolidColorPaint(SKColors.Transparent),
Stroke = new SolidColorPaint(SKColors.Red),
GeometryFill = new SolidColorPaint(SKColors.Red),
GeometryStroke = new SolidColorPaint(SKColors.Red),
GeometrySize = 8,
ScalesYAt = 1
}
},
new Axis[]
{
new Axis
{
Labels = labels,
LabelsRotation = 45,
TextSize = 14,
Name = bottomText,
NameTextSize = 14,
NamePaint=new SolidColorPaint(SKColors.White),
SeparatorsPaint = new SolidColorPaint(SKColors.White),
LabelsPaint = new SolidColorPaint(SKColors.White),
TicksPaint = new SolidColorPaint(SKColors.White),
SubticksPaint = new SolidColorPaint(SKColors.White)
}
},
new Axis[]
{
new Axis
{
TextSize = 14,
MinLimit = 0,
Name=leftText,
NameTextSize = 14,
NamePaint=new SolidColorPaint(SKColors.White),
Position = LiveChartsCore.Measure.AxisPosition.Start,
SeparatorsPaint = new SolidColorPaint(SKColors.White),
LabelsPaint = new SolidColorPaint(SKColors.White),
TicksPaint = new SolidColorPaint(SKColors.White),
SubticksPaint = new SolidColorPaint(SKColors.White)
},
new Axis
{
TextSize = 14,
MinLimit = 0f,
MaxLimit = 100f,
Name=rightText,
NameTextSize = 14,
NamePaint=new SolidColorPaint(SKColors.White),
Position = LiveChartsCore.Measure.AxisPosition.End,
SeparatorsPaint = new SolidColorPaint(SKColors.White)
{
PathEffect = new DashEffect([4, 4])
},
LabelsPaint = new SolidColorPaint(SKColors.White),
TicksPaint = new SolidColorPaint(SKColors.White),
SubticksPaint = new SolidColorPaint(SKColors.White)
}
}
);
}
private static (ISeries[] Series, Axis[] XAxes, Axis[] YAxes) BuildHistogram(IEnumerable<double> values, double? min = null, double? max = null, string leftText = "", string rightText = "", string bottomText = "", string barLegend = "", string lineLegend = "") =>
BuildHistogram(values.Select(v => (float)v), min, max, leftText, rightText, bottomText, barLegend, lineLegend);
}
@@ -1,11 +0,0 @@
using GSS2.UI.Core.ViewModels;
namespace GSS2.ViewModels.AmineContent;
public partial class ResultsViewModel : ViewModelBase
{
public ResultsViewModel()
{
}
}
@@ -8,28 +8,30 @@ 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.Core.Hardware;
using GSS2.UI.Core.ViewModels;
using System.Diagnostics;
using GSS2.UI.Core.Services;
namespace GSS2.ViewModels.AmineContent;
public partial class SampleCalibrationViewModel : ViewModelBase
{
private readonly ILogger<SampleCalibrationViewModel> _logger;
private readonly AmineContentCalibrationContext _context;
private readonly AmineContentImageCapturerService _imageCapturer;
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<CalibrationRecord> Records { get; set; }
[ObservableProperty] public partial ObservableCollection<SampleRecord> Records { get; set; }
[ObservableProperty] public partial CalibrationRecord? SelectedRecord { get; set; }
[ObservableProperty] public partial CalibrationRecord EditingRecord { 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;
@@ -43,45 +45,31 @@ public partial class SampleCalibrationViewModel : ViewModelBase
[ObservableProperty] public partial Task? CaptureImagesTask { get; set; } = null;
[ObservableProperty] public partial int ImagesSectionCurrentIndex { get; set; } = 0;
[ObservableProperty] public partial bool ImagesSectionCanScrollForward { get; set; } = false;
[ObservableProperty] public partial bool ImagesSectionCanScrollBackward { get; set; } = false;
[ObservableProperty] public partial bool ImagesSectionIsScrolling { get; set; } = true;
[ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false;
[ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = "";
[ObservableProperty] public partial ObservableCollection<AsyncImageRecordViewModel> SeparatorImages { get; set; } = new();
[ObservableProperty] public partial int SeparatorImagesSectionCurrentIndex { get; set; } = 0;
[ObservableProperty] public partial bool SeparatorImagesSectionCanScrollForward { get; set; } = false;
[ObservableProperty] public partial bool SeparatorImagesSectionCanScrollBackward { get; set; } = false;
[ObservableProperty] public partial bool SeparatorImagesSectionIsScrolling { get; set; } = true;
[ObservableProperty] public partial ObservableCollection<AsyncImageRecordViewModel> SampleImages { get; set; } = new();
[ObservableProperty] public partial int SampleImagesSectionCurrentIndex { get; set; } = 0;
[ObservableProperty] public partial bool SampleImagesSectionCanScrollForward { get; set; } = false;
[ObservableProperty] public partial bool SampleImagesSectionCanScrollBackward { get; set; } = false;
[ObservableProperty] public partial bool SampleImagesSectionIsScrolling { get; set; } = true;
[ObservableProperty] public partial AsyncImageRecordViewModel? Image { get; set; } = null;
public SampleCalibrationViewModel(
ILogger<SampleCalibrationViewModel> logger,
AmineContentCalibrationContext context,
AmineContentImageCapturerService imageCapturer,
ImageStorageService imageStorage
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<CalibrationRecord>(
_context.CalibrationRecords
.Include(r => r.SampleImages)
.Include(r => r.SeparatorImages)
);
Records = new ObservableCollection<SampleRecord>(_context.SampleRecords);
// Берём первую запись
SelectedRecord = Records.FirstOrDefault();
@@ -95,15 +83,15 @@ public partial class SampleCalibrationViewModel : ViewModelBase
SampleComment = "",
SampleBrand = "",
SampleMass = -1.0,
SamplingDateTime = DateTime.Now,
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
MixtureName = "",
MixtureAmineContent = -1.0,
MixtureNormalRate = -1.0,
MixtureActualRate = -1.0,
MeasuredContent = -1.0,
SampleImages = new(),
SeparatorImages = new()
Image = "",
VectorRecords = new()
};
else
EditingRecord = CopyRecord(SelectedRecord);
@@ -115,11 +103,10 @@ public partial class SampleCalibrationViewModel : ViewModelBase
/// Вызывается при изменении <see cref="SelectedRecord"/>.<br/>
/// Копирует <see cref="SelectedRecord"/> в <see cref="EditingRecord"/> или издаёт пустую <see cref="EditingRecord"/>.
/// </summary>
partial void OnSelectedRecordChanged(CalibrationRecord? value)
partial void OnSelectedRecordChanged(SampleRecord? value)
{
// Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись)
// то создаём пустую запись
// TODO: Подумать над тем, чтобы сразу удалять несохранённые изображения из хранилища
if (SelectedRecord is null)
{
EditingRecord = new()
@@ -128,15 +115,15 @@ public partial class SampleCalibrationViewModel : ViewModelBase
SampleComment = "",
SampleBrand = "",
SampleMass = -1.0,
SamplingDateTime = DateTime.Now,
SamplingDateTime = DateTime.UtcNow,
SamplingPlace = "",
MixtureName = "",
MixtureAmineContent = -1.0,
MixtureNormalRate = -1.0,
MixtureActualRate = -1.0,
MeasuredContent = -1.0,
SampleImages = new(),
SeparatorImages = new()
Image = "",
VectorRecords = new()
};
}
else
@@ -148,63 +135,21 @@ public partial class SampleCalibrationViewModel : ViewModelBase
/// <summary>
/// Вызывается при изменении <see cref="EditingRecord"/>.<br/>
/// Заполняет <see cref="Images"/> изображениями из <see cref="EditingRecord"/>.
/// Изменяет <see cref="Image"/> изображением из <see cref="EditingRecord"/>.
/// </summary>
partial void OnEditingRecordChanged(CalibrationRecord value)
partial void OnEditingRecordChanged(SampleRecord value)
{
// Обновляем изображения сепаратора
var imagePath = _imageStorage.GetFullPath(value.Image, "calibration-sample");
if (!File.Exists(imagePath))
{
var images = value.SeparatorImages
// Для каждого изображения
.Select(i =>
{
// Находим путь через ImageStorageService
string? path;
// Если ImagesSectionCurrentIndex == 0, то выбрана секция с изображениями сепаратора
if (ImagesSectionCurrentIndex == 0)
path = _imageStorage.GetFullPath(i.ImagePath, "separator");
// Если ImagesSectionCurrentIndex == 1, то выбрана секция с изображениями пробы
else if (ImagesSectionCurrentIndex == 1)
path = _imageStorage.GetFullPath(i.ImagePath, "sample");
else
throw new UnreachableException();
// Если изображения не найдено, возвращаем null
if (path is null)
return null;
// Создаём AsyncImageRecordViewModel
return new AsyncImageRecordViewModel(path, i);
})
// Оставляем только не null значения
.OfType<AsyncImageRecordViewModel>();
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
SeparatorImages.Clear();
SeparatorImages = new(images);
Image = null;
return;
}
// Обновляем изображения пробы
{
var images = value.SampleImages
// Для каждого изображения
.Select(i =>
{
// Находим путь через ImageStorageService
var path = _imageStorage.GetFullPath(i.ImagePath, "sample");
// Если изображения не найдено, возвращаем null
if (path is null)
return null;
// Создаём AsyncImageRecordViewModel
return new AsyncImageRecordViewModel(path, i);
})
// Оставляем только не null значения
.OfType<AsyncImageRecordViewModel>();
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
SampleImages.Clear();
SampleImages = new(images);
}
if (Image is null)
Image = new AsyncImageRecordViewModel(imagePath);
else
Image.Path = imagePath;
}
// Просто команды управления меню
@@ -217,7 +162,7 @@ public partial class SampleCalibrationViewModel : ViewModelBase
[RelayCommand]
private void CreateRecord()
{
// Из-за того что, если создана новая запись, то SelectedRecord == null
// Если уже создана новая запись, то SelectedRecord == null и
// при простом присвоении SelectedRecord = null событие SelectedRecordChanged не вызовется,
// потому что событие срабатывает только при изменении значения, а значение было null и присвоилось ему null,
// поэтому если SelectedRecord == null, то нужно явно вызвать метод OnSelectedRecordChanged
@@ -231,28 +176,47 @@ public partial class SampleCalibrationViewModel : ViewModelBase
[RelayCommand]
private void DeleteRecord()
{
// Сохраняем текущую запись
var deletedRecord = SelectedRecord;
// Создаём новую запись
CreateRecord();
// Если запись на была сохранена в базу данных, то ничего не делаем
if (deletedRecord is null)
var confirmViewModel = new DeleteConfirmViewModel(_navigation)
{
Question = "Вы уверены, что хотиите удаить запись?"
};
if (SelectedRecord is null)
{
CreateRecord();
return;
}
confirmViewModel.Closes += (_, ea) =>
{
if (ea != true)
return;
// Если запись была в без данных, то
// она была и в Records, удаляем её из Records
Records.Remove(deletedRecord);
// Удаляем запись из базы данных и сохраняем изменения
// TODO: Подумать над тем, что возможно стоит не удалять запись полностью, а добавить флаг удаления,
// но тогда нужно будет сохранять и изображения этой записи, как в базе данных, так и на диске,
// тогда удалённые записи будут заполнять диск
// TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища
_context.CalibrationRecords.Remove(deletedRecord);
_context.SaveChanges();
// Сохраняем текущую запись
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()
{
@@ -262,41 +226,26 @@ public partial class SampleCalibrationViewModel : ViewModelBase
// Если это новая запись, то копируем её
var newRecord = CopyRecord(EditingRecord);
// Для каждого изображения сепаратора в записи
for (int i = 0; i < newRecord.SeparatorImages.Count(); i++)
// Для каждого вектора
for (int i = 0; i < newRecord.VectorRecords.Count(); i++)
{
var image = newRecord.SeparatorImages[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
var vector = newRecord.VectorRecords[i];
// Если вектор есть в базе данных, то пропускаем его
if (_context.Entry(vector).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
// Если вектора нет в базе данных, то добавляем его в базу
var attachedRecord = _context.VectorRecords.Add(vector);
// Удаляем отвязанное от базы данных изображение из записи
newRecord.SeparatorImages.RemoveAt(i);
newRecord.VectorRecords.RemoveAt(i);
// Добавляем привязанное изображение к записи
newRecord.SeparatorImages.Insert(i, attachedImage.Entity);
}
// Для каждого изображения пробы в записи
for (int i = 0; i < newRecord.SampleImages.Count(); i++)
{
var image = newRecord.SampleImages[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
// Удаляем отвязанное от базы данных изображение из записи
newRecord.SampleImages.RemoveAt(i);
// Добавляем привязанное изображение к записи
newRecord.SampleImages.Insert(i, attachedImage.Entity);
newRecord.VectorRecords.Insert(i, attachedRecord.Entity);
}
// Если записи нет в базе данных
if (_context.Entry(newRecord).State is EntityState.Detached)
{
// То добавляем её в базу
var attachedRecord = _context.CalibrationRecords.Add(newRecord);
var attachedRecord = _context.SampleRecords.Add(newRecord);
// Заменяем текущую запись на привязанную к базе данных
newRecord = attachedRecord.Entity;
}
@@ -323,111 +272,51 @@ public partial class SampleCalibrationViewModel : ViewModelBase
SelectedRecord.MixtureNormalRate = EditingRecord.MixtureNormalRate;
SelectedRecord.MixtureActualRate = EditingRecord.MixtureActualRate;
SelectedRecord.MeasuredContent = EditingRecord.MeasuredContent;
SelectedRecord.Image = EditingRecord.Image;
// Удаляем старые изображения сепаратора в записи
// Удаляем старые векторы в записи
{
// Находим изображения которые нужно удалить -
// Находим векторы которые нужно удалить -
// те которые есть в выбранной записи, но нет в редактируемой
var imagesToRemove = SelectedRecord.SeparatorImages
.Where(e => EditingRecord.SeparatorImages.All(i => i.Id != e.Id))
var vectorsToRemove = SelectedRecord.VectorRecords
.Where(e => EditingRecord.VectorRecords.All(i => i.Id != e.Id))
.ToList();
// Удаляем старые изображения
// TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища
foreach (var image in imagesToRemove)
SelectedRecord.SeparatorImages.Remove(image);
// Удаляем старые векторы
foreach (var image in vectorsToRemove)
SelectedRecord.VectorRecords.Remove(image);
// Для каждого изображения в редактируемой записи
foreach (var editedImage in EditingRecord.SeparatorImages)
// Для каждого вектора в редактируемой записи
foreach (var editedImage in EditingRecord.VectorRecords)
{
// Находим изображение в выбранной записи (оно будет привязано к базе данных)
var existing = SelectedRecord.SeparatorImages
var existing = SelectedRecord.VectorRecords
.FirstOrDefault(i => i.Id == editedImage.Id);
// Если изображение не найдено
// Если вектор не найден
// Копируем вектор в выбранную запись
if (existing is null)
{
// Копируем изображение в выбранную запись
SelectedRecord.SeparatorImages.Add(CopyRecord(editedImage));
}
// Если изображение найдено
SelectedRecord.VectorRecords.Add(CopyRecord(editedImage));
// Если вектор найден
// Изменяем данные вектора на данные вектора из редактируемой записи
else
{
// Изменяем данные изображения на данные изображения из редактируемой записи
existing.ImagePath = editedImage.ImagePath;
existing.VisibleIntensity = editedImage.VisibleIntensity;
existing.Uv365Intensity = editedImage.Uv365Intensity;
existing.Uv254Intensity = editedImage.Uv254Intensity;
}
existing.Values = editedImage.Values;
}
// Для каждого изображения в выбранной записи
for (int i = 0; i < SelectedRecord.SeparatorImages.Count(); i++)
// Для каждого вектора в выбранной записи
for (int i = 0; i < SelectedRecord.VectorRecords.Count(); i++)
{
var image = SelectedRecord.SeparatorImages[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
var vector = SelectedRecord.VectorRecords[i];
// Если вектор есть в базе данных, то пропускаем его
if (_context.Entry(vector).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
var attachedRecord = _context.VectorRecords.Add(vector);
// Удаляем отвязанное от базы данных изображение из записи
SelectedRecord.SeparatorImages.RemoveAt(i);
SelectedRecord.VectorRecords.RemoveAt(i);
// Добавляем привязанное изображение к записи
SelectedRecord.SeparatorImages.Insert(i, attachedImage.Entity);
}
}
// Удаляем старые изображения пробы в записи
{
// Находим изображения которые нужно удалить -
// те которые есть в выбранной записи, но нет в редактируемой
var imagesToRemove = SelectedRecord.SampleImages
.Where(e => EditingRecord.SampleImages.All(i => i.Id != e.Id))
.ToList();
// Удаляем старые изображения
// TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища
foreach (var image in imagesToRemove)
SelectedRecord.SampleImages.Remove(image);
// Для каждого изображения в редактируемой записи
foreach (var editedImage in EditingRecord.SampleImages)
{
// Находим изображение в выбранной записи (оно будет привязано к базе данных)
var existing = SelectedRecord.SampleImages
.FirstOrDefault(i => i.Id == editedImage.Id);
// Если изображение не найдено
if (existing is null)
{
// Копируем изображение в выбранную запись
SelectedRecord.SampleImages.Add(CopyRecord(editedImage));
}
// Если изображение найдено
else
{
// Изменяем данные изображения на данные изображения из редактируемой записи
existing.ImagePath = editedImage.ImagePath;
existing.VisibleIntensity = editedImage.VisibleIntensity;
existing.Uv365Intensity = editedImage.Uv365Intensity;
existing.Uv254Intensity = editedImage.Uv254Intensity;
}
}
// Для каждого изображения в выбранной записи
for (int i = 0; i < SelectedRecord.SampleImages.Count(); i++)
{
var image = SelectedRecord.SampleImages[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
// Удаляем отвязанное от базы данных изображение из записи
SelectedRecord.SampleImages.RemoveAt(i);
// Добавляем привязанное изображение к записи
SelectedRecord.SampleImages.Insert(i, attachedImage.Entity);
SelectedRecord.VectorRecords.Insert(i, attachedRecord.Entity);
}
}
@@ -435,7 +324,7 @@ public partial class SampleCalibrationViewModel : ViewModelBase
if (_context.Entry(SelectedRecord).State is EntityState.Detached)
{
// То добавляем её в базу
var attachedRecord = _context.CalibrationRecords.Add(SelectedRecord);
var attachedRecord = _context.SampleRecords.Add(SelectedRecord);
// Находим текущую запись в списке
var index = Records.IndexOf(SelectedRecord);
// Заменяем её в списке на привязанную к базе данных
@@ -456,17 +345,6 @@ public partial class SampleCalibrationViewModel : ViewModelBase
[RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--;
[RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++;
// Команда навигации по информации
[RelayCommand] private void ImagesSectionSwitch() => ImagesSectionCurrentIndex = ImagesSectionCurrentIndex == 0 ? 1 : 0;
// Команда навигации по изображениям сепаратора
[RelayCommand] private void SeparatorImagesSectionPrevious() => SeparatorImagesSectionCurrentIndex--;
[RelayCommand] private void SeparatorImagesSectionNext() => SeparatorImagesSectionCurrentIndex++;
// Команда навигации по изображениям пробы
[RelayCommand] private void SampleImagesSectionPrevious() => SampleImagesSectionCurrentIndex--;
[RelayCommand] private void SampleImagesSectionNext() => SampleImagesSectionCurrentIndex++;
// Команда съёмки изображений
[RelayCommand]
private void CaptureImage()
@@ -479,19 +357,24 @@ public partial class SampleCalibrationViewModel : ViewModelBase
_captureImagesTaskCts = new CancellationTokenSource();
var cancellationToken = _captureImagesTaskCts.Token;
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: 0 / {ImageCapturerService.IlluminatorIntensities.Count()}");
// Запускаем получение изображений асинхронно
CaptureImagesTask = Task.Run(async () =>
{
// Список полученных изображений
var imageRecords = new List<ImageRecord>();
var imagesData = new List<Task<ImageData>>();
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesIsCapturing = true);
// Получаем изображения
var task = _imageCapturer.CaptureImages(async (data) =>
var captureTask = _imageCapturer.CaptureImages(async (data) =>
{
// Распаковываем данные
var (i, visible, uv365, uv254, image) = data;
@@ -499,104 +382,94 @@ public partial class SampleCalibrationViewModel : ViewModelBase
cancellationToken.ThrowIfCancellationRequested();
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {AmineContentImageCapturerService.IlluminatorIntensities.Count()}");
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {ImageCapturerService.IlluminatorIntensities.Count()}");
_ = Task.Run(async () =>
var imagesDataTask = Task.Run(() =>
{
// Кодируем изображение в png
var bytes = image.ImEncode(".png");
// Сохраняем изображение в ImageStorageService
string fileName;
// Если ImagesSectionCurrentIndex == 0, то выбрана секция с изображениями сепаратора
if (ImagesSectionCurrentIndex == 0)
using (var stream = new MemoryStream(bytes))
fileName = await _imageStorage.SaveAsync(stream, ".png", "separator", cancellationToken);
// Если ImagesSectionCurrentIndex == 1, то выбрана секция с изображениями пробы
else if (ImagesSectionCurrentIndex == 1)
using (var stream = new MemoryStream(bytes))
fileName = await _imageStorage.SaveAsync(stream, ".png", "sample", cancellationToken);
else
throw new UnreachableException();
// Сохраняем png изображение во временную папку
var path = Path.Join(Path.GetTempPath(), $"gss2-calibration-sample-{visible}-{uv365}-{uv254}.png");
image.ImWrite(path);
// Освобождаем память изображения
image.Release();
image = null;
bytes = null;
// Добавляем изображение в список полученных
imageRecords.Add(
new ImageRecord()
{
ImagePath = fileName,
VisibleIntensity = visible,
Uv365Intensity = uv365,
Uv254Intensity = uv254,
}
);
// Добавляем данные изображения в список
return new ImageData(path, visible, uv365, uv254);
});
imagesData.Add(imagesDataTask);
}, cancellationToken);
_ = task.ContinueWith(t =>
// Обрабатываем изображения
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;
}
var images = imageRecords
// Для каждого изображения
.Select(i =>
// Проверяем исключения задач сохранения изображений
foreach (var imageData in imagesData)
if (imageData.Exception is not null)
{
// Находим путь через ImageStorageService
string? path;
// Если ImagesSectionCurrentIndex == 0, то выбрана секция с изображениями сепаратора
if (ImagesSectionCurrentIndex == 0)
path = _imageStorage.GetFullPath(i.ImagePath, "separator");
// Если ImagesSectionCurrentIndex == 1, то выбрана секция с изображениями пробы
else if (ImagesSectionCurrentIndex == 1)
path = _imageStorage.GetFullPath(i.ImagePath, "sample");
else
throw new UnreachableException();
// Если изображения не найдено, возвращаем null
if (path is null)
return null;
_logger.LogError(imageData.Exception, "Исключение во время получения изображения");
foreach (var ex in imageData.Exception.InnerExceptions ?? [])
_logger.LogError(ex, "Исключение во время получения изображения");
return;
}
// Создаём AsyncImageRecordViewModel
return new AsyncImageRecordViewModel(path, i);
})
// Оставляем только не null значения
.OfType<AsyncImageRecordViewModel>();
// Обновляем данные в редактируемой коллекции
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() =>
// Вычисляем вектор признаков
var result = AnalyzerService.GetFeatureVectors(imagesData.Select(i => i.Result));
// Если вектор признаков вычислен
if (result is not null)
{
// Если ImagesSectionCurrentIndex == 0, то выбрана секция с изображениями сепаратора
if (ImagesSectionCurrentIndex == 0)
{
EditingRecord.SeparatorImages = imageRecords;
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
SeparatorImages = new(images);
}
// Если ImagesSectionCurrentIndex == 1, то выбрана секция с изображениями пробы
else if (ImagesSectionCurrentIndex == 1)
{
EditingRecord.SampleImages = imageRecords;
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
SampleImages = new(images);
}
else
throw new UnreachableException();
});
// Распаковываем результат
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
)
);
// Сбрасываем состояние задачи съёмки изображений
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Кодируем и сохраняем среднее изображение
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(() =>
{
@@ -612,12 +485,12 @@ public partial class SampleCalibrationViewModel : ViewModelBase
[RelayCommand] private void CancelImageCapturing() => _captureImagesTaskCts?.Cancel();
/// <summary>
/// Копирует <see cref="CalibrationRecord"/>, отвязывая её от базы данных
/// Копирует <see cref="SampleRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="CalibrationRecord"/></returns>
private static CalibrationRecord CopyRecord(CalibrationRecord source)
/// <returns>Скопированная <see cref="SampleRecord"/></returns>
private static SampleRecord CopyRecord(SampleRecord source)
{
return new CalibrationRecord
return new SampleRecord
{
Id = source.Id,
SampleName = source.SampleName,
@@ -631,27 +504,22 @@ public partial class SampleCalibrationViewModel : ViewModelBase
MixtureNormalRate = source.MixtureNormalRate,
MixtureActualRate = source.MixtureActualRate,
MeasuredContent = source.MeasuredContent,
SampleImages = source.SampleImages
.Select(CopyRecord)
.ToList(),
SeparatorImages = source.SeparatorImages
Image = source.Image,
VectorRecords = source.VectorRecords
.Select(CopyRecord)
.ToList()
};
}
/// <summary>
/// Копирует <see cref="ImageRecord"/>, отвязывая её от базы данных
/// Копирует <see cref="VectorRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="ImageRecord"/></returns>
private static ImageRecord CopyRecord(ImageRecord source)
/// <returns>Скопированная <see cref="VectorRecord"/></returns>
private static VectorRecord CopyRecord(VectorRecord source)
{
return new ImageRecord
return new VectorRecord
{
Id = source.Id,
ImagePath = source.ImagePath,
VisibleIntensity = source.VisibleIntensity,
Uv365Intensity = source.Uv365Intensity,
Uv254Intensity = source.Uv254Intensity
Values = (float[])source.Values.Clone()
};
}
}
@@ -8,19 +8,22 @@ 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.Core.Hardware;
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 AmineContentCalibrationContext _context;
private readonly AmineContentImageCapturerService _imageCapturer;
private readonly CalibrationContext _context;
private readonly ImageCapturerService _imageCapturer;
private readonly ImageStorageService _imageStorage;
private readonly DependencyInjectionViewLocator _viewLocator;
private readonly NavigationService _navigation;
private CancellationTokenSource? _captureImagesTaskCts = null;
@@ -35,31 +38,38 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
[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 ObservableCollection<AsyncImageRecordViewModel> Images { get; set; } = new();
[ObservableProperty] public partial bool ImagesIsCapturing { get; set; } = false;
[ObservableProperty] public partial string ImagesCapturingProgress { get; set; } = "";
[ObservableProperty] public partial int ImagesSectionCurrentIndex { get; set; } = 0;
[ObservableProperty] public partial bool ImagesSectionCanScrollForward { get; set; } = false;
[ObservableProperty] public partial bool ImagesSectionCanScrollBackward { get; set; } = false;
[ObservableProperty] public partial bool ImagesSectionIsScrolling { get; set; } = true;
[ObservableProperty] public partial AsyncImageRecordViewModel? Image { get; set; } = null;
public SeparatorCalibrationViewModel(
ILogger<SeparatorCalibrationViewModel> logger,
AmineContentCalibrationContext context,
AmineContentImageCapturerService imageCapturer,
ImageStorageService imageStorage
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.Include(r => r.Images));
Records = new ObservableCollection<SeparatorRecord>(_context.SeparatorRecords);
// Берём первую запись
SelectedRecord = Records.FirstOrDefault();
@@ -71,7 +81,8 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
{
Type = "",
Batch = "",
Images = new()
Image = "",
VectorRecords = new()
};
else
EditingRecord = CopyRecord(SelectedRecord);
@@ -87,14 +98,14 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
{
// Если выбранная запись отсутствует (в базе данных нет записей или создана новая запись)
// то создаём пустую запись
// TODO: Подумать над тем, чтобы сразу удалять несохранённые изображения из хранилища
if (SelectedRecord is null)
{
EditingRecord = new()
{
Type = "",
Batch = "",
Images = new()
Image = "",
VectorRecords = new()
};
}
else
@@ -106,29 +117,22 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
/// <summary>
/// Вызывается при изменении <see cref="EditingRecord"/>.<br/>
/// Заполняет <see cref="Images"/> изображениями из <see cref="EditingRecord"/>.
/// Изменяет <see cref="Image"/> изображением из <see cref="EditingRecord"/>.
/// </summary>
partial void OnEditingRecordChanged(SeparatorRecord value)
{
var images = value.Images
// Для каждого изображения
.Select(i =>
{
// Находим путь через ImageStorageService
var path = _imageStorage.GetFullPath(i.ImagePath, "separator");
// Если изображения не найдено, возвращаем null
if (path is null)
return null;
// Создаём AsyncImageRecordViewModel
return new AsyncImageRecordViewModel(path, i);
})
// Оставляем только не null значения
.OfType<AsyncImageRecordViewModel>();
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
Images.Clear();
Images = new(images);
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;
}
// Просто команды управления меню
@@ -141,7 +145,7 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
[RelayCommand]
private void CreateRecord()
{
// Из-за того что, если создана новая запись, то SelectedRecord == null
// Если уже создана новая запись, то SelectedRecord == null и
// при простом присвоении SelectedRecord = null событие SelectedRecordChanged не вызовется,
// потому что событие срабатывает только при изменении значения, а значение было null и присвоилось ему null,
// поэтому если SelectedRecord == null, то нужно явно вызвать метод OnSelectedRecordChanged
@@ -155,28 +159,47 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
[RelayCommand]
private void DeleteRecord()
{
// Сохраняем текущую запись
var deletedRecord = SelectedRecord;
// Создаём новую запись
CreateRecord();
// Если запись на была сохранена в базу данных, то ничего не делаем
if (deletedRecord is null)
var confirmViewModel = new DeleteConfirmViewModel(_navigation)
{
Question = "Вы уверены, что хотиите удаить запись?"
};
if (SelectedRecord is null)
{
CreateRecord();
return;
}
confirmViewModel.Closes += (_, ea) =>
{
if (ea != true)
return;
// Если запись была в без данных, то
// она была и в Records, удаляем её из Records
Records.Remove(deletedRecord);
// Удаляем запись из базы данных и сохраняем изменения
// TODO: Подумать над тем, что возможно стоит не удалять запись полностью, а добавить флаг удаления,
// но тогда нужно будет сохранять и изображения этой записи, как в базе данных, так и на диске,
// тогда удалённые записи будут заполнять диск
// TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища
_context.SeparatorRecords.Remove(deletedRecord);
_context.SaveChanges();
// Сохраняем текущую запись
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()
{
@@ -186,20 +209,20 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
// Если это новая запись, то копируем её
var newRecord = CopyRecord(EditingRecord);
// Для каждого изображения в записи
for (int i = 0; i < newRecord.Images.Count(); i++)
// Для каждого вектора
for (int i = 0; i < newRecord.VectorRecords.Count(); i++)
{
var image = newRecord.Images[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
var vector = newRecord.VectorRecords[i];
// Если вектор есть в базе данных, то пропускаем его
if (_context.Entry(vector).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
// Если вектора нет в базе данных, то добавляем его в базу
var attachedRecord = _context.VectorRecords.Add(vector);
// Удаляем отвязанное от базы данных изображение из записи
newRecord.Images.RemoveAt(i);
newRecord.VectorRecords.RemoveAt(i);
// Добавляем привязанное изображение к записи
newRecord.Images.Insert(i, attachedImage.Entity);
newRecord.VectorRecords.Insert(i, attachedRecord.Entity);
}
// Если записи нет в базе данных
if (_context.Entry(newRecord).State is EntityState.Detached)
@@ -223,57 +246,54 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
// Переносим данные из редактируемой записи в выбранную
SelectedRecord.Type = EditingRecord.Type;
SelectedRecord.Batch = EditingRecord.Batch;
SelectedRecord.Image = EditingRecord.Image;
// Находим изображения которые нужно удалить -
// те которые есть в выбранной записи, но нет в редактируемой
var imagesToRemove = SelectedRecord.Images
.Where(e => EditingRecord.Images.All(i => i.Id != e.Id))
.ToList();
// Удаляем старые изображения
// TODO: Подумать над тем, чтобы сразу удалять изображения из хранилища
foreach (var image in imagesToRemove)
SelectedRecord.Images.Remove(image);
// Для каждого изображения в редактируемой записи
foreach (var editedImage in EditingRecord.Images)
// Удаляем старые векторы в записи
{
// Находим изображение в выбранной записи (оно будет привязано к базе данных)
var existing = SelectedRecord.Images
.FirstOrDefault(i => i.Id == editedImage.Id);
// Находим векторы которые нужно удалить -
// те которые есть в выбранной записи, но нет в редактируемой
var vectorsToRemove = SelectedRecord.VectorRecords
.Where(e => EditingRecord.VectorRecords.All(i => i.Id != e.Id))
.ToList();
// Если изображение не найдено
if (existing is null)
// Удаляем старые векторы
foreach (var image in vectorsToRemove)
SelectedRecord.VectorRecords.Remove(image);
// Для каждого вектора в редактируемой записи
foreach (var editedImage in EditingRecord.VectorRecords)
{
// Копируем изображение в выбранную запись
SelectedRecord.Images.Add(CopyRecord(editedImage));
// Находим изображение в выбранной записи (оно будет привязано к базе данных)
var existing = SelectedRecord.VectorRecords
.FirstOrDefault(i => i.Id == editedImage.Id);
// Если вектор не найден
// Копируем вектор в выбранную запись
if (existing is null)
SelectedRecord.VectorRecords.Add(CopyRecord(editedImage));
// Если вектор найден
// Изменяем данные вектора на данные вектора из редактируемой записи
else
existing.Values = editedImage.Values;
}
// Если изображение найдено
else
// Для каждого вектора в выбранной записи
for (int i = 0; i < SelectedRecord.VectorRecords.Count(); i++)
{
// Изменяем данные изображения на данные изображения из редактируемой записи
existing.ImagePath = editedImage.ImagePath;
existing.VisibleIntensity = editedImage.VisibleIntensity;
existing.Uv365Intensity = editedImage.Uv365Intensity;
existing.Uv254Intensity = editedImage.Uv254Intensity;
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);
}
}
// Для каждого изображения в выбранной записи
for (int i = 0; i < SelectedRecord.Images.Count(); i++)
{
var image = SelectedRecord.Images[i];
// Если изображение есть в базе данных, то пропускаем его
if (_context.Entry(image).State is not EntityState.Detached)
continue;
// Если изображения нет в базе данных, то добавляем его в базу
var attachedImage = _context.ImageRecords.Add(image);
// Удаляем отвязанное от базы данных изображение из записи
SelectedRecord.Images.RemoveAt(i);
// Добавляем привязанное изображение к записи
SelectedRecord.Images.Insert(i, attachedImage.Entity);
}
// Если записи нет в базе данных
if (_context.Entry(SelectedRecord).State is EntityState.Detached)
{
@@ -295,9 +315,9 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
// Команда изменения основной секции (информация <-> изображения)
[RelayCommand] private void SwitchMainSection() => MainSectionCurrentIndex = MainSectionCurrentIndex == 0 ? 1 : 0;
// Команда навигации по изображениям
[RelayCommand] private void ImagesSectionPrevious() => ImagesSectionCurrentIndex--;
[RelayCommand] private void ImagesSectionNext() => ImagesSectionCurrentIndex++;
// Команда навигации по информации
[RelayCommand] private void InfoSectionPrevious() => InfoSectionCurrentIndex--;
[RelayCommand] private void InfoSectionNext() => InfoSectionCurrentIndex++;
// Команда съёмки изображений
[RelayCommand]
@@ -311,19 +331,24 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
_captureImagesTaskCts = new CancellationTokenSource();
var cancellationToken = _captureImagesTaskCts.Token;
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: 0 / {ImageCapturerService.IlluminatorIntensities.Count()}");
// Запускаем получение изображений асинхронно
CaptureImagesTask = Task.Run(async () =>
{
// Список полученных изображений
var imageRecords = new List<ImageRecord>();
var imagesData = new List<Task<ImageData>>();
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesIsCapturing = true);
// Получаем изображения
var task = _imageCapturer.CaptureImages(async (data) =>
var captureTask = _imageCapturer.CaptureImages(async (data) =>
{
// Распаковываем данные
var (i, visible, uv365, uv254, image) = data;
@@ -331,73 +356,94 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
cancellationToken.ThrowIfCancellationRequested();
// Обновляем данные в интерфейсе
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {AmineContentImageCapturerService.IlluminatorIntensities.Count()}");
_ = Dispatcher.UIThread.InvokeAsync(() => ImagesCapturingProgress = $"Получение изображений: {i + 1} / {ImageCapturerService.IlluminatorIntensities.Count()}");
_ = Task.Run(async () =>
var imagesDataTask = Task.Run(() =>
{
// Кодируем изображение в png
var bytes = image.ImEncode(".png");
// Сохраняем изображение в ImageStorageService
string fileName;
using (var stream = new MemoryStream(bytes))
fileName = await _imageStorage.SaveAsync(stream, ".png", "separator", cancellationToken);
// Сохраняем png изображение во временную папку
var path = Path.Join(Path.GetTempPath(), $"gss2-calibration-sample-{visible}-{uv365}-{uv254}.png");
image.ImWrite(path);
// Освобождаем память изображения
image.Release();
image = null;
bytes = null;
// Добавляем изображение в список полученных
imageRecords.Add(
new ImageRecord()
{
ImagePath = fileName,
VisibleIntensity = visible,
Uv365Intensity = uv365,
Uv254Intensity = uv254,
}
);
// Добавляем данные изображения в список
return new ImageData(path, visible, uv365, uv254);
});
imagesData.Add(imagesDataTask);
}, cancellationToken);
_ = task.ContinueWith(t =>
// Обрабатываем изображения
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;
}
var images = imageRecords
// Для каждого изображения
.Select(i =>
// Проверяем исключения задач сохранения изображений
foreach (var imageData in imagesData)
if (imageData.Exception is not null)
{
// Находим путь через ImageStorageService
var path = _imageStorage.GetFullPath(i.ImagePath, "separator");
// Если изображения не найдено, возвращаем null
if (path is null)
return null;
// Создаём AsyncImageRecordViewModel
return new AsyncImageRecordViewModel(path, i);
})
// Оставляем только не null значения
.OfType<AsyncImageRecordViewModel>();
_logger.LogError(imageData.Exception, "Исключение во время получения изображения");
foreach (var ex in imageData.Exception.InnerExceptions ?? [])
_logger.LogError(ex, "Исключение во время получения изображения");
return;
}
// Обновляем данные в редактируемой коллекции
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// а изменения данных в интерфейсе могут быть вызваны только из потока в котором интерфейс был создан
_ = Dispatcher.UIThread.InvokeAsync(() =>
// Вычисляем вектор признаков
var result = AnalyzerService.GetFeatureVectors(imagesData.Select(i => i.Result));
// Если вектор признаков вычислен
if (result is not null)
{
EditingRecord.Images = imageRecords;
// Создаём новую коллекцию изображений
// TODO: Подумать над тем, чтобы чистить старую коллекцию перед созданием новой
Images = new(images);
});
// Распаковываем результат
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
)
);
// Сбрасываем состояние задачи съёмки изображений
// Так же Dispatcher.UIThread.Invoke нужен потому-что этот метод может (и скорее всего будет) вызван в другом потоке,а
// Кодируем и сохраняем среднее изображение
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(() =>
{
@@ -423,24 +469,22 @@ public partial class SeparatorCalibrationViewModel : ViewModelBase
Id = source.Id,
Type = source.Type,
Batch = source.Batch,
Images = source.Images
Image = source.Image,
VectorRecords = source.VectorRecords
.Select(CopyRecord)
.ToList()
};
}
/// <summary>
/// Копирует <see cref="ImageRecord"/>, отвязывая её от базы данных
/// Копирует <see cref="VectorRecord"/>, отвязывая её от базы данных
/// </summary>
/// <returns>Скопированная <see cref="ImageRecord"/></returns>
private static ImageRecord CopyRecord(ImageRecord source)
/// <returns>Скопированная <see cref="VectorRecord"/></returns>
private static VectorRecord CopyRecord(VectorRecord source)
{
return new ImageRecord
return new VectorRecord
{
Id = source.Id,
ImagePath = source.ImagePath,
VisibleIntensity = source.VisibleIntensity,
Uv365Intensity = source.Uv365Intensity,
Uv254Intensity = source.Uv254Intensity
Values = (float[])source.Values.Clone()
};
}
}