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() 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/ #!/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(Source:https://dev.to/lpgit/did-someone-repost-my-malfoid-ai-portrait-finding-crops-flips-and-screenshots-with-perceptual-34g4Related posts
Streamline Documentation Delivery: Export GitHub Markdown to PDF for Enhanced Productivity

The AI Didn't Break In. Someone Opened the Door.

The Undo Button for Agents That Is Not a Fork
Build Real-World Microsoft Skills for Free with Microsoft Learn Applied Skills 🚀
WriteFreely: 49 title matches and an administrator who may be the first user
