The Poisson solve
a side note to Painting → Bas-Relief, on how you get a surface back
out of a pile of slopes
Everything here is checkable arithmetic. No calculus is assumed.
0. The problem in one picture
The compression step in §6.2 hands you a field of slopes — for every pixel,
how much the height should change going right, and how much going down. What it does not hand you is
a surface. Getting from one to the other is the entire job of
poisson_from_gradients.
In 1D that is trivial. In 2D it is not, for one specific reason, and the Poisson solve is the fix.
1. One dimension, where it is easy
Take five heights. The slopes between them are just the differences:
heights 0 2 3 1 4
slopes +2 +1 -2 +3
cumulative sum from 0:
0 2 3 1 4 <- the heights, exactly
To go back the other way you add the slopes up as you walk along — a cumulative sum. Start at 0, add 2, add 1, subtract 2, add 3, and you are back where you started from.
One thing is permanently lost. The slopes never told you how high the first point was. Start your
walk at 100 instead of 0 and every height comes out 100 higher, with exactly the same slopes. This is
the + C from school integration, and it never goes away — in the code it
is the reason for h_hat[0, 0] = 0.0, which simply picks the version whose average is
zero. For a relief it does not matter at all: norm01 renormalises straight afterwards.
2. Two dimensions, where it breaks
Now you have two slope fields: gx (how much height changes going right) and
gy (going down). To find the height at some pixel, walk to it from the corner, adding
slopes as you go.
But now there is more than one route. And here is the thing that makes 2D genuinely different:
If the slopes came from a real surface, every route gives the same answer. If they did not, different routes disagree — and there is no surface to find.
Take a real 3×3 surface and its true slopes, then scale gx by 0.5 and leave
gy alone — which is exactly the kind of thing compress_range does:
surface H true gx true gy gx scaled by 0.5
0 1 3 1 2 0 1 1 -2 0.5 1.0 0
1 2 1 1 -1 0 3 -2 1 0.5 -0.5 0
4 0 2 -4 2 0 0 0 0 -2.0 1.0 0
walk from the top-left corner to the bottom-right corner:
along the top, then down the right edge -> +0.500
down the left edge, then along the bottom -> +3.000
disagreement 2.500
Two and a half units of disagreement about the height of a single corner, depending on which way round you walked. No surface in existence has those slopes. And §6.2's whole method is built on producing exactly this kind of impossible field on purpose — attenuating the steep slopes and not the shallow ones is, by construction, an independent rescaling.
No surface has these slopes, so the question becomes:
Which surface comes closest to having these slopes?
3. “Closest”, made precise
Closest in the least-squares sense: of all possible surfaces, find the one where the total
squared disagreement between its actual slopes and the slopes you asked for is as small as
possible. Add up, over every pixel, (actual gx − wanted gx)² plus
(actual gy − wanted gy)², and make that total as small as it will
go.
That is a minimisation over a third of a million unknowns, but a standard move makes it tractable: at the minimum of a smooth function, the derivative is zero. Writing “the derivative with respect to every height is zero” and simplifying gives a single equation:
That is the Poisson equation. Both symbols are simpler than they look, and both are just arithmetic on neighbouring pixels.
3.1 The right-hand side: divergence
∇ · g is the divergence of the slope field.
Per pixel it is one subtraction per axis: how much gx changed since the pixel on the
left, plus how much gy changed since the pixel above.
Read it as a measure of how much the slope field is spreading out at that point. Positive divergence means the arrows around this pixel are pointing away from each other, which is what happens at the bottom of a bowl. Negative means they converge — the top of a hill. It is a single number per pixel saying “this is a dip” or “this is a bump”, and it is computed directly from the slopes you were given, so the right-hand side is known before you start.
3.2 The left-hand side: the Laplacian
∇2h is the Laplacian of the surface. For a grid it is the famous five-point stencil: take a pixel's four neighbours, add them up, subtract four times the pixel itself.
h[r-1, c]
|
h[r, c-1] -- h[r, c] -- h[r, c+1] laplacian = (sum of the four neighbours)
| - 4 * h[r, c]
h[r+1, c]
Read it as how much this pixel differs from the average of its neighbours. Zero means the pixel sits exactly on the average — locally flat, in the sense of no curvature. Positive means it sits below its surroundings, negative means it pokes above them.
So the equation says: find the surface whose local curvature everywhere matches the spreading-out of the slopes you asked for. That is all it says.
4. Boundaries, and why Neumann
The stencil needs four neighbours. On the edge of the image, one of them does not exist. On a corner, two do not. The equation is silent about this, so you have to decide — and the decision has a name.
Dirichlet boundaries fix the value at the edge: “the border is at height zero”. Neumann boundaries fix the slope at the edge: “nothing flows across the border”, which in practice means pretending the missing neighbour is a copy of the pixel itself.
A useful mental picture: a Poisson solve is a soap film settling. Dirichlet is a film on a wire loop — the rim is pinned where the wire is. Neumann is a film whose edge is free to slide up and down a frictionless wall — it can sit at any height, but it must meet the wall at right angles.
For a relief panel, Neumann is obviously right. You do not know in advance how high the edges of
your relief should be, and you certainly do not want them forced to zero. Pinning the border would
warp the entire field to reach it — on the same test problem, the Neumann solve recovers the
true surface to 4 × 10−15 while the Dirichlet solve is off by 4.03
near the edge. The recessed border you do want comes later and separately, from
apply_rim, as a deliberate cosmetic choice rather than a constraint baked into the
physics.
Neumann has one quirk that explains a line of the code. Since nothing flows across the boundary, the total of the whole right-hand side must come to zero — whatever spreads out somewhere has to converge somewhere else:
f 3. 1. 4. 1. 5.
padded 3. 3. 1. 4. 1. 5. 5. <- edge value repeated
laplacian -2. 5. -6. 7. -4.
sum = 0.0 <- always, under Neumann
That also means the constant surface satisfies the equation with zero on the right. Which is the same + C from §1, arriving from a different direction: the solution is unique only up to an overall height.
5. Solving it without a solver
Written out, the Poisson equation is one linear equation per pixel — 329,120 of them at the build guide's grid, each coupling a pixel to its four neighbours. As a matrix that is 329,120 × 329,120. Storing it densely would take about 0.9 TB, and solving it the naive way is on the order of 1016 operations.
Nobody does that. The usual approach is an iterative solver — guess, refine, repeat until it stops changing — which works but introduces a tolerance to tune and a convergence to worry about.
Under Neumann boundaries there is something much better available, and it rests on one fact:
Cosine waves are eigenvectors of the Laplacian. Apply the five-point stencil to a cosine and you get the same cosine back, just scaled.
“Eigenvector” only means: this operation does not change my shape, it only changes my size. Check it on a 5×5 grid — the predicted scale factor, and the one measured by actually applying the stencil:
basis fn predicted lambda measured spread
cos(0,0) 0.0000 0.0000 0.0e+00
cos(0,1) -0.3820 -0.3820 5.0e-16
cos(1,0) -0.3820 -0.3820 1.7e-16
cos(2,3) -4.0000 -4.0000 1.3e-15
cos(4,4) -7.2361 -7.2361 6.2e-15
The scaling factor for the cosine with i wiggles down and j across is
and it turns the whole problem inside out. In ordinary space the Laplacian is a huge tangle of coupled equations. In the cosine basis every equation is independent — each cosine component of the answer is just that component of the right-hand side divided by one number. A matrix inversion becomes a division.
That gives the recipe, which is the entire solver:
1. DCT the right-hand side -> how much of each cosine it contains
2. divide each one by its lambda -> how much of each cosine the answer contains
3. inverse DCT -> the answer, back in pixel space
The DCT is the transform that decomposes an image into exactly these cosines, and it runs in O(n log n) — about 6 × 106 operations at the real grid rather than 1016, and the result is exact to floating point.
5.1 The two fiddly lines
Look at the eigenvalue grid for a 5×5 and one entry stands out:
0.000 -0.382 -1.382 -2.618 -3.618
-0.382 -0.764 -1.764 -3.000 -4.000
-1.382 -1.764 -2.764 -4.000 -5.000
-2.618 -3.000 -4.000 -5.236 -6.236
-3.618 -4.000 -5.000 -6.236 -7.236
The corner is exactly zero, and it is the only one. That is the constant surface — the + C — showing up as an eigenvalue of zero, because a flat surface has no curvature anywhere. Dividing by it would be dividing by zero.
Hence the two lines in the code that otherwise look like superstition:
lam[0, 0] = 1.0 # make the division legal
h_hat = dctn(div, type=2, norm="ortho") / lam
h_hat[0, 0] = 0.0 # discard the meaningless result
Set the offending eigenvalue to anything non-zero so the division is legal, then throw away whatever landed in that slot afterwards. The value 1.0 is arbitrary; nothing survives it. What you are choosing is the zero-mean member of the family of valid answers.
6. Does it actually work?
Two checks, both cheap.
When the slopes are consistent, it is exact. Take a real surface, compute its slopes, reintegrate, and you get the surface back to floating-point noise — 1.1 × 10−15 on the 3×3 example above, and 2.7 × 10−13 at 256×256 in §6.5's self-test.
When they are not, it finds the genuine minimum. Feed it the impossible field from §2 and it returns a surface with a squared error of 2.4479 against what was asked for. Nudge that surface 20,000 times at random and see how many nudges do better:
squared error of the Poisson answer: 2.4479
of 20,000 random nudges to it, how many did better: 0
None — it is the provably closest surface to a request that could not be satisfied.
7. The code, mapped
Everything above is fourteen lines in
heightfield.py. Each line
now has a name:
div[:, 1:] += gx[:, 1:] - gx[:, :-1]and the three lines after it — §3.1's divergence. The twodiv[:, 0]anddiv[0, :]lines are §4's zero-flux boundary, in its discrete form._laplacian_eigenvalues— the λ formula from §5, built by broadcasting a column of row-frequencies against a row of column-frequencies.lam[0, 0] = 1.0andh_hat[0, 0] = 0.0— §5.1's dodge around the constant mode.dctn(…) / lamthenidctn(…)— §5's transform, divide, transform back.norm="ortho"makes the pair exact inverses so no scaling factor has to be tracked by hand.
Open a Python prompt with heightfield.py importable and paste the 3×3 example
from §2. Print the surface it gives back, take its differences, and compare them with what you
asked for. Ten seconds, and the idea stops being abstract — you can see it splitting the
difference between two irreconcilable demands.
8. Where else this turns up
Once you recognise the shape of it, gradient-domain editing is everywhere. The pattern is always the same: it is easier to say what the slopes should be than what the values should be, so you specify slopes and solve for values.
- Seamless image compositing. Paste a region by matching its gradients rather than its pixels, and the seam disappears because the solve absorbs the brightness difference across the whole patch instead of leaving it at the boundary.
- HDR tone mapping. Exactly the construction in §6.2, applied to brightness instead of height — which is unsurprising, since both are the problem of fitting an enormous range into a small one without losing the small stuff.
- Shape from shading, and photometric stereo. Surface normals are slopes; turning measured normals into a height map is this solve. It is the same Frankot–Chellappa step that turns an SVBRDF normal map into printable relief.
- Physics, originally. Steady-state heat, electrostatic potential from a charge distribution, gravity from a mass distribution. Poisson wrote it down in 1813 for gravity; the soap film of §4 obeys the same equation.
References
- Weyrich, Deng, Barnes, Rusinkiewicz, Finkelstein (2007), Digital Bas-Relief from 3D Scenes — the construction this solver serves.
- Fattal, Lischinski, Werman (2002), Gradient Domain High Dynamic Range Compression — the same idea for brightness, and the origin of the log attenuation.
- Pérez, Gangnet, Blake (2003), Poisson Image Editing — the compositing application, and the clearest short account of why boundary conditions matter.
- Frankot, Chellappa (1988), A Method for Enforcing Integrability in Shape from Shading — the transform-domain projection onto integrable gradient fields.