From 8cf1c8986309a8ce549b13acc7cef58e0c37d28d Mon Sep 17 00:00:00 2001 From: Alek-ban Date: Tue, 7 Apr 2026 13:28:20 +0300 Subject: [PATCH] feat: starting to build a application itself add "capture" and "camera-tuning" commands for cli --- .../AmineContentAnalyzerService.cs | 1535 ++++++++++++++++- .../AmineContentCalibrationDataService.cs | 23 - .../AmineContentCalibrationService.cs | 6 - .../AmineContentResultsDataService.cs | 23 - GSS2.Core/DependencyInjectionHelper.cs | 9 +- GSS2.Test/ProgramImagesStorageClear.cs | 4 +- GSS2/Commands/CameraTuning.cs | 473 +++++ GSS2/Commands/Capture.cs | 212 +++ GSS2/Commands/Helper.cs | 16 + GSS2/Commands/ImageStorageClear.cs | 43 + GSS2/Commands/UI.cs | 74 + GSS2/GSS2.csproj | 42 + GSS2/Program.cs | 100 ++ GSS2/appsettings.json | 82 + 14 files changed, 2587 insertions(+), 55 deletions(-) delete mode 100644 GSS2.Core/Analysis/AmineContent/AmineContentCalibrationDataService.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/AmineContentCalibrationService.cs delete mode 100644 GSS2.Core/Analysis/AmineContent/AmineContentResultsDataService.cs create mode 100644 GSS2/Commands/CameraTuning.cs create mode 100644 GSS2/Commands/Capture.cs create mode 100644 GSS2/Commands/Helper.cs create mode 100644 GSS2/Commands/ImageStorageClear.cs create mode 100644 GSS2/Commands/UI.cs create mode 100644 GSS2/GSS2.csproj create mode 100644 GSS2/Program.cs create mode 100644 GSS2/appsettings.json diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs index 96936f0..0bd0561 100644 --- a/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs +++ b/GSS2.Core/Analysis/AmineContent/AmineContentAnalyzerService.cs @@ -1,6 +1,1539 @@ +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"; -} \ No newline at end of file + 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 indencities to correlate with separator images intencities"); + + 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)> CalcaulateAmineContent(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 проценталь (1% самых горячих пикселей), которые скорее всего являются шумом или загрязенением + var oldMean = Cv2.Mean(result, sampleMask).Val0; + values.Sort(); + float p99 = values[(int)(values.Length * 0.99)]; + 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/AmineContentCalibrationDataService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentCalibrationDataService.cs deleted file mode 100644 index f81747e..0000000 --- a/GSS2.Core/Analysis/AmineContent/AmineContentCalibrationDataService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using GSS2.Core.Analysis.AmineContent.Database; -using GSS2.Core.Analysis.AmineContent.Database.Calibration; - -using Microsoft.Extensions.Logging; - -namespace GSS2.Core.Analysis.AmineContent; - -public class AmineContentCalibrationDataService -{ - private readonly ILogger _logger; - private AmineContentCalibrationContext _calibrationContext; - - public AmineContentCalibrationDataService(ILogger logger, AmineContentCalibrationContext calibrationContext) - { - _logger = logger; - _calibrationContext = calibrationContext; - } - - public async Task LoadData(CancellationToken cancellationToken = default) - { - //TODO - } -} \ No newline at end of file diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentCalibrationService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentCalibrationService.cs deleted file mode 100644 index d5c3786..0000000 --- a/GSS2.Core/Analysis/AmineContent/AmineContentCalibrationService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace GSS2.Core.Analysis.AmineContent; - -public class AmineContentCalibrationService -{ - -} \ No newline at end of file diff --git a/GSS2.Core/Analysis/AmineContent/AmineContentResultsDataService.cs b/GSS2.Core/Analysis/AmineContent/AmineContentResultsDataService.cs deleted file mode 100644 index 371111f..0000000 --- a/GSS2.Core/Analysis/AmineContent/AmineContentResultsDataService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using GSS2.Core.Analysis.AmineContent.Database; -using GSS2.Core.Analysis.AmineContent.Database.Calibration; - -using Microsoft.Extensions.Logging; - -namespace GSS2.Core.Analysis.AmineContent; - -public class AmineContentResultsDataService -{ - private readonly ILogger _logger; - private AmineContentCalibrationContext _calibrationContext; - - public AmineContentResultsDataService(ILogger logger, AmineContentCalibrationContext calibrationContext) - { - _logger = logger; - _calibrationContext = calibrationContext; - } - - public async Task LoadData(CancellationToken cancellationToken = default) - { - //TODO - } -} \ No newline at end of file diff --git a/GSS2.Core/DependencyInjectionHelper.cs b/GSS2.Core/DependencyInjectionHelper.cs index b2c39e3..5e03e9f 100644 --- a/GSS2.Core/DependencyInjectionHelper.cs +++ b/GSS2.Core/DependencyInjectionHelper.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using GSS2.Core.Hardware; using GSS2.Core.Analysis.AmineContent.Database.Calibration; using GSS2.Core.Analysis.AmineContent.Database.Results; +using GSS2.Core.Analysis.AmineContent; namespace GSS2.Core; @@ -107,4 +108,10 @@ public static class DependencyInjectionHelper }, ServiceLifetime.Singleton ); -} \ No newline at end of file + + public static IServiceCollection AddAmineContentImageCapturerService(this IServiceCollection services) => services + .AddSingleton(); + + public static IServiceCollection AddAmineContentAnalyzerService(this IServiceCollection services) => services + .AddSingleton(); +} diff --git a/GSS2.Test/ProgramImagesStorageClear.cs b/GSS2.Test/ProgramImagesStorageClear.cs index e609245..a453941 100644 --- a/GSS2.Test/ProgramImagesStorageClear.cs +++ b/GSS2.Test/ProgramImagesStorageClear.cs @@ -42,7 +42,9 @@ public partial class Program .Select(Path.GetFileName) .OfType(); - var unusedFiles = storedFiles.Except(recordsFiles); + var unusedFiles = storedFiles.Except(recordsFiles) + .ToList() + .Order(); if (unusedFiles is null || unusedFiles.Count() == 0) { diff --git a/GSS2/Commands/CameraTuning.cs b/GSS2/Commands/CameraTuning.cs new file mode 100644 index 0000000..4041820 --- /dev/null +++ b/GSS2/Commands/CameraTuning.cs @@ -0,0 +1,473 @@ +using System.CommandLine; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using OpenCvSharp; + +using GSS2.Core; +using GSS2.Core.Logging; +using GSS2.Core.Hardware; +using GSS2.Core.Extensions; + +namespace GSS2.Commands; + +public static class CameraTuning +{ + private class CameraTuningWorker : BackgroundService + { + private const double PatternOuterRadius = 180.0; + private const double PatternCenterRadius = 60.0; + + private static readonly Scalar[] KnownSegments = + { + new Scalar(0,255,255), // Голубой + new Scalar(0,0,255), // Синий + new Scalar(255,0,255), // Пурпурный + new Scalar(128,128,128), // Серый + new Scalar(255,0,0), // Красный + new Scalar(255,128,0), // Оранжевый + new Scalar(255,255,0), // Жёлтый + new Scalar(0,255,0) // Зелёный + }; + + private readonly ILogger _logger; + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly CameraService _cameraService; + private readonly IlluminatorService _illuminatorService; + private readonly FileInfo? _output; + private double _gainR; + private double _gainG; + private double _gainB; + private readonly double _threshold; + private readonly uint _steps; + private readonly double _intensityWhite; + + public CameraTuningWorker( + ILogger logger, + IHostApplicationLifetime applicationLifetime, + CameraService cameraService, + IlluminatorService illuminatorService, + FileInfo? output, + double gainR, + double gainG, + double gainB, + double threshold, + uint steps, + double intensityWhite + ) + { + _applicationLifetime = applicationLifetime; + _logger = logger; + _cameraService = cameraService; + _illuminatorService = illuminatorService; + _output = output; + _gainR = gainR; + _gainG = gainG; + _gainB = gainB; + _threshold = threshold; + _steps = steps; + _intensityWhite = intensityWhite; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Логгируем информацию + _logger.LogInformation("Регулировка параметров камеры..."); + _logger.LogInformation("Файл выходного изображения: {}", _output); + _logger.LogInformation("Начальное усиление цвета (красный канал): {}", _gainR); + _logger.LogInformation("Начальное усиление цвета (зелёный канал): {}", _gainG); + _logger.LogInformation("Начальное усиление цвета (синий канал): {}", _gainB); + _logger.LogInformation("Порог прекращения регулировки: {}", _threshold); + _logger.LogInformation("Максимальное количество шагов регулировки: {}", _steps); + _logger.LogInformation("Интенсивность белого канала осветителя: {}", _intensityWhite); + + // Инициализируем камеру + await _cameraService.InitializeAsync(stoppingToken); + stoppingToken.ThrowIfCancellationRequested(); + + Mat? image; + bool optimalGainsFound = false; + + // Включаем осветитель + _illuminatorService.SetIntensity(_intensityWhite, 0, 0); + _illuminatorService.TurnOn(); + + // Пока количество шагов не дойдёт до максимума или не будут найдены оптимальные параметры + for (int step = 0; step < _steps || !optimalGainsFound; step++) + { + _logger.LogInformation("Шаг {}/{}. Параметры баланса белого:\n\tКрасный: {}\n\tЗелёный: {}\n\tСиний: {}", step + 1, _steps, _gainR, _gainG, _gainB); + + // Устанавливаем усиления цветовых каналов в конфигурацию камеры + _cameraService.Configuration.DecodeGainR = _gainR; + _cameraService.Configuration.DecodeGainG = _gainG; + _cameraService.Configuration.DecodeGainB = _gainB; + + // Захватываем изображение + try + { + image = await _cameraService.CaptureImage(stoppingToken, 2); + + if (image is null) + throw new Exception("Не удаётся захватить изображение с камеры."); + + // Проверяем тип изображения + if (image.Type() != MatType.CV_8UC3 && + image.Type() != MatType.CV_8UC4 && + image.Type() != MatType.CV_16UC3 && + image.Type() != MatType.CV_16UC4) + _logger.LogCritical("Захваченное изображение имеет неподдерживаемый формат: {}", image.Type().ToString()); + + // Приводим изображение к типу CV_8 + if (image.Type() == MatType.CV_16UC3) + image.ConvertTo(image, MatType.CV_8UC3, 1 / 256.0); + if (image.Type() == MatType.CV_16UC4) + image.ConvertTo(image, MatType.CV_8UC4, 1 / 256.0); + + // Если есть альфа-канал, то удаляем его + if (image.Channels() == 4) + Cv2.CvtColor(image, image, ColorConversionCodes.BGRA2BGR); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Ошибка при захвате изображения"); + throw; + } + + // Если нужно, сохраняем изображение в файл + try + { + if (_output is not null) + { + image.ImWrite(path: _output.FullName); + _logger.LogInformation("Изображение сохранено: {}", _output.FullName); + } + } + catch (Exception ex) + { + // Сохранение в файл - в данном случае не критическая операция, + // поэтому логгируем сбой как некритическую ошибку и не прерываем программу + _logger.LogError(ex, "Ошибка при сохранении изображения"); + } + + try + { + var gains = ComputeColorGains(image); + + if (gains is null) + throw new Exception("Не удаётся вычислить параметры баланса белого"); + + var (r, g, b) = gains.Value; + + // Если усиления по всем каналам изменились меньше чем на пороговое значение, + // то мы считаем, что оптимальные параметры баланса белого найдены, и прерываем цикл + if (Math.Abs(_gainR - r) < _threshold && + Math.Abs(_gainG - g) < _threshold && + Math.Abs(_gainB - b) < _threshold) + { + optimalGainsFound = true; + break; + } + + // Если оптимальные параметры баланса белого не найдены, + // то определяем новое усиление, как среднее значение + // между усилением с прошлого шага + // и усилением, найденным на этом шаге + _gainR = (_gainR + r) / 2; + _gainG = (_gainG + g) / 2; + _gainB = (_gainB + b) / 2; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Exception occurred while gains computing"); + break; + } + step++; + } + + _illuminatorService.TurnOff(); + + if (!optimalGainsFound) + _logger.LogWarning("Оптимальные параметры баланса белого не найдены!"); + else + _logger.LogInformation("Оптимальные параметры баланса белого:\n\tКрасный: {}\n\tЗелёный: {}\n\tСиний: {}", _gainR, _gainG, _gainB); + + _applicationLifetime.StopApplication(); + } + + // TODO: Добавить комментарии к методам вычисления параметров баланса белого + /// + /// Вычисляет параметры баланса белого + /// + /// Изображение opencv в формате CV_8UC3 + /// + /// если по какой-то причине вычислить параметры баланса белого не удалось, + /// иначе вычисленные параметры баланса белого + /// + private static (double r, double g, double b)? ComputeColorGains(Mat image) + { + var circle = FindCircle(image); + if (circle is null) + return null; + + var (rx, ry, rr) = circle.Value; + + double scale = rr / PatternOuterRadius; + double innerPx = PatternCenterRadius * scale; + int midR = (int)((innerPx + rr) / 2.0); + + double phi0 = DetectOrientation(image, rx, ry, rr); + if (double.IsNaN(phi0)) + phi0 = Math.PI / 2.0; + + var samples = new List(); + var refs = new List(); + + int N = KnownSegments.Length; + + for (int i = 0; i < N; i++) + { + if (i == 3) + continue; + + double theta = phi0 - (2 * Math.PI / N) * i; + + int cx = (int)(rx + midR * Math.Cos(theta)); + int cy = (int)(ry - midR * Math.Sin(theta)); + + var roi = new Rect( + Math.Max(cx - 8, 0), + Math.Max(cy - 8, 0), + Math.Min(16, image.Width - Math.Max(cx - 8, 0)), + Math.Min(16, image.Height - Math.Max(cy - 8, 0)) + ); + + if (roi.Width < 8 || roi.Height < 8) + continue; + + using var patch = new Mat(image, roi); + var mean = Cv2.Mean(patch); + + // Поскольку изображение имеет формат BGR, то изменяем порядок каналов под RGB + samples.Add(new Vec3d(mean.Val2, mean.Val1, mean.Val0)); + + var refColor = KnownSegments[i]; + refs.Add(new Vec3d(refColor.Val2, refColor.Val1, refColor.Val0)); + } + + if (samples.Count < 3) + return null; + + // Формируем матрицы для least squares + var S = new Mat(samples.Count, 3, MatType.CV_64F); + var R = new Mat(samples.Count, 3, MatType.CV_64F); + + for (int i = 0; i < samples.Count; i++) + { + S.Set(i, 0, samples[i][0]); + S.Set(i, 1, samples[i][1]); + S.Set(i, 2, samples[i][2]); + + R.Set(i, 0, refs[i][0]); + R.Set(i, 1, refs[i][1]); + R.Set(i, 2, refs[i][2]); + } + + var M = new Mat(); + Cv2.Solve(S, R, M, DecompTypes.Normal); + + double r = Math.Max(M.At(0, 0), 1.0); + double g = 1.0; + double b = Math.Max(M.At(2, 2), 1.0); + + return (r, g, b); + } + private static (int x, int y, int r)? FindCircle(Mat image) + { + using var gray = new Mat(); + Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY); + Cv2.GaussianBlur(gray, gray, new Size(9, 9), 2); + + var circles = Cv2.HoughCircles( + gray, + HoughModes.Gradient, + dp: 1.2, + minDist: gray.Rows / 3, + param1: 100, + param2: 30, + minRadius: (int)(gray.Rows * 0.3), + maxRadius: (int)(gray.Rows * 0.6)); + + if (circles.Length == 0) + return null; + + var c = circles[0]; + return ((int)c.Center.X, (int)c.Center.Y, (int)c.Radius); + } + private static double DetectOrientation(Mat image, int rx, int ry, int rr) + { + double scale = rr / PatternOuterRadius; + int innerR = (int)(PatternCenterRadius * scale); + + using var gray = new Mat(); + Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY); + + using var mask = Mat.Zeros(image.Size(), MatType.CV_8U).ToMat(); + Cv2.Circle(mask, new Point(rx, ry), innerR, Scalar.White, -1); + + var whitePoints = new List(); + + for (int y = 0; y < gray.Rows; y++) + for (int x = 0; x < gray.Cols; x++) + if (mask.At(y, x) == 255 && gray.At(y, x) > 128) + whitePoints.Add(new Point(x, y)); + + if (whitePoints.Count < 20) + return double.NaN; + + double cx = whitePoints.Average(p => p.X); + double cy = whitePoints.Average(p => p.Y); + + return Math.Atan2(ry - cy, cx - rx); + } + } + + public static readonly RootCommand RootCommand; + private static readonly Command Command; + private static readonly Option Output; + private static readonly Option GainR; + private static readonly Option GainG; + private static readonly Option GainB; + private static readonly Option Threshold; + private static readonly Option Steps; + private static readonly Option IntensityWhite; + + static CameraTuning() + { + // Создаём команды, опции, аргументы и пр. + RootCommand = new("Запустить регулировку параметров камеры"); + Command = new("camera-tuning") + { + Description = "Запустить регулировку параметров камеры" + }; + Output = new("--output", ["-o"]) + { + Description = "Файл выходного изображения", + Required = false + }; + GainR = new("--gain-r", "-r") + { + Description = "Начальное усиление цвета (красный канал)", + DefaultValueFactory = (_) => 1 + }; + GainG = new("--gain-g", "-g") + { + Description = "Начальное усиление цвета (зелёный канал)", + DefaultValueFactory = (_) => 1 + }; + GainB = new("--gain-b", "-b") + { + Description = "Начальное усиление цвета (синий канал)", + DefaultValueFactory = (_) => 1 + }; + Threshold = new("--threshold", "-t") + { + Description = "Порог прекращения регулировки", + DefaultValueFactory = (_) => 0.1 + }; + Steps = new("--steps", "-s") + { + Description = "Максимальное количество шагов регулировки", + DefaultValueFactory = (_) => 10 + }; + IntensityWhite = new("--intensity-white", ["-iw"]) + { + Description = "Интенсивность белого канала осветителя (от 0 до 1)", + DefaultValueFactory = (_) => 1 + }; + + // Изменяем описания стандартных опций + Helper.TranslateDefaultOptionDescriptions(RootCommand); + + // Наполняем команды + RootCommand.Add(Output); + RootCommand.Add(GainR); + RootCommand.Add(GainG); + RootCommand.Add(GainB); + RootCommand.Add(Threshold); + RootCommand.Add(Steps); + RootCommand.Add(IntensityWhite); + RootCommand.SetAction(CommandAction); + + Command.Add(Output); + Command.Add(GainR); + Command.Add(GainG); + Command.Add(GainB); + Command.Add(Threshold); + Command.Add(Steps); + Command.Add(IntensityWhite); + Command.SetAction(CommandAction); + } + + /// + /// Получить команду как корневую + /// + public static RootCommand GetCommand() => RootCommand; + + /// + /// Зарегистрировать команду как подкоманду + /// + /// Команда в которой необходимо зарегистрировать подкоманду + public static void RegisterCommand(Command command) => command.Add(Command); + + private static void CommandAction(ParseResult result) + { + /// Получаем значения аргументов командной строки + var output = result.GetValue(Output); + var gainR = result.GetRequiredValue(GainR); + var gainG = result.GetRequiredValue(GainG); + var gainB = result.GetRequiredValue(GainB); + var threshold = result.GetRequiredValue(Threshold); + var steps = result.GetRequiredValue(Steps); + var intensityWhite = result.GetRequiredValue(IntensityWhite); + + // Создаём хост + var builder = Host.CreateApplicationBuilder(); + + // Добавляем конфигурацию из appsettings.json + builder.Configuration.AddAppSettingsJson(); + + // Конфигурируем логгер + builder.Logging.ConfigureLogging(); + builder.Services.AddSingleton(); + + // Добавляем сервисы аппаратной части + builder.Services.AddIlluminatorService("Hardware:Illuminator"); + builder.Services.AddCameraService("Hardware:Camera"); + + // Добавляем рабочий сервис + builder.Services.AddHostedService(services => + new CameraTuningWorker( + services.GetRequiredService>(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + output, + gainR, gainG, gainB, + threshold, steps, + intensityWhite + ) + ); + + // Собираем хост + var host = builder.Build(); + + // Поскольку LibCameraLogSink, который перенаправляет логи из libcamera в хост, + // не будет создан ни одним из других сервисов, его необходимо создать превентивно + host.Services.GetRequiredService(); + + // Запускаем хост + host.Run(); + } +} diff --git a/GSS2/Commands/Capture.cs b/GSS2/Commands/Capture.cs new file mode 100644 index 0000000..1110896 --- /dev/null +++ b/GSS2/Commands/Capture.cs @@ -0,0 +1,212 @@ +using System.CommandLine; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using OpenCvSharp; + +using GSS2.Core; +using GSS2.Core.Logging; +using GSS2.Core.Hardware; +using GSS2.Core.Extensions; + +namespace GSS2.Commands; + +public static class Capture +{ + private class CaptureWorker : BackgroundService + { + private readonly ILogger _logger; + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly CameraService _cameraService; + private readonly IlluminatorService _illuminatorService; + + private readonly FileInfo _output; + private readonly double _intensityWhite; + private readonly double _intensityUv365; + private readonly double _intensityUv254; + + public CaptureWorker( + ILogger logger, + IHostApplicationLifetime applicationLifetime, + CameraService cameraService, + IlluminatorService illuminatorService, + FileInfo output, + double intensityWhite, + double intensityUv365, + double intensityUv254) + { + _applicationLifetime = applicationLifetime; + _logger = logger; + _cameraService = cameraService; + _illuminatorService = illuminatorService; + _output = output; + _intensityWhite = intensityWhite; + _intensityUv365 = intensityUv365; + _intensityUv254 = intensityUv254; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Логгируем информацию + _logger.LogInformation("Захват изображения с камеры..."); + _logger.LogInformation("Файл выходного изображения: {}", _output); + _logger.LogInformation("Интенсивность белого канала осветителя: {}", _intensityWhite); + _logger.LogInformation("Интенсивность УФ канала осветителя 365 нм: {}", _intensityUv365); + _logger.LogInformation("Интенсивность УФ канала осветителя 254 нм: {}", _intensityUv254); + + // Инициализируем камеру + await _cameraService.InitializeAsync(stoppingToken); + stoppingToken.ThrowIfCancellationRequested(); + + // Включаем осветитель + _illuminatorService.SetIntensity(_intensityWhite, _intensityUv365, _intensityUv254); + _illuminatorService.TurnOn(); + + // Захватываем изображение + Mat? image; + try + { + image = await _cameraService.CaptureImage(stoppingToken, 5); + + if (image is null) + throw new Exception("Не удаётся захватить изображение с камеры."); + + _illuminatorService.TurnOff(); + } + catch (Exception ex) + { + _illuminatorService.TurnOff(); + _logger.LogCritical(ex, "Ошибка при захвате изображения"); + _applicationLifetime.StopApplication(); + return; + } + + // Сохраняем изображение в файл + try + { + image.ImWrite(path: _output.FullName); + _logger.LogInformation("Изображение сохранено: {}", _output.FullName); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Ошибка при сохранении изображения"); + _applicationLifetime.StopApplication(); + return; + } + + _applicationLifetime.StopApplication(); + } + } + + public static readonly RootCommand RootCommand; + private static readonly Command Command; + private static readonly Option Output; + private static readonly Option IntensityWhite; + private static readonly Option IntensityUv365; + private static readonly Option IntensityUv254; + + static Capture() + { + // Создаём команды, опции, аргументы и пр. + RootCommand = new("Захватить изображение с камеры"); + Command = new("capture") + { + Description = "Захватить изображение с камеры" + }; + Output = new("--output", ["-o"]) + { + Description = "Файл выходного изображения", + DefaultValueFactory = (_) => new FileInfo("image.png") + }; + IntensityWhite = new("--intensity-white", ["-iw"]) + { + Description = "Интенсивность белого канала осветителя (от 0 до 1)", + DefaultValueFactory = (_) => 1 + }; + IntensityUv365 = new("--intensity-uv365", ["-i365"]) + { + Description = "Интенсивность УФ канала осветителя 365 нм (от 0 до 1)", + DefaultValueFactory = (_) => 0 + }; + IntensityUv254 = new("--intensity-uv254", ["-i254"]) + { + Description = "Интенсивность УФ канала осветителя 254 нм (from 0 to 1)", + DefaultValueFactory = (_) => 0 + }; + + // Изменяем описания стандартных опций + Helper.TranslateDefaultOptionDescriptions(RootCommand); + + // Наполняем команды + RootCommand.Add(Output); + RootCommand.Add(IntensityWhite); + RootCommand.Add(IntensityUv365); + RootCommand.Add(IntensityUv254); + RootCommand.SetAction(CommandAction); + + Command.Add(Output); + Command.Add(IntensityWhite); + Command.Add(IntensityUv365); + Command.Add(IntensityUv254); + Command.SetAction(CommandAction); + } + + /// + /// Получить команду как корневую + /// + public static RootCommand GetCommand() => RootCommand; + + /// + /// Зарегистрировать команду как подкоманду + /// + /// Команда в которой необходимо зарегистрировать подкоманду + public static void RegisterCommand(Command command) => command.Add(Command); + + private static void CommandAction(ParseResult result) + { + /// Получаем значения аргументов командной строки + var outputFile = result.GetRequiredValue(Output); + var intensityWhite = result.GetRequiredValue(IntensityWhite); + var intensityUv365 = result.GetRequiredValue(IntensityUv365); + var intensityUv254 = result.GetRequiredValue(IntensityUv254); + + // Создаём хост + var builder = Host.CreateApplicationBuilder(); + + // Добавляем конфигурацию из appsettings.json + builder.Configuration.AddAppSettingsJson(); + + // Конфигурируем логгер + builder.Logging.ConfigureLogging(); + builder.Services.AddSingleton(); + + // Добавляем сервисы аппаратной части + builder.Services.AddIlluminatorService("Hardware:Illuminator"); + builder.Services.AddCameraService("Hardware:Camera"); + + // Добавляем рабочий сервис + builder.Services.AddHostedService(services => + new CaptureWorker( + services.GetRequiredService>(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + outputFile, + intensityWhite, intensityUv365, intensityUv254 + ) + ); + + // Собираем хост + var host = builder.Build(); + + // Поскольку LibCameraLogSink, который перенаправляет логи из libcamera в хост, + // не будет создан ни одним из других сервисов, его необходимо создать превентивно + host.Services.GetRequiredService(); + + // Запускаем хост + host.Run(); + } +} diff --git a/GSS2/Commands/Helper.cs b/GSS2/Commands/Helper.cs new file mode 100644 index 0000000..3501f14 --- /dev/null +++ b/GSS2/Commands/Helper.cs @@ -0,0 +1,16 @@ +using System.CommandLine; + +namespace GSS2.Commands; + +static class Helper +{ + public static void TranslateDefaultOptionDescriptions(RootCommand rootCommand) + { + rootCommand.Options + .FirstOrDefault(o => o.Name == "--help")? + .Description = "Показать справку и информацию об использовании"; + rootCommand.Options + .FirstOrDefault(o => o.Name == "--version")? + .Description = "Показать информацию о версии"; + } +} diff --git a/GSS2/Commands/ImageStorageClear.cs b/GSS2/Commands/ImageStorageClear.cs new file mode 100644 index 0000000..2de07d5 --- /dev/null +++ b/GSS2/Commands/ImageStorageClear.cs @@ -0,0 +1,43 @@ +using System.CommandLine; + +namespace GSS2.Commands; + +public static class ImageStorageClear +{ + 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) + { + + } +} diff --git a/GSS2/Commands/UI.cs b/GSS2/Commands/UI.cs new file mode 100644 index 0000000..ef5b0d3 --- /dev/null +++ b/GSS2/Commands/UI.cs @@ -0,0 +1,74 @@ +using System.CommandLine; + +namespace GSS2.Commands; + +public static class UI +{ + public enum RenderingModes + { + X11, + DRM + } + + public static readonly RootCommand RootCommand; + public static readonly Command Command; + public static readonly Option Fullscreen; + public static readonly Option RenderingMode; + public static readonly Option HideConsole; + + static UI() + { + // Создаём команды, опции, аргументы и пр. + RootCommand = new("Запустить графическое приложение"); + Command = new("ui") + { + Description = "Запустить графическое приложение" + }; + Fullscreen = new("--fullscreen", ["-f"]) + { + Description = "Запустить приложение в полноэкранном режиме", + }; + RenderingMode = new("--mode", ["--rendering-mode", "-m"]) + { + Description = "Режим рендеринга", + + DefaultValueFactory = (_) => RenderingModes.X11 + }; + HideConsole = new("--hide-console", ["--no-console", "-c"]) + { + Description = "Отключить вывод в консоль", + DefaultValueFactory = (result) => result.GetValue(RenderingMode) == RenderingModes.DRM + }; + + // Изменяем описания стандартных опций + Helper.TranslateDefaultOptionDescriptions(RootCommand); + + // Наполняем команды + RootCommand.Add(Fullscreen); + RootCommand.Add(RenderingMode); + RootCommand.Add(HideConsole); + RootCommand.SetAction(CommandAction); + + Command.Add(Fullscreen); + Command.Add(RenderingMode); + Command.Add(HideConsole); + Command.SetAction(CommandAction); + + } + + /// + /// Получить команду как корневую + /// + public static RootCommand GetCommand() => RootCommand; + + /// + /// Зарегистрировать команду как подкоманду + /// + /// Команда в которой необходимо зарегистрировать подкоманду + public static void RegisterCommand(Command command) => command.Add(Command); + + private static void CommandAction(ParseResult result) + { + + } +} diff --git a/GSS2/GSS2.csproj b/GSS2/GSS2.csproj new file mode 100644 index 0000000..19190ff --- /dev/null +++ b/GSS2/GSS2.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + + + $(NoWarn);IDE0090;IDE1006;IDE0028;IDE0305;IDE0290;CA1826;CA1829;CA1873 + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + PreserveNewest + + + + diff --git a/GSS2/Program.cs b/GSS2/Program.cs new file mode 100644 index 0000000..41300a0 --- /dev/null +++ b/GSS2/Program.cs @@ -0,0 +1,100 @@ +namespace GSS2; + +public partial class Program +{ + // Список аргументов командной строки + private static List _args = new List(); + + /// + /// Точка входа + /// + [STAThread] + public static int Main(string[] args) + { + _args = args.ToList(); + +#if DEBUG + WaitDebuggerIfNeeded(); +#endif + + // Создаём обработчик аргументов командной строки + var rootCommand = Commands.UI.GetCommand(); + Commands.CameraTuning.RegisterCommand(rootCommand); + Commands.Capture.RegisterCommand(rootCommand); + Commands.ImageStorageClear.RegisterCommand(rootCommand); + + // Обрабатываем аргументы + var parseResult = rootCommand?.Parse(_args); + + // Обрабатываем ошибки + if (parseResult is null) + { + Console.Error.WriteLine("Неизвестная ошибка при обработке аргументов командной строки."); + Environment.Exit(1); + } + if (parseResult.Errors.Count() != 0) + { + Console.Error.WriteLine( + (parseResult.Errors.Count() == 1 ? "Ошибка" : "Ошибки") + + " при обработке аргументов командной строки:" + ); + foreach (var parseError in parseResult.Errors) + Console.Error.WriteLine(parseError.Message); + Environment.Exit(1); + } + + // Исполняем команду + Console.WriteLine("Нажмите Ctrl+C для выхода."); + try + { + return parseResult.Invoke(); + } + catch (Exception ex) + { + Console.Error.WriteLine("Ошибка при выполнении программы:"); + Console.Write(ex.Message); + Console.Write(ex.StackTrace); + return 1; + } + } + + /// + /// Если в присутствует аргумент --debug, + /// то удаляет --debug из , выводит информацию о процессе в консоль и ожидает подключения отладчика + /// + private static void WaitDebuggerIfNeeded() + { + if (System.Diagnostics.Debugger.IsAttached) + return; + + if (!_args.Contains("--debug")) + return; + + _args.Remove("--debug"); + + bool keepWaiting = true; + + void CancelWait(object? _, ConsoleCancelEventArgs ea) + { + ea.Cancel = true; + keepWaiting = false; + Console.WriteLine("\nОперация отменена пользователем."); + Environment.Exit(0); + } + Console.CancelKeyPress += CancelWait; + + Console.WriteLine("Ожидание подключения отладчика."); + Console.WriteLine($"Имя процесса: {Environment.ProcessPath}"); + Console.WriteLine($"Id процесса: {Environment.ProcessId}"); + Console.WriteLine($"Id потока: {Environment.CurrentManagedThreadId}"); + Console.WriteLine("Нажмите Ctrl+C для выхода."); + + while (!System.Diagnostics.Debugger.IsAttached && keepWaiting) + Thread.Sleep(100); + + if (System.Diagnostics.Debugger.IsAttached) + System.Diagnostics.Debugger.Break(); + + Console.CancelKeyPress -= CancelWait; + } +} diff --git a/GSS2/appsettings.json b/GSS2/appsettings.json new file mode 100644 index 0000000..5187e69 --- /dev/null +++ b/GSS2/appsettings.json @@ -0,0 +1,82 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Migrations": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "GSS2.Core.Hardware.LightsService": "Information", + "GSS2.Core.Hardware.IlluminatorService": "Information", + "GSS2.Core.Hardware.CameraService": "Information", + "LibCamera": "Information", + "LibCamera-Camera": "Information", + "LibCamera-RPI": "Information", + "LibCamera-DeviceEnumerator": "Information", + "LibCamera-IPAProxy": "Information", + "LibCamera-V4L2": "Information", + "LibCamera-RPISTREAM": "Information", + "LibCamera-WRAPPER": "Information", + "Avalonia.*": "Warning" + } + }, + "ConnectionStrings": { + "AmineContentCalibrationDatabasePath": "AmineContentCalibration.db", + "AmineContentResultsDatabasePath": "AmineContentResults.db" + }, + "Hardware": { + "Illuminator": { + "WhitePwmPin": 13, + "Uv254PwmPin": 18, + "Uv365PwmPin": 12, + "WhiteRelayPin": 16, + "Uv254RelayPin": 15, + "Uv365RelayPin": 20, + "FanPin": 21, + "PwmFrequency": 200, + "MaxWhitePwmDuty": 1.0, + "MaxUv254PwmDuty": 1.0, + "MaxUv365PwmDuty": 1.0 + }, + "Camera": { + "CameraId": "/base/axi/pcie@1000120000/rp1/i2c@70000/imx477@1a", + "ViewFinderFrameBufferCount": 5, + "ViewFinderWidth": 4056, + "ViewFinderHeight": 3040, + "ViewFinderFps": 30, + "ImageCaptureFrameBufferCount": 5, + "ImageCaptureWidth": 4056, + "ImageCaptureHeight": 3040, + "CameraHardSettings": { + "AeEnable": false, + "ExposureValue": 8.0, + "ExposureTime": 30000, + "ExposureTimeMode": "ExposureTimeModeManual", + "AnalogueGain": 23.0, + "AnalogueGainMode": "AnalogueGainModeManual", + "Brightness": 0.0, + "Contrast": 1.0, + "AwbEnable": false, + "ColourGains": [ + 3.2, + 1.0 + ], + "HdrMode": "HdrModeOff", + "NoiseReductionMode": "NoiseReductionModeOff", + "StatsOutputEnable": true, + "SyncMode": "SyncModeOff", + "CnnEnableInputTensor": false + }, + "DecodeGainR": 3.2, + "DecodeGainG": 1.0, + "DecodeGainB": 1.0, + "DecodeBlackLevel": 4096 + }, + "TemperatureHumidity": { + "Bus": 6, + "Address": 69 + }, + "Lights": { + "ServerAddress": "http://127.0.0.1:50051" + } + } +} \ No newline at end of file