diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..30bd42b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Build & Run tests + +on: + workflow_dispatch: + pull_request: + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: zulu + cache: gradle + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v2 + + - name: Build (without tests) + run: ./gradlew assemble + + - name: Run tests + run: ./gradlew test --stacktrace + + - name: Check formatting + run: ./gradlew ktlintCheck diff --git a/README.md b/README.md index 3618963..66faa39 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,93 @@ -# image-convolution +# Image Convolution + +A command-line tool for applying 2D convolution filters to images or datasets. Supports multiple parallelization strategies and buffered pipelines for efficient image processing. + +## Features + +* Apply predefined filters (blur, sharpen, edge, etc.) to single images or directories. +* Multiple convolution strategies: + + * **SERIAL** – single-threaded processing + * **PIXELWISE** – parallelized per pixel + * **COLUMNS** – parallelized per image column + * **ROWS** – parallelized per image row + * **ALLPROCESSORS** – parallelized using all available CPU cores +* Configurable channel buffers for dataset pipelines to balance memory usage and processing speed. +* Outputs processed images and optional benchmarking CSV and chart. + +## Installation + +1. Clone the repository: + +```bash +git clone https://github.com/shakareem/image-convolution.git +cd image-convolution +``` + +2. Build the project using Gradle: + +```bash +./gradlew build +``` + +1. Run the tool via Gradle: + +```bash +./gradlew run --args="..." +``` + +## Usage + +### CLI Arguments + +```text +-i, --input Input file or directory (required) +-o, --output Output file or directory (required) +-f, --filter Filter name (required, available: blur, sharpen, edge, etc.) +-m, --mode Convolution mode (SERIAL, PIXELWISE, COLUMNS, ROWS, ALLPROCESSORS, default ALLPROCESSORS) +-b, --buffer Channel buffer size for dataset processing (optional, default 8) +``` + +### Examples + +Apply a blur filter to a single image: + +```bash +./gradlew run --args="-i input.bmp -o output.bmp -f blur -m ALLPROCESSORS" +``` + +Apply a sharpen filter to an entire directory with buffer size 16: + +```bash +./gradlew run --args="-i images/ -o processed/ -f sharpen -m PIXELWISE -b 16" +``` + +## Benchmarking + +The tool includes a benchmark script (`Benchmark.kt`) that measures the performance of different convolution modes with varying buffer sizes. +It outputs: + +* `benchmark.csv` – average processing time per mode and buffer +* `benchmark.png` – bar chart visualizing the results + +Running benchmark: +```bash +./gradlew benchmark +``` + +## Benchmark Results + +![Benchmark](docs/benchmark.png) + +> Note: Speedup depends on image size, number of images, and number of available CPU cores. Parallel strategies show most benefit on larger images. + +## Dependencies + +* Kotlin Standard Library +* kotlinx-coroutines +* XChart for benchmark plotting + +## License + +MIT License – see `LICENSE` file. -A tool for performing serial and parallel image convolution. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd0849a..ff6df99 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "1.9.25" application + id("org.jlleitschuh.gradle.ktlint") version "11.5.0" } repositories { @@ -9,9 +10,33 @@ repositories { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") - implementation("org.bytedeco:opencv-platform:4.10.0-1.5.11") + implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.6") + implementation("org.knowm.xchart:xchart:3.8.2") + implementation("com.twelvemonkeys.imageio:imageio-bmp:3.9.4") + implementation("com.twelvemonkeys.imageio:imageio-core:3.9.4") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.0") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.11.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.0") } application { mainClass.set("AppKt") } + +tasks.register("fmt") { + group = "formatting" + description = "Format Kotlin code with ktlint" + dependsOn("ktlintFormat") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.register("benchmark") { + group = "benchmark" + description = "Run image processing benchmark" + + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("BenchmarkKt") +} diff --git a/app/src/main/kotlin/App.kt b/app/src/main/kotlin/App.kt index 277db96..1e59cca 100644 --- a/app/src/main/kotlin/App.kt +++ b/app/src/main/kotlin/App.kt @@ -1,3 +1,58 @@ -fun main() { - println("Hello, World!") +import filters.FILTERS +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.cli.default +import kotlinx.cli.required +import pipeline.Mode +import pipeline.processDataset +import pipeline.processSingleFile +import java.io.File + +fun main(args: Array) { + val parser = ArgParser("image-convolver") + + val input by parser.option( + ArgType.String, + shortName = "i", + description = "Input file or directory" + ).required() + + val output by parser.option( + ArgType.String, + shortName = "o", + description = "Output directory" + ).default(".") + + val filterName by parser.option( + ArgType.String, + shortName = "f", + description = "Filter name: ${FILTERS.keys.joinToString()}" + ).required() + + val mode by parser.option( + ArgType.Choice(), + shortName = "m", + description = "Mode: SERIAL, PIXELWISE, COLUMNS, ROWS, ALLPROCESSORS" + ).default(Mode.ALLPROCESSORS) + + val bufferSize by parser.option( + ArgType.Int, + shortName = "b", + description = "Buffer size for channels" + ).default(8) + + parser.parse(args) + + val kernel = FILTERS[filterName] ?: error("Unknown filter: $filterName") + + val inputFile = File(input) + val outputFile = File(output) + + if (inputFile.isFile) { + processSingleFile(inputFile, outputFile, kernel, mode) + } else if (inputFile.isDirectory) { + processDataset(inputFile, outputFile, kernel, mode, bufferSize) + } else { + error("Input path is not a file or directory: $input") + } } diff --git a/app/src/main/kotlin/Benchmark.kt b/app/src/main/kotlin/Benchmark.kt new file mode 100644 index 0000000..84df8be --- /dev/null +++ b/app/src/main/kotlin/Benchmark.kt @@ -0,0 +1,107 @@ + +import org.knowm.xchart.BitmapEncoder +import org.knowm.xchart.CategoryChartBuilder +import org.knowm.xchart.style.Styler +import pipeline.Mode +import pipeline.processDataset +import java.io.File +import java.nio.file.Paths +import kotlin.system.measureTimeMillis + +fun diagonalMatrix(size: Int): Array { + return Array(size) { row -> + DoubleArray(size) { col -> + if (row == col) 1.0 else 0.0 + } + } +} + +fun main() { + val projectDir = Paths.get("").toAbsolutePath().toString() + val inputDir = File(projectDir, "src/test/resources") + if (!inputDir.exists() || inputDir.listFiles().isNullOrEmpty()) { + error("Input directory not found or empty: ${inputDir.absolutePath}") + } + val outputDir = File("build/benchmark_output") + if (!outputDir.exists()) outputDir.mkdirs() + + val docsDir = File("../docs") + if (!docsDir.exists()) docsDir.mkdirs() + + val kernel = diagonalMatrix(11) + + val modes = Mode.values().toList() + val bufferSizes = listOf(1, 4, 8, 16, 32) + val repeats = 5 + + val results = mutableListOf>() + + for (mode in modes) { + if (mode == Mode.PIXELWISE) { + continue + } else if (mode == Mode.SERIAL) { + val times = mutableListOf() + repeat(repeats) { + val time = measureTimeMillis { + processDataset(inputDir, outputDir, kernel, mode, 1) + } + println("Run ${it + 1}: $time ms") + times.add(time) + } + val avgTime = times.average().toLong() + println("Average: $avgTime ms\n") + results.add(Triple(mode.name, -1, avgTime)) + continue + } + + for (buffer in bufferSizes) { + println("Benchmarking $mode with buffer $buffer ...") + val times = mutableListOf() + repeat(repeats) { + val time = measureTimeMillis { + processDataset(inputDir, outputDir, kernel, mode, buffer) + } + println("Run ${it + 1}: $time ms") + times.add(time) + } + val avgTime = times.average().toLong() + println("Average: $avgTime ms\n") + results.add(Triple(mode.name, buffer, avgTime)) + } + } + + val csvFile = File(docsDir, "benchmark.csv") + csvFile.printWriter().use { out -> + out.println("mode,buffer,time_ms") + results.forEach { (mode, buffer, time) -> + val bufferStr = if (buffer == -1) "" else buffer.toString() + out.println("$mode,$bufferStr,$time") + } + } + + val chart = CategoryChartBuilder().width(1000).height(600) + .title("Image Processing Benchmark") + .xAxisTitle("Mode") + .yAxisTitle("Time (ms)") + .build() + + chart.styler.legendPosition = Styler.LegendPosition.InsideNE + + val allModes = results.map { it.first }.distinct() + + val serialTimes = allModes.map { mode -> + results.find { it.first == "SERIAL" }?.third?.toDouble() + ?.takeIf { mode == "SERIAL" } ?: Double.NaN + } + chart.addSeries("SERIAL", allModes, serialTimes) + + bufferSizes.forEach { buffer -> + val times = allModes.map { mode -> + results.find { it.first == mode && it.second == buffer }?.third?.toDouble() ?: Double.NaN + } + chart.addSeries("buffer=$buffer", allModes, times) + } + + BitmapEncoder.saveBitmap(chart, File(docsDir, "benchmark.png").path, BitmapEncoder.BitmapFormat.PNG) + println("Benchmark completed. CSV saved to benchmark.csv, graph saved to benchmark.png") +} diff --git a/app/src/main/kotlin/convolution/ParallelConvolution.kt b/app/src/main/kotlin/convolution/ParallelConvolution.kt new file mode 100644 index 0000000..804e276 --- /dev/null +++ b/app/src/main/kotlin/convolution/ParallelConvolution.kt @@ -0,0 +1,96 @@ +package convolution + +import images.Bitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.min + +suspend fun pixelwiseConvolve(image: Bitmap, kernel: Bitmap) = parallelConvolve(image, kernel) { output, k -> + pixelwiseStrategy(image, k, output) +} + +suspend fun columnsConvolve(image: Bitmap, kernel: Bitmap) = parallelConvolve(image, kernel) { output, k -> + columnsStrategy(image, k, output) +} + +suspend fun rowsConvolve(image: Bitmap, kernel: Bitmap) = parallelConvolve(image, kernel) { output, k -> + rowsStrategy(image, k, output) +} + +suspend fun allProcessorsConvolve(image: Bitmap, kernel: Bitmap) = parallelConvolve(image, kernel) { output, k -> + allProcessorsStrategy(image, k, output) +} + +private suspend fun parallelConvolve( + image: Bitmap, + kernel: Bitmap, + dispatchStrategy: suspend (output: Bitmap, kernel: Bitmap) -> Unit +): Bitmap { + require(kernel.size % 2 == 1 && kernel[0].size % 2 == 1) { "Kernel dimensions must be odd" } + val output = Array(image.size) { DoubleArray(image[0].size) } + coroutineScope { + dispatchStrategy(output, kernel) + } + return output +} + +private suspend fun pixelwiseStrategy(image: Bitmap, kernel: Bitmap, output: Bitmap) { + coroutineScope { + for (y in 0 until image.size) { + for (x in 0 until image[0].size) { + launch(Dispatchers.Default) { + output[y][x] = convolvePixel(image, kernel, y, x) + } + } + } + } +} + +private suspend fun columnsStrategy(image: Bitmap, kernel: Bitmap, output: Bitmap) { + coroutineScope { + for (y in 0 until image.size) { + launch(Dispatchers.Default) { + for (x in 0 until image[0].size) { + output[y][x] = convolvePixel(image, kernel, y, x) + } + } + } + } +} + +private suspend fun rowsStrategy(image: Bitmap, kernel: Bitmap, output: Bitmap) { + coroutineScope { + for (x in 0 until image[0].size) { + launch(Dispatchers.Default) { + for (y in 0 until image.size) { + output[y][x] = convolvePixel(image, kernel, y, x) + } + } + } + } +} + +private suspend fun allProcessorsStrategy(image: Bitmap, kernel: Bitmap, output: Bitmap) { + val imageHeight = image.size + val imageWidth = image[0].size + val numProcessors = Runtime.getRuntime().availableProcessors() + val rowsPerChunk = (imageHeight + numProcessors - 1) / numProcessors + + coroutineScope { + for (chunk in 0 until numProcessors) { + val startRow = chunk * rowsPerChunk + val endRow = min(startRow + rowsPerChunk, imageHeight) + + if (startRow <= imageHeight) { + launch(Dispatchers.Default) { + for (y in startRow until endRow) { + for (x in 0 until imageWidth) { + output[y][x] = convolvePixel(image, kernel, y, x) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/convolution/SerialConvolution.kt b/app/src/main/kotlin/convolution/SerialConvolution.kt new file mode 100644 index 0000000..56795ab --- /dev/null +++ b/app/src/main/kotlin/convolution/SerialConvolution.kt @@ -0,0 +1,40 @@ +package convolution + +import images.Bitmap + +fun serialConvolve(image: Bitmap, kernel: Bitmap): Bitmap { + require(kernel.size % 2 == 1 && kernel[0].size % 2 == 1) { "Kernel dimensions must be odd" } + + val imageHeight = image.size + val imageWidth = image[0].size + + val output = Array(imageHeight) { DoubleArray(imageWidth) } + + for (y in 0 until imageHeight) { + for (x in 0 until imageWidth) { + output[y][x] = convolvePixel(image, kernel, y, x) + } + } + + return output +} + +fun convolvePixel(image: Bitmap, kernel: Bitmap, y: Int, x: Int): Double { + val imageHeight = image.size + val imageWidth = image[0].size + val kernelHeight = kernel.size + val kernelWidth = kernel[0].size + + var sum = 0.0 + for (ky in 0 until kernelHeight) { + for (kx in 0 until kernelWidth) { + val imageY = (y - kernelHeight / 2 + ky + imageHeight) % imageHeight + val imageX = (x - kernelWidth / 2 + kx + imageWidth) % imageWidth + + // use reflected kernel (classical discrete convolution, not correlation) + sum += kernel[kernelHeight - 1 - ky][kernelWidth - 1 - kx] * image[imageY][imageX] + } + } + // working with images as math objects, so pixels can be (< 0) or (> 255) + return sum +} diff --git a/app/src/main/kotlin/filters/Filters.kt b/app/src/main/kotlin/filters/Filters.kt new file mode 100644 index 0000000..de8a07a --- /dev/null +++ b/app/src/main/kotlin/filters/Filters.kt @@ -0,0 +1,55 @@ +package filters + +import images.Bitmap + +val BLACK: Bitmap = arrayOf( + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.0, 0.0, 0.0) +) + +val ID: Bitmap = arrayOf( + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.0, 1.0, 0.0), + doubleArrayOf(0.0, 0.0, 0.0) +) + +val SHIFTLEFT: Bitmap = arrayOf( + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(1.0, 0.0, 0.0), + doubleArrayOf(0.0, 0.0, 0.0) +) + +val SHIFTRIGHT: Bitmap = arrayOf( + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.0, 0.0, 1.0), + doubleArrayOf(0.0, 0.0, 0.0) +) + +val SHARPEN: Bitmap = arrayOf( + doubleArrayOf(0.0, -1.0, 0.0), + doubleArrayOf(-1.0, 5.0, -1.0), + doubleArrayOf(0.0, -1.0, 0.0) +) + +val BLUR: Bitmap = arrayOf( + doubleArrayOf(1.0 / 9, 1.0 / 9, 1.0 / 9), + doubleArrayOf(1.0 / 9, 1.0 / 9, 1.0 / 9), + doubleArrayOf(1.0 / 9, 1.0 / 9, 1.0 / 9) +) + +val EDGE: Bitmap = arrayOf( + doubleArrayOf(-1.0, -1.0, -1.0), + doubleArrayOf(-1.0, 8.0, -1.0), + doubleArrayOf(-1.0, -1.0, -1.0) +) + +val FILTERS = mapOf( + "black" to BLACK, + "id" to ID, + "left" to SHIFTLEFT, + "right" to SHIFTRIGHT, + "sharpen" to SHARPEN, + "blur" to BLUR, + "edge" to EDGE +) diff --git a/app/src/main/kotlin/images/ImageReaderWriter.kt b/app/src/main/kotlin/images/ImageReaderWriter.kt new file mode 100644 index 0000000..43111ae --- /dev/null +++ b/app/src/main/kotlin/images/ImageReaderWriter.kt @@ -0,0 +1,40 @@ +package images + +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +typealias Bitmap = Array + +fun readImage(filePath: String): Bitmap { + val img = ImageIO.read(File(filePath)) ?: error("Cannot read image $filePath") + val height = img.height + val width = img.width + val bitmap = Array(height) { DoubleArray(width) } + + for (y in 0 until height) { + for (x in 0 until width) { + val c = Color(img.getRGB(x, y)) + val gray = 0.299 * c.red + 0.587 * c.green + 0.114 * c.blue + bitmap[y][x] = gray + } + } + return bitmap +} + +fun writeImage(image: Bitmap, filePath: String) { + val height = image.size + val width = image[0].size + val out = BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY) + + for (y in 0 until height) { + for (x in 0 until width) { + val gray = image[y][x].coerceIn(0.0, 255.0).toInt() + val rgb = Color(gray, gray, gray).rgb + out.setRGB(x, y, rgb) + } + } + + ImageIO.write(out, "bmp", File(filePath)) +} diff --git a/app/src/main/kotlin/pipeline/Pipeline.kt b/app/src/main/kotlin/pipeline/Pipeline.kt new file mode 100644 index 0000000..07bcb31 --- /dev/null +++ b/app/src/main/kotlin/pipeline/Pipeline.kt @@ -0,0 +1,101 @@ +package pipeline + +import convolution.allProcessorsConvolve +import convolution.columnsConvolve +import convolution.pixelwiseConvolve +import convolution.rowsConvolve +import convolution.serialConvolve +import images.Bitmap +import images.readImage +import images.writeImage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.File + +enum class Mode { SERIAL, PIXELWISE, COLUMNS, ROWS, ALLPROCESSORS } + +class Image(val bitmap: Bitmap, val name: String) + +fun processSingleFile( + file: File, + outDir: File, + kernel: Bitmap, + mode: Mode +) { + println("Processing ${file.path} -> ${outDir.path + file.name} with mode $mode") + if (!outDir.exists()) outDir.mkdirs() + val image = readImage(file.absolutePath) + val result = runBlocking { convolveWithMode(image, kernel, mode) } + writeImage(result, File(outDir, "convolved_" + file.name).path) +} + +fun processDataset( + inDirectory: File, + outDirectory: File, + kernel: Bitmap, + mode: Mode, + bufferSize: Int +) = runBlocking { + val inputImages = readDataset(bufferSize, inDirectory) + val outputImages = processImages(bufferSize, inputImages, kernel, mode) + writeDataset(outDirectory, outputImages) +} + +private suspend fun convolveWithMode( + image: Bitmap, + kernel: Bitmap, + mode: Mode +): Bitmap { + return when (mode) { + Mode.SERIAL -> serialConvolve(image, kernel) + Mode.PIXELWISE -> pixelwiseConvolve(image, kernel) + Mode.COLUMNS -> columnsConvolve(image, kernel) + Mode.ROWS -> rowsConvolve(image, kernel) + Mode.ALLPROCESSORS -> allProcessorsConvolve(image, kernel) + } +} + +// producers close channels when they finish +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +private fun CoroutineScope.readDataset( + bufferSize: Int, + directory: File +) = produce(capacity = bufferSize) { + directory.listFiles { f -> f.isFile }?.forEach { file -> + val image = readImage(file.absolutePath) + send(Image(image, file.name)) + } +} + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +private fun CoroutineScope.processImages( + bufferSize: Int, + images: ReceiveChannel, + kernel: Bitmap, + mode: Mode +) = produce(capacity = bufferSize) { + coroutineScope { // waitgroup + for (image in images) { + launch(Dispatchers.Default) { + val result = convolveWithMode(image.bitmap, kernel, mode) + send(Image(result, "convolved_" + image.name)) + } + } + } +} + +private suspend fun writeDataset( + directory: File, + images: ReceiveChannel +) { + if (!directory.exists()) directory.mkdirs() + for (image in images) { + val filePath = File(directory, image.name) + writeImage(image.bitmap, filePath.path) + } +} diff --git a/app/src/test/kotlin/.keep b/app/src/test/kotlin/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/test/kotlin/convolution/UnitTests.kt b/app/src/test/kotlin/convolution/UnitTests.kt new file mode 100644 index 0000000..fded838 --- /dev/null +++ b/app/src/test/kotlin/convolution/UnitTests.kt @@ -0,0 +1,143 @@ +package convolution + +import filters.BLACK +import filters.FILTERS +import filters.ID +import filters.SHIFTLEFT +import filters.SHIFTRIGHT +import images.Bitmap +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import util.assertImagesEqual +import util.randomImage +import util.randomKernel +import util.randomOddSize +import java.util.stream.Stream +import kotlin.random.Random + +typealias Convolver = (Bitmap, Bitmap) -> Bitmap + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ParameterizedConvolutionTest { + private val smallSize = 10 + private val bigSize = 100 + private val sizes = listOf(smallSize, bigSize) + + companion object { + @JvmStatic + fun convolvers(): Stream { + return Stream.of( + Arguments.of("serial", ::serialConvolve), + Arguments.of("parallel-pixelwise", { img: Bitmap, k: Bitmap -> + runBlocking { pixelwiseConvolve(img, k) } + }), + Arguments.of("parallel-rows", { img: Bitmap, k: Bitmap -> + runBlocking { rowsConvolve(img, k) } + }), + Arguments.of("parallel-columns", { img: Bitmap, k: Bitmap -> + runBlocking { columnsConvolve(img, k) } + }), + Arguments.of("parallel-all", { img: Bitmap, k: Bitmap -> + runBlocking { allProcessorsConvolve(img, k) } + }) + ) + } + } + + @ParameterizedTest(name = "{0} - kernel with even dimensions raises an exception") + @MethodSource("convolvers") + fun `kernel with even dimensions raises an exception`(name: String, convolve: Convolver) { + val image = Array(smallSize) { DoubleArray(smallSize) } + val evenKernel = arrayOf(doubleArrayOf(1.0, 1.0)) + assertThrows { + convolve(image, evenKernel) + } + } + + @ParameterizedTest(name = "{0} - composition of shift left and shift right kernels creates ID") + @MethodSource("convolvers") + fun `composition of shift left and shift right kernels creates ID`(name: String, convolve: Convolver) { + val comp = convolve(SHIFTLEFT, SHIFTRIGHT) + assertImagesEqual(comp, ID) + } + + @ParameterizedTest(name = "{0} - there exist inverse-like shift kernels whose composition is identity") + @MethodSource("convolvers") + fun `there exist inverse-like shift kernels whose composition is identity`(name: String, convolve: Convolver) { + val rnd = Random(123) + for (size in sizes) { + val image = randomImage(size, size, rnd) + + val seq = convolve(convolve(image, SHIFTRIGHT), SHIFTLEFT) + val direct = convolve(image, arrayOf(doubleArrayOf(1.0))) + + assertImagesEqual(seq, direct) + } + } + + @ParameterizedTest(name = "{0} - zero-extend kernel does not change result when centered") + @MethodSource("convolvers") + fun `zero-extend kernel does not change result when centered`(name: String, convolve: Convolver) { + val rnd = Random(7) + for (size in sizes) { + repeat(6) { + val image = randomImage(size, size, rnd) + val kh = randomOddSize(5, rnd) + val kw = randomOddSize(5, rnd) + val k = randomKernel(kh, kw, rnd) + + val extH = if (kh == size) kh else (if (size % 2 == 1) size else size - 1) + val extW = if (kw == size) kw else (if (size % 2 == 1) size else size - 1) + val kExtended = Array(extH) { DoubleArray(extW) { 0.0 } } + val oh = k.size + val ow = k[0].size + val offY = extH / 2 - oh / 2 + val offX = extW / 2 - ow / 2 + for (y in 0 until oh) for (x in 0 until ow) kExtended[y + offY][x + offX] = k[y][x] + + val r1 = convolve(image, k) + val r2 = convolve(image, kExtended) + assertImagesEqual(r1, r2) + } + } + } + + @ParameterizedTest(name = "{0} - known kernels produce known results") + @MethodSource("convolvers") + fun `known kernels produce known results`(name: String, convolve: Convolver) { + val rnd = Random(99) + for (size in sizes) { + repeat(6) { + val image = randomImage(size, size, rnd) + + val outZero = convolve(image, BLACK) + assertImagesEqual(outZero, Array(image.size) { DoubleArray(image[0].size) { 0.0 } }) + + val outId = convolve(image, ID) + assertImagesEqual(outId, image) + } + } + } + + @Test + fun `all parallel strategies produce same result as serial`() { + val image = randomImage(randomOddSize(100, Random(123)), randomOddSize(100, Random(321)), Random(213)) + for (kernel in FILTERS.values) { + val serialResult = serialConvolve(image, kernel) + val pixelwiseResult = runBlocking { pixelwiseConvolve(image, kernel) } + val rowResult = runBlocking { rowsConvolve(image, kernel) } + val columnResult = runBlocking { columnsConvolve(image, kernel) } + val allProcessorsResult = runBlocking { allProcessorsConvolve(image, kernel) } + + assertImagesEqual(serialResult, pixelwiseResult) + assertImagesEqual(serialResult, rowResult) + assertImagesEqual(serialResult, columnResult) + assertImagesEqual(serialResult, allProcessorsResult) + } + } +} diff --git a/app/src/test/kotlin/util/Util.kt b/app/src/test/kotlin/util/Util.kt new file mode 100644 index 0000000..1e3daff --- /dev/null +++ b/app/src/test/kotlin/util/Util.kt @@ -0,0 +1,45 @@ +package util + +import images.Bitmap +import org.junit.jupiter.api.Assertions.assertEquals +import kotlin.random.Random + +fun assertImagesEqual( + img1: Bitmap, + img2: Bitmap, + eps: Double = 1e-9 +) { + assertEquals(img1.size, img2.size, "Different image heights") + + for (y in img1.indices) { + assertEquals(img1[y].size, img2[y].size, "Different row widths at row $y") + + for (x in img1[y].indices) { + assertEquals( + img1[y][x], + img2[y][x], + eps, + "Pixels differ at ($y,$x)" + ) + } + } +} + +fun randomImage(width: Int, height: Int, rnd: Random): Bitmap { + return Array(height) { DoubleArray(width) { rnd.nextDouble(0.0, 16.0) } } +} + +fun randomOddSize(max: Int, rnd: Random): Int { + val choices = (1..max step 2).toList() + return choices[rnd.nextInt(choices.size)] +} + +fun randomKernel(height: Int, width: Int, rnd: Random): Bitmap { + val k = Array(height) { DoubleArray(width) { rnd.nextDouble() } } + var sum = 0.0 + for (row in k) for (v in row) sum += v + if (sum > 1.0) { + for (y in k.indices) for (x in k[y].indices) k[y][x] = k[y][x] / sum + } + return k +} diff --git a/app/src/test/resources/BoatsColor.bmp b/app/src/test/resources/BoatsColor.bmp new file mode 100644 index 0000000..515178d Binary files /dev/null and b/app/src/test/resources/BoatsColor.bmp differ diff --git a/app/src/test/resources/Colorchecker.bmp b/app/src/test/resources/Colorchecker.bmp new file mode 100644 index 0000000..aa7fb3f Binary files /dev/null and b/app/src/test/resources/Colorchecker.bmp differ diff --git a/app/src/test/resources/MachColor.bmp b/app/src/test/resources/MachColor.bmp new file mode 100644 index 0000000..ba147da Binary files /dev/null and b/app/src/test/resources/MachColor.bmp differ diff --git a/app/src/test/resources/Malamute.bmp b/app/src/test/resources/Malamute.bmp new file mode 100644 index 0000000..74dd8d9 Binary files /dev/null and b/app/src/test/resources/Malamute.bmp differ diff --git a/app/src/test/resources/Maltese.bmp b/app/src/test/resources/Maltese.bmp new file mode 100644 index 0000000..8e92c01 Binary files /dev/null and b/app/src/test/resources/Maltese.bmp differ diff --git a/app/src/test/resources/Mercury.bmp b/app/src/test/resources/Mercury.bmp new file mode 100644 index 0000000..4645f17 Binary files /dev/null and b/app/src/test/resources/Mercury.bmp differ diff --git a/app/src/test/resources/Rainier.bmp b/app/src/test/resources/Rainier.bmp new file mode 100644 index 0000000..2ee7ab0 Binary files /dev/null and b/app/src/test/resources/Rainier.bmp differ diff --git a/app/src/test/resources/Sunrise.bmp b/app/src/test/resources/Sunrise.bmp new file mode 100644 index 0000000..d586866 Binary files /dev/null and b/app/src/test/resources/Sunrise.bmp differ diff --git a/app/src/test/resources/ZeldaColor.bmp b/app/src/test/resources/ZeldaColor.bmp new file mode 100644 index 0000000..eaa83ac Binary files /dev/null and b/app/src/test/resources/ZeldaColor.bmp differ diff --git a/app/src/test/resources/airplane.bmp b/app/src/test/resources/airplane.bmp new file mode 100644 index 0000000..faa89dd Binary files /dev/null and b/app/src/test/resources/airplane.bmp differ diff --git a/app/src/test/resources/airplaneU2.bmp b/app/src/test/resources/airplaneU2.bmp new file mode 100644 index 0000000..0c0b188 Binary files /dev/null and b/app/src/test/resources/airplaneU2.bmp differ diff --git a/app/src/test/resources/baboon.bmp b/app/src/test/resources/baboon.bmp new file mode 100644 index 0000000..2aef6c9 Binary files /dev/null and b/app/src/test/resources/baboon.bmp differ diff --git a/app/src/test/resources/barbara.bmp b/app/src/test/resources/barbara.bmp new file mode 100644 index 0000000..bea744e Binary files /dev/null and b/app/src/test/resources/barbara.bmp differ diff --git a/app/src/test/resources/blobs.bmp b/app/src/test/resources/blobs.bmp new file mode 100644 index 0000000..9ffc570 Binary files /dev/null and b/app/src/test/resources/blobs.bmp differ diff --git a/app/src/test/resources/boats.bmp b/app/src/test/resources/boats.bmp new file mode 100644 index 0000000..2415552 Binary files /dev/null and b/app/src/test/resources/boats.bmp differ diff --git a/app/src/test/resources/brickwall.bmp b/app/src/test/resources/brickwall.bmp new file mode 100644 index 0000000..4ca6922 Binary files /dev/null and b/app/src/test/resources/brickwall.bmp differ diff --git a/app/src/test/resources/bridge.bmp b/app/src/test/resources/bridge.bmp new file mode 100644 index 0000000..53f5059 Binary files /dev/null and b/app/src/test/resources/bridge.bmp differ diff --git a/app/src/test/resources/cablecar.bmp b/app/src/test/resources/cablecar.bmp new file mode 100644 index 0000000..3ee0a7a Binary files /dev/null and b/app/src/test/resources/cablecar.bmp differ diff --git a/app/src/test/resources/cameraman.bmp b/app/src/test/resources/cameraman.bmp new file mode 100644 index 0000000..2cd57c1 Binary files /dev/null and b/app/src/test/resources/cameraman.bmp differ diff --git a/app/src/test/resources/carpet.bmp b/app/src/test/resources/carpet.bmp new file mode 100644 index 0000000..b893a9a Binary files /dev/null and b/app/src/test/resources/carpet.bmp differ diff --git a/app/src/test/resources/cellcolony.bmp b/app/src/test/resources/cellcolony.bmp new file mode 100644 index 0000000..fec4173 Binary files /dev/null and b/app/src/test/resources/cellcolony.bmp differ diff --git a/app/src/test/resources/checker_0.bmp b/app/src/test/resources/checker_0.bmp new file mode 100644 index 0000000..a730abd Binary files /dev/null and b/app/src/test/resources/checker_0.bmp differ diff --git a/app/src/test/resources/checker_1.bmp b/app/src/test/resources/checker_1.bmp new file mode 100644 index 0000000..081bd5a Binary files /dev/null and b/app/src/test/resources/checker_1.bmp differ diff --git a/app/src/test/resources/checker_2.bmp b/app/src/test/resources/checker_2.bmp new file mode 100644 index 0000000..a730abd Binary files /dev/null and b/app/src/test/resources/checker_2.bmp differ diff --git a/app/src/test/resources/checker_3.bmp b/app/src/test/resources/checker_3.bmp new file mode 100644 index 0000000..081bd5a Binary files /dev/null and b/app/src/test/resources/checker_3.bmp differ diff --git a/app/src/test/resources/circle_0.bmp b/app/src/test/resources/circle_0.bmp new file mode 100644 index 0000000..b65218b Binary files /dev/null and b/app/src/test/resources/circle_0.bmp differ diff --git a/app/src/test/resources/circle_1.bmp b/app/src/test/resources/circle_1.bmp new file mode 100644 index 0000000..b65218b Binary files /dev/null and b/app/src/test/resources/circle_1.bmp differ diff --git a/app/src/test/resources/circle_2.bmp b/app/src/test/resources/circle_2.bmp new file mode 100644 index 0000000..b65218b Binary files /dev/null and b/app/src/test/resources/circle_2.bmp differ diff --git a/app/src/test/resources/circle_3.bmp b/app/src/test/resources/circle_3.bmp new file mode 100644 index 0000000..b65218b Binary files /dev/null and b/app/src/test/resources/circle_3.bmp differ diff --git a/app/src/test/resources/clown.bmp b/app/src/test/resources/clown.bmp new file mode 100644 index 0000000..b5074cd Binary files /dev/null and b/app/src/test/resources/clown.bmp differ diff --git a/app/src/test/resources/cornfield.bmp b/app/src/test/resources/cornfield.bmp new file mode 100644 index 0000000..fc03401 Binary files /dev/null and b/app/src/test/resources/cornfield.bmp differ diff --git a/app/src/test/resources/couple.bmp b/app/src/test/resources/couple.bmp new file mode 100644 index 0000000..7430e08 Binary files /dev/null and b/app/src/test/resources/couple.bmp differ diff --git a/app/src/test/resources/crowd.bmp b/app/src/test/resources/crowd.bmp new file mode 100644 index 0000000..44df662 Binary files /dev/null and b/app/src/test/resources/crowd.bmp differ diff --git a/app/src/test/resources/flower.bmp b/app/src/test/resources/flower.bmp new file mode 100644 index 0000000..7f9e36b Binary files /dev/null and b/app/src/test/resources/flower.bmp differ diff --git a/app/src/test/resources/flowers.bmp b/app/src/test/resources/flowers.bmp new file mode 100644 index 0000000..3e40906 Binary files /dev/null and b/app/src/test/resources/flowers.bmp differ diff --git a/app/src/test/resources/fruits.bmp b/app/src/test/resources/fruits.bmp new file mode 100644 index 0000000..02c4712 Binary files /dev/null and b/app/src/test/resources/fruits.bmp differ diff --git a/app/src/test/resources/girl.bmp b/app/src/test/resources/girl.bmp new file mode 100644 index 0000000..42d5c98 Binary files /dev/null and b/app/src/test/resources/girl.bmp differ diff --git a/app/src/test/resources/girlface.bmp b/app/src/test/resources/girlface.bmp new file mode 100644 index 0000000..ec1cc42 Binary files /dev/null and b/app/src/test/resources/girlface.bmp differ diff --git a/app/src/test/resources/goldhill.bmp b/app/src/test/resources/goldhill.bmp new file mode 100644 index 0000000..55fe444 Binary files /dev/null and b/app/src/test/resources/goldhill.bmp differ diff --git a/app/src/test/resources/gradient_0.bmp b/app/src/test/resources/gradient_0.bmp new file mode 100644 index 0000000..73a6bd4 Binary files /dev/null and b/app/src/test/resources/gradient_0.bmp differ diff --git a/app/src/test/resources/gradient_1.bmp b/app/src/test/resources/gradient_1.bmp new file mode 100644 index 0000000..8918e1f Binary files /dev/null and b/app/src/test/resources/gradient_1.bmp differ diff --git a/app/src/test/resources/gradient_2.bmp b/app/src/test/resources/gradient_2.bmp new file mode 100644 index 0000000..1ee67c5 Binary files /dev/null and b/app/src/test/resources/gradient_2.bmp differ diff --git a/app/src/test/resources/gradient_3.bmp b/app/src/test/resources/gradient_3.bmp new file mode 100644 index 0000000..2c3471b Binary files /dev/null and b/app/src/test/resources/gradient_3.bmp differ diff --git a/app/src/test/resources/houses.bmp b/app/src/test/resources/houses.bmp new file mode 100644 index 0000000..3f20579 Binary files /dev/null and b/app/src/test/resources/houses.bmp differ diff --git a/app/src/test/resources/kiel.bmp b/app/src/test/resources/kiel.bmp new file mode 100644 index 0000000..b43af18 Binary files /dev/null and b/app/src/test/resources/kiel.bmp differ diff --git a/app/src/test/resources/lenna.bmp b/app/src/test/resources/lenna.bmp new file mode 100644 index 0000000..d78becd Binary files /dev/null and b/app/src/test/resources/lenna.bmp differ diff --git a/app/src/test/resources/lighthouse.bmp b/app/src/test/resources/lighthouse.bmp new file mode 100644 index 0000000..c2fed2f Binary files /dev/null and b/app/src/test/resources/lighthouse.bmp differ diff --git a/app/src/test/resources/man.bmp b/app/src/test/resources/man.bmp new file mode 100644 index 0000000..34febf8 Binary files /dev/null and b/app/src/test/resources/man.bmp differ diff --git a/app/src/test/resources/monarch.bmp b/app/src/test/resources/monarch.bmp new file mode 100644 index 0000000..85b01ef Binary files /dev/null and b/app/src/test/resources/monarch.bmp differ diff --git a/app/src/test/resources/noise_0.bmp b/app/src/test/resources/noise_0.bmp new file mode 100644 index 0000000..717e436 Binary files /dev/null and b/app/src/test/resources/noise_0.bmp differ diff --git a/app/src/test/resources/noise_1.bmp b/app/src/test/resources/noise_1.bmp new file mode 100644 index 0000000..cb8f842 Binary files /dev/null and b/app/src/test/resources/noise_1.bmp differ diff --git a/app/src/test/resources/noise_2.bmp b/app/src/test/resources/noise_2.bmp new file mode 100644 index 0000000..e99155a Binary files /dev/null and b/app/src/test/resources/noise_2.bmp differ diff --git a/app/src/test/resources/noise_3.bmp b/app/src/test/resources/noise_3.bmp new file mode 100644 index 0000000..0ce560f Binary files /dev/null and b/app/src/test/resources/noise_3.bmp differ diff --git a/app/src/test/resources/pens.bmp b/app/src/test/resources/pens.bmp new file mode 100644 index 0000000..1a67f25 Binary files /dev/null and b/app/src/test/resources/pens.bmp differ diff --git a/app/src/test/resources/pepper.bmp b/app/src/test/resources/pepper.bmp new file mode 100644 index 0000000..1032e6f Binary files /dev/null and b/app/src/test/resources/pepper.bmp differ diff --git a/app/src/test/resources/sailboat.bmp b/app/src/test/resources/sailboat.bmp new file mode 100644 index 0000000..8dc167d Binary files /dev/null and b/app/src/test/resources/sailboat.bmp differ diff --git a/app/src/test/resources/soccer.bmp b/app/src/test/resources/soccer.bmp new file mode 100644 index 0000000..6e320ba Binary files /dev/null and b/app/src/test/resources/soccer.bmp differ diff --git a/app/src/test/resources/tank.bmp b/app/src/test/resources/tank.bmp new file mode 100644 index 0000000..d567bc2 Binary files /dev/null and b/app/src/test/resources/tank.bmp differ diff --git a/app/src/test/resources/tank2.bmp b/app/src/test/resources/tank2.bmp new file mode 100644 index 0000000..a94d27c Binary files /dev/null and b/app/src/test/resources/tank2.bmp differ diff --git a/app/src/test/resources/textureA.bmp b/app/src/test/resources/textureA.bmp new file mode 100644 index 0000000..6fa7900 Binary files /dev/null and b/app/src/test/resources/textureA.bmp differ diff --git a/app/src/test/resources/textureB.bmp b/app/src/test/resources/textureB.bmp new file mode 100644 index 0000000..bf90743 Binary files /dev/null and b/app/src/test/resources/textureB.bmp differ diff --git a/app/src/test/resources/tiffany.bmp b/app/src/test/resources/tiffany.bmp new file mode 100644 index 0000000..b0fcbc1 Binary files /dev/null and b/app/src/test/resources/tiffany.bmp differ diff --git a/app/src/test/resources/truck.bmp b/app/src/test/resources/truck.bmp new file mode 100644 index 0000000..00d8ea6 Binary files /dev/null and b/app/src/test/resources/truck.bmp differ diff --git a/app/src/test/resources/trucks.bmp b/app/src/test/resources/trucks.bmp new file mode 100644 index 0000000..cca00de Binary files /dev/null and b/app/src/test/resources/trucks.bmp differ diff --git a/app/src/test/resources/yacht.bmp b/app/src/test/resources/yacht.bmp new file mode 100644 index 0000000..5b885a9 Binary files /dev/null and b/app/src/test/resources/yacht.bmp differ diff --git a/app/src/test/resources/zelda.bmp b/app/src/test/resources/zelda.bmp new file mode 100644 index 0000000..674d3eb Binary files /dev/null and b/app/src/test/resources/zelda.bmp differ diff --git a/app/src/test/resources/zelda2.bmp b/app/src/test/resources/zelda2.bmp new file mode 100644 index 0000000..bc9f817 Binary files /dev/null and b/app/src/test/resources/zelda2.bmp differ diff --git a/docs/benchmark.csv b/docs/benchmark.csv new file mode 100644 index 0000000..27e6920 --- /dev/null +++ b/docs/benchmark.csv @@ -0,0 +1,17 @@ +mode,buffer,time_ms +SERIAL,,7798 +COLUMNS,1,3589 +COLUMNS,4,3422 +COLUMNS,8,3384 +COLUMNS,16,3394 +COLUMNS,32,3209 +ROWS,1,3236 +ROWS,4,3155 +ROWS,8,3382 +ROWS,16,3360 +ROWS,32,3334 +ALLPROCESSORS,1,3099 +ALLPROCESSORS,4,3146 +ALLPROCESSORS,8,3467 +ALLPROCESSORS,16,3310 +ALLPROCESSORS,32,2985 diff --git a/docs/benchmark.png b/docs/benchmark.png new file mode 100644 index 0000000..846d640 Binary files /dev/null and b/docs/benchmark.png differ