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, a.tmax)
v, f = mesh(z, res); ok, msg = check_closed(f)
assert ok, msg
write_stl(a.wedge, v, f)
print(f"wedge {a.wedge}: steps (mm) " + " ".join(f"{x:.2f}" for x in t) + f"; {msg}"); return
if not (a.image and a.out):
ap.error("need IMAGE and OUT (or --wedge / --fit)")
if a.map == "beer" and a.mu is None:
ap.error("--map beer needs --mu (calibrate with --wedge and --fit)")
img = Image.open(a.image)
levels = tuple(map(float, a.levels.split(","))) if a.levels else None
z = heightfield(img, a.width, a.res, a.tmin, a.tmax, a.border, a.map, a.mu or 1.0, levels, a.mirror)
v, f = mesh(z, a.res)
ok, msg = check_closed(f)
if not ok:
sys.exit(f"mesh not closed: {msg}")
write_stl(a.out, v, f)
ny, nx = z.shape
vol = volume(v, f)
print(f"source {img.width}x{img.height} px -> grid {nx}x{ny} at {a.res} mm "
f"({img.width / (a.width / 25.4):.0f} source px per inch at {a.width:g} mm wide)")
print(f"size {(nx - 1) * a.res:.1f} x {(ny - 1) * a.res:.1f} mm, thickness {z.min():.2f}-{z.max():.2f} mm")
print(f"{len(f):,} triangles, {84 + 50 * len(f):,} bytes, volume {vol / 1000:.1f} cm3; {msg}")
if a.layer:
q = np.round(z / a.layer) * a.layer
print(f"printed flat at {a.layer} mm layers: {len(np.unique(q))} thickness levels "
f"(printed upright: limited by XY, not layers)")
if a.preview:
mu = a.mu or 1.0
tr = np.exp(-mu * z); tr = (tr - tr.min()) / max(tr.max() - tr.min(), 1e-9)
Image.fromarray((tr * 255).astype(np.uint8)).save(a.preview)
print(f"preview {a.preview} (backlit simulation, mu={mu:g}/mm)")
if __name__ == "__main__":
main()
A few design choices worth explaining:
- Closed solid, not just a surface. The top is a grid of two triangles per cell. The bottom is a flat fan from a centre point to the perimeter vertices, so it costs only a few thousand triangles instead of another full grid. The walls join the top perimeter to the bottom perimeter. Slicers repair small holes, but you shouldn't need them to.
-
check_closedbefore writing. Every directed edge(a, b)must appear exactly once and its reverse(b, a)exactly once. That's a cheap test for "no holes, no duplicate faces, consistent winding", all in onenp.unique. - Border. A 3 mm frame at full thickness. Without it the thin bright edges of the picture are also the edges of the part, and they chip.
-
--mirror. If you want the smooth side facing the room and the relief on the back, mirror the picture, otherwise the portrait reads backwards from the front.
Running it on a synthetic portrait
My test image is a drawn stand-in (dark corridor, candle glow, a pale oval face, platinum hair with thin strands, white shirt, green-and-silver tie, dark robe). No real face, and no generated result. Default settings:
$ python lithophane.py test.png test.stl --layer 0.12 --preview preview.png
source 768x1024 px -> grid 531x698 at 0.2 mm (195 source px per inch at 100 mm wide)
size 106.0 x 139.4 mm, thickness 0.80-3.00 mm
746,182 triangles, 37,309,184 bytes, volume 34.4 cm3; closed, consistently wound
printed flat at 0.12 mm layers: 19 thickness levels (printed upright: limited by XY, not layers)
preview preview.png (backlit simulation, mu=1/mm)
The size includes the 3 mm border on each side (100 + 6 mm wide). It ran in 1.7 seconds on an 8-core box. At --res 0.4 it's 189,896 triangles, a 9.5 MB file and 0.4 seconds, which is worth trying first if your slicer struggles with a 37 MB STL.
To make sure my own checker wasn't fooling me, I loaded the files with trimesh as an independent check:
test.stl watertight True winding consistent True volume 34.38 cm3 bounds [0,0,0]-[106.0,139.4,3.0]
beer+mirror watertight True winding consistent True volume 32.79 cm3 bounds [0,0,0]-[106.0,139.4,3.0]
wedge.stl watertight True winding consistent True volume 2.95 cm3 bounds [0,0,0]-[79.5,19.5,3.0]
Where the face goes: thickness by region
Because the test image is drawn from known shapes, I can measure each region exactly without a face detector. This is the table that explains the "pale face problem". Mean luma (0–1) and the thickness each mapping gives it. levels=(1, 99) stretches the darkest 1% to black and the brightest 1% to white first:
levels=None
region luma linear mm beer mu=1 beer mu=2
background 0.146 2.68 2.22 1.73
hair 0.844 1.14 0.95 0.88
face 0.737 1.38 1.07 0.95
eye whites+iris 0.804 1.23 0.99 0.91
shirt 0.882 1.06 0.91 0.86
tie 0.495 1.91 1.40 1.15
robe 0.171 2.62 2.14 1.65
levels=(1, 99)
region luma linear mm beer mu=1 beer mu=2
background 0.087 2.81 2.47 1.96
hair 0.889 1.04 0.90 0.86
face 0.767 1.31 1.03 0.93
eye whites+iris 0.814 1.21 0.98 0.90
shirt 0.933 0.95 0.86 0.83
tie 0.488 1.93 1.41 1.15
robe 0.116 2.74 2.34 1.83
The numbers come from a 50-line helper (measure_regions.py, not shown) that rebuilds each region mask from the same drawing coordinates the test image uses.
Read the hair and face rows together. With levels stretched, the linear map makes the face 0.27 mm thicker than the hair; the Beer–Lambert map at mu=1 gives a gap of 0.13 mm, and at mu=2 just 0.07 mm. That's about one layer of a typical print. Meanwhile the dark robe and corridor take up most of the thickness range. In plain words: the picture's real contrast is between "pale person" and "dark background", and the face/hair boundary sits in a narrow band near the thin end. A real Malfoid portrait has more going on in the face than my flat oval, but the platinum-hair-on-pale-skin look is exactly what makes this band crowded.
What the lamp would show (model, not a print)
Using the same exponential model, here's the relative brightness each region would appear when backlit, for two made-up mu values. "target" is the picture's (stretched) brightness:
mu=1.0/mm region target linear beer
background 0.087 0.026 0.087
hair 0.889 0.756 0.889
face 0.767 0.549 0.767
robe 0.116 0.036 0.116
shirt 0.933 0.845 0.933
mu=2.0/mm region target linear beer
background 0.087 0.006 0.087
hair 0.889 0.609 0.889
face 0.767 0.351 0.767
robe 0.116 0.008 0.116
shirt 0.933 0.741 0.933
Under the model, beer reproduces the target by construction (one of the unit tests checks exactly that). linear darkens every mid-tone: the face drops from 0.77 to 0.55 or 0.35, and the dark corridor goes almost black. Some people like that: the hair glows and the face separates more from it. So I don't think "linear is wrong". It's a choice, and it's only a fair comparison once you know your mu. These are model outputs with assumed values, not photographs of a print.
Calibrating mu with the wedge
$ python lithophane.py --wedge wedge.stl
wedge wedge.stl: steps (mm) 0.80 1.11 1.43 1.74 2.06 2.37 2.69 3.00; closed, consistently wound
Print it with the same filament and settings, hold it in front of your lamp, take one photo with fixed exposure, and read the average brightness of each step in any image viewer. Then:
$ python lithophane.py --fit "0.8:231,1.11:170,1.43:128,1.74:96,2.06:71,2.37:53,2.69:40,3.0:30"
mu = 0.925 per mm (use: --map beer --mu 0.925)
Those eight readings are invented to show the format. Use your own. The fit is a straight line through ln(brightness) against thickness; a test checks that it recovers mu = 1.3 from noisy synthetic readings to within 0.05. If your camera auto-adjusts exposure between shots, the readings are worthless, so take one photo with the whole wedge in it.
Layers: why "19 levels" matters
If you print the plate lying flat, thickness can only change in whole layers. Between 0.8 and 3.0 mm at 0.12 mm layers that's 19 distinct levels, and the face/hair difference from the table above is about one of them. That's where the banding comes from. Many people print lithophanes standing upright for this reason: the thickness is then drawn by the nozzle moving sideways rather than by stacking layers. The script reports the flat-print level count so you can see the trade-off before slicing.
Tests
$ python test_lithophane.py
test_beer_is_linear_in_light ... ok
test_bright_is_thin ... ok
test_closed ... ok
test_fit_recovers_mu ... ok
test_flipped_triangle_detected ... ok
test_hole_detected ... ok
test_mirror ... ok
test_triangle_count ... ok
test_volume_matches_heightfield ... ok
Ran 9 tests in 0.080s
OK
Two of them are mutation tests: delete one triangle (a hole) or reverse one triangle (bad winding) and check_closed must say no. Another compares the mesh's signed volume with the exact volume under the triangulated height field, which catches walls or a base that face the wrong way. Without them, a mesh checker that always says "closed" would look just as good as a real one.
Before you print someone's face on a lamp
- Check the portrait first. The studio's check-before-posting guide lists what to look at: the face, then hands, hair edges, tie and background symbols. A lithophane makes a stray background mark glow, so crop it out first.
- Start from a good photo. The photo guide asks for one adult facing the camera in even light, no heavy filter, JPEG/PNG/WebP up to 10 MB. Strong coloured light carries into the portrait, and a lithophane throws away colour anyway, so even light matters more than usual.
- Whose face it is. The acceptable-use page says: your own photo or an adult who agreed, never a child or a celebrity. A lamp for a friend is a gift with their face on it; ask them. If it's for a shelf rather than for a person, the no-photo preset makes a fictional adult in the same look (which preset?).
- It's fan art. If you post the print, label it as AI fan art, not an official character image.
What a portrait costs
As shown on the pricing page on November 10, 2026: one portrait is $4.99 paid once with no account, from your photo or without one. Signed-in accounts can buy one-time credit packs instead: $13.99 for 30 credits, $35.99 for 100, $59.99 for 200. A portrait spends 2 credits, packs don't renew, and there's no subscription. The studio doesn't add a watermark, so there's nothing in the corner to glow on your lamp. If a paid portrait fails before delivery, the refunds page explains what happens.
Code: MIT. The test image is synthetic; no real face or generated result is used. Not tested on a physical printer.
Top comments (0)