Skip to content

VectorFieldTopology.kt

src/jvmMain/kotlin/afield/geometry/topology/vectorfield/VectorFieldTopology.kt · 389 lines

package afield.geometry.topology.vectorfield

import afield.geometry.network.Channel
import afield.geometry.network.GeometryCurve
import afield.geometry.network.GeometryNetwork
import afield.geometry.network.GeometryNodes
import afield.geometry.path.FLine
import afield.geometry.topology.morsesmale.CriticalType
import afield.geometry.topology.tracking.Feature
import afield.math.linalg.Vec4
import afield.media.image.Img
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.hypot
import kotlin.math.sqrt

The topology of a 2-D flow field: where it stands still, and the curves that divide it up.

VectorFieldTopology.compute reads the flow either as two images, u and v in their first channel, or as one RGB image with u in channel 0 and v in channel 1. It finds the field's zeros as FlowCriticalPoints, classified by FlowCriticalType into sources, sinks, saddles, the two kinds of focus and centres, together with the Separatrix curves that leave each saddle along its eigen-directions. Those curves cut the frame into regions inside which every streamline starts at the same place and ends at the same place, so they summarise the whole field rather than sampling it.

What comes back is ready to draw: VectorFieldTopology.streamlines for a grid-seeded picture of the flow, VectorFieldTopology.toFLines for the separatrices and VectorFieldTopology.criticalPointsFLine for the points, coloured by type. VectorFieldTopology.features hands the critical points to afield.geometry.topology.tracking.FeatureTracker, so a flow that changes can be followed frame by frame, and VectorFieldTopology.network gives the same topology as a GeometryNetwork, which is what the other geometry modules read.

Everything here is Kotlin, with no optional native library behind it. A scalar field can be read the same way through VectorFieldTopology.gradientOf, which turns one into its gradient, or into the flow along its contours. The worked account is public/docs/vector_field_topology.md.

enum class FlowCriticalType { SOURCE, SINK, SADDLE, REPELLING_FOCUS, ATTRACTING_FOCUS, CENTER }

What a flow does near a critical point: flows out (SOURCE), in (SINK), spirals out or in (REPELLING_FOCUS, ATTRACTING_FOCUS), circles (CENTER), or comes in along one direction and leaves along another (SADDLE).

data class FlowCriticalPoint

A zero of the flow, at (x, y) in pixels. jacobian is the flow's derivative there, row-major (du/dx, du/dy, dv/dx, dv/dy); divergence and curl are its trace and rotation, and strength sqrt(|det J|), how fast the flow moves away from the point (in units of the field per pixel).

data class FlowCriticalPoint(
    val id: Int,
    val x: Double,
    val y: Double,
    val type: FlowCriticalType,
    val jacobian: DoubleArray,
) {

val divergence get() = jacobian[0] + jacobian[3]

val curl get() = jacobian[2] - jacobian[1]

val strength get() = sqrt(abs(jacobian[0] * jacobian[3] - jacobian[1] * jacobian[2]))

override fun equals(other: Any?)

    override fun equals(other: Any?) = other is FlowCriticalPoint && other.id == id && other.x == x && other.y == y

override fun hashCode() = id

class Separatrix(val saddle: Int, val unstable: Boolean, val points: DoubleArray, val end: Int?)

A separatrix: the streamline that leaves a saddle along one of its four eigen-directions. An unstable one flows out of the saddle (forward in time); a stable one flows into it (traced backward). points are pixel positions starting at the saddle; end is the critical point it reached, or null if it left the frame, ran out of length, or stopped at a point weaker than minStrength.

val size get() = points.size / 2

fun toFLine(): FLine

    fun toFLine(): FLine = FLine().also { f ->
        for (i in 0 until size) if (i == 0) f.moveTo(points[0], points[1]) else f.lineTo(points[2 * i], points[2 * i + 1])
        f.properties["color"] = if (unstable) VectorFieldTopology.UNSTABLE else VectorFieldTopology.STABLE
        f.properties["thickness"] = 1.5
    }
}

class VectorFieldTopology internal constructor

The topology of a 2D vector field: its critical points, classified, and the separatrices joining its saddles to the rest. The separatrices divide the frame into regions inside which all streamlines start at the same place and end at the same place.

class VectorFieldTopology internal constructor(
    val width: Int,
    val height: Int,
    val criticalPoints: List<FlowCriticalPoint>,
    val separatrices: List<Separatrix>,
    private val field: Field,
) {

fun of(vararg types: FlowCriticalType) = criticalPoints.filter { it.type in types }

Critical points of the given types.

fun toFLines(): List<FLine> = separatrices.map { it.toFLine() }

The separatrices as FLines, unstable in UNSTABLE colour, stable in STABLE.

fun criticalPointsFLine(size: Double = 6.0): FLine

The critical points as one pointed FLine, each node coloured by type: sources and repelling foci red, sinks and attracting foci blue, saddles white, centres yellow.

    fun criticalPointsFLine(size: Double = 6.0): FLine = FLine().also { f ->
        criticalPoints.forEach { c -> f.moveTo(c.x, c.y); f.nodes.last().properties["color"] = colourOf(c.type) }
        f.properties["pointed"] = true; f.properties["stroked"] = false; f.properties["pointSize"] = size
    }

fun streamline(x: Double, y: Double, length: Double = 200.0, step: Double = 0.5, minSpeed: Double = 0.0): FLine

The streamline through (x, y), traced length pixels forward and backward (RK4, steps of step pixels, stopping at the frame's edge or where the flow is slower than minSpeed). Streamlines follow the flow's direction whatever its speed, so a minSpeed keeps them out of places where the flow is only noise.

    fun streamline(x: Double, y: Double, length: Double = 200.0, step: Double = 0.5, minSpeed: Double = 0.0): FLine {
        val back = field.trace(x, y, -1, length, step, emptyList(), -1, minSpeed).first
        val fwd = field.trace(x, y, 1, length, step, emptyList(), -1, minSpeed).first
        return FLine().also { f ->
            val n = back.size / 2
            for (i in n - 1 downTo 0) if (i == n - 1) f.moveTo(back[2 * i], back[2 * i + 1]) else f.lineTo(back[2 * i], back[2 * i + 1])
            for (i in 1 until fwd.size / 2) f.lineTo(fwd[2 * i], fwd[2 * i + 1])
        }
    }

fun streamlines(spacing: Double = 12.0, length: Double = 40.0, color: Vec4 = Vec4(0.6f, 0.6f, 0.6f, 0.5f), minSpeed: Double = 0.0): List<FLine>

Streamlines seeded on a grid every spacing pixels, each traced length pixels both ways: a quick picture of the flow to draw the topology over. Seeds where the flow is slower than minSpeed are skipped, and lines stop there.

    fun streamlines(spacing: Double = 12.0, length: Double = 40.0, color: Vec4 = Vec4(0.6f, 0.6f, 0.6f, 0.5f), minSpeed: Double = 0.0): List<FLine> {
        val probe = DoubleArray(2)
        val out = ArrayList<FLine>()
        var y = spacing / 2
        while (y < height - 1) {
            var x = spacing / 2
            while (x < width - 1) {
                if (minSpeed > 0 && (!field.at(x, y, probe) || hypot(probe[0], probe[1]) < minSpeed)) { x += spacing; continue }
                out.add(streamline(x, y, length, minSpeed = minSpeed).also { it.properties["color"] = color; it.properties["thickness"] = 1.0 })
                x += spacing
            }
            y += spacing
        }
        return out
    }

fun features(minStrength: Double = 0.0): List<Feature>

The critical points as tracking Features, for afield.geometry.topology.tracking.FeatureTracker: sinks and attracting foci as MINIMUM, sources and repelling foci as MAXIMUM, saddles as SADDLE (centres are left out). value is the divergence and persistence the strength.

    fun features(minStrength: Double = 0.0): List<Feature> = criticalPoints.mapNotNull { c ->
        val t = when (c.type) {
            FlowCriticalType.SINK, FlowCriticalType.ATTRACTING_FOCUS -> CriticalType.MINIMUM
            FlowCriticalType.SOURCE, FlowCriticalType.REPELLING_FOCUS -> CriticalType.MAXIMUM
            FlowCriticalType.SADDLE -> CriticalType.SADDLE
            FlowCriticalType.CENTER -> null
        } ?: return@mapNotNull null
        if (c.strength < minStrength) null else Feature(t, c.x, c.y, c.divergence, c.strength, c.id)
    }

fun network(): GeometryNetwork

The topology as a GeometryNetwork in pixel space: critical points as nodes (channels type, the FlowCriticalType ordinal, and strength), separatrices as curves from their saddle to the point they reach (attribute unstable, 1 or 0). A separatrix that leaves the frame ends at a node of type -1 at its last point.

    fun network(): GeometryNetwork {
        val ids = ArrayList<Int>(); val pos = ArrayList<Double>(); val type = ArrayList<Double>(); val strength = ArrayList<Double>()
        criticalPoints.forEach { ids.add(it.id); pos.add(it.x); pos.add(it.y); type.add(it.type.ordinal.toDouble()); strength.add(it.strength) }
        var next = (criticalPoints.maxOfOrNull { it.id } ?: -1) + 1
        val curves = separatrices.mapIndexed { i, s ->
            val end = s.end ?: next++.also { id ->
                ids.add(id); pos.add(s.points[s.points.size - 2]); pos.add(s.points[s.points.size - 1]); type.add(-1.0); strength.add(0.0)
            }
            val (a, b) = if (s.unstable) s.saddle to end else end to s.saddle
            val pts = if (s.unstable) s.points else DoubleArray(s.points.size).also { r -> for (k in 0 until s.size) { r[2 * k] = s.points[2 * (s.size - 1 - k)]; r[2 * k + 1] = s.points[2 * (s.size - 1 - k) + 1] } }
            GeometryCurve(i, 2, pts, a, b, attributes = mapOf("unstable" to if (s.unstable) 1.0 else 0.0))
        }
        return GeometryNetwork(2,
            GeometryNodes(2, ids.toIntArray(), pos.toDoubleArray(), mapOf("type" to Channel.scalar(type.toDoubleArray()), "strength" to Channel.scalar(strength.toDoubleArray()))),
            curves, mapOf("source" to "vectorFieldTopology"))
    }

override fun toString()

    override fun toString() = "VectorFieldTopology(${width}x$height, " +
        criticalPoints.groupingBy { it.type }.eachCount().entries.joinToString { "${it.value} ${it.key.name.lowercase()}" } +
        ", ${separatrices.size} separatrices)"

internal class Field(val w: Int, val h: Int, val u: FloatArray, val v: FloatArray)

    internal class Field(val w: Int, val h: Int, val u: FloatArray, val v: FloatArray) {

fun at(x: Double, y: Double, out: DoubleArray): Boolean

Bilinear sample, or null outside the frame.

        fun at(x: Double, y: Double, out: DoubleArray): Boolean {
            if (x < 0 || y < 0 || x > w - 1 || y > h - 1) return false
            val x0 = floor(x).toInt().coerceAtMost(w - 2).coerceAtLeast(0); val y0 = floor(y).toInt().coerceAtMost(h - 2).coerceAtLeast(0)
            val fx = x - x0; val fy = y - y0
            val i = y0 * w + x0
            if (w == 1 || h == 1) { out[0] = u[i].toDouble(); out[1] = v[i].toDouble(); return true }
            out[0] = (u[i] * (1 - fx) + u[i + 1] * fx) * (1 - fy) + (u[i + w] * (1 - fx) + u[i + w + 1] * fx) * fy
            out[1] = (v[i] * (1 - fx) + v[i + 1] * fx) * (1 - fy) + (v[i + w] * (1 - fx) + v[i + w + 1] * fx) * fy
            return true
        }

fun trace(x0: Double, y0: Double, sign: Int, length: Double, step: Double, targets: List<FlowCriticalPoint>, from: Int, minSpeed: Double = 0.0, armed: Boolean =

Traces with unit-speed RK4 (the direction of the flow, step pixels at a time) in direction sign, up to length pixels. Stops on leaving the frame, where the flow vanishes, or within captureRadius (3 px) of a critical point other than from; returns the points and the index of the critical point reached, if any. Where the flow is slower than minSpeed the trace stops too; with armed false that check waits until the trace has first been faster than minSpeed, so a separatrix can leave the slow neighbourhood of its own saddle.

        fun trace(x0: Double, y0: Double, sign: Int, length: Double, step: Double, targets: List<FlowCriticalPoint>, from: Int, minSpeed: Double = 0.0, armed: Boolean = true): Pair<DoubleArray, Int?> {
            val pts = ArrayList<Double>(); pts.add(x0); pts.add(y0)
            var x = x0; var y = y0; var travelled = 0.0
            val k = DoubleArray(2)
            var fast = armed
            fun dir(px: Double, py: Double, out: DoubleArray): Boolean {
                if (!at(px, py, k)) return false
                val m = hypot(k[0], k[1]); if (m < 1e-12) return false
                if (m >= minSpeed) fast = true else if (fast) return false
                out[0] = sign * k[0] / m; out[1] = sign * k[1] / m; return true
            }
            val k1 = DoubleArray(2); val k2 = DoubleArray(2); val k3 = DoubleArray(2); val k4 = DoubleArray(2)
            while (travelled < length) {
                if (!dir(x, y, k1) || !dir(x + k1[0] * step / 2, y + k1[1] * step / 2, k2) ||
                    !dir(x + k2[0] * step / 2, y + k2[1] * step / 2, k3) || !dir(x + k3[0] * step, y + k3[1] * step, k4)) break
                x += step / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]); y += step / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
                travelled += step
                pts.add(x); pts.add(y)
                if (travelled > 2 * CAPTURE) for (c in targets) if (c.id != from && hypot(c.x - x, c.y - y) < CAPTURE) {
                    pts.add(c.x); pts.add(c.y); return pts.toDoubleArray() to c.id
                }
            }
            return pts.toDoubleArray() to null
        }
    }

companion object

internal const val CAPTURE = 1.5

val UNSTABLE = Vec4(1f, 0.45f, 0.25f, 1f)

val STABLE = Vec4(0.3f, 0.6f, 1f, 1f)

fun colourOf(type: FlowCriticalType): Vec4

        fun colourOf(type: FlowCriticalType): Vec4 = when (type) {
            FlowCriticalType.SOURCE, FlowCriticalType.REPELLING_FOCUS -> Vec4(1f, 0.35f, 0.25f, 1f)
            FlowCriticalType.SINK, FlowCriticalType.ATTRACTING_FOCUS -> Vec4(0.3f, 0.55f, 1f, 1f)
            FlowCriticalType.SADDLE -> Vec4(1f, 1f, 1f, 1f)
            FlowCriticalType.CENTER -> Vec4(1f, 0.85f, 0.2f, 1f)
        }

fun compute

The topology of the flow with x components u and y components v (one-channel images of the same size, or pass both in one image to the other overload).

val topo = VectorFieldTopology.compute(u, v)
display("flow", lines(topo.streamlines() + topo.toFLines() + topo.criticalPointsFLine()))

The flow is linear on each triangle of the grid (each pixel square cut along its (x+1, y+1) diagonal, as in Field's Morse–Smale complexes), so each triangle holds at most one zero, found exactly, and its Jacobian is that triangle's. Points weaker than minStrength are dropped; a focus whose rotation is less than centerTolerance of its divergence counts as a source or sink, and one whose divergence is under centerTolerance of its rotation as a centre. Separatrices are traced up to maxLength pixels, and stop where the flow, having left the saddle, slows below minSpeed (pixels per frame; 0 traces the direction whatever the speed).

body — 43 lines, from line 268
        fun compute(
            u: Img,
            v: Img,
            minStrength: Double = 0.0,
            centerTolerance: Double = 0.02,
            maxLength: Double = 2000.0,
            step: Double = 0.5,
            minSpeed: Double = 0.0,
        ): VectorFieldTopology {
            require(u.width == v.width && u.height == v.height) { "u is ${u.width}x${u.height} but v is ${v.width}x${v.height}" }
            val w = u.width; val h = u.height
            val uu = FloatArray(w * h) { u.data[it * u.channels] }; val vv = FloatArray(w * h) { v.data[it * v.channels] }
            require(uu.none { it.isNaN() } && vv.none { it.isNaN() }) { "the field has NaN values" }
            val field = Field(w, h, uu, vv)
            val all = ArrayList<FlowCriticalPoint>()
            for (y in 0 until h - 1) for (x in 0 until w - 1) {
                val a = y * w + x
                // the two triangles of this square: (a, a+1, a+w+1) and (a, a+w+1, a+w)
                for (tri in 0..1) {
                    val p1 = if (tri == 0) a + 1 else a + w + 1
                    val p2 = if (tri == 0) a + w + 1 else a + w
                    zeroIn(field, a, p1, p2, w)?.let { (px, py, j) ->
                        // a zero on a shared edge or vertex would be found twice
                        if (all.none { abs(it.x - px) < 1e-9 && abs(it.y - py) < 1e-9 })
                            all.add(FlowCriticalPoint(2 * a + tri, px, py, classify(j, centerTolerance), j))
                    }
                }
            }
            val cps = all.filter { it.strength >= minStrength }
            val kept = cps.map { it.id }.toSet()
            val seps = ArrayList<Separatrix>()
            for (s in cps) if (s.type == FlowCriticalType.SADDLE) {
                val j = s.jacobian
                for ((lambda, ev) in eigen(j)) for (sgn in intArrayOf(1, -1)) {
                    val unstable = lambda > 0
                    val sx = s.x + sgn * ev[0] * CAPTURE * 0.8; val sy = s.y + sgn * ev[1] * CAPTURE * 0.8
                    // traced against every zero, so it stops cleanly at a weak one too (and then ends nowhere)
                    val (pts, end) = field.trace(sx, sy, if (unstable) 1 else -1, maxLength, step, all, s.id, minSpeed, armed = false)
                    seps.add(Separatrix(s.id, unstable, doubleArrayOf(s.x, s.y) + pts, end?.takeIf { it in kept }))
                }
            }
            return VectorFieldTopology(w, h, cps, seps, field)
        }

fun compute(flow: Img, minStrength: Double = 0.0, centerTolerance: Double = 0.02, maxLength: Double = 2000.0, step: Double = 0.5, minSpeed: Double = 0.0): Vecto

The topology of a flow given as one image with u in channel 0 and v in channel 1 (a third channel is ignored).

        fun compute(flow: Img, minStrength: Double = 0.0, centerTolerance: Double = 0.02, maxLength: Double = 2000.0, step: Double = 0.5, minSpeed: Double = 0.0): VectorFieldTopology {
            require(flow.channels >= 3) { "a flow image holds u and v in channels 0 and 1 of an RGB image, this has ${flow.channels} channel(s)" }
            val n = flow.width * flow.height
            return compute(Img(flow.width, flow.height, 1, FloatArray(n) { flow.data[it * flow.channels] }),
                Img(flow.width, flow.height, 1, FloatArray(n) { flow.data[it * flow.channels + 1] }), minStrength, centerTolerance, maxLength, step, minSpeed)
        }

fun gradientOf(field: Img, rotate: Boolean = false): Img

The flow (u, v) = rotated or plain gradient of a scalar field: rotate false gives the gradient, which flows uphill (sinks at maxima, sources at minima, saddles at saddles), true gives the gradient turned 90° (flow along the contours: centres at extrema). The result holds u and v in channels 0 and 1 of an RGB image, channel 2 zero.

        fun gradientOf(field: Img, rotate: Boolean = false): Img {
            val w = field.width; val h = field.height
            val out = FloatArray(w * h * 3)
            fun f(x: Int, y: Int) = field.data[(y.coerceIn(0, h - 1) * w + x.coerceIn(0, w - 1)) * field.channels]
            for (y in 0 until h) for (x in 0 until w) {
                val gx = (f(x + 1, y) - f(x - 1, y)) / 2; val gy = (f(x, y + 1) - f(x, y - 1)) / 2
                out[3 * (y * w + x)] = if (rotate) -gy else gx
                out[3 * (y * w + x) + 1] = if (rotate) gx else gy
            }
            return Img(w, h, 3, out)
        }

        private fun zeroIn(f: Field, i0: Int, i1: Int, i2: Int, w: Int): Triple<Double, Double, DoubleArray>? {
            val x0 = (i0 % w).toDouble(); val y0 = (i0 / w).toDouble()
            val x1 = (i1 % w).toDouble(); val y1 = (i1 / w).toDouble()
            val x2 = (i2 % w).toDouble(); val y2 = (i2 / w).toDouble()
            val u0 = f.u[i0].toDouble(); val u1 = f.u[i1].toDouble(); val u2 = f.u[i2].toDouble()
            val v0 = f.v[i0].toDouble(); val v1 = f.v[i1].toDouble(); val v2 = f.v[i2].toDouble()
            // solve u0 + a(u1-u0) + b(u2-u0) = 0 and the same for v, for barycentric a, b
            val a11 = u1 - u0; val a12 = u2 - u0; val a21 = v1 - v0; val a22 = v2 - v0
            val det = a11 * a22 - a12 * a21
            if (abs(det) < 1e-18) return null
            val a = (-u0 * a22 + v0 * a12) / det
            val b = (-v0 * a11 + u0 * a21) / det
            val eps = -1e-12
            if (a < eps || b < eps || a + b > 1 - eps) return null
            val px = x0 + a * (x1 - x0) + b * (x2 - x0); val py = y0 + a * (y1 - y0) + b * (y2 - y0)
            // the triangle's Jacobian: d(u,v)/d(a,b) times d(a,b)/d(x,y)
            val e11 = x1 - x0; val e12 = x2 - x0; val e21 = y1 - y0; val e22 = y2 - y0
            val ed = e11 * e22 - e12 * e21
            val i11 = e22 / ed; val i12 = -e12 / ed; val i21 = -e21 / ed; val i22 = e11 / ed
            val j = doubleArrayOf(a11 * i11 + a12 * i21, a11 * i12 + a12 * i22, a21 * i11 + a22 * i21, a21 * i12 + a22 * i22)
            return Triple(px, py, j)
        }

internal fun classify(j: DoubleArray, centerTolerance: Double): FlowCriticalType

        internal fun classify(j: DoubleArray, centerTolerance: Double): FlowCriticalType {
            val tr = j[0] + j[3]; val det = j[0] * j[3] - j[1] * j[2]
            if (det < 0) return FlowCriticalType.SADDLE
            val disc = tr * tr - 4 * det
            if (disc >= 0) return if (tr > 0) FlowCriticalType.SOURCE else FlowCriticalType.SINK
            val rot = sqrt(-disc) / 2
            return when {
                abs(tr) / 2 < centerTolerance * rot -> FlowCriticalType.CENTER
                rot < centerTolerance * abs(tr) / 2 -> if (tr > 0) FlowCriticalType.SOURCE else FlowCriticalType.SINK
                tr > 0 -> FlowCriticalType.REPELLING_FOCUS
                else -> FlowCriticalType.ATTRACTING_FOCUS
            }
        }

Real eigenvalues and unit eigenvectors of a saddle's Jacobian.

        private fun eigen(j: DoubleArray): List<Pair<Double, DoubleArray>> {
            val tr = j[0] + j[3]; val det = j[0] * j[3] - j[1] * j[2]
            val s = sqrt((tr * tr / 4 - det).coerceAtLeast(0.0))
            return listOf(tr / 2 + s, tr / 2 - s).map { l ->
                // (J - l) e = 0: take the longer of the two rows' null vectors
                val r1 = doubleArrayOf(j[1], l - j[0]); val r2 = doubleArrayOf(l - j[3], j[2])
                val e = if (hypot(r1[0], r1[1]) > hypot(r2[0], r2[1])) r1 else r2
                val m = hypot(e[0], e[1]).takeIf { it > 1e-18 } ?: 1.0
                l to doubleArrayOf(e[0] / m, e[1] / m)
            }
        }
    }
}