November 3, 2026. I build malfoidai.com, a small studio that turns one photo of an adult into a still 3:4 Malfoid (Female Malfoy) fan portrait. This post is about the step before any of that: picking the photo. The script below is mine and runs locally. It doesn't upload anything.
Short answer: the photo decides most of the result. The studio's own photo guide puts it as "one clear, front-facing photo of one adult with the whole face visible, in even light and without a heavy filter. JPEG, PNG or WebP up to 10 MB." Most of that can be checked by a machine in under a second: is it a supported file, is there exactly one face, is the face big enough, is it sharp, is it lit. So I wrote a 100-line checker that does those checks before you pay $4.99 for a portrait (pricing).
Why check the photo at all?
A one-photo portrait pipeline keeps the face from your picture and replaces everything else: hair, clothes, light, background. The guide says it bluntly: "the face is the only thing the photo has to get right." That makes the input unusually important. The model can't invent detail your photo never had. A face that's 75 pixels tall, motion-blurred or lost in a dark room becomes a guess, and a guess rarely looks like you.
It also changes what "bad result" means. The pre-posting guide separates a portrait that failed from one that "finished but does not look right," and for the second kind "the fix is usually the photo." A delivered portrait isn't refunded as a change of mind (refunds). So the cheap place to catch a bad photo is before you upload it.
What the checker tests
| Check | Rule source | How it's measured |
|---|---|---|
| File size | Site: "up to 10 MB" | os.path.getsize |
| Format | Site: "JPEG, PNG or WebP" | Pillow's decoded format, not the file extension |
| Face count | Site: "a group photo can pick the wrong face" | OpenCV Haar frontal-face detector |
| Face size | Heuristic | face box height vs photo height and in pixels |
| Sharpness | Heuristic | Laplacian std / grey std on a 256x256 face crop |
| Exposure | Site: "even light" | face mean brightness and clipped pixels |
| Stamp test | Site's "ten-second test" | shrink to 48 px wide, enlarge back, look for the face again |
The last one comes straight from the photo guide: "Open the photo on your phone and zoom out until it is about the size of a stamp. If you can still tell whose face it is, the photo is good." A detector is a crude stand-in for your eyes, but it fails in the same situations: tiny faces, deep shadow, heavy blur.
Two design choices worth explaining:
- Orientation first. Phone photos are often stored sideways with an EXIF Orientation tag.
ImageOps.exif_transposemakes the pixels upright before detection. Skip that and a perfectly good selfie reports "no face." - Sharpness is normalised. Raw variance of the Laplacian (the usual blur metric) drops when a photo is dark, so a sharp-but-dim selfie looks "blurry." Dividing by the crop's own contrast separates the two problems. In my tests the dark photo scored sharper than the normal one (0.152 vs 0.110) while failing exposure, which is what you want: two different warnings for two different fixes.
The script
python3 -m venv .venv && . .venv/bin/activate pip install "opencv-python-headless<5" pillow numpy python3 selfie_check.py my_selfie.jpg Pin OpenCV below 5. In the 5.0 wheel I tried, cv2.CascadeClassifier is gone from the main module, and the script fails at import.
#!/usr/bin/env python3 """selfie_check.py - pre-upload checks for a one-photo AI portrait (e.g. a Malfoid portrait). Checks a photo against the published upload rules (JPEG/PNG/WebP, up to 10 MB, one front-facing face) plus four heuristics: face size, face sharpness, exposure and the "stamp test" (is the face still a face when the photo is shrunk to stamp size?). pip install opencv-python-headless pillow numpy python3 selfie_check.py photo.jpg [more.jpg ...] Exit code: 0 if every photo has no FAIL, 1 otherwise. Thresholds are heuristics, not anything a generator publishes. Tune them on your own photos. """ import os, sys import numpy as np import cv2 from PIL import Image, ImageOps MAX_BYTES = 10 * 1024 * 1024 # "JPEG, PNG or WebP up to 10 MB" FORMATS = {"JPEG", "PNG", "WEBP"} MIN_FACE_FRAC = 0.15 # face box height / image height MIN_FACE_PX = 160 # face box height in pixels MIN_SHARP = 0.07 # Laplacian std / grey std on a 256x256 face crop STAMP_PX = 48 # width of the "stamp" in the stamp test CASCADE = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml") def faces(gray): h = gray.shape[0] found, _, weights = CASCADE.detectMultiScale3(gray, scaleFactor=1.1, minNeighbors=10, minSize=(max(24, h // 80),) * 2, outputRejectLevels=True) # most confident detection first order = np.argsort(-np.asarray(weights).ravel()) if len(found) else [] return [tuple(int(v) for v in found[i]) for i in order] def check(path): out = [] # (level, name, message) size = os.path.getsize(path) out.append(("FAIL" if size > MAX_BYTES else "OK", "file size", f"{size / 1048576:.2f} MB (limit 10 MB)")) try: im = Image.open(path) fmt = im.format im = ImageOps.exif_transpose(im).convert("RGB") # what the phone shows, upright except Exception as e: return [("FAIL", "decode", f"not a readable image: {e.__class__.__name__}")] out.append(("OK" if fmt in FORMATS else "FAIL", "format", f"{fmt} (allowed: JPEG, PNG, WebP)")) rgb = np.asarray(im) gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) H, W = gray.shape fs = faces(gray) if not fs: out.append(("FAIL", "faces", "no front-facing face found (profile, sunglasses, too dark, or no person)")) return out lvl = "OK" if len(fs) == 1 else "WARN" out.append((lvl, "faces", f"{len(fs)} found" + ("" if len(fs) == 1 else " - a group photo can pick the wrong face"))) x, y, w, h = fs[0] frac = h / H lvl = "OK" if (frac >= MIN_FACE_FRAC and h >= MIN_FACE_PX) else "WARN" out.append((lvl, "face size", f"{h}px tall, {frac:.0%} of the photo height (want >= {MIN_FACE_FRAC:.0%} and >= {MIN_FACE_PX}px)")) crop = gray[y:y + h, x:x + w] crop = cv2.resize(crop, (256, 256), interpolation=cv2.INTER_AREA).astype(np.float64) # Normalising by the crop's own contrast keeps a dark-but-sharp photo from looking "blurry". sharp = float(cv2.Laplacian(crop, cv2.CV_64F).std() / max(crop.std(), 1.0)) out.append(("OK" if sharp >= MIN_SHARP else "WARN", "face sharpness", f"{sharp:.3f} (want >= {MIN_SHARP}; low = blur, motion or a smoothing filter)")) face_mean = float(crop.mean()) clip_hi = float((crop >= 250).mean()); clip_lo = float((crop <= 5).mean()) lvl = "OK" if face_mean < 60 or face_mean > 210 or clip_hi > 0.05 or clip_lo > 0.05: lvl = "WARN" out.append((lvl, "exposure", f"face mean {face_mean:.0f}/255, clipped {clip_lo:.1%} black / {clip_hi:.1%} white")) stamp = cv2.resize(gray, (STAMP_PX, int(STAMP_PX * H / W)), interpolation=cv2.INTER_AREA) back = cv2.resize(stamp, (W, H), interpolation=cv2.INTER_LINEAR) still = faces(back) lvl = "OK" if still else "WARN" out.append((lvl, "stamp test", f"face {'still' if still else 'NOT'} detectable at {STAMP_PX}px wide")) return out def main(paths): worst = 0 for p in paths: print(p) for lvl, name, msg in check(p): worst = max(worst, {"OK": 0, "WARN": 0, "FAIL": 1}[lvl]) print(f" {lvl:4} {name:15} {msg}") return worst if __name__ == "__main__": if len(sys.argv) < 2: sys.exit(__doc__) sys.exit(main(sys.argv[1:])) What it printed on my test photos
I used a public-domain NASA portrait (scikit-image's astronaut sample) as the "good selfie" and made four bad versions from it: Gaussian blur, 25% brightness, the same face pasted small into a 3000x4000 frame, and two copies side by side as a "group photo." Trimmed output:
good.jpg OK faces 1 found OK face size 197px tall, 19% of the photo height (want >= 15% and >= 160px) OK face sharpness 0.110 (want >= 0.07; low = blur, motion or a smoothing filter) OK exposure face mean 153/255, clipped 0.8% black / 0.0% white OK stamp test face still detectable at 48px wide blurry.jpg OK faces 1 found OK face size 194px tall, 19% of the photo height (want >= 15% and >= 160px) WARN face sharpness 0.036 (want >= 0.07; low = blur, motion or a smoothing filter) OK exposure face mean 155/255, clipped 0.2% black / 0.0% white OK stamp test face still detectable at 48px wide dark.jpg OK faces 1 found OK face size 197px tall, 19% of the photo height (want >= 15% and >= 160px) OK face sharpness 0.152 (want >= 0.07; low = blur, motion or a smoothing filter) WARN exposure face mean 38/255, clipped 2.3% black / 0.0% white WARN stamp test face NOT detectable at 48px wide tiny_face.jpg OK faces 1 found WARN face size 75px tall, 2% of the photo height (want >= 15% and >= 160px) OK face sharpness 0.231 (want >= 0.07; low = blur, motion or a smoothing filter) OK exposure face mean 155/255, clipped 0.6% black / 0.0% white WARN stamp test face NOT detectable at 48px wide group.jpg WARN faces 2 found - a group photo can pick the wrong face OK face size 205px tall, 20% of the photo height (want >= 15% and >= 160px) OK face sharpness 0.105 (want >= 0.07; low = blur, motion or a smoothing filter) OK exposure face mean 151/255, clipped 1.4% black / 0.0% white OK stamp test face still detectable at 48px wide noface.png FAIL faces no front-facing face found (profile, sunglasses, too dark, or no person) Each bad photo trips a different check, and the good one passes all of them. The tiny-face photo is the interesting one. The detector finds the face, but at 75 px tall (2% of the frame) it's marked too small, and the stamp test loses it. That's the classic holiday photo the guide warns about: "Holiday photos often fail because the face is small, in sunglasses or in hard midday sun."
What it can't tell you
- Whether the person agreed. The studio accepts "your own, or an adult who agreed. Not a child, a celebrity or someone from an image search" (acceptable use). No script can check that.
- Profile shots and sunglasses just show up as "no face found." The Haar cascade only knows frontal faces. That matches the guide's advice ("Facing the camera, eyes open"), but don't read the message as an age or identity check. It isn't one.
- Filters. Beauty filters often raise the sharpness score because they add crisp edges while flattening skin. The guide says to avoid "Beauty filters, face-swap effects, cartoon filters." Use the original photo, not an edited copy.
- The thresholds are mine. Nothing here is published by any generator. They're tuned on a handful of photos. If your good selfies keep getting WARN, loosen
MIN_FACE_FRACorMIN_SHARPat the top of the file.
If no photo passes
Two options. Take a new selfie facing a window with no filter ("a new selfie takes a minute and nearly always beats an old favorite photo," per the guide). Or skip the photo: the no-photo preset makes a fictional adult in the same Malfoid look, also $4.99 or 2 credits. Both are one still 3:4 picture at 1K, and there's no prompt box to tune.
If you're testing several photos, a pack works out cheaper per portrait. The packs are $13.99 for 30 credits, $35.99 for 100, or $59.99 for 200, they're one-time purchases, and buying one needs an account. A single portrait can be paid as a guest (pricing). Run the checker first, though. It costs nothing, and the photo is the variable that matters most.
Malfoid is unofficial fan art, not affiliated with any book, film, studio or author. Prices checked on malfoidai.com on November 3, 2026, 18:00 CST (UTC+8).
