ai newsMi., 4. Nov.
EilmeldungAI code review: team rules for Copilot approvals

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

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 i

dev.toMittwoch 11:19 · 04.11.16 Min. Lesezeit
Bild: dev.to

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.

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 () Enter fullscreen mode Exit fullscreen mode 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/ Enter fullscreen mode Exit fullscreen mode #!/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 ) Enter fullscreen mode Exit fullscreen mode 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 } " ) Enter fullscreen mode Exit fullscreen mode Results 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 - Enter fullscreen mode Exit fullscreen mode 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 - Enter fullscreen mode Exit fullscreen mode 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.

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.

Quelle: dev.to (dev.to). Original öffnen