Dev.to AI 🤖 Ai 👁 0 📖 11 min read

Can You Upscale a Malfoid AI Portrait for a Poster? Lanczos vs FSRCNN vs Nearest, Measured With a Round-Trip Test

Short answer: yes for about 2x, and you should measure it, not trust the name of the filter. A small learned upscaler (FSRCNN, 40 KB, runs on a CPU through OpenCV) rebuilt my test portrait better than Lanczos or bicubic

Short answer: yes for about 2x, and you should measure it, not trust the name of the filter. A small learned upscaler (FSRCNN, 40 KB, runs on a CPU through OpenCV) rebuilt my test portrait better than Lanczos or bicubic at 2x. At 4x every method was close to every other, because the detail simply isn't in the file. The script below does a round-trip test on your own picture: shrink it, enlarge it back with each method, score against the original, and only then enlarge the real thing with the winner.

I run malfoidai.com, a small paid studio that makes still Malfoid (Female Malfoy / Fem Draco) fan portraits. The output is one still 3:4 picture at 1K, and the studio's own check-before-posting guide says plainly that it's "not meant for large prints; enlarging it far past its size will look soft." "Can I upscale it?" is still the most common follow-up question, so here's the honest, measured version.

What "1K" gets you on paper

The site doesn't publish an exact pixel size, so check yours: open the download's file info, or run python -c "from PIL import Image; print(Image.open('portrait.png').size)". For the arithmetic below I use 768×1024, a common 3:4 "1K" size; swap in your own numbers.

Pixels At 300 ppi (photo print) At 150 ppi (poster seen from 1 m)
768 × 1024 (as downloaded) 6.5 × 8.7 cm 13 × 17.3 cm
1536 × 2048 (2x upscale) 13 × 17.3 cm 26 × 34.7 cm
3072 × 4096 (4x upscale) 26 × 34.7 cm 52 × 69 cm

An upscaler can't invent the real strands of hair that weren't rendered. What it can do is make edges clean instead of blurry or stair-stepped. That difference is measurable.

The round-trip test

You don't have a "true" 2048-pixel version of your portrait, so you can't score a 2x enlargement directly. You can, however, make a smaller copy of the picture you do have, enlarge that copy back, and compare it with the original. A method that rebuilds 1024 from 512 well will usually build 2048 from 1024 well too. (It's an approximation: real camera-to-model detail isn't the same as an area downscale. It's still far better than eyeballing two blurry PNGs.)

Three scores per method:

  • PSNR (dB, higher is better): plain pixel error on luma.
  • SSIM (0–1, higher is better): local structure, closer to what eyes notice.
  • Ringing: average overshoot in 8-bit levels right next to strong edges. Lanczos and unsharp masks create bright/dark halos there, which look like a "glow" around hair against a dark robe.

Setup

pip install opencv-contrib-python-headless numpy
mkdir models
curl -L -o models/FSRCNN_x2.pb https://raw.githubusercontent.com/Saafke/FSRCNN_Tensorflow/master/models/FSRCNN_x2.pb
curl -L -o models/FSRCNN_x4.pb https://raw.githubusercontent.com/Saafke/FSRCNN_Tensorflow/master/models/FSRCNN_x4.pb

opencv-contrib is needed for cv2.dnn_superres. The two .pb files are about 40 KB each. Without them the script just skips FSRCNN.

The script

#!/usr/bin/env python3
"""Compare ways to enlarge a 1K portrait, with numbers instead of eyeballing.

Round-trip test: shrink the picture 2x (or 4x), enlarge it back with each
method, and score the result against the original (PSNR and SSIM on luma,
plus a ringing score around hard edges). The method that rebuilds the
original best is the one to use for a real 2x/4x enlargement.

    pip install opencv-contrib-python-headless numpy
    python upscale_compare.py portrait.png [--scale 2] [--models DIR] [--out DIR] [--sheet sheet.png]

--models DIR is optional; put FSRCNN_x2.pb / FSRCNN_x4.pb there (from
github.com/Saafke/FSRCNN_Tensorflow/tree/master/models) to include the
small learned upscaler. Without it only the classic filters run.
"""
import argparse, os, sys
import cv2
import numpy as np


def luma(img):
    return cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)[:, :, 0].astype(np.float64)


def psnr(a, b):
    mse = np.mean((luma(a) - luma(b)) ** 2)
    return float("inf") if mse == 0 else 10 * np.log10(255.0 ** 2 / mse)


def ssim(a, b):
    x, y = luma(a), luma(b)
    c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
    blur = lambda z: cv2.GaussianBlur(z, (11, 11), 1.5)
    mx, my = blur(x), blur(y)
    sxx, syy, sxy = blur(x * x) - mx * mx, blur(y * y) - my * my, blur(x * y) - mx * my
    m = ((2 * mx * my + c1) * (2 * sxy + c2)) / ((mx * mx + my * my + c1) * (sxx + syy + c2))
    return float(m.mean())


def ringing(ref, out):
    """Mean overshoot (in 8-bit levels) next to strong edges of the reference."""
    e = cv2.Canny(ref, 80, 160) > 0
    band = cv2.dilate(e.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
    band &= ~e
    lo = cv2.erode(luma(ref), np.ones((5, 5)))
    hi = cv2.dilate(luma(ref), np.ones((5, 5)))
    y = luma(out)
    over = np.maximum(y - hi, 0) + np.maximum(lo - y, 0)
    return float(over[band].mean()) if band.any() else 0.0


def unsharp(img, amount=0.6, sigma=1.0):
    blur = cv2.GaussianBlur(img, (0, 0), sigma)
    return cv2.addWeighted(img, 1 + amount, blur, -amount, 0)


def methods(scale, models):
    m = {
        "nearest": lambda im: cv2.resize(im, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST),
        "bilinear": lambda im: cv2.resize(im, None, fx=scale, fy=scale, interpolation=cv2.INTER_LINEAR),
        "bicubic": lambda im: cv2.resize(im, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC),
        "lanczos4": lambda im: cv2.resize(im, None, fx=scale, fy=scale, interpolation=cv2.INTER_LANCZOS4),
        "lanczos4+unsharp": lambda im: unsharp(cv2.resize(im, None, fx=scale, fy=scale, interpolation=cv2.INTER_LANCZOS4)),
    }
    path = os.path.join(models, f"FSRCNN_x{scale}.pb") if models else ""
    if path and os.path.exists(path):
        sr = cv2.dnn_superres.DnnSuperResImpl_create()
        sr.readModel(path)
        sr.setModel("fsrcnn", scale)
        m["fsrcnn"] = sr.upsample
    elif models:
        print(f"note: {path} not found, skipping FSRCNN", file=sys.stderr)
    return m


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("image")
    ap.add_argument("--scale", type=int, default=2, choices=[2, 4])
    ap.add_argument("--models", default="")
    ap.add_argument("--out", default="")
    ap.add_argument("--sheet", default="", help="write a labelled crop sheet (PNG) to look at")
    ap.add_argument("--crop", default="0.35,0.30,0.30,0.20",
                    help="sheet crop as x,y,w,h fractions of the image (default: around the eyes)")
    a = ap.parse_args()
    ref = cv2.imread(a.image, cv2.IMREAD_COLOR)
    if ref is None:
        sys.exit(f"cannot read {a.image}")
    h, w = ref.shape[:2]
    h, w = h - h % a.scale, w - w % a.scale
    ref = ref[:h, :w]
    small = cv2.resize(ref, (w // a.scale, h // a.scale), interpolation=cv2.INTER_AREA)
    print(f"{a.image}: {w}x{h}, round trip via {w // a.scale}x{h // a.scale} (x{a.scale})")
    print(f"{'method':<18}{'PSNR dB':>9}{'SSIM':>8}{'ringing':>9}")
    ms = methods(a.scale, a.models)
    rows, ups = [], {}
    for name, f in ms.items():
        up = f(small)[:h, :w]
        ups[name] = up
        rows.append((name, psnr(ref, up), ssim(ref, up), ringing(ref, up)))
    for name, p, s, r in sorted(rows, key=lambda t: -t[2]):
        print(f"{name:<18}{p:>9.2f}{s:>8.4f}{r:>9.2f}")
    # Nearest-neighbour can top SSIM on fine random texture (it keeps local
    # contrast), but it prints as visible blocks, so it never gets auto-picked.
    best = max((r for r in rows if r[0] != "nearest"), key=lambda t: t[2])[0]
    print(f"best by SSIM (excluding nearest): {best}")
    if a.sheet:
        fx, fy, fw, fh = (float(v) for v in a.crop.split(","))
        x0, y0, cw, ch = int(fx * w), int(fy * h), int(fw * w), int(fh * h)
        tiles = []
        for name in ["original"] + [r[0] for r in rows]:
            src = ref if name == "original" else ups[name]
            t = cv2.resize(src[y0:y0 + ch, x0:x0 + cw], (cw * 2, ch * 2), interpolation=cv2.INTER_NEAREST)
            t = cv2.copyMakeBorder(t, 28, 4, 4, 4, cv2.BORDER_CONSTANT, value=(255, 255, 255))
            cv2.putText(t, name, (6, 21), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1, cv2.LINE_AA)
            tiles.append(t)
        while len(tiles) % 3:
            tiles.append(np.full_like(tiles[0], 255))
        grid = np.vstack([np.hstack(tiles[i:i + 3]) for i in range(0, len(tiles), 3)])
        cv2.imwrite(a.sheet, grid)
        print(f"wrote {a.sheet} (crop shown at 2x with nearest, so you see pixels)")
    if a.out:
        os.makedirs(a.out, exist_ok=True)
        full = ms[best](cv2.imread(a.image, cv2.IMREAD_COLOR))
        p = os.path.join(a.out, f"x{a.scale}-{best}.png")
        cv2.imwrite(p, full)
        H, W = full.shape[:2]
        print(f"wrote {p} {W}x{H}; at 300 ppi that prints {W / 300 * 2.54:.1f} x {H / 300 * 2.54:.1f} cm")


if __name__ == "__main__":
    main()

Test pictures (no real face)

I don't test on customer pictures or on anyone's face. This draws a synthetic 3:4 "Malfoid-look" card from shapes: dark corridor, candle glow, platinum hair, white shirt, green-and-silver tie, dark robe. --texture adds thin hair strands and fabric grain so there is fine detail to lose:

#!/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. --texture adds thin
hair strands and fabric grain, so resampling and line-art scripts have some
fine detail to chew on. No real face, no AI, nobody's photo.

    python make_test_portrait.py out.png [--seed 1] [--size 768x1024] [--texture]
"""
import argparse
import random
from PIL import Image, ImageDraw, ImageFilter


def face(d, s, fw, skin):
    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)))


def portrait(w=768, h=1024, seed=1, texture=False):
    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
    face(d, s, fw, skin)
    if texture:
        tr = random.Random(seed * 7919)
        for _ in range(260):                                               # hair strands
            x0 = tr.uniform(180, 588) * s
            side = -1 if x0 < 384 * s else 1
            pts = [(x0, 160 * s)]
            for k in range(1, 9):
                pts.append((x0 + side * k * tr.uniform(1, 5) * s, (160 + k * 70) * s))
            c = tr.randint(170, 250)
            d.line([(int(a), int(b)) for a, b in pts], fill=(c, c - 4, c - 22), width=1)
        face(d, s, fw, skin)                                               # face back on top
        px = img.load()
        for _ in range(int(w * h * 0.05)):                                 # fabric grain on the robe
            x, y = tr.randrange(w), tr.randrange(int(700 * s), h)
            r, g, b = px[x, y]
            if r < 40 and g < 40:
                v = tr.randint(-12, 12)
                px[x, y] = (max(0, r + v), max(0, g + v), max(0, b + v))
    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")
    ap.add_argument("--texture", action="store_true")
    a = ap.parse_args()
    w, h = map(int, a.size.lower().split("x"))
    portrait(w, h, a.seed, a.texture).save(a.out)
    print(f"wrote {a.out} {w}x{h}")
python make_test_portrait.py tex.png --texture
python make_test_portrait.py flat.png
python upscale_compare.py tex.png --scale 2 --models models --sheet sheet.png --crop 0.22,0.30,0.30,0.20
python upscale_compare.py tex.png --scale 4 --models models
python upscale_compare.py flat.png --scale 2 --models models

Results (verbatim, OpenCV 5.0, CPU)

Textured card, 2x:

method              PSNR dB    SSIM  ringing
nearest               23.32  0.8806     0.00
fsrcnn                23.23  0.8742     0.35
bicubic               23.14  0.8696     0.91
lanczos4+unsharp      22.95  0.8692     2.46
lanczos4              23.02  0.8670     1.21
bilinear              22.94  0.8583     0.00
best by SSIM (excluding nearest): fsrcnn

Textured card, 4x:

method              PSNR dB    SSIM  ringing
nearest               21.39  0.8036     0.43
fsrcnn                21.54  0.7995     0.47
bicubic               21.43  0.7956     0.56
bilinear              21.35  0.7931     0.64
lanczos4              21.39  0.7923     0.56
lanczos4+unsharp      21.39  0.7921     0.63

Flat card (clean shapes, no strands), 2x:

method              PSNR dB    SSIM  ringing
fsrcnn                36.65  0.9914     1.25
bicubic               33.88  0.9894     3.82
bilinear              33.08  0.9882     0.00
nearest               32.53  0.9882     0.00
lanczos4              33.92  0.9880     4.07
lanczos4+unsharp      34.21  0.9859     7.02

What the numbers say

  1. FSRCNN wins where there are real edges. On the flat card it's almost 3 dB ahead of bicubic with a third of the ringing. Face outline, eyes, tie stripes: that's the kind of content a portrait is mostly made of.
  2. "Lanczos + sharpen" is the halo machine. It has the worst ringing in every run (7.02 on the flat card). The "crisp" look people like in a preview turns into a light outline around dark hair once it's printed.
  3. Nearest neighbour "winning" SSIM on hair texture is a metric trap. On 1-pixel random strands, blocky pixels keep local contrast, and SSIM rewards contrast. Look at sheet.png: the nearest crop is visibly stair-stepped. That's why the script never auto-picks it, and why you should always look at the crop sheet, not just the table.
  4. At 4x, nothing helps much. All six methods land within 0.2 dB. Detail that was never in the 1K file can't be recovered by a 40 KB model, and a big generative upscaler that does add detail is drawing new hair, not recovering yours. For a likeness portrait that's a real trade-off: it can change the face.

A practical recipe

  • Phone, social post, profile picture: use the file as it is. 1K is plenty there.
  • A print up to about 13 × 17 cm, or an A4 seen from a distance: run the script, take its 2x pick (--out out/), and print at 300 ppi.
  • Anything bigger: accept the softness, or choose a poster style that hides it (a halftone, a frame with a wide border). Don't stack 4x upscales and a sharpen filter.
  • Before you print, check the face and hands at 100% in the enlarged file. Upscalers amplify small artefacts as faithfully as they amplify edges.

Where the portrait comes from

On malfoidai.com a portrait is $4.99 paid once (no account), from your photo or without one, or 2 credits from a one-time pack: $13.99 for 30 credits, $35.99 for 100, $59.99 for 200 (packs need sign-in, don't renew, no subscription). Prices as shown on the pricing page on November 9, 2026. The studio doesn't add a watermark, so there's nothing to "clean up" before enlarging. The portrait is unofficial fan art: not affiliated with the books, films, author or studios, and a portrait made from a photo is not a verified likeness of that person.

Code: MIT. Test images are synthetic; no real face or generated result is used or shown.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.