MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
PathShape.hpp
Go to the documentation of this file.
1#pragma once
2
4
5namespace MayaFlux::Kinesis {
6
7// =============================================================================
8// Ordering convention
9//
10// Every function in this file takes a path in CHRONOLOGICAL order:
11// path[0] is the earliest sample, path[n-1] the latest. This is the
12// opposite of the newest-first convention HistoryBuffer and the span
13// overloads in Differential.hpp use.
14//
15// The difference is not cosmetic. Reversing a path negates its signed
16// turning and its signed area, which are precisely the quantities this
17// file exists to compute, so a silently reversed path does not produce
18// a slightly wrong answer, it produces the mirror answer with full
19// confidence. Rather than provide overloads that would make the
20// ordering ambiguous at the call site, the bridge is explicit:
21// chronological_window<T>() (Differential.hpp, beside to_history)
22// reverses a HistoryBuffer into the order these functions expect.
23// =============================================================================
24
25/**
26 * @brief Resample a path to a fixed point count at uniform arc length
27 * @param path Positions in chronological order, at least two
28 * @param count Output point count, minimum 2
29 * @return @p count points evenly spaced along the path by distance
30 *
31 * Removes speed from the path entirely. Two strokes tracing the same
32 * shape, one hurried through its middle and one dwelling there, produce
33 * different sample distributions in time and identical ones after this,
34 * which is the precondition for comparing them point against point.
35 *
36 * A path whose total length is below the epsilon guard is degenerate,
37 * every sample having landed in effectively one place, and is returned
38 * as @p count copies of its first point rather than dividing by zero.
39 *
40 * MotionCurves::reparameterize_by_arc_length does the same thing over
41 * an Eigen::MatrixXd with columns as points. This exists so a caller
42 * holding glm::vec2 samples does not transpose into a matrix and back
43 * for what is a linear walk.
44 */
45[[nodiscard]] inline std::vector<glm::vec2> resample_uniform(
46 std::span<const glm::vec2> path, size_t count)
47{
48 count = count < 2 ? 2 : count;
49 if (path.size() < 2)
50 return std::vector<glm::vec2>(count, path.empty() ? glm::vec2 { 0.0F } : path[0]);
51
52 std::vector<float> arc;
53 arc.reserve(path.size());
54 arc.push_back(0.0F);
55 float total = 0.0F;
56 for (size_t i = 1; i < path.size(); ++i) {
57 total += glm::length(path[i] - path[i - 1]);
58 arc.push_back(total);
59 }
60
61 if (total < 1e-6F)
62 return std::vector<glm::vec2>(count, path[0]);
63
64 std::vector<glm::vec2> out;
65 out.reserve(count);
66 const float step = total / static_cast<float>(count - 1);
67
68 size_t upper = 1;
69 for (size_t i = 0; i < count; ++i) {
70 const float target = static_cast<float>(i) * step;
71 while (upper < arc.size() - 1 && arc[upper] < target)
72 ++upper;
73 const size_t lower = upper - 1;
74 const float segment = arc[upper] - arc[lower];
75 const float t = (segment > 1e-9F) ? ((target - arc[lower]) / segment) : 0.0F;
76 out.push_back(glm::mix(path[lower], path[upper], t));
77 }
78 return out;
79}
80
81/**
82 * @brief Turning accumulated along a path, with sign retained
83 * @param path Positions in chronological order, at least three
84 * @return Sum of signed angular deltas between consecutive segment
85 * headings, positive for net counterclockwise
86 *
87 * The signed counterpart to total_turning in Differential.hpp, which
88 * accumulates absolute values. That one measures how much a path bent
89 * in total and is blind to direction; this one measures where it ended
90 * up in heading and lets opposing bends cancel.
91 *
92 * The pair separates cases neither resolves alone. A closed circle and
93 * a figure-eight of the same arc length accumulate similar absolute
94 * turning and very different signed turning, near one full revolution
95 * against near zero. A clockwise and a counterclockwise circle are
96 * identical under absolute turning and opposite under this.
97 */
98[[nodiscard]] inline float signed_turning(std::span<const glm::vec2> path) noexcept
99{
100 if (path.size() < 3)
101 return 0.0F;
102
103 float total = 0.0F;
104 float prev = heading(path[1] - path[0]);
105 for (size_t i = 2; i < path.size(); ++i) {
106 const float h = heading(path[i] - path[i - 1]);
107 total += angular_delta(h, prev);
108 prev = h;
109 }
110 return total;
111}
112
113/**
114 * @brief Net revolutions a path turned through
115 * @param path Positions in chronological order
116 * @return signed_turning divided by two pi
117 *
118 * Reads directly as a count: near 1 for one counterclockwise loop, near
119 * -1 for one clockwise loop, near 0 for a path that returned to its
120 * starting heading without net rotation.
121 */
122[[nodiscard]] inline float winding_number(std::span<const glm::vec2> path) noexcept
123{
124 return signed_turning(path) / (2.0F * std::numbers::pi_v<float>);
125}
126
127/**
128 * @brief Signed area enclosed by treating the path as a closed polygon
129 * @param path Positions in chronological order, at least three
130 * @return Shoelace area, positive for counterclockwise winding
131 *
132 * Implicitly closes the path from its last point back to its first.
133 * Distinct from signed_turning as a direction measure: turning responds
134 * to the sequence of headings and stays large for a tight scribble that
135 * encloses nothing, while this responds to enclosure and stays near
136 * zero for a path that doubles back over itself regardless of how much
137 * it turned doing so.
138 */
139[[nodiscard]] inline float signed_area(std::span<const glm::vec2> path) noexcept
140{
141 if (path.size() < 3)
142 return 0.0F;
143
144 float acc = 0.0F;
145 for (size_t i = 0; i < path.size(); ++i) {
146 const glm::vec2& a = path[i];
147 const glm::vec2& b = path[(i + 1) % path.size()];
148 acc += cross_2d(a, b);
149 }
150 return 0.5F * acc;
151}
152
153/**
154 * @struct TurningProfile
155 * @brief A path's heading as a function of arc length, at a fixed
156 * sample count.
157 *
158 * The shape descriptor. Sampling heading at evenly spaced positions
159 * along the path discards where the path was drawn and how fast, and
160 * retains only how it bent, so two strokes of the same shape at
161 * different sizes in different corners of the space produce the same
162 * profile. Comparing shapes then reduces to comparing two equal-length
163 * vectors of angles.
164 *
165 * Headings are unwrapped rather than confined to a principal range, so
166 * a path that turns through more than one revolution reads as
167 * continuing to climb rather than folding back on itself. A profile
168 * built with rotation invariance has its first heading subtracted from
169 * every sample, which makes a shape match regardless of the direction
170 * it was started in; without it, orientation is part of the identity of
171 * the shape, and the same arc drawn upward and downward are different
172 * things. Which is correct is a decision about the meaning being
173 * defined, not a property of the mathematics, so it is a parameter.
174 *
175 * initial_heading and length are retained rather than discarded so a
176 * caller can reintroduce the orientation and scale the profile threw
177 * away, either as separate axes of a feature vector or to recover the
178 * approximate original.
179 */
181 std::vector<float> turning; ///< Unwrapped heading at each arc-length sample.
182 float initial_heading { 0.0F }; ///< Heading of the first segment, before any invariance subtraction.
183 float length { 0.0F }; ///< Total arc length of the source path.
184};
185
186/**
187 * @brief Build a turning profile from a path
188 * @param path Positions in chronological order, at least three
189 * @param samples Profile length, minimum 3
190 * @param rotation_invariant Subtract the initial heading from every
191 * sample, making the profile independent of which direction the
192 * path was started in
193 * @return A profile of exactly @p samples entries
194 *
195 * Resamples to uniform arc length first, so the profile is indexed by
196 * distance along the path rather than by time, and two recordings of
197 * the same shape at different frame rates produce comparable profiles.
198 */
199[[nodiscard]] inline TurningProfile turning_profile(
200 std::span<const glm::vec2> path, size_t samples, bool rotation_invariant = true)
201{
202 samples = samples < 3 ? 3 : samples;
203
204 TurningProfile profile;
205 profile.turning.assign(samples - 1, 0.0F);
206
207 if (path.size() < 2)
208 return profile;
209
210 for (size_t i = 1; i < path.size(); ++i)
211 profile.length += glm::length(path[i] - path[i - 1]);
212
213 const std::vector<glm::vec2> even = resample_uniform(path, samples);
214
215 float unwrapped = heading(even[1] - even[0]);
216 profile.initial_heading = unwrapped;
217 profile.turning[0] = unwrapped;
218
219 float prev = unwrapped;
220 for (size_t i = 2; i < even.size(); ++i) {
221 const float h = heading(even[i] - even[i - 1]);
222 unwrapped += angular_delta(h, prev);
223 prev = h;
224 profile.turning[i - 1] = unwrapped;
225 }
226
227 if (rotation_invariant) {
228 for (auto& t : profile.turning)
229 t -= profile.initial_heading;
230 }
231
232 return profile;
233}
234
235/**
236 * @brief Root mean square difference between two turning profiles
237 * @param a Left profile
238 * @param b Right profile, same sample count as @p a
239 * @return RMS angular difference in radians, or infinity if the sample
240 * counts differ
241 *
242 * In radians, so the result is directly interpretable: a value near
243 * zero is the same shape, a value near pi is a shape bending the
244 * opposite way at every point. Profiles of different lengths are not
245 * comparable and return infinity rather than silently truncating,
246 * since a partial comparison of two shapes is not a weaker answer but
247 * a wrong one.
248 */
249[[nodiscard]] inline float profile_distance(
250 const TurningProfile& a, const TurningProfile& b) noexcept
251{
252 if (a.turning.size() != b.turning.size() || a.turning.empty())
253 return std::numeric_limits<float>::infinity();
254
255 float acc = 0.0F;
256 for (size_t i = 0; i < a.turning.size(); ++i) {
257 const float d = a.turning[i] - b.turning[i];
258 acc += d * d;
259 }
260 return std::sqrt(acc / static_cast<float>(a.turning.size()));
261}
262
263/**
264 * @brief Centre a path at the origin and scale it to unit spread
265 * @param path Positions in chronological order
266 * @return Path translated so its centroid is at the origin and scaled
267 * so its root mean square distance from the origin is one
268 *
269 * The alternative shape normalization to a turning profile. A profile
270 * compares how paths bent; this compares where their points are, once
271 * position and size are removed. Point comparison keeps information
272 * about proportion that turning discards, and unlike turning it stays
273 * well defined for paths with stationary stretches where no heading
274 * exists. Rotation is not removed, so orientation remains part of the
275 * identity of the shape under this normalization.
276 */
277[[nodiscard]] inline std::vector<glm::vec2> normalize_shape(
278 std::span<const glm::vec2> path)
279{
280 std::vector<glm::vec2> out(path.begin(), path.end());
281 if (out.empty())
282 return out;
283
284 glm::vec2 mean { 0.0F };
285 for (const auto& p : out)
286 mean += p;
287 mean /= static_cast<float>(out.size());
288
289 float acc = 0.0F;
290 for (auto& p : out) {
291 p -= mean;
292 acc += glm::dot(p, p);
293 }
294
295 const float rms = std::sqrt(acc / static_cast<float>(out.size()));
296 if (rms < 1e-6F)
297 return out;
298
299 for (auto& p : out)
300 p /= rms;
301 return out;
302}
303
304/**
305 * @brief Root mean square point distance between two paths after
306 * resampling and normalization
307 * @param a Left path in chronological order
308 * @param b Right path in chronological order
309 * @param samples Point count both paths are resampled to
310 * @return RMS distance in normalized units
311 *
312 * Resamples both to the same arc-length spacing, removes position and
313 * scale from each, and compares point against point. Sensitive to
314 * orientation, unlike a rotation-invariant turning profile, and
315 * sensitive to proportion, unlike turning in general.
316 */
317[[nodiscard]] inline float shape_distance(
318 std::span<const glm::vec2> a, std::span<const glm::vec2> b, size_t samples = 32)
319{
320 const std::vector<glm::vec2> ra = normalize_shape(resample_uniform(a, samples));
321 const std::vector<glm::vec2> rb = normalize_shape(resample_uniform(b, samples));
322 if (ra.size() != rb.size() || ra.empty())
323 return std::numeric_limits<float>::infinity();
324
325 float acc = 0.0F;
326 for (size_t i = 0; i < ra.size(); ++i) {
327 const glm::vec2 d = ra[i] - rb[i];
328 acc += glm::dot(d, d);
329 }
330 return std::sqrt(acc / static_cast<float>(ra.size()));
331}
332
333/**
334 * @brief Dynamic time warping cost between two sequences
335 * @tparam T Element type
336 * @param a Left sequence
337 * @param b Right sequence
338 * @param distance Pointwise cost between one element of each
339 * @param band Sakoe-Chiba radius: the furthest an alignment may stray
340 * from the diagonal, in elements. Zero means unconstrained
341 * @return Accumulated cost of the cheapest monotone alignment, or
342 * infinity if either sequence is empty or the band admits no
343 * complete alignment
344 *
345 * Resampling by arc length removes speed variation from a path by
346 * discarding time entirely, which is right when only shape matters and
347 * wrong when the quantity being compared is not positional. This
348 * instead keeps both sequences and finds the cheapest correspondence
349 * between them, allowing one to stretch against the other, so two
350 * recordings of the same thing performed at different tempos align
351 * rather than disagree at every step.
352 *
353 * The band is the difference between a bounded cost and a quadratic
354 * one, and it also encodes an assumption: a nonzero band asserts that
355 * the two sequences are roughly in step and only locally out of it. A
356 * band narrower than the genuine offset between two sequences reports
357 * infinity rather than a poor alignment, which is the honest answer
358 * given the constraint it was handed.
359 */
360template <typename T>
361[[nodiscard]] inline float dtw_cost(
362 std::span<const T> a,
363 std::span<const T> b,
364 const std::function<float(const T&, const T&)>& distance,
365 size_t band = 0)
366{
367 if (a.empty() || b.empty())
368 return std::numeric_limits<float>::infinity();
369
370 const size_t n = a.size();
371 const size_t m = b.size();
372 constexpr float inf = std::numeric_limits<float>::infinity();
373
374 std::vector<float> prev(m + 1, inf);
375 std::vector<float> curr(m + 1, inf);
376 prev[0] = 0.0F;
377
378 for (size_t i = 1; i <= n; ++i) {
379 std::ranges::fill(curr, inf);
380
381 size_t lo = 1;
382 size_t hi = m;
383 if (band > 0) {
384 const auto centre = static_cast<size_t>(
385 (static_cast<double>(i) * static_cast<double>(m)) / static_cast<double>(n));
386 lo = (centre > band) ? (centre - band) : 1;
387 hi = std::min(m, centre + band);
388 if (lo > hi)
389 return inf;
390 }
391
392 for (size_t j = lo; j <= hi; ++j) {
393 const float cost = distance(a[i - 1], b[j - 1]);
394 const float best = std::min({ prev[j], curr[j - 1], prev[j - 1] });
395 curr[j] = (best == inf) ? inf : (cost + best);
396 }
397 std::swap(prev, curr);
398 }
399
400 return prev[m];
401}
402
403} // namespace MayaFlux::Kinesis
uint32_t h
Definition InkPress.cpp:29
uint32_t total
size_t a
size_t b
std::vector< float > * out
size_t count
float lo
float hi
float shape_distance(std::span< const glm::vec2 > a, std::span< const glm::vec2 > b, size_t samples=32)
Root mean square point distance between two paths after resampling and normalization.
float winding_number(std::span< const glm::vec2 > path) noexcept
Net revolutions a path turned through.
float angular_delta(float to, float from) noexcept
Shortest signed angular difference between two headings.
TurningProfile turning_profile(std::span< const glm::vec2 > path, size_t samples, bool rotation_invariant=true)
Build a turning profile from a path.
float cross_2d(const glm::vec2 &a, const glm::vec2 &b) noexcept
2D scalar cross product, sign of turn direction
std::vector< glm::vec2 > normalize_shape(std::span< const glm::vec2 > path)
Centre a path at the origin and scale it to unit spread.
std::vector< glm::vec2 > resample_uniform(std::span< const glm::vec2 > path, size_t count)
Resample a path to a fixed point count at uniform arc length.
Definition PathShape.hpp:45
float signed_turning(std::span< const glm::vec2 > path) noexcept
Turning accumulated along a path, with sign retained.
Definition PathShape.hpp:98
SpatialField distance(const glm::vec3 &anchor, float radius, DistanceMetric metric=DistanceMetric::EUCLIDEAN)
Normalized distance from an anchor point using the specified metric.
float signed_area(std::span< const glm::vec2 > path) noexcept
Signed area enclosed by treating the path as a closed polygon.
float heading(const glm::vec2 &v) noexcept
Heading angle of a 2D vector.
float profile_distance(const TurningProfile &a, const TurningProfile &b) noexcept
Root mean square difference between two turning profiles.
float dtw_cost(std::span< const T > a, std::span< const T > b, const std::function< float(const T &, const T &)> &distance, size_t band=0)
Dynamic time warping cost between two sequences.
double rms(const std::vector< double > &data)
Calculate RMS (Root Mean Square) energy of single-channel data.
Definition Yantra.cpp:102
double mean(const std::vector< double > &data)
Calculate mean of single-channel data.
Definition Yantra.cpp:55
float initial_heading
Heading of the first segment, before any invariance subtraction.
float length
Total arc length of the source path.
std::vector< float > turning
Unwrapped heading at each arc-length sample.
A path's heading as a function of arc length, at a fixed sample count.