Skip to content
Open
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
6 changes: 6 additions & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
plugins {
application
kotlin("jvm") version "2.1.10"
}

group = "org.example"
group = "convolution"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

dependencies {
implementation("org.openpnp:opencv:4.9.0-0")
testImplementation(kotlin("test"))
}

tasks.test {
useJUnitPlatform()
}

application {
mainClass = "convolution.MainKt"
}

kotlin {
jvmToolchain(23)
}
}
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "task_1"
rootProject.name = "convolution"

65 changes: 65 additions & 0 deletions src/main/kotlin/convolution/ConvolutionKernel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package convolution

data class ConvolutionKernel(
val name: String,
val size: Int,
val coefficients: DoubleArray,
) {
init {
require(size % 2 == 1) { "Kernel size must be odd." }
require(coefficients.size == size * size) {
"Kernel $name must contain exactly ${size * size} coefficients."
}
}
}

object Kernels {
private val all = listOf(
ConvolutionKernel(
name = "identity3",
size = 3,
coefficients = doubleArrayOf(
0.0, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 0.0,
),
),
ConvolutionKernel(
name = "box3",
size = 3,
coefficients = doubleArrayOf(
1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0,
1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0,
1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0,
),
),
ConvolutionKernel(
name = "gaussian3",
size = 3,
coefficients = doubleArrayOf(
1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0,
2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0,
1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0,
),
),
ConvolutionKernel(
name = "sharpen3",
size = 3,
coefficients = doubleArrayOf(
0.0, -1.0, 0.0,
-1.0, 5.0, -1.0,
0.0, -1.0, 0.0,
),
),
)

private val byName = all.associateBy { it.name }

fun resolve(name: String): ConvolutionKernel {
return byName[name] ?: error(
"Unknown kernel '$name'. Available kernels: ${availableNames()}."
)
}

fun availableNames(): String = all.joinToString(", ") { it.name }
}
60 changes: 60 additions & 0 deletions src/main/kotlin/convolution/Main.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package convolution

import java.nio.file.Path
import kotlin.system.exitProcess

private data class CliConfig(
val inputPath: Path,
val outputPath: Path,
val kernelName: String,
)

fun main(args: Array<String>) {
val config = parseArgs(args) ?: run {
printUsage()
exitProcess(1)
}

val exitCode = runCatching {
val kernel = Kernels.resolve(config.kernelName)
val source = OpenCvSupport.readGrayscale(config.inputPath)
val result = SequentialConvolution.apply(source, kernel)
OpenCvSupport.write(config.outputPath, result)
println("Saved '${kernel.name}' result to ${config.outputPath}")
}.fold(
onSuccess = { 0 },
onFailure = { error ->
System.err.println(error.message ?: error.toString())
1
},
)

exitProcess(exitCode)
}

private fun parseArgs(args: Array<String>): CliConfig? {
if (args.size !in 2..3) {
return null
}

return CliConfig(
inputPath = Path.of(args[0]),
outputPath = Path.of(args[1]),
kernelName = args.getOrElse(2) { "gaussian3" },
)
}

private fun printUsage() {
println(
"""
Usage:
./gradlew run --args="input/source.bmp output/result.bmp [kernel]"

Available kernels:
${Kernels.availableNames()}

Example:
./gradlew run --args="input/source.bmp output/result.bmp sharpen3"
""".trimIndent()
)
}
37 changes: 37 additions & 0 deletions src/main/kotlin/convolution/OpenCvSupport.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package convolution

import nu.pattern.OpenCV
import org.opencv.core.Core
import org.opencv.core.Mat
import org.opencv.imgcodecs.Imgcodecs
import java.nio.file.Files
import java.nio.file.Path

object OpenCvSupport {
private var loaded = false

fun readGrayscale(path: Path): Mat {
require(Files.exists(path)) { "Input file does not exist: $path" }
ensureLoaded()

val image = Imgcodecs.imread(path.toString(), Imgcodecs.IMREAD_GRAYSCALE)
require(!image.empty()) { "Failed to read image from $path" }
return image
}

fun write(path: Path, image: Mat) {
ensureLoaded()
path.parent?.let(Files::createDirectories)
check(Imgcodecs.imwrite(path.toString(), image)) { "Failed to write image to $path" }
}

private fun ensureLoaded() {
if (loaded) {
return
}

OpenCV.loadLocally()
Core.setNumThreads(1)
loaded = true
}
}
62 changes: 62 additions & 0 deletions src/main/kotlin/convolution/SequentialConvolution.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package convolution

import kotlin.math.roundToInt
import org.opencv.core.CvType
import org.opencv.core.Mat

object SequentialConvolution {
fun apply(source: Mat, kernel: ConvolutionKernel): Mat {
require(source.type() == CvType.CV_8UC1) {
"Expected a grayscale CV_8UC1 image, got type ${source.type()}."
}

val width = source.cols()
val height = source.rows()
val inputBytes = ByteArray(width * height)
source.get(0, 0, inputBytes)

val input = IntArray(inputBytes.size) { index ->
inputBytes[index].toInt() and 0xFF
}

val output = apply_aux(width, height, input, kernel)
val outputBytes = ByteArray(output.size) { index -> output[index].toByte() }

return Mat(height, width, CvType.CV_8UC1).apply {
put(0, 0, outputBytes)
}
}

fun apply_aux(width: Int, height: Int, input: IntArray, kernel: ConvolutionKernel): IntArray {
require(width > 0) { "Image width must be positive." }
require(height > 0) { "Image height must be positive." }
require(input.size == width * height) {
"Input array size ${input.size} does not match image shape ${width}x$height."
}

val radius = kernel.size / 2
val output = IntArray(input.size)

for (y in 0 until height) {
for (x in 0 until width) {
var sum = 0.0
var kernelIndex = 0

for (kernelY in -radius..radius) {
val sourceY = (y + kernelY).coerceIn(0, height - 1)
val rowOffset = sourceY * width

for (kernelX in -radius..radius) {
val sourceX = (x + kernelX).coerceIn(0, width - 1)
val pixel = input[rowOffset + sourceX]
sum += pixel * kernel.coefficients[kernelIndex++]
}
}

output[y * width + x] = sum.roundToInt().coerceIn(0, 255)
}
}

return output
}
}
48 changes: 48 additions & 0 deletions src/test/kotlin/convolution/SequentialConvolutionTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package convolution

import kotlin.test.Test
import kotlin.test.assertContentEquals

class SequentialConvolutionTest {
@Test
fun `identity kernel keeps the image unchanged`() {
val input = intArrayOf(
10, 20, 30,
40, 50, 60,
70, 80, 90,
)

val actual = SequentialConvolution.apply_aux(
width = 3,
height = 3,
input = input,
kernel = Kernels.resolve("identity3"),
)

assertContentEquals(input, actual)
}

@Test
fun `box blur uses replicated borders`() {
val input = intArrayOf(
1, 2, 3,
4, 5, 6,
7, 8, 9,
)

val actual = SequentialConvolution.apply_aux(
width = 3,
height = 3,
input = input,
kernel = Kernels.resolve("box3"),
)

val expected = intArrayOf(
2, 3, 4,
4, 5, 6,
6, 7, 8,
)

assertContentEquals(expected, actual)
}
}