Skip to main content

cumulus3d_features/sift/
mod.rs

1/*
2 * mod.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * Third-party notices: parts of the algorithms, default parameters and data
26 * formats in this file follow other open-source projects. Their copyright
27 * notices and licenses are reproduced in THIRD_PARTY_NOTICES.md.
28 *
29 * SPDX-License-Identifier: Apache-2.0
30 */
31
32//! SIFT. Heavy computation sits behind [`SiftEngine`].
33
34pub mod detect;
35pub mod orient;
36pub mod pyramid;
37
38use crate::gray::GrayImage;
39use cumulus3d_core::{Descriptors, Error, Keypoint, Result};
40use detect::{Candidate, DetectParams};
41pub use orient::DescriptorNormalization;
42use orient::{Gradient, OrientParams};
43use pyramid::{BufferPool, ScaleSpace};
44use rayon::prelude::*;
45
46/// Maximum feature count limiting mode.
47#[derive(Clone, Copy, Debug, PartialEq, Default)]
48pub enum FeatureSelection {
49    /// Level-wise rule: whole levels from the coarsest, including the level that crosses the cap. Default.
50    #[default]
51    CompatLevels,
52    /// Improvement: coarse levels first + within a level descending |DoG response|, exactly K features.
53    TopK,
54    /// Improvement: split the image into a `cells x cells` grid and pick alternately per cell in |response| order,
55    /// exactly K features (spatially uniform).
56    SpatialGrid {
57        /// Number of grid cells per axis.
58        cells: u32,
59    },
60}
61
62/// SIFT options. Defaults = pipeline settings + level-wise selection behavior.
63#[derive(Clone, Debug, PartialEq)]
64pub struct SiftOptions {
65    /// Maximum feature count (0 = unlimited).
66    pub max_num_features: usize,
67    /// -1 (2x upsample) or 0.
68    pub first_octave: i32,
69    /// None = automatic from image size (floor(log2 min) - 3). Some(n) = fixed.
70    pub num_octaves: Option<usize>,
71    /// Number of detection levels per octave S.
72    pub octave_resolution: usize,
73    /// DoG extremum threshold (contrast).
74    pub peak_threshold: f32,
75    /// Edge suppression threshold (principal curvature ratio).
76    pub edge_threshold: f32,
77    /// Maximum number of orientations per keypoint.
78    pub max_num_orientations: usize,
79    /// If true, θ = 0 without orientation assignment.
80    pub upright: bool,
81    /// Descriptor normalization mode.
82    pub normalization: DescriptorNormalization,
83    /// Round the input width down to a multiple of 4 (default behavior). If disabled, the right columns are not dropped (improvement).
84    pub truncate_width_to_4: bool,
85    /// Number of sub-pixel refinement iterations. 1 = once (no shift, default). 5 = iterate while shifting pixels (improvement).
86    pub refinement_iterations: usize,
87    /// Discard if the Hessian is singular (improvement). Default false = pass with δ=0.
88    pub reject_singular_refinement: bool,
89    /// Linear interpolation between adjacent orientation histogram bins (improvement).
90    pub orientation_bin_interpolation: bool,
91    /// Maximum feature count limiting mode.
92    pub selection: FeatureSelection,
93}
94
95impl Default for SiftOptions {
96    fn default() -> Self {
97        Self {
98            max_num_features: 8192,
99            first_octave: -1,
100            num_octaves: None,
101            octave_resolution: 3,
102            peak_threshold: 0.006667,
103            edge_threshold: 10.0,
104            max_num_orientations: 2,
105            upright: false,
106            normalization: DescriptorNormalization::L1Root,
107            truncate_width_to_4: true,
108            refinement_iterations: 1,
109            reject_singular_refinement: false,
110            orientation_bin_interpolation: false,
111            selection: FeatureSelection::CompatLevels,
112        }
113    }
114}
115
116/// One feature (in input-image pixel units, top-left pixel center = (0.5, 0.5)).
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct SiftFeature {
119    /// x coordinate (pixels).
120    pub x: f32,
121    /// y coordinate (pixels).
122    pub y: f32,
123    /// Scale σ (in input-image pixel units).
124    pub scale: f32,
125    /// Output orientation = (2π - θ_int) mod 2π.
126    pub orientation: f32,
127    /// Detection octave index.
128    pub octave: i32,
129    /// Detection level j within the octave (0..S).
130    pub level: u32,
131    /// Refined DoG value.
132    pub response: f32,
133}
134
135impl SiftFeature {
136    /// Affine keypoint.
137    pub fn keypoint(&self) -> Keypoint {
138        Keypoint::from_scale_orientation(self.x, self.y, self.scale, self.orientation)
139    }
140}
141
142/// Extraction result. `features[i]` <-> `descriptors.row(i)`.
143#[derive(Clone, Debug, Default)]
144pub struct SiftOutput {
145    /// Feature list.
146    pub features: Vec<SiftFeature>,
147    /// 128-dimensional uint8 descriptors (row i <-> feature i).
148    pub descriptors: Descriptors,
149}
150
151impl SiftOutput {
152    /// Converts the features to affine keypoints.
153    pub fn keypoints(&self) -> Vec<Keypoint> {
154        self.features.iter().map(|f| f.keypoint()).collect()
155    }
156    /// Number of features.
157    pub fn len(&self) -> usize {
158        self.features.len()
159    }
160    /// True if there are no features.
161    pub fn is_empty(&self) -> bool {
162        self.features.is_empty()
163    }
164}
165
166/// SIFT compute backend. A trait so that other implementations such as CUDA can be plugged in.
167pub trait SiftEngine: Send + Sync {
168    /// Backend name (for logging).
169    fn name(&self) -> &str;
170    /// Extracts features from a grayscale image. Coordinates are in input-image pixel units.
171    fn extract(&self, image: &GrayImage, options: &SiftOptions) -> Result<SiftOutput>;
172}
173
174/// CPU (rayon) backend. Reuses pyramid buffers across images.
175#[derive(Default)]
176pub struct CpuSift {
177    pool: BufferPool,
178}
179
180impl CpuSift {
181    /// Creates a CPU backend with an empty buffer pool.
182    pub fn new() -> Self {
183        Self::default()
184    }
185}
186
187/// Result of one level (including orientations).
188struct LevelFeats {
189    oi: usize,
190    j: usize,
191    items: Vec<(Candidate, u16)>,
192}
193
194impl SiftEngine for CpuSift {
195    fn name(&self) -> &str {
196        "cpu"
197    }
198
199    fn extract(&self, image: &GrayImage, opts: &SiftOptions) -> Result<SiftOutput> {
200        if opts.octave_resolution == 0 {
201            return Err(Error::InvalidArgument("octave_resolution 은 1 이상".into()));
202        }
203        if opts.first_octave != -1 && opts.first_octave != 0 {
204            return Err(Error::Unsupported(format!("first_octave {} (−1, 0 만 지원)", opts.first_octave)));
205        }
206        let w = if opts.truncate_width_to_4 { image.width - image.width % 4 } else { image.width };
207        let h = image.height;
208        if w < 8 || h < 8 {
209            return Ok(SiftOutput::default());
210        }
211        // Input normalization /255 (+ width truncation).
212        let mut base = self.pool.take(w * h);
213        base.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
214            let src = &image.data[y * image.width..y * image.width + w];
215            for (o, &v) in row.iter_mut().zip(src) {
216                *o = v as f32 / 255.0;
217            }
218        });
219        let o_min = opts.first_octave;
220        let (w0, h0, base) = if o_min == -1 {
221            let mut up = self.pool.take(4 * w * h);
222            pyramid::upsample2(&base, w, h, &mut up);
223            self.pool.give(base);
224            (2 * w, 2 * h, up)
225        } else {
226            (w, h, base)
227        };
228        let ss = ScaleSpace::new(opts.octave_resolution);
229        let n_oct = opts.num_octaves.unwrap_or_else(|| pyramid::auto_num_octaves(w0, h0)).max(1);
230        let pyr = pyramid::build_pyramid(base, w0, h0, o_min, n_oct, ss, 0.5, &self.pool);
231
232        let s = ss.s;
233        let k_max = if opts.max_num_features == 0 { usize::MAX } else { opts.max_num_features };
234        let dp = DetectParams {
235            peak_threshold: opts.peak_threshold,
236            edge_threshold: opts.edge_threshold,
237            refinement_iterations: opts.refinement_iterations,
238            reject_singular: opts.reject_singular_refinement,
239            sigma0: ss.sigma0,
240            k: ss.k,
241        };
242        let op = OrientParams { max_num_orientations: opts.max_num_orientations, bin_interpolation: opts.orientation_bin_interpolation };
243
244        // From coarse octaves and high levels first. Under the level-wise rule, levels that would be cut
245        // skip detection entirely (same result).
246        let mut levels: Vec<LevelFeats> = Vec::new();
247        let (mut cum_det, mut cum_or) = (0usize, 0usize);
248        'outer: for oi in (0..pyr.len()).rev() {
249            let oct = &pyr[oi];
250            let (ow, oh) = (oct.w, oct.h);
251            let mut dogs: Vec<Vec<f32>> = Vec::new();
252            for j in (0..s).rev() {
253                let stop = match opts.selection {
254                    FeatureSelection::CompatLevels => cum_det > k_max,
255                    FeatureSelection::TopK => cum_or >= k_max,
256                    FeatureSelection::SpatialGrid { .. } => false,
257                };
258                if stop {
259                    for d in dogs {
260                        self.pool.give(d);
261                    }
262                    break 'outer;
263                }
264                if dogs.is_empty() {
265                    for d in 0..s + 2 {
266                        let mut buf = self.pool.take(ow * oh);
267                        detect::dog(&oct.gauss[d + 1], &oct.gauss[d], &mut buf);
268                        dogs.push(buf);
269                    }
270                }
271                let cands = detect::detect_level(&dogs[j], &dogs[j + 1], &dogs[j + 2], ow, oh, j, &dp);
272                cum_det += cands.len();
273                let items: Vec<(Candidate, u16)> = if opts.upright {
274                    cands.into_iter().map(|c| (c, 0u16)).collect()
275                } else {
276                    // The orientation window is small, so on-the-fly gradients are advantageous.
277                    let grad = Gradient::lazy(oct.level(j as i32), ow, oh);
278                    cands
279                        .par_iter()
280                        .flat_map_iter(|c| orient::orientations(&grad, c.x, c.y, c.sigma, &op).into_iter().map(move |q| (*c, q)))
281                        .collect()
282                };
283                cum_or += items.len();
284                levels.push(LevelFeats { oi, j, items });
285            }
286            for d in dogs {
287                self.pool.give(d);
288            }
289        }
290
291        select(&mut levels, opts.selection, k_max, &pyr);
292
293        // Output order: octave ascending, level ascending, detection order within a level.
294        levels.sort_by_key(|l| (l.oi, l.j));
295        let total: usize = levels.iter().map(|l| l.items.len()).sum();
296        let mut features = Vec::with_capacity(total);
297        let mut desc = Descriptors::with_capacity(total);
298        for lv in &levels {
299            if lv.items.is_empty() {
300                continue;
301            }
302            let oct = &pyr[lv.oi];
303            let g = oct.level(lv.j as i32);
304            let sig = ss.sigma(lv.j as f32);
305            let work = lv.items.len() as f64 * 225.0 * (sig as f64).powi(2);
306            let grad = if work > (oct.w * oct.h) as f64 { Gradient::precomputed(g, oct.w, oct.h) } else { Gradient::lazy(g, oct.w, oct.h) };
307            let ds: Vec<[u8; 128]> = lv
308                .items
309                .par_iter()
310                .map(|(c, q)| orient::descriptor(&grad, c.x, c.y, c.sigma, orient::dequantize_angle(*q), opts.normalization))
311                .collect();
312            let scale_o = 2f32.powi(oct.o);
313            for ((c, q), d) in lv.items.iter().zip(&ds) {
314                let th = orient::dequantize_angle(*q);
315                let ori = (std::f32::consts::TAU - th).rem_euclid(std::f32::consts::TAU);
316                features.push(SiftFeature {
317                    x: scale_o * (c.x - 0.5) + 0.5,
318                    y: scale_o * (c.y - 0.5) + 0.5,
319                    scale: scale_o * c.sigma,
320                    orientation: ori,
321                    octave: oct.o,
322                    level: lv.j as u32,
323                    response: c.response,
324                });
325                desc.push(d);
326            }
327        }
328        for oct in pyr {
329            for g in oct.gauss {
330                self.pool.give(g);
331            }
332        }
333        Ok(SiftOutput { features, descriptors: desc })
334    }
335}
336
337/// Limits the maximum feature count. `levels` is in coarse -> fine order.
338fn select(levels: &mut [LevelFeats], mode: FeatureSelection, k: usize, pyr: &[pyramid::Octave]) {
339    let total: usize = levels.iter().map(|l| l.items.len()).sum();
340    if total <= k {
341        return;
342    }
343    match mode {
344        FeatureSelection::CompatLevels => {
345            // From the finest level, remove whole levels while "total - that level > K".
346            let mut total = total;
347            for lv in levels.iter_mut().rev() {
348                if total - lv.items.len() > k {
349                    total -= lv.items.len();
350                    lv.items.clear();
351                } else {
352                    break;
353                }
354            }
355        }
356        FeatureSelection::TopK => {
357            let mut remaining = k;
358            for lv in levels.iter_mut() {
359                if lv.items.len() <= remaining {
360                    remaining -= lv.items.len();
361                    continue;
362                }
363                let mut idx: Vec<usize> = (0..lv.items.len()).collect();
364                idx.sort_by(|&a, &b| lv.items[b].0.response.abs().total_cmp(&lv.items[a].0.response.abs()).then(a.cmp(&b)));
365                let mut keep = vec![false; lv.items.len()];
366                for &i in idx.iter().take(remaining) {
367                    keep[i] = true;
368                }
369                let mut it = keep.iter();
370                lv.items.retain(|_| *it.next().unwrap_or(&false));
371                remaining = 0;
372            }
373        }
374        FeatureSelection::SpatialGrid { cells } => {
375            let cells = cells.max(1) as usize;
376            // Input image size ~ first octave size x 2^o_min.
377            let o0 = &pyr[0];
378            let sc = 2f32.powi(o0.o);
379            let (iw, ih) = (o0.w as f32 * sc, o0.h as f32 * sc);
380            let mut buckets: Vec<Vec<(f32, usize, usize)>> = vec![Vec::new(); cells * cells];
381            for (li, lv) in levels.iter().enumerate() {
382                let so = 2f32.powi(pyr[lv.oi].o);
383                for (ii, (c, _)) in lv.items.iter().enumerate() {
384                    let x = so * (c.x - 0.5) + 0.5;
385                    let y = so * (c.y - 0.5) + 0.5;
386                    let cx = ((x / iw * cells as f32) as usize).min(cells - 1);
387                    let cy = ((y / ih * cells as f32) as usize).min(cells - 1);
388                    buckets[cy * cells + cx].push((c.response.abs(), li, ii));
389                }
390            }
391            for b in &mut buckets {
392                b.sort_by(|a, b| b.0.total_cmp(&a.0).then((a.1, a.2).cmp(&(b.1, b.2))));
393            }
394            let mut keep: Vec<Vec<bool>> = levels.iter().map(|l| vec![false; l.items.len()]).collect();
395            let mut taken = 0;
396            let mut round = 0;
397            while taken < k {
398                let mut any = false;
399                for b in &buckets {
400                    if let Some(&(_, li, ii)) = b.get(round) {
401                        any = true;
402                        if taken < k {
403                            keep[li][ii] = true;
404                            taken += 1;
405                        }
406                    }
407                }
408                if !any {
409                    break;
410                }
411                round += 1;
412            }
413            for (lv, kp) in levels.iter_mut().zip(keep) {
414                let mut it = kp.into_iter();
415                lv.items.retain(|_| it.next().unwrap_or(false));
416            }
417        }
418    }
419}