Skip to main content

nv_view/
epoch.rs

1//! Epoch policy trait and default implementation.
2
3use nv_core::{AffineTransform2D, Duration};
4
5use crate::camera_motion::CameraMotionState;
6use crate::provider::MotionReport;
7use crate::validity::DegradationReason;
8use crate::view_state::ViewState;
9
10/// Decision returned by an [`EpochPolicy`] when camera motion is detected.
11#[derive(Clone, Debug)]
12pub enum EpochDecision {
13    /// No temporal state change. View is continuous despite motion.
14    Continue,
15
16    /// Degrade context validity but keep the current epoch.
17    Degrade { reason: DegradationReason },
18
19    /// Degrade, but also apply a compensation transform to existing
20    /// track positions so they align with the new view.
21    Compensate {
22        reason: DegradationReason,
23        transform: AffineTransform2D,
24    },
25
26    /// Full segmentation: increment epoch, close trajectory segments,
27    /// notify stages.
28    Segment,
29}
30
31/// Context passed to [`EpochPolicy::decide`].
32pub struct EpochPolicyContext<'a> {
33    /// The view state from the previous frame.
34    pub previous_view: &'a ViewState,
35    /// The motion report for the current frame.
36    pub current_report: &'a MotionReport,
37    /// The computed motion state for the current frame.
38    pub motion_state: CameraMotionState,
39    /// How long the camera has been in the current motion state.
40    pub state_duration: Duration,
41}
42
43/// User-implementable trait: controls the response to detected camera motion.
44///
45/// The library ships [`DefaultEpochPolicy`] for common threshold-based decisions.
46/// Users with complex PTZ deployments can implement custom policies.
47pub trait EpochPolicy: Send + Sync + 'static {
48    /// Decide what to do in response to detected camera motion.
49    fn decide(&self, ctx: &EpochPolicyContext<'_>) -> EpochDecision;
50}
51
52/// Default epoch policy based on configurable thresholds.
53///
54/// Covers the common case: large PTZ jumps → segment, small motions → degrade,
55/// high-confidence transforms → compensate.
56#[derive(Debug, Clone)]
57pub struct DefaultEpochPolicy {
58    /// Pan/tilt delta (degrees) above which a PTZ move triggers `Segment`.
59    /// Below this, triggers `Degrade`. Default: `15.0`.
60    pub segment_angle_threshold: f32,
61
62    /// Zoom ratio change above which a zoom move triggers `Segment`.
63    /// Default: `0.3`.
64    pub segment_zoom_threshold: f32,
65
66    /// Inferred-motion displacement (normalized coords) above which
67    /// triggers `Segment`. Default: `0.25`.
68    pub segment_displacement_threshold: f32,
69
70    /// Minimum confidence for a `Compensate` decision (instead of `Segment`)
71    /// when a transform is available. Default: `0.8`.
72    pub compensate_min_confidence: f32,
73
74    /// If `true`, small motions below segment thresholds produce `Degrade`
75    /// instead of `Continue`. Default: `true`.
76    pub degrade_on_small_motion: bool,
77
78    /// Minimum PTZ telemetry delta (degrees for pan/tilt, ratio for zoom)
79    /// below which changes are treated as sensor noise and ignored.
80    /// Prevents floating-point jitter from triggering spurious `Degrade`
81    /// events when the camera is at rest. Default: `0.01`.
82    pub ptz_deadband: f32,
83}
84
85impl Default for DefaultEpochPolicy {
86    fn default() -> Self {
87        Self {
88            segment_angle_threshold: 15.0,
89            segment_zoom_threshold: 0.3,
90            segment_displacement_threshold: 0.25,
91            compensate_min_confidence: 0.8,
92            degrade_on_small_motion: true,
93            ptz_deadband: 0.01,
94        }
95    }
96}
97
98impl EpochPolicy for DefaultEpochPolicy {
99    fn decide(&self, ctx: &EpochPolicyContext<'_>) -> EpochDecision {
100        // A preset recall is always a full discontinuity.
101        for event in &ctx.current_report.ptz_events {
102            if matches!(event, crate::ptz::PtzEvent::PresetRecall { .. }) {
103                return EpochDecision::Segment;
104            }
105        }
106
107        // Check PTZ telemetry for large moves.
108        if let (Some(prev_ptz), Some(curr_ptz)) = (
109            ctx.previous_view.ptz.as_ref(),
110            ctx.current_report.ptz.as_ref(),
111        ) {
112            let pan_delta = angular_delta(curr_ptz.pan, prev_ptz.pan);
113            let tilt_delta = angular_delta(curr_ptz.tilt, prev_ptz.tilt);
114            let zoom_delta = (curr_ptz.zoom - prev_ptz.zoom).abs();
115
116            if pan_delta > self.segment_angle_threshold
117                || tilt_delta > self.segment_angle_threshold
118                || zoom_delta > self.segment_zoom_threshold
119            {
120                // Check if compensation is possible.
121                if let Some(ref transform) = ctx.current_report.frame_transform
122                    && transform.confidence >= self.compensate_min_confidence
123                {
124                    return EpochDecision::Compensate {
125                        reason: DegradationReason::PtzMoving,
126                        transform: transform.transform,
127                    };
128                }
129                return EpochDecision::Segment;
130            }
131
132            if self.degrade_on_small_motion
133                && (pan_delta > self.ptz_deadband
134                    || tilt_delta > self.ptz_deadband
135                    || zoom_delta > self.ptz_deadband)
136            {
137                return EpochDecision::Degrade {
138                    reason: DegradationReason::PtzMoving,
139                };
140            }
141        }
142
143        // Check inferred displacement.
144        if let CameraMotionState::Moving {
145            displacement: Some(disp),
146            ..
147        } = &ctx.motion_state
148        {
149            if *disp > self.segment_displacement_threshold {
150                if let Some(ref transform) = ctx.current_report.frame_transform
151                    && transform.confidence >= self.compensate_min_confidence
152                {
153                    return EpochDecision::Compensate {
154                        reason: DegradationReason::LargeJump,
155                        transform: transform.transform,
156                    };
157                }
158                return EpochDecision::Segment;
159            }
160            if self.degrade_on_small_motion && *disp > 0.0 {
161                return EpochDecision::Degrade {
162                    reason: DegradationReason::InferredMotionLowConfidence,
163                };
164            }
165        }
166
167        EpochDecision::Continue
168    }
169}
170
171/// Compute the shortest angular delta between two angles in degrees,
172/// correctly handling wraparound at the 0°/360° boundary.
173fn angular_delta(a: f32, b: f32) -> f32 {
174    let raw = (a - b).abs();
175    raw.min(360.0 - raw)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::ptz::{PtzEvent, PtzTelemetry};
182    use crate::transform::{GlobalTransformEstimate, TransformEstimationMethod};
183    use crate::view_state::{ViewState, ViewVersion};
184    use nv_core::MonotonicTs;
185
186    fn stable_ctx<'a>(prev: &'a ViewState, report: &'a MotionReport) -> EpochPolicyContext<'a> {
187        EpochPolicyContext {
188            previous_view: prev,
189            current_report: report,
190            motion_state: CameraMotionState::Stable,
191            state_duration: Duration::from_secs(10),
192        }
193    }
194
195    fn moving_ctx<'a>(
196        prev: &'a ViewState,
197        report: &'a MotionReport,
198        displacement: f32,
199    ) -> EpochPolicyContext<'a> {
200        EpochPolicyContext {
201            previous_view: prev,
202            current_report: report,
203            motion_state: CameraMotionState::Moving {
204                angular_velocity: None,
205                displacement: Some(displacement),
206            },
207            state_duration: Duration::from_secs(1),
208        }
209    }
210
211    // -- Continue --
212
213    #[test]
214    fn stable_camera_continues() {
215        let policy = DefaultEpochPolicy::default();
216        let prev = ViewState::fixed_initial();
217        let report = MotionReport::default();
218        let ctx = stable_ctx(&prev, &report);
219        assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
220    }
221
222    // -- PTZ thresholds --
223
224    #[test]
225    fn large_ptz_pan_segments() {
226        let policy = DefaultEpochPolicy::default();
227        let mut prev = ViewState::observed_initial();
228        prev.ptz = Some(PtzTelemetry {
229            pan: 0.0,
230            tilt: 0.0,
231            zoom: 0.5,
232            ts: MonotonicTs::from_nanos(0),
233        });
234        let report = MotionReport {
235            ptz: Some(PtzTelemetry {
236                pan: 20.0, // > 15.0 threshold
237                tilt: 0.0,
238                zoom: 0.5,
239                ts: MonotonicTs::from_nanos(33_000_000),
240            }),
241            ..Default::default()
242        };
243        let ctx = stable_ctx(&prev, &report);
244        assert!(matches!(policy.decide(&ctx), EpochDecision::Segment));
245    }
246
247    #[test]
248    fn small_ptz_move_degrades() {
249        let policy = DefaultEpochPolicy::default();
250        let mut prev = ViewState::observed_initial();
251        prev.ptz = Some(PtzTelemetry {
252            pan: 0.0,
253            tilt: 0.0,
254            zoom: 0.5,
255            ts: MonotonicTs::from_nanos(0),
256        });
257        let report = MotionReport {
258            ptz: Some(PtzTelemetry {
259                pan: 2.0, // < 15.0 threshold, > 0
260                tilt: 0.0,
261                zoom: 0.5,
262                ts: MonotonicTs::from_nanos(33_000_000),
263            }),
264            ..Default::default()
265        };
266        let ctx = stable_ctx(&prev, &report);
267        let d = policy.decide(&ctx);
268        assert!(
269            matches!(d, EpochDecision::Degrade { .. }),
270            "small PTZ move should degrade, got: {d:?}"
271        );
272    }
273
274    #[test]
275    fn large_ptz_with_high_confidence_transform_compensates() {
276        let policy = DefaultEpochPolicy::default();
277        let mut prev = ViewState::observed_initial();
278        prev.ptz = Some(PtzTelemetry {
279            pan: 0.0,
280            tilt: 0.0,
281            zoom: 0.5,
282            ts: MonotonicTs::from_nanos(0),
283        });
284        let report = MotionReport {
285            ptz: Some(PtzTelemetry {
286                pan: 20.0,
287                tilt: 0.0,
288                zoom: 0.5,
289                ts: MonotonicTs::from_nanos(33_000_000),
290            }),
291            frame_transform: Some(GlobalTransformEstimate {
292                transform: nv_core::AffineTransform2D::IDENTITY,
293                confidence: 0.95, // > 0.8 threshold
294                method: TransformEstimationMethod::FeatureMatching,
295                computed_at: ViewVersion::INITIAL,
296            }),
297            ..Default::default()
298        };
299        let ctx = stable_ctx(&prev, &report);
300        assert!(matches!(
301            policy.decide(&ctx),
302            EpochDecision::Compensate { .. }
303        ));
304    }
305
306    // -- Inferred displacement thresholds --
307
308    #[test]
309    fn large_inferred_displacement_segments() {
310        let policy = DefaultEpochPolicy::default();
311        let prev = ViewState::observed_initial();
312        let report = MotionReport::default();
313        let ctx = moving_ctx(&prev, &report, 0.3); // > 0.25 threshold
314        assert!(matches!(policy.decide(&ctx), EpochDecision::Segment));
315    }
316
317    #[test]
318    fn small_inferred_displacement_degrades() {
319        let policy = DefaultEpochPolicy::default();
320        let prev = ViewState::observed_initial();
321        let report = MotionReport::default();
322        let ctx = moving_ctx(&prev, &report, 0.05); // > 0 but < 0.25
323        let d = policy.decide(&ctx);
324        assert!(
325            matches!(d, EpochDecision::Degrade { .. }),
326            "small displacement should degrade, got: {d:?}"
327        );
328    }
329
330    // -- PTZ events --
331
332    #[test]
333    fn preset_recall_event_segments() {
334        let policy = DefaultEpochPolicy::default();
335        let prev = ViewState::observed_initial();
336        let report = MotionReport {
337            ptz_events: vec![PtzEvent::PresetRecall {
338                preset_id: 3,
339                ts: MonotonicTs::from_nanos(100_000),
340            }],
341            ..Default::default()
342        };
343        let ctx = stable_ctx(&prev, &report);
344        assert!(
345            matches!(policy.decide(&ctx), EpochDecision::Segment),
346            "preset recall should force segment"
347        );
348    }
349
350    #[test]
351    fn move_start_event_alone_does_not_segment() {
352        let policy = DefaultEpochPolicy::default();
353        let prev = ViewState::observed_initial();
354        let report = MotionReport {
355            ptz_events: vec![PtzEvent::MoveStart {
356                ts: MonotonicTs::from_nanos(100_000),
357            }],
358            ..Default::default()
359        };
360        let ctx = stable_ctx(&prev, &report);
361        // MoveStart alone doesn't change telemetry or displacement,
362        // so the policy falls through to Continue.
363        assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
364    }
365
366    // -- Configuration --
367
368    #[test]
369    fn degrade_on_small_motion_can_be_disabled() {
370        let policy = DefaultEpochPolicy {
371            degrade_on_small_motion: false,
372            ..DefaultEpochPolicy::default()
373        };
374        let prev = ViewState::observed_initial();
375        let report = MotionReport::default();
376        let ctx = moving_ctx(&prev, &report, 0.05);
377        // With degrade disabled, small motion should continue.
378        assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
379    }
380
381    #[test]
382    fn ptz_jitter_within_deadband_continues() {
383        let policy = DefaultEpochPolicy::default();
384        let mut prev = ViewState::observed_initial();
385        prev.ptz = Some(PtzTelemetry {
386            pan: 10.0,
387            tilt: 5.0,
388            zoom: 0.5,
389            ts: MonotonicTs::from_nanos(0),
390        });
391        let report = MotionReport {
392            ptz: Some(PtzTelemetry {
393                // Tiny jitter below the 0.01 deadband
394                pan: 10.005,
395                tilt: 5.003,
396                zoom: 0.5002,
397                ts: MonotonicTs::from_nanos(33_000_000),
398            }),
399            ..Default::default()
400        };
401        let ctx = stable_ctx(&prev, &report);
402        assert!(
403            matches!(policy.decide(&ctx), EpochDecision::Continue),
404            "floating-point jitter within deadband should not degrade"
405        );
406    }
407}