feat: camera tuning + capturing command + raw image correction

This commit is contained in:
2026-02-17 14:42:50 +03:00
parent 9c4f12d45e
commit 5b8db82894
7 changed files with 579 additions and 70 deletions
+73 -2
View File
@@ -14,11 +14,68 @@ public partial class Program
}
private static string[]? _args = null;
private static readonly RootCommand _rootCommand = new("GranuSight Software 2");
private static readonly RootCommand _rootCommand = new("Run UI application");
private static readonly Command _cameraTuningCommand = new("camera-tuning")
{
Description = "Tune camera parameters"
};
private static readonly Option<FileInfo> _cameraTuningOutputFile = new("--output")
{
Description = "Output image file",
Required = false
};
private static readonly Option<double> _cameraTuningGainR = new("--gain-r")
{
Description = "Color gain (red channel) to start from",
DefaultValueFactory = (_) => 1
};
private static readonly Option<double> _cameraTuningGainG = new("--gain-g")
{
Description = "Color gain (green channel) to start from",
DefaultValueFactory = (_) => 1
};
private static readonly Option<double> _cameraTuningGainB = new("--gain-b")
{
Description = "Color gain (blue channel) to start from",
DefaultValueFactory = (_) => 1
};
private static readonly Option<double> _cameraTuningThreshold = new("--threshold")
{
Description = "Threshold to stop on",
DefaultValueFactory = (_) => 0.1
};
private static readonly Option<uint> _cameraTuningSteps = new("--steps")
{
Description = "Maximum steps count",
DefaultValueFactory = (_) => 10
};
private static readonly Command _captureCommand = new("capture")
{
Description = "Capture image from camera"
};
private static readonly Option<FileInfo> _captureOutputFile = new("--output")
{
Description = "Output image file",
DefaultValueFactory = (_) => new FileInfo("image.png")
};
private static readonly Option<double> _captureIntencityWhite = new("--intencity-white")
{
Description = "Illuminator intencity for white (from 0 to 1)",
DefaultValueFactory = (_) => 1
};
private static readonly Option<double> _captureIntencityUv365nm = new("--intencity-uv365nm")
{
Description = "Illuminator intencity for UV 365 nm (from 0 to 1)",
DefaultValueFactory = (_) => 0
};
private static readonly Option<double> _captureIntencityUv254nm = new("--intencity-uv254nm")
{
Description = "Illuminator intencity for UV 254 nm (from 0 to 1)",
DefaultValueFactory = (_) => 0
};
private static readonly Option<RenderingMode> _renderingModeArgument = new("--rendering-mode")
{
Description = "Rendering mode to use",
@@ -38,11 +95,25 @@ public partial class Program
_args = args;
_rootCommand.Add(_cameraTuningCommand);
_cameraTuningCommand.Add(_cameraTuningOutputFile);
_cameraTuningCommand.Add(_cameraTuningGainR);
_cameraTuningCommand.Add(_cameraTuningGainG);
_cameraTuningCommand.Add(_cameraTuningGainB);
_cameraTuningCommand.Add(_cameraTuningThreshold);
_cameraTuningCommand.Add(_cameraTuningSteps);
_captureCommand.Add(_captureOutputFile);
_captureCommand.Add(_captureIntencityWhite);
_captureCommand.Add(_captureIntencityUv365nm);
_captureCommand.Add(_captureIntencityUv254nm);
_rootCommand.Add(_captureCommand);
_rootCommand.Add(_renderingModeArgument);
_rootCommand.Add(_hideConsoleArgument);
_rootCommand.SetAction(Ui);
_cameraTuningCommand.SetAction(CameraTuning);
_captureCommand.SetAction(Capture);
Console.CancelKeyPress += new ConsoleCancelEventHandler(OnProgramShutdown);
Console.WriteLine("Press Ctrl+C to shut down.");
+276 -3
View File
@@ -6,6 +6,8 @@ using GSS2.Core.Logging;
using Microsoft.Extensions.Logging.Console;
using OpenCvSharp;
using ConsoleFormatter = GSS2.Core.Logging.ConsoleFormatter;
namespace GSS2.Test;
@@ -15,26 +17,290 @@ public partial class Program
private class CameraTuningWorker : BackgroundService
{
private readonly ILogger<CameraTuningWorker> _logger;
private readonly IHostApplicationBuilder _applicationLifetime;
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly CameraService _cameraService;
private readonly IlluminatorService _illuminatorService;
private readonly FileInfo? _outputFile;
private double _gainR;
private double _gainG;
private double _gainB;
private readonly double _threshold;
private readonly uint _steps;
private static readonly Scalar[] KnownSegments =
{
new Scalar(0,255,255), // Cyan
new Scalar(0,0,255), // Blue
new Scalar(255,0,255), // Magenta
new Scalar(128,128,128), // Gray (skip)
new Scalar(255,0,0), // Red
new Scalar(255,128,0), // Orange
new Scalar(255,255,0), // Yellow
new Scalar(0,255,0) // Green
};
private const double PatternOuterRadius = 180.0;
private const double PatternCenterRadius = 60.0;
public CameraTuningWorker(ILogger<CameraTuningWorker> logger, IHostApplicationBuilder applicationLifetime, CameraService cameraService, IlluminatorService illuminatorService)
public CameraTuningWorker(ILogger<CameraTuningWorker> logger, IHostApplicationLifetime applicationLifetime, CameraService cameraService, IlluminatorService illuminatorService, FileInfo? outputFile, double gainR, double gainG, double gainB, double threshold, uint steps)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
_cameraService = cameraService;
_illuminatorService = illuminatorService;
_outputFile = outputFile;
_gainR = gainR;
_gainG = gainG;
_gainB = gainB;
_threshold = threshold;
_steps = steps;
_logger.LogInformation("Initialized");
_logger.LogInformation("Output File: {}", _outputFile);
_logger.LogInformation("Gain R: {}", _gainR);
_logger.LogInformation("Gain G: {}", _gainG);
_logger.LogInformation("Gain B: {}", _gainB);
_logger.LogInformation("Threshold: {}", _threshold);
_logger.LogInformation("Steps: {}", _steps);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Running camera fine tuning");
Mat? image;
int i = 0;
bool optimalGainsFound = false;
_illuminatorService.SetIntensity(1, 0, 0);
_illuminatorService.TurnOn();
while (i < _steps)
{
_cameraService.Configuration.DecodeGainR = _gainR;
_cameraService.Configuration.DecodeGainG = _gainG;
_cameraService.Configuration.DecodeGainB = _gainB;
try
{
image = await _cameraService.CaptureImage(stoppingToken, 2);
if (image is null)
{
_logger.LogError("Cannot get image");
break;
}
if (image.Type() != MatType.CV_8UC3 &&
image.Type() != MatType.CV_8UC4 &&
image.Type() != MatType.CV_16UC3 &&
image.Type() != MatType.CV_16UC4)
{
_logger.LogCritical("Unsupported image type: {}", image.Type().ToString());
break;
}
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);
var channels = image.Split();
Cv2.Merge([channels[0], channels[1], channels[2]], image);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while image capturing");
break;
}
try
{
if (_outputFile is not null)
image.ImWrite(_outputFile.FullName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while image saving");
}
try
{
var gains = ComputeWhiteBalanceGains(image);
if (gains is null)
{
_logger.LogError("Cannot compute gains");
break;
}
var (r, g, b) = gains.Value;
if (Math.Abs(_gainR - r) < _threshold &&
Math.Abs(_gainG - g) < _threshold &&
Math.Abs(_gainB - b) < _threshold)
{
_logger.LogInformation("Optimal color gains: r={}, g={}, b:={}", r, g, b);
optimalGainsFound = true;
break;
}
_gainR = (_gainR + r) / 2;
_gainG = (_gainG + g) / 2;
_gainB = (_gainB + b) / 2;
_logger.LogInformation("Step {}/{} color gains: r={}, g={}, b:={}", i + 1, _steps, _gainR, _gainG, _gainB);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while gains computing");
break;
}
i++;
}
_illuminatorService.TurnOff();
if (!optimalGainsFound)
_logger.LogWarning("Optimal gains not found");
_applicationLifetime.StopApplication();
}
private (double rGain, double gGain, double bGain)? ComputeWhiteBalanceGains(Mat image)
{
var circle = FindCircle(image);
if (circle == 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<Vec3d>();
var refs = new List<Vec3d>();
int N = KnownSegments.Length;
for (int i = 0; i < N; i++)
{
if (i == 3) continue; // skip gray
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 rGain = Math.Max(M.At<double>(0, 0), 1.0);
double gGain = 1.0;
double bGain = Math.Max(M.At<double>(2, 2), 1.0);
return (rGain, gGain, bGain);
}
private (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 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<Point>();
for (int y = 0; y < gray.Rows; y++)
{
for (int x = 0; x < gray.Cols; x++)
{
if (mask.At<byte>(y, x) == 255 &&
gray.At<byte>(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);
}
}
private static void CameraTuning(ParseResult parseResult)
{
var outputFile = parseResult.GetValue(_cameraTuningOutputFile);
var gainR = parseResult.GetRequiredValue(_cameraTuningGainR);
var gainG = parseResult.GetRequiredValue(_cameraTuningGainG);
var gainB = parseResult.GetRequiredValue(_cameraTuningGainB);
var threshold = parseResult.GetRequiredValue(_cameraTuningThreshold);
var steps = parseResult.GetRequiredValue(_cameraTuningSteps);
var builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
@@ -47,7 +313,14 @@ public partial class Program
builder.Services.AddIlluminatorService("Hardware:Illuminator");
builder.Services.AddCameraService("Hardware:Camera", true);
builder.Services.AddHostedService<CameraTuningWorker>();
builder.Services.AddHostedService<CameraTuningWorker>(services =>
{
var logger = services.GetRequiredService<ILogger<CameraTuningWorker>>();
var applicationLifetime = services.GetRequiredService<IHostApplicationLifetime>();
var cameraService = services.GetRequiredService<CameraService>();
var illuminatorService = services.GetRequiredService<IlluminatorService>();
return new CameraTuningWorker(logger, applicationLifetime, cameraService, illuminatorService, outputFile, gainB, gainG, gainR, threshold, steps);
});
var host = builder.Build();
host.Services.GetRequiredService<LibCameraLogSink>();
+122
View File
@@ -0,0 +1,122 @@
using System.CommandLine;
using GSS2.Core;
using GSS2.Core.Hardware;
using GSS2.Core.Logging;
using Microsoft.Extensions.Logging.Console;
using OpenCvSharp;
using ConsoleFormatter = GSS2.Core.Logging.ConsoleFormatter;
namespace GSS2.Test;
public partial class Program
{
private class CaptureWorker : BackgroundService
{
private readonly ILogger<CaptureWorker> _logger;
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly CameraService _cameraService;
private readonly IlluminatorService _illuminatorService;
private readonly FileInfo _outputFile;
private readonly double _intencityWhite;
private readonly double _intencityUv365nm;
private readonly double _intencityUv254nm;
public CaptureWorker(ILogger<CaptureWorker> logger, IHostApplicationLifetime applicationLifetime, CameraService cameraService, IlluminatorService illuminatorService, FileInfo outputFile, double intencityWhite, double intencityUv365nm, double intencityUv254nm)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
_cameraService = cameraService;
_illuminatorService = illuminatorService;
_outputFile = outputFile;
_intencityWhite = intencityWhite;
_intencityUv365nm = intencityUv365nm;
_intencityUv254nm = intencityUv254nm;
_logger.LogInformation("Initialized");
_logger.LogInformation("Output File: {}", _outputFile);
_logger.LogInformation("Intencity White: {}", _intencityWhite);
_logger.LogInformation("Intencity UV 365 nm: {}", _intencityUv365nm);
_logger.LogInformation("Intencity UV 254 nm: {}", _intencityUv254nm);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Running camera fine tuning");
_illuminatorService.SetIntensity(_intencityWhite, _intencityUv365nm, _intencityUv254nm);
_illuminatorService.TurnOn();
Mat? image;
try
{
image = await _cameraService.CaptureImage(stoppingToken, 5);
if (image is null)
{
_logger.LogCritical("Cannot get image");
_applicationLifetime.StopApplication();
return;
}
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Exception occured while image capturing");
_applicationLifetime.StopApplication();
return;
}
finally
{
_illuminatorService.TurnOff();
}
try
{
image.ImWrite(_outputFile.FullName);
_logger.LogInformation("Image saved: {}", _outputFile.FullName);
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Exception occured while image saving");
}
_applicationLifetime.StopApplication();
}
}
private static void Capture(ParseResult parseResult)
{
var outputFile = parseResult.GetRequiredValue(_captureOutputFile);
var intencityWhite = parseResult.GetRequiredValue(_captureIntencityWhite);
var intencityUv365nm = parseResult.GetRequiredValue(_captureIntencityUv365nm);
var intencityUv254nm = parseResult.GetRequiredValue(_captureIntencityUv254nm);
var builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.FormatterName = nameof(ConsoleFormatter));
builder.Logging.AddConsoleFormatter<ConsoleFormatter, ConsoleFormatterOptions>();
builder.Logging.AddProvider(new FileLoggerProvider("full.log", true));
builder.Logging.AddProvider(new FileLoggerProvider("last_run.log", false));
builder.Services.AddSingleton<LibCameraLogSink>();
builder.Services.AddIlluminatorService("Hardware:Illuminator");
builder.Services.AddCameraService("Hardware:Camera", true);
builder.Services.AddHostedService<CaptureWorker>(services =>
{
var logger = services.GetRequiredService<ILogger<CaptureWorker>>();
var applicationLifetime = services.GetRequiredService<IHostApplicationLifetime>();
var cameraService = services.GetRequiredService<CameraService>();
var illuminatorService = services.GetRequiredService<IlluminatorService>();
return new CaptureWorker(logger, applicationLifetime, cameraService, illuminatorService, outputFile, intencityWhite, intencityUv365nm, intencityUv254nm);
});
var host = builder.Build();
host.Services.GetRequiredService<LibCameraLogSink>();
host.Run();
}
}
+11 -23
View File
@@ -32,7 +32,7 @@
"Uv365RelayPin": 20,
"FanPin": 21,
"PwmFrequency": 200,
"MaxWhitePwmDuty": 0.03,
"MaxWhitePwmDuty": 0.05,
"MaxUv254PwmDuty": 1.0,
"MaxUv365PwmDuty": 1.0
},
@@ -48,39 +48,27 @@
"CameraHardSettings": {
"AeEnable": false,
"ExposureValue": 8.0,
"ExposureTime": 1000000,
"ExposureTime": 30000,
"ExposureTimeMode": "ExposureTimeModeManual",
"AnalogueGain": 1.0,
"AnalogueGain": 23.0,
"AnalogueGainMode": "AnalogueGainModeManual",
"AeFlickerMode": "FlickerOff",
"AeFlickerPeriod": 5000,
"Brightness": 0.0,
"Contrast": 1.0,
"AwbEnable": true,
"AwbEnable": false,
"ColourGains": [
3.3,
1.5
],
"ColourTemperature": 5500,
"Saturation": 1.0,
"Sharpness": 1.0,
"ScalerCrop": [
0,
0,
4056,
3040
],
"FrameDurationLimits": [
100000,
694434742
1.2,
1.0
],
"HdrMode": "HdrModeOff",
"NoiseReductionMode": "NoiseReductionModeOff",
"StatsOutputEnable": true,
"SyncMode": "SyncModeOff",
"SyncFrames": 1,
"CnnEnableInputTensor": false
}
},
"DecodeGainR": 3.2,
"DecodeGainG": 1.0,
"DecodeGainB": 1.0,
"DecodeBlackLevel": 4096
},
"TemperatureHumidity": {
"Bus": 6,