1use 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#[derive(Clone, Debug)]
12pub enum EpochDecision {
13 Continue,
15
16 Degrade { reason: DegradationReason },
18
19 Compensate {
22 reason: DegradationReason,
23 transform: AffineTransform2D,
24 },
25
26 Segment,
29}
30
31pub struct EpochPolicyContext<'a> {
33 pub previous_view: &'a ViewState,
35 pub current_report: &'a MotionReport,
37 pub motion_state: CameraMotionState,
39 pub state_duration: Duration,
41}
42
43pub trait EpochPolicy: Send + Sync + 'static {
48 fn decide(&self, ctx: &EpochPolicyContext<'_>) -> EpochDecision;
50}
51
52#[derive(Debug, Clone)]
57pub struct DefaultEpochPolicy {
58 pub segment_angle_threshold: f32,
61
62 pub segment_zoom_threshold: f32,
65
66 pub segment_displacement_threshold: f32,
69
70 pub compensate_min_confidence: f32,
73
74 pub degrade_on_small_motion: bool,
77
78 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 for event in &ctx.current_report.ptz_events {
102 if matches!(event, crate::ptz::PtzEvent::PresetRecall { .. }) {
103 return EpochDecision::Segment;
104 }
105 }
106
107 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 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 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
171fn 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 #[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 #[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, 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, 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, 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 #[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); 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); let d = policy.decide(&ctx);
324 assert!(
325 matches!(d, EpochDecision::Degrade { .. }),
326 "small displacement should degrade, got: {d:?}"
327 );
328 }
329
330 #[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 assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
364 }
365
366 #[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 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 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}