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

> Source: <https://dev.to/lpgit/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots-with-perceptual-34g4>
> Published: 2026-11-04 10:19:54+00:00

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](https://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.

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.

``` bash
#!/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()
```

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

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/
bash
#!/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)
```

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

``` bash
#!/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}")
```

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  -
```

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  -
```

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.

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](https://malfoidai.com/guides/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:

`--max 10` as the MATCH line and treat 9–12 as "look at it yourself".
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](https://malfoidai.com/acceptable-use/)), and post the result labelled as AI fan art ([check before posting](https://malfoidai.com/guides/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](https://malfoidai.com/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.
