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
- 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.
- "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.
-
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. - 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.
Top comments (0)