using OpenCvSharp; using OpenCvSharp.Aruco; using TraceCad.Vision.Calibration; using Xunit; namespace TraceCad.Tests; public sealed class SheetCalibratorTests { [Fact] public void CalibrateDetectsSyntheticA4SheetMarkers() { const double pixelsPerMillimetre = 4.0; var template = ReferenceSheetTemplate.DefaultA4(); using var sheet = RenderSyntheticSheet(template, pixelsPerMillimetre); var tempDirectory = Directory.CreateTempSubdirectory("easytrace-calibration-test-").FullName; try { var imagePath = Path.Combine(tempDirectory, "sheet.png"); Cv2.ImWrite(imagePath, sheet); var result = new SheetCalibrator().Calibrate(imagePath, tempDirectory, template); Assert.True(result.Success, result.Message); Assert.Equal(8, result.MatchedMarkerCount); Assert.Equal(0.0625, result.MmPerPixel, 9); Assert.Equal("perspective", result.CorrectionMode); Assert.Null(result.Quality); Assert.True(File.Exists(result.CorrectedImagePath)); } finally { Directory.Delete(tempDirectory, recursive: true); } } [Fact] public void CalibrateAppliesResidualWarpForSyntheticLensDistortion() { const double pixelsPerMillimetre = 8.0; var template = ReferenceSheetTemplate.DefaultA4(); using var sheet = RenderSyntheticSheet(template, pixelsPerMillimetre); using var distorted = ApplyRadialDistortion(sheet, -0.025); var tempDirectory = Directory.CreateTempSubdirectory("easytrace-distortion-test-").FullName; try { var imagePath = Path.Combine(tempDirectory, "distorted-sheet.png"); Cv2.ImWrite(imagePath, distorted); var result = new SheetCalibrator().Calibrate( imagePath, tempDirectory, template, SheetCalibrationOptions.Default with { PixelsPerMillimetre = 8.0, MinimumResidualImprovementMm = -100.0 }); Assert.True(result.Success, result.Message); Assert.True(result.MatchedMarkerCount >= 4); Assert.True( result.CorrectionMode.StartsWith("perspective + cross-validated residual warp", StringComparison.Ordinal), $"{result.Message} mode {result.CorrectionMode}"); Assert.True(File.Exists(result.CorrectedImagePath)); } finally { Directory.Delete(tempDirectory, recursive: true); } } private static Mat RenderSyntheticSheet(ReferenceSheetTemplate template, double pixelsPerMillimetre) { var sheet = new Mat( (int)Math.Round(template.HeightMm * pixelsPerMillimetre), (int)Math.Round(template.WidthMm * pixelsPerMillimetre), MatType.CV_8UC3, Scalar.White); var dictionary = CvAruco.GetPredefinedDictionary(PredefinedDictionaryName.Dict5X5_1000); foreach (var marker in template.Markers) { using var markerImage = new Mat(); dictionary.GenerateImageMarker( marker.Id, (int)Math.Round(marker.SizeMm * pixelsPerMillimetre), markerImage, 1); using var markerColor = new Mat(); Cv2.CvtColor(markerImage, markerColor, ColorConversionCodes.GRAY2BGR); var target = new Rect( (int)Math.Round(marker.TopLeftMm.X * pixelsPerMillimetre), (int)Math.Round(marker.TopLeftMm.Y * pixelsPerMillimetre), markerColor.Width, markerColor.Height); markerColor.CopyTo(new Mat(sheet, target)); } DrawControlGeometry(sheet, template, pixelsPerMillimetre); return sheet; } private static void DrawControlGeometry( Mat sheet, ReferenceSheetTemplate template, double pixelsPerMillimetre) { var thickness = Math.Max(1, (int)Math.Round(0.2 * pixelsPerMillimetre)); Cv2.Line( sheet, ToPixel(template.ReferenceLineStartMm, pixelsPerMillimetre), ToPixel(template.ReferenceLineEndMm, pixelsPerMillimetre), Scalar.Black, thickness); var center = ToPixel(template.CrosshairMm, pixelsPerMillimetre); var halfTick = (int)Math.Round(6.0 * pixelsPerMillimetre); Cv2.Line( sheet, new Point(center.X, center.Y - halfTick), new Point(center.X, center.Y + halfTick), Scalar.Black, thickness); } private static Point ToPixel(TraceCad.Core.Geometry.Point2 point, double pixelsPerMillimetre) => new( (int)Math.Round(point.X * pixelsPerMillimetre), (int)Math.Round(point.Y * pixelsPerMillimetre)); private static Mat ApplyRadialDistortion(Mat source, double coefficient) { using var mapX = new Mat(source.Height, source.Width, MatType.CV_32FC1); using var mapY = new Mat(source.Height, source.Width, MatType.CV_32FC1); var centerX = (source.Width - 1.0) / 2.0; var centerY = (source.Height - 1.0) / 2.0; var radius = Math.Min(centerX, centerY); for (var y = 0; y < source.Height; y++) { for (var x = 0; x < source.Width; x++) { var normalizedX = (x - centerX) / radius; var normalizedY = (y - centerY) / radius; var r2 = (normalizedX * normalizedX) + (normalizedY * normalizedY); var scale = 1.0 + (coefficient * r2); mapX.Set(y, x, (float)(centerX + ((x - centerX) * scale))); mapY.Set(y, x, (float)(centerY + ((y - centerY) * scale))); } } var distorted = new Mat(); Cv2.Remap(source, distorted, mapX, mapY, InterpolationFlags.Linear, BorderTypes.Constant, Scalar.White); return distorted; } }