File size: 5,467 Bytes
d708e6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""
duplicate_detector.py
----------------------
Two layers of duplicate detection:

  1. Exact duplicates -- SHA256 of the raw PNG-encoded pixel bytes.
  2. Visual (near) duplicates -- a perceptual hash (dHash, 64-bit) with
     Hamming-distance comparison. dHash is used instead of average-hash
     because it is far more robust to the flat color blocks typical of
     pixel art (aHash tends to false-positive heavily on sprites that
     share a dominant background/body color).

No external `imagehash` dependency is required -- the hash is implemented
directly with numpy/Pillow (both already required elsewhere in this
project), which also keeps the Space's dependency footprint smaller.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from PIL import Image

from config import PHASH_DUPLICATE_DISTANCE_FRACTION, PHASH_SIZE
from image_utils import sha256_bytes

TOTAL_HASH_BITS = PHASH_SIZE * PHASH_SIZE + 4 * 4 * 3  # structural + color bits


@dataclass
class DuplicateMatch:
    sprite_id: str
    exact: bool
    hamming_distance: int
    similarity_percent: float


def compute_sha256(png_bytes: bytes) -> str:
    return sha256_bytes(png_bytes)


def compute_dhash(rgba: np.ndarray, hash_size: int = PHASH_SIZE) -> str:
    """
    Perceptual hash combining two signals so that visually distinct
    sprites of the same *shape* (e.g. two same-silhouette plants in
    different colors) are not conflated:

      1. Structural dHash (grayscale gradient hash, `hash_size x hash_size`
         bits): captures the silhouette/shape/light-dark edge pattern.
      2. Coarse color hash (4x4 average-color grid, 3 bits per cell --
         one bit per RGB channel vs. that channel's sprite-wide median):
         captures roughly *which* colors dominate the sprite, cheaply.

    Both resizes are for HASH COMPUTATION ONLY -- never written to disk or
    used as the exported sprite -- so this does not violate the
    pixel-preservation rule, which applies to dataset *output*.

    Returns a hex string encoding the combined bit string.
    """
    if rgba.shape[0] == 0 or rgba.shape[1] == 0:
        return "0" * ((hash_size * hash_size + 4 * 4 * 3) // 4 + 1)

    img = Image.fromarray(rgba, mode="RGBA")
    background = Image.new("RGBA", img.size, (128, 128, 128, 255))
    composited = Image.alpha_composite(background, img)

    # --- structural component (grayscale gradient hash) ---
    gray = composited.convert("L")
    small_gray = gray.resize((hash_size + 1, hash_size), resample=Image.NEAREST)
    gray_pixels = np.asarray(small_gray, dtype=np.int16)
    structural_bits = (gray_pixels[:, 1:] > gray_pixels[:, :-1]).flatten()

    # --- color component (coarse 4x4 average-color grid, absolute-referenced) ---
    # Compared against a fixed midpoint (128) rather than the sprite's own
    # median: a self-referenced median captures "brighter/darker than this
    # sprite's own average", which duplicates the structural hash's job and
    # collapses to the identical bit pattern for e.g. a green sprite and a
    # magenta sprite of the same silhouette on the same background (their
    # own two-tone medians make every foreground pixel "above median" and
    # every background pixel "below" regardless of which color it actually
    # is). An absolute reference keeps the hash sensitive to hue and channel
    # balance, which is exactly the visual duplicate signal color is meant
    # to add on top of the structural hash.
    rgb_img = composited.convert("RGB")
    grid = 4
    small_color = rgb_img.resize((grid, grid), resample=Image.NEAREST)
    color_pixels = np.asarray(small_color, dtype=np.int16).reshape(-1, 3)  # (16, 3)
    color_bits = (color_pixels > 128).flatten()  # 16*3 = 48 bits

    all_bits = np.concatenate([structural_bits, color_bits])

    value = 0
    for bit in all_bits:
        value = (value << 1) | int(bit)
    hex_digits = (len(all_bits) + 3) // 4
    return format(value, f"0{hex_digits}x")


def hamming_distance(hash_a: str, hash_b: str) -> int:
    int_a = int(hash_a, 16)
    int_b = int(hash_b, 16)
    return bin(int_a ^ int_b).count("1")


def find_duplicates(
    sha256_hash: str,
    dhash: str,
    existing: list[dict],
    distance_threshold: int | None = None,
) -> list[DuplicateMatch]:
    """
    `existing` is a list of dicts with at least {"id", "sha256", "phash"}
    representing already-accepted or already-staged sprites to compare
    against. `distance_threshold` defaults to
    PHASH_DUPLICATE_DISTANCE_FRACTION * TOTAL_HASH_BITS when not given.
    """
    if distance_threshold is None:
        distance_threshold = round(PHASH_DUPLICATE_DISTANCE_FRACTION * TOTAL_HASH_BITS)

    matches: list[DuplicateMatch] = []
    for item in existing:
        if item.get("sha256") == sha256_hash:
            matches.append(DuplicateMatch(sprite_id=item["id"], exact=True, hamming_distance=0, similarity_percent=100.0))
            continue
        other_hash = item.get("phash")
        if not other_hash:
            continue
        dist = hamming_distance(dhash, other_hash)
        if dist <= distance_threshold:
            similarity = 100.0 * (1 - dist / max(1, TOTAL_HASH_BITS))
            matches.append(
                DuplicateMatch(sprite_id=item["id"], exact=False, hamming_distance=dist, similarity_percent=similarity)
            )
    matches.sort(key=lambda m: (not m.exact, m.hamming_distance))
    return matches