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.

Pieter Bruegel the Elder, Hunters in the Snow (Winter), 1565
the paintingPieter Bruegel the Elder, Hunters in the Snow (Winter), 1565. Oil on wood, 162 × 117 cm, Kunsthistorisches Museum, Vienna. Google Art Project scan, public domain.
the printed bas-relief panel
the printThe carve-for-the-nozzle print: 170 × 121 mm, 8 mm relief on a 3 mm base, 0.08 mm layers.

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

WhatRequirementNotes
ComputerApple 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 printerFDM, 0.4 mm nozzle, plate ≥ 175 × 125 mmtested 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 panelprints shown here used matte PLA, dried, at 210–215 °C
Disk & time~2 GB, half a daymodel 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:

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 × 4853aspect 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.

config.py — header and input/output
"""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. Path rather than plain strings so that IMAGE.parent, IMAGE.stem and the / join operator below all work without any string surgery, and so main.py can 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, and Path will 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 FileNotFoundError on 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 / is pathlib's join operator, not division — Path defines 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.py calls .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.py computes int((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) in main.py skip the crop entirely.
config.py — depth model

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.npz is 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 -hf suffix matters: it is the transformers-native conversion, which is what AutoModelForDepthEstimation can load. The bare Depth-Anything-V2-Large repo holds the original .pth checkpoint 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 — _round14 in depth.py enforces 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 False for the first run and while tuning — it costs 12 inferences instead of 1 — and back to True for 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.py rescales 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 False to 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.
config.py — heightfield conditioning

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_filter is 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.py now 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).
config.py — physical print

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.
config.py — preview lighting
# ---- 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.

depth.py — imports, device pick, cached model load
"""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 infer and 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.py defers 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.interpolate at the end of infer. 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 Auto classes 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 swapping MODEL_ID for 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 cpu fallback 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 what PYTORCH_ENABLE_MPS_FALLBACK=1 is for.
def load(model_id, device):
Fetch-and-cache. Called by infer on 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_ID mid-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's t0, 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=True is 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 the pipeline() 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_hub draws 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.
depth.py — infer(): choosing the input size

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_refine call 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.interpolate line 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_affine exists 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 INVERT flag, the 01_depth_global.png checkpoint and the automatic polarity verdict in main.py all 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 as w, h every time.
if w >= h:
Which side is longer decides which one long_side applies 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_refine passes square crops, where w >= h holds and the first branch runs.
size = {"height": _round14(long_side), "width": _round14(long_side * w / h)}
The mirror image of the landscape case.
depth.py — infer(): run the model, resample to the input grid

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. size overrides its built-in default, which is the whole reason §5.1 rejects the pipeline() one-liner — the pipeline gives you no control over the resolution the model actually sees. do_rescale=True maps 0–255 to 0–1 before the mean/std normalisation; pass an already-scaled float array and you must set it False or 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 plain BatchFeature mapping, 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_depth is the float tensor at the model's internal resolution. §5.1 contrasts it with the pipeline's result["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.interpolate below 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 transformers releases, 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. bicubic rather than bilinear because the result is about to be differentiated by compress_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=False is 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 under inference_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:

  1. Affine-align each crop to the global pass. Least-squares fit a·local + b ≈ global over the crop's footprint. Now every crop lives in the global pass's units.
  2. 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.
  3. 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.

depth.py — the 2D Hann window
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 for n + 2 and 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.outer makes the 2D window as the product of two 1D ones, which is what “separable” means here. And the maximum(…, 1e-6) floor guarantees the weight accumulator wacc can never be zero at any pixel, so the acc / wacc division at the end cannot produce a NaN even in a corner covered by exactly one tapering crop.
depth.py — least-squares affine alignment

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 a and b in a·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=None opts 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.
depth.py — tile positions along one axis

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 sets TILE_OVERLAP greater than or equal to TILE.
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))
set removes the duplicate when the flush position happens to coincide with a regular one; sorted restores order after the set destroys it. For this image the two axes give 4 and 3 positions, hence the 12 tiles §5.4 quotes.
depth.py — tiled_refine(): setup

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=print is dependency injection in its smallest form: pass log=lambda *a: None for 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 CROP cannot 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 ys uses H and xs uses W — 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. total is 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.
depth.py — tiled_refine(): the loop and the frequency merge

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 tile pixels 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 _hann2d floors the window at 1e-6 rather than letting it reach zero, and because _positions guarantees 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: merged minus its own blur is exactly the detail the blur removed. The result is lowpass(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.

g′ = sign(g) · log(1 + α|g|)α2h = ∇ · g′

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:

α = 0
α = 0Uncompressed. The trees and the near bank hold nearly all the height.
α = 2
α = 2The middle distance starts to arrive. Small figures become legible.
α = 4
α = 4The guide's default. Texture and form in balance.
α = 6
α = 6Further still — more surface, less depth story.
α = 15
α = 15Past the point of no return. Pure texture; the recession is gone.
alpha sweep, whole panel
The whole panel at α = 4, for scale. The crops above are the strip of trees at the upper left.

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.

heightfield.py — imports
"""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 in depth.py and 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.fft rather than the older scipy.fftpack — same algorithm, better multithreading and a saner interface.
from scipy.ndimage import uniform_filter, gaussian_filter, zoom
Three separable filters. uniform_filter is a box mean and powers the guided filter; gaussian_filter supplies the low-pass in add_detail; zoom does spline resampling in resize_to. All three are separable, so their cost is linear in pixels and — for the box filter — independent of the window size, which is why GUIDED_RADIUS is free to raise.
heightfield.py — utilities

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)
asarray rather than array: it does not copy when the input is already a float64 ndarray, so calling norm01 six 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=3 is bicubic spline interpolation — smooth enough not to introduce staircase artefacts into a field you are about to differentiate, which is exactly what compress_range does to it a moment later. Dropping to order=1 (bilinear) would put visible facet edges into the relief.
zy, zx = shape[0] / a.shape[0], shape[1] / a.shape[1]
zoom takes 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 after main.py has 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 zoom call. Because the scale factors are floats, zoom rounds 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 inside add_detail with an error message that names neither function.
heightfield.py — guided_filter()

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. src is the thing being filtered (the soft depth map); guide is 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 carries src's values but guide'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 of a and b per 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_filter is 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 why GUIDED_RADIUS is 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)
I and p are 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 var this is everything a per-window linear regression needs.
a = cov / (var + eps)
The regression slope, and the line where GUIDED_EPS earns its keep. Where the guide has a real edge, var is large, eps is negligible, and a approaches cov/var — the output tracks the guide and inherits its edge. Where the guide is flat, var is near zero, eps dominates, a collapses toward 0, and the output becomes just the local mean of p — 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 p exactly. 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 box calls are for, and it is what makes the output continuous instead of blocky. Note the shape of the result: values from p, boundaries from I. §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.
heightfield.py — the Poisson solve

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_range can 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_rim does 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_range produced gx with 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=2 is the standard DCT-II; norm="ortho" makes the transform its own inverse up to the matching idctn, 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 — norm01 runs immediately after this in main.py — but leaving the garbage value from the fake eigenvalue in place would produce an enormous DC term and a norm01 that 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.
heightfield.py — compress_range()

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 = 0 to see the uncompressed relief for comparison, so this path is used deliberately, not just defensively.
return np.array(h, dtype=np.float64)
np.array rather than asarray — a genuine copy. Every early return in this file copies, so callers can never accidentally alias and mutate their input. The one exception is apply_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.diff returns 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. hypot rather than sqrt(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 alpha means the same thing on every image. Gradients are divided by their own mean magnitude before the log is applied, so COMPRESS_ALPHA = 4 behaves identically whether the depth map spans 0–1 or 0–1000. Without this line, alpha would 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×. log1p rather than log(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.
heightfield.py — equalize()

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()
ravel flattens 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)
empty rather than zeros: 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. order lists 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 EQUALIZE is 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.
heightfield.py — add_detail()
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_PX sets 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_GAIN readable 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.py wraps the call in norm01 for exactly that reason.
heightfield.py — apply_rim()

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 = 0 lets 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.py assigns 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 the t line.
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. arange counts 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 t is 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.
heightfield.py — hillshade()

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.gradient returns 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 sin on x and cos on 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 norm01 downstream 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.

selftest.py — the scene, and how it is measured
"""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.py imports 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]. mgrid is the slice-syntax cousin of meshgrid; dividing by N - 1 rather than N makes the last row and column land exactly on 1.0. Only xx and yy as 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 by np.ones((N, 1)) broadcasts it down every row. Building it from an integer comparison rather than from xx means 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. step is ground truth; soft is 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_rng rather than the legacy np.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.
selftest.py — [1] the Poisson round-trip
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_range is built directly on the solver.
gx = np.zeros_like(scene); gx[:, :-1] = np.diff(scene, axis=1)
Forward differences, written exactly as compress_range writes 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_gradients or _laplacian_eigenvalues.
selftest.py — [2] the guided filter
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.py uses, 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.
selftest.py — [3] range compression
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.py does 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.
selftest.py — [4] the shaping helpers
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 = 0 and t = 1, where t*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 isfinite half is the one that matters: a NaN here would sail through save_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.

mesh.py — imports
"""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_watertight and volume, and writing binary STL. §2.3 notes what was deliberately not imported — no numpy-stl, no open3d — because trimesh covers all three and the alternatives each bring wheel-compatibility problems on arm64.
mesh.py — heightfield_to_mesh(): the top surface

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.py wraps almost every conditioning step in norm01. The row-0-is-top note is the one that prevents an upside-down print, and it is honoured on the ys line below.
H, W = h01.shape
Rows then columns, i.e. height then width. The reversal against the width_mm, height_mm argument 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. linspace is inclusive at both ends, so the first and last columns land exactly on 0 and width_mm — the panel is exactly the size you asked for, and the spacing works out to width_mm/(W-1), a hair under PITCH_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_MM and RELIEF_MM actually 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 survived apply_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=1 makes each row an (x, y, z) triple rather than each column, which is the layout trimesh expects. ravel flattens 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 ravel flattened, idx[r, c] is exactly the row of top holding that sample. Slicing idx now 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: a is every cell's top-left, b top-right, c bottom-right, d bottom-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 db diagonal, 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.
mesh.py — heightfield_to_mesh(): the boundary ring and floor

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::-1 form 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 -1 because trimesh face indices must be non-negative.
mesh.py — heightfield_to_mesh(): walls, floor fan, assembly

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 just ring, written this way so the pairing with t1 reads symmetrically.
f0, f1 = n_top + k, n_top + k1
The two floor vertices directly beneath them. Because floor was built from ring in the same order, adding n_top is 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_normals will repair it if a particular geometry defeats the assumption.
np.stack([t0, f1, t1], axis=1),
Second wall triangle, sharing the t0f1 diagonal 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. Note f1 before f0 — 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_consistent comes back true.
return mesh
A watertight solid, verified rather than assumed on the next few lines.
mesh.py — validate()

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, and norm01'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_normals ran. 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 -90000 tells you the mesh is inside-out; a volume of 0 tells you it is flat. Same check, two very different diagnoses.
return problems
An empty list means everything passed, which is why main.py can write if 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.

main.py — imports and a PNG helper
"""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() or pickle because 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.py is orchestration, so the array work is delegated to heightfield. 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. trimesh comes 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 DecompressionBombError rather 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 clip is load-bearing rather than decorative: add_detail can 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.
main.py — the cache signature

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_ALPHA twenty 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 .npz file, 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. work is the derived working size, which folds in WIDTH_MM, PITCH_MM and 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 — 1 and True would 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 infer or tiled_refine is 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.
main.py — the polarity check

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.png shows.
"""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 spread makes 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.
main.py — stage 1: load, crop, size

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=True also creates intermediate directories; exist_ok=True makes a second run a no-op rather than an error. This is the line that turns the OUTDIR path 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.open is 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 t shadows nothing here, but it is a different t from t0 above — 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 - fraction because 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 CROP is 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. round before int rather 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_MM costs 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_h will 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. LANCZOS is 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 CROP is 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 for luma later, because this image is only ever for checking framing. Dividing by 255 puts it in the [0, 1] range save_png expects.
main.py — stage 2: is the cache still good?

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. .npz because 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. None means “no depth map yet”, and the second block's if d is None is 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=False is the security-conscious default and worth keeping. Pickle can execute arbitrary code on load, so refusing it means a corrupted or hostile .npz can 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 sig key 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.py free 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.
main.py — stages 2–3: otherwise, run the model

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 depth pulls in torch and transformers, which costs seconds even when unused — so a cached run, which never enters this branch, never pays it.
dev = pick_device()
mps or cpu, 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. norm01 is 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_MM or WIDTH_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_compressed rather than savez takes 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.
main.py — stage 4: conditioning

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 from work. 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 d is normalised to [0, 1] on the line above. Doing it here rather than later means every checkpoint from 03 onward 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 norm01 wrapper 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 norm01 wrapper here, because equalize normalises internally on its way out — one of the few functions in heightfield.py that 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 norm01 the 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_ALPHA is too low.
d = hf.norm01(hf.add_detail(d, luma, C.DETAIL_GAIN, C.DETAIL_SIGMA_PX))
The same luma computed once above, reused. add_detail adds 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 no norm01 after 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_MM and 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.
main.py — stage 5: mesh and export
    # ---- 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_mm is 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 problems reads 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"
.stem is the filename without its extension; the 40-character truncation keeps the output name manageable, since the Google Art Project name is 76 characters. Yours becomes Pieter_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.
main.py — entry point
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 main from 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.

00_source
00_sourcethe working image, greyscale, at the print grid
01_depth_global
01_depth_globalone pass over the whole painting. Bright = near
02_depth_refined
02_depth_refinedafter twelve tiled crops. Same structure, finer detail
03_guided
03_guidededges snapped onto the painted edges, flats smoothed
04_compressed
04_compressedflatter and busier — the middle distance has appeared
05_heightfield
05_heightfieldplus texture and the rim fade. The printed height
06_preview_lit
06_preview_litthe same field under a raking lamp, the image to judge by

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.

the printed panel
the panelBruegel’s composition survives the trip: trees, hunters, the valley, the mill. Under a straight-on light it reads quietly; a raking lamp wakes it up.
close-up of the hunters corner
the hunters, closethe drawing arrives as fine embossed linework — hunters, dogs, the inn sign. At this scale the panel is more engraving than sculpture: legible, delicate, and shallow. Deeper volume is possible, but it is a different set of trade-offs than this guide’s recipe makes.

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.pngthe 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.pngjudge 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 0246
0.000.0130.2820.3800.440
0.550.0980.4770.5960.664
1.000.2300.6300.7520.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

  1. Set DETAIL_GAIN = 0 first. 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.
  2. 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).
  3. 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.
  4. Then bring DETAIL_GAIN back, 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 — raise DETAIL_SIGMA_PX to 4 and try again.
  5. Re-check under a second light angle. Set LIGHT_AZIMUTH_DEG to 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°
    315°The default, and the convention your eye expects.
    45°
    45°From the upper right. Everything that read as a ridge now reads as a groove.
    135°
    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:

FieldIslands per layerMedian island
smooth, no fine detail~449 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:

Distancea = 10000500020001000500 mm/s²
0.2 mm — one small island4532201410
1.55 mm — a typical island's path12488 563928
10 mm run31622414110071
170 mm — a full pass1304922583412292

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 accelerationTime for the top surface, 50 layersCost
10000 mm/s²13 min
500018 min+5 min
200029 min+16 min
100041 min+28 min
50059 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_GAINDETAIL_SIGMA_PXIslands
0.122.55,030  (the defaults)
0.084.03,824
0.002.55,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 layers3,779 cm²
Area in islands too small to extrude2.6 cm²
Fraction of material lost0.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 percentile0 µm
99th percentile3 µm
99.9th percentile43 µm
Worst single point on the panel332 µm
Panel more than one layer (0.10 mm) low0.01 %
The nozzle's own XY floor, for scale420 µ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:

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:

COMPRESS_ALPHA = 0
COMPRESS_ALPHA = 0A flat wall with a foreground silhouette stuck on it. The trees and the near bank hold all 5 mm; the village and the valley share almost nothing. This is the failure §0 opens with.
COMPRESS_ALPHA = 15
COMPRESS_ALPHA = 15Uniform mush. Every gradient is crushed to the same magnitude, so texture is all that is left and the depth story is gone.
INVERT wrong
INVERT wrongForeground sunken, mountains raised. The whole scene reads as a mould of itself. This is what the polarity check in §5.2 exists to catch before you print it.
EQUALIZE = 1.0
EQUALIZE = 1.0Pressure-flattened. Every depth band gets equal height whether it deserves it or not, so the recession stops being believable.

And the tree crop for the ones that live in the fine detail, where a whole-panel view would show you nothing:

your settings
your settingsα = 2.0, EQUALIZE 0.5, DETAIL_GAIN 0.5 — for comparison.
DETAIL_GAIN = 0
DETAIL_GAIN = 0Pure depth. Soft and a little lifeless — the branches are implied by form alone, with no surface texture to catch the light.
DETAIL_GAIN = 0.90
DETAIL_GAIN = 0.90Speckled. The luminance high-pass now dominates the depth, and flat snow that should be smooth is covered in noise.
GUIDED_EPS = 0.1
GUIDED_EPS = 0.1Halos. With the regularisation this high the filter smooths across edges instead of snapping to them, so depth boundaries drift off the painted ones.

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:

the sculpt branch preview
The sculpt branch’s 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

the printed sculpt branch
the printthe volume is real — and everything reads soft, like a relief cast in warm wax. The corners also curled: 8 mm of relief on a 2 mm base wants BASE_MM = 3.0 and a brim.
hunter devolved into a blob
the huntercircled: a figure that is perfectly crisp in the preview above, printed as a shapeless mound. His internal drawing was raised linework finer than the nozzle.
tree trunks with vein artefacts
the trees, from the sidesilhouette-extruded trunks became columns, and the luminance detail on their steep flanks printed as cords — veins running down alien pillars.

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:

hillshade preview versus nozzle simulation
The same hunter, same height field. Left: the hillshade we judged. Right: the nozzle simulation — which matches the photograph above, blob for blob. Every candidate field should pass this gate before it reaches the slicer.

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 v4 print
The carve-for-the-nozzle print: 170 × 121 mm, 8 mm relief on a 3 mm base, 0.08 mm layers. Real volume and a readable drawing, in ordinary room light.
the magpie
the magpie§12’s blob, now a bird. Simplification and the engraved drawing, cooperating.
the water wheel
the millwheel, bridge and village legible — the slicer preview had predicted worm-mush here — the sliced view exaggerates fine detail too, so judge the simulation instead.
the third hunter as a mound
the third hunterthe chapter’s failure: an undifferentiated mound with legs. Explanation below.
trees from the side
from the siderounded masses, no veins, no alien columns. The slope rules did their job.

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 stageedge slope, mm/mm
guided filter7.40
equalize6.96
compress_range (α = 2)1.76
background suppression3.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.

third hunter before and after, simulated
The third hunter in simulated plastic: the printed version (left) against the revised pipeline (right) — head, gun over the shoulder, legs. Not yet printed.

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:

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