diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..e471b25 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,30 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Install OpenCV + run: sudo apt-get update && sudo apt-get install -y libjpeg-dev libpng-dev libtiff-dev libavcodec-dev libavformat-dev libswscale-dev libv4l-dev libxvidcore-dev libx264-dev libopencv-dev + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/Convolver.sln b/Convolver.sln index 706996e..024a03a 100644 --- a/Convolver.sln +++ b/Convolver.sln @@ -7,6 +7,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E7170ACE-9AA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BMPConvolver.Core", "src\BMPConvolver.Core\BMPConvolver.Core.csproj", "{523C0934-7353-45C5-AB89-DFB54C98071F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BMPConvolver.Cli", "src\BMPConvolver.Cli\BMPConvolver.Cli.csproj", "{A99E9047-15E4-492A-8C77-B2C82CAF0B0B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5F4B7EAD-0AB8-4FBF-85F1-EA0BEE09832F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BMPConvolver.Tests", "test\BMPConvolver.Tests\BMPConvolver.Tests.csproj", "{69A51A1D-CE2E-4C75-AD2E-64ADC5F8E593}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,5 +41,7 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {523C0934-7353-45C5-AB89-DFB54C98071F} = {E7170ACE-9AAD-4689-ADC6-22E048D93B80} + {A99E9047-15E4-492A-8C77-B2C82CAF0B0B} = {E7170ACE-9AAD-4689-ADC6-22E048D93B80} + {69A51A1D-CE2E-4C75-AD2E-64ADC5F8E593} = {5F4B7EAD-0AB8-4FBF-85F1-EA0BEE09832F} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 8b13789..84f56c6 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ +Для запуска приложения выполните следующую команду: + +```bash +dotnet run --project src/BMPConvolver.Cli/BMPConvolver.Cli.csproj [опции] +``` + +Опции включают: +- `--mode seq|par`: режим выполнения (последовательный или параллельный) +- `--partition pixels|rows|cols|grid`: способ разделения работы +- `--grid x`: размер сетки для grid partition +- `--border zero|clamp`: режим обработки границ +- `--kernel box3|sharpen|identity`: предустановленный kernel +- `--kernel-text "..."`: kernel в текстовом формате +- `--kernel-file path.txt`: kernel из файла + +## Тесты + +Для запуска тестов: + +```bash +dotnet test +``` diff --git a/src/BMPConvolver.Cli/BMPConvolver.Cli.csproj b/src/BMPConvolver.Cli/BMPConvolver.Cli.csproj new file mode 100644 index 0000000..a233af0 --- /dev/null +++ b/src/BMPConvolver.Cli/BMPConvolver.Cli.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/src/BMPConvolver.Cli/KernelParser/KernelTextParser.cs b/src/BMPConvolver.Cli/KernelParser/KernelTextParser.cs new file mode 100644 index 0000000..06d7a94 --- /dev/null +++ b/src/BMPConvolver.Cli/KernelParser/KernelTextParser.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using BMPConvolver.Core; + +namespace BMPConvolver.Cli.KernelParser; + +public static class KernelTextParser +{ + /// + /// Parses a 2D kernel from text. + /// Rows can be separated by ';' or newlines, values separated by whitespace and/or ','. + /// Center defaults to (width/2, height/2) unless specified. + /// + public static Kernel Parse(string text, int? centerX = null, int? centerY = null) + { + ArgumentNullException.ThrowIfNull(text); + + var rows = new List(); + + foreach (var line in SplitLinesAndSemicolons(text)) + { + var values = line + .Replace(',', ' ') + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => float.Parse(t, CultureInfo.InvariantCulture)) + .ToArray(); + + if (values.Length == 0) continue; + rows.Add(values); + } + + if (rows.Count == 0) throw new FormatException("Kernel text has no numeric rows."); + + var width = rows[0].Length; + if (width == 0) throw new FormatException("Kernel width must be > 0."); + + var height = rows.Count; + for (var i = 1; i < height; i++) + if (rows[i].Length != width) + throw new FormatException("All kernel rows must have the same number of values."); + + + var cx = centerX ?? (width / 2); + var cy = centerY ?? (height / 2); + + var weights = new float[width * height]; + for (var y = 0; y < height; y++) + { + var row = rows[y]; + Array.Copy(row, 0, weights, y * width, width); + } + + return new Kernel(width, height, cx, cy, weights); + } + + private static IEnumerable SplitLinesAndSemicolons(string text) + { + // Normalize CRLF/CR -> LF first, then split further by ';' + var normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); + foreach (var line in normalized.Split('\n')) + { + var parts = line.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + foreach (var p in parts) + yield return p; + } + } +} \ No newline at end of file diff --git a/src/BMPConvolver.Cli/Program.cs b/src/BMPConvolver.Cli/Program.cs new file mode 100644 index 0000000..004c736 --- /dev/null +++ b/src/BMPConvolver.Cli/Program.cs @@ -0,0 +1,97 @@ +using BMPConvolver.Core; +using BMPConvolver.Core.ImageSharp; +using BMPConvolver.Cli.KernelParser; + +static int PrintUsage() +{ + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" BMPConvolver.Cli [--border zero|clamp]"); + Console.Error.WriteLine(" [--kernel box3|sharpen|identity] [--kernel-text \"...\"] [--kernel-file path.txt]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Kernel text format:"); + Console.Error.WriteLine(" Rows separated by ';' or newlines, values by spaces/commas."); + return 2; +} + +if (args.Length < 2) return PrintUsage(); + +var inputPath = args[0]; +var outputPath = args[1]; + +var border = "zero"; +string? kernelPreset = null; +string? kernelText = null; +string? kernelFile = null; + +for (var i = 2; i < args.Length; i++) +{ + var a = args[i]; + if (a == "--border" && i + 1 < args.Length) { border = args[++i]; continue; } + if (a == "--kernel" && i + 1 < args.Length) { kernelPreset = args[++i]; continue; } + if (a == "--kernel-text" && i + 1 < args.Length) { kernelText = args[++i]; continue; } + if (a == "--kernel-file" && i + 1 < args.Length) { kernelFile = args[++i]; continue; } + + return PrintUsage(); +} + +BorderMode borderMode; +Kernel kernel; +try +{ + borderMode = border.ToLowerInvariant() switch + { + "zero" => BorderMode.Zero, + "clamp" => BorderMode.Clamp, + _ => throw new ArgumentException($"Unknown border mode: {border}") + }; + + kernel = ResolveKernel(kernelPreset, kernelText, kernelFile); +} +catch (Exception e) +{ + Console.Error.WriteLine(e.Message); + return 2; +} + +static Kernel ResolveKernel(string? preset, string? text, string? file) +{ + var specified = 0; + if (!string.IsNullOrWhiteSpace(preset)) specified++; + if (!string.IsNullOrWhiteSpace(text)) specified++; + if (!string.IsNullOrWhiteSpace(file)) specified++; + if (specified > 1) throw new ArgumentException("You can specify only one of --kernel, --kernel-text, --kernel-file."); + + if (!string.IsNullOrWhiteSpace(text)) + return KernelTextParser.Parse(text); + + if (!string.IsNullOrWhiteSpace(file)) + return KernelTextParser.Parse(File.ReadAllText(file)); + + var p = (preset ?? "box3").Trim().ToLowerInvariant(); + return p switch + { + "identity" => Kernel.Identity(), + "box3" => Kernel.BoxBlur(), + "sharpen" => Kernel.Sharpen(), + _ => throw new ArgumentException($"Unknown kernel preset: {preset}. Use box3|sharpen|identity.") + }; +} + +var input = GrayImageIo.LoadAsGray(inputPath); + +var timer = System.Diagnostics.Stopwatch.StartNew(); +GrayImage output; +try +{ + output = Convolver.ConvolveSequential(input, kernel, borderMode); +} +catch (Exception e) +{ + Console.Error.WriteLine(e.Message); + return 2; +} +timer.Stop(); + +GrayImageIo.SaveGrayAsBmp(output, outputPath); +Console.WriteLine($"Done. {input.Width}x{input.Height}, border={border}, elapsed={timer.ElapsedMilliseconds} ms"); +return 0; diff --git a/src/BMPConvolver.Core/BorderMode.cs b/src/BMPConvolver.Core/BorderMode.cs new file mode 100644 index 0000000..93a32f3 --- /dev/null +++ b/src/BMPConvolver.Core/BorderMode.cs @@ -0,0 +1,8 @@ +namespace BMPConvolver.Core; + +public enum BorderMode +{ + Zero = 0, + Clamp = 1, +} + diff --git a/src/BMPConvolver.Core/Convolver.cs b/src/BMPConvolver.Core/Convolver.cs new file mode 100644 index 0000000..5e03a60 --- /dev/null +++ b/src/BMPConvolver.Core/Convolver.cs @@ -0,0 +1,55 @@ +namespace BMPConvolver.Core; + +public static class Convolver +{ + public static GrayImage ConvolveSequential(GrayImage input, Kernel kernel, BorderMode borderMode) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(kernel); + + var output = new float[input.Pixels.Length]; + ConvolveInternal(input, output, kernel, borderMode); + return new GrayImage(input.Width, input.Height, output); + } + + private static void ConvolveInternal(GrayImage input, float[] output, Kernel kernel, BorderMode borderMode) + { + var src = input.Pixels; + var weights = kernel.Weights; + + var yEnd = input.Height; + var xEnd = input.Width; + + for (var y = 0; y < yEnd; y++) + for (var x = 0; x < xEnd; x++) + { + var sum = 0f; + + for (var ky = 0; ky < kernel.Height; ky++) + { + var iy = y + (ky - kernel.CenterY); + var yInRange = (uint)iy < (uint)input.Height; + if (!yInRange && borderMode == BorderMode.Zero) continue; + if (!yInRange && borderMode == BorderMode.Clamp) iy = iy < 0 ? 0 : (input.Height - 1); + + var srcRow = iy * input.Width; + var kRow = ky * kernel.Width; + + for (var kx = 0; kx < kernel.Width; kx++) + { + var ix = x + (kx - kernel.CenterX); + var xInRange = (uint)ix < (uint)input.Width; + if (!xInRange && borderMode == BorderMode.Zero) continue; + if (!xInRange && borderMode == BorderMode.Clamp) ix = ix < 0 ? 0 : (input.Width - 1); + + var pixel = src[srcRow + ix]; + var weight = weights[kRow + kx]; + sum += pixel * weight; + } + } + + output[(y * input.Width) + x] = sum; + } + } +} + diff --git a/src/BMPConvolver.Core/ImageIO/GrayImageIO.cs b/src/BMPConvolver.Core/ImageIO/GrayImageIO.cs new file mode 100644 index 0000000..0a2a5ee --- /dev/null +++ b/src/BMPConvolver.Core/ImageIO/GrayImageIO.cs @@ -0,0 +1,49 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace BMPConvolver.Core.ImageSharp; + +public static class GrayImageIo +{ + public static GrayImage LoadAsGray(string path) + { + using var image = Image.Load(path); + var pixels = new float[image.Width * image.Height]; + var bytes = new byte[pixels.Length]; + + image.CopyPixelDataTo(bytes); + for (var i = 0; i < bytes.Length; i++) + pixels[i] = bytes[i] / 255f; + + return new GrayImage(image.Width, image.Height, pixels); + } + + public static void SaveGrayAsBmp(GrayImage image, string path) + { + using var outImage = new Image(image.Width, image.Height); + var src = image.Pixels; + var dest = new L8[src.Length]; + + // Convert float to L8 + for (int i = 0; i < src.Length; i++) + { + float v = Math.Clamp(src[i], 0f, 1f); + dest[i] = new L8((byte)MathF.Round(v * 255f)); + } + + outImage.ProcessPixelRows(accessor => + { + int offset = 0; + int width = image.Width; + + for (var y = 0; y < image.Height; y++) + { + dest.AsSpan(offset, width).CopyTo(accessor.GetRowSpan(y)); + offset += width; + } + }); + + outImage.SaveAsBmp(path); + } +} + diff --git a/src/BMPConvolver.Core/ImageTypes/GrayImage.cs b/src/BMPConvolver.Core/ImageTypes/GrayImage.cs new file mode 100644 index 0000000..226e639 --- /dev/null +++ b/src/BMPConvolver.Core/ImageTypes/GrayImage.cs @@ -0,0 +1,30 @@ +namespace BMPConvolver.Core; + +public sealed class GrayImage +{ + public int Width { get; } + public int Height { get; } + public float[] Pixels { get; } + + public GrayImage(int width, int height, float[] pixels) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentNullException.ThrowIfNull(pixels); + if (pixels.Length != width * height) throw new ArgumentException("Pixels length must be equal to width * height."); + + Width = width; + Height = height; + Pixels = pixels; + } + + public float GetPixel(int x, int y) => Pixels[(y * Width) + x]; + public void SetPixel(int x, int y, float value) => Pixels[(y * Width) + x] = value; + + public GrayImage Clone() + { + var copy = new float[Pixels.Length]; + Array.Copy(Pixels, copy, copy.Length); + return new GrayImage(Width, Height, copy); + } +} diff --git a/src/BMPConvolver.Core/Kernel.cs b/src/BMPConvolver.Core/Kernel.cs new file mode 100644 index 0000000..ee290eb --- /dev/null +++ b/src/BMPConvolver.Core/Kernel.cs @@ -0,0 +1,143 @@ +namespace BMPConvolver.Core; + +public sealed class Kernel +{ + public int Width { get; } + public int Height { get; } + public int CenterX { get; } + public int CenterY { get; } + public float[] Weights { get; } + + public Kernel(int width, int height, int centerX, int centerY, float[] weights) + { + if( width <= 0 ) throw new ArgumentOutOfRangeException(nameof(width), width, "Size must be a positive integer."); + if( height <= 0 ) throw new ArgumentOutOfRangeException(nameof(height), height, "Size must be a positive integer."); + if( centerX < 0 ) throw new ArgumentOutOfRangeException(nameof(centerX), centerX, "Center must be within the kernel bounds."); + if( centerX >= width ) throw new ArgumentOutOfRangeException(nameof(centerX), centerX, "Center must be within the kernel bounds."); + if( centerY < 0 ) throw new ArgumentOutOfRangeException(nameof(centerY), centerY, "Center must be within the kernel bounds."); + if( centerY >= height ) throw new ArgumentOutOfRangeException(nameof(centerY), centerY, "Center must be within the kernel bounds."); + ArgumentNullException.ThrowIfNull(weights); + if (weights.Length != width * height) throw new ArgumentException(nameof(weights), "Weights length must be width*height."); + + Width = width; + Height = height; + CenterX = centerX; + CenterY = centerY; + Weights = weights; + } + + public float Get(int x, int y) => Weights[(y * Width) + x]; + + public static Kernel Identity() + => Delta(0, 0); + + public static Kernel Zero(int width, int height) + { + var centerX = width / 2; + var centerY = height / 2; + return new Kernel(width, height, centerX, centerY, new float[width * height]); + } + + /// + /// Discrete shift kernel: output(x,y) = input(x-dx, y-dy) + /// + public static Kernel Delta(int dx, int dy) + { + var width = Math.Abs(dx) * 2 + 1; + var height = Math.Abs(dy) * 2 + 1; + var centerX = width / 2; + var centerY = height / 2; + + var weights = new float[width * height]; + var kx = centerX - dx; + var ky = centerY - dy; + weights[(ky * width) + kx] = 1f; + return new Kernel(width, height, centerX, centerY, weights); + } + + /// + /// Box blur kernel of the given size. + /// + public static Kernel BoxBlur(int size = 3) + { + if (size <= 0 || size % 2 == 0) + throw new ArgumentOutOfRangeException(nameof(size), "Size must be a positive odd number."); + + var weight = 1f / (size * size); + var weights = new float[size * size]; + Array.Fill(weights, weight); + var center = size / 2; + + return new Kernel(size, size, center, center, weights); + } + + /// + /// Sharpening kernel (Laplacian-based unsharp mask). + /// + public static Kernel Sharpen() + => new( + width: 3, + height: 3, + centerX: 1, + centerY: 1, + weights: + [ + 0f, -1f, 0f, + -1f, 5f, -1f, + 0f, -1f, 0f, + ]); + + /// + /// Composition for Zero border (infinite zero-extended domain): + /// applying a then b equals applying Compose(a, b). + /// + public static Kernel Compose(Kernel a, Kernel b) + { + var width = a.Width + b.Width - 1; + var height = a.Height + b.Height - 1; + var centerX = a.CenterX + b.CenterX; + var centerY = a.CenterY + b.CenterY; + var weights = new float[width * height]; + + for (var bY = 0; bY < b.Height; bY++) + for (var bX = 0; bX < b.Width; bX++) + { + var bWeight = b.Get(bX, bY); + if (bWeight == 0f) continue; + + for (var aY = 0; aY < a.Height; aY++) + for (var aX = 0; aX < a.Width; aX++) + { + var aWeight = a.Get(aX, aY); + if (aWeight == 0f) continue; + + var rX = aX + bX; + var rY = aY + bY; + weights[(rY * width) + rX] += bWeight * aWeight; + } + } + + return new Kernel(width, height, centerX, centerY, weights); + } + + public Kernel PadZeros(int padLeft, int padTop, int padRight, int padBottom) + { + if (padLeft < 0) throw new ArgumentOutOfRangeException(nameof(padLeft)); + if (padTop < 0) throw new ArgumentOutOfRangeException(nameof(padTop)); + if (padRight < 0) throw new ArgumentOutOfRangeException(nameof(padRight)); + if (padBottom < 0) throw new ArgumentOutOfRangeException(nameof(padBottom)); + + var newW = Width + padLeft + padRight; + var newH = Height + padTop + padBottom; + var newWeights = new float[newW * newH]; + + for (var y = 0; y < Height; y++) + { + var srcRow = y * Width; + var dstRow = (y + padTop) * newW + padLeft; + Array.Copy(Weights, srcRow, newWeights, dstRow, Width); + } + + return new Kernel(newW, newH, CenterX + padLeft, CenterY + padTop, newWeights); + } +} diff --git a/test/BMPConvolver.Tests/BMPConvolver.Tests.csproj b/test/BMPConvolver.Tests/BMPConvolver.Tests.csproj new file mode 100644 index 0000000..1310f25 --- /dev/null +++ b/test/BMPConvolver.Tests/BMPConvolver.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/BMPConvolver.Tests/CliTests/CliUnitTests.cs b/test/BMPConvolver.Tests/CliTests/CliUnitTests.cs new file mode 100644 index 0000000..673e93e --- /dev/null +++ b/test/BMPConvolver.Tests/CliTests/CliUnitTests.cs @@ -0,0 +1,112 @@ +using BMPConvolver.Cli.KernelParser; + +namespace BMPConvolver.Tests.CLITests; + +public class CliTests +{ + [Fact] + public void KernelTextParser_ParsesSemicolonSeparatedMatrix() + { + var k = KernelTextParser.Parse("0 -1 0; -1 5 -1; 0 -1 0"); + Assert.Equal(3, k.Width); + Assert.Equal(3, k.Height); + Assert.Equal(1, k.CenterX); + Assert.Equal(1, k.CenterY); + Assert.Equal(5f, k.Get(1, 1)); + Assert.Equal(-1f, k.Get(1, 0)); + } + + [Fact] + public void KernelTextParser_ParsesNewlinesAndComments() + { + var text = """ + 0 0 0 + 0 1 0 + 0 0 0 + """; + var k = KernelTextParser.Parse(text); + Assert.Equal(3, k.Width); + Assert.Equal(3, k.Height); + Assert.Equal(1f, k.Get(1, 1)); + } + + [Fact] + public void KernelTextParser_ParsesCommaSeparatedValues() + { + var k = KernelTextParser.Parse("1,2,3;4,5,6;7,8,9"); + Assert.Equal(3, k.Width); + Assert.Equal(3, k.Height); + Assert.Equal(1f, k.Get(0, 0)); + Assert.Equal(5f, k.Get(1, 1)); + Assert.Equal(9f, k.Get(2, 2)); + } + + [Fact] + public void KernelTextParser_ParsesSingleRow() + { + var k = KernelTextParser.Parse("1 2 3"); + Assert.Equal(3, k.Width); + Assert.Equal(1, k.Height); + Assert.Equal(1, k.CenterX); + Assert.Equal(0, k.CenterY); + Assert.Equal(1f, k.Get(0, 0)); + Assert.Equal(2f, k.Get(1, 0)); + Assert.Equal(3f, k.Get(2, 0)); + } + + [Fact] + public void KernelTextParser_ParsesWithCustomCenter() + { + var k = KernelTextParser.Parse("1 2 3;4 5 6", centerX: 0, centerY: 1); + Assert.Equal(3, k.Width); + Assert.Equal(2, k.Height); + Assert.Equal(0, k.CenterX); + Assert.Equal(1, k.CenterY); + } + + [Fact] + public void KernelTextParser_ThrowsOnEmptyText() + { + Assert.Throws(() => KernelTextParser.Parse("")); + } + + [Fact] + public void KernelTextParser_ThrowsOnNoNumericRows() + { + Assert.Throws(() => KernelTextParser.Parse(" ; ; ")); + } + + [Fact] + public void KernelTextParser_ThrowsOnInconsistentRowLengths() + { + Assert.Throws(() => KernelTextParser.Parse("1 2;3 4 5")); + } + + [Fact] + public void KernelTextParser_IgnoresEmptyLines() + { + var text = """ + + 1 2 + + 3 4 + + """; + var k = KernelTextParser.Parse(text); + Assert.Equal(2, k.Width); + Assert.Equal(2, k.Height); + Assert.Equal(1f, k.Get(0, 0)); + Assert.Equal(4f, k.Get(1, 1)); + } + + [Fact] + public void KernelTextParser_ParsesFloatingPointValues() + { + var k = KernelTextParser.Parse("0.5 -0.25 1.0"); + Assert.Equal(3, k.Width); + Assert.Equal(1, k.Height); + Assert.Equal(0.5f, k.Get(0, 0)); + Assert.Equal(-0.25f, k.Get(1, 0)); + Assert.Equal(1.0f, k.Get(2, 0)); + } +} \ No newline at end of file diff --git a/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs b/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs new file mode 100644 index 0000000..4ecea21 --- /dev/null +++ b/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs @@ -0,0 +1,320 @@ +using BMPConvolver.Core; +using OpenCvSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace BMPConvolver.Tests.CoreTests; + +public class CoreTests +{ + private const int Seed = 1; + private const float Epsilon = 1e-6f; + + [Theory] + [InlineData(1, 1)] + [InlineData(32, 32)] + [InlineData(120, 30)] + [InlineData(256, 128)] + [InlineData(512, 256)] + [InlineData(512, 512)] + [InlineData(768, 512)] + public void IdentityKernel_IsIdentity(int width, int height) + { + var img = RandomImage(width, height); + var outImg = Convolver.ConvolveSequential(img, Kernel.Identity(), BorderMode.Zero); + AssertImagesEqual(img, outImg); + } + + [Theory] + [InlineData(1, 1, 3, 3)] + [InlineData(17, 9, 5, 7)] + [InlineData(128, 512, 7, 5)] + [InlineData(256, 256, 9, 9)] + [InlineData(320, 240, 11, 7)] + [InlineData(512, 128, 15, 13)] + public void ZeroKernel_ProducesZeros(int width, int height, int kernelWidth, int kernelHeight) + { + var img = RandomImage(width, height); + var kernel = Kernel.Zero(kernelWidth, kernelHeight); + var outImg = Convolver.ConvolveSequential(img, kernel, BorderMode.Zero); + Assert.All(outImg.Pixels, w => Assert.Equal(0f, w)); + } + + [Theory] + [InlineData(5, 5, 1, 2, 3, 4)] + [InlineData(19, 11, 2, 2, 2, 2)] + [InlineData(31, 29, 4, 3, 5, 2)] + [InlineData(64, 64, 8, 8, 8, 8)] + [InlineData(127, 65, 6, 5, 6, 5)] + public void PaddingKernelWithZeros_DoesNotChangeResult(int width, int height, int padLeft, int padTop, int padRight, int padBottom) + { + var img = RandomImage(width, height); + var kernel = RandomKernelOdd(maxSize: 7); + var padded = kernel.PadZeros(padLeft, padTop, padRight, padBottom); + + var image_A = Convolver.ConvolveSequential(img, kernel, BorderMode.Zero); + var image_B = Convolver.ConvolveSequential(img, padded, BorderMode.Zero); + AssertImagesEqual(image_A, image_B, Epsilon); + } + + [Theory] + [InlineData(9, 7)] + [InlineData(31, 29)] + [InlineData(63, 61)] + [InlineData(80, 72)] + [InlineData(128, 96)] + public void ShiftAndInverseShift_ComposeToIdentity(int width, int height) + { + var img = RandomImage(width, height); + var dx = 2; + var dy = -1; + var shift = Kernel.Delta(dx, dy); + var inv = Kernel.Delta(-dx, -dy); + var composed = Kernel.Compose(shift, inv); + + var seq = Convolver.ConvolveSequential(Convolver.ConvolveSequential(img, shift, BorderMode.Zero), inv, BorderMode.Zero); + var one = Convolver.ConvolveSequential(img, composed, BorderMode.Zero); + // Composition is guaranteed on the "safe" interior. When applying filters sequentially on a finite array, + // the intermediate result is implicitly cropped, so border pixels can differ from a single composed kernel. + AssertImagesEqualInterior(seq, one, marginX: Math.Abs(dx) * 2, marginY: Math.Abs(dy) * 2, eps: Epsilon); + } + + [Theory] + [InlineData(17, 13, 3, 3, 5, 5)] + [InlineData(64, 31, 1, 1, 7, 3)] + [InlineData(128, 128, 5, 5, 3, 7)] + [InlineData(192, 97, 7, 7, 9, 5)] + [InlineData(256, 64, 9, 9, 3, 3)] + public void SequentialApplication_EqualsKernelComposition(int width, int height, int KernelWidth_1, int KernelHeight_1, int KernelWidth_2, int KernelHeight_2) + { + var img = RandomImage(width, height); + var a = RandomKernelOddFrom(KernelWidth_1, KernelHeight_1); + var b = RandomKernelOddFrom(KernelWidth_2, KernelHeight_2); + + var seq = Convolver.ConvolveSequential(Convolver.ConvolveSequential(img, a, BorderMode.Zero), b, BorderMode.Zero); + var composed = Kernel.Compose(a, b); + var one = Convolver.ConvolveSequential(img, composed, BorderMode.Zero); + + var marginX = (a.Width / 2) + (b.Width / 2); + var marginY = (a.Height / 2) + (b.Height / 2); + AssertImagesEqualInterior(seq, one, marginX, marginY, eps: Epsilon); + } + + [Theory] + [InlineData(64, 48)] + [InlineData(128, 128)] + [InlineData(256, 64)] + [InlineData(192, 512)] + public void BoxBlur3_MatchesImageSharpBoxBlurRadius1(int width, int height) + { + var img = RandomImage(width, height); + var kernel = Kernel.BoxBlur(); + + var ours = Convolver.ConvolveSequential(img, kernel, BorderMode.Clamp); + var oursBytes = ToL8BytesClamped(ours); + + using var sharp = ToImageL8(img); + + sharp.Mutate(c => c.BoxBlur(1)); + + var sharpBytes = FromImageL8ToBytes(sharp); + + Assert.Equal(oursBytes.Length, sharpBytes.Length); + for (var i = 0; i < oursBytes.Length; i++) + Assert.True(Math.Abs(oursBytes[i] - sharpBytes[i]) <= 1, $"Byte index {i}: ours={oursBytes[i]} sharp={sharpBytes[i]}"); + } + + [Theory] + [InlineData(BorderMode.Zero, 1, 1)] + [InlineData(BorderMode.Zero, 2, 7)] + [InlineData(BorderMode.Zero, 13, 9)] + [InlineData(BorderMode.Zero, 64, 33)] + [InlineData(BorderMode.Zero, 128, 64)] + [InlineData(BorderMode.Zero, 192, 128)] + [InlineData(BorderMode.Clamp, 1, 1)] + [InlineData(BorderMode.Clamp, 2, 7)] + [InlineData(BorderMode.Clamp, 13, 9)] + [InlineData(BorderMode.Clamp, 64, 33)] + [InlineData(BorderMode.Clamp, 128, 64)] + [InlineData(BorderMode.Clamp, 192, 128)] + public void Convolution_MatchesOpenCv_Filter2D_ForRandomKernels(BorderMode borderMode, int width, int height) + { + var kernel = RandomKernelOdd(maxSize: 9); + + var img = RandomImage(width, height); + + var ours = Convolver.ConvolveSequential(img, kernel, borderMode); + var cv = OpenCvFilter2D(img, kernel, borderMode); + + AssertImagesEqual(ours, cv, eps: Epsilon); + } + + private static GrayImage RandomImage(int width, int height) + { + var rnd = new Random(Seed); + var pixels = new float[width * height]; + for (var i = 0; i < pixels.Length; i++) + pixels[i] = (float)rnd.NextDouble(); + return new GrayImage(width, height, pixels); + } + + private static Kernel RandomKernelOdd(int maxSize) + { + var rnd = new Random(Seed); + var width= 1 + 2 * rnd.Next(0, Math.Max(1, (maxSize + 1) / 2)); + var height = 1 + 2 * rnd.Next(0, Math.Max(1, (maxSize + 1) / 2)); + return RandomKernelOddFrom(width, height); + } + + private static Kernel RandomKernelOddFrom(int wRequested, int hRequested) + { + var width= wRequested <= 1 ? 1 : (wRequested % 2 == 1 ? wRequested : wRequested + 1); + var height = hRequested <= 1 ? 1 : (hRequested % 2 == 1 ? hRequested : hRequested + 1); + + var rnd = new Random(Seed); + var weights = new float[width * height]; + for (var i = 0; i < weights.Length; i++) + { + weights[i] = ((float)rnd.NextDouble() - 0.5f) * 0.5f; + } + return new Kernel(width, height, width/ 2, height / 2, weights); + } + + private static void AssertImagesEqual(GrayImage a, GrayImage b, float eps = 0f) + { + Assert.Equal(a.Width, b.Width); + Assert.Equal(a.Height, b.Height); + Assert.Equal(a.Pixels.Length, b.Pixels.Length); + + for (var i = 0; i < a.Pixels.Length; i++) + { + var da = a.Pixels[i]; + var db = b.Pixels[i]; + if (eps == 0f) + Assert.Equal(da, db); + else + Assert.True(MathF.Abs(da - db) <= eps, $"Index {i}: {da} != {db} (eps={eps})"); + } + } + + private static void AssertImagesEqualInterior(GrayImage a, GrayImage b, int marginX, int marginY, float eps = 0f) + { + Assert.Equal(a.Width, b.Width); + Assert.Equal(a.Height, b.Height); + + var width= a.Width; + var height = a.Height; + var x0 = Math.Clamp(marginX, 0, width); + var y0 = Math.Clamp(marginY, 0, height); + var x1 = Math.Clamp(width - marginX, 0, width); + var y1 = Math.Clamp(height - marginY, 0, height); + + for (var y = y0; y < y1; y++) + for (var x = x0; x < x1; x++) + { + var i = (y * width) + x; + var da = a.Pixels[i]; + var db = b.Pixels[i]; + Assert.True(MathF.Abs(da - db) <= eps, $"({x},{y}): {da} != {db} (eps={eps})"); + } + } + + private static Image ToImageL8(GrayImage img) + { + var image = new Image(img.Width, img.Height); + var width= img.Width; + var height = img.Height; + var src = img.Pixels; + + image.ProcessPixelRows(accessor => + { + for (var y = 0; y < height; y++) + { + var row = accessor.GetRowSpan(y); + var srcRow = y * width; + for (var x = 0; x < width; x++) + { + var weight = src[srcRow + x]; + if (weight < 0f) weight = 0f; + if (weight > 1f) weight = 1f; + row[x] = new L8((byte)MathF.Round(weight * 255f)); + } + } + }); + + return image; + } + + private static byte[] FromImageL8ToBytes(Image img) + { + var width= img.Width; + var height = img.Height; + var bytes = new byte[width * height]; + img.ProcessPixelRows(accessor => + { + for (var y = 0; y < height; y++) + { + var row = accessor.GetRowSpan(y); + var dstRow = y * width; + for (var x = 0; x < width; x++) + bytes[dstRow + x] = row[x].PackedValue; + } + }); + return bytes; + } + + private static byte[] ToL8BytesClamped(GrayImage img) + { + var bytes = new byte[img.Pixels.Length]; + for (var i = 0; i < img.Pixels.Length; i++) + { + var weight = img.Pixels[i]; + if (weight < 0f) weight = 0f; + if (weight > 1f) weight = 1f; + bytes[i] = (byte)MathF.Round(weight * 255f); + } + return bytes; + } + + private static GrayImage OpenCvFilter2D(GrayImage img, Kernel kernel, BorderMode borderMode) + { + using var src = new Mat(img.Height, img.Width, MatType.CV_32FC1); + var i = 0; + for (var y = 0; y < img.Height; y++) + for (var x = 0; x < img.Width; x++) + src.Set(y, x, img.Pixels[i++]); + + using var kernelMat = new Mat(kernel.Height, kernel.Width, MatType.CV_32FC1); + i = 0; + for (var y = 0; y < kernel.Height; y++) + for (var x = 0; x < kernel.Width; x++) + kernelMat.Set(y, x, kernel.Weights[i++]); + + using var dst = new Mat(img.Height, img.Width, MatType.CV_32FC1); + + var border = borderMode switch + { + BorderMode.Zero => BorderTypes.Constant, + BorderMode.Clamp => BorderTypes.Replicate, + _ => throw new ArgumentOutOfRangeException(nameof(borderMode)) + }; + + Cv2.Filter2D( + src: src, + dst: dst, + ddepth: MatType.CV_32FC1, + kernel: kernelMat, + anchor: new OpenCvSharp.Point(kernel.CenterX, kernel.CenterY), + delta: 0, + borderType: border); + + var outPixels = new float[img.Width * img.Height]; + i = 0; + for (var y = 0; y < img.Height; y++) + for (var x = 0; x < img.Width; x++) + outPixels[i++] = dst.Get(y, x); + + return new GrayImage(img.Width, img.Height, outPixels); + } +} \ No newline at end of file