cd /news/computer-vision/did-someone-repost-my-malfoid-ai-por… · home › topics › computer-vision › article
[ARTICLE · art-163756] src=dev.to ↗ pub= topic=computer-vision verified=true sentiment=· neutral

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

A developer who runs the unofficial portrait studio malfoidai.com published a roughly 120-line Python script (Pillow and NumPy only) that detects reposted images using 64-bit perceptual hashes — aHash, dHash and pHash — and reports the minimum Hamming distance across mirrored candidates and several crops of the original. The script trims uniform screenshot bars only when their color differs from the original's corner color, and the author warns that preset-based generators can produce near-identical hashes for two different people's portraits.

by read11 min views1 publishedNov 4, 2026

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.

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.

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

#!/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). 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), 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.

── more in #computer-vision 4 stories · sorted by recency
── more on @malfoidai.com 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/did-someone-repost-m…] indexed:0 read:11min 2026-11-04 · —