Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add pHash #709

Merged
merged 20 commits into from
Jan 19, 2025
Merged
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
Loading