use std::collections::BTreeMap;
use std::sync::atomic::{compiler_fence, fence, Ordering};
use serde::{Deserialize, Serialize};
use thiserror::Error;
mod seqlock;
use seqlock::{load_seq_volatile, store_seq_volatile};
pub const VISION_FRAME_TOPIC: &str = "vision.frame";
pub const MAX_SLOT_COUNT: u32 = u16::MAX as u32;
pub const VISION_DETECTION_TOPIC: &str = "vision.detection";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FrameFormat {
Rgb24,
Nv12,
Yuv420p,
Gray8,
Gray16,
BayerRggb8,
BayerRggb16,
}
impl FrameFormat {
pub fn frame_bytes(self, width: u32, height: u32) -> usize {
let px = width as usize * height as usize;
match self {
FrameFormat::Rgb24 => px * 3,
FrameFormat::Nv12 | FrameFormat::Yuv420p => px + px / 2,
FrameFormat::Gray8 | FrameFormat::BayerRggb8 => px,
FrameFormat::Gray16 | FrameFormat::BayerRggb16 => px * 2,
}
}
pub fn ffmpeg_pix_fmt(self) -> &'static str {
match self {
FrameFormat::Rgb24 => "rgb24",
FrameFormat::Nv12 => "nv12",
FrameFormat::Yuv420p => "yuv420p",
FrameFormat::Gray8 => "gray",
FrameFormat::Gray16 => "gray16le",
FrameFormat::BayerRggb8 => "bayer_rggb8",
FrameFormat::BayerRggb16 => "bayer_rggb16le",
}
}
pub fn is_bayer(self) -> bool {
matches!(self, FrameFormat::BayerRggb8 | FrameFormat::BayerRggb16)
}
}
pub const FRAMEBUS_DESCRIPTOR_VERSION: u16 = 2;
pub const TIMEBASE_HOST: u8 = 0;
pub const TIMEBASE_SENSOR: u8 = 1;
pub const TIMEBASE_SIM: u8 = 2;
mod clock;
pub use clock::mono_ns;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameDescriptor {
#[serde(rename = "v")]
pub v: u16,
pub camera_id: String,
pub frame_id: u64,
pub ts_ns: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts_sensor_ns: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exposure_ns: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_seq: Option<u64>,
pub timebase: u8,
pub width: u32,
pub height: u32,
pub format: FrameFormat,
pub shm_name: String,
pub slot: u32,
pub seq: u64,
pub byte_len: u32,
}
impl FrameDescriptor {
pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
pub fn from_msgpack(bytes: &[u8]) -> Result<Self, DescriptorError> {
let desc: FrameDescriptor = rmp_serde::from_slice(bytes)?;
if desc.v != FRAMEBUS_DESCRIPTOR_VERSION {
return Err(DescriptorError::Version {
got: desc.v,
ours: FRAMEBUS_DESCRIPTOR_VERSION,
});
}
Ok(desc)
}
}
#[derive(Debug, Error)]
pub enum DescriptorError {
#[error("msgpack decode error: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("unsupported vision.frame descriptor version {got} (this build speaks {ours})")]
Version { got: u16, ours: u16 },
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RingError {
#[error("slot {slot} out of range (ring has {slot_count} slots)")]
SlotOutOfRange { slot: u32, slot_count: u32 },
#[error("payload of {len} bytes exceeds slot capacity {cap}")]
PayloadTooLarge { len: usize, cap: usize },
#[error("shared region is {got} bytes, layout needs {need}")]
RegionTooSmall { got: usize, need: usize },
#[error("slot_count {slot_count} exceeds the header maximum {max}")]
SlotCountTooLarge { slot_count: u32, max: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RingLayout {
pub slot_count: u32,
pub slot_bytes: u32,
}
impl RingLayout {
pub const MAGIC: u32 = 0x4144_5631;
pub const VERSION: u16 = 1;
pub const HEADER_LEN: usize = 16;
pub const SLOT_HEADER_LEN: usize = 16;
pub const SLOT_TRAILER_LEN: usize = 8;
pub fn for_frame(slot_count: u32, width: u32, height: u32, format: FrameFormat) -> Self {
RingLayout {
slot_count,
slot_bytes: format.frame_bytes(width, height) as u32,
}
}
pub fn slot_stride(&self) -> usize {
Self::SLOT_HEADER_LEN + self.slot_bytes as usize + Self::SLOT_TRAILER_LEN
}
pub fn total_len(&self) -> usize {
Self::HEADER_LEN + self.slot_count as usize * self.slot_stride()
}
fn slot_offset(&self, slot: u32) -> usize {
Self::HEADER_LEN + slot as usize * self.slot_stride()
}
pub fn validate(&self) -> Result<(), RingError> {
if self.slot_count > MAX_SLOT_COUNT {
return Err(RingError::SlotCountTooLarge {
slot_count: self.slot_count,
max: MAX_SLOT_COUNT,
});
}
Ok(())
}
pub fn write_header(&self, region: &mut [u8]) -> Result<(), RingError> {
self.validate()?;
self.check_region(region.len())?;
region[0..4].copy_from_slice(&Self::MAGIC.to_le_bytes());
region[4..6].copy_from_slice(&Self::VERSION.to_le_bytes());
region[6..8].copy_from_slice(&(self.slot_count as u16).to_le_bytes());
region[8..12].copy_from_slice(&self.slot_bytes.to_le_bytes());
region[12..16].copy_from_slice(&0u32.to_le_bytes());
Ok(())
}
pub fn read_header(region: &[u8]) -> Option<RingLayout> {
if region.len() < Self::HEADER_LEN {
return None;
}
let magic = u32::from_le_bytes(region[0..4].try_into().unwrap());
let version = u16::from_le_bytes(region[4..6].try_into().unwrap());
if magic != Self::MAGIC || version != Self::VERSION {
return None;
}
let slot_count = u16::from_le_bytes(region[6..8].try_into().unwrap()) as u32;
let slot_bytes = u32::from_le_bytes(region[8..12].try_into().unwrap());
Some(RingLayout {
slot_count,
slot_bytes,
})
}
fn check_region(&self, got: usize) -> Result<(), RingError> {
let need = self.total_len();
if got < need {
return Err(RingError::RegionTooSmall { got, need });
}
Ok(())
}
}
pub fn write_slot(
region: &mut [u8],
layout: &RingLayout,
slot: u32,
seq: u64,
data: &[u8],
) -> Result<(), RingError> {
if slot >= layout.slot_count {
return Err(RingError::SlotOutOfRange {
slot,
slot_count: layout.slot_count,
});
}
let cap = layout.slot_bytes as usize;
if data.len() > cap {
return Err(RingError::PayloadTooLarge {
len: data.len(),
cap,
});
}
layout.check_region(region.len())?;
let base = layout.slot_offset(slot);
let data_off = base + RingLayout::SLOT_HEADER_LEN;
let trailer_off = data_off + cap;
store_seq_volatile(region, base, seq);
region[base + 8..base + 12].copy_from_slice(&(data.len() as u32).to_le_bytes());
region[base + 12..base + 16].copy_from_slice(&0u32.to_le_bytes());
fence(Ordering::Release);
region[data_off..data_off + data.len()].copy_from_slice(data);
fence(Ordering::Release);
store_seq_volatile(region, trailer_off, seq);
Ok(())
}
pub fn read_slot_into(
region: &[u8],
layout: &RingLayout,
slot: u32,
expected_seq: u64,
out: &mut Vec<u8>,
) -> Result<Option<usize>, RingError> {
if slot >= layout.slot_count {
return Err(RingError::SlotOutOfRange {
slot,
slot_count: layout.slot_count,
});
}
layout.check_region(region.len())?;
let cap = layout.slot_bytes as usize;
let base = layout.slot_offset(slot);
let data_off = base + RingLayout::SLOT_HEADER_LEN;
let trailer_off = data_off + cap;
let seq_end = load_seq_volatile(region, trailer_off);
if seq_end != expected_seq {
return Ok(None);
}
let byte_len = u32::from_le_bytes(region[base + 8..base + 12].try_into().unwrap()) as usize;
if byte_len > cap {
return Ok(None);
}
fence(Ordering::Acquire);
compiler_fence(Ordering::Acquire);
out.clear();
out.extend_from_slice(®ion[data_off..data_off + byte_len]);
fence(Ordering::Acquire);
let seq_begin = load_seq_volatile(region, base);
let seq_end2 = load_seq_volatile(region, trailer_off);
if seq_begin != expected_seq || seq_end2 != expected_seq {
return Ok(None);
}
Ok(Some(byte_len))
}
pub mod methods;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelKind {
Detection,
Segmentation,
Classification,
Tracking,
Pose,
Depth,
Reid,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub kind: ModelKind,
pub execution: ModelExecution,
pub backend_loaded: bool,
pub output_classes: Vec<String>,
#[serde(default)]
pub fps: f32,
#[serde(default, rename = "latencyMs")]
pub latency_ms: f32,
#[serde(default, rename = "isInferenceCapable")]
pub is_inference_capable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelExecution {
EngineRun,
PluginSide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DetectionHead {
#[default]
Yolo8,
Yolo5,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelMetadata {
pub id: String,
pub kind: ModelKind,
pub execution: ModelExecution,
pub input_width: u32,
pub input_height: u32,
pub input_format: FrameFormat,
#[serde(default)]
pub output_classes: Vec<String>,
#[serde(default)]
pub model_path: Option<String>,
#[serde(default)]
pub head: DetectionHead,
}
impl ModelMetadata {
pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
pub fn from_msgpack(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
rmp_serde::from_slice(bytes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BoundingBox {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Keypoint {
pub x: f32,
pub y: f32,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LockState {
Locked,
Uncertain,
Lost,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Detection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bbox: Option<BoundingBox>,
pub class_label: String,
pub confidence: f32,
#[serde(default)]
pub track_id: Option<u64>,
#[serde(default)]
pub assoc_confidence: Option<f32>,
#[serde(default)]
pub lock_state: Option<LockState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<BTreeMap<String, rmpv::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Vec<[f32; 2]>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keypoints: Option<Vec<Keypoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub world_pos: Option<[f64; 3]>,
}
pub const VISION_DETECTION_VERSION: u16 = 3;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DetectionBatch {
#[serde(rename = "v")]
pub v: u16,
pub model_id: String,
pub camera_id: String,
pub frame_id: u64,
pub ts_ms: i64,
#[serde(default)]
pub frame_width: u32,
#[serde(default)]
pub frame_height: u32,
pub detections: Vec<Detection>,
}
impl DetectionBatch {
pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
pub fn from_msgpack(bytes: &[u8]) -> Result<Self, DetectionBatchError> {
let batch: DetectionBatch = rmp_serde::from_slice(bytes)?;
if batch.v != VISION_DETECTION_VERSION {
return Err(DetectionBatchError::Version {
got: batch.v,
ours: VISION_DETECTION_VERSION,
});
}
Ok(batch)
}
}
#[derive(Debug, Error)]
pub enum DetectionBatchError {
#[error("msgpack decode error: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("unsupported vision.detection version {got} (this build speaks {ours})")]
Version { got: u16, ours: u16 },
}
#[cfg(test)]
mod contract_tests {
use super::*;
#[test]
fn model_metadata_round_trips() {
let m = ModelMetadata {
id: "com.example.weeds".into(),
kind: ModelKind::Detection,
execution: ModelExecution::EngineRun,
input_width: 640,
input_height: 480,
input_format: FrameFormat::Rgb24,
output_classes: vec!["weed".into(), "crop".into()],
model_path: Some("/opt/ados/models/vision/weeds.onnx".into()),
head: DetectionHead::Yolo8,
};
let bytes = m.to_msgpack().unwrap();
assert_eq!(ModelMetadata::from_msgpack(&bytes).unwrap(), m);
}
#[test]
fn model_metadata_without_head_defaults_to_yolo8() {
let legacy = ModelMetadataLegacy {
id: "com.example.old".into(),
kind: ModelKind::Detection,
execution: ModelExecution::EngineRun,
input_width: 640,
input_height: 640,
input_format: FrameFormat::Rgb24,
output_classes: vec!["uav".into()],
model_path: None,
};
let bytes = rmp_serde::to_vec_named(&legacy).unwrap();
let decoded = ModelMetadata::from_msgpack(&bytes).unwrap();
assert_eq!(decoded.head, DetectionHead::Yolo8);
assert_eq!(decoded.id, "com.example.old");
}
#[derive(Serialize)]
struct ModelMetadataLegacy {
id: String,
kind: ModelKind,
execution: ModelExecution,
input_width: u32,
input_height: u32,
input_format: FrameFormat,
output_classes: Vec<String>,
model_path: Option<String>,
}
#[test]
fn detection_head_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&DetectionHead::Yolo8).unwrap(),
"\"yolo8\""
);
assert_eq!(
serde_json::to_string(&DetectionHead::Yolo5).unwrap(),
"\"yolo5\""
);
}
#[test]
fn detection_batch_round_trips() {
let b = DetectionBatch {
v: VISION_DETECTION_VERSION,
model_id: "com.example.weeds".into(),
camera_id: "uvc-0".into(),
frame_id: 7,
ts_ms: 1_700_000_000_000,
frame_width: 640,
frame_height: 480,
detections: vec![Detection {
bbox: Some(BoundingBox {
x: 12.0,
y: 20.0,
width: 64.0,
height: 32.0,
}),
class_label: "weed".into(),
confidence: 0.87,
track_id: None,
assoc_confidence: None,
lock_state: None,
attributes: None,
mask: None,
keypoints: None,
depth: None,
world_pos: None,
}],
};
let bytes = b.to_msgpack().unwrap();
assert_eq!(DetectionBatch::from_msgpack(&bytes).unwrap(), b);
}
#[test]
fn detection_version_matches_registry() {
assert_eq!(
VISION_DETECTION_VERSION,
crate::contracts::contract_version("vision.detection").unwrap()
);
}
#[test]
fn detection_rejects_a_future_version() {
let batch = DetectionBatch {
v: VISION_DETECTION_VERSION + 1,
model_id: "m".into(),
camera_id: "c".into(),
frame_id: 1,
ts_ms: 0,
frame_width: 640,
frame_height: 480,
detections: vec![],
};
let bytes = batch.to_msgpack().unwrap();
assert!(matches!(
DetectionBatch::from_msgpack(&bytes),
Err(DetectionBatchError::Version { .. })
));
}
#[test]
fn detection_missing_version_fails_decode() {
#[derive(Serialize)]
struct NoVersionBatch {
model_id: String,
camera_id: String,
frame_id: u64,
ts_ms: i64,
detections: Vec<Detection>,
}
let old = NoVersionBatch {
model_id: "m".into(),
camera_id: "c".into(),
frame_id: 1,
ts_ms: 0,
detections: vec![],
};
let bytes = rmp_serde::to_vec_named(&old).unwrap();
assert!(matches!(
DetectionBatch::from_msgpack(&bytes),
Err(DetectionBatchError::Decode(_))
));
}
#[test]
fn detection_lock_fields_round_trip_present() {
let d = Detection {
bbox: Some(BoundingBox {
x: 1.0,
y: 2.0,
width: 10.0,
height: 20.0,
}),
class_label: "target".into(),
confidence: 0.91,
track_id: Some(42),
assoc_confidence: Some(0.73),
lock_state: Some(LockState::Uncertain),
attributes: None,
mask: None,
keypoints: None,
depth: None,
world_pos: None,
};
let bytes = rmp_serde::to_vec_named(&d).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, d);
}
#[test]
fn detection_lock_state_serializes_lowercase() {
let s = rmp_serde::to_vec_named(&LockState::Locked).unwrap();
assert_eq!(rmp_serde::from_slice::<String>(&s).unwrap(), "locked");
let s = rmp_serde::to_vec_named(&LockState::Lost).unwrap();
assert_eq!(rmp_serde::from_slice::<String>(&s).unwrap(), "lost");
}
#[test]
fn detection_attributes_round_trip() {
let mut attrs = BTreeMap::new();
attrs.insert("depth_m".to_string(), rmpv::Value::from(4.2_f64));
attrs.insert("source".to_string(), rmpv::Value::from("plugin"));
let d = Detection {
bbox: Some(BoundingBox {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
}),
class_label: "target".into(),
confidence: 0.9,
track_id: Some(7),
assoc_confidence: None,
lock_state: Some(LockState::Locked),
attributes: Some(attrs.clone()),
mask: None,
keypoints: None,
depth: None,
world_pos: None,
};
let bytes = rmp_serde::to_vec_named(&d).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, d);
assert_eq!(back.attributes, Some(attrs));
}
#[test]
fn detection_without_attributes_defaults_none() {
#[derive(Serialize)]
struct NoAttrsDetection {
bbox: BoundingBox,
class_label: String,
confidence: f32,
track_id: Option<u64>,
assoc_confidence: Option<f32>,
lock_state: Option<LockState>,
}
let old = NoAttrsDetection {
bbox: BoundingBox {
x: 0.0,
y: 0.0,
width: 5.0,
height: 5.0,
},
class_label: "target".into(),
confidence: 0.5,
track_id: None,
assoc_confidence: None,
lock_state: None,
};
let bytes = rmp_serde::to_vec_named(&old).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back.attributes, None);
}
#[test]
fn detection_new_fields_default_when_absent() {
#[derive(Serialize)]
struct OldDetection {
bbox: BoundingBox,
class_label: String,
confidence: f32,
track_id: Option<u64>,
}
let old = OldDetection {
bbox: BoundingBox {
x: 0.0,
y: 0.0,
width: 5.0,
height: 5.0,
},
class_label: "weed".into(),
confidence: 0.5,
track_id: Some(7),
};
let bytes = rmp_serde::to_vec_named(&old).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back.track_id, Some(7));
assert_eq!(back.assoc_confidence, None);
assert_eq!(back.lock_state, None);
}
#[test]
fn detection_new_fields_skipped_for_old_readers() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct OldReader {
bbox: BoundingBox,
class_label: String,
confidence: f32,
#[serde(default)]
track_id: Option<u64>,
}
let d = Detection {
bbox: Some(BoundingBox {
x: 1.0,
y: 1.0,
width: 2.0,
height: 2.0,
}),
class_label: "target".into(),
confidence: 0.8,
track_id: Some(3),
assoc_confidence: Some(0.4),
lock_state: Some(LockState::Lost),
attributes: None,
mask: None,
keypoints: None,
depth: None,
world_pos: None,
};
let bytes = rmp_serde::to_vec_named(&d).unwrap();
let back: OldReader = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back.track_id, Some(3));
assert_eq!(back.class_label, "target");
}
#[test]
fn enums_serialize_to_expected_strings() {
let k = rmp_serde::to_vec_named(&ModelKind::Tracking).unwrap();
assert_eq!(rmp_serde::from_slice::<String>(&k).unwrap(), "tracking");
let e = rmp_serde::to_vec_named(&ModelExecution::PluginSide).unwrap();
assert_eq!(rmp_serde::from_slice::<String>(&e).unwrap(), "plugin_side");
}
#[test]
fn model_kind_new_variants_serialize_lowercase() {
for (kind, want) in [
(ModelKind::Pose, "pose"),
(ModelKind::Depth, "depth"),
(ModelKind::Reid, "reid"),
] {
let bytes = rmp_serde::to_vec_named(&kind).unwrap();
assert_eq!(rmp_serde::from_slice::<String>(&bytes).unwrap(), want);
}
}
#[test]
fn percept_typed_fields_round_trip() {
let d = Detection {
bbox: Some(BoundingBox {
x: 10.0,
y: 12.0,
width: 30.0,
height: 40.0,
}),
class_label: "person".into(),
confidence: 0.88,
track_id: Some(9),
assoc_confidence: Some(0.7),
lock_state: Some(LockState::Locked),
attributes: None,
mask: Some(vec![[10.0, 12.0], [40.0, 12.0], [40.0, 52.0], [10.0, 52.0]]),
keypoints: Some(vec![
Keypoint {
x: 25.0,
y: 18.0,
confidence: 0.9,
},
Keypoint {
x: 25.0,
y: 40.0,
confidence: 0.6,
},
]),
depth: Some(4.5),
world_pos: Some([12.5, -3.0, 1.2]),
};
let bytes = rmp_serde::to_vec_named(&d).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, d);
assert_eq!(back.mask.as_ref().unwrap().len(), 4);
assert_eq!(back.keypoints.as_ref().unwrap().len(), 2);
assert_eq!(back.depth, Some(4.5));
assert_eq!(back.world_pos, Some([12.5, -3.0, 1.2]));
}
#[test]
fn boxless_percept_round_trips() {
let d = Detection {
bbox: None,
class_label: "region".into(),
confidence: 0.5,
track_id: None,
assoc_confidence: None,
lock_state: None,
attributes: None,
mask: Some(vec![[0.0, 0.0], [100.0, 0.0], [50.0, 100.0]]),
keypoints: None,
depth: Some(2.0),
world_pos: None,
};
let bytes = rmp_serde::to_vec_named(&d).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, d);
assert_eq!(back.bbox, None);
}
#[test]
fn detection_predating_percept_fields_defaults_none() {
#[derive(Serialize)]
struct PrePerceptDetection {
bbox: BoundingBox,
class_label: String,
confidence: f32,
track_id: Option<u64>,
assoc_confidence: Option<f32>,
lock_state: Option<LockState>,
}
let old = PrePerceptDetection {
bbox: BoundingBox {
x: 0.0,
y: 0.0,
width: 5.0,
height: 5.0,
},
class_label: "target".into(),
confidence: 0.6,
track_id: Some(1),
assoc_confidence: None,
lock_state: Some(LockState::Locked),
};
let bytes = rmp_serde::to_vec_named(&old).unwrap();
let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(
back.bbox,
Some(BoundingBox {
x: 0.0,
y: 0.0,
width: 5.0,
height: 5.0,
})
);
assert_eq!(back.mask, None);
assert_eq!(back.keypoints, None);
assert_eq!(back.depth, None);
assert_eq!(back.world_pos, None);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mono_ns_is_monotonic() {
let a = mono_ns();
let b = mono_ns();
assert!(a > 0 && b >= a);
}
#[test]
fn frame_bytes_matches_format() {
assert_eq!(FrameFormat::Rgb24.frame_bytes(640, 480), 640 * 480 * 3);
assert_eq!(FrameFormat::Nv12.frame_bytes(640, 480), 640 * 480 * 3 / 2);
assert_eq!(
FrameFormat::Yuv420p.frame_bytes(1280, 720),
1280 * 720 * 3 / 2
);
assert_eq!(FrameFormat::Gray8.frame_bytes(640, 480), 640 * 480);
assert_eq!(FrameFormat::Gray16.frame_bytes(640, 480), 640 * 480 * 2);
assert_eq!(FrameFormat::BayerRggb8.frame_bytes(641, 481), 641 * 481);
assert_eq!(
FrameFormat::BayerRggb16.frame_bytes(640, 480),
640 * 480 * 2
);
}
#[test]
fn descriptor_round_trips_through_msgpack() {
let base = FrameDescriptor {
v: FRAMEBUS_DESCRIPTOR_VERSION,
camera_id: "uvc-0".into(),
frame_id: 42,
ts_ns: 1_700_000_000_000_000_000,
ts_sensor_ns: None,
exposure_ns: None,
trigger_seq: None,
timebase: TIMEBASE_HOST,
width: 640,
height: 480,
format: FrameFormat::Rgb24,
shm_name: "ados-vision-uvc-0".into(),
slot: 3,
seq: 1234,
byte_len: (640 * 480 * 3) as u32,
};
let bytes = base.to_msgpack().unwrap();
assert_eq!(FrameDescriptor::from_msgpack(&bytes).unwrap(), base);
for format in [
FrameFormat::Gray8,
FrameFormat::Gray16,
FrameFormat::BayerRggb8,
FrameFormat::BayerRggb16,
] {
let d = FrameDescriptor {
ts_sensor_ns: Some(123_456_789),
exposure_ns: Some(4_000_000),
trigger_seq: Some(77),
timebase: TIMEBASE_SENSOR,
format,
byte_len: format.frame_bytes(640, 480) as u32,
..base.clone()
};
let bytes = d.to_msgpack().unwrap();
assert_eq!(FrameDescriptor::from_msgpack(&bytes).unwrap(), d);
}
}
#[test]
fn descriptor_version_matches_registry() {
assert_eq!(
FRAMEBUS_DESCRIPTOR_VERSION,
crate::contracts::contract_version("framebus.descriptor").unwrap()
);
}
#[test]
fn ring_version_matches_registry() {
assert_eq!(
RingLayout::VERSION,
crate::contracts::contract_version("framebus.ring").unwrap()
);
}
#[test]
fn descriptor_rejects_a_future_version() {
let d = FrameDescriptor {
v: FRAMEBUS_DESCRIPTOR_VERSION + 1,
camera_id: "uvc-0".into(),
frame_id: 1,
ts_ns: 0,
ts_sensor_ns: None,
exposure_ns: None,
trigger_seq: None,
timebase: TIMEBASE_HOST,
width: 8,
height: 8,
format: FrameFormat::Rgb24,
shm_name: "ados-vision-uvc-0".into(),
slot: 0,
seq: 1,
byte_len: 192,
};
let bytes = d.to_msgpack().unwrap();
assert!(matches!(
FrameDescriptor::from_msgpack(&bytes),
Err(DescriptorError::Version { .. })
));
}
#[test]
fn descriptor_v1_with_ts_ms_is_rejected() {
#[derive(Serialize)]
struct V1Descriptor {
v: u16,
camera_id: String,
frame_id: u64,
ts_ms: i64,
width: u32,
height: u32,
format: FrameFormat,
shm_name: String,
slot: u32,
seq: u64,
byte_len: u32,
}
let old = V1Descriptor {
v: 1,
camera_id: "uvc-0".into(),
frame_id: 1,
ts_ms: 0,
width: 8,
height: 8,
format: FrameFormat::Rgb24,
shm_name: "ados-vision-uvc-0".into(),
slot: 0,
seq: 1,
byte_len: 192,
};
let bytes = rmp_serde::to_vec_named(&old).unwrap();
assert!(matches!(
FrameDescriptor::from_msgpack(&bytes),
Err(DescriptorError::Decode(_) | DescriptorError::Version { .. })
));
}
#[test]
fn format_serializes_lowercase() {
let bytes = rmp_serde::to_vec_named(&FrameFormat::Yuv420p).unwrap();
let back: String = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, "yuv420p");
for (f, name) in [
(FrameFormat::Gray8, "gray8"),
(FrameFormat::Gray16, "gray16"),
(FrameFormat::BayerRggb8, "bayerrggb8"),
(FrameFormat::BayerRggb16, "bayerrggb16"),
] {
let bytes = rmp_serde::to_vec_named(&f).unwrap();
let back: String = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, name);
}
}
#[test]
fn header_round_trips() {
let layout = RingLayout::for_frame(4, 64, 48, FrameFormat::Rgb24);
let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
let read = RingLayout::read_header(®ion).unwrap();
assert_eq!(read, layout);
}
#[test]
fn write_then_read_returns_the_frame() {
let layout = RingLayout::for_frame(4, 8, 8, FrameFormat::Rgb24); let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
let frame: Vec<u8> = (0..layout.slot_bytes as u8).collect();
let seq = 9u64;
let slot = (seq % layout.slot_count as u64) as u32;
write_slot(&mut region, &layout, slot, seq, &frame).unwrap();
let mut out = Vec::new();
let got = read_slot_into(®ion, &layout, slot, seq, &mut out).unwrap();
assert_eq!(got, Some(frame.len()));
assert_eq!(out, frame);
}
#[test]
fn read_slot_into_reuses_capacity() {
let layout = RingLayout::for_frame(2, 8, 8, FrameFormat::Rgb24);
let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
let mut out = Vec::with_capacity(layout.slot_bytes as usize);
out.extend_from_slice(&[0xAA; 5]);
let (ptr, cap) = (out.as_ptr(), out.capacity());
for seq in 1..=4u64 {
let frame = vec![seq as u8; layout.slot_bytes as usize];
let slot = (seq % 2) as u32;
write_slot(&mut region, &layout, slot, seq, &frame).unwrap();
let n = read_slot_into(®ion, &layout, slot, seq, &mut out).unwrap();
assert_eq!(n, Some(frame.len()));
assert_eq!(out, frame);
assert_eq!(out.as_ptr(), ptr);
assert_eq!(out.capacity(), cap);
}
}
#[test]
fn stale_seq_reads_none() {
let layout = RingLayout::for_frame(2, 4, 4, FrameFormat::Rgb24);
let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
write_slot(&mut region, &layout, 0, 10, &[1, 2, 3]).unwrap();
let mut out = Vec::new();
assert_eq!(
read_slot_into(®ion, &layout, 0, 11, &mut out).unwrap(),
None
);
}
#[test]
fn recycled_slot_is_detected_as_torn() {
let layout = RingLayout::for_frame(1, 4, 4, FrameFormat::Rgb24);
let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
write_slot(&mut region, &layout, 0, 100, &[7, 7, 7, 7]).unwrap();
write_slot(&mut region, &layout, 0, 101, &[8, 8, 8, 8]).unwrap();
let mut out = Vec::new();
assert_eq!(
read_slot_into(®ion, &layout, 0, 100, &mut out).unwrap(),
None
);
assert_eq!(
read_slot_into(®ion, &layout, 0, 101, &mut out).unwrap(),
Some(4)
);
assert_eq!(out, vec![8; 4]);
}
#[test]
fn oversized_payload_rejected() {
let layout = RingLayout::for_frame(2, 2, 2, FrameFormat::Rgb24); let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
let too_big = vec![0u8; layout.slot_bytes as usize + 1];
assert!(matches!(
write_slot(&mut region, &layout, 0, 1, &too_big),
Err(RingError::PayloadTooLarge { .. })
));
}
#[test]
fn slot_out_of_range_rejected() {
let layout = RingLayout::for_frame(2, 2, 2, FrameFormat::Rgb24);
let mut region = vec![0u8; layout.total_len()];
layout.write_header(&mut region).unwrap();
assert!(matches!(
write_slot(&mut region, &layout, 2, 1, &[0]),
Err(RingError::SlotOutOfRange { .. })
));
}
#[test]
fn small_region_rejected() {
let layout = RingLayout::for_frame(4, 64, 64, FrameFormat::Rgb24);
let mut tiny = vec![0u8; 8];
assert!(matches!(
layout.write_header(&mut tiny),
Err(RingError::RegionTooSmall { .. })
));
}
#[test]
fn slot_count_at_the_header_maximum_round_trips() {
let layout = RingLayout {
slot_count: MAX_SLOT_COUNT,
slot_bytes: 4,
};
assert!(layout.validate().is_ok());
let mut header_region = vec![0u8; layout.total_len()];
layout.write_header(&mut header_region).unwrap();
let read = RingLayout::read_header(&header_region).unwrap();
assert_eq!(read.slot_count, MAX_SLOT_COUNT);
}
#[test]
fn slot_count_above_the_header_maximum_is_rejected() {
let layout = RingLayout {
slot_count: MAX_SLOT_COUNT + 1,
slot_bytes: 4,
};
assert!(matches!(
layout.validate(),
Err(RingError::SlotCountTooLarge { .. })
));
let mut region = vec![0u8; 64];
assert!(matches!(
layout.write_header(&mut region),
Err(RingError::SlotCountTooLarge { .. })
));
}
#[test]
fn concurrent_writer_and_reader_never_yield_a_torn_frame() {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as O};
use std::sync::Arc;
const SLOTS: u32 = 3;
const FRAME: usize = 256;
let layout = RingLayout {
slot_count: SLOTS,
slot_bytes: FRAME as u32,
};
let total = layout.total_len();
struct Shared(*mut u8, usize);
unsafe impl Send for Shared {}
unsafe impl Sync for Shared {}
let mut backing = vec![0u8; total];
layout.write_header(&mut backing).unwrap();
let ptr = backing.as_mut_ptr();
let shared = Arc::new(Shared(ptr, total));
let latest = Arc::new(AtomicU64::new(0));
let stop = Arc::new(AtomicBool::new(false));
let w_shared = shared.clone();
let w_latest = latest.clone();
let w_stop = stop.clone();
let writer = std::thread::spawn(move || {
let region = unsafe { std::slice::from_raw_parts_mut(w_shared.0, w_shared.1) };
let mut seq = 1u64;
while !w_stop.load(O::Relaxed) {
let marker = (seq & 0xFF) as u8;
let frame = vec![marker; FRAME];
let slot = (seq % SLOTS as u64) as u32;
write_slot(region, &layout, slot, seq, &frame).unwrap();
w_latest.store(seq, O::Release);
seq += 1;
}
});
let r_shared = shared.clone();
let r_latest = latest.clone();
let mut reads = 0u64;
let mut iters = 0u64;
let mut data = Vec::new();
while reads < 10_000 && iters < 50_000_000 {
iters += 1;
let seq = r_latest.load(O::Acquire);
if seq == 0 {
std::hint::spin_loop();
continue;
}
let slot = (seq % SLOTS as u64) as u32;
let region = unsafe { std::slice::from_raw_parts(r_shared.0, r_shared.1) };
if read_slot_into(region, &layout, slot, seq, &mut data)
.unwrap()
.is_some()
{
reads += 1;
let marker = (seq & 0xFF) as u8;
assert!(
data.iter().all(|&b| b == marker),
"torn read at seq {seq}: expected all {marker:#x}"
);
}
}
stop.store(true, O::Relaxed);
writer.join().unwrap();
assert!(reads > 0, "reader observed no committed frames");
}
}