use nv_core::{AffineTransform2D, Duration};
use crate::camera_motion::CameraMotionState;
use crate::provider::MotionReport;
use crate::validity::DegradationReason;
use crate::view_state::ViewState;
#[derive(Clone, Debug)]
pub enum EpochDecision {
Continue,
Degrade { reason: DegradationReason },
Compensate {
reason: DegradationReason,
transform: AffineTransform2D,
},
Segment,
}
pub struct EpochPolicyContext<'a> {
pub previous_view: &'a ViewState,
pub current_report: &'a MotionReport,
pub motion_state: CameraMotionState,
pub state_duration: Duration,
}
pub trait EpochPolicy: Send + Sync + 'static {
fn decide(&self, ctx: &EpochPolicyContext<'_>) -> EpochDecision;
}
#[derive(Debug, Clone)]
pub struct DefaultEpochPolicy {
pub segment_angle_threshold: f32,
pub segment_zoom_threshold: f32,
pub segment_displacement_threshold: f32,
pub compensate_min_confidence: f32,
pub degrade_on_small_motion: bool,
pub ptz_deadband: f32,
}
impl Default for DefaultEpochPolicy {
fn default() -> Self {
Self {
segment_angle_threshold: 15.0,
segment_zoom_threshold: 0.3,
segment_displacement_threshold: 0.25,
compensate_min_confidence: 0.8,
degrade_on_small_motion: true,
ptz_deadband: 0.01,
}
}
}
impl EpochPolicy for DefaultEpochPolicy {
fn decide(&self, ctx: &EpochPolicyContext<'_>) -> EpochDecision {
for event in &ctx.current_report.ptz_events {
if matches!(event, crate::ptz::PtzEvent::PresetRecall { .. }) {
return EpochDecision::Segment;
}
}
if let (Some(prev_ptz), Some(curr_ptz)) = (
ctx.previous_view.ptz.as_ref(),
ctx.current_report.ptz.as_ref(),
) {
let pan_delta = angular_delta(curr_ptz.pan, prev_ptz.pan);
let tilt_delta = angular_delta(curr_ptz.tilt, prev_ptz.tilt);
let zoom_delta = (curr_ptz.zoom - prev_ptz.zoom).abs();
if pan_delta > self.segment_angle_threshold
|| tilt_delta > self.segment_angle_threshold
|| zoom_delta > self.segment_zoom_threshold
{
if let Some(ref transform) = ctx.current_report.frame_transform
&& transform.confidence >= self.compensate_min_confidence
{
return EpochDecision::Compensate {
reason: DegradationReason::PtzMoving,
transform: transform.transform,
};
}
return EpochDecision::Segment;
}
if self.degrade_on_small_motion
&& (pan_delta > self.ptz_deadband
|| tilt_delta > self.ptz_deadband
|| zoom_delta > self.ptz_deadband)
{
return EpochDecision::Degrade {
reason: DegradationReason::PtzMoving,
};
}
}
if let CameraMotionState::Moving {
displacement: Some(disp),
..
} = &ctx.motion_state
{
if *disp > self.segment_displacement_threshold {
if let Some(ref transform) = ctx.current_report.frame_transform
&& transform.confidence >= self.compensate_min_confidence
{
return EpochDecision::Compensate {
reason: DegradationReason::LargeJump,
transform: transform.transform,
};
}
return EpochDecision::Segment;
}
if self.degrade_on_small_motion && *disp > 0.0 {
return EpochDecision::Degrade {
reason: DegradationReason::InferredMotionLowConfidence,
};
}
}
EpochDecision::Continue
}
}
fn angular_delta(a: f32, b: f32) -> f32 {
let raw = (a - b).abs();
raw.min(360.0 - raw)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ptz::{PtzEvent, PtzTelemetry};
use crate::transform::{GlobalTransformEstimate, TransformEstimationMethod};
use crate::view_state::{ViewState, ViewVersion};
use nv_core::MonotonicTs;
fn stable_ctx<'a>(prev: &'a ViewState, report: &'a MotionReport) -> EpochPolicyContext<'a> {
EpochPolicyContext {
previous_view: prev,
current_report: report,
motion_state: CameraMotionState::Stable,
state_duration: Duration::from_secs(10),
}
}
fn moving_ctx<'a>(
prev: &'a ViewState,
report: &'a MotionReport,
displacement: f32,
) -> EpochPolicyContext<'a> {
EpochPolicyContext {
previous_view: prev,
current_report: report,
motion_state: CameraMotionState::Moving {
angular_velocity: None,
displacement: Some(displacement),
},
state_duration: Duration::from_secs(1),
}
}
#[test]
fn stable_camera_continues() {
let policy = DefaultEpochPolicy::default();
let prev = ViewState::fixed_initial();
let report = MotionReport::default();
let ctx = stable_ctx(&prev, &report);
assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
}
#[test]
fn large_ptz_pan_segments() {
let policy = DefaultEpochPolicy::default();
let mut prev = ViewState::observed_initial();
prev.ptz = Some(PtzTelemetry {
pan: 0.0,
tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(0),
});
let report = MotionReport {
ptz: Some(PtzTelemetry {
pan: 20.0, tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(33_000_000),
}),
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
assert!(matches!(policy.decide(&ctx), EpochDecision::Segment));
}
#[test]
fn small_ptz_move_degrades() {
let policy = DefaultEpochPolicy::default();
let mut prev = ViewState::observed_initial();
prev.ptz = Some(PtzTelemetry {
pan: 0.0,
tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(0),
});
let report = MotionReport {
ptz: Some(PtzTelemetry {
pan: 2.0, tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(33_000_000),
}),
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
let d = policy.decide(&ctx);
assert!(
matches!(d, EpochDecision::Degrade { .. }),
"small PTZ move should degrade, got: {d:?}"
);
}
#[test]
fn large_ptz_with_high_confidence_transform_compensates() {
let policy = DefaultEpochPolicy::default();
let mut prev = ViewState::observed_initial();
prev.ptz = Some(PtzTelemetry {
pan: 0.0,
tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(0),
});
let report = MotionReport {
ptz: Some(PtzTelemetry {
pan: 20.0,
tilt: 0.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(33_000_000),
}),
frame_transform: Some(GlobalTransformEstimate {
transform: nv_core::AffineTransform2D::IDENTITY,
confidence: 0.95, method: TransformEstimationMethod::FeatureMatching,
computed_at: ViewVersion::INITIAL,
}),
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
assert!(matches!(
policy.decide(&ctx),
EpochDecision::Compensate { .. }
));
}
#[test]
fn large_inferred_displacement_segments() {
let policy = DefaultEpochPolicy::default();
let prev = ViewState::observed_initial();
let report = MotionReport::default();
let ctx = moving_ctx(&prev, &report, 0.3); assert!(matches!(policy.decide(&ctx), EpochDecision::Segment));
}
#[test]
fn small_inferred_displacement_degrades() {
let policy = DefaultEpochPolicy::default();
let prev = ViewState::observed_initial();
let report = MotionReport::default();
let ctx = moving_ctx(&prev, &report, 0.05); let d = policy.decide(&ctx);
assert!(
matches!(d, EpochDecision::Degrade { .. }),
"small displacement should degrade, got: {d:?}"
);
}
#[test]
fn preset_recall_event_segments() {
let policy = DefaultEpochPolicy::default();
let prev = ViewState::observed_initial();
let report = MotionReport {
ptz_events: vec![PtzEvent::PresetRecall {
preset_id: 3,
ts: MonotonicTs::from_nanos(100_000),
}],
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
assert!(
matches!(policy.decide(&ctx), EpochDecision::Segment),
"preset recall should force segment"
);
}
#[test]
fn move_start_event_alone_does_not_segment() {
let policy = DefaultEpochPolicy::default();
let prev = ViewState::observed_initial();
let report = MotionReport {
ptz_events: vec![PtzEvent::MoveStart {
ts: MonotonicTs::from_nanos(100_000),
}],
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
}
#[test]
fn degrade_on_small_motion_can_be_disabled() {
let policy = DefaultEpochPolicy {
degrade_on_small_motion: false,
..DefaultEpochPolicy::default()
};
let prev = ViewState::observed_initial();
let report = MotionReport::default();
let ctx = moving_ctx(&prev, &report, 0.05);
assert!(matches!(policy.decide(&ctx), EpochDecision::Continue));
}
#[test]
fn ptz_jitter_within_deadband_continues() {
let policy = DefaultEpochPolicy::default();
let mut prev = ViewState::observed_initial();
prev.ptz = Some(PtzTelemetry {
pan: 10.0,
tilt: 5.0,
zoom: 0.5,
ts: MonotonicTs::from_nanos(0),
});
let report = MotionReport {
ptz: Some(PtzTelemetry {
pan: 10.005,
tilt: 5.003,
zoom: 0.5002,
ts: MonotonicTs::from_nanos(33_000_000),
}),
..Default::default()
};
let ctx = stable_ctx(&prev, &report);
assert!(
matches!(policy.decide(&ctx), EpochDecision::Continue),
"floating-point jitter within deadband should not degrade"
);
}
}