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