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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +