Skip to main content

spatialrust_vision/
feature2d.rs

1//! Feature2D keypoint, descriptor, and correspondence data contracts.
2
3use crate::{VisionError, VisionResult};
4
5/// One scale-space image keypoint in pixel coordinates.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct Keypoint2 {
8    x: f32,
9    y: f32,
10    size: f32,
11    angle_degrees: Option<f32>,
12    response: f32,
13    octave: i32,
14    class_id: Option<i32>,
15}
16
17impl Keypoint2 {
18    /// Creates an unoriented one-pixel keypoint with a detector response.
19    pub fn try_new(x: f32, y: f32, response: f32) -> VisionResult<Self> {
20        if !x.is_finite() || !y.is_finite() || !response.is_finite() {
21            return Err(VisionError::InvalidParameter(
22                "keypoint coordinates and response must be finite".into(),
23            ));
24        }
25        Ok(Self { x, y, size: 1.0, angle_degrees: None, response, octave: 0, class_id: None })
26    }
27
28    /// Sets a positive finite feature diameter in pixels.
29    pub fn with_size(mut self, size: f32) -> VisionResult<Self> {
30        if !size.is_finite() || size <= 0.0 {
31            return Err(VisionError::InvalidParameter(
32                "keypoint size must be positive and finite".into(),
33            ));
34        }
35        self.size = size;
36        Ok(self)
37    }
38
39    /// Sets a finite orientation, normalized into `[0, 360)` degrees.
40    pub fn with_angle_degrees(mut self, angle: f32) -> VisionResult<Self> {
41        if !angle.is_finite() {
42            return Err(VisionError::InvalidParameter("keypoint angle must be finite".into()));
43        }
44        self.angle_degrees = Some(angle.rem_euclid(360.0));
45        Ok(self)
46    }
47
48    /// Sets a scale-pyramid octave.
49    #[must_use]
50    pub const fn with_octave(mut self, octave: i32) -> Self {
51        self.octave = octave;
52        self
53    }
54
55    /// Sets an optional detector- or application-defined class identifier.
56    #[must_use]
57    pub const fn with_class_id(mut self, class_id: i32) -> Self {
58        self.class_id = Some(class_id);
59        self
60    }
61
62    /// Returns the horizontal pixel coordinate.
63    pub const fn x(self) -> f32 {
64        self.x
65    }
66
67    /// Returns the vertical pixel coordinate.
68    pub const fn y(self) -> f32 {
69        self.y
70    }
71
72    /// Returns the feature diameter in pixels.
73    pub const fn size(self) -> f32 {
74        self.size
75    }
76
77    /// Returns the normalized orientation, or `None` when not estimated.
78    pub const fn angle_degrees(self) -> Option<f32> {
79        self.angle_degrees
80    }
81
82    /// Returns the detector response; larger values are more salient.
83    pub const fn response(self) -> f32 {
84        self.response
85    }
86
87    /// Returns the scale-pyramid octave.
88    pub const fn octave(self) -> i32 {
89        self.octave
90    }
91
92    /// Returns the optional application-defined class identifier.
93    pub const fn class_id(self) -> Option<i32> {
94        self.class_id
95    }
96}
97
98/// Scalar representation used by a descriptor matrix.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
100pub enum DescriptorKind {
101    /// Packed binary descriptor; matching uses Hamming distance.
102    Binary,
103    /// Float32 descriptor; matching normally uses L2 distance.
104    Float32,
105}
106
107#[derive(Clone, Debug, PartialEq)]
108enum DescriptorStorage {
109    Binary(Vec<u8>),
110    Float32(Vec<f32>),
111}
112
113/// Row-major descriptor matrix with a fixed width and explicit scalar kind.
114#[derive(Clone, Debug, PartialEq)]
115pub struct DescriptorBuffer {
116    rows: usize,
117    width: usize,
118    storage: DescriptorStorage,
119}
120
121impl DescriptorBuffer {
122    /// Creates a checked row-major binary descriptor matrix.
123    pub fn try_binary(rows: usize, bytes_per_row: usize, data: Vec<u8>) -> VisionResult<Self> {
124        Self::validate_layout(rows, bytes_per_row, data.len())?;
125        Ok(Self { rows, width: bytes_per_row, storage: DescriptorStorage::Binary(data) })
126    }
127
128    /// Creates a checked row-major float32 descriptor matrix.
129    pub fn try_float32(rows: usize, values_per_row: usize, data: Vec<f32>) -> VisionResult<Self> {
130        if data.iter().any(|value| !value.is_finite()) {
131            return Err(VisionError::InvalidParameter(
132                "float descriptors must contain only finite values".into(),
133            ));
134        }
135        Self::validate_layout(rows, values_per_row, data.len())?;
136        Ok(Self { rows, width: values_per_row, storage: DescriptorStorage::Float32(data) })
137    }
138
139    fn validate_layout(rows: usize, width: usize, actual: usize) -> VisionResult<()> {
140        if width == 0 {
141            return Err(VisionError::InvalidParameter("descriptor width must be positive".into()));
142        }
143        let expected = rows
144            .checked_mul(width)
145            .ok_or_else(|| VisionError::InvalidParameter("descriptor layout overflows".into()))?;
146        if actual != expected {
147            return Err(VisionError::DescriptorLayout { rows, width, expected, actual });
148        }
149        Ok(())
150    }
151
152    /// Returns the descriptor scalar representation.
153    pub const fn kind(&self) -> DescriptorKind {
154        match self.storage {
155            DescriptorStorage::Binary(_) => DescriptorKind::Binary,
156            DescriptorStorage::Float32(_) => DescriptorKind::Float32,
157        }
158    }
159
160    /// Returns the descriptor row count.
161    pub const fn len(&self) -> usize {
162        self.rows
163    }
164
165    /// Returns whether there are no descriptor rows.
166    pub const fn is_empty(&self) -> bool {
167        self.rows == 0
168    }
169
170    /// Returns bytes or float values per descriptor.
171    pub const fn width(&self) -> usize {
172        self.width
173    }
174
175    /// Returns one binary descriptor, or `None` for a wrong kind or row index.
176    pub fn binary_row(&self, index: usize) -> Option<&[u8]> {
177        let DescriptorStorage::Binary(data) = &self.storage else {
178            return None;
179        };
180        let start = index.checked_mul(self.width)?;
181        data.get(start..start + self.width)
182    }
183
184    /// Returns one float descriptor, or `None` for a wrong kind or row index.
185    pub fn float32_row(&self, index: usize) -> Option<&[f32]> {
186        let DescriptorStorage::Float32(data) = &self.storage else {
187            return None;
188        };
189        let start = index.checked_mul(self.width)?;
190        data.get(start..start + self.width)
191    }
192
193    /// Returns packed binary storage when the descriptor kind is binary.
194    pub fn binary_data(&self) -> Option<&[u8]> {
195        match &self.storage {
196            DescriptorStorage::Binary(data) => Some(data),
197            DescriptorStorage::Float32(_) => None,
198        }
199    }
200
201    /// Returns packed float storage when the descriptor kind is float32.
202    pub fn float32_data(&self) -> Option<&[f32]> {
203        match &self.storage {
204            DescriptorStorage::Float32(data) => Some(data),
205            DescriptorStorage::Binary(_) => None,
206        }
207    }
208}
209
210/// Keypoints paired one-to-one with descriptor rows.
211#[derive(Clone, Debug, PartialEq)]
212pub struct FeatureSet2 {
213    keypoints: Vec<Keypoint2>,
214    descriptors: DescriptorBuffer,
215}
216
217impl FeatureSet2 {
218    /// Validates and owns a keypoint/descriptor pair.
219    pub fn try_new(keypoints: Vec<Keypoint2>, descriptors: DescriptorBuffer) -> VisionResult<Self> {
220        if keypoints.len() != descriptors.len() {
221            return Err(VisionError::FeatureCountMismatch {
222                keypoints: keypoints.len(),
223                descriptors: descriptors.len(),
224            });
225        }
226        Ok(Self { keypoints, descriptors })
227    }
228
229    /// Returns keypoints in descriptor-row order.
230    pub fn keypoints(&self) -> &[Keypoint2] {
231        &self.keypoints
232    }
233
234    /// Returns the associated descriptor matrix.
235    pub const fn descriptors(&self) -> &DescriptorBuffer {
236        &self.descriptors
237    }
238}
239
240/// One descriptor correspondence from a query feature to a train feature.
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub struct FeatureMatch {
243    query_index: usize,
244    train_index: usize,
245    distance: f32,
246}
247
248impl FeatureMatch {
249    /// Creates a correspondence with a finite non-negative distance.
250    pub fn try_new(query_index: usize, train_index: usize, distance: f32) -> VisionResult<Self> {
251        if !distance.is_finite() || distance < 0.0 {
252            return Err(VisionError::InvalidParameter(
253                "feature-match distance must be finite and non-negative".into(),
254            ));
255        }
256        Ok(Self { query_index, train_index, distance })
257    }
258
259    /// Validates both indices against their feature collections.
260    pub fn validate(self, query_count: usize, train_count: usize) -> VisionResult<Self> {
261        if self.query_index >= query_count || self.train_index >= train_count {
262            return Err(VisionError::MatchIndexOutOfBounds {
263                query: self.query_index,
264                queries: query_count,
265                train: self.train_index,
266                trains: train_count,
267            });
268        }
269        Ok(self)
270    }
271
272    /// Returns the query descriptor row index.
273    pub const fn query_index(self) -> usize {
274        self.query_index
275    }
276
277    /// Returns the train descriptor row index.
278    pub const fn train_index(self) -> usize {
279        self.train_index
280    }
281
282    /// Returns the descriptor distance; smaller is a better match.
283    pub const fn distance(self) -> f32 {
284        self.distance
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::{DescriptorBuffer, DescriptorKind, FeatureMatch, FeatureSet2, Keypoint2};
291    use crate::VisionError;
292
293    #[test]
294    fn keypoint_normalizes_orientation_and_rejects_non_finite_values() {
295        let keypoint = Keypoint2::try_new(2.5, 3.5, -0.2)
296            .unwrap()
297            .with_size(7.0)
298            .unwrap()
299            .with_angle_degrees(-45.0)
300            .unwrap()
301            .with_octave(2)
302            .with_class_id(9);
303        assert_eq!(keypoint.angle_degrees(), Some(315.0));
304        assert_eq!((keypoint.x(), keypoint.y(), keypoint.size()), (2.5, 3.5, 7.0));
305        assert_eq!(
306            (keypoint.response(), keypoint.octave(), keypoint.class_id()),
307            (-0.2, 2, Some(9))
308        );
309        assert!(Keypoint2::try_new(f32::NAN, 0.0, 1.0).is_err());
310    }
311
312    #[test]
313    fn descriptor_layout_and_kind_are_checked() {
314        let binary = DescriptorBuffer::try_binary(2, 4, (0..8).collect()).unwrap();
315        assert_eq!(binary.kind(), DescriptorKind::Binary);
316        assert_eq!(binary.binary_row(1), Some(&[4, 5, 6, 7][..]));
317        assert!(binary.float32_row(0).is_none());
318        assert!(matches!(
319            DescriptorBuffer::try_float32(2, 3, vec![0.0; 5]),
320            Err(VisionError::DescriptorLayout { expected: 6, actual: 5, .. })
321        ));
322        assert!(DescriptorBuffer::try_float32(1, 1, vec![f32::NAN]).is_err());
323    }
324
325    #[test]
326    fn feature_rows_and_match_indices_are_checked() {
327        let keypoint = Keypoint2::try_new(1.0, 2.0, 3.0).unwrap();
328        let descriptors = DescriptorBuffer::try_binary(1, 2, vec![0xaa, 0x55]).unwrap();
329        let features = FeatureSet2::try_new(vec![keypoint], descriptors).unwrap();
330        assert_eq!(features.keypoints(), &[keypoint]);
331        assert_eq!(features.descriptors().len(), 1);
332        let correspondence = FeatureMatch::try_new(0, 0, 4.0).unwrap().validate(1, 1).unwrap();
333        assert_eq!((correspondence.query_index(), correspondence.train_index()), (0, 0));
334        assert_eq!(correspondence.distance(), 4.0);
335        assert!(FeatureMatch::try_new(0, 0, f32::INFINITY).is_err());
336        assert!(matches!(
337            FeatureMatch::try_new(1, 0, 0.0).unwrap().validate(1, 1),
338            Err(VisionError::MatchIndexOutOfBounds { .. })
339        ));
340    }
341}