MorseSmale.kt
src/jvmMain/kotlin/afield/geometry/topology/morsesmale/MorseSmale.kt · 403 lines
package afield.geometry.topology.morsesmale
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
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import java.lang.foreign.MemorySegment
import java.lang.ref.Cleaner
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.math.abs
import kotlin.math.floor
The Morse–Smale complex of a 2-D scalar field: where its peaks, pits and passes are, how they are joined, and which of them are significant.
MorseSmale is the entry point. It builds a MorseSmaleComplex once, from an image channel or
a float grid, and the complex then answers for any persistence threshold without being rebuilt,
which is what makes a persistence slider cheap. Each threshold gives a MorseSmaleResult: the
surviving CriticalPoints, the separatrices between them as a GeometryNetwork, and the basin
and peak segmentation as one id per pixel. The whole hierarchy is readable from the complex as
PersistencePairs and Cancellations, and MorseSmaleTimings splits the build by stage.
Persistence is in the field's own units and removes features by significance, so it is a
different thing from blurring the field or smoothing the drawn separatrices. What the threshold
does to a picture, and what the complex is good for once you have it, are in
public/docs/morse_smale.md; following the critical points through time or scale is
afield.geometry.topology.tracking.
The complex is computed by the optional native library field_morse_smale
(MorseSmaleLibrary), so it holds native memory and is closed by the box's next run.
enum class CriticalType { MINIMUM, SADDLE, MAXIMUM }
What kind of critical point: a minimum is a vertex, a saddle an edge, a maximum a triangle of the grid.
data class CriticalPoint
A critical point of the unsimplified complex.
id is the grid cell it sits on, stable for as long as the feature stays on that cell, so it can key
downstream objects across recomputation. x, y are in pixel units (a saddle sits on an edge
midpoint, a maximum on a triangle centroid). cancelledAt is the persistence threshold at which
simplification removes it, or infinity if none does.
data class CriticalPoint(
val id: Int,
val type: CriticalType,
val value: Double,
val x: Double,
val y: Double,
val cancelledAt: Double,
)
data class PersistencePair(val birth: Int, val death: Int?, val dimension: Int, val persistence: Double)
One pair of the persistence diagram. dimension 0 pairs a minimum (birth) with the saddle that
merges its basin away (death); dimension 1 pairs a saddle with the maximum that fills its cycle.
The global minimum's death is null and its persistence infinite.
data class PersistencePair(val birth: Int, val death: Int?, val dimension: Int, val persistence: Double)
data class Cancellation(val saddle: Int, val extremum: Int, val persistence: Double)
One step of the simplification hierarchy: saddle and extremum cancel at persistence.
data class MorseSmaleTimings(val gradientMs: Double, val separatricesMs: Double, val hierarchyMs: Double, val persistenceMs: Double)
Wall-clock milliseconds spent building a complex, by stage.
val totalMs get() = gradientMs + separatricesMs + hierarchyMs + persistenceMs
class MorseSmaleComplex internal constructor
The discrete Morse–Smale complex of a scalar field, built once and simplified at any persistence threshold without rebuilding.
val complex = MorseSmale.build(img) // channel 0 of an Img
val coarse = complex.simplify(0.1)
display("ms", coarse.toFLines())
complex.close()
The complex holds native memory. One built while a box runs belongs to that run and is closed when the box runs again, so a box that rebuilds on every run does not accumulate them; it stays open after a stop, so a slider keeps working. Outside a box run, close it yourself; a Cleaner frees a forgotten one eventually.
class MorseSmaleComplex internal constructor(
val width: Int,
val height: Int,
private val values: FloatArray,
handle: MemorySegment,
) : AutoCloseable {
private class Owner(var handle: MemorySegment?) : Runnable {
override fun run() {
synchronized(this) { handle?.let(MorseSmaleNative::free); handle = null }
}
}
private val owner = Owner(handle)
private val cleanable = CLEANER.register(this, owner)
private val base = MorseSmaleNative.read(handle)
val criticalPoints: List<CriticalPoint>
Every critical point of the unsimplified complex.
val criticalPoints: List<CriticalPoint> = List(base.cell.size) { i ->
CriticalPoint(
base.cell[i], CriticalType.entries[base.type[i]], base.value[i].toDouble(),
base.xy[2 * i].toDouble(), base.xy[2 * i + 1].toDouble(), base.cancelledAt[i],
)
}
private val byId = criticalPoints.associateBy { it.id }
fun critical(id: Int): CriticalPoint? = byId[id]
The critical point with cell id, or null.
val persistencePairs: List<PersistencePair>
The persistence diagram, computed independently of the complex by union-find.
val persistencePairs: List<PersistencePair> = List(base.pairBirth.size) { i ->
PersistencePair(base.pairBirth[i], base.pairDeath[i].takeIf { it >= 0 }, base.pairDimension[i], base.pairPersistence[i])
}
val cancellations: List<Cancellation>
The simplification hierarchy in the order it is applied; persistence never decreases along it.
val cancellations: List<Cancellation> = List(base.cancelSaddle.size) { i ->
Cancellation(base.cancelSaddle[i], base.cancelExtremum[i], base.cancelPersistence[i])
}
val timings
val timings = MorseSmaleTimings(base.timings[0], base.timings[1], base.timings[2], base.timings[3])
fun survivalCurve(): List<Pair<Double, Int>>
How many critical points survive at each threshold where the count changes: (threshold, count).
fun survivalCurve(): List<Pair<Double, Int>> {
var alive = criticalPoints.size
val out = mutableListOf(0.0 to alive)
cancellations.forEach { c ->
alive -= 2
if (out.last().first == c.persistence) out[out.size - 1] = c.persistence to alive else out.add(c.persistence to alive)
}
return out
}
fun sample(x: Double, y: Double): Double
The field value at (x, y) in pixel units, bilinear, clamped at the edges.
fun sample(x: Double, y: Double): Double {
val cx = x.coerceIn(0.0, width - 1.0)
val cy = y.coerceIn(0.0, height - 1.0)
val x0 = floor(cx).toInt().coerceAtMost(width - 2)
val y0 = floor(cy).toInt().coerceAtMost(height - 2)
val fx = cx - x0
val fy = cy - y0
fun v(i: Int, j: Int) = values[j * width + i].toDouble()
return (v(x0, y0) * (1 - fx) + v(x0 + 1, y0) * fx) * (1 - fy) + (v(x0, y0 + 1) * (1 - fx) + v(x0 + 1, y0 + 1) * fx) * fy
}
fun simplify(persistence: Double = 0.0): MorseSmaleResult
The complex after cancelling every saddle–extremum pair with persistence below persistence,
in field units. 0 gives the unsimplified complex. Replays the stored hierarchy, so it is cheap
to call repeatedly from a slider.
fun simplify(persistence: Double = 0.0): MorseSmaleResult {
require(!persistence.isNaN()) { "persistence threshold is NaN" }
val view = synchronized(owner) {
val h = owner.handle ?: throw IllegalStateException("this MorseSmaleComplex has been closed")
MorseSmaleNative.simplify(h, persistence, width * height)
}
return MorseSmaleResult(this, persistence, view)
}
val isClosed: Boolean get() = synchronized(owner) { owner.handle == null }
override fun close() = cleanable.clean()
override fun toString()
override fun toString() = "MorseSmaleComplex(${width}x$height, ${criticalPoints.size} critical points, ${cancellations.size} cancellations)"
companion object
companion object {
private val CLEANER: Cleaner = Cleaner.create()
}
}
class MorseSmaleResult internal constructor
A Morse–Smale complex at one persistence threshold.
network holds the surviving critical points as nodes and the separatrices as curves, in pixel
units. Curves run from their saddle (startNode) to their extremum (endNode); an ascending
separatrix that leaves the grid has a null endNode. Node channels: type (0 minimum, 1 saddle,
2 maximum), value, cancelledAt. Curve attributes: kind (0 descending to a minimum, 1
ascending to a maximum), persistence (|f(saddle) - f(extremum)|), saddleValue,
extremumValue. Per-vertex sample: value, the field along the curve.
network is built on first access. basins and peaks give, per pixel, the id of the minimum whose descending manifold (basin) holds it and of the maximum whose ascending manifold (peak) holds it (-1 for the outside).
class MorseSmaleResult internal constructor(
val complex: MorseSmaleComplex,
val threshold: Double,
view: MorseSmaleNative.View,
) {
val width get() = complex.width
val height get() = complex.height
val criticalPoints: List<CriticalPoint> = view.nodes.map { complex.critical(it)!! }
val basins: IntArray = view.basin
val peaks: IntArray = view.peak
val separatrixCount: Int = view.arcSaddle.size
How many separatrices survive; known without building network.
val network: GeometryNetwork by lazy
Built on first access, since an unsimplified noisy field can have millions of separatrices and a slider that only reads criticalPoints or basins should not pay for them.
body — 53 lines, from line 212
val network: GeometryNetwork by lazy {
val nodes = GeometryNodes(
2,
IntArray(criticalPoints.size) { criticalPoints[it].id },
DoubleArray(criticalPoints.size * 2) { i -> criticalPoints[i / 2].let { if (i % 2 == 0) it.x else it.y } },
mapOf(
"type" to Channel.scalar(DoubleArray(criticalPoints.size) { criticalPoints[it].type.ordinal.toDouble() }),
"value" to Channel.scalar(DoubleArray(criticalPoints.size) { criticalPoints[it].value }),
"cancelledAt" to Channel.scalar(DoubleArray(criticalPoints.size) { criticalPoints[it].cancelledAt }),
),
)
// Curve ids: saddle id * 4 + kind * 2 + ordinal. A saddle has at most two arcs of each kind,
// and ordering them by extremum keeps the id stable for as long as both endpoints survive.
val order = view.arcSaddle.indices.sortedWith(compareBy({ view.arcSaddle[it] }, { view.arcKind[it] }, { view.arcExtremum[it] }))
val offsets = IntArray(view.arcLength.size + 1).also { o -> view.arcLength.forEachIndexed { i, l -> o[i + 1] = o[i] + l } }
var previous = -1L
var ordinal = 0
val curves = order.map { a ->
val key = view.arcSaddle[a].toLong() * 2 + view.arcKind[a]
ordinal = if (key == previous) ordinal + 1 else 0
previous = key
val n = view.arcLength[a]
val coords = DoubleArray(2 * n) { view.paths[2 * offsets[a] + it].toDouble() }
val samples = DoubleArray(n) { complex.sample(coords[2 * it], coords[2 * it + 1]) }
val saddle = complex.critical(view.arcSaddle[a])!!
val extremum = view.arcExtremum[a].takeIf { it >= 0 }?.let { complex.critical(it)!! }
GeometryCurve(
id = view.arcSaddle[a] * 4 + view.arcKind[a] * 2 + ordinal,
dimension = 2,
coordinates = coords,
startNode = saddle.id,
endNode = extremum?.id,
attributes = mapOf(
"kind" to view.arcKind[a].toDouble(),
"persistence" to (extremum?.let { abs(it.value - saddle.value) } ?: Double.POSITIVE_INFINITY),
"saddleValue" to saddle.value,
"extremumValue" to (extremum?.value ?: Double.POSITIVE_INFINITY),
),
samples = mapOf("value" to Channel.scalar(samples)),
)
}
GeometryNetwork(
2, nodes, curves,
mapOf(
"source" to "morseSmale",
"threshold" to threshold.toString(),
"width" to width.toString(),
"height" to height.toString(),
"units" to "pixels",
"library" to MorseSmaleNative.version(),
),
)
}
fun count(type: CriticalType) = criticalPoints.count { it.type == type }
val descending: GeometryNetwork get() = network.filterCurves { it.attributes["kind"] == 0.0 }
Separatrices running down to minima.
val ascending: GeometryNetwork get() = network.filterCurves { it.attributes["kind"] == 1.0 }
Separatrices running up to maxima (or out of the grid).
fun toFLines
Separatrices as FLines: descending ones in descendingColor, ascending ones in
ascendingColor. With smooth the polylines become splines through the same vertices; the
topology does not change. Each line keeps curveId, startNode, endNode, and geometryNetwork,
which is the whole network even when one kind is left out.
fun toFLines(
smooth: Boolean = false,
traceDescending: Boolean = true,
traceAscending: Boolean = true,
descendingColor: Vec4 = Vec4(0.15f, 0.35f, 0.9f, 0.9f),
ascendingColor: Vec4 = Vec4(0.9f, 0.25f, 0.15f, 0.9f),
thickness: Double = 1.5,
): List<FLine> = network.filterCurves {
(traceDescending && it.attributes["kind"] == 0.0) || (traceAscending && it.attributes["kind"] == 1.0)
}.toFLines(smooth).onEach { line ->
line.properties["geometryNetwork"] = network
line.properties["color"] = if (line.properties["kind"] == 0.0) descendingColor else ascendingColor
line.properties["thickness"] = thickness
}
fun criticalPointsFLine(pointSize: Double = 6.0): FLine
The surviving critical points as one FLine of dots: minima blue, saddles green, maxima red.
fun criticalPointsFLine(pointSize: Double = 6.0): FLine = network.nodesFLine().also { f ->
f.properties["pointed"] = true
f.properties["stroked"] = false
f.properties["pointSize"] = pointSize
f.nodes.forEach { n ->
n.properties["color"] = when ((n.properties["type"] as Double).toInt()) {
0 -> Vec4(0.1f, 0.3f, 1f, 1f)
1 -> Vec4(0.1f, 0.75f, 0.2f, 1f)
else -> Vec4(1f, 0.2f, 0.1f, 1f)
}
}
}
fun segmentationImage(peaks: Boolean = false): Img
The basins (or, with peaks true, the peaks) as an RGB image, one colour per region.
fun segmentationImage(peaks: Boolean = false): Img {
val labels = if (peaks) this.peaks else basins
val data = FloatArray(labels.size * 3)
labels.forEachIndexed { i, l ->
if (l < 0) return@forEachIndexed
var h = l * -0x61c88647
h = h xor (h ushr 15)
data[3 * i] = 0.35f + 0.6f * ((h and 0xff) / 255f)
data[3 * i + 1] = 0.35f + 0.6f * (((h ushr 8) and 0xff) / 255f)
data[3 * i + 2] = 0.35f + 0.6f * (((h ushr 16) and 0xff) / 255f)
}
return Img(width, height, 3, data)
}
override fun toString()
override fun toString() =
"MorseSmaleResult(threshold $threshold: ${count(CriticalType.MINIMUM)} minima, ${count(CriticalType.SADDLE)} saddles, " +
"${count(CriticalType.MAXIMUM)} maxima, ${network.curves.size} separatrices)"
}
object MorseSmale
Morse–Smale complexes of 2D scalar fields: critical points, the separatrices between them, basin
and peak segmentation, and persistence-based simplification. Needs the optional native library
field_morse_smale (see MorseSmaleLibrary).
val result = MorseSmale.compute(img, persistence = 0.05)
display("ms", result.toFLines() + result.criticalPointsFLine())
Persistence is measured in the field's own units. It removes features by significance; it is not
geometric smoothing, and toFLines(smooth = true) smooths only the drawing of each separatrix.
object MorseSmale {
private val workers = Executors.newFixedThreadPool(maxOf(1, Runtime.getRuntime().availableProcessors() / 2)) { r ->
Thread(r, "Field-morse-smale").apply { isDaemon = true }
}
val isAvailable: Boolean get() = MorseSmaleLibrary.isAvailable
fun build
Builds the complex of a field with width × height samples, where sample (x, y) is
values[offset + y * rowStride + x * pixelStride]. Nothing is simplified yet: call
MorseSmaleComplex.simplify.
fun build(
values: FloatArray,
width: Int,
height: Int,
pixelStride: Int = 1,
rowStride: Int = width * pixelStride,
offset: Int = 0,
): MorseSmaleComplex {
val last = offset.toLong() + (height - 1).toLong() * rowStride + (width - 1).toLong() * pixelStride
require(offset >= 0 && last < values.size) { "a ${width}x$height field with these strides needs ${last + 1} values, got ${values.size}" }
val handle = MorseSmaleNative.build(values, offset, width, height, pixelStride, rowStride)
val copy = FloatArray(width * height) { i -> values[offset + (i / width) * rowStride + (i % width) * pixelStride] }
val complex = MorseSmaleComplex(width, height, copy, handle)
// released by the box's next run, not by a stop (see ReleasePolicy.ACCUMULATING)
RunScope.current()?.own(complex, ReleasePolicy.ACCUMULATING)
return complex
}
fun build(img: Img, channel: Int = 0): MorseSmaleComplex
Builds the complex of one channel of img, read in place.
fun build(img: Img, channel: Int = 0): MorseSmaleComplex {
require(channel in 0 until img.channels) { "channel $channel out of range for a ${img.channels}-channel image" }
return build(img.data, img.width, img.height, img.channels, img.width * img.channels, channel)
}
fun compute(values: FloatArray, width: Int, height: Int, persistence: Double = 0.0): MorseSmaleResult
Builds, simplifies at persistence, and frees the native complex.
fun compute(values: FloatArray, width: Int, height: Int, persistence: Double = 0.0): MorseSmaleResult =
build(values, width, height).use { it.simplify(persistence) }
fun compute(img: Img, persistence: Double = 0.0, channel: Int = 0): MorseSmaleResult
Builds, simplifies at persistence, and frees the native complex.
fun compute(img: Img, persistence: Double = 0.0, channel: Int = 0): MorseSmaleResult =
build(img, channel).use { it.simplify(persistence) }
fun buildAsync(img: Img, channel: Int = 0): CompletableFuture<MorseSmaleComplex>
build on a worker thread, for runAsync. A complex built off the box's thread is not owned
by the run, so close it when you are done:
runAsync(onResult = { c: MorseSmaleComplex -> lines(c.simplify(0.05).toFLines()); c.close() }) {
MorseSmale.buildAsync(img)
}
fun buildAsync(img: Img, channel: Int = 0): CompletableFuture<MorseSmaleComplex> =
CompletableFuture.supplyAsync({ build(img, channel) }, workers)
fun computeAsync(img: Img, persistence: Double = 0.0, channel: Int = 0): CompletableFuture<MorseSmaleResult>
compute on a worker thread.
fun computeAsync(img: Img, persistence: Double = 0.0, channel: Int = 0): CompletableFuture<MorseSmaleResult> =
CompletableFuture.supplyAsync({ compute(img, persistence, channel) }, workers)
}