From 3cc4d21947c7f7415d0027b171255aac1feeea81 Mon Sep 17 00:00:00 2001 From: Alek-ban Date: Wed, 13 May 2026 12:56:32 +0300 Subject: [PATCH] feat: massive rework 1) GSS2.Core: - strip prefixes in GSS2.Core.Analysis.AmineContent namespace class names - add IEnumerable.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 --- .../AmineContentAnalyzerService.cs | 1539 ----------------- .../Analysis/AmineContent/AnalyzerService.cs | 534 ++++++ .../AmineContentCalibrationContext.cs | 80 - .../Calibration/CalibrationContext.cs | 29 + ...actory.cs => CalibrationContextFactory.cs} | 10 +- .../Database/Calibration/ImageRecord.cs | 27 - ...20260313083001_AmineContentCalibration0.cs | 137 -- ...> 20260428223237_Calibration0.Designer.cs} | 162 +- .../Migrations/20260428223237_Calibration0.cs | 104 ++ ....cs => CalibrationContextModelSnapshot.cs} | 160 +- .../{CalibrationRecord.cs => SampleRecord.cs} | 40 +- .../Database/Calibration/SeparatorRecord.cs | 11 +- .../Database/Calibration/VectorRecord.cs | 17 + .../Results/AmineContentResultsContext.cs | 70 - ...313083009_AmineContentResults0.Designer.cs | 167 -- .../20260313083009_AmineContentResults0.cs | 126 -- .../20260429070705_Results0.Designer.cs | 123 ++ .../Migrations/20260429070705_Results0.cs | 51 + ...AmineContentResultsContextModelSnapshot.cs | 164 -- .../Migrations/ResultsContextModelSnapshot.cs | 120 ++ .../Database/Results/ResultRecord.cs | 53 + .../Database/Results/ResultsContext.cs | 12 + ...extFactory.cs => ResultsContextFactory.cs} | 10 +- ...urerService.cs => ImageCapturerService.cs} | 8 +- GSS2.Core/Analysis/AmineContent/ImageData.cs | 17 + GSS2.Core/DependencyInjectionHelper.cs | 11 +- GSS2.Core/Extensions/EnumerableExtensions.cs | 13 +- GSS2.Core/GSS2.Core.csproj | 1 + GSS2.Core/Hardware/UsbService.cs | 72 + .../{Hardware => }/ImageStorageService.cs | 23 +- GSS2.Core/tools/CreateMigrations.ps1 | 4 +- GSS2.Core/tools/CreateMigrations.sh | 4 +- GSS2.UI.Core/GSS2.UI.Core.csproj | 52 +- .../ViewModels/AsyncImageViewModel.cs | 16 +- GSS2/Application.axaml.cs | 4 +- GSS2/Commands/ImageStorageClear.cs | 160 -- GSS2/Commands/UI.cs | 6 +- GSS2/DependencyInjectionHelper.cs | 7 +- GSS2/GSS2.csproj | 3 +- GSS2/Program.cs | 1 - .../AmineContent/AnalysisViewModel.cs | 778 ++++++++- .../AmineContent/ResultsViewModel.cs | 11 - .../SampleCalibrationViewModel.cs | 522 +++--- .../SeparatorCalibrationViewModel.cs | 374 ++-- GSS2/ViewModels/AmineContentViewModel.cs | 6 - GSS2/ViewModels/DeleteConfirmViewModel.cs | 38 + GSS2/ViewModels/NumericKeyboardViewModel.cs | 36 + GSS2/ViewModels/TextKeyboardViewModel.cs | 37 + GSS2/Views/AmineContent/AnalysisView.axaml | 599 ++++++- GSS2/Views/AmineContent/AnalysisView.axaml.cs | 11 + GSS2/Views/AmineContent/ResultsView.axaml | 13 - GSS2/Views/AmineContent/ResultsView.axaml.cs | 11 - .../AmineContent/SampleCalibrationView.axaml | 508 +----- .../SeparatorCalibrationView.axaml | 237 +-- GSS2/Views/AmineContentView.axaml | 36 +- GSS2/Views/DeleteConfirmView.axaml | 64 + GSS2/Views/DeleteConfirmView.axaml.cs | 11 + GSS2/Views/MainView.axaml.cs | 1 + GSS2/Views/NumericKeyboardView.axaml | 129 ++ GSS2/Views/NumericKeyboardView.axaml.cs | 128 ++ GSS2/Views/TextKeyboardView.axaml | 422 +++++ GSS2/Views/TextKeyboardView.axaml.cs | 172 ++ GranuSightSoftware2.sln | 28 +- 63 files changed, 4359 insertions(+), 3961 deletions(-) delete mode 100644 GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs create mode 100644 GSS2.Core/Analysis/AmineContent/AnalyzerService.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContext.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContext.cs rename GSS2.Core/Analysis/AmineContent/Database/Calibration/{AmineContentCalibrationContextFactory.cs => CalibrationContextFactory.cs} (50%) delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/ImageRecord.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.cs rename GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/{20260313083001_AmineContentCalibration0.Designer.cs => 20260428223237_Calibration0.Designer.cs} (57%) create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.cs rename GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/{AmineContentCalibrationContextModelSnapshot.cs => CalibrationContextModelSnapshot.cs} (56%) rename GSS2.Core/Analysis/AmineContent/Database/Calibration/{CalibrationRecord.cs => SampleRecord.cs} (51%) create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Calibration/VectorRecord.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContext.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.Designer.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.Designer.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/AmineContentResultsContextModelSnapshot.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/ResultsContextModelSnapshot.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/ResultRecord.cs create mode 100644 GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContext.cs rename GSS2.Core/Analysis/AmineContent/Database/Results/{AmineContentResultsContextFactory.cs => ResultsContextFactory.cs} (50%) rename GSS2.Core/Analysis/AmineContent/{AmineContentImageCapturerService.cs => ImageCapturerService.cs} (95%) create mode 100644 GSS2.Core/Analysis/AmineContent/ImageData.cs create mode 100644 GSS2.Core/Hardware/UsbService.cs rename GSS2.Core/{Hardware => }/ImageStorageService.cs (80%) delete mode 100644 GSS2/Commands/ImageStorageClear.cs delete mode 100644 GSS2/ViewModels/AmineContent/ResultsViewModel.cs create mode 100644 GSS2/ViewModels/DeleteConfirmViewModel.cs create mode 100644 GSS2/ViewModels/NumericKeyboardViewModel.cs create mode 100644 GSS2/ViewModels/TextKeyboardViewModel.cs delete mode 100644 GSS2/Views/AmineContent/ResultsView.axaml delete mode 100644 GSS2/Views/AmineContent/ResultsView.axaml.cs create mode 100644 GSS2/Views/DeleteConfirmView.axaml create mode 100644 GSS2/Views/DeleteConfirmView.axaml.cs create mode 100755 GSS2/Views/NumericKeyboardView.axaml create mode 100755 GSS2/Views/NumericKeyboardView.axaml.cs create mode 100755 GSS2/Views/TextKeyboardView.axaml create mode 100755 GSS2/Views/TextKeyboardView.axaml.cs diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs deleted file mode 100644 index c5a76e9..0000000 --- a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs +++ /dev/null @@ -1,1539 +0,0 @@ -using System.Diagnostics; - -using GSS2.Core.Analysis.AmineContent.Database.Calibration; -using GSS2.Core.Hardware; -using GSS2.Core.Extensions; -using GSS2.Core.Utils; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.ML; -using Microsoft.ML.Data; - -using OpenCvSharp; - -namespace GSS2.Core.Analysis.AmineContent; - -public class AmineContentAnalyzerService -{ - public const int HISTOGRAM_ROWS = 500; - public const int HISTOGRAM_COLORS = 256; - public const int HISTOGRAM_CHANNELS = 3; - public const int HISTOGRAM_TOTAL_COUNT = 12; - public const int HISTOGRAM_VISIBLE_ONLY_COUNT = 3; - public const int HISTOGRAM_UV_COUNT = 9; - public const int SEPARATOR_TYPE_FEATURES_LENGTH = (HISTOGRAM_ROWS * HISTOGRAM_COLORS * HISTOGRAM_CHANNELS + 3) * HISTOGRAM_TOTAL_COUNT; - public const int SAMPLE_BRAND_FEATURES_LENGTH = (HISTOGRAM_COLORS * HISTOGRAM_CHANNELS + 1) * HISTOGRAM_VISIBLE_ONLY_COUNT; - public const int SAMPLE_AMINE_CONTENT_FEATURES_LENGTH = (HISTOGRAM_CHANNELS) * HISTOGRAM_UV_COUNT + 1; - public const float CONTOUR_MAX_SIZE = 100; - public const float CONTOUR_MAX_AREA = (float)Math.PI * (CONTOUR_MAX_SIZE * CONTOUR_MAX_SIZE) / 4.0f; - public const float CONTOUR_MIN_SIZE = 12; - public const float CONTOUR_MIN_AREA = (float)Math.PI * (CONTOUR_MIN_SIZE * CONTOUR_MIN_SIZE) / 4.0f / 4.0f; - private const string _cachePath = "./cache"; - - private class SeparatorTypeData - { - [ColumnName("Label")] - public required int Id { get; set; } - [ColumnName("Features")] - [VectorType(SEPARATOR_TYPE_FEATURES_LENGTH)] - public required float[] Features { get; set; } - } - private class SeparatorTypePredictionResult - { - [ColumnName("PredictedLabel")] - public int Id { get; set; } - public float Probability { get; set; } - public float[] Score { get; set; } - - public SeparatorTypePredictionResult() - { - Id = -1; - Probability = 0; - Score = new float[2]; - } - } - - private class SampleBrandData - { - [ColumnName("Label")] - public required int Id { get; set; } - [ColumnName("Features")] - [VectorType(SAMPLE_BRAND_FEATURES_LENGTH)] - public required float[] Features { get; set; } - } - private class SampleBrandPredictionResult - { - [ColumnName("PredictedLabel")] - public int Id { get; set; } - public float Probability { get; set; } - public float[] Score { get; set; } - - public SampleBrandPredictionResult() - { - Id = -1; - Probability = 0; - Score = new float[2]; - } - } - - private class SampleAmineContentClusterizationData - { - [ColumnName("Label")] - public required bool IsProcessed { get; set; } - [ColumnName("Features")] - [VectorType(SAMPLE_AMINE_CONTENT_FEATURES_LENGTH)] - public required float[] Features { get; set; } - } - private class SampleAmineContentClusterizationPredictionResult - { - [ColumnName("PredictedLabel")] - public bool IsProcessed { get; set; } - public float Probability { get; set; } - public float Score { get; set; } - - public SampleAmineContentClusterizationPredictionResult() - { - IsProcessed = false; - Probability = 0; - Score = 0; - } - } - - private class SampleAmineContentRegressionData - { - [ColumnName("Label")] - public required float AmineContent { get; set; } - [ColumnName("Features")] - [VectorType(SAMPLE_AMINE_CONTENT_FEATURES_LENGTH)] - public required float[] Features { get; set; } - } - private class SampleAmineContentRegressionPredictionResult - { - [ColumnName("Score")] - public float AmineContent { get; set; } - - public SampleAmineContentRegressionPredictionResult() - { - AmineContent = -1; - } - } - - private readonly ILogger _logger; - private readonly AmineContentCalibrationContext _calibrationContext; - private readonly ImageStorageService _imageStorage; - - private MLContext? _ml = null; - private PredictionEngine? _separatorTypePredictionEngine = null; - private PredictionEngine? _sampleBrandPredictionEngine = null; - private PredictionEngine? _sampleAmineContentRegressionPredictionEngine = null; - private PredictionEngine? _sampleAmineContentClusterizationPredictionEngine = null; - - public bool Initialized { get; private set; } = false; - - public AmineContentAnalyzerService(ILogger logger, AmineContentCalibrationContext calibrationContext, ImageStorageService imageStorage) - { - _logger = logger; - _calibrationContext = calibrationContext; - _imageStorage = imageStorage; - } - - public async Task Initialize(CancellationToken cancellationToken = default) - { - Initialized = false; - - await InitializeMl(cancellationToken); - - Initialized = true; - } - private async Task InitializeMl(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - _ml = new MLContext(); - _ml.Log += (_, ea) => - { - if (ea.Kind >= Microsoft.ML.Runtime.ChannelMessageKind.Info) - _logger.LogInformation("ML Log: [{}] {}", ea.Kind, ea.Message); - }; - - await InitializeMlSeparatorTypePredictor(cancellationToken); - await InitializeMlSampleBrandPredictor(cancellationToken); - await InitializeMlSampleAmineContentRegressionPredictor(cancellationToken); - // await InitializeMlSampleAmineContentClusterizationPredictor(cancellationToken); - } - - private async Task InitializeMlSeparatorTypePredictor(CancellationToken cancellationToken) - { - _logger.LogInformation("Initializing separator type predictor"); - - if (_ml is null) - throw new InvalidOperationException(); - - cancellationToken.ThrowIfCancellationRequested(); - - IEnumerable TrainDataGenerator() - { - _logger.LogInformation("Preparing train data"); - foreach (var separatorRecord in _calibrationContext.SeparatorRecords.Include(r => r.Images)) - { - var imagePaths = GetImageRecordsFilePaths(separatorRecord.Images); - - var roi = CalculateSeparatorRoi(imagePaths, 5, cancellationToken); - if (roi is null) - continue; - - var features = CreateSeparatorTypeFeatureVector(separatorRecord.Images, roi.Value, cancellationToken); - - yield return new SeparatorTypeData - { - Id = separatorRecord.Id, - Features = features - }; - } - } - - cancellationToken.ThrowIfCancellationRequested(); - - var modelPath = "separator_predictor.zip"; - if (File.Exists(modelPath)) - { - var model = _ml.Model.Load(modelPath, out _); - _separatorTypePredictionEngine = _ml.Model.CreatePredictionEngine(model); - (model as IDisposable)?.Dispose(); - } - else - { - _logger.LogInformation("Building pipeline"); - var pipeline = _ml.Transforms.Conversion.MapValueToKey("Label") - .Append(_ml.MulticlassClassification.Trainers.NaiveBayes()) - .Append(_ml.Transforms.Conversion.MapKeyToValue("PredictedLabel")); - var trainData = _ml.Data.LoadFromEnumerable(TrainDataGenerator()); - - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogInformation("Training model"); - var model = pipeline.Fit(trainData); - _ml.Model.Save(model, trainData.Schema, modelPath); - - _separatorTypePredictionEngine = _ml.Model.CreatePredictionEngine(model); - model.Dispose(); - } - } - private float[] CreateSeparatorTypeFeatureVector(IEnumerable imageRecords, Rect roi, CancellationToken cancellationToken) - { - var features = new float[SEPARATOR_TYPE_FEATURES_LENGTH]; - int i = 0; - - foreach (var imageRecord in imageRecords - .OrderBy(r => r.VisibleIntensity) - .OrderBy(r => r.Uv365Intensity) - .OrderBy(r => r.Uv254Intensity) - ) - { - var imagePath = _imageStorage.GetFullPath(imageRecord.ImagePath); - if (imagePath is null) - throw new Exception($"Image not found {imageRecord.ImagePath}"); - var histogram = CalculateRowHistogram(imagePath, roi, cancellationToken); - - features[i++] = (float)imageRecord.VisibleIntensity; - features[i++] = (float)imageRecord.Uv365Intensity; - features[i++] = (float)imageRecord.Uv254Intensity; - for (int r = 0; r < HISTOGRAM_ROWS; r++) - for (int c = 0; c < HISTOGRAM_COLORS; c++) - for (int ch = 0; ch < HISTOGRAM_CHANNELS; ch++) - features[i++] = histogram[r, c, ch]; - } - - return features; - } - private async Task InitializeMlSampleBrandPredictor(CancellationToken cancellationToken) - { - _logger.LogInformation("Initializing sample brand predictor"); - - if (_ml is null) - throw new InvalidOperationException(); - - cancellationToken.ThrowIfCancellationRequested(); - - IEnumerable TrainDataGenerator() - { - _logger.LogInformation("Preparing train data"); - foreach (var calibrationRecord in _calibrationContext.CalibrationRecords - .Include(r => r.SampleImages) - .Include(r => r.SeparatorImages) - ) - { - cancellationToken.ThrowIfCancellationRequested(); - - var sampleImagePaths = GetImageRecordsFilePaths(calibrationRecord.SampleImages); - var separatorImagePaths = GetImageRecordsFilePaths(calibrationRecord.SeparatorImages); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken); - if (sampleRoi is null) - continue; - - var features = CreateSampleBrandFeatureVector(calibrationRecord.SampleImages, calibrationRecord.SeparatorImages, sampleRoi.Value, cancellationToken); - - yield return new SampleBrandData - { - Id = calibrationRecord.Id, - Features = features - }; - } - } - - cancellationToken.ThrowIfCancellationRequested(); - - var modelPath = "brand_predictor.zip"; - if (File.Exists(modelPath)) - { - var model = _ml.Model.Load(modelPath, out _); - _sampleBrandPredictionEngine = _ml.Model.CreatePredictionEngine(model); - (model as IDisposable)?.Dispose(); - } - else - { - _logger.LogInformation("Building pipeline"); - var pipeline = _ml.Transforms.Conversion.MapValueToKey("Label") - .Append(_ml.MulticlassClassification.Trainers.NaiveBayes()) - .Append(_ml.Transforms.Conversion.MapKeyToValue("PredictedLabel")); - var trainData = _ml.Data.LoadFromEnumerable(TrainDataGenerator()); - - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogInformation("Training model"); - var model = pipeline.Fit(trainData); - _ml.Model.Save(model, trainData.Schema, modelPath); - - _sampleBrandPredictionEngine = _ml.Model.CreatePredictionEngine(model); - model.Dispose(); - } - } - private float[] CreateSampleBrandFeatureVector(IEnumerable sampleImageRecords, IEnumerable separatorImageRecords, Rect sampleRoi, CancellationToken cancellationToken) - { - var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); - var separatorImagePaths = GetImageRecordsFilePaths(separatorImageRecords); - - var features = new float[SAMPLE_BRAND_FEATURES_LENGTH]; - int i = 0; - - _logger.LogInformation("Segmenting images"); - var contours = CalculateContours(sampleImagePaths, separatorImagePaths, cancellationToken); - - var visibleSampleImageRecords = sampleImageRecords - .Where(r => r.Uv365Intensity == 0 && r.Uv254Intensity == 0) - .OrderBy(r => r.VisibleIntensity); - - foreach (var imageRecord in visibleSampleImageRecords) - { - var imagePath = _imageStorage.GetFullPath(imageRecord.ImagePath); - if (imagePath is null) - throw new Exception($"Image not found {imageRecord.ImagePath}"); - - var histogram = CalculateContoursHistogram(imagePath, sampleRoi, contours, cancellationToken); - features[i++] = (float)imageRecord.VisibleIntensity; - for (int c = 0; c < HISTOGRAM_COLORS; c++) - for (int ch = 0; ch < HISTOGRAM_CHANNELS; ch++) - features[i++] = histogram[c, ch]; - } - - return features; - } - - private async Task InitializeMlSampleAmineContentRegressionPredictor(CancellationToken cancellationToken) - { - _logger.LogInformation("Initializing sample amine content regression predictor"); - - if (_ml is null) - throw new InvalidOperationException(); - - cancellationToken.ThrowIfCancellationRequested(); - - IEnumerable TrainDataGenerator() - { - _logger.LogInformation("Preparing train data"); - var calibrationRecords = _calibrationContext.CalibrationRecords - .Include(r => r.SampleImages) - .Include(r => r.SeparatorImages) - .ToList(); - - var ratios = calibrationRecords - .GroupBy(r => r.SampleBrand) - .Where(g => g.Key is not null) - .Select(g => new KeyValuePair( - g.Key!, - Math.Clamp( - (float)g.Count(r => r.MixtureActualRate is not null && r.MixtureActualRate == 0) * 180 / - (float)g.Count(r => r.MixtureActualRate is not null && r.MixtureActualRate != 0), - 1, - 100 - )) - ).ToDictionary(); - foreach (var ratio in ratios) - _logger.LogInformation("Correction of data quantity imbalance ratio: {} = {}", ratio.Key, ratio.Value); - - var dataYielded = new Dictionary(); - - foreach (var (i, calibrationRecord) in calibrationRecords.Enumerate()) - { - if (calibrationRecord.MixtureActualRate is null) - continue; - if (calibrationRecord.SampleBrand is null) - continue; - - _logger.LogInformation("{}: Id={}, Brand={}, Name={}, AmineContent={}", i, calibrationRecord.Id, calibrationRecord.SampleBrand, calibrationRecord.SampleName, (float)calibrationRecord.MixtureActualRate.Value); - - cancellationToken.ThrowIfCancellationRequested(); - - var sampleImageRecords = calibrationRecord.SampleImages; - var separatorImageRecords = calibrationRecord.SeparatorImages; - var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); - var separatorImagePaths = GetImageRecordsFilePaths(separatorImageRecords); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken); - if (sampleRoi is null) - continue; - - var contours = CalculateContours(sampleImagePaths, separatorImagePaths, cancellationToken); - - var sampleMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - foreach (var contour in contours) - { - var bbox = Cv2.BoundingRect(contour); - var area = Cv2.ContourArea(contour); - - if (bbox.Width > CONTOUR_MAX_SIZE || - bbox.Height > CONTOUR_MAX_SIZE || - area > CONTOUR_MAX_AREA) - continue; - - if (bbox.Width < CONTOUR_MIN_SIZE || - bbox.Height < CONTOUR_MIN_SIZE || - area < CONTOUR_MIN_AREA) - continue; - - Cv2.DrawContours(sampleMask, [contour], 0, new Scalar(255), -1, LineTypes.Link8); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var uvSampleImages = sampleImageRecords - .Where(r => r.Uv365Intensity != 0 || r.Uv254Intensity != 0); - - if (calibrationRecord.MixtureAmineContent != 0) - // if (true) - { - var features = new float[SAMPLE_AMINE_CONTENT_FEATURES_LENGTH]; - int j = 0; - features[j++] = 0; - // features[i++] = (float)calibrationRecord.Id; TODO - - foreach (var sampleImageRecord in uvSampleImages) - { - var sampleImagePath = _imageStorage.GetFullPath(sampleImageRecord.ImagePath); - if (sampleImagePath is null) - throw new Exception($"Image not found {sampleImageRecord.ImagePath}"); - - // features[j++] = (float)sampleImageRecord.VisibleIntensity; - // features[j++] = (float)sampleImageRecord.Uv365Intensity; - // features[j++] = (float)sampleImageRecord.Uv254Intensity; - - var sampleImage = Cv2.ImRead(sampleImagePath); - sampleImage = new Mat(sampleImage, sampleRoi.Value); - Cv2.GaussianBlur(sampleImage, sampleImage, new Size(5, 5), 1); - Cv2.Resize(sampleImage, sampleImage, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - var mean = Cv2.Mean(sampleImage, sampleMask); - features[j++] = (float)mean.Val0 / 255; - features[j++] = (float)mean.Val1 / 255; - features[j++] = (float)mean.Val2 / 255; - - sampleImage.Release(); - } - - if (ratios.TryGetValue(calibrationRecord.SampleBrand, out var ratio)) - { - - for (int k = 0; k < ratio; k++) - { - if (!dataYielded.ContainsKey(calibrationRecord.SampleBrand)) - dataYielded.Add(calibrationRecord.SampleBrand, new(0, 0)); - dataYielded[calibrationRecord.SampleBrand] = new(dataYielded[calibrationRecord.SampleBrand].processed + 1, dataYielded[calibrationRecord.SampleBrand].nonProcessed); - - yield return new SampleAmineContentRegressionData - { - AmineContent = (float)calibrationRecord.MixtureActualRate.Value, - Features = features - }; - } - } - else - { - if (!dataYielded.ContainsKey(calibrationRecord.SampleBrand)) - dataYielded.Add(calibrationRecord.SampleBrand, new(0, 0)); - dataYielded[calibrationRecord.SampleBrand] = new(dataYielded[calibrationRecord.SampleBrand].processed + 1, dataYielded[calibrationRecord.SampleBrand].nonProcessed); - - yield return new SampleAmineContentRegressionData - { - AmineContent = (float)calibrationRecord.MixtureActualRate.Value, - Features = features - }; - } - } - else - { - var histograms = new List(); - foreach (var sampleImageRecord in uvSampleImages) - { - var sampleImagePath = _imageStorage.GetFullPath(sampleImageRecord.ImagePath); - if (sampleImagePath is null) - throw new Exception($"Image not found {sampleImageRecord.ImagePath}"); - - histograms.Add(CalculateContoursHistogram(sampleImagePath, sampleRoi.Value, contours, cancellationToken)); - } - - for (var color = 0; color < HISTOGRAM_COLORS; color++) - { - if (histograms.All(h => h[color, 0] == 0 && h[color, 1] == 0 && h[color, 2] == 0)) - continue; - - var features = new float[SAMPLE_AMINE_CONTENT_FEATURES_LENGTH]; - int j = 0; - features[j++] = 0; - // features[i++] = (float)calibrationRecord.Id; TODO - - foreach (var pair in Enumerable.Zip(uvSampleImages, histograms)) - { - // features[j++] = (float)pair.First.VisibleIntensity; - // features[j++] = (float)pair.First.Uv365Intensity; - // features[j++] = (float)pair.First.Uv254Intensity; - features[j++] = (float)pair.Second[color, 0] / 255; - features[j++] = (float)pair.Second[color, 1] / 255; - features[j++] = (float)pair.Second[color, 2] / 255; - } - - if (!dataYielded.ContainsKey(calibrationRecord.SampleBrand)) - dataYielded.Add(calibrationRecord.SampleBrand, new(0, 0)); - dataYielded[calibrationRecord.SampleBrand] = new(dataYielded[calibrationRecord.SampleBrand].processed, dataYielded[calibrationRecord.SampleBrand].nonProcessed + 1); - - yield return new SampleAmineContentRegressionData - { - AmineContent = (float)calibrationRecord.MixtureActualRate.Value, - Features = features - }; - } - } - } - - _logger.LogInformation("Train data preparation finished"); - foreach (var yielded in dataYielded) - _logger.LogInformation("Current data quantity imbalance ratio (non processed per processed): {} = {}", yielded.Key, (float)yielded.Value.nonProcessed / (float)yielded.Value.processed); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var modelPath = "amine_content_regression_predictor.zip"; - var datasetPath = "amine_content_regression_dataset.bin"; - if (File.Exists(modelPath)) - { - var model = _ml.Model.Load(modelPath, out _); - _sampleAmineContentRegressionPredictionEngine = _ml.Model.CreatePredictionEngine(model); - (model as IDisposable)?.Dispose(); - } - else - { - _logger.LogInformation("Building pipeline"); - var pipeline = _ml.Regression.Trainers.Sdca(maximumNumberOfIterations: 30000); // Работает, в принципе можно юзать - - IDataView trainData; - if (File.Exists(datasetPath)) - { - trainData = _ml.Data.LoadFromBinary(datasetPath); - } - else - { - var trainDataSet = TrainDataGenerator().ToList(); - trainDataSet.Shuffle(); - trainData = _ml.Data.LoadFromEnumerable(trainDataSet); - using (var stream = File.OpenWrite(datasetPath)) - _ml.Data.SaveAsBinary(trainData, stream); - } - - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogInformation("Training model"); - var model = pipeline.Fit(trainData); - _ml.Model.Save(model, trainData.Schema, modelPath); - - _sampleAmineContentRegressionPredictionEngine = _ml.Model.CreatePredictionEngine(model); - model.Dispose(); - } - } - - private async Task InitializeMlSampleAmineContentClusterizationPredictor(CancellationToken cancellationToken) - { - _logger.LogInformation("Initializing sample amine content clusterization predictor"); - - if (_ml is null) - throw new InvalidOperationException(); - - cancellationToken.ThrowIfCancellationRequested(); - - IEnumerable TrainDataGenerator() - { - _logger.LogInformation("Preparing train data"); - var calibrationRecords = _calibrationContext.CalibrationRecords - .Include(r => r.SampleImages) - .Include(r => r.SeparatorImages) - .ToList(); - - foreach (var (i, calibrationRecord) in calibrationRecords.Enumerate()) - { - if (calibrationRecord.MixtureActualRate is null) - continue; - if (calibrationRecord.SampleBrand is null) - continue; - - _logger.LogInformation("{}: Id={}, Brand={}, Name={}, IsProcessed={}", i, calibrationRecord.Id, calibrationRecord.SampleBrand, calibrationRecord.SampleName, calibrationRecord.MixtureActualRate.Value > 0); - - cancellationToken.ThrowIfCancellationRequested(); - - var sampleImageRecords = calibrationRecord.SampleImages; - var separatorImageRecords = calibrationRecord.SeparatorImages; - var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); - var separatorImagePaths = GetImageRecordsFilePaths(separatorImageRecords); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken); - if (sampleRoi is null) - continue; - - var contours = CalculateContours(sampleImagePaths, separatorImagePaths, cancellationToken); - - var sampleMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - foreach (var contour in contours) - { - var bbox = Cv2.BoundingRect(contour); - var area = Cv2.ContourArea(contour); - - if (bbox.Width > CONTOUR_MAX_SIZE || - bbox.Height > CONTOUR_MAX_SIZE || - area > CONTOUR_MAX_AREA) - continue; - - if (bbox.Width < CONTOUR_MIN_SIZE || - bbox.Height < CONTOUR_MIN_SIZE || - area < CONTOUR_MIN_AREA) - continue; - - Cv2.DrawContours(sampleMask, [contour], 0, new Scalar(255), -1, LineTypes.Link8); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var uvSampleImages = sampleImageRecords - .Where(r => r.Uv365Intensity != 0 || r.Uv254Intensity != 0); - - var histograms = new List(); - foreach (var sampleImageRecord in uvSampleImages) - { - var sampleImagePath = _imageStorage.GetFullPath(sampleImageRecord.ImagePath); - if (sampleImagePath is null) - throw new Exception($"Image not found {sampleImageRecord.ImagePath}"); - - histograms.Add(CalculateContoursHistogram(sampleImagePath, sampleRoi.Value, contours, cancellationToken)); - } - - for (var color = 0; color < HISTOGRAM_COLORS; color++) - { - if (histograms.All(h => h[color, 0] == 0 && h[color, 1] == 0 && h[color, 2] == 0)) - continue; - - var features = new float[SAMPLE_AMINE_CONTENT_FEATURES_LENGTH]; - int j = 0; - features[j++] = 0; - // features[i++] = (float)calibrationRecord.Id; TODO - - foreach (var pair in Enumerable.Zip(uvSampleImages, histograms)) - { - features[j++] = (float)pair.Second[color, 0] / 255; - features[j++] = (float)pair.Second[color, 1] / 255; - features[j++] = (float)pair.Second[color, 2] / 255; - } - - yield return new SampleAmineContentClusterizationData - { - IsProcessed = calibrationRecord.MixtureActualRate.Value > 0, - Features = features - }; - } - } - } - - cancellationToken.ThrowIfCancellationRequested(); - - var modelPath = "amine_content_clusterization_predictor.zip"; - var datasetPath = "amine_content_clusterization_dataset.bin"; - if (File.Exists(modelPath)) - { - var model = _ml.Model.Load(modelPath, out _); - _sampleAmineContentClusterizationPredictionEngine = _ml.Model.CreatePredictionEngine(model); - (model as IDisposable)?.Dispose(); - } - else - { - _logger.LogInformation("Building pipeline"); - - var pipeline = _ml.BinaryClassification.Trainers.LinearSvm(); - - - IDataView trainData; - if (File.Exists(datasetPath)) - { - trainData = _ml.Data.LoadFromBinary(datasetPath); - } - else - { - var trainDataSet = TrainDataGenerator().ToList(); - trainDataSet.Shuffle(); - trainData = _ml.Data.LoadFromEnumerable(trainDataSet); - using (var stream = File.OpenWrite(datasetPath)) - _ml.Data.SaveAsBinary(trainData, stream); - } - - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogInformation("Training model"); - var model = pipeline.Fit(trainData); - _ml.Model.Save(model, trainData.Schema, modelPath); - - _sampleAmineContentClusterizationPredictionEngine = _ml.Model.CreatePredictionEngine(model); - model.Dispose(); - } - } - - public async Task PredictSeparatorType(IEnumerable imageRecords, CancellationToken cancellationToken = default) - { - if (!Initialized) - throw new Exception("Not initialized"); - - cancellationToken.ThrowIfCancellationRequested(); - - if (imageRecords.Count() != HISTOGRAM_TOTAL_COUNT) - throw new Exception($"Image records count mismatch, expected {HISTOGRAM_TOTAL_COUNT}, got {imageRecords.Count()}"); - - var imagePaths = GetImageRecordsFilePaths(imageRecords); - - var roi = CalculateSeparatorRoi(imagePaths, 5, cancellationToken); - if (roi is null) - throw new Exception($"Cannot calculate ROI"); - - var features = CreateSeparatorTypeFeatureVector(imageRecords, roi.Value, cancellationToken); - - var predictionData = new SeparatorTypeData - { - Id = -1, - Features = features - }; - - _logger.LogInformation("Predicting separator"); - var predictionResult = _separatorTypePredictionEngine?.Predict(predictionData); - if (predictionResult is null) - throw new UnreachableException(); - - _logger.LogInformation("Separator prediction result: id={}, probability={}, score={}", predictionResult.Id, predictionResult.Probability, predictionResult.Score); - - var separatorRecord = _calibrationContext.SeparatorRecords.Include(r => r.Images) - .FirstOrDefault(r => r.Id == predictionResult.Id); - if (separatorRecord is null) - throw new UnreachableException(); - - return separatorRecord; - } - - public async Task CalculatePollutionRate(IEnumerable imageRecords, SeparatorRecord separatorRecord, CancellationToken cancellationToken = default) - { - if (!Initialized) - throw new Exception("Not initialized"); - - cancellationToken.ThrowIfCancellationRequested(); - - if (imageRecords.Count() != separatorRecord.Images.Count()) - throw new Exception("Provided image records count not equal to provided separator record images count"); - - var pairs = Enumerable.Zip( - imageRecords - .OrderBy(r => r.VisibleIntensity) - .OrderBy(r => r.Uv365Intensity) - .OrderBy(r => r.Uv254Intensity), - separatorRecord.Images - .OrderBy(r => r.VisibleIntensity) - .OrderBy(r => r.Uv365Intensity) - .OrderBy(r => r.Uv254Intensity) - ); - if (pairs.Any(p => - p.First.VisibleIntensity != p.Second.VisibleIntensity || - p.First.Uv365Intensity != p.Second.Uv365Intensity || - p.First.Uv254Intensity != p.Second.Uv254Intensity)) - throw new Exception("Image records intensities to correlate with separator images intensities"); - - var imagePaths = GetImageRecordsFilePaths(imageRecords); - var roi = CalculateSeparatorRoi(imagePaths, 5, cancellationToken); - if (roi is null) - throw new Exception($"Cannot calculate ROI"); - - var calibrationImagePaths = GetImageRecordsFilePaths(separatorRecord.Images); - var calibrationRoi = CalculateSeparatorRoi(calibrationImagePaths, 5, cancellationToken); - if (calibrationRoi is null) - throw new Exception($"Cannot calculate ROI"); - - var separatorMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - Cv2.Circle(separatorMask, HISTOGRAM_ROWS, HISTOGRAM_ROWS, HISTOGRAM_ROWS, new Scalar(255), -1); - - float pollutionRateSum = 0; - - _logger.LogInformation("Calculating pollution rate"); - foreach (var pair in pairs) - { - var imageRecord = pair.First; - var imagePath = _imageStorage.GetFullPath(imageRecord.ImagePath); - if (imagePath is null) - throw new Exception($"Image not found {imageRecord.ImagePath}"); - var image = Cv2.ImRead(imagePath); - image = new Mat(image, roi.Value); - Cv2.GaussianBlur(image, image, new Size(5, 5), 1); - Cv2.Resize(image, image, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - Cv2.CvtColor(image, image, ColorConversionCodes.BGR2HSV); - - var calibrationImageRecord = pair.Second; - var calibrationImagePath = _imageStorage.GetFullPath(calibrationImageRecord.ImagePath); - if (calibrationImagePath is null) - throw new Exception($"Image not found {calibrationImageRecord.ImagePath}"); - var calibrationImage = Cv2.ImRead(calibrationImagePath); - calibrationImage = new Mat(calibrationImage, calibrationRoi.Value); - Cv2.GaussianBlur(calibrationImage, calibrationImage, new Size(5, 5), 1); - Cv2.Resize(calibrationImage, calibrationImage, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - Cv2.CvtColor(calibrationImage, calibrationImage, ColorConversionCodes.BGR2HSV); - - var diff = new Mat(); - Cv2.Absdiff(image, calibrationImage, diff); - pollutionRateSum += (float)Cv2.Mean(diff, separatorMask).Val0 / 255; - pollutionRateSum += (float)Cv2.Mean(diff, separatorMask).Val1 / 255; - pollutionRateSum += (float)Cv2.Mean(diff, separatorMask).Val2 / 255; - - diff.Release(); - image.Release(); - calibrationImage.Release(); - } - - var pollutionRate = pollutionRateSum / 3 / pairs.Count() / 0.2f; - - return pollutionRate; - } - - public async Task PredictSampleBrand(IEnumerable sampleImageRecords, IEnumerable separatorImageRecords, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); - var separatorImagePaths = GetImageRecordsFilePaths(separatorImageRecords); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken); - if (sampleRoi is null) - throw new Exception(/* TODO */); - - var features = CreateSampleBrandFeatureVector(sampleImageRecords, separatorImageRecords, sampleRoi.Value, cancellationToken); - - var predictionData = new SampleBrandData - { - Id = -1, - Features = features - }; - - _logger.LogInformation("Predicting sample"); - var predictionResult = _sampleBrandPredictionEngine?.Predict(predictionData); - if (predictionResult is null) - throw new UnreachableException(); - - _logger.LogInformation("Sample prediction result: id={}, probability={}, score={}", predictionResult.Id, predictionResult.Probability, predictionResult.Score); - - var calibrationRecord = _calibrationContext.CalibrationRecords - .Include(r => r.SampleImages) - .Include(r => r.SeparatorImages) - .FirstOrDefault(r => r.Id == predictionResult.Id); - if (calibrationRecord is null) - throw new UnreachableException(); - - return calibrationRecord; - } - - public async Task<(Mat result, Mat mask)> CalculateAmineContent(IEnumerable sampleImageRecords, IEnumerable separatorImageRecords, CalibrationRecord calibrationRecord, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var sampleImagePaths = GetImageRecordsFilePaths(sampleImageRecords); - var separatorImagePaths = GetImageRecordsFilePaths(separatorImageRecords); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken); - if (sampleRoi is null) - throw new Exception(/* TODO */); - - var contours = CalculateContours(sampleImagePaths, separatorImagePaths, cancellationToken); - var sampleMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var contour in contours) - { - var bbox = Cv2.BoundingRect(contour); - var area = Cv2.ContourArea(contour); - - if (bbox.Width > CONTOUR_MAX_SIZE || - bbox.Height > CONTOUR_MAX_SIZE || - area > CONTOUR_MAX_AREA) - continue; - - if (bbox.Width < CONTOUR_MIN_SIZE || - bbox.Height < CONTOUR_MIN_SIZE || - area < CONTOUR_MIN_AREA) - continue; - - Cv2.DrawContours(sampleMask, [contour], 0, new Scalar(255), -1, LineTypes.Link8); - } - - Cv2.Resize(sampleMask, sampleMask, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - var uvSampleImageRecords = sampleImageRecords - .Where(r => r.Uv365Intensity != 0 || r.Uv254Intensity != 0) - .OrderBy(r => r.VisibleIntensity) - .OrderBy(r => r.Uv365Intensity) - .OrderBy(r => r.Uv254Intensity); - - var uvSampleImagePaths = GetImageRecordsFilePaths(uvSampleImageRecords); - var uvSampleImages = uvSampleImagePaths.Select(uvSampleImagePath => - { - var uvSampleImage = Cv2.ImRead(uvSampleImagePath); - uvSampleImage = new Mat(uvSampleImage, sampleRoi.Value); - Cv2.GaussianBlur(uvSampleImage, uvSampleImage, new Size(5, 5), 1); - Cv2.Resize(uvSampleImage, uvSampleImage, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - return uvSampleImage; - }); - - var uvSampleImage = new Mat(); - Cv2.Merge(uvSampleImages.SelectMany(i => i.Split()).ToArray(), uvSampleImage); - uvSampleImages.ToList().ForEach(i => i.Release()); - - var result = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_32FC1, new Scalar(0)); - - var values = new float[HISTOGRAM_ROWS * HISTOGRAM_ROWS * 4]; - for (int row = 0; row < HISTOGRAM_ROWS * 2; row++) - { - for (int column = 0; column < HISTOGRAM_ROWS * 2; column++) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (sampleMask.Get(row, column) == 0) - continue; - - var features = new float[SAMPLE_AMINE_CONTENT_FEATURES_LENGTH]; - int i = 0; - - // features[i++] = (float)calibrationRecord.Id; - features[i++] = 0; - - foreach (var (image, uvSampleImageRecord) in uvSampleImageRecords.Enumerate()) - { - cancellationToken.ThrowIfCancellationRequested(); - - features[i++] = (float)uvSampleImage.Get([row, column, image * 3 + 0]) / 255; - features[i++] = (float)uvSampleImage.Get([row, column, image * 3 + 1]) / 255; - features[i++] = (float)uvSampleImage.Get([row, column, image * 3 + 2]) / 255; - } - - // var clusterizationPredictionData = new SampleAmineContentClusterizationData - // { - // IsProcessed = false, - // Features = features - // }; - // var clusterizationPredictionResult = _sampleAmineContentClusterizationPredictionEngine?.Predict(clusterizationPredictionData); - // if (clusterizationPredictionResult is null) - // throw new UnreachableException(); - - // if (!clusterizationPredictionResult.IsProcessed) - // { - // result.Set(row, column, 0); - // values[row * HISTOGRAM_ROWS * 2 + column] = 0; - // continue; - // } - // else - // { - // result.Set(row, column, 1); - // values[row * HISTOGRAM_ROWS * 2 + column] = 1; - // continue; - // } - - var regressionPredictionData = new SampleAmineContentRegressionData - { - AmineContent = -1, - Features = features - }; - - var regressionPredictionResult = _sampleAmineContentRegressionPredictionEngine?.Predict(regressionPredictionData); - if (regressionPredictionResult is null) - throw new UnreachableException(); - - result.Set(row, column, regressionPredictionResult.AmineContent); - values[row * HISTOGRAM_ROWS * 2 + column] = regressionPredictionResult.AmineContent; - } - } - - // Срезаем 99.9 процентиль (0.1% самых горячих пикселей), которые скорее всего являются шумом или загрязнением - var oldMean = Cv2.Mean(result, sampleMask).Val0; - values.Sort(); - float p99 = values[(int)(values.Length * 0.999)]; - Cv2.Threshold(result, result, p99, p99, ThresholdTypes.Trunc); - var newMean = Cv2.Mean(result, sampleMask).Val0; - var scale = oldMean / newMean; - Cv2.ConvertScaleAbs(result, result, scale); - - uvSampleImage.Release(); - - return (result, sampleMask); - } - - private IEnumerable GetImageRecordsFilePaths(IEnumerable imageRecords) => - imageRecords.Select(r => - { - var imagePath = _imageStorage.GetFullPath(r.ImagePath); - if (imagePath is null) - throw new Exception($"Image not found {r.ImagePath}"); - return imagePath; - }); - - private Mat CalculateAverageImage(IEnumerable imagePaths, CancellationToken cancellationToken) - { - _logger.LogDebug("Calculating average image"); - - cancellationToken.ThrowIfCancellationRequested(); - - if (imagePaths.Count() == 0) - return new Mat(); - - cancellationToken.ThrowIfCancellationRequested(); - var cachePath = Path.Join(_cachePath, "avg_" + GetShortCacheKey(imagePaths) + ".bmp"); - if (File.Exists(cachePath)) - { - _logger.LogDebug("Average image found in cache"); - return Cv2.ImRead(cachePath); - } - - _logger.LogDebug("Average image not found in cache"); - Mat avgImage = new Mat(); - Mat? image = null; - try - { - foreach (var imagePath in imagePaths) - { - cancellationToken.ThrowIfCancellationRequested(); - - image = Cv2.ImRead(imagePath); - - if (image.Channels() != 3) - throw new Exception($"Image channels count mismatch, expected 3, got {image.Channels()}"); - - if (avgImage.Empty()) - avgImage = new Mat(image.Size(), MatType.CV_16UC3, new Scalar(0, 0, 0)); - - if (image.Size().Width != avgImage.Size().Width || - image.Size().Height != avgImage.Size().Height) - throw new Exception($"Image sizes mismatch, expected {avgImage.Size()}, got {image.Size()}"); - - Cv2.Add(image, avgImage, avgImage, dtype: (int)MatType.CV_16UC3); - - image.Release(); - } - - avgImage.ConvertTo(avgImage, MatType.CV_8UC(avgImage.Channels()), 1f / imagePaths.Count()); - - if (!avgImage.Empty()) - avgImage.ImWrite(path: cachePath); - } - finally - { - image?.Release(); - } - - return avgImage; - } - private Rect? CalculateSeparatorRoi(IEnumerable imagePaths, int threshold, CancellationToken cancellationToken) - { - _logger.LogDebug("Calculating separator ROI"); - - cancellationToken.ThrowIfCancellationRequested(); - - if (imagePaths.Count() == 0) - return null; - - cancellationToken.ThrowIfCancellationRequested(); - var cachePath = Path.Join(_cachePath, "roi_" + GetShortCacheKey(imagePaths) + ".mda"); - if (File.Exists(cachePath)) - { - _logger.LogDebug("Separator ROI found in cache"); - Array array; - using (var stream = File.OpenRead(cachePath)) - array = MultiDimensionalArraySerializer.Deserialize(stream); - - if (array is int[] typedArray && - typedArray.Length == 4) - return new Rect(typedArray[0], typedArray[1], typedArray[2], typedArray[3]); - - _logger.LogWarning("Separator ROI cache data corrupted"); - } - - _logger.LogDebug("Separator ROI not found in cache"); - Mat? avgImage = null; - Mat? mask = null; - Point[][]? contours = null; - try - { - cancellationToken.ThrowIfCancellationRequested(); - avgImage = CalculateAverageImage(imagePaths, cancellationToken); - if (avgImage.Empty()) - return null; - - cancellationToken.ThrowIfCancellationRequested(); - avgImage = avgImage.CvtColor(ColorConversionCodes.BGR2GRAY); - mask = new Mat(); - - cancellationToken.ThrowIfCancellationRequested(); - Cv2.Threshold(avgImage, mask, threshold, 255, ThresholdTypes.Binary); - - cancellationToken.ThrowIfCancellationRequested(); - Cv2.FindContours(mask, out contours, out _, RetrievalModes.List, ContourApproximationModes.ApproxSimple); - } - finally - { - avgImage?.Release(); - mask?.Release(); - } - - cancellationToken.ThrowIfCancellationRequested(); - var maxContour = contours.MaxBy(c => Cv2.ContourArea(c)); - if (maxContour is null) - return null; - - var roi = Cv2.BoundingRect(maxContour); - - using (var stream = File.OpenWrite(cachePath)) - MultiDimensionalArraySerializer.Serialize(stream, new int[4] { roi.X, roi.Y, roi.Width, roi.Height }); - - return roi; - } - private float[,,] CalculateRowHistogram(string imagePath, Rect roi, CancellationToken cancellationToken) - { - _logger.LogDebug("Calculating row histogram, imagePath: {}", imagePath); - - cancellationToken.ThrowIfCancellationRequested(); - - var cachePath = Path.Join(_cachePath, "rows_hist_" + GetShortCacheKey([imagePath]) + ".mda"); - if (File.Exists(cachePath)) - { - _logger.LogDebug("Row histogram found in cache"); - Array array; - using (var stream = File.OpenRead(cachePath)) - array = MultiDimensionalArraySerializer.Deserialize(stream); - - if (array is float[,,] typedArray && - typedArray.GetLength(0) == HISTOGRAM_ROWS && - typedArray.GetLength(1) == HISTOGRAM_COLORS && - typedArray.GetLength(2) == HISTOGRAM_CHANNELS) - return typedArray; - - _logger.LogWarning("Row histogram cache data corrupted"); - } - - _logger.LogDebug("Row histogram not found in cache"); - var image = Cv2.ImRead(imagePath); - image = new Mat(image, roi); - Cv2.GaussianBlur(image, image, new Size(5, 5), 1); - Cv2.Resize(image, image, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - Mat[]? channels = null; - var histogram = new float[HISTOGRAM_ROWS, HISTOGRAM_COLORS, HISTOGRAM_CHANNELS]; - - try - { - cancellationToken.ThrowIfCancellationRequested(); - - if (image.Channels() != 3) - throw new Exception($"Image channels count mismatch, expected 3, got {image.Channels()}"); - - cancellationToken.ThrowIfCancellationRequested(); - Cv2.WarpPolar(image, image, new Size(HISTOGRAM_ROWS, HISTOGRAM_ROWS), new Point(HISTOGRAM_ROWS, HISTOGRAM_ROWS), HISTOGRAM_ROWS, InterpolationFlags.Area, WarpPolarMode.Linear); - Cv2.Rotate(image, image, RotateFlags.Rotate90Clockwise); - - cancellationToken.ThrowIfCancellationRequested(); - Cv2.Split(image, out channels); - - for (int row = 0; row < HISTOGRAM_ROWS; row++) - for (int channel = 0; channel < HISTOGRAM_CHANNELS; channel++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var rowMat = channels[channel].Row(row); - var channelHistogram = new Mat(); - Cv2.CalcHist([rowMat], [0], null, channelHistogram, 1, [HISTOGRAM_COLORS], [[0, HISTOGRAM_COLORS]]); - Cv2.Transpose(channelHistogram, channelHistogram); - - channelHistogram.ConvertTo(channelHistogram, MatType.CV_32FC1, 1f / HISTOGRAM_ROWS); - channelHistogram.GetArray(out float[] channelHistogramData); - - for (int col = 0; col < HISTOGRAM_COLORS; col++) - histogram[row, col, channel] = channelHistogramData[col]; - - channelHistogram.Release(); - rowMat.Release(); - } - } - finally - { - image.Release(); - channels?.ToList() - .ForEach(i => i.Release()); - } - - using (var stream = File.OpenWrite(cachePath)) - MultiDimensionalArraySerializer.Serialize(stream, histogram); - - return histogram; - } - private float[,] CalculateContoursHistogram(string imagePath, Rect roi, IEnumerable contours, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var cachePath = Path.Join(_cachePath, "conts_hist_" + GetShortCacheKey([imagePath]) + ".mda"); - if (File.Exists(cachePath)) - { - Array array; - using (var stream = File.OpenRead(cachePath)) - array = MultiDimensionalArraySerializer.Deserialize(stream); - - if (array is float[,] typedArray && - typedArray.GetLength(0) == HISTOGRAM_COLORS && - typedArray.GetLength(1) == HISTOGRAM_CHANNELS) - return typedArray; - } - - var image = Cv2.ImRead(imagePath); - image = new Mat(image, roi); - Cv2.GaussianBlur(image, image, new Size(5, 5), 1); - Cv2.Resize(image, image, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - Mat[]? channels = null; - var histogram = new float[HISTOGRAM_COLORS, HISTOGRAM_CHANNELS]; - - try - { - cancellationToken.ThrowIfCancellationRequested(); - - if (image.Channels() != 3) - throw new Exception($"Image channels count mismatch, expected 3, got {image.Channels()}"); - - var sampleMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var contour in contours) - { - var bbox = Cv2.BoundingRect(contour); - var area = Cv2.ContourArea(contour); - - if (bbox.Width > CONTOUR_MAX_SIZE || - bbox.Height > CONTOUR_MAX_SIZE || - area > CONTOUR_MAX_AREA) - continue; - - if (bbox.Width < CONTOUR_MIN_SIZE || - bbox.Height < CONTOUR_MIN_SIZE || - area < CONTOUR_MIN_AREA) - continue; - - Cv2.DrawContours(sampleMask, [contour], 0, new Scalar(255), -1, LineTypes.Link8); - } - var maskedPixelsCount = Cv2.CountNonZero(sampleMask); - - cancellationToken.ThrowIfCancellationRequested(); - - Cv2.Split(image, out channels); - - for (int channel = 0; channel < HISTOGRAM_CHANNELS; channel++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var channelMat = channels[channel]; - Cv2.BitwiseAnd(channelMat, sampleMask, channelMat); - var channelHistogram = new Mat(); - Cv2.CalcHist([channelMat], [0], null, channelHistogram, 1, [HISTOGRAM_COLORS], [[0, HISTOGRAM_COLORS]]); - Cv2.Transpose(channelHistogram, channelHistogram); - - channelHistogram.ConvertTo(channelHistogram, MatType.CV_32FC1, 1f / maskedPixelsCount); - channelHistogram.GetArray(out float[] channelHistogramData); - - for (int col = 0; col < HISTOGRAM_COLORS; col++) - histogram[col, channel] = channelHistogramData[col]; - - channelHistogram.Release(); - } - } - finally - { - image.Release(); - channels?.ToList() - .ForEach(i => i.Release()); - } - - using (var stream = File.OpenWrite(cachePath)) - MultiDimensionalArraySerializer.Serialize(stream, histogram); - - return histogram; - } - private IEnumerable CalculateContours(IEnumerable sampleImagePaths, IEnumerable separatorImagePaths, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (sampleImagePaths.Count() == 0 || - separatorImagePaths.Count() == 0) - return []; - - var cachePath = Path.Join(_cachePath, "conts_" + GetShortCacheKey([.. sampleImagePaths, .. separatorImagePaths]) + ".mda"); - if (File.Exists(cachePath)) - { - Array array; - using (var stream = File.OpenRead(cachePath)) - array = MultiDimensionalArraySerializer.Deserialize(stream); - - if (array is int[] typedArray) - { - var loadedContours = new List(); - var i = 0; - var contoursCount = typedArray[i++]; - for (var j = 0; j < contoursCount; j++) - { - var pointsCount = typedArray[i++]; - var contour = new Point[pointsCount]; - for (var k = 0; k < pointsCount; k++) - contour[k] = new Point(typedArray[i++], typedArray[i++]); - loadedContours.Add(contour); - } - - loadedContours.Where(c => - { - var bbox = Cv2.BoundingRect(c); - var area = Cv2.ContourArea(c); - return CONTOUR_MIN_SIZE < bbox.Width && bbox.Width < CONTOUR_MAX_SIZE && - CONTOUR_MIN_SIZE < bbox.Height && bbox.Height < CONTOUR_MAX_SIZE && - CONTOUR_MIN_AREA < area && area < CONTOUR_MAX_AREA; - }); - } - } - - var str = "20|CMY-2,HSV-0,HSV-1"; - var threshStr = str.Split('|') - .First() - .Trim(); - var thresh = int.Parse(threshStr); - - var channelsStrs = str.Split('|') - .Last() - .Split(',') - .Select(s => s.Trim()) - .ToList(); - if (channelsStrs.Count() != 3) - throw new Exception(); - - var separatorMask = new Mat(new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2), MatType.CV_8UC1, new Scalar(0)); - Cv2.Circle(separatorMask, HISTOGRAM_ROWS, HISTOGRAM_ROWS, HISTOGRAM_ROWS, new Scalar(255), -1); - - cancellationToken.ThrowIfCancellationRequested(); - - var sampleRoi = CalculateSeparatorRoi(sampleImagePaths, 5, cancellationToken) ?? throw new Exception(); - var sample = CalculateAverageImage(sampleImagePaths, cancellationToken); - sample = new Mat(sample, sampleRoi); - Cv2.Resize(sample, sample, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - var separatorRoi = CalculateSeparatorRoi(separatorImagePaths, 5, cancellationToken) ?? throw new Exception(); - var separator = CalculateAverageImage(separatorImagePaths, cancellationToken); - separator = new Mat(separator, separatorRoi); - Cv2.Resize(separator, separator, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - Mat[] sampleChannels = new Mat[3]; - Mat[] separatorChannels = new Mat[3]; - - for (int ch = 0; ch < 3; ch++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var colorSpace = channelsStrs[ch].Split("-").First(); - var channelNumberStr = channelsStrs[ch].Split("-").Last(); - var channelNumber = int.Parse(channelNumberStr); - - var sampleTemp = new Mat(); - sample.CopyTo(sampleTemp); - var separatorTemp = new Mat(); - separator.CopyTo(separatorTemp); - - switch (colorSpace) - { - case "RGB": - Cv2.CvtColor(sampleTemp, sampleTemp, ColorConversionCodes.BGR2RGB); - Cv2.CvtColor(separatorTemp, separatorTemp, ColorConversionCodes.BGR2RGB); - break; - case "HSV": - Cv2.CvtColor(sampleTemp, sampleTemp, ColorConversionCodes.BGR2HSV); - Cv2.CvtColor(separatorTemp, separatorTemp, ColorConversionCodes.BGR2HSV); - break; - case "LAB": - Cv2.CvtColor(sampleTemp, sampleTemp, ColorConversionCodes.BGR2Lab); - Cv2.CvtColor(separatorTemp, separatorTemp, ColorConversionCodes.BGR2Lab); - break; - case "CMY": - Cv2.Split(sampleTemp, out var sampleTempChannels); - Cv2.Merge([ - (sampleTempChannels[0] / 2 + sampleTempChannels[1] / 2).ToMat(), - (sampleTempChannels[0] / 2 + sampleTempChannels[2] / 2).ToMat(), - (sampleTempChannels[1] / 2 + sampleTempChannels[2] / 2).ToMat(), - ], sampleTemp); - sampleTempChannels.ToList().ForEach(c => c.Release()); - - Cv2.Split(separatorTemp, out var separatorTempChannels); - Cv2.Merge([ - (separatorTempChannels[0] / 2 + separatorTempChannels[1] / 2).ToMat(), - (separatorTempChannels[0] / 2 + separatorTempChannels[2] / 2).ToMat(), - (separatorTempChannels[1] / 2 + separatorTempChannels[2] / 2).ToMat(), - ], separatorTemp); - separatorTempChannels.ToList().ForEach(c => c.Release()); - break; - } - - { - Cv2.Split(sampleTemp, out var sampleTempChannels); - sampleChannels[ch] = sampleTempChannels[channelNumber]; - sampleTempChannels.Where((_, i) => i != channelNumber).ToList().ForEach(c => c.Release()); - - Cv2.Split(separatorTemp, out var separatorTempChannels); - separatorChannels[ch] = separatorTempChannels[channelNumber]; - separatorTempChannels.Where((_, i) => i != channelNumber).ToList().ForEach(c => c.Release()); - } - - sampleTemp.Release(); - separatorTemp.Release(); - } - - Cv2.Merge(sampleChannels, sample); - Cv2.Merge(separatorChannels, separator); - - sampleChannels.ToList().ForEach(c => c.Release()); - separatorChannels.ToList().ForEach(c => c.Release()); - - var diff = new Mat(); - Cv2.Absdiff(sample, separator, diff); - - sample.Release(); - sample = CalculateAverageImage(sampleImagePaths, cancellationToken); - sample = new Mat(sample, sampleRoi); - Cv2.Resize(sample, sample, new Size(HISTOGRAM_ROWS * 2, HISTOGRAM_ROWS * 2)); - - Cv2.CvtColor(diff, diff, ColorConversionCodes.BGR2GRAY); - Cv2.BitwiseAnd(diff, separatorMask, diff); - - Cv2.GaussianBlur(diff, diff, new Size(5, 5), 1); - Cv2.Threshold(diff, diff, thresh, 255, ThresholdTypes.Binary); - Cv2.MorphologyEx(diff, diff, MorphTypes.Close, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)), iterations: 2); - Cv2.MorphologyEx(diff, diff, MorphTypes.Open, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)), iterations: 2); - Cv2.Dilate(diff, diff, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3))); - Cv2.GaussianBlur(diff, diff, new Size(5, 5), 1); - Cv2.Threshold(diff, diff, 127, 255, ThresholdTypes.Binary); - - Cv2.FindContours(diff, out var contours, out _, RetrievalModes.List, ContourApproximationModes.ApproxSimple); - - // int y = 0; - // foreach (var contour in contours) - // { - // var bbox = Cv2.BoundingRect(contour); - // var area = Cv2.ContourArea(contour); - - // if (bbox.Width > AmineContentAnalyzerService.CONTOUR_MAX_SIZE || - // bbox.Height > AmineContentAnalyzerService.CONTOUR_MAX_SIZE || - // area > AmineContentAnalyzerService.CONTOUR_MAX_AREA) - // { - // Cv2.DrawContours(sample, [contour], 0, new Scalar(0, 255, 0), 1, LineTypes.AntiAlias); - // _logger.LogInformation($"Contour is big {y}, {bbox.Width}x{bbox.Height} ({area})"); - // Cv2.PutText(sample, $"{y++}", new Point((bbox.Left + bbox.Right) / 2, (bbox.Top + bbox.Bottom) / 2), HersheyFonts.HersheySimplex, 2, Scalar.White, 2); - // continue; - // } - - // if (bbox.Width < AmineContentAnalyzerService.CONTOUR_MIN_SIZE || - // bbox.Height < AmineContentAnalyzerService.CONTOUR_MIN_SIZE || - // area < AmineContentAnalyzerService.CONTOUR_MIN_AREA) - // { - // Cv2.DrawContours(sample, [contour], 0, new Scalar(255, 0, 0), 1, LineTypes.AntiAlias); - // _logger.LogInformation($"Contour is small {y}, {bbox.Width}x{bbox.Height} ({area})"); - // Cv2.PutText(sample, $"{y++}", new Point((bbox.Left + bbox.Right) / 2, (bbox.Top + bbox.Bottom) / 2), HersheyFonts.HersheySimplex, 0.5, Scalar.White, 1); - // continue; - // } - - - // Cv2.DrawContours(sample, [contour], 0, new Scalar(0, 0, 255), 1, LineTypes.AntiAlias); - // } - - // Cv2.ImWrite($"_{i}_0_sam.jpg", sample); - - sample.Release(); - separator.Release(); - - { - var array = new int[contours.Sum(c => c.Length * 2 + 1) + 1]; - var i = 0; - array[i++] = contours.Count(); - foreach (var contour in contours) - { - array[i++] = contour.Count(); - foreach (var point in contour) - { - array[i++] = point.X; - array[i++] = point.Y; - } - } - - using (var stream = File.OpenWrite(cachePath)) - MultiDimensionalArraySerializer.Serialize(stream, array); - } - - return contours.Where(c => - { - var bbox = Cv2.BoundingRect(c); - var area = Cv2.ContourArea(c); - return CONTOUR_MIN_SIZE < bbox.Width && bbox.Width < CONTOUR_MAX_SIZE && - CONTOUR_MIN_SIZE < bbox.Height && bbox.Height < CONTOUR_MAX_SIZE && - CONTOUR_MIN_AREA < area && area < CONTOUR_MAX_AREA; - }); - } - - private static string GetShortCacheKey(IEnumerable imagePaths, int length = 16) - { - if (imagePaths.Count() == 0) - throw new Exception($"Provided {nameof(imagePaths)} does not contain elements"); - - var paths = imagePaths.OrderBy(p => p); - var normalized = string.Join("|", paths.Select(p => (p ?? string.Empty).Trim())); - - using var sha = System.Security.Cryptography.SHA256.Create(); - var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(normalized)); - - string base64 = Convert.ToBase64String(hash) - .Replace('+', '-') - .Replace('/', '_') - .TrimEnd('='); - - return base64.Substring(0, Math.Min(length, base64.Length)); - } -} diff --git a/GSS2.Core/Analysis/AmineContent/AnalyzerService.cs b/GSS2.Core/Analysis/AmineContent/AnalyzerService.cs new file mode 100644 index 0000000..5f926c5 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/AnalyzerService.cs @@ -0,0 +1,534 @@ +using Microsoft.Extensions.Logging; +using Microsoft.ML; +using Microsoft.ML.Data; + +using OpenCvSharp; + +using GSS2.Core.Extensions; +using GSS2.Core.Analysis.AmineContent.Database.Calibration; + +namespace GSS2.Core.Analysis.AmineContent; + +public partial class AnalyzerService +{ + //TODO: Перенести все гиперпараметры в appsettings.json + public const int CHANNELS_COUNT = 3; + public const int IMAGES_COUNT = 12; + public const int FEATURES_LENGTH = (CHANNELS_COUNT + 3) * IMAGES_COUNT; + public const int EFFECTIVE_IMAGE_SIZE = 200; + public const int ANALYSIS_IMAGE_SIZE = 1000; + public const int SEPARATOR_ROI_THRESHOLD = 5; + + public const float CONTOUR_MAX_SIZE = 100; + public const float CONTOUR_MAX_AREA = (float)Math.PI * (CONTOUR_MAX_SIZE * CONTOUR_MAX_SIZE) / 4.0f; + public const float CONTOUR_MIN_SIZE = 12; + public const float CONTOUR_MIN_AREA = (float)Math.PI * (CONTOUR_MIN_SIZE * CONTOUR_MIN_SIZE) / 4.0f / 4.0f; + + public const int SEPARATOR_X = 519; + public const int SEPARATOR_Y = 17; + public const int SEPARATOR_WIDTH = 2989; + public const int SEPARATOR_HEIGHT = 2985; + + private class ClusterizationData + { + [ColumnName("Label")] + public required string Label { get; set; } + [ColumnName("Features")] + [VectorType(FEATURES_LENGTH)] + public required float[] Features { get; set; } + } + private class ClusterizationPrediction + { + [ColumnName("PredictedLabel")] + public string Label { get; set; } + public float Probability { get; set; } + public float[] Score { get; set; } + + public ClusterizationPrediction() + { + Label = ""; + Probability = 0; + Score = new float[2]; + } + } + + private class RegressionData + { + [ColumnName("Label")] + public required float Value { get; set; } + [ColumnName("Features")] + [VectorType(FEATURES_LENGTH)] + public required float[] Features { get; set; } + } + private class RegressionPrediction + { + [ColumnName("Score")] + public float Value { get; set; } + + public RegressionPrediction() + { + Value = -1; + } + } + + private readonly ILogger _logger; + private readonly CalibrationContext _calibrationContext; + + private MLContext? _ml = null; + private PredictionEngine? _clusterizationEngine = null; + private PredictionEngine? _regressionEngine = null; + + public bool Initialized { get; private set; } = false; + + public AnalyzerService(ILogger logger, CalibrationContext calibrationContext) + { + _logger = logger; + _calibrationContext = calibrationContext; + } + + public async Task Initialize(string? brand = null, CancellationToken cancellationToken = default) + { + Initialized = false; + + _logger.LogInformation("Инициализация"); + + _ml = new MLContext(); + _ml.Log += (_, ea) => + { + if (ea.Kind >= Microsoft.ML.Runtime.ChannelMessageKind.Info) + _logger.LogInformation("ML: [{}] {}", ea.Kind, ea.Message); + }; + + try + { + await InitializeMlClusterizationEngine(cancellationToken); + await InitializeMlRegressionEngine(brand, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Исключение во время инициализации моделей"); + return; + } + + Initialized = true; + } + private async Task InitializeMlClusterizationEngine(CancellationToken cancellationToken) + { + _logger.LogInformation("Инициализация модели кластеризации"); + + var separatorPrefix = "SEPARATOR"; + var samplePrefix = "SAMPLE"; + + if (_ml is null) + throw new InvalidOperationException(); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Создание конвейера"); + var pipeline = _ml.Transforms.Conversion.MapValueToKey("Label") + .Append(_ml.MulticlassClassification.Trainers.LbfgsMaximumEntropy()) + .Append(_ml.Transforms.Conversion.MapKeyToValue("PredictedLabel")); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Подготовка данных"); + var trainDataSet = _calibrationContext.VectorRecords.Select(r => + new ClusterizationData + { + Label = r.SeparatorRecord != null ? + $"{separatorPrefix}|{r.SeparatorRecord.Type}" : + r.SampleRecord != null ? + $"{samplePrefix}|{r.SampleRecord.SampleBrand}" : + "", + Features = r.Values + }) + .ToList(); + var trainData = _ml.Data.LoadFromEnumerable(trainDataSet); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Обучение модели"); + var model = pipeline.Fit(trainData); + + _clusterizationEngine = _ml.Model.CreatePredictionEngine(model); + model.Dispose(); + } + private async Task InitializeMlRegressionEngine(string? brand, CancellationToken cancellationToken) + { + _logger.LogInformation("Инициализация модели регрессии для марки \"{}\"", brand); + + if (_ml is null) + throw new InvalidOperationException(); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Создание конвейера"); + var pipeline = _ml.Regression.Trainers.Sdca(); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Подготовка данных"); + var trainDataSet = + + _calibrationContext.VectorRecords + .Where(r => r.SampleRecord != null) + .Where(r => brand == null || r.SampleRecord!.SampleBrand == brand) + .Select(r => + new RegressionData + { + Value = (float)( + r.SampleRecord!.MeasuredContent >= 0 ? + r.SampleRecord!.MeasuredContent : + r.SampleRecord!.MixtureActualRate >= 0 ? + r.SampleRecord!.MixtureActualRate : + r.SampleRecord!.MixtureNormalRate + ), + Features = r.Values + }) + .ToList(); + var trainData = _ml.Data.LoadFromEnumerable(trainDataSet); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Обучение модели"); + var model = pipeline.Fit(trainData); + + _regressionEngine = _ml.Model.CreatePredictionEngine(model); + + model.Dispose(); + } + + public async Task<(Mat avgImage, Rect roi, Mat mask, IEnumerable contours, Mat result, string separatorType, string sampleBrand)?> Analyze(IEnumerable imagesData) + { + if (!Initialized) + return null; + + if (imagesData.Count() != IMAGES_COUNT) + return null; + + try + { + var segmentationResult = await Segment(imagesData); + if (segmentationResult is null) + return null; + + var (avgImage, roi, mask, contours, separatorType, sampleBrand) = segmentationResult.Value; + + var images = new List<(Mat mat, double visible, double uv365, double uv254)>(); + foreach (var imageData in imagesData + .OrderBy(i => i.VisibleIntensity) + .ThenBy(i => i.Uv365Intensity) + .ThenBy(i => i.Uv254Intensity)) + images.Add(( + LoadAndPrepare(imageData.Path, roi, ANALYSIS_IMAGE_SIZE), + imageData.VisibleIntensity, + imageData.Uv365Intensity, + imageData.Uv254Intensity + )); + + var result = new Mat(new Size(ANALYSIS_IMAGE_SIZE, ANALYSIS_IMAGE_SIZE), MatType.CV_32FC1, new Scalar(0)); + var values = new float[ANALYSIS_IMAGE_SIZE * ANALYSIS_IMAGE_SIZE]; + + for (var row = 0; row < ANALYSIS_IMAGE_SIZE; row++) + for (var col = 0; col < ANALYSIS_IMAGE_SIZE; col++) + { + if (mask.At(row, col) != 255) + continue; + var vector = new float[FEATURES_LENGTH]; + foreach (var (i, image) in images.Enumerate()) + { + vector[i * (CHANNELS_COUNT + 3) + 0] = (float)image.visible; + vector[i * (CHANNELS_COUNT + 3) + 1] = (float)image.uv365; + vector[i * (CHANNELS_COUNT + 3) + 2] = (float)image.uv254; + var color = image.mat.At(row, col); + vector[i * (CHANNELS_COUNT + 3) + 3] = color.Item0 / 255.0f; + vector[i * (CHANNELS_COUNT + 3) + 4] = color.Item1 / 255.0f; + vector[i * (CHANNELS_COUNT + 3) + 5] = color.Item2 / 255.0f; + } + var prediction = _regressionEngine?.Predict(new RegressionData + { + Value = -1, + Features = vector + }); + if (prediction is null) + continue; + result.Set(row, col, prediction.Value); + values[row * ANALYSIS_IMAGE_SIZE + col] = prediction.Value; + } + + var oldMean = Cv2.Mean(result, mask).Val0; + values.Sort(); + float p999 = values[(int)(values.Length * 0.999)]; + Cv2.Threshold(result, result, p999, p999, ThresholdTypes.Trunc); + var newMean = Cv2.Mean(result, mask).Val0; + var scale = oldMean / newMean; + Cv2.ConvertScaleAbs(result, result, scale); + return (avgImage, roi, mask, contours, result, separatorType, sampleBrand); + } + catch (Exception ex) + { + _logger.LogError(ex, "Исключение при проведении анализа"); + return null; + } + } + private async Task<(Mat avgImage, Rect roi, Mat mask, IEnumerable contours, string separatorType, string sampleBrand)?> Segment(IEnumerable imagesData) + { + var separatorPrefix = "SEPARATOR"; + var samplePrefix = "SAMPLE"; + + if (imagesData.Count() != IMAGES_COUNT) + return null; + + var avgImage = CalculateAverageImage(imagesData.Select(i => i.Path)); + if (avgImage is null) + return null; + + var roi = CalculateSeparatorRoi(avgImage); + if (roi is null) + { + avgImage.Release(); + return null; + } + + avgImage = new Mat(avgImage, roi.Value); + Cv2.Resize(avgImage, avgImage, new Size(ANALYSIS_IMAGE_SIZE, ANALYSIS_IMAGE_SIZE)); + + var images = new List<(Mat mat, double visible, double uv365, double uv254)>(); + foreach (var imageData in imagesData + .OrderBy(i => i.VisibleIntensity) + .ThenBy(i => i.Uv365Intensity) + .ThenBy(i => i.Uv254Intensity)) + images.Add(( + LoadAndPrepare(imageData.Path, roi.Value, ANALYSIS_IMAGE_SIZE), + imageData.VisibleIntensity, + imageData.Uv365Intensity, + imageData.Uv254Intensity + )); + + var separatorMask = new Mat(new Size(ANALYSIS_IMAGE_SIZE, ANALYSIS_IMAGE_SIZE), MatType.CV_8UC1, new Scalar(0)); + Cv2.Circle(separatorMask, ANALYSIS_IMAGE_SIZE / 2, ANALYSIS_IMAGE_SIZE / 2, ANALYSIS_IMAGE_SIZE / 2, new Scalar(255), -1); + + var result = new Mat(new Size(ANALYSIS_IMAGE_SIZE, ANALYSIS_IMAGE_SIZE), MatType.CV_16UC1, new Scalar(0)); + var keyMap = new Dictionary(); + ushort lastKey = 0; + + for (var row = 0; row < ANALYSIS_IMAGE_SIZE; row++) + for (var col = 0; col < ANALYSIS_IMAGE_SIZE; col++) + { + if (separatorMask.At(row, col) != 255) + continue; + var vector = new float[FEATURES_LENGTH]; + foreach (var (i, image) in images.Enumerate()) + { + vector[i * (CHANNELS_COUNT + 3) + 0] = (float)image.visible; + vector[i * (CHANNELS_COUNT + 3) + 1] = (float)image.uv365; + vector[i * (CHANNELS_COUNT + 3) + 2] = (float)image.uv254; + var color = image.mat.At(row, col); + vector[i * (CHANNELS_COUNT + 3) + 3] = color.Item0 / 255.0f; + vector[i * (CHANNELS_COUNT + 3) + 4] = color.Item1 / 255.0f; + vector[i * (CHANNELS_COUNT + 3) + 5] = color.Item2 / 255.0f; + } + var prediction = _clusterizationEngine?.Predict(new ClusterizationData + { + Label = "", + Features = vector + }); + if (prediction is null) + continue; + if (!keyMap.ContainsKey(prediction.Label)) + keyMap.Add(prediction.Label, ++lastKey); + result.Set(row, col, keyMap[prediction.Label]); + } + + foreach (var image in images) + image.mat.Release(); + + var valueMap = keyMap.ToDictionary(kv => kv.Value, kv => kv.Key); + var frequency = new Dictionary(); + + + for (var row = 0; row < ANALYSIS_IMAGE_SIZE; row++) + for (var col = 0; col < ANALYSIS_IMAGE_SIZE; col++) + { + var value = result.At(row, col); + if (value == 0) + continue; + + if (!frequency.ContainsKey(valueMap[value])) + frequency.Add(valueMap[value], 0); + frequency[valueMap[value]] += 1; + if (valueMap[value].StartsWith(separatorPrefix)) + result.Set(row, col, 0); + if (valueMap[value].StartsWith(samplePrefix)) + result.Set(row, col, ushort.MaxValue); + } + + result.ConvertTo(result, MatType.CV_8UC1, 1 / 255.0); + + Cv2.MorphologyEx(result, result, MorphTypes.Close, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)), iterations: 2); + Cv2.MorphologyEx(result, result, MorphTypes.Open, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)), iterations: 2); + Cv2.Dilate(result, result, Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3))); + Cv2.GaussianBlur(result, result, new Size(5, 5), 1); + Cv2.Threshold(result, result, 127, 255, ThresholdTypes.Binary); + + Cv2.FindContours(result, out var contours, out _, RetrievalModes.List, ContourApproximationModes.ApproxSimple); + + result = new Mat(new Size(ANALYSIS_IMAGE_SIZE, ANALYSIS_IMAGE_SIZE), MatType.CV_8UC1, new Scalar(0)); + Cv2.DrawContours(result, contours.Where(ContourIsValid), -1, new Scalar(255), -1); + + return ( + avgImage, + roi.Value, + result, + contours.Where(ContourIsValid), + frequency + .Where(kv => kv.Key.StartsWith(separatorPrefix)) + .MaxBy(kv => kv.Value) + .Key + .Substring(separatorPrefix.Count() + 1), + frequency + .Where(kv => kv.Key.StartsWith(samplePrefix)) + .MaxBy(kv => kv.Value) + .Key + .Substring(samplePrefix.Count() + 1) + ); + } + + public static (Mat avgImage, Rect roi, List vectors)? GetFeatureVectors(IEnumerable imagesData) + { + if (imagesData.Count() != IMAGES_COUNT) + return null; + + var avgImage = CalculateAverageImage(imagesData.Select(i => i.Path)); + if (avgImage is null) + return null; + + var roi = CalculateSeparatorRoi(avgImage); + if (roi is null) + return null; + + var images = new List<(Mat mat, double visible, double uv365, double uv254)>(); + foreach (var imageData in imagesData + .OrderBy(i => i.VisibleIntensity) + .ThenBy(i => i.Uv365Intensity) + .ThenBy(i => i.Uv254Intensity)) + images.Add(( + LoadAndPrepare(imageData.Path, roi.Value), + imageData.VisibleIntensity, + imageData.Uv365Intensity, + imageData.Uv254Intensity + )); + + var separatorMask = new Mat(new Size(EFFECTIVE_IMAGE_SIZE, EFFECTIVE_IMAGE_SIZE), MatType.CV_8UC1, new Scalar(0)); + Cv2.Circle(separatorMask, EFFECTIVE_IMAGE_SIZE / 2, EFFECTIVE_IMAGE_SIZE / 2, EFFECTIVE_IMAGE_SIZE / 2, new Scalar(255), -1); + + var vectors = new List(); + + for (var row = 0; row < EFFECTIVE_IMAGE_SIZE; row++) + for (var col = 0; col < EFFECTIVE_IMAGE_SIZE; col++) + { + if (separatorMask.At(row, col) != 255) + continue; + var vector = new float[FEATURES_LENGTH]; + + foreach (var (i, image) in images.Enumerate()) + { + vector[i * (CHANNELS_COUNT + 3) + 0] = (float)image.visible; + vector[i * (CHANNELS_COUNT + 3) + 1] = (float)image.uv365; + vector[i * (CHANNELS_COUNT + 3) + 2] = (float)image.uv254; + + var color = image.mat.At(row, col).ToVec3f() / 255.0f; + + vector[i * (CHANNELS_COUNT + 3) + 3] = color.Item0; + vector[i * (CHANNELS_COUNT + 3) + 4] = color.Item1; + vector[i * (CHANNELS_COUNT + 3) + 5] = color.Item2; + } + + vectors.Add(vector); + } + + foreach (var (mat, _, _, _) in images) + mat.Release(); + + return (avgImage, roi.Value, vectors); + } + private static Mat LoadAndPrepare(string imagePath, Rect roi, int size = EFFECTIVE_IMAGE_SIZE) + { + var image = Cv2.ImRead(imagePath); + image = new Mat(image, roi); + Cv2.GaussianBlur(image, image, new Size(5, 5), 1); + Cv2.Resize(image, image, new Size(size, size)); + return image; + } + public static Mat? CalculateAverageImage(IEnumerable imagePaths) + { + if (imagePaths.Count() == 0) + return null; + + Mat avgImage = new Mat(); + + foreach (var imagePath in imagePaths) + { + var image = Cv2.ImRead(imagePath); + + if (image.Channels() != 3) + return null; + + if (avgImage.Empty()) + avgImage = new Mat(image.Size(), MatType.CV_16UC3, new Scalar(0, 0, 0)); + + if (image.Size().Width != avgImage.Size().Width || + image.Size().Height != avgImage.Size().Height) + return null; + + Cv2.Add(image, avgImage, avgImage, dtype: MatType.CV_16U); + + image.Release(); + } + + avgImage.ConvertTo(avgImage, MatType.CV_8UC(avgImage.Channels()), 1f / imagePaths.Count()); + + return avgImage; + } + public static Rect? CalculateSeparatorRoi(Mat avgImage) + { + if (avgImage.Empty()) + return null; + return new Rect(SEPARATOR_X, SEPARATOR_Y, SEPARATOR_WIDTH, SEPARATOR_HEIGHT); + } + // Этот метод конечно хорош, но проблема с тем, что при калибровке на чёрном продукте + // он выдаёт очень обрезает часть полезного изображения из-за неравномерности освещения + // public static Rect? CalculateSeparatorRoi(Mat avgImage) + // { + // if (avgImage.Empty()) + // return null; + + // Mat avgImageClone = new Mat(); + // Mat mask = new Mat(); + // Rect? roi = null; + + // avgImage.CopyTo(avgImageClone); + + // avgImageClone = avgImageClone.CvtColor(ColorConversionCodes.BGR2GRAY); + // Cv2.Threshold(avgImageClone, mask, SEPARATOR_ROI_THRESHOLD, 255, ThresholdTypes.Binary); + // Cv2.FindContours(mask, out var contours, out _, RetrievalModes.List, ContourApproximationModes.ApproxSimple); + + // var maxContour = contours.MaxBy(c => Cv2.ContourArea(c)); + // if (maxContour is null) + // return null; + + // roi = Cv2.BoundingRect(maxContour); + + // avgImageClone.Release(); + // mask.Release(); + + // return roi; + // } + private static bool ContourIsValid(Point[] contour) + { + var bbox = Cv2.BoundingRect(contour); + var area = Cv2.ContourArea(contour); + return CONTOUR_MIN_SIZE < bbox.Width && CONTOUR_MIN_SIZE < bbox.Height && CONTOUR_MIN_AREA < area && + CONTOUR_MAX_SIZE > bbox.Width && CONTOUR_MAX_SIZE > bbox.Height && CONTOUR_MAX_AREA > area; + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContext.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContext.cs deleted file mode 100644 index 77af498..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContext.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; - -public class AmineContentCalibrationContext : DbContext -{ - public DbSet ImageRecords => Set(); - public DbSet SeparatorRecords => Set(); - public DbSet CalibrationRecords => Set(); - - public AmineContentCalibrationContext(DbContextOptions options) - : base(options) - { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.Property(x => x.ImagePath) - .IsRequired() - .HasMaxLength(1024); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.HasMany(x => x.Images) - .WithOne(x => x.UsedInSeparatorRecord) - .HasForeignKey(x => x.UsedInSeparatorRecordId) - .OnDelete(DeleteBehavior.Cascade); - entity.Property(x => x.Type) - .IsRequired() - .HasMaxLength(256); - entity.Property(x => x.Batch) - .IsRequired() - .HasMaxLength(256); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.HasMany(x => x.SampleImages) - .WithOne(x => x.UsedInCalibrationRecordSample) - .HasForeignKey(x => x.UsedInCalibrationRecordSampleId) - .OnDelete(DeleteBehavior.Cascade); - entity.HasMany(x => x.SeparatorImages) - .WithMany(x => x.UsedInCalibrationRecordsSeparator) - .UsingEntity>( - "CalibrationSeparatorImage", - j => j - .HasOne() - .WithMany() - .HasForeignKey("ImageRecordId") - .OnDelete(DeleteBehavior.Cascade), - j => j - .HasOne() - .WithMany() - .HasForeignKey("CalibrationRecordId") - .OnDelete(DeleteBehavior.Cascade), - j => - { - j.HasKey("CalibrationRecordId", "ImageRecordId"); - j.ToTable("CalibrationSeparatorImages"); - }); - entity.Property(x => x.SampleName) - .HasMaxLength(512); - entity.Property(x => x.SampleComment) - .HasMaxLength(2048); - entity.Property(x => x.SampleBrand) - .HasMaxLength(512); - entity.Property(x => x.SamplingPlace) - .HasMaxLength(512); - entity.Property(x => x.MixtureName) - .HasMaxLength(512); - }); - } -} \ No newline at end of file diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContext.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContext.cs new file mode 100644 index 0000000..558e6c0 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; + +namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; + +public class CalibrationContext : DbContext +{ + public DbSet SeparatorRecords => Set(); + public DbSet SampleRecords => Set(); + public DbSet VectorRecords => Set(); + + public CalibrationContext(DbContextOptions options) + : base(options) + { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(x => x.VectorRecords) + .WithOne(x => x.SeparatorRecord) + .HasForeignKey(x => x.SeparatorRecordId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.VectorRecords) + .WithOne(x => x.SampleRecord) + .HasForeignKey(x => x.SampleRecordId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContextFactory.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContextFactory.cs similarity index 50% rename from GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContextFactory.cs rename to GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContextFactory.cs index 2961a9b..d5cdf23 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/AmineContentCalibrationContextFactory.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationContextFactory.cs @@ -3,16 +3,16 @@ using Microsoft.EntityFrameworkCore.Design; namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; -public class AmineContentCalibrationContextFactory : IDesignTimeDbContextFactory +public class CalibrationContextFactory : IDesignTimeDbContextFactory { - public AmineContentCalibrationContext CreateDbContext(string[] args) + public CalibrationContext CreateDbContext(string[] args) { - var optionsBuilder = new DbContextOptionsBuilder(); + var optionsBuilder = new DbContextOptionsBuilder(); // Строка подключения для миграций в design-time - // в runtime должна использоваться настоящая ConnectionString + // в runtime должна использоваться настоящая ConnectionString optionsBuilder.UseSqlite("Data Source=AmineContentCalibration.db"); - return new AmineContentCalibrationContext(optionsBuilder.Options); + return new CalibrationContext(optionsBuilder.Options); } } diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/ImageRecord.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/ImageRecord.cs deleted file mode 100644 index 4fc5c5c..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/ImageRecord.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; - -public record ImageRecord -{ - [Comment("Уникальный идентификатор")] - public int Id { get; set; } - [Comment("Интенсивность видимого света (0..1)")] - public required double VisibleIntensity { get; set; } - [Comment("Интенсивность УФ излучения 365 нм (0..1)")] - public required double Uv365Intensity { get; set; } - [Comment("Интенсивность УФ излучения 254 нм (0..1)")] - public required double Uv254Intensity { get; set; } - [Comment("Путь к файлу изображения")] - public required string ImagePath { get; set; } - - // 1:N - public int? UsedInSeparatorRecordId { get; set; } - public SeparatorRecord? UsedInSeparatorRecord { get; set; } - - public int? UsedInCalibrationRecordSampleId { get; set; } - public CalibrationRecord? UsedInCalibrationRecordSample { get; set; } - - // M:N - public List UsedInCalibrationRecordsSeparator { get; set; } = new(); -} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.cs deleted file mode 100644 index 48e6ccc..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations -{ - /// - public partial class AmineContentCalibration0 : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "CalibrationRecords", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") - .Annotation("Sqlite:Autoincrement", true), - SampleName = table.Column(type: "TEXT", maxLength: 512, nullable: true, comment: "Название пробы"), - SampleComment = table.Column(type: "TEXT", maxLength: 2048, nullable: true, comment: "Дополнительная информация о пробе"), - SampleBrand = table.Column(type: "TEXT", maxLength: 512, nullable: true, comment: "Марка пробы"), - SampleMass = table.Column(type: "REAL", nullable: true, comment: "Масса пробы (кг)"), - SamplingDateTime = table.Column(type: "TEXT", nullable: true, comment: "Дата и время отбора пробы"), - SamplingPlace = table.Column(type: "TEXT", maxLength: 512, nullable: true, comment: "Место отбора пробы"), - MixtureName = table.Column(type: "TEXT", maxLength: 512, nullable: true, comment: "Название используемой кондиционирующей смеси"), - MixtureAmineContent = table.Column(type: "REAL", nullable: true, comment: "Содержание аминов в кондиционирующей смеси (%)"), - MixtureNormalRate = table.Column(type: "REAL", nullable: true, comment: "Норма расхода кондиционирующей смеси (кг/т | гр/кг)"), - MixtureActualRate = table.Column(type: "REAL", nullable: true, comment: "Фактический расход кондиционирующей смеси (кг/т | гр/кг)"), - MeasuredContent = table.Column(type: "REAL", nullable: true, comment: "Измеренное количество масла (кг/т | гр/кг)") - }, - constraints: table => - { - table.PrimaryKey("PK_CalibrationRecords", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "SeparatorRecords", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") - .Annotation("Sqlite:Autoincrement", true), - Type = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Тип сепаратора"), - Batch = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Партия сепаратора") - }, - constraints: table => - { - table.PrimaryKey("PK_SeparatorRecords", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "ImageRecords", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") - .Annotation("Sqlite:Autoincrement", true), - VisibleIntensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность видимого света (0..1)"), - Uv365Intensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность УФ излучения 365 нм (0..1)"), - Uv254Intensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность УФ излучения 254 нм (0..1)"), - ImagePath = table.Column(type: "TEXT", maxLength: 1024, nullable: false, comment: "Путь к файлу изображения"), - UsedInSeparatorRecordId = table.Column(type: "INTEGER", nullable: true), - UsedInCalibrationRecordSampleId = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ImageRecords", x => x.Id); - table.ForeignKey( - name: "FK_ImageRecords_CalibrationRecords_UsedInCalibrationRecordSampleId", - column: x => x.UsedInCalibrationRecordSampleId, - principalTable: "CalibrationRecords", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ImageRecords_SeparatorRecords_UsedInSeparatorRecordId", - column: x => x.UsedInSeparatorRecordId, - principalTable: "SeparatorRecords", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "CalibrationSeparatorImages", - columns: table => new - { - CalibrationRecordId = table.Column(type: "INTEGER", nullable: false), - ImageRecordId = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CalibrationSeparatorImages", x => new { x.CalibrationRecordId, x.ImageRecordId }); - table.ForeignKey( - name: "FK_CalibrationSeparatorImages_CalibrationRecords_CalibrationRecordId", - column: x => x.CalibrationRecordId, - principalTable: "CalibrationRecords", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_CalibrationSeparatorImages_ImageRecords_ImageRecordId", - column: x => x.ImageRecordId, - principalTable: "ImageRecords", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_CalibrationSeparatorImages_ImageRecordId", - table: "CalibrationSeparatorImages", - column: "ImageRecordId"); - - migrationBuilder.CreateIndex( - name: "IX_ImageRecords_UsedInCalibrationRecordSampleId", - table: "ImageRecords", - column: "UsedInCalibrationRecordSampleId"); - - migrationBuilder.CreateIndex( - name: "IX_ImageRecords_UsedInSeparatorRecordId", - table: "ImageRecords", - column: "UsedInSeparatorRecordId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CalibrationSeparatorImages"); - - migrationBuilder.DropTable( - name: "ImageRecords"); - - migrationBuilder.DropTable( - name: "CalibrationRecords"); - - migrationBuilder.DropTable( - name: "SeparatorRecords"); - } - } -} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.Designer.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.Designer.cs similarity index 57% rename from GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.Designer.cs rename to GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.Designer.cs index 84daae6..98a1c29 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260313083001_AmineContentCalibration0.Designer.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.Designer.cs @@ -10,9 +10,9 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations { - [DbContext(typeof(AmineContentCalibrationContext))] - [Migration("20260313083001_AmineContentCalibration0")] - partial class AmineContentCalibration0 + [DbContext(typeof(CalibrationContext))] + [Migration("20260428223237_Calibration0")] + partial class Calibration0 { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -20,120 +20,76 @@ namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - modelBuilder.Entity("CalibrationSeparatorImage", b => - { - b.Property("CalibrationRecordId") - .HasColumnType("INTEGER"); - - b.Property("ImageRecordId") - .HasColumnType("INTEGER"); - - b.HasKey("CalibrationRecordId", "ImageRecordId"); - - b.HasIndex("ImageRecordId"); - - b.ToTable("CalibrationSeparatorImages", (string)null); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasComment("Уникальный идентификатор"); - b.Property("MeasuredContent") + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + + b.Property("MeasuredContent") .HasColumnType("REAL") .HasComment("Измеренное количество масла (кг/т | гр/кг)"); - b.Property("MixtureActualRate") + b.Property("MixtureActualRate") .HasColumnType("REAL") .HasComment("Фактический расход кондиционирующей смеси (кг/т | гр/кг)"); - b.Property("MixtureAmineContent") + b.Property("MixtureAmineContent") .HasColumnType("REAL") .HasComment("Содержание аминов в кондиционирующей смеси (%)"); b.Property("MixtureName") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Название используемой кондиционирующей смеси"); - b.Property("MixtureNormalRate") + b.Property("MixtureNormalRate") .HasColumnType("REAL") .HasComment("Норма расхода кондиционирующей смеси (кг/т | гр/кг)"); b.Property("SampleBrand") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Марка пробы"); b.Property("SampleComment") - .HasMaxLength(2048) + .IsRequired() + .HasMaxLength(512) .HasColumnType("TEXT") .HasComment("Дополнительная информация о пробе"); - b.Property("SampleMass") + b.Property("SampleMass") .HasColumnType("REAL") .HasComment("Масса пробы (кг)"); b.Property("SampleName") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Название пробы"); - b.Property("SamplingDateTime") + b.Property("SamplingDateTime") .HasColumnType("TEXT") .HasComment("Дата и время отбора пробы"); b.Property("SamplingPlace") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Место отбора пробы"); b.HasKey("Id"); - b.ToTable("CalibrationRecords"); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("ImagePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("TEXT") - .HasComment("Путь к файлу изображения"); - - b.Property("UsedInCalibrationRecordSampleId") - .HasColumnType("INTEGER"); - - b.Property("UsedInSeparatorRecordId") - .HasColumnType("INTEGER"); - - b.Property("Uv254Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 254 нм (0..1)"); - - b.Property("Uv365Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 365 нм (0..1)"); - - b.Property("VisibleIntensity") - .HasColumnType("REAL") - .HasComment("Интенсивность видимого света (0..1)"); - - b.HasKey("Id"); - - b.HasIndex("UsedInCalibrationRecordSampleId"); - - b.HasIndex("UsedInSeparatorRecordId"); - - b.ToTable("ImageRecords"); + b.ToTable("SampleRecords"); }); modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", b => @@ -149,6 +105,12 @@ namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations .HasColumnType("TEXT") .HasComment("Партия сепаратора"); + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + b.Property("Type") .IsRequired() .HasMaxLength(256) @@ -160,46 +122,58 @@ namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations b.ToTable("SeparatorRecords"); }); - modelBuilder.Entity("CalibrationSeparatorImage", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.VectorRecord", b => { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", null) - .WithMany() - .HasForeignKey("CalibrationRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasComment("Уникальный идентификатор"); - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", null) - .WithMany() - .HasForeignKey("ImageRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("SampleRecordId") + .HasColumnType("INTEGER"); + + b.Property("SeparatorRecordId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Values") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Значения вектора"); + + b.HasKey("Id"); + + b.HasIndex("SampleRecordId"); + + b.HasIndex("SeparatorRecordId"); + + b.ToTable("VectorRecords"); }); - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.VectorRecord", b => { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", "UsedInCalibrationRecordSample") - .WithMany("SampleImages") - .HasForeignKey("UsedInCalibrationRecordSampleId") + b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", "SampleRecord") + .WithMany("VectorRecords") + .HasForeignKey("SampleRecordId") .OnDelete(DeleteBehavior.Cascade); - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", "UsedInSeparatorRecord") - .WithMany("Images") - .HasForeignKey("UsedInSeparatorRecordId") + b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", "SeparatorRecord") + .WithMany("VectorRecords") + .HasForeignKey("SeparatorRecordId") .OnDelete(DeleteBehavior.Cascade); - b.Navigation("UsedInCalibrationRecordSample"); + b.Navigation("SampleRecord"); - b.Navigation("UsedInSeparatorRecord"); + b.Navigation("SeparatorRecord"); }); - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", b => { - b.Navigation("SampleImages"); + b.Navigation("VectorRecords"); }); modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", b => { - b.Navigation("Images"); + b.Navigation("VectorRecords"); }); #pragma warning restore 612, 618 } diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.cs new file mode 100644 index 0000000..5c9ba37 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/20260428223237_Calibration0.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations +{ + /// + public partial class Calibration0 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SampleRecords", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") + .Annotation("Sqlite:Autoincrement", true), + SampleName = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Название пробы"), + SampleComment = table.Column(type: "TEXT", maxLength: 512, nullable: false, comment: "Дополнительная информация о пробе"), + SampleBrand = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Марка пробы"), + SampleMass = table.Column(type: "REAL", nullable: false, comment: "Масса пробы (кг)"), + SamplingDateTime = table.Column(type: "TEXT", nullable: false, comment: "Дата и время отбора пробы"), + SamplingPlace = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Место отбора пробы"), + MixtureName = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Название используемой кондиционирующей смеси"), + MixtureAmineContent = table.Column(type: "REAL", nullable: false, comment: "Содержание аминов в кондиционирующей смеси (%)"), + MixtureNormalRate = table.Column(type: "REAL", nullable: false, comment: "Норма расхода кондиционирующей смеси (кг/т | гр/кг)"), + MixtureActualRate = table.Column(type: "REAL", nullable: false, comment: "Фактический расход кондиционирующей смеси (кг/т | гр/кг)"), + MeasuredContent = table.Column(type: "REAL", nullable: false, comment: "Измеренное количество масла (кг/т | гр/кг)"), + Image = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Изображение") + }, + constraints: table => + { + table.PrimaryKey("PK_SampleRecords", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SeparatorRecords", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") + .Annotation("Sqlite:Autoincrement", true), + Type = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Тип сепаратора"), + Batch = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Партия сепаратора"), + Image = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Изображение") + }, + constraints: table => + { + table.PrimaryKey("PK_SeparatorRecords", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VectorRecords", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") + .Annotation("Sqlite:Autoincrement", true), + SeparatorRecordId = table.Column(type: "INTEGER", nullable: true), + SampleRecordId = table.Column(type: "INTEGER", nullable: true), + Values = table.Column(type: "TEXT", nullable: false, comment: "Значения вектора") + }, + constraints: table => + { + table.PrimaryKey("PK_VectorRecords", x => x.Id); + table.ForeignKey( + name: "FK_VectorRecords_SampleRecords_SampleRecordId", + column: x => x.SampleRecordId, + principalTable: "SampleRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VectorRecords_SeparatorRecords_SeparatorRecordId", + column: x => x.SeparatorRecordId, + principalTable: "SeparatorRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VectorRecords_SampleRecordId", + table: "VectorRecords", + column: "SampleRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_VectorRecords_SeparatorRecordId", + table: "VectorRecords", + column: "SeparatorRecordId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VectorRecords"); + + migrationBuilder.DropTable( + name: "SampleRecords"); + + migrationBuilder.DropTable( + name: "SeparatorRecords"); + } + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/AmineContentCalibrationContextModelSnapshot.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/CalibrationContextModelSnapshot.cs similarity index 56% rename from GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/AmineContentCalibrationContextModelSnapshot.cs rename to GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/CalibrationContextModelSnapshot.cs index 06fa5f6..341515c 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/AmineContentCalibrationContextModelSnapshot.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/Migrations/CalibrationContextModelSnapshot.cs @@ -9,128 +9,84 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations { - [DbContext(typeof(AmineContentCalibrationContext))] - partial class AmineContentCalibrationContextModelSnapshot : ModelSnapshot + [DbContext(typeof(CalibrationContext))] + partial class CalibrationContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - modelBuilder.Entity("CalibrationSeparatorImage", b => - { - b.Property("CalibrationRecordId") - .HasColumnType("INTEGER"); - - b.Property("ImageRecordId") - .HasColumnType("INTEGER"); - - b.HasKey("CalibrationRecordId", "ImageRecordId"); - - b.HasIndex("ImageRecordId"); - - b.ToTable("CalibrationSeparatorImages", (string)null); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasComment("Уникальный идентификатор"); - b.Property("MeasuredContent") + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + + b.Property("MeasuredContent") .HasColumnType("REAL") .HasComment("Измеренное количество масла (кг/т | гр/кг)"); - b.Property("MixtureActualRate") + b.Property("MixtureActualRate") .HasColumnType("REAL") .HasComment("Фактический расход кондиционирующей смеси (кг/т | гр/кг)"); - b.Property("MixtureAmineContent") + b.Property("MixtureAmineContent") .HasColumnType("REAL") .HasComment("Содержание аминов в кондиционирующей смеси (%)"); b.Property("MixtureName") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Название используемой кондиционирующей смеси"); - b.Property("MixtureNormalRate") + b.Property("MixtureNormalRate") .HasColumnType("REAL") .HasComment("Норма расхода кондиционирующей смеси (кг/т | гр/кг)"); b.Property("SampleBrand") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Марка пробы"); b.Property("SampleComment") - .HasMaxLength(2048) + .IsRequired() + .HasMaxLength(512) .HasColumnType("TEXT") .HasComment("Дополнительная информация о пробе"); - b.Property("SampleMass") + b.Property("SampleMass") .HasColumnType("REAL") .HasComment("Масса пробы (кг)"); b.Property("SampleName") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Название пробы"); - b.Property("SamplingDateTime") + b.Property("SamplingDateTime") .HasColumnType("TEXT") .HasComment("Дата и время отбора пробы"); b.Property("SamplingPlace") - .HasMaxLength(512) + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT") .HasComment("Место отбора пробы"); b.HasKey("Id"); - b.ToTable("CalibrationRecords"); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("ImagePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("TEXT") - .HasComment("Путь к файлу изображения"); - - b.Property("UsedInCalibrationRecordSampleId") - .HasColumnType("INTEGER"); - - b.Property("UsedInSeparatorRecordId") - .HasColumnType("INTEGER"); - - b.Property("Uv254Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 254 нм (0..1)"); - - b.Property("Uv365Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 365 нм (0..1)"); - - b.Property("VisibleIntensity") - .HasColumnType("REAL") - .HasComment("Интенсивность видимого света (0..1)"); - - b.HasKey("Id"); - - b.HasIndex("UsedInCalibrationRecordSampleId"); - - b.HasIndex("UsedInSeparatorRecordId"); - - b.ToTable("ImageRecords"); + b.ToTable("SampleRecords"); }); modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", b => @@ -146,6 +102,12 @@ namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations .HasColumnType("TEXT") .HasComment("Партия сепаратора"); + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + b.Property("Type") .IsRequired() .HasMaxLength(256) @@ -157,46 +119,58 @@ namespace GSS2.Core.Analysis.AmineContent.Database.Calibration.Migrations b.ToTable("SeparatorRecords"); }); - modelBuilder.Entity("CalibrationSeparatorImage", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.VectorRecord", b => { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", null) - .WithMany() - .HasForeignKey("CalibrationRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasComment("Уникальный идентификатор"); - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", null) - .WithMany() - .HasForeignKey("ImageRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("SampleRecordId") + .HasColumnType("INTEGER"); + + b.Property("SeparatorRecordId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Values") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Значения вектора"); + + b.HasKey("Id"); + + b.HasIndex("SampleRecordId"); + + b.HasIndex("SeparatorRecordId"); + + b.ToTable("VectorRecords"); }); - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.ImageRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.VectorRecord", b => { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", "UsedInCalibrationRecordSample") - .WithMany("SampleImages") - .HasForeignKey("UsedInCalibrationRecordSampleId") + b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", "SampleRecord") + .WithMany("VectorRecords") + .HasForeignKey("SampleRecordId") .OnDelete(DeleteBehavior.Cascade); - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", "UsedInSeparatorRecord") - .WithMany("Images") - .HasForeignKey("UsedInSeparatorRecordId") + b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", "SeparatorRecord") + .WithMany("VectorRecords") + .HasForeignKey("SeparatorRecordId") .OnDelete(DeleteBehavior.Cascade); - b.Navigation("UsedInCalibrationRecordSample"); + b.Navigation("SampleRecord"); - b.Navigation("UsedInSeparatorRecord"); + b.Navigation("SeparatorRecord"); }); - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.CalibrationRecord", b => + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SampleRecord", b => { - b.Navigation("SampleImages"); + b.Navigation("VectorRecords"); }); modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Calibration.SeparatorRecord", b => { - b.Navigation("Images"); + b.Navigation("VectorRecords"); }); #pragma warning restore 612, 618 } diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationRecord.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/SampleRecord.cs similarity index 51% rename from GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationRecord.cs rename to GSS2.Core/Analysis/AmineContent/Database/Calibration/SampleRecord.cs index c84b738..cc3cb17 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/CalibrationRecord.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/SampleRecord.cs @@ -1,35 +1,43 @@ +using System.ComponentModel.DataAnnotations; + using Microsoft.EntityFrameworkCore; namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; -public record CalibrationRecord +public record SampleRecord { [Comment("Уникальный идентификатор")] public int Id { get; set; } [Comment("Название пробы")] - public required string? SampleName { get; set; } + [MaxLength(256)] + public required string SampleName { get; set; } [Comment("Дополнительная информация о пробе")] - public required string? SampleComment { get; set; } + [MaxLength(512)] + public required string SampleComment { get; set; } [Comment("Марка пробы")] - public required string? SampleBrand { get; set; } + [MaxLength(256)] + public required string SampleBrand { get; set; } [Comment("Масса пробы (кг)")] - public required double? SampleMass { get; set; } + public required double SampleMass { get; set; } [Comment("Дата и время отбора пробы")] - public required DateTime? SamplingDateTime { get; set; } + public required DateTime SamplingDateTime { get; set; } [Comment("Место отбора пробы")] - public required string? SamplingPlace { get; set; } + [MaxLength(256)] + public required string SamplingPlace { get; set; } [Comment("Название используемой кондиционирующей смеси")] - public required string? MixtureName { get; set; } + [MaxLength(256)] + public required string MixtureName { get; set; } [Comment("Содержание аминов в кондиционирующей смеси (%)")] - public required double? MixtureAmineContent { get; set; } + public required double MixtureAmineContent { get; set; } [Comment("Норма расхода кондиционирующей смеси (кг/т | гр/кг)")] - public required double? MixtureNormalRate { get; set; } + public required double MixtureNormalRate { get; set; } [Comment("Фактический расход кондиционирующей смеси (кг/т | гр/кг)")] - public required double? MixtureActualRate { get; set; } + public required double MixtureActualRate { get; set; } [Comment("Измеренное количество масла (кг/т | гр/кг)")] - public required double? MeasuredContent { get; set; } - [Comment("Изображения пробы")] - public required List SampleImages { get; set; } - [Comment("Изображения пустого сепаратора")] - public required List SeparatorImages { get; set; } + public required double MeasuredContent { get; set; } + [Comment("Изображение")] + [MaxLength(256)] + public required string Image { get; set; } + [Comment("Векторы")] + public required List VectorRecords { get; set; } = new(); } diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/SeparatorRecord.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/SeparatorRecord.cs index 1ac16af..7e559ea 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Calibration/SeparatorRecord.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/SeparatorRecord.cs @@ -1,4 +1,4 @@ -using System.IO.Pipelines; +using System.ComponentModel.DataAnnotations; using Microsoft.EntityFrameworkCore; @@ -9,9 +9,14 @@ public record SeparatorRecord [Comment("Уникальный идентификатор")] public int Id { get; set; } [Comment("Тип сепаратора")] + [MaxLength(256)] public required string Type { get; set; } [Comment("Партия сепаратора")] + [MaxLength(256)] public required string Batch { get; set; } - [Comment("Изображения сепаратора")] - public required List Images { get; set; } + [Comment("Изображение")] + [MaxLength(256)] + public required string Image { get; set; } + [Comment("Векторы")] + public required List VectorRecords { get; set; } = new(); } diff --git a/GSS2.Core/Analysis/AmineContent/Database/Calibration/VectorRecord.cs b/GSS2.Core/Analysis/AmineContent/Database/Calibration/VectorRecord.cs new file mode 100644 index 0000000..e120cbf --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Calibration/VectorRecord.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace GSS2.Core.Analysis.AmineContent.Database.Calibration; + +public record VectorRecord +{ + [Comment("Уникальный идентификатор")] + public int Id { get; set; } + [Comment("Значения вектора")] + public required float[] Values { get; set; } + + public int? SeparatorRecordId { get; set; } + public SeparatorRecord? SeparatorRecord { get; set; } + + public int? SampleRecordId { get; set; } + public SampleRecord? SampleRecord { get; set; } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContext.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContext.cs deleted file mode 100644 index d131b50..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContext.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace GSS2.Core.Analysis.AmineContent.Database.Results; - -public class AmineContentResultsContext : DbContext -{ - public class Record - { - [Comment("Уникальный идентификатор")] - public int Id { get; set; } - [Comment("Название пробы")] - public string SampleName { get; set; } = string.Empty; - [Comment("Дополнительная информация о пробе")] - public string SampleComment { get; set; } = string.Empty; - [Comment("Марка пробы")] - public string SampleBrand { get; set; } = string.Empty; - [Comment("Масса пробы (кг)")] - public double SampleMass { get; set; } = double.NaN; - [Comment("Дата и время отбора пробы")] - public DateTime SamplingDateTime { get; set; } = DateTime.Now; - [Comment("Место отбора пробы")] - public string SamplingPlace { get; set; } = string.Empty; - [Comment("Изображения пробы")] - public List SampleImages { get; set; } = new(); - [Comment("Изображения пустого сепаратора")] - public List SeparatorImages { get; set; } = new(); - [Comment("Температура воздуха (℃)")] - public double AirTemperature { get; set; } = new(); - [Comment("Относительная влажность воздуха (%)")] - public double AirHumidity { get; set; } = new(); - } - public class ImageData - { - [Comment("Уникальный идентификатор")] - public int Id { get; set; } - [Comment("Интенсивность видимого света (0..1)")] - public double VisibleIntensity { get; set; } = double.NaN; - [Comment("Интенсивность УФ излучения 365 нм (0..1)")] - public double Uv365Intensity { get; set; } = double.NaN; - [Comment("Интенсивность УФ излучения 254 нм (0..1)")] - public double Uv254Intensity { get; set; } = double.NaN; - [Comment("Тип изображения (sample - проба или separator - сепаратор)")] - public string ImageType { get; set; } = string.Empty; - [Comment("Путь к файлу изображения")] - public string ImagePath { get; set; } = string.Empty; - - [Comment("Записи в которых это изображение используется как изображение пробы")] - public List SampleRecords { get; set; } = new(); - [Comment("Записи в которых это изображение используется как изображение сепаратора")] - public List SeparatorRecords { get; set; } = new(); - } - - public DbSet Records => Set(); - public DbSet Images => Set(); - - public AmineContentResultsContext(DbContextOptions options) : base(options) { } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasMany(r => r.SampleImages) - .WithMany(i => i.SampleRecords) - .UsingEntity(j => j.ToTable("SampleImages")); - - modelBuilder.Entity() - .HasMany(r => r.SeparatorImages) - .WithMany(i => i.SeparatorRecords) - .UsingEntity(j => j.ToTable("SeparatorImages")); - } -} \ No newline at end of file diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.Designer.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.Designer.cs deleted file mode 100644 index 19930d7..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.Designer.cs +++ /dev/null @@ -1,167 +0,0 @@ -// -using System; -using GSS2.Core.Analysis.AmineContent.Database.Results; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations -{ - [DbContext(typeof(AmineContentResultsContext))] - [Migration("20260313083009_AmineContentResults0")] - partial class AmineContentResults0 - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("ImagePath") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Путь к файлу изображения"); - - b.Property("ImageType") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Тип изображения (sample - проба или separator - сепаратор)"); - - b.Property("Uv254Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 254 нм (0..1)"); - - b.Property("Uv365Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 365 нм (0..1)"); - - b.Property("VisibleIntensity") - .HasColumnType("REAL") - .HasComment("Интенсивность видимого света (0..1)"); - - b.HasKey("Id"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("AirHumidity") - .HasColumnType("REAL") - .HasComment("Относительная влажность воздуха (%)"); - - b.Property("AirTemperature") - .HasColumnType("REAL") - .HasComment("Температура воздуха (℃)"); - - b.Property("SampleBrand") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Марка пробы"); - - b.Property("SampleComment") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Дополнительная информация о пробе"); - - b.Property("SampleMass") - .HasColumnType("REAL") - .HasComment("Масса пробы (кг)"); - - b.Property("SampleName") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Название пробы"); - - b.Property("SamplingDateTime") - .HasColumnType("TEXT") - .HasComment("Дата и время отбора пробы"); - - b.Property("SamplingPlace") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Место отбора пробы"); - - b.HasKey("Id"); - - b.ToTable("Records"); - }); - - modelBuilder.Entity("ImageDataRecord", b => - { - b.Property("SampleImagesId") - .HasColumnType("INTEGER"); - - b.Property("SampleRecordsId") - .HasColumnType("INTEGER"); - - b.HasKey("SampleImagesId", "SampleRecordsId"); - - b.HasIndex("SampleRecordsId"); - - b.ToTable("SampleImages", (string)null); - }); - - modelBuilder.Entity("ImageDataRecord1", b => - { - b.Property("SeparatorImagesId") - .HasColumnType("INTEGER"); - - b.Property("SeparatorRecordsId") - .HasColumnType("INTEGER"); - - b.HasKey("SeparatorImagesId", "SeparatorRecordsId"); - - b.HasIndex("SeparatorRecordsId"); - - b.ToTable("SeparatorImages", (string)null); - }); - - modelBuilder.Entity("ImageDataRecord", b => - { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", null) - .WithMany() - .HasForeignKey("SampleImagesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", null) - .WithMany() - .HasForeignKey("SampleRecordsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("ImageDataRecord1", b => - { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", null) - .WithMany() - .HasForeignKey("SeparatorImagesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", null) - .WithMany() - .HasForeignKey("SeparatorRecordsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.cs deleted file mode 100644 index a8bfc4a..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260313083009_AmineContentResults0.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations -{ - /// - public partial class AmineContentResults0 : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Images", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") - .Annotation("Sqlite:Autoincrement", true), - VisibleIntensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность видимого света (0..1)"), - Uv365Intensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность УФ излучения 365 нм (0..1)"), - Uv254Intensity = table.Column(type: "REAL", nullable: false, comment: "Интенсивность УФ излучения 254 нм (0..1)"), - ImageType = table.Column(type: "TEXT", nullable: false, comment: "Тип изображения (sample - проба или separator - сепаратор)"), - ImagePath = table.Column(type: "TEXT", nullable: false, comment: "Путь к файлу изображения") - }, - constraints: table => - { - table.PrimaryKey("PK_Images", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Records", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") - .Annotation("Sqlite:Autoincrement", true), - SampleName = table.Column(type: "TEXT", nullable: false, comment: "Название пробы"), - SampleComment = table.Column(type: "TEXT", nullable: false, comment: "Дополнительная информация о пробе"), - SampleBrand = table.Column(type: "TEXT", nullable: false, comment: "Марка пробы"), - SampleMass = table.Column(type: "REAL", nullable: false, comment: "Масса пробы (кг)"), - SamplingDateTime = table.Column(type: "TEXT", nullable: false, comment: "Дата и время отбора пробы"), - SamplingPlace = table.Column(type: "TEXT", nullable: false, comment: "Место отбора пробы"), - AirTemperature = table.Column(type: "REAL", nullable: false, comment: "Температура воздуха (℃)"), - AirHumidity = table.Column(type: "REAL", nullable: false, comment: "Относительная влажность воздуха (%)") - }, - constraints: table => - { - table.PrimaryKey("PK_Records", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "SampleImages", - columns: table => new - { - SampleImagesId = table.Column(type: "INTEGER", nullable: false), - SampleRecordsId = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SampleImages", x => new { x.SampleImagesId, x.SampleRecordsId }); - table.ForeignKey( - name: "FK_SampleImages_Images_SampleImagesId", - column: x => x.SampleImagesId, - principalTable: "Images", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_SampleImages_Records_SampleRecordsId", - column: x => x.SampleRecordsId, - principalTable: "Records", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "SeparatorImages", - columns: table => new - { - SeparatorImagesId = table.Column(type: "INTEGER", nullable: false), - SeparatorRecordsId = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SeparatorImages", x => new { x.SeparatorImagesId, x.SeparatorRecordsId }); - table.ForeignKey( - name: "FK_SeparatorImages_Images_SeparatorImagesId", - column: x => x.SeparatorImagesId, - principalTable: "Images", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_SeparatorImages_Records_SeparatorRecordsId", - column: x => x.SeparatorRecordsId, - principalTable: "Records", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_SampleImages_SampleRecordsId", - table: "SampleImages", - column: "SampleRecordsId"); - - migrationBuilder.CreateIndex( - name: "IX_SeparatorImages_SeparatorRecordsId", - table: "SeparatorImages", - column: "SeparatorRecordsId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "SampleImages"); - - migrationBuilder.DropTable( - name: "SeparatorImages"); - - migrationBuilder.DropTable( - name: "Images"); - - migrationBuilder.DropTable( - name: "Records"); - } - } -} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.Designer.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.Designer.cs new file mode 100644 index 0000000..ed6a903 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.Designer.cs @@ -0,0 +1,123 @@ +// +using System; +using GSS2.Core.Analysis.AmineContent.Database.Results; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations +{ + [DbContext(typeof(ResultsContext))] + [Migration("20260429070705_Results0")] + partial class Results0 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.ResultRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasComment("Уникальный идентификатор"); + + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + + b.Property("MaskImage") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Маска"); + + b.Property("MaxValue") + .HasColumnType("REAL") + .HasComment("Максимальное значение"); + + b.Property("MeanValue") + .HasColumnType("REAL") + .HasComment("Среднее значение"); + + b.PrimitiveCollection("MeanValues") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Средние значения"); + + b.Property("MeanVariance") + .HasColumnType("REAL") + .HasComment("Средняя дисперсия"); + + b.Property("MinValue") + .HasColumnType("REAL") + .HasComment("Минимальное значение"); + + b.Property("ParticlesCount") + .HasColumnType("INTEGER") + .HasComment("Количество частиц"); + + b.Property("ResultImage") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение результата"); + + b.Property("SampleBrand") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Марка пробы"); + + b.Property("SampleComment") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasComment("Дополнительная информация о пробе"); + + b.Property("SampleName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Название пробы"); + + b.Property("SamplingDateTime") + .HasColumnType("TEXT") + .HasComment("Дата и время отбора пробы"); + + b.Property("SamplingPlace") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Место отбора пробы"); + + b.Property("SeparatorType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Тип сепаратора"); + + b.Property("VarianceOfMean") + .HasColumnType("REAL") + .HasComment("Дисперсия среднего"); + + b.PrimitiveCollection("Variances") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Дисперсии"); + + b.HasKey("Id"); + + b.ToTable("ResultRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.cs new file mode 100644 index 0000000..38dcd8d --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/20260429070705_Results0.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations +{ + /// + public partial class Results0 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ResultRecords", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false, comment: "Уникальный идентификатор") + .Annotation("Sqlite:Autoincrement", true), + SampleName = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Название пробы"), + SampleComment = table.Column(type: "TEXT", maxLength: 512, nullable: false, comment: "Дополнительная информация о пробе"), + SamplingDateTime = table.Column(type: "TEXT", nullable: false, comment: "Дата и время отбора пробы"), + SamplingPlace = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Место отбора пробы"), + Image = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Изображение"), + SeparatorType = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Тип сепаратора"), + SampleBrand = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Марка пробы"), + MaskImage = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Маска"), + ResultImage = table.Column(type: "TEXT", maxLength: 256, nullable: false, comment: "Изображение результата"), + MinValue = table.Column(type: "REAL", nullable: false, comment: "Минимальное значение"), + MaxValue = table.Column(type: "REAL", nullable: false, comment: "Максимальное значение"), + ParticlesCount = table.Column(type: "INTEGER", nullable: false, comment: "Количество частиц"), + MeanValue = table.Column(type: "REAL", nullable: false, comment: "Среднее значение"), + MeanVariance = table.Column(type: "REAL", nullable: false, comment: "Средняя дисперсия"), + VarianceOfMean = table.Column(type: "REAL", nullable: false, comment: "Дисперсия среднего"), + MeanValues = table.Column(type: "TEXT", nullable: false, comment: "Средние значения"), + Variances = table.Column(type: "TEXT", nullable: false, comment: "Дисперсии") + }, + constraints: table => + { + table.PrimaryKey("PK_ResultRecords", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ResultRecords"); + } + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/AmineContentResultsContextModelSnapshot.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/AmineContentResultsContextModelSnapshot.cs deleted file mode 100644 index 35c1e3d..0000000 --- a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/AmineContentResultsContextModelSnapshot.cs +++ /dev/null @@ -1,164 +0,0 @@ -// -using System; -using GSS2.Core.Analysis.AmineContent.Database.Results; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations -{ - [DbContext(typeof(AmineContentResultsContext))] - partial class AmineContentResultsContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("ImagePath") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Путь к файлу изображения"); - - b.Property("ImageType") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Тип изображения (sample - проба или separator - сепаратор)"); - - b.Property("Uv254Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 254 нм (0..1)"); - - b.Property("Uv365Intensity") - .HasColumnType("REAL") - .HasComment("Интенсивность УФ излучения 365 нм (0..1)"); - - b.Property("VisibleIntensity") - .HasColumnType("REAL") - .HasComment("Интенсивность видимого света (0..1)"); - - b.HasKey("Id"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasComment("Уникальный идентификатор"); - - b.Property("AirHumidity") - .HasColumnType("REAL") - .HasComment("Относительная влажность воздуха (%)"); - - b.Property("AirTemperature") - .HasColumnType("REAL") - .HasComment("Температура воздуха (℃)"); - - b.Property("SampleBrand") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Марка пробы"); - - b.Property("SampleComment") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Дополнительная информация о пробе"); - - b.Property("SampleMass") - .HasColumnType("REAL") - .HasComment("Масса пробы (кг)"); - - b.Property("SampleName") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Название пробы"); - - b.Property("SamplingDateTime") - .HasColumnType("TEXT") - .HasComment("Дата и время отбора пробы"); - - b.Property("SamplingPlace") - .IsRequired() - .HasColumnType("TEXT") - .HasComment("Место отбора пробы"); - - b.HasKey("Id"); - - b.ToTable("Records"); - }); - - modelBuilder.Entity("ImageDataRecord", b => - { - b.Property("SampleImagesId") - .HasColumnType("INTEGER"); - - b.Property("SampleRecordsId") - .HasColumnType("INTEGER"); - - b.HasKey("SampleImagesId", "SampleRecordsId"); - - b.HasIndex("SampleRecordsId"); - - b.ToTable("SampleImages", (string)null); - }); - - modelBuilder.Entity("ImageDataRecord1", b => - { - b.Property("SeparatorImagesId") - .HasColumnType("INTEGER"); - - b.Property("SeparatorRecordsId") - .HasColumnType("INTEGER"); - - b.HasKey("SeparatorImagesId", "SeparatorRecordsId"); - - b.HasIndex("SeparatorRecordsId"); - - b.ToTable("SeparatorImages", (string)null); - }); - - modelBuilder.Entity("ImageDataRecord", b => - { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", null) - .WithMany() - .HasForeignKey("SampleImagesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", null) - .WithMany() - .HasForeignKey("SampleRecordsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("ImageDataRecord1", b => - { - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+ImageData", null) - .WithMany() - .HasForeignKey("SeparatorImagesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("GSS2.Core.Analysis.AmineContent.Database.Results.AmineContentResultsContext+Record", null) - .WithMany() - .HasForeignKey("SeparatorRecordsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/ResultsContextModelSnapshot.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/ResultsContextModelSnapshot.cs new file mode 100644 index 0000000..2d89871 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/Migrations/ResultsContextModelSnapshot.cs @@ -0,0 +1,120 @@ +// +using System; +using GSS2.Core.Analysis.AmineContent.Database.Results; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GSS2.Core.Analysis.AmineContent.Database.Results.Migrations +{ + [DbContext(typeof(ResultsContext))] + partial class ResultsContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("GSS2.Core.Analysis.AmineContent.Database.Results.ResultRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasComment("Уникальный идентификатор"); + + b.Property("Image") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение"); + + b.Property("MaskImage") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Маска"); + + b.Property("MaxValue") + .HasColumnType("REAL") + .HasComment("Максимальное значение"); + + b.Property("MeanValue") + .HasColumnType("REAL") + .HasComment("Среднее значение"); + + b.PrimitiveCollection("MeanValues") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Средние значения"); + + b.Property("MeanVariance") + .HasColumnType("REAL") + .HasComment("Средняя дисперсия"); + + b.Property("MinValue") + .HasColumnType("REAL") + .HasComment("Минимальное значение"); + + b.Property("ParticlesCount") + .HasColumnType("INTEGER") + .HasComment("Количество частиц"); + + b.Property("ResultImage") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Изображение результата"); + + b.Property("SampleBrand") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Марка пробы"); + + b.Property("SampleComment") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasComment("Дополнительная информация о пробе"); + + b.Property("SampleName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Название пробы"); + + b.Property("SamplingDateTime") + .HasColumnType("TEXT") + .HasComment("Дата и время отбора пробы"); + + b.Property("SamplingPlace") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Место отбора пробы"); + + b.Property("SeparatorType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasComment("Тип сепаратора"); + + b.Property("VarianceOfMean") + .HasColumnType("REAL") + .HasComment("Дисперсия среднего"); + + b.PrimitiveCollection("Variances") + .IsRequired() + .HasColumnType("TEXT") + .HasComment("Дисперсии"); + + b.HasKey("Id"); + + b.ToTable("ResultRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/ResultRecord.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultRecord.cs new file mode 100644 index 0000000..c74df96 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultRecord.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.EntityFrameworkCore; + +namespace GSS2.Core.Analysis.AmineContent.Database.Results; + +public class ResultRecord +{ + [Comment("Уникальный идентификатор")] + public int Id { get; set; } + [Comment("Название пробы")] + [MaxLength(256)] + public required string SampleName { get; set; } + [Comment("Дополнительная информация о пробе")] + [MaxLength(512)] + public required string SampleComment { get; set; } + [Comment("Дата и время отбора пробы")] + public required DateTime SamplingDateTime { get; set; } + [Comment("Место отбора пробы")] + [MaxLength(256)] + public required string SamplingPlace { get; set; } + [Comment("Изображение")] + [MaxLength(256)] + public required string Image { get; set; } + [Comment("Тип сепаратора")] + [MaxLength(256)] + public required string SeparatorType { get; set; } + [Comment("Марка пробы")] + [MaxLength(256)] + public required string SampleBrand { get; set; } + [Comment("Маска")] + [MaxLength(256)] + public required string MaskImage { get; set; } + [Comment("Изображение результата")] + [MaxLength(256)] + public required string ResultImage { get; set; } + [Comment("Минимальное значение")] + public required double MinValue { get; set; } + [Comment("Максимальное значение")] + public required double MaxValue { get; set; } + [Comment("Количество частиц")] + public required int ParticlesCount { get; set; } + [Comment("Среднее значение")] + public required double MeanValue { get; set; } + [Comment("Средняя дисперсия")] + public required double MeanVariance { get; set; } + [Comment("Дисперсия среднего")] + public required double VarianceOfMean { get; set; } + [Comment("Средние значения")] + public required double[] MeanValues { get; set; } + [Comment("Дисперсии")] + public required double[] Variances { get; set; } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContext.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContext.cs new file mode 100644 index 0000000..e36c301 --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace GSS2.Core.Analysis.AmineContent.Database.Results; + +public class ResultsContext : DbContext +{ + public DbSet ResultRecords => Set(); + + public ResultsContext(DbContextOptions options) + : base(options) + { } +} diff --git a/GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContextFactory.cs b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContextFactory.cs similarity index 50% rename from GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContextFactory.cs rename to GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContextFactory.cs index 02439ac..1eb9a68 100644 --- a/GSS2.Core/Analysis/AmineContent/Database/Results/AmineContentResultsContextFactory.cs +++ b/GSS2.Core/Analysis/AmineContent/Database/Results/ResultsContextFactory.cs @@ -3,16 +3,16 @@ using Microsoft.EntityFrameworkCore.Design; namespace GSS2.Core.Analysis.AmineContent.Database.Results; -public class AmineContentResultsContextFactory : IDesignTimeDbContextFactory +public class ResultsContextFactory : IDesignTimeDbContextFactory { - public AmineContentResultsContext CreateDbContext(string[] args) + public ResultsContext CreateDbContext(string[] args) { - var optionsBuilder = new DbContextOptionsBuilder(); + var optionsBuilder = new DbContextOptionsBuilder(); // Строка подключения для миграций в design-time - // в runtime должна использоваться настоящая ConnectionString + // в runtime должна использоваться настоящая ConnectionString optionsBuilder.UseSqlite("Data Source=AmineContentResults.db"); - return new AmineContentResultsContext(optionsBuilder.Options); + return new ResultsContext(optionsBuilder.Options); } } diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs b/GSS2.Core/Analysis/AmineContent/ImageCapturerService.cs similarity index 95% rename from GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs rename to GSS2.Core/Analysis/AmineContent/ImageCapturerService.cs index 5671a2f..44c1710 100644 --- a/GSS2.Core/Analysis/AmineContent/AmineContentImageCapturerService.cs +++ b/GSS2.Core/Analysis/AmineContent/ImageCapturerService.cs @@ -11,7 +11,7 @@ using OpenCvSharp; namespace GSS2.Core.Analysis.AmineContent; -public class AmineContentImageCapturerService +public class ImageCapturerService { // Список интенсивностей освещения для каждого кадра public static readonly ReadOnlyCollection<(double visible, double uv365, double uv254)> IlluminatorIntensities = @@ -30,13 +30,13 @@ public class AmineContentImageCapturerService (0.00625, 1.0, 1.0) ]; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly CameraService _camera; private readonly IlluminatorService _illuminator; private readonly LightsService _lights; - public AmineContentImageCapturerService( - ILogger logger, + public ImageCapturerService( + ILogger logger, CameraService camera, IlluminatorService illuminator, LightsService lights diff --git a/GSS2.Core/Analysis/AmineContent/ImageData.cs b/GSS2.Core/Analysis/AmineContent/ImageData.cs new file mode 100644 index 0000000..4bb0b9f --- /dev/null +++ b/GSS2.Core/Analysis/AmineContent/ImageData.cs @@ -0,0 +1,17 @@ +namespace GSS2.Core.Analysis.AmineContent; + +public record ImageData +{ + public string Path { get; set; } + public double VisibleIntensity { get; set; } + public double Uv365Intensity { get; set; } + public double Uv254Intensity { get; set; } + + public ImageData(string path, double visibleIntensity, double uv365Intensity, double uv254Intensity) + { + Path = path; + VisibleIntensity = visibleIntensity; + Uv365Intensity = uv365Intensity; + Uv254Intensity = uv254Intensity; + } +} diff --git a/GSS2.Core/DependencyInjectionHelper.cs b/GSS2.Core/DependencyInjectionHelper.cs index 3470321..f629ee4 100644 --- a/GSS2.Core/DependencyInjectionHelper.cs +++ b/GSS2.Core/DependencyInjectionHelper.cs @@ -95,6 +95,9 @@ public static class DependencyInjectionHelper ); public static IServiceCollection AddComputeResourcesService(this IServiceCollection services) => services .AddSingleton(); + [Obsolete] + public static IServiceCollection AddUsbService(this IServiceCollection services) => services + .AddSingleton(); public static IServiceCollection AddImageStorageService(this IServiceCollection services, DirectoryInfo rootDirectory) => services .AddSingleton( serviceProvider => @@ -107,7 +110,7 @@ public static class DependencyInjectionHelper ); public static IServiceCollection AddAmineContentCalibrationContext(this IServiceCollection services, IConfigurationManager configuration) => services - .AddDbContext( + .AddDbContext( options => { var databasePath = configuration.GetConnectionString("AmineContentCalibrationDatabasePath"); @@ -116,7 +119,7 @@ public static class DependencyInjectionHelper ServiceLifetime.Singleton ); public static IServiceCollection AddAmineContentResultsContext(this IServiceCollection services, IConfigurationManager configuration) => services - .AddDbContext( + .AddDbContext( options => { var databasePath = configuration.GetConnectionString("AmineContentResultsDatabasePath"); @@ -126,8 +129,8 @@ public static class DependencyInjectionHelper ); public static IServiceCollection AddAmineContentImageCapturerService(this IServiceCollection services) => services - .AddSingleton(); + .AddSingleton(); public static IServiceCollection AddAmineContentAnalyzerService(this IServiceCollection services) => services - .AddSingleton(); + .AddSingleton(); } diff --git a/GSS2.Core/Extensions/EnumerableExtensions.cs b/GSS2.Core/Extensions/EnumerableExtensions.cs index 79fae17..ca0ff76 100644 --- a/GSS2.Core/Extensions/EnumerableExtensions.cs +++ b/GSS2.Core/Extensions/EnumerableExtensions.cs @@ -34,5 +34,16 @@ public static class EnumerableExtensions foreach (var kv in collection) values.Add(kv); } -} + public static double Variance(this IEnumerable values) + { + if (values.Count() == 1) + return 0; + + double mean = values.Average(); + double variance = 0.0; + foreach (int value in values) + variance += Math.Pow(value - mean, 2.0); + return variance / values.Count(); + } +} diff --git a/GSS2.Core/GSS2.Core.csproj b/GSS2.Core/GSS2.Core.csproj index 530327b..2465a29 100644 --- a/GSS2.Core/GSS2.Core.csproj +++ b/GSS2.Core/GSS2.Core.csproj @@ -17,6 +17,7 @@ + diff --git a/GSS2.Core/Hardware/UsbService.cs b/GSS2.Core/Hardware/UsbService.cs new file mode 100644 index 0000000..8efaefc --- /dev/null +++ b/GSS2.Core/Hardware/UsbService.cs @@ -0,0 +1,72 @@ +using System.Device.I2c; + +using Microsoft.Extensions.Logging; + +using GSS2.Core.Abstractions; +using System.Diagnostics; + +namespace GSS2.Core.Hardware; + +[Obsolete("Not implemented")] +public class UsbService : IDisposable +{ + private bool _disposedValue; + private readonly ILogger _logger; + private readonly Process _monitor; + + public UsbService(ILogger logger) + { + _logger = logger; + + _logger.LogInformation("Инициализация"); + + _monitor = new Process(); + _monitor.StartInfo = new ProcessStartInfo() + { + FileName = "udisksctl", + Arguments = "monitor", + RedirectStandardOutput = true, + RedirectStandardError = true + }; + _monitor.OutputDataReceived += MonitorDataReceived; + _monitor.ErrorDataReceived += MonitorDataReceived; + _monitor.Exited += (s, ea) => _logger.LogWarning("Монитор udisksctl отключен"); + if (_monitor.Start()) + _logger.LogInformation("Монитор udisksctl запущен"); + else + _logger.LogError("Не удалось запустить монитор udisksctl"); + _monitor.BeginOutputReadLine(); + _monitor.BeginErrorReadLine(); + + _logger.LogInformation("Инициализировано"); + } + + private void MonitorDataReceived(object sender, DataReceivedEventArgs ea) + { + if (ea.Data is null) + return; + _logger.LogInformation("Data rec {}", ea.Data); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + try + { + _monitor.Close(); + } + catch { } + } + + _disposedValue = true; + } + } + void IDisposable.Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/GSS2.Core/Hardware/ImageStorageService.cs b/GSS2.Core/ImageStorageService.cs similarity index 80% rename from GSS2.Core/Hardware/ImageStorageService.cs rename to GSS2.Core/ImageStorageService.cs index 26d403c..d4837cd 100644 --- a/GSS2.Core/Hardware/ImageStorageService.cs +++ b/GSS2.Core/ImageStorageService.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace GSS2.Core.Hardware; +namespace GSS2.Core; public class ImageStorageService { @@ -10,19 +10,19 @@ public class ImageStorageService public ImageStorageService(ILogger logger, DirectoryInfo rootDirectory) { _logger = logger; - _logger.LogInformation("Initialization"); + _logger.LogInformation("Инициализация"); _rootDirectory = rootDirectory; if (!_rootDirectory.Exists) { _rootDirectory.Create(); - _logger.LogInformation("Created directory: {}", _rootDirectory.FullName); + _logger.LogInformation("Создана директория: {}", _rootDirectory.FullName); } - _logger.LogInformation("Initialized"); + _logger.LogInformation("Инициализировано"); } - public async Task SaveAsync(Stream fileStream, string extension, string subDirectory, CancellationToken ct = default) + public async Task SaveAsync(Stream fileStream, string extension, string subDirectory, CancellationToken cancellationToken = default) { var fileName = GenerateFileName(extension); var fullPath = GetFullPath(fileName, subDirectory); @@ -39,9 +39,9 @@ public class ImageStorageService 81920, useAsync: true); - await fileStream.CopyToAsync(file, ct); + await fileStream.CopyToAsync(file, cancellationToken); - _logger.LogInformation("File saved: {FileName}", fileName); + _logger.LogInformation("Файл сохранён: {}", fileName); return fileName; } @@ -64,13 +64,13 @@ public class ImageStorageService return false; File.Delete(fullPath); - _logger.LogInformation("File removed: {}", fileName); + _logger.LogInformation("Файл удалён: {}", fileName); var parentDirectory = Directory.GetParent(fullPath); if (parentDirectory?.GetFiles().Count() == 0) { parentDirectory.Delete(); - _logger.LogInformation("Empty directory removed: {}", parentDirectory.Name); + _logger.LogInformation("Удалена пустая директория: {}", parentDirectory.Name); } return true; @@ -84,6 +84,9 @@ public class ImageStorageService public string? GetFullPath(string fileName, string? subDirectory = null) { + if (fileName.Count() < 4) + return null; + var level1 = fileName.Substring(0, 2); var level2 = fileName.Substring(2, 2); @@ -107,4 +110,4 @@ public class ImageStorageService .SelectMany(ListFiles) .ToList(); } -} \ No newline at end of file +} diff --git a/GSS2.Core/tools/CreateMigrations.ps1 b/GSS2.Core/tools/CreateMigrations.ps1 index 83c2019..880085b 100644 --- a/GSS2.Core/tools/CreateMigrations.ps1 +++ b/GSS2.Core/tools/CreateMigrations.ps1 @@ -1,2 +1,2 @@ -dotnet ef migrations add -o .\Analysis\AmineContent\Database\Calibration\Migrations --project .\GSS2.Core\ AmineContentCalibration0 --context AmineContentCalibrationContext -dotnet ef migrations add -o .\Analysis\AmineContent\Database\Results\Migrations --project .\GSS2.Core\ AmineContentResults0 --context AmineContentResultsContext \ No newline at end of file +dotnet ef migrations add -o .\Analysis\AmineContent\Database\Calibration\Migrations --project .\GSS2.Core\ Calibration0 --context CalibrationContext +dotnet ef migrations add -o .\Analysis\AmineContent\Database\Results\Migrations --project .\GSS2.Core\ Results0 --context ResultsContext diff --git a/GSS2.Core/tools/CreateMigrations.sh b/GSS2.Core/tools/CreateMigrations.sh index d069ec8..31ad058 100644 --- a/GSS2.Core/tools/CreateMigrations.sh +++ b/GSS2.Core/tools/CreateMigrations.sh @@ -1,2 +1,2 @@ -dotnet ef migrations add -o ./Analysis/AmineContent/Database/Calibration/Migrations --project ./GSS2.Core/ AmineContentCalibration0 --context AmineContentCalibrationContext -dotnet ef migrations add -o ./Analysis/AmineContent/Database/Results/Migrations --project ./GSS2.Core/ AmineContentResults0 --context AmineContentResultsContext \ No newline at end of file +dotnet ef migrations add -o ./Analysis/AmineContent/Database/Calibration/Migrations --project ./GSS2.Core/ Calibration0 --context CalibrationContext +dotnet ef migrations add -o ./Analysis/AmineContent/Database/Results/Migrations --project ./GSS2.Core/ Results0 --context ResultsContext diff --git a/GSS2.UI.Core/GSS2.UI.Core.csproj b/GSS2.UI.Core/GSS2.UI.Core.csproj index f4778a4..22fe7e2 100644 --- a/GSS2.UI.Core/GSS2.UI.Core.csproj +++ b/GSS2.UI.Core/GSS2.UI.Core.csproj @@ -1,30 +1,32 @@  - - net10.0 - enable - enable - true - + + net10.0 + enable + enable + true + - - - - - - - - - - - None - All - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs b/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs index cdfaad1..ac087fb 100644 --- a/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs +++ b/GSS2.UI.Core/ViewModels/AsyncImageViewModel.cs @@ -4,34 +4,30 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using GSS2.Core.Analysis.AmineContent.Database.Calibration; - namespace GSS2.UI.Core.ViewModels; public partial class AsyncImageRecordViewModel : ViewModelBase { private static readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly string _path; - - [ObservableProperty] public partial ImageRecord ImageRecord { get; set; } + [ObservableProperty] public partial string Path { get; set; } [ObservableProperty] public partial IImage? Image { get; set; } [ObservableProperty] public partial bool IsLoading { get; set; } = true; - public AsyncImageRecordViewModel(string path, ImageRecord imageRecord) + public AsyncImageRecordViewModel(string path) { - _path = path; - ImageRecord = imageRecord; - _ = LoadAsync(); + Path = path; } + partial void OnPathChanged(string value) => _ = LoadAsync(); + private async Task LoadAsync() { await _semaphore.WaitAsync(); try { - var bitmap = await Task.Run(() => new Bitmap(_path)); + var bitmap = await Task.Run(() => new Bitmap(Path)); await Dispatcher.UIThread.InvokeAsync(() => { diff --git a/GSS2/Application.axaml.cs b/GSS2/Application.axaml.cs index b722263..fc766f2 100644 --- a/GSS2/Application.axaml.cs +++ b/GSS2/Application.axaml.cs @@ -23,7 +23,7 @@ public partial class Application : Avalonia.Application private bool _allShutdownSequencesRunning; - public NavigationService Navigation { get; } + public NavigationService? Navigation { get; private set; } = null; public Application(IServiceProvider services, bool fullscreen = false) { @@ -37,7 +37,6 @@ public partial class Application : Avalonia.Application // Создаём DependencyInjectionViewLocator _logger = _services.GetRequiredService>(); _viewLocator = _services.GetRequiredService(); - Navigation = _services.GetRequiredService(); } public override void Initialize() @@ -131,6 +130,7 @@ public partial class Application : Avalonia.Application desktop.MainWindow = mainWindow; // Переключаемся на начальный элемент управления + Navigation = _services.GetRequiredService(); Navigation.NavigateTo(mainView); break; diff --git a/GSS2/Commands/ImageStorageClear.cs b/GSS2/Commands/ImageStorageClear.cs deleted file mode 100644 index 09eb32a..0000000 --- a/GSS2/Commands/ImageStorageClear.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.CommandLine; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Configuration; - -using GSS2.Core; -using GSS2.Core.Hardware; -using GSS2.Core.Analysis.AmineContent.Database.Calibration; -using GSS2.Core.Analysis.AmineContent.Database.Results; - -namespace GSS2.Commands; - -public static class ImageStorageClear -{ - private class ImageStorageClearWorker : BackgroundService - { - private readonly ILogger _logger; - private readonly IHostApplicationLifetime _applicationLifetime; - private readonly AmineContentCalibrationContext _context; - private readonly ImageStorageService _imageStorage; - - public ImageStorageClearWorker( - ILogger logger, - IHostApplicationLifetime applicationLifetime, - AmineContentCalibrationContext context, - ImageStorageService imageStorage - ) - { - _applicationLifetime = applicationLifetime; - _logger = logger; - _context = context; - _imageStorage = imageStorage; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Логгируем информацию - _logger.LogInformation("Очистка хранилища от неиспользуемых изображений..."); - - // Получаем все изображения из базы данных - var recordsFiles = _context.ImageRecords.Select(r => r.ImagePath); - // Получаем все изображения в хранилище - var storedFiles = _imageStorage.ListFiles() - .Select(Path.GetFileName) - .OfType(); - - // Находим неиспользуемые изображения - var unusedFiles = storedFiles.Except(recordsFiles) - .ToList() - .Order(); - - // Если неиспользуемых изображений нет, то закрываемся - if (unusedFiles is null || unusedFiles.Count() == 0) - { - _logger.LogInformation("Неиспользуемые изображения не найдены"); - _applicationLifetime.StopApplication(); - return; - } - - _logger.LogInformation("Найдено неиспользуемых изображений: {}\n\t{}", unusedFiles.Count(), string.Join("\n\t", unusedFiles)); - - // Пока пользователь не выдал правильное действие или не закрыл приложение - while (!stoppingToken.IsCancellationRequested) - { - // Задаём вопрос - _logger.LogInformation("Удалить эти файлы? (Да/Нет) "); - // Читаем консоль - var input = Console.ReadLine()?.Trim().ToLower(); - - // Если нет, то выходим - if (input == "n" || input == "no" || input == "н" || input == "нет") - break; - // Если да, то удаляем файлы и выходим - if (input == "y" || input == "yes" || input == "д" || input == "да") - { - foreach (var file in unusedFiles) - _imageStorage.Delete(file); - break; - } - } - - _applicationLifetime.StopApplication(); - } - } - - public static readonly RootCommand RootCommand; - private static readonly Command Command; - - static ImageStorageClear() - { - // Создаём команды, опции, аргументы и пр. - RootCommand = new("Очистить хранилище от неиспользуемых изображений"); - Command = new("image-storage-clear") - { - Description = "Очистить хранилище от неиспользуемых изображений" - }; - - // Изменяем описания стандартных опций - Helper.TranslateDefaultOptionDescriptions(RootCommand); - - // Наполняем команды - RootCommand.SetAction(CommandAction); - - Command.SetAction(CommandAction); - } - - /// - /// Получить команду как корневую - /// - public static RootCommand GetCommand() => RootCommand; - - /// - /// Зарегистрировать команду как подкоманду - /// - /// Команда в которой необходимо зарегистрировать подкоманду - public static void RegisterCommand(Command command) => command.Add(Command); - - private static void CommandAction(ParseResult result) - { - // Создаём хост - var builder = Host.CreateApplicationBuilder(); - - // Добавляем конфигурацию из appsettings.json - builder.Configuration.AddAppSettingsJson(); - - // Конфигурируем логгер - builder.Logging.ConfigureLogging(); - - // Добавляем сервисы баз данных - builder.Services.AddAmineContentCalibrationContext(builder.Configuration); - builder.Services.AddAmineContentResultsContext(builder.Configuration); - - // Добавляем сервисы аппаратной части - builder.Services.AddImageStorageService(new DirectoryInfo(builder.Configuration.GetValue("ImageStorage") ?? "images")); - - // Добавляем рабочий сервис - builder.Services.AddHostedService(); - - // Собираем хост - var host = builder.Build(); - - // Применяем миграции баз данных если их схема данных изменялась - using (var scope = host.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - context.Database.Migrate(); - } - using (var scope = host.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - context.Database.Migrate(); - } - - // Запускаем хост - host.Run(); - } -} diff --git a/GSS2/Commands/UI.cs b/GSS2/Commands/UI.cs index aade487..1c440ef 100644 --- a/GSS2/Commands/UI.cs +++ b/GSS2/Commands/UI.cs @@ -12,6 +12,7 @@ using GSS2.Core; using GSS2.Core.Logging; using GSS2.Core.Analysis.AmineContent.Database.Calibration; using GSS2.Core.Analysis.AmineContent.Database.Results; +using GSS2.Core.Hardware; namespace GSS2.Commands; @@ -160,6 +161,7 @@ public static class UI builder.Services.AddComputeResourcesService(); builder.Services.AddImageStorageService(new DirectoryInfo(builder.Configuration.GetValue("ImageStorage") ?? "images")); builder.Services.AddAmineContentImageCapturerService(); + builder.Services.AddAmineContentAnalyzerService(); // Добавляем элементы интерфейса как сервисы builder.Services.AddViewsAndModels(); @@ -174,12 +176,12 @@ public static class UI // Применяем миграции баз данных если их схема данных изменялась using (var scope = host.Services.CreateScope()) { - var context = scope.ServiceProvider.GetRequiredService(); + var context = scope.ServiceProvider.GetRequiredService(); context.Database.Migrate(); } using (var scope = host.Services.CreateScope()) { - var context = scope.ServiceProvider.GetRequiredService(); + var context = scope.ServiceProvider.GetRequiredService(); context.Database.Migrate(); } diff --git a/GSS2/DependencyInjectionHelper.cs b/GSS2/DependencyInjectionHelper.cs index 8ca4a7d..e27d441 100644 --- a/GSS2/DependencyInjectionHelper.cs +++ b/GSS2/DependencyInjectionHelper.cs @@ -31,6 +31,11 @@ public static class DependencyInjectionHelper .AddSingleton() .AddSingleton() .AddSingleton() + // База + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() // Общие .AddSingleton() .AddSingleton() @@ -39,12 +44,10 @@ public static class DependencyInjectionHelper // AmineContent .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton(); } diff --git a/GSS2/GSS2.csproj b/GSS2/GSS2.csproj index 47a1ea9..97de1db 100644 --- a/GSS2/GSS2.csproj +++ b/GSS2/GSS2.csproj @@ -5,6 +5,7 @@ net10.0 enable enable + true $(NoWarn);IDE0090;IDE1006;IDE0028;IDE0305;IDE0290;CA1826;CA1829;CA1873 @@ -17,7 +18,7 @@ - + diff --git a/GSS2/Program.cs b/GSS2/Program.cs index c8795a0..119a389 100644 --- a/GSS2/Program.cs +++ b/GSS2/Program.cs @@ -30,7 +30,6 @@ public partial class Program var rootCommand = Commands.UI.GetCommand(); Commands.CameraTuning.RegisterCommand(rootCommand); Commands.Capture.RegisterCommand(rootCommand); - Commands.ImageStorageClear.RegisterCommand(rootCommand); // Обрабатываем аргументы var parseResult = rootCommand?.Parse(_args); diff --git a/GSS2/ViewModels/AmineContent/AnalysisViewModel.cs b/GSS2/ViewModels/AmineContent/AnalysisViewModel.cs index 9590818..3db0d63 100644 --- a/GSS2/ViewModels/AmineContent/AnalysisViewModel.cs +++ b/GSS2/ViewModels/AmineContent/AnalysisViewModel.cs @@ -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 _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 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 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(_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("Инициализировано"); + } + + /// + /// Вызывается при изменении .
+ /// Копирует в или издаёт пустую . + ///
+ 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); + } + } + + /// + /// Вызывается при изменении .
+ /// Изменяет изображением из . + ///
+ 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>(); + + // Обновляем данные в интерфейсе + // 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(); + var variances = new List(); + + // Создаём временную маску для каждого контура + 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; + } + + /// + /// Копирует , отвязывая её от базы данных + /// + /// Скопированная + 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(), [new Axis()], [new Axis()]); + } + private static (ISeries[] Series, Axis[] XAxes, Axis[] YAxes) BuildHistogram(IEnumerable 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 + { + Values = binsPDF, + Name = barLegend, + Fill = new SolidColorPaint(SKColors.White), + ScalesYAt = 0 + }, + new LineSeries + { + 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 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); } diff --git a/GSS2/ViewModels/AmineContent/ResultsViewModel.cs b/GSS2/ViewModels/AmineContent/ResultsViewModel.cs deleted file mode 100644 index a654227..0000000 --- a/GSS2/ViewModels/AmineContent/ResultsViewModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -using GSS2.UI.Core.ViewModels; - -namespace GSS2.ViewModels.AmineContent; - -public partial class ResultsViewModel : ViewModelBase -{ - public ResultsViewModel() - { - - } -} diff --git a/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs b/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs index 4faa5fd..be0ab01 100644 --- a/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs +++ b/GSS2/ViewModels/AmineContent/SampleCalibrationViewModel.cs @@ -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 _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 Records { get; set; } + [ObservableProperty] public partial ObservableCollection 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 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 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 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( - _context.CalibrationRecords - .Include(r => r.SampleImages) - .Include(r => r.SeparatorImages) - ); + Records = new ObservableCollection(_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 /// Вызывается при изменении .
/// Копирует в или издаёт пустую . /// - 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 /// /// Вызывается при изменении .
- /// Заполняет изображениями из . + /// Изменяет изображением из . ///
- 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(); - - // Создаём новую коллекцию изображений - // 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(); - // Создаём новую коллекцию изображений - // 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(); + var imagesData = new List>(); // Обновляем данные в интерфейсе - // 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(); - - // Обновляем данные в редактируемой коллекции - // Так же 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(); /// - /// Копирует , отвязывая её от базы данных + /// Копирует , отвязывая её от базы данных /// - /// Скопированная - private static CalibrationRecord CopyRecord(CalibrationRecord source) + /// Скопированная + 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() }; } /// - /// Копирует , отвязывая её от базы данных + /// Копирует , отвязывая её от базы данных /// - /// Скопированная - private static ImageRecord CopyRecord(ImageRecord source) + /// Скопированная + 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() }; } } diff --git a/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs b/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs index 310280c..8fee2a3 100644 --- a/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs +++ b/GSS2/ViewModels/AmineContent/SeparatorCalibrationViewModel.cs @@ -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 _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 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 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(_context.SeparatorRecords.Include(r => r.Images)); + Records = new ObservableCollection(_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 /// /// Вызывается при изменении .
- /// Заполняет изображениями из . + /// Изменяет изображением из . ///
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(); - // Создаём новую коллекцию изображений - // 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(); + var imagesData = new List>(); // Обновляем данные в интерфейсе - // 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(); + _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() }; } /// - /// Копирует , отвязывая её от базы данных + /// Копирует , отвязывая её от базы данных /// - /// Скопированная - private static ImageRecord CopyRecord(ImageRecord source) + /// Скопированная + 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() }; } } diff --git a/GSS2/ViewModels/AmineContentViewModel.cs b/GSS2/ViewModels/AmineContentViewModel.cs index 9008c29..ebd9d2b 100644 --- a/GSS2/ViewModels/AmineContentViewModel.cs +++ b/GSS2/ViewModels/AmineContentViewModel.cs @@ -13,9 +13,7 @@ public partial class AmineContentViewModel : ViewModelBase { private readonly NavigationService _navigationService; - [ObservableProperty] public partial StartViewModel Start { get; set; } [ObservableProperty] public partial AnalysisViewModel Analysis { get; set; } - [ObservableProperty] public partial ResultsViewModel Results { get; set; } [ObservableProperty] public partial SampleCalibrationViewModel SampleCalibration { get; set; } [ObservableProperty] public partial SeparatorCalibrationViewModel SeparatorCalibration { get; set; } [ObservableProperty] public partial CameraViewModel Camera { get; set; } @@ -26,9 +24,7 @@ public partial class AmineContentViewModel : ViewModelBase public AmineContentViewModel( NavigationService navigationService, - StartViewModel start, AnalysisViewModel analysis, - ResultsViewModel results, SampleCalibrationViewModel sampleCalibration, SeparatorCalibrationViewModel separatorCalibration, CameraViewModel camera, @@ -36,9 +32,7 @@ public partial class AmineContentViewModel : ViewModelBase ) { _navigationService = navigationService; - Start = start; Analysis = analysis; - Results = results; SampleCalibration = sampleCalibration; SeparatorCalibration = separatorCalibration; Camera = camera; diff --git a/GSS2/ViewModels/DeleteConfirmViewModel.cs b/GSS2/ViewModels/DeleteConfirmViewModel.cs new file mode 100644 index 0000000..3933298 --- /dev/null +++ b/GSS2/ViewModels/DeleteConfirmViewModel.cs @@ -0,0 +1,38 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using GSS2.UI.Core.Services; +using GSS2.UI.Core.ViewModels; + +namespace GSS2.ViewModels; + +public partial class DeleteConfirmViewModel : ViewModelBase +{ + public event EventHandler? Closes; + + private readonly NavigationService _navigation; + + [ObservableProperty] public partial string Question { get; set; } = ""; + [ObservableProperty] public partial bool Result { get; set; } = false; + + public DeleteConfirmViewModel(NavigationService navigation) + { + _navigation = navigation; + } + + [RelayCommand] + private void Delete() + { + Result = true; + Closes?.Invoke(this, Result); + _navigation.NavigateBack(); + } + + [RelayCommand] + private void Close() + { + Result = false; + Closes?.Invoke(this, Result); + _navigation.NavigateBack(); + } +} diff --git a/GSS2/ViewModels/NumericKeyboardViewModel.cs b/GSS2/ViewModels/NumericKeyboardViewModel.cs new file mode 100644 index 0000000..a03c088 --- /dev/null +++ b/GSS2/ViewModels/NumericKeyboardViewModel.cs @@ -0,0 +1,36 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using GSS2.UI.Core.Services; +using GSS2.UI.Core.ViewModels; + +namespace GSS2.ViewModels; + +public partial class NumericKeyboardViewModel : ViewModelBase +{ + public event EventHandler? Closes; + + private readonly NavigationService _navigation; + + [ObservableProperty] public partial double? Value { get; set; } = null; + + public NumericKeyboardViewModel(NavigationService navigation) + { + _navigation = navigation; + } + + [RelayCommand] + private void Accept() + { + Closes?.Invoke(this, Value); + _navigation.NavigateBack(); + } + + [RelayCommand] + private void Cancel() + { + Value = null; + Closes?.Invoke(this, Value); + _navigation.NavigateBack(); + } +} diff --git a/GSS2/ViewModels/TextKeyboardViewModel.cs b/GSS2/ViewModels/TextKeyboardViewModel.cs new file mode 100644 index 0000000..9562007 --- /dev/null +++ b/GSS2/ViewModels/TextKeyboardViewModel.cs @@ -0,0 +1,37 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using GSS2.UI.Core.Services; +using GSS2.UI.Core.ViewModels; + +namespace GSS2.ViewModels; + +public partial class TextKeyboardViewModel : ViewModelBase +{ + public event EventHandler? Closes; + + private readonly NavigationService _navigation; + + [ObservableProperty] public partial string? Value { get; set; } = null; + [ObservableProperty] public partial bool IsMultiline { get; set; } = false; + + public TextKeyboardViewModel(NavigationService navigation) + { + _navigation = navigation; + } + + [RelayCommand] + private void Accept() + { + Closes?.Invoke(this, Value); + _navigation.NavigateBack(); + } + + [RelayCommand] + private void Cancel() + { + Value = null; + Closes?.Invoke(this, Value); + _navigation.NavigateBack(); + } +} diff --git a/GSS2/Views/AmineContent/AnalysisView.axaml b/GSS2/Views/AmineContent/AnalysisView.axaml index 1a73a41..759c6a6 100644 --- a/GSS2/Views/AmineContent/AnalysisView.axaml +++ b/GSS2/Views/AmineContent/AnalysisView.axaml @@ -3,11 +3,606 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:GSS2.ViewModels.AmineContent" + xmlns:converters="using:GSS2.UI.Core.Converters" + xmlns:avalonia_converters="using:Avalonia.Data.Converters" + xmlns:i="using:Avalonia.Xaml.Interactivity" + xmlns:ia="using:Avalonia.Xaml.Interactions.Custom" + xmlns:controls="using:GSS2.Controls" + xmlns:core="using:GSS2.UI.Core" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia" x:DataType="vm:AnalysisViewModel"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GSS2/Views/AmineContent/AnalysisView.axaml.cs b/GSS2/Views/AmineContent/AnalysisView.axaml.cs index 0fe6bfe..d3f7975 100644 --- a/GSS2/Views/AmineContent/AnalysisView.axaml.cs +++ b/GSS2/Views/AmineContent/AnalysisView.axaml.cs @@ -1,5 +1,7 @@ using Avalonia.Controls; +using GSS2.ViewModels.AmineContent; + namespace GSS2.Views.AmineContent; public partial class AnalysisView : UserControl @@ -7,5 +9,14 @@ public partial class AnalysisView : UserControl public AnalysisView() { InitializeComponent(); + + (DataContext as AnalysisViewModel)?.UpdateCharts += OnChartsUpdate; + DataContextChanged += (_, ea) => (DataContext as AnalysisViewModel)?.UpdateCharts += OnChartsUpdate; + } + + private void OnChartsUpdate(object? sender, EventArgs ea) + { + MeanValuesChart.CoreChart.Update(); + VariancesChart.CoreChart.Update(); } } diff --git a/GSS2/Views/AmineContent/ResultsView.axaml b/GSS2/Views/AmineContent/ResultsView.axaml deleted file mode 100644 index 6afd1c5..0000000 --- a/GSS2/Views/AmineContent/ResultsView.axaml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/GSS2/Views/AmineContent/ResultsView.axaml.cs b/GSS2/Views/AmineContent/ResultsView.axaml.cs deleted file mode 100644 index 3643e38..0000000 --- a/GSS2/Views/AmineContent/ResultsView.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace GSS2.Views.AmineContent; - -public partial class ResultsView : UserControl -{ - public ResultsView() - { - InitializeComponent(); - } -} diff --git a/GSS2/Views/AmineContent/SampleCalibrationView.axaml b/GSS2/Views/AmineContent/SampleCalibrationView.axaml index 16a39f4..d779c3f 100644 --- a/GSS2/Views/AmineContent/SampleCalibrationView.axaml +++ b/GSS2/Views/AmineContent/SampleCalibrationView.axaml @@ -7,9 +7,8 @@ xmlns:avalonia_converters="using:Avalonia.Data.Converters" xmlns:i="using:Avalonia.Xaml.Interactivity" xmlns:ia="using:Avalonia.Xaml.Interactions.Custom" - xmlns:local="using:GSS2" + xmlns:controls="using:GSS2.Controls" xmlns:core="using:GSS2.UI.Core" - xmlns:core_vm="using:GSS2.UI.Core.ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" x:DataType="vm:SampleCalibrationViewModel"> @@ -83,7 +82,7 @@ - + @@ -153,23 +152,23 @@ - + - + - + - + - + @@ -197,23 +196,23 @@ - + - + - + - + - + @@ -229,19 +228,15 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - + + + + + + + + + - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - @@ -708,9 +353,7 @@ - - + diff --git a/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml b/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml index bd8654e..8afe7c2 100644 --- a/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml +++ b/GSS2/Views/AmineContent/SeparatorCalibrationView.axaml @@ -7,15 +7,15 @@ xmlns:avalonia_converters="using:Avalonia.Data.Converters" xmlns:i="using:Avalonia.Xaml.Interactivity" xmlns:ia="using:Avalonia.Xaml.Interactions.Custom" - xmlns:local="using:GSS2" + xmlns:controls="using:GSS2.Controls" xmlns:core="using:GSS2.UI.Core" - xmlns:core_vm="using:GSS2.UI.Core.ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" x:DataType="vm:SeparatorCalibrationViewModel"> + @@ -53,6 +53,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GSS2/Views/DeleteConfirmView.axaml.cs b/GSS2/Views/DeleteConfirmView.axaml.cs new file mode 100644 index 0000000..2a72e4b --- /dev/null +++ b/GSS2/Views/DeleteConfirmView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace GSS2.Views; + +public partial class DeleteConfirmView : UserControl +{ + public DeleteConfirmView() + { + InitializeComponent(); + } +} diff --git a/GSS2/Views/MainView.axaml.cs b/GSS2/Views/MainView.axaml.cs index 2b81cbf..b9b8f47 100644 --- a/GSS2/Views/MainView.axaml.cs +++ b/GSS2/Views/MainView.axaml.cs @@ -7,5 +7,6 @@ public partial class MainView : UserControl public MainView() { InitializeComponent(); + Loaded += (_, _) => (DataContext as GSS2.ViewModels.MainViewModel)?.NavigateToAmineContentAnalysis(); } } diff --git a/GSS2/Views/NumericKeyboardView.axaml b/GSS2/Views/NumericKeyboardView.axaml new file mode 100755 index 0000000..19b86aa --- /dev/null +++ b/GSS2/Views/NumericKeyboardView.axaml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +