Someone asked me whether a Malfoid portrait (the Female Malfoy / Fem Draco fan look: long platinum hair, white shirt, green-and-silver tie, dark robe) could be turned into one of those faceted low-poly pictures you see on posters and stream overlays, and whether you need Illustrator for it. You don't. Low-poly is three steps: choose points, connect them into triangles, paint each triangle one flat colour. The interesting part is the first step, because where you put the points decides whether the face survives.

Context: I build malfoidai.com, which makes one still 3:4 Malfoid portrait from one photo or without a photo. Everything below runs on synthetic shape-only test cards (no real face, no AI output), so you can reproduce every number.

Short answer

  • Yes, and the output can be a real SVG that scales to any size, which a 1K raster can't.
  • Sample points where the edges are (Sobel on CIELAB lightness), not uniformly. On 8 test cards it cuts the whole-picture colour error by 10–19% at the same triangle count, and by up to half on the tie stripes.
  • But a face is mostly smooth skin, so an edge-only sampler starves it. Give the face box its own density boost: at 1,000 points the median face error drops from 4.32 to 2.82 ΔE with almost no change to the whole-picture error.
  • Budget: about 2 triangles per point and roughly 94 bytes per triangle in plain SVG (about 12 bytes gzipped). 1,000 points ≈ 2,050 triangles ≈ 190 KB raw, 25 KB gzipped.

The pipeline

  1. Density map. Blur the L* channel slightly, take the Sobel gradient magnitude, normalise it, add a small floor (0.05) so flat background still gets a few points. Optionally multiply a face box by 4.
  2. Sample N points from that map without replacement (numpy.random.Generator.choice with p=), jitter them inside their pixel, and add evenly spaced border points so the triangulation covers the whole frame.
  3. Delaunay triangulation with OpenCV's cv2.Subdiv2D (no SciPy needed). Drop the triangles that touch Subdiv2D's virtual outer vertices.
  4. Colour. Rasterise every triangle into an int32 label map with cv2.fillConvexPoly, then get the mean colour of each triangle in one pass with np.bincount(labels, weights=channel). That's the trick that keeps it fast: no per-triangle mask.
  5. Write SVG, one <polygon> per triangle. Each polygon gets a stroke in its own fill colour, because browsers anti-alias abutting polygon edges and you'd otherwise see a faint mesh of hairline gaps.

The script

#!/usr/bin/env python3 """Turn a portrait into a low-poly SVG: edge-weighted points, Delaunay triangles, one flat colour per triangle. NumPy + Pillow + OpenCV (Subdiv2D). python lowpoly.py portrait.png out.svg --points 1500 python lowpoly.py portrait.png out.svg --points 1500 --face 264,250,240,310 --preview out.png Sampling: points are drawn with probability proportional to edge strength (Sobel on CIELAB L*) plus a floor, so flat background gets few, big triangles and the eyes, hairline and tie stripes get many small ones. --face adds a density boost inside a box, because a face is mostly smooth skin and an edge-only sampler starves it.""" import argparse import sys import xml.etree.ElementTree as ET import cv2 import numpy as np from PIL import Image def lab_l(rgb): return cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)[..., 0].astype(np.float32) * (100 / 255) def density(rgb, mode="edges", face=None, face_boost=4.0, floor=0.05, gamma=1.0): """Sampling density map that sums to 1.""" h, w = rgb.shape[:2] if mode == "uniform": d = np.ones((h, w), np.float64) else: L = cv2.GaussianBlur(lab_l(rgb), (0, 0), 1.0) g = np.hypot(cv2.Sobel(L, cv2.CV_32F, 1, 0), cv2.Sobel(L, cv2.CV_32F, 0, 1)).astype(np.float64) g = g / (g.max() or 1) d = g ** gamma + floor if face is not None: x, y, fw, fh = face d[max(0, y):y + fh, max(0, x):x + fw] *= face_boost return d / d.sum() def sample_points(rgb, n, mode="edges", face=None, seed=0, border=12): """n interior points from the density map + evenly spaced border points.""" h, w = rgb.shape[:2] rng = np.random.default_rng(seed) d = density(rgb, mode, face).ravel() idx = rng.choice(d.size, size=n, replace=False, p=d) ys, xs = np.divmod(idx, w) pts = np.c_[xs + rng.random(n), ys + rng.random(n)] bx = np.linspace(0, w - 1, border) by = np.linspace(0, h - 1, int(border * h / w)) edge = np.r_[np.c_[bx, np.zeros_like(bx)], np.c_[bx, np.full_like(bx, h - 1)], np.c_[np.zeros_like(by), by], np.c_[np.full_like(by, w - 1), by]] pts = np.r_[pts, edge] return np.unique(np.round(pts, 2), axis=0) def triangulate(pts, w, h): sub = cv2.Subdiv2D((0, 0, w, h)) for x, y in pts: sub.insert((float(min(x, w - 1)), float(min(y, h - 1)))) tris = sub.getTriangleList().reshape(-1, 3, 2) ok = ((tris[..., 0] >= 0) & (tris[..., 0] <= w) & (tris[..., 1] >= 0) & (tris[..., 1] <= h)).all(axis=1) return tris[ok] def label_map(tris, w, h): """Each pixel -> index of the triangle that covers it (-1 if none).""" lab = np.full((h, w), -1, np.int32) for i, t in enumerate(tris): cv2.fillConvexPoly(lab, np.round(t).astype(np.int32), int(i)) return lab def colours(rgb, lab, n): """Mean colour of the pixels in each triangle (tiny triangles: colour at the centroid).""" flat = lab.ravel() good = flat >= 0 cnt = np.bincount(flat[good], minlength=n).astype(np.float64) out = np.zeros((n, 3)) for c in range(3): out[:, c] = np.bincount(flat[good], weights=rgb[..., c].ravel()[good], minlength=n) with np.errstate(invalid="ignore"): out = out / cnt[:, None] return out, cnt def lowpoly(img, n=1500, mode="edges", face=None, seed=0): rgb = np.asarray(img.convert("RGB")) h, w = rgb.shape[:2] pts = sample_points(rgb, n, mode, face, seed) tris = triangulate(pts, w, h) lab = label_map(tris, w, h) cols, cnt = colours(rgb, lab, len(tris)) for i in np.where(cnt == 0)[0]: # sliver with no pixel centre inside cx, cy = tris[i].mean(axis=0) cols[i] = rgb[min(h - 1, int(cy)), min(w - 1, int(cx))] return tris, np.clip(np.round(cols), 0, 255).astype(np.uint8), lab def to_svg(tris, cols, w, h): parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" ' f'shape-rendering="crispEdges">'] for t, c in zip(tris, cols): p = " ".join(f"{x:.0f},{y:.0f}" for x, y in t) # stroke in the same colour hides the hairline gaps browsers draw between abutting polygons hexc = "#%02x%02x%02x" % tuple(int(v) for v in c) parts.append(f'<polygon points="{p}" fill="{hexc}" stroke="{hexc}" stroke-width="0.6"/>') parts.append("</svg>") return "\n".join(parts) def render(cols, lab, rgb_fallback=None): """Raster version of the SVG (what the label map says), for scoring.""" out = cols[np.maximum(lab, 0)] if rgb_fallback is not None: out[lab < 0] = rgb_fallback[lab < 0] return out def delta_e(a, b): la = cv2.cvtColor(a, cv2.COLOR_RGB2LAB).astype(np.float64) lb = cv2.cvtColor(b, cv2.COLOR_RGB2LAB).astype(np.float64) # OpenCV 8-bit Lab: L*255/100, a+128, b+128 la[..., 0] *= 100 / 255; lb[..., 0] *= 100 / 255 return np.linalg.norm(la - lb, axis=-1) def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("src") ap.add_argument("dst") ap.add_argument("--points", type=int, default=1500) ap.add_argument("--mode", choices=["edges", "uniform"], default="edges") ap.add_argument("--face", help="x,y,w,h box that gets 4x the point density") ap.add_argument("--seed", type=int, default=0) ap.add_argument("--preview", help="also write a PNG render") a = ap.parse_args(argv) img = Image.open(a.src).convert("RGB") face = tuple(int(v) for v in a.face.split(",")) if a.face else None tris, cols, lab = lowpoly(img, a.points, a.mode, face, a.seed) w, h = img.size svg = to_svg(tris, cols, w, h) open(a.dst, "w").write(svg) rgb = np.asarray(img) r = render(cols, lab, rgb) de = delta_e(rgb, r) if a.preview: Image.fromarray(r).save(a.preview) print(f"{len(tris)} triangles, {len(svg.encode())} bytes SVG, mean dE {de.mean():.2f}" + (f", face dE {de[face[1]:face[1] + face[3], face[0]:face[0] + face[2]].mean():.2f}" if face else "")) if __name__ == "__main__": sys.exit(main()) 

Usage:

pip install numpy pillow opencv-python-headless python lowpoly.py portrait.png out.svg --points 1000 --face 264,250,240,310 --preview out.png # 2048 triangles, 193886 bytes SVG, mean dE 4.69, face dE 2.19 (test card 1) 

--face is x,y,w,h in pixels. If you don't want to type it, any face detector will give you one; I used the known box of the drawn test face.

What the numbers say

The benchmark (bench_lowpoly.py) runs three samplers at five point counts on 8 test cards (the 6 first "clean" cards, one with a recoloured tie, one with warmer hair), all 768 × 1024. Error is mean CIE76 ΔE between the original and a raster of the triangles; "face" and "tie" are the same error inside those boxes. Verbatim output, medians over the 8 cards:

median over 8 cards sampler points tris SVG KB gz KB dE all dE face dE tie uniform 250 549 50.6 6.9 7.81 8.66 22.48 uniform 500 1049 96.6 12.5 6.62 6.57 20.06 uniform 1000 2048 188.8 23.0 5.67 4.92 16.55 uniform 2000 4046 372.8 43.8 5.06 3.98 13.90 uniform 4000 8043 740.8 83.9 4.49 3.35 11.12 edges 250 550 50.8 7.3 6.93 6.62 19.26 edges 500 1050 97.0 13.4 5.95 5.65 17.07 edges 1000 2049 189.4 25.1 4.90 4.32 12.92 edges 2000 4046 373.9 48.1 4.18 3.34 8.41 edges 4000 8042 742.9 92.7 3.65 2.71 5.51 edges+face 250 550 50.9 7.3 7.09 4.97 20.15 edges+face 500 1050 97.1 13.3 6.06 3.80 16.69 edges+face 1000 2050 189.5 24.8 4.86 2.82 12.63 edges+face 2000 4047 374.2 47.3 4.16 2.35 9.70 edges+face 4000 8042 743.5 91.1 3.68 1.89 7.16 

How I read it:

  • Edge-weighted beats uniform everywhere that has structure. At 2,000 points the tie error is 8.41 vs 13.90. Thin diagonal stripes need small triangles exactly where the stripe edges are, and uniform sampling just doesn't put enough there.
  • The face is the exception. Edge-weighted sampling sends points to the eyes and the hairline, but the cheeks and forehead are flat, so they get a few big triangles whose mean colour is a blend of skin and hair. The face box boost fixes that cheaply (4.32 → 2.82 at 1,000 points; 3.34 → 2.35 at 2,000).
  • Nothing is free. With the boost, the points the face gains come from somewhere. At 2,000 and 4,000 points the tie error goes back up (8.41 → 9.70, 5.51 → 7.16). If the tie matters to you, give it a box too; the density map is just an array.
  • Diminishing returns. Going from 1,000 to 4,000 points quadruples the file and only takes the whole-picture error from about 4.9 to 3.7. For a poster look, 500–1,500 points is the sweet spot. More than that and it stops looking low-poly and starts looking like a blurry photo.

Tests

test_lowpoly.py (6 tests, all passing on Python 3.13, NumPy 2.5, OpenCV 5.0, Pillow 13.0):

  • the triangle count follows Euler's formula for a Delaunay triangulation (about 2n minus the hull);
  • more than 99.9% of pixels are covered by some triangle;
  • a flat image comes back with zero error;
  • more points means less error, and the face boost lowers the face error;
  • the SVG parses as XML, has one polygon per triangle, and when rendered with CairoSVG matches the raster used for scoring (mean difference under 2 levels; I measured 0.5 on test card 1);
  • the same seed gives a byte-identical SVG.

The last one matters more than it looks: without a fixed seed, every run reshuffles the triangles and you can't diff two versions of your poster.

Things that bit me

  • Subdiv2D adds three far-away virtual vertices. getTriangleList() returns triangles connected to them. Filter anything with a vertex outside the frame, or you'll get giant slivers across the image.
  • Slivers with no pixel centre inside. A very thin triangle can rasterise to zero pixels, so its mean colour is 0/0. The script falls back to the colour at the triangle's centroid.
  • OpenCV 8-bit Lab is scaled. cv2.COLOR_RGB2LAB on uint8 gives L* × 255/100 and a, b offset by 128. Rescale L before you call anything a ΔE.
  • File size is mostly coordinates. If 190 KB is too much, round points to integers (already done), drop the stroke and accept faint seams, or serve it gzipped (SVG compresses about 8×).

Using your own portrait, and labelling it

Any portrait works. If you don't have one, malfoidai.com makes one still 3:4 picture at 1K from one photo of an adult, or without a photo, for $4.99 paid once without an account, or 2 credits from a one-time pack (30 credits $13.99, 100 for $35.99, 200 for $59.99; packs need sign-in, and the pricing page says credits never expire). I re-checked the pricing page on 2026-11-13 around 18:00 CST (UTC+8). It makes stills only, no video. The photo guide explains which photo works best.

A low-poly version of an AI portrait is still AI fan art. Malfoid is unofficial: not from the books or films, and not affiliated with them. Label it that way when you post it, and use only your own face or one from an adult who agreed (acceptable use). The pre-posting checklist covers the rest.

Tested on Python 3.13, NumPy 2.5, ...