Skip to content

Commit

Permalink
add pHash (#709)
Browse files Browse the repository at this point in the history
* add phash 
* update msrv
  • Loading branch information
cospectrum authored Jan 19, 2025
1 parent ab9e19c commit 33a88f8
Show file tree
Hide file tree
Showing 9 changed files with 601 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
# https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
strategy:
matrix:
msrv: ["1.79.0"] # Don't forget to update the `rust-version` in Cargo.toml as well
msrv: ["1.80.1"] # Don't forget to update the `rust-version` in Cargo.toml as well
name: ubuntu / ${{ matrix.msrv }}
steps:
- uses: actions/checkout@v4
Expand Down
14 changes: 9 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "imageproc"
version = "0.26.0"
authors = ["theotherphil"]
# note: when changed, also update `msrv` in `.github/workflows/check.yml`
rust-version = "1.79.0"
rust-version = "1.80.1"
edition = "2021"
license = "MIT"
description = "Image processing operations"
Expand All @@ -22,20 +22,21 @@ ab_glyph = { version = "0.2.23", default-features = false, features = ["std"] }
approx = { version = "0.5", default-features = false }
image = { version = "0.25.0", default-features = false }
itertools = { version = "0.13.0", default-features = false, features = [
"use_std",
"use_std",
] }
nalgebra = { version = "0.32", default-features = false, features = ["std"] }
num = { version = "0.4.1", default-features = false }
rand = { version = "0.8.5", default-features = false, features = [
"std",
"std_rng",
"std",
"std_rng",
] }
rand_distr = { version = "0.4.3", default-features = false }
rayon = { version = "1.8.0", optional = true, default-features = false }
sdl2 = { version = "0.36", optional = true, default-features = false, features = [
"bundled",
"bundled",
] }
katexit = { version = "0.1.4", optional = true, default-features = false }
rustdct = "0.7.1"

[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", default-features = false, features = ["js"] }
Expand All @@ -54,6 +55,9 @@ features = ["property-testing", "katexit"]
opt-level = 3
debug = true

[profile.dev]
opt-level = 1

[profile.bench]
opt-level = 3
debug = true
Expand Down
2 changes: 1 addition & 1 deletion examples/template_matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ If the optional boolean argument parallel is given, match_template will be calle
let template_y = args[4].parse().unwrap();
let template_w = args[5].parse().unwrap();
let template_h = args[6].parse().unwrap();
let parallel = args.get(7).map_or(false, |s| s.parse().unwrap());
let parallel = args.get(7).is_some_and(|s| s.parse().unwrap());

TemplateMatchingArgs {
input_path,
Expand Down
106 changes: 106 additions & 0 deletions src/image_hash/bits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct Bits64(u64);

impl Bits64 {
const N: usize = 64;

pub fn new(v: impl IntoIterator<Item = bool>) -> Self {
let mut bits = Self::zeros();
let mut n = 0;
for bit in v {
if bit {
bits.set_bit_at(n);
} else {
bits.unset_bit_at(n);
};
n += 1;
}
assert_eq!(n, Self::N);
bits
}
pub fn zeros() -> Self {
Self(0)
}
#[allow(dead_code)]
pub fn to_bitarray(self) -> [bool; Self::N] {
let mut bits = [false; Self::N];
for (n, bit) in bits.iter_mut().enumerate() {
*bit = self.bit_at(n)
}
bits
}
pub fn hamming_distance(self, other: Bits64) -> u32 {
self.xor(other).0.count_ones()
}
fn xor(self, other: Self) -> Self {
Self(self.0 ^ other.0)
}
fn bit_at(self, n: usize) -> bool {
assert!(n < Self::N);
let bit = self.0 & (1 << n);
bit != 0
}
fn set_bit_at(&mut self, n: usize) {
assert!(n < Self::N);
self.0 |= 1 << n;
}
fn unset_bit_at(&mut self, n: usize) {
assert!(n < Self::N);
self.0 &= !(1 << n);
}
}

#[cfg(test)]
mod tests {
use std::collections::HashMap;

use super::*;

#[test]
fn test_bits64_ops() {
let mut bits = Bits64::zeros();
bits.set_bit_at(0);
assert_eq!(bits, Bits64(1));
bits.set_bit_at(1);
assert_eq!(bits, Bits64(1 + 2));
bits.unset_bit_at(0);
assert_eq!(bits, Bits64(2));
bits.unset_bit_at(1);
assert_eq!(bits, Bits64::zeros());

bits.set_bit_at(2);
assert_eq!(bits, Bits64(4));
}
#[test]
fn test_bitarray() {
let mut v = [false; Bits64::N];
v[3] = true;
v[6] = true;
let bits = Bits64::new(v);
assert_eq!(bits.to_bitarray(), v);
}
#[test]
fn test_bits64_new() {
const N: usize = 64;

let mut v = [false; N];
v[0] = true;
assert_eq!(Bits64::new(v), Bits64(1));
v[1] = true;
assert_eq!(Bits64::new(v), Bits64(1 + 2));
}
#[test]
#[should_panic]
fn test_bits64_new_fail() {
const N: usize = 64;
let it = (1..N).map(|x| x % 2 == 0);
let _bits = Bits64::new(it);
}

#[test]
fn test_hash() {
let one = Bits64(1);
let map = HashMap::from([(one, "1")]);
assert_eq!(map[&one], "1");
}
}
11 changes: 11 additions & 0 deletions src/image_hash/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! [Perceptual hashing] algorithms for images.
//!
//! [Perceptual hashing]: https://en.wikipedia.org/wiki/Perceptual_hashing
mod bits;
mod phash;
mod signals;

use bits::Bits64;

pub use phash::{phash, PHash};
146 changes: 146 additions & 0 deletions src/image_hash/phash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
use super::{signals, Bits64};
use crate::definitions::Image;
use image::{imageops, math::Rect, Luma};
use std::borrow::Cow;

/// Stores the result of the [`phash`].
/// Implements [`Hash`] trait.
///
/// # Note
/// The hash value may vary between versions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PHash(Bits64);

impl PHash {
/// Compute the [hamming distance] between hashes.
///
/// # Example
/// ```no_run
/// use imageproc::image_hash;
///
/// # fn main() {
/// # let img1 = image::open("first.png").unwrap().to_luma32f();
/// # let img2 = image::open("second.png").unwrap().to_luma32f();
/// let hash1 = image_hash::phash(&img1);
/// let hash2 = image_hash::phash(&img2);
/// dbg!(hash1.hamming_distance(hash2));
/// # }
/// ```
///
/// [hamming distance]: https://en.wikipedia.org/wiki/Hamming_distance
pub fn hamming_distance(self, PHash(other): PHash) -> u32 {
self.0.hamming_distance(other)
}
}

/// Compute the [pHash] using [DCT].
///
/// # Example
///
/// ```no_run
/// use imageproc::image_hash;
///
/// # fn main() {
/// let img1 = image::open("first.png").unwrap().to_luma32f();
/// let img2 = image::open("second.png").unwrap().to_luma32f();
/// let hash1 = image_hash::phash(&img1);
/// let hash2 = image_hash::phash(&img2);
/// dbg!(hash1.hamming_distance(hash2));
/// # }
/// ```
///
/// [pHash]: https://phash.org/docs/pubs/thesis_zauner.pdf
/// [DCT]: https://en.wikipedia.org/wiki/Discrete_cosine_transform
pub fn phash(img: &Image<Luma<f32>>) -> PHash {
const N: u32 = 8;
const HASH_FACTOR: u32 = 4;
let img = imageops::resize(
img,
HASH_FACTOR * N,
HASH_FACTOR * N,
imageops::FilterType::Lanczos3,
);
let dct = signals::dct2d(Cow::Owned(img));
let topleft = Rect {
x: 1,
y: 1,
width: N,
height: N,
};
let topleft_dct = crate::compose::crop(&dct, topleft);
debug_assert_eq!(topleft_dct.dimensions(), (N, N));
assert_eq!(topleft_dct.len(), (N * N) as usize);
let mean =
topleft_dct.iter().copied().reduce(|a, b| a + b).unwrap() / (topleft_dct.len() as f32);
let bits = topleft_dct.iter().map(|&x| x > mean);
PHash(Bits64::new(bits))
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_phash() {
let img1 = gray_image!(type: f32,
1., 2., 3.;
4., 5., 6.
);
let mut img2 = img1.clone();
*img2.get_pixel_mut(0, 0) = Luma([0f32]);
let mut img3 = img2.clone();
*img3.get_pixel_mut(0, 1) = Luma([0f32]);

let hash1 = phash(&img1);
let hash2 = phash(&img2);
let hash3 = phash(&img3);

assert_eq!(0, hash1.hamming_distance(hash1));
assert_eq!(0, hash2.hamming_distance(hash2));
assert_eq!(0, hash3.hamming_distance(hash3));

assert_eq!(hash1.hamming_distance(hash2), hash2.hamming_distance(hash1));

assert!(hash1.hamming_distance(hash2) > 0);
assert!(hash1.hamming_distance(hash3) > 0);
assert!(hash2.hamming_distance(hash3) > 0);

assert!(hash1.hamming_distance(hash2) < hash1.hamming_distance(hash3));
}
}

#[cfg(not(miri))]
#[cfg(test)]
mod proptests {
use super::*;
use crate::proptest_utils::arbitrary_image;
use proptest::prelude::*;

const N: usize = 100;

proptest! {
#[test]
fn proptest_phash(img in arbitrary_image(0..N, 0..N)) {
let hash = phash(&img);
assert_eq!(0, hash.hamming_distance(hash));
}
}
}

#[cfg(not(miri))]
#[cfg(test)]
mod benches {
use super::*;
use crate::utils::luma32f_bench_image;
use test::{black_box, Bencher};

const N: u32 = 600;

#[bench]
fn bench_phash(b: &mut Bencher) {
let img = luma32f_bench_image(N, N);
b.iter(|| {
let img = black_box(&img);
black_box(phash(img));
});
}
}
Loading

0 comments on commit 33a88f8

Please sign in to comment.