File size: 3,958 Bytes
37e3d5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""Perceptual image hashing (DCT-based pHash), pure Python stdlib.

Plan 1.3. Shared by:
  - reference-admission duplicate detection (stage1_intake/check_reference_admission.py, §4.5.2)
  - the Divine Eye's coarse "same-thing?" pre-gate signal (stage4_review/divine_eye.py, §3.1)

Classic pHash: grayscale -> downsample to N×N (default 32) -> 2D DCT-II -> keep the
top-left low-frequency K×K block (default 8, excluding the DC term for robustness to
overall brightness) -> hash bit = coefficient > median. Produces a 64-bit integer whose
Hamming distance approximates perceptual similarity and is invariant to scale, small
blur, and uniform brightness shifts — exactly the invariances a duplicate/near-duplicate
check wants. No PIL/numpy.
"""

from __future__ import annotations

import math

_DCT_CACHE: dict[int, list[list[float]]] = {}


def _dct_matrix(n: int) -> list[list[float]]:
    """Cached DCT-II basis matrix M where out = M @ in."""
    cached = _DCT_CACHE.get(n)
    if cached is not None:
        return cached
    matrix: list[list[float]] = []
    factor = math.pi / (2.0 * n)
    for k in range(n):
        scale = math.sqrt(1.0 / n) if k == 0 else math.sqrt(2.0 / n)
        row = [scale * math.cos((2 * i + 1) * k * factor) for i in range(n)]
        matrix.append(row)
    _DCT_CACHE[n] = matrix
    return matrix


def _dct_2d(block: list[list[float]]) -> list[list[float]]:
    """Separable 2D DCT-II of a square matrix."""
    n = len(block)
    m = _dct_matrix(n)
    # rows: temp = M @ block
    temp = [[sum(m[k][i] * block[i][j] for i in range(n)) for j in range(n)] for k in range(n)]
    # cols: out = temp @ M^T
    out = [[sum(temp[k][j] * m[l][j] for j in range(n)) for l in range(n)] for k in range(n)]
    return out


def to_grayscale_downsampled(
    width: int,
    height: int,
    pixels: list[tuple[int, int, int, int]],
    size: int = 32,
) -> list[list[float]]:
    """Box-average downsample of the luminance channel to size×size (Rec.709 luma)."""
    if width <= 0 or height <= 0 or not pixels:
        return [[0.0] * size for _ in range(size)]
    out = [[0.0] * size for _ in range(size)]
    counts = [[0] * size for _ in range(size)]
    for idx, (r, g, b, _a) in enumerate(pixels):
        x = idx % width
        y = idx // width
        if y >= height:
            break
        sx = min(size - 1, x * size // width)
        sy = min(size - 1, y * size // height)
        out[sy][sx] += 0.2126 * r + 0.7152 * g + 0.0722 * b
        counts[sy][sx] += 1
    for sy in range(size):
        for sx in range(size):
            c = counts[sy][sx]
            if c:
                out[sy][sx] /= c
    return out


def phash(gray: list[list[float]], hash_size: int = 8) -> int:
    """64-bit pHash (for hash_size=8) from a square grayscale matrix (side ≥ hash_size)."""
    n = len(gray)
    if n < hash_size:
        raise ValueError(f"grayscale side {n} smaller than hash_size {hash_size}")
    coeffs = _dct_2d(gray)
    low = [coeffs[k][l] for k in range(hash_size) for l in range(hash_size)]
    # Exclude the DC term (index 0) from the median so overall brightness doesn't dominate.
    ac = low[1:]
    ordered = sorted(ac)
    mid = len(ordered) // 2
    median = ordered[mid] if len(ordered) % 2 else 0.5 * (ordered[mid - 1] + ordered[mid])
    bits = 0
    for i, value in enumerate(low):
        bits <<= 1
        if value > median:
            bits |= 1
    return bits


def phash_from_image(
    width: int,
    height: int,
    pixels: list[tuple[int, int, int, int]],
    hash_size: int = 8,
    img_size: int = 32,
) -> int:
    return phash(to_grayscale_downsampled(width, height, pixels, img_size), hash_size)


def hamming(a: int, b: int) -> int:
    return bin(a ^ b).count("1")


def normalized_similarity(a: int, b: int, bits: int = 64) -> float:
    """1.0 = identical hash, 0.0 = maximally different. Used as a [0,1] agreement score."""
    return 1.0 - hamming(a, b) / bits