Skip to content

Commit 0a267f7

Browse files
Add Curve Pop Detection and Filtering
Curve pop detection is intended to clean up curves with accidental pops or extreme noise/jitter in only a sub-section of the curve. We also add a simple curve resampling feature, and statistics/distribution related functions. GitHub issue #268.
1 parent 71ecea3 commit 0a267f7

28 files changed

+2722
-136
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ publish = false
2020
[workspace.dependencies]
2121
anyhow = "1.0.89"
2222
approx = "0.5.1"
23-
argmin = { version = "0.10.0", default-features = true, features = ["serde1"] } # ,
23+
argmin = { version = "0.10.0", default-features = true, features = ["serde1"] }
2424
argmin-math = { version = "0.4", default-features = true, features = ["primitives", "vec", "ndarray_latest"] }
2525
criterion = { version = "0.5.1", default-features = false, features = ["html_reports"] }
2626
exr = "1.72.0"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// Copyright (C) 2024 David Cattermole.
3+
//
4+
// This file is part of mmSolver.
5+
//
6+
// mmSolver is free software: you can redistribute it and/or modify it
7+
// under the terms of the GNU Lesser General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// mmSolver is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Lesser General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Lesser General Public License
17+
// along with mmSolver. If not, see <https://www.gnu.org/licenses/>.
18+
// ====================================================================
19+
//
20+
21+
pub mod pops;
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
//
2+
// Copyright (C) 2024 David Cattermole.
3+
//
4+
// This file is part of mmSolver.
5+
//
6+
// mmSolver is free software: you can redistribute it and/or modify it
7+
// under the terms of the GNU Lesser General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// mmSolver is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Lesser General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Lesser General Public License
17+
// along with mmSolver. If not, see <https://www.gnu.org/licenses/>.
18+
// ====================================================================
19+
//
20+
21+
use anyhow::bail;
22+
use anyhow::Result;
23+
use log::debug;
24+
use std::fmt;
25+
26+
use crate::constant::Real;
27+
use crate::curve::derivatives::allocate_derivatives_order_1;
28+
use crate::curve::derivatives::calculate_derivatives_order_1;
29+
use crate::math::distributions::standard_deviation_of_values;
30+
use crate::math::distributions::Statistics;
31+
32+
// Calculates how many standard deviations a value is from the mean.
33+
fn calculate_z_score(stats: &Statistics, value: f64) -> f64 {
34+
(value - stats.mean).abs() / stats.std_dev.max(1e-10)
35+
}
36+
37+
// Normalize the deviations relative to the global statistics.
38+
//
39+
// Adjusts a local deviation score relative to global statistics.
40+
fn normalize_local_deviation(
41+
global_stats: &Statistics,
42+
local_stats: &Statistics,
43+
deviation: f64,
44+
) -> f64 {
45+
deviation * (local_stats.std_dev / global_stats.std_dev.max(1e-10))
46+
}
47+
48+
// Computes a smoothness score for a window of the animation curve using velocity statistics.
49+
fn calculate_window_smoothness_score(
50+
i: usize,
51+
window_start: usize,
52+
window_end: usize,
53+
times: &[f64],
54+
values: &[f64],
55+
velocity: &[f64],
56+
global_velocity_stats: &Statistics,
57+
) -> f64 {
58+
let window_size = if window_end > window_start {
59+
window_end - window_start
60+
} else {
61+
window_start - window_end
62+
};
63+
if window_size < 2 {
64+
return 0.0;
65+
}
66+
67+
let local_velocity = &velocity[window_start..window_end];
68+
let local_velocity_stats = standard_deviation_of_values(&local_velocity);
69+
let local_velocity_stats = match local_velocity_stats {
70+
Some(value) => value,
71+
None => return 0.0,
72+
};
73+
74+
// Check for discontinuity.
75+
let velocity_deviation =
76+
calculate_z_score(&local_velocity_stats, velocity[i]);
77+
78+
// Normalize the deviations relative to the global statistics.
79+
let smoothness_score = normalize_local_deviation(
80+
&global_velocity_stats,
81+
&local_velocity_stats,
82+
velocity_deviation,
83+
);
84+
85+
smoothness_score
86+
}
87+
88+
/// Represents a point that was classified as a spike
89+
#[derive(Debug)]
90+
pub struct SpikePoint {
91+
pub time: f64,
92+
pub value: f64,
93+
pub score: f64,
94+
}
95+
96+
impl fmt::Display for SpikePoint {
97+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98+
write!(
99+
f,
100+
"SpikePoint [ t={:.2}, v={:.2} (score={:.2}) ]",
101+
self.time, self.value, self.score
102+
)
103+
}
104+
}
105+
106+
fn calculate_per_frame_pop_score(
107+
times: &[f64],
108+
values: &[f64],
109+
senstivity: f64,
110+
out_velocity: &mut [f64],
111+
out_scores: &mut [f64],
112+
) -> Result<()> {
113+
if (times.len() != values.len()) && (times.len() != out_scores.len()) {
114+
bail!("Times, values and output arrays must have the same length.");
115+
}
116+
117+
calculate_derivatives_order_1(times, values, out_velocity)?;
118+
119+
// Calculate statistics for each derivative
120+
let global_velocity_stats =
121+
standard_deviation_of_values(&out_velocity).unwrap();
122+
123+
let n = times.len();
124+
let window_size = 2;
125+
126+
// Forward pass
127+
let mut i = window_size;
128+
while i < n {
129+
let window_start = i - window_size;
130+
let window_end = i;
131+
132+
let score = calculate_window_smoothness_score(
133+
i,
134+
window_start,
135+
window_end,
136+
times,
137+
values,
138+
&out_velocity,
139+
&global_velocity_stats,
140+
);
141+
out_scores[i] = score;
142+
143+
i += 1;
144+
if score > senstivity {
145+
let next = i + 1;
146+
if next <= (n - 1) {
147+
i = next;
148+
}
149+
}
150+
}
151+
152+
// Backward pass
153+
let mut i = n - window_size;
154+
while i > 0 {
155+
let window_start = i;
156+
let window_end = i + window_size;
157+
158+
let score = calculate_window_smoothness_score(
159+
i,
160+
window_start,
161+
window_end,
162+
times,
163+
values,
164+
&out_velocity,
165+
&global_velocity_stats,
166+
);
167+
out_scores[i] = score.min(out_scores[i]);
168+
169+
i -= 1;
170+
if score > senstivity {
171+
let next = i.saturating_sub(1);
172+
if next <= (n - 1) {
173+
i = next;
174+
}
175+
}
176+
}
177+
178+
Ok(())
179+
}
180+
181+
/// Find spikes in the data.
182+
pub fn detect_curve_pops(
183+
times: &[f64],
184+
values: &[f64],
185+
threshold: f64,
186+
) -> Result<Vec<SpikePoint>> {
187+
if times.len() != values.len() {
188+
bail!("Times and values must have the same length.");
189+
}
190+
191+
let n = times.len();
192+
let mut velocity = allocate_derivatives_order_1(times.len())?;
193+
let mut scores = vec![0.0; n];
194+
195+
let sensitivity = threshold;
196+
calculate_per_frame_pop_score(
197+
&times,
198+
&values,
199+
sensitivity,
200+
&mut velocity,
201+
&mut scores,
202+
)?;
203+
204+
let mut out_values = Vec::new();
205+
out_values.reserve(n);
206+
207+
for i in 0..n {
208+
let prev = i.saturating_sub(1);
209+
let next = (i + 1).min(n - 1);
210+
211+
let score_prev = scores[prev];
212+
let score_current = scores[i];
213+
let score_next = scores[next];
214+
215+
let pop_prev = score_prev > threshold;
216+
let pop_current = score_current > threshold;
217+
let pop_next = score_next > threshold;
218+
219+
if pop_prev || pop_current || pop_next {
220+
let t = times[i];
221+
let v = values[i];
222+
223+
let point = SpikePoint {
224+
time: t,
225+
value: v,
226+
score: score_current,
227+
};
228+
out_values.push(point);
229+
}
230+
}
231+
232+
Ok(out_values)
233+
}
234+
235+
pub fn filter_curve_pops(
236+
times: &[f64],
237+
values: &[f64],
238+
threshold: f64,
239+
) -> Result<Vec<(f64, f64)>> {
240+
if times.len() != values.len() {
241+
bail!("Times and values must have the same length.");
242+
}
243+
244+
let n = times.len();
245+
let mut velocity = allocate_derivatives_order_1(times.len())?;
246+
let mut scores = vec![0.0; n];
247+
248+
let sensitivity = threshold;
249+
calculate_per_frame_pop_score(
250+
&times,
251+
&values,
252+
sensitivity,
253+
&mut velocity,
254+
&mut scores,
255+
)?;
256+
257+
let mut out_values_xy = Vec::new();
258+
out_values_xy.reserve(n);
259+
260+
for i in 0..n {
261+
let prev = i.saturating_sub(1);
262+
let next = (i + 1).min(n - 1);
263+
264+
let score_prev = scores[prev];
265+
let score_current = scores[i];
266+
let score_next = scores[next];
267+
268+
let pop_prev = score_prev <= threshold;
269+
let pop_current = score_current <= threshold;
270+
let pop_next = score_next <= threshold;
271+
272+
if pop_prev || pop_current || pop_next {
273+
let t = times[i];
274+
let v = values[i];
275+
out_values_xy.push((t, v));
276+
}
277+
}
278+
279+
// Ok((out_values_x, out_values_y))
280+
Ok(out_values_xy)
281+
}
282+
283+
#[cfg(test)]
284+
mod tests {
285+
use super::*;
286+
287+
#[test]
288+
fn test_detect_acceleration_changes() -> Result<()> {
289+
let times = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
290+
let values = vec![
291+
// Smooth acceleration
292+
1.0, 1.2, 1.5, 2.0, 2.7, 3.6,
293+
// Sudden pop (discontinuous acceleration)
294+
5.0, // Return to smooth motion
295+
5.5, 5.8, 6.0,
296+
];
297+
298+
let threshold = 3.0;
299+
let pops = detect_curve_pops(&times, &values, threshold)?;
300+
301+
assert!(pops[4].score < threshold); // Smooth acceleration should not be detected
302+
assert!(pops[6].score > threshold); // Sudden pop should be detected
303+
304+
Ok(())
305+
}
306+
307+
#[test]
308+
fn test_smooth_acceleration() -> Result<()> {
309+
let times = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
310+
// Gradually increasing acceleration
311+
let values = vec![0.0, 0.1, 0.4, 0.9, 1.6, 2.5, 3.6, 4.9];
312+
313+
let threshold = 3.0;
314+
let pops = detect_curve_pops(&times, &values, threshold)?;
315+
316+
// Should not detect any pops in smoothly accelerating curve
317+
assert!(pops.iter().all(|x| x.score < threshold));
318+
319+
Ok(())
320+
}
321+
}

lib/rust/mmscenegraph/src/curve/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@
1919
//
2020

2121
pub mod derivatives;
22+
pub mod detect;
23+
pub mod resample;

0 commit comments

Comments
 (0)