1use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12pub const SEMANTIC_OVERLAY_STATE_VERSION: u32 = 1;
14
15pub const SEMANTIC_CONFIDENCE_SCALE: u32 = 1_000_000;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
22pub struct SemanticOverlayModel {
23 pub model_id: String,
25 pub backend: String,
27 pub runtime: String,
29 pub deterministic: bool,
31 pub input_feature_count: u32,
33 pub output_class_count: u32,
35 pub input_host_bytes: u64,
37 pub output_host_bytes: u64,
39 pub device_upload_bytes: u64,
41 pub device_readback_bytes: u64,
43}
44
45impl SemanticOverlayModel {
46 #[allow(clippy::too_many_arguments)]
48 pub fn try_new(
49 model_id: impl Into<String>,
50 backend: impl Into<String>,
51 runtime: impl Into<String>,
52 deterministic: bool,
53 input_feature_count: u32,
54 output_class_count: u32,
55 input_host_bytes: u64,
56 output_host_bytes: u64,
57 device_upload_bytes: u64,
58 device_readback_bytes: u64,
59 ) -> ViewerResult<Self> {
60 let model = Self {
61 model_id: model_id.into(),
62 backend: backend.into(),
63 runtime: runtime.into(),
64 deterministic,
65 input_feature_count,
66 output_class_count,
67 input_host_bytes,
68 output_host_bytes,
69 device_upload_bytes,
70 device_readback_bytes,
71 };
72 model.validate()?;
73 Ok(model)
74 }
75
76 pub fn validate(&self) -> ViewerResult<()> {
78 if self.model_id.trim().is_empty()
79 || self.backend.trim().is_empty()
80 || self.runtime.trim().is_empty()
81 {
82 return Err(ViewerError::InvalidState(
83 "semantic overlay model identity and runtime must not be empty".into(),
84 ));
85 }
86 if self.input_feature_count == 0 || self.output_class_count == 0 {
87 return Err(ViewerError::InvalidState(
88 "semantic overlay model dimensions must be positive".into(),
89 ));
90 }
91 Ok(())
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
99pub struct SemanticOverlayClass {
100 pub class_id: u32,
102 pub label: String,
104 pub color_rgb: [u8; 3],
106 pub entity_count: u64,
108 pub mean_confidence_million: u32,
110 pub max_confidence_million: u32,
112}
113
114impl SemanticOverlayClass {
115 pub fn try_new(
117 class_id: u32,
118 label: impl Into<String>,
119 color_rgb: [u8; 3],
120 entity_count: u64,
121 mean_confidence_million: u32,
122 max_confidence_million: u32,
123 ) -> ViewerResult<Self> {
124 let class = Self {
125 class_id,
126 label: label.into(),
127 color_rgb,
128 entity_count,
129 mean_confidence_million,
130 max_confidence_million,
131 };
132 class.validate()?;
133 Ok(class)
134 }
135
136 pub fn validate(&self) -> ViewerResult<()> {
138 if self.label.trim().is_empty()
139 || self.mean_confidence_million > SEMANTIC_CONFIDENCE_SCALE
140 || self.max_confidence_million > SEMANTIC_CONFIDENCE_SCALE
141 || self.mean_confidence_million > self.max_confidence_million
142 || (self.entity_count == 0
143 && (self.mean_confidence_million > 0 || self.max_confidence_million > 0))
144 {
145 return Err(ViewerError::InvalidState(
146 "semantic overlay class label or confidence statistics are invalid".into(),
147 ));
148 }
149 Ok(())
150 }
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
157pub struct SemanticOverlayEntity {
158 pub id: String,
160 pub source_index: u64,
162 pub class_id: u32,
164 pub label: String,
166 pub confidence_million: u32,
168 pub centroid_um: [i64; 3],
170}
171
172impl SemanticOverlayEntity {
173 pub fn try_new(
175 id: impl Into<String>,
176 source_index: u64,
177 class_id: u32,
178 label: impl Into<String>,
179 confidence_million: u32,
180 centroid_um: [i64; 3],
181 ) -> ViewerResult<Self> {
182 let entity = Self {
183 id: id.into(),
184 source_index,
185 class_id,
186 label: label.into(),
187 confidence_million,
188 centroid_um,
189 };
190 entity.validate()?;
191 Ok(entity)
192 }
193
194 pub fn validate(&self) -> ViewerResult<()> {
196 if self.id.trim().is_empty()
197 || self.label.trim().is_empty()
198 || self.confidence_million > SEMANTIC_CONFIDENCE_SCALE
199 {
200 return Err(ViewerError::InvalidState(
201 "semantic overlay entity identity, label, or confidence is invalid".into(),
202 ));
203 }
204 Ok(())
205 }
206}
207
208#[derive(Clone, Debug, PartialEq, Eq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
212pub struct SemanticOverlaySummary {
213 pub input_point_count: u64,
215 pub sampled_point_count: u64,
217 pub entity_count: u64,
219 pub visible_entity_count: u64,
221 pub class_count: u64,
223 pub mean_confidence_million: u32,
225 pub p95_confidence_million: u32,
227 pub coverage_million: u32,
229 pub source_identity_match: bool,
231 pub frame_identity_match: bool,
233 pub calibration_applied: bool,
235}
236
237impl SemanticOverlaySummary {
238 #[allow(clippy::too_many_arguments)]
240 pub fn try_new(
241 input_point_count: u64,
242 sampled_point_count: u64,
243 entity_count: u64,
244 visible_entity_count: u64,
245 class_count: u64,
246 mean_confidence_million: u32,
247 p95_confidence_million: u32,
248 coverage_million: u32,
249 source_identity_match: bool,
250 frame_identity_match: bool,
251 calibration_applied: bool,
252 ) -> ViewerResult<Self> {
253 let summary = Self {
254 input_point_count,
255 sampled_point_count,
256 entity_count,
257 visible_entity_count,
258 class_count,
259 mean_confidence_million,
260 p95_confidence_million,
261 coverage_million,
262 source_identity_match,
263 frame_identity_match,
264 calibration_applied,
265 };
266 summary.validate()?;
267 Ok(summary)
268 }
269
270 pub fn validate(&self) -> ViewerResult<()> {
272 if self.sampled_point_count > self.input_point_count
273 || self.entity_count > self.sampled_point_count
274 || self.visible_entity_count > self.entity_count
275 || self.mean_confidence_million > SEMANTIC_CONFIDENCE_SCALE
276 || self.p95_confidence_million > SEMANTIC_CONFIDENCE_SCALE
277 || self.coverage_million > SEMANTIC_CONFIDENCE_SCALE
278 || (self.entity_count == 0
279 && (self.class_count > 0
280 || self.visible_entity_count > 0
281 || self.mean_confidence_million > 0
282 || self.p95_confidence_million > 0))
283 || (self.input_point_count == 0
284 && (self.sampled_point_count > 0 || self.coverage_million > 0))
285 || (self.sampled_point_count == 0 && self.coverage_million > 0)
286 {
287 return Err(ViewerError::InvalidState(
288 "semantic overlay summary counts or confidence metrics are invalid".into(),
289 ));
290 }
291 if self.calibration_applied && (!self.source_identity_match || !self.frame_identity_match) {
292 return Err(ViewerError::InvalidState(
293 "semantic overlay calibration cannot be applied to an unbound or mixed-frame source"
294 .into(),
295 ));
296 }
297 Ok(())
298 }
299}
300
301#[derive(Clone, Debug, PartialEq, Eq)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
305pub struct SemanticOverlayState {
306 pub version: u32,
308 pub title: String,
310 pub source: StudioSource,
312 pub frame_id: String,
314 pub expected_frame_id: String,
316 pub time_basis: String,
318 pub model: SemanticOverlayModel,
320 pub classes: Vec<SemanticOverlayClass>,
322 pub entities: Vec<SemanticOverlayEntity>,
324 pub artifacts: Vec<ReplayArtifact>,
326 pub summary: SemanticOverlaySummary,
328 pub overlay_ready: bool,
330 pub mapping_admitted: bool,
332 pub blockers: Vec<String>,
334}
335
336impl SemanticOverlayState {
337 #[allow(clippy::too_many_arguments)]
339 pub fn try_new(
340 title: impl Into<String>,
341 source: StudioSource,
342 frame_id: impl Into<String>,
343 expected_frame_id: impl Into<String>,
344 time_basis: impl Into<String>,
345 model: SemanticOverlayModel,
346 classes: Vec<SemanticOverlayClass>,
347 entities: Vec<SemanticOverlayEntity>,
348 artifacts: Vec<ReplayArtifact>,
349 summary: SemanticOverlaySummary,
350 blockers: Vec<String>,
351 ) -> ViewerResult<Self> {
352 let overlay_ready = source.identity_matches
353 && summary.source_identity_match
354 && summary.frame_identity_match
355 && summary.entity_count > 0
356 && summary.entity_count == u64::try_from(entities.len()).unwrap_or(u64::MAX);
357 let mapping_admitted = overlay_ready && summary.calibration_applied;
358 let state = Self {
359 version: SEMANTIC_OVERLAY_STATE_VERSION,
360 title: title.into(),
361 source,
362 frame_id: frame_id.into(),
363 expected_frame_id: expected_frame_id.into(),
364 time_basis: time_basis.into(),
365 model,
366 classes,
367 entities,
368 artifacts,
369 summary,
370 overlay_ready,
371 mapping_admitted,
372 blockers,
373 };
374 state.validate()?;
375 Ok(state)
376 }
377
378 pub fn validate(&self) -> ViewerResult<()> {
380 if self.version != SEMANTIC_OVERLAY_STATE_VERSION {
381 return Err(ViewerError::InvalidState(format!(
382 "unsupported semantic overlay state version {}",
383 self.version
384 )));
385 }
386 if self.title.trim().is_empty()
387 || self.frame_id.trim().is_empty()
388 || self.expected_frame_id.trim().is_empty()
389 || self.time_basis.trim().is_empty()
390 {
391 return Err(ViewerError::InvalidState(
392 "semantic overlay title, frame, and time basis must not be empty".into(),
393 ));
394 }
395 self.source.validate()?;
396 self.model.validate()?;
397 self.summary.validate()?;
398 if self.summary.source_identity_match != self.source.identity_matches {
399 return Err(ViewerError::InvalidState(
400 "semantic overlay source identity disagrees with the checked source".into(),
401 ));
402 }
403 if self.summary.frame_identity_match != (self.frame_id == self.expected_frame_id) {
404 return Err(ViewerError::InvalidState(
405 "semantic overlay frame identity disagrees with frame fields".into(),
406 ));
407 }
408
409 let mut class_ids = BTreeSet::new();
410 let mut class_labels = BTreeSet::new();
411 for class in &self.classes {
412 class.validate()?;
413 if !class_ids.insert(class.class_id) || !class_labels.insert(&class.label) {
414 return Err(ViewerError::InvalidState(
415 "semantic overlay classes must have unique IDs and labels".into(),
416 ));
417 }
418 }
419 let class_by_id =
420 self.classes.iter().map(|class| (class.class_id, class)).collect::<BTreeMap<_, _>>();
421 let mut entity_ids = BTreeSet::new();
422 let mut source_indices = BTreeSet::new();
423 let mut counts = BTreeMap::<u32, (u64, u64, u32)>::new();
424 let mut confidence = Vec::with_capacity(self.entities.len());
425 for entity in &self.entities {
426 entity.validate()?;
427 if !entity_ids.insert(&entity.id) || !source_indices.insert(entity.source_index) {
428 return Err(ViewerError::InvalidState(
429 "semantic overlay entity IDs and source indices must be unique".into(),
430 ));
431 }
432 let class = class_by_id.get(&entity.class_id).ok_or_else(|| {
433 ViewerError::InvalidState(
434 "semantic overlay entity references an unknown class".into(),
435 )
436 })?;
437 if class.label != entity.label {
438 return Err(ViewerError::InvalidState(
439 "semantic overlay entity label disagrees with its class legend".into(),
440 ));
441 }
442 let entry = counts.entry(entity.class_id).or_default();
443 entry.0 = entry.0.checked_add(1).ok_or_else(|| {
444 ViewerError::InvalidState("semantic overlay class count overflow".into())
445 })?;
446 entry.1 =
447 entry.1.checked_add(u64::from(entity.confidence_million)).ok_or_else(|| {
448 ViewerError::InvalidState("semantic overlay confidence sum overflow".into())
449 })?;
450 entry.2 = entry.2.max(entity.confidence_million);
451 confidence.push(entity.confidence_million);
452 }
453 for class in &self.classes {
454 let (count, sum, max) = counts.get(&class.class_id).copied().unwrap_or_default();
455 if class.entity_count != count
456 || class.max_confidence_million != max
457 || (count > 0 && class.mean_confidence_million != (sum / count) as u32)
458 {
459 return Err(ViewerError::InvalidState(
460 "semantic overlay class statistics disagree with entities".into(),
461 ));
462 }
463 }
464 confidence.sort_unstable();
465 let entity_count = u64::try_from(self.entities.len()).unwrap_or(u64::MAX);
466 let visible_count = entity_count;
467 let class_count = u64::try_from(counts.len()).unwrap_or(u64::MAX);
468 let confidence_sum = confidence.iter().try_fold(0_u64, |sum, value| {
469 sum.checked_add(u64::from(*value)).ok_or_else(|| {
470 ViewerError::InvalidState("semantic overlay confidence sum overflow".into())
471 })
472 })?;
473 let expected_mean = u32::try_from(confidence_sum.checked_div(entity_count).unwrap_or(0))
474 .unwrap_or(u32::MAX);
475 let expected_p95 = if confidence.is_empty() {
476 0
477 } else {
478 confidence[confidence.len().saturating_mul(95).div_ceil(100).saturating_sub(1)]
479 };
480 let expected_coverage = if self.summary.input_point_count == 0 {
481 0
482 } else {
483 u32::try_from(
484 (u128::from(self.summary.sampled_point_count)
485 * u128::from(SEMANTIC_CONFIDENCE_SCALE)
486 / u128::from(self.summary.input_point_count))
487 .min(u128::from(SEMANTIC_CONFIDENCE_SCALE)),
488 )
489 .unwrap_or(u32::MAX)
490 };
491 if self.summary.entity_count != entity_count
492 || self.summary.visible_entity_count != visible_count
493 || self.summary.class_count != class_count
494 || self.summary.mean_confidence_million != expected_mean
495 || self.summary.p95_confidence_million != expected_p95
496 || self.summary.coverage_million != expected_coverage
497 {
498 return Err(ViewerError::InvalidState(
499 "semantic overlay summary disagrees with class/entity data".into(),
500 ));
501 }
502
503 let mut artifact_roles = BTreeSet::new();
504 let mut artifact_paths = BTreeSet::new();
505 for artifact in &self.artifacts {
506 artifact.validate()?;
507 if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
508 return Err(ViewerError::InvalidState(
509 "semantic overlay artifacts must have unique roles and paths".into(),
510 ));
511 }
512 }
513 let calculated_ready = self.source.identity_matches
514 && self.summary.source_identity_match
515 && self.summary.frame_identity_match
516 && self.summary.entity_count > 0;
517 if self.overlay_ready != calculated_ready {
518 return Err(ViewerError::InvalidState(
519 "overlay_ready disagrees with source, frame, or prediction admission".into(),
520 ));
521 }
522 let calculated_mapping = self.overlay_ready && self.summary.calibration_applied;
523 if self.mapping_admitted != calculated_mapping {
524 return Err(ViewerError::InvalidState(
525 "mapping_admitted disagrees with semantic overlay calibration admission".into(),
526 ));
527 }
528 if !self.mapping_admitted && self.blockers.is_empty() {
529 return Err(ViewerError::InvalidState(
530 "blocked semantic overlay mapping must expose at least one blocker".into(),
531 ));
532 }
533 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
534 return Err(ViewerError::InvalidState(
535 "semantic overlay blockers must not contain empty messages".into(),
536 ));
537 }
538 Ok(())
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
547 const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
548
549 fn source(observed: &str) -> StudioSource {
550 StudioSource::try_new("canonical", "/media/input.db3", SHA, observed, observed == SHA)
551 .unwrap()
552 }
553
554 fn model() -> SemanticOverlayModel {
555 SemanticOverlayModel::try_new(
556 "mock-semantic-classes",
557 "mock",
558 "deterministic test profile",
559 true,
560 4,
561 3,
562 48,
563 24,
564 0,
565 0,
566 )
567 .unwrap()
568 }
569
570 fn classes() -> Vec<SemanticOverlayClass> {
571 vec![SemanticOverlayClass::try_new(0, "ground", [54, 211, 153], 1, 800_000, 800_000)
572 .unwrap()]
573 }
574
575 fn entities() -> Vec<SemanticOverlayEntity> {
576 vec![SemanticOverlayEntity::try_new(
577 "semantic:0",
578 0,
579 0,
580 "ground",
581 800_000,
582 [1_000_000, 2_000_000, 3_000_000],
583 )
584 .unwrap()]
585 }
586
587 fn summary(source_match: bool, frame_match: bool) -> SemanticOverlaySummary {
588 SemanticOverlaySummary::try_new(
589 10,
590 1,
591 if source_match && frame_match { 1 } else { 0 },
592 if source_match && frame_match { 1 } else { 0 },
593 if source_match && frame_match { 1 } else { 0 },
594 if source_match && frame_match { 800_000 } else { 0 },
595 if source_match && frame_match { 800_000 } else { 0 },
596 100_000,
597 source_match,
598 frame_match,
599 false,
600 )
601 .unwrap()
602 }
603
604 #[test]
605 fn valid_state_roundtrips_with_serde() {
606 let state = SemanticOverlayState::try_new(
607 "AI Semantic Overlay",
608 source(SHA),
609 "lidar_front",
610 "lidar_front",
611 "PointCloud2 header stamp",
612 model(),
613 classes(),
614 entities(),
615 Vec::new(),
616 summary(true, true),
617 vec!["clock calibration not applied".into()],
618 )
619 .unwrap();
620 assert!(state.overlay_ready);
621 assert!(!state.mapping_admitted);
622 #[cfg(feature = "serde")]
623 {
624 let json = serde_json::to_string(&state).unwrap();
625 assert_eq!(serde_json::from_str::<SemanticOverlayState>(&json).unwrap(), state);
626 }
627 }
628
629 #[test]
630 fn source_mismatch_withholds_overlay() {
631 let state = SemanticOverlayState::try_new(
632 "AI Semantic Overlay",
633 source(OTHER_SHA),
634 "lidar_front",
635 "lidar_front",
636 "PointCloud2 header stamp",
637 model(),
638 Vec::new(),
639 Vec::new(),
640 Vec::new(),
641 SemanticOverlaySummary::try_new(10, 0, 0, 0, 0, 0, 0, 0, false, true, false).unwrap(),
642 vec!["input SHA-256 mismatch".into()],
643 )
644 .unwrap();
645 assert!(!state.overlay_ready);
646 assert!(!state.mapping_admitted);
647 }
648
649 #[test]
650 fn entity_label_and_confidence_are_fail_closed() {
651 assert!(SemanticOverlayEntity::try_new("", 0, 0, "ground", 1, [0, 0, 0]).is_err());
652 assert!(SemanticOverlayEntity::try_new(
653 "semantic:0",
654 0,
655 0,
656 "ground",
657 SEMANTIC_CONFIDENCE_SCALE + 1,
658 [0, 0, 0],
659 )
660 .is_err());
661 }
662}