Skip to content

Commit 33a88f8

Browse files
authored
add pHash (#709)
* add phash * update msrv
1 parent ab9e19c commit 33a88f8

File tree

9 files changed

+601
-12
lines changed

9 files changed

+601
-12
lines changed

.github/workflows/check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ jobs:
9898
# https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
9999
strategy:
100100
matrix:
101-
msrv: ["1.79.0"] # Don't forget to update the `rust-version` in Cargo.toml as well
101+
msrv: ["1.80.1"] # Don't forget to update the `rust-version` in Cargo.toml as well
102102
name: ubuntu / ${{ matrix.msrv }}
103103
steps:
104104
- uses: actions/checkout@v4

Cargo.toml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "imageproc"
33
version = "0.26.0"
44
authors = ["theotherphil"]
55
# note: when changed, also update `msrv` in `.github/workflows/check.yml`
6-
rust-version = "1.79.0"
6+
rust-version = "1.80.1"
77
edition = "2021"
88
license = "MIT"
99
description = "Image processing operations"
@@ -22,20 +22,21 @@ ab_glyph = { version = "0.2.23", default-features = false, features = ["std"] }
2222
approx = { version = "0.5", default-features = false }
2323
image = { version = "0.25.0", default-features = false }
2424
itertools = { version = "0.13.0", default-features = false, features = [
25-
"use_std",
25+
"use_std",
2626
] }
2727
nalgebra = { version = "0.32", default-features = false, features = ["std"] }
2828
num = { version = "0.4.1", default-features = false }
2929
rand = { version = "0.8.5", default-features = false, features = [
30-
"std",
31-
"std_rng",
30+
"std",
31+
"std_rng",
3232
] }
3333
rand_distr = { version = "0.4.3", default-features = false }
3434
rayon = { version = "1.8.0", optional = true, default-features = false }
3535
sdl2 = { version = "0.36", optional = true, default-features = false, features = [
36-
"bundled",
36+
"bundled",
3737
] }
3838
katexit = { version = "0.1.4", optional = true, default-features = false }
39+
rustdct = "0.7.1"
3940

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

58+
[profile.dev]
59+
opt-level = 1
60+
5761
[profile.bench]
5862
opt-level = 3
5963
debug = true

examples/template_matching.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ If the optional boolean argument parallel is given, match_template will be calle
4545
let template_y = args[4].parse().unwrap();
4646
let template_w = args[5].parse().unwrap();
4747
let template_h = args[6].parse().unwrap();
48-
let parallel = args.get(7).map_or(false, |s| s.parse().unwrap());
48+
let parallel = args.get(7).is_some_and(|s| s.parse().unwrap());
4949

5050
TemplateMatchingArgs {
5151
input_path,

src/image_hash/bits.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2+
pub(super) struct Bits64(u64);
3+
4+
impl Bits64 {
5+
const N: usize = 64;
6+
7+
pub fn new(v: impl IntoIterator<Item = bool>) -> Self {
8+
let mut bits = Self::zeros();
9+
let mut n = 0;
10+
for bit in v {
11+
if bit {
12+
bits.set_bit_at(n);
13+
} else {
14+
bits.unset_bit_at(n);
15+
};
16+
n += 1;
17+
}
18+
assert_eq!(n, Self::N);
19+
bits
20+
}
21+
pub fn zeros() -> Self {
22+
Self(0)
23+
}
24+
#[allow(dead_code)]
25+
pub fn to_bitarray(self) -> [bool; Self::N] {
26+
let mut bits = [false; Self::N];
27+
for (n, bit) in bits.iter_mut().enumerate() {
28+
*bit = self.bit_at(n)
29+
}
30+
bits
31+
}
32+
pub fn hamming_distance(self, other: Bits64) -> u32 {
33+
self.xor(other).0.count_ones()
34+
}
35+
fn xor(self, other: Self) -> Self {
36+
Self(self.0 ^ other.0)
37+
}
38+
fn bit_at(self, n: usize) -> bool {
39+
assert!(n < Self::N);
40+
let bit = self.0 & (1 << n);
41+
bit != 0
42+
}
43+
fn set_bit_at(&mut self, n: usize) {
44+
assert!(n < Self::N);
45+
self.0 |= 1 << n;
46+
}
47+
fn unset_bit_at(&mut self, n: usize) {
48+
assert!(n < Self::N);
49+
self.0 &= !(1 << n);
50+
}
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use std::collections::HashMap;
56+
57+
use super::*;
58+
59+
#[test]
60+
fn test_bits64_ops() {
61+
let mut bits = Bits64::zeros();
62+
bits.set_bit_at(0);
63+
assert_eq!(bits, Bits64(1));
64+
bits.set_bit_at(1);
65+
assert_eq!(bits, Bits64(1 + 2));
66+
bits.unset_bit_at(0);
67+
assert_eq!(bits, Bits64(2));
68+
bits.unset_bit_at(1);
69+
assert_eq!(bits, Bits64::zeros());
70+
71+
bits.set_bit_at(2);
72+
assert_eq!(bits, Bits64(4));
73+
}
74+
#[test]
75+
fn test_bitarray() {
76+
let mut v = [false; Bits64::N];
77+
v[3] = true;
78+
v[6] = true;
79+
let bits = Bits64::new(v);
80+
assert_eq!(bits.to_bitarray(), v);
81+
}
82+
#[test]
83+
fn test_bits64_new() {
84+
const N: usize = 64;
85+
86+
let mut v = [false; N];
87+
v[0] = true;
88+
assert_eq!(Bits64::new(v), Bits64(1));
89+
v[1] = true;
90+
assert_eq!(Bits64::new(v), Bits64(1 + 2));
91+
}
92+
#[test]
93+
#[should_panic]
94+
fn test_bits64_new_fail() {
95+
const N: usize = 64;
96+
let it = (1..N).map(|x| x % 2 == 0);
97+
let _bits = Bits64::new(it);
98+
}
99+
100+
#[test]
101+
fn test_hash() {
102+
let one = Bits64(1);
103+
let map = HashMap::from([(one, "1")]);
104+
assert_eq!(map[&one], "1");
105+
}
106+
}

src/image_hash/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! [Perceptual hashing] algorithms for images.
2+
//!
3+
//! [Perceptual hashing]: https://en.wikipedia.org/wiki/Perceptual_hashing
4+
5+
mod bits;
6+
mod phash;
7+
mod signals;
8+
9+
use bits::Bits64;
10+
11+
pub use phash::{phash, PHash};

src/image_hash/phash.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use super::{signals, Bits64};
2+
use crate::definitions::Image;
3+
use image::{imageops, math::Rect, Luma};
4+
use std::borrow::Cow;
5+
6+
/// Stores the result of the [`phash`].
7+
/// Implements [`Hash`] trait.
8+
///
9+
/// # Note
10+
/// The hash value may vary between versions.
11+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12+
pub struct PHash(Bits64);
13+
14+
impl PHash {
15+
/// Compute the [hamming distance] between hashes.
16+
///
17+
/// # Example
18+
/// ```no_run
19+
/// use imageproc::image_hash;
20+
///
21+
/// # fn main() {
22+
/// # let img1 = image::open("first.png").unwrap().to_luma32f();
23+
/// # let img2 = image::open("second.png").unwrap().to_luma32f();
24+
/// let hash1 = image_hash::phash(&img1);
25+
/// let hash2 = image_hash::phash(&img2);
26+
/// dbg!(hash1.hamming_distance(hash2));
27+
/// # }
28+
/// ```
29+
///
30+
/// [hamming distance]: https://en.wikipedia.org/wiki/Hamming_distance
31+
pub fn hamming_distance(self, PHash(other): PHash) -> u32 {
32+
self.0.hamming_distance(other)
33+
}
34+
}
35+
36+
/// Compute the [pHash] using [DCT].
37+
///
38+
/// # Example
39+
///
40+
/// ```no_run
41+
/// use imageproc::image_hash;
42+
///
43+
/// # fn main() {
44+
/// let img1 = image::open("first.png").unwrap().to_luma32f();
45+
/// let img2 = image::open("second.png").unwrap().to_luma32f();
46+
/// let hash1 = image_hash::phash(&img1);
47+
/// let hash2 = image_hash::phash(&img2);
48+
/// dbg!(hash1.hamming_distance(hash2));
49+
/// # }
50+
/// ```
51+
///
52+
/// [pHash]: https://phash.org/docs/pubs/thesis_zauner.pdf
53+
/// [DCT]: https://en.wikipedia.org/wiki/Discrete_cosine_transform
54+
pub fn phash(img: &Image<Luma<f32>>) -> PHash {
55+
const N: u32 = 8;
56+
const HASH_FACTOR: u32 = 4;
57+
let img = imageops::resize(
58+
img,
59+
HASH_FACTOR * N,
60+
HASH_FACTOR * N,
61+
imageops::FilterType::Lanczos3,
62+
);
63+
let dct = signals::dct2d(Cow::Owned(img));
64+
let topleft = Rect {
65+
x: 1,
66+
y: 1,
67+
width: N,
68+
height: N,
69+
};
70+
let topleft_dct = crate::compose::crop(&dct, topleft);
71+
debug_assert_eq!(topleft_dct.dimensions(), (N, N));
72+
assert_eq!(topleft_dct.len(), (N * N) as usize);
73+
let mean =
74+
topleft_dct.iter().copied().reduce(|a, b| a + b).unwrap() / (topleft_dct.len() as f32);
75+
let bits = topleft_dct.iter().map(|&x| x > mean);
76+
PHash(Bits64::new(bits))
77+
}
78+
79+
#[cfg(test)]
80+
mod tests {
81+
use super::*;
82+
#[test]
83+
fn test_phash() {
84+
let img1 = gray_image!(type: f32,
85+
1., 2., 3.;
86+
4., 5., 6.
87+
);
88+
let mut img2 = img1.clone();
89+
*img2.get_pixel_mut(0, 0) = Luma([0f32]);
90+
let mut img3 = img2.clone();
91+
*img3.get_pixel_mut(0, 1) = Luma([0f32]);
92+
93+
let hash1 = phash(&img1);
94+
let hash2 = phash(&img2);
95+
let hash3 = phash(&img3);
96+
97+
assert_eq!(0, hash1.hamming_distance(hash1));
98+
assert_eq!(0, hash2.hamming_distance(hash2));
99+
assert_eq!(0, hash3.hamming_distance(hash3));
100+
101+
assert_eq!(hash1.hamming_distance(hash2), hash2.hamming_distance(hash1));
102+
103+
assert!(hash1.hamming_distance(hash2) > 0);
104+
assert!(hash1.hamming_distance(hash3) > 0);
105+
assert!(hash2.hamming_distance(hash3) > 0);
106+
107+
assert!(hash1.hamming_distance(hash2) < hash1.hamming_distance(hash3));
108+
}
109+
}
110+
111+
#[cfg(not(miri))]
112+
#[cfg(test)]
113+
mod proptests {
114+
use super::*;
115+
use crate::proptest_utils::arbitrary_image;
116+
use proptest::prelude::*;
117+
118+
const N: usize = 100;
119+
120+
proptest! {
121+
#[test]
122+
fn proptest_phash(img in arbitrary_image(0..N, 0..N)) {
123+
let hash = phash(&img);
124+
assert_eq!(0, hash.hamming_distance(hash));
125+
}
126+
}
127+
}
128+
129+
#[cfg(not(miri))]
130+
#[cfg(test)]
131+
mod benches {
132+
use super::*;
133+
use crate::utils::luma32f_bench_image;
134+
use test::{black_box, Bencher};
135+
136+
const N: u32 = 600;
137+
138+
#[bench]
139+
fn bench_phash(b: &mut Bencher) {
140+
let img = luma32f_bench_image(N, N);
141+
b.iter(|| {
142+
let img = black_box(&img);
143+
black_box(phash(img));
144+
});
145+
}
146+
}

0 commit comments

Comments
 (0)