Skip to content

Recover thickness on poorly resolved bilayers (cubic sampling, full-radius averaging, opt-in forced prior) - #64

Merged
bbarad merged 1 commit into
mainfrom
thickness-forced-prior
Oct 7, 2026
Merged

bbarad merged 1 commit into
mainfrom
thickness-forced-prior

Conversation

@bbarad

@bbarad bbarad commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Problem

On a high-quality 9.98 Å/px OMM dataset, measure_thickness returned NaN for 47–84% of triangles even after refinement. At ~1 nm/px the two leaflets merge into one flat-topped peak. The whole-surface average is unresolved too, so _global_bilayer_prior returned None, and the recovery tier for merged triangles never ran. Every failure was "single peak, no prior". The tomograms are not low-passed (power extends to Nyquist).

Changes

  • Opt-in thickness_measurements.force_bilayer_prior (default false; CLI --force_bilayer_prior/--no_force_bilayer_prior). Fits an unresolved surface average as a bilayer anyway, accepted only if it passes the recovery checks (R², separation clear of the floor, comparable leaflet amplitudes), and uses it as the prior.
  • New per-triangle forced_bilayer_prior column (1/0): 1 where the thickness relies on the forced prior. thickness stays a single column; together with bilayer_resolution, the flag identifies low-confidence regions. The column is always written so the schema is stable.
  • Hint when the average is unresolved: measure_thickness now prints a message suggesting a less-binned tomogram, or the new option.
  • density_sampling.interpolation: cubic (new default) or linear, used by sample_density and refine_mesh. Linear interpolation's between-voxel blur is enough to merge leaflets at ~1 nm/px. Spline coefficients are computed only for the block the surface spans, and out-of-volume samples stay NaN.
  • radius_neighbors replaces the five cKDTree.query(k=500) neighbor queries in measure_thickness and refine_mesh. On dense meshes those silently shrank the averaging radius (~8 nm instead of 12 nm; ~16 nm instead of 20 nm on the example OMM). The surface is now thinned uniformly with a fixed seed so neighborhoods span the full radius at the same cost.
  • fit_triangle_chunk now returns (thickness, offset, resolution, recovered).

Results

Four 9.98 Å/px OMM surfaces, 1,500 sampled triangles each, 12 nm radius:

Surface Before New default + force_bilayer_prior (flagged)
lam12 22% 36% 85% (49%)
lam25 15% 25% 81% (56%)
lam26_1 49% 58% 86% (28%)
lam10 41% 54% 87% (34%)
  • Median thickness with the new default: 0.05–0.27 nm lower than before. Don't pool measurements made with and without this change; interpolation: linear reproduces earlier sampling.
  • Bias caveat for forced values: these read thinner than strict fits. In a synthetic blur test, though, the strict survivors read thick (they are selected for wide separation) and the forced values matched the unblurred truth. The docs therefore say "lower-confidence" without claiming a direction.
  • Well-resolved example surface: thickness is unchanged (median 2.92 → 2.90 nm), and the share of strictly resolved triangles rises from 54% to 67%. Cubic sampling had no meaningful time or memory cost (8.9 s vs 8.0 s; 2.1 GB vs 2.4 GB peak).

Refinement check

A 4-iteration refine_mesh on the example OMM was run with main, with this branch using linear sampling, and with this branch using cubic sampling. All three produce nearly identical surfaces:

  • vertices differ by a median of 0.02–0.03 nm (95th percentile ≤0.15 nm);
  • convergence curves overlap;
  • measured fraction, centering, bilayer dip depth, and roughness match, with cubic marginally best.

The neighbor cap was hit for 92% of triangles at 20 nm. It barely matters for centering here, likely because offsets are smoothed again over 25 nm with an uncapped query.

Tests

119 pass:

  • new test_forced_bilayer_prior.py, test_cubic_sampling.py, test_radius_neighbors.py;
  • updated test_thickness_worker.py for the 4-tuple.

sample_density + measure_thickness were also run end to end on the example tomogram, both with and without the forced prior (the latter on deliberately blurred profiles).

Release

The CHANGELOG entries are folded into the 2.0.0b6 section (version stays 2.0.0b6), so the beta can be re-cut from main after merge.

🤖 Generated with Claude Code

At ~1 nm/px an OMM's leaflets merge into one flat-topped peak. The
whole-surface average is then unresolved too, so no global prior was
formed, the recovery tier never ran, and most triangles came back NaN.

- Opt-in thickness_measurements.force_bilayer_prior (default false;
  --force_bilayer_prior): fit the unresolved surface average as a
  bilayer anyway, accepted only if it passes the recovery checks, and
  use it as the prior. New per-triangle forced_bilayer_prior column
  (1/0) flags thicknesses that rely on it; with bilayer_resolution it
  identifies low-confidence regions. Prints a hint when the average is
  unresolved.
- density_sampling.interpolation: cubic (new default) or linear.
  Linear interpolation's extra between-voxel blur merges leaflets at
  ~1 nm/px. Spline coefficients are computed only for the block the
  surface spans; out-of-volume samples stay NaN. Used by sample_density
  and refine_mesh.
- radius_neighbors replaces the k=500 nearest queries in
  measure_thickness and refine_mesh, which silently shrank the
  averaging radius on dense meshes (~8 nm instead of 12). It thins the
  surface uniformly (fixed seed) so neighborhoods span the full radius.

On four 9.98 A/px OMM surfaces: strict measured fraction 14-49% ->
25-58%, and 81-87% with the forced prior. A 4-iteration refine_mesh on
the example OMM matches main (vertices differ by a 0.02-0.03 nm median).
CHANGELOG entries are folded into 2.0.0b6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bbarad
bbarad merged commit 4d912b4 into main Oct 7, 2026
2 checks passed
@bbarad
bbarad deleted the thickness-forced-prior branch October 7, 2026 18:44
bbarad added a commit that referenced this pull request Oct 7, 2026
Conflicts resolved to keep both features:
- measure_thickness: process_single_surface, run_measure_thickness and the CLI take
  both force_bilayer_prior (#64) and the split-half noise options. The split-half fits
  reuse the same global prior, and radius_neighbors' padding (index n, distance inf)
  matches what split_half_neighbors expects.
- docs/quantifications.md (moved on this branch): picks up the updated
  bilayer_resolution text and the new forced_bilayer_prior column.

Real-data split-half results on the example OMM/IMM are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant