Painting → Bas-Relief
Building a depth-derived relief pipeline for Bruegel's Hunters in the Snow
Target: Bambu Lab A1 mini, single filament, 170 × 121 × 7 mm panel.
About this project
The goal. Turn a painting into a bas-relief: a printed panel on which the painting’s composition reads as physical depth — near things standing proud, distant things receding, the drawing carved into the surface. The subject is Bruegel’s Hunters in the Snow (1565), from the Google Art Project scan; the output is a 170 × 121 mm panel from a desktop FDM printer.
The approach, in plain words. A monocular depth model looks at the painting and estimates how far away everything is. That estimate is then conditioned into a printable height map: edges are snapped onto the painted edges, the depth range is compressed so the middle distance survives, the ground is subtracted so objects stand at full height, and the dark linework is cut into the surface as engraving. The height map becomes a watertight mesh, the mesh goes to the slicer, and the printer does the rest. The whole pipeline is six short Python files you type in yourself.
The form. Part guide, part lab diary. §1–11 are the recipe: the environment, the code file by file, and the print settings that produced the first panel — you can follow them start to finish and stop there. §12–13 are the revisions that came after, kept in the order they happened: what was changed, what the printed plastic showed, which ideas failed, and what each failure taught — the failed branches are documented as fully as the working one, because that is where most of the transferable lessons live (above all: the printer is a physical low-pass filter, and a screen preview will lie to you about what it can make). Decisions and their reasons are recorded at the point they were taken, so you can disagree with one and branch off from there.
0. The shape of the thing
painting.jpg
│
├─▶ [A] depth model Depth Anything V2 → relative disparity map
│ (near = large value)
├─▶ [B] tiled refinement re-run on overlapping crops, affine-align,
│ keep only the high frequencies
├─▶ [C] conditioning guided filter → histogram equalise →
│ gradient-domain range compression →
│ luminance detail → rim fade
├─▶ [D] resample to the print grid (0.25 mm pitch)
├─▶ [E] mesh heightfield → watertight solid
└─▶ relief.stl → Bambu Studio → A1 mini
The one idea that matters. A depth map is not a heightfield. Raw depth is dominated by a handful of huge jumps — the hunters are 20 m away, the mountains are 5 km away. Map that linearly onto 5 mm of travel and the entire village, the skaters, the birds, the whole middle of the painting occupy about 0.2 mm. You get a foreground silhouette glued to a flat wall.
Step [C] is where the print is won. Everything else is plumbing.
1. Why this painting in particular
Three things make Hunters in the Snow a great subject and a hard one:
It has real recession. Bruegel built the composition as a depth ladder — the hunters and dogs in the immediate foreground, the diagonal of trees, the frozen ponds at mid-distance, the village, then those impossible Alpine peaks. A depth model has something genuine to find. Compare this to a portrait, where you get a face-shaped lump and little else.
Luminance lies about depth here, badly. The snow is the brightest thing in the painting and it is simultaneously the nearest thing (foreground bank) and the furthest (distant fields). The trees are the darkest thing and they sit at mid-distance. Any pure brightness-to-height mapping produces nonsense on this image. This is exactly why you want the depth model rather than the lithophane approach.
Its detail is high-frequency and thin. Bare branches, birds, tiny skaters. A depth model at its native 518 px working resolution will smear all of that. Hence step [B], and hence the luminance detail term in step [C].
2. Environment
Hardware
| What | Requirement | Notes |
|---|---|---|
| Computer | Apple Silicon Mac, 16 GB+ | written and tested on an M-series MacBook with 32 GB. 16 GB is enough at the INFER_LONG_SIDE = 728 this guide uses. The pipeline is plain PyTorch, so any CUDA or CPU machine works with small changes — the mps lines are the only Apple-specific part |
| 3D printer | FDM, 0.4 mm nozzle, plate ≥ 175 × 125 mm | tested on a Bambu Lab A1 mini. §10's speed and acceleration numbers are A1-mini values; the geometry reasoning applies to any FDM machine |
| Filament | ~100–150 g PLA per panel | prints shown here used matte PLA, dried, at 210–215 °C |
| Disk & time | ~2 GB, half a day | model weights + Python environment; the pipeline itself runs in minutes, a full panel prints in 10–14 h at 0.08 mm layers |
On Apple Silicon, PyTorch uses the mps backend — the machine's GPU cores. No CUDA,
no Rosetta, nothing exotic.
2.1 Python
macOS ships a Python you should not use. Get a clean one:
brew install python@3.12
If you don't have Homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
2.2 Project folder and virtualenv
The scan's filename has parentheses in it, and your own path may have spaces — quote paths
everywhere. The examples use /Users/you/painting2model; put the folder wherever you
like and substitute your own path.
cd "/Users/you/painting2model"
/opt/homebrew/bin/python3.12 -m venv .venv
source .venv/bin/activate
python -V # expect 3.12.x
If that last line reports anything but 3.12. The venv is almost certainly fine —
what usually breaks is the name python, not the environment. Check which:
type -a python python3
python3 -V
python3 -V reads the venv through PATH and is hard to fool, so if it says
3.12.x your environment is correct and only the lookup of python is wrong. The two things
that shadow it:
- An alias. A line like
alias python=/usr/bin/python3in your~/.zshrcbeatsPATHoutright, sopythonsilently means Apple's system 3.9 no matter which venv is active.type -a pythonprints it first when this is the case. Delete the alias, or repoint it atpython3so it resolves throughPATHlike everything else —grep -n "alias python" ~/.zshrc ~/.zprofile ~/.zshenvfinds it. This matters beyond this line: everypythonin §§2.4, 2.6, 6.5 and 9 would otherwise run the system interpreter, which has none of your packages. - The wrong activate script.
source .venv/bin/activateis bash/zsh syntax. Under fish or csh useactivate.fishoractivate.csh— both are already in your.venv/bin.
If python3 -V is also wrong, then the venv really was built by the wrong
interpreter. cat .venv/pyvenv.cfg names the one that made it; delete the folder and re-run
the block above with the full Homebrew path.
2.3 Dependencies
pip install --upgrade pip
pip install torch torchvision
pip install "transformers>=4.45" pillow numpy scipy trimesh
Note what is not here: no opencv, no open3d, no numpy-stl. The guided filter
is fifteen lines of scipy.ndimage (§6.1) and trimesh writes STL directly. Fewer
moving parts, fewer wheel-compatibility problems on arm64.
2.4 Confirm the GPU is live
python - <<'PY'
import torch
print("torch", torch.__version__)
print("mps available:", torch.backends.mps.is_available())
print("mps built:", torch.backends.mps.is_built())
PY
Both should be True. If mps available is False, the pipeline still runs on CPU —
roughly 8–15× slower, which for a handful of inferences means minutes rather than
seconds. Not fatal.
Add this to your shell profile or prepend it to runs; a few ops in the DPT head occasionally lack MPS kernels and this lets them silently fall back to CPU instead of crashing:
export PYTORCH_ENABLE_MPS_FALLBACK=1
2.5 Which model size
| Your Mac | Model | INFER_LONG_SIDE |
|---|---|---|
| 8 GB unified memory | Depth-Anything-V2-Base-hf (~98M) |
728 |
| 16 GB | Depth-Anything-V2-Large-hf (~335M) |
1036 |
| 24 GB+ | Depth-Anything-V2-Large-hf |
1036–1428 |
INFER_LONG_SIDE must be a multiple of 14 (the ViT patch size): 518, 728, 1036, 1428.
The model was trained at 518; going higher recovers real detail but with
diminishing returns and quadratic memory cost. First run downloads weights
(~1.3 GB for Large) into ~/.cache/huggingface. Neither checkpoint is gated —
no Hugging Face account or token is needed. Both are released under CC-BY-NC-4.0,
so a panel on your own wall is fine and selling prints of one is not.
One coupling to know about: main.py feeds the model a working image at twice the
print grid, i.e. 1360 × 968 px. Setting INFER_LONG_SIDE above 1360 would just be
upsampling — if you want to go there, raise the grid_w * 2 factor in main.py to
* 3 first.
2.6 Record your versions
Two kinds of number appear in this guide. The maths — the Poisson round-trip,
the guided filter's behaviour, the compression table — is pure numpy/scipy, and §6.5 lets
you re-derive all of it on your own machine in about a second. The model's
behaviour is not: it depends on the exact transformers and torch you
installed, and the DPT processor's handling of size and do_rescale has
moved between releases before. So record what you have, once:
mkdir -p out
python - <<'PY' | tee out/versions.txt
import platform, torch, transformers, numpy, scipy, PIL, trimesh
print("macos ", platform.mac_ver()[0], platform.machine())
print("python ", platform.python_version())
print("torch ", torch.__version__)
print("transformers", transformers.__version__)
print("numpy ", numpy.__version__)
print("scipy ", scipy.__version__)
print("pillow ", PIL.__version__)
print("trimesh ", trimesh.__version__)
PY
If a reinstall later changes what the model returns, that file tells you what moved, and
you can pin from it — pip install torch==<the version in your file> — rather than
from memory. Nothing in §§4–8 pins a version, because a pin invented today ages worse than
a recorded fact.
2.7 The source image
Nothing so far has put a painting in the folder. Get the highest-resolution scan you can — for this one, the Google Art Project file on Wikimedia Commons, which is public domain (the painting is from 1565) and about 8 MB at 6819 × 4853. Download the full-size original, not a thumbnail: every pixel you skip here is detail the model cannot recover later. Put it in the project folder and check what you actually got:
cd "/Users/you/painting2model"
python - <<'PY'
from PIL import Image
from pathlib import Path
Image.MAX_IMAGE_PIXELS = None
for p in sorted(Path(".").glob("*.jpg")) + sorted(Path(".").glob("*.png")):
print(f"{p.name} {Image.open(p).size}")
PY
Write down the filename and the size, because config.py in §4 needs the exact path
and the pixel dimensions decide the panel's proportions. The listing in §4 uses
6819 × 4853 — aspect 1.4051 — and every derived number in this guide follows
from it. If your file differs, your grid and triangle counts will differ by the same ratio;
nothing is wrong, the numbers just won't match line for line.
A different painting works too — see §14 — but then §1's argument about why this one suits a depth model is yours to re-make.
3. Project layout
painting2model/
├── .venv/
├── config.py all parameters, nothing else
├── heightfield.py the maths — pure numpy/scipy, no torch
├── depth.py the model — torch only lives here
├── mesh.py heightfield → STL
├── main.py orchestration + checkpoint images
├── out/ generated
└── Pieter_Bruegel_the_Elder_-_Hunters_in_the_Snow_(Winter)_-_Google_Art_Project.jpg
Keeping heightfield.py free of torch is deliberate: it means you can iterate on the
conditioning — which is where you'll spend your time — without reloading a 1.3 GB
model. That split only pays off if the depth map itself is cached between runs, which is
what USE_CACHED_DEPTH does (§8): the first run writes
out/depth_cache.npz, and every later run that changes only conditioning parameters
reloads it and skips the model entirely. Seconds instead of minutes, which is the difference
between tuning §6 and not bothering.
4. config.py
Write this first. Every parameter you'll want to turn is here, and nowhere else.
The file is one continuous listing; it is broken into its five sections here so each group of knobs can be read on its own. Type them in this order into a single config.py.
"""All tunable parameters. Edit here, never in the other modules."""
from pathlib import Path
# ---- input / output -------------------------------------------------------
IMAGE = Path("/Users/you/painting2model/"
"Pieter_Bruegel_the_Elder_-_Hunters_in_the_Snow_(Winter)_-_Google_Art_Project.jpg")
OUTDIR = IMAGE.parent / "out"
# Crop the scanned frame/margin away, as fractions of width/height.
# (left, top, right, bottom). Check out/00_source.png and adjust.
CROP = (0.0, 0.0, 0.0, 0.0)
Line by line — header and input/output, 9 lines
- """All tunable parameters. Edit here, never in the other modules."""
- “Never in the other modules” is the rule that keeps this project tunable. Every number that changes the result lives here, so a tuning session is a diff of one file, and you can always answer “what did I change?” The other five modules take their values as arguments and hold no defaults worth arguing with.
- from pathlib import Path
- The only import.
Pathrather than plain strings so thatIMAGE.parent,IMAGE.stemand the/join operator below all work without any string surgery, and somain.pycan call.mkdir()and.stat()on these values directly. - # ---- input / output -------------------------------------------------------
- The five banner comments are the file's table of contents: input, model, conditioning, physical, preview. They are ordered by how often you will touch them — the conditioning block is the one you will live in.
- IMAGE = Path("/Users/you/painting2model/"
- An absolute path, deliberately, so the pipeline runs correctly no matter which directory you launch it from. The string is split across two lines using Python's adjacent-literal concatenation — two quoted strings side by side are joined at compile time, with no
+needed. Note there is no comma between them: add one and you silently get a tuple, andPathwill interpret it as two path segments. - "Pieter_Bruegel_the_Elder_-_Hunters_in_the_Snow_(Winter)_-_Google_Art_Project.jpg")
- The continuation. This is the exact filename Wikimedia Commons serves (§2.7) — parentheses, hyphens and all. If your download has a different name, this is the line to edit, and it is the single most likely cause of a
FileNotFoundErroron your first run. The path is never quoted with shell escaping because Python is not a shell: the spaces and parentheses need no special handling inside a string literal. - OUTDIR = IMAGE.parent / "out"
- Derived rather than repeated, so moving the project needs one edit rather than two. The
/ispathlib's join operator, not division —Pathdefines what/means for its own objects, chosen because it reads like a path separator and handles the separator for you. Nothing on disk exists yet;main.pycalls.mkdir()on this in stage 1. - # Crop the scanned frame/margin away, as fractions of width/height.
- Fractions rather than pixels, so the same numbers work on a different scan of the same painting. It is the only parameter here whose right value you cannot reason about in advance — you have to look at the image.
- # (left, top, right, bottom). Check out/00_source.png and adjust.
- The order matters and is easy to get wrong, hence the comment. Note the right and bottom values are fractions to remove from that edge, not coordinates:
main.pycomputesint((1 - r) * w0), so 0.05 means “drop the rightmost 5%”. - CROP = (0.0, 0.0, 0.0, 0.0)
- No crop by default. Worth checking rather than assuming: Google Art Project scans routinely include a sliver of frame or gallery wall, and §9 warns why that matters — a frame edge is a near-vertical depth discontinuity, so it becomes a hard 5 mm cliff running around your whole panel. All four zeros also make
if any(C.CROP)inmain.pyskip the crop entirely.
Which checkpoint, at what resolution, and how the tiled second pass is shaped.
# ---- depth model ----------------------------------------------------------
MODEL_ID = "depth-anything/Depth-Anything-V2-Large-hf" # Base-hf on 8 GB Macs
INFER_LONG_SIDE = 1036 # must be a multiple of 14. 518 / 728 / 1036 / 1428
USE_TILED_REFINE = True
TILE = 518 # multiple of 14
TILE_OVERLAP = 172
TILE_LOWPASS_SIGMA_PX = 24.0 # in working-grid pixels
USE_CACHED_DEPTH = True # reuse out/depth_cache.npz while tuning the block below
Line by line — depth model, 8 lines
- # ---- depth model ----------------------------------------------------------
- Everything the model sees. Change anything in this block and the cached depth map in
out/depth_cache.npzis invalidated automatically —depth_signature()in §8 lists exactly these values, which is why the next block can be tuned freely without re-running inference. - MODEL_ID = "depth-anything/Depth-Anything-V2-Large-hf" # Base-hf on 8 GB Macs
- A Hugging Face repository id, resolved and downloaded on first use into
~/.cache/huggingface. The-hfsuffix matters: it is thetransformers-native conversion, which is whatAutoModelForDepthEstimationcan load. The bareDepth-Anything-V2-Largerepo holds the original.pthcheckpoint and will not work here. Neither is gated, and both are CC-BY-NC-4.0 (§2.5). - INFER_LONG_SIDE = 1036 # must be a multiple of 14. 518 / 728 / 1036 / 1428
- The resolution the model actually runs at. 14 is the ViT patch size, so the input must divide evenly into patches —
_round14indepth.pyenforces this even if you get it wrong here. The model was trained at 518; going higher recovers real detail at quadratic memory cost. There is a hard ceiling you cannot exceed usefully: the working image is twice the print grid, so anything above that is the model upsampling its own input (§2.5, §8). - USE_TILED_REFINE = True
- The second pass over overlapping crops (§5.4). Set it to
Falsefor the first run and while tuning — it costs 12 inferences instead of 1 — and back toTruefor the final one. §9 makes this the explicit workflow. - TILE = 518 # multiple of 14
- The crop size for the tiled pass, at the model's native training resolution. Each crop is fed to the model at its own size, so a 518 px crop of a 1360 px image is seen at roughly 2.6× the effective resolution of the global pass — which is exactly where the extra detail comes from.
- TILE_OVERLAP = 172
- How much neighbouring crops share. The overlap is what the Hann window cross-fades across, so raising it makes seams less likely and costs more tiles. §11 sends you to 240 if you see grid seams. At 518/172 the stride is 346 px, which for this image works out to 4×3 = 12 tiles.
- TILE_LOWPASS_SIGMA_PX = 24.0 # in working-grid pixels
- The frequency at which authority passes from the global pass to the tiles. Below this scale the merged crops win; above it the global pass wins. Too low and the tiles start dictating large-scale structure they cannot see, which is what reintroduces seams; too high and you throw away the detail you just paid twelve inferences for. Note
main.pyrescales this by the working-to-grid ratio before passing it in, so the number stays meaningful if you change the pitch. - USE_CACHED_DEPTH = True # reuse out/depth_cache.npz while tuning the block below
- The switch that makes tuning bearable. With it on, a run whose upstream settings are unchanged loads the depth map from disk and never imports torch at all — seconds instead of minutes. Set it to
Falseto force a fresh inference every run, or just delete the.npz. It cannot go stale silently: the signature check re-infers automatically when anything above it changes.
The seven numbers that decide what the relief actually looks like. This is the block you will come back to.
# ---- heightfield conditioning --------------------------------------------
GUIDED_RADIUS = 6 # px, snaps depth edges onto painted edges
GUIDED_EPS = 2e-3
EQUALIZE = 0.55 # 0 = keep model's depth distribution, 1 = flat histogram
COMPRESS_ALPHA = 4.0 # gradient-domain range compression. 0 = off, 2-6 typical
DETAIL_GAIN = 0.12 # brushwork/texture from image luminance. 0 = pure depth
DETAIL_SIGMA_PX = 2.5
INVERT = False # True if the print comes out with foreground sunken
RIM_MM = 2.0 # flat recessed border. 0 = relief runs to the edge
Line by line — heightfield conditioning, 9 lines
- # ---- heightfield conditioning --------------------------------------------
- The block §0 calls the one that wins the print, and the only one you should expect to iterate on. Nothing here invalidates the depth cache, by design — that is the entire point of the signature in §8.
- GUIDED_RADIUS = 6 # px, snaps depth edges onto painted edges
- The half-width of the guided filter's window, so it works over a 13×13 neighbourhood. It costs nothing to raise —
uniform_filteris a running-sum implementation whose runtime does not depend on the window size — so treat §11's advice to go to 8–10 for halos as free. The upper limit is conceptual rather than computational: too large and the local linear model stops being a good description of the neighbourhood. - GUIDED_EPS = 2e-3
- The regularisation that decides what counts as an edge. It is compared against the local variance of the guide, so it lives in units of luminance-squared on a [0, 1] image. Larger means more smoothing and less edge-snapping. Measured on a synthetic step, 1e-4 and 2e-3 give almost the same result (0.509 against 0.504) while 1e-1 washes it out to 0.342 — the default sits on a plateau, so §11's suggestion to drop it to 1e-3 is a gentle nudge rather than a lever.
- EQUALIZE = 0.55 # 0 = keep model's depth distribution, 1 = flat histogram
- A blend, not a switch — 0.55 means slightly more than half way to a flat histogram. It matters for this painting specifically because an enormous number of pixels sit at mid-distance on the valley floor, and without equalisation they share a thin slab of the 5 mm. At 1.0 the scene starts to look pressure-flattened (§6.4).
- COMPRESS_ALPHA = 4.0 # gradient-domain range compression. 0 = off, 2-6 typical
- The single most consequential number in the file. It controls how hard large gradients are attenuated before reintegration, i.e. how much of your 5 mm goes to the foreground/background split versus to everything in between. §6.2's table and §6.5's self-test both measure the tradeoff; 15 flattens the depth story entirely.
- DETAIL_GAIN = 0.12 # brushwork/texture from image luminance. 0 = pure depth
- How much high-frequency luminance is added as surface texture, as a fraction of the height range — so 0.12 is roughly 0.6 mm on 5 mm. It is the one place brightness re-enters the geometry after §1 spends three paragraphs explaining why brightness lies about depth here, and the low default reflects that caution. Set it to 0 for a pure depth interpretation and expect a noticeably softer result.
- DETAIL_SIGMA_PX = 2.5
- The blur radius that defines “high frequency” for the line above. At 2.5 px you keep bare branches and roof tiles and discard broad tonal gradients. §11 sends you to 4 if the surface looks speckled — that is asking for a coarser crossover, i.e. less of the finest noise.
- INVERT = False # True if the print comes out with foreground sunken
- The escape hatch for the polarity convention §5.2 warns about. Depth Anything outputs inverse depth, where larger means nearer, which happens to be what a relief wants — but that is a convention, not a law, and it is the thing most likely to flip silently if you swap models.
main.pynow prints an automatic verdict on this after the global pass, so you should know before you print rather than after. - RIM_MM = 2.0 # flat recessed border. 0 = relief runs to the edge
- A flat border faded down to the base plate. Cosmetic in intent, but it also guarantees the outermost pixels sit at base height, so the panel's edge is a clean wall rather than a ragged silhouette of whatever the model saw at the frame. One consequence worth knowing: because the fade is applied last, a field whose peak falls inside the border gets faded with it, and the panel comes out fractionally under the nominal height (§9).
Millimetres and sample spacing. Everything downstream is derived from these four.
# ---- physical print -------------------------------------------------------
WIDTH_MM = 170.0 # A1 mini plate is 180x180; leave margin for a brim
BASE_MM = 2.0 # solid backing plate
RELIEF_MM = 5.0 # peak-to-valley sculpted depth
PITCH_MM = 0.25 # sample spacing; 0.4 mm nozzle, so 0.25 is ~1.6x oversampled
Line by line — physical print, 5 lines
- # ---- physical print -------------------------------------------------------
- Four numbers, and everything about the output geometry follows from them: grid size, triangle count, file size, print time. They are also the only values in this file measured in millimetres rather than pixels or dimensionless factors.
- WIDTH_MM = 170.0 # A1 mini plate is 180x180; leave margin for a brim
- The panel's width; the height is not specified anywhere, because the script derives it from your image's real aspect ratio. 178 would technically fit the 180 mm plate but leaves nothing for the 5 mm brim §10 asks for and puts you inside the plate's exclusion margins. This is also the knob for §10's advice to print a small test first — drop it to 85 and re-run.
- BASE_MM = 2.0 # solid backing plate
- The solid slab under the relief. Below about 1.5 mm a 170 mm panel will cup as it cools; 2 mm is twenty layers at 0.10 and stiff enough, with something to drill or glue a hanger onto. §11 sends you to 3.0 if it cups anyway.
- RELIEF_MM = 5.0 # peak-to-valley sculpted depth
- The parameter people get wrong: it is a resolution, not a size. Your Z resolution is the layer height, so at 0.10 mm layers, 5 mm of relief buys you exactly 50 distinct height levels — fifty grey levels, in effect. Halve the relief and you get 25 and visible terracing. The right response to wanting more is a finer layer height, not a deeper relief.
- PITCH_MM = 0.25 # sample spacing; 0.4 mm nozzle, so 0.25 is ~1.6x oversampled
- The XY sample spacing, and therefore the grid size and the triangle count. It is set against the nozzle, not against the image: 0.4 mm is the true feature floor, and 0.25 gives the slicer about 1.6 samples per nozzle width — enough for smooth diagonals. Halving it quadruples the file for detail the nozzle physically cannot lay down.
# ---- preview --------------------------------------------------------------
LIGHT_AZIMUTH_DEG = 315.0
LIGHT_ALTITUDE_DEG = 32.0
Line by line — preview lighting, 3 lines
- # ---- preview --------------------------------------------------------------
- Two numbers that change nothing about the print — they only affect
06_preview_lit.png. Which makes them more useful than they look, because §9 tells you to judge the whole result from that image. - LIGHT_AZIMUTH_DEG = 315.0
- Compass bearing of the simulated lamp, measured clockwise from north, so 315° is from the upper left. That is the convention nearly every reader's visual system already assumes for shaded relief, and getting it wrong is what makes a hillshade read inside-out. Worth sweeping through 0–360 once on a finished heightfield: a relief that only reads under one lighting angle is a relief that will disappoint you on a real wall.
- LIGHT_ALTITUDE_DEG = 32.0
- The lamp's height above the horizon. Low is the point — “raking” light throws long shadows and is what makes a few millimetres of relief legible at all. Push this toward 90° and the preview flattens into nothing, which is also an honest simulation of hanging the panel under a ceiling downlight.
Where those numbers come from
WIDTH_MM = 170. The A1 mini build volume is 180 × 180 × 180 mm. Going to 178
technically fits but leaves nothing for a brim and puts you inside the plate's
exclusion margins. At 170 the panel is 170 × 121.0 mm for the scan in §2.7. Note that
that is the scan's aspect, 1.4051, not the painting's — Hunters in the Snow is
162 × 117 cm, aspect 1.385, and the Google Art Project file is a little wider than the
stretcher. The script reads the real aspect from your file rather than assuming, so a
different scan shifts every number in this section slightly.
PITCH_MM = 0.25. Your nozzle is 0.4 mm, so 0.4 mm is the true XY feature floor.
Sampling at 0.25 gives the slicer ~1.6 samples per nozzle width, which is enough to
render smooth diagonals without wasting geometry. That's a 680 × 484 grid → 662,886
triangles → a 33.1 MB binary STL. Bambu Studio handles that comfortably. Dropping to
0.15 mm quadruples the file for detail the nozzle physically cannot lay down.
RELIEF_MM = 5.0 and the layer-height trap. This is the parameter people get
wrong. Your real Z resolution is not infinite — it is the layer height. At 0.10 mm
layers, 5 mm of relief gives you 50 distinct height levels, and that's it. Fifty
grey levels, in effect. Shrink the relief to 2 mm and you have 20, and the print
will visibly terrace. Go to 8 mm and you get 80 levels but the panel starts to read
as a sculpture rather than a relief, and print time climbs. 5 mm at 0.10 mm layers
is the sweet spot; if you want more, drop to 0.08 mm layers (62 levels) rather than
increasing the depth.
BASE_MM = 2.0. Below about 1.5 mm a 170 mm panel will cup as it cools. 2 mm is
20 layers at 0.10 — stiff enough, and it gives you something to drill or glue a
hanger onto.
5. depth.py — running the model
This file runs Depth Anything V2 on the painting. How such a model can read depth out of a single image at all — its architecture, training and metrics — is a subject of its own: How the depth model works, a primer that assumes no machine-learning background.
5.1 Why not pipeline()
The one-liner works:
pipe = pipeline("depth-estimation", model=MODEL_ID, device="mps")
result = pipe(image)
but its result["depth"] is an 8-bit PIL image. Eight bits, quantised, for a
field you're about to compress in the gradient domain. You'd be throwing away most of
your dynamic range before you started. result["predicted_depth"] is the float
tensor, but it comes out at the model's internal resolution and the pipeline gives you
no control over what that resolution is.
Use the processor and model directly. Slightly more code, complete control.
5.2 The polarity thing
Depth Anything outputs relative inverse depth — disparity. Larger values are
NEARER. That happens to be exactly what you want for a relief (near things stick
out), so no inversion is needed. But this is a convention, not a law, and it is the
single most likely thing to silently flip on you if you swap models. The INVERT
flag in config and checkpoint image 01_depth_global.png exist so you can verify it
in two seconds rather than after a twelve-hour print.
Because this is the failure most likely to cost you a print, main.py also checks
it for you: after the global pass it compares the mean depth of the bottom quarter of the
frame against the top quarter and prints a verdict. That is a heuristic about receding
ground planes, not a law — an aerial view, a ceiling fresco or a flat-on close-up
will fail it legitimately — so it reports unclear rather than guessing whenever the
margin is small. For Hunters in the Snow, with a foreground bank at the bottom and
Alpine peaks at the top, it is decisive.
5.3 The file
Four short pieces, in order, make up depth.py.
"""Monocular depth inference on Apple Silicon (MPS)."""
import time
import numpy as np
import torch
import torch.nn.functional as F
from scipy.ndimage import gaussian_filter
from transformers import AutoImageProcessor, AutoModelForDepthEstimation
_CACHE = {}
def pick_device():
return "mps" if torch.backends.mps.is_available() else "cpu"
def load(model_id, device):
if model_id not in _CACHE:
t = time.time()
print(f" fetching {model_id}", flush=True)
proc = AutoImageProcessor.from_pretrained(model_id)
model = AutoModelForDepthEstimation.from_pretrained(model_id)
print(f" weights in RAM after {time.time() - t:.0f}s; moving to {device}", flush=True)
model = model.to(device).eval()
_CACHE[model_id] = (proc, model)
print(f" model ready in {time.time() - t:.0f}s", flush=True)
return _CACHE[model_id]
def _round14(n):
return max(int(round(n / 14)) * 14, 14)
Line by line — imports, device pick, cached model load, 23 lines
- """Monocular depth inference on Apple Silicon (MPS)."""
- “Monocular” is the whole difficulty in one word: depth from a single image, with no stereo pair and no parallax. The model is guessing from learned cues — occlusion, texture gradient, familiar sizes — which is why §1 argues so hard that this painting gives it something real to find, and why §14 warns it falls apart on abstract art.
- import time
- Here for one purpose only: reporting how long the model load is taking. The first call into this file is the longest wait in the whole pipeline, and a stopwatch is the cheapest way to turn “is it stuck?” into “it is 40 seconds in”.
- import numpy as np
- The boundary of this file is a numpy array. Torch tensors live inside
inferand never escape it, which is what lets everything downstream stay torch-free. - import torch
- The only file in the project that imports it, by design (§3).
main.pydefers this import into a branch, so a cached run never pays for it — torch takes a couple of seconds to import even when you do not use it. - import torch.nn.functional as F
- Imported solely for
F.interpolateat the end ofinfer. The functional namespace rather than a layer object because there is no state to hold. - from scipy.ndimage import gaussian_filter
- Used only by
tiled_refine, for the low-pass/high-pass split that decides which pass is authoritative at which scale. It is the one non-torch numerical operation in this file. - from transformers import AutoImageProcessor, AutoModelForDepthEstimation
- The
Autoclasses read the model repository's config and instantiate the right concrete class — here a DPT head on a DINOv2 backbone — so the code names no architecture and swappingMODEL_IDfor another depth model mostly just works. The processor is the other half: it handles resizing and normalisation exactly as the model was trained to expect. - _CACHE = {}
- A module-level dictionary, and worth being precise about what it does not do. It caches the loaded model for the lifetime of one Python process, so the twelve tiled inferences do not reload 1.3 GB of weights twelve times. It does not survive the process exiting, and it has nothing to do with the depth-map cache in
main.py— that is a file on disk, and it is the one that makes tuning fast. - def pick_device():
- One line of hardware detection, isolated so that no other function has to think about it.
- return "mps" if torch.backends.mps.is_available() else "cpu"
- Metal Performance Shaders — the GPU cores on Apple Silicon. No CUDA branch, because there is no CUDA on this machine. The
cpufallback is a real fallback, not a failure: §2.4 notes it is 8–15× slower, which for a handful of inferences means minutes rather than seconds. Note this checks availability, not correctness — a few DPT ops lack MPS kernels, which is whatPYTORCH_ENABLE_MPS_FALLBACK=1is for. - def load(model_id, device):
- Fetch-and-cache. Called by
inferon every invocation, which is why the cache matters — without it the tiled pass would spend its entire runtime on disk I/O. - if model_id not in _CACHE:
- Keyed on the model id rather than a boolean, so switching
MODEL_IDmid-session loads the new one without discarding the old. Everything inside this branch happens exactly once per process, so the reporting below never clutters the tiled pass. - t = time.time()
- The stopwatch for this load only — separate from
main.py'st0, which times the whole run. - print(f" fetching {model_id}", flush=True)
- Announces the download before it starts, so the repository id is on screen while
huggingface_hub's own transfer bar fills in beneath it.flush=Trueis the part that matters: Python buffers stdout, and without it this line could sit unwritten while the thing it describes is already running. - proc = AutoImageProcessor.from_pretrained(model_id)
- Downloads (first time) or reads from
~/.cache/huggingface. A few kilobytes of JSON, so effectively instant even on a cold cache. §5.1 explains why the processor is used directly instead of thepipeline()one-liner: control over resolution, and float output rather than an 8-bit PIL image. - model = AutoModelForDepthEstimation.from_pretrained(model_id)
- The 1.3 GB one, and the only genuinely long wait in a first run.
huggingface_hubdraws its own transfer bar for this, so the download is the one part of the pipeline that already reported itself. What follows it does not — hence the next line, and hence.to(device)being split off rather than chained on here. - print(f" weights in RAM after {time.time() - t:.0f}s; moving to {device}", flush=True)
- By this point the transfer bar has finished and scrolled away, and what happens next is a silent 1.3 GB copy into GPU memory that reports nothing at all. This line is the marker separating “still downloading” from “downloaded, now loading” — two very different situations that otherwise look identical from the outside.
- model = model.to(device).eval()
- Two things, formerly chained onto the line above.
.to(device)moves the parameters onto the GPU;.eval()switches off dropout and batch-norm's training behaviour. Forgetting.eval()is a classic silent bug — the model still runs, it just gives subtly different answers each time. - _CACHE[model_id] = (proc, model)
- Both halves stored together, since neither is useful alone.
- print(f" model ready in {time.time() - t:.0f}s", flush=True)
- The total, which is the number worth remembering. On a warm cache it is a few seconds and tells you the download is genuinely behind you; on a cold one it is minutes, and seeing it at all means the slow part is over.
- return _CACHE[model_id]
- Subsequent calls are a dictionary lookup and print nothing — which is why the twelve tiled inferences stay quiet and let
tiled_refine's own per-tile log do the talking. - def _round14(n):
- Private helper enforcing the patch-size constraint. The leading underscore says: this is an implementation detail of
infer, not part of the module's interface. - return max(int(round(n / 14)) * 14, 14)
- Rounds to the nearest multiple of 14 — the ViT patch size — because the transformer divides the image into 14×14 patches and cannot handle a partial one. Rounds to nearest rather than truncating, so 1360 becomes 1358 rather than 1344. The
max(…, 14)stops a very small input from rounding to zero, which would produce an empty tensor and a baffling error several frames deeper.
The processor is fed an explicit size so the model runs at a resolution you chose rather than one it picked. _round14 keeps it legal for DPT.
@torch.inference_mode()
def infer(pil_img, model_id, device, long_side):
"""Raw model output as float64 numpy, at the input image's own size.
Depth Anything outputs relative inverse depth (disparity):
LARGER VALUES ARE NEARER.
"""
proc, model = load(model_id, device)
w, h = pil_img.size
if w >= h:
size = {"width": _round14(long_side), "height": _round14(long_side * h / w)}
else:
size = {"height": _round14(long_side), "width": _round14(long_side * w / h)}
Line by line — infer(): choosing the input size, 12 lines
- @torch.inference_mode()
- A decorator that disables autograd for everything inside. Stronger than the older
no_grad(): it also skips version counters and view tracking, so tensors are cheaper and the whole call uses less memory. There is no training here and never will be, so this is pure upside — and on a 16 GB machine at 1036 px it is part of why the run fits. - def infer(pil_img, model_id, device, long_side):
- Takes a PIL image, returns a numpy array. No config, no globals — which is what lets
tiled_refinecall it twelve times on crops without any special-casing. - """Raw model output as float64 numpy, at the input image's own size.
- “At the input image's own size” is a promise the caller depends on: whatever resolution the model ran at internally, what comes back matches what went in. The
F.interpolateline at the end is what keeps it. - Depth Anything outputs relative inverse depth (disparity):
- “Relative” means there are no units and no absolute scale — two different crops of the same scene can report the same tree at 0.3 and at 1.7, which is exactly the problem
_fit_affineexists to solve. “Inverse depth” means the value is proportional to 1/distance rather than distance. - LARGER VALUES ARE NEARER.
- Shouted, and §5.2 explains why: this is a convention rather than a law, it happens to be exactly what a relief wants, and it is the single thing most likely to flip silently if you swap models. The
INVERTflag, the01_depth_global.pngcheckpoint and the automatic polarity verdict inmain.pyall exist because of this one line. - """
- Closes the docstring.
- proc, model = load(model_id, device)
- Cached after the first call, as above.
- w, h = pil_img.size
- PIL reports (width, height) — the opposite order from numpy's
.shape, which gives (rows, cols) = (height, width). Mixing the two up is the most common source of transposed-image bugs, and this file is careful to unpack PIL sizes asw, hevery time. - if w >= h:
- Which side is longer decides which one
long_sideapplies to. Landscape images take the first branch; this painting is landscape, so it always does. - size = {"width": _round14(long_side), "height": _round14(long_side * h / w)}
- The long side is set to the requested resolution and the short side is scaled to preserve aspect ratio — then both are rounded to multiples of 14. The rounding means the aspect is preserved only approximately, by up to seven pixels, which is far below anything that matters after the interpolation back to the original size.
- else:
- The portrait branch. Never taken for this painting, but
tiled_refinepasses square crops, wherew >= hholds and the first branch runs. - size = {"height": _round14(long_side), "width": _round14(long_side * w / h)}
- The mirror image of the landscape case.
Output comes back at the model's internal resolution, so it is interpolated back up to the image's own size before leaving the torch world as float64.
inputs = proc(images=pil_img, return_tensors="pt", size=size, do_rescale=True)
inputs = {k: v.to(device) for k, v in inputs.items()}
pred = model(**inputs).predicted_depth # (1, h', w')
if pred.dim() == 3:
pred = pred.unsqueeze(1)
pred = F.interpolate(pred.float(), size=(h, w), mode="bicubic", align_corners=False)
return pred[0, 0].detach().to("cpu").numpy().astype(np.float64)
Line by line — infer(): run the model, resample to the input grid, 7 lines
- inputs = proc(images=pil_img, return_tensors="pt", size=size, do_rescale=True)
- The processor resizes, rescales and normalises in one call.
sizeoverrides its built-in default, which is the whole reason §5.1 rejects thepipeline()one-liner — the pipeline gives you no control over the resolution the model actually sees.do_rescale=Truemaps 0–255 to 0–1 before the mean/std normalisation; pass an already-scaled float array and you must set itFalseor the image arrives 255× too dark.return_tensors="pt"asks for torch rather than numpy. - inputs = {k: v.to(device) for k, v in inputs.items()}
- Moves every tensor in the batch dict onto the GPU. A dict comprehension rather than
inputs.to(device)because what the processor returns is a plainBatchFeaturemapping, and being explicit costs nothing. - pred = model(**inputs).predicted_depth # (1, h', w')
- The forward pass — the only line here that does real work, and the one that takes 10–40 seconds.
predicted_depthis the float tensor at the model's internal resolution. §5.1 contrasts it with the pipeline'sresult["depth"], which is an 8-bit PIL image: quantised to 256 levels before you have done any of the gradient-domain work that needs the dynamic range. - if pred.dim() == 3:
- A compatibility guard. Some versions return (batch, h, w) and some (batch, channel, h, w), and
F.interpolatebelow insists on four dimensions. - pred = pred.unsqueeze(1)
- Inserts a channel axis at position 1. This is exactly the kind of thing §2.6 is about: it is a shape convention that has moved between
transformersreleases, so the code accepts both rather than pinning a version. - pred = F.interpolate(pred.float(), size=(h, w), mode="bicubic", align_corners=False)
- Back up to the input image's own resolution, honouring the docstring's promise.
bicubicrather thanbilinearbecause the result is about to be differentiated bycompress_range, and bilinear upsampling leaves first-derivative discontinuities at every source pixel boundary — invisible in the image, very visible in the gradient field.align_corners=Falseis the geometrically correct convention, treating pixels as areas rather than points. - return pred[0, 0].detach().to("cpu").numpy().astype(np.float64)
- The exit from torch, in four deliberate steps.
[0, 0]drops the batch and channel axes..detach()severs any autograd history — redundant underinference_mode, but harmless and explicit..to("cpu")is mandatory before.numpy(), which cannot read GPU memory..astype(np.float64)widens from the model's float32, giving the Poisson solve downstream the precision that makes its round-trip exact to 1e-13 rather than 1e-5.
do_rescale=True and the explicit size dict override the processor's defaults;
the DPT processor wants multiples of 14, which _round14 guarantees.
Expect roughly 10–40 s for one Large inference at 1036 on an M-series Mac, dominated by the first call (weight loading). A fanless machine will thermal-throttle partway through a long tiled run. That's normal and harmless.
5.4 Tiled refinement (append to depth.py)
One global pass sees the whole painting at ~1036 px and produces coherent but soft depth. Running the model again on overlapping 518 px crops lets it resolve the branches and the skaters — but each crop comes back with its own arbitrary scale and offset, because monocular depth is only relative. Two adjacent crops might report the same tree at 0.3 and at 1.7.
The fix is three moves:
- Affine-align each crop to the global pass. Least-squares fit
a·local + b ≈ globalover the crop's footprint. Now every crop lives in the global pass's units. - Hann-window blend. Weight each crop by a 2D raised cosine so contributions taper to zero at crop edges. Overlapping crops cross-fade instead of butting.
- Keep only the high frequencies from the merged result. The global pass is
authoritative about large-scale structure; the crops are authoritative about
fine structure.
result = lowpass(global) + highpass(merged).
Measured on a synthetic scene with known ground truth: high-frequency energy went from 0.0216 (global only) to 0.0475 (refined) against a true value of 0.0530, and correlation with truth rose from 0.973 to 0.9986. Residual seam visibility at tile boundaries was 0.5–1.4× the mean gradient, i.e. lost in the noise.
Three small helpers, then the routine itself split at the point where the tile loop begins. All of it appends to depth.py.
def _hann2d(h, w):
return np.maximum(np.outer(np.hanning(h + 2)[1:-1], np.hanning(w + 2)[1:-1]), 1e-6)
Line by line — the 2D Hann window, 2 lines
- def _hann2d(h, w):
- The blending weight for the tiled pass. Each crop is multiplied by this before being accumulated, so crops contribute fully at their centres and taper to nothing at their edges — which is what makes overlapping crops cross-fade instead of butting against each other with a visible seam.
- return np.maximum(np.outer(np.hanning(h + 2)[1:-1], np.hanning(w + 2)[1:-1]), 1e-6)
- Three ideas in one line.
np.hanning(n)is a raised cosine that is exactly zero at both ends — so asking forn + 2and slicing off those ends gives a window that is small but strictly positive everywhere, which matters because a genuine zero would contribute nothing while still occupying the array.np.outermakes the 2D window as the product of two 1D ones, which is what “separable” means here. And themaximum(…, 1e-6)floor guarantees the weight accumulatorwacccan never be zero at any pixel, so theacc / waccdivision at the end cannot produce a NaN even in a corner covered by exactly one tapering crop.
Fits a·local + b ≈ global, with a mean-offset fallback when the crop is degenerate (a flat sky patch, for instance).
def _fit_affine(local, ref):
A = np.stack([local.ravel(), np.ones(local.size)], axis=1)
a, b = np.linalg.lstsq(A, ref.ravel(), rcond=None)[0]
if not np.isfinite(a) or abs(a) < 1e-8:
return 1.0, float(ref.mean() - local.mean())
return float(a), float(b)
Line by line — least-squares affine alignment, 6 lines
- def _fit_affine(local, ref):
- The fix for monocular depth's central inconvenience. Each crop comes back with its own arbitrary scale and offset — the same tree might read 0.3 in one crop and 1.7 in its neighbour — so before a crop can be merged it has to be mapped into the global pass's units. This finds the
aandbina·local + b ≈ ref. - A = np.stack([local.ravel(), np.ones(local.size)], axis=1)
- The design matrix for a linear least-squares fit: first column the local depths, second column all ones. The column of ones is what makes the fit affine rather than purely multiplicative — it is the coefficient of the intercept.
- a, b = np.linalg.lstsq(A, ref.ravel(), rcond=None)[0]
- Solves for the slope and intercept that minimise squared error over the crop's whole footprint — hundreds of thousands of pixels, so it is a very well-determined fit.
rcond=Noneopts into the modern default for the singular-value cutoff; omitting it emits a deprecation warning.[0]takes the solution, discarding residuals and rank. - if not np.isfinite(a) or abs(a) < 1e-8:
- Two degenerate cases in one test. A non-finite slope means the fit failed outright. A near-zero slope means the crop had essentially no depth variation — a patch of empty sky, or a flat wall — in which case the fitted scale is meaningless and applying it would multiply that region to nothing.
- return 1.0, float(ref.mean() - local.mean())
- The fallback: no rescaling, just a shift that matches the means. Conservative by design — for a featureless crop, matching the average is the most you can honestly claim to know.
- return float(a), float(b)
- Cast to Python floats so the arithmetic in the caller is scalar rather than 0-d array operations.
Regular stride, plus a final flush-to-the-edge position so the last strip is never dropped.
def _positions(n, tile, overlap):
step = max(tile - overlap, 1)
pos = list(range(0, max(n - tile, 0) + 1, step))
if not pos or pos[-1] != max(n - tile, 0):
pos.append(max(n - tile, 0))
return sorted(set(pos))
Line by line — tile positions along one axis, 6 lines
- def _positions(n, tile, overlap):
- Where to place crops along a single axis. Called once per axis, and the two results are combined into a grid by the double loop below.
- step = max(tile - overlap, 1)
- The stride between crop origins. At the defaults that is 518 − 172 = 346 px. The
max(…, 1)prevents an infinite loop if someone setsTILE_OVERLAPgreater than or equal toTILE. - pos = list(range(0, max(n - tile, 0) + 1, step))
- Regularly spaced origins from 0 up to the last position where a full tile still fits. The
max(n - tile, 0)handles an image smaller than one tile, where the only valid origin is 0. - if not pos or pos[-1] != max(n - tile, 0):
- Has the regular stride reached the far edge? Almost never, because the image dimension is rarely an exact multiple of the stride.
- pos.append(max(n - tile, 0))
- A final crop flush against the far edge. Without this, a strip up to 345 px wide along the right and bottom would get no tiled refinement at all — it would silently keep the soft global depth while everything else got sharpened, which reads as a blurred band rather than as a missing tile.
- return sorted(set(pos))
setremoves the duplicate when the flush position happens to coincide with a regular one;sortedrestores order after the set destroys it. For this image the two axes give 4 and 3 positions, hence the 12 tiles §5.4 quotes.
Accumulators for the weighted sum and the weights, one shared window, and the grid of crop origins.
def tiled_refine(pil_img, global_depth, model_id, device,
tile=518, overlap=172, lowpass_sigma=24.0, log=print):
W, H = pil_img.size
tile = min(tile, H, W)
acc = np.zeros((H, W)); wacc = np.zeros((H, W))
win = _hann2d(tile, tile)
ys, xs = _positions(H, tile, overlap), _positions(W, tile, overlap)
total, n = len(ys) * len(xs), 0
Line by line — tiled_refine(): setup, 8 lines
- def tiled_refine(pil_img, global_depth, model_id, device,
- Takes the image and the global pass, because every crop has to be aligned against something. The global depth is the shared reference frame that makes twelve independent inferences commensurable.
- tile=518, overlap=172, lowpass_sigma=24.0, log=print):
- Defaults mirroring
config.py, so the function is usable standalone.log=printis dependency injection in its smallest form: passlog=lambda *a: Nonefor a silent run, or a real logger, without the function knowing anything about either. - W, H = pil_img.size
- PIL order again — width first.
- tile = min(tile, H, W)
- Clamps the crop size to the image, so a small image or an aggressive
CROPcannot ask for a 518 px crop out of a 400 px picture. - acc = np.zeros((H, W)); wacc = np.zeros((H, W))
- The two halves of a weighted average, accumulated separately: the sum of weight×value, and the sum of weights. Dividing them at the end gives the correct blend at every pixel regardless of how many crops happened to cover it — which is what lets the tile grid be irregular at the edges without any special-casing.
- win = _hann2d(tile, tile)
- One window, computed once and reused for every crop. All crops are the same size, so there is nothing to recompute.
- ys, xs = _positions(H, tile, overlap), _positions(W, tile, overlap)
- The two axes' origin lists. Note
ysusesHandxsusesW— the one place in this function where getting the pairing wrong would produce a confusing partial coverage rather than an outright error. - total, n = len(ys) * len(xs), 0
- Progress bookkeeping.
totalis what makes the log line say “tile 7/12” rather than just “tile 7”, which matters when the alternative is staring at a fanless laptop for ten minutes wondering how far along it is.
Each crop is inferred, affine-aligned, and accumulated under the window. The return line is the whole trick: low frequencies from the global pass, high frequencies from the merged crops.
for y in ys:
for x in xs:
n += 1
log(f" tile {n}/{total} at ({x}, {y})")
crop = pil_img.crop((x, y, x + tile, y + tile))
local = infer(crop, model_id, device, long_side=tile)
a, b = _fit_affine(local, global_depth[y:y + tile, x:x + tile])
acc[y:y + tile, x:x + tile] += (a * local + b) * win
wacc[y:y + tile, x:x + tile] += win
merged = acc / wacc
return (gaussian_filter(global_depth, lowpass_sigma)
+ merged - gaussian_filter(merged, lowpass_sigma))
Line by line — tiled_refine(): the loop and the frequency merge, 12 lines
- for y in ys:
- Outer loop over rows of the tile grid.
- for x in xs:
- Inner loop over columns. The only genuine Python loop in the entire project — justified because each iteration runs a neural network, so the loop overhead is irrelevant.
- n += 1
- Tile counter for the progress line.
- log(f" tile {n}/{total} at ({x}, {y})")
- Printed before the inference, not after, so the number on screen is the tile currently being worked on. Printing after would leave you looking at a stale count during the slowest part.
- crop = pil_img.crop((x, y, x + tile, y + tile))
- PIL's crop box is (left, upper, right, lower) with the right and lower bounds exclusive, so this is exactly
tilepixels square. Cropping the PIL image rather than a numpy array means the processor's resize and normalisation see a normal image, exactly as in the global pass. - local = infer(crop, model_id, device, long_side=tile)
- The model runs on the crop at its own native size. That is the entire source of the extra detail: a 518 px crop of a 1360 px image is seen at roughly 2.6× the effective resolution of the global pass, so branches and skaters that were smeared into the background now resolve.
- a, b = _fit_affine(local, global_depth[y:y + tile, x:x + tile])
- Aligns this crop to the global pass over exactly its own footprint. After this the crop's values are in the global pass's units and can be added to a neighbour's without a step at the boundary.
- acc[y:y + tile, x:x + tile] += (a * local + b) * win
- Accumulate the aligned crop under its Hann weight.
+=on a slice is an in-place add into the existing array, so overlapping crops sum rather than overwrite — this is the cross-fade actually happening. - wacc[y:y + tile, x:x + tile] += win
- The matching weight sum. Every pixel's total weight is whatever the crops covering it happened to contribute, and the division below normalises it away.
- merged = acc / wacc
- The weighted average. Because
_hann2dfloors the window at 1e-6 rather than letting it reach zero, and because_positionsguarantees full coverage, no pixel can have a zero denominator. - return (gaussian_filter(global_depth, lowpass_sigma)
- The third and cleverest move, and the reason this whole function works. The global pass is authoritative about large-scale structure — it is the only one that ever saw the whole painting at once — so its low frequencies are kept.
- + merged - gaussian_filter(merged, lowpass_sigma))
- And the crops are authoritative about fine structure, so their high frequencies are added:
mergedminus its own blur is exactly the detail the blur removed. The result islowpass(global) + highpass(merged). Any residual disagreement between crops at large scales — the thing that would show as seams — lives entirely in the low frequencies, which this line discards. §5.4 quotes the measurement: high-frequency energy rises from 0.0216 to 0.0475 against a true 0.0530, correlation with truth from 0.973 to 0.9986, and seam visibility lost in the noise at 0.5–1.4× the mean gradient.
At the settings in config this is 12 tiles for Hunters in the Snow — call it
3–10 minutes. Set USE_TILED_REFINE = False while you're tuning the conditioning,
turn it on for the final run.
6. heightfield.py — the part that matters
6.1 Guided filter
The depth model works at low resolution and upsamples, so its boundaries are soft and misaligned with the painting's actual edges: a tree trunk's depth edge sits three or four pixels off from the tree trunk. Printed, that reads as a halo.
The guided filter (He et al., 2010) fixes this by locally fitting depth as a linear function of image luminance within a sliding window, then applying that fit. Where the image has a crisp edge, the depth inherits it. Where the image is flat, it averages, killing noise.
Measured on a synthetic test: edge step magnitude went 0.180 → 0.470 (true edge 1.000) while noise in flat regions fell from 0.049 → 0.0017. Sharper and cleaner.
6.2 Range compression — the core of it
Take the depth gradient field, attenuate large gradients logarithmically, leave small ones untouched, then reintegrate by solving a Poisson equation. This is the Weyrich et al. (2007) digital-bas-relief construction and it is the difference between a print that reads as a sculpted scene and a print that reads as a step.
For small |g|, log(1+αg)/α ≈ g — fine detail passes through untouched. For large
|g|, it grows logarithmically — a 100× gradient becomes a ~5× gradient. Big steps
get crushed, texture survives.
Reintegration is a Poisson solve with Neumann boundaries, which has an exact
closed-form solution in the DCT domain: transform the divergence, divide by the
Laplacian eigenvalues 2(cos(πi/M) − 1) + 2(cos(πj/N) − 1), zero the DC term,
inverse transform. One DCT pair. No iteration, no solver library. Round-trip error
measured at 3.9 × 10⁻¹⁴ — exact to floating point.
If that sentence went past too quickly — what a Poisson solve is, why 2D needs one at all, and what “Neumann” buys you — there is a side note that builds the whole thing from arithmetic: The Poisson solve. It is self-contained and assumes no calculus.
Measured effect on a test scene with a hard foreground step plus fine texture:
COMPRESS_ALPHA |
step height | texture amplitude | texture ÷ step |
|---|---|---|---|
| 0 (off) | 0.945 | 0.010 | 0.010 |
| 2 | 0.376 | 0.106 | 0.281 |
| 4 | 0.258 | 0.124 | 0.481 |
| 6 | 0.200 | 0.133 | 0.664 |
| 15 | 0.101 | 0.147 | 1.458 |
α = 15 flattens the scene into pure texture — the depth story is gone. α = 2–6 is the usable band. Start at 4.
The same crop — bare branches over the valley — through the whole sweep. The middle three are from a real tuning session; the two ends are rendered from the same depth map to show where the band stops working:
6.3 The file
This is the longest file, so it is broken here into one block per function. They concatenate, in this order, into heightfield.py.
"""Turn a raw relative-depth map into a printable heightfield. No torch here."""
import numpy as np
from scipy.fft import dctn, idctn
from scipy.ndimage import uniform_filter, gaussian_filter, zoom
Line by line — imports, 4 lines
- """Turn a raw relative-depth map into a printable heightfield. No torch here."""
- The docstring carries a promise, not a description. Nothing in this file may import
torch— that is the architectural rule §3 sets up, and it is what lets a tuning run reload a cached depth map and skip a 1.3 GB model entirely. If you ever find yourself wanting a torch call here, the right move is to put it indepth.pyand pass the result in. - import numpy as np
- Everything in this file is dense float64 array arithmetic. float64 rather than float32 matters in one place specifically: the Poisson round-trip below is exact to ~1e-13 in double precision, and would be roughly 1e-5 in single — still fine for a print, but you would lose the ability to assert exactness in the self-test.
- from scipy.fft import dctn, idctn
- The discrete cosine transform, forward and inverse, in n dimensions. This is the whole Poisson solver: a DCT-II diagonalises the discrete Laplacian under Neumann (zero-flux) boundary conditions, so a problem that looks like it needs a sparse linear solver becomes a transform, a division, and a transform back.
scipy.fftrather than the olderscipy.fftpack— same algorithm, better multithreading and a saner interface. - from scipy.ndimage import uniform_filter, gaussian_filter, zoom
- Three separable filters.
uniform_filteris a box mean and powers the guided filter;gaussian_filtersupplies the low-pass inadd_detail;zoomdoes spline resampling inresize_to. All three are separable, so their cost is linear in pixels and — for the box filter — independent of the window size, which is whyGUIDED_RADIUSis free to raise.
Normalise to [0,1] with a zero-range guard, and a resize that will not off-by-one you.
# ---------------------------------------------------------------- utilities
def norm01(a):
a = np.asarray(a, dtype=np.float64)
lo, hi = float(a.min()), float(a.max())
return np.zeros_like(a) if hi - lo < 1e-12 else (a - lo) / (hi - lo)
def resize_to(a, shape, order=3):
zy, zx = shape[0] / a.shape[0], shape[1] / a.shape[1]
out = zoom(a, (zy, zx), order=order, mode="nearest")
return out[:shape[0], :shape[1]]
Line by line — utilities, 9 lines
- # ---------------------------------------------------------------- utilities
- The banner comments divide this file into its four jobs: utilities, the edge-aware filter, the gradient-domain compression, and the tonal shaping. If you are hunting for a parameter's effect, the banner tells you which section owns it.
- def norm01(a):
- Rescale to [0, 1]. It is called constantly — after almost every stage in
main.py— because most of the operations here neither expect nor preserve a particular range. The Poisson solve in particular returns a field with an arbitrary offset and scale, so normalising after it is not optional. - a = np.asarray(a, dtype=np.float64)
asarrayrather thanarray: it does not copy when the input is already a float64 ndarray, so callingnorm01six times in a pipeline costs six no-ops rather than six full-grid copies. The explicit dtype guards against an integer input silently integer-dividing later.- lo, hi = float(a.min()), float(a.max())
- Both extremes in one pass each. The
float()casts turn numpy scalars into Python floats so the comparison below is a plain scalar comparison rather than a 0-d array operation — cosmetic, but it keeps the next line readable. - return np.zeros_like(a) if hi - lo < 1e-12 else (a - lo) / (hi - lo)
- The guard is the point. A constant field — a fully cropped-away image, a depth map the model gave up on, a rim that ate everything — would otherwise divide by zero and fill the array with
nan. NaNs propagate silently through every later stage and finally surface as “STL reports not watertight”, which is the single most misleading symptom in the whole pipeline (§11 lists it, and this line is the reason it is rare). Returning zeros instead gives you a flat panel: obviously wrong, immediately diagnosable. - def resize_to(a, shape, order=3):
- Resample to an exact target shape.
order=3is bicubic spline interpolation — smooth enough not to introduce staircase artefacts into a field you are about to differentiate, which is exactly whatcompress_rangedoes to it a moment later. Dropping toorder=1(bilinear) would put visible facet edges into the relief. - zy, zx = shape[0] / a.shape[0], shape[1] / a.shape[1]
zoomtakes a scale factor per axis, not a target size, so the factors are computed here. Note rows first: this file is consistently (row, col) = (y, x), matching numpy and PIL's transposed convention only aftermain.pyhas done the flipping.- out = zoom(a, (zy, zx), order=order, mode="nearest")
mode="nearest"controls how the spline reads beyond the array edge: it clamps to the edge value rather than reflecting or wrapping. Reflection would fold the mountains back into the sky at the top border; wrapping would be worse. Clamping simply extends the edge, which is the least surprising thing to do to a depth map.- return out[:shape[0], :shape[1]]
- The defensive crop, and the reason this helper exists at all rather than a bare
zoomcall. Because the scale factors are floats,zoomrounds and can return an array one pixel larger than you asked for. One stray pixel would make the heightfield and the luminance array different shapes, and the next broadcast would raise deep insideadd_detailwith an error message that names neither function.
The whole filter is six lines of box means: local variance of the guide, local covariance with the source, then the linear coefficients.
# ------------------------------------------------- edge-aware guided filter
def guided_filter(guide, src, radius=6, eps=2e-3):
"""He et al. 2010. Snaps `src` onto the edges of `guide` and kills noise."""
box = lambda a: uniform_filter(a, size=2 * radius + 1, mode="nearest")
I, p = np.asarray(guide, np.float64), np.asarray(src, np.float64)
mI, mp = box(I), box(p)
var = box(I * I) - mI * mI
cov = box(I * p) - mI * mp
a = cov / (var + eps)
b = mp - a * mI
return box(a) * I + box(b)
Line by line — guided_filter(), 11 lines
- # ------------------------------------------------- edge-aware guided filter
- Section two: the one operation that looks at the painting and the depth map together. Everything above this line is bookkeeping; everything below it changes the shape of the relief.
- def guided_filter(guide, src, radius=6, eps=2e-3):
- Two images in, one out.
srcis the thing being filtered (the soft depth map);guideis the thing whose edges you want to borrow (the painting's luminance). They must be the same shape. The asymmetry is the whole idea: the output carriessrc's values butguide's boundaries. - """He et al. 2010. Snaps `src` onto the edges of `guide` and kills noise."""
- The citation matters because the derivation is not obvious from the code. The paper's assumption is that within any small window, the output is an affine function of the guide:
out = a·I + b. Everything below is the least-squares fit ofaandbper window, done for all windows at once. - box = lambda a: uniform_filter(a, size=2 * radius + 1, mode="nearest")
- A local mean over a (2r+1)×(2r+1) window, defined once because the six lines below are nothing but box means.
uniform_filteris separable and implemented with running sums, so its cost does not grow with the window: measured at 900×900, radius 2 and radius 96 both take about 0.01 s. That is whyGUIDED_RADIUSis a free parameter — raising it to 10, as §11 suggests for halos, costs nothing.mode="nearest"again clamps at the border rather than reflecting. - I, p = np.asarray(guide, np.float64), np.asarray(src, np.float64)
Iandpare the paper's own symbols — guide image and filtering input — kept deliberately so the code can be read against the paper. One-letter names are usually a smell; here they are a citation.- mI, mp = box(I), box(p)
- The local means, μI and μp. Two of the four statistics the fit needs.
- var = box(I * I) - mI * mI
- Local variance via the identity Var(X) = E[X²] − E[X]². This costs one extra box filter rather than a second pass over every window, which is what makes the whole filter O(1) per pixel. It is numerically the shakier of the two ways to compute a variance, but the inputs here are normalised to [0, 1], so catastrophic cancellation is not in play.
- cov = box(I * p) - mI * mp
- Local covariance between guide and source, by the same identity. Together with
varthis is everything a per-window linear regression needs. - a = cov / (var + eps)
- The regression slope, and the line where
GUIDED_EPSearns its keep. Where the guide has a real edge,varis large,epsis negligible, andaapproaches cov/var — the output tracks the guide and inherits its edge. Where the guide is flat,varis near zero,epsdominates,acollapses toward 0, and the output becomes just the local mean ofp— which is smoothing. One expression, both behaviours, selected automatically by local contrast. Measured on a synthetic step:eps= 1e-4 gives an edge of 0.509, 2e-3 gives 0.504, and 1e-1 gives 0.342, so the default sits on the plateau and §11's advice to lower it for halos moves you along a genuinely flat part of the curve. - b = mp - a * mI
- The intercept that makes the fit pass through the window's centroid, so the filter preserves the local mean of
pexactly. Without it the output would be a scaled copy of the guide's luminance — which is the lithophane failure §1 spends three paragraphs arguing against. - return box(a) * I + box(b)
- Each pixel belongs to (2r+1)² overlapping windows, each with its own fit, so the coefficients are averaged before being applied — that is what the two outer
boxcalls are for, and it is what makes the output continuous instead of blocky. Note the shape of the result: values fromp, boundaries fromI. §6.5 measures both halves of that claim at once — the edge step rises 0.080 → 0.400 while flat-region noise falls 0.0510 → 0.0019.
Divergence of the gradient field, then a DCT, a division by the Laplacian eigenvalues, and a DCT back. Setting lam[0,0] = 1 before dividing and zeroing the DC term afterwards is what pins the arbitrary constant.
# --------------------------------------- gradient-domain range compression
def _laplacian_eigenvalues(shape):
M, N = shape
ii = np.arange(M).reshape(-1, 1)
jj = np.arange(N).reshape(1, -1)
return 2 * (np.cos(np.pi * ii / M) - 1) + 2 * (np.cos(np.pi * jj / N) - 1)
def poisson_from_gradients(gx, gy):
"""Solve laplacian(h) = div(g), Neumann boundary, via DCT. Exact and fast."""
div = np.zeros_like(gx)
div[:, 1:] += gx[:, 1:] - gx[:, :-1]
div[:, 0] += gx[:, 0]
div[1:, :] += gy[1:, :] - gy[:-1, :]
div[0, :] += gy[0, :]
lam = _laplacian_eigenvalues(div.shape)
lam[0, 0] = 1.0
h_hat = dctn(div, type=2, norm="ortho") / lam
h_hat[0, 0] = 0.0
return idctn(h_hat, type=2, norm="ortho")
Line by line — the Poisson solve, 18 lines
- # --------------------------------------- gradient-domain range compression
- Section three, and the reason this project is not a lithophane. Two functions here are pure machinery — the eigenvalues and the solver — and one,
compress_range, is the idea. - def _laplacian_eigenvalues(shape):
- The leading underscore marks it private — nothing outside this file should call it. It returns the eigenvalues of the discrete Laplacian operator under Neumann boundary conditions, which is the array you divide by to invert that operator.
- M, N = shape
- Rows and columns. The eigenvalue depends on both axes independently, which is why the two terms below simply add.
- ii = np.arange(M).reshape(-1, 1)
- A column vector of row-frequency indices. Reshaping to (M, 1) rather than (M,) is what lets the final line broadcast into a full (M, N) grid without an explicit meshgrid — cheaper in memory and clearer once you have seen the pattern once.
- jj = np.arange(N).reshape(1, -1)
- The matching row vector of column frequencies, shape (1, N). Broadcasting (M, 1) against (1, N) yields (M, N).
- return 2 * (np.cos(np.pi * ii / M) - 1) + 2 * (np.cos(np.pi * jj / N) - 1)
- The eigenvalue of the 5-point Laplacian for DCT-II basis function (i, j). It is separable — a row term plus a column term — because the 2D Laplacian is a sum of two 1D ones. Every entry is ≤ 0, and exactly one is zero: the (0, 0) constant mode, which is dealt with below. The π/M rather than 2π/M is the DCT's half-frequency spacing, and it is what encodes the zero-flux boundary.
- def poisson_from_gradients(gx, gy):
- The inverse of taking a gradient. You hand it a gradient field that may not be integrable — one that is not the gradient of any actual surface — and it returns the surface whose gradient is closest to it in the least-squares sense. That tolerance for inconsistent input is precisely why
compress_rangecan rescale gradients freely and still get a coherent heightfield back. - """Solve laplacian(h) = div(g), Neumann boundary, via DCT. Exact and fast."""
- Neumann means zero-flux at the border: the surface is free to sit at any height, but nothing flows across the edge. That is the right condition for a relief panel, where the rim is a free boundary rather than a clamped one. Dirichlet (fixed-value) boundaries would need a DST instead and would pin the border to zero — which
apply_rimdoes later anyway, but as a deliberate cosmetic choice rather than a constraint baked into the physics. - div = np.zeros_like(gx)
- The right-hand side of the equation, accumulated in four steps below rather than one expression, because the boundary rows and columns need different treatment from the interior.
- div[:, 1:] += gx[:, 1:] - gx[:, :-1]
- Backward difference of the x-gradient across the interior — the ∂/∂x half of the divergence. Backward here because
compress_rangeproducedgxwith a forward difference; the divergence operator must be the negative adjoint of the gradient operator or the round-trip is not exact. §6.5 asserts that exactness at 1e-9, and measures 2.7×10−13. - div[:, 0] += gx[:, 0]
- The first column has no left neighbour. Treating the off-grid value as zero is the discrete form of the zero-flux boundary — and it is the specific choice that makes the operator match the eigenvalues computed above. Change this line and the round-trip error stops being 1e-13.
- div[1:, :] += gy[1:, :] - gy[:-1, :]
- The same backward difference down the columns, the ∂/∂y half. The
+=accumulates onto the x contribution already there — divergence is the sum of the two. - div[0, :] += gy[0, :]
- The top row's boundary term, mirroring the first-column case.
- lam = _laplacian_eigenvalues(div.shape)
- The array to divide by. Computed fresh each call, which is a few milliseconds at this grid — cacheable, but not worth the state.
- lam[0, 0] = 1.0
- The constant mode's eigenvalue is exactly zero, so this dodges a division by zero. The value 1.0 is arbitrary — anything non-zero works — because whatever lands in that bin is discarded on the line after next.
- h_hat = dctn(div, type=2, norm="ortho") / lam
- The entire solve. Transform to the cosine basis, divide each coefficient by its eigenvalue, and you have inverted the Laplacian.
type=2is the standard DCT-II;norm="ortho"makes the transform its own inverse up to the matchingidctn, so no scaling factor has to be tracked by hand. No iteration, no solver library, no tolerance to tune. - h_hat[0, 0] = 0.0
- Discards the arbitrary constant. A gradient field determines a surface only up to an additive offset, so this picks the zero-mean member of that family. Harmless either way —
norm01runs immediately after this inmain.py— but leaving the garbage value from the fake eigenvalue in place would produce an enormous DC term and anorm01that crushes everything else to a single grey. - return idctn(h_hat, type=2, norm="ortho")
- Back to the spatial domain. One forward and one inverse transform for the whole solve, and it is exact rather than iterative — the source of the “3.9 × 10−14” claim in §6.2 and of §6.5's first check.
Gradients are normalised by their own mean magnitude first, so alpha means the same thing regardless of the depth map's scale.
def compress_range(h, alpha=4.0):
"""Logarithmically attenuate large gradients, leave small ones alone."""
if alpha <= 0:
return np.array(h, dtype=np.float64)
gx = np.zeros_like(h); gx[:, :-1] = np.diff(h, axis=1)
gy = np.zeros_like(h); gy[:-1, :] = np.diff(h, axis=0)
mag = np.hypot(gx, gy)
nz = mag > 1e-12
if not np.any(nz):
return np.array(h, dtype=np.float64)
s = mag[nz].mean()
scale = np.ones_like(mag)
mn = mag[nz] / s
scale[nz] = np.log1p(alpha * mn) / (alpha * mn)
return poisson_from_gradients(gx * scale, gy * scale)
Line by line — compress_range(), 15 lines
- def compress_range(h, alpha=4.0):
- The function §0 calls the one that wins the print. It takes a heightfield and returns a heightfield with the same fine detail and dramatically less large-scale range — the operation that turns a depth map into something a 5 mm panel can carry.
- """Logarithmically attenuate large gradients, leave small ones alone."""
- The whole method in one sentence. Note it is a statement about gradients, not heights: nothing here looks at absolute depth, which is why it works the same on a valley and a portrait.
- if alpha <= 0:
- The off switch. §11's tuning table sends you to
COMPRESS_ALPHA = 0to see the uncompressed relief for comparison, so this path is used deliberately, not just defensively. - return np.array(h, dtype=np.float64)
np.arrayrather thanasarray— a genuine copy. Every early return in this file copies, so callers can never accidentally alias and mutate their input. The one exception isapply_rim's early return, which is a deliberate identity.- gx = np.zeros_like(h); gx[:, :-1] = np.diff(h, axis=1)
- Forward difference along x, with the last column left at zero.
np.diffreturns an array one narrower than its input, so writing into a zero array both sizes it correctly and sets the boundary gradient to zero — matching the Neumann assumption the solver makes. The two statements share a line because they are one idea. - gy = np.zeros_like(h); gy[:-1, :] = np.diff(h, axis=0)
- The same down y, last row zeroed.
- mag = np.hypot(gx, gy)
- Gradient magnitude.
hypotrather thansqrt(gx**2 + gy**2)because it is overflow-safe and marginally more accurate — irrelevant at these magnitudes, but free. - nz = mag > 1e-12
- A mask of pixels with a gradient worth scaling. Flat regions are excluded from both the mean below and the scaling itself, so a large expanse of sky cannot drag the normalisation down.
- if not np.any(nz):
- A perfectly flat input has no gradients to compress.
- return np.array(h, dtype=np.float64)
- Returning early also avoids a divide-by-zero in the mean on the next line.
- s = mag[nz].mean()
- The normalising constant, and the reason
alphameans the same thing on every image. Gradients are divided by their own mean magnitude before the log is applied, soCOMPRESS_ALPHA = 4behaves identically whether the depth map spans 0–1 or 0–1000. Without this line,alphawould have to be retuned for every model and every image. - scale = np.ones_like(mag)
- Default scaling of 1 — no change — so flat pixels pass through untouched by construction.
- mn = mag[nz] / s
- The normalised magnitudes: 1.0 is an average-sized gradient, 100 is a cliff.
- scale[nz] = np.log1p(alpha * mn) / (alpha * mn)
- The compression curve, and the heart of the Weyrich construction. The multiplier is log(1 + αm)/(αm). For small m it tends to 1 — fine detail passes through untouched. For large m it decays like log(m)/m — a 100× gradient is cut to roughly 5×.
log1prather thanlog(1 + x)keeps precision when the argument is tiny, which is exactly the brushwork case. §6.5 measures the consequence: as α goes 0 → 15 the step falls 0.952 → 0.285 while the texture rises 0.0119 → 0.1629. - return poisson_from_gradients(gx * scale, gy * scale)
- Reintegrate. The rescaled field is no longer a true gradient of anything — scaling x and y independently destroys the curl-free property — which is why a least-squares Poisson solve is the right tool rather than a naive cumulative sum. A cumulative sum would streak the errors across the whole panel; the solve spreads them out.
A rank transform, blended with the original by strength.
# ------------------------------------------------------------ tonal shaping
def equalize(h, strength=1.0):
"""Push the depth histogram toward flat so every depth band gets relief."""
if strength <= 0:
return np.array(h, dtype=np.float64)
flat = np.asarray(h, np.float64).ravel()
order = np.argsort(flat)
ranks = np.empty(flat.size, dtype=np.float64)
ranks[order] = np.linspace(0.0, 1.0, flat.size)
return norm01((1 - strength) * norm01(flat) + strength * ranks).reshape(h.shape)
Line by line — equalize(), 10 lines
- # ------------------------------------------------------------ tonal shaping
- Section four: three cheap, independent adjustments applied after the heavy lifting. Each is a single config number, and each can be switched off without touching the others.
- def equalize(h, strength=1.0):
- Histogram equalisation on the heightfield, blended by
strength. It is the answer to a specific complaint about this painting: an enormous population of pixels sits at mid-distance on the valley floor, and without this they all share one thin slab of the 5 mm. - """Push the depth histogram toward flat so every depth band gets relief."""
- “Flat histogram” means every height band ends up with roughly the same number of pixels — which by definition gives crowded bands more millimetres and sparse ones fewer.
- if strength <= 0:
- Off switch, as elsewhere.
- return np.array(h, dtype=np.float64)
- A copy, so callers cannot alias their input.
- flat = np.asarray(h, np.float64).ravel()
ravelflattens to 1D without copying where possible. The ranking below is a global operation over all pixels, so the 2D structure is irrelevant until the reshape at the end.- order = np.argsort(flat)
- The indices that would sort the array — the single expensive line in this file, O(n log n) on 330,000 elements. Still only a few hundredths of a second, which is why no attempt is made to approximate it with a histogram.
- ranks = np.empty(flat.size, dtype=np.float64)
emptyrather thanzeros: every element is about to be written, so there is no reason to pay for initialisation.- ranks[order] = np.linspace(0.0, 1.0, flat.size)
- The inverse-permutation trick, and the line most worth staring at.
orderlists positions in sorted order, so assigning evenly spaced values through it writes each pixel's normalised rank back to that pixel's original location. One vectorised assignment replaces a loop. Note the consequence for ties: equal heights receive distinct adjacent ranks in whatever order argsort happened to produce, so a large perfectly flat region comes out as a faint gradient rather than staying flat. In practice depth maps have no exact ties, but it is worth knowing before you feed this a synthetic test image. - return norm01((1 - strength) * norm01(flat) + strength * ranks).reshape(h.shape)
- A linear blend between the original distribution and the fully flattened one, so
EQUALIZEis continuous rather than a toggle. Both sides are normalised first so the blend is meaningful, and the result is normalised again because a convex combination of two [0, 1] fields need not span the full range. §6.4 explains why 0.55 rather than 1.0: at full strength the scene starts to look pressure-flattened.
def add_detail(h, luma, gain=0.12, sigma=2.5):
"""Add the painting's high-frequency structure as fine surface texture."""
if gain <= 0:
return np.array(h, dtype=np.float64)
hp = luma - gaussian_filter(luma, sigma)
denom = np.abs(hp).max()
if denom < 1e-9:
return np.array(h, dtype=np.float64)
return h + gain * (hp / denom)
Line by line — add_detail(), 9 lines
- def add_detail(h, luma, gain=0.12, sigma=2.5):
- The one place the painting's own brightness re-enters the geometry. §1 argues at length that luminance lies about depth in this image — this function is the carefully bounded exception, adding only the high-frequency part of luminance, where it encodes brushwork and edges rather than distance.
- """Add the painting's high-frequency structure as fine surface texture."""
- “Texture” is meant literally: at the default gain this contributes about 0.6 mm on top of 5 mm of depth-driven form. It is what you feel with a fingertip, not what you see from across the room.
- if gain <= 0:
- §6.4 tells you to set this to 0 for a pure depth interpretation, so the branch is a documented workflow rather than defensive code.
- return np.array(h, dtype=np.float64)
- A copy, consistent with the rest of the file.
- hp = luma - gaussian_filter(luma, sigma)
- An unsharp mask: the image minus its own blur is everything the blur removed, which is the high-frequency residual.
DETAIL_SIGMA_PXsets the crossover — at 2.5 px you keep bare branches and roof tiles and discard the broad tonal gradients that would fight the depth map. §11's advice to raise it to 4 for a speckled surface is asking you to move that crossover coarser. - denom = np.abs(hp).max()
- The peak absolute response, used to normalise. This is what makes
DETAIL_GAINreadable as a fraction of the relief: gain 0.12 means the loudest bit of brushwork stands 0.12 of the full height range proud, whatever the image's contrast happens to be. - if denom < 1e-9:
- A perfectly smooth luminance — a solid colour, or a sigma so small the blur is a no-op — has no high-frequency content, and dividing by its zero peak would produce NaNs.
- return np.array(h, dtype=np.float64)
- Same guarded copy.
- return h + gain * (hp / denom)
- Addition, not multiplication, and deliberately so. A multiplicative blend would scale the brushwork by the local depth, making detail vanish in the valleys — which is where most of this painting's content lives. Note this can push values slightly outside [0, 1];
main.pywraps the call innorm01for exactly that reason.
Distance to the nearest edge, clipped to the rim width, put through a smoothstep so the border fades rather than steps.
def apply_rim(h, rim_mm, pitch_mm):
"""Fade the outermost few mm down to the base plate: a clean recessed border."""
if rim_mm <= 0:
return h
r = max(int(round(rim_mm / pitch_mm)), 1)
H, W = h.shape
dy = np.minimum(np.arange(H), np.arange(H)[::-1]).reshape(-1, 1)
dx = np.minimum(np.arange(W), np.arange(W)[::-1]).reshape(1, -1)
t = np.clip(np.minimum(dy, dx) / r, 0.0, 1.0)
return h * (t * t * (3 - 2 * t)) # smoothstep
Line by line — apply_rim(), 10 lines
- def apply_rim(h, rim_mm, pitch_mm):
- A flat recessed border. Cosmetic in intent, but it also does real work: it guarantees the panel's outermost pixels sit at base height, so the edge of the print is a clean wall rather than a ragged silhouette of whatever the depth model happened to see at the frame.
- """Fade the outermost few mm down to the base plate: a clean recessed border."""
- “Fade”, not “cut”. A hard cut would create a vertical cliff, and while a heightfield can always print a cliff without support, it would read as an error.
- if rim_mm <= 0:
RIM_MM = 0lets the relief run to the very edge.- return h
- The one early return in this file that does not copy. Deliberate: with no rim there is genuinely nothing to do, and
main.pyassigns the result straight back over the same name. - r = max(int(round(rim_mm / pitch_mm)), 1)
- Millimetres converted to pixels — the only place in this file that knows about physical units. At the defaults that is 2.0/0.25 = 8 px. The
max(…, 1)stops a very coarse pitch from rounding the rim to zero and reintroducing the divide-by-zero on thetline. - H, W = h.shape
- Rows and columns, needed to build the two distance ramps.
- dy = np.minimum(np.arange(H), np.arange(H)[::-1]).reshape(-1, 1)
- Distance to the nearest horizontal edge, for every row.
arangecounts down from the top, its reverse counts up from the bottom, and the elementwise minimum is the distance to whichever is closer. Reshaped to a column so it broadcasts. - dx = np.minimum(np.arange(W), np.arange(W)[::-1]).reshape(1, -1)
- The same for columns, reshaped to a row. Two 1D arrays instead of a full 2D distance transform — the geometry is a rectangle, so the exact answer is separable.
- t = np.clip(np.minimum(dy, dx) / r, 0.0, 1.0)
- Distance to the nearest edge of any kind, in units of the rim width, capped at 1. Broadcasting (H, 1) against (1, W) builds the full (H, W) field here without ever allocating a meshgrid. Inside the panel
tis 1 and nothing changes; only the 8-pixel margin sees a value below 1. - return h * (t * t * (3 - 2 * t)) # smoothstep
- The classic smoothstep, 3t² − 2t³. It runs 0 → 1 like a linear ramp would, but with zero slope at both ends — so the rim meets the base plate and rejoins the relief without a crease at either junction, and the printed border has no visible ridge. A linear ramp would leave two. One consequence worth knowing, and the reason §9 flags it: because this runs last, a field whose peak happens to lie inside the rim gets faded down with everything else there, and the finished panel comes out fractionally under the nominal
BASE_MM + RELIEF_MM.
Lambertian shading of the surface normals. Note the row/world sign flip on dzdy — get it wrong and the preview lights from the opposite side.
# ------------------------------------------------------------------ preview
def hillshade(h01, relief_mm, pitch_mm, azimuth_deg=315.0, altitude_deg=32.0):
"""What the print will look like under a raking lamp."""
dz_drow, dzdx = np.gradient(h01 * relief_mm, pitch_mm)
dzdy = -dz_drow # image rows run downward, world +y runs up
nz = np.ones_like(dzdx)
norm = np.sqrt(dzdx ** 2 + dzdy ** 2 + 1.0)
az, alt = np.radians(azimuth_deg), np.radians(altitude_deg)
lx, ly, lz = np.cos(alt) * np.sin(az), np.cos(alt) * np.cos(az), np.sin(alt)
shade = (-dzdx * lx + -dzdy * ly + nz * lz) / norm
return np.clip(shade, 0.0, 1.0)
Line by line — hillshade(), 11 lines
- # ------------------------------------------------------------------ preview
- Section five, which produces no geometry at all. It exists so you can judge the result before committing twelve hours of printer time to it.
- def hillshade(h01, relief_mm, pitch_mm, azimuth_deg=315.0, altitude_deg=32.0):
- The most valuable function in the file for your eyes, and the only one whose output never reaches the printer. It renders the heightfield as it will look under a raking lamp, which is the only honest way to judge a relief before it exists. §6.4 is blunt about this: judge from
06_preview_lit.png, not from the greyscale heightfield, which your eye insists on reading as a picture rather than as a surface. - """What the print will look like under a raking lamp."""
- Raking, i.e. low-angle. The 32° default altitude is chosen to throw long shadows — a lamp directly overhead would flatten the whole panel into invisibility, which is also true of the physical object on a wall.
- dz_drow, dzdx = np.gradient(h01 * relief_mm, pitch_mm)
- Physical slopes, in millimetres of rise per millimetre of run. The height is scaled to real millimetres and the sample spacing is passed so the derivative is dimensionless — this is why the preview changes when you alter
RELIEF_MM, exactly as the real panel would. A single scalar spacing applies to every axis, which is correct here because the grid is square.np.gradientreturns axes in array order, so the first result is the row derivative, not x. - dzdy = -dz_drow # image rows run downward, world +y runs up
- The sign flip that separates a convincing preview from an inside-out one. Image row indices increase downward while world y increases upward, so the row derivative is the negative of the world y-derivative. Drop this minus sign and every hill reads as a pit — the classic hollow-face illusion, and the single most likely bug in any hillshade implementation.
- nz = np.ones_like(dzdx)
- The z-component of the surface normal. A heightfield's normal is proportional to (−∂z/∂x, −∂z/∂y, 1), so this component is 1 everywhere before normalisation. Written as an array rather than the scalar 1 purely so the final expression reads symmetrically as a three-term dot product.
- norm = np.sqrt(dzdx ** 2 + dzdy ** 2 + 1.0)
- The normal's length, computed once and divided out at the end rather than normalising the vector up front — same result, one array pass instead of three.
- az, alt = np.radians(azimuth_deg), np.radians(altitude_deg)
- Degrees are for the config file, radians are for the trigonometry.
- lx, ly, lz = np.cos(alt) * np.sin(az), np.cos(alt) * np.cos(az), np.sin(alt)
- The light direction, in the surveyor's convention: azimuth measured clockwise from north, altitude above the horizon. Hence
sinon x andcoson y rather than the other way round. At the default 315° the light comes from the upper left, which is the convention every reader's visual system already expects — verified here on a synthetic bump: the upper-left flank reads 0.965 against the lower-right flank's 0.000. - shade = (-dzdx * lx + -dzdy * ly + nz * lz) / norm
- Lambertian shading: the dot product of the unit surface normal with the light direction. Nothing more sophisticated is warranted — there is no cast-shadow computation and no specular term — but matte PLA is very nearly a Lambertian surface, so §10's insistence on matte filament is partly what makes this preview honest.
- return np.clip(shade, 0.0, 1.0)
- Surfaces facing away from the light produce a negative dot product; clipping turns those into black rather than letting
norm01downstream rescale the whole image around a meaningless negative floor. The output is a displayable image, not a physical quantity, so clipping is the correct end of the pipeline.
6.4 On equalize and add_detail
EQUALIZE remaps the depth histogram toward flat, so that depth bands containing
lots of pixels get proportionally more of your 5 mm. Bruegel's painting has a big
population of pixels at mid-distance (the valley floor); without equalisation they
share a narrow slab. At 1.0 it's aggressive and the scene starts to look
pressure-flattened; 0.5–0.6 is a good landing.
add_detail is the one place luminance re-enters. You chose the depth panel, not
the hybrid, so this defaults low — 0.12 puts about 0.6 mm of high-frequency
brushwork on top of 5 mm of depth-driven form. It's what makes the bare branches and
the roof tiles legible rather than implied. Set it to 0 for a pure depth
interpretation, and be aware the result will be noticeably softer.
hillshade is your most valuable checkpoint. It renders the heightfield under a
simulated raking light — Lambertian shading of the surface normals. What you see in
06_preview_lit.png is very close to what the physical panel will look like under a
lamp. Judge the print from this image, not from the greyscale heightfield, which is
misleading (your eye reads it as an image, not a surface).
6.5 Checking your typing
You have just hand-typed a hundred lines of numerical code in which one transposed index
or one sign produces a plausible-looking but wrong relief — the kind of bug you would
otherwise discover as a disappointing print. Every claim §6.1 and §6.2 make about this code
is checkable in a second. Save this as selftest.py beside heightfield.py
and run python selftest.py.
"""Checks heightfield.py against the claims in this guide. Run: python selftest.py"""
import numpy as np
from scipy.ndimage import gaussian_filter
import heightfield as hf
N = 256
yy, xx = np.mgrid[0:N, 0:N] / (N - 1.0)
EDGE = 102 # the step sits between column 101 and 102
# One hard step (a near slab on the left) with fine texture laid over it.
step = np.where(np.arange(N)[None, :] < EDGE, 0.90, 0.10) * np.ones((N, 1))
scene = step + 0.02 * np.sin(2 * np.pi * 16 * xx) * np.sin(2 * np.pi * 16 * yy)
# What a depth model hands you: the same edge, but soft, and noisy.
rng = np.random.default_rng(0)
guide = np.where(np.arange(N)[None, :] < EDGE, 0.85, 0.15) * np.ones((N, 1))
soft = gaussian_filter(step, 4.0) + 0.05 * rng.standard_normal((N, N))
d_edge = lambda a: float(a[:, EDGE - 1].mean() - a[:, EDGE].mean())
d_noise = lambda a: float(a[N // 4:N // 2, N // 8:N // 4].std())
d_texture = lambda a: float(a[N // 4:3 * N // 4, N // 8:N // 3].std())
Line by line — the scene, and how it is measured, 17 lines
- """Checks heightfield.py against the claims in this guide. Run: python selftest.py"""
- The purpose is narrower than “tests”. It does not check that the code is correct in the abstract — it checks that the code you typed exhibits the specific behaviours §6.1 and §6.2 claim for it. A passing run means your hand copy behaves like the one the measurements were taken from.
- import numpy as np
- The only maths needed to build the scene and measure it.
- from scipy.ndimage import gaussian_filter
- Used once, to blur a hard step into something resembling what a depth model actually returns. Importing it here rather than relying on
heightfield's import keeps this file independently readable. - import heightfield as hf
- The module under test, imported the same way
main.pyimports it — so if this file runs, the import path is right too. Note there is no torch anywhere: the entire test suite exercises the file §3 deliberately kept torch-free, which is why it takes a second rather than a minute. - N = 256
- Small enough to run instantly, large enough that a 6-pixel filter radius and a 16-cycle texture are both well resolved. Everything below is expressed in terms of
N, so you can raise it to 1024 and the assertions still hold — the measured numbers shift slightly, the properties do not. - yy, xx = np.mgrid[0:N, 0:N] / (N - 1.0)
- Normalised coordinate grids in [0, 1].
mgridis the slice-syntax cousin ofmeshgrid; dividing byN - 1rather thanNmakes the last row and column land exactly on 1.0. Onlyxxandyyas a pair are used, for the texture. - EDGE = 102 # the step sits between column 101 and 102
- The edge position as an explicit integer, and the comment is doing real work. An earlier draft computed this as
int(0.40 * (N - 1))and then measured the step across columns 102 and 103 — both on the same side of the boundary, so the measured edge was zero and the test failed for a reason that had nothing to do with the code. Naming the column removes the ambiguity entirely. - # One hard step (a near slab on the left) with fine texture laid over it.
- The scene is designed to be the pathological case §0 describes: one enormous depth discontinuity plus fine detail that is two orders of magnitude smaller. That ratio is exactly what range compression exists to fix.
- step = np.where(np.arange(N)[None, :] < EDGE, 0.90, 0.10) * np.ones((N, 1))
- A 0.8-tall cliff at column 102.
[None, :]makes the column indices a row vector; multiplying bynp.ones((N, 1))broadcasts it down every row. Building it from an integer comparison rather than fromxxmeans the edge lands on an exact column with no floating-point rounding to reason about. - scene = step + 0.02 * np.sin(2 * np.pi * 16 * xx) * np.sin(2 * np.pi * 16 * yy)
- The texture: a 16-by-16 cycle sinusoidal grid at amplitude 0.02, i.e. 1/40th of the step. That ratio is the whole point — it stands in for brushwork against a foreground/background split, and test [3] measures how the two trade off as α rises.
- # What a depth model hands you: the same edge, but soft, and noisy.
- The distinction that makes test [2] meaningful.
stepis ground truth;softis what you actually get, and the guided filter's job is to move the second toward the first. - rng = np.random.default_rng(0)
- A seeded generator, so the printed numbers are reproducible to the last digit and the expected output in §6.5 is a fingerprint rather than an approximation.
default_rngrather than the legacynp.random.seed— it is the modern API and does not share global state with anything else. - guide = np.where(np.arange(N)[None, :] < EDGE, 0.85, 0.15) * np.ones((N, 1))
- The guide image: the same edge in the same place, crisp. Its values are 0.85/0.15 rather than 0.90/0.10 so it is clearly a different signal that happens to share a boundary — which is the real situation, where luminance and depth agree about where the tree is and disagree about everything else.
- soft = gaussian_filter(step, 4.0) + 0.05 * rng.standard_normal((N, N))
- Both degradations at once: a σ = 4 blur smears the edge over about 10 px, and 5% Gaussian noise fills the flat regions. Sigma 4 is chosen to be comparable to the filter's radius of 6 — much blurrier and no local linear model could recover the edge; much sharper and the test would prove nothing.
- d_edge = lambda a: float(a[:, EDGE - 1].mean() - a[:, EDGE].mean())
- The step height measured across the single boundary — column 101 against column 102, averaged down all 256 rows. Measuring across adjacent columns is what makes this sensitive to sharpness rather than to overall contrast: on the blurred input it reads 0.080, on ground truth 0.800.
- d_noise = lambda a: float(a[N // 4:N // 2, N // 8:N // 4].std())
- Standard deviation inside a patch that is dead flat in the ground truth, so any variance there is noise by definition. The patch sits well left of the edge and well inside the array, so neither the discontinuity nor a boundary effect can contaminate it.
- d_texture = lambda a: float(a[N // 4:3 * N // 4, N // 8:N // 3].std())
- The same idea for the sinusoid: standard deviation over a larger patch on the near slab, which for this scene is dominated by the texture. Used only in test [3], where what matters is the ratio of texture to step, not either in isolation.
print("[1] poisson round-trip")
gx = np.zeros_like(scene); gx[:, :-1] = np.diff(scene, axis=1)
gy = np.zeros_like(scene); gy[:-1, :] = np.diff(scene, axis=0)
back = hf.poisson_from_gradients(gx, gy)
err = np.abs((back - back.mean()) - (scene - scene.mean())).max()
print(f" max abs error {err:.2e}")
assert err < 1e-9, "reintegration is not inverting the gradient operator"
Line by line — [1] the Poisson round-trip, 7 lines
- print("[1] poisson round-trip")
- The first and strictest test. If this one fails, nothing downstream is worth reading —
compress_rangeis built directly on the solver. - gx = np.zeros_like(scene); gx[:, :-1] = np.diff(scene, axis=1)
- Forward differences, written exactly as
compress_rangewrites them. That is deliberate: the test must use the same gradient convention as the code, because the round-trip is only exact for the matching operator pair. - gy = np.zeros_like(scene); gy[:-1, :] = np.diff(scene, axis=0)
- The same down the columns.
- back = hf.poisson_from_gradients(gx, gy)
- Reintegrate the untouched gradient field. With no scaling applied, the result should be the original surface — this is testing the solver in isolation, before compression is introduced.
- err = np.abs((back - back.mean()) - (scene - scene.mean())).max()
- Both fields are mean-subtracted before comparison, because a gradient field determines a surface only up to an additive constant — the solver deliberately discards the DC term. Comparing without this would measure an offset that is not an error.
.max()rather than a mean, because one bad pixel matters. - print(f" max abs error {err:.2e}")
- Scientific notation, since the interesting thing about this number is its exponent. 2.69e-13 is floating-point noise; anything like 1e-3 would mean the operators do not match.
- assert err < 1e-9, "reintegration is not inverting the gradient operator"
- A threshold four orders of magnitude above the observed value — generous enough to survive a numpy or scipy upgrade, tight enough that a genuine sign error or a mis-indexed boundary cannot slip through. The message names the property, not the line, because the fault could be in either
poisson_from_gradientsor_laplacian_eigenvalues.
print("[2] guided filter")
g = hf.guided_filter(guide, soft, radius=6, eps=2e-3)
print(f" edge {d_edge(soft):.3f} -> {d_edge(g):.3f} (true {d_edge(step):.3f})")
print(f" noise {d_noise(soft):.4f} -> {d_noise(g):.4f}")
assert d_edge(g) > 2.0 * d_edge(soft), "guided filter did not sharpen the edge"
assert d_noise(g) < 0.2 * d_noise(soft), "guided filter did not suppress noise"
Line by line — [2] the guided filter, 6 lines
- print("[2] guided filter")
- Testing §6.1's two simultaneous claims: sharper and cleaner.
- g = hf.guided_filter(guide, soft, radius=6, eps=2e-3)
- Called with the config defaults, so what is being verified is the behaviour you will actually get. Guide first, source second — the same order
main.pyuses, and inverting it is the classic misuse. - print(f" edge {d_edge(soft):.3f} -> {d_edge(g):.3f} (true {d_edge(step):.3f})")
- Three numbers on one line: before, after, and the unreachable ideal. Printing ground truth alongside is what keeps the result honest — 0.080 → 0.400 is a fivefold improvement, and also only halfway to 0.800. The filter is good, not magic.
- print(f" noise {d_noise(soft):.4f} -> {d_noise(g):.4f}")
- Four decimals because the after-value is 0.0019 and two would round it to nothing. A twenty-five-fold reduction, in the same pass that sharpened the edge.
- assert d_edge(g) > 2.0 * d_edge(soft), "guided filter did not sharpen the edge"
- A factor of two against an observed factor of five — deliberately loose, because the exact ratio depends on the interaction between the blur sigma and the filter radius and would be brittle if pinned tightly.
- assert d_noise(g) < 0.2 * d_noise(soft), "guided filter did not suppress noise"
- The other half, and the reason both assertions are needed. A filter that merely blurred would pass the noise test and fail the edge test; one that merely sharpened would do the reverse. Only a genuinely edge-aware filter passes both.
print("[3] range compression")
print(" alpha step texture ratio")
rows = []
for a in (0.0, 2.0, 4.0, 6.0, 15.0):
c = hf.norm01(hf.compress_range(scene, a))
s, t = d_edge(c), d_texture(c)
rows.append((s, t))
print(f" {a:5.0f} {s:.3f} {t:.4f} {t / s:.3f}")
assert all(rows[i][0] > rows[i + 1][0] for i in range(len(rows) - 1)), "step must shrink"
assert all(rows[i][1] < rows[i + 1][1] for i in range(len(rows) - 1)), "texture must grow"
Line by line — [3] range compression, 10 lines
- print("[3] range compression")
- The test that reproduces §6.2's table on your machine — the argument at the centre of the whole guide, re-derived rather than quoted.
- print(" alpha step texture ratio")
- A header, because four unlabelled columns of floats are unreadable. The column order matches §6.2's table so the two can be compared directly.
- rows = []
- Collected so the assertions below can check the trend across all five, not just endpoints.
- for a in (0.0, 2.0, 4.0, 6.0, 15.0):
- The same five values §6.2 tabulates: off, the low end of usable, the default, the high end, and one deliberately absurd. 15 is included precisely to show the failure mode — the depth story disappearing into uniform texture.
- c = hf.norm01(hf.compress_range(scene, a))
- Normalised after compression, exactly as
main.pydoes it. Without this the five rows would not be comparable, since the solve returns an arbitrary scale. - s, t = d_edge(c), d_texture(c)
- One measurement of each, on the same field.
- rows.append((s, t))
- Stored for the monotonicity checks.
- print(f" {a:5.0f} {s:.3f} {t:.4f} {t / s:.3f}")
- The ratio in the last column is the number that actually matters: texture relative to step, climbing 0.013 → 0.572 across the sweep. That is the tradeoff §6.2 describes, in one column.
- assert all(rows[i][0] > rows[i + 1][0] for i in range(len(rows) - 1)), "step must shrink"
- Tests the shape of the relationship rather than any single value: the step must fall monotonically as α rises. That is a much more robust assertion than pinning numbers, and it is what would actually break if the log curve were mis-implemented.
- assert all(rows[i][1] < rows[i + 1][1] for i in range(len(rows) - 1)), "texture must grow"
- The complementary direction. Together the two assertions say: compression trades large-scale range for fine detail, monotonically, over the whole usable band. Note neither asserts a magnitude — the numbers belong to this scene, the trend belongs to the method.
print("[4] shaping")
e = hf.equalize(hf.norm01(scene), 1.0)
r = hf.apply_rim(np.ones((N, N)), rim_mm=2.0, pitch_mm=0.25)
sh = hf.hillshade(hf.norm01(scene), 5.0, 0.25)
print(f" equalize(1.0) median {np.median(e):.3f}")
print(f" rim border {r[0, 0]:.3f} centre {r[N // 2, N // 2]:.3f}")
print(f" hillshade in [{sh.min():.3f}, {sh.max():.3f}]")
assert abs(np.median(e) - 0.5) < 0.02, "equalize(1.0) should flatten the histogram"
assert r[0, 0] == 0.0 and r[N // 2, N // 2] == 1.0, "rim should fade edge to zero"
assert np.isfinite(sh).all() and sh.min() >= 0.0 and sh.max() <= 1.0
print("\nall checks passed")
Line by line — [4] the shaping helpers, 11 lines
- print("[4] shaping")
- Three small functions with three cheap, unambiguous properties. Less interesting than [1]–[3], but each has a plausible failure mode that would be invisible in a finished print.
- e = hf.equalize(hf.norm01(scene), 1.0)
- Full strength, because that is the setting with a checkable property. At the config's 0.55 the result is a blend and the median could be anything.
- r = hf.apply_rim(np.ones((N, N)), rim_mm=2.0, pitch_mm=0.25)
- A field of ones, so the output is the rim mask — the cleanest possible way to inspect it. The two millimetre arguments give an 8-pixel border, comfortably inside a 256-pixel array.
- sh = hf.hillshade(hf.norm01(scene), 5.0, 0.25)
- Default azimuth and altitude, since what is being checked is the output range rather than the lighting direction.
- print(f" equalize(1.0) median {np.median(e):.3f}")
- The median is the diagnostic: a genuinely flat histogram on [0, 1] has its median at exactly 0.5, whatever the input distribution looked like.
- print(f" rim border {r[0, 0]:.3f} centre {r[N // 2, N // 2]:.3f}")
- The two ends of the fade. The corner is the most extreme point of the rim; the centre should be untouched.
- print(f" hillshade in [{sh.min():.3f}, {sh.max():.3f}]")
- The observed range. The maximum is 0.634 rather than 1.0 because this scene's slopes never face the light squarely — which is fine, and why the assertion below bounds rather than pins it.
- assert abs(np.median(e) - 0.5) < 0.02, "equalize(1.0) should flatten the histogram"
- A two-percent tolerance, which is generous for a rank transform that should hit 0.500 exactly. The slack absorbs tie-handling, which for a scene with a perfectly flat region is not entirely trivial.
- assert r[0, 0] == 0.0 and r[N // 2, N // 2] == 1.0, "rim should fade edge to zero"
- Exact equality, unusually — and justified here because both values are produced by the smoothstep at exactly
t = 0andt = 1, wheret*t*(3 - 2*t)evaluates to precisely 0.0 and 1.0 in floating point with no rounding involved. - assert np.isfinite(sh).all() and sh.min() >= 0.0 and sh.max() <= 1.0
- Three properties, no message — the only bare assertion in the file, because if it fires the expression itself says everything. The
isfinitehalf is the one that matters: a NaN here would sail throughsave_png's clip and then poison the mesh, surfacing much later as §11's most misleading symptom, “STL reports not watertight”. - print("\nall checks passed")
- The leading newline separates the verdict from the numbers above it. Reaching this line at all is the actual result — every assertion above would have raised — so the message is confirmation rather than information, and its absence is what you look for.
It builds one synthetic scene — a hard step with fine texture over it, plus the softened,
noisy version a depth model would hand you — and puts heightfield.py through it.
Expected output, about one second:
[1] poisson round-trip
max abs error 2.69e-13
[2] guided filter
edge 0.080 -> 0.400 (true 0.800)
noise 0.0510 -> 0.0019
[3] range compression
alpha step texture ratio
0 0.952 0.0119 0.013
2 0.459 0.1293 0.282
4 0.383 0.1454 0.380
6 0.346 0.1524 0.440
15 0.285 0.1629 0.572
[4] shaping
equalize(1.0) median 0.500
rim border 0.000 centre 1.000
hillshade in [0.000, 0.634]
all checks passed
Read it as four statements. The Poisson solve inverts the gradient operator exactly — 2.7 × 10⁻¹³ is floating-point noise, not approximation error, which is what earns the “no iteration, no solver library” claim in §6.2. The guided filter multiplies the edge step by five (0.080 → 0.400, against a true 0.800) while cutting flat-region noise by twenty-five (0.0510 → 0.0019) — sharper and cleaner, in one pass. The step column falls monotonically as the texture column rises, which is the entire argument of §6.2 reproduced on your machine; note the ratio climbing 0.013 → 0.572 as α goes 0 → 15, and that α = 15 has flattened the step to 0.285 — the depth story is gone, exactly as the table above warns. The shaping helpers do what their names say: a flat histogram, a border at zero, a hillshade that stays in range.
The absolute values belong to this scene and this seed, so treat them as a fingerprint rather than a specification — the asserts carry generous tolerances and will survive a numpy or scipy upgrade. If one fires, the message names the property that broke, and the function to re-read is the one it names.
7. mesh.py
7.1 Building a solid, cheaply
The obvious approach — duplicate the whole grid at z = 0 — doubles vertex count for a face nobody sees. Instead: full grid on top, a boundary ring of vertices at z = 0, quad walls stitching ring to ring, and a triangle fan across the floor from a centre point. That's 331,445 vertices instead of 658,240, and the result is genuinely watertight (verified, not assumed).
The fan needs a centre vertex rather than fanning from a corner. Fanning from a corner produces zero-area triangles for every ring point collinear with it, and degenerate facets make some slicers complain.
7.2 No supports, ever
Worth noticing: a heightfield is by construction a function z = f(x, y). Every
point on the top surface is visible from directly above. There are no overhangs
anywhere, at any α, no matter how steep the compression leaves the cliffs. This
geometry can never need support material. Measured on a real conditioned field: across all
fifty relief layers, the number of pixels in a layer not supported by the layer beneath it is zero
— guaranteed, because {h ≥ z} only ever shrinks as z rises.
Bambu Studio will nonetheless warn you about floating regions on the first slice, and it is not wrong to. That warning is about toolpaths rather than overhangs — parts of the relief are finer than the nozzle can lay down, so the slicer drops them and then notices material with nothing printed beneath. It is expected, it is harmless, and §10 works through the numbers. The one thing not to do is take it up on the offer of supports: there is nothing for them to hold up, and they would be generated against the relief surface you just spent all this effort on.
7.3 The file
Five blocks, in order, make up mesh.py. The middle three are all one function — heightfield_to_mesh — split at its natural seams.
"""Heightfield -> watertight solid -> binary STL."""
import numpy as np
import trimesh
Line by line — imports, 3 lines
- """Heightfield -> watertight solid -> binary STL."""
- Three words, three jobs. “Watertight” is the load-bearing one: a slicer intersects your mesh with a stack of horizontal planes and fills what is inside, which is only meaningful if “inside” is well defined. A surface with a hole has no inside, and the slicer will either refuse it or invent something.
- import numpy as np
- The whole file is index arithmetic on integer arrays. Not one Python loop touches a vertex — 662,886 triangles are built by slicing and stacking, which is why meshing takes seconds rather than minutes.
- import trimesh
- The only heavyweight dependency outside torch, and it earns its place by doing three things: holding vertices and faces, answering
is_watertightandvolume, and writing binary STL. §2.3 notes what was deliberately not imported — nonumpy-stl, noopen3d— because trimesh covers all three and the alternatives each bring wheel-compatibility problems on arm64.
Grid coordinates in millimetres, one vertex per sample, two triangles per cell. ys counts down so image row 0 lands at the top of the panel.
def heightfield_to_mesh(h01, width_mm, height_mm, base_mm, relief_mm):
"""h01 is (H, W) in [0, 1]; row 0 is the TOP of the image."""
H, W = h01.shape
xs = np.linspace(0.0, width_mm, W)
ys = np.linspace(height_mm, 0.0, H)
X, Y = np.meshgrid(xs, ys)
Z = base_mm + relief_mm * np.asarray(h01, np.float64)
top = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1)
idx = np.arange(H * W).reshape(H, W)
# two triangles per cell, wound counter-clockwise seen from +Z
a = idx[:-1, :-1]; b = idx[:-1, 1:]; c = idx[1:, 1:]; d = idx[1:, :-1]
top_faces = np.concatenate([
np.stack([d.ravel(), c.ravel(), b.ravel()], axis=1),
np.stack([d.ravel(), b.ravel(), a.ravel()], axis=1),
])
Line by line — heightfield_to_mesh(): the top surface, 15 lines
- def heightfield_to_mesh(h01, width_mm, height_mm, base_mm, relief_mm):
- Everything physical arrives as an argument; the function holds no defaults and reads no config. That keeps it testable in isolation — §6.5's approach would work here too — and means the same function serves the 170 mm panel and §10's 85 mm test print without modification.
- """h01 is (H, W) in [0, 1]; row 0 is the TOP of the image."""
- Two contracts in one sentence. The [0, 1] range is why
main.pywraps almost every conditioning step innorm01. The row-0-is-top note is the one that prevents an upside-down print, and it is honoured on theysline below. - H, W = h01.shape
- Rows then columns, i.e. height then width. The reversal against the
width_mm, height_mmargument order is the sort of thing that causes silent transposition bugs, which is why the two are never used interchangeably below. - xs = np.linspace(0.0, width_mm, W)
- Column index to millimetres.
linspaceis inclusive at both ends, so the first and last columns land exactly on 0 andwidth_mm— the panel is exactly the size you asked for, and the spacing works out towidth_mm/(W-1), a hair underPITCH_MM. - ys = np.linspace(height_mm, 0.0, H)
- Counting down, and this is the line that keeps the print the right way up. Image row 0 is the top of the picture but the largest world y, because screen coordinates run downward and the printer's do not. Swap the two arguments and the panel comes out vertically mirrored — a mistake you would not notice until the STL is in the slicer.
- X, Y = np.meshgrid(xs, ys)
- Broadcasts the two 1D axes into full (H, W) coordinate grids. It allocates two arrays the size of the heightfield, which at this grid is a few megabytes — worth it for the clarity, and the mesh build is not where the time goes.
- Z = base_mm + relief_mm * np.asarray(h01, np.float64)
- The only line that turns a dimensionless field into physical height. This is where
BASE_MMandRELIEF_MMactually apply, and where the panel's total thickness is decided:base_mm + relief_mm × max(h01). That maximum is 1.0 only if the field's peak survivedapply_rim, which is why §9 warns the printed Z can come out a fraction under the nominal 7 mm. - top = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1)
- Three (H, W) grids flattened and stacked into one (H·W, 3) vertex array.
axis=1makes each row an (x, y, z) triple rather than each column, which is the layout trimesh expects.ravelflattens in row-major order, and every index computation below depends on that. - idx = np.arange(H * W).reshape(H, W)
- The trick that makes the rest of the file readable: a lookup table from grid position to vertex number. Because it is reshaped the same row-major way
ravelflattened,idx[r, c]is exactly the row oftopholding that sample. Slicingidxnow yields whole blocks of vertex indices at once. - # two triangles per cell, wound counter-clockwise seen from +Z
- Winding order is not cosmetic. It defines which side of each triangle is outward-facing, and STL consumers use it — together with the stored normal — to decide what is solid. Get it backwards and the slicer sees a solid universe with a panel-shaped hole in it.
- a = idx[:-1, :-1]; b = idx[:-1, 1:]; c = idx[1:, 1:]; d = idx[1:, :-1]
- The four corners of every grid cell, all at once. Each is an (H−1, W−1) array of vertex indices:
ais every cell's top-left,btop-right,cbottom-right,dbottom-left. Four array slices replace a double loop over 328,000 cells. - top_faces = np.concatenate([
- Two triangles per cell, built as two whole arrays and then joined — rather than appending row by row, which for this many faces would dominate the runtime.
- np.stack([d.ravel(), c.ravel(), b.ravel()], axis=1),
- The first triangle of each cell: bottom-left, bottom-right, top-right. Read in the world frame where y increases upward, that ordering is counter-clockwise seen from above, so its normal points at the viewer.
- np.stack([d.ravel(), b.ravel(), a.ravel()], axis=1),
- The second triangle completes the quad: bottom-left, top-right, top-left. Both share the
d–bdiagonal, so the two halves meet exactly with no crack between them. Splitting every quad along the same diagonal also gives the surface a consistent grain, which is invisible at 0.25 mm but would show as a directional pattern at a coarse pitch. - ])
- Closes the concatenate. The result is 2(H−1)(W−1) triangles — the top surface, complete.
The four edges walked once, counter-clockwise. The floor is that same ring flattened to z = 0, plus a single centre vertex.
# boundary ring, counter-clockwise seen from above
ring = np.concatenate([
idx[H - 1, :], # bottom edge, left -> right
idx[H - 2:0:-1, W - 1], # right edge, bottom -> top
idx[0, W - 1::-1], # top edge, right -> left
idx[1:H - 1, 0], # left edge, top -> bottom
])
R = ring.size
n_top = top.shape[0]
floor = np.concatenate([top[ring, :2], np.zeros((R, 1))], axis=1)
centre = np.array([[width_mm / 2.0, height_mm / 2.0, 0.0]])
verts = np.concatenate([top, floor, centre])
centre_i = n_top + R
Line by line — heightfield_to_mesh(): the boundary ring and floor, 13 lines
- # boundary ring, counter-clockwise seen from above
- The idea §7.1 introduces: rather than duplicating the whole grid at z = 0 for a floor nobody will ever see, walk the boundary once and build walls down from it. That is 331,445 vertices instead of 658,240.
- ring = np.concatenate([
- One closed loop of vertex indices, assembled from four edge traversals. The order must be continuous — each segment picking up where the last left off — or the walls below will connect the wrong pairs.
- idx[H - 1, :], # bottom edge, left -> right
- The last row, left to right. Starting at the bottom-left corner and going right is counter-clockwise when y increases upward, matching the top surface's winding.
- idx[H - 2:0:-1, W - 1], # right edge, bottom -> top
- The last column, walked upward. The slice deliberately excludes both endpoints — it stops before row 0 and starts one below row H−1 — because the two corners were already contributed by the bottom edge and will be contributed by the top edge. A duplicated corner would create a zero-length wall segment and a degenerate triangle.
- idx[0, W - 1::-1], # top edge, right -> left
- Row 0, right to left, including both corners this time. The
W - 1::-1form counts down from the last column to zero inclusive — note it is not::-1, which would give the same thing here but reads less explicitly about where it starts. - idx[1:H - 1, 0], # left edge, top -> bottom
- The first column, downward, again excluding both corners. The loop is now closed: the last index is adjacent to the first.
- ])
- The finished ring, of length 2(H + W) − 4 — every boundary vertex exactly once.
- R = ring.size
- The ring length, used constantly below. At this grid it is 2,324.
- n_top = top.shape[0]
- Where the floor vertices will begin. All the index arithmetic below is relative to this offset, so the three vertex groups — top surface, floor ring, centre point — can be concatenated into one array and still be addressable.
- floor = np.concatenate([top[ring, :2], np.zeros((R, 1))], axis=1)
- The floor ring: the same x and y as the boundary vertices above it, with z forced to 0.
top[ring, :2]is fancy indexing — it gathers those specific rows' first two columns — so the floor is exactly beneath the rim by construction and the walls are guaranteed vertical. - centre = np.array([[width_mm / 2.0, height_mm / 2.0, 0.0]])
- One extra vertex in the middle of the floor, and the reason §7.1 dwells on it. The floor is closed with a triangle fan, and fanning from a corner would produce zero-area triangles for every ring vertex that happens to be collinear with it — which, on a rectangle, is an entire edge's worth. Degenerate facets make some slicers complain. A centre point is collinear with nothing. Note the double brackets: this must be a (1, 3) array, not a (3,) one, to concatenate.
- verts = np.concatenate([top, floor, centre])
- The complete vertex list, in three known blocks: top surface, then floor ring, then the single centre point.
- centre_i = n_top + R
- The centre vertex's index — the last one. Computed rather than written as
-1because trimesh face indices must be non-negative.
Two triangles per ring segment for the walls, one per segment for the fan.
k = np.arange(R); k1 = (k + 1) % R
t0, t1 = ring[k], ring[k1]
f0, f1 = n_top + k, n_top + k1
walls = np.concatenate([
np.stack([t0, f0, f1], axis=1),
np.stack([t0, f1, t1], axis=1),
])
floor_fan = np.stack([np.full(R, centre_i), f1, f0], axis=1)
mesh = trimesh.Trimesh(
vertices=verts,
faces=np.concatenate([top_faces, walls, floor_fan]),
process=False,
)
mesh.update_faces(mesh.nondegenerate_faces())
mesh.fix_normals()
return mesh
Line by line — heightfield_to_mesh(): walls, floor fan, assembly, 16 lines
- k = np.arange(R); k1 = (k + 1) % R
- Every ring position and its successor, with the modulo closing the loop so the last position's neighbour is the first. This is what turns a list of boundary vertices into a list of boundary segments, and it is why no special case is needed for the seam.
- t0, t1 = ring[k], ring[k1]
- The two top vertices of each wall segment.
ring[k]is justring, written this way so the pairing witht1reads symmetrically. - f0, f1 = n_top + k, n_top + k1
- The two floor vertices directly beneath them. Because
floorwas built fromringin the same order, addingn_topis all the translation needed — no lookup, no search. - walls = np.concatenate([
- Each wall segment is a quad — two top vertices, two floor vertices — split into two triangles, exactly as the top surface was.
- np.stack([t0, f0, f1], axis=1),
- First wall triangle. Wound so the normal points outward, away from the solid; trimesh will verify this and
fix_normalswill repair it if a particular geometry defeats the assumption. - np.stack([t0, f1, t1], axis=1),
- Second wall triangle, sharing the
t0–f1diagonal with the first. - ])
- 2R wall triangles, forming a continuous skirt from the rim down to the plate.
- floor_fan = np.stack([np.full(R, centre_i), f1, f0], axis=1)
- The floor, closed with one triangle per ring segment radiating from the centre vertex.
np.full(R, centre_i)repeats that index R times so it can be stacked as a column. Notef1beforef0— the reverse of the wall order — because the floor faces down, so its winding must be opposite to everything else. - mesh = trimesh.Trimesh(
- Assembly. Up to this point nothing but integer arrays has existed; this is where it becomes a mesh object with geometry queries attached.
- vertices=verts,
- The (N, 3) float array of positions, in millimetres — which is why §10 can say the STL arrives in the slicer correctly scaled with nothing to set.
- faces=np.concatenate([top_faces, walls, floor_fan]),
- All three groups joined into one (M, 3) integer array. Order does not matter to the format; grouping them this way just makes the construction auditable.
- process=False,
- Important, and easy to miss. Trimesh's default is to merge duplicate vertices and drop degenerate faces on construction — a spatial hash over 331,445 vertices that here finds nothing, because the grid has no duplicates by construction. Leaving it on costs real time for no benefit.
- )
- Closes the constructor.
- mesh.update_faces(mesh.nondegenerate_faces())
- The one cleanup that is wanted: dropping any zero-area triangle. The centre-vertex fan should have prevented these, so this is belt and braces — but a heightfield with two identical adjacent rows could still produce one, and a degenerate facet is exactly the kind of thing that makes a slicer refuse an otherwise valid file.
- mesh.fix_normals()
- Makes the winding globally consistent and outward-facing, reversing any triangle that disagrees with its neighbours. The windings above were reasoned out by hand; this is the machine checking the reasoning, and it is why
is_winding_consistentcomes back true. - return mesh
- A watertight solid, verified rather than assumed on the next few lines.
Three cheap assertions worth running before every export.
def validate(mesh):
problems = []
if not mesh.is_watertight:
problems.append("not watertight")
if not mesh.is_winding_consistent:
problems.append("inconsistent winding")
if mesh.volume <= 0:
problems.append(f"non-positive volume ({mesh.volume:.1f})")
return problems
Line by line — validate(), 9 lines
- def validate(mesh):
- Three cheap assertions worth running before every export. Cheap relative to the twelve hours of printing that a bad mesh wastes, at least — the watertightness query itself has to build a full edge-adjacency structure, so it is the slowest thing in a cached run.
- problems = []
- Collect all the failures rather than raising on the first. If the mesh is broken you want to know every way in which it is broken, not just the first one encountered.
- if not mesh.is_watertight:
- The essential check: every edge shared by exactly two faces, no holes, no boundary. Only a watertight mesh has a well-defined inside for the slicer to fill.
- problems.append("not watertight")
- In practice this fires for one reason above all others: NaNs in the heightfield, which turn vertex coordinates into garbage. §11's tuning table points straight at
np.isfinite(d).all()for that reason, andnorm01's zero-range guard is what usually prevents it. - if not mesh.is_winding_consistent:
- Every triangle agreeing with its neighbours about which side is out. A mesh can be watertight and still inside-out in patches.
- problems.append("inconsistent winding")
- Should never fire, because
fix_normalsran. If it does, something is wrong with the topology rather than the orientation, and the watertight check will usually have fired too. - if mesh.volume <= 0:
- The cheapest possible sanity check, and a surprisingly good one. Signed volume computed from outward-facing normals is positive for a solid; a globally inverted mesh gives exactly the negative of the right answer, and a degenerate one gives zero.
- problems.append(f"non-positive volume ({mesh.volume:.1f})")
- Reporting the value, not just the failure. A volume of
-90000tells you the mesh is inside-out; a volume of0tells you it is flat. Same check, two very different diagnoses. - return problems
- An empty list means everything passed, which is why
main.pycan writeif not problems. Note it returns rather than raises: the STL is written either way, so you can load a suspect mesh in a viewer and look at it instead of only reading about it.
process=False on the constructor is important — trimesh's default vertex merging
would spend a long time on 336k vertices to find nothing, since the grid has no
duplicates by construction.
8. main.py
One line here is the coupling flagged back at the end of §2.5:
work = img.resize((grid_w * 2, grid_h * 2)). The model never sees more than
twice the print grid — 1360 × 968 px at these settings — so raising
INFER_LONG_SIDE above 1360 buys you nothing but upsampling. Raise this factor to
* 3 first if you want to go higher.
Nine blocks, in order, make up main.py. The five at the end are the body of main(), one per numbered stage of the pipeline — keep them indented inside the function.
"""painting -> printable bas-relief. Run: python main.py"""
import json
import time
import numpy as np
from PIL import Image
import config as C
import heightfield as hf
from mesh import heightfield_to_mesh, validate
Image.MAX_IMAGE_PIXELS = None # Google Art Project scans trip PIL's bomb guard
def save_png(path, a01):
Image.fromarray((np.clip(a01, 0, 1) * 255).astype(np.uint8)).save(path)
print(f" wrote {path}")
Line by line — imports and a PNG helper, 12 lines
- """painting -> printable bas-relief. Run: python main.py"""
- The whole pipeline in one arrow, plus the only command you ever need. Everything else in the project is a module that this file orchestrates; nothing but this one has a
__main__block. - import json
- Used for exactly one thing: serialising the cache signature to a canonical string. JSON is chosen over
repr()orpicklebecause it is stable across Python versions and sorts deterministically, which is what makes the comparison reliable months later. - import time
- One stopwatch, started on the first line of
main()and read on the last. - import numpy as np
- Used lightly here —
main.pyis orchestration, so the array work is delegated toheightfield. The exceptions are the PNG conversion, the cache load/save and the luminance dot product. - from PIL import Image
- Everything that touches the JPEG. PIL rather than an array library because the depth model's processor expects PIL images, so keeping the source in that form until the last moment avoids a round trip.
- import config as C
- The single-letter alias is deliberate. Every tunable value in this file reads
C.SOMETHING, so at a glance you can see exactly which lines are parameterised and which are structural — and you can find every use of the config with one search. - import heightfield as hf
- Imported at module level because it is pure numpy/scipy and costs almost nothing to import. Contrast this with
depth, which is imported inside a branch further down precisely because torch is expensive. - from mesh import heightfield_to_mesh, validate
- Named imports rather than the module, because only two functions are used and both read better unqualified at the call site.
trimeshcomes along as a transitive dependency, which is a second or so of import time paid on every run — acceptable, since every run meshes. - Image.MAX_IMAGE_PIXELS = None # Google Art Project scans trip PIL's bomb guard
- PIL refuses images above about 89 megapixels by default, as a defence against decompression-bomb attacks — a small file that expands to fill memory. Your scan is 6819×4853, comfortably under that, but higher-resolution Art Project scans of other paintings are not, and the failure is a confusing
DecompressionBombErrorrather than anything about size. Disabling the guard is safe here because the input is a file you chose. - def save_png(path, a01):
- Every checkpoint image goes through this one function, which is why they are all directly comparable — same range convention, same bit depth, same clipping.
- Image.fromarray((np.clip(a01, 0, 1) * 255).astype(np.uint8)).save(path)
- Float [0, 1] to 8-bit PNG. The
clipis load-bearing rather than decorative:add_detailcan push values slightly outside the range, and without clipping,astype(np.uint8)would wrap rather than saturate — 256 becomes 0, so the brightest highlights would come out black. A wrap-around artefact in a checkpoint image is exactly the kind of thing that sends you debugging the wrong stage. - print(f" wrote {path}")
- Two leading spaces, which is the file's convention for a sub-step: bracketed stage numbers are flush left, their details are indented. Printing the full path rather than the filename means you can paste it straight into an open command.
The point of the cache is that it survives exactly the edits you make while tuning, and nothing else. So the signature lists what feeds the model — never a §6 parameter.
def depth_signature(work_size):
"""Everything upstream of the depth map. Change one of these and the cache
is stale; change a conditioning parameter and it is still good."""
return json.dumps({
"image": C.IMAGE.name, "bytes": C.IMAGE.stat().st_size,
"crop": list(C.CROP), "work": list(work_size),
"model": C.MODEL_ID, "long_side": C.INFER_LONG_SIDE,
"tiled": bool(C.USE_TILED_REFINE), "tile": C.TILE,
"overlap": C.TILE_OVERLAP, "sigma": C.TILE_LOWPASS_SIGMA_PX,
}, sort_keys=True)
Line by line — the cache signature, 10 lines
- def depth_signature(work_size):
- Returns a string that fingerprints everything the depth map depends on. Comparing two of these answers “is the cached map still valid?” without having to think about it.
- """Everything upstream of the depth map. Change one of these and the cache
- “Upstream” is the whole design. The dividing line runs exactly between §4's depth-model block and its conditioning block, which is why the parameters you tune are precisely the ones that keep the cache warm.
- is stale; change a conditioning parameter and it is still good."""
- The second half of the promise, and the reason the tuning loop is worth having. Editing
COMPRESS_ALPHAtwenty times costs twenty conditioning passes and zero inferences. - return json.dumps({
- A dict rendered to a string, rather than comparing dicts directly, because the result has to survive a round trip through a numpy
.npzfile, which stores strings but not mappings. - "image": C.IMAGE.name, "bytes": C.IMAGE.stat().st_size,
- Filename and size together. Not a content hash — that would mean reading eight megabytes on every run to catch a case that essentially never happens. Size plus name catches a swapped file; it would miss an edit that preserved the byte count exactly, which is a trade made knowingly.
- "crop": list(C.CROP), "work": list(work_size),
list()on both because JSON has no tuple type and would render one as an array anyway — being explicit keeps the round trip symmetric.workis the derived working size, which folds inWIDTH_MM,PITCH_MMand the source image's aspect without having to name any of them: change any one and the working size changes, so the cache correctly invalidates.- "model": C.MODEL_ID, "long_side": C.INFER_LONG_SIDE,
- The two most obvious invalidators. Swapping Large for Base, or 1036 for 1358, produces a genuinely different depth map.
- "tiled": bool(C.USE_TILED_REFINE), "tile": C.TILE,
- The
bool()guards against a truthy non-boolean —1andTruewould otherwise serialise differently and spuriously invalidate a good cache. - "overlap": C.TILE_OVERLAP, "sigma": C.TILE_LOWPASS_SIGMA_PX,
- The last two tiling parameters. This closes the set: every argument that reaches
inferortiled_refineis represented here, which is the invariant to preserve if you ever add a parameter. - }, sort_keys=True)
- The detail that makes the whole thing work. Without
sort_keys, two identical configurations could serialise to different strings depending on dict insertion order, and the cache would miss for no reason. With it, the string is canonical.
A cheap guard on the failure §5.2 calls the most expensive one to get wrong.
def polarity_hint(d):
"""Heuristic, not a law: where a ground plane recedes, the bottom of the
frame is nearer than the top. Aerial views and ceilings break it."""
H = d.shape[0]
k = max(H // 4, 1)
spread = float(d.max() - d.min())
if spread < 1e-9:
return "unclear (depth map is flat)"
m = (float(d[H - k:].mean()) - float(d[:k].mean())) / spread
if m > 0.05:
return f"looks right (bottom nearer by {m:.2f} of range)"
if m < -0.05:
return f"!! LOOKS INVERTED (top nearer by {-m:.2f}) -- try INVERT = True"
return f"unclear (margin {m:+.2f}) -- judge 01_depth_global.png by eye"
Line by line — the polarity check, 14 lines
- def polarity_hint(d):
- A cheap guard on the failure §5.2 calls the most expensive one to get wrong. It runs on the raw depth map, before any conditioning, which is the same thing
01_depth_global.pngshows. - """Heuristic, not a law: where a ground plane recedes, the bottom of the
- Stated as a limitation first, deliberately. The assumption is that the picture contains a ground plane running away from the viewer — true of this painting and most landscapes, and false often enough to matter.
- frame is nearer than the top. Aerial views and ceilings break it."""
- The named counterexamples. A ceiling fresco inverts the assumption outright; a flat-on close-up has no recession at all. Naming them is what keeps this a hint rather than a false universal.
- H = d.shape[0]
- Rows only — the test is purely vertical.
- k = max(H // 4, 1)
- A quarter of the frame from each end, so the comparison uses half the image and ignores the middle band where a receding plane is ambiguous. The
max(…, 1)keeps it meaningful for a tiny test image. - spread = float(d.max() - d.min())
- The depth map's full range, used to normalise. Without it the margin would be in the model's arbitrary units and the thresholds below could not be fixed constants.
- if spread < 1e-9:
- A flat map has no polarity to check.
- return "unclear (depth map is flat)"
- Says so rather than dividing by zero. A genuinely flat depth map is itself a red flag — it usually means the model failed on the image, which §9 tells you to check for.
- m = (float(d[H - k:].mean()) - float(d[:k].mean())) / spread
- The margin: mean of the bottom quarter minus mean of the top quarter, as a fraction of the total range. Positive means the bottom reads nearer, which is what you want given the model's larger-is-nearer convention. Normalising by
spreadmakes the number comparable across images and models. - if m > 0.05:
- A five-percent margin, chosen so ordinary noise cannot produce a confident verdict.
- return f"looks right (bottom nearer by {m:.2f} of range)"
- Reports the margin, not just the verdict. A margin of 0.75 is emphatic; 0.06 scraped past the threshold and deserves a look at the checkpoint image anyway.
- if m < -0.05:
- The same threshold in the other direction.
- return f"!! LOOKS INVERTED (top nearer by {-m:.2f}) -- try INVERT = True"
- Shouty on purpose, and it names the exact fix. This is the message that saves a twelve-hour print, so it is the one line in the project written to be impossible to skim past.
- return f"unclear (margin {m:+.2f}) -- judge 01_depth_global.png by eye"
- The honest middle. Within ±0.05 the test declines to guess and hands you back to your own eyes with the specific file to open. The
{m:+.2f}forces a sign, so you can see which way it was leaning even when it will not commit.
Everything physical is derived here: panel height from the image's real aspect, grid size from the pitch, and the working image at twice the grid.
def main():
t0 = time.time()
C.OUTDIR.mkdir(parents=True, exist_ok=True)
# ---- 1. load, crop, size ----------------------------------------------
img = Image.open(C.IMAGE).convert("RGB")
w0, h0 = img.size
l, t, r, b = C.CROP
if any(C.CROP):
img = img.crop((int(l * w0), int(t * h0), int((1 - r) * w0), int((1 - b) * h0)))
print(f"[1] source {w0}x{h0} -> working {img.size[0]}x{img.size[1]}"
f" aspect {img.size[0] / img.size[1]:.4f}")
height_mm = C.WIDTH_MM * img.size[1] / img.size[0]
grid_w = int(round(C.WIDTH_MM / C.PITCH_MM))
grid_h = int(round(height_mm / C.PITCH_MM))
print(f" print {C.WIDTH_MM:.1f} x {height_mm:.1f} x "
f"{C.BASE_MM + C.RELIEF_MM:.1f} mm grid {grid_w} x {grid_h}")
# The model never needs more pixels than the print grid can carry.
work = img.resize((grid_w * 2, grid_h * 2), Image.LANCZOS)
save_png(C.OUTDIR / "00_source.png",
np.asarray(work.resize((grid_w, grid_h), Image.LANCZOS), np.float64).mean(2) / 255)
Line by line — stage 1: load, crop, size, 20 lines
- def main():
- The only function that knows the order of operations. Everything else is a tool it calls.
- t0 = time.time()
- Started before anything else so the final timing covers the whole run, model download included.
- C.OUTDIR.mkdir(parents=True, exist_ok=True)
- Creates
out/if needed.parents=Truealso creates intermediate directories;exist_ok=Truemakes a second run a no-op rather than an error. This is the line that turns theOUTDIRpath built in §4 into an actual directory. - # ---- 1. load, crop, size ----------------------------------------------
- The five numbered banners match the bracketed stage numbers in the printed output, so a line on your terminal maps to a block of source without searching.
- img = Image.open(C.IMAGE).convert("RGB")
Image.openis lazy — it reads the header and defers decoding, so this line is fast and the actual JPEG decode happens on first access.convert("RGB")is the important half: it normalises greyscale, CMYK and palette images to three channels, so the luminance dot product further down always has exactly three to work with.- w0, h0 = img.size
- The original dimensions, kept so the log line can report source and working size together.
- l, t, r, b = C.CROP
- Unpacked in the order the comment in §4 documents. Note
tshadows nothing here, but it is a differenttfromt0above — a near-miss worth noticing when reading. - if any(C.CROP):
- Skips the crop entirely when all four fractions are zero, which is the default.
any()on a tuple of floats is true if any is non-zero, so this reads as “is there a crop at all?” - img = img.crop((int(l * w0), int(t * h0), int((1 - r) * w0), int((1 - b) * h0)))
- Fractions to pixels. The right and bottom are computed as
1 - fractionbecause the config expresses them as amounts to remove from those edges, not as coordinates.int()truncates rather than rounds, which can lose one pixel — irrelevant at this scale, and truncation guarantees the box stays inside the image. - print(f"[1] source {w0}x{h0} -> working {img.size[0]}x{img.size[1]}"
- Source and post-crop size on one line so a mis-specified
CROPis visible immediately. With no crop the two are identical, which is itself the confirmation that no crop was applied. - f" aspect {img.size[0] / img.size[1]:.4f}")
- Four decimal places, because this is the number every physical dimension downstream derives from. For your scan it is 1.4051 — the scan's aspect, not the painting's 1.385, which is why §4 flags that they differ.
- height_mm = C.WIDTH_MM * img.size[1] / img.size[0]
- The panel's height, derived rather than configured. This is the design decision that makes the guide robust to a different scan: you specify one dimension and the image decides the other, so the relief is never stretched.
- grid_w = int(round(C.WIDTH_MM / C.PITCH_MM))
- Millimetres over millimetres-per-sample gives samples. 170/0.25 = 680.
roundbeforeintrather than relying on truncation, so a pitch that does not divide evenly lands on the nearest grid rather than always short. - grid_h = int(round(height_mm / C.PITCH_MM))
- The same for rows — 484 for your file. These two numbers determine the triangle count, the STL size and the mesh statistics §9 prints.
- print(f" print {C.WIDTH_MM:.1f} x {height_mm:.1f} x "
- The physical panel, reported before anything expensive happens, so a wrong
WIDTH_MMcosts you a second rather than a run. - f"{C.BASE_MM + C.RELIEF_MM:.1f} mm grid {grid_w} x {grid_h}")
- Total thickness is base plus relief — the nominal figure. The printed panel can come out fractionally under it if the field's peak lands inside the rim, which §9 explains.
- # The model never needs more pixels than the print grid can carry.
- The rationale for the line below, and the coupling §2.5 and §8 both flag. Anything finer than twice the print grid is detail the resampling to
grid_w×grid_hwill throw away. - work = img.resize((grid_w * 2, grid_h * 2), Image.LANCZOS)
- The image the model actually sees: 1360×968 for your scan. The 2× factor is deliberate oversampling — enough headroom that the guided filter and the tiled pass have real detail to work with, without paying for the full 6819 px scan.
LANCZOSis the highest-quality downsampling filter PIL offers, which matters at a 5× reduction where a cheaper filter would alias the brushwork into moiré. - save_png(C.OUTDIR / "00_source.png",
- The first checkpoint, written before any model runs — so if
CROPis wrong you find out in seconds rather than after an inference. - np.asarray(work.resize((grid_w, grid_h), Image.LANCZOS), np.float64).mean(2) / 255)
- Greyscale at exactly the print grid, so what you are looking at has the same proportions and resolution as the final panel.
.mean(2)averages the three colour channels — a plain unweighted mean, unlike the perceptual weights used forlumalater, because this image is only ever for checking framing. Dividing by 255 puts it in the [0, 1] rangesave_pngexpects.
A hit skips the model stage entirely. A miss says so and falls through rather than guessing, so you can never tune against a depth map that belongs to different settings.
# ---- 2. depth ---------------------------------------------------------
cache = C.OUTDIR / "depth_cache.npz"
sig = depth_signature(work.size)
d = None
if C.USE_CACHED_DEPTH and cache.exists():
z = np.load(cache, allow_pickle=False)
if "sig" in z.files and str(z["sig"]) == sig:
d = z["depth"]
print(f"[2] depth from {cache.name} -- skipping the model")
else:
print(f"[2] {cache.name} does not match these settings, re-inferring")
Line by line — stage 2: is the cache still good?, 11 lines
- # ---- 2. depth ---------------------------------------------------------
- The expensive stage, and the only one with a cache in front of it.
- cache = C.OUTDIR / "depth_cache.npz"
- One file, in
out/alongside the checkpoints..npzbecause it holds two arrays — the depth map and the signature string — in one container. Delete it any time to force a fresh inference. - sig = depth_signature(work.size)
- Computed here rather than inside the branch because it is needed twice: once to test a hit, once to write alongside a miss.
- d = None
- The sentinel that couples the two blocks.
Nonemeans “no depth map yet”, and the second block'sif d is Noneis what makes the fallthrough work regardless of which way the test above went. - if C.USE_CACHED_DEPTH and cache.exists():
- Both conditions, in cheap-first order. Short-circuit evaluation means the filesystem is not touched at all when the flag is off.
- z = np.load(cache, allow_pickle=False)
allow_pickle=Falseis the security-conscious default and worth keeping. Pickle can execute arbitrary code on load, so refusing it means a corrupted or hostile.npzcan at worst fail to parse. Nothing here needs pickle: plain arrays and a string load fine without it.- if "sig" in z.files and str(z["sig"]) == sig:
- Two checks in one condition, and the first is not redundant. A cache written by an earlier version of this script has no
sigkey at all, and asking for a missing key raises rather than returning nothing — so without the membership test, upgrading the script would crash on your existing cache instead of quietly rebuilding it.str()unwraps numpy's 0-d string array back to a Python string for the comparison. - d = z["depth"]
- The hit. This is the line that turns a three-minute run into a ten-second one, and it is the entire justification for §3 keeping
heightfield.pyfree of torch. - print(f"[2] depth from {cache.name} -- skipping the model")
- Says plainly that the model did not run, so you are never in doubt about whether a change you made to a model parameter took effect.
- else:
- Signature mismatch, or an old-format cache.
- print(f"[2] {cache.name} does not match these settings, re-inferring")
- Announces the invalidation rather than silently re-running. Seeing this after editing a conditioning parameter would mean the signature is over-broad — worth knowing.
depth is imported inside this branch, so a cached run never pays torch's import
cost — which is what makes the tuning loop quick. The polarity line sits outside the branch
and prints either way: a cached run does not let you skip the check §5.2 calls the most
expensive one to get wrong. out/depth_cache.npz is about 10 MB; delete it to force
a fresh inference.
if d is None:
from depth import pick_device, infer, tiled_refine
dev = pick_device()
print(f"[2] depth on {dev} with {C.MODEL_ID} (first run downloads ~1.3 GB)")
d = infer(work, C.MODEL_ID, dev, C.INFER_LONG_SIDE)
save_png(C.OUTDIR / "01_depth_global.png", hf.norm01(d))
if C.USE_TILED_REFINE:
print("[3] tiled refinement")
d = tiled_refine(work, d, C.MODEL_ID, dev, C.TILE, C.TILE_OVERLAP,
C.TILE_LOWPASS_SIGMA_PX * work.size[0] / grid_w)
save_png(C.OUTDIR / "02_depth_refined.png", hf.norm01(d))
np.savez_compressed(cache, depth=d, sig=sig)
print(f" cached to {cache.name}")
print(f" polarity: {polarity_hint(d)}")
Line by line — stages 2–3: otherwise, run the model, 14 lines
- if d is None:
- Covers both a cache miss and the cache being disabled, in one branch. Everything torch-related lives inside it.
- from depth import pick_device, infer, tiled_refine
- A function-local import, and the one place in the project where that is not a code smell. Importing
depthpulls in torch andtransformers, which costs seconds even when unused — so a cached run, which never enters this branch, never pays it. - dev = pick_device()
mpsorcpu, decided once and passed down.- print(f"[2] depth on {dev} with {C.MODEL_ID} (first run downloads ~1.3 GB)")
- The parenthetical is there because of what happens next: on a first run the process appears to hang for minutes with no further output while the checkpoint downloads. Saying so up front is the difference between waiting and killing the process.
- d = infer(work, C.MODEL_ID, dev, C.INFER_LONG_SIDE)
- The global pass — one inference over the whole working image. Coherent about large-scale structure and soft about detail, which is exactly the split the tiled pass exploits.
- save_png(C.OUTDIR / "01_depth_global.png", hf.norm01(d))
- Written before any refinement, so this checkpoint always shows the model's unaided opinion.
norm01is needed because raw disparity has no fixed range. - if C.USE_TILED_REFINE:
- The optional second pass. Off while tuning, on for the final run.
- print("[3] tiled refinement")
- Its own stage number, because it is the slowest thing in the pipeline — twelve inferences, 3–10 minutes on a fanless laptop.
- d = tiled_refine(work, d, C.MODEL_ID, dev, C.TILE, C.TILE_OVERLAP,
- The global depth is passed in as well as the image, because every crop has to be affine-aligned against a shared reference before it can be blended.
- C.TILE_LOWPASS_SIGMA_PX * work.size[0] / grid_w)
- The one rescaled argument. The config expresses the crossover in working-grid pixels; multiplying by the working-to-print ratio keeps its physical meaning fixed when you change
PITCH_MMorWIDTH_MM. With the 2× working factor this ratio is simply 2. - save_png(C.OUTDIR / "02_depth_refined.png", hf.norm01(d))
- The checkpoint §9 tells you to compare against
01: same large-scale structure, sharper detail. A visible difference at a distance means the alignment is not working. - np.savez_compressed(cache, depth=d, sig=sig)
- Writes the cache — after the tiled pass, so what is stored is the final depth map rather than the global one.
savez_compressedrather thansaveztakes the file from 10.7 MB to about 9.7 MB and costs about a second; the read back is well under a tenth of a second either way. - print(f" cached to {cache.name}")
- Confirms the write, so a permissions problem in
out/is visible. - print(f" polarity: {polarity_hint(d)}")
- Deliberately outside the
if, at function indentation. A cached run gets the check too — the depth map is the same one either way, and skipping the most consequential sanity check just because the map came from disk would be exactly the wrong economy.
The pipeline proper, in the order the guide describes it, with a checkpoint PNG written after each meaningful step.
# ---- 4. condition -----------------------------------------------------
print("[4] conditioning")
d = hf.resize_to(hf.norm01(d), (grid_h, grid_w))
luma = hf.norm01(np.asarray(img.resize((grid_w, grid_h), Image.LANCZOS),
np.float64) @ [0.2126, 0.7152, 0.0722] / 255)
if C.INVERT:
d = 1.0 - d
d = hf.norm01(hf.guided_filter(luma, d, C.GUIDED_RADIUS, C.GUIDED_EPS))
save_png(C.OUTDIR / "03_guided.png", d)
d = hf.equalize(d, C.EQUALIZE)
d = hf.norm01(hf.compress_range(d, C.COMPRESS_ALPHA))
save_png(C.OUTDIR / "04_compressed.png", d)
d = hf.norm01(hf.add_detail(d, luma, C.DETAIL_GAIN, C.DETAIL_SIGMA_PX))
d = hf.apply_rim(d, C.RIM_MM, C.PITCH_MM)
save_png(C.OUTDIR / "05_heightfield.png", d)
save_png(C.OUTDIR / "06_preview_lit.png",
hf.hillshade(d, C.RELIEF_MM, C.PITCH_MM,
C.LIGHT_AZIMUTH_DEG, C.LIGHT_ALTITUDE_DEG))
Line by line — stage 4: conditioning, 18 lines
- # ---- 4. condition -----------------------------------------------------
- Step [C] of §0, and the stage §0 says wins the print. Everything here is fast; every line is a parameter you tune.
- print("[4] conditioning")
- No stage 3 in the log when tiling is off — the numbering follows the pipeline, not the run.
- d = hf.resize_to(hf.norm01(d), (grid_h, grid_w))
- Down from the working grid to the print grid, and normalised on the way. This is the moment the depth map stops being an image and becomes a heightfield sampled at the printer's resolution. Note the shape argument is (rows, cols) — numpy order, the reverse of the PIL calls above.
- luma = hf.norm01(np.asarray(img.resize((grid_w, grid_h), Image.LANCZOS),
- Resampled from
img, the full-resolution original — not fromwork. That matters: going straight from 6819 px to 680 px in one Lanczos step preserves finer detail than two chained reductions would, and this array is the guide for the edge-aware filter. - np.float64) @ [0.2126, 0.7152, 0.0722] / 255)
- Perceptual luminance via the Rec. 709 weights — green counts for about 72% because human vision is most sensitive there. The
@is matrix multiplication, contracting the colour axis of an (H, W, 3) array against a length-3 vector to give (H, W). A plain.mean(2)would work but would make the trees and the snow less distinct exactly where §1 says the edges matter. - if C.INVERT:
- The manual override for the polarity convention, applied before anything else looks at the depth.
- d = 1.0 - d
- A flip in place, valid because
dis normalised to [0, 1] on the line above. Doing it here rather than later means every checkpoint from03onward shows the corrected polarity. - d = hf.norm01(hf.guided_filter(luma, d, C.GUIDED_RADIUS, C.GUIDED_EPS))
- Argument order matters and is easy to invert: the guide comes first, the thing being filtered second. Swap them and you would be snapping the painting onto the depth map's soft edges, which is precisely backwards. The
norm01wrapper is needed because the filter's affine fit does not preserve range. - save_png(C.OUTDIR / "03_guided.png", d)
- Checkpoint: edges should now be crisp and sitting on the painted edges, and flat sky should be smooth.
- d = hf.equalize(d, C.EQUALIZE)
- No
norm01wrapper here, becauseequalizenormalises internally on its way out — one of the few functions inheightfield.pythat does. - d = hf.norm01(hf.compress_range(d, C.COMPRESS_ALPHA))
- The Poisson solve, and the one call whose output genuinely needs renormalising: reintegration returns a field with an arbitrary offset and scale, so without
norm01the next stage would receive something with no defined range at all. - save_png(C.OUTDIR / "04_compressed.png", d)
- The most informative checkpoint after the lit preview. §9: it should look noticeably flatter and busier than
03. If it looks the same,COMPRESS_ALPHAis too low. - d = hf.norm01(hf.add_detail(d, luma, C.DETAIL_GAIN, C.DETAIL_SIGMA_PX))
- The same
lumacomputed once above, reused.add_detailadds rather than multiplies and can push slightly out of range, hence the wrapper. - d = hf.apply_rim(d, C.RIM_MM, C.PITCH_MM)
- Last, and the order is deliberate — the rim has to be applied after everything that could raise the border again. It is also why the panel's Z can land fractionally under
BASE_MM + RELIEF_MM, and why there is nonorm01after it: renormalising would undo the fade. - save_png(C.OUTDIR / "05_heightfield.png", d)
- The finished field. Worth one look to confirm the border falls to black on all four sides.
- save_png(C.OUTDIR / "06_preview_lit.png",
- The checkpoint §6.4 tells you to judge the whole print from — and the only one that shows the heightfield as a surface rather than as a picture.
- hf.hillshade(d, C.RELIEF_MM, C.PITCH_MM,
- The physical parameters are passed in because the shading depends on real slope: change
RELIEF_MMand the preview changes exactly as the panel would. - C.LIGHT_AZIMUTH_DEG, C.LIGHT_ALTITUDE_DEG))
- The two preview-only parameters from §4's last block. Worth sweeping once — a relief that only reads under one lighting angle will disappoint on a real wall.
# ---- 5. mesh ----------------------------------------------------------
print("[5] meshing")
m = heightfield_to_mesh(d, C.WIDTH_MM, height_mm, C.BASE_MM, C.RELIEF_MM)
problems = validate(m)
print(f" {len(m.vertices):,} verts {len(m.faces):,} faces"
f" volume {m.volume / 1000:.1f} cm^3")
print(f" bbox {np.round(m.extents, 2)}")
print(" OK, watertight" if not problems else f" PROBLEMS: {problems}")
stl = C.OUTDIR / f"{C.IMAGE.stem[:40]}_relief.stl"
m.export(stl)
print(f"[6] wrote {stl} ({stl.stat().st_size / 1e6:.1f} MB) in {time.time() - t0:.0f}s")
Line by line — stage 5: mesh and export, 11 lines
- # ---- 5. mesh ----------------------------------------------------------
- Geometry at last. Everything above this line has been a 2D array; below it, a solid.
- print("[5] meshing")
- Worth announcing because it is the slowest part of a cached run — about six seconds at this grid, against well under one for all of stage 4.
- m = heightfield_to_mesh(d, C.WIDTH_MM, height_mm, C.BASE_MM, C.RELIEF_MM)
- Note
height_mmis the local variable derived in stage 1, not a config value — the one physical dimension the image decides rather than you. - problems = validate(m)
- Called before the summary is printed, so the expensive watertightness query happens once and its result is reused.
- print(f" {len(m.vertices):,} verts {len(m.faces):,} faces"
- The
:,format spec inserts thousands separators — trivial, but 331,445 is readable at a glance where 331445 is not. These two numbers are the quickest check that the grid came out the size you expected. - f" volume {m.volume / 1000:.1f} cm^3")
- Cubic millimetres to cubic centimetres. Content-dependent, since it scales with the mean height of the conditioned field — around 90 cm³ at these settings, and it moves whenever you retune §6.
- print(f" bbox {np.round(m.extents, 2)}")
- The bounding box, and the fastest check that nothing is upside down or mis-scaled. X and Y should match the printed panel size exactly; Z is
BASE_MM + RELIEF_MM × max(h), so slightly under 7 whenever the field's peak fell inside the rim. - print(" OK, watertight" if not problems else f" PROBLEMS: {problems}")
- An empty list is falsy, so
not problemsreads as “nothing went wrong”. Note it prints a warning and carries on rather than raising — you get the STL either way, so you can open a suspect mesh in a viewer instead of only reading about it. - stl = C.OUTDIR / f"{C.IMAGE.stem[:40]}_relief.stl"
.stemis the filename without its extension; the 40-character truncation keeps the output name manageable, since the Google Art Project name is 76 characters. Yours becomesPieter_Bruegel_the_Elder_-_Hunters_in_th_relief.stl— cut mid-word, which is ugly but stable and collision-free for one project.- m.export(stl)
- Trimesh picks the format from the extension. STL rather than 3MF because every slicer reads it and the mesh carries no colour or units to lose — the vertices are already in millimetres, which is what §10 means about it arriving correctly scaled.
- print(f"[6] wrote {stl} ({stl.stat().st_size / 1e6:.1f} MB) in {time.time() - t0:.0f}s")
- The last line, and three facts in it. The full path, so you can drag it into the slicer. The size, which should be about 33 MB and is a quick sanity check on the triangle count. And the elapsed time since
t0, which tells you at a glance whether the model actually ran or the cache did its job.
if __name__ == "__main__":
main()
Line by line — entry point, 2 lines
- if __name__ == "__main__":
- True when the file is run directly, false when it is imported. Without this guard,
import mainfrom a REPL — or from a test — would immediately kick off a full pipeline run. It is also what makes the file safe to poke at interactively while debugging. - main()
- Two characters of actual program. Everything else is definitions.
9. Running it, and what to look at
cd "/Users/you/painting2model"
source .venv/bin/activate
export PYTORCH_ENABLE_MPS_FALLBACK=1
python main.py
First run: set USE_TILED_REFINE = False. You want the fast loop while you're
looking at checkpoints. The first run is much the longest, and it narrates itself, so you can tell
waiting from hanging:
[2] depth on mps with depth-anything/Depth-Anything-V2-Large-hf
fetching depth-anything/Depth-Anything-V2-Large-hf
model.safetensors: 38%|##### | 511M/1.34G [00:42<01:08, 12.1MB/s]
weights in RAM after <NN>s; moving to mps
model ready in <NN>s
wrote <project>/out/01_depth_global.png
The transfer bar is huggingface_hub's own and is on by default, so the download is
the one part of the pipeline that already reported itself. The three fetching /
weights in RAM / model ready lines are ours, and they cover what the bar
does not: the silent 1.3 GB copy into GPU memory that happens after the download finishes. The only
quiet stretch left is the forward pass itself, between model ready and the first
wrote — 10–40 s, per §5.3.
All of that happens once. Every run after that reuses the cached weights, and every run that
changes only §6 parameters skips the model altogether on the strength of
out/depth_cache.npz.
Every line the script prints, in order, for the §2.7 scan at the §4 settings with tiling
off. Paths are shown as <project>/; the script prints them in full. The three
values in angle brackets are the only ones that depend on your particular run — everything
else is fixed by your source file and your config:
[1] source 6819x4853 -> working 6819x4853 aspect 1.4051
print 170.0 x 121.0 x 7.0 mm grid 680 x 484
wrote <project>/out/00_source.png
[2] depth on mps with depth-anything/Depth-Anything-V2-Large-hf (first run downloads ~1.3 GB)
wrote <project>/out/01_depth_global.png
cached to depth_cache.npz
polarity: looks right (bottom nearer by <0.xx> of range)
[4] conditioning
wrote <project>/out/03_guided.png
wrote <project>/out/04_compressed.png
wrote <project>/out/05_heightfield.png
wrote <project>/out/06_preview_lit.png
[5] meshing
331,445 verts 662,886 faces volume <~90> cm^3
bbox [170. 120.99 <6.9-7.0>]
OK, watertight
[6] wrote <project>/out/Pieter_Bruegel_the_Elder_-_Hunters_in_th_relief.stl (33.1 MB) in <NN>s
Two of those deserve a note. The volume scales with the mean height of your
conditioned field, so it moves whenever you retune §6 — around 90 cm³ at these settings. And the
bbox Z is not necessarily 7.00: it is
BASE_MM + RELIEF_MM × max(h), and apply_rim runs last, so if your field's
peak happens to fall inside the RIM_MM border it gets faded down with everything else
there. A composition whose nearest point runs to the edge of the frame — this one, at the bottom —
will print a fraction under the nominal 7 mm. That is the rim working as designed, not a bug, but
it does mean the rim quietly costs you a little relief range.
The checkpoint images, in order of what they tell you
All seven, from one real run of this pipeline on the Google Art Project scan. Read them left to right: a photograph goes in, a printable surface comes out, and every stage in between is one of the operations §5 and §6 describe.
And in plastic
The same pipeline, printed: 170 × 121 mm, matte PLA, 0.10 mm layers, about twelve hours. This is the print every number in this guide was measured against.
00_source.png — is the frame cropped off? Google Art Project scans usually
include a sliver of frame or wall. Set CROP and re-run. This costs you nothing and
a frame edge will otherwise become a hard 5 mm cliff around your panel.
01_depth_global.png — the polarity check. The hunters, the dogs, and the
big foreground trees must be BRIGHT. The mountains at the horizon must be
DARK. If it's the other way round, set INVERT = True. Also check the model
actually understood the scene: you should see a clear gradient down the valley, not a
uniform grey with the trees stamped on it. If it's confused, the painting is probably
too stylised for the model's photographic prior — drop to INFER_LONG_SIDE = 728,
which sometimes reads composition better than a high-resolution pass that gets lost
in brushwork.
02_depth_refined.png — only written when USE_TILED_REFINE is on.
Compare it against 01: the large-scale structure should be identical and the fine
structure sharper. If the two look different at a distance, the affine alignment is not doing
its job and TILE_OVERLAP is the first thing to raise.
03_guided.png — edges should now be crisp and coincident with painted edges,
and the flat sky should be smooth.
04_compressed.png — should look noticeably flatter and busier than 03. The
foreground/background split is less dominant; middle-ground structure has appeared.
If it looks the same as 03, COMPRESS_ALPHA is too low. If everything has turned into
uniform mush, it's too high.
05_heightfield.png — the finished field, after detail and the rim fade.
Worth one look to confirm the border really does fall to black on all four sides; past that,
judge from 06 instead, because a greyscale heightfield reads to your eye as a picture
rather than as a surface.
06_preview_lit.png — judge the print from this one. Simulated raking light
from the upper left. Can you read the hunters? The village? Do the mountains still
exist as form? Iterate on COMPRESS_ALPHA, EQUALIZE, and DETAIL_GAIN until this
image looks like an object you'd want on a wall — see the order below. With USE_CACHED_DEPTH = True each of
those iterations reloads depth_cache.npz and takes seconds rather than re-running the
model — which is the whole reason §3 keeps heightfield.py free of torch. The
conditioning itself is well under a second at this grid; what is left of a cached run is the
mesh build and the 33 MB STL write, which dominate it.
Which way to turn them
The three knobs are not independent, and two of them push in the same direction, so turning them at random mostly produces confusion. Measured on §6.5's synthetic scene — a hard step with fine texture on it, which is this painting's problem in miniature — here is the texture-to-step ratio, i.e. how much of the height budget goes to detail rather than to the foreground/background split:
EQUALIZE ↓ COMPRESS_ALPHA → |
0 | 2 | 4 | 6 |
|---|---|---|---|---|
| 0.00 | 0.013 | 0.282 | 0.380 | 0.440 |
| 0.55 | 0.098 | 0.477 | 0.596 | 0.664 |
| 1.00 | 0.230 | 0.630 | 0.752 | 0.821 |
Read across the top row: COMPRESS_ALPHA alone moves the ratio from 0.013 to 0.440, a
factor of 34. Read down the α = 4 column: EQUALIZE alone
moves it from 0.380 to 0.752, a factor of 2. That is the whole argument for the
order below — α is the knob, equalisation is the trim. They compound rather
than duplicating each other, which is exactly why raising both at once overshoots and lands you in
“uniform mush” without your knowing which one did it.
DETAIL_GAIN is a different animal, and its name slightly oversells it. It is not free
detail added on top; it competes for the same 5 mm. On the same scene, with luminance carrying
only fine texture, raising it from 0 to 0.25 shrinks the depth step from 0.279 to 0.194 —
because the added texture widens the total range and norm01 rescales everything to fit.
And where a luminance edge happens to sit on a depth edge, it does the opposite and sharpens it hard:
the same sweep takes the step from 0.279 to 0.620. So its effect depends on the
painting, not just on the number, which is the reason to set it last and by eye.
The order
- Set
DETAIL_GAIN = 0first. You cannot judge the depth interpretation with brushwork laid over it — texture reads as detail even when the form underneath is wrong. Get the form right in the dark, then dress it. - Sweep
COMPRESS_ALPHA: 2, then 4, then 6. This is the knob that does the work. Stop when the middle distance — the village, the ponds, the skaters — has become legible form rather than a smear against the background, and before the foreground stops reading as foreground. Beyond about 8 you are trading away the depth story; at 15 it is gone (§6.2). - Then
EQUALIZE, between 0.3 and 0.7. Raise it if a large population of pixels at one distance — for this painting, the valley floor — is sharing a thin slab while other depths have room to spare. Lower it if the scene starts to look pressure-flattened, everything at one apparent distance. If you find yourself wanting more than 0.7, the honest fix is usually another notch of α instead. - Then bring
DETAIL_GAINback, 0.10–0.20. Raise it until the bare branches and the roof tiles read as objects rather than as suggestions; stop as soon as the flat snow starts to look speckled rather than smooth. If both happen at once, the crossover is wrong rather than the amount — raiseDETAIL_SIGMA_PXto 4 and try again. - Re-check under a second light angle. Set
LIGHT_AZIMUTH_DEGto 45 and re-run. A relief that only reads from the upper left is a relief that will disappoint on a real wall, where the lamp is wherever the lamp is.315°The default, and the convention your eye expects. 45°From the upper right. Everything that read as a ridge now reads as a groove. 135°From below right — the least flattering angle most rooms can produce.
Two habits that make this quick. Change one number per run — with the depth cache
warm each iteration is seconds, so there is no reason to bundle edits and then wonder which one did
what. And keep the ones you like: copy 06_preview_lit.png to
06_a4_eq55.png or similar before the next run, because the previews are otherwise
overwritten every time and judging two settings side by side is far easier than judging one against a
memory.
When something specific looks wrong rather than merely unsatisfying, §11 is the faster route — it is indexed by symptom, and it covers the knobs outside this trio as well.
Once you're happy, set USE_TILED_REFINE = True and do the real run.
10. Slicing for the A1 mini
Import the STL into Bambu Studio (or Orca). It arrives sitting flat on the plate, relief facing up, correctly scaled — the mesh is authored in millimetres.
Settings
| Setting | Value | Why |
|---|---|---|
| Printer | A1 mini, 0.4 mm nozzle | stock |
| Layer height | 0.10 mm, uniform (0.08 also proven) | 50 distinct height levels out of your 5 mm of relief — and 0.10 is what the print on this page was made at. 0.08 is the 0.4 nozzle's official floor (Bambu supports 0.08–0.28) and buys 62 levels at the cost of a longer print. Either way keep it uniform: variable layer height saves time but leaves visible demarcation lines where the bands change, and this whole panel is display surface |
| First layer | 0.20 mm | adhesion on a 170 mm footprint |
| Wall loops | 3 | the relief surface is mostly walls; more helps definition |
| Top shell layers | 5 | the whole top surface is "top", don't skimp |
| Bottom shell layers | 4 | it's the visible back |
| Sparse infill | 10 %, gyroid | it's a panel, it needs stiffness not strength |
| Supports | off | a heightfield has no overhangs by construction (§7.2) |
| Brim | 5 mm outer brim | cheap insurance against corner lift on a thin wide part |
| Top surface speed | 60 mm/s (or about half your profile's default) | the visible face of a relief is 42 layers, not the last one — see below |
| Top surface acceleration | 2000 mm/s² — already the stock value | and demonstrably the right one: below it the island geometry, not the acceleration, is your limit |
| Outer wall acceleration | 2000 mm/s² (stock is 5000) | the terrace faces — 1614 mm per layer, and what casts the shadows. Costs +2.5 min |
| First layer speed | 20 mm/s | 206 cm² is a lot of footprint to lift off the plate |
| Seam position | Aligned, Back | keeps Z-seam artefacts off the front face. Bambu's UI says Back, not Rear |
Leave "Detect thin walls" on. Turn off "Fuzzy skin" — it fights the surface you just spent all this effort computing.
Most of the table above is invisible until you switch the parameter panel out of Simple mode. Bambu Studio opens in Simple, which hides the majority of these; the Advanced toggle sits at the top of the Process panel. Wall loops, the shell layer counts, seam position and every speed row need it. If a setting in this table appears not to exist, that is almost always why rather than a version difference.
Speed, and why the usual advice inverts
Nearly every speed guide for this printer is written for the ordinary case, where the outer wall is the surface you look at. A bas-relief is not that case, and following the standard advice here spends hours buying nothing.
The visible face of this print is forty-two layers, not one. On a normal part “top surface” means the last few layers. On a heightfield it does not: the surface is a continuously varying height, so at every Z some part of it terminates. Measured on a conditioned field at these settings, 42 of the 50 relief layers each carry more than 1% of the panel as freshly exposed top surface, averaging 4.1 cm² apiece — and they sum to the full 206 cm² of the panel, because every square millimetre of what you will look at is somebody's top surface. So whatever your profile does to top-surface speed and acceleration, it does to essentially the entire object. That is the setting to spend on.
The outer wall is not the panel's rim — it is every terrace face. It is tempting to reason that the outer wall here is just the 582 mm rectangle around the edge of the panel, and therefore negligible. It is not. On each layer the outer wall is the contour of the cross-section, and on a relief that contour is long and convoluted: a median of 1614 mm per layer, three times the rim, and 1.7× the top-surface path. Those contours are the near-vertical face of every 0.1 mm terrace, which is exactly what casts the shadows that make the relief readable. Drop the outer-wall acceleration from its stock 5000 to 2000: it costs about 2.5 minutes over the whole print and calms roughly 167 direction changes per layer.
Note the difference in kind between the two. Those contours run about 10 mm apiece, which is far enough to reach a commanded 60 mm/s even at low acceleration — so on the walls the speed setting is the real lever and the acceleration is cheap insurance. On the 1.55 mm top-surface islands it is the other way round entirely.
And on the top surface, acceleration matters more than speed — which is a consequence of geometry worth seeing the numbers for. Slice a relief at 0.10 mm and each layer's exposed region is the set of points whose height falls in a 0.10 mm band. How that band looks depends entirely on how much fine detail the conditioning put in:
| Field | Islands per layer | Median island |
|---|---|---|
| smooth, no fine detail | ~4 | 49 mm² (about 7 mm across) |
conditioned with DETAIL_GAIN | ~630 | 0.06 mm² (about 0.2 mm across) |
Six hundred islands a fifth of a millimetre wide is not a regime in which a commanded speed of 200 mm/s means anything. A move that starts and ends at rest over a distance L never reaches its commanded speed unless the distance allows it; it peaks at √(a·L) and then decelerates. Those peaks, in mm/s:
| Distance | a = 10000 | 5000 | 2000 | 1000 | 500 mm/s² |
|---|---|---|---|---|---|
| 0.2 mm — one small island | 45 | 32 | 20 | 14 | 10 |
| 1.55 mm — a typical island's path | 124 | 88 | 56 | 39 | 28 |
| 10 mm run | 316 | 224 | 141 | 100 | 71 |
| 170 mm — a full pass | 1304 | 922 | 583 | 412 | 292 |
Read the second row. That 1.55 mm is this print's real unit of work — 4.1 cm² of top surface per layer, at a 0.42 mm line width, divided among 630 islands. At 2000 mm/s² the toolhead peaks at 56 mm/s on it no matter what number you typed in the speed box. Which happens to land squarely in the 50–80 mm/s band that quality-focused profiles recommend for a visible surface anyway — so 2000 mm/s² is the point where acceleration stops being your constraint and the geometry takes over. That is the number to set, and it is why lowering it further has sharply diminishing returns.
The cost of getting there is small, which is the other half of the argument:
| Top surface acceleration | Time for the top surface, 50 layers | Cost |
|---|---|---|
| 10000 mm/s² | 13 min | — |
| 5000 | 18 min | +5 min |
| 2000 | 29 min | +16 min |
| 1000 | 41 min | +28 min |
| 500 | 59 min | +45 min |
Sixteen minutes on a twelve-hour print, for the surface that is the object, is a good trade. Go down to 1000 if you can see ringing or ghosting on the test print — the A1 mini moves its bed in Y, and a 170 mm panel is a lot of mass to reverse — but below about 500 you are paying real time for motion the geometry was never going to allow anyway. OrcaSlicer's own guidance is to set top surface acceleration to roughly your outer-wall acceleration, so if you have already tuned that, match it and move on.
Jerk, if your profile exposes it, wants to come down alongside: 7–9 mm/s is the usual recommendation for surface quality on this machine. It governs how abruptly the head may change direction without ramping, which on a field of six hundred tiny islands it is doing constantly.
Leave the rest fast. Twenty of your seventy layers are the base plate and will never be seen, the sparse infill is invisible by definition, and internal solid infill only ever sits under something. And you can ignore minimum layer time entirely — each layer of a 206 cm² panel already takes minutes, so cooling is never the binding constraint here, unlike the small parts most speed guides assume.
What does not follow from this
It is tempting to conclude that DETAIL_GAIN is therefore also a print-time setting —
turn it down, get fewer islands, finish sooner. On a synthetic test that is exactly what happens. On a
real depth map it does not. Sweeping the conditioning on an
actual cached depth map of this painting:
DETAIL_GAIN | DETAIL_SIGMA_PX | Islands |
|---|---|---|
| 0.12 | 2.5 | 5,030 (the defaults) |
| 0.08 | 4.0 | 3,824 |
| 0.00 | 2.5 | 5,035 |
Turning detail entirely off changes nothing. The fine structure that fragments the slice is
already in the depth map after range compression — add_detail is not where it comes
from. So tune DETAIL_GAIN by eye for how the panel looks, which is what §9 says, and do not
expect it to buy you print time.
The broader point: a smooth test field slices into four islands
per layer and a real one into five thousand, so any intuition about fragmentation built on a synthetic
scene will be wrong by three orders of magnitude. Measure on your own
out/depth_cache.npz before believing a claim of this shape, including this one.
The “floating regions” warning
Your first slice will produce this, and it looks alarming:
It seems object …_relief.stl has floating regions. Please re-orient the object or enable support generation.
Dismiss it. Do not enable supports. §7.2 has the geometric argument — a heightfield is monotone, so no layer can be unsupported, and on a real conditioned field the count of unsupported pixels across all fifty layers is exactly zero. What the slicer is actually reporting is that parts of your relief are finer than a 0.42 mm extrusion. It cannot lay those down, so it drops them, and its own supported-area check then sees material with nothing printed beneath.
The reasonable next worry is whether a print that drops that much is worth having. It is, and the number that settles it is an area rather than a count. About two thirds of the cross-section islands are below one extrusion width — but islands are small by definition, which is what made them islands:
| Measured over the whole print | |
|---|---|
| Extrusion the slicer lays down, all layers | 3,779 cm² |
| Area in islands too small to extrude | 2.6 cm² |
| Fraction of material lost | 0.070 % |
Simulate it properly — drop every unprintable island layer by layer, rebuild the finished surface, compare against what was asked for — and the error is smaller than the machine:
| How far below the intended surface the print lands | |
|---|---|
| Median, and 90th percentile | 0 µm |
| 99th percentile | 3 µm |
| 99.9th percentile | 43 µm |
| Worst single point on the panel | 332 µm |
| Panel more than one layer (0.10 mm) low | 0.01 % |
| The nozzle's own XY floor, for scale | 420 µm |
The worst point on the whole panel is off by less than the smallest feature the nozzle could have rendered there anyway. And the reason it is benign is structural rather than lucky: the dropped slivers are the topmost 0.10 mm of tiny peaks, so what remains is the layer beneath them. They are never holes through the part, never load-bearing, and never in the base — layer 0 is one solid 205.7 cm² region with zero unprintable area. Fragmentation only appears as you climb toward the peaks.
What you lose is detail finer than the nozzle, in the places where the relief was finer than the nozzle. The slicer is telling you it noticed, not that it failed.
Filament
Use matte PLA in a light neutral (ivory, bone white, ash grey). Three reasons:
- Matte hides layer lines. On a relief this is not cosmetic, it's functional — gloss creates specular highlights that compete with the shadows carrying your image.
- Light colours show shadow. The entire image is shadow. Dark filament gives you nothing to see. Black matte PLA is the single worst choice for a relief.
- PLA over PETG. Less stringing on the fine detail, lower warp on a wide thin part, and the A1 mini is open-frame so ABS/ASA are out anyway.
Expectations
The solid volume of the mesh is about 90 cm³ — it scales with the mean height of your conditioned field, so expect a few cm³ either way; at 10 % infill you'll actually use roughly 40–70 g. Print time will be somewhere in the 10–16 hour range — the slicer's estimate is authoritative, mine is not; a mostly-solid 206 cm² surface at 0.10 mm over 70 layers is simply a lot of extrusion.
Print the small one first. Set WIDTH_MM = 85 in config, re-run (about ten
seconds once the depth map is cached), and print that. It's roughly an hour and it
will tell you whether your COMPRESS_ALPHA is right, whether terracing is visible,
and whether the filament colour works — before you commit fourteen hours.
If you want to go further
A 0.2 mm nozzle on the A1 mini halves your XY feature size and would genuinely
show more of Bruegel's detail. Drop PITCH_MM to 0.12 to match. Print time roughly
triples. This is the single highest-leverage upgrade for this specific subject.
11. Tuning table
| What you see | What to change |
|---|---|
| Foreground sunken, mountains raised | INVERT = True |
| Flat wall with a foreground silhouette stuck on it | raise COMPRESS_ALPHA toward 6 |
| Uniform mush, no sense of depth | lower COMPRESS_ALPHA toward 2; lower EQUALIZE |
| Visible stair-stepping / contour terracing | layer height 0.10 → 0.08, or raise RELIEF_MM to 6.5 |
| Halos around trees and figures | raise GUIDED_RADIUS to 8–10; lower GUIDED_EPS to 1e-3 |
| Surface looks noisy / speckled | lower DETAIL_GAIN; raise DETAIL_SIGMA_PX to 4 |
| Branches and small figures invisible | raise DETAIL_GAIN to 0.2; enable USE_TILED_REFINE |
| Depth map looks like nonsense | try INFER_LONG_SIDE = 728; try the Base model; check CROP |
| Visible grid seams in the refined depth | raise TILE_OVERLAP to 240 |
| Hard cliff around the panel border | your CROP is leaving frame in — check 00_source.png |
| STL reports "not watertight" | you have NaNs in the heightfield; check np.isfinite(d).all() after conditioning |
| Corners lift off the plate | brim 5 → 8 mm; slow first layer to 20 mm/s |
| Panel cups after cooling | raise BASE_MM to 3.0 |
What the failures look like
The table above is indexed by symptom, which only helps if you can recognise the symptom. Here is each one, produced deliberately from the same depth map. The whole panel for the failures that are global:
And the tree crop for the ones that live in the fine detail, where a whole-panel view would show you nothing:
Two things stand out across the set. The global failures — wrong α, wrong polarity, over-equalised — are obvious at a glance once you have seen them once. The fine ones are not: DETAIL_GAIN = 0 and GUIDED_EPS = 0.1 both simply look a little soft until you have something sharper beside them. That is why §9 says to keep your previews rather than judge one against a memory.
12. The sculpt branch — tested, and set aside
Print the recipe above and live with it for a day, and one criticism arrives on its own: the panel is a drawing, not a carving. The linework is all there — hunters, dogs, the inn sign — but nearly half the height budget is spent on an invisible floor-to-horizon ramp, and the objects stand barely a millimetre proud of it. Under a raking lamp it reads well; under ordinary room light it goes quiet. This section documents the branch we built to fix that, what it genuinely bought, and the printed evidence that made us fold it back up. The code is complete, so you can walk the same branch yourself; the printed results below show why we stepped back.
Three ideas, three functions
First: stop spending height on the ground. A rolling-ball lower envelope (a grey erosion, smoothed) hugs the terrain from below; subtract most of it and objects keep their full height while the ramp they stand on drops out. The erosion matters: a Gaussian estimate of the background overshoots near tall objects and digs moats around them — we measured −0.5 to −0.9 mm of moat before switching. An envelope that can never rise above the surface cannot halo.
def suppress_background(h, window_px, smooth_px, amount=0.9):
"""Subtract the rolling-ball lower envelope, so objects keep their full
height while the ground they stand on drops out. The envelope can never
rise above the surface, so unlike a Gaussian estimate it cannot halo."""
if amount <= 0:
return np.array(h, dtype=np.float64)
w = max(int(window_px), 3)
bg = gaussian_filter(grey_erosion(h, size=(w, w)), smooth_px)
d = h - amount * bg
lo, hi = np.percentile(d, [0.1, 99.5])
d = np.clip((d - lo) / max(hi - lo, 1e-12), 0.0, None)
m = d > 0.85
d[m] = 0.85 + 0.15 * np.tanh((d[m] - 0.85) / 0.15) # soft knee: no flat mesas
return np.clip(d, 0.0, 1.0)
Second: compensate perspective. Bruegel painted the distant skaters small, so
the depth model gives them proportionally small structure — measured on the conditioned
field, an object on the far ponds got 0.4 mm of relief where a foreground dog got 0.9. A
carver would compress the scene’s depth but keep distant figures carved proud, so
this stage lifts object-scale structure in proportion to farness. Third: replace
the final norm01 with a normalisation anchored at both ends — we found a single
spike was quietly eating 15 % of the height budget.
def perspective_boost(d, depth_cue, gain, radius_px=8, eps=4e-3, limit=0.04):
"""Distant objects are painted small, so their relief comes out small too.
Lift object-scale structure in proportion to farness, the way a relief
carver does. The self-guided filter keeps big edges out of the band (no
halos); the positive-only band lifts objects without digging moats."""
if gain <= 0:
return np.array(d, dtype=np.float64)
far = 1.0 - norm01(gaussian_filter(depth_cue, 10.0))
lp = guided_filter(d, d, radius_px, eps)
band = gaussian_filter(np.tanh((d - lp) / limit) * limit, 1.0)
band = np.maximum(band, 0.0)
return np.clip(d + gain * far * band, 0.0, 1.0)
def finish_range(d, p_lo=0.1, p_hi=99.8, knee=0.90):
"""Robust final normalisation: anchored at BOTH ends by percentiles, so a
lone spike cannot eat the height budget and the low tail cannot clip into
dead-flat plate. Soft knee above, nothing slams into 1.0."""
lo, hi = np.percentile(d, [p_lo, p_hi])
d = (d - lo) / max(hi - lo, 1e-12)
m = d > knee
d[m] = knee + (1.0 - knee) * np.tanh((d[m] - knee) / (1.0 - knee))
return np.clip(d, 0.0, 1.0)
The wiring, and the knobs it adds:
# config.py — the branch's knobs
RELIEF_MM = 8.0 # up from 5: volume needs headroom
BG_SUPPRESS = 0.9 # how much of the lower envelope to remove, 0..1
BG_WINDOW_MM = 20.0 # masses wider than this count as background
BG_SMOOTH_MM = 10.0 # envelope smoothing
FAR_BOOST = 2.5 # perspective compensation. 0 = off
# main.py — wiring, after 04_compressed and before add_detail
depth_cue = d.copy() # snapshot BEFORE the guided filter
...
if C.BG_SUPPRESS > 0:
d = hf.suppress_background(d, round(C.BG_WINDOW_MM / C.PITCH_MM),
C.BG_SMOOTH_MM / C.PITCH_MM, C.BG_SUPPRESS)
if C.FAR_BOOST > 0:
d = hf.perspective_boost(d, depth_cue, C.FAR_BOOST)
...
d = hf.finish_range(hf.add_detail(d, luma, C.DETAIL_GAIN, C.DETAIL_SIGMA_PX))
What it bought — on screen
Real volume, honestly won. Object-scale relief roughly doubled everywhere it mattered (hunters 0.67 → 0.92 mm, far skaters 0.43 → 0.67 mm), the recession ordering survived, the mesh stayed watertight, and the preview looked like this:
06_preview_lit.png: sculpted masses, standing
figures, full linework. Every number checked out, and the panel went to the printer.What the plastic said
BASE_MM = 3.0 and a brim.The lesson: the preview lies, the nozzle is a filter
None of this is visible in the hillshade, because a hillshade renders whatever frequencies the array contains. A 0.4 mm nozzle does not. An extruded bead has a minimum width and molten PLA has surface tension, so the printer is a mechanical low-pass filter: raised detail much finer than a millimetre does not print small, it melts into beads. The branch’s boost amplified exactly those frequencies — it made the unprintable taller. You can see this before wasting a print by simulating the nozzle: close then open the height field with a disk the size of the bead (0.21 mm radius at this pitch), blur ~0.15 mm for melt flow, quantise to the layer height, and judge that:
Two design rules fall out, and they point at a different branch than this one. First, the asymmetry: raised fine lines melt, but recessed fine lines print crisply — a groove is negative space and has no bead to collapse. Bruegel’s drawing wants to be engraved (intaglio), not embossed. Second, a carver’s discipline: round the masses instead of extruding silhouettes, keep texture off steep flanks, and simplify distant content into clean small forms rather than amplifying detail the bead cannot articulate. That branch — carve for the nozzle, not the preview — is where the project goes next; this section records the experiment that showed why.
13. Carve for the nozzle — the branch that worked
This chapter covers the revision built around §12’s two design rules and the printer’s physics, the print it produced — the best from this pipeline so far — and one discovery its remaining flaws forced, which had been hiding in the maths since the very first print. The code below is the current state of the project files; where it differs from the recipe of §4–8, this chapter supersedes it.
The five rules, as five stages
Simplify what is far. §12’s failed branch amplified distant detail; a carver does the opposite — distant content becomes clean small masses. Weighted by the same depth cue the boost used, applied only where the scene is genuinely far:
def simplify_far(d, w, far_px):
"""A carver simplifies distant content into clean small masses instead of
amplifying detail the bead cannot articulate."""
return gaussian_filter(d, 0.6) * (1 - w) + gaussian_filter(d, far_px) * w
def far_weight(depth_cue):
"""0 in the foreground, 1 in the far distance. Simplification applies
only where the scene is genuinely far (cue above 0.65)."""
far = 1.0 - norm01(gaussian_filter(depth_cue, 10.0))
return _smoothstep((far - 0.65) / 0.30)
Round only the columns. Slopes steeper than 62° get clamped and the surface rebuilt through the same Poisson solver §6 uses — but only for steep regions big enough to be trunks or walls. That size test matters more than it looks; the first version rounded everything, and the cost of that appears later in this chapter:
def limit_slope(d, relief_mm, pitch_mm, max_deg):
"""Round the columns: clamp slopes above max_deg and rebuild the surface
(Poisson), blending the fix in only around the steep zones so the rest of
the panel is untouched."""
gmax = np.tan(np.radians(max_deg)) * pitch_mm / relief_mm
gx = np.zeros_like(d)
gy = np.zeros_like(d)
gx[:, :-1] = d[:, 1:] - d[:, :-1]
gy[:-1, :] = d[1:, :] - d[:-1, :]
m = np.hypot(gx, gy)
steep = _big_components(m > gmax) # figures keep crisp silhouettes
s = np.where(steep, gmax / np.maximum(m, 1e-12), 1.0)
recon = norm01(poisson_from_gradients(gx * s, gy * s))
blend = _smoothstep(gaussian_filter(steep.astype(float), 3.0) / 0.25)
return d * (1 - blend) + recon * blend
def _big_components(mask, min_area_px=150):
"""Keep only connected steep regions big enough to be columns or walls.
A figure's outline is a thin small ring; a trunk is hundreds of pixels."""
lab, n = label(mask)
if not n:
return mask
sizes = np.bincount(lab.ravel())
keep = sizes >= min_area_px
keep[0] = False
return keep[lab]
Raise only what the bead can articulate. The luminance detail splits into a mid-scale forms band (2–6 mm wide — the figures’ bodies, comfortably printable) and fine lights capped at 0.3 mm, below the height at which raised lines melt into beads. Neither goes on steep flanks — that is what printed as veins in §12:
def raise_forms(d, luma, w, relief_mm, pitch_mm, forms_mm, emboss_mm, sigma):
"""Raised content the bead CAN articulate: mid-scale luminance forms
(2-6 mm wide -- the figures' body) plus fine lights capped well below the
height at which raised lines melt. Nothing goes on steep flanks."""
band = gaussian_filter(luma, sigma) - gaussian_filter(luma, 8.0)
band = np.tanh(band / (np.percentile(np.abs(band), 99) + 1e-9))
d = d + (band * forms_mm * slope_mask(d, relief_mm, pitch_mm, 30, 55)
* (1 - 0.5 * w) / relief_mm)
hp = luma - gaussian_filter(luma, sigma)
hp = hp / (np.percentile(np.abs(hp), 99) + 1e-9)
up = np.tanh(np.clip(hp, 0.0, None))
return d + (up * emboss_mm * slope_mask(d, relief_mm, pitch_mm, 25, 45)
* (1 - 0.5 * w) / relief_mm)
Engrave the drawing. The dark linework is cut into the surface instead of raised from it — a groove is negative space, and has no bead to collapse. Strong lines cut deeper than faint ones (that asymmetry turns out to be essential), a groove may never take more than 70 % of the local height, and only the big steep walls are off-limits — a small figure’s flank is exactly where its drawing lives:
def engrave_lines(d, luma, w, relief_mm, pitch_mm, engrave_mm, sigma):
"""The dark drawing, cut IN. Raised fine lines melt on the nozzle;
recessed lines print crisply (a groove is negative space -- no bead to
collapse). Grooves are widened to at least the bead, and a groove never
takes more than 70% of the local height, so it cannot reach the plate."""
hp = luma - gaussian_filter(luma, sigma)
hp = hp / (np.percentile(np.abs(hp), 99) + 1e-9)
dn = grey_dilation(np.clip(np.clip(-hp, 0.0, None) * 1.2, 0.0, 2.0), size=(2, 2))
gy, gx = np.gradient(d * relief_mm, pitch_mm)
walls = _big_components(np.degrees(np.arctan(np.hypot(gx, gy))) > 62.0)
m_eng = 1.0 - _smoothstep(gaussian_filter(walls.astype(float), 2.0) / 0.4)
cut = dn * engrave_mm * m_eng * (1 - 0.2 * w) / relief_mm
return np.clip(d - np.minimum(cut, 0.7 * d), 0.0, 1.0)
def fine_band(a, sigma_px=1.5):
"""The fine structure of a field -- used to restore silhouette edge cores
that gradient-domain range compression flattens."""
return a - gaussian_filter(a, sigma_px)
And judge the simulation, never the preview. The gate from
§12, now a pipeline checkpoint. 07_print_sim.png is the image to trust; the raw
hillshade shows frequencies the nozzle cannot make:
def nozzle_preview(d, relief_mm, pitch_mm, layer_mm=0.08):
"""What the printer will actually make of this field: bead-width
morphology, melt blur, layer quantisation. Judge THIS, never the raw
hillshade -- the raw preview renders frequencies the nozzle cannot."""
r = max(1, round(0.21 / pitch_mm))
k = np.zeros((2 * r + 1, 2 * r + 1))
yy, xx = np.mgrid[-r:r + 1, -r:r + 1]
k[yy * yy + xx * xx <= r * r + 0.5] = 1
s = grey_opening(grey_closing(d * relief_mm, footprint=k), footprint=k)
s = gaussian_filter(s, 0.15 / pitch_mm)
return np.round(s / layer_mm) * layer_mm
The knobs and the wiring, current state:
# config.py — the chapter’s knobs (DETAIL_GAIN is gone; sigma stays)
BG_SUPPRESS = 0.8 # envelope subtraction, eased from 0.9
FAR_SIMPLIFY_MM = 0.9 # distant forms smooth into clean masses at this scale
MAX_SLOPE_DEG = 62.0 # round the columns: no wall steeper than this
FORMS_MM = 0.8 # raised mid-scale forms (2-6 mm wide, bead-safe)
EMBOSS_MM = 0.3 # raised fine lights, capped below what melts
ENGRAVE_MM = 0.8 # the dark drawing, cut in as grooves
SILHOUETTE_RESTORE = 0.7 # re-sharpen object edges that compression flattens
BASE_MM = 3.0 # backing plate; 3 mm resists the corner curl
# main.py — the wiring
pre = d.copy() # pre-compression edges, for silhouette restore
d = hf.norm01(hf.compress_range(d, C.COMPRESS_ALPHA))
d = d + C.SILHOUETTE_RESTORE * hf.fine_band(pre)
...
far_w = hf.far_weight(depth_cue)
d = hf.simplify_far(d, far_w, C.FAR_SIMPLIFY_MM / C.PITCH_MM)
d = hf.limit_slope(d, C.RELIEF_MM, C.PITCH_MM, C.MAX_SLOPE_DEG)
...
d = hf.raise_forms(d, luma, far_w, C.RELIEF_MM, C.PITCH_MM,
C.FORMS_MM, C.EMBOSS_MM, C.DETAIL_SIGMA_PX)
d = hf.finish_range(d)
d = hf.engrave_lines(d, luma, far_w, C.RELIEF_MM, C.PITCH_MM,
C.ENGRAVE_MM, C.DETAIL_SIGMA_PX)
...
sim = hf.nozzle_preview(d, C.RELIEF_MM, C.PITCH_MM)
save_png(C.OUTDIR / "07_print_sim.png", ...) # judge THIS one
The print
The discovery the failure forced
Two flaws survived: the third hunter printed as a mound, and the fire and inn at the left edge were less pronounced than the flat first print had managed. The reflex is to reach for amplitude — but measurement said both zones carry more height than before. What collapsed was edge sharpness. Tracing one number — the 99th-percentile edge slope at the hunter — through the pipeline, stage by stage:
| after stage | edge slope, mm/mm |
|---|---|
| guided filter | 7.40 |
| equalize | 6.96 |
| compress_range (α = 2) | 1.76 |
| background suppression | 3.82 |
Gradient-domain range compression flattens object silhouettes four-fold, and always has. A silhouette is a large gradient, and attenuating large gradients is the stage’s entire job. Every earlier version hid this: §8’s recipe restored the edges by accident, through its raw signed detail cutting hard at every outline, and §12’s boost did it too. This version’s carefully capped and masked channels were the first to leave the flattening exposed — the third hunter, a solid dark figure with no interior drawing, had nothing left but his silhouette, and the pipeline had sanded it off.
Three changes, shipped as the current file state. SILHOUETTE_RESTORE adds back
the fine band of the pre-compression field — depth-derived, so it restores edge
cores rather than luminance noise. The slope limiter got the size test shown above, so figures
keep crisp silhouettes while trunks stay rounded. And the engraving lost its depth saturation:
the strongest contour lines now cut up to twice the nominal depth, which is the mechanism the
first print had been using all along, unintentionally.
Print-settings delta
Relative to §10: BASE_MM = 3.0 (2 mm cupped under 8 mm of
relief), a 4 mm brim — the plate allows no more around a 170 mm panel —
and everything else unchanged. One brim lesson from this print: two corners separated from
the brim mid-print. The brim–object gap is a deliberately weak seam; on a long print
the corner shrinkage peels it open. Set the gap to 0 (the brim then trims off with a blade
instead of snapping), keep the bed at 65 °C, glue-stick the corners, and keep the
printer out of draughts. The raft is the reserve weapon if a corner still lifts.
14. Where this generalises
The only painting-specific thing in this pipeline is the depth model's photographic prior. Swap the input and it still works:
- Photographs — better, actually. The model is on home turf.
- Design documents, CAD drawings, PCB layers — skip steps [A] and [B] entirely. These have explicit Z structure; parse it and feed the heightfield straight into step [C].
- Real terrain — swap [A] for a GeoTIFF read. Set
COMPRESS_ALPHAlow (1–2) since the elevation is honest and you probably want it faithful. - Abstract or heavily stylised art — this is where monocular depth estimation falls apart. The model assumes it's looking at a photograph of a physical scene. For a Rothko you'd be better served by the luminance-as-height route, i.e. a lithophane — which is exactly what the wrapped branch ends up building.
And it generalises in a second direction: not to other inputs but to more dimensions of output. The route from this relief toward a genuine sculpture — deep relief, layered dioramas with real parallax, figures generated in the round — is mapped in a companion page, From Relief to Sculpture (Branch B).
And in a third direction: keep the heightfield but change the surface it lives on. From Panel to Cylinder wraps this same conditioned field around a cylinder to make a carved column and a lithophane lamp — and §7 of that page is the one place in this project where a printed object overturned a prediction the pipeline was confident about.
If you would rather skip the pipeline and just make one, the lamp half of that branch runs as a single web page: painting2lamp — drop in a painting, get the STL. No install, no server, and the image never leaves your machine.
References
- Weyrich, Deng, Barnes, Rusinkiewicz, Finkelstein (2007), Digital Bas-Relief from 3D Scenes — the gradient-domain compression construction in §6.2.
- He, Sun, Tang (2010), Guided Image Filtering — §6.1.
- Yang et al. (2024), Depth Anything V2 — the model in §5. A plain-language primer on how it works, is trained and is scored: How the depth model works.
- From Panel to Cylinder — the same heightfield on a cylindrical base surface: a carved column, a mirrored lithophane lamp, and the print measurements that decided which face the relief goes on.
- painting2lamp — that lamp shell as a browser app: image in, STL out, offline. A port of the reference code, checked against it to 1.1e−5 mm.
- Bambu Lab A1 mini: 180 × 180 × 180 mm build volume, 0.4 mm stock nozzle.