Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions phash.mbt
Original file line number Diff line number Diff line change
@@ -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..<ye {
for x in xs..<xe {
let base = (y * self.width + x) * 4
sum = sum +
(
299 * self.data[base].to_int() +
587 * self.data[base + 1].to_int() +
114 * self.data[base + 2].to_int()
) /
1000
count = count + 1
}
}
sum / count
}

///|
/// Average hash (aHash): bit `i = y*8 + x` is set when the luma of cell
/// `(x, y)` in the 8x8 thumbnail is strictly greater than the thumbnail
/// mean. Robust to scaling and mild blur; brightness inversion flips
/// every bit.
pub fn Image::average_hash(self : Image) -> 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
}
108 changes: 108 additions & 0 deletions phash_test.mbt
Original file line number Diff line number Diff line change
@@ -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,
)
}
4 changes: 4 additions & 0 deletions pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading