Skip to content

ContourTrees.kt

src/jvmMain/kotlin/afield/geometry/topology/trees/ContourTrees.kt · 356 lines

package afield.geometry.topology.trees

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.math.linalg.Vec4
import afield.media.image.Img

Merge trees and contour trees of a scalar field on a regular grid.

ContourTrees.joinTree follows the components of the sublevel sets as the threshold rises, ContourTrees.splitTree does the same from the top down, and ContourTrees.contourTree follows every component of every level set, so joins and splits are both recorded. Each hands back a ScalarTree of TreeNodes typed by TreeNodeType and TreeArcs between them, and every arc owns the pixels of the level-set component it stands for, readable per pixel through ScalarTree.arcOfPixel.

ScalarTree.simplify is what makes a tree readable: it repeatedly removes leaf arcs whose value span is under a persistence threshold, so small bumps and dips go first. ScalarTree.plot draws the tree with height for value, ScalarTree.segmentationImage paints each pixel by the arc that owns it, and ScalarTree.network hands the tree on as a GeometryNetwork.

The field is read as the same piecewise-linear surface Field's Morse–Smale complexes use: the grid cut into triangles along one diagonal, with ties between equal samples broken by pixel index. The worked account is public/docs/contour_trees.md.

enum class TreeNodeType { MINIMUM, MAXIMUM, SADDLE, ROOT }

The kind of a tree node: where a component of the level set begins or ends (MINIMUM, MAXIMUM), where two components join or one splits (SADDLE), or where the tree is rooted (ROOT, the global extremum a merge tree ends at).

data class TreeNode(val id: Int, val vertex: Int, val value: Double, val x: Double, val y: Double, val type: TreeNodeType)

A node of a ScalarTree: the grid vertex it sits on, its value and position (pixels).

data class TreeNode(val id: Int, val vertex: Int, val value: Double, val x: Double, val y: Double, val type: TreeNodeType)

data class TreeArc(val id: Int, val lower: Int, val upper: Int, val vertices: IntArray)

An arc of a ScalarTree from node lower to node upper (by value), with the grid vertices it owns: the regular vertices whose level-set component it represents.

override fun equals(other: Any?) = other is TreeArc && other.id == id

override fun hashCode() = id

class ScalarTree internal constructor

A merge tree or contour tree of a scalar field on a regular grid: critical nodes joined by arcs, each arc owning the pixels whose level-set component it stands for.

The field is the same piecewise-linear one Field's Morse–Smale complexes use: the grid cut into triangles along the (x+1, y+1) diagonal, ties between equal samples broken by pixel index.

class ScalarTree internal constructor(
    val kind: String,
    val width: Int,
    val height: Int,
    val nodes: List<TreeNode>,
    val arcs: List<TreeArc>,
) {
    private val byId = nodes.associateBy { it.id }

fun node(id: Int): TreeNode = byId.getValue(id)

val arcOfPixel: IntArray by lazy

Per pixel, the id of the arc that owns it (nodes' own pixels belong to an arc ending there), or -1.

    val arcOfPixel: IntArray by lazy {
        val out = IntArray(width * height) { -1 }
        arcs.forEach { a -> a.vertices.forEach { out[it] = a.id } }
        arcs.forEach { a -> listOf(a.lower, a.upper).forEach { n -> val v = node(n).vertex; if (out[v] < 0) out[v] = a.id } }
        out
    }

fun arcsAt(id: Int): List<TreeArc> = arcs.filter { it.lower == id || it.upper == id }

The arcs at node id.

fun simplify(persistence: Double): ScalarTree

The tree with every leaf arc shorter than persistence (its value span) removed, repeatedly, and nodes left with one arc above and one below merged away. The leaf's pixels go to the arc it joined. This is the tree's persistence simplification: small bumps and dips vanish first.

    fun simplify(persistence: Double): ScalarTree {
        val arcs = this.arcs.associateBy { it.id }.toMutableMap()
        val pixels = arcs.mapValues { (_, a) -> a.vertices.toMutableList() }.toMutableMap()
        var nextArc = (arcs.keys.maxOrNull() ?: -1) + 1
        fun degree(n: Int) = arcs.values.count { it.lower == n || it.upper == n }
        fun isLeaf(n: Int) = degree(n) == 1 && node(n).type != TreeNodeType.ROOT
        while (true) {
            val leafArc = arcs.values.filter { a -> (isLeaf(a.lower) || isLeaf(a.upper)) && arcs.size > 1 }
                .minByOrNull { node(it.upper).value - node(it.lower).value } ?: break
            if (node(leafArc.upper).value - node(leafArc.lower).value >= persistence) break
            val leaf = if (isLeaf(leafArc.lower)) leafArc.lower else leafArc.upper
            val keep = if (leaf == leafArc.lower) leafArc.upper else leafArc.lower
            arcs.remove(leafArc.id)
            val orphaned = pixels.remove(leafArc.id)!! + node(leaf).vertex
            // merge away `keep` if it is now regular: one arc below, one above
            val around = arcs.values.filter { it.lower == keep || it.upper == keep }
            val below = around.filter { it.upper == keep }; val above = around.filter { it.lower == keep }
            if (below.size == 1 && above.size == 1 && node(keep).type != TreeNodeType.ROOT) {
                val a = below[0]; val b = above[0]
                arcs.remove(a.id); arcs.remove(b.id)
                val merged = pixels.remove(a.id)!! + node(keep).vertex + pixels.remove(b.id)!! + orphaned
                val id = nextArc++
                arcs[id] = TreeArc(id, a.lower, b.upper, IntArray(0))
                pixels[id] = merged.toMutableList()
            } else {
                around.firstOrNull()?.let { pixels.getValue(it.id).addAll(orphaned) }
            }
        }
        val used = arcs.values.flatMap { listOf(it.lower, it.upper) }.toSet()
        val finalArcs = arcs.values.map { TreeArc(it.id, it.lower, it.upper, pixels.getValue(it.id).toIntArray()) }.sortedBy { it.id }
        return ScalarTree(kind, width, height, nodes.filter { it.id in used }, finalArcs)
    }

fun plot(width: Double = 400.0, height: Double = 240.0, margin: Double = 0.0, color: Vec4 = Vec4(0.9f, 0.85f, 0.75f, 1f)): List<FLine>

The tree drawn the classic way, as a plot: each node at height = its value (scaled into height pixels, higher values up), leaves spread evenly along x in depth-first order and every other node above the mean of the leaves below it; arcs drawn as right-angled elbows. margin insets the whole plot from the edges of the width by height area.

    fun plot(width: Double = 400.0, height: Double = 240.0, margin: Double = 0.0, color: Vec4 = Vec4(0.9f, 0.85f, 0.75f, 1f)): List<FLine> {
        if (nodes.isEmpty()) return emptyList()
        val lo = nodes.minOf { it.value }; val hi = nodes.maxOf { it.value }
        val h = height - 2 * margin
        fun y(v: Double) = margin + h - (v - lo) / (hi - lo).coerceAtLeast(1e-12) * h
        val adj = nodes.associate { n -> n.id to arcsAt(n.id).map { if (it.lower == n.id) it.upper else it.lower } }
        val rootId = nodes.firstOrNull { it.type == TreeNodeType.ROOT }?.id ?: nodes.maxBy { adj.getValue(it.id).size }.id
        val xOf = HashMap<Int, Double>()
        var leafIndex = 0
        val leaves = nodes.count { adj.getValue(it.id).size == 1 && it.id != rootId }.coerceAtLeast(1)
        fun place(n: Int, parent: Int): Double {
            val kids = adj.getValue(n).filter { it != parent }
            val x = if (kids.isEmpty()) margin + (leafIndex++ + 0.5) / leaves * (width - 2 * margin) else kids.map { place(it, n) }.average()
            xOf[n] = x
            return x
        }
        place(rootId, -1)
        return arcs.map { a ->
            val l = node(a.lower); val u = node(a.upper)
            FLine().moveTo(xOf.getValue(l.id), y(l.value)).lineTo(xOf.getValue(l.id), y(u.value)).lineTo(xOf.getValue(u.id), y(u.value))
                .also { it.properties["color"] = colourOf(a.id); it.properties["thickness"] = 2.0; it.properties["arcId"] = a.id }
        } + FLine().also { f ->
            nodes.forEach { n -> f.moveTo(xOf.getValue(n.id), y(n.value)); f.nodes.last().properties["color"] = color }
            f.properties["pointed"] = true; f.properties["stroked"] = false; f.properties["pointSize"] = 6.0
        }
    }

fun segmentationImage(): Img

Each arc's pixels in its own colour: the field divided into the regions the tree's arcs stand for.

    fun segmentationImage(): Img {
        val data = FloatArray(width * height * 3)
        arcOfPixel.forEachIndexed { i, a ->
            if (a < 0) return@forEachIndexed
            val c = colourOf(a)
            data[3 * i] = c.x; data[3 * i + 1] = c.y; data[3 * i + 2] = c.z
        }
        return Img(width, height, 3, data)
    }

fun network(): GeometryNetwork

The tree as a GeometryNetwork in image space: nodes at their pixels (channels type, value), each arc a straight curve between its nodes (attributes lowerValue, upperValue, pixels).

    fun network(): GeometryNetwork = GeometryNetwork(
        2,
        GeometryNodes(2, IntArray(nodes.size) { nodes[it].id }, DoubleArray(nodes.size * 2) { i -> nodes[i / 2].let { if (i % 2 == 0) it.x else it.y } },
            mapOf("type" to Channel.scalar(DoubleArray(nodes.size) { nodes[it].type.ordinal.toDouble() }),
                "value" to Channel.scalar(DoubleArray(nodes.size) { nodes[it].value }))),
        arcs.map { a ->
            val l = node(a.lower); val u = node(a.upper)
            GeometryCurve(a.id, 2, doubleArrayOf(l.x, l.y, u.x, u.y), l.id, u.id,
                attributes = mapOf("lowerValue" to l.value, "upperValue" to u.value, "pixels" to a.vertices.size.toDouble()))
        },
        mapOf("source" to kind),
    )

override fun toString() = "ScalarTree($kind, ${nodes.size} nodes, ${arcs.size} arcs)"

companion object

fun colourOf(id: Int): Vec4

        fun colourOf(id: Int): Vec4 {
            val h = (id * -0x61c88647) ushr 8
            return Vec4(0.35f + 0.6f * ((h and 0xff) / 255f), 0.35f + 0.6f * (((h shr 8) and 0xff) / 255f), 0.35f + 0.6f * (((h shr 16) and 0xff) / 255f), 1f)
        }
    }
}

object ContourTrees

Merge trees and contour trees of 2D scalar fields.

val field = imgFromFile("terrain.png").luma()
val tree = ContourTrees.contourTree(field).simplify(persistence = 0.05)
display("tree", tree.plot())

A join tree follows the sublevel sets {f ≤ t} as t rises: a component is born at each minimum, components join at saddles, and everything has joined by the global maximum. A split tree is the same from the top down. The contour tree combines them: it follows every connected component of every level set {f = t}, so it records joins and splits.

object ContourTrees {
    private class Grid(val w: Int, val h: Int, val values: FloatArray) {
        val n = w * h
        val order: IntArray = (0 until n).sortedWith(compareBy({ values[it] }, { it })).toIntArray()
        val rank = IntArray(n).also { r -> order.forEachIndexed { i, v -> r[v] = i } }
        private val dx = intArrayOf(1, 1, 0, -1, -1, 0)
        private val dy = intArrayOf(0, 1, 1, 0, -1, -1)
        inline fun forNeighbours(v: Int, f: (Int) -> Unit) {
            val x = v % w; val y = v / w
            for (k in 0 until 6) {
                val nx = x + dx[k]; val ny = y + dy[k]
                if (nx in 0 until w && ny in 0 until h) f(ny * w + nx)
            }
        }
    }

    private fun gridOf(img: Img, channel: Int): Grid {
        require(channel in 0 until img.channels) { "no channel $channel" }
        val v = FloatArray(img.width * img.height) { img.data[it * img.channels + channel] }
        require(v.none { it.isNaN() }) { "the field has NaN values" }
        return Grid(img.width, img.height, v)
    }

    private class UnionFind(n: Int) {
        val parent = IntArray(n) { it }
        fun find(v: Int): Int { var r = v; while (parent[r] != r) r = parent[r]; var x = v; while (parent[x] != r) { val nx = parent[x]; parent[x] = r; x = nx }; return r }
    }

The augmented join tree (ascending) or split tree (descending): for every vertex, the vertices it is joined to below (join) or above (split). Each vertex links to the most recently added vertex of every component it touches.

    private fun augmented(g: Grid, ascending: Boolean): Array<IntArray> {
        val uf = UnionFind(g.n)
        val head = IntArray(g.n) { -1 }
        val seen = BooleanArray(g.n)
        val down = Array(g.n) { IntArray(0) }
        val seq = if (ascending) g.order else g.order.reversedArray()
        for (v in seq) {
            val roots = ArrayList<Int>(6)
            g.forNeighbours(v) { u -> if (seen[u]) { val r = uf.find(u); if (r !in roots) roots.add(r) } }
            down[v] = IntArray(roots.size) { head[roots[it]] }
            seen[v] = true
            roots.forEach { uf.parent[it] = v }
            head[v] = v
        }
        return down
    }

    private fun build(kind: String, g: Grid, edges: List<Pair<Int, Int>>, typeOf: (Int, Int, Int) -> TreeNodeType, root: Int?): ScalarTree {
        // edges are (lower vertex, upper vertex) of the augmented tree; contract degree-2 vertices
        val up = HashMap<Int, MutableList<Int>>(); val dn = HashMap<Int, MutableList<Int>>()
        edges.forEach { (a, b) -> up.getOrPut(a) { mutableListOf() }.add(b); dn.getOrPut(b) { mutableListOf() }.add(a) }
        fun ups(v: Int) = up[v] ?: emptyList<Int>()
        fun dns(v: Int) = dn[v] ?: emptyList<Int>()
        val critical = (0 until g.n).filter { v -> !(ups(v).size == 1 && dns(v).size == 1) || v == root }.toSet()
        val nodeOf = HashMap<Int, Int>()
        val nodes = critical.sortedBy { g.rank[it] }.mapIndexed { i, v ->
            nodeOf[v] = i
            TreeNode(i, v, g.values[v].toDouble(), v % g.w.toDouble(), (v / g.w).toDouble(),
                if (v == root) TreeNodeType.ROOT else typeOf(v, dns(v).size, ups(v).size))
        }
        val arcs = ArrayList<TreeArc>()
        for (v in critical) for (start in ups(v)) {
            val path = ArrayList<Int>()
            var cur = start
            while (cur !in critical) { path.add(cur); cur = ups(cur).single() }
            arcs.add(TreeArc(arcs.size, nodeOf.getValue(v), nodeOf.getValue(cur), path.toIntArray()))
        }
        return ScalarTree(kind, g.w, g.h, nodes, arcs)
    }

fun joinTree(field: Img, channel: Int = 0): ScalarTree

The join tree: sublevel components born at minima, joining at saddles, rooted at the global maximum.

    fun joinTree(field: Img, channel: Int = 0): ScalarTree {
        val g = gridOf(field, channel)
        val down = augmented(g, ascending = true)
        val edges = ArrayList<Pair<Int, Int>>()
        for (v in 0 until g.n) down[v].forEach { edges.add(it to v) }
        return build("joinTree", g, edges, { _, d, _ -> if (d == 0) TreeNodeType.MINIMUM else TreeNodeType.SADDLE }, g.order.last())
    }

fun splitTree(field: Img, channel: Int = 0): ScalarTree

The split tree: superlevel components born at maxima, joining at saddles, rooted at the global minimum.

    fun splitTree(field: Img, channel: Int = 0): ScalarTree {
        val g = gridOf(field, channel)
        val above = augmented(g, ascending = false)
        val edges = ArrayList<Pair<Int, Int>>()
        for (v in 0 until g.n) above[v].forEach { edges.add(v to it) }
        return build("splitTree", g, edges, { _, _, u -> if (u == 0) TreeNodeType.MAXIMUM else TreeNodeType.SADDLE }, g.order.first())
    }

fun contourTree(field: Img, channel: Int = 0): ScalarTree

The contour tree, by Carr, Snoeyink and Axen's merge of the augmented join and split trees: leaves are peeled off one at a time, each giving one contour-tree arc, until one vertex is left. O(n log n) for the sort and close to linear after it.

body — 58 lines, from line 298
    fun contourTree(field: Img, channel: Int = 0): ScalarTree {
        val g = gridOf(field, channel)
        val jDown = augmented(g, ascending = true)   // join tree: v's neighbours below
        val sUp = augmented(g, ascending = false)    // split tree: v's neighbours above
        // adjacency as mutable sets: join tree (up, down), split tree (up, down)
        val jUp = Array(g.n) { HashSet<Int>(2) }; val jDn = Array(g.n) { HashSet<Int>(2) }
        val sUpS = Array(g.n) { HashSet<Int>(2) }; val sDn = Array(g.n) { HashSet<Int>(2) }
        for (v in 0 until g.n) {
            jDown[v].forEach { u -> jDn[v].add(u); jUp[u].add(v) }
            sUp[v].forEach { u -> sUpS[v].add(u); sDn[u].add(v) }
        }
        // a lower leaf starts a join-tree branch and continues one split-tree arc; an upper leaf the reverse
        fun isLeaf(v: Int) = (jDn[v].isEmpty() && sUpS[v].size == 1) || (sUpS[v].isEmpty() && jDn[v].size == 1)
        val queue = ArrayDeque<Int>()
        val removed = BooleanArray(g.n)
        val queued = BooleanArray(g.n)
        for (v in 0 until g.n) if (isLeaf(v)) { queue.add(v); queued[v] = true }
        val edges = ArrayList<Pair<Int, Int>>(g.n)
        var left = g.n
        fun detach(v: Int, up: Array<HashSet<Int>>, dn: Array<HashSet<Int>>) {
            // splice v out of one tree: its single neighbour on one side inherits the other side
            val ups = up[v].toList(); val dns = dn[v].toList()
            ups.forEach { dn[it].remove(v) }; dns.forEach { up[it].remove(v) }
            if (ups.size == 1) dns.forEach { d -> up[d].add(ups[0]); dn[ups[0]].add(d) }
            else if (dns.size == 1) ups.forEach { u -> dn[u].add(dns[0]); up[dns[0]].add(u) }
            up[v].clear(); dn[v].clear()
        }
        while (left > 1 && queue.isNotEmpty()) {
            val v = queue.removeFirst()
            if (removed[v]) continue
            queued[v] = false
            if (!isLeaf(v)) continue
            val u: Int
            if (jDn[v].isEmpty() && sUpS[v].size == 1) {
                // a lower leaf (a join-tree leaf): its arc goes up to its join-tree neighbour
                u = jUp[v].first()
                edges.add(v to u)
            } else {
                // an upper leaf (a split-tree leaf): its arc goes down to its split-tree neighbour
                u = sDn[v].first()
                edges.add(u to v)
            }
            val touched = (jUp[v] + jDn[v] + sUpS[v] + sDn[v]).toList()
            detach(v, jUp, jDn); detach(v, sUpS, sDn)
            removed[v] = true; left--
            touched.forEach { w -> if (!removed[w] && !queued[w] && isLeaf(w)) { queue.add(w); queued[w] = true } }
        }
        // orient each edge from lower to higher value
        val oriented = edges.map { (a, b) -> if (g.rank[a] < g.rank[b]) a to b else b to a }
        return build("contourTree", g, oriented, { _, d, u ->
            when {
                d == 0 -> TreeNodeType.MINIMUM
                u == 0 -> TreeNodeType.MAXIMUM
                else -> TreeNodeType.SADDLE
            }
        }, null)
    }
}