November 10, 2026

Short answer: map each pixel's brightness to plastic thickness (bright = thin, so light comes through), turn that height field into a closed solid (top surface, flat bottom, four walls), check that every edge is shared by exactly two triangles, and write a binary STL. A 1K still is enough: at a 0.2 mm sample spacing, which is about what a 0.4 mm nozzle can show, 100 mm of width only needs 501 samples. The part to watch on a Malfoid portrait is that pale skin and platinum hair end up only about 0.1 to 0.3 mm apart in thickness, so the outline of the face is the first thing a lithophane loses. The script below is under 200 lines of NumPy and Pillow, and I tested it on a synthetic portrait (numbers below).

I run malfoidai.com, a small, independent, unofficial fan-art studio. It makes one still 3:4 portrait at 1K in the Malfoid look (also called Female Malfoy or Fem Draco): long platinum hair, white shirt, green striped tie, dark robe, candlelit corridor. You make it from one photo of an adult, or use the preset that needs no photo. The studio isn't affiliated with the books, films, author or studios. People ask what they can make with the picture besides a profile picture, and a lamp-lit lithophane suits a portrait that's mostly pale hair against a dark background.

What I did not do: print it. Everything below is geometry and arithmetic I could check on a computer: mesh validity, sizes, thicknesses, triangle counts, run times. Where I describe how something will look on a lamp, I say it's a model and give the assumption.

What a lithophane needs from the picture

A lithophane is a thin plate of light-coloured plastic. Thick areas block light and look dark; thin areas glow. Three numbers decide almost everything:

  • Thickness range. I use 0.8 mm (brightest) to 3.0 mm (darkest) as defaults. Thinner than about 0.8 mm gets fragile; the right maximum depends on your filament. Both are flags.
  • Sample spacing. One height sample every 0.2 mm. A finer grid makes a bigger file but your printer can't show the extra detail.
  • Mapping. How brightness turns into millimetres. This one matters more than you'd think (see below).

The site doesn't publish the exact pixel size of a "1K" download, so check yours (python -c "from PIL import Image; print(Image.open('portrait.png').size)"). My test image is 768×1024. At 0.2 mm spacing, 768 pixels cover about 153 mm, so a lithophane up to roughly 15 cm wide uses every pixel and anything smaller is downsampled. A lithophane is one of the few things where 1K is plenty.

Linear or Beer–Lambert mapping

The obvious mapping is linear: thickness = tmax - brightness × (tmax - tmin). But light doesn't fade linearly through plastic. To a first approximation it falls off exponentially with thickness (the Beer–Lambert law): transmitted ∝ exp(-mu × t), where mu is how strongly your filament attenuates light, per millimetre.

If you want the glow to be proportional to the picture's brightness, you have to solve for thickness in log space. That's the beer mode in the script. The catch is that mu depends on the filament, its colour, the infill, the lamp and your camera. I don't know your value, and I'm not going to pretend there's a standard one. So the script also prints a calibration wedge: eight steps from 0.8 to 3.0 mm. You photograph it in front of the lamp you'll actually use, read the brightness of each step, and fit mu.

The script

pip install numpy pillow python lithophane.py portrait.png portrait.stl --layer 0.12 --preview preview.png 
#!/usr/bin/env python3 """Turn a still portrait into a 3D-printable lithophane (binary STL). Bright pixels become thin plastic (light shines through), dark pixels thick. The mesh is a closed solid: height-field top, flat bottom, four walls, and the script checks that every edge is shared by exactly two triangles before it writes the file. pip install numpy pillow python lithophane.py portrait.png out.stl [--width 100] [--res 0.2] [--tmin 0.8] [--tmax 3.0] [--border 3] [--map linear|beer --mu 1.0] [--levels 1,99] [--mirror] [--layer 0.12] [--preview preview.png] python lithophane.py --wedge wedge.stl [--tmin 0.8] [--tmax 3.0] python lithophane.py --fit "0.8:212,1.2:180,1.6:151,..." --map beer needs --mu (light attenuation per mm of YOUR filament). Print the wedge, photograph it in front of the lamp you will use, read the brightness of each step, and pass the pairs to --fit to get mu. """ import argparse, math, struct, sys import numpy as np from PIL import Image def luminance(img, levels=None): """Rec.709 luma of sRGB pixels in 0..1, optional percentile stretch.""" a = np.asarray(img.convert("RGB"), dtype=np.float64) / 255.0 y = a @ np.array([0.2126, 0.7152, 0.0722]) if levels: lo, hi = np.percentile(y, levels) y = np.clip((y - lo) / max(hi - lo, 1e-6), 0, 1) return y def thickness(bright, tmin, tmax, mode="linear", mu=1.0): """Map brightness 0..1 to plastic thickness in mm (bright -> thin).""" if mode == "linear": return tmax - bright * (tmax - tmin) # Beer-Lambert: transmitted light ~ exp(-mu * t). Make transmitted light # (not thickness) proportional to the wanted brightness. t_lo, t_hi = math.exp(-mu * tmax), math.exp(-mu * tmin) return -np.log(t_lo + bright * (t_hi - t_lo)) / mu def heightfield(img, width_mm, res, tmin, tmax, border, mode, mu, levels, mirror): nx = int(round(width_mm / res)) + 1 ny = int(round(nx * img.height / img.width)) small = img.convert("RGB").resize((nx, ny), Image.LANCZOS) if mirror: small = small.transpose(Image.FLIP_LEFT_RIGHT) z = thickness(luminance(small, levels), tmin, tmax, mode, mu) b = int(round(border / res)) if b: z = np.pad(z, b, constant_values=tmax) # opaque frame return z def mesh(z, res): """Closed triangle mesh for a height field z[row, col] (row 0 = image top).""" ny, nx = z.shape xs = np.arange(nx) * res ys = (ny - 1 - np.arange(ny)) * res # image top = +y X, Y = np.meshgrid(xs, ys) top = np.stack([X, Y, z], -1).reshape(-1, 3) idx = lambda r, c: r * nx + c r, c = np.meshgrid(np.arange(ny - 1), np.arange(nx - 1), indexing="ij") a, b2, d, e = idx(r, c), idx(r, c + 1), idx(r + 1, c), idx(r + 1, c + 1) # rows go down in y, so (a, d, e) and (a, e, b) are counter-clockwise from above tris = [np.stack([a, d, e], -1).reshape(-1, 3), np.stack([a, e, b2], -1).reshape(-1, 3)] # perimeter of the top grid, counter-clockwise seen from above per = ([idx(0, k) for k in range(nx - 1, 0, -1)] + [idx(k, 0) for k in range(ny - 1)] + [idx(ny - 1, k) for k in range(nx - 1)] + [idx(k, nx - 1) for k in range(ny - 1, 0, -1)]) nt = len(top) bottom = top[per].copy(); bottom[:, 2] = 0.0 centre = np.array([[xs[-1] / 2, ys[0] / 2, 0.0]]) verts = np.vstack([top, bottom, centre]) P, ci = len(per), nt + len(per) walls, base = [], [] for k in range(P): t0, t1 = per[k], per[(k + 1) % P] b0, b1 = nt + k, nt + (k + 1) % P walls += [(t0, b0, b1), (t0, b1, t1)] base.append((ci, b1, b0)) # bottom faces -z faces = np.vstack(tris + [np.array(walls), np.array(base)]).astype(np.int64) return verts, faces def check_closed(faces): """Every directed edge once, and its reverse once: closed and consistently wound.""" e = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]) key = e[:, 0] * (faces.max() + 1) + e[:, 1] rkey = e[:, 1] * (faces.max() + 1) + e[:, 0] uniq, counts = np.unique(key, return_counts=True) if (counts != 1).any(): return False, f"{int((counts != 1).sum())} directed edges used more than once" missing = ~np.isin(rkey, uniq) if missing.any(): return False, f"{int(missing.sum())} edges without a partner (holes)" return True, "closed, consistently wound" def volume(verts, faces): v0, v1, v2 = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]] return float(np.einsum("ij,ij->i", v0, np.cross(v1, v2)).sum() / 6.0) def write_stl(path, verts, faces): v0, v1, v2 = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]] n = np.cross(v1 - v0, v2 - v0) n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-12) rec = np.zeros(len(faces), dtype=[("n", "<f4", 3), ("v", "<f4", (3, 3)), ("a", "<u2")]) rec["n"], rec["v"] = n, np.stack([v0, v1, v2], 1) with open(path, "wb") as f: f.write(b"lithophane.py".ljust(80, b" ")); f.write(struct.pack("<I", len(faces))) f.write(rec.tobytes()) def wedge(tmin, tmax, steps=8, step_mm=10.0, res=0.5): """Calibration strip: `steps` flat steps from tmin to tmax.""" t = np.linspace(tmin, tmax, steps) per = int(round(step_mm / res)) row = np.repeat(t, per) return np.tile(row, (int(round(20 / res)), 1)), res, t def fit_mu(pairs): """Least-squares slope of ln(brightness) vs thickness -> mu (1/mm).""" t = np.array([p[0] for p in pairs]); b = np.array([p[1] for p in pairs], float) slope, _ = np.polyfit(t, np.log(b), 1) return -slope def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("image", nargs="?"); ap.add_argument("out", nargs="?") ap.add_argument("--width", type=float, default=100.0, help="picture width in mm (without border)") ap.add_argument("--res", type=float, default=0.2, help="mm per sample; ~half your nozzle width") ap.add_argument("--tmin", type=float, default=0.8); ap.add_argument("--tmax", type=float, default=3.0) ap.add_argument("--border", type=float, default=3.0) ap.add_argument("--map", choices=["linear", "beer"], default="linear") ap.add_argument("--mu", type=float, default=None) ap.add_argument("--levels", default=None, help="percentile stretch, e.g. 1,99") ap.add_argument("--mirror", action="store_true", help="relief on the back: mirror the image") ap.add_argument("--layer", type=float, default=None, help="report grey levels if printed flat") ap.add_argument("--preview", default=None); ap.add_argument("--wedge", default=None) ap.add_argument("--fit", default=None) a = ap.parse_args() if a.fit: pairs = [tuple(map(float, p.split(":"))) for p in a.fit.split(",")] print(f"mu = {fit_mu(pairs):.3f} per mm (use: --map beer --mu {fit_mu(pairs):.3f})"); return if a.wedge: z, res, t = wedge(a.tmin, ...
Source:https://dev.to/lpgit/how-do-you-turn-a-malfoid-ai-portrait-into-a-3d-printed-lithophane-image-to-a-watertight-stl-in-2gmo