MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
FeatureExtent.hpp
Go to the documentation of this file.
1#pragma once
2
3#include <Eigen/Core>
4
5namespace MayaFlux::Kinesis {
6
7namespace detail {
8
9 /**
10 * @brief Hermite ramp from 0 at @p edge0 to 1 at @p edge1
11 *
12 * The same curve as glm::smoothstep, confirmed against
13 * ShaderCompat.hpp's re-export of it, kept as a local double-precision
14 * copy rather than calling through glm::smoothstep directly: that one
15 * is float-based, and every membership computation in this file works
16 * in double so that two extents differing only slightly near a
17 * boundary do not lose the difference to a float round-trip.
18 */
19 [[nodiscard]] inline double ramp(double edge0, double edge1, double x) noexcept
20 {
21 if (edge1 <= edge0)
22 return x < edge0 ? 0.0 : 1.0;
23 const double t = std::clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
24 return t * t * (3.0 - 2.0 * t);
25 }
26
27} // namespace detail
28
29/**
30 * @struct FeatureMetric
31 * @brief Per-axis weighting and wrapping for a space whose axes carry
32 * different units.
33 *
34 * A feature space assembled from measured quantities has no natural
35 * metric. Speed in normalized units per second, pressure in 0..1, and
36 * an angle in radians are three incommensurate axes, and Euclidean
37 * distance across them means whichever axis happens to have the largest
38 * numeric range dominates every comparison. Weights fix that by scaling
39 * each axis before the difference is taken.
40 *
41 * Periods handle the axes that wrap. An angle at 0.01 and an angle at
42 * 6.27 are adjacent, not opposite, and no amount of weighting expresses
43 * that. A non-zero period on an axis makes its difference take the
44 * shorter way round; a zero period leaves it linear.
45 *
46 * distance_sq has the signature SpatialIndex expects for its DistanceFn,
47 * so an index over a feature space can be constructed with this metric
48 * directly rather than defaulting to unweighted Euclidean.
49 *
50 * Distinct from Tendency::DistanceMetric (Euclidean, Euclidean-squared,
51 * Manhattan, Chebyshev), which is a fixed choice of norm over glm::vec3
52 * specifically, with no per-axis weighting and no wrapping. That enum
53 * answers "which norm" for a 3D world-space query. This answers "what
54 * does one unit of difference mean on each axis of an assembled feature
55 * space", which a fixed norm over three hardcoded axes cannot express.
56 */
58 Eigen::VectorXd weights; ///< Per-axis scale applied before differencing. Empty means unit weights.
59 Eigen::VectorXd periods; ///< Per-axis wrap period. Zero or empty means the axis is linear.
60
61 /**
62 * @brief Componentwise difference, wrapped on any axis with a period
63 * @param a Left operand
64 * @param b Right operand
65 * @return a - b, with each wrapping axis taken the short way round
66 */
67 [[nodiscard]] Eigen::VectorXd delta(const Eigen::VectorXd& a, const Eigen::VectorXd& b) const
68 {
69 Eigen::VectorXd d = a - b;
70 if (periods.size() == d.size()) {
71 for (Eigen::Index i = 0; i < d.size(); ++i) {
72 const double p = periods(i);
73 if (p > 0.0) {
74 d(i) = std::remainder(d(i), p);
75 }
76 }
77 }
78 return d;
79 }
80
81 /**
82 * @brief Weighted squared distance under this metric
83 * @param a Left operand
84 * @param b Right operand
85 * @return Sum over axes of (weight * wrapped difference) squared
86 */
87 [[nodiscard]] float distance_sq(const Eigen::VectorXd& a, const Eigen::VectorXd& b) const
88 {
89 const Eigen::VectorXd d = delta(a, b);
90 double acc = 0.0;
91 for (Eigen::Index i = 0; i < d.size(); ++i) {
92 const double w = (weights.size() == d.size()) ? weights(i) : 1.0;
93 const double s = w * d(i);
94 acc += s * s;
95 }
96 return static_cast<float>(acc);
97 }
98
99 /**
100 * @brief A metric with unit weights and no wrapping
101 * @param dimensions Axis count
102 */
103 [[nodiscard]] static FeatureMetric uniform(Eigen::Index dimensions)
104 {
105 return { .weights = Eigen::VectorXd::Ones(dimensions),
106 .periods = Eigen::VectorXd::Zero(dimensions) };
107 }
108};
109
110/**
111 * @struct BoxExtent
112 * @brief An axis-aligned interval per axis, with a soft outer margin.
113 *
114 * The conjunction case: this axis within these bounds, and that axis
115 * within those bounds, and so on. Membership is governed by the axis
116 * that is furthest out of range rather than by their product, so an
117 * observation satisfying every axis but one does not accumulate a
118 * misleadingly high score from the axes it does satisfy.
119 *
120 * Softness is what makes membership continuous. With softness zero the
121 * extent is a hard test and membership is 0 or 1. With softness above
122 * zero, membership falls from 1 at the boundary to 0 at boundary plus
123 * softness, measured per axis in that axis's own units.
124 */
125struct BoxExtent {
126 Eigen::VectorXd lower; ///< Inclusive lower bound per axis.
127 Eigen::VectorXd upper; ///< Inclusive upper bound per axis.
128 Eigen::VectorXd softness; ///< Outward margin per axis over which membership falls to zero. Empty means hard.
129};
130
131/**
132 * @struct EllipsoidExtent
133 * @brief A centre, a radius per axis, and a soft outer shell.
134 *
135 * The graded case: distance from a prototype rather than satisfaction
136 * of independent bands. Radii are per axis so an ellipsoid derived from
137 * observed spread is tighter on the axes that were consistent across
138 * observations and looser on the ones that were not, which is the
139 * behaviour a extent fitted from demonstrations should have.
140 *
141 * Axis-aligned rather than fully general: a full covariance ellipsoid
142 * needs an eigendecomposition to test membership and correlated feature
143 * axes are usually a sign that the axes were badly chosen rather than
144 * something to model.
145 *
146 * The closer analog elsewhere is Nexus::Presence::FalloffFn, which also
147 * maps a normalized distance to a weight. The two differ in what
148 * "normalized distance" means and in whether an inside exists at all.
149 * Presence radiates outward from a point in 3D world space with no hard
150 * boundary, weight one at the centre falling toward zero at the falloff
151 * radius. This has a genuine inside, membership exactly one for any
152 * point within the per-axis radii, and softness only describes the
153 * outer shell beyond that. A Presence has no analog of "fully inside".
154 */
156 Eigen::VectorXd centre; ///< Prototype point.
157 Eigen::VectorXd radii; ///< Per-axis extent at which normalized distance reaches one.
158 double softness { 0.0 }; ///< Additional normalized distance over which membership falls to zero.
159};
160
161/**
162 * @brief Graded membership of a point in a box extent
163 * @param extent Extent to test against
164 * @param point Observation, same dimension as the extent
165 * @param metric Supplies wrapping; weights do not participate since the
166 * bounds are already stated in each axis's own units
167 * @return 1.0 fully inside, 0.0 fully outside, intermediate within the
168 * soft margin, governed by the worst axis
169 */
170[[nodiscard]] inline double membership(
171 const BoxExtent& extent,
172 const Eigen::VectorXd& point,
173 const FeatureMetric& metric)
174{
175 double worst = 1.0;
176 for (Eigen::Index i = 0; i < point.size(); ++i) {
177 const double centre = 0.5 * (extent.lower(i) + extent.upper(i));
178 const double half = 0.5 * (extent.upper(i) - extent.lower(i));
179
180 double offset = point(i) - centre;
181 if (metric.periods.size() == point.size() && metric.periods(i) > 0.0)
182 offset = std::remainder(offset, metric.periods(i));
183
184 const double outside = std::abs(offset) - half;
185 const double soft = (extent.softness.size() == point.size())
186 ? extent.softness(i)
187 : 0.0;
188
189 const double axis = (outside <= 0.0)
190 ? 1.0
191 : 1.0 - detail::ramp(0.0, std::max(soft, 1e-12), outside);
192
193 worst = std::min(worst, axis);
194 if (worst <= 0.0)
195 return 0.0;
196 }
197 return worst;
198}
199
200/**
201 * @brief Graded membership of a point in an ellipsoid extent
202 * @param extent Extent to test against
203 * @param point Observation, same dimension as the extent
204 * @param metric Supplies wrapping on the offset from the centre
205 * @return 1.0 at normalized distance one or below, falling to 0.0 at
206 * one plus softness
207 */
208[[nodiscard]] inline double membership(
209 const EllipsoidExtent& extent,
210 const Eigen::VectorXd& point,
211 const FeatureMetric& metric)
212{
213 const Eigen::VectorXd d = metric.delta(point, extent.centre);
214 double acc = 0.0;
215 for (Eigen::Index i = 0; i < d.size(); ++i) {
216 const double r = (extent.radii(i) > 0.0) ? extent.radii(i) : 1e-12;
217 const double s = d(i) / r;
218 acc += s * s;
219 }
220 const double normalized = std::sqrt(acc);
221 if (normalized <= 1.0)
222 return 1.0;
223 return 1.0 - detail::ramp(1.0, 1.0 + std::max(extent.softness, 1e-12), normalized);
224}
225
226/**
227 * @brief Derive per-axis metric weights from the spread of a sample set
228 * @param samples Observations, all of the same dimension
229 * @param periods Per-axis wrap periods, or an empty vector for none
230 * @return A metric whose weights are the reciprocal of each axis's
231 * standard deviation, so one standard deviation on any axis
232 * contributes equally to distance
233 *
234 * The answer to incommensurate units when a corpus is available: rather
235 * than the caller guessing that pressure should count twice as much as
236 * speed, the weights come from how much each axis actually varied across
237 * the observations. An axis with no variation gets unit weight rather
238 * than an infinite one.
239 */
240[[nodiscard]] inline FeatureMetric fit_metric(
241 std::span<const Eigen::VectorXd> samples,
242 const Eigen::VectorXd& periods = {})
243{
244 if (samples.empty())
245 return {};
246
247 const Eigen::Index n = samples.front().size();
248 Eigen::VectorXd mean = Eigen::VectorXd::Zero(n);
249 for (const auto& s : samples)
250 mean += s;
251 mean /= static_cast<double>(samples.size());
252
253 Eigen::VectorXd var = Eigen::VectorXd::Zero(n);
254 for (const auto& s : samples) {
255 const Eigen::VectorXd d = s - mean;
256 var += d.cwiseProduct(d);
257 }
258 var /= static_cast<double>(samples.size());
259
260 Eigen::VectorXd weights(n);
261 for (Eigen::Index i = 0; i < n; ++i) {
262 const double sd = std::sqrt(var(i));
263 weights(i) = (sd > 1e-12) ? (1.0 / sd) : 1.0;
264 }
265
266 return { .weights = weights,
267 .periods = (periods.size() == n) ? periods : Eigen::VectorXd::Zero(n) };
268}
269
270/**
271 * @brief Fit a box extent covering a central fraction of a sample set
272 * @param samples Observations, all of the same dimension
273 * @param coverage Fraction of observations the bounds should contain per
274 * axis, in 0..1. One takes the full min and max
275 * @param softness_fraction Soft margin per axis, as a fraction of that
276 * axis's fitted width
277 * @return Bounds at the symmetric quantiles implied by @p coverage
278 *
279 * Quantiles rather than min and max so one badly performed demonstration
280 * does not widen the extent to include everything between it and the
281 * others. A coverage of one recovers the min and max behaviour for
282 * callers who want it.
283 */
284[[nodiscard]] inline BoxExtent fit_box(
285 std::span<const Eigen::VectorXd> samples,
286 double coverage = 0.9,
287 double softness_fraction = 0.15)
288{
289 if (samples.empty())
290 return {};
291
292 const Eigen::Index n = samples.front().size();
293 BoxExtent extent {
294 .lower = Eigen::VectorXd::Zero(n),
295 .upper = Eigen::VectorXd::Zero(n),
296 .softness = Eigen::VectorXd::Zero(n)
297 };
298
299 const double tail = 0.5 * (1.0 - std::clamp(coverage, 0.0, 1.0));
300 std::vector<double> axis;
301 axis.reserve(samples.size());
302
303 for (Eigen::Index i = 0; i < n; ++i) {
304 axis.clear();
305 for (const auto& s : samples)
306 axis.push_back(s(i));
307 std::ranges::sort(axis);
308
309 const auto last = static_cast<double>(axis.size() - 1);
310 const auto lo_idx = static_cast<size_t>(std::floor(tail * last));
311 const auto hi_idx = static_cast<size_t>(std::ceil((1.0 - tail) * last));
312
313 extent.lower(i) = axis[lo_idx];
314 extent.upper(i) = axis[hi_idx];
315 extent.softness(i) = std::max(
316 (extent.upper(i) - extent.lower(i)) * softness_fraction, 1e-9);
317 }
318
319 return extent;
320}
321
322/**
323 * @brief Fit an ellipsoid extent from the mean and spread of a sample set
324 * @param samples Observations, all of the same dimension
325 * @param radius_sigma Per-axis radius as a multiple of that axis's
326 * standard deviation
327 * @param softness Additional normalized distance over which membership
328 * falls to zero beyond the fitted radius
329 * @return Centre at the sample mean, radii at @p radius_sigma standard
330 * deviations
331 *
332 * The prototype form of a demonstrated meaning: the centre is what was
333 * done on average and the radii are how much it varied, so an axis held
334 * consistently across demonstrations constrains membership tightly while
335 * one that wandered constrains it loosely.
336 */
337[[nodiscard]] inline EllipsoidExtent fit_ellipsoid(
338 std::span<const Eigen::VectorXd> samples,
339 double radius_sigma = 2.0,
340 double softness = 0.5)
341{
342 if (samples.empty())
343 return {};
344
345 const Eigen::Index n = samples.front().size();
346 Eigen::VectorXd mean = Eigen::VectorXd::Zero(n);
347 for (const auto& s : samples)
348 mean += s;
349 mean /= static_cast<double>(samples.size());
350
351 Eigen::VectorXd var = Eigen::VectorXd::Zero(n);
352 for (const auto& s : samples) {
353 const Eigen::VectorXd d = s - mean;
354 var += d.cwiseProduct(d);
355 }
356 var /= static_cast<double>(samples.size());
357
358 Eigen::VectorXd radii(n);
359 for (Eigen::Index i = 0; i < n; ++i)
360 radii(i) = std::max(radius_sigma * std::sqrt(var(i)), 1e-9);
361
362 return { .centre = mean, .radii = radii, .softness = softness };
363}
364
365/**
366 * @class ThresholdLatch
367 * @brief Two-level hysteresis turning a continuous value into a held
368 * boolean.
369 *
370 * A single threshold on a noisy continuous membership produces a rapid
371 * alternation whenever the value sits near it, which downstream reads as
372 * many separate occurrences of one event. Two levels separate the point
373 * at which the latch engages from the point at which it releases, so a
374 * value hovering between them holds whatever state it last reached.
375 *
376 * The gap between the levels is the caller's decision and depends on how
377 * noisy the incoming value is; there is no defensible default, so both
378 * are required at construction.
379 */
381public:
382 /**
383 * @brief Construct a latch
384 * @param engage Value at or above which the latch turns on
385 * @param release Value at or below which the latch turns off; should
386 * be below @p engage
387 */
388 ThresholdLatch(double engage, double release)
389 : m_engage(engage)
390 , m_release(release)
391 {
392 }
393
394 /**
395 * @brief Feed one value and report the resulting state
396 * @param value Current continuous value
397 * @return True if the latch is engaged after this observation
398 */
399 bool update(double value)
400 {
401 if (!m_engaged && value >= m_engage) {
402 m_engaged = true;
403 } else if (m_engaged && value <= m_release) {
404 m_engaged = false;
405 }
406 return m_engaged;
407 }
408
409 /** @brief Current state without feeding a value. */
410 [[nodiscard]] bool engaged() const { return m_engaged; }
411
412 /** @brief Force the latch off, for use on a known discontinuity. */
413 void reset() { m_engaged = false; }
414
415private:
416 double m_engage;
417 double m_release;
418 bool m_engaged { false };
419};
420
421} // namespace MayaFlux::Kinesis
size_t a
size_t b
glm::vec3 value
float offset
ThresholdLatch(double engage, double release)
Construct a latch.
bool update(double value)
Feed one value and report the resulting state.
bool engaged() const
Current state without feeding a value.
void reset()
Force the latch off, for use on a known discontinuity.
Two-level hysteresis turning a continuous value into a held boolean.
double ramp(double edge0, double edge1, double x) noexcept
Hermite ramp from 0 at edge0 to 1 at edge1.
EllipsoidExtent fit_ellipsoid(std::span< const Eigen::VectorXd > samples, double radius_sigma=2.0, double softness=0.5)
Fit an ellipsoid extent from the mean and spread of a sample set.
double membership(const BoxExtent &extent, const Eigen::VectorXd &point, const FeatureMetric &metric)
Graded membership of a point in a box extent.
FeatureMetric fit_metric(std::span< const Eigen::VectorXd > samples, const Eigen::VectorXd &periods={})
Derive per-axis metric weights from the spread of a sample set.
BoxExtent fit_box(std::span< const Eigen::VectorXd > samples, double coverage=0.9, double softness_fraction=0.15)
Fit a box extent covering a central fraction of a sample set.
std::vector< double > normalized(const std::vector< double > &data, double target_peak)
Normalize single-channel data (non-destructive)
Definition Yantra.cpp:588
double mean(const std::vector< double > &data)
Calculate mean of single-channel data.
Definition Yantra.cpp:55
Eigen::VectorXd softness
Outward margin per axis over which membership falls to zero. Empty means hard.
Eigen::VectorXd upper
Inclusive upper bound per axis.
Eigen::VectorXd lower
Inclusive lower bound per axis.
An axis-aligned interval per axis, with a soft outer margin.
Eigen::VectorXd radii
Per-axis extent at which normalized distance reaches one.
Eigen::VectorXd centre
Prototype point.
double softness
Additional normalized distance over which membership falls to zero.
A centre, a radius per axis, and a soft outer shell.
Eigen::VectorXd periods
Per-axis wrap period. Zero or empty means the axis is linear.
static FeatureMetric uniform(Eigen::Index dimensions)
A metric with unit weights and no wrapping.
Eigen::VectorXd weights
Per-axis scale applied before differencing. Empty means unit weights.
float distance_sq(const Eigen::VectorXd &a, const Eigen::VectorXd &b) const
Weighted squared distance under this metric.
Eigen::VectorXd delta(const Eigen::VectorXd &a, const Eigen::VectorXd &b) const
Componentwise difference, wrapped on any axis with a period.
Per-axis weighting and wrapping for a space whose axes carry different units.