particleflow.kt
src/jvmMain/kotlin/afield/media/video/particleflow.kt · 187 lines
package afield.media.video
import afield.media.image.Img
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.hypot
import kotlin.math.roundToInt
Tracked particles from a video, turned into a velocity field that can be measured.
ParticleVideo follows thousands of points through a movie and leaves a cloud of samples per
frame. This file splats those velocities onto a regular grid, one FlowFrame per frame, so every
cell carries a velocity whether or not a particle landed in it. ParticleFlow.at hands one frame
back as an Img, u in channel 0 and v in channel 1, which is the form the vector-field topology
and streamline code reads.
A tracked video's flow often describes the camera more than its subject, and on old film the whole frame shifts and turns a little from one frame to the next. So each frame also carries the camera motion measured from its own particles: a median shift, and an affine fit that picks up a slight zoom or rotation as well. CameraMotion chooses how much of that to subtract when a frame is read back. A flow is far smaller than the tracks it came from, and smaller again gzipped and quantised, so ParticleFlow.save and loadParticleFlow let a page or a test begin from a saved flow instead of running the tracker.
enum class CameraMotion { KEEP, SHIFT, AFFINE }
What to take out of a flow before using it: nothing (KEEP), the frame's median velocity
(SHIFT), or an affine motion fitted to every particle and refitted without the outliers
(AFFINE), which also removes a camera's slight zoom and rotation, or old film weaving in the gate.
class FlowFrame(val u: FloatArray, val v: FloatArray, val w: FloatArray, val shift: DoubleArray, val affine: DoubleArray)
One frame of a ParticleFlow: the particle velocities splatted onto the grid (weighted sums
u, v and total weight w per cell), and the camera motion measured from the particles, as a
median shift (vx, vy) and an affine fit (vx = a0 + a1 x + a2 y, vy = b0 + b1 x + b2 y, in
processed-image pixels).
class FlowFrame(val u: FloatArray, val v: FloatArray, val w: FloatArray, val shift: DoubleArray, val affine: DoubleArray)
class ParticleFlow(val width: Int, val height: Int, val cell: Int, val fps: Double, val frames: List<FlowFrame>)
The tracked particles of a ParticleVideo turned into a dense velocity field: each frame's particle velocities spread onto a grid of cell-pixel cells with a Gaussian of sigma cells.
val flow = loadParticleVideo("tracks.pvt").flow(cell = 4)
val topology = VectorFieldTopology.compute(flow.at(120, camera = CameraMotion.AFFINE))
at gives one frame's flow as an image (u in channel 0, v in channel 1, in pixels per frame), averaged over a few neighbouring frames and with the camera's motion taken out or left in. Positions on the grid are in cells: multiply by cell for processed-image pixels. A flow is much smaller than its tracks, so save and loadParticleFlow keep it for later.
val frameCount get() = frames.size
fun at(frame: Int, window: Int = 1, camera: CameraMotion = CameraMotion.AFFINE, prior: Float = 0.5f): Img
The flow at frame, averaged over window frames either side, with camera motion
removed. Cells with no particles near them fade towards zero (the average is divided by the
weight plus prior).
fun at(frame: Int, window: Int = 1, camera: CameraMotion = CameraMotion.AFFINE, prior: Float = 0.5f): Img {
require(frame in 0 until frameCount) { "frame $frame is outside 0 until $frameCount" }
val n = width * height
val su = FloatArray(n); val sv = FloatArray(n); val sw = FloatArray(n)
for (f in (frame - window).coerceAtLeast(0)..(frame + window).coerceAtMost(frameCount - 1)) {
val fr = frames[f]
for (i in 0 until n) {
val x = (i % width + 0.5) * cell; val y = (i / width + 0.5) * cell
val (cu, cv) = when (camera) {
CameraMotion.KEEP -> 0.0 to 0.0
CameraMotion.SHIFT -> fr.shift[0] to fr.shift[1]
CameraMotion.AFFINE -> (fr.affine[0] + fr.affine[1] * x + fr.affine[2] * y) to (fr.affine[3] + fr.affine[4] * x + fr.affine[5] * y)
}
// the sums are weighted, so the camera's share of a cell is its weight times the camera velocity
su[i] += fr.u[i] - (fr.w[i] * cu).toFloat(); sv[i] += fr.v[i] - (fr.w[i] * cv).toFloat(); sw[i] += fr.w[i]
}
}
val out = FloatArray(n * 3)
for (i in 0 until n) { val d = sw[i] + prior; out[3 * i] = su[i] / d; out[3 * i + 1] = sv[i] / d }
return Img(width, height, 3, out)
}
fun save(path: String)
Writes the flow, gzipped and quantised: each cell's mean velocity to 1/64 pixel per frame and its weight to 1/256, far finer than the tracking itself, which keeps a few hundred frames to a few megabytes.
fun save(path: String) {
DataOutputStream(GZIPOutputStream(File(path).outputStream().buffered())).use { o ->
o.writeInt(MAGIC); o.writeInt(width); o.writeInt(height); o.writeInt(cell); o.writeDouble(fps); o.writeInt(frames.size)
fun q(x: Float, scale: Float) = (x * scale).roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
for (f in frames) {
for (i in f.w.indices) {
val wt = f.w[i]; val mu = if (wt > 0) f.u[i] / wt else 0f; val mv = if (wt > 0) f.v[i] / wt else 0f
o.writeShort(q(mu, 64f)); o.writeShort(q(mv, 64f)); o.writeShort(q(wt, 256f).coerceAtLeast(0))
}
f.shift.forEach(o::writeDouble); f.affine.forEach(o::writeDouble)
}
}
}
internal companion object
const val MAGIC = 0x464c4f57 // "FLOW"
fun loadParticleFlow(path: String): ParticleFlow
Reads a flow written by ParticleFlow.save.
fun loadParticleFlow(path: String): ParticleFlow =
DataInputStream(GZIPInputStream(File(path).inputStream().buffered())).use { i ->
require(i.readInt() == ParticleFlow.MAGIC) { "$path is not a particle flow" }
val w = i.readInt(); val h = i.readInt(); val cell = i.readInt(); val fps = i.readDouble(); val count = i.readInt()
val n = w * h
ParticleFlow(w, h, cell, fps, List(count) {
val u = FloatArray(n); val v = FloatArray(n); val wt = FloatArray(n)
for (k in 0 until n) {
val mu = i.readShort() / 64f; val mv = i.readShort() / 64f; wt[k] = i.readShort() / 256f
u[k] = mu * wt[k]; v[k] = mv * wt[k]
}
FlowFrame(u, v, wt, DoubleArray(2) { i.readDouble() }, DoubleArray(6) { i.readDouble() })
})
}
fun ParticleVideo.flow(cell: Int = 4, sigma: Double = 1.5): ParticleFlow
Every frame's particle velocities spread onto a grid of cell-pixel cells, with a Gaussian of sigma cells, and the camera motion of each frame measured: see ParticleFlow.
fun ParticleVideo.flow(cell: Int = 4, sigma: Double = 1.5): ParticleFlow {
require(cell >= 1 && sigma > 0) { "cell must be at least 1 and sigma positive" }
val gw = width / cell; val gh = height / cell
val r = kotlin.math.ceil(3 * sigma).toInt()
return ParticleFlow(gw, gh, cell, fps, (0 until frameCount).map { f ->
val samples = frame(f).samples
val u = FloatArray(gw * gh); val v = FloatArray(gw * gh); val w = FloatArray(gw * gh)
for (s in samples) {
val gx = s.x / cell - 0.5; val gy = s.y / cell - 0.5
for (yy in (gy.toInt() - r)..(gy.toInt() + r + 1)) for (xx in (gx.toInt() - r)..(gx.toInt() + r + 1)) {
if (xx !in 0 until gw || yy !in 0 until gh) continue
val k = exp(-((xx - gx) * (xx - gx) + (yy - gy) * (yy - gy)) / (2 * sigma * sigma)).toFloat()
val i = yy * gw + xx
u[i] += k * s.vx; v[i] += k * s.vy; w[i] += k
}
}
FlowFrame(u, v, w, medianShift(samples), affineCamera(samples))
})
}
private fun medianShift(s: List<ParticleSample>): DoubleArray {
if (s.isEmpty()) return DoubleArray(2)
return doubleArrayOf(s.map { it.vx }.sorted()[s.size / 2].toDouble(), s.map { it.vy }.sorted()[s.size / 2].toDouble())
}
Least-squares affine flow over s, refitted once without samples more than 3 median residuals off.
private fun affineCamera(s: List<ParticleSample>): DoubleArray {
if (s.size < 10) return DoubleArray(6)
fun fit(p: List<ParticleSample>): DoubleArray {
val m = Array(3) { DoubleArray(3) }; val bu = DoubleArray(3); val bv = DoubleArray(3)
for (q in p) {
val b = doubleArrayOf(1.0, q.x.toDouble(), q.y.toDouble())
for (a in 0..2) { for (c in 0..2) m[a][c] += b[a] * b[c]; bu[a] += b[a] * q.vx; bv[a] += b[a] * q.vy }
}
fun solve(rhs: DoubleArray): DoubleArray {
val a = Array(3) { m[it].copyOf() + rhs[it] }
for (c in 0..2) {
val p = (c..2).maxBy { abs(a[it][c]) }; val t = a[c]; a[c] = a[p]; a[p] = t
if (abs(a[c][c]) < 1e-12) return DoubleArray(3)
for (r in 0..2) if (r != c) { val k = a[r][c] / a[c][c]; for (cc in c..3) a[r][cc] -= k * a[c][cc] }
}
return DoubleArray(3) { a[it][3] / a[it][it] }
}
return solve(bu) + solve(bv)
}
fun residual(q: ParticleSample, c: DoubleArray) =
hypot(q.vx - (c[0] + c[1] * q.x + c[2] * q.y), q.vy - (c[3] + c[4] * q.x + c[5] * q.y))
val first = fit(s)
val rs = s.map { residual(it, first) }.sorted()
val cut = 3 * rs[rs.size / 2] + 0.05
return fit(s.filter { residual(it, first) < cut })
}