MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
MotionChannel.hpp
Go to the documentation of this file.
1#pragma once
2
7
8namespace MayaFlux::Kinesis {
9
10/**
11 * @brief Number of scalar components Estimate must independently track for T
12 *
13 * Estimate is hard-typed to double: it has no notion of a vector. A
14 * MotionChannel<glm::vec2> needs two independent Estimate instances (one
15 * per axis), since each axis can have genuinely different noise
16 * character (a tablet's X and Y sensors are not identical). This trait
17 * makes that fan-out explicit rather than pretending Estimate is
18 * generic when it is not.
19 */
20template <typename T>
22 static constexpr size_t value = 1;
23};
24
25template <glm::length_t L, typename U, glm::qualifier Q>
26struct estimate_component_count<glm::vec<L, U, Q>> {
27 static constexpr size_t value = static_cast<size_t>(L);
28};
29
30template <typename T>
32
33/**
34 * @brief Assemble a scalar or vector T from N doubles
35 *
36 * Inverse of reading T's components. Plain arithmetic T constructs
37 * directly from components[0]. glm vector T constructs component-wise.
38 */
39template <typename T>
40[[nodiscard]] inline T assemble_from_components(const std::array<double, estimate_component_count_v<T>>& components) noexcept
41{
42 if constexpr (GlmType<T>) {
43 using Comp = glm_component_type<T>;
44 T result {};
45 for (size_t i = 0; i < estimate_component_count_v<T>; ++i)
46 result[static_cast<glm::length_t>(i)] = static_cast<Comp>(components[i]);
47 return result;
48 } else {
49 return static_cast<T>(components[0]);
50 }
51}
52
53/**
54 * @brief Read T's components into an array of doubles
55 */
56template <typename T>
57[[nodiscard]] inline std::array<double, estimate_component_count_v<T>> read_components(const T& value) noexcept
58{
59 std::array<double, estimate_component_count_v<T>> out {};
60 if constexpr (GlmType<T>) {
61 for (size_t i = 0; i < estimate_component_count_v<T>; ++i)
62 out[i] = static_cast<double>(value[static_cast<glm::length_t>(i)]);
63 } else {
64 out[0] = static_cast<double>(value);
65 }
66 return out;
67}
68
69/**
70 * @class MotionChannel
71 * @brief Wires Estimate, Differential, and HampelFilter for one stream
72 * without hiding any of them.
73 *
74 * A caller tracking a raw stream through denoise -> differentiate ->
75 * spike-screen has to make several real decisions: which EstimateModel
76 * fits this stream's behavior, what window sizes, which derivative
77 * orders (velocity, acceleration, ...) actually need Hampel screening
78 * versus which can be trusted straight out of Differential. None of
79 * those decisions have validated defaults; they depend on the specific
80 * device and use case. MotionChannel does not guess them. It requires
81 * the caller to configure each one explicitly through methods named
82 * after the exact class and parameter being configured, then does the
83 * mechanical wiring (per-component Estimate fan-out for vector T,
84 * HistoryBuffer maintenance, routing each requested derivative order
85 * through its configured filter or leaving it unfiltered) so the
86 * caller is not hand-assembling that plumbing at every call site.
87 *
88 * Every piece MotionChannel owns is reachable: estimate_for_component(),
89 * history(), filter_for_order() return direct references, so a caller
90 * can inspect or override anything the builder configured rather than
91 * trusting an opaque pipeline.
92 *
93 * @tparam T Sample type: double, glm::vec2, or glm::vec3.
94 *
95 * ```cpp
96 * MotionChannel<glm::vec2> position(EstimateModel::QUIET_PERIOD_FLOOR, 8);
97 * position.filter_order(2, 8, 3.5); // Hampel-screen acceleration, not velocity
98 *
99 * for (auto raw : incoming_stream) {
100 * position.update(raw, dt);
101 * glm::vec2 vel = position.derivative<1>();
102 * glm::vec2 acc = position.derivative<2>(); // passes through the filter above
103 * }
104 * ```
105 */
106template <typename T>
108public:
109 static constexpr size_t component_count = estimate_component_count_v<T>;
110
111 /**
112 * @brief Construct a channel
113 * @param model EstimateModel applied identically to every component.
114 * A vec2's x and y each get their own Estimate instance with
115 * this model, not one shared instance, since components are
116 * independent streams.
117 * @param window Window size forwarded to each Estimate
118 * @param adapt_rate Adapt rate forwarded to each Estimate, ignored
119 * by models other than EWM_VARIANCE
120 * @param history_capacity HistoryBuffer capacity; must be at least
121 * one more than the highest derivative order this channel
122 * will be asked to compute, per Differential's own
123 * capacity requirements
124 */
127 size_t window,
128 double adapt_rate = 0.05,
129 size_t history_capacity = 8)
130 : m_history(history_capacity)
131 {
132 for (size_t i = 0; i < component_count; ++i)
133 m_estimates.emplace_back(model, adapt_rate, window);
134 }
135
136 /**
137 * @brief Feed one raw sample, denoise it, and push the cleaned value
138 * @param raw_sample Raw value for this step
139 * @param dt Elapsed time since the previous sample; stored for use
140 * by derivative<N>() and curvature() so the caller does not
141 * have to pass it again at every read
142 */
143 void update(const T& raw_sample, double dt)
144 {
145 m_last_dt = dt;
146 ++m_generation;
147 const auto raw_components = read_components(raw_sample);
148 std::array<double, component_count> clean_components {};
149 for (size_t i = 0; i < component_count; ++i) {
150 m_estimates[i].update(raw_components[i]);
151 clean_components[i] = m_estimates[i].value();
152 }
153 const T clean = assemble_from_components<T>(clean_components);
154 m_history.push(clean);
155
156 if constexpr (std::is_same_v<T, glm::vec2>) {
157 if (m_trajectory_2d)
158 m_trajectory_2d->update(clean);
159 }
160 if constexpr (std::is_same_v<T, glm::vec3>) {
161 if (m_trajectory_3d)
162 m_trajectory_3d->update(clean);
163 }
164 }
165
166 /**
167 * @brief Compute the N-th derivative, routed through a configured
168 * HampelFilter for that order if one exists
169 * @tparam N Derivative order, forwarded to Differential::backward_difference
170 * @return The derivative, filtered if filter_order(N, ...) was called,
171 * unfiltered otherwise
172 *
173 * Idempotent within a single update() cycle: the result for a given
174 * N is computed once and cached against the current generation
175 * counter (incremented every update()), and repeat calls to
176 * derivative<N>() before the next update() return the cached value
177 * rather than recomputing. This matters specifically because a
178 * configured HampelFilter's accept() has side effects (it may push
179 * into its own window or advance its rejection counter); calling
180 * derivative<N>() twice per frame without memoization would run
181 * accept() twice against the same underlying data in the same
182 * frame and silently corrupt the filter's state.
183 */
184 template <size_t N>
185 [[nodiscard]] T derivative()
186 {
187 auto cached = m_derivative_cache.find(N);
188 if (cached != m_derivative_cache.end() && cached->second.generation == m_generation)
189 return cached->second.value;
190
191 const T raw = backward_difference<N>(m_history, m_last_dt);
192 T result = raw;
193 auto it = m_filters.find(N);
194 if (it != m_filters.end())
195 result = it->second->accept(raw);
196
198 return result;
199 }
200
201 /**
202 * @brief Configure Hampel screening for one derivative order
203 * @param order Which derivative to filter (1 = velocity, 2 = acceleration, ...)
204 * @param window Forwarded to HampelFilter
205 * @param threshold_mad Forwarded to HampelFilter
206 * @param max_consecutive_rejections Forwarded to HampelFilter
207 *
208 * Not called for every order by default: velocity is often usable
209 * unfiltered, while acceleration and above amplify noise enough
210 * that a spike guard is usually worth its added lag. This is the
211 * caller's decision per order, not a channel-wide default.
212 */
213 void filter_order(size_t order, size_t window = 8, double threshold_mad = 3.5,
214 size_t max_consecutive_rejections = 3)
215 {
216 m_filters[order] = std::make_unique<HampelFilter<T>>(window, threshold_mad, max_consecutive_rejections);
217 }
218
219 /**
220 * @brief Direct access to one component's Estimate instance
221 * @param component Index (0 for x/scalar, 1 for y, 2 for z)
222 */
223 [[nodiscard]] Stochastic::Estimate& estimate_for_component(size_t component)
224 {
225 return m_estimates[component];
226 }
227
228 /**
229 * @brief Direct access to the underlying HistoryBuffer
230 */
231 [[nodiscard]] Memory::HistoryBuffer<T>& history() { return m_history; }
232
233 /**
234 * @brief Direct access to a configured filter, or nullptr if that
235 * order has no filter configured
236 */
237 [[nodiscard]] HampelFilter<T>* filter_for_order(size_t order)
238 {
239 auto it = m_filters.find(order);
240 return it == m_filters.end() ? nullptr : it->second.get();
241 }
242
243 /**
244 * @brief dt passed to the most recent update() call
245 */
246 [[nodiscard]] double last_dt() const { return m_last_dt; }
247
248 /**
249 * @brief Enable symbolic trajectory tracking over a 2D lattice
250 * @param lattice Partition to observe the channel's cleaned position through
251 * @param window Retained observation window, forwarded to SymbolicTrajectory
252 *
253 * Only valid when T = glm::vec2. A scalar or 3D channel has no
254 * meaningful 2D partition to observe; calling this on a channel of
255 * a different T is a compile error via the requires clause rather
256 * than a silent no-op.
257 */
258 void enable_trajectory_2d(Lattice2D lattice, size_t window = 16)
259 requires std::is_same_v<T, glm::vec2>
260 {
261 m_trajectory_2d = std::make_unique<SymbolicTrajectory<Lattice2D, glm::uvec2>>(lattice, window);
262 }
263
264 /**
265 * @brief Enable symbolic trajectory tracking over a 3D lattice
266 * @param lattice Partition to observe the channel's cleaned position through
267 * @param window Retained observation window, forwarded to SymbolicTrajectory
268 *
269 * Only valid when T = glm::vec3.
270 */
271 void enable_trajectory_3d(Lattice3D lattice, size_t window = 16)
272 requires std::is_same_v<T, glm::vec3>
273 {
274 m_trajectory_3d = std::make_unique<SymbolicTrajectory<Lattice3D, glm::uvec3>>(lattice, window);
275 }
276
277 /**
278 * @brief Direct access to the 2D trajectory, or nullptr if not enabled
279 */
281 requires std::is_same_v<T, glm::vec2>
282 {
283 return m_trajectory_2d.get();
284 }
285
286 /**
287 * @brief Direct access to the 3D trajectory, or nullptr if not enabled
288 */
290 requires std::is_same_v<T, glm::vec3>
291 {
292 return m_trajectory_3d.get();
293 }
294
295private:
296 std::vector<Stochastic::Estimate> m_estimates;
298 /**
299 * @brief One memoized derivative<N>() result, tagged by the update()
300 * generation it was computed for
301 */
303 uint64_t generation;
305 };
306
307 std::map<size_t, std::unique_ptr<HampelFilter<T>>> m_filters;
308 std::map<size_t, DerivativeCacheEntry> m_derivative_cache;
309 double m_last_dt { 0.0 };
310 uint64_t m_generation { 0 };
311
312 std::unique_ptr<SymbolicTrajectory<Lattice2D, glm::uvec2>> m_trajectory_2d;
313 std::unique_ptr<SymbolicTrajectory<Lattice3D, glm::uvec3>> m_trajectory_3d;
314};
315
316/**
317 * @brief Curvature helper for MotionChannel<glm::vec2>, self-calibrating
318 * its min_speed guard from the channel's own learned noise floor
319 * @param channel A MotionChannel<glm::vec2> that has had at least one update()
320 * @return curvature(velocity, acceleration, min_speed), where min_speed
321 * is derived from the combined per-axis Estimate floors divided
322 * by dt rather than a fixed constant
323 *
324 * A fixed min_speed constant has to assume a coordinate scale (pixels,
325 * normalized 0..1, millimeters); this instead asks "how much speed
326 * could this channel's own measurement noise alone produce", which
327 * scales automatically with whatever device and coordinate range the
328 * channel is actually receiving. Kept as a free function rather than a
329 * MotionChannel method since it is glm::vec2-specific and curvature
330 * itself does not generalize to vec3 the way velocity/acceleration do.
331 */
332[[nodiscard]] inline float channel_curvature(MotionChannel<glm::vec2>& channel)
333{
334 const glm::vec2 vel = channel.derivative<1>();
335 const glm::vec2 accel = channel.derivative<2>();
336
337 const glm::vec2 floor_vec {
338 static_cast<float>(channel.estimate_for_component(0).floor()),
339 static_cast<float>(channel.estimate_for_component(1).floor())
340 };
341 const auto dt = static_cast<float>(channel.last_dt());
342 const float min_speed = (dt > 0.0F) ? (glm::length(floor_vec) / dt) : 0.0F;
343
344 return curvature(vel, accel, min_speed);
345}
346
347} // namespace MayaFlux::Kinesis
#define N(method_name, full_type_name)
Definition Creator.hpp:106
float value
Holds the last accepted value when a new sample looks like an isolated outlier relative to its recent...
void update(const T &raw_sample, double dt)
Feed one raw sample, denoise it, and push the cleaned value.
Memory::HistoryBuffer< T > m_history
MotionChannel(Stochastic::EstimateModel model, size_t window, double adapt_rate=0.05, size_t history_capacity=8)
Construct a channel.
void enable_trajectory_3d(Lattice3D lattice, size_t window=16)
Enable symbolic trajectory tracking over a 3D lattice.
std::vector< Stochastic::Estimate > m_estimates
SymbolicTrajectory< Lattice2D, glm::uvec2 > * trajectory_2d()
Direct access to the 2D trajectory, or nullptr if not enabled.
void enable_trajectory_2d(Lattice2D lattice, size_t window=16)
Enable symbolic trajectory tracking over a 2D lattice.
std::unique_ptr< SymbolicTrajectory< Lattice3D, glm::uvec3 > > m_trajectory_3d
double last_dt() const
dt passed to the most recent update() call
std::unique_ptr< SymbolicTrajectory< Lattice2D, glm::uvec2 > > m_trajectory_2d
std::map< size_t, std::unique_ptr< HampelFilter< T > > > m_filters
Memory::HistoryBuffer< T > & history()
Direct access to the underlying HistoryBuffer.
SymbolicTrajectory< Lattice3D, glm::uvec3 > * trajectory_3d()
Direct access to the 3D trajectory, or nullptr if not enabled.
void filter_order(size_t order, size_t window=8, double threshold_mad=3.5, size_t max_consecutive_rejections=3)
Configure Hampel screening for one derivative order.
static constexpr size_t component_count
HampelFilter< T > * filter_for_order(size_t order)
Direct access to a configured filter, or nullptr if that order has no filter configured.
T derivative()
Compute the N-th derivative, routed through a configured HampelFilter for that order if one exists.
Stochastic::Estimate & estimate_for_component(size_t component)
Direct access to one component's Estimate instance.
std::map< size_t, DerivativeCacheEntry > m_derivative_cache
Wires Estimate, Differential, and HampelFilter for one stream without hiding any of them.
double floor() const
Current learned floor.
Definition Estimate.hpp:234
Stateful statistical characterization of an evolving scalar stream.
Definition Estimate.hpp:125
Tracks a moving point's sequence of cells through a lattice partition over time.
History buffer for difference equations and recursive relations.
EstimateModel
Strategies for characterizing an evolving stream's statistical behavior.
Definition Estimate.hpp:20
constexpr size_t estimate_component_count_v
float channel_curvature(MotionChannel< glm::vec2 > &channel)
Curvature helper for MotionChannel<glm::vec2>, self-calibrating its min_speed guard from the channel'...
float curvature(const glm::vec2 &vel, const glm::vec2 &accel, float min_speed=1e-3F) noexcept
Signed curvature from velocity and acceleration.
std::array< double, estimate_component_count_v< T > > read_components(const T &value) noexcept
Read T's components into an array of doubles.
T assemble_from_components(const std::array< double, estimate_component_count_v< T > > &components) noexcept
Assemble a scalar or vector T from N doubles.
A regular subdivision of an AABB2D into a cell count per axis.
Definition Lattice.hpp:125
A regular subdivision of an AABB3D into a cell count per axis.
Definition Lattice.hpp:25
One memoized derivative<N>() result, tagged by the update() generation it was computed for.
Number of scalar components Estimate must independently track for T.