Skip to main content

nv_core/
geom.rs

1//! Geometry primitives for the NextVision runtime.
2//!
3//! All spatial types use normalized `[0, 1]` coordinates relative to frame dimensions.
4//! This eliminates resolution-dependency throughout the perception pipeline.
5//!
6//! To convert to pixel coordinates: `px = normalized * dimension`.
7
8use std::fmt;
9
10/// Axis-aligned bounding box in normalized `[0, 1]` coordinates.
11///
12/// `x_min <= x_max` and `y_min <= y_max` are expected invariants.
13/// The origin is the top-left corner of the frame.
14#[derive(Clone, Copy, PartialEq)]
15pub struct BBox {
16    pub x_min: f32,
17    pub y_min: f32,
18    pub x_max: f32,
19    pub y_max: f32,
20}
21
22impl BBox {
23    /// Create a new bounding box from normalized coordinates.
24    #[must_use]
25    pub fn new(x_min: f32, y_min: f32, x_max: f32, y_max: f32) -> Self {
26        Self {
27            x_min,
28            y_min,
29            x_max,
30            y_max,
31        }
32    }
33
34    /// Width of the bounding box in normalized coordinates.
35    #[must_use]
36    pub fn width(&self) -> f32 {
37        self.x_max - self.x_min
38    }
39
40    /// Height of the bounding box in normalized coordinates.
41    #[must_use]
42    pub fn height(&self) -> f32 {
43        self.y_max - self.y_min
44    }
45
46    /// Area of the bounding box in normalized coordinates.
47    #[must_use]
48    pub fn area(&self) -> f32 {
49        self.width() * self.height()
50    }
51
52    /// Center point of the bounding box.
53    #[must_use]
54    pub fn center(&self) -> Point2 {
55        Point2 {
56            x: (self.x_min + self.x_max) * 0.5,
57            y: (self.y_min + self.y_max) * 0.5,
58        }
59    }
60
61    /// Compute intersection-over-union with another bounding box.
62    #[must_use]
63    pub fn iou(&self, other: &Self) -> f32 {
64        let ix_min = self.x_min.max(other.x_min);
65        let iy_min = self.y_min.max(other.y_min);
66        let ix_max = self.x_max.min(other.x_max);
67        let iy_max = self.y_max.min(other.y_max);
68
69        let iw = (ix_max - ix_min).max(0.0);
70        let ih = (iy_max - iy_min).max(0.0);
71        let intersection = iw * ih;
72
73        let union = self.area() + other.area() - intersection;
74        if union <= 0.0 {
75            return 0.0;
76        }
77        intersection / union
78    }
79}
80
81impl fmt::Debug for BBox {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(
84            f,
85            "BBox([{:.3}, {:.3}] -> [{:.3}, {:.3}])",
86            self.x_min, self.y_min, self.x_max, self.y_max
87        )
88    }
89}
90
91/// 2D point in normalized `[0, 1]` coordinates.
92#[derive(Clone, Copy, PartialEq)]
93pub struct Point2 {
94    pub x: f32,
95    pub y: f32,
96}
97
98impl Point2 {
99    /// Create a new point.
100    #[must_use]
101    pub fn new(x: f32, y: f32) -> Self {
102        Self { x, y }
103    }
104
105    /// Euclidean distance to another point (in normalized coordinates).
106    #[must_use]
107    pub fn distance_to(&self, other: &Self) -> f32 {
108        let dx = self.x - other.x;
109        let dy = self.y - other.y;
110        (dx * dx + dy * dy).sqrt()
111    }
112}
113
114impl fmt::Debug for Point2 {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "Point2({:.4}, {:.4})", self.x, self.y)
117    }
118}
119
120/// Closed polygon in normalized `[0, 1]` coordinates.
121///
122/// Defined by an ordered list of vertices. The polygon is implicitly closed
123/// (last vertex connects to first).
124#[derive(Clone, Debug, PartialEq)]
125pub struct Polygon {
126    /// Ordered vertices. Minimum 3 for a valid polygon.
127    pub vertices: Vec<Point2>,
128}
129
130impl Polygon {
131    /// Create a polygon from vertices.
132    #[must_use]
133    pub fn new(vertices: Vec<Point2>) -> Self {
134        Self { vertices }
135    }
136
137    /// Number of vertices.
138    #[must_use]
139    pub fn len(&self) -> usize {
140        self.vertices.len()
141    }
142
143    /// Whether the polygon has no vertices.
144    #[must_use]
145    pub fn is_empty(&self) -> bool {
146        self.vertices.is_empty()
147    }
148}
149
150/// 2D affine transform represented as a 3×3 matrix in row-major order.
151///
152/// Stored as 6 elements `[a, b, tx, c, d, ty]` representing:
153/// ```text
154/// | a  b  tx |
155/// | c  d  ty |
156/// | 0  0   1 |
157/// ```
158///
159/// Uses `f64` for numerical precision in composed transforms.
160#[derive(Clone, Copy, PartialEq)]
161pub struct AffineTransform2D {
162    /// `[a, b, tx, c, d, ty]` — the upper two rows of a 3×3 affine matrix.
163    pub m: [f64; 6],
164}
165
166impl AffineTransform2D {
167    /// The identity transform.
168    pub const IDENTITY: Self = Self {
169        m: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
170    };
171
172    /// Create from the six affine parameters.
173    #[must_use]
174    pub fn new(a: f64, b: f64, tx: f64, c: f64, d: f64, ty: f64) -> Self {
175        Self {
176            m: [a, b, tx, c, d, ty],
177        }
178    }
179
180    /// Apply this transform to a [`Point2`].
181    ///
182    /// The point is converted to `f64`, transformed, and converted back.
183    #[must_use]
184    pub fn apply(&self, p: Point2) -> Point2 {
185        let x = p.x as f64;
186        let y = p.y as f64;
187        Point2 {
188            x: (self.m[0] * x + self.m[1] * y + self.m[2]) as f32,
189            y: (self.m[3] * x + self.m[4] * y + self.m[5]) as f32,
190        }
191    }
192
193    /// Apply this transform to a [`BBox`] using the transform-and-re-bound method.
194    ///
195    /// All four corners are transformed, then an axis-aligned bounding box
196    /// is computed from the results. This may enlarge the box if the transform
197    /// involves rotation.
198    #[must_use]
199    pub fn apply_bbox(&self, bbox: BBox) -> BBox {
200        let corners = [
201            self.apply(Point2::new(bbox.x_min, bbox.y_min)),
202            self.apply(Point2::new(bbox.x_max, bbox.y_min)),
203            self.apply(Point2::new(bbox.x_max, bbox.y_max)),
204            self.apply(Point2::new(bbox.x_min, bbox.y_max)),
205        ];
206        BBox {
207            x_min: corners.iter().map(|c| c.x).fold(f32::INFINITY, f32::min),
208            y_min: corners.iter().map(|c| c.y).fold(f32::INFINITY, f32::min),
209            x_max: corners
210                .iter()
211                .map(|c| c.x)
212                .fold(f32::NEG_INFINITY, f32::max),
213            y_max: corners
214                .iter()
215                .map(|c| c.y)
216                .fold(f32::NEG_INFINITY, f32::max),
217        }
218    }
219
220    /// Compose two transforms: `self` followed by `other`.
221    ///
222    /// Equivalent to matrix multiplication `other * self`.
223    #[must_use]
224    pub fn then(&self, other: &Self) -> Self {
225        let a = self.m;
226        let b = other.m;
227        Self {
228            m: [
229                b[0] * a[0] + b[1] * a[3],
230                b[0] * a[1] + b[1] * a[4],
231                b[0] * a[2] + b[1] * a[5] + b[2],
232                b[3] * a[0] + b[4] * a[3],
233                b[3] * a[1] + b[4] * a[4],
234                b[3] * a[2] + b[4] * a[5] + b[5],
235            ],
236        }
237    }
238}
239
240impl fmt::Debug for AffineTransform2D {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(
243            f,
244            "AffineTransform2D([{:.4}, {:.4}, {:.4}; {:.4}, {:.4}, {:.4}])",
245            self.m[0], self.m[1], self.m[2], self.m[3], self.m[4], self.m[5]
246        )
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn bbox_center() {
256        let b = BBox::new(0.1, 0.2, 0.5, 0.8);
257        let c = b.center();
258        assert!((c.x - 0.3).abs() < 1e-6);
259        assert!((c.y - 0.5).abs() < 1e-6);
260    }
261
262    #[test]
263    fn bbox_iou_identical() {
264        let b = BBox::new(0.0, 0.0, 0.5, 0.5);
265        assert!((b.iou(&b) - 1.0).abs() < 1e-6);
266    }
267
268    #[test]
269    fn bbox_iou_disjoint() {
270        let a = BBox::new(0.0, 0.0, 0.2, 0.2);
271        let b = BBox::new(0.5, 0.5, 1.0, 1.0);
272        assert!((a.iou(&b)).abs() < 1e-6);
273    }
274
275    #[test]
276    fn affine_identity() {
277        let t = AffineTransform2D::IDENTITY;
278        let p = Point2::new(0.3, 0.7);
279        let q = t.apply(p);
280        assert!((q.x - p.x).abs() < 1e-6);
281        assert!((q.y - p.y).abs() < 1e-6);
282    }
283
284    #[test]
285    fn affine_translation() {
286        let t = AffineTransform2D::new(1.0, 0.0, 0.1, 0.0, 1.0, 0.2);
287        let p = Point2::new(0.3, 0.4);
288        let q = t.apply(p);
289        assert!((q.x - 0.4).abs() < 1e-5);
290        assert!((q.y - 0.6).abs() < 1e-5);
291    }
292
293    #[test]
294    fn affine_compose_identity() {
295        let t = AffineTransform2D::new(2.0, 0.0, 0.0, 0.0, 3.0, 0.0);
296        let composed = t.then(&AffineTransform2D::IDENTITY);
297        assert_eq!(t.m, composed.m);
298    }
299}