Skip to content

imaging.kt

src/jvmMain/kotlin/afield/media/image/imaging.kt · 872 lines

@file:afield.runtime.completion.CompletionCategory("Imaging")

package afield.media.image

import afield.ui.visualization.ToHTML
import afield.media.image.util.PngEncoder
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt

CPU image and computer-vision family for the Field stdlib: the raster counterpart to the time-domain timeseries.kt and frequency-domain spectral.kt families.

This file, with imaging_filters.kt, imaging_features.kt, imaging_geometry.kt and imaging_field.kt, is the stdlib's image layer: convolution and blurs, gradients, pyramids, corner and edge features, and warps, over a dependency-free core.

The whole family is built on one value type, Img:

val photo  = imgFromFile("cat.png")        // 3-channel sRGB-ish floats in 0..1
val gray   = photo.luma()                  // 1 channel
val edges  = gray.sobelMagnitude()         // 1 channel
val canny  = gray.canny(0.08, 0.20)        // 1 channel binary mask
canny.toFLines()                           // .. straight into Field's drawing layer

What the family commits to:

  • Float channels, not bytes. Every operation is float math in 0..1 (or unbounded, for gradients and Laplacians, which legitimately go negative). Nothing quantises mid-pipeline, so a 12-stage chain does not accumulate rounding steps. Clamping happens once, on output.
  • Immutable-by-convention value type. Ops return a new Img; the backing FloatArray is public (data) so a hot inner loop in a box can write it directly if it wants to. Interleaved channel-major-last layout (data[(y*width + x)*channels + c]), the same layout BufferedImage/GL/Vulkan uploads want.
  • 1, 3 or 4 channels. 1 = scalar/gray/mask, 3 = RGB, 4 = RGBA. Nothing else; a 12-channel feature tensor is a different problem, and not one this family takes on.
  • No external dependency. Pure Kotlin plus javax.imageio for load/save and afield.media.image.util.PngEncoder for the AWT-free path used by box output.
  • Live-codeable. Img implements ToHTML, so evaluating an Img in a box (or img.h) shows the actual picture as an inline thumbnail, not Img@3f1a2b.

Out of scope: GPU work (that is Field 4 / StackSL / RectSL), video decoding, and anything needing a model file.

Img — the value type

class Img

A CPU raster image: width * height * channels floats, interleaved, row-major, origin top-left (the image-processing convention, matching BufferedImage and PNG).

Values are conventionally in 0..1 for colour data, but nothing enforces that — gradient, Laplacian and flow images are signed and unbounded, and toBufferedImage clamps only at the moment of conversion.

class Img(
    /** Pixels across; at least 1. */
    val width: Int,
    /** Pixels down; at least 1. */
    val height: Int,
    /** 1 (scalar/gray), 3 (RGB) or 4 (RGBA); the constructor rejects anything else. */
    val channels: Int,
    /** Interleaved samples, `data[(y * width + x) * channels + c]`. */
    val data: FloatArray
) : ToHTML {

init

    init {
        require(width >= 1 && height >= 1) { "Img must be at least 1x1, got ${width}x$height" }
        require(channels == 1 || channels == 3 || channels == 4) {
            "Img supports 1 (gray), 3 (RGB) or 4 (RGBA) channels, got $channels"
        }
        require(data.size == width * height * channels) {
            "Img data must be width*height*channels = ${width * height * channels}, got ${data.size}"
        }
    }

val pixelCount: Int get() = width * height

Number of pixels (not samples).

val isScalar: Boolean get() = channels == 1

true when this is a single-channel scalar image (gray / mask / gradient / distance).

element access

fun index(x: Int, y: Int, c: Int = 0): Int = (y * width + x) * channels + c

Flat index of channel c of pixel (x, y). No bounds checking; see at.

fun at(x: Int, y: Int, c: Int = 0): Float

Sample at (x, y), channel c; throws if out of bounds.

    fun at(x: Int, y: Int, c: Int = 0): Float {
        require(x in 0 until width && y in 0 until height) { "($x, $y) outside ${width}x$height" }
        require(c in 0 until channels) { "channel $c outside 0..${channels - 1}" }
        return data[index(x, y, c)]
    }

fun set(x: Int, y: Int, c: Int, v: Float)

Write v at (x, y), channel c; throws if out of bounds. Mutates in place.

    fun set(x: Int, y: Int, c: Int, v: Float) {
        require(x in 0 until width && y in 0 until height) { "($x, $y) outside ${width}x$height" }
        require(c in 0 until channels) { "channel $c outside 0..${channels - 1}" }
        data[index(x, y, c)] = v
    }

fun get(x: Int, y: Int, c: Int = 0, border: Border = Border.CLAMP): Float

Sample with an out-of-range policy — the workhorse every filter uses at the image border. @param border how coordinates outside the image are resolved, see Border.

    fun get(x: Int, y: Int, c: Int = 0, border: Border = Border.CLAMP): Float {
        val cc = if (c < channels) c else channels - 1
        if (x in 0 until width && y in 0 until height) return data[index(x, y, cc)]
        return when (border) {
            Border.ZERO -> 0f
            Border.CLAMP -> data[index(x.coerceIn(0, width - 1), y.coerceIn(0, height - 1), cc)]
            Border.REFLECT -> data[index(reflectIndex(x, width), reflectIndex(y, height), cc)]
            Border.WRAP -> data[index(Math.floorMod(x, width), Math.floorMod(y, height), cc)]
        }
    }

fun copy(): Img = Img(width, height, channels, data.copyOf())

An independent copy.

fun zerosLike(channels: Int = this.channels): Img

A same-shape image of zeros (optionally a different channel count).

    fun zerosLike(channels: Int = this.channels): Img =
        Img(width, height, channels, FloatArray(width * height * channels))

fun sameShapeAs(other: Img): Boolean

true if other has the same width, height and channel count.

    fun sameShapeAs(other: Img): Boolean =
        width == other.width && height == other.height && channels == other.channels

bulk maps

inline fun map(f: (Float) -> Float): Img {

Apply f to every sample (all channels), returning a new image.

    inline fun map(f: (Float) -> Float): Img {
        val out = FloatArray(data.size)
        for (i in data.indices) out[i] = f(data[i])
        return Img(width, height, channels, out)
    }

inline fun mapIndexed(f: (x: Int, y: Int, c: Int, v: Float) -> Float): Img {

Apply f to every pixel given (x, y, channel, value), returning a new image.

    inline fun mapIndexed(f: (x: Int, y: Int, c: Int, v: Float) -> Float): Img {
        val out = FloatArray(data.size)
        var i = 0
        for (y in 0 until height) for (x in 0 until width) for (c in 0 until channels) {
            out[i] = f(x, y, c, data[i]); i++
        }
        return Img(width, height, channels, out)
    }

inline fun zip(other: Img, f: (Float, Float) -> Float): Img {

Element-wise combination of two same-shape images.

    inline fun zip(other: Img, f: (Float, Float) -> Float): Img {
        require(sameShapeAs(other)) {
            "shape mismatch: ${width}x$height x$channels vs ${other.width}x${other.height} x${other.channels}"
        }
        val out = FloatArray(data.size)
        for (i in data.indices) out[i] = f(data[i], other.data[i])
        return Img(width, height, channels, out)
    }

operator fun plus(other: Img): Img = zip(other) { a, b -> a + b }

operator fun minus(other: Img): Img = zip(other) { a, b -> a - b }

operator fun times(other: Img): Img = zip(other) { a, b -> a * b }

operator fun times(k: Number): Img = map { it * k.toFloat() }

operator fun plus(k: Number): Img = map { it + k.toFloat() }

operator fun unaryMinus(): Img = map { -it }

fun abs(): Img = map { kotlin.math.abs(it) }

Absolute value of every sample.

fun clamp(lo: Float = 0f, hi: Float = 1f): Img = map { it.coerceIn(lo, hi) }

Clamp every sample into [lo, hi] (default the display range 0..1).

reductions

fun min(): Float = data.min()

Smallest sample over all channels.

fun max(): Float = data.max()

Largest sample over all channels.

fun mean(): Float

Arithmetic mean of every sample.

    fun mean(): Float {
        var s = 0.0
        for (v in data) s += v
        return (s / data.size).toFloat()
    }

fun sum(): Double

Sum of every sample — the "DC" of the image; convolution with a normalised kernel preserves it.

    fun sum(): Double {
        var s = 0.0
        for (v in data) s += v
        return s
    }

fun stdDev(): Float

Population standard deviation of every sample.

    fun stdDev(): Float {
        val m = mean().toDouble()
        var s = 0.0
        for (v in data) { val d = v - m; s += d * d }
        return sqrt(s / data.size).toFloat()
    }

override fun toString(): String

    override fun toString(): String =
        "Img(${width}x$height x$channels, range ${"%.3f".format(min())}..${"%.3f".format(max())})"

override fun toHTML(): String = afield.ui.visualization.ImagingVisualizer.img(this)

Inline picture for the value visualizer — the actual pixels, aspect-correct, PNG-encoded and inlined as a data URI, plus a readout and a disclosure holding the per-channel strip, the histogram and the statistics.

The rendering lives in afield.ui.visualization.ImagingVisualizer, with the rest of the imaging family's visualizers, because the interesting part is not the <img> tag but the policy: this family is float-channel and unbounded, so a signed Sobel response, an unnormalised gradient and a stray NaN each need a different (and stated) treatment rather than a silent clamp. See that file's KDoc for the three display modes and why non-finite samples come out magenta.

companion object

    companion object {

fun zeros(width: Int, height: Int, channels: Int = 1): Img

All-zero image of the given shape.

        fun zeros(width: Int, height: Int, channels: Int = 1): Img =
            Img(width, height, channels, FloatArray(width * height * channels))

fun filled(width: Int, height: Int, channels: Int = 1, value: Float): Img

Constant-valued image.

        fun filled(width: Int, height: Int, channels: Int = 1, value: Float): Img =
            Img(width, height, channels, FloatArray(width * height * channels) { value })

inline fun generate(width: Int, height: Int, crossinline f: (x: Int, y: Int) -> Float): Img {

Build an image from a per-pixel function of (x, y) returning one value per channel. The classic way to make a synthetic test image:

val ramp = Img.generate(64, 64) { x, _ -> x / 63f }

        inline fun generate(width: Int, height: Int, crossinline f: (x: Int, y: Int) -> Float): Img {
            val out = FloatArray(width * height)
            var i = 0
            for (y in 0 until height) for (x in 0 until width) out[i++] = f(x, y)
            return Img(width, height, 1, out)
        }

inline fun generateRGB

Build a 3-channel image from a per-pixel function returning Triple(r, g, b).

        inline fun generateRGB(
            width: Int,
            height: Int,
            crossinline f: (x: Int, y: Int) -> Triple<Float, Float, Float>
        ): Img {
            val out = FloatArray(width * height * 3)
            var i = 0
            for (y in 0 until height) for (x in 0 until width) {
                val (r, g, b) = f(x, y)
                out[i++] = r; out[i++] = g; out[i++] = b
            }
            return Img(width, height, 3, out)
        }
    }
}

enum class Border

How a filter resolves coordinates that fall outside the image.

enum class Border {

Out-of-range reads return 0.

    ZERO,

Out-of-range reads clamp to the nearest edge pixel (the usual default).

    CLAMP,

Out-of-range reads mirror back into the image (no seam in the derivative).

    REFLECT,

Out-of-range reads wrap around (tileable images).

    WRAP
}

internal fun reflectIndex(i: Int, n: Int): Int

Mirror i back into 0 until n (reflect-101 style, edge pixel not duplicated).

Internal rather than private so the SIMD convolution passes can resolve a whole row's border once instead of once per sample — see convolveSeparable.

internal fun reflectIndex(i: Int, n: Int): Int {
    if (n == 1) return 0
    var v = i
    val period = 2 * n - 2
    v = Math.floorMod(v, period)
    return if (v < n) v else period - v
}

Construction / conversion

fun BufferedImage.toImg(channels: Int? = null): Img

Convert a BufferedImage to an Img with samples in 0..1.

@param channels 1 (luma via RGB_TO_LUMA), 3 (RGB) or 4 (RGBA); null picks 4 if the source has an alpha channel, otherwise 3.

fun BufferedImage.toImg(channels: Int? = null): Img {
    val hasAlpha = colorModel.hasAlpha()
    val ch = channels ?: if (hasAlpha) 4 else 3
    require(ch == 1 || ch == 3 || ch == 4) { "channels must be 1, 3 or 4, got $ch" }
    val out = FloatArray(width * height * ch)
    var i = 0
    for (y in 0 until height) for (x in 0 until width) {
        val argb = getRGB(x, y)
        val r = ((argb shr 16) and 0xff) / 255f
        val g = ((argb shr 8) and 0xff) / 255f
        val b = (argb and 0xff) / 255f
        val a = ((argb ushr 24) and 0xff) / 255f
        when (ch) {
            1 -> out[i++] = RGB_TO_LUMA[0] * r + RGB_TO_LUMA[1] * g + RGB_TO_LUMA[2] * b
            3 -> { out[i++] = r; out[i++] = g; out[i++] = b }
            else -> { out[i++] = r; out[i++] = g; out[i++] = b; out[i++] = a }
        }
    }
    return Img(width, height, ch, out)
}

fun Img.toBufferedImage(): BufferedImage

Convert to a BufferedImage for AWT drawing / ImageIO. Samples are clamped to 0..1 and quantised to 8 bits at this point (and only at this point). A 1-channel image becomes gray RGB.

fun Img.toBufferedImage(): BufferedImage {
    val type = if (channels == 4) BufferedImage.TYPE_INT_ARGB else BufferedImage.TYPE_INT_RGB
    val out = BufferedImage(width, height, type)
    for (y in 0 until height) for (x in 0 until width) {
        val i = index(x, y, 0)
        val r: Int; val g: Int; val b: Int; var a = 255
        when (channels) {
            1 -> { val v = q8(data[i]); r = v; g = v; b = v }
            3 -> { r = q8(data[i]); g = q8(data[i + 1]); b = q8(data[i + 2]) }
            else -> { r = q8(data[i]); g = q8(data[i + 1]); b = q8(data[i + 2]); a = q8(data[i + 3]) }
        }
        out.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b)
    }
    return out
}

private fun q8(v: Float): Int = (v.coerceIn(0f, 1f) * 255f + 0.5f).toInt().coerceIn(0, 255)

fun Img.toPngBytes(): ByteArray

Encode straight to PNG bytes with no AWT (via PngEncoder) — the path a box uses to put a picture in its own output, and the path Img.toHTML uses for its thumbnail.

fun Img.toPngBytes(): ByteArray {
    val px = IntArray(pixelCount)
    for (p in 0 until pixelCount) {
        val i = p * channels
        val r: Int; val g: Int; val b: Int; var a = 255
        when (channels) {
            1 -> { val v = q8(data[i]); r = v; g = v; b = v }
            3 -> { r = q8(data[i]); g = q8(data[i + 1]); b = q8(data[i + 2]) }
            else -> { r = q8(data[i]); g = q8(data[i + 1]); b = q8(data[i + 2]); a = q8(data[i + 3]) }
        }
        px[p] = (a shl 24) or (r shl 16) or (g shl 8) or b
    }
    return PngEncoder.encode(px, width, height)
}

fun Img.savePng(path: String): File

Write this image to path as a PNG (creating parent directories). Returns the file.

fun Img.savePng(path: String): File {
    val f = File(path)
    f.parentFile?.mkdirs()
    f.writeBytes(toPngBytes())
    return f
}

fun imgFromFile(path: String, channels: Int? = null): Img

Loads an image from a file path, straight through ImageIO — the path is used verbatim and does not go through FileManager, so a workspace-relative spec will not resolve here.

@param channels how many channels the result should have; see BufferedImage.toImg for what null means @throws IllegalArgumentException when nothing is at path @throws IllegalStateException when ImageIO cannot decode what is

fun imgFromFile(path: String, channels: Int? = null): Img {
    val f = File(path)
    require(f.isFile) { "no such image file: ${f.absolutePath}" }
    val bi = ImageIO.read(f) ?: error("ImageIO could not decode ${f.absolutePath}")
    return bi.toImg(channels)
}

fun imgFromBytes(bytes: ByteArray, channels: Int? = null): Img

Decode an image from in-memory encoded bytes (PNG/JPEG/… — whatever ImageIO handles).

fun imgFromBytes(bytes: ByteArray, channels: Int? = null): Img {
    val bi = ImageIO.read(bytes.inputStream()) ?: error("ImageIO could not decode ${bytes.size} bytes")
    return bi.toImg(channels)
}

fun floatsToImg(values: FloatArray, width: Int, height: Int): Img

Wrap a plain FloatArray of scalars as a 1-channel image — the bridge from the rest of the stdlib (a heightfield, a spectrogram frame stack, a DoubleArray reduction) into imaging.

fun floatsToImg(values: FloatArray, width: Int, height: Int): Img {
    require(values.size == width * height) { "expected ${width * height} values, got ${values.size}" }
    return Img(width, height, 1, values.copyOf())
}

fun doublesToImg(values: DoubleArray, width: Int, height: Int): Img

floatsToImg for doubles.

fun doublesToImg(values: DoubleArray, width: Int, height: Int): Img {
    require(values.size == width * height) { "expected ${width * height} values, got ${values.size}" }
    return Img(width, height, 1, FloatArray(values.size) { values[it].toFloat() })
}

Sampling / geometry (resolution, not transforms — see imaging_geometry.kt for warps)

fun Img.sampleNearest(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float

Nearest-neighbour sample at continuous coordinates.

fun Img.sampleNearest(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float =
    get(Math.round(x).toInt(), Math.round(y).toInt(), c, border)

fun Img.sampleBilinear(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float

Bilinear sample at continuous coordinates — pixel centres sit at integer coordinates, so sampleBilinear(3.0, 4.0) == at(3, 4) exactly.

fun Img.sampleBilinear(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float {
    val x0 = floor(x).toInt()
    val y0 = floor(y).toInt()
    val fx = (x - x0).toFloat()
    val fy = (y - y0).toFloat()
    val v00 = get(x0, y0, c, border)
    val v10 = get(x0 + 1, y0, c, border)
    val v01 = get(x0, y0 + 1, c, border)
    val v11 = get(x0 + 1, y0 + 1, c, border)
    val top = v00 + (v10 - v00) * fx
    val bot = v01 + (v11 - v01) * fx
    return top + (bot - top) * fy
}

fun Img.sampleBilinearAll(x: Double, y: Double, border: Border = Border.CLAMP): FloatArray

All channels bilinearly sampled at once.

fun Img.sampleBilinearAll(x: Double, y: Double, border: Border = Border.CLAMP): FloatArray =
    FloatArray(channels) { sampleBilinear(x, y, it, border) }

fun Img.crop(x: Int, y: Int, w: Int, h: Int): Img

Rectangular sub-image. Coordinates are clamped into range; the result is at least 1x1.

fun Img.crop(x: Int, y: Int, w: Int, h: Int): Img {
    require(w >= 1 && h >= 1) { "crop size must be >= 1x1, got ${w}x$h" }
    val x0 = x.coerceIn(0, width - 1)
    val y0 = y.coerceIn(0, height - 1)
    val ww = w.coerceAtMost(width - x0)
    val hh = h.coerceAtMost(height - y0)
    val out = FloatArray(ww * hh * channels)
    var i = 0
    for (yy in 0 until hh) for (xx in 0 until ww) for (c in 0 until channels) {
        out[i++] = data[index(x0 + xx, y0 + yy, c)]
    }
    return Img(ww, hh, channels, out)
}

fun Img.pad(pad: Int, border: Border = Border.CLAMP): Img

Grow the image by pad pixels on every side, filling from border.

fun Img.pad(pad: Int, border: Border = Border.CLAMP): Img {
    require(pad >= 0) { "pad must be >= 0, got $pad" }
    if (pad == 0) return copy()
    val w = width + 2 * pad
    val h = height + 2 * pad
    val out = FloatArray(w * h * channels)
    var i = 0
    for (y in 0 until h) for (x in 0 until w) for (c in 0 until channels) {
        out[i++] = get(x - pad, y - pad, c, border)
    }
    return Img(w, h, channels, out)
}

fun Img.resize(newWidth: Int, newHeight: Int, areaAverage: Boolean = true): Img

Resize to newWidth x newHeight.

Downscaling uses area averaging (each output pixel is the mean of the input pixels it covers), which is the right anti-aliased answer and what resize should do by default; upscaling uses bilinear interpolation. Set areaAverage to false to force bilinear both ways.

fun Img.resize(newWidth: Int, newHeight: Int, areaAverage: Boolean = true): Img {
    require(newWidth >= 1 && newHeight >= 1) { "resize target must be >= 1x1, got ${newWidth}x$newHeight" }
    if (newWidth == width && newHeight == height) return copy()
    val shrinking = newWidth < width || newHeight < height
    return if (areaAverage && shrinking) resizeArea(newWidth, newHeight) else resizeBilinear(newWidth, newHeight)
}

private fun Img.resizeBilinear(nw: Int, nh: Int): Img {
    val out = FloatArray(nw * nh * channels)
    val sx = if (nw == 1) 0.0 else (width - 1).toDouble() / (nw - 1)
    val sy = if (nh == 1) 0.0 else (height - 1).toDouble() / (nh - 1)
    var i = 0
    for (y in 0 until nh) for (x in 0 until nw) for (c in 0 until channels) {
        out[i++] = sampleBilinear(x * sx, y * sy, c)
    }
    return Img(nw, nh, channels, out)
}

private fun Img.resizeArea(nw: Int, nh: Int): Img {
    val out = FloatArray(nw * nh * channels)
    val sx = width.toDouble() / nw
    val sy = height.toDouble() / nh
    var i = 0
    for (y in 0 until nh) {
        val y0 = (y * sy).toInt().coerceIn(0, height - 1)
        val y1 = max(y0 + 1, ((y + 1) * sy).toInt().coerceAtMost(height))
        for (x in 0 until nw) {
            val x0 = (x * sx).toInt().coerceIn(0, width - 1)
            val x1 = max(x0 + 1, ((x + 1) * sx).toInt().coerceAtMost(width))
            val n = (y1 - y0) * (x1 - x0)
            for (c in 0 until channels) {
                var s = 0.0
                for (yy in y0 until y1) for (xx in x0 until x1) s += data[index(xx, yy, c)]
                out[i++] = (s / n).toFloat()
            }
        }
    }
    return Img(nw, nh, channels, out)
}

fun Img.upscaleNearest(factor: Int): Img

Enlarge by an integer factor with no interpolation — every pixel becomes a crisp factor x factor block.

resize and scaleBy both filter, which is right for photographs and wrong for anything whose pixels are meant to be seen as pixels: a tile grid, a Wave Function Collapse output, a mask, a small palettised sprite, a per-cell heatmap. Bilinear upsampling turns those into mush, and the structure you were trying to show is the first thing it destroys.

@param factor how many output pixels per input pixel, per axis; must be at least 1

fun Img.upscaleNearest(factor: Int): Img {
    require(factor >= 1) { "upscale factor must be at least 1, got $factor" }
    if (factor == 1) return copy()
    val nw = width * factor
    val nh = height * factor
    val out = FloatArray(nw * nh * channels)
    for (y in 0 until nh) {
        val sy = y / factor
        for (x in 0 until nw) {
            val si = index(x / factor, sy, 0)
            val di = (y * nw + x) * channels
            for (c in 0 until channels) out[di + c] = data[si + c]
        }
    }
    return Img(nw, nh, channels, out)
}

fun Img.scaleBy(factor: Double, areaAverage: Boolean = true): Img

Resize by a scale factor (both axes). See resize.

fun Img.scaleBy(factor: Double, areaAverage: Boolean = true): Img {
    require(factor > 0.0) { "scale factor must be > 0, got $factor" }
    return resize(max(1, (width * factor).roundToInt()), max(1, (height * factor).roundToInt()), areaAverage)
}

fun Img.flipX(): Img = mapIndexed { x, y, c, _ -> data[index(width - 1 - x, y, c)] }

Mirror left-right.

fun Img.flipY(): Img = mapIndexed { x, y, c, _ -> data[index(x, height - 1 - y, c)] }

Mirror top-bottom.

fun Img.rotate90(): Img

Rotate 90 degrees clockwise (width and height swap).

fun Img.rotate90(): Img {
    val out = FloatArray(data.size)
    var i = 0
    for (y in 0 until width) for (x in 0 until height) for (c in 0 until channels) {
        out[i++] = data[index(y, height - 1 - x, c)]
    }
    return Img(height, width, channels, out)
}

fun Img.transpose(): Img

Transpose (x and y swap).

fun Img.transpose(): Img {
    val out = FloatArray(data.size)
    var i = 0
    for (y in 0 until width) for (x in 0 until height) for (c in 0 until channels) {
        out[i++] = data[index(y, x, c)]
    }
    return Img(height, width, channels, out)
}

Channels and colour

val RGB_TO_LUMA: FloatArray = floatArrayOf(0.2126f, 0.7152f, 0.0722f)

ITU-R BT.709 luma weights — the perceptual RGB→gray mix used throughout the family.

fun Img.channel(c: Int): Img

Extract channel c as a 1-channel image.

fun Img.channel(c: Int): Img {
    require(c in 0 until channels) { "channel $c outside 0..${channels - 1}" }
    val out = FloatArray(pixelCount)
    for (p in 0 until pixelCount) out[p] = data[p * channels + c]
    return Img(width, height, 1, out)
}

fun Img.split(): List<Img> = (0 until channels).map { channel(it) }

Split into one 1-channel image per channel.

fun mergeChannels(vararg parts: Img): Img

Merge 1-channel images into a multi-channel image. All inputs must share a shape and the count must be 1, 3 or 4.

fun mergeChannels(vararg parts: Img): Img {
    require(parts.isNotEmpty()) { "mergeChannels needs at least one image" }
    val n = parts.size
    require(n == 1 || n == 3 || n == 4) { "mergeChannels takes 1, 3 or 4 images, got $n" }
    val w = parts[0].width
    val h = parts[0].height
    parts.forEachIndexed { i, p ->
        require(p.channels == 1) { "mergeChannels part $i must be 1-channel, got ${p.channels}" }
        require(p.width == w && p.height == h) {
            "mergeChannels part $i is ${p.width}x${p.height}, expected ${w}x$h"
        }
    }
    val out = FloatArray(w * h * n)
    for (p in 0 until w * h) for (c in 0 until n) out[p * n + c] = parts[c].data[p]
    return Img(w, h, n, out)
}

fun Img.luma(): Img

Perceptual grayscale (BT.709 luma) as a 1-channel image. A 1-channel input is returned as a copy; an RGBA input ignores alpha.

fun Img.luma(): Img {
    if (channels == 1) return copy()
    val out = FloatArray(pixelCount)
    for (p in 0 until pixelCount) {
        val i = p * channels
        out[p] = RGB_TO_LUMA[0] * data[i] + RGB_TO_LUMA[1] * data[i + 1] + RGB_TO_LUMA[2] * data[i + 2]
    }
    return Img(width, height, 1, out)
}

fun Img.toRGB(): Img

Replicate a 1-channel image into 3 channels (the inverse direction of luma).

fun Img.toRGB(): Img {
    if (channels == 3) return copy()
    if (channels == 4) return mergeChannels(channel(0), channel(1), channel(2))
    val out = FloatArray(pixelCount * 3)
    for (p in 0 until pixelCount) {
        val v = data[p]; out[p * 3] = v; out[p * 3 + 1] = v; out[p * 3 + 2] = v
    }
    return Img(width, height, 3, out)
}

fun Img.toRGBA(alpha: Float = 1f): Img

Add an opaque (or alpha-valued) alpha channel.

fun Img.toRGBA(alpha: Float = 1f): Img {
    val rgb = toRGB()
    val out = FloatArray(pixelCount * 4)
    for (p in 0 until pixelCount) {
        out[p * 4] = rgb.data[p * 3]
        out[p * 4 + 1] = rgb.data[p * 3 + 1]
        out[p * 4 + 2] = rgb.data[p * 3 + 2]
        out[p * 4 + 3] = alpha
    }
    return Img(width, height, 4, out)
}

fun linearToSrgb(v: Float): Float

sRGB transfer function applied to a linear-light value.

fun linearToSrgb(v: Float): Float =
    if (v <= 0.0031308f) 12.92f * v else 1.055f * v.toDouble().pow(1.0 / 2.4).toFloat() - 0.055f

fun srgbToLinear(v: Float): Float

Inverse sRGB transfer function: encoded value → linear light.

fun srgbToLinear(v: Float): Float =
    if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).toDouble().pow(2.4).toFloat()

fun Img.toLinear(): Img

Decode sRGB-encoded samples to linear light. Blurring, downsampling and averaging are only correct in linear light; img.toLinear().gaussianBlur(2.0).toSrgb() is the physically right blur, and visibly different from blurring the encoded values.

fun Img.toLinear(): Img = mapIndexed { _, _, c, v -> if (channels == 4 && c == 3) v else srgbToLinear(v) }

fun Img.toSrgb(): Img

Encode linear-light samples back to sRGB. Inverse of toLinear.

fun Img.toSrgb(): Img = mapIndexed { _, _, c, v -> if (channels == 4 && c == 3) v else linearToSrgb(v) }

fun Img.rgbToHsv(): Img

RGB → HSV, in place of the channels: h in 0..1 (hue angle / 360), s in 0..1, v in 0..1. Requires a 3- or 4-channel image; alpha is dropped.

fun Img.rgbToHsv(): Img {
    require(channels >= 3) { "rgbToHsv needs an RGB or RGBA image, got $channels channel(s)" }
    val out = FloatArray(pixelCount * 3)
    for (p in 0 until pixelCount) {
        val i = p * channels
        val r = data[i]; val g = data[i + 1]; val b = data[i + 2]
        val mx = max(r, max(g, b))
        val mn = min(r, min(g, b))
        val d = mx - mn
        val h = when {
            d < 1e-7f -> 0f
            mx == r -> ((g - b) / d).let { if (it < 0) it + 6f else it } / 6f
            mx == g -> ((b - r) / d + 2f) / 6f
            else -> ((r - g) / d + 4f) / 6f
        }
        out[p * 3] = h
        out[p * 3 + 1] = if (mx <= 1e-7f) 0f else d / mx
        out[p * 3 + 2] = mx
    }
    return Img(width, height, 3, out)
}

fun Img.hsvToRgb(): Img

HSV → RGB, the inverse of rgbToHsv.

fun Img.hsvToRgb(): Img {
    require(channels == 3) { "hsvToRgb needs a 3-channel HSV image, got $channels" }
    val out = FloatArray(pixelCount * 3)
    for (p in 0 until pixelCount) {
        val i = p * 3
        val h = (data[i] - floor(data[i].toDouble()).toFloat()) * 6f
        val s = data[i + 1].coerceIn(0f, 1f)
        val v = data[i + 2]
        val sector = floor(h.toDouble()).toInt()
        val f = h - sector
        val p1 = v * (1f - s)
        val q = v * (1f - s * f)
        val t = v * (1f - s * (1f - f))
        val (r, g, b) = when (sector % 6) {
            0 -> Triple(v, t, p1)
            1 -> Triple(q, v, p1)
            2 -> Triple(p1, v, t)
            3 -> Triple(p1, q, v)
            4 -> Triple(t, p1, v)
            else -> Triple(v, p1, q)
        }
        out[i] = r; out[i + 1] = g; out[i + 2] = b
    }
    return Img(width, height, 3, out)
}

Statistics: normalisation, histograms, thresholding

fun Img.normalized(lo: Float = 0f, hi: Float = 1f): Img

Linearly rescale so the sample range becomes [lo, hi] (default 0..1). A constant image maps to the midpoint rather than dividing by zero.

fun Img.normalized(lo: Float = 0f, hi: Float = 1f): Img {
    val mn = min()
    val mx = max()
    val d = mx - mn
    if (d < 1e-12f) return map { (lo + hi) / 2f }
    val k = (hi - lo) / d
    return map { lo + (it - mn) * k }
}

fun Img.normalizedPercentile(pLow: Double = 0.01, pHigh: Double = 0.99): Img

Robust normalisation: map the pLow/pHigh percentiles of the sample distribution to 0..1 and clamp. Far more useful than normalized on noisy gradient/response images, where one hot pixel otherwise squashes everything else to black.

fun Img.normalizedPercentile(pLow: Double = 0.01, pHigh: Double = 0.99): Img {
    require(pLow in 0.0..1.0 && pHigh in 0.0..1.0 && pLow < pHigh) {
        "percentiles must satisfy 0 <= pLow < pHigh <= 1, got $pLow / $pHigh"
    }
    val sorted = data.copyOf()
    sorted.sort()
    val lo = sorted[((sorted.size - 1) * pLow).toInt().coerceIn(0, sorted.size - 1)]
    val hi = sorted[((sorted.size - 1) * pHigh).toInt().coerceIn(0, sorted.size - 1)]
    if (hi - lo < 1e-12f) return map { 0.5f }
    val k = 1f / (hi - lo)
    return map { ((it - lo) * k).coerceIn(0f, 1f) }
}

fun Img.histogram(bins: Int = 256, lo: Float = 0f, hi: Float = 1f): IntArray

Histogram of a 1-channel image over [lo, hi] with bins equal-width buckets. Values outside the range land in the first/last bucket.

fun Img.histogram(bins: Int = 256, lo: Float = 0f, hi: Float = 1f): IntArray {
    require(channels == 1) { "histogram expects a 1-channel image; call .luma() or .channel(c) first" }
    require(bins >= 2) { "bins must be >= 2, got $bins" }
    require(hi > lo) { "hi must exceed lo, got $lo..$hi" }
    val out = IntArray(bins)
    val k = bins / (hi - lo)
    for (v in data) out[(((v - lo) * k).toInt()).coerceIn(0, bins - 1)]++
    return out
}

fun Img.equalize(bins: Int = 256): Img

Histogram equalisation of a 1-channel image: remap intensities through the cumulative histogram so the output distribution is (approximately) flat. Classic contrast enhancement.

fun Img.equalize(bins: Int = 256): Img {
    require(channels == 1) { "equalize expects a 1-channel image; call .luma() first" }
    val mn = min()
    val mx = max()
    if (mx - mn < 1e-12f) return copy()
    val h = histogram(bins, mn, mx)
    val cdf = DoubleArray(bins)
    var acc = 0.0
    for (i in 0 until bins) { acc += h[i]; cdf[i] = acc }
    val total = acc
    val cdfMin = cdf.first { it > 0.0 }
    val denom = (total - cdfMin).coerceAtLeast(1.0)
    val k = bins / (mx - mn)
    return map { v ->
        val b = (((v - mn) * k).toInt()).coerceIn(0, bins - 1)
        (((cdf[b] - cdfMin) / denom).toFloat()).coerceIn(0f, 1f)
    }
}

fun Img.otsuThreshold(bins: Int = 256): Float

Otsu's method: the intensity threshold in [lo, hi] that maximises between-class variance — i.e. the best two-way split of a bimodal histogram, computed rather than guessed.

@return the threshold value, in the same units as the samples.

fun Img.otsuThreshold(bins: Int = 256): Float {
    require(channels == 1) { "otsuThreshold expects a 1-channel image; call .luma() first" }
    val mn = min()
    val mx = max()
    if (mx - mn < 1e-12f) return mn
    val h = histogram(bins, mn, mx)
    val total = pixelCount.toDouble()
    var sumAll = 0.0
    for (i in 0 until bins) sumAll += i * h[i].toDouble()
    var sumB = 0.0
    var wB = 0.0
    var best = 0.0
    var bestBin = 0
    for (i in 0 until bins) {
        wB += h[i]
        if (wB == 0.0) continue
        val wF = total - wB
        if (wF == 0.0) break
        sumB += i * h[i].toDouble()
        val mB = sumB / wB
        val mF = (sumAll - sumB) / wF
        val between = wB * wF * (mB - mF) * (mB - mF)
        if (between > best) { best = between; bestBin = i }
    }
    // Threshold at the upper edge of the chosen bin.
    return mn + (bestBin + 1).toFloat() * (mx - mn) / bins
}

fun Img.threshold(threshold: Float): Img

Binarise: samples > threshold become 1, everything else 0. Result is 1-channel.

fun Img.threshold(threshold: Float): Img {
    val src = if (channels == 1) this else luma()
    return src.map { if (it > threshold) 1f else 0f }
}

fun Img.thresholdOtsu(bins: Int = 256): Img

Binarise at the otsuThreshold.

fun Img.thresholdOtsu(bins: Int = 256): Img {
    val src = if (channels == 1) this else luma()
    return src.threshold(src.otsuThreshold(bins))
}

fun Img.thresholdAdaptive(radius: Int = 8, bias: Float = 0.02f): Img

Adaptive (local-mean) threshold: each pixel is compared to the mean of a (2*radius+1) square neighbourhood, minus bias. Handles uneven illumination that defeats a global threshold.

fun Img.thresholdAdaptive(radius: Int = 8, bias: Float = 0.02f): Img {
    val src = if (channels == 1) this else luma()
    val mean = src.boxBlur(radius)
    return src.zip(mean) { v, m -> if (v > m - bias) 1f else 0f }
}

fun Img.meanAbsDiff(other: Img): Double

Mean absolute difference per sample against other — a blunt but unbiased similarity measure.

fun Img.meanAbsDiff(other: Img): Double {
    require(sameShapeAs(other)) { "meanAbsDiff needs matching shapes" }
    var s = 0.0
    for (i in data.indices) s += abs(data[i] - other.data[i])
    return s / data.size
}

fun Img.psnr(other: Img): Double

Peak signal-to-noise ratio in dB against other, assuming a 0..1 signal range.

fun Img.psnr(other: Img): Double {
    require(sameShapeAs(other)) { "psnr needs matching shapes" }
    var s = 0.0
    for (i in data.indices) { val d = (data[i] - other.data[i]).toDouble(); s += d * d }
    val mse = s / data.size
    if (mse <= 0.0) return Double.POSITIVE_INFINITY
    return 10.0 * kotlin.math.log10(1.0 / mse)
}