Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
94 changes: 92 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 26 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
kotlin("jvm") version "1.9.25"
application
id("org.jlleitschuh.gradle.ktlint") version "11.5.0"
}

repositories {
Expand All @@ -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<JavaExec>("benchmark") {
group = "benchmark"
description = "Run image processing benchmark"

classpath = sourceSets["main"].runtimeClasspath
mainClass.set("BenchmarkKt")
}
59 changes: 57 additions & 2 deletions app/src/main/kotlin/App.kt
Original file line number Diff line number Diff line change
@@ -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<String>) {
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<Mode>(),
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")
}
}
107 changes: 107 additions & 0 deletions app/src/main/kotlin/Benchmark.kt
Original file line number Diff line number Diff line change
@@ -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<DoubleArray> {
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<Triple<String, Int, Long>>()

for (mode in modes) {
if (mode == Mode.PIXELWISE) {
continue
} else if (mode == Mode.SERIAL) {
val times = mutableListOf<Long>()
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<Long>()
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")
}
Loading