blog updates. more demo assets, SURF-attempts and more
This commit is contained in:
+644
@@ -0,0 +1,644 @@
|
||||
//! Salient-point detector and descriptor, built for hashing experiments.
|
||||
//!
|
||||
//! The detector is SIFT-like: extrema in a Difference-of-Gaussians scale
|
||||
//! space, filtered for contrast and edge responses. The descriptor is
|
||||
//! SURF-like: a 4x4 grid of subregions around the keypoint, each summarized
|
||||
//! by four gradient sums, 64 floats total. Every descriptor is measured in
|
||||
//! the keypoint's own scale and orientation frame, which is where the scale
|
||||
//! and rotation invariance comes from.
|
||||
//!
|
||||
//! Descriptors are canonicalized under horizontal mirroring: a flipped patch
|
||||
//! produces the same 64 floats, so hashes built from them inherit flip
|
||||
//! invariance the way the DCT hash does. See `mirror_descriptor` for the
|
||||
//! exact symmetry.
|
||||
//!
|
||||
//! Not wired into the Descriptor trait or the stores. The salientlab binary
|
||||
//! runs the experiments.
|
||||
|
||||
use image::DynamicImage;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
const ORI_BINS: usize = 36;
|
||||
|
||||
/// Descriptor sampling grid: DESC_SAMPLES x DESC_SAMPLES points spanning
|
||||
/// +-DESC_HALF_EXTENT keypoint sigmas, grouped into DESC_GRID x DESC_GRID
|
||||
/// subregions. These define the exact window the descriptor reads, so the
|
||||
/// browser demo draws the same box by calling Keypoint::window_corners.
|
||||
const DESC_SAMPLES: usize = 20;
|
||||
const DESC_HALF_EXTENT: f32 = 9.5;
|
||||
pub const DESC_GRID: usize = 4;
|
||||
|
||||
/// Grayscale f32 image, values in [0, 1]
|
||||
pub struct GrayF32 {
|
||||
w: usize,
|
||||
h: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl GrayF32 {
|
||||
pub fn from_image(img: &DynamicImage) -> GrayF32 {
|
||||
let luma = img.to_luma32f();
|
||||
GrayF32 {
|
||||
w: luma.width() as usize,
|
||||
h: luma.height() as usize,
|
||||
data: luma.into_raw(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, x: isize, y: isize) -> f32 {
|
||||
let x = x.clamp(0, self.w as isize - 1) as usize;
|
||||
let y = y.clamp(0, self.h as isize - 1) as usize;
|
||||
self.data[y * self.w + x]
|
||||
}
|
||||
|
||||
fn bilinear(&self, x: f32, y: f32) -> f32 {
|
||||
let x0 = x.floor();
|
||||
let y0 = y.floor();
|
||||
let fx = x - x0;
|
||||
let fy = y - y0;
|
||||
let (xi, yi) = (x0 as isize, y0 as isize);
|
||||
let v00 = self.get(xi, yi);
|
||||
let v10 = self.get(xi + 1, yi);
|
||||
let v01 = self.get(xi, yi + 1);
|
||||
let v11 = self.get(xi + 1, yi + 1);
|
||||
v00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ v10 * fx * (1.0 - fy)
|
||||
+ v01 * (1.0 - fx) * fy
|
||||
+ v11 * fx * fy
|
||||
}
|
||||
|
||||
/// Image gradient at a fractional position, central differences
|
||||
fn gradient(&self, x: f32, y: f32) -> (f32, f32) {
|
||||
(
|
||||
(self.bilinear(x + 1.0, y) - self.bilinear(x - 1.0, y)) * 0.5,
|
||||
(self.bilinear(x, y + 1.0) - self.bilinear(x, y - 1.0)) * 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
/// Separable gaussian blur, borders clamped
|
||||
fn gauss_blur(&self, sigma: f32) -> GrayF32 {
|
||||
let radius = (sigma * 3.0).ceil().max(1.0) as isize;
|
||||
let mut kernel = Vec::with_capacity(2 * radius as usize + 1);
|
||||
for i in -radius..=radius {
|
||||
kernel.push((-(i * i) as f32 / (2.0 * sigma * sigma)).exp());
|
||||
}
|
||||
let sum: f32 = kernel.iter().sum();
|
||||
for k in kernel.iter_mut() {
|
||||
*k /= sum;
|
||||
}
|
||||
|
||||
let mut tmp = vec![0.0f32; self.w * self.h];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let mut acc = 0.0;
|
||||
for (ki, k) in kernel.iter().enumerate() {
|
||||
acc += k * self.get(x as isize + ki as isize - radius, y as isize);
|
||||
}
|
||||
tmp[y * self.w + x] = acc;
|
||||
}
|
||||
}
|
||||
let tmp = GrayF32 { w: self.w, h: self.h, data: tmp };
|
||||
let mut out = vec![0.0f32; self.w * self.h];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let mut acc = 0.0;
|
||||
for (ki, k) in kernel.iter().enumerate() {
|
||||
acc += k * tmp.get(x as isize, y as isize + ki as isize - radius);
|
||||
}
|
||||
out[y * self.w + x] = acc;
|
||||
}
|
||||
}
|
||||
GrayF32 { w: self.w, h: self.h, data: out }
|
||||
}
|
||||
|
||||
/// Every other pixel, for the next octave
|
||||
fn half(&self) -> GrayF32 {
|
||||
let w = (self.w / 2).max(1);
|
||||
let h = (self.h / 2).max(1);
|
||||
let mut data = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
data.push(self.data[(y * 2) * self.w + x * 2]);
|
||||
}
|
||||
}
|
||||
GrayF32 { w, h, data }
|
||||
}
|
||||
|
||||
fn sub(a: &GrayF32, b: &GrayF32) -> GrayF32 {
|
||||
let data = a.data.iter().zip(&b.data).map(|(a, b)| a - b).collect();
|
||||
GrayF32 { w: a.w, h: a.h, data }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Keypoint {
|
||||
/// Position in original image coordinates
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Scale in original image coordinates
|
||||
pub sigma: f32,
|
||||
/// Dominant gradient orientation, radians
|
||||
pub angle: f32,
|
||||
/// Absolute DoG value at the extremum
|
||||
pub response: f32,
|
||||
}
|
||||
|
||||
impl Keypoint {
|
||||
/// Map a point from the keypoint's oriented frame (u along the
|
||||
/// orientation, v perpendicular) into image coordinates. Same rotation
|
||||
/// the descriptor uses to place its samples.
|
||||
fn to_image(&self, u: f32, v: f32) -> (f32, f32) {
|
||||
let (sin, cos) = self.angle.sin_cos();
|
||||
(self.x + u * cos - v * sin, self.y + u * sin + v * cos)
|
||||
}
|
||||
|
||||
/// The four corners of the oriented descriptor window in image
|
||||
/// coordinates, from the (-u,-v) corner counter-clockwise. For drawing
|
||||
/// the region the descriptor summarizes.
|
||||
pub fn window_corners(&self) -> [(f32, f32); 4] {
|
||||
let e = DESC_HALF_EXTENT * self.sigma;
|
||||
[(-e, -e), (e, -e), (e, e), (-e, e)].map(|(u, v)| self.to_image(u, v))
|
||||
}
|
||||
|
||||
/// Centres of the DESC_GRID x DESC_GRID subregions in image coordinates,
|
||||
/// in descriptor order (row = perpendicular to orientation, column =
|
||||
/// along it), so a caller can line them up with the 64-float descriptor.
|
||||
pub fn subregion_centers(&self) -> [(f32, f32); DESC_GRID * DESC_GRID] {
|
||||
let cell = DESC_SAMPLES / DESC_GRID;
|
||||
let mut out = [(0.0, 0.0); DESC_GRID * DESC_GRID];
|
||||
for j in 0..DESC_GRID {
|
||||
for i in 0..DESC_GRID {
|
||||
let su = (i * cell + cell / 2) as f32 - DESC_HALF_EXTENT;
|
||||
let sv = (j * cell + cell / 2) as f32 - DESC_HALF_EXTENT;
|
||||
out[j * DESC_GRID + i] = self.to_image(su * self.sigma, sv * self.sigma);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Feature {
|
||||
pub kp: Keypoint,
|
||||
pub desc: [f32; 64],
|
||||
}
|
||||
|
||||
pub struct Params {
|
||||
/// DoG scale samples per octave
|
||||
pub intervals: usize,
|
||||
/// Blur of the first level of each octave
|
||||
pub base_sigma: f32,
|
||||
/// Minimum absolute DoG value for a keypoint (image values are 0..1)
|
||||
pub contrast_threshold: f32,
|
||||
/// Maximum principal curvature ratio, rejects points on straight edges
|
||||
pub edge_ratio: f32,
|
||||
/// Keep only the strongest keypoints
|
||||
pub max_features: usize,
|
||||
/// Canonicalize descriptors under horizontal mirroring
|
||||
pub flip_canonical: bool,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Params {
|
||||
intervals: 3,
|
||||
base_sigma: 1.6,
|
||||
contrast_threshold: 0.01,
|
||||
edge_ratio: 10.0,
|
||||
max_features: 100,
|
||||
flip_canonical: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RawKp {
|
||||
octave: usize,
|
||||
level: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
bx: f32,
|
||||
by: f32,
|
||||
bsigma: f32,
|
||||
response: f32,
|
||||
}
|
||||
|
||||
pub struct Salient {
|
||||
pub params: Params,
|
||||
}
|
||||
|
||||
impl Default for Salient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Salient {
|
||||
pub fn new() -> Salient {
|
||||
Salient { params: Params::default() }
|
||||
}
|
||||
|
||||
fn k(&self) -> f32 {
|
||||
2f32.powf(1.0 / self.params.intervals as f32)
|
||||
}
|
||||
|
||||
/// Gaussian pyramid: per octave `intervals + 3` blur levels, so the DoG
|
||||
/// stack has `intervals` usable middle layers
|
||||
fn pyramid(&self, img: &DynamicImage) -> Vec<Vec<GrayF32>> {
|
||||
let s = self.params.intervals;
|
||||
let k = self.k();
|
||||
let sigma0 = self.params.base_sigma;
|
||||
// assume the input carries sigma 0.5, lift it to base_sigma
|
||||
let gray = GrayF32::from_image(img);
|
||||
let mut base = gray.gauss_blur((sigma0 * sigma0 - 0.25).max(0.01).sqrt());
|
||||
|
||||
let mut octaves = Vec::new();
|
||||
while base.w.min(base.h) >= 24 && octaves.len() < 6 {
|
||||
let mut levels = vec![base];
|
||||
for i in 1..s + 3 {
|
||||
let sig_prev = sigma0 * k.powi(i as i32 - 1);
|
||||
let sig_total = sigma0 * k.powi(i as i32);
|
||||
let sig_diff = (sig_total * sig_total - sig_prev * sig_prev).sqrt();
|
||||
levels.push(levels[i - 1].gauss_blur(sig_diff));
|
||||
}
|
||||
// levels[s] carries 2 * base_sigma: halved it seeds the next octave
|
||||
base = levels[s].half();
|
||||
octaves.push(levels);
|
||||
}
|
||||
octaves
|
||||
}
|
||||
|
||||
fn detect(&self, pyramid: &[Vec<GrayF32>]) -> Vec<RawKp> {
|
||||
let k = self.k();
|
||||
let mut raw = Vec::new();
|
||||
for (o, levels) in pyramid.iter().enumerate() {
|
||||
let dogs: Vec<GrayF32> = levels
|
||||
.windows(2)
|
||||
.map(|w| GrayF32::sub(&w[1], &w[0]))
|
||||
.collect();
|
||||
let scale_up = (1usize << o) as f32;
|
||||
for l in 1..dogs.len() - 1 {
|
||||
let d = &dogs[l];
|
||||
for y in 1..d.h - 1 {
|
||||
for x in 1..d.w - 1 {
|
||||
let v = d.data[y * d.w + x];
|
||||
if v.abs() < self.params.contrast_threshold {
|
||||
continue;
|
||||
}
|
||||
if !is_extremum(&dogs, l, x, y, v) {
|
||||
continue;
|
||||
}
|
||||
if is_edge(d, x, y, self.params.edge_ratio) {
|
||||
continue;
|
||||
}
|
||||
let osigma = self.params.base_sigma * k.powi(l as i32);
|
||||
raw.push(RawKp {
|
||||
octave: o,
|
||||
level: l,
|
||||
x,
|
||||
y,
|
||||
bx: x as f32 * scale_up,
|
||||
by: y as f32 * scale_up,
|
||||
bsigma: osigma * scale_up,
|
||||
response: v.abs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// Detect, orient and describe the strongest keypoints of an image
|
||||
pub fn features(&self, img: &DynamicImage) -> Vec<Feature> {
|
||||
let pyramid = self.pyramid(img);
|
||||
let mut raw = self.detect(&pyramid);
|
||||
raw.sort_by(|a, b| b.response.total_cmp(&a.response));
|
||||
|
||||
// greedy dedupe: extrema often fire in adjacent scale layers
|
||||
let mut picked: Vec<RawKp> = Vec::new();
|
||||
for kp in raw {
|
||||
if picked.len() >= self.params.max_features {
|
||||
break;
|
||||
}
|
||||
let dup = picked.iter().any(|p| {
|
||||
let (dx, dy) = (p.bx - kp.bx, p.by - kp.by);
|
||||
let ratio = (p.bsigma / kp.bsigma).max(kp.bsigma / p.bsigma);
|
||||
dx * dx + dy * dy < 4.0 && ratio < 1.6
|
||||
});
|
||||
if !dup {
|
||||
picked.push(kp);
|
||||
}
|
||||
}
|
||||
|
||||
picked
|
||||
.iter()
|
||||
.map(|kp| {
|
||||
let img = &pyramid[kp.octave][kp.level];
|
||||
let osigma = self.params.base_sigma * self.k().powi(kp.level as i32);
|
||||
let angle = orientation(img, kp.x, kp.y, osigma);
|
||||
let mut desc = describe(img, kp.x as f32, kp.y as f32, osigma, angle);
|
||||
if self.params.flip_canonical {
|
||||
desc = canonicalize(desc);
|
||||
}
|
||||
Feature {
|
||||
kp: Keypoint {
|
||||
x: kp.bx,
|
||||
y: kp.by,
|
||||
sigma: kp.bsigma,
|
||||
angle,
|
||||
response: kp.response,
|
||||
},
|
||||
desc,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict local extremum over the 26 neighbors in space and scale
|
||||
fn is_extremum(dogs: &[GrayF32], l: usize, x: usize, y: usize, v: f32) -> bool {
|
||||
for layer in &dogs[l - 1..=l + 1] {
|
||||
for dy in -1isize..=1 {
|
||||
for dx in -1isize..=1 {
|
||||
let n = layer.get(x as isize + dx, y as isize + dy);
|
||||
if std::ptr::eq(layer, &dogs[l]) && dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
if (v > 0.0 && n >= v) || (v < 0.0 && n <= v) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Principal curvature ratio test on the 2x2 spatial Hessian of the DoG,
|
||||
/// rejects points that sit on an edge and slide along it
|
||||
fn is_edge(d: &GrayF32, x: usize, y: usize, r: f32) -> bool {
|
||||
let (x, y) = (x as isize, y as isize);
|
||||
let v = d.get(x, y);
|
||||
let dxx = d.get(x + 1, y) - 2.0 * v + d.get(x - 1, y);
|
||||
let dyy = d.get(x, y + 1) - 2.0 * v + d.get(x, y - 1);
|
||||
let dxy = (d.get(x + 1, y + 1) - d.get(x + 1, y - 1) - d.get(x - 1, y + 1)
|
||||
+ d.get(x - 1, y - 1))
|
||||
/ 4.0;
|
||||
let tr = dxx + dyy;
|
||||
let det = dxx * dyy - dxy * dxy;
|
||||
det <= 0.0 || tr * tr * r >= (r + 1.0) * (r + 1.0) * det
|
||||
}
|
||||
|
||||
/// Dominant gradient orientation: 36-bin histogram of gradient angles in a
|
||||
/// gaussian-weighted neighborhood, smoothed, peak refined by a parabola
|
||||
fn orientation(img: &GrayF32, x: usize, y: usize, sigma: f32) -> f32 {
|
||||
let sig_w = 1.5 * sigma;
|
||||
let radius = (3.0 * sig_w).round() as isize;
|
||||
let mut hist = [0f32; ORI_BINS];
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let (px, py) = (x as isize + dx, y as isize + dy);
|
||||
let gx = (img.get(px + 1, py) - img.get(px - 1, py)) * 0.5;
|
||||
let gy = (img.get(px, py + 1) - img.get(px, py - 1)) * 0.5;
|
||||
let mag = (gx * gx + gy * gy).sqrt();
|
||||
if mag == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut phi = gy.atan2(gx);
|
||||
if phi < 0.0 {
|
||||
phi += 2.0 * PI;
|
||||
}
|
||||
let w = (-((dx * dx + dy * dy) as f32) / (2.0 * sig_w * sig_w)).exp();
|
||||
let bin = ((phi / (2.0 * PI) * ORI_BINS as f32) as usize).min(ORI_BINS - 1);
|
||||
hist[bin] += w * mag;
|
||||
}
|
||||
}
|
||||
// two passes of circular [1 2 1] smoothing
|
||||
for _ in 0..2 {
|
||||
let orig = hist;
|
||||
for b in 0..ORI_BINS {
|
||||
let l = orig[(b + ORI_BINS - 1) % ORI_BINS];
|
||||
let r = orig[(b + 1) % ORI_BINS];
|
||||
hist[b] = (l + 2.0 * orig[b] + r) / 4.0;
|
||||
}
|
||||
}
|
||||
let peak = (0..ORI_BINS)
|
||||
.max_by(|a, b| hist[*a].total_cmp(&hist[*b]))
|
||||
.unwrap();
|
||||
let l = hist[(peak + ORI_BINS - 1) % ORI_BINS];
|
||||
let c = hist[peak];
|
||||
let r = hist[(peak + 1) % ORI_BINS];
|
||||
let denom = l - 2.0 * c + r;
|
||||
let delta = if denom.abs() > 1e-12 { 0.5 * (l - r) / denom } else { 0.0 };
|
||||
(peak as f32 + 0.5 + delta) * 2.0 * PI / ORI_BINS as f32
|
||||
}
|
||||
|
||||
/// SURF-style descriptor: 20x20 samples spaced `sigma` apart, rotated to the
|
||||
/// keypoint orientation, grouped into 4x4 subregions. Each subregion gets
|
||||
/// [sum du, sum dv, sum |du|, sum |dv|] where (du, dv) is the gradient
|
||||
/// rotated into the keypoint frame. Unit normalized.
|
||||
///
|
||||
/// Layout: index = (j * 4 + i) * 4 + c, with i along the orientation axis,
|
||||
/// j perpendicular to it, c the component.
|
||||
fn describe(img: &GrayF32, x: f32, y: f32, sigma: f32, angle: f32) -> [f32; 64] {
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let sig_w = 3.3 * sigma;
|
||||
let cell = DESC_SAMPLES / DESC_GRID;
|
||||
let mut desc = [0f32; 64];
|
||||
for sj in 0..DESC_SAMPLES {
|
||||
for si in 0..DESC_SAMPLES {
|
||||
let u = (si as f32 - DESC_HALF_EXTENT) * sigma;
|
||||
let v = (sj as f32 - DESC_HALF_EXTENT) * sigma;
|
||||
let px = x + u * cos - v * sin;
|
||||
let py = y + u * sin + v * cos;
|
||||
let (gx, gy) = img.gradient(px, py);
|
||||
let du = cos * gx + sin * gy;
|
||||
let dv = -sin * gx + cos * gy;
|
||||
let w = (-(u * u + v * v) / (2.0 * sig_w * sig_w)).exp();
|
||||
let base = ((sj / cell) * DESC_GRID + si / cell) * 4;
|
||||
desc[base] += w * du;
|
||||
desc[base + 1] += w * dv;
|
||||
desc[base + 2] += w * du.abs();
|
||||
desc[base + 3] += w * dv.abs();
|
||||
}
|
||||
}
|
||||
let norm: f32 = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for v in desc.iter_mut() {
|
||||
*v /= norm;
|
||||
}
|
||||
}
|
||||
desc
|
||||
}
|
||||
|
||||
/// The descriptor a horizontally mirrored copy of the patch would produce.
|
||||
///
|
||||
/// Under a mirror the dominant orientation maps to pi - theta. Working out
|
||||
/// the sample grid in that frame: the u axis is preserved, the v axis
|
||||
/// negates. So subregion (i, j) swaps with (i, 3 - j), the dv sum changes
|
||||
/// sign, and the other three components are untouched.
|
||||
pub fn mirror_descriptor(d: &[f32; 64]) -> [f32; 64] {
|
||||
let mut out = [0f32; 64];
|
||||
for j in 0..4 {
|
||||
for i in 0..4 {
|
||||
let src = (j * 4 + i) * 4;
|
||||
let dst = ((3 - j) * 4 + i) * 4;
|
||||
out[dst] = d[src];
|
||||
out[dst + 1] = -d[src + 1];
|
||||
out[dst + 2] = d[src + 2];
|
||||
out[dst + 3] = d[src + 3];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pick the mirror representative deterministically, so a patch and its
|
||||
/// mirror image canonicalize to the same descriptor
|
||||
fn canonicalize(d: [f32; 64]) -> [f32; 64] {
|
||||
let dv_total: f32 = (0..16).map(|s| d[s * 4 + 1]).sum();
|
||||
let mirror = if dv_total.abs() > 1e-6 {
|
||||
dv_total < 0.0
|
||||
} else {
|
||||
// fallback: skew of the |dv| mass along the v axis
|
||||
let skew: f32 = (0..16).map(|s| ((s / 4) as f32 - 1.5) * d[s * 4 + 3]).sum();
|
||||
skew < 0.0
|
||||
};
|
||||
if mirror {
|
||||
mirror_descriptor(&d)
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// 64 bits from one descriptor: the 32 signed sums contribute their sign,
|
||||
/// the 32 magnitude sums contribute a comparison against their own median.
|
||||
/// Same recipe as the DCT hash: signs plus ordinal facts, nothing absolute.
|
||||
pub fn binarize64(desc: &[f32; 64]) -> u64 {
|
||||
let mut mags: Vec<f32> = (0..16)
|
||||
.flat_map(|s| [desc[s * 4 + 2], desc[s * 4 + 3]])
|
||||
.collect();
|
||||
mags.sort_by(f32::total_cmp);
|
||||
let median = mags[16];
|
||||
let mut mask = 0u64;
|
||||
for (i, &v) in desc.iter().enumerate() {
|
||||
mask <<= 1;
|
||||
let bit = if i % 4 < 2 { v > 0.0 } else { v > median };
|
||||
if bit {
|
||||
mask |= 1;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Response-weighted mean of all descriptors, unit normalized.
|
||||
/// Symmetric pooling, so it inherits every per-keypoint invariance.
|
||||
pub fn pooled_vector(feats: &[Feature]) -> [f32; 64] {
|
||||
let mut acc = [0f32; 64];
|
||||
for f in feats {
|
||||
for (a, d) in acc.iter_mut().zip(&f.desc) {
|
||||
*a += f.kp.response * d;
|
||||
}
|
||||
}
|
||||
let norm: f32 = acc.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for v in acc.iter_mut() {
|
||||
*v /= norm;
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Variant 1: binarized pooled descriptor
|
||||
pub fn hash_pooled(feats: &[Feature]) -> u64 {
|
||||
binarize64(&pooled_vector(feats))
|
||||
}
|
||||
|
||||
/// Variant 2: binarized descriptor of the single strongest keypoint
|
||||
pub fn hash_strongest(feats: &[Feature]) -> u64 {
|
||||
match feats.first() {
|
||||
Some(f) => binarize64(&f.desc),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant 3: per-bit majority vote over all binarized descriptors
|
||||
pub fn hash_consensus(feats: &[Feature]) -> u64 {
|
||||
if feats.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let hashes: Vec<u64> = feats.iter().map(|f| binarize64(&f.desc)).collect();
|
||||
let mut mask = 0u64;
|
||||
for bit in (0..64).rev() {
|
||||
let votes = hashes.iter().filter(|h| (*h >> bit) & 1 == 1).count();
|
||||
mask <<= 1;
|
||||
if votes * 2 > hashes.len() {
|
||||
mask |= 1;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptors::testimg;
|
||||
|
||||
fn dist(a: u64, b: u64) -> u32 {
|
||||
(a ^ b).count_ones()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_is_an_involution() {
|
||||
let mut d = [0f32; 64];
|
||||
for (i, v) in d.iter_mut().enumerate() {
|
||||
*v = (i as f32 * 0.37).sin();
|
||||
}
|
||||
assert_eq!(mirror_descriptor(&mirror_descriptor(&d)), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_ignores_mirroring() {
|
||||
let mut d = [0f32; 64];
|
||||
for (i, v) in d.iter_mut().enumerate() {
|
||||
*v = (i as f32 * 0.37).sin();
|
||||
}
|
||||
assert_eq!(canonicalize(d), canonicalize(mirror_descriptor(&d)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binarize_packs_all_bits() {
|
||||
// strongest positive signal on the first and last positions
|
||||
let mut d = [0f32; 64];
|
||||
d[0] = 1.0; // first sign bit, lands on bit 63
|
||||
d[63] = 1.0; // a magnitude above the (zero) median, lands on bit 0
|
||||
let h = binarize64(&d);
|
||||
assert_eq!(h, (1 << 63) | 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_deterministic() {
|
||||
// synthetic gradients are too smooth to produce DoG extrema
|
||||
let img = image::open("img/meowl.jpg").unwrap();
|
||||
let s = Salient::new();
|
||||
let a = s.features(&img);
|
||||
let b = s.features(&img);
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(!a.is_empty());
|
||||
for (fa, fb) in a.iter().zip(&b) {
|
||||
assert_eq!(fa.desc, fb.desc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_invariant_on_real_photo() {
|
||||
let img = image::open("img/meowl.jpg").unwrap();
|
||||
let s = Salient::new();
|
||||
let a = s.features(&img);
|
||||
let b = s.features(&img.fliph());
|
||||
assert!(!a.is_empty());
|
||||
// detection mirrors exactly, orientation binning wobbles a little
|
||||
let d = dist(hash_pooled(&a), hash_pooled(&b));
|
||||
assert!(d <= 6, "pooled flip distance {d}");
|
||||
let d = dist(hash_consensus(&a), hash_consensus(&b));
|
||||
assert!(d <= 6, "consensus flip distance {d}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user