Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 9 min read

Can I Use My Malfoid AI Portrait as a Favicon or App Icon? ICO, Apple Touch and Maskable Icons in Pillow (Tested)

People who make a Malfoid portrait for a fan page, a Discord bot or a small fic archive keep asking the same thing: can I use it as the site icon? Yes, but the obvious move (shrink the whole 3:4 picture to 16 ร— 16) gives

People who make a Malfoid portrait for a fan page, a Discord bot or a small fic archive keep asking the same thing: can I use it as the site icon? Yes, but the obvious move (shrink the whole 3:4 picture to 16 ร— 16) gives you a smudge. This post is a small, tested Pillow script that builds the whole icon set from one portrait: favicon.ico, the Apple touch icon, the PWA icons, and a maskable icon that survives Android's circle crop. It also prints a number that tells you whether the face is still there at 16 px.

Context: I build malfoidai.com, which turns one photo into a still 3:4 Malfoid (Female Malfoy / Fem Draco) portrait. The script works on any portrait, but the 3:4 frame and the platinum hair on a dark background are what it was tuned for.

Why the whole picture fails at favicon size

A favicon is square and tiny. A 3:4 portrait squeezed into a square gets letterboxed, and the head ends up as a fraction of a fraction of 256 pixels. I measured it. The test image is a synthetic shape-only portrait (no real face, no AI output) from the make_test_portrait.py helper I publish with my other guides. For each size I count the share of pixels that are pale head pixels (platinum hair and light face):

head box (161, 150, 449, 484), crop 449px square, bg #29211c
size variant    head share contrast
  16 whole 3:4         17%    0.276
  16 head crop         53%    0.200
  32 whole 3:4         17%    0.283
  32 head crop         51%    0.216
  48 whole 3:4         17%    0.286
  48 head crop         51%    0.220

When you shrink the whole frame, the head covers 17% of the icon at every size. A crop around the head brings that to 51โ€“53%. The RMS contrast number drops with the crop (0.28 โ†’ 0.20 to 0.22), because there's less dark corridor left in the frame. That's expected and fine: at 16 px you want the silhouette of the hair to fill the square, not a lot of contrast spread over a background nobody can read.

On a second test sitter (seed 7, darker skin, so the pale-pixel mask also catches more of the face), the jump was 21% โ†’ 78โ€“79%.

What a 2026 icon set actually needs

You don't need twenty files. Browsers and phones read these:

File Size Who reads it
favicon.ico 16, 32, 48 in one file Every browser tab, bookmarks, old tools that only ask for /favicon.ico
favicon-32.png 32 Browsers that prefer PNG via <link rel="icon">
apple-touch-icon.png 180, no transparency iOS "Add to Home Screen" (transparent areas turn black)
icon-192.png, icon-512.png 192, 512 Web app manifest, Android install
maskable-512.png 512, art inside the centre 80% circle Android adaptive icons, which crop to a circle, squircle or rounded square

The maskable one is where most hand-made icons break. Android can cut the icon to a circle whose diameter is 80% of the square. Anything outside that circle can disappear. A head crop that fills the square edge to edge will lose its hair.

The script

It needs Pillow and NumPy. It guesses the head from pale, low-saturation pixels in the top 62% of the frame (platinum hair and a light face against a dark background). If your portrait doesn't fit that pattern, pass --box x,y,w,h yourself.

#!/usr/bin/env python3
"""Favicon + app-icon set from a 3:4 portrait (Pillow + NumPy).

    python favicon_set.py portrait.png out/ [--box x,y,w,h] [--bg auto|#rrggbb]

Writes favicon.ico (16/32/48), favicon-32.png, apple-touch-icon.png (180,
opaque), icon-192.png, icon-512.png, maskable-512.png, site.webmanifest and
head.html, then prints a legibility report for the small sizes.
"""
import argparse, json, os
import numpy as np
from PIL import Image

def head_box(img):
    """Guess the head from pale, low-saturation pixels (platinum hair + face)."""
    a = np.asarray(img.convert("RGB"), dtype=np.float32)
    luma = a @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
    sat = a.max(2) - a.min(2)
    m = (luma > 150) & (sat < 60)
    h, w = m.shape
    m[int(h * 0.62):] = False                   # ignore the shirt below the collar
    cols = np.where(m.sum(0) > 0.03 * h)[0]
    rows = np.where(m.sum(1) > 0.03 * w)[0]
    if len(cols) == 0 or len(rows) == 0:
        raise SystemExit("no head found; pass --box x,y,w,h")
    return int(cols[0]), int(rows[0]), int(cols[-1] - cols[0] + 1), int(rows[-1] - rows[0] + 1)

def square_crop(img, box, tight=1.0):
    x, y, w, h = box
    side = int(min(max(w, h * 0.75) * tight, img.width, img.height))
    cx, cy = x + w / 2, y + h * 0.42            # bias up: eyes sit above the box centre
    left = int(min(max(cx - side / 2, 0), img.width - side))
    top = int(min(max(cy - side / 2, 0), img.height - side))
    return img.crop((left, top, left + side, top + side))

def edge_colour(img):
    a = np.asarray(img.convert("RGB"))
    ring = np.concatenate([a[0], a[-1], a[:, 0], a[:, -1]])
    return tuple(int(v) for v in np.median(ring, 0))

def letterbox(img, bg):
    side = max(img.size)
    out = Image.new("RGB", (side, side), bg)
    out.paste(img, ((side - img.width) // 2, (side - img.height) // 2))
    return out

def maskable(sq, bg, size=512, safe=0.8):
    """Fit the whole square crop inside the safe circle (diameter = 80%)."""
    inner = int(size * safe / 2 ** 0.5)        # square inscribed in the circle
    out = Image.new("RGB", (size, size), bg)
    out.paste(sq.resize((inner, inner), Image.LANCZOS), ((size - inner) // 2,) * 2)
    return out

def rms_contrast(img):
    l = np.asarray(img.convert("L"), dtype=np.float32) / 255
    return float(l.std())

def subject_share(icon):
    """Share of icon pixels that are pale head pixels (hair/face)."""
    a = np.asarray(icon.convert("RGB"), dtype=np.float32)
    luma = a @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
    return float(((luma > 150) & (a.max(2) - a.min(2) < 60)).mean())

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("src"); ap.add_argument("out")
    ap.add_argument("--box"); ap.add_argument("--bg", default="auto")
    a = ap.parse_args()
    os.makedirs(a.out, exist_ok=True)
    img = Image.open(a.src).convert("RGB")
    box = tuple(map(int, a.box.split(","))) if a.box else head_box(img)
    sq = square_crop(img, box)
    bg = edge_colour(sq) if a.bg == "auto" else tuple(int(a.bg[i:i + 2], 16) for i in (1, 3, 5))
    big = sq.resize((512, 512), Image.LANCZOS)
    big.save(os.path.join(a.out, "icon-512.png"))
    big.resize((192, 192), Image.LANCZOS).save(os.path.join(a.out, "icon-192.png"))
    big.resize((180, 180), Image.LANCZOS).save(os.path.join(a.out, "apple-touch-icon.png"))
    big.resize((32, 32), Image.LANCZOS).save(os.path.join(a.out, "favicon-32.png"))
    big.save(os.path.join(a.out, "favicon.ico"), sizes=[(16, 16), (32, 32), (48, 48)])
    maskable(sq, bg).save(os.path.join(a.out, "maskable-512.png"))
    json.dump({"name": "My Malfoid fan page", "short_name": "Malfoid", "display": "standalone",
               "background_color": "#%02x%02x%02x" % bg, "theme_color": "#%02x%02x%02x" % bg,
               "icons": [{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
                         {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"},
                         {"src": "/maskable-512.png", "sizes": "512x512", "type": "image/png",
                          "purpose": "maskable"}]},
              open(os.path.join(a.out, "site.webmanifest"), "w"), indent=2)
    open(os.path.join(a.out, "head.html"), "w").write(
        '<link rel="icon" href="/favicon.ico" sizes="48x48">\n'
        '<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">\n'
        '<link rel="apple-touch-icon" href="/apple-touch-icon.png">\n'
        '<link rel="manifest" href="/site.webmanifest">\n')
    print(f"head box {box}, crop {sq.size[0]}px square, bg #%02x%02x%02x" % bg)
    whole = letterbox(img, bg)
    print(f"{'size':>4} {'variant':<10} {'head share':>10} {'contrast':>8}")
    for s in (16, 32, 48):
        for name, src in (("whole 3:4", whole), ("head crop", big)):
            ic = src.resize((s, s), Image.LANCZOS)
            print(f"{s:>4} {name:<10} {subject_share(ic):>10.0%} {rms_contrast(ic):>8.3f}")

if __name__ == "__main__":
    main()

Run it:

pip install pillow numpy
python favicon_set.py my_portrait.png icons/

Then paste icons/head.html into your page's <head> and copy the files to the site root.

Three choices in there are deliberate:

  • The crop is biased upward (cy = y + h * 0.42). The head box runs from the crown down to where the hair ends at the shoulders, so its geometric centre sits around the chin. Eyes are what make a face read as a face at 16 px, so the crop centres higher.
  • Every output is RGB, never RGBA. iOS fills transparent pixels with black, and a black square around platinum hair looks like a mistake. If you want a coloured backdrop, pass --bg #1a5c34 for the green.
  • The maskable icon shrinks the crop into the square inscribed in the safe circle (side = 0.8 ร— 512 / โˆš2 โ‰ˆ 289 px) and fills the rest with the median edge colour of the crop. That gives a smaller face than the plain icon, which is the price of surviving any mask shape.

Checking the output instead of eyeballing it

A second script checks what the files claim to be. It reads the ICO back and confirms all three sizes are inside, confirms no file has an alpha channel, and checks that every pixel outside the 80% circle of the maskable icon is flat background:

#!/usr/bin/env python3
"""Check the icon set: ICO sizes, opaque apple icon, maskable safe zone."""
import sys, os, json
import numpy as np
from PIL import Image
d = sys.argv[1]
ok = True
def check(cond, msg):
    global ok; ok &= cond; print(("PASS " if cond else "FAIL ") + msg)
ico = Image.open(os.path.join(d, "favicon.ico"))
check(sorted(ico.info["sizes"]) == [(16, 16), (32, 32), (48, 48)], f"favicon.ico sizes {sorted(ico.info['sizes'])}")
for f, s in (("apple-touch-icon.png", 180), ("icon-192.png", 192), ("icon-512.png", 512), ("maskable-512.png", 512), ("favicon-32.png", 32)):
    im = Image.open(os.path.join(d, f))
    check(im.size == (s, s) and im.mode == "RGB", f"{f} {im.size} {im.mode} (no alpha)")
# maskable: everything outside the 80% circle must be flat background
m = np.asarray(Image.open(os.path.join(d, "maskable-512.png")).convert("RGB")).astype(int)
yy, xx = np.mgrid[:512, :512]
outside = (xx - 255.5) ** 2 + (yy - 255.5) ** 2 > (0.4 * 512) ** 2
bg = np.median(m[outside], 0)
dev = np.abs(m[outside] - bg).max()
check(dev <= 2, f"maskable: pixels outside safe circle are background (max dev {dev})")
man = json.load(open(os.path.join(d, "site.webmanifest")))
check(any(i.get("purpose") == "maskable" for i in man["icons"]), "manifest lists a maskable icon")
sys.exit(0 if ok else 1)

Output on the test portrait:

PASS favicon.ico sizes [(16, 16), (32, 32), (48, 48)]
PASS apple-touch-icon.png (180, 180) RGB (no alpha)
PASS icon-192.png (192, 192) RGB (no alpha)
PASS icon-512.png (512, 512) RGB (no alpha)
PASS maskable-512.png (512, 512) RGB (no alpha)
PASS favicon-32.png (32, 32) RGB (no alpha)
PASS maskable: pixels outside safe circle are background (max dev 0.0)
PASS manifest lists a maskable icon

The maskable check is strict (max deviation โ‰ค 2 levels). If you change the safe-zone factor, it will tell you straight away when hair starts leaking into the area Android may cut.

Things the numbers won't tell you

  • Look at the 16 px version at 100%. Zoomed screenshots lie. Open favicon.ico in a browser tab next to other tabs. At that size, a Malfoid icon is a pale oval with a dark ring around it. That's fine. The silhouette is the recognisable part.
  • Dark-mode tabs. A dark backdrop vanishes against a dark tab strip. If your audience lives in dark mode, try --bg with the green or a mid grey.
  • The head detector is a heuristic. It works because platinum hair is pale and unsaturated. It will misfire on a white wall behind the subject or a very pale robe. Use --box when it does. The first printed line shows the box it picked.
  • Credit and label. If the icon is a portrait of you made with an AI tool, that's your call. If it's a friend, ask first, the same as you would before posting it anywhere. If the site is public, add a line saying it's AI fan art; Malfoid is an unofficial fan character, not an official one.

Getting a portrait to start from

Any 3:4 portrait with a clear face works. If you don't have one yet, malfoidai.com makes one still 3:4 portrait at 1K from one photo of an adult (or without a photo) for $4.99, paid once with no account, or 2 credits from a pack. I re-checked the pricing page on 2026-11-11 at 18:00 CST (UTC+8). There's no free portrait, and the studio doesn't add a watermark, so the crop above won't fight a logo in the corner.

One 1K portrait is far more than an icon set needs. The 512 px maskable icon uses about a 450 px square out of it.

Tested on Python 3.13, Pillow 13.0, NumPy 2.5, on synthetic shape-only portraits. No real face was used.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.