diff --git a/phash.mbt b/phash.mbt new file mode 100644 index 0000000..ac6ba7b --- /dev/null +++ b/phash.mbt @@ -0,0 +1,105 @@ +///| +/// Perceptual hashes: compact 64-bit fingerprints that survive resizing, +/// recompression and mild tonal shifts, so visually similar images map to +/// nearby hashes. `average_hash` thresholds an 8x8 luma thumbnail against +/// its mean; `difference_hash` compares horizontal neighbors on a 9x8 +/// thumbnail. Similarity is measured with `hamming_distance` (0 = same, +/// 64 = opposite). + +///| +/// Mean luma of the source-image region covering thumbnail cell +/// `(cx, cy)` in a `cols` x `rows` grid. Uses exact integer accumulation +/// over the (possibly clipped, always non-empty) pixel block. +fn Image::cell_luma( + self : Image, + cx : Int, + cy : Int, + cols : Int, + rows : Int, +) -> Int { + let x0 = cx * self.width / cols + let x1_raw = (cx + 1) * self.width / cols + let y0 = cy * self.height / rows + let y1_raw = (cy + 1) * self.height / rows + // Guarantee at least one pixel per cell even when the image is smaller + // than the grid. + let x1 = if x1_raw <= x0 { x0 + 1 } else { x1_raw } + let y1 = if y1_raw <= y0 { y0 + 1 } else { y1_raw } + let xe = if x1 > self.width { self.width } else { x1 } + let ye = if y1 > self.height { self.height } else { y1 } + let xs = if x0 >= xe { xe - 1 } else { x0 } + let ys = if y0 >= ye { ye - 1 } else { y0 } + let mut sum = 0 + let mut count = 0 + for y in ys.. UInt64 { + let cells = FixedArray::make(64, 0) + let mut total = 0 + for cy in 0..<8 { + for cx in 0..<8 { + let l = self.cell_luma(cx, cy, 8, 8) + cells[cy * 8 + cx] = l + total = total + l + } + } + let mean = total / 64 + let mut hash = 0UL + for i in 0..<64 { + if cells[i] > mean { + hash = hash | (1UL << i) + } + } + hash +} + +///| +/// Difference hash (dHash): bit `i = y*8 + x` is set when cell `(x+1, y)` +/// of a 9x8 luma thumbnail is brighter than cell `(x, y)` — a horizontal +/// gradient signature that is highly robust to global tonal changes. +pub fn Image::difference_hash(self : Image) -> UInt64 { + let mut hash = 0UL + for cy in 0..<8 { + for cx in 0..<8 { + let left = self.cell_luma(cx, cy, 9, 8) + let right = self.cell_luma(cx + 1, cy, 9, 8) + if right > left { + hash = hash | (1UL << (cy * 8 + cx)) + } + } + } + hash +} + +///| +/// Number of differing bits between two 64-bit hashes (0..64). As a rule +/// of thumb for aHash/dHash: <= 5 means "very likely the same image", +/// >= 20 means "clearly different". +pub fn hamming_distance(a : UInt64, b : UInt64) -> Int { + let mut x = a ^ b + let mut count = 0 + while x != 0UL { + count = count + 1 + x = x & (x - 1UL) // clear the lowest set bit + } + count +} diff --git a/phash_test.mbt b/phash_test.mbt new file mode 100644 index 0000000..8f358b5 --- /dev/null +++ b/phash_test.mbt @@ -0,0 +1,108 @@ +///| +/// Perceptual hash tests: hand-computed hashes on tiny images, invariance +/// to scaling, inversion behavior, and Hamming-distance arithmetic. + +///| +test "hamming_distance counts differing bits exactly" { + assert_eq(hamming_distance(0UL, 0UL), 0) + assert_eq(hamming_distance(0xFFUL, 0UL), 8) + assert_eq(hamming_distance(0b1010UL, 0b0101UL), 4) + // All 64 bits differ between all-zeros and all-ones. + assert_eq(hamming_distance(0UL, 0xFFFFFFFFFFFFFFFFUL), 64) +} + +///| +test "average_hash of a half-dark half-bright image is hand-computable" { + // 8x8 image: left 4 columns luma 0, right 4 columns luma 255. Mean is + // ~127, so exactly the 32 right-half bits are set. + let img = @pixelforge.Image::new(8, 8) + for y in 0..<8 { + for x in 0..<8 { + let v : Byte = if x < 4 { b'\x00' } else { b'\xFF' } + img.set_pixel(x, y, v, v, v, b'\xFF') + } + } + let h = img.average_hash() + // Count set bits: must be exactly 32, and bit (y*8+x) set iff x >= 4. + assert_eq(hamming_distance(h, 0UL), 32) + for y in 0..<8 { + for x in 0..<8 { + let bit = (h >> (y * 8 + x)) & 1UL + if x >= 4 { + assert_eq(bit.to_int(), 1) + } else { + assert_eq(bit.to_int(), 0) + } + } + } +} + +///| +test "average_hash is invariant under 4x upscaling" { + // The thumbnail averages regions, so scaling the same pattern up must + // produce the identical hash. + let small = @pixelforge.Image::new(8, 8) + for y in 0..<8 { + for x in 0..<8 { + let v = @pixelforge.clamp_byte((x * 37 + y * 59) % 256) + small.set_pixel(x, y, v, v, v, b'\xFF') + } + } + let big = small.resize_nearest(32, 32) + assert_eq(hamming_distance(small.average_hash(), big.average_hash()), 0) +} + +///| +test "inverting an image flips every average_hash bit" { + // invert() maps luma L to ~255-L, so every above-mean cell goes below + // the (also inverted) mean: distance is exactly 64. + let img = @pixelforge.Image::new(16, 16) + for y in 0..<16 { + for x in 0..<16 { + let v = @pixelforge.clamp_byte(x * 16) + img.set_pixel(x, y, v, v, v, b'\xFF') + } + } + let d = hamming_distance(img.average_hash(), img.invert().average_hash()) + assert_eq(d, 64) +} + +///| +test "difference_hash tracks horizontal gradients" { + // Monotonically brightening columns: every right neighbor is brighter, + // so all 64 dHash bits are set. The vertically-varying image has no + // horizontal gradient, so no bits are set. + let ramp = @pixelforge.Image::new(9, 8) + for y in 0..<8 { + for x in 0..<9 { + let v = @pixelforge.clamp_byte(x * 28) + ramp.set_pixel(x, y, v, v, v, b'\xFF') + } + } + assert_eq(hamming_distance(ramp.difference_hash(), 0UL), 64) + let vert = @pixelforge.Image::new(9, 8) + for y in 0..<8 { + for x in 0..<9 { + let v = @pixelforge.clamp_byte(y * 30) + vert.set_pixel(x, y, v, v, v, b'\xFF') + } + } + assert_eq(hamming_distance(vert.difference_hash(), 0UL), 0) +} + +///| +test "small brightness shifts keep dHash distance at zero" { + // dHash depends only on neighbor ordering, which +20 brightness keeps. + let img = @pixelforge.Image::new(18, 16) + for y in 0..<16 { + for x in 0..<18 { + let v = @pixelforge.clamp_byte((x * 13 + y * 7) % 200) + img.set_pixel(x, y, v, v, v, b'\xFF') + } + } + let shifted = img.brightness(20) + assert_eq( + hamming_distance(img.difference_hash(), shifted.difference_hash()), + 0, + ) +} diff --git a/pkg.generated.mbti b/pkg.generated.mbti index a598a79..aaf8106 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -10,6 +10,8 @@ pub fn clamp_byte(Int) -> Byte pub fn gif_decode(Array[Byte]) -> Image? +pub fn hamming_distance(UInt64, UInt64) -> Int + pub fn hsv_to_rgb(Double, Double, Double) -> (Int, Int, Int) pub fn png_decode(Array[Byte]) -> Image? @@ -65,6 +67,7 @@ pub(all) struct Image { pub fn Image::affine(Self, Affine) -> Self pub fn Image::apply_filter_id(Self, Int, Double) -> Self pub fn Image::auto_contrast(Self) -> Self +pub fn Image::average_hash(Self) -> UInt64 pub fn Image::bilateral(Self, Int, Double, Double) -> Self pub fn Image::blur(Self) -> Self pub fn Image::box_blur(Self, Int) -> Self @@ -76,6 +79,7 @@ pub fn Image::convolve(Self, Kernel) -> Self pub fn Image::copy(Self) -> Self pub fn Image::count_components(Self, Int) -> Int pub fn Image::crop(Self, Int, Int, Int, Int) -> Self +pub fn Image::difference_hash(Self) -> UInt64 pub fn Image::dilate(Self) -> Self pub fn Image::distance_transform(Self, Int) -> Self pub fn Image::dither_grayscale(Self, Int) -> Self