Skip to content

Bumpy flatmap redesign - #720

Open
alexhuth wants to merge 18 commits into
claude/issue-714-irf5g0from
claude/bumpy-flatmap-design-e0n5zr
Open

Bumpy flatmap redesign#720
alexhuth wants to merge 18 commits into
claude/issue-714-irf5g0from
claude/bumpy-flatmap-design-e0n5zr

Conversation

@alexhuth

Copy link
Copy Markdown
Contributor

This change revamps how the bumpy flatmap and equivolume transform are computed. Previously, they were both computed on the client side in javascript. This was the source of a lot of complexity on the javascript side and was inconsistent with how most other things in pycortex work. Now, both the bumpy flatmap offsets and equivolume are computed in python and then passed to the web viewer, similar to curvature, etc. Moving these computations to python let us do things that are a little fancier and end up looking better. In particular, the bumpy flatmap computation now uses better smoothing than before, making it look prettier. (see screenshot)

image

The python version of these algorithms is a bit more succinct than the javascript version, but this change ends up adding a decent number of lines of code due to additional code to transport the data from python to javascript, test code, examples, and documentation.

claude added 18 commits August 23, 2026 20:40
The bumpy flatmap gives a flatmap relief that survives full data coverage.
It was computed in javascript, at viewer load time, and it was noisy enough
that the result was treated with iterated Laplacian smoothing.

Reading the shipped code, two things stood out. The height was
`computeFlatVolumeHeight(wmareas, vertexvolumes)` -- the denominator was the
*folded* white matter vertex area, not the flattened one -- so with
`A_pial = r**2 * A_wm` it collapsed to `thickness * (1 + r + r**2) / 3` and
never looked at the flatmap at all. The version that does use flat areas was
commented out, because it spikes: `V / A_flat` has a denominator that goes to
zero wherever flattening crushed a triangle, and smoothing a field of ratios
afterwards is dominated by exactly those outliers.

Replace it with a physical model, computed in python and cached.
cortex.polyutils.FlatSlab treats the tissue between the white matter and pial
surfaces as a soft elastic solid, pins the white matter side to the flatmap and
lets the pial side settle, minimising a stable Neo-Hookean energy (Smith, De
Goes & Kim 2018) over wedge elements cut into tetrahedra. What keeps real
tissue from spiking is not gravity -- over a 3mm slab `rho*g*t/mu` is about
0.026, a couple of percent -- but shear: a tall narrow column is expensive
because it shears against its neighbours and relieves that by spreading
sideways. So the pial vertices get three degrees of freedom rather than one,
gyral crowns end up wider at the top than at the bottom and sulcal fundi
narrower, and the relief comes out smooth without anything being smoothed.

With no body force and a uniform material the shear modulus factors out of the
minimiser entirely, so Poisson's ratio is the only material parameter that
affects the result and it is the only one the API takes.

On S1's left hemisphere this drops the height's p99.9-to-median ratio from 2.4
(naive) and 3.8 (the old javascript) to 1.9, with the tallest point at 5.9mm
rather than 13.9mm, and the height correlates with mean curvature at +0.52.

Also move the equivolume depth sampling's vertex areas out of javascript, which
lets them use the cotangent-weighted operator in Surface.smooth rather than a
uniform umbrella, and removes the last of the per-load numeric kernels.

Plumbing:

- surfinfo.bumpy_flatmap and surfinfo.equivolume_areas cache the results.
  Both store more than one value per vertex, so they avoid the `left`/`right`
  keys that get_surfinfo turns into a Vertex by concatenation.
- get_surfinfo takes `generate=False` for info too expensive to compute behind
  someone's back. The bumpy flatmap uses it: it is generated when a flatmap is
  imported, and a subject imported before this existed gets a flat flatmap and
  a message saying how to fill it in, rather than a viewer that disappears for
  a quarter of an hour.
- import_flat generates it, and now also clears the surface info derived from
  the flatmap it just replaced -- clear_cache only empties cache/, so
  distortion and flat_border could go stale there too.
- The offsets ride to the viewer in a `flatoffset` ctm attribute. The surface
  shaders use all 16 of the vertex attribute slots webgl guarantees (see #715),
  so javascript unpacks them into `flatbump.w` and the unused `w` of `wm` and
  of the flat morph target rather than adding a seventeenth.
- bumpy_flatmap_scale exaggerates the relief for display without touching the
  cached geometry.
- The ctm cache token goes to v4, since older packs have neither attribute.

Tests check the relaxation against cases with known answers rather than against
stored output: an identity flat map has to leave the slab exactly alone, a
uniformly stretched thin slab has to reproduce the homogeneous solution in its
interior, and the requested Poisson ratio has to come back out of the small
strain elasticity tensor. A headless test renders the flatmap with and without
the relief and fails if the two images match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
Three measured changes, on S1's left hemisphere (874k tetrahedra):

Work in the transposed convention throughout. A column slice of a C-ordered
(n, 3, 3) array is strided while a row slice is contiguous, and the objective
spent much of its time in cross products and matrix multiplies over strided
views. Transposing the deformation gradient, the cofactor and the per-node
gradient -- and folding the element volume into a precomputed `vol0 * Dm^-1`
rather than scaling afterwards -- takes an energy-and-gradient evaluation from
1.31s to 0.72s in isolation. The gradients agree with the old code to 1.2e-11
relative.

Scatter the gradient in one pass. Three bincounts over strided component slices
each had to copy their weights out first; one bincount over a precomputed
flattened index takes the per-element array's own buffer.

Raise the L-BFGS memory from the default 10 correction pairs to 60. At a fixed
150 iterations this reaches a 29% lower energy (1.16e5 against 1.64e5) for about
10% more time per iteration.

Relax the two hemispheres in parallel, which is worth much less than the factor
of two it looks like -- the objective is memory-bandwidth-bound, so two copies
compete -- but is still about 15% off the wall clock.

Together: both hemispheres of S1 at 400 iterations now take 15.6 minutes rather
than roughly 25.

Two things that did not work and are recorded here so they are not retried:
single precision is unusable, because a cortical mesh's element volumes span
enough orders of magnitude that `vol0 * Dm^-1` loses its dynamic range in
float32 and the energy comes out 12x wrong; and reusing preallocated buffers is
slower than allocating, because writing into `(n, 3, 3)` slices is strided.

Cutting the iteration count is not a safe optimisation: at 150 iterations the
heights still differ from the 400-iteration answer by 8% at the median.

Also store the cached offsets compressed and in single precision -- they end up
in a float32 vertex attribute regardless -- which takes the file for a subject
from about 7MB to 3.3MB, and rewrite the module docstring as technical prose
with references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
Relaxing the cortical slab takes about a quarter of an hour for both
hemispheres, which is a long time to wait to see what the feature does on the
subject that ships with pycortex. Committing the result means S1's flatmap has
relief straight out of the box, and the gallery example and the headless viewer
test do not each have to compute it.

Generated with the shipped defaults (poisson_ratio=0.45, 400 iterations),
compressed and stored as float32, which is 3.3MB. The rest of surface-info/
stays ignored, as before; this one file is checked in deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
Now that S1's relaxation is in the filestore the example can look it up instead
of recomputing a shortened version of it, which takes the page from 259 seconds
to build to 5, and shows the converged result for both hemispheres rather than
one truncated hemisphere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
A quasi-Newton method takes far longer to resolve the long wavelength part of
this problem -- how the cortical sheet as a whole slides as it settles -- than
the local detail, and on a mesh of 150,000 vertices that is what dominates the
cost. Solving a smaller mesh first finds exactly that part cheaply.

Getting a coarser mesh is easy here only because a flatmap is planar. Take a
maximal independent set of the vertices, so no two survivors were neighbours,
and retriangulate them with a two dimensional Delaunay triangulation; no
surface-aware decimation is needed, and none is available anyway since
polyutils.decimate needs tvtk.

Delaunay triangulates the convex hull, though, so it bridges the medial wall and
every relaxation cut. Those bridges join vertices that are close together in the
plane but far apart across the surface, so a coarse triangle is kept only if its
vertices are within a few steps of each other in the fine mesh's own graph. How
many steps that should be depends on how regular the mesh is, so it is not fixed
but measured: the number chosen is the one whose coarse mesh covers the same
total area as the fine one. On S1 that lands on five steps and matches the fine
area to 0.01%, against 17% too much with no rejection at all.

Each level is about three and a half times smaller than the one above, so on S1
the hierarchy is 146k, 42k and 11k vertices. A coarse solution is prolonged onto
the next level by barycentric interpolation, with the few vertices that fall
outside the coarse triangulation taking their nearest coarse vertex's value.

Every level gets the same iteration count, which means the hierarchy adds about
40% to the cost of one pass over the full mesh. Giving the coarse levels more
than that was measurably a waste: they were converged well past the point where
the full mesh could still tell the difference, and spent 65% of the total time
doing it.

The tests check the properties this rests on rather than a stored result: that
the surviving vertices really are an independent set, that a hole punched in a
mesh is still a hole after coarsening (which fails if the bridge rejection is
removed), that prolongation reproduces affine fields exactly and is a partition
of unity, and that a coarse-to-fine solve reaches the same energy and the same
geometry as a single-level solve on a patch small enough for both to converge.

Also from a docs audit: add the new public API to the sphinx reference, make
polyutils re-export everything bumpy.py declares public, say precisely why
equivolume_areas cannot be a Vertex (it holds four scalar maps, not a
multi-component one), and delete a commented-out javascript fallback that
referred to attributes which no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
With the coarse-to-fine solve in place the full mesh no longer needs anything
like as many iterations, because it no longer starts from scratch. Measured on
S1's left hemisphere, energy against wall clock:

    single level, 150 iterations   250s   1.16e5
    single level, 250 iterations   344s   5.49e4
    single level, 400 iterations   555s   2.10e4     <- the old default
    coarse to fine,  60 iterations 128s   7.20e4
    coarse to fine, 100 iterations 209s   3.86e4
    coarse to fine, 150 iterations 301s   1.61e4

So 150 iterations coarse to fine reaches a *lower* energy than 400 did on its
own, in a little over half the time. Comparing at matched energy rather than
matched iterations, the hierarchy is worth between two and two and a half times.
Default max_iter accordingly drops from 400 to 150.

Relaxing the two hemispheres in separate processes now buys nothing and stops
being the default. The solve is memory-bandwidth-bound rather than
compute-bound, so two at once mostly compete for the same bandwidth: on S1 that
is 611 seconds in parallel against 602 sequential, for twice the peak memory.
The parameter stays for machines with bandwidth to spare.

Regenerate the shipped S1 offsets with the new defaults. Both hemispheres now
take 611 seconds rather than 938, and land at a lower energy: 1.61e4 against
2.10e4 on the left. Volume loss rises from about 9% to about 12%, which is the
solution getting closer to the true minimum rather than a regression -- at a
Poisson ratio of 0.45 the material is compressible and the minimum genuinely
sheds that much; raising the ratio towards 0.5 recovers it, which is what
test_incompressible_slab_conserves_volume checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
bumpy_flatmap_scale was only reachable by editing the config file, which is an
awkward way to adjust something whose whole purpose is to be looked at. Put it
in the surface controls next to the bumpy_flatmap toggle, bound straight to the
shader uniform the way depth and opacity are, so dragging it redraws
immediately.

The slider starts wherever the configuration file put it. Its range runs from 0
to five times true scale, or to twice the configured value when that is already
higher, so someone who deliberately set 8 does not find it clamped to 5 the
first time the menu is drawn. A missing or unparseable setting falls back to 1;
that cannot be written as `parseFloat(...) || 1`, because 0 is a legitimate
value and has to survive.

Adding it to the surface menu is enough to make it settable from python and
saved with a view: JSMixer.view_props is built from the menu's own controls.

The headless test now also drives the slider and checks the render changes,
which is what catches the uniform not reaching the shader. It deliberately does
not read the value back afterwards: reading any surface menu property through
the javascript proxy returns an empty dict, for unfold, depth and opacity just
as much as for this one, so a read-back assertion would be testing that
pre-existing limitation rather than this feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
Two independent defects, both mine, and neither to do with Poisson's ratio --
which is why sweeping it made no difference.

A slab one element thick. The whole design argument was that shear across the
thickness of the slab supplies the regularisation, and then the slab was built
with exactly one element layer through it. A single linear element cannot
represent a shear profile across a layer, so the coupling being relied on did
not exist: neighbouring columns of tissue exchanged material far too freely and
the relief flattened towards a slab of uniform thickness. On a patch of S1 the
correlation between relief and mean curvature is 0.77 for the unrelaxed prism
estimate, 0.52 after the one-layer relaxation, and 0.79 with four layers.

No regularisation at all. The javascript this replaced smoothed vertex areas
over five iterations and thickness over twenty. This smoothed nothing, so
millimetre-scale noise in the segmentation went straight into the relief.
Smoothing the white-to-pial displacement -- the folding is left alone, only the
local thickness is regularised -- takes mesh-scale roughness from 0.21 to 0.10
relative to the signal.

Neither substitutes for the other. Smoothing alone leaves the correlation at
0.57; layers alone leave the roughness at 0.21.

Four layers is four times the elements, so `resolution` now stops the hierarchy
before the finest mesh and interpolates the rest of the way. The relief varies
over millimetres and solving it at the scale of a single triangle mostly buys
the opportunity to wrinkle; solving S1's 1.6mm level rather than its 0.9mm one
costs a fifth as much and band-limits the result by construction. Solving every
level at four layers takes upwards of forty minutes a hemisphere and is not
worth it. Both hemispheres of S1 now take 729 seconds against 611, and volume
loss drops from about 12% to 4-6% because the relaxation can no longer squeeze
material as freely.

The test that should have caught this did not exist: the relaxation was only
ever checked for energy, volume and convergence, all of which improved while the
picture got worse. test_relief_follows_the_folding_and_is_not_mesh_scale_noise
now asserts both properties on a real patch and pins the cause by requiring a
one-layer unsmoothed slab to do worse on both.

test_coarse_to_fine_matches_a_single_level_solve needed resolution=0 to keep
meaning what it says, since at the default the hierarchy deliberately stops
before the finest mesh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG9XE4ZAitPgsQYRDwtwVT
…scale

Three problems showed up once the relief was actually usable.

It was far too slow. Both hemispheres of S1 took 729 seconds at import.
Solving at 3.1 mm rather than 1.6 mm, three element layers rather than
four, and 60 iterations rather than 150 bring that to 57. This is not
free -- the correlation between the relief and high-passed mean curvature
falls from 0.70 to 0.57, so real gyral-scale detail is being given up --
but a subject import that pauses for a minute is a different thing from
one that pauses for twelve.

Two of those three are nearly free on their own: a fourth element layer
costs 60% more time and moves the correlation by 0.002 in the wrong
direction, and 100 iterations against 60 costs a third more time for
0.001. Almost all of the loss is the resolution, and almost all of the
speed is too.

It was not smooth. The heights looked fine; the lighting did not. A
shading normal is the derivative of the height field, and every level
finer than `resolution` is reached by barycentric interpolation, which is
only C0 -- so the field had creases along the coarse triangle edges that
were invisible in the heights and glaring in the shading. Smoothing the
finished offsets (`polish`) takes the RMS angle between the normals of
neighbouring triangles from 8.8 degrees to 2.4, and *improves* the
correlation with curvature, so at this strength it removes noise rather
than signal.

The shading did not track the scale slider. The geometry grew and the
lighting stayed where it was. Exaggeration is now vertical only, like on
a topographic map, so turning the slider up does not smear the relief
sideways relative to the data underneath it -- and because it is vertical
only, the corrected normal is exact rather than approximate: scaling a
height field by s scales the normal's in-plane components by s and leaves
the out-of-plane one alone. It needs no recomputation when the slider
moves and degenerates correctly to the flat normal at zero.

Two further defects found while tracing the normals. The displacement
ramped over anatomical-to-inflated while the shading normal ramped over
inflated-to-flat, so the two disagreed across the whole first half of the
unfold -- harmless when the offset was a height along the current normal,
wrong once I made it a fixed offset in flatmap coordinates, which mean
nothing on a folded surface. Both now ramp over the last segment. And
`computeNormal` gave vertices in no triangle a zero normal, which the
fragment shader then divided by; they fall back to the sheet's own normal.

Two speedups that cost nothing: `Surface.smooth` factorises its operator
on every call, so smoothing a three-component field paid for three
factorisations of the same matrix; and the coarsening's hop search
multiplied full n-by-n reachability matrices when only the surviving
third of the rows was ever read.

Not fixed here, but found: the pick and depth shaders have no HASFLAT
branch, so they render undisplaced geometry while the colour pass renders
displaced geometry. Picking drifts from what is drawn, and the drift
grows with the scale slider.
The bumpy flatmap read as fine texture rather than hills and valleys, for
the third round running. The reason it kept coming back is that every
metric it had ever been tuned against was blind to the thing being
complained about: `silk` compares neighbouring face normals, `roughness`
compares a vertex to its one-ring, and the correlation guard high-passed
the field before measuring it. All three are local. Nothing in the repo
could see a relief that was beautifully smooth and anatomically empty,
which is exactly what those three parameters had been driven to produce.

Measuring it by spatial band shows the mistake. `Surface.smooth`'s
`factor` is a diffusion time, and one backward-Euler step has transfer
function 1/(1 + k^2 t), so its half-power point sits at a wavelength of
2*pi*sqrt(t) -- a factor of 4.4 longer than the Gaussian-equivalent sigma
that made these values look mild when I picked them.

  smooth = 1.0   cuts at  6.3 mm
  polish = 4.0   cuts at 12.6 mm
  correlation_length = 2.5 mm cuts at 15.7 mm

Gyri are 8-16 mm across. All three were filtering the signal, not the
noise.

`smooth` was the worst of them, and it is now off by default. It was
added to keep segmentation noise out of the relief and it does, but at
6.3 mm it was removing the thickness variation that makes gyri thicker
than sulci: on S1 it roughly halved the correlation with mean curvature,
0.47 to 0.25 in the 8-16 mm band and 0.62 to 0.42 in 16-32 mm. Even 0.05
measured slightly worse than zero. `polish` removes the same noise
afterwards for a fraction of the signal, so there is no regime where it
helps.

`polish` drops 4.0 -> 2.0 and `correlation_length` 2.5 -> 0.5 mm. The
second of those should not matter at all -- it sets only the starting
point -- and it matters because the solve stops at `max_iter` rather than
at a tolerance, which nothing printed and I had never checked. It does
not converge: `info['converged']` is False with a final gradient sup-norm
around 3e3 against scipy's 1e-5. An unconverged solve stays near its
starting point, so over-smoothing that point costs real amplitude.
`_relax_hemisphere` now says so out loud.

On S1, against what shipped before, and slightly faster because `smooth`
was itself a sparse solve:

  runtime      56.7 s -> 49.1 s
  hp-corr       0.567 -> 0.739
  height std    0.536 -> 0.703 mm
  silk           2.41 -> 2.80 deg, but 4.52 -> 4.00 per unit of relief

That beats the old resolution=1.5 configuration (0.700) while running
four times faster than it, so the resolution trade made last round was
never the real cost.

Two things this rules out. Poisson's ratio is irrelevant -- 0.499 against
0.45 changes amplitude by 1.02x and makes the correlation worse -- which
settles the earlier experiments with 0.45/0.49/0.499. And the flattening
area change correlates 0.00-0.05 with both mean and Gaussian curvature in
every band, so it carries no folding signal at all; the relief is
essentially cortical thickness, and a theory I had that flattening
compression is Gaussian-curvature-driven and therefore intrinsically
speckled is simply wrong.

Adds a band-resolved test, since the absence of one is what allowed this.

Still open, and now visible rather than hidden: the solve does not
converge, and at the default `resolution` the floor clamp leaves only the
coarsest level actually solved, so the coarse-to-fine cascade is not
running. Adding levels coarser than the floor would cost very little.
The relief still reads as texture rather than terrain, and no amount of
solver work will change that, because shaded relief is itself a high-pass
filter: brightness follows the surface gradient, so a band at wavelength
L and amplitude A contributes contrast in proportion to A/L. A 30 mm
swell needs roughly ten times the amplitude of a 3 mm wrinkle to read as
strongly. Terrain looks like hills and valleys because its amplitude
spectrum falls steeply with frequency; cortical relief's does not.

`polish` cannot fix it -- a low-pass shrinks the whole relief and has to
be chased with the viewer's scale slider, which rescales every band
equally and so changes nothing about the balance. `tilt` boosts the long
wavelengths and leaves the short ones alone. On S1, the share of shading
above 32 mm against the share in 4-16 mm:

  tilt=1   25% vs 40%   silk 2.80   relief std 0.70 mm
  tilt=2   33% vs 33%   silk 3.00   relief std 1.22 mm
  tilt=3   38% vs 28%   silk 3.24   relief std 1.75 mm
  tilt=5   43% vs 24%   silk 3.75   relief std 2.81 mm

Note how little the normal continuity moves for how much relief is added
-- tilt=3 nearly triples it for half a degree -- because a long
wavelength carries very little gradient per unit amplitude. That is the
same fact that makes the relief look high-pass, used in reverse.

`tilt_scale` defaults to 24 mm, chosen to sit over the bands that
actually carry folding: the relief's correlation with mean curvature is
0.62 at 16-32 mm and 0.66 at 32-64 mm, but only 0.30 above 64 mm, so
exaggerating much past 40 mm looks smoother while spending the
exaggeration on the one band that is barely anatomical.

This is a display choice and it is documented as one. `tilt=1` is off and
is the default, so nothing changes unless it is asked for.

Also plumbs FlatSlab's keyword arguments through `surfinfo.bumpy_flatmap`,
which previously accepted only `poisson_ratio` -- the parameter that turns
out to matter least. `db.get_surfinfo` folds them into the cache
filename, but `brainctm` looks for the unsuffixed file, so the docstring
gives the recipe for trying a setting in the viewer.
`tilt` boosted the relief's long wavelengths, on the reasoning that shaded
relief is a high-pass filter so the long wavelengths need help. Measured
on the real field, it makes things worse, and the report that it "just
increases the overall height" is exactly right.

The relief has a whole-map swell in it. Per-band standard deviation on S1,
with each band's own mean removed:

  <2     2-4     4-8    8-16   16-32   32-64    >64      total
  0.008  0.019   0.054  0.112  0.157   0.147    0.421    0.700

That last column is 36% of the variance sitting in one mode broad enough
to span the flatmap. A long-wavelength shelf boosts it hardest -- of all
the variance `tilt=3` added, 53% went there -- so the map gets taller and
domed while the gyri stay where they were. Normalising the gyral gain by
the height increase:

  shelf tilt=3        1.94 / 2.49 = 0.78   <- worse than doing nothing
  bandpass 8-48 g=3   2.32 / 1.89 = 1.23
  dome removed        0.88 / 0.66 = 1.33
  dome + bandpass     2.20 / 1.64 = 1.34

The shelf also lowered the correlation with mean curvature in every band
(0.62 -> 0.58 at 16-32 mm). So the right move is the opposite one: stop
spending amplitude on structure larger than folding. `detrend` removes it,
and the band-pass then adds nothing that the viewer's scale slider does
not already provide, so it is not worth a parameter.

The swell is real -- cortex is regionally thicker in some lobes than
others -- but it is not folding: it correlates with mean curvature at 0.30
where the gyral bands manage 0.62-0.66, and `bumpy_flatmap_scale`
multiplies it along with everything else, so it eats the visual dynamic
range that should be showing the folding. Removing it takes the relief
from 0.70 mm to 0.46 mm and leaves the gyral bands within 10-15% of where
they were, so the same slider setting times 1.5 gets back to the same
height with the folding half again as strong. Correlation improves in
every band, most above 64 mm (0.30 -> 0.56).

On S1: 55.3 s, silk 2.76 (was 2.80), hp-corr 0.735 (was 0.739), height
std 0.460 (was 0.703). The two quality numbers are unchanged because both
are blind to this band by construction -- hp-corr high-passes at 31 mm --
which is the same blind spot that let the earlier defaults drift.

Found while checking the previous commit's own claim, and it took two
attempts: the first measurement subtracted the mean over interior
vertices only, leaving a step at the flatmap boundary that put 70% of the
apparent shading below 2 mm. Bands are now taken from the untouched field
and reported as per-band standard deviations.
…ot fix

The relief reads as round knobs rather than gyral ridges. Measured cause:
with resolution=3.2 only the coarsest ~3.1 mm level is solved, so the
finished relief lives entirely in the span of that mesh's hat functions,
and on a near-equilateral Delaunay mesh each hat is a circular disc about
6.2 mm across -- the width of a gyral ridge. A 6 x 20 mm ridge holds
about 14 coarse vertices: 6-7 along its length and 2 across it. Across
the ridge that is one linear tent and no shape; along it, a chain of
independent round hats.

Two changes here.

`detail` puts ridge-scale structure back. Shear only couples the slab
over roughly its own thickness, and a ridge is several times wider than
that -- the regime where the cheap volume-preserving prism height is
already close to right. So the relaxation supplies the long wavelengths,
the full-resolution prism height supplies the crossover down to
`detail_floor`, and nothing comes from below that.

`anisotropy` makes the polish follow the gyri. Isotropic diffusion
applied to a chain of knobs rounds each one off; it cannot join them up.
The direction comes from the structure tensor of mean curvature, damped
in proportion to coherence so an isotropic patch is not combed into
streaks from noise. The stiffness is the standard FEM generalisation of
the cotangent formula, reusing the rotated edge vectors already cached on
Surface; with an identity tensor it reproduces the existing operator to
1.8e-15, which is asserted in the tests because a subtly wrong stiffness
would still produce a plausible-looking smooth field.

Honest accounting of what this buys. Ridgeness -- the inequality of the
principal curvatures at crests -- goes 0.677 to 0.699, and correlation
with thickness 0.668 to 0.714. That is real and it is not enough, because
the ceiling is low for the quantity being computed:

    mean curvature              0.833
    pia/wm area ratio r         0.776
    legacy javascript height    0.736
    thickness                   0.705
    this relief                 0.695
    naive prism                 0.688

The legacy javascript version really is more ridge-like than the
relaxation, and the decomposition says why. It computes
thickness * (1 + r + r**2) / 3; thickness alone scores only 0.705, and
what carries the ridges is r, the pial-to-white area ratio, which tracks
folding. The relaxation instead computes a thickness-and-flattening
quantity, and flattening compression correlates with curvature at 0.00 to
0.05 -- essentially noise -- which leaves it as smoothed thickness. Every
field available to rebalance sits at 0.69-0.71, so no reweighting of them
reaches the ridges; pushing the crossover to 48 mm hands almost
everything to the prism and asymptotes at 0.710.

Getting ridges means putting the folding term into the elastic reference,
which is a change to what is being relaxed rather than to how it is
filtered, and is not attempted here.

Also adds the first test in this file with anisotropic geometry. Every
existing synthetic case is a square grid stretched equally in both
directions, so not one of them could fail on a relief made of round
knobs.
The relief gave almost no sense of folding, and the reason was the
quantity rather than the machinery.

Both candidate heights are the same folded volume over an area; they
differ only in which area. The old one divided by the *flattened*
triangle area, the honest model of a slab laid flat. But the flatmap's
own area distortion measures essentially uncorrelated with both mean and
Gaussian curvature -- as a denominator it contributes no anatomy and
injects the flattening algorithm's artifacts in its place. What is left
is close to a map of cortical thickness, and thickness is a blobby field
that reads as round knobs.

Dividing by the *folded* white matter area instead gives
thickness * (1 + r + r^2) / 3 with r = sqrt(A_pia/A_wm), and r tracks
folding directly: the pia carries more area than the white matter beneath
a gyral crown and less in a fundus. Measured on S1 with every field
smoothed alike, by anisotropy of the Hessian at crests: r scores 0.776
where thickness alone scores 0.705 and mean curvature -- the folding
itself -- scores 0.833.

This is algebraically what the old javascript implementation computed,
which is why that one looked more folded despite being cruder in every
other respect. It scores 0.734; the relief now scores 0.759, and its
correlation with smoothed curvature goes from 0.739 to 0.894/0.854.

It has to be computed on the full-resolution mesh. Every coarse level
subsamples the white and pial surfaces and retriangulates them with the
*flat* Delaunay connectivity, so each coarse edge is a chord through the
fold rather than an arc along it. The pial flare is second order --
curvature times thickness -- while the surfaces are zeroth order, so it
is the first thing that deficit removes: the coarse area ratio collapses
towards one and the coarse solve minimises an energy whose reference has
already had the gyri taken out of it. Both the detail splice and the
coarse warm start now take the folding term from the finest mesh. Fixing
the coarse reference properly is recorded in the module docstring.

Two consequences worth being plain about. The relief no longer shows
flattening compression at all -- a patch that is flat but whose flatmap
is distorted now produces no relief, by design. And the relaxation
contributes much less to what is on screen: with max_iter=60 the solve
stays near its starting point, and the starting point is now the folding
height. The one-layer control in the folding test has been removed rather
than adjusted, because it no longer isolates anything.

Costs 68.5s for both hemispheres against a 60s target, and the relief is
about twice as tall, so the raw inter-face angle rises from 2.8 to 3.8
degrees -- but per unit relief the surface is smoother than before.

New tests: folding at constant thickness must still produce relief;
folding height must be invariant to flatmap distortion where the prism
height is not; and the frustum algebra is pinned.
The relief is right, so this removes the machinery that was used to find
it rather than to express it. bumpy.py goes from 1452 lines to 415 and
test_bumpy.py from 866 to 354; the feature as a whole from +3104 lines to
+1413, and from 68.5s to 11.3s for both hemispheres.

What goes is the elastic solver and its multigrid: the Neo-Hookean
energy, the tetrahedral element assembly, L-BFGS, the maximal-independent
-set coarsening, the barycentric prolongation, the hierarchy, and the
eight parameters that only fed them. Also the anisotropic ridge filter --
130 lines for 0.017 of ridgeness.

Deleting the solver was decided by measurement against a threshold fixed
beforehand, and the honest account is that the threshold said keep. The
solver moves the answer by 31% RMS, far over the 5% agreed. But
correlation between the two is 0.991 and ridgeness 0.741 against 0.738:
the difference is almost entirely a gain factor of about 0.85, and the
in-plane slip only the solver can produce is 0.047mm rms against a relief
of 1.0mm. So it was acting as a constant the viewer's scale setting
absorbs, at 34 of the 38 seconds. The gain is given back by setting
bumpy_flatmap_scale to 1.55 rather than the 1.8 that was asked for, so
what reaches the screen is what was approved.

On S1 the relief comes out at hp-corr 0.886/0.880 against 0.894/0.854
before. Silk rises from 3.84 to 4.66 degrees, part from the taller relief
and part from losing the anisotropic filter; scaled by the new default
that is about 4% rougher on screen than before.

With no relaxation the relief is purely vertical, so the shader carries
it in one component instead of three: flatbump.w alone, giving back the
spare w lanes of `wm` and the flat morph target. And at 5s a hemisphere
the plumbing built for a slow computation goes too -- get_surfinfo's
`generate` flag, brainctm's not-generated-on-demand contract, the
import_flat hook, and surfinfo's parallel path.

Kept: legacy_js_height and naive_prism_height, the two reference heights
the folding quantity was judged against, and which the gallery example
plots. The coarse-reference chord deficit that motivated computing the
folding term at full resolution is recorded in the module docstring; it
is no longer reachable, but it is why the answer is not simply relaxed.
Docstrings that recounted how the answer was found rather than what the
code does. The scorecards ("0.776 where thickness scores 0.705"), the
account of the elastic solver's removal, and the notes on which metric
misled which round of tuning are all history, and history belongs in the
log. bumpy.py 415 -> 388, test_bumpy.py 354 -> 345, and the feature from
+1413 to +1345.

Kept the facts that stop someone making a mistake: that a diffusion time
`t` is half power at a wavelength of 2*pi*sqrt(t) and not sqrt(t), which
is a factor of 4.4 and easy to get backwards; why the denominator is the
folded area; and why the log-space regularisation is the right averaging
for a ratio.

Two real errors fixed along the way. Every top-level def in bumpy.py had
lost its blank-line separation -- the previous commit normalised
whitespace with a regex that collapsed them. And the shader still carried
a paragraph explaining that the pial surface slides sideways so the
offset is a full vector rather than a height, immediately above the line
that now writes two zeros; the relief has been purely vertical since the
relaxation went.
…tils

You were right about the duplication -- there was more of it than the
helpers I had noticed. bumpy.py goes 388 -> 251, and the feature from
+1345 to +1201.

Four duplicates, three of them of things already in polyutils:

`_face_areas(pts, polys)` was `misc.face_area(pts[polys])` written out
again. `_vertex_areas` was `_lumped` of that. `face_prism_volumes` was
`misc.face_volume` -- the same three-tetrahedron decomposition, except
that misc's version loops in python and prints its progress every
thousand faces, which is presumably why it got rewritten rather than
used. Vectorised it in place instead, checked against `brick_vol`, and
deleted the copy.

`_smooth_vectors` was the fourth and the least obvious: it was
`Surface.smooth`'s operator assembly copied out so that one factorisation
could serve three columns. The relief has been a single column since the
relaxation went, so the reason for it had already gone -- `Surface.smooth`
is now called directly. `_regularise_log_height` was assembling that same
operator a third time; since M - t(W-V) is M + tL, the screened-Poisson
solve it wants is exactly what Surface.smooth does, so it is now a log,
a smooth and an exp.

Also removes `legacy_js_height` and `_umbrella_smooth`. It served its
purpose -- it is what showed the relief should be driven by the pia/white
area ratio -- but the answer is in `folding_height` now and the example
does not need three curves to make the point.

`misc.face_volume` is the one behaviour change outside the feature: same
signature and same numbers, no longer O(n) in python and no longer
printing. It was exported but unused.
…e slip

Checking for stale references turned up a real bug rather than only prose.

The example's plotting loops used `height` as the loop variable while
`height` was also the folding relief being plotted. So after the first
loop the name was bound to the naive field, and the histogram's "folding
height" curve and the final curvature hexbin both drew that instead. The
shadowing came in when `relaxed` was renamed to `height` two commits ago;
the example still ran and still produced plausible figures, which is why
nothing caught it. The relief is now `relief` and the loop variable
`field`.

Removed with it: a whole section plotting "how far the pial surface
slides sideways", which is identically zero now -- it was described as
"the part a vertical-prism model cannot represent at all". A sentence
about the javascript height that trailed off mid-clause when that curve
was deleted. And the headless test's claim that javascript packs the
offset into `flatbump`, `wm` and the flat morph target, when it has been
`flatbump.w` alone since the relief went vertical.

Also "relaxation cuts" -> "flattening cuts" in the example, which was the
freesurfer term for the cuts made to flatten a surface but now reads as a
reference to a relaxation that no longer exists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants