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)) dSource:https://dev.to/lpgit/can-you-upscale-a-malfoid-ai-portrait-for-a-poster-lanczos-vs-fsrcnn-vs-nearest-measured-with-a-3nih

Related posts