"""Hashing — cryptographic (SHA-256) + perceptual (pHash, dHash, aHash, wHash). Consolidates every hashing need into one module so cache keys, duplicate detection, and integrity checks all use identical implementations. """ from __future__ import annotations import hashlib import cv2 import numpy as np # --------------------------------------------------------------------------- # # Cryptographic # --------------------------------------------------------------------------- # def sha256_bytes(data: bytes) -> str: """SHA-256 hex digest of raw bytes.""" return hashlib.sha256(data).hexdigest() def sha256_image(img: np.ndarray, quality: int = 90) -> str: """SHA-256 of the JPEG-encoded image — stable cache key.""" ok, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality]) if not ok: raise ValueError("Could not encode image for hashing.") return sha256_bytes(buffer.tobytes()) # --------------------------------------------------------------------------- # # Perceptual # --------------------------------------------------------------------------- # def phash(img: np.ndarray, hash_size: int = 8) -> str: """pHash: DCT-based perceptual hash. Returns 64-bit string.""" gray = _to_gray(img) resized = cv2.resize(gray, (hash_size * 4, hash_size * 4), interpolation=cv2.INTER_AREA) dct = cv2.dct(np.float32(resized)) dct_low = dct[:hash_size, :hash_size] median = np.median(dct_low) bits = (dct_low > median).flatten() return _bits_to_hex(bits) def dhash(img: np.ndarray, hash_size: int = 8) -> str: """dHash: difference-based perceptual hash.""" gray = _to_gray(img) resized = cv2.resize(gray, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA) diff = resized[:, 1:] > resized[:, :-1] return _bits_to_hex(diff.flatten()) def ahash(img: np.ndarray, hash_size: int = 8) -> str: """aHash: average hash.""" gray = _to_gray(img) resized = cv2.resize(gray, (hash_size, hash_size), interpolation=cv2.INTER_AREA) avg = resized.mean() bits = (resized > avg).flatten() return _bits_to_hex(bits) def whash(img: np.ndarray, hash_size: int = 8) -> str: """wHash: wavelet hash (Haar wavelet).""" try: import pywt except ImportError: # Fall back to pHash if PyWavelets not available return phash(img, hash_size) gray = _to_gray(img) resized = cv2.resize(gray, (hash_size * 2, hash_size * 2), interpolation=cv2.INTER_AREA) coeffs = pywt.dwt2(resized, "haar") ll, _ = coeffs median = np.median(ll) bits = (ll > median).flatten() return _bits_to_hex(bits) def hamming_distance(a: str, b: str) -> int: """Hamming distance between two hex hash strings.""" if len(a) != len(b): return max(len(a), len(b)) try: ai = int(a, 16) bi = int(b, 16) except ValueError: return sum(c1 != c2 for c1, c2 in zip(a, b)) return bin(ai ^ bi).count("1") # --------------------------------------------------------------------------- # # Internal # --------------------------------------------------------------------------- # def _to_gray(img: np.ndarray) -> np.ndarray: if img.ndim == 2: return img if img.shape[2] == 4: return cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY) return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) def _bits_to_hex(bits: np.ndarray) -> str: """Convert a boolean array to a hex string.""" bits_str = "".join("1" if b else "0" for b in bits) # Pad to multiple of 4 while len(bits_str) % 4 != 0: bits_str += "0" return "".join(hex(int(bits_str[i:i+4], 2))[2:] for i in range(0, len(bits_str), 4))