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}