Skip to content

heatmap.kt

src/jvmMain/kotlin/afield/ui/visualization/heatmap.kt · 297 lines

package afield.ui.visualization

import afield.app.protocol.put
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.document.graph.layers
import afield.runtime.execution.plus
import afield.runtime.lifecycle.frames
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.with
import afield.ui.output.column
import afield.ui.output.md

import afield.audio.synth.*
import afield.document.graph.Box
import afield.graphics.color.Color4
import afield.graphics.color.Colormaps
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.media.image.util.PngEncoder
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.roundToInt

A rectangular array of numbers as a colour-mapped picture in a box output, in one call.

The file is two layers. heatmapImage is pure: a rectangular matrix in, a RasterImage of ARGB pixels out, with the colour scale, the optional log transform, the bilinear upsample and the overlay trace all resolved there, so a test or a docs capture can render one without a Box. Box.showHeatmap adds the two steps a box wants, encoding to PNG and displaying it into an output channel, and returns the bytes so the caller can save or re-display them. Box.showImage is that same second layer over a pixel buffer you already have.

Two conventions decide which way up the picture comes out. The matrix is read row-major, outer index the row and inner index the column, so rows are the vertical axis; and flipY defaults to true, drawing row 0 at the bottom, which is what a spectrogram's frequency bins want. A spectrogram stored the other way round, frame-outer and bin-inner, is the transpose = true case: frames become x and bins become y.

Colour is a Colormaps name and a range. Under normalize the range is the data's own minimum and maximum; vmin and vmax pin it instead, which is what makes two captures comparable; logScale runs each value through log10(max(v, 1e-12)) first, for magnitude spectra and anything else spanning orders of magnitude.

class RasterImage(val pixels: IntArray, val width: Int, val height: Int)

A colour-mapped raster produced by heatmapImage: ARGB pixels laid out row-major, width × height. Call png for PNG bytes ready to display or persist.

class RasterImage(val pixels: IntArray, val width: Int, val height: Int) {

fun png(): ByteArray = PngEncoder.encode(pixels, width, height)

Encode to PNG bytes (ARGB, sRGB) via PngEncoder.

fun heatmapImage

Render a 2-D matrix to a colour-mapped RasterImage — the pure, Box-free core of showHeatmap (usable from tests, docs harnesses, or any non-Box context).

The matrix is indexed matrixrow: rows map to the vertical (y) axis, columns to the horizontal (x) axis. `flipY` draws row 0 at the *bottom* (the natural spectrogram convention where rows are frequency bins); `transpose` treats the data asx(e.g. aframe` spectrogram → time on x, frequency on y). SeeshowHeatmap` for the parameter meanings.

body — 104 lines, from line 75
fun heatmapImage(
    matrix: Array<DoubleArray>,
    colormap: String = "viridis",
    width: Int = 1200,
    height: Int = -1,
    normalize: Boolean = true,
    vmin: Double = Double.NaN,
    vmax: Double = Double.NaN,
    logScale: Boolean = false,
    smooth: Boolean = true,
    flipY: Boolean = true,
    transpose: Boolean = false,
    overlay: List<Pair<Double, Double>>? = null,
    overlayColor: Int = -0x1, // opaque white (0xFFFFFFFF)
    overlayThickness: Int = 2,
): RasterImage {
    require(matrix.isNotEmpty() && matrix[0].isNotEmpty()) { "heatmap: matrix must be non-empty" }
    val cols0 = matrix[0].size
    require(matrix.all { it.size == cols0 }) { "heatmap: matrix must be rectangular (row lengths differ)" }

    // Logical grid the caller means to see: rows -> y, cols -> x (after an optional transpose).
    val rows = if (transpose) cols0 else matrix.size
    val cols = if (transpose) matrix.size else cols0
    fun raw(r: Int, c: Int): Double = if (transpose) matrix[c][r] else matrix[r][c]
    fun t(v: Double): Double = if (logScale) log10(max(v, 1e-12)) else v

    // Colour-scale bounds, in the (post-logScale) value domain.
    var lo = vmin
    var hi = vmax
    if (lo.isNaN() || hi.isNaN()) {
        if (normalize) {
            var mn = Double.POSITIVE_INFINITY
            var mx = Double.NEGATIVE_INFINITY
            for (r in 0 until rows) for (c in 0 until cols) {
                val v = t(raw(r, c))
                if (v < mn) mn = v
                if (v > mx) mx = v
            }
            if (lo.isNaN()) lo = mn
            if (hi.isNaN()) hi = mx
        } else {
            if (lo.isNaN()) lo = 0.0
            if (hi.isNaN()) hi = 1.0
        }
    }
    val span = (hi - lo).takeIf { it > 1e-12 } ?: 1.0
    fun norm(v: Double): Double = ((t(v) - lo) / span).coerceIn(0.0, 1.0)

    // Precompute the ARGB stops for the whole map, then index by a quantised parameter — this turns
    // per-pixel colormap interpolation into a table lookup (the hot loop is the image, not the grid),
    // so a wide capture stays cheap.
    val lut = IntArray(1024) { Colormaps.sample(colormap, it / 1023.0).toArgb() }
    fun colorOf(v: Double): Int = lut[(norm(v) * 1023.0).roundToInt().coerceIn(0, 1023)]

    val w = width.coerceAtLeast(1)
    val h = (if (height > 0) height else (width.toDouble() * rows / cols).roundToInt()).coerceAtLeast(1)
    val px = IntArray(w * h)

    val gx = (cols - 1).coerceAtLeast(1).toDouble()
    val gy = (rows - 1).coerceAtLeast(1).toDouble()
    for (yp in 0 until h) {
        // Map output row -> grid row; flipY puts grid-row 0 at the bottom of the image.
        val yf = if (h == 1) 0.0 else yp.toDouble() / (h - 1)
        val fy = (if (flipY) 1.0 - yf else yf) * gy
        for (xp in 0 until w) {
            val fx = (if (w == 1) 0.0 else xp.toDouble() / (w - 1)) * gx
            val v = if (smooth) bilinear(::raw, fx, fy, cols, rows) else raw(
                fy.roundToInt().coerceIn(0, rows - 1),
                fx.roundToInt().coerceIn(0, cols - 1),
            )
            px[xp + yp * w] = colorOf(v)
        }
    }

    // Optional bright overlay trace (e.g. a spectral-centroid line over a spectrogram). Points are in
    // normalized image coords with (0,0) at the bottom-left, (1,1) top-right, drawn as a connected
    // polyline `overlayThickness` px wide.
    if (overlay != null && overlay.isNotEmpty()) {
        val half = (overlayThickness - 1).coerceAtLeast(0) / 2
        fun stamp(xp: Int, yp: Int) {
            for (dy in -half..half) for (dx in -half..half) {
                val xx = xp + dx; val yy = yp + dy
                if (xx in 0 until w && yy in 0 until h) px[xx + yy * w] = overlayColor
            }
        }
        fun toPx(p: Pair<Double, Double>): Pair<Int, Int> =
            (p.first.coerceIn(0.0, 1.0) * (w - 1)).roundToInt() to
                ((1.0 - p.second.coerceIn(0.0, 1.0)) * (h - 1)).roundToInt()

        var (px0, py0) = toPx(overlay[0])
        stamp(px0, py0)
        for (i in 1 until overlay.size) {
            val (px1, py1) = toPx(overlay[i])
            // DDA line between consecutive points.
            val steps = max(kotlin.math.abs(px1 - px0), kotlin.math.abs(py1 - py0)).coerceAtLeast(1)
            for (s in 1..steps) {
                val f = s.toDouble() / steps
                stamp((px0 + (px1 - px0) * f).roundToInt(), (py0 + (py1 - py0) * f).roundToInt())
            }
            px0 = px1; py0 = py1
        }
    }
    return RasterImage(px, w, h)
}

fun Box.showHeatmap

Terse "put this 2-D field on the screen as a colour-mapped heatmap" helper — the raster counterpart of showSignals.

Collapses the recurring boilerplate (build an ARGB IntArray, min-max normalise, bilinear- upsample a coarse grid, flip the y-axis, pack the colormap, PngEncoder.encode, display) that every spectrogram / matrix / field capture was hand-rolling (see spectral_examples.md) into one call. The PNG is displayed into channel on return (via display, which detects PNG bytes and shows an inline image) and also returned so callers can persist or re-display it.

The matrix is indexed matrixrow: rows map to the vertical (y) axis, columns to the horizontal (x) axis. By default `flipY` draws row 0 at the *bottom* (the natural convention for a spectrogram whose rows are frequency bins). For a spectrogram whose data isframe` passtranspose` = true so frames become the x-axis and bins the y-axis.

@param matrix the data, row; must be non-empty and rectangular. @param colormap a Colormaps name — viridis / magma / inferno / plasma / turbo / coolwarm / grayscale / cyanorange (default viridis). Colormaps.names is the list. @param width output width in pixels; height defaults to an aspect-preserving value. @param height output height in pixels; <= 0 derives it from the grid aspect ratio. @param normalize min-max the (optionally log-scaled) values into [0,1] before colouring. With false and no vmin/vmax the values are assumed already in [0,1]. @param vmin,vmax explicit colour-scale bounds (in the value domain after logScale); NaN means "derive from the data" (when normalize) or 0/1 (when not). @param logScale map each value through log10(max(v, 1e-12)) before normalising — use for magnitude spectra / anything spanning orders of magnitude (a dB-like view). @param smooth bilinear-upsample the coarse grid so the magnified image is smooth, not blocky; false gives crisp nearest-neighbour cells. @param flipY draw row 0 at the bottom (default) rather than the top. @param transpose treat matrix as x instead of row (e.g. a frame spectrogram → time on x, frequency on y). @param overlay optional bright trace drawn over the heatmap (e.g. a spectral-centroid line); points are normalized image coords, (0,0) bottom-left, (1,1) top-right. @param overlayColor ARGB colour of the overlay trace (default opaque white). @param overlayThickness stroke width of the overlay trace in pixels.

fun Box.showHeatmap(
    matrix: Array<DoubleArray>,
    colormap: String = "viridis",
    channel: String = "output",
    width: Int = 1200,
    height: Int = -1,
    normalize: Boolean = true,
    vmin: Double = Double.NaN,
    vmax: Double = Double.NaN,
    logScale: Boolean = false,
    smooth: Boolean = true,
    flipY: Boolean = true,
    transpose: Boolean = false,
    overlay: List<Pair<Double, Double>>? = null,
    overlayColor: Int = -0x1,
    overlayThickness: Int = 2,
): ByteArray {
    val png = heatmapImage(
        matrix, colormap, width, height, normalize, vmin, vmax, logScale, smooth, flipY, transpose,
        overlay, overlayColor, overlayThickness,
    ).png()
    display(channel, png)
    return png
}

fun Box.showHeatmap

List<List<Number>> convenience — copies into a rectangular Array<DoubleArray>.

fun Box.showHeatmap(
    matrix: List<List<Number>>,
    colormap: String = "viridis",
    channel: String = "output",
    width: Int = 1200,
    height: Int = -1,
    normalize: Boolean = true,
    vmin: Double = Double.NaN,
    vmax: Double = Double.NaN,
    logScale: Boolean = false,
    smooth: Boolean = true,
    flipY: Boolean = true,
    transpose: Boolean = false,
    overlay: List<Pair<Double, Double>>? = null,
    overlayColor: Int = -0x1,
    overlayThickness: Int = 2,
): ByteArray {
    require(matrix.isNotEmpty()) { "showHeatmap: matrix must be non-empty" }
    val arr = Array(matrix.size) { r -> DoubleArray(matrix[r].size) { c -> matrix[r][c].toDouble() } }
    return showHeatmap(
        arr, colormap, channel, width, height, normalize, vmin, vmax, logScale, smooth, flipY, transpose,
        overlay, overlayColor, overlayThickness,
    )
}

fun Box.showImage(pixels: IntArray, width: Int, height: Int, channel: String = "output"): ByteArray

Show a ready-made ARGB pixels raster as an inline image (encode + display), returning the PNG bytes. The thin sibling of showHeatmap for when you already have a pixel buffer.

fun Box.showImage(pixels: IntArray, width: Int, height: Int, channel: String = "output"): ByteArray {
    require(pixels.size >= width * height) { "showImage: pixels ($width x $height) shorter than $width*$height" }
    val png = PngEncoder.encode(pixels, width, height)
    display(channel, png)
    return png
}

Pack a Color4 (components in [0,1]) into an ARGB int for PngEncoder.

private fun Color4.toArgb(): Int {
    fun ch(x: Double): Int = (x.coerceIn(0.0, 1.0) * 255.0 + 0.5).toInt()
    return (ch(a) shl 24) or (ch(r) shl 16) or (ch(g) shl 8) or ch(b)
}

Bilinear sample of a row reader at fractional grid coords (fx = col, fy = row).

private inline fun bilinear(raw: (Int, Int) -> Double, fx: Double, fy: Double, cols: Int, rows: Int): Double {
    val x0 = fx.toInt().coerceIn(0, cols - 1)
    val y0 = fy.toInt().coerceIn(0, rows - 1)
    val x1 = (x0 + 1).coerceAtMost(cols - 1)
    val y1 = (y0 + 1).coerceAtMost(rows - 1)
    val tx = (fx - x0).coerceIn(0.0, 1.0)
    val ty = (fy - y0).coerceIn(0.0, 1.0)
    val top = raw(y0, x0) * (1 - tx) + raw(y0, x1) * tx
    val bot = raw(y1, x0) * (1 - tx) + raw(y1, x1) * tx
    return top * (1 - ty) + bot * ty
}