1use spatialrust_viz::PointCloudView;
2
3use crate::{ViewerError, ViewerResult};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub struct FrameTimestamps {
9 pub rgb_nanos: u64,
11 pub depth_nanos: u64,
13 pub cloud_nanos: Option<u64>,
15}
16
17impl FrameTimestamps {
18 fn range(self) -> (u64, u64) {
19 let mut min = self.rgb_nanos.min(self.depth_nanos);
20 let mut max = self.rgb_nanos.max(self.depth_nanos);
21 if let Some(cloud) = self.cloud_nanos {
22 min = min.min(cloud);
23 max = max.max(cloud);
24 }
25 (min, max)
26 }
27
28 #[must_use]
30 pub fn display_nanos(self) -> u64 {
31 let (min, max) = self.range();
32 min + (max - min) / 2
33 }
34}
35
36#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct RgbdFrameView<'a> {
39 pub frame_id: &'a str,
41 pub timestamps: FrameTimestamps,
43 pub width: usize,
45 pub height: usize,
47 pub rgb: &'a [u8],
49 pub depth: &'a [f32],
51 pub cloud: Option<PointCloudView<'a>>,
53}
54
55impl<'a> RgbdFrameView<'a> {
56 pub fn validate(self, max_skew_nanos: u64) -> ViewerResult<()> {
58 if self.frame_id.trim().is_empty() || self.width == 0 || self.height == 0 {
59 return Err(ViewerError::InvalidState(
60 "RGB-D frame ID and dimensions must be non-empty".into(),
61 ));
62 }
63 let pixels = self
64 .width
65 .checked_mul(self.height)
66 .ok_or_else(|| ViewerError::InvalidState("RGB-D pixel count overflow".into()))?;
67 let rgb_len = pixels
68 .checked_mul(3)
69 .ok_or_else(|| ViewerError::InvalidState("RGB byte count overflow".into()))?;
70 if self.rgb.len() != rgb_len || self.depth.len() != pixels {
71 return Err(ViewerError::InvalidState(
72 "RGB/depth lengths must exactly match frame dimensions".into(),
73 ));
74 }
75 if self.depth.iter().any(|depth| depth.is_infinite() || *depth < 0.0) {
76 return Err(ViewerError::InvalidState(
77 "depth values must be non-negative and not infinite".into(),
78 ));
79 }
80 let (min, max) = self.timestamps.range();
81 if max - min > max_skew_nanos {
82 return Err(ViewerError::InvalidState(format!(
83 "RGB-D/cloud timestamp skew {} exceeds {} ns",
84 max - min,
85 max_skew_nanos
86 )));
87 }
88 if self.cloud.is_some() != self.timestamps.cloud_nanos.is_some() {
89 return Err(ViewerError::InvalidState(
90 "cloud payload and cloud timestamp must either both be present or absent".into(),
91 ));
92 }
93 Ok(())
94 }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct RgbdPixelSample {
100 pub x: usize,
102 pub y: usize,
104 pub rgb: [u8; 3],
106 pub depth: f32,
108 pub camera_point: Option<spatialrust_math::Vec3<f64>>,
110}
111
112#[derive(Clone, Debug, PartialEq)]
114pub struct RgbdTimeline<'a> {
115 frames: Vec<RgbdFrameView<'a>>,
116 max_skew_nanos: u64,
117}
118
119impl<'a> RgbdTimeline<'a> {
120 pub fn try_new(
122 frames: impl IntoIterator<Item = RgbdFrameView<'a>>,
123 max_skew_nanos: u64,
124 ) -> ViewerResult<Self> {
125 let frames: Vec<_> = frames.into_iter().collect();
126 let mut previous = None;
127 for frame in &frames {
128 frame.validate(max_skew_nanos)?;
129 let timestamp = frame.timestamps.display_nanos();
130 if previous.is_some_and(|value| timestamp < value) {
131 return Err(ViewerError::InvalidState(
132 "RGB-D timeline timestamps must be non-decreasing".into(),
133 ));
134 }
135 previous = Some(timestamp);
136 }
137 Ok(Self { frames, max_skew_nanos })
138 }
139
140 #[must_use]
142 pub fn frames(&self) -> &[RgbdFrameView<'a>] {
143 &self.frames
144 }
145
146 #[must_use]
148 pub const fn max_skew_nanos(&self) -> u64 {
149 self.max_skew_nanos
150 }
151
152 #[must_use]
154 pub fn nearest(&self, timestamp_nanos: u64) -> Option<RgbdFrameView<'a>> {
155 self.frames.iter().copied().min_by_key(|frame| {
156 let stamp = frame.timestamps.display_nanos();
157 (stamp.abs_diff(timestamp_nanos), stamp)
158 })
159 }
160
161 #[cfg(feature = "camera")]
163 pub fn inspect_pixel(
164 &self,
165 frame_index: usize,
166 x: usize,
167 y: usize,
168 camera: &spatialrust_camera::PinholeCamera,
169 ) -> ViewerResult<RgbdPixelSample> {
170 let frame = self
171 .frames
172 .get(frame_index)
173 .ok_or_else(|| ViewerError::InvalidState("RGB-D frame index out of bounds".into()))?;
174 if camera.intrinsics.width != frame.width || camera.intrinsics.height != frame.height {
175 return Err(ViewerError::InvalidState(
176 "camera dimensions must match RGB-D frame".into(),
177 ));
178 }
179 if x >= frame.width || y >= frame.height {
180 return Err(ViewerError::InvalidState("RGB-D pixel out of bounds".into()));
181 }
182 let index = y * frame.width + x;
183 let rgb_offset = index * 3;
184 let depth = frame.depth[index];
185 let camera_point = if depth.is_finite() && depth > 0.0 {
186 Some(
187 camera
188 .unproject(spatialrust_math::Vec2 { x: x as f64, y: y as f64 }, depth as f64)
189 .map_err(|error| ViewerError::InvalidState(error.to_string()))?,
190 )
191 } else {
192 None
193 };
194 Ok(RgbdPixelSample {
195 x,
196 y,
197 rgb: [frame.rgb[rgb_offset], frame.rgb[rgb_offset + 1], frame.rgb[rgb_offset + 2]],
198 depth,
199 camera_point,
200 })
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::{FrameTimestamps, RgbdFrameView, RgbdTimeline};
207
208 fn frame<'a>(
209 rgb: &'a [u8],
210 depth: &'a [f32],
211 rgb_nanos: u64,
212 depth_nanos: u64,
213 ) -> RgbdFrameView<'a> {
214 RgbdFrameView {
215 frame_id: "camera",
216 timestamps: FrameTimestamps { rgb_nanos, depth_nanos, cloud_nanos: None },
217 width: 2,
218 height: 1,
219 rgb,
220 depth,
221 cloud: None,
222 }
223 }
224
225 #[test]
226 fn validates_alignment_and_selects_nearest_with_earlier_tie() {
227 let rgb = [1, 2, 3, 4, 5, 6];
228 let depth = [1.0, f32::NAN];
229 let timeline =
230 RgbdTimeline::try_new([frame(&rgb, &depth, 98, 102), frame(&rgb, &depth, 198, 202)], 5)
231 .unwrap();
232 assert_eq!(timeline.nearest(150).unwrap().timestamps.display_nanos(), 100);
233 assert_eq!(timeline.max_skew_nanos(), 5);
234 }
235
236 #[test]
237 fn rejects_skew_lengths_order_and_unpaired_cloud_timestamp() {
238 let rgb = [0; 6];
239 let depth = [1.0, 2.0];
240 assert!(RgbdTimeline::try_new([frame(&rgb, &depth, 0, 10)], 5).is_err());
241 assert!(RgbdTimeline::try_new(
242 [frame(&rgb, &depth, 200, 200), frame(&rgb, &depth, 100, 100)],
243 0
244 )
245 .is_err());
246 let mut invalid = frame(&rgb, &depth, 0, 0);
247 invalid.timestamps.cloud_nanos = Some(0);
248 assert!(invalid.validate(0).is_err());
249 assert!(frame(&rgb[..3], &depth, 0, 0).validate(0).is_err());
250 }
251
252 #[cfg(feature = "camera")]
253 #[test]
254 fn inspects_rgb_depth_and_projection_deterministically() {
255 let rgb = [10, 20, 30, 40, 50, 60];
256 let depth = [2.0, f32::NAN];
257 let timeline = RgbdTimeline::try_new([frame(&rgb, &depth, 0, 0)], 0).unwrap();
258 let camera = spatialrust_camera::PinholeCamera::new(
259 spatialrust_camera::CameraIntrinsics::try_new(2.0, 2.0, 0.0, 0.0, 2, 1).unwrap(),
260 );
261 let sample = timeline.inspect_pixel(0, 0, 0, &camera).unwrap();
262 assert_eq!(sample.rgb, [10, 20, 30]);
263 assert_eq!(sample.camera_point.unwrap(), spatialrust_math::Vec3::new(0.0, 0.0, 2.0));
264 assert!(timeline.inspect_pixel(0, 1, 0, &camera).unwrap().camera_point.is_none());
265 assert!(timeline.inspect_pixel(0, 2, 0, &camera).is_err());
266 }
267}