Bvh.kt
src/jvmMain/kotlin/afield/io/mocap/Bvh.kt · 260 lines
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.io.mocap
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.tubeMesh
import afield.geometry.path.FLine
import afield.math.linalg.Vec3
import afield.math.oracle.Trajectory
import java.io.File
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
Motion capture in BVH format: a joint hierarchy and one line of channel values per frame.
Bvh.read parses a file; Bvh.positions runs forward kinematics to give every joint's position
(and every end site's: fingertips, toes, the top of the head) at a frame. Bvh.toTrajectory
turns a take into a Trajectory of joint positions for analysis or sequence memory, optionally
in the performer's own frame (travel and turning removed), and Bvh.skeleton draws a pose as
3D FLines for three(...). Tests: BvhTest.
class BvhJoint internal constructor
One joint of a Bvh hierarchy, or an end site (no channels, isEndSite).
class BvhJoint internal constructor(
val name: String,
/** Index of the parent in [Bvh.joints], or -1 for the root. */
val parent: Int,
/** Offset from the parent, in the file's units. */
val offset: DoubleArray,
/** Channel names in file order, e.g. `Zrotation Yrotation Xrotation`. */
val channels: List<String>,
val isEndSite: Boolean,
/** Index of this joint's first channel in a frame line. */
internal val channelStart: Int,
)
class Bvh internal constructor
A parsed BVH take: joints in file order (end sites included, named <parent>_end), one
frames row of channel values per frame, frameTime seconds apart.
class Bvh internal constructor(
val joints: List<BvhJoint>,
val frameTime: Double,
val frames: Array<DoubleArray>,
val name: String,
) {
val frameCount: Int get() = frames.size
Number of frames.
val sampleRate: Double get() = 1.0 / frameTime
Frames per second.
val duration: Double get() = frameCount * frameTime
Duration in seconds.
fun joint(name: String): Int
Index of the joint called name.
fun joint(name: String): Int = joints.indexOfFirst { it.name == name }.also { require(it >= 0) { "no joint '$name' in ${joints.filter { !it.isEndSite }.map { it.name }}" } }
val bones: List<Pair<Int, Int>> get()
Parent–child pairs, one per bone.
val bones: List<Pair<Int, Int>> get() = joints.indices.filter { joints[it].parent >= 0 }.map { joints[it].parent to it }
fun positions(frame: Int, bodyFrame: Boolean = false): Array<DoubleArray>
World position of every joint at frame, [joint](#L63)[x, y, z], y up as in the file. With
bodyFrame the root's horizontal position and heading are removed: positions are relative
to the point on the floor under the root, facing +z, so the same movement gives the same
numbers wherever the performer is and whichever way they face. Height is kept.
fun positions(frame: Int, bodyFrame: Boolean = false): Array<DoubleArray> {
val row = frames[frame]
val world = arrayOfNulls<DoubleArray>(joints.size) // 4x4 row-major transforms
val out = Array(joints.size) { DoubleArray(3) }
for ((i, j) in joints.withIndex()) {
var m = translation(j.offset[0], j.offset[1], j.offset[2])
for ((k, ch) in j.channels.withIndex()) {
val v = row[j.channelStart + k]
m = when (ch) {
"Xposition" -> mul(m, translation(v, 0.0, 0.0))
"Yposition" -> mul(m, translation(0.0, v, 0.0))
"Zposition" -> mul(m, translation(0.0, 0.0, v))
"Xrotation" -> mul(m, rotation(0, v))
"Yrotation" -> mul(m, rotation(1, v))
"Zrotation" -> mul(m, rotation(2, v))
else -> m
}
}
val w = if (j.parent < 0) m else mul(world[j.parent]!!, m)
world[i] = w
out[i][0] = w[3]; out[i][1] = w[7]; out[i][2] = w[11]
}
if (!bodyFrame) return out
val root = world[0]!!
// heading: where the root's local +z points, projected on the floor
val heading = atan2(root[2], root[10])
val c = cos(-heading); val s = sin(-heading)
val ox = root[3]; val oz = root[11]
for (p in out) {
val x = p[0] - ox; val z = p[2] - oz
p[0] = c * x + s * z; p[2] = -s * x + c * z
}
return out
}
val restPoseFrames: Int by lazy
Frames at the start that are a rest pose rather than motion: 1 when frame 0 jumps to frame 1 by far more (over 20×) than the take's typical frame-to-frame motion, as in the CMU BVH conversion, whose first frame is a T-pose; otherwise 0.
val restPoseFrames: Int by lazy {
if (frameCount < 10) return@lazy 0
fun step(f: Int): Double { val a = positions(f); val b = positions(f + 1); return a.indices.sumOf { j -> (0 until 3).sumOf { (a[j][it] - b[j][it]).let { d -> d * d } } } }
val typical = (1 until min(frameCount - 1, 200) step 10).map { step(it) }.sorted().let { it[it.size / 2] }
if (step(0) > 400 * typical.coerceAtLeast(1e-12)) 1 else 0
}
fun toTrajectory
The take as a Trajectory of joint positions resampled to rate Hz: channels named
Joint.x, Joint.y, Joint.z for each of jointNames (all joints and end sites by
default). bodyFrame as in positions. from and to trim the take, in seconds; from
starts after any restPoseFrames.
fun toTrajectory(
rate: Double = 30.0,
jointNames: List<String> = joints.map { it.name },
bodyFrame: Boolean = true,
from: Double = restPoseFrames * frameTime,
to: Double = duration,
): Trajectory {
val ids = jointNames.map { joint(it) }
val n = max(1, ((min(to, duration) - from) * rate).roundToInt())
val values = Array(ids.size * 3) { DoubleArray(n) }
for (t in 0 until n) {
val f = ((from + t / rate) * sampleRate).roundToInt().coerceIn(0, frameCount - 1)
val p = positions(f, bodyFrame)
for ((k, id) in ids.withIndex()) for (a in 0 until 3) values[k * 3 + a][t] = p[id][a]
}
val names = jointNames.flatMap { listOf("$it.x", "$it.y", "$it.z") }
return Trajectory(rate, names, values, name = name)
}
fun skeleton(frame: DoubleArray, channels: List<String>, at: DoubleArray = DoubleArray(3)): List<FLine>
A pose drawn as 3D FLines, one per bone, for three(...). frame is one sample of a
toTrajectory trajectory (all joints, in order), or of any trajectory with the same
channels; joints missing from channels are skipped. at shifts the figure, e.g. to lay
out poses side by side.
fun skeleton(frame: DoubleArray, channels: List<String>, at: DoubleArray = DoubleArray(3)): List<FLine> {
val index = HashMap<String, Int>()
for ((i, c) in channels.withIndex()) if (c.endsWith(".x")) index[c.removeSuffix(".x")] = i
fun p(j: Int): DoubleArray? = index[joints[j].name]?.let { doubleArrayOf(frame[it] + at[0], frame[it + 1] + at[1], frame[it + 2] + at[2]) }
return bones.mapNotNull { (a, b) ->
val pa = p(a) ?: return@mapNotNull null
val pb = p(b) ?: return@mapNotNull null
FLine().moveTo(pa[0], pa[1], pa[2]).lineTo(pb[0], pb[1], pb[2])
}
}
fun skeletonMesh(frame: DoubleArray, channels: List<String>, radius: Double = 0.5, color: DoubleArray = doubleArrayOf(0.5, 0.85, 1.0), at: DoubleArray = DoubleA
A pose as one solid mesh: a round tube of radius along every bone, painted color (linear
rgb), for three { mesh(...) }. Unlike skeleton's lines, which the viewer draws one pixel wide,
tubes keep their thickness when a figure is scaled down, as in a recorded film. frame,
channels and at as for skeleton.
fun skeletonMesh(frame: DoubleArray, channels: List<String>, radius: Double = 0.5, color: DoubleArray = doubleArrayOf(0.5, 0.85, 1.0), at: DoubleArray = DoubleArray(3)): TriMesh {
val index = HashMap<String, Int>()
for ((i, c) in channels.withIndex()) if (c.endsWith(".x")) index[c.removeSuffix(".x")] = i
fun p(j: Int): Vec3? = index[joints[j].name]?.let { Vec3((frame[it] + at[0]).toFloat(), (frame[it + 1] + at[1]).toFloat(), (frame[it + 2] + at[2]).toFloat()) }
val tubes = bones.mapNotNull { (a, b) ->
val pa = p(a) ?: return@mapNotNull null
val pb = p(b) ?: return@mapNotNull null
if (pa.distance(pb) < 1e-6f) null else tubeMesh(listOf(pa, pb), radius, sides = 8)
}
require(tubes.isNotEmpty()) { "no bones found in the given channels" }
val c = Vec3(color[0].toFloat(), color[1].toFloat(), color[2].toFloat())
return tubes.reduce { m, t -> m + t }.coloredBy { c }
}
override fun toString()
override fun toString() = "Bvh($name: ${joints.count { !it.isEndSite }} joints, $frameCount frames @ ${"%.0f".format(sampleRate)} Hz, ${"%.1f".format(duration)} s)"
companion object
companion object {
fun read(file: File): Bvh
Reads a BVH file, gzipped or not (.bvh or .bvh.gz).
fun read(file: File): Bvh {
val text = if (file.name.endsWith(".gz")) java.util.zip.GZIPInputStream(file.inputStream()).bufferedReader().readText() else file.readText()
return parse(text, file.name.removeSuffix(".gz").removeSuffix(".bvh"))
}
fun read(path: String): Bvh = read(File(path))
Reads a BVH file by path.
fun parse(text: String, name: String = ""): Bvh
Parses BVH text.
body — 64 lines, from line 196
fun parse(text: String, name: String = ""): Bvh {
val tok = text.split(Regex("\\s+")).filter { it.isNotEmpty() }
var i = 0
fun next() = tok[i++]
fun expect(s: String) { val t = next(); require(t.equals(s, ignoreCase = true)) { "BVH: expected $s, found $t" } }
val joints = ArrayList<BvhJoint>()
var channelCount = 0
fun joint(parent: Int, endSite: Boolean, jname: String) {
expect("{")
expect("OFFSET")
val offset = DoubleArray(3) { next().toDouble() }
val channels = ArrayList<String>()
if (!endSite && tok[i].equals("CHANNELS", true)) {
next()
val n = next().toInt()
repeat(n) { channels.add(next()) }
}
val me = joints.size
joints.add(BvhJoint(jname, parent, offset, channels, endSite, channelCount))
channelCount += channels.size
while (true) {
when (val t = next().uppercase()) {
"JOINT" -> joint(me, false, next())
"END" -> { next(); joint(me, true, "${jname}_end") }
"}" -> return
else -> error("BVH: unexpected '$t' in joint $jname")
}
}
}
expect("HIERARCHY")
expect("ROOT")
joint(-1, false, next())
expect("MOTION")
expect("Frames:")
val count = next().toInt()
expect("Frame")
expect("Time:")
val frameTime = next().toDouble()
val frames = Array(count) { DoubleArray(channelCount) { next().toDouble() } }
return Bvh(joints, frameTime, frames, name)
}
private fun translation(x: Double, y: Double, z: Double) = doubleArrayOf(1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, y, 0.0, 0.0, 1.0, z, 0.0, 0.0, 0.0, 1.0)
private fun rotation(axis: Int, degrees: Double): DoubleArray {
val a = degrees * PI / 180; val c = cos(a); val s = sin(a)
return when (axis) {
0 -> doubleArrayOf(1.0, 0.0, 0.0, 0.0, 0.0, c, -s, 0.0, 0.0, s, c, 0.0, 0.0, 0.0, 0.0, 1.0)
1 -> doubleArrayOf(c, 0.0, s, 0.0, 0.0, 1.0, 0.0, 0.0, -s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0)
else -> doubleArrayOf(c, -s, 0.0, 0.0, s, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
}
}
private fun mul(a: DoubleArray, b: DoubleArray): DoubleArray {
val r = DoubleArray(16)
for (row in 0 until 4) for (col in 0 until 4) {
var s = 0.0
for (k in 0 until 4) s += a[row * 4 + k] * b[k * 4 + col]
r[row * 4 + col] = s
}
return r
}
}
}