MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
HampelFilter.hpp
Go to the documentation of this file.
1#pragma once
2
4
5#include <glm/glm.hpp>
6
7namespace MayaFlux::Kinesis {
8
9/**
10 * @class HampelFilter
11 * @brief Holds the last accepted value when a new sample looks like an
12 * isolated outlier relative to its recent neighbors.
13 *
14 * Implements the Hampel identifier: a candidate is rejected when its
15 * deviation from the median of a recent window exceeds threshold_mad
16 * scaled median absolute deviations. This is the standard robust
17 * alternative to a mean/standard-deviation outlier test, chosen
18 * because a single genuine spike should not be allowed to inflate the
19 * threshold that screens for itself: one wild sample shifts a window's
20 * mean and stddev substantially, but only shifts a median by at most
21 * one rank. The hold-last-value response on rejection is the filtering
22 * variant of the Hampel identifier, as opposed to flag-only detection.
23 *
24 * Differential's derivatives amplify noise: a single bad raw sample that
25 * survives upstream smoothing (Stochastic::Estimate) still gets
26 * multiplied by 1/dt^2 or 1/dt^3, producing one wildly spiking
27 * acceleration or jerk reading even though the underlying motion was
28 * smooth. HampelFilter screens for that specific shape of problem, an
29 * isolated one-sample spike in an already-differentiated signal, which
30 * is a different concern from Estimate's job of characterizing an
31 * evolving raw stream's noise floor. HampelFilter has no notion of a
32 * learned floor and does not filter a raw signal; it only asks whether
33 * one candidate value is consistent with its immediate recent history.
34 *
35 * On rejection, this returns the last accepted value rather than the
36 * candidate, silently. This adds up to one sample of lag on a genuine
37 * fast transient that happens to also look statistically unusual,
38 * which is the deliberate tradeoff: a caller feeding this into a
39 * visual parameter or gesture classifier is generally better served
40 * by a held value than a single-frame glitch. A caller that needs to
41 * know a rejection happened rather than have it silently smoothed over
42 * should not use this class; it is intentionally not a flag-only tool.
43 *
44 * @tparam T Sample type. Must support operator-, and the resulting
45 * difference type must be usable with scalar_t<T> reductions
46 * (a plain arithmetic value directly, a glm vector via its
47 * magnitude). See magnitude_of() below for the exact rule.
48 */
49template <typename T>
51public:
52 /**
53 * @brief Construct a filter
54 * @param window Number of recent accepted samples to test new
55 * candidates against, minimum 3 since MAD needs at least a
56 * few points to be meaningful
57 * @param threshold_mad Number of scaled-MAD units beyond which a
58 * candidate is rejected, typically 3.0 to 4.0
59 * @param max_consecutive_rejections Forces the next candidate to be
60 * accepted unconditionally, and clears history to restart
61 * the window from it, once this many candidates in a row
62 * have been rejected. Without this, a real sustained change
63 * in the signal (which any consecutive Differential reading
64 * during real motion looks like relative to an older,
65 * now-stale window) can never be re-accepted: once every
66 * recent candidate is rejected against a frozen median, the
67 * filter locks onto whatever value it last accepted and
68 * holds it indefinitely, since a rejected candidate is never
69 * added to the window that future candidates are tested
70 * against. Default 3, since three consecutive genuine
71 * outliers from the same underlying cause is already an
72 * unusual coincidence; more than that is far more likely to
73 * be a real change the window has fallen behind.
74 */
75 explicit HampelFilter(size_t window = 8, double threshold_mad = 3.5,
76 size_t max_consecutive_rejections = 3)
77 : m_history(window < 3 ? 3 : window)
78 , m_threshold_mad(threshold_mad)
79 , m_max_consecutive_rejections(max_consecutive_rejections)
80 , m_last_good(T {})
81 , m_has_history(false)
82 {
83 }
84
85 /**
86 * @brief Test a candidate value and return the value to actually use
87 * @param candidate Newly computed value, typically straight out of
88 * a Kinesis::Differential function
89 * @return candidate if it looks consistent with recent history, or
90 * the last accepted value if candidate looks like an
91 * isolated spike
92 *
93 * The first call and the next (window - 1) calls always accept,
94 * since MAD is not meaningful against a window that has not yet
95 * filled with real data; a held value from an uninitialized filter
96 * would be an arbitrary default, not a real prior reading.
97 */
98 T accept(const T& candidate)
99 {
100 if (!m_has_history) {
101 m_history.push(candidate);
102 m_last_good = candidate;
104 if (m_sample_count >= m_history.capacity())
105 m_has_history = true;
106 return candidate;
107 }
108
109 const auto view = m_history.linearized_view();
110
111 std::vector<double> magnitudes;
112 magnitudes.reserve(view.size());
113 for (const auto& v : view)
114 magnitudes.push_back(magnitude_of(v));
115
116 std::vector<double> sorted = magnitudes;
117 std::ranges::sort(sorted);
118 const double median_mag = sorted[sorted.size() / 2];
119
120 std::vector<double> deviations;
121 deviations.reserve(sorted.size());
122 for (double m : sorted)
123 deviations.push_back(std::abs(m - median_mag));
124 std::ranges::sort(deviations);
125 const double mad = deviations[deviations.size() / 2] * 1.4826;
126
127 const double candidate_mag = magnitude_of(candidate);
128 const bool looks_like_spike = (mad > 1e-12)
129 && (std::abs(candidate_mag - median_mag) / mad > m_threshold_mad);
130
131 if (looks_like_spike && m_consecutive_rejections < m_max_consecutive_rejections) {
133 return m_last_good;
134 }
135
136 if (looks_like_spike) {
137 m_history.reset();
138 m_sample_count = 0;
139 m_has_history = false;
140 }
141
143 m_history.push(candidate);
144 m_last_good = candidate;
146 if (m_sample_count >= m_history.capacity())
147 m_has_history = true;
148 return candidate;
149 }
150
151 /**
152 * @brief Reset to uninitialized state
153 *
154 * Call on a known discontinuity (device reconnect, deliberate
155 * large jump the caller does not want screened as a spike) so the
156 * next window's worth of candidates re-accepts unconditionally
157 * rather than being tested against now-stale history.
158 */
159 void reset()
160 {
161 m_history.reset();
162 m_last_good = T {};
163 m_has_history = false;
164 m_sample_count = 0;
166 }
167
168 /**
169 * @brief Current last accepted value
170 */
171 [[nodiscard]] const T& last_good() const { return m_last_good; }
172
173 /**
174 * @brief Whether the filter has enough history to actually screen candidates
175 *
176 * False during the initial window-filling period, when accept()
177 * always returns its argument unconditionally.
178 */
179 [[nodiscard]] bool is_active() const { return m_has_history; }
180
181private:
182 /**
183 * @brief Scalar reduction used for the MAD comparison
184 *
185 * Plain arithmetic T reduces to its absolute value directly.
186 * glm vector T reduces to its length. This means HampelFilter
187 * screens on magnitude of change, not direction: a candidate whose
188 * magnitude is consistent with recent history is accepted even if
189 * its direction differs, which is deliberate, since a real sharp
190 * turn is a direction change with unremarkable magnitude and
191 * should not be screened out as if it were a spike.
192 */
193 template <typename U>
194 static double magnitude_of(const U& v) noexcept
195 {
196 if constexpr (requires { glm::length(v); }) {
197 return static_cast<double>(glm::length(v));
198 } else {
199 return static_cast<double>(std::abs(v));
200 }
201 }
202
208 size_t m_sample_count { 0 };
210};
211
212} // namespace MayaFlux::Kinesis
const T & last_good() const
Current last accepted value.
T accept(const T &candidate)
Test a candidate value and return the value to actually use.
Memory::HistoryBuffer< T > m_history
bool is_active() const
Whether the filter has enough history to actually screen candidates.
HampelFilter(size_t window=8, double threshold_mad=3.5, size_t max_consecutive_rejections=3)
Construct a filter.
void reset()
Reset to uninitialized state.
static double magnitude_of(const U &v) noexcept
Scalar reduction used for the MAD comparison.
Holds the last accepted value when a new sample looks like an isolated outlier relative to its recent...
History buffer for difference equations and recursive relations.