Vector field topology
A flow, whether wind, a fluid simulation, optical flow between frames, or the gradient of an image,
has a skeleton. It has points where it stops: sources it pours out of, sinks it drains into,
foci it spirals around, centres it circles, and saddles where it arrives from two sides
and leaves to the other two. From each saddle four streamlines, the separatrices, run to the
other points or off the frame, and they divide the frame into regions where every streamline starts
in the same place and ends in the same place. VectorFieldTopology finds all of these and gives
them back as points and FLines.
This example makes a flow from a few swirls, sources and sinks, and draws its topology over a light scatter of streamlines: sources and outward spirals in red, sinks and inward spirals in blue, saddles in white, the separatrices leaving saddles in orange and those arriving in blue:
import afield.geometry.topology.vectorfield.*
// Each entry is (x, y, outflow, swirl): positive outflow is a source, negative a sink, swirl turns it.
val features = [
[50.0, 45.0, 1.0, 0.6],
[150.0, 40.0, -1.0, -0.8],
[100.0, 100.0, 0.2, 1.2],
[40.0, 115.0, -0.8, 0.0],
[165.0, 115.0, 0.9, -0.3],
]
val flow =
Img.generateRGB(200, 150) { x, y ->
var u = 0.0
var v = 0.0
for ((cx, cy, out, swirl) in features) {
val dx = x - cx
val dy = y - cy
val k = exp(-(dx * dx + dy * dy) / 900.0)
u += k * (out * dx - swirl * dy)
v += k * (out * dy + swirl * dx)
}
Triple(u.toFloat(), v.toFloat(), 0f)
}
val topology = VectorFieldTopology.compute(flow, minStrength = 0.01)
val black = Img(200, 150, 3, FloatArray(200 * 150 * 3))
showImgGrid(
"${topology.criticalPoints.size} critical points, ${topology.separatrices.size} separatrices" to
black.drawFLines(
topology.streamlines(spacing = 9.0, length = 14.0) +
topology.toFLines() +
topology.criticalPointsFLine(8.0),
scale = 3.0,
),
columns = 1,
cellSide = 600,
)

The flow is treated as linear across each small triangle of the grid. Critical points are found within that interpolated field, each with its own Jacobian; detail absent from the samples cannot be recovered. The type comes from the Jacobian: a negative determinant is a saddle; otherwise the sign of the divergence says out or in, and complex eigenvalues add the spiral.
The gradient of an image
Any scalar field gives a flow: its gradient, which runs uphill. VectorFieldTopology.gradientOf
computes it. The gradient's sinks are the field's peaks, its sources the pits, and its separatrices
are the ridges and valleys between them: the same skeleton a Morse–Smale complex
finds, here from plain Kotlin. With rotate = true the gradient is turned a quarter turn so the flow
runs along the contours, and every peak and pit becomes a centre.
This example takes the brightness of the leaf print, blurred enough that only its larger shapes remain, and draws the topology of its gradient over it:
import afield.geometry.topology.vectorfield.*
val print = imgFromFile("public/docs/assets/vision/fline-viewer.jpg", 3).crop(78, 262, 128, 236)
val brightness = print.luma().gaussianBlur(7.0)
val topology = VectorFieldTopology.compute(VectorFieldTopology.gradientOf(brightness))
val dark = Img(print.width, print.height, 3, FloatArray(print.width * print.height * 3))
showImgGrid(
"photograph" to print.scaleBy(2.0),
"${topology.criticalPoints.size} points" to
print.overlay(dark, 0.6).drawFLines(topology.toFLines() + topology.criticalPointsFLine(7.0), scale = 2.0),
columns = 2,
cellSide = 300,
)

What you get
topology.criticalPoints are FlowCriticalPoints: position, type (a FlowCriticalType:
SOURCE, SINK, SADDLE, REPELLING_FOCUS, ATTRACTING_FOCUS, CENTER), the jacobian, and
from it divergence, curl and strength. of(types) filters them. topology.separatrices are
Separatrixes: the saddle they start from, whether they are unstable (leaving it), their
points, and the critical point they end at, or null if they left the frame.
For drawing: toFLines() for the separatrices, criticalPointsFLine() for the points coloured by
type, streamline(x, y) for one streamline and streamlines(spacing) for a grid of them. The
whole skeleton is also a GeometryNetwork through network(), with the points
as nodes and the separatrices as curves between them.
compute takes the flow as one image, u in channel 0 and v in channel 1 of an RGB image, or as two
one-channel images compute(u, v). minStrength drops weak points (noise makes many), and
centerTolerance sets how much spiral a centre or how little a focus may have.
Flow from old film
A flow can come from footage too. Video particles tracks thousands of points
through a movie, and video.flow() spreads their velocities onto a grid, one flow per frame. This
clip is from Golden Eagles, a 1930s Kodak Cinegraph nature film: an eagle crossing a field to
land on a falconer's fist. Its flow is in assets/vector_field_topology/eagle.flow.gz, saved with
flow.save(...) from the tracks so the page does not need the tracker.
Taken as it is, the flow describes the camera more than the bird. The film weaves in the gate, so the whole frame shifts and turns a little every frame, and the topology of that motion sweeps across the sky:
import afield.geometry.topology.vectorfield.*
// The eagle's flow, one grid per frame (cells of 4 pixels), and the film it came from.
val flow = loadParticleFlow("public/docs/assets/vector_field_topology/eagle.flow.gz")
val film = videoFrames("public/docs/assets/vector_field_topology/eagle.mp4", width = 480).toList()
fun panel(frame: Int, camera: CameraMotion, minSpeed: Double): Pair<String, Img> {
val topology =
VectorFieldTopology.compute(flow.at(frame, window = 1, camera = camera), minStrength = 0.02, maxLength = 300.0)
val lines =
topology.streamlines(spacing = 1.5, length = 14.0, minSpeed = minSpeed) +
topology.toFLines() +
topology.criticalPointsFLine(6.0)
val picture = film[frame]
val dark = Img(picture.width, picture.height, 3, FloatArray(picture.width * picture.height * 3))
return "frame $frame" to
picture.overlay(dark, 0.55).drawFLines(lines.map { it * flow.cell.toDouble() }, scale = 2.0)
}
showImgGrid(
*[60, 120, 210, 270].map { panel(it, CameraMotion.KEEP, minSpeed = 0.0) }.toTypedArray(),
columns = 2,
cellSide = 480,
)

The whole clip in this look, rendered by the second box of the
eagle_flow_explorer starting point:
The flow's critical points depend on what is taken to be standing still. CameraMotion.AFFINE
fits the camera's motion to all the particles in each frame (a shift, a slight turn and zoom),
refits it without the particles that disagree, which are mostly the bird's, and subtracts it.
CameraMotion.SHIFT subtracts only the median velocity, which is not enough here: the weave turns
as well as shifts. With the camera taken out, and streamlines drawn only where the flow is faster
than a pixel per frame (minSpeed = 1.0), the flow is the eagle's:
import afield.geometry.topology.vectorfield.*
:::fold the same loading and drawing as above
val flow = loadParticleFlow("public/docs/assets/vector_field_topology/eagle.flow.gz")
val film = videoFrames("public/docs/assets/vector_field_topology/eagle.mp4", width = 480).toList()
fun panel(frame: Int, camera: CameraMotion, minSpeed: Double): Pair<String, Img> {
val topology =
VectorFieldTopology.compute(
flow.at(frame, window = 1, camera = camera),
minStrength = 0.02,
maxLength = 300.0,
minSpeed = minSpeed,
)
val lines =
topology.streamlines(spacing = 1.5, length = 14.0, minSpeed = minSpeed) +
topology.toFLines() +
topology.criticalPointsFLine(6.0)
val picture = film[frame]
val dark = Img(picture.width, picture.height, 3, FloatArray(picture.width * picture.height * 3))
return "frame $frame" to
picture.overlay(dark, 0.55).drawFLines(lines.map { it * flow.cell.toDouble() }, scale = 2.0)
}
:::
showImgGrid(
*[60, 150, 210, 270].map { panel(it, CameraMotion.AFFINE, minSpeed = 1.0) }.toTypedArray(),
columns = 2,
cellSide = 480,
)

Both pictures are the same footage read in two frames of reference.
Where the footage came from
Golden Eagles (Kodak Cinegraph, 1930s), public domain, from the Internet Archive item
55294-golden-eagles (Periscope Film). The clip starts 5 min 9 s in and runs 17 s; it was
cropped of its pillarbox and burned-in timecode and tracked with particleTracker() at 480
pixels wide, 16,000 particles, on Apple Metal (device = "mps"). The 393 MB of tracks were
reduced to the 8.6 MB flow with loadParticleVideo(...).flow(cell = 4, sigma = 1.5).save(...).
Following a flow through time
topology.features() turns the critical points into the Features that
tracking matches from one moment to the next: sinks as minima, sources as
maxima, saddles as saddles, with strength as their persistence. So the swirls of a flow can be
followed through time just like the peaks of a field:
import afield.geometry.topology.tracking.*
import afield.geometry.topology.vectorfield.*
val tracker = FeatureTracker(MatchingWeights(birthDistance = 10.0, maxDistance = 20.0))
for ((time, flow) in frames) tracker.add(time, VectorFieldTopology.compute(flow, minStrength = 0.01).features())
Over the eagle film, from its approach to the landing, the tracker follows the swirls frame by frame. Only one lasts twelve frames or more: a saddle that rides the banking wing.
import afield.geometry.topology.morsesmale.CriticalType
import afield.geometry.topology.tracking.*
import afield.geometry.topology.vectorfield.*
val flow = loadParticleFlow("public/docs/assets/vector_field_topology/eagle.flow.gz")
val film = videoFrames("public/docs/assets/vector_field_topology/eagle.mp4", width = 480).toList()
// The bird's own motion, frame by frame from its approach to the landing, into one tracker.
val tracker = FeatureTracker(MatchingWeights(birthDistance = 10.0, maxDistance = 30.0))
for (frame in 150..270) {
val topology = VectorFieldTopology.compute(
flow.at(frame, window = 1, camera = CameraMotion.AFFINE),
minStrength = 0.005,
minSpeed = 0.5,
)
tracker.add(frame.toDouble(), topology.features())
}
// Every track seen in at least twelve frames, over the last frame: sinks blue, sources red, saddles white.
val long = tracker.tracks.filter { it.samples.size >= 12 }
val lines = long.map { track ->
(track.toFLine() * flow.cell.toDouble()).apply {
thickness = 1.5
color = when (track.type) {
CriticalType.MINIMUM -> vec(0.4, 0.6, 1.0, 0.9)
CriticalType.MAXIMUM -> vec(1.0, 0.35, 0.3, 0.9)
else -> vec(1.0, 1.0, 1.0, 0.8)
}
}
}
val picture = film[270]
val dark = Img(picture.width, picture.height, 3, FloatArray(picture.width * picture.height * 3))
showImgGrid(
"${long.size} track${if (long.size == 1) "" else "s"} over frames 150 to 270" to
picture.overlay(dark, 0.6).drawFLines(lines, scale = 2.0),
columns = 1,
cellSide = 720,
)
