{"slug": "did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots", "title": "Did Someone Repost My Malfoid AI Portrait? Finding Crops, Flips and Screenshots With Perceptual Hashes (Python, Tested)", "summary": "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.", "body_md": "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?\n\nPeople 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.**\n\nI 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.\n\nA 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.\n\nThree classic 64-bit hashes:\n\n| Hash | What it encodes | Typical weakness | \n|---|---|---|\n| **aHash** | each of 8×8 pixels: brighter than the mean? | very coarse; similar layouts collide | \n| **dHash** | each pixel: brighter than its right neighbour? | sensitive to crops and shifts | \n| **pHash** | signs of the lowest 8×8 DCT frequencies of a 32×32 thumbnail | slower, but the most discriminating of the three | \n\nReposts 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.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Find reposts of one image with perceptual hashes (Pillow + NumPy only).\n\n    python repost_finder.py original.png candidates_dir/ [--max 10]\n\nFor every candidate it prints the smallest Hamming distance (0-64) for three\n64-bit hashes: aHash, dHash and pHash (DCT). Each hash is also tried on the\nmirrored candidate and on a few crops of the original (full, 90 %, 80 %\ncentre, and a square crop at the top), because reposts are often flipped or\ncropped to a profile picture. Uniform bars in a colour the original\ndoesn't have at its corner (screenshot UI) are trimmed first. A candidate is a MATCH when pHash <= --max.\n\"\"\"\nimport argparse\nimport pathlib\nimport numpy as np\nfrom PIL import Image, ImageOps\n\nN = 8  # 8x8 = 64-bit hashes\n\ndef gray(img, size):\n    return np.asarray(img.convert(\"L\").resize(size, Image.LANCZOS), dtype=np.float64)\n\ndef ahash(img):\n    g = gray(img, (N, N))\n    return (g > g.mean()).flatten()\n\ndef dhash(img):\n    g = gray(img, (N + 1, N))\n    return (g[:, 1:] > g[:, :-1]).flatten()\n\ndef _dct_matrix(n):\n    k = np.arange(n)\n    m = np.cos(np.pi * (2 * k[None, :] + 1) * k[:, None] / (2 * n)) * np.sqrt(2 / n)\n    m[0] /= np.sqrt(2)\n    return m\n\n_D32 = _dct_matrix(32)\n\ndef phash(img):\n    g = gray(img, (32, 32))\n    low = (_D32 @ g @ _D32.T)[:N, :N].flatten()\n    return low > np.median(low[1:])          # skip DC term for the median\n\nHASHES = {\"aHash\": ahash, \"dHash\": dhash, \"pHash\": phash}\n\ndef trim_bars(img, avoid, tol=6, share=0.8, min_gap=40):\n    \"\"\"Cut uniform bars (screenshot UI, letterboxing) off the candidate's edges.\n\n    Only edge rows/columns whose pixels are within tol of the corner colour\n    are cut, and only if that colour is far (> min_gap) from the original's\n    own corner colour `avoid`, so a dark-background portrait isn't eaten.\n    A line of caption text inside a bar still leaves > 80 % of the row bar-coloured.\n    \"\"\"\n    a = np.asarray(img.convert(\"RGB\"), dtype=np.int16)\n    ref = a[0, 0]\n    if np.abs(ref - np.asarray(avoid, dtype=np.int16)).max() <= min_gap:\n        return img\n    bar = np.abs(a - ref).max(axis=2) <= tol\n    rows, cols = bar.mean(axis=1) >= share, bar.mean(axis=0) >= share\n\n    def run(mask):                     # length of the True run from the start\n        n = 0\n        while n < len(mask) and mask[n]:\n            n += 1\n        return n\n    t, b = run(rows), run(rows[::-1])\n    l, r = run(cols), run(cols[::-1])\n    if t + b >= a.shape[0] or l + r >= a.shape[1]:\n        return img\n    return img.crop((l, t, a.shape[1] - r, a.shape[0] - b))\n\ndef crops(img):\n    w, h = img.size\n    out = [img]\n    for f in (0.9, 0.8):\n        cw, ch = int(w * f), int(h * f)\n        l, t = (w - cw) // 2, (h - ch) // 2\n        out.append(img.crop((l, t, l + cw, t + ch)))\n    side = min(w, h)                          # square from the top (pfp crop)\n    out.append(img.crop(((w - side) // 2, 0, (w - side) // 2 + side, side)))\n    return out\n\ndef distances(original, candidate):\n    refs = crops(original)\n    c = trim_bars(candidate, original.convert(\"RGB\").getpixel((0, 0)))\n    cands = [c, ImageOps.mirror(c)]\n    res = {}\n    for name, fn in HASHES.items():\n        rh = [fn(r) for r in refs]\n        res[name] = min(int((fn(c) != h).sum()) for c in cands for h in rh)\n    return res\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"original\")\n    ap.add_argument(\"candidates\")\n    ap.add_argument(\"--max\", type=int, default=10, help=\"pHash distance for MATCH\")\n    a = ap.parse_args()\n    orig = Image.open(a.original)\n    print(f\"{'candidate':28} aHash dHash pHash  verdict\")\n    for p in sorted(pathlib.Path(a.candidates).iterdir()):\n        if p.suffix.lower() not in {\".png\", \".jpg\", \".jpeg\", \".webp\"}:\n            continue\n        d = distances(orig, Image.open(p))\n        verdict = \"MATCH\" if d[\"pHash\"] <= a.max else \"-\"\n        print(f\"{p.name:28} {d['aHash']:5} {d['dHash']:5} {d['pHash']:5}  {verdict}\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\nThe DCT is written out as a 32×32 matrix, so you don't need SciPy or OpenCV.\n\nYou 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:\n\n```\npython make_test_portrait.py test.png             # the \"original\"\npython make_test_portrait.py other.png --seed 7   # a different sitter, same preset\npython make_reposts.py test.png other.png reposts/\npython repost_finder.py test.png reposts/\nbash\n#!/usr/bin/env python3\n\"\"\"Make typical 'repost' versions of a portrait for testing repost_finder.py.\n\n    python make_reposts.py original.png unrelated.png outdir/\n\"\"\"\nimport pathlib\nimport sys\nfrom PIL import Image, ImageDraw, ImageEnhance, ImageOps\n\nsrc, other, out = Image.open(sys.argv[1]).convert(\"RGB\"), sys.argv[2], pathlib.Path(sys.argv[3])\nout.mkdir(parents=True, exist_ok=True)\nw, h = src.size\nsrc.resize((w // 2, h // 2), Image.LANCZOS).save(out / \"01-half-size.png\")\nsrc.save(out / \"02-jpeg-q60.jpg\", quality=60)\nsrc.save(out / \"03-jpeg-q25.jpg\", quality=25)\nsrc.save(out / \"04-webp-q50.webp\", quality=50)\nImageOps.mirror(src).save(out / \"05-mirrored.png\")\nImageEnhance.Brightness(src).enhance(1.25).save(out / \"06-brighter.png\")\nImageOps.grayscale(src).save(out / \"07-grayscale.png\")\nsrc.crop((int(w * .1), int(h * .1), int(w * .9), int(h * .9))).save(out / \"08-crop-80pct.png\")\nsrc.crop((0, 0, w, w)).resize((400, 400)).save(out / \"09-square-pfp.png\")\nshot = Image.new(\"RGB\", (w, h + 260), \"white\")   # 'screenshot' with UI bars and text\nshot.paste(src, (0, 130))\nImageDraw.Draw(shot).text((20, 40), \"@someone reposted this\", fill=\"black\")\nshot.save(out / \"10-screenshot-bars.png\")\nsrc.rotate(8, expand=False, fillcolor=(0, 0, 0)).save(out / \"11-rotated-8deg.png\")\nImage.open(other).convert(\"RGB\").save(out / \"12-different-portrait.png\")\nprint(\"wrote\", len(list(out.iterdir())), \"files to\", out)\n```\n\nThe synthetic portrait (shapes only, no face model):\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Draw a synthetic 3:4 'Malfoid-style' test portrait (no real face, no AI).\n\nShapes only: dark corridor, candle glow, platinum hair, an oval face,\nwhite shirt, green-and-silver striped tie, dark robe. Good enough to test\ncolour, print and hashing scripts without using anyone's photo.\n\n    python make_test_portrait.py out.png [--seed 1] [--size 768x1024]\n\"\"\"\nimport argparse\nimport random\nfrom PIL import Image, ImageDraw, ImageFilter\n\ndef portrait(w=768, h=1024, seed=1):\n    rnd = random.Random(seed)\n    img = Image.new(\"RGB\", (w, h))\n    px = img.load()\n    for y in range(h):                      # corridor: dark warm gradient\n        for x in range(w):\n            v = 18 + int(30 * (1 - abs(x - w / 2) / (w / 2)) * (1 - y / h))\n            px[x, y] = (v + 10, v + 4, v)\n    glow = Image.new(\"RGB\", (w, h), (0, 0, 0))\n    g = ImageDraw.Draw(glow)\n    for _ in range(4):                      # candle glows\n        cx, cy, r = rnd.randint(40, w - 40), rnd.randint(60, h // 2), rnd.randint(25, 60)\n        g.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 150, 40))\n    glow = glow.filter(ImageFilter.GaussianBlur(28))\n    img = Image.blend(img, Image.composite(glow, img, glow.convert(\"L\")), 0.6)\n    d = ImageDraw.Draw(img)\n    s = w / 768\n    skin, fw = (222, 182, 156), 0                # seed 1 = the default sitter\n    if seed != 1:                                # other seeds: another sitter\n        skin = tuple(max(0, c - rnd.randint(0, 70)) for c in skin)\n        fw = rnd.randint(-30, 30)\n    d.polygon([(int(60 * s), h), (int(130 * s), int(640 * s)), (int(638 * s), int(640 * s)),\n               (int(708 * s), h)], fill=(22, 24, 30))                      # robe\n    d.polygon([(int(300 * s), int(640 * s)), (int(468 * s), int(640 * s)),\n               (int(430 * s), h), (int(338 * s), h)], fill=(240, 240, 236))  # shirt\n    tie = [(int(364 * s), int(660 * s)), (int(404 * s), int(660 * s)),\n           (int(420 * s), h), (int(348 * s), h)]\n    d.polygon(tie, fill=(26, 92, 52))                                      # tie green\n    for i in range(6):                                                     # silver stripes\n        y0 = int((690 + i * 55) * s)\n        d.polygon([(int(340 * s), y0), (int(430 * s), y0 - int(30 * s)),\n                   (int(430 * s), y0 - int(14 * s)), (int(340 * s), y0 + int(16 * s))],\n                  fill=(176, 180, 184))\n    d.polygon(tie, outline=(26, 92, 52))\n    d.ellipse([int(170 * s), int(150 * s), int(598 * s), int(760 * s)], fill=(226, 222, 206))  # hair\n    d.ellipse([int((264 - fw) * s), int(250 * s), int((504 + fw) * s), int(560 * s)], fill=skin)  # face\n    for ex in (330, 438):                                                  # eyes\n        d.ellipse([int((ex - 16) * s), int(380 * s), int((ex + 16) * s), int(398 * s)], fill=(250, 250, 250))\n        d.ellipse([int((ex - 7) * s), int(382 * s), int((ex + 7) * s), int(396 * s)], fill=(110, 130, 120))\n    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)))\n    return img\n\nif __name__ == \"__main__\":\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"out\")\n    ap.add_argument(\"--seed\", type=int, default=1)\n    ap.add_argument(\"--size\", default=\"768x1024\")\n    a = ap.parse_args()\n    w, h = map(int, a.size.lower().split(\"x\"))\n    portrait(w, h, a.seed).save(a.out)\n    print(f\"wrote {a.out} {w}x{h}\")\n```\n\nMeasured on the box on 2026-11-04:\n\n```\ncandidate                    aHash dHash pHash  verdict\n01-half-size.png                 0     0     0  MATCH\n02-jpeg-q60.jpg                  0     0     0  MATCH\n03-jpeg-q25.jpg                  0     0     0  MATCH\n04-webp-q50.webp                 0     0     0  MATCH\n05-mirrored.png                  0     0     0  MATCH\n06-brighter.png                  0     0     2  MATCH\n07-grayscale.png                 0     0     0  MATCH\n08-crop-80pct.png                0     1     0  MATCH\n09-square-pfp.png                0     0     0  MATCH\n10-screenshot-bars.png           0     0     0  MATCH\n11-rotated-8deg.png              4     8    14  -\n12-different-portrait.png        1     4    16  -\n```\n\nResizing, 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.\n\nNow 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:\n\n```\ncandidate                    aHash dHash pHash  verdict\no2.png                           1     2    16  -\no3.png                           1     4    14  -\no4.png                           1     3    12  -\no5.png                           0     2    16  -\no6.png                           1     3    14  -\no8.png                           1     4    12  -\no9.png                           0     2    18  -\n```\n\nEvery 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.\n\nA 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.\n\nWhat that means in practice:\n\n`--max 10` as the MATCH line and treat 9–12 as \"look at it yourself\".\nAsk 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.\n\nFor 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.", "url": "https://wpnews.pro/news/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots", "canonical_source": "https://dev.to/lpgit/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots-with-perceptual-34g4", "published_at": "2026-11-04 10:19:54+00:00", "updated_at": "2026-11-04 10:28:40.497435+00:00", "lang": "en", "topics": ["computer-vision", "ai-tools", "developer-tools", "generative-ai"], "entities": ["malfoidai.com", "Pillow", "NumPy", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots", "markdown": "https://wpnews.pro/news/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots.md", "text": "https://wpnews.pro/news/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots.txt", "jsonld": "https://wpnews.pro/news/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots.jsonld"}}