1use spatialrust_core::{FrameId, HasPositions3, Timestamp};
4use spatialrust_math::Vec3;
5use spatialrust_records::{RecordProvenance, SpatialRecord};
6
7use crate::{Embedding, SemanticError, SemanticResult};
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub struct EntityId(pub String);
12
13impl EntityId {
14 #[must_use]
16 pub fn new(value: impl Into<String>) -> Self {
17 Self(value.into())
18 }
19}
20
21#[derive(Clone, Debug, PartialEq)]
23pub struct OpenVocabLabel {
24 pub text: String,
26 pub confidence: f32,
28}
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct SemanticEntity {
33 pub id: EntityId,
35 pub centroid: Option<Vec3<f32>>,
37 pub labels: Vec<OpenVocabLabel>,
39 pub embedding: Option<Embedding>,
41}
42
43#[derive(Clone, Debug, PartialEq)]
50pub struct SpatialRecordEntity {
51 pub entity: SemanticEntity,
53 pub provenance: RecordProvenance,
55 pub frame_id: FrameId,
57 pub timestamp: Timestamp,
59}
60
61impl SpatialRecordEntity {
62 pub fn try_from_record(
64 record: &SpatialRecord,
65 labels: Vec<OpenVocabLabel>,
66 embedding: Option<Embedding>,
67 ) -> SemanticResult<Self> {
68 record
69 .provenance()
70 .validate()
71 .map_err(|error| SemanticError::InvalidConfiguration(error.to_string()))?;
72 validate_labels(&labels)?;
73 let centroid = centroid(record)?;
74 let provenance = record.provenance().clone();
75 let id = record_entity_id(&provenance);
76 Ok(Self {
77 entity: SemanticEntity { id, centroid, labels, embedding },
78 provenance,
79 frame_id: record.metadata().frame_id.clone(),
80 timestamp: record.metadata().timestamp,
81 })
82 }
83
84 #[must_use]
86 pub fn entity(&self) -> &SemanticEntity {
87 &self.entity
88 }
89}
90
91#[must_use]
93pub fn record_entity_id(provenance: &RecordProvenance) -> EntityId {
94 let stream = provenance.stream_id.as_deref().unwrap_or("record");
95 let sequence =
96 provenance.sequence.map_or_else(|| "unsequenced".to_owned(), |value| value.to_string());
97 EntityId::new(format!("record:{}:{stream}:{sequence}", provenance.source_id))
98}
99
100fn centroid(record: &SpatialRecord) -> SemanticResult<Option<Vec3<f32>>> {
101 let cloud = record.cloud();
102 if cloud.is_empty() {
103 return Ok(None);
104 }
105 let (x, y, z) = cloud.positions3()?;
106 let mut sum = [0.0_f64; 3];
107 for index in 0..cloud.len() {
108 let values = [x[index], y[index], z[index]];
109 if values.iter().any(|value| !value.is_finite()) {
110 return Err(SemanticError::InvalidConfiguration(
111 "record positions must be finite for semantic centroid".into(),
112 ));
113 }
114 for axis in 0..3 {
115 sum[axis] += f64::from(values[axis]);
116 }
117 }
118 let count = cloud.len() as f64;
119 let values = [sum[0] / count, sum[1] / count, sum[2] / count];
120 if values.iter().any(|value| !value.is_finite()) {
121 return Err(SemanticError::InvalidConfiguration("record centroid is not finite".into()));
122 }
123 Ok(Some(Vec3::new(values[0] as f32, values[1] as f32, values[2] as f32)))
124}
125
126fn validate_labels(labels: &[OpenVocabLabel]) -> SemanticResult<()> {
127 for label in labels {
128 if label.text.trim().is_empty() {
129 return Err(SemanticError::InvalidConfiguration(
130 "semantic labels must be non-empty".into(),
131 ));
132 }
133 if !label.confidence.is_finite() || !(0.0..=1.0).contains(&label.confidence) {
134 return Err(SemanticError::InvalidConfiguration(
135 "semantic label confidence must be finite in [0, 1]".into(),
136 ));
137 }
138 }
139 Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144 use super::{record_entity_id, OpenVocabLabel, SpatialRecordEntity};
145 use crate::Embedding;
146 use spatialrust_core::{
147 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
148 };
149 use spatialrust_records::{RecordProvenance, SchemaVersion, SpatialRecord};
150
151 fn sample_record() -> SpatialRecord {
152 let mut buffers = PointBufferSet::new();
153 buffers.insert("x", PointBuffer::from_f32(vec![0.0, 2.0]));
154 buffers.insert("y", PointBuffer::from_f32(vec![1.0, 3.0]));
155 buffers.insert("z", PointBuffer::from_f32(vec![2.0, 4.0]));
156 let cloud = PointCloud::try_from_parts(
157 StandardSchemas::point_xyz(),
158 buffers,
159 SpatialMetadata::new("map", Timestamp::from_nanos(7)),
160 )
161 .unwrap();
162 SpatialRecord::try_from_cloud_with_provenance(
163 "point",
164 SchemaVersion::new(1, 0),
165 cloud,
166 RecordProvenance::try_new("bag")
167 .unwrap()
168 .with_stream_id("/lidar")
169 .with_sequence(Some(3)),
170 )
171 .unwrap()
172 }
173
174 #[test]
175 fn derives_centroid_and_lineage_stable_entity_id() {
176 let record = sample_record();
177 let entity = SpatialRecordEntity::try_from_record(
178 &record,
179 vec![OpenVocabLabel { text: "surface".into(), confidence: 0.8 }],
180 Some(Embedding::try_new(vec![1.0, 0.0]).unwrap()),
181 )
182 .unwrap();
183 assert_eq!(entity.entity.id, record_entity_id(record.provenance()));
184 assert_eq!(entity.entity.centroid, Some(spatialrust_math::Vec3::new(1.0, 2.0, 3.0)));
185 assert_eq!(entity.frame_id.0, "map");
186 assert_eq!(entity.timestamp.as_nanos(), 7);
187 assert_eq!(entity.provenance, *record.provenance());
188 }
189
190 #[test]
191 fn rejects_invalid_label_confidence() {
192 let error = SpatialRecordEntity::try_from_record(
193 &sample_record(),
194 vec![OpenVocabLabel { text: "surface".into(), confidence: 1.1 }],
195 None,
196 )
197 .unwrap_err();
198 assert!(error.to_string().contains("confidence"));
199 }
200}