Trajectory.kt
src/jvmMain/kotlin/afield/math/oracle/Trajectory.kt · 231 lines
@file:afield.runtime.completion.CompletionCategory("Math")
package afield.math.oracle
import kotlin.math.max
import kotlin.math.sqrt
Sequence memory for low-dimensional continuous time series: remember one or a few demonstrations, cue the memory with a recent trajectory, and let it continue in a related style while a performer takes over channels.
Trajectory holds uniformly sampled multichannel recordings with episode boundaries;
GestureFeatures turns one into scaled value-and-velocity frames; VariableMarkovOracle
(Wang and Dubnov's VMO, a Factor Oracle over thresholded continuous frames) indexes the
repeated material; Improviser walks it, recording where every output sample came from.
Tests: VariableMarkovOracleTest.
class Trajectory
A uniformly sampled multichannel recording: valueschannel` atsampleRate` samples per
second, split into one or more episodes.
Episodes are separate demonstrations stored end to end. episodeStarts marks where each
begins, so nothing built from a trajectory invents a transition from the last sample of one
demonstration to the first of the next.
class Trajectory(
/** Samples per second. */
val sampleRate: Double,
/** One name per channel. */
val channels: List<String>,
/** `values[channel][t]`; every channel has the same length. */
val values: Array<DoubleArray>,
/** First sample of each episode, ascending, starting with 0. */
val episodeStarts: IntArray = intArrayOf(0),
/** Where this recording came from (free text, kept for provenance). */
val name: String = "",
) {
init
init {
require(channels.size == values.size) { "${channels.size} channel names for ${values.size} channels" }
require(values.isNotEmpty()) { "a trajectory needs at least one channel" }
require(values.all { it.size == values[0].size }) { "channels differ in length" }
require(sampleRate > 0) { "sampleRate must be positive" }
require(episodeStarts.isNotEmpty() && episodeStarts[0] == 0) { "episodeStarts must begin with 0" }
for (i in 1 until episodeStarts.size) require(episodeStarts[i] > episodeStarts[i - 1] && episodeStarts[i] < length) { "bad episode start ${episodeStarts[i]}" }
}
val length: Int get() = values[0].size
Number of samples.
val width: Int get() = values.size
Number of channels.
val duration: Double get() = length / sampleRate
Duration in seconds.
fun channel(name: String): DoubleArray
The samples of channel name.
fun channel(name: String): DoubleArray = values[channels.indexOf(name).also { require(it >= 0) { "no channel '$name' in $channels" } }]
fun frame(t: Int): DoubleArray = DoubleArray(width) { values[it][t] }
All channels at sample t.
fun episodeOf(t: Int): Int
Index of the episode containing sample t.
fun episodeOf(t: Int): Int {
var e = 0
while (e + 1 < episodeStarts.size && episodeStarts[e + 1] <= t) e++
return e
}
fun endsEpisode(t: Int): Boolean = t == length - 1 || episodeStarts.contains(t + 1)
True when t is the last sample of its episode, so t + 1 is not its continuation.
fun startsEpisode(t: Int): Boolean = episodeStarts.contains(t)
True when t begins an episode.
fun episode(e: Int): Trajectory
Episode e as a trajectory of its own.
fun episode(e: Int): Trajectory {
val from = episodeStarts[e]
val to = if (e + 1 < episodeStarts.size) episodeStarts[e + 1] else length
return Trajectory(sampleRate, channels, Array(width) { values[it].copyOfRange(from, to) }, name = "$name[$e]")
}
fun series(prefix: String = ""): Array<Pair<String, List<Double>>>
Every channel as a name to samples pair, ready for showSignals(*t.series()).
fun series(prefix: String = ""): Array<Pair<String, List<Double>>> =
Array(width) { "$prefix${channels[it]}" to values[it].toList() }
fun resample(rate: Double): Trajectory
Resampled to rate by linear interpolation, episode by episode.
fun resample(rate: Double): Trajectory {
val parts = episodeStarts.indices.map { e ->
val ep = episode(e)
val n = max(1, (ep.length * rate / sampleRate).toInt())
Array(width) { c ->
DoubleArray(n) { i ->
val x = i * sampleRate / rate
val a = x.toInt().coerceAtMost(ep.length - 1)
val b = (a + 1).coerceAtMost(ep.length - 1)
val f = x - a
ep.values[c][a] * (1 - f) + ep.values[c][b] * f
}
}
}
return join(parts.map { Trajectory(rate, channels, it) }, name)
}
fun trimStill(threshold: Double = 0.25, pad: Double = 0.3): Trajectory
Each episode with its still lead-in and tail removed: samples at the start and end where the
speed (RMS over channels of the step, each channel in units of its own typical step) stays
below threshold times the episode's median speed. pad seconds of the stillness are kept
at each end, so a take still begins and ends at rest. An episode that is still throughout is
kept whole.
fun trimStill(threshold: Double = 0.25, pad: Double = 0.3): Trajectory {
val stepSd = DoubleArray(width) { c ->
var s = 0.0; var n = 0
for (i in 1 until length) if (!startsEpisode(i)) { val d = values[c][i] - values[c][i - 1]; s += d * d; n++ }
sqrt(s / max(1, n)).coerceAtLeast(1e-12)
}
val keep = (pad * sampleRate).toInt()
val parts = episodeStarts.indices.map { e ->
val ep = episode(e)
val speed = DoubleArray(ep.length) { i ->
if (i == 0) 0.0 else sqrt((0 until width).sumOf { c -> ((ep.values[c][i] - ep.values[c][i - 1]) / stepSd[c]).let { it * it } } / width)
}
val median = speed.sorted()[speed.size / 2]
val moving = speed.indices.filter { speed[it] >= threshold * median }
if (moving.isEmpty()) ep
else {
val from = (moving.first() - keep).coerceAtLeast(0)
val to = (moving.last() + keep).coerceAtMost(ep.length - 1)
Trajectory(sampleRate, channels, Array(width) { c -> ep.values[c].copyOfRange(from, to + 1) }, name = ep.name)
}
}
return join(parts, name)
}
override fun toString()
override fun toString() = "Trajectory($name: $width ch $channels, $length samples @ ${sampleRate}Hz, ${episodeStarts.size} episode(s))"
companion object
companion object {
fun of(sampleRate: Double, vararg channels: Pair<String, List<Number>>, name: String = ""): Trajectory
A one-episode trajectory from named channels, e.g. Trajectory.of(30.0, "x" to xs, "y" to ys).
fun of(sampleRate: Double, vararg channels: Pair<String, List<Number>>, name: String = ""): Trajectory =
Trajectory(sampleRate, channels.map { it.first }, Array(channels.size) { c -> DoubleArray(channels[c].second.size) { channels[c].second[it].toDouble() } }, name = name)
fun join(episodes: List<Trajectory>, name: String = episodes.joinToString("+") { it.name }): Trajectory
Episodes kept apart: the result remembers where each one starts.
fun join(episodes: List<Trajectory>, name: String = episodes.joinToString("+") { it.name }): Trajectory {
require(episodes.isNotEmpty())
val first = episodes[0]
require(episodes.all { it.channels == first.channels && it.sampleRate == first.sampleRate }) { "episodes must share channels and sample rate" }
val starts = ArrayList<Int>()
var at = 0
for (e in episodes) { for (s in e.episodeStarts) starts.add(at + s); at += e.length }
return Trajectory(first.sampleRate, first.channels, Array(first.width) { c -> episodes.map { it.values[c] }.reduce { a, b -> a + b } }, starts.toIntArray(), name)
}
}
}
class GestureFeatures
Frozen per-channel scaling and the feature frames a VariableMarkovOracle compares.
Each frame is the channel values, centred and divided by their spread, followed by the first
differences divided by their spread and multiplied by velocityWeight. The velocity half is
what tells a path crossing itself in one direction from the same point crossed the other way.
channelWeights scale both halves of a channel; a weight of 0 ignores it.
Spreads have a floor (scaleFloor times the largest spread, or 1e-9), so a channel that never
moves does not divide by zero. With shapeOnly the value half is dropped and only motion is
matched, regardless of where it happens.
class GestureFeatures(
val center: DoubleArray,
val scale: DoubleArray,
val velocityScale: DoubleArray,
val velocityWeight: Double = 1.0,
val channelWeights: DoubleArray = DoubleArray(center.size) { 1.0 },
val shapeOnly: Boolean = false,
) {
val width: Int get() = center.size
Channels.
val dimension: Int get() = if (shapeOnly) width else 2 * width
Length of one feature frame.
fun frame(current: DoubleArray, previous: DoubleArray?, into: DoubleArray = DoubleArray(dimension)): DoubleArray
Features of a single sample given its predecessor's values (previous = null at an episode start).
fun frame(current: DoubleArray, previous: DoubleArray?, into: DoubleArray = DoubleArray(dimension)): DoubleArray {
var k = 0
if (!shapeOnly) for (c in 0 until width) into[k++] = (current[c] - center[c]) / scale[c] * channelWeights[c]
for (c in 0 until width) into[k++] = if (previous == null) 0.0 else (current[c] - previous[c]) / velocityScale[c] * velocityWeight * channelWeights[c]
return into
}
fun frames(t: Trajectory): Array<DoubleArray>
Feature frames for every sample of t; velocity is 0 at each episode start.
fun frames(t: Trajectory): Array<DoubleArray> {
val out = Array(t.length) { DoubleArray(dimension) }
var prev: DoubleArray? = null
for (i in 0 until t.length) {
val cur = t.frame(i)
frame(cur, if (t.startsEpisode(i)) null else prev, out[i])
prev = cur
}
return out
}
companion object
companion object {
fun fit
Scales fitted to t (standard deviations, episode boundaries excluded from differences).
fun fit(
t: Trajectory,
velocityWeight: Double = 1.0,
channelWeights: DoubleArray = DoubleArray(t.width) { 1.0 },
shapeOnly: Boolean = false,
scaleFloor: Double = 1e-3,
): GestureFeatures {
val center = DoubleArray(t.width) { t.values[it].average() }
val sd = DoubleArray(t.width) { c -> sqrt(t.values[c].sumOf { (it - center[c]) * (it - center[c]) } / t.length) }
val vsd = DoubleArray(t.width) { c ->
var s = 0.0; var n = 0
for (i in 1 until t.length) if (!t.startsEpisode(i)) { val d = t.values[c][i] - t.values[c][i - 1]; s += d * d; n++ }
sqrt(s / max(1, n))
}
val f1 = max(1e-9, scaleFloor * (sd.maxOrNull() ?: 0.0))
val f2 = max(1e-9, scaleFloor * (vsd.maxOrNull() ?: 0.0))
return GestureFeatures(center, DoubleArray(t.width) { max(sd[it], f1) }, DoubleArray(t.width) { max(vsd[it], f2) }, velocityWeight, channelWeights, shapeOnly)
}
}
}
internal fun distance(a: DoubleArray, b: DoubleArray): Double
internal fun distance(a: DoubleArray, b: DoubleArray): Double {
var s = 0.0
for (i in a.indices) { val d = a[i] - b[i]; s += d * d }
return sqrt(s)
}