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/.gitignore b/.gitignore index d69c327..9d901db 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ bld/ # .NET Core project.lock.json project.fragment.lock.json +BenchmarkDotNet.Artifacts # ASP.NET Scaffolding ScaffoldingReadMe.txt diff --git a/Convolver.sln b/Convolver.sln index 706996e..6396c93 100644 --- a/Convolver.sln +++ b/Convolver.sln @@ -7,6 +7,16 @@ 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 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bench", "bench", "{2507B97A-9F2C-4A9C-A7A3-58541DDFA976}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BMPConvolver.Benchmarks", "bench\BMPConvolver.Benchmarks\BMPConvolver.Benchmarks.csproj", "{4E6BFCD2-D954-4FB9-AC30-1024FA7A9A3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,5 +45,8 @@ 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} + {4E6BFCD2-D954-4FB9-AC30-1024FA7A9A3C} = {2507B97A-9F2C-4A9C-A7A3-58541DDFA976} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 8b13789..7aa73db 100644 --- a/README.md +++ b/README.md @@ -1 +1,157 @@ +# Запуск +Для запуска приложения выполните следующую команду: + +```bash +dotnet run --project src/BMPConvolver.Cli/BMPConvolver.Cli.csproj [опции] +``` + +Для потоковой обработки каталога изображений: + +```bash +dotnet run --project src/BMPConvolver.Cli/BMPConvolver.Cli.csproj --batch --mode par --partition grid --grid 8x8 +``` + +Pipeline сам управляет ресурсами: выбирает стартовое количество reader/convolver/writer worker'ов, ограничивает очереди между стадиями и при `--mode par` делит CPU-бюджет между количеством одновременно обрабатываемых изображений и внутренним параллелизмом одной свертки. Во время работы балансировщик следит за заполненностью очередей: если загруженные изображения копятся, он добавляет активные свертки; если очередь перед записью забита, добавляет writers и притормаживает свертку; если чтение забегает вперед, уменьшает readers. + +Опции включают: +- `--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 +``` + +## Бенчмарки + +Для запуска бенчмарков: + +```bash +dotnet run --project bench/BMPConvolver.Benchmarks/BMPConvolver.Benchmarks.csproj +``` + +Запуск только pipeline IO benchmark: + +```bash +dotnet run -c Release --project bench/BMPConvolver.Benchmarks/BMPConvolver.Benchmarks.csproj --filter "*PipelineIoBench*" +``` +В этом бенчмарке сравниваются две стратегии IO (`Sequential IO` и `Pipeline IO`) для каждой стратегии свертки. + +# Анализ производительнсти (последовательная и параллельная свертка) +- **Зависимость от ширины и высоты**: При фиксированной высоте увеличение ширины приводит к росту времени в 4-5 раз, тогда как при фиксированной ширине увеличение высоты дает рост в 2-3 раза. Такая разнца связана с особенностями кэширования памяти и последовательным доступом к данным в массивах. +- **Зависимость от способа параллелизма**: + - **Sequential**: Базовый последовательный метод, без параллелизма. Примерно в 5 раз медленнее самого эффективного параллельного метода + - **Parallel.Pixels**: Параллелизм по каждому пикселю создает огромное количество задач, что приводит к высоким накладным расходам на синхронизацию. Самый медленный метод с высокой дисперсией. + - **Parallel.Rows**: Параллелизм по строкам эффективен для изображений с большим количеством строк. Разделяет изображение на горизонтальные полосы, минимизируя конфликты памяти. Часто быстрее Parallel.Columns. + - **Parallel.Columns**: Параллелизм по столбцам показывает себя лучше на изображениях с большим количеством столбцов. Однако он может страдать от неравномерности нагрузки при широких изображениях. + - **Parallel.Grid**: Наиболее эффективный метод, разделяющий изображение на прямоугольные блоки (сетку). Балансирует нагрузку между потоками, минимизирует накладные расходы и обеспечивает лучшую масштабируемость. Часто в 2-3 раза быстрее Sequential и в 10+ раз быстрее Parallel.Pixels. + +### Диаграмма +![Столбчатая_диаграмма_Zero](public/img/Border_mode_Zero_columnar.png) + +### График +![График_Zero](public/img/Border_mode_Zero_Graphics.png) + +### Таблица + +| Method | Width | Height | Mean | Error | +|----------------- |------ |------- |-----------:|-----------:| +| Sequential | 1024 | 1024 | 11.347 ms | 0.1680 ms | +| Parallel.Pixels | 1024 | 1024 | 24.677 ms | 1.3195 ms | +| Parallel.Rows | 1024 | 1024 | 2.510 ms | 0.0188 ms | +| Parallel.Columns | 1024 | 1024 | 2.313 ms | 0.0259 ms | +| Parallel.Grid | 1024 | 1024 | 2.247 ms | 0.0162 ms | +| | | | | | +| Sequential | 1024 | 2048 | 22.219 ms | 0.0945 ms | +| Parallel.Pixels | 1024 | 2048 | 51.281 ms | 5.7572 ms | +| Parallel.Rows | 1024 | 2048 | 4.961 ms | 0.0466 ms | +| Parallel.Columns | 1024 | 2048 | 5.252 ms | 0.0444 ms | +| Parallel.Grid | 1024 | 2048 | 4.517 ms | 0.0601 ms | +| | | | | | +| Sequential | 1024 | 4096 | 48.451 ms | 0.1076 ms | +| Parallel.Pixels | 1024 | 4096 | 110.977 ms | 19.1888 ms | +| Parallel.Rows | 1024 | 4096 | 9.724 ms | 0.0335 ms | +| Parallel.Columns | 1024 | 4096 | 10.589 ms | 0.0641 ms | +| Parallel.Grid | 1024 | 4096 | 8.937 ms | 0.0491 ms | +| | | | | | +| Sequential | 2048 | 1024 | 24.379 ms | 0.1265 ms | +| Parallel.Pixels | 2048 | 1024 | 49.390 ms | 6.5355 ms | +| Parallel.Rows | 2048 | 1024 | 4.982 ms | 0.0440 ms | +| Parallel.Columns | 2048 | 1024 | 6.388 ms | 0.1713 ms | +| Parallel.Grid | 2048 | 1024 | 5.264 ms | 0.1405 ms | +| | | | | | +| Sequential | 2048 | 2048 | 48.880 ms | 0.2759 ms | +| Parallel.Pixels | 2048 | 2048 | 102.091 ms | 8.1502 ms | +| Parallel.Rows | 2048 | 2048 | 9.650 ms | 0.0547 ms | +| Parallel.Columns | 2048 | 2048 | 9.829 ms | 0.1386 ms | +| Parallel.Grid | 2048 | 2048 | 9.884 ms | 0.0655 ms | +| | | | | | +| Sequential | 2048 | 4096 | 98.503 ms | 0.8038 ms | +| Parallel.Pixels | 2048 | 4096 | 236.901 ms | 4.6947 ms | +| Parallel.Rows | 2048 | 4096 | 18.314 ms | 0.2121 ms | +| Parallel.Columns | 2048 | 4096 | 20.790 ms | 0.1510 ms | +| Parallel.Grid | 2048 | 4096 | 19.169 ms | 0.1776 ms | +| | | | | | +| Sequential | 4096 | 1024 | 48.863 ms | 0.2360 ms | +| Parallel.Pixels | 4096 | 1024 | 103.113 ms | 11.9344 ms | +| Parallel.Rows | 4096 | 1024 | 8.819 ms | 0.0642 ms | +| Parallel.Columns | 4096 | 1024 | 9.795 ms | 0.0708 ms | +| Parallel.Grid | 4096 | 1024 | 9.115 ms | 0.2993 ms | +| | | | | | +| Sequential | 4096 | 2048 | 96.191 ms | 3.7277 ms | +| Parallel.Pixels | 4096 | 2048 | 223.957 ms | 24.3798 ms | +| Parallel.Rows | 4096 | 2048 | 16.587 ms | 0.0625 ms | +| Parallel.Columns | 4096 | 2048 | 22.102 ms | 0.0916 ms | +| Parallel.Grid | 4096 | 2048 | 20.067 ms | 0.2590 ms | +| | | | | | +| Sequential | 4096 | 4096 | 203.417 ms | 3.6494 ms | +| Parallel.Pixels | 4096 | 4096 | 483.104 ms | 3.5202 ms | +| Parallel.Rows | 4096 | 4096 | 37.907 ms | 0.3498 ms | +| Parallel.Columns | 4096 | 4096 | 45.954 ms | 0.4596 ms | +| Parallel.Grid | 4096 | 4096 | 38.499 ms | 0.2745 ms | + +# Анализ производительности (потоковая и последовательная обработка каталога изображений) + +В этом бенчмарке сравнивается обработка одного и того же каталога BMP-файлов двумя способами: +- **Sequential IO**: каждый файл полностью проходит цепочку `чтение -> свертка -> запись`, после чего начинается следующий файл. +- **Pipeline IO**: чтение, свертка и запись разделены на стадии, между которыми стоят ограниченные очереди. Это позволяет читать следующий файл, пока предыдущий сворачивается или записывается. + +Основные выводы: +- **Sequential convolution**: pipeline дает заметный выигрыш, потому что несколько независимых изображений могут сворачиваться разными worker'ами, а чтение и запись перекрываются с вычислениями. Время уменьшается с `461.7 ms` до `361.3 ms`, то есть примерно в `1.28x`. +- **ParallelPixels**: pipeline тоже дает заметное ускорение (`326.4 ms` против `231.2 ms`, примерно `1.41x`). Несмотря на высокие накладные расходы этой стратегии, перекрытие чтения, свертки и записи в каталоге изображений оказывается полезным. +- **ParallelRows** и **ParallelColumns**: pipeline дает небольшой выигрыш (`1.07x` и `1.13x`). Эти стратегии уже эффективно используют CPU внутри одного изображения, поэтому дополнительный эффект от pipeline ограничен перекрытием чтения и записи; у `ParallelRows` погрешность pipeline-измерения заметно выше, поэтому результат стоит трактовать осторожно. +- **ParallelGrid**: лучший результат для pipeline (`188.4 ms` против `136.4 ms`, примерно `1.38x`). Grid хорошо балансирует вычисления внутри изображения, а pipeline дополнительно убирает простои между чтением, обработкой и записью. + +### Диаграмма +![Столбчатая_диаграмма_Pipeline](public/img/Pipeline_IO_columnar.png) + +### График +![График_Pipeline](public/img/Pipeline_IO_graphics.png) + +### Таблица + +| Method | Strategy | Mean | Error | +|---------------- |---------------- |---------:|---------:| +| 'Sequential IO' | Sequential | 461.7 ms | 8.70 ms | +| 'Pipeline IO' | Sequential | 361.3 ms | 33.37 ms | +| | | | | +| 'Sequential IO' | ParallelPixels | 326.4 ms | 28.00 ms | +| 'Pipeline IO' | ParallelPixels | 231.2 ms | 32.51 ms | +| | | | | +| 'Sequential IO' | ParallelRows | 178.1 ms | 9.45 ms | +| 'Pipeline IO' | ParallelRows | 166.9 ms | 57.76 ms | +| | | | | +| 'Sequential IO' | ParallelColumns | 193.4 ms | 29.41 ms | +| 'Pipeline IO' | ParallelColumns | 171.1 ms | 41.00 ms | +| | | | | +| 'Sequential IO' | ParallelGrid | 188.4 ms | 15.65 ms | +| 'Pipeline IO' | ParallelGrid | 136.4 ms | 7.53 ms | diff --git a/bench/BMPConvolver.Benchmarks/BMPConvolver.Benchmarks.csproj b/bench/BMPConvolver.Benchmarks/BMPConvolver.Benchmarks.csproj new file mode 100644 index 0000000..599fe74 --- /dev/null +++ b/bench/BMPConvolver.Benchmarks/BMPConvolver.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/bench/BMPConvolver.Benchmarks/ConvolverBench.cs b/bench/BMPConvolver.Benchmarks/ConvolverBench.cs new file mode 100644 index 0000000..4e3653c --- /dev/null +++ b/bench/BMPConvolver.Benchmarks/ConvolverBench.cs @@ -0,0 +1,87 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BMPConvolver.Core; +using BMPConvolver.Core.WorkPartitioning; + +namespace BMPConvolver.Benchmarks; + +[Config(typeof(Config))] +[HideColumns(Column.RatioSD, Column.AllocRatio, Column.Gen0, Column.Gen1, Column.Gen2)] +public class ConvolutionBench +{ + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default.WithWarmupCount(3).WithIterationCount(15)); + } + } + private const int Seed = 1; + + [Params(1024, 2048, 4096)] + public int Width { get; set; } + + [Params(1024, 2048, 4096)] + public int Height { get; set; } + + [Params(BorderMode.Zero, BorderMode.Clamp)] + public BorderMode Border { get; set; } + + public int KernelSize { get; set; } = 3; + + public int Grid { get; set; } = 8; + + private GrayImage _input = null!; + private Kernel _kernel = null!; + + [GlobalSetup] + public void Setup() + { + _input = RandomImage(Width, Height); + _kernel = RandomKernelOddFrom(KernelSize, KernelSize); + } + + [Benchmark(Baseline = true, Description = "Sequential")] + public GrayImage Sequential() + => Convolver.ConvolveSequential(_input, _kernel, Border); + + [Benchmark(Description = "Parallel.Pixels")] + public GrayImage ParallelByPixels() + => Convolver.ConvolveParallel(_input, _kernel, Border, PartitioningMode.Pixels, gridX: Grid, gridY: Grid); + + [Benchmark(Description = "Parallel.Rows")] + public GrayImage ParallelByRows() + => Convolver.ConvolveParallel(_input, _kernel, Border, PartitioningMode.Rows, gridX: Grid, gridY: Grid); + + [Benchmark(Description = "Parallel.Columns")] + public GrayImage ParallelByColumns() + => Convolver.ConvolveParallel(_input, _kernel, Border, PartitioningMode.Columns, gridX: Grid, gridY: Grid); + + [Benchmark(Description = "Parallel.Grid")] + public GrayImage ParallelByGrid() + => Convolver.ConvolveParallel(_input, _kernel, Border, PartitioningMode.Grid, gridX: Grid, gridY: Grid); + + 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 RandomKernelOddFrom(int widthRequested, int heightRequested) + { + var width = widthRequested <= 1 ? 1 : (widthRequested % 2 == 1 ? widthRequested : widthRequested + 1); + var height = heightRequested <= 1 ? 1 : (heightRequested % 2 == 1 ? heightRequested : heightRequested + 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); + } +} + diff --git a/bench/BMPConvolver.Benchmarks/PipelineIoBench.cs b/bench/BMPConvolver.Benchmarks/PipelineIoBench.cs new file mode 100644 index 0000000..a0efc61 --- /dev/null +++ b/bench/BMPConvolver.Benchmarks/PipelineIoBench.cs @@ -0,0 +1,166 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BMPConvolver.Core; +using BMPConvolver.Core.ImageSharp; +using BMPConvolver.Core.Pipeline; +using BMPConvolver.Core.WorkPartitioning; + +namespace BMPConvolver.Benchmarks; + +public enum ConvolutionStrategy +{ + Sequential, + ParallelPixels, + ParallelRows, + ParallelColumns, + ParallelGrid +} + +[Config(typeof(Config))] +[HideColumns(Column.RatioSD, Column.AllocRatio, Column.Gen0, Column.Gen1, Column.Gen2)] +public class PipelineIoBench +{ + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default.WithWarmupCount(2).WithIterationCount(8)); + } + } + + private static readonly string InputDir = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "..", + "..", + "..", + "data", + "pipeline-input")); + + private static readonly string OutputRoot = Path.Combine(Path.GetTempPath(), "BMPConvolver.PipelineIoBench"); + + private readonly string[] _expectedNames = + [ + "img_01.bmp", + "img_02.bmp", + "img_03.bmp", + "img_04.bmp", + "img_05.bmp", + "img_06.bmp", + "img_07.bmp", + "img_08.bmp" + ]; + + private ImageConvolutionJob[] _jobs = null!; + private Kernel _kernel = null!; + + [Params( + ConvolutionStrategy.Sequential, + ConvolutionStrategy.ParallelPixels, + ConvolutionStrategy.ParallelRows, + ConvolutionStrategy.ParallelColumns, + ConvolutionStrategy.ParallelGrid)] + public ConvolutionStrategy Strategy { get; set; } + + [GlobalSetup] + public void Setup() + { + var inputPaths = _expectedNames + .Select(name => Path.Combine(InputDir, name)) + .ToArray(); + + var missing = inputPaths.Where(path => !File.Exists(path)).ToArray(); + if (missing.Length > 0) + { + throw new DirectoryNotFoundException( + "Pipeline IO benchmark input images were not found. Put 8 BMP files named " + + string.Join(", ", _expectedNames) + $" into: {InputDir}"); + } + + _jobs = inputPaths + .Select(path => new ImageConvolutionJob(path, Path.Combine(OutputRoot, Path.GetFileName(path)))) + .ToArray(); + + _kernel = Kernel.BoxBlur(size: 5); + } + + [IterationSetup] + public void IterationSetup() + { + if (Directory.Exists(OutputRoot)) + Directory.Delete(OutputRoot, recursive: true); + Directory.CreateDirectory(OutputRoot); + } + + [GlobalCleanup] + public void Cleanup() + { + if (Directory.Exists(OutputRoot)) + Directory.Delete(OutputRoot, recursive: true); + } + + [Benchmark(Baseline = true, Description = "Sequential IO")] + public int SequentialIo() + { + foreach (var job in _jobs) + { + Directory.CreateDirectory(Path.GetDirectoryName(job.OutputPath)!); + + var input = GrayImageIo.LoadAsGray(job.InputPath); + var output = Convolve(input, Strategy, maxDegreeOfParallelism: null); + GrayImageIo.SaveGrayAsBmp(output, job.OutputPath); + } + + return _jobs.Length; + } + + [Benchmark(Description = "Pipeline IO")] + public Task PipelineIo() + => ImageConvolutionPipeline.ProcessAsync(_jobs, _kernel, BorderMode.Clamp, CreatePipelineOptions(Strategy)); + + private GrayImage Convolve(GrayImage input, ConvolutionStrategy strategy, int? maxDegreeOfParallelism) + { + var partitioningMode = ToPartitioningMode(strategy); + return partitioningMode is null + ? Convolver.ConvolveSequential(input, _kernel, BorderMode.Clamp) + : Convolver.ConvolveParallel( + input, + _kernel, + BorderMode.Clamp, + partitioningMode.Value, + gridX: 8, + gridY: 8, + maxDegreeOfParallelism: maxDegreeOfParallelism); + } + + private static BatchConvolutionOptions CreatePipelineOptions(ConvolutionStrategy strategy) + { + var partitioningMode = ToPartitioningMode(strategy); + var usesInnerParallelism = partitioningMode is not null; + + return new BatchConvolutionOptions + { + UseParallelConvolution = usesInnerParallelism, + PartitioningMode = partitioningMode ?? PartitioningMode.Rows, + GridX = 8, + GridY = 8 + }; + } + + private static PartitioningMode? ToPartitioningMode(ConvolutionStrategy strategy) + => strategy switch + { + ConvolutionStrategy.Sequential => null, + ConvolutionStrategy.ParallelPixels => PartitioningMode.Pixels, + ConvolutionStrategy.ParallelRows => PartitioningMode.Rows, + ConvolutionStrategy.ParallelColumns => PartitioningMode.Columns, + ConvolutionStrategy.ParallelGrid => PartitioningMode.Grid, + _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown convolution strategy.") + }; +} diff --git a/bench/BMPConvolver.Benchmarks/Program.cs b/bench/BMPConvolver.Benchmarks/Program.cs new file mode 100644 index 0000000..d1153c1 --- /dev/null +++ b/bench/BMPConvolver.Benchmarks/Program.cs @@ -0,0 +1,12 @@ +using BenchmarkDotNet.Running; + +namespace BMPConvolver.Benchmarks; + +public static class Program +{ + public static int Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + return 0; + } +} diff --git a/bench/data/pipeline-input/img_01.bmp b/bench/data/pipeline-input/img_01.bmp new file mode 100644 index 0000000..4fbbe3e Binary files /dev/null and b/bench/data/pipeline-input/img_01.bmp differ diff --git a/bench/data/pipeline-input/img_02.bmp b/bench/data/pipeline-input/img_02.bmp new file mode 100644 index 0000000..6bf82d8 Binary files /dev/null and b/bench/data/pipeline-input/img_02.bmp differ diff --git a/bench/data/pipeline-input/img_03.bmp b/bench/data/pipeline-input/img_03.bmp new file mode 100644 index 0000000..d4059e8 Binary files /dev/null and b/bench/data/pipeline-input/img_03.bmp differ diff --git a/bench/data/pipeline-input/img_04.bmp b/bench/data/pipeline-input/img_04.bmp new file mode 100644 index 0000000..131aafe Binary files /dev/null and b/bench/data/pipeline-input/img_04.bmp differ diff --git a/bench/data/pipeline-input/img_05.bmp b/bench/data/pipeline-input/img_05.bmp new file mode 100644 index 0000000..713f99a Binary files /dev/null and b/bench/data/pipeline-input/img_05.bmp differ diff --git a/bench/data/pipeline-input/img_06.bmp b/bench/data/pipeline-input/img_06.bmp new file mode 100644 index 0000000..1030281 Binary files /dev/null and b/bench/data/pipeline-input/img_06.bmp differ diff --git a/bench/data/pipeline-input/img_07.bmp b/bench/data/pipeline-input/img_07.bmp new file mode 100644 index 0000000..51a66dd Binary files /dev/null and b/bench/data/pipeline-input/img_07.bmp differ diff --git a/bench/data/pipeline-input/img_08.bmp b/bench/data/pipeline-input/img_08.bmp new file mode 100644 index 0000000..a4f4b38 Binary files /dev/null and b/bench/data/pipeline-input/img_08.bmp differ diff --git a/public/img/Border_mode_Clamp_Graphics.png b/public/img/Border_mode_Clamp_Graphics.png new file mode 100644 index 0000000..3f14ee3 Binary files /dev/null and b/public/img/Border_mode_Clamp_Graphics.png differ diff --git a/public/img/Border_mode_Clamp_columnar.png b/public/img/Border_mode_Clamp_columnar.png new file mode 100644 index 0000000..99c81e7 Binary files /dev/null and b/public/img/Border_mode_Clamp_columnar.png differ diff --git a/public/img/Border_mode_Zero_Graphics.png b/public/img/Border_mode_Zero_Graphics.png new file mode 100644 index 0000000..5fa5f35 Binary files /dev/null and b/public/img/Border_mode_Zero_Graphics.png differ diff --git a/public/img/Border_mode_Zero_columnar.png b/public/img/Border_mode_Zero_columnar.png new file mode 100644 index 0000000..737d695 Binary files /dev/null and b/public/img/Border_mode_Zero_columnar.png differ diff --git a/public/img/Pipeline_IO_columnar.png b/public/img/Pipeline_IO_columnar.png new file mode 100644 index 0000000..a692835 Binary files /dev/null and b/public/img/Pipeline_IO_columnar.png differ diff --git a/public/img/Pipeline_IO_graphics.png b/public/img/Pipeline_IO_graphics.png new file mode 100644 index 0000000..b7d1866 Binary files /dev/null and b/public/img/Pipeline_IO_graphics.png differ 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..29fa3f8 --- /dev/null +++ b/src/BMPConvolver.Cli/Program.cs @@ -0,0 +1,183 @@ +using BMPConvolver.Cli.KernelParser; +using BMPConvolver.Core; +using BMPConvolver.Core.ImageSharp; +using BMPConvolver.Core.Pipeline; +using BMPConvolver.Core.WorkPartitioning; + +static int PrintUsage() +{ + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" BMPConvolver.Cli [--mode seq|par] [--partition pixels|rows|cols|grid] [--grid XxY] [--border zero|clamp]"); + Console.Error.WriteLine(" [--kernel box3|sharpen|identity] [--kernel-text \"...\"] [--kernel-file path.txt]"); + Console.Error.WriteLine(" BMPConvolver.Cli --batch [same convolution options]"); + Console.Error.WriteLine(" Pipeline worker and queue options are automatic and dynamically balanced."); + 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 mode = "seq"; +var partition = "rows"; +var border = "zero"; +var gridX = 4; +var gridY = 4; +var batch = false; +string? kernelPreset = null; +string? kernelText = null; +string? kernelFile = null; + +for (var i = 2; i < args.Length; i++) +{ + var a = args[i]; + if (a == "--mode" && i + 1 < args.Length) { mode = args[++i]; continue; } + if (a == "--partition" && i + 1 < args.Length) { partition = args[++i]; continue; } + if (a == "--border" && i + 1 < args.Length) { border = args[++i]; continue; } + if (a == "--batch") { batch = true; 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; } + if (a == "--grid" && i + 1 < args.Length) + { + var s = args[++i]; + var parts = s.Split('x'); + if (parts.Length != 2 || !int.TryParse(parts[0], out gridX) || !int.TryParse(parts[1], out gridY)) + return PrintUsage(); + continue; + } + + return PrintUsage(); +} + +BorderMode borderMode; +PartitioningMode partitionMode; +Kernel kernel; +try +{ + borderMode = border.ToLowerInvariant() switch + { + "zero" => BorderMode.Zero, + "clamp" => BorderMode.Clamp, + _ => throw new ArgumentException($"Unknown border mode: {border}") + }; + + partitionMode = partition.ToLowerInvariant() switch + { + "pixels" => PartitioningMode.Pixels, + "rows" => PartitioningMode.Rows, + "cols" or "columns" => PartitioningMode.Columns, + "grid" => PartitioningMode.Grid, + _ => throw new ArgumentException($"Unknown partition mode: {partition}") + }; + + kernel = ResolveKernel(kernelPreset, kernelText, kernelFile); + + mode = mode.ToLowerInvariant(); + if (mode is not ("seq" or "par")) + throw new ArgumentException($"Unknown mode: {mode}"); +} +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.") + }; +} + +if (batch) +{ + if (!Directory.Exists(inputPath)) + { + Console.Error.WriteLine($"Input directory does not exist: {inputPath}"); + return 2; + } + + var files = Directory + .EnumerateFiles(inputPath, "*.bmp", SearchOption.TopDirectoryOnly) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (files.Length == 0) + { + Console.Error.WriteLine($"Input directory has no .bmp files: {inputPath}"); + return 2; + } + + var jobs = files.Select(path => new ImageConvolutionJob( + path, + Path.Combine(outputPath, Path.GetFileName(path)))); + + var pipelineOptions = new BatchConvolutionOptions + { + UseParallelConvolution = mode == "par", + PartitioningMode = partitionMode, + GridX = gridX, + GridY = gridY + }; + + BatchConvolutionResult result; + try + { + result = await ImageConvolutionPipeline.ProcessAsync(jobs, kernel, borderMode, pipelineOptions); + } + catch (Exception e) + { + Console.Error.WriteLine(e.Message); + return 2; + } + + Console.WriteLine( + $"Done. files={result.Count}, mode={mode}, partition={partition}, border={border}"); + return 0; +} + +var input = GrayImageIo.LoadAsGray(inputPath); + +var timer = System.Diagnostics.Stopwatch.StartNew(); +GrayImage output; +try +{ + output = mode.ToLowerInvariant() switch + { + "seq" => Convolver.ConvolveSequential(input, kernel, borderMode), + "par" => Convolver.ConvolveParallel(input, kernel, borderMode, partitionMode, gridX, gridY), + _ => throw new ArgumentException($"Unknown mode: {mode}") + }; +} +catch (Exception e) +{ + Console.Error.WriteLine(e.Message); + return 2; +} +timer.Stop(); + +GrayImageIo.SaveGrayAsBmp(output, outputPath); +Console.WriteLine($"Done. {input.Width}x{input.Height}, mode={mode}, partition={partition}, 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..75a9fe2 --- /dev/null +++ b/src/BMPConvolver.Core/Convolver.cs @@ -0,0 +1,85 @@ +using BMPConvolver.Core.WorkPartitioning; + +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, new WorkRect(0, 0, input.Width, input.Height)); + return new GrayImage(input.Width, input.Height, output); + } + + public static GrayImage ConvolveParallel( + GrayImage input, + Kernel kernel, + BorderMode borderMode, + PartitioningMode partitioningMode, + int gridX = 1, + int gridY = 1, + int? maxDegreeOfParallelism = null) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(kernel); + + var output = new float[input.Pixels.Length]; + + var rects = Partitioner.Create(input.Width, input.Height, partitioningMode, gridX, gridY); + var opts = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? -1 + }; + + Parallel.ForEach(rects, opts, rect => + { + ConvolveInternal(input, output, kernel, borderMode, rect); + }); + + return new GrayImage(input.Width, input.Height, output); + } + + private static void ConvolveInternal(GrayImage input, float[] output, Kernel kernel, BorderMode borderMode, WorkRect rect) + { + var src = input.Pixels; + var weights = kernel.Weights; + + var yEnd = rect.Y2Exclusive; + var xEnd = rect.X2Exclusive; + + for (var y = rect.Y; y < yEnd; y++) + for (var x = rect.X; 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/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionOptions.cs b/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionOptions.cs new file mode 100644 index 0000000..be6fcb8 --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionOptions.cs @@ -0,0 +1,17 @@ +using BMPConvolver.Core.WorkPartitioning; + +namespace BMPConvolver.Core.Pipeline; + +public sealed class BatchConvolutionOptions +{ + public bool UseParallelConvolution { get; init; } + public PartitioningMode PartitioningMode { get; init; } = PartitioningMode.Rows; + public int GridX { get; init; } = 4; + public int GridY { get; init; } = 4; + + internal void Validate() + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(GridX); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(GridY); + } +} diff --git a/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionResult.cs b/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionResult.cs new file mode 100644 index 0000000..40332e6 --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Contracts/BatchConvolutionResult.cs @@ -0,0 +1,18 @@ +namespace BMPConvolver.Core.Pipeline; + +public sealed record ImageConvolutionFileResult( + string InputPath, + string OutputPath, + int Width, + int Height); + +public sealed class BatchConvolutionResult +{ + public BatchConvolutionResult(IReadOnlyList files) + { + Files = files; + } + + public IReadOnlyList Files { get; } + public int Count => Files.Count; +} diff --git a/src/BMPConvolver.Core/Pipeline/Contracts/ImageConvolutionJob.cs b/src/BMPConvolver.Core/Pipeline/Contracts/ImageConvolutionJob.cs new file mode 100644 index 0000000..942a5be --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Contracts/ImageConvolutionJob.cs @@ -0,0 +1,3 @@ +namespace BMPConvolver.Core.Pipeline; + +public sealed record ImageConvolutionJob(string InputPath, string OutputPath); diff --git a/src/BMPConvolver.Core/Pipeline/ImageConvolutionPipeline.cs b/src/BMPConvolver.Core/Pipeline/ImageConvolutionPipeline.cs new file mode 100644 index 0000000..dd6cf0d --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/ImageConvolutionPipeline.cs @@ -0,0 +1,332 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using BMPConvolver.Core.ImageSharp; + +namespace BMPConvolver.Core.Pipeline; + +public static class ImageConvolutionPipeline +{ + public static async Task ProcessAsync( + IEnumerable jobs, + Kernel kernel, + BorderMode borderMode, + BatchConvolutionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(jobs); + ArgumentNullException.ThrowIfNull(kernel); + + options ??= new BatchConvolutionOptions(); + var resourceController = new PipelineResourceController( + options, + Environment.ProcessorCount); + + using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var balancingCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationSource.Token); + + var source = Channel.CreateBounded(BoundedOptions(resourceController.JobQueueCapacity)); + var loaded = Channel.CreateBounded(BoundedOptions(resourceController.ReadQueueCapacity)); + var convolved = Channel.CreateBounded(BoundedOptions(resourceController.WriteQueueCapacity)); + + var sourceQueue = new PipelineQueueMeter(resourceController.JobQueueCapacity); + var readQueue = new PipelineQueueMeter(resourceController.ReadQueueCapacity); + var writeQueue = new PipelineQueueMeter(resourceController.WriteQueueCapacity); + + var results = new ConcurrentBag(); + + var balancingTask = resourceController.BalanceAsync( + sourceQueue, + readQueue, + writeQueue, + balancingCancellationSource.Token + ); + + var producerTask = RunProducerAsync(jobs, source.Writer, sourceQueue, cancellationSource); + + var readerTasks = StartWorkers(resourceController.MaxReaderCount, + () => RunReaderAsync( + source.Reader, + sourceQueue, + loaded.Writer, + readQueue, + resourceController.Readers, + cancellationSource) + ); + var loadedCompletionTask = CompleteAfterWorkersAsync(readerTasks, loaded.Writer, cancellationSource); + + var convolverTasks = StartWorkers(resourceController.MaxConvolverCount, + () => RunConvolverAsync( + loaded.Reader, + readQueue, + convolved.Writer, + writeQueue, + kernel, + borderMode, + options, + resourceController, + cancellationSource) + ); + var convolvedCompletionTask = CompleteAfterWorkersAsync(convolverTasks, convolved.Writer, cancellationSource); + + var writerTasks = StartWorkers(resourceController.MaxWriterCount, + () => RunWriterAsync( + convolved.Reader, + writeQueue, + results, + resourceController.Writers, + cancellationSource) + ); + + var allTasks = new List(readerTasks.Length + convolverTasks.Length + writerTasks.Length + 3) + { + producerTask, + loadedCompletionTask, + convolvedCompletionTask + }; + allTasks.AddRange(readerTasks); + allTasks.AddRange(convolverTasks); + allTasks.AddRange(writerTasks); + + try + { + await Task.WhenAll(allTasks).ConfigureAwait(false); + } + catch + { + cancellationSource.Cancel(); + throw; + } + finally + { + balancingCancellationSource.Cancel(); + try + { + await balancingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (balancingCancellationSource.IsCancellationRequested) + { + } + } + + var ordered = results + .OrderBy(x => x.Index) + .Select(x => x.Result) + .ToArray(); + + return new BatchConvolutionResult(ordered); + } + + private static async Task RunProducerAsync( + IEnumerable jobs, + ChannelWriter writer, + PipelineQueueMeter sourceQueue, + CancellationTokenSource cancellationSource) + { + try + { + var index = 0; + foreach (var job in jobs) + { + cancellationSource.Token.ThrowIfCancellationRequested(); + ValidateJob(job); + await WriteMeteredAsync( + writer, + sourceQueue, + new IndexedJob(index++, job), + cancellationSource.Token).ConfigureAwait(false); + } + + writer.TryComplete(); + } + catch (Exception e) + { + cancellationSource.Cancel(); + writer.TryComplete(e); + throw; + } + } + + private static async Task RunReaderAsync( + ChannelReader reader, + PipelineQueueMeter sourceQueue, + ChannelWriter writer, + PipelineQueueMeter readQueue, + ManagedConcurrencyGate gate, + CancellationTokenSource cancellationSource) + { + try + { + while (await reader.WaitToReadAsync(cancellationSource.Token).ConfigureAwait(false)) + { + using var lease = await gate.AcquireAsync(cancellationSource.Token).ConfigureAwait(false); + if (!reader.TryRead(out var item)) + continue; + + sourceQueue.Decrement(); + var image = GrayImageIo.LoadAsGray(item.Job.InputPath); + + await WriteMeteredAsync( + writer, + readQueue, + new LoadedImage(item, image), + cancellationSource.Token).ConfigureAwait(false); + } + } + catch (Exception e) + { + cancellationSource.Cancel(); + writer.TryComplete(e); + throw; + } + } + + private static async Task RunConvolverAsync( + ChannelReader reader, + PipelineQueueMeter readQueue, + ChannelWriter writer, + PipelineQueueMeter writeQueue, + Kernel kernel, + BorderMode borderMode, + BatchConvolutionOptions options, + PipelineResourceController resourceController, + CancellationTokenSource cancellationSource) + { + try + { + while (await reader.WaitToReadAsync(cancellationSource.Token).ConfigureAwait(false)) + { + using var lease = await resourceController.Convolvers + .AcquireAsync(cancellationSource.Token) + .ConfigureAwait(false); + if (!reader.TryRead(out var item)) + continue; + + readQueue.Decrement(); + var output = resourceController.UseParallelConvolution + ? Convolver.ConvolveParallel( + item.Image, + kernel, + borderMode, + options.PartitioningMode, + options.GridX, + options.GridY, + resourceController.CurrentInnerMaxDegreeOfParallelism) + : Convolver.ConvolveSequential(item.Image, kernel, borderMode); + + await WriteMeteredAsync( + writer, + writeQueue, + new ConvolvedImage(item.IndexedJob, output), + cancellationSource.Token).ConfigureAwait(false); + } + } + catch (Exception e) + { + cancellationSource.Cancel(); + writer.TryComplete(e); + throw; + } + } + + private static async Task RunWriterAsync( + ChannelReader reader, + PipelineQueueMeter writeQueue, + ConcurrentBag results, + ManagedConcurrencyGate gate, + CancellationTokenSource cancellationSource) + { + try + { + while (await reader.WaitToReadAsync(cancellationSource.Token).ConfigureAwait(false)) + { + using var lease = await gate.AcquireAsync(cancellationSource.Token).ConfigureAwait(false); + if (!reader.TryRead(out var item)) + continue; + + writeQueue.Decrement(); + var directory = Path.GetDirectoryName(item.IndexedJob.Job.OutputPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + GrayImageIo.SaveGrayAsBmp(item.Image, item.IndexedJob.Job.OutputPath); + + results.Add(new IndexedResult( + item.IndexedJob.Index, + new ImageConvolutionFileResult( + item.IndexedJob.Job.InputPath, + item.IndexedJob.Job.OutputPath, + item.Image.Width, + item.Image.Height))); + } + } + catch + { + cancellationSource.Cancel(); + throw; + } + } + + private static async ValueTask WriteMeteredAsync( + ChannelWriter writer, + PipelineQueueMeter meter, + T item, + CancellationToken cancellationToken) + { + meter.Increment(); + try + { + await writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); + } + catch + { + meter.Decrement(); + throw; + } + } + + private static Task[] StartWorkers(int count, Func worker) + { + var tasks = new Task[count]; + for (var i = 0; i < tasks.Length; i++) + tasks[i] = Task.Run(worker); + + return tasks; + } + + private static async Task CompleteAfterWorkersAsync( + Task[] workers, + ChannelWriter writer, + CancellationTokenSource cancellationSource) + { + try + { + await Task.WhenAll(workers).ConfigureAwait(false); + writer.TryComplete(); + } + catch (Exception e) + { + cancellationSource.Cancel(); + writer.TryComplete(e); + throw; + } + } + + private static BoundedChannelOptions BoundedOptions(int capacity) => new (capacity) + { + FullMode = BoundedChannelFullMode.Wait + }; + + private static void ValidateJob(ImageConvolutionJob job) + { + ArgumentNullException.ThrowIfNull(job); + if (string.IsNullOrWhiteSpace(job.InputPath)) + throw new ArgumentException("Input path must not be empty.", nameof(job)); + if (string.IsNullOrWhiteSpace(job.OutputPath)) + throw new ArgumentException("Output path must not be empty.", nameof(job)); + } + + private sealed record IndexedJob(int Index, ImageConvolutionJob Job); + private sealed record LoadedImage(IndexedJob IndexedJob, GrayImage Image); + private sealed record ConvolvedImage(IndexedJob IndexedJob, GrayImage Image); + private sealed record IndexedResult(int Index, ImageConvolutionFileResult Result); +} diff --git a/src/BMPConvolver.Core/Pipeline/Scheduling/ManagedConcurrencyGate.cs b/src/BMPConvolver.Core/Pipeline/Scheduling/ManagedConcurrencyGate.cs new file mode 100644 index 0000000..230d8bd --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Scheduling/ManagedConcurrencyGate.cs @@ -0,0 +1,122 @@ +namespace BMPConvolver.Core.Pipeline; + +// A semaphore-like gate whose active concurrency limit can change at runtime. +internal sealed class ManagedConcurrencyGate +{ + private readonly object _sync = new(); + private readonly Queue _waiters = new(); + private readonly int _maxLimit; + private int _activeCount; + private int _limit; + + public ManagedConcurrencyGate(int initialLimit, int maxLimit) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialLimit); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLimit); + ArgumentOutOfRangeException.ThrowIfGreaterThan(initialLimit, maxLimit); + + _limit = initialLimit; + _maxLimit = maxLimit; + } + + public int Limit + { + get + { + lock (_sync) + return _limit; + } + } + + // Acquires one active worker slot, or waits until the current limit allows it. + public ValueTask AcquireAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + if (_activeCount < _limit) + { + _activeCount++; + return ValueTask.FromResult(new Lease(this)); + } + + var waiter = new Waiter(cancellationToken); + _waiters.Enqueue(waiter); + return new ValueTask(waiter.Task); + } + } + + // Changes how many workers may be active at the same time. + public void SetLimit(int limit) + { + lock (_sync) + { + _limit = Math.Clamp(limit, 1, _maxLimit); + DrainWaiters(); + } + } + + private void Release() + { + lock (_sync) + { + _activeCount--; + DrainWaiters(); + } + } + + // Wakes queued waiters while there is free capacity under the current limit. + private void DrainWaiters() + { + while (_activeCount < _limit && _waiters.Count > 0) + { + var waiter = _waiters.Dequeue(); + if (waiter.IsCompleted) + continue; + + _activeCount++; + if (!waiter.TrySetResult(new Lease(this))) + _activeCount--; + } + } + + // Holds one acquired slot and releases it automatically from a using block. + public readonly struct Lease : IDisposable + { + private readonly ManagedConcurrencyGate? _gate; + + internal Lease(ManagedConcurrencyGate gate) + { + _gate = gate; + } + + public void Dispose() + => _gate?.Release(); + } + + // Represents a worker waiting for a future slot. + private sealed class Waiter + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Waiter(CancellationToken cancellationToken) + { + if (cancellationToken.CanBeCanceled) + { + cancellationToken.Register(static state => + { + var waiter = (Waiter)state!; + waiter._completion.TrySetCanceled(); + }, this); + } + } + + public Task Task => _completion.Task; + public bool IsCompleted => _completion.Task.IsCompleted; + + public bool TrySetResult(Lease lease) + => _completion.TrySetResult(lease); + } +} diff --git a/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineQueueMeter.cs b/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineQueueMeter.cs new file mode 100644 index 0000000..03b1651 --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineQueueMeter.cs @@ -0,0 +1,25 @@ +namespace BMPConvolver.Core.Pipeline; + +// Tracks a bounded channel's approximate fill level for balancing decisions. +internal sealed class PipelineQueueMeter +{ + private int _count; + + public PipelineQueueMeter(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _capacity = capacity; + } + + private readonly int _capacity; + + public int Count => Volatile.Read(ref _count); + + public double FillRatio => Math.Clamp((double)Count / _capacity, 0.0, 1.0); + + public void Increment() + => Interlocked.Increment(ref _count); + + public void Decrement() + => Interlocked.Decrement(ref _count); +} diff --git a/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineResourceController.cs b/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineResourceController.cs new file mode 100644 index 0000000..505daaf --- /dev/null +++ b/src/BMPConvolver.Core/Pipeline/Scheduling/PipelineResourceController.cs @@ -0,0 +1,156 @@ +namespace BMPConvolver.Core.Pipeline; + +// Chooses initial pipeline resources and rebalances active workers using queue pressure. +internal sealed class PipelineResourceController +{ + private readonly bool _canBalanceReaders; + private readonly bool _canBalanceConvolvers; + private readonly bool _canBalanceWriters; + private readonly int _processorCount; + private static readonly TimeSpan BalancingInterval = TimeSpan.FromMilliseconds(100); + + public int JobQueueCapacity { get; } + public int ReadQueueCapacity { get; } + public int WriteQueueCapacity { get; } + public int MaxReaderCount { get; } + public int MaxConvolverCount { get; } + public int MaxWriterCount { get; } + public bool UseParallelConvolution { get; } + + public ManagedConcurrencyGate Readers { get; } + public ManagedConcurrencyGate Convolvers { get; } + public ManagedConcurrencyGate Writers { get; } + + public PipelineResourceController( + BatchConvolutionOptions options, + int processorCount) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + + _processorCount = Math.Max(1, processorCount); + + var readerCount = DefaultIoWorkerCount(_processorCount); + var writerCount = DefaultIoWorkerCount(_processorCount); + int convolverCount = options.UseParallelConvolution + ? DefaultConvolverCount(_processorCount) + : _processorCount; + + JobQueueCapacity = Math.Max(1, readerCount * 2); + ReadQueueCapacity = DefaultImageQueueCapacity(convolverCount); + WriteQueueCapacity = DefaultImageQueueCapacity(Math.Max(convolverCount, writerCount)); + UseParallelConvolution = options.UseParallelConvolution; + + var maxReaderCountCandidate = Math.Max(readerCount, Math.Min(4, _processorCount)); + var maxWriterCountCandidate = Math.Max(writerCount, Math.Min(4, _processorCount)); + var maxConvolverCountCandidate = _processorCount; + + _canBalanceReaders = maxReaderCountCandidate > 1; + _canBalanceWriters = maxWriterCountCandidate > 1; + _canBalanceConvolvers = maxConvolverCountCandidate > 1; + + MaxReaderCount = _canBalanceReaders ? maxReaderCountCandidate : readerCount; + MaxConvolverCount = _canBalanceConvolvers ? maxConvolverCountCandidate : convolverCount; + MaxWriterCount = _canBalanceWriters ? maxWriterCountCandidate : writerCount; + + Readers = new ManagedConcurrencyGate(readerCount, MaxReaderCount); + Convolvers = new ManagedConcurrencyGate(convolverCount, MaxConvolverCount); + Writers = new ManagedConcurrencyGate(writerCount, MaxWriterCount); + } + + public int? CurrentInnerMaxDegreeOfParallelism => + UseParallelConvolution + ? Math.Max(1, _processorCount / Convolvers.Limit) + : null; + + // Periodically adjusts stage gates based on queue fill ratios. + public async Task BalanceAsync( + PipelineQueueMeter sourceQueue, + PipelineQueueMeter readQueue, + PipelineQueueMeter writeQueue, + CancellationToken cancellationToken) + { + if (!_canBalanceReaders && !_canBalanceConvolvers && !_canBalanceWriters) + return; + + while (!cancellationToken.IsCancellationRequested) + { + BalanceOnce(sourceQueue, readQueue, writeQueue); + await Task.Delay(BalancingInterval, cancellationToken).ConfigureAwait(false); + } + } + + // One balancing step, kept separate so the policy is easy to inspect. + internal void BalanceOnce( + PipelineQueueMeter sourceQueue, + PipelineQueueMeter readQueue, + PipelineQueueMeter writeQueue) + { + if (_canBalanceWriters) + BalanceWriters(writeQueue); + + if (_canBalanceReaders) + BalanceReaders(sourceQueue, readQueue); + + if (_canBalanceConvolvers) + BalanceConvolvers(sourceQueue, readQueue, writeQueue); + } + + // Adds writers when completed images start piling up. + private void BalanceWriters(PipelineQueueMeter writeQueue) + { + var limit = Writers.Limit; + + if (writeQueue.FillRatio >= 0.50) + limit++; + else if (writeQueue.FillRatio <= 0.10) + limit--; + + Writers.SetLimit(limit); + } + + // Slows reading when loaded images are already waiting. + private void BalanceReaders(PipelineQueueMeter sourceQueue, PipelineQueueMeter readQueue) + { + var limit = Readers.Limit; + + if (readQueue.FillRatio >= 0.75) + limit--; + else if (sourceQueue.Count > 0 && readQueue.FillRatio <= 0.25) + limit++; + + Readers.SetLimit(limit); + } + + // Moves CPU capacity toward or away from convolution depending on bottlenecks. + private void BalanceConvolvers( + PipelineQueueMeter sourceQueue, + PipelineQueueMeter readQueue, + PipelineQueueMeter writeQueue) + { + var limit = Convolvers.Limit; + + if (writeQueue.FillRatio >= 0.75) + limit--; + else if (readQueue.FillRatio >= 0.50 && writeQueue.FillRatio <= 0.50) + limit++; + else if (readQueue.Count == 0 && sourceQueue.Count == 0) + limit--; + + Convolvers.SetLimit(limit); + } + + private static int DefaultIoWorkerCount(int processorCount) + => processorCount >= 4 ? 2 : 1; + + private static int DefaultImageQueueCapacity(int consumerCount) + => Math.Max(1, Math.Min(8, consumerCount * 2)); + + private static int DefaultConvolverCount(int processorCount) + => processorCount switch + { + <= 2 => 1, + <= 8 => 2, + _ => Math.Min(4, processorCount / 4) + }; +} diff --git a/src/BMPConvolver.Core/WorkPartitioning/Partitioner.cs b/src/BMPConvolver.Core/WorkPartitioning/Partitioner.cs new file mode 100644 index 0000000..adfae9d --- /dev/null +++ b/src/BMPConvolver.Core/WorkPartitioning/Partitioner.cs @@ -0,0 +1,70 @@ +namespace BMPConvolver.Core.WorkPartitioning; + +public static class Partitioner +{ + public static IEnumerable Create(int width, int height, PartitioningMode mode, int gridX = 1, int gridY = 1) + { + ArgumentNullException.ThrowIfNull(width); + ArgumentNullException.ThrowIfNull(height); + + return mode switch + { + PartitioningMode.Pixels => Pixels(width, height), + PartitioningMode.Rows => Rows(width, height), + PartitioningMode.Columns => Columns(width, height), + PartitioningMode.Grid => Grid(width, height, gridX, gridY), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown partitioning mode."), + }; + } + + private static IEnumerable Pixels(int width, int height) + { + // One pixel per task is intentionally "bad" for overhead and cache locality, + // but it's useful to study the effects the user asked about. + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + yield return new WorkRect(x, y, 1, 1); + } + + private static IEnumerable Rows(int width, int height) + { + for (var y = 0; y < height; y++) + yield return new WorkRect(0, y, width, 1); + } + + private static IEnumerable Columns(int width, int height) + { + for (var x = 0; x < width; x++) + yield return new WorkRect(x, 0, 1, height); + } + + private static IEnumerable Grid(int width, int height, int gridX, int gridY) + { + ArgumentNullException.ThrowIfNull(gridX); + ArgumentNullException.ThrowIfNull(gridY); + + gridX = Math.Min(gridX, width); + gridY = Math.Min(gridY, height); + + var baseWidth = width / gridX; + var remWidth = width % gridX; + var baseHeight = height / gridY; + var remHeight = height % gridY; + + var curY = 0; + for (var gy = 0; gy < gridY; gy++) + { + var squareHeight = baseHeight + (gy < remHeight ? 1 : 0); // первые remHeight блоков выше на 1 пиксель + var curX = 0; + for (var gx = 0; gx < gridX; gx++) + { + var squareWidth = baseWidth + (gx < remWidth ? 1 : 0); // первые remWidth блоков шире на 1 пиксель + yield return new WorkRect(curX, curY, squareWidth, squareHeight); + + curX += squareWidth; + } + curY += squareHeight; + } + } +} + diff --git a/src/BMPConvolver.Core/WorkPartitioning/PartitioningMode.cs b/src/BMPConvolver.Core/WorkPartitioning/PartitioningMode.cs new file mode 100644 index 0000000..2663a99 --- /dev/null +++ b/src/BMPConvolver.Core/WorkPartitioning/PartitioningMode.cs @@ -0,0 +1,10 @@ +namespace BMPConvolver.Core.WorkPartitioning; + +public enum PartitioningMode +{ + Pixels = 0, + Rows = 1, + Columns = 2, + Grid = 3, +} + diff --git a/src/BMPConvolver.Core/WorkPartitioning/WorkRect.cs b/src/BMPConvolver.Core/WorkPartitioning/WorkRect.cs new file mode 100644 index 0000000..3f9ce36 --- /dev/null +++ b/src/BMPConvolver.Core/WorkPartitioning/WorkRect.cs @@ -0,0 +1,8 @@ +namespace BMPConvolver.Core.WorkPartitioning; + +public readonly record struct WorkRect(int X, int Y, int Width, int Height) +{ + public int X2Exclusive => X + Width; + public int Y2Exclusive => Y + Height; +} + 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/CoreTestHelpers.cs b/test/BMPConvolver.Tests/CoreTests/CoreTestHelpers.cs new file mode 100644 index 0000000..e18562f --- /dev/null +++ b/test/BMPConvolver.Tests/CoreTests/CoreTestHelpers.cs @@ -0,0 +1,54 @@ +using BMPConvolver.Core; + +namespace BMPConvolver.Tests.CoreTests; + +internal static class CoreTestHelpers +{ + public static GrayImage RandomImage(int width, int height, int seed = 1) + { + 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); + } + + public static Kernel RandomKernelOdd(int maxSize, int seed = 1) + { + 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, seed); + } + + public static Kernel RandomKernelOddFrom(int wRequested, int hRequested, int seed = 1) + { + 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); + } + + public 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})"); + } + } +} diff --git a/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs b/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs new file mode 100644 index 0000000..2edf3b6 --- /dev/null +++ b/test/BMPConvolver.Tests/CoreTests/CoreUnitTests.cs @@ -0,0 +1,378 @@ +using BMPConvolver.Core; +using BMPConvolver.Core.WorkPartitioning; +using OpenCvSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using static BMPConvolver.Tests.CoreTests.CoreTestHelpers; + +namespace BMPConvolver.Tests.CoreTests; + +public class CoreTests +{ + 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(1, 1, BorderMode.Zero)] + [InlineData(2, 7, BorderMode.Zero)] + [InlineData(13, 9, BorderMode.Zero)] + [InlineData(64, 64, BorderMode.Zero)] + [InlineData(127, 33, BorderMode.Zero)] + [InlineData(128, 128, BorderMode.Zero)] + [InlineData(400, 200, BorderMode.Zero)] + [InlineData(1, 1, BorderMode.Clamp)] + [InlineData(2, 7, BorderMode.Clamp)] + [InlineData(13, 9, BorderMode.Clamp)] + [InlineData(64, 64, BorderMode.Clamp)] + [InlineData(127, 33, BorderMode.Clamp)] + [InlineData(128, 128, BorderMode.Clamp)] + [InlineData(400, 200, BorderMode.Clamp)] + public void Parallel_EqualsSequential_PixelsPartitioning(int width, int height, BorderMode borderMode) + { + var kernel = RandomKernelOdd(maxSize: 9); + + var img = RandomImage(width, height); + var seq = Convolver.ConvolveSequential(img, kernel, borderMode); + + var parPixel = Convolver.ConvolveParallel(img, kernel, borderMode, PartitioningMode.Pixels); + + AssertImagesEqual(seq, parPixel); + } + + [Theory] + [InlineData(1, 1, BorderMode.Zero)] + [InlineData(2, 7, BorderMode.Zero)] + [InlineData(13, 9, BorderMode.Zero)] + [InlineData(64, 64, BorderMode.Zero)] + [InlineData(127, 33, BorderMode.Zero)] + [InlineData(128, 128, BorderMode.Zero)] + [InlineData(400, 200, BorderMode.Zero)] + [InlineData(1, 1, BorderMode.Clamp)] + [InlineData(2, 7, BorderMode.Clamp)] + [InlineData(13, 9, BorderMode.Clamp)] + [InlineData(64, 64, BorderMode.Clamp)] + [InlineData(127, 33, BorderMode.Clamp)] + [InlineData(128, 128, BorderMode.Clamp)] + [InlineData(400, 200, BorderMode.Clamp)] + public void Parallel_EqualsSequential_ForRowsPartitioning(int width, int height, BorderMode borderMode) + { + var kernel = RandomKernelOdd(maxSize: 9); + + var img = RandomImage(width, height); + var seq = Convolver.ConvolveSequential(img, kernel, borderMode); + var parRows = Convolver.ConvolveParallel(img, kernel, borderMode, PartitioningMode.Rows); + + AssertImagesEqual(seq, parRows); + } + + [Theory] + [InlineData(1, 1, BorderMode.Zero)] + [InlineData(2, 7, BorderMode.Zero)] + [InlineData(13, 9, BorderMode.Zero)] + [InlineData(64, 64, BorderMode.Zero)] + [InlineData(127, 33, BorderMode.Zero)] + [InlineData(128, 128, BorderMode.Zero)] + [InlineData(400, 200, BorderMode.Zero)] + [InlineData(1, 1, BorderMode.Clamp)] + [InlineData(2, 7, BorderMode.Clamp)] + [InlineData(13, 9, BorderMode.Clamp)] + [InlineData(64, 64, BorderMode.Clamp)] + [InlineData(127, 33, BorderMode.Clamp)] + [InlineData(128, 128, BorderMode.Clamp)] + [InlineData(400, 200, BorderMode.Clamp)] + public void Parallel_EqualsSequential_ForColumnsPartitioning(int width, int height, BorderMode borderMode) + { + var kernel = RandomKernelOdd(maxSize: 9); + + var img = RandomImage(width, height); + var seq = Convolver.ConvolveSequential(img, kernel, borderMode); + var parCols = Convolver.ConvolveParallel(img, kernel, borderMode, PartitioningMode.Columns); + + AssertImagesEqual(seq, parCols); + } + + [Theory] + [InlineData(1, 1, BorderMode.Zero)] + [InlineData(2, 7, BorderMode.Zero)] + [InlineData(13, 9, BorderMode.Zero)] + [InlineData(64, 64, BorderMode.Zero)] + [InlineData(127, 33, BorderMode.Zero)] + [InlineData(128, 128, BorderMode.Zero)] + [InlineData(400, 200, BorderMode.Zero)] + [InlineData(1, 1, BorderMode.Clamp)] + [InlineData(2, 7, BorderMode.Clamp)] + [InlineData(13, 9, BorderMode.Clamp)] + [InlineData(64, 64, BorderMode.Clamp)] + [InlineData(127, 33, BorderMode.Clamp)] + [InlineData(128, 128, BorderMode.Clamp)] + [InlineData(400, 200, BorderMode.Clamp)] + public void Parallel_EqualsSequential_ForGridPartitioning(int width, int height, BorderMode borderMode) + { + var kernel = RandomKernelOdd(maxSize: 9); + + var img = RandomImage(width, height); + var seq = Convolver.ConvolveSequential(img, kernel, borderMode); + var parGrid = Convolver.ConvolveParallel(img, kernel, borderMode, PartitioningMode.Grid, gridX: 8, gridY: 8); + + AssertImagesEqual(seq, parGrid); + } + + [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 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); + } +} diff --git a/test/BMPConvolver.Tests/CoreTests/PipelineUnitTests.cs b/test/BMPConvolver.Tests/CoreTests/PipelineUnitTests.cs new file mode 100644 index 0000000..9a70a8b --- /dev/null +++ b/test/BMPConvolver.Tests/CoreTests/PipelineUnitTests.cs @@ -0,0 +1,153 @@ +using BMPConvolver.Core; +using BMPConvolver.Core.ImageSharp; +using BMPConvolver.Core.Pipeline; +using BMPConvolver.Core.WorkPartitioning; +using static BMPConvolver.Tests.CoreTests.CoreTestHelpers; + +namespace BMPConvolver.Tests.CoreTests; + +public class PipelineUnitTests +{ + [Theory] + [InlineData(1, 1, false)] + [InlineData(2, 7, false)] + [InlineData(13, 9, false)] + [InlineData(64, 33, false)] + [InlineData(28, 64, false)] + [InlineData(192, 128, false)] + [InlineData(1, 1, true)] + [InlineData(2, 7, true)] + [InlineData(13, 9, true)] + [InlineData(64, 33, true)] + [InlineData(28, 64, true)] + [InlineData(192, 128, true)] + public async Task Pipeline_ProcessesMultipleImages_WithBoundedQueues(int width, int height, bool useParallelConvolution) + { + var root = Path.Combine(Path.GetTempPath(), "BMPConvolver.Tests", Guid.NewGuid().ToString("N")); + var inputDir = Path.Combine(root, "input"); + var outputDir = Path.Combine(root, "output"); + + try + { + Directory.CreateDirectory(inputDir); + + var inputPaths = Enumerable.Range(0, 4) + .Select(i => + { + var path = Path.Combine(inputDir, $"img_{i}.bmp"); + GrayImageIo.SaveGrayAsBmp(RandomImage(width, height, seed: i), path); + return path; + }) + .ToArray(); + + var jobs = inputPaths.Select(path => new ImageConvolutionJob( + path, + Path.Combine(outputDir, Path.GetFileName(path)))); + + var options = new BatchConvolutionOptions + { + UseParallelConvolution = useParallelConvolution, + PartitioningMode = PartitioningMode.Grid, + GridX = 2, + GridY = 2 + }; + + var result = await ImageConvolutionPipeline.ProcessAsync( + jobs, + Kernel.Identity(), + BorderMode.Zero, + options); + + Assert.Equal(inputPaths.Length, result.Count); + + foreach (var inputPath in inputPaths) + { + var outputPath = Path.Combine(outputDir, Path.GetFileName(inputPath)); + Assert.True(File.Exists(outputPath), $"Output file was not created: {outputPath}"); + + var expected = GrayImageIo.LoadAsGray(inputPath); + var actual = GrayImageIo.LoadAsGray(outputPath); + AssertImagesEqual(expected, actual); + } + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 7)] + [InlineData(13, 9)] + [InlineData(64, 33)] + [InlineData(28, 64)] + [InlineData(192, 128)] + public async Task PipelineConvolution_Equals_DirectConvolution( + int width, + int height) + { + var root = Path.Combine(Path.GetTempPath(), "BMPConvolver.Tests", Guid.NewGuid().ToString("N")); + var inputDir = Path.Combine(root, "input"); + var outputDir = Path.Combine(root, "output"); + var expectedDir = Path.Combine(root, "expected"); + + try + { + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(expectedDir); + + var inputPaths = Enumerable.Range(0, 4) + .Select(i => + { + var path = Path.Combine(inputDir, $"img_{i}.bmp"); + GrayImageIo.SaveGrayAsBmp(RandomImage(width, height, seed: i), path); + return path; + }) + .ToArray(); + + var jobs = inputPaths.Select(path => new ImageConvolutionJob( + path, + Path.Combine(outputDir, Path.GetFileName(path)))); + + var kernel = RandomKernelOdd(5); + var options = new BatchConvolutionOptions + { + UseParallelConvolution = false, + }; + + var result = await ImageConvolutionPipeline.ProcessAsync( + jobs, + kernel, + BorderMode.Clamp, + options); + + Assert.Equal(inputPaths.Length, result.Count); + + foreach (var inputPath in inputPaths) + { + var expectedPath = Path.Combine(expectedDir, Path.GetFileName(inputPath)); + var outputPath = Path.Combine(outputDir, Path.GetFileName(inputPath)); + Assert.True(File.Exists(outputPath), $"Output file was not created: {outputPath}"); + + var input = GrayImageIo.LoadAsGray(inputPath); + var expected = Convolver.ConvolveSequential( + input, + kernel, + BorderMode.Clamp); + + GrayImageIo.SaveGrayAsBmp(expected, expectedPath); + + var expectedRoundTripped = GrayImageIo.LoadAsGray(expectedPath); + var actual = GrayImageIo.LoadAsGray(outputPath); + AssertImagesEqual(expectedRoundTripped, actual); + } + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +}