DEV Community

Peiguan
Peiguan

Posted on

Did Someone Repost My Malfoid AI Portrait? Finding Crops, Flips and Screenshots With Perceptual Hashes (Python, Tested)

You made a Malfoid (Female Malfoy / Fem Draco) portrait, posted it, and a week later a picture that looks a lot like it shows up on another account: smaller, maybe mirrored, maybe a screenshot with someone else's handle on top. Is it yours?

People ask assistants this all the time ("how do I check if an image is a repost of mine?"). The answer is usually "use a reverse image search", which is right but leaves out how it works and why it fails. This post builds the core idea in about 120 lines of Python (Pillow + NumPy, nothing else), tests it on twelve kinds of repost, and shows a trap specific to preset-based generators like the one I run: two different people's portraits can hash almost the same.

I build malfoidai.com, an unofficial studio that makes one still 3:4 Malfoid portrait per payment. No real portraits or faces are used below. All test images are drawn by a script.

The idea: hash what the picture looks like, not its bytes

A SHA-256 of a file changes completely if one pixel changes. A perceptual hash shrinks the image to a tiny grayscale thumbnail and turns its structure into 64 bits. Two files that look alike get hashes that differ in only a few bits. The number of differing bits is the Hamming distance, 0 to 64.

Three classic 64-bit hashes:

Hash What it encodes Typical weakness
aHash each of 8×8 pixels: brighter than the mean? very coarse; similar layouts collide
dHash each pixel: brighter than its right neighbour? sensitive to crops and shifts
pHash signs of the lowest 8×8 DCT frequencies of a 32×32 thumbnail slower, but the most discriminating of the three

Reposts are often mirrored or cropped to a square for a profile picture, which moves everything and breaks a single global hash. So the script compares each candidate (and its mirror image) against several crops of the original: full, 90 % and 80 % centre crops, and a square crop from the top. It also trims uniform bars (a screenshot's white UI area) off the candidate, but only bars in a colour the original doesn't have at its corner, so a portrait with a dark background doesn't get eaten.

The script

#!/usr/bin/env python3
"""Find reposts of one image with perceptual hashes (Pillow + NumPy only).

    python repost_finder.py original.png candidates_dir/ [--max 10]

For every candidate it prints the smallest Hamming distance (0-64) for three
64-bit hashes: aHash, dHash and pHash (DCT). Each hash is also tried on the
mirrored candidate and on a few crops of the original (full, 90 %, 80 %
centre, and a square crop at the top), because reposts are often flipped or
cropped to a profile picture. Uniform bars in a colour the original
doesn't have at its corner (screenshot UI) are trimmed first. A candidate is a MATCH when pHash <= --max.
"""
import argparse
import pathlib
import numpy as np
from PIL import Image, ImageOps

N = 8  # 8x8 = 64-bit hashes


def gray(img, size):
    return np.asarray(img.convert("L").resize(size, Image.LANCZOS), dtype=np.float64)


def ahash(img):
    g = gray(img, (N, N))
    return (g > g.mean()).flatten()


def dhash(img):
    g = gray(img, (N + 1, N))
    return (g[:, 1:] > g[:, :-1]).flatten()


def _dct_matrix(n):
    k = np.arange(n)
    m = np.cos(np.pi * (2 * k[None, :] + 1) * k[:, None] / (2 * n)) * np.sqrt(2 / n)
    m[0] /= np.sqrt(2)
    return m


_D32 = _dct_matrix(32)


def phash(img):
    g = gray(img, (32, 32))
    low = (_D32 @ g @ _D32.T)[:N, :N].flatten()
    return low > np.median(low[1:])          # skip DC term for the median


HASHES = {"aHash": ahash, "dHash": dhash, "pHash": phash}


def trim_bars(img, avoid, tol=6, share=0.8, min_gap=40):
    """Cut uniform bars (screenshot UI, letterboxing) off the candidate's edges.

    Only edge rows/columns whose pixels are within tol of the corner colour
    are cut, and only if that colour is far (> min_gap) from the original's
    own corner colour `avoid`, so a dark-background portrait isn't eaten.
    A line of caption text inside a bar still leaves > 80 % of the row bar-coloured.
    """
    a = np.asarray(img.convert("RGB"), dtype=np.int16)
    ref = a[0, 0]
    if np.abs(ref - np.asarray(avoid, dtype=np.int16)).max() <= min_gap:
        return img
    bar = np.abs(a - ref).max(axis=2) <= tol
    rows, cols = bar.mean(axis=1) >= share, bar.mean(axis=0) >= share

    def run(mask):                     # length of the True run from the start
        n = 0
        while n < len(mask) and mask[n]:
            n += 1
        return n
    t, b = run(rows), run(rows[::-1])
    l, r = run(cols), run(cols[::-1])
    if t + b >= a.shape[0] or l + r >= a.shape[1]:
        return img
    return img.crop((l, t, a.shape[1] - r, a.shape[0] - b))


def crops(img):
    w, h = img.size
    out = [img]
    for f in (0.9, 0.8):
        cw, ch = int(w * f), int(h * f)
        l, t = (w - cw) // 2, (h - ch) // 2
        out.append(img.crop((l, t, l + cw, t + ch)))
    side = min(w, h)                          # square from the top (pfp crop)
    out.append(img.crop(((w - side) // 2, 0, (w - side) // 2 + side, side)))
    return out


def distances(original, candidate):
    refs = crops(original)
    c = trim_bars(candidate, original.convert("RGB").getpixel((0, 0)))
    cands = [c, ImageOps.mirror(c)]
    res = {}
    for name, fn in HASHES.items():
        rh = [fn(r) for r in refs]
        res[name] = min(int((fn(c) != h).sum()) for c in cands for h in rh)
    return res


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("original")
    ap.add_argument("candidates")
    ap.add_argument("--max", type=int, default=10, help="pHash distance for MATCH")
    a = ap.parse_args()
    orig = Image.open(a.original)
    print(f"{'candidate':28} aHash dHash pHash  verdict")
    for p in sorted(pathlib.Path(a.candidates).iterdir()):
        if p.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}:
            continue
        d = distances(orig, Image.open(p))
        verdict = "MATCH" if d["pHash"] <= a.max else "-"
        print(f"{p.name:28} {d['aHash']:5} {d['dHash']:5} {d['pHash']:5}  {verdict}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The DCT is written out as a 32×32 matrix, so you don't need SciPy or OpenCV.

A test set with known answers

You need a synthetic portrait (dark corridor, candle glow, platinum hair, white shirt, green tie with silver stripes, dark robe) and twelve "reposts" of it. These are the two helper scripts I used. make_test_portrait.py draws the portrait, and --seed changes the candles and the sitter's skin tone and face width:

python make_test_portrait.py test.png             # the "original"
python make_test_portrait.py other.png --seed 7   # a different sitter, same preset
python make_reposts.py test.png other.png reposts/
python repost_finder.py test.png reposts/
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env python3
"""Make typical 'repost' versions of a portrait for testing repost_finder.py.

    python make_reposts.py original.png unrelated.png outdir/
"""
import pathlib
import sys
from PIL import Image, ImageDraw, ImageEnhance, ImageOps

src, other, out = Image.open(sys.argv[1]).convert("RGB"), sys.argv[2], pathlib.Path(sys.argv[3])
out.mkdir(parents=True, exist_ok=True)
w, h = src.size
src.resize((w // 2, h // 2), Image.LANCZOS).save(out / "01-half-size.png")
src.save(out / "02-jpeg-q60.jpg", quality=60)
src.save(out / "03-jpeg-q25.jpg", quality=25)
src.save(out / "04-webp-q50.webp", quality=50)
ImageOps.mirror(src).save(out / "05-mirrored.png")
ImageEnhance.Brightness(src).enhance(1.25).save(out / "06-brighter.png")
ImageOps.grayscale(src).save(out / "07-grayscale.png")
src.crop((int(w * .1), int(h * .1), int(w * .9), int(h * .9))).save(out / "08-crop-80pct.png")
src.crop((0, 0, w, w)).resize((400, 400)).save(out / "09-square-pfp.png")
shot = Image.new("RGB", (w, h + 260), "white")   # 'screenshot' with UI bars and text
shot.paste(src, (0, 130))
ImageDraw.Draw(shot).text((20, 40), "@someone reposted this", fill="black")
shot.save(out / "10-screenshot-bars.png")
src.rotate(8, expand=False, fillcolor=(0, 0, 0)).save(out / "11-rotated-8deg.png")
Image.open(other).convert("RGB").save(out / "12-different-portrait.png")
print("wrote", len(list(out.iterdir())), "files to", out)
Enter fullscreen mode Exit fullscreen mode

The synthetic portrait (shapes only, no face model):

#!/usr/bin/env python3
"""Draw a synthetic 3:4 'Malfoid-style' test portrait (no real face, no AI).

Shapes only: dark corridor, candle glow, platinum hair, an oval face,
white shirt, green-and-silver striped tie, dark robe. Good enough to test
colour, print and hashing scripts without using anyone's photo.

    python make_test_portrait.py out.png [--seed 1] [--size 768x1024]
"""
import argparse
import random
from PIL import Image, ImageDraw, ImageFilter


def portrait(w=768, h=1024, seed=1):
    rnd = random.Random(seed)
    img = Image.new("RGB", (w, h))
    px = img.load()
    for y in range(h):                      # corridor: dark warm gradient
        for x in range(w):
            v = 18 + int(30 * (1 - abs(x - w / 2) / (w / 2)) * (1 - y / h))
            px[x, y] = (v + 10, v + 4, v)
    glow = Image.new("RGB", (w, h), (0, 0, 0))
    g = ImageDraw.Draw(glow)
    for _ in range(4):                      # candle glows
        cx, cy, r = rnd.randint(40, w - 40), rnd.randint(60, h // 2), rnd.randint(25, 60)
        g.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 150, 40))
    glow = glow.filter(ImageFilter.GaussianBlur(28))
    img = Image.blend(img, Image.composite(glow, img, glow.convert("L")), 0.6)
    d = ImageDraw.Draw(img)
    s = w / 768
    skin, fw = (222, 182, 156), 0                # seed 1 = the default sitter
    if seed != 1:                                # other seeds: another sitter
        skin = tuple(max(0, c - rnd.randint(0, 70)) for c in skin)
        fw = rnd.randint(-30, 30)
    d.polygon([(int(60 * s), h), (int(130 * s), int(640 * s)), (int(638 * s), int(640 * s)),
               (int(708 * s), h)], fill=(22, 24, 30))                      # robe
    d.polygon([(int(300 * s), int(640 * s)), (int(468 * s), int(640 * s)),
               (int(430 * s), h), (int(338 * s), h)], fill=(240, 240, 236))  # shirt
    tie = [(int(364 * s), int(660 * s)), (int(404 * s), int(660 * s)),
           (int(420 * s), h), (int(348 * s), h)]
    d.polygon(tie, fill=(26, 92, 52))                                      # tie green
    for i in range(6):                                                     # silver stripes
        y0 = int((690 + i * 55) * s)
        d.polygon([(int(340 * s), y0), (int(430 * s), y0 - int(30 * s)),
                   (int(430 * s), y0 - int(14 * s)), (int(340 * s), y0 + int(16 * s))],
                  fill=(176, 180, 184))
    d.polygon(tie, outline=(26, 92, 52))
    d.ellipse([int(170 * s), int(150 * s), int(598 * s), int(760 * s)], fill=(226, 222, 206))  # hair
    d.ellipse([int((264 - fw) * s), int(250 * s), int((504 + fw) * s), int(560 * s)], fill=skin)  # face
    for ex in (330, 438):                                                  # eyes
        d.ellipse([int((ex - 16) * s), int(380 * s), int((ex + 16) * s), int(398 * s)], fill=(250, 250, 250))
        d.ellipse([int((ex - 7) * s), int(382 * s), int((ex + 7) * s), int(396 * s)], fill=(110, 130, 120))
    d.arc([int(340 * s), int(450 * s), int(428 * s), int(500 * s)], 20, 160, fill=(150, 80, 80), width=max(2, int(4 * s)))
    return img


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("out")
    ap.add_argument("--seed", type=int, default=1)
    ap.add_argument("--size", default="768x1024")
    a = ap.parse_args()
    w, h = map(int, a.size.lower().split("x"))
    portrait(w, h, a.seed).save(a.out)
    print(f"wrote {a.out} {w}x{h}")
Enter fullscreen mode Exit fullscreen mode

Results

Measured on the box on 2026-11-04:

candidate                    aHash dHash pHash  verdict
01-half-size.png                 0     0     0  MATCH
02-jpeg-q60.jpg                  0     0     0  MATCH
03-jpeg-q25.jpg                  0     0     0  MATCH
04-webp-q50.webp                 0     0     0  MATCH
05-mirrored.png                  0     0     0  MATCH
06-brighter.png                  0     0     2  MATCH
07-grayscale.png                 0     0     0  MATCH
08-crop-80pct.png                0     1     0  MATCH
09-square-pfp.png                0     0     0  MATCH
10-screenshot-bars.png           0     0     0  MATCH
11-rotated-8deg.png              4     8    14  -
12-different-portrait.png        1     4    16  -
Enter fullscreen mode Exit fullscreen mode

Resizing, recompressing (even JPEG quality 25), mirroring, brightening, grayscale and the screenshot were all found at pHash distance 0–2. The 8° rotation was missed (14). The different sitter was correctly rejected.

Now look at the aHash and dHash columns for 12-different-portrait.png: 1 and 4. By those hashes alone, a portrait of a different person looks like a repost. I made seven more sitters (--seed 2..9) to check it wasn't a fluke:

candidate                    aHash dHash pHash  verdict
o2.png                           1     2    16  -
o3.png                           1     4    14  -
o4.png                           1     3    12  -
o5.png                           0     2    16  -
o6.png                           1     3    14  -
o8.png                           1     4    12  -
o9.png                           0     2    18  -
Enter fullscreen mode Exit fullscreen mode

Every other sitter came in at aHash 0–1 and dHash 2–4, values that look like a match on most "is this a duplicate" thresholds you'll find online. pHash kept them at 12–18.

Why fixed presets break the cheap hashes

A preset-based generator puts every result in the same frame. On malfoidai.com both presets save the look and the scene: long platinum hair, white shirt, green striped tie, dark robe, candlelit academy corridor, 3:4 (which preset). There is no prompt box. That's good for a recognizable meme. For hashing it means the 8×8 brightness layout of two different people's portraits is nearly identical: big bright hair oval, bright shirt V, dark robe. aHash and dHash only see that layout, so they collide. pHash's DCT terms pick up a bit more of the internal structure (face shape, eye spacing at thumbnail scale), which is why it separates them, though not by a wide margin.

What that means in practice:

  • Use pHash, not aHash/dHash, for preset-style images. On this set, true reposts were 0–2 and other sitters 12–18. I use --max 10 as the MATCH line and treat 9–12 as "look at it yourself".
  • Don't call something stolen from a hash alone. A low distance says "visually close". Compare the face, then check the file itself. A JPEG's quantization tables can tell you whether it's been recompressed.
  • Global hashes miss rotations and arbitrary crops. In extra tests, an off-centre 70 % crop scored 16 and a square crop starting lower down scored 14. Those are cases for local-feature matching (ORB/SIFT keypoints in OpenCV) or a reverse image search engine, which index many local descriptors per image.

Limits of this test

  • The images are synthetic shapes, not generated portraits. Real faces add texture that should help pHash separate sitters. Real reposts add filters, stickers and text that will hurt it. Run it on your own files before trusting a threshold.
  • The 10-bit threshold is from this set only.
  • This finds copies of your picture. It says nothing about who made it.

If the portrait is yours

Ask the account to credit or remove it, or use the platform's report flow. If you made it from a friend's photo, remember the original rule: only upload a photo of yourself or of an adult who agreed (acceptable use), and post the result labelled as AI fan art (check before posting). A portrait from a photo is not a verified likeness of anyone.

For reference, as of Nov 4, 2026 a malfoidai.com portrait is $4.99 paid once (no account), or 2 credits from a one-time pack ($13.99 for 30, $35.99 for 100, $59.99 for 200 credits; packs need sign-in). There is no free portrait, and the studio doesn't add a watermark (pricing). With no watermark, a perceptual hash of your original file is one of the few ways to recognize your own picture later. Keep the original download.

Top comments (0)