Skip to main content

kornia_imgproc/features/
cells.rs

1//! Layered FAST detector API: rectangle → grid of cells → multi-level pyramid.
2//!
3//! The three entry points here (`fast_detect_rect_u8`, `fast_detect_cells_u8`,
4//! `fast_detect_pyramid_u8`) let a caller pick the layer that matches their
5//! existing scaffolding:
6//!
7//! * **Rect** — zero-copy FAST over an arbitrary sub-rectangle. Basalt-style
8//!   consumers that already own a grid walker plug in here.
9//! * **Cells** — adaptive-threshold cell walker + NMS budget per cell. What
10//!   both ORB-SLAM3 and Basalt build on top of the raw detector.
11//! * **Pyramid** — run `cells` across a caller-supplied pyramid and rescale
12//!   back to full-resolution coordinates. Pyramid geometry (scale factor,
13//!   number of levels, u8 vs u16) is the caller's problem — we only care
14//!   about `&[&Image<u8>]`.
15//!
16//! Returns are all named structs ([`FastCorner`], [`CellKeypoint`],
17//! [`PyramidKeypoint`]) so adding fields later is non-breaking.
18use kornia_image::{allocator::ImageAllocator, Image};
19use rayon::prelude::*;
20
21use super::fast::fast_detect_rows_u8_serial;
22
23/// Axis-aligned rectangle in image pixel coordinates.
24///
25/// Used to specify the region-of-interest for [`fast_detect_rect_u8`]. All
26/// coordinates are in the coordinate system of the input image (no downscale).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Rect {
29    /// Column of the top-left corner (inclusive).
30    pub x: usize,
31    /// Row of the top-left corner (inclusive).
32    pub y: usize,
33    /// Width in pixels.
34    pub w: usize,
35    /// Height in pixels.
36    pub h: usize,
37}
38
39impl Rect {
40    /// Right edge (exclusive).
41    #[inline]
42    pub fn x_end(&self) -> usize {
43        self.x + self.w
44    }
45
46    /// Bottom edge (exclusive).
47    #[inline]
48    pub fn y_end(&self) -> usize {
49        self.y + self.h
50    }
51}
52
53/// A single FAST corner — position plus response score.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct FastCorner {
56    /// Position as `[col, row]` in image pixels.
57    pub xy: [f32; 2],
58    /// FAST response score (higher = stronger corner). Normalized to roughly
59    /// `[0, 16)` — the sum of absolute intensity differences around the
60    /// Bresenham ring divided by 255.
61    pub response: f32,
62}
63
64/// A FAST corner annotated with the cell it was detected in.
65///
66/// Returned by [`fast_detect_cells_u8`]. The `cell_id` is a row-major index
67/// into the grid of cells the image was partitioned into; downstream code can
68/// use it to build occupancy masks, do per-cell top-k selection, etc.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct CellKeypoint {
71    /// Position as `[col, row]` in image pixels.
72    pub xy: [f32; 2],
73    /// FAST response score.
74    pub response: f32,
75    /// Row-major cell index the corner falls into.
76    pub cell_id: u32,
77}
78
79/// A FAST corner detected in a pyramid level, with coordinates rescaled to
80/// the full-resolution (level-0) image.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct PyramidKeypoint {
83    /// Full-resolution position as `[col, row]`.
84    pub xy: [f32; 2],
85    /// FAST response score at the detection level (not rescaled).
86    pub response: f32,
87    /// Pyramid level index (0 = full resolution).
88    pub level: u8,
89    /// Row-major cell index within the detection level.
90    pub cell_id: u32,
91}
92
93/// Configuration for the cell-based adaptive-threshold FAST detector.
94///
95/// Mirrors the cell loop both ORB-SLAM3 and Basalt run around `cv::FAST`.
96/// Setting `target_per_cell = 1` and a long threshold cascade reproduces
97/// Basalt's `detectKeypoints`; setting `target_per_cell = usize::MAX` and a
98/// two-step cascade reproduces ORB-SLAM3's ini/min-threshold pattern.
99#[derive(Debug, Clone)]
100pub struct CellDetectConfig {
101    /// Side length of each square cell in pixels.
102    pub cell_size: usize,
103    /// Ordered thresholds (u8-scale, expressed as `f32` in `[0, 255]`) to
104    /// try per cell. For each cell, the detector tries the first threshold;
105    /// if fewer than `target_per_cell` corners emerge it falls through to
106    /// the next, and so on. Must be non-empty.
107    pub threshold_cascade: Vec<f32>,
108    /// Maximum corners kept per cell. The top-`target_per_cell` by response
109    /// are retained. `usize::MAX` disables per-cell clamping (keep all).
110    pub target_per_cell: usize,
111    /// Minimum arc length for the FAST segment test (9 or 12).
112    pub arc_length: usize,
113    /// Pixel border skipped at image edges (≥3, the Bresenham radius).
114    pub border: usize,
115}
116
117impl Default for CellDetectConfig {
118    fn default() -> Self {
119        // Defaults target ORB-SLAM3's per-cell pattern: two-tier threshold,
120        // keep all corners in a cell (downstream octree NMS picks the top-N).
121        Self {
122            cell_size: 35,
123            threshold_cascade: vec![20.0, 7.0],
124            target_per_cell: usize::MAX,
125            arc_length: 9,
126            border: 3,
127        }
128    }
129}
130
131/// Run FAST over a rectangular sub-region of an image without copying.
132///
133/// The `rect` is clipped to the image bounds (minus `border`); out-of-range
134/// rects yield an empty result. Thresholding matches [`super::FastDetector`]
135/// semantics: `threshold` is in the u8 intensity scale `[0, 255]`, converted
136/// internally to normalized form.
137///
138/// This is **Layer 1** of the layered API: it produces a flat list of
139/// corners inside the rect, with no cell bookkeeping or NMS. For the usual
140/// ORB-SLAM / Basalt cell loop, prefer [`fast_detect_cells_u8`].
141pub fn fast_detect_rect_u8<A: ImageAllocator>(
142    image: &Image<u8, 1, A>,
143    rect: Rect,
144    threshold: f32,
145    arc_length: usize,
146    border: usize,
147) -> Vec<FastCorner> {
148    let margin = border.max(3);
149    let w = image.width();
150    let h = image.height();
151
152    let x0 = rect.x.max(margin);
153    let y0 = rect.y.max(margin);
154    let x1 = rect.x_end().min(w.saturating_sub(margin));
155    let y1 = rect.y_end().min(h.saturating_sub(margin));
156    if x1 <= x0 || y1 <= y0 {
157        return Vec::new();
158    }
159
160    // Delegate the row-level NEON kernel. The existing function emits corners
161    // over full rows; we filter by column range on the way out. Column
162    // filtering is ~1 compare per emitted candidate — cheaper than duplicating
163    // the ~600-line row kernel for a column bound.
164    let t_norm = (threshold / 255.0).clamp(0.0, 1.0);
165    let raw = fast_detect_rows_u8_serial(image, t_norm, arc_length, border, y0..y1);
166
167    let mut out = Vec::with_capacity(raw.len());
168    for ([y, x], r) in raw {
169        if x >= x0 && x < x1 {
170            out.push(FastCorner {
171                xy: [x as f32, y as f32],
172                response: r,
173            });
174        }
175    }
176    out
177}
178
179/// Grid dimensions — number of cells along each axis.
180#[inline]
181fn grid_dims(image_w: usize, image_h: usize, cell_size: usize) -> (usize, usize) {
182    let nx = image_w.div_ceil(cell_size);
183    let ny = image_h.div_ceil(cell_size);
184    (nx, ny)
185}
186
187/// Adaptive-threshold cell-based FAST detector.
188///
189/// Partitions the image into a grid of `cfg.cell_size` × `cfg.cell_size`
190/// cells (the final row/column may be smaller). For each cell not flagged
191/// in `occupancy`, runs [`fast_detect_rect_u8`] with the first threshold in
192/// `cfg.threshold_cascade`; if fewer than `cfg.target_per_cell` corners
193/// emerge, tries the next threshold in the cascade, and so on. The top-k by
194/// response within each cell are retained.
195///
196/// `occupancy` (if `Some`) is a row-major boolean slice of length
197/// `n_cells_x * n_cells_y`; `true` means "skip this cell". Used by
198/// Basalt-style trackers to suppress cells that already contain tracked
199/// points without rebuilding the image.
200///
201/// Cells are processed in parallel via rayon.
202pub fn fast_detect_cells_u8<A: ImageAllocator + Sync>(
203    image: &Image<u8, 1, A>,
204    cfg: &CellDetectConfig,
205    occupancy: Option<&[bool]>,
206) -> Vec<CellKeypoint> {
207    assert!(
208        !cfg.threshold_cascade.is_empty(),
209        "threshold_cascade must be non-empty"
210    );
211    assert!(cfg.cell_size > 0, "cell_size must be positive");
212
213    let w = image.width();
214    let h = image.height();
215    let (nx, ny) = grid_dims(w, h, cfg.cell_size);
216    let total_cells = nx * ny;
217    if let Some(occ) = occupancy {
218        assert_eq!(
219            occ.len(),
220            total_cells,
221            "occupancy length must match grid ({} × {} = {})",
222            nx,
223            ny,
224            total_cells
225        );
226    }
227
228    (0..total_cells)
229        .into_par_iter()
230        .flat_map_iter(|cell_id| {
231            if occupancy.is_some_and(|occ| occ[cell_id]) {
232                return Vec::new().into_iter();
233            }
234            let cx = cell_id % nx;
235            let cy = cell_id / nx;
236            let rect = Rect {
237                x: cx * cfg.cell_size,
238                y: cy * cfg.cell_size,
239                w: cfg.cell_size,
240                h: cfg.cell_size,
241            };
242
243            let mut corners: Vec<FastCorner> = Vec::new();
244            for &thr in &cfg.threshold_cascade {
245                corners = fast_detect_rect_u8(image, rect, thr, cfg.arc_length, cfg.border);
246                if corners.len() >= cfg.target_per_cell {
247                    break;
248                }
249            }
250
251            if cfg.target_per_cell < corners.len() {
252                // Partial-sort: keep the top-k by response.
253                corners.sort_by(|a, b| {
254                    b.response
255                        .partial_cmp(&a.response)
256                        .unwrap_or(std::cmp::Ordering::Equal)
257                });
258                corners.truncate(cfg.target_per_cell);
259            }
260
261            let cell_id_u32 = cell_id as u32;
262            corners
263                .into_iter()
264                .map(move |c| CellKeypoint {
265                    xy: c.xy,
266                    response: c.response,
267                    cell_id: cell_id_u32,
268                })
269                .collect::<Vec<_>>()
270                .into_iter()
271        })
272        .collect()
273}
274
275/// Run [`fast_detect_cells_u8`] across a caller-supplied pyramid and rescale
276/// all keypoint coordinates back to the full-resolution (level-0) frame.
277///
278/// `levels[0]` is treated as full resolution; higher indices are coarser.
279/// The same `cfg` is used at every level — if different thresholds per
280/// level are required, call this function per level and concatenate.
281///
282/// Rescale uses the exact width/height ratio `levels[0].size / levels[i].size`
283/// rather than assuming a fixed factor (1.2×, 2×, …), so ORB-SLAM's 1.2×
284/// chain and Basalt's 2× chain both work without special-casing.
285pub fn fast_detect_pyramid_u8<A: ImageAllocator + Sync>(
286    levels: &[&Image<u8, 1, A>],
287    cfg: &CellDetectConfig,
288) -> Vec<PyramidKeypoint> {
289    if levels.is_empty() {
290        return Vec::new();
291    }
292    let full_w = levels[0].width() as f32;
293    let full_h = levels[0].height() as f32;
294
295    let mut out = Vec::new();
296    for (lvl, img) in levels.iter().enumerate() {
297        let sx = full_w / img.width() as f32;
298        let sy = full_h / img.height() as f32;
299        let level_u8 = lvl as u8;
300        let kps = fast_detect_cells_u8(img, cfg, None);
301        out.reserve(kps.len());
302        for k in kps {
303            out.push(PyramidKeypoint {
304                xy: [k.xy[0] * sx, k.xy[1] * sy],
305                response: k.response,
306                level: level_u8,
307                cell_id: k.cell_id,
308            });
309        }
310    }
311    out
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use kornia_image::ImageSize;
318    use kornia_tensor::CpuAllocator;
319
320    /// Black background with 5×5 bright squares placed on a regular grid.
321    /// Each square has 4 strong FAST corners (center pixel bright, most of the
322    /// Bresenham ring outside the square → long dark run), so the detector
323    /// fires reliably at every grid position — unlike an axis-aligned
324    /// checkerboard, whose T-junctions only produce runs of ~5 same-sign
325    /// pixels, below the FAST-9 threshold.
326    fn dot_image(w: usize, h: usize, spacing: usize) -> Image<u8, 1, CpuAllocator> {
327        let size = ImageSize {
328            width: w,
329            height: h,
330        };
331        let mut buf = vec![0u8; w * h];
332        let mut cy = spacing;
333        while cy + 5 < h {
334            let mut cx = spacing;
335            while cx + 5 < w {
336                for dy in 0..5 {
337                    for dx in 0..5 {
338                        buf[(cy + dy) * w + (cx + dx)] = 255;
339                    }
340                }
341                cx += spacing;
342            }
343            cy += spacing;
344        }
345        Image::from_size_slice(size, &buf, CpuAllocator).unwrap()
346    }
347
348    #[test]
349    fn rect_clips_to_bounds() {
350        let img = dot_image(64, 64, 16);
351        // A rect overhanging the image should be clipped, not crash.
352        let rect = Rect {
353            x: 50,
354            y: 50,
355            w: 100,
356            h: 100,
357        };
358        let _ = fast_detect_rect_u8(&img, rect, 20.0, 9, 3);
359        // Degenerate rect (outside image) → empty.
360        let out = fast_detect_rect_u8(
361            &img,
362            Rect {
363                x: 1000,
364                y: 1000,
365                w: 10,
366                h: 10,
367            },
368            20.0,
369            9,
370            3,
371        );
372        assert!(out.is_empty());
373    }
374
375    #[test]
376    fn cells_detects_in_every_non_border_cell() {
377        // Dot image with 16-px spacing places a 5×5 bright square in every
378        // 32-px cell of a 128×128 image → every non-edge cell fires.
379        let img = dot_image(128, 128, 16);
380        let cfg = CellDetectConfig {
381            cell_size: 32,
382            threshold_cascade: vec![20.0],
383            target_per_cell: 1,
384            arc_length: 9,
385            border: 3,
386        };
387        let kps = fast_detect_cells_u8(&img, &cfg, None);
388        // 128/32 = 4 cells per side → 16 cells. Border cells may miss; we
389        // just require the middle 4 cells to fire.
390        assert!(
391            kps.len() >= 4,
392            "expected ≥4 cell keypoints, got {}",
393            kps.len()
394        );
395        // All cell_ids are in range.
396        let (nx, ny) = grid_dims(128, 128, 32);
397        for k in &kps {
398            assert!((k.cell_id as usize) < nx * ny);
399        }
400    }
401
402    #[test]
403    fn cells_respects_occupancy_mask() {
404        let img = dot_image(128, 128, 16);
405        let (nx, ny) = grid_dims(128, 128, 32);
406        // Mark every cell as occupied → zero keypoints out.
407        let occ = vec![true; nx * ny];
408        let cfg = CellDetectConfig {
409            cell_size: 32,
410            threshold_cascade: vec![20.0],
411            target_per_cell: 1,
412            arc_length: 9,
413            border: 3,
414        };
415        let kps = fast_detect_cells_u8(&img, &cfg, Some(&occ));
416        assert!(kps.is_empty(), "occupancy-full mask should suppress all");
417    }
418
419    #[test]
420    fn pyramid_rescales_to_full_res() {
421        // A 2-level pyramid with 2× downscale. Corners detected at level 1
422        // should have full-res coordinates approximately 2× their level-1
423        // coordinates.
424        let l0 = dot_image(128, 128, 16);
425        let l1 = dot_image(64, 64, 16);
426        let levels = [&l0, &l1];
427        let cfg = CellDetectConfig {
428            cell_size: 32,
429            threshold_cascade: vec![20.0],
430            target_per_cell: 1,
431            arc_length: 9,
432            border: 3,
433        };
434        let kps = fast_detect_pyramid_u8(&levels, &cfg);
435        let max_lvl = kps.iter().map(|k| k.level).max().unwrap_or(0);
436        assert!(max_lvl >= 1, "expected keypoints on both pyramid levels");
437        // A level-1 keypoint's full-res x must fit in [0, 128].
438        for k in &kps {
439            assert!(k.xy[0] >= 0.0 && k.xy[0] <= 128.0);
440            assert!(k.xy[1] >= 0.0 && k.xy[1] <= 128.0);
441        }
442    }
443}