visual_hash/lib.rs
1//! A crate that provides several perceptual hashing algorithms for images.
2//! Supports images opened with the [image] crate from Piston.
3//!
4//! ```rust,no_run
5//! # use img_hash::{HasherConfig, HashAlg};
6//! let image1 = image::open("image1.png").unwrap();
7//! let image2 = image::open("image2.png").unwrap();
8//!
9//! let hasher = HasherConfig::new().to_hasher();
10//!
11//! let hash1 = hasher.hash_image(&image1);
12//! let hash2 = hasher.hash_image(&image2);
13//!
14//! println!("Image1 hash: {}", hash1.to_base64());
15//! println!("Image2 hash: {}", hash2.to_base64());
16//!
17//! println!("Hamming Distance: {}", hash1.dist(&hash2));
18//! ```
19//! [image]: https://github.com/PistonDevelopers/image
20#![deny(missing_docs)]
21
22use std::{borrow::Cow, fmt, marker::PhantomData};
23
24mod alg;
25mod dct;
26mod traits;
27
28use dct::DctCtxt;
29use image::imageops;
30use image::GrayImage;
31use serde::{Deserialize, Serialize};
32
33pub use image::imageops::FilterType;
34
35pub use alg::HashAlg;
36
37pub(crate) use traits::BitSet;
38
39pub use traits::{DiffImage, HashBytes, Image};
40
41/// **Start here**. Configuration builder for [`Hasher`](::Hasher).
42///
43/// Playing with the various options on this struct allows you to tune the performance of image
44/// hashing to your needs.
45///
46/// Sane, reasonably fast defaults are provided by the [`::new()`](#method.new) constructor. If
47/// you just want to start hashing images and don't care about the details, it's as simple as:
48///
49/// ```rust
50/// use img_hash::HasherConfig;
51///
52/// let hasher = HasherConfig::new().to_hasher();
53/// // hasher.hash_image(image);
54/// ```
55///
56/// # Configuration Options
57/// The hash API is highly configurable to tune both performance characteristics and hash
58/// resilience.
59///
60/// ### Hash Size
61/// Setter: [`.hash_size()`](#method.hash_size)
62///
63/// Dimensions of the final hash, as width x height, in bits. A hash size of `8, 8` produces an
64/// 8 x 8 bit (8 byte) hash. Larger hash sizes take more time to compute as well as more memory,
65/// but aren't necessarily better for comparing images. The best hash size depends on both
66/// the [hash algorithm](#hash-algorithm) and the input dataset. If your images are mostly
67/// wide aspect ratio (landscape) then a larger width and a smaller height hash size may be
68/// preferable. Optimal values can really only be discovered empirically though.
69///
70/// (As the author experiments, suggested values will be added here for various algorithms.)
71///
72/// ### Hash Algorithm
73/// Setter: [`.hash_alg()`](#method.hash_alg)
74/// Definition: [`HashAlg`](enum.HashAlg.html)
75///
76/// Multiple methods of calculating image hashes are provided in this crate under the `HashAlg`
77/// enum. Each algorithm is different but they all produce the same size hashes as governed by
78/// `hash_size`.
79///
80/// ### Hash Bytes Container / `B` Type Param
81/// Use [`with_bytes_type::<B>()`](#method.with_bytes_type) instead of `new()` to customize.
82///
83/// This hash API allows you to specify the bytes container type for generated hashes. The default
84/// allows for any arbitrary hash size (see above) but requires heap-allocation. Instead, you
85/// can select an array type which allows hashes to be allocated inline, but requires consideration
86/// of the possible sizes of hash you want to generate so you don't waste memory.
87///
88/// Another advantage of using a constant-sized hash type is that the compiler may be able to
89/// produce more optimal code for generating and comparing hashes.
90///
91/// ```rust
92/// # use img_hash::*;
93///
94/// // Use default container type, good for any hash size
95/// let config = HasherConfig::new();
96///
97/// /// Inline hash container that exactly fits the default hash size
98/// let config = HasherConfig::with_bytes_type::<[u8; 8]>();
99/// ```
100///
101#[derive(Serialize, Deserialize)]
102pub struct HasherConfig<B = Box<[u8]>> {
103 width: u32,
104 height: u32,
105 gauss_sigmas: Option<[f32; 2]>,
106 #[serde(with = "SerdeFilterType")]
107 resize_filter: FilterType,
108 dct: bool,
109 hash_alg: HashAlg,
110 _bytes_type: PhantomData<B>,
111}
112
113impl HasherConfig<Box<[u8]>> {
114 /// Construct a new hasher config with sane, reasonably fast defaults.
115 ///
116 /// A default hash container type is provided as a default type parameter which is guaranteed
117 /// to fit any hash size.
118 pub fn new() -> Self {
119 Self::with_bytes_type()
120 }
121
122 /// Construct a new config with the selected [`HashBytes`](trait.HashBytes.html) impl.
123 ///
124 /// You may opt for an array type which allows inline allocation of hash data.
125 ///
126 /// ### Note
127 /// The default hash size requires 64 bits / 8 bytes of storage. You can change this
128 /// with [`.hash_size()`](#method.hash_size).
129 pub fn with_bytes_type<B_: HashBytes>() -> HasherConfig<B_> {
130 HasherConfig {
131 width: 8,
132 height: 8,
133 gauss_sigmas: None,
134 resize_filter: FilterType::Lanczos3,
135 dct: false,
136 hash_alg: HashAlg::Gradient,
137 _bytes_type: PhantomData,
138 }
139 }
140}
141
142impl<B: HashBytes> HasherConfig<B> {
143 /// Set a new hash width and height; these can be the same.
144 ///
145 /// The number of bits in the resulting hash will be `width * height`. If you are using
146 /// a fixed-size `HashBytes` type then you must ensure it can hold at least this many bits.
147 /// You can check this with [`HashBytes::max_bits()`](#method.max_bits).
148 ///
149 /// ### Rounding Behavior
150 /// Certain hash algorithms need to round this value to function properly:
151 ///
152 /// * [`DoubleGradient`](enum.HashAlg.html#variant.DoubleGradient) rounds to the next multiple of 2;
153 /// * [`Blockhash`](enum.HashAlg.html#variant.Blockhash) rounds to the next multiple of 4.
154 ///
155 /// If the chosen values already satisfy these requirements then nothing is changed.
156 ///
157 /// ### Recommended Values
158 /// The hash granularity increases with `width * height`, although there are diminishing
159 /// returns for higher values. Start small. A good starting value to try is `8, 8`.
160 ///
161 /// When using DCT preprocessing having `width` and `height` be the same value will improve
162 /// hashing performance as only one set of coefficients needs to be used.
163 pub fn hash_size(self, width: u32, height: u32) -> Self {
164 Self {
165 width,
166 height,
167 ..self
168 }
169 }
170
171 /// Set the filter used to resize images during hashing.
172 ///
173 /// Note when picking a filter that images are almost always reduced in size.
174 /// Has no effect with the Blockhash algorithm as it does not resize.
175 pub fn resize_filter(self, resize_filter: FilterType) -> Self {
176 Self {
177 resize_filter,
178 ..self
179 }
180 }
181
182 /// Set the algorithm used to generate hashes.
183 ///
184 /// Each algorithm has different performance characteristics.
185 pub fn hash_alg(self, hash_alg: HashAlg) -> Self {
186 Self { hash_alg, ..self }
187 }
188
189 /// Enable preprocessing with the Discrete Cosine Transform (DCT).
190 ///
191 /// Does nothing when used with [the Blockhash.io algorithm](HashAlg::Blockhash)
192 /// which does not scale the image.
193 /// (RFC: it would be possible to shoehorn a DCT into the Blockhash algorithm but it's
194 /// not clear what benefits, if any, that would provide).
195 ///
196 /// After conversion to grayscale, the image is scaled down to `width * 2 x height * 2`
197 /// and then the Discrete Cosine Transform is performed on the luminance values. The DCT
198 /// essentially transforms the 2D image from the spatial domain with luminance values
199 /// to a 2D frequency domain where the values are amplitudes of cosine waves. The resulting
200 /// 2D matrix is then cropped to the low `width * height` corner and the
201 /// configured hash algorithm is performed on that.
202 ///
203 /// In layman's terms, this essentially converts the image into a mathematical representation
204 /// of the "broad strokes" of the data, which allows the subsequent hashing step to be more
205 /// robust against changes that may otherwise produce different hashes, such as significant
206 /// edits to portions of the image.
207 ///
208 /// However, on most machines this usually adds an additional 50-100% to the average hash time.
209 ///
210 /// This is a very similar process to JPEG compression, although the implementation is too
211 /// different for this to be optimized specifically for JPEG encoded images.
212 ///
213 /// Further Reading:
214 /// * http://www.hackerfactor.com/blog/?/archives/432-Looks-Like-It.html
215 /// Krawetz describes a "pHash" algorithm which is equivalent to Mean + DCT preprocessing here.
216 /// However there is nothing to say that DCT preprocessing cannot compose with other hash
217 /// algorithms; Gradient + DCT might well perform better in some aspects.
218 /// * https://en.wikipedia.org/wiki/Discrete_cosine_transform
219 pub fn preproc_dct(self) -> Self {
220 Self { dct: true, ..self }
221 }
222
223 /// Enable preprocessing with the Difference of Gaussians algorithm with default sigma values.
224 ///
225 /// Recommended only for use with [the Blockhash.io algorithm](enum.HashAlg#variant.Blockhash)
226 /// as it significantly reduces entropy in the scaled down image for other algorithms.
227 ///
228 /// See [`Self::preproc_diff_gauss_sigmas()](#method.preproc_diff_gauss_sigmas) for more info.
229 pub fn preproc_diff_gauss(self) -> Self {
230 self.preproc_diff_gauss_sigmas(5.0, 10.0)
231 }
232
233 /// Enable preprocessing with the Difference of Gaussians algorithm with the given sigma values.
234 ///
235 /// Recommended only for use with [the Blockhash.io algorithm](enum.HashAlg#variant.Blockhash)
236 /// as it significantly reduces entropy in the scaled down image for other algorithms.
237 ///
238 /// After the image is converted to grayscale, it is blurred with a Gaussian blur using
239 /// two different sigmas, and then the images are subtracted from each other. This reduces
240 /// the image to just sharp transitions in luminance, i.e. edges. Varying the sigma values
241 /// changes how sharp the edges are^[citation needed].
242 ///
243 /// Further reading:
244 /// * https://en.wikipedia.org/wiki/Difference_of_Gaussians
245 /// * http://homepages.inf.ed.ac.uk/rbf/HIPR2/log.htm
246 /// (Difference of Gaussians is an approximation of a Laplacian of Gaussian filter)
247 pub fn preproc_diff_gauss_sigmas(self, sigma_a: f32, sigma_b: f32) -> Self {
248 Self {
249 gauss_sigmas: Some([sigma_a, sigma_b]),
250 ..self
251 }
252 }
253
254 /// Create a [`Hasher`](struct.Hasher.html) from this config which can be used to hash images.
255 ///
256 /// ### Panics
257 /// If the chosen hash size (`width x height`, rounded for the algorithm if necessary)
258 /// is too large for the chosen container type (`B::max_bits()`).
259 pub fn to_hasher(&self) -> Hasher<B> {
260 let Self {
261 hash_alg,
262 width,
263 height,
264 gauss_sigmas,
265 resize_filter,
266 dct,
267 ..
268 } = *self;
269
270 let (width, height) = hash_alg.round_hash_size(width, height);
271
272 assert!(
273 (width * height) as usize <= B::max_bits(),
274 "hash size too large for container: {} x {}",
275 width,
276 height
277 );
278
279 // Blockhash doesn't resize the image so don't waste time calculating coefficients
280 let dct_coeffs = if dct && hash_alg != HashAlg::Blockhash {
281 // calculate the coefficients based on the resize dimensions
282 let (dct_width, dct_height) = hash_alg.resize_dimensions(width, height);
283 Some(DctCtxt::new(dct_width, dct_height))
284 } else {
285 None
286 };
287
288 Hasher {
289 ctxt: HashCtxt {
290 gauss_sigmas,
291 dct_ctxt: dct_coeffs,
292 width,
293 height,
294 resize_filter,
295 },
296 hash_alg,
297 bytes_type: PhantomData,
298 }
299 }
300}
301
302// cannot be derived because of `FilterType`
303impl<B> fmt::Debug for HasherConfig<B> {
304 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
305 f.debug_struct("HasherConfig")
306 .field("width", &self.width)
307 .field("height", &self.height)
308 .field("hash_alg", &self.hash_alg)
309 .field("resize_filter", &debug_filter_type(&self.resize_filter))
310 .field("gauss_sigmas", &self.gauss_sigmas)
311 .field("use_dct", &self.dct)
312 .finish()
313 }
314}
315
316impl Default for HasherConfig {
317 fn default() -> Self {
318 HasherConfig::new()
319 }
320}
321
322/// Generates hashes for images.
323///
324/// Constructed via [`HasherConfig::to_hasher()`](struct.HasherConfig#method.to_hasher).
325pub struct Hasher<B = Box<[u8]>> {
326 ctxt: HashCtxt,
327 hash_alg: HashAlg,
328 bytes_type: PhantomData<B>,
329}
330
331impl<B> Hasher<B>
332where
333 B: HashBytes,
334{
335 /// Calculate a hash for the given image with the configured options.
336 pub fn hash_image<I: Image>(&self, img: &I) -> ImageHash<B> {
337 let hash = self.hash_alg.hash_image(&self.ctxt, img);
338 ImageHash { hash }
339 }
340}
341
342enum CowImage<'a, I: Image> {
343 Borrowed(&'a I),
344 Owned(I::Buf),
345}
346
347impl<'a, I: Image> CowImage<'a, I> {
348 fn to_grayscale(&self) -> Cow<GrayImage> {
349 match self {
350 CowImage::Borrowed(img) => img.to_grayscale(),
351 CowImage::Owned(img) => img.to_grayscale(),
352 }
353 }
354}
355
356enum HashVals {
357 Floats(Vec<f32>),
358 Bytes(Vec<u8>),
359}
360
361// TODO: implement `Debug`, needs adaptor for `FilterType`
362struct HashCtxt {
363 gauss_sigmas: Option<[f32; 2]>,
364 dct_ctxt: Option<DctCtxt>,
365 resize_filter: FilterType,
366 width: u32,
367 height: u32,
368}
369
370impl HashCtxt {
371 /// If Difference of Gaussians preprocessing is configured, produce a new image with it applied.
372 fn gauss_preproc<'a, I: Image>(&self, image: &'a I) -> CowImage<'a, I> {
373 if let Some([sigma_a, sigma_b]) = self.gauss_sigmas {
374 let mut blur_a = image.blur(sigma_a);
375 let blur_b = image.blur(sigma_b);
376 blur_a.diff_inplace(&blur_b);
377
378 CowImage::Owned(blur_a)
379 } else {
380 CowImage::Borrowed(image)
381 }
382 }
383
384 /// If DCT preprocessing is configured, produce a vector of floats, otherwise a vector of bytes.
385 fn calc_hash_vals(&self, img: &GrayImage, width: u32, height: u32) -> HashVals {
386 if let Some(ref dct_ctxt) = self.dct_ctxt {
387 let img =
388 imageops::resize(img, dct_ctxt.width(), dct_ctxt.height(), self.resize_filter);
389
390 let img_vals = img.into_vec();
391 let mut packed_2d: Vec<_> = img_vals.iter().copied().map(f32::from).collect();
392 dct_ctxt.dct_2d(&mut packed_2d);
393 HashVals::Floats(dct_ctxt.crop_2d(packed_2d))
394 } else {
395 let img = imageops::resize(img, width, height, self.resize_filter);
396 HashVals::Bytes(img.into_vec())
397 }
398 }
399}
400
401/// A struct representing an image processed by a perceptual hash.
402/// For efficiency, does not retain a copy of the image data after hashing.
403///
404/// Get an instance with `ImageHash::hash()`.
405#[derive(PartialEq, Eq, Hash, Debug, Clone)]
406pub struct ImageHash<B = Box<[u8]>> {
407 hash: B,
408}
409
410impl<B: AsRef<[u8]>> ImageHash<B> {
411 /// Format this image has as a hex string.
412 pub fn to_hex(&self) -> String {
413 static CHARS: &[u8] = b"0123456789abcdef";
414
415 let bytes = self.hash.as_ref();
416 let mut v = Vec::with_capacity(bytes.len() * 2);
417 for &byte in bytes {
418 v.push(CHARS[(byte >> 4) as usize]);
419 v.push(CHARS[(byte & 0xf) as usize]);
420 }
421
422 unsafe { String::from_utf8_unchecked(v) }
423 }
424}
425
426/// Error that can happen constructing a `ImageHash` from bytes.
427#[derive(Debug, PartialEq)]
428pub enum InvalidBytesError {
429 /// Byte slice passed to `from_bytes` was the wrong length.
430 BytesWrongLength {
431 /// Number of bytes the `ImageHash` type expected.
432 expected: usize,
433 /// Number of bytes found when parsing the hash bytes.
434 found: usize,
435 },
436 /// String passed was not valid base64.
437 Base64(base64::DecodeError),
438}
439
440impl<B: HashBytes> ImageHash<B> {
441 /// Get the bytes of this hash.
442 pub fn as_bytes(&self) -> &[u8] {
443 self.hash.as_slice()
444 }
445
446 /// Create an `ImageHash` instance from the given bytes.
447 ///
448 /// ## Errors:
449 /// Returns a `InvalidBytesError::BytesWrongLength` error if the slice passed can't fit in `B`.
450 pub fn from_bytes(bytes: &[u8]) -> Result<ImageHash<B>, InvalidBytesError> {
451 if bytes.len() * 8 > B::max_bits() {
452 return Err(InvalidBytesError::BytesWrongLength {
453 expected: B::max_bits() / 8,
454 found: bytes.len(),
455 });
456 }
457
458 Ok(ImageHash {
459 hash: B::from_iter(bytes.iter().copied()),
460 })
461 }
462
463 /// Calculate the Hamming distance between this and `other`.
464 ///
465 /// Equivalent to counting the 1-bits of the XOR of the two hashes.
466 ///
467 /// Essential to determining the perceived difference between `self` and `other`.
468 ///
469 /// ### Note
470 /// This return value is meaningless if these two hashes are from different hash sizes or
471 /// algorithms.
472 pub fn dist(&self, other: &Self) -> u32 {
473 BitSet::hamming(&self.hash, &other.hash)
474 }
475
476 /// Create an `ImageHash` instance from the given Base64-encoded string.
477 ///
478 /// ## Errors:
479 /// Returns `InvaidBytesError::Base64(DecodeError::InvalidLength)` if the string wasn't valid base64`.
480 /// Otherwise returns the same errors as `from_bytes`.
481 pub fn from_base64(encoded_hash: &str) -> Result<ImageHash<B>, InvalidBytesError> {
482 let bytes = base64::decode(encoded_hash).map_err(InvalidBytesError::Base64)?;
483
484 Self::from_bytes(&bytes)
485 }
486
487 /// Get a Base64 string representing the bits of this hash.
488 ///
489 /// Mostly for printing convenience.
490 pub fn to_base64(&self) -> String {
491 base64::encode(self.hash.as_slice())
492 }
493}
494
495/// Provide Serde a typedef for `image::FilterType`: https://serde.rs/remote-derive.html
496/// This is automatically checked, if Serde complains then double-check with the original definition
497#[derive(Serialize, Deserialize)]
498#[serde(remote = "FilterType")]
499enum SerdeFilterType {
500 Nearest,
501 Triangle,
502 CatmullRom,
503 Gaussian,
504 Lanczos3,
505}
506
507fn debug_filter_type(ft: &FilterType) -> &'static str {
508 match ft {
509 FilterType::Triangle => "Triangle",
510 FilterType::Nearest => "Nearest",
511 FilterType::CatmullRom => "CatmullRom",
512 FilterType::Lanczos3 => "Lanczos3",
513 FilterType::Gaussian => "Gaussian",
514 }
515}