Written November 2, 2026 (18:00 CST, UTC+8). I build malfoidai.com, an independent studio that makes still Malfoid (Female Malfoy / Fem Draco) portraits, so this is a builder's write-up, not a neutral review. The script below was run today on synthetic test images; no paid generations were run for this post and no customer photos were used. What I say about model internals is the general design of latent diffusion image models, not a description of any one hosted model.
Short answer: "platinum" is a very narrow target: hair that is very light, with almost no colour in it, and only a slight warm or cool lean. Three things push it off target. The model fills in hair colour from what it learned, and "blonde" in the training data is mostly golden. Your source photo's lighting leaks into the whole picture. And highlights are the first thing compression and re-encoding damage. You can't fix the model, but you can control the photo, and you can measure the result instead of arguing with your eyes. The script below reads the hair region in CIELAB and tells you whether it's in the platinum range, golden, ashy or just grey.
What "platinum" means in numbers
Your eye reads hair colour relative to everything around it, which is why the same portrait can look silver on a dark phone screen and cream on a white page. To talk about it without that problem, use CIELAB. It splits a colour into:
- L* lightness, 0 (black) to 100 (white)
- a* green (−) to red/magenta (+)
- b* blue (−) to yellow (+)
- C* chroma,
sqrt(a*² + b*²), how much colour there is at all
Platinum blonde, roughly speaking, is high L* (the 70s and up for the lit parts of the hair) with low chroma and a b* close to zero or slightly positive. Golden blonde has a similar L* but a large positive b*. "Grey" hair in a failed render usually means low chroma and a lightness that has dropped into the 50s or 60s: no colour, but no light either. These cut-offs are my working heuristics for checking portraits, not a colour standard.
The Malfoid look specifically calls for long platinum hair with green-and-silver academy clothes (what the meme is). The cool green and silver of the robe and tie are part of why platinum reads right. Golden hair next to a green tie reads as a different character.
Where the colour goes wrong in the pipeline
A photo-to-portrait generator does roughly this: encode your photo, keep the face (identity), and paint new hair, clothes, light and background in a compressed latent space, then decode back to pixels. The colour can drift at each step.
1. The prior pulls toward golden. When a model draws "blonde hair", it reaches for the average of the blonde hair it saw. Most of that is warm. A preset can push hard toward pale, cool hair, but the pull is still there. It shows up most when the rest of the scene is warm, because...
2. Lighting is shared across the whole image. The default Malfoid scene is a candlelit academy corridor (home page). Candlelight is warm. A realistic render puts warm light on the hair too, and very light hair reflects whatever light hits it. So some warmth on the lit side is correct. Platinum hair in candlelight should show a small positive b* in the highlights and stay neutral or cool in the shadows. It shouldn't turn the whole mass yellow.
3. Your photo's colour cast leaks in. The model conditions on your photo to keep the face. If the photo was taken under a yellow kitchen bulb or next to a blue monitor, that cast is in the skin, and the model tends to make the rest of the picture agree with the skin. The site's photo guide says the same in plain terms: colored lights "tint the skin in the portrait too". Soft, even light from a window is the fix. It's cheaper than any correction afterwards.
4. Highlights clip, and compression flattens them. Near-white hair sits at the top of the range, where an 8-bit image has the least room. A dark source photo that you brightened in an editor adds noise. The photo guide warns that "the studio reads noise as detail", and in hair that noise becomes grey speckle. Every re-save as JPEG then eats the faint colour differences that make hair look like hair rather than a grey shape. If you post a portrait, then screenshot it, then post the screenshot, you'll watch the hair lose life with each step.
5. The decoder is lossy. Latent image models decode from a compressed representation, and fine, low-contrast texture in very light areas is exactly what such decoders smooth out. It's one reason pale hair sometimes looks like a plastic helmet at full size but fine at phone size.
Measure it: hair_tone.py
This script takes a box over the hair (fractions of width and height), keeps only the light pixels in it, converts them to CIELAB with the standard sRGB/D65 formulas, and reports the median. Median, not mean, so a few stray dark strands or a bit of face in the box don't swing the result. It needs only NumPy and Pillow.
#!/usr/bin/env python3 """hair_tone.py - measure how "platinum" the hair in a portrait really is.""" import argparse, sys import numpy as np from PIL import Image, ImageOps def srgb_to_lab(rgb): c = rgb / 255.0 lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) m = np.array([[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]]) xyz = lin @ m.T / np.array([0.95047, 1.0, 1.08883]) e = 216 / 24389 f = np.where(xyz > e, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116) return 116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]), 200 * (f[..., 1] - f[..., 2]) def verdict(L, a, b, C): if L < 55: return "dark: this region is not light hair (wrong box, or the hair came out dark)" if b > 18: return "golden/yellow blond: warm light in the source photo is a common cause" if b < -6: return "blue/ashy cast: cool light or a blue screen in the source photo" if C < 6 and L < 70: return "grey/dull: low light or heavy compression flattens the highlights" if L >= 70 and b <= 12: return "platinum range" return "light blond, between platinum and golden" def main(): p = argparse.ArgumentParser() p.add_argument("image") p.add_argument("--box", default="0.10,0.02,0.90,0.40") # x0,y0,x1,y1 as fractions p.add_argument("--min-l", type=float, default=55.0) p.add_argument("--cast", action="store_true", help="whole-image colour cast (for the source photo)") a = p.parse_args() im = ImageOps.exif_transpose(Image.open(a.image)).convert("RGB") w, h = im.size if a.cast: small = np.asarray(im.resize((256, int(256 * h / w))), dtype=np.float64) L, A, B = srgb_to_lab(small) mid = (L > 20) & (L < 95) # ignore clipped shadows/highlights am, bm = float(A[mid].mean()), float(B[mid].mean()) tint = "neutral" if np.hypot(am, bm) < 6 else ("warm/yellow" if bm > 0 else "cool/blue") \ + (" + magenta" if am > 6 else " + green" if am < -6 else "") print(f"{a.image}: mean a*={am:+.1f} b*={bm:+.1f} -> cast {tint}") return 0 x0, y0, x1, y1 = (float(v) for v in a.box.split(",")) crop = np.asarray(im.crop((int(x0*w), int(y0*h), int(x1*w), int(y1*h))), dtype=np.float64) L, A, B = srgb_to_lab(crop) mask = L >= a.min_l if mask.sum() < 50: print(f"{a.image}: only {mask.sum()} pixels with L*>={a.min_l} in the box; move the box onto the hair") return 2 Lm, Am, Bm = (float(np.median(v[mask])) for v in (L, A, B)) C = float(np.hypot(Am, Bm)) print(f"{a.image}: {w}x{h}, box {a.box}, bright pixels {mask.mean():.0%}") print(f" L*={Lm:.1f} a*={Am:+.1f} b*={Bm:+.1f} C*={C:.1f}") print(f" -> {verdict(Lm, Am, Bm, C)}") return 0 if __name__ == "__main__": sys.exit(main()) I tested it on four synthetic 768×1024 "portraits" (a flat hair shape, a face oval and a dark robe, with noise added and saved as JPEG). One had near-white hair, one golden, one mid-grey and one blue-tinted. Output, verbatim:
platinum.jpg: 768x1024, box 0.10,0.02,0.90,0.40, bright pixels 60% L*=90.0 a*=-0.5 b*=+5.9 C*=5.9 -> platinum range golden.jpg: 768x1024, box 0.10,0.02,0.90,0.40, bright pixels 60% L*=81.1 a*=+1.7 b*=+43.7 C*=43.7 -> golden/yellow blond: warm light in the source photo is a common cause grey.jpg: 768x1024, box 0.10,0.02,0.90,0.40, bright pixels 60% L*=62.5 a*=+0.4 b*=-1.0 C*=1.1 -> grey/dull: low light or heavy compression flattens the highlights ashy.jpg: 768x1024, box 0.10,0.02,0.90,0.40, bright pixels 60% L*=84.0 a*=+0.7 b*=-12.2 C*=12.2 -> blue/ashy cast: cool light or a blue screen in the source photo And with a box that misses the hair entirely, it refuses to guess:
platinum.jpg: only 0 pixels with L*>=55.0 in the box; move the box onto the hair Real hair is not a flat shape, so on a real portrait put the box on the lit part of the hair beside the face and keep the face out of it. Skin is also light and will drag b* up. Try two or three boxes; if they disagree a lot, the hair has a strong light-to-shadow gradient, which is normal for candlelight.
Check the source photo before you blame the model
Run the same script with --cast on the photo you plan to upload. It averages a* and b* over the mid-tones (ignoring clipped blacks and whites) on the idea that most everyday photos average out roughly neutral. That's the old "grey world" assumption: crude, but good at catching a strong cast. Synthetic test, verbatim:
selfie_neutral.jpg: mean a*=-0.0 b*=+0.0 -> cast neutral selfie_warm.jpg: mean a*=-0.4 b*=+20.3 -> cast warm/yellow selfie_blue.jpg: mean a*=+0.5 b*=-16.1 -> cast cool/blue If your selfie comes back "warm/yellow" with b* in the teens or higher, retake it facing a window in daylight. Don't white-balance it in an editor first. A corrected photo is still a re-processed photo, and a fresh one in good light is better input.
What to do with the numbers
- Golden hair, warm source photo: retake the photo in daylight. This is the most common case and the cheapest fix.
- Golden hair, neutral source photo: some warmth from the candlelit scene is expected. Compare the hair's shadow side, which should be near neutral. If the whole mass is strongly yellow, that's a miss.
- Grey or dull: check whether you started from a dark or heavily compressed photo. Use the original file, not a screenshot or a photo forwarded through a chat app.
- Ashy or blue: usually a monitor or a blue-hour window behind the camera. Change the light.
- Platinum range, but it looks yellow to you: check your screen's night mode or warm display setting before you check anything else.
The site's check-before-posting guide covers the rest of a pre-post check (face, hands, hair edges, tie, background symbols), and Help lists the photo fixes that solve most odd results.
The product side, briefly
On malfoidai.com a portrait is one still 3:4 picture at 1K. There's no prompt box, so you can't type "more platinum". The look and the candlelit scene are saved in the preset (FAQ). That makes the photo the one input you control, which is why I keep writing about the photo. As of today a portrait is $4.99 whether it's made from your photo or without one, or 2 credits from a signed-in pack (pricing). There's no free trial. If you're going to try a second photo because the first one had bad light, it's worth getting the light right first.
Tested with Python 3, NumPy 2.5 and Pillow 13 on synthetic images. Malfoid is unofficial fan art; malfoidai.com is not affiliated with any book, film, studio or author.