Repository navigation
fix(feature): orientation-invariant mesh shape features, unit-consistent curvature, lazy computation - #61
fix(feature): orientation-invariant mesh shape features, unit-consistent curvature, lazy computation#61alxndrkalinin wants to merge 27 commits into
Conversation
extract_mesh_features computed all 20 features whatever was asked for. Mean curvature alone took 15.4 s of a 15.8 s per-label total on a 13k-vertex nucleus, so extract_features(img, ["Volume"]) paid for it on every label. Each feature is now a registry entry evaluated only when requested; trimesh caches the shared convex hull and bounding primitives, so features that share them still compute them once. Default output is bitwise identical to before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The test job synced only the dev extra, so trimesh was missing and tests/feature/test_mesh.py skipped at import on every run: all of its tests had never executed in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Min/Med/Max Axis Length were the sorted extents of the axis-aligned bounding box, so they measured how an object sat in the image rather than its shape: turning an ellipsoid with axes (12, 16, 48) by 45 degrees about z gave (12, 36, 36). Extent divided by the same box and fell from 0.52 to 0.31. The axis lengths are now the full axes of the ellipsoid with the same principal moments of inertia, regionprops' axis_major_length convention: (12.02, 15.96, 47.70) at 0 degrees, (12.12, 16.01, 47.66) at 45. Extent divides by the oriented bounding box volume (0.50-0.55 across 0/30/45 degrees, against pi/6 = 0.524 for an ideal ellipsoid). Scale (the axis-aligned box diagonal) and Bounding Box Volume are dropped: both are defined on the image axes, and the oriented box volume and the axis lengths already carry what they measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Both curvature features were vertex averages of trimesh's curvature measures integrated over balls of radius 1 in the units of spacing. The radius did not follow the unit or the resolution: the same voxels with spacing 0.1 instead of 1 changed them 78x and 11x rather than 100x and 10x, and sampling the same physical sphere twice as finely changed the Gaussian one 0.46x. The mean curvature measure also looped over vertices in Python and took 15.4 s of a 15.8 s per-label total for a 13k-vertex nucleus. They are now surface averages. The mean curvature integrates as half the sum of edge length times signed dihedral angle (Cohen-Steiner and Morvan), giving 1/R within 2.2% on voxelized spheres of radius 4-24 and converging under finer sampling. The Gaussian curvature integrates to the angle-defect sum, 2 pi times the Euler characteristic exactly, so it is 4 pi / area for objects without handles or cavities. Both are vectorized: the full default feature set now takes 0.29 s for the 13k-vertex nucleus and 0.83 s for a 51k-vertex one (was 208 s). Neither needs mesh.face_adjacency_tree, so rtree leaves the mesh extra and the per-function dependency guard goes back to a trimesh check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The formula is Wadell's sphericity for any body, nothing specific to ellipsoids, and the name suggested otherwise. No caller outside mesh.py used the old name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
An unknown name raised a bare KeyError only after every feature of the first label had been computed, and an image without labels accepted it silently. Names are now checked up front in extract_features and extract_mesh_features, and the ValueError lists the available ones. features=[] now yields an (n_labels, 0) matrix instead of silently falling back to the full default set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
voxel.extract_features returns (labels, values, column_names) while the mesh variant returned only (labels, values), so the two could not be swapped and callers had to rebuild the names themselves. The mesh variant now returns the same triple. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
cubic.feature did not export the mesh module, so cubic.feature.mesh raised AttributeError unless the submodule had been imported explicitly. A module-level __getattr__ now imports it on first access, the pattern cubic.scipy already uses, so the 0.75-3 s trimesh import still stays off the import path of the voxel and texture features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The inline literal was split across two docstring lines, which breaks its rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Reviewer's GuideThis PR makes mesh feature extraction lazy and API-consistent, changes axis and extent measurements to orientation-invariant definitions, replaces rtree-dependent curvature calculations with fast spacing-aware surface averages, removes redundant features, and ensures the mesh test suite runs in CI. Sequence diagram for requested mesh feature extractionsequenceDiagram
participant Caller
participant Extract as extract_features
participant Surface as extract_surface_features
participant Mesh as extract_mesh_features
participant Registry as _MESH_FEATURES
Caller->>Extract: extract_features(label_image, features, spacing)
Extract->>Extract: _check_feature_names(features)
loop each label
Extract->>Surface: extract_surface_features(mask, spacing, features)
Surface->>Surface: mask2mesh(mask, spacing)
Surface->>Mesh: extract_mesh_features(mesh, features)
loop each requested feature
Mesh->>Registry: evaluate feature callable
Registry-->>Mesh: feature value
end
Mesh-->>Surface: requested feature values
Surface-->>Extract: feature dictionary
end
Extract-->>Caller: labels, feature_matrix, column_names
Flow diagram for spacing-aware curvature calculationflowchart TD
A[Trimesh surface with spacing-scaled vertices] --> B{Requested curvature}
B -->|Avg Gaussian Curvature| C[Sum mesh.vertex_defects]
C --> D[Divide by mesh.area]
B -->|Avg Mean Curvature| E[Get face adjacency edges and lengths]
E --> F[Apply signed dihedral angles]
F --> G[Sum edge contributions and divide by mesh.area]
D --> H[Return surface average]
G --> H
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 3 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="cubic/feature/mesh.py" line_range="302" />
<code_context>
- return float(gaussian), float(mean)
+ edges = mesh.vertices[mesh.face_adjacency_edges]
+ lengths = np.linalg.norm(edges[:, 1] - edges[:, 0], axis=1)
+ signs = np.where(mesh.face_adjacency_convex, 1.0, -1.0)
+ return 0.5 * np.sum(lengths * mesh.face_adjacency_angles * signs) / mesh.area
+
</code_context>
<issue_to_address>
**Cavity curvature loses its sign**
When a label contains an enclosed cavity and mean curvature is requested, `mask2mesh` passes disconnected shells through `trimesh.repair.fix_normals`, which orients each shell outward independently. `_avg_mean_curvature` then treats the inner cavity shell's convex edges as positive, so its signed surface average does not represent the boundary curvature of the labeled solid.
Preserve or reconstruct shell orientation relative to the labeled material so inner cavity surfaces contribute with the opposite sign.
</issue_to_address>
### Comment 2
<location path="cubic/feature/mesh.py" line_range="130" />
<code_context>
mask = label_image[bboxes[label - 1]] == label
- mesh_features = extract_surface_features(mask, spacing=spacing)
+ mesh_features = extract_surface_features(
+ mask, spacing=spacing, features=features
+ )
feature_values.append([mesh_features[feat] for feat in features])
</code_context>
<issue_to_address>
**Empty selections still build meshes**
When a caller passes `features=[]` for an image with labels, `extract_features` calls `extract_surface_features` for every label, which constructs and validates a mesh even though no features are requested; callers incur unnecessary work and can receive geometry errors instead of the zero-column result.
Return the zero-column result from `extract_features` before per-label mesh extraction when `features` is empty.
Also at `cubic/feature/mesh.py:144-148`.
</issue_to_address>
### Comment 3
<location path="cubic/feature/mesh.py" line_range="242" />
<code_context>
- features = features or mesh_feature_list()
+ if features is None:
+ features = mesh_feature_list()
+ _check_feature_names(features)
# ``find_objects`` returns the bounding box slices indexed by ``label - 1``,
# so each label is meshed over its own crop instead of the whole volume.
</code_context>
<issue_to_address>
**Unknown names lose precedence**
When a caller requests an unknown feature with an invalid mesh, or passes an unknown feature to `extract_surface_features`, `extract_surface_features` constructs a mesh before forwarding feature names, and `extract_mesh_features` reads `mesh.volume` and `mesh.area` before validating them, so meshing or geometry validation fails before the caller receives the unknown-feature error.
Validate feature names at each function entry before constructing a mesh or accessing mesh geometry.
Also at `cubic/feature/mesh.py:148`.
</issue_to_address>Sourcery assessment
Approval pending. 3 findings to address first.
Blocking findings: cubic/feature/mesh.py:302, cubic/feature/mesh.py:130, cubic/feature/mesh.py:242
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are identified for the mesh measurement and API updates.
No open issues 🎉
What changed in this PR
This PR improves cubic’s mesh-based morphometry with orientation-invariant shape measurements, unit-consistent curvature, and computation limited to requested features. It also updates the mesh API and enables mesh tests in CI.
Changes:
- Replace axis-aligned measurements and fixed-radius curvature calculations with inertia-based axes, oriented Extent, and surface-average curvatures.
- Add selective feature computation, feature-name validation, column names in results, and lazy module access; remove two obsolete features.
- Drop
rtree, expand regression coverage, and install mesh dependencies in CI.
| File | Description |
|---|---|
uv.lock |
Remove locked rtree dependency entries. |
tests/feature/test_mesh.py |
Cover geometry, curvature, selective computation, and API behavior. |
pyproject.toml |
Remove rtree from the mesh extra. |
cubic/feature/mesh.py |
Revise measurements, compute requested features, and update the extraction API. |
cubic/feature/__init__.py |
Expose the mesh submodule through lazy loading. |
AGENTS.md |
Document mesh capabilities and execution scope. |
.github/workflows/lint-format.yml |
Install mesh dependencies so mesh tests run in CI. |
Review effort: Balanced
_avg_mean_curvature rebuilt Trimesh.integral_mean_curvature, a cached trimesh property that gives the same value (equal to the last bit on a voxelized ball). trimesh signed the dihedral angles only from 4.6.12 on; 4.6.11 returns 71.8 instead of 2 pi^2 R = 59.2 on a torus. The mesh extra therefore now requires trimesh>=4.6.12. The mesh tests pass on both 4.6.12 and the locked 4.11.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
_TRIMESH_AVAILABLE restated whether the import had bound trimesh, and was set in three places to stay in sync with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
trimesh annotates volume, area and mass as numpy float64, which the numpy 2.0 stubs do not treat as float. With trimesh installed, mypy rejected the registry's Callable[..., float] values and the sphericity call. CI's lint job runs without trimesh, so it did not see this. The registry now returns SupportsFloat, which extract_mesh_features already converts, and sphericity receives plain floats. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
extract_features and extract_mesh_features each repeated the same default-then-validate block. _resolve_features now returns the resolved list, so the default and the error message live in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
_equivalent_ellipsoid_axes described the bounding-box extents it replaced, with the numbers of one example. That history is in the tests and the commit log; the docstring now states only what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Three tests each defined the same extract_surface_features stub, so adding the features parameter meant three identical edits. A surface_calls fixture now records each call's mask and spacing. The ball, ellipsoid and torus builders share _centred_grid instead of three spellings of a centred meshgrid, one of them with a hardcoded centre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The subprocess imported trimesh only to check the last assertion, 3.9 s of the file's 7.6 s. It now checks only that importing cubic.feature skips trimesh. The in-process call to the package __getattr__ covers the access path. The test takes 0.11 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
With "mesh" in __all__, `from cubic.feature import *` resolved it through __getattr__ and loaded trimesh, undoing the lazy import, and warned on hosts without trimesh. Attribute access still loads it on demand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
tests/test_plot_utils.py skipped at import in CI for want of matplotlib, the same gap the mesh extra had. The plot extra is as light, so the test job installs it too. cellpose stays out because it pulls in torch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
mesh_feature_list and AGENTS.md called every feature orientation- invariant. None is defined on the image axes any more, but voxelization moves all of them a little, and trimesh's oriented box and bounding cylinder come from approximate minimum searches: Extent spans 0.50-0.55 for one ellipsoid at 0, 30 and 45 degrees. The docs now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
mypy types every unknown attribute of a module that defines __getattr__
with that function's return type, so cubic.feature.regionprop type-
checked as a module and failed only at run time. __getattr__ is now
defined only outside type checking, and mypy learns about mesh from a
TYPE_CHECKING import. The typo is now reported ("Module has no
attribute 'regionprop'; maybe 'regionprops'?").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The subprocess had no timeout, so an import that hangs in the child, such as CUDA probing on a node with a broken driver, would stall the whole pytest run without output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
extract_features(img, "Volume") iterated the string and reported Unknown mesh features ['V', 'o', 'l', 'u', 'm', 'e']. A string now raises a TypeError that names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
…ristic As a surface average, the Gaussian curvature is exactly 2 pi * euler_number / area by Gauss-Bonnet, so the column was 4 pi / Area for every label without holes and carried nothing beyond Area and one integer. That integer is now its own column: twice the separate pieces plus cavities, minus twice the handles (2 for a ball, 4 for a hollow ball or two balls, 0 for a torus). It is free to compute and does not depend on the spacing unit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
A hollow ball's shells must keep their orientation relative to the material: the cavity faces inward and counts as concave, so the hollow ball integrates to the outer ball minus the inner one (82.1), not their sum (244.2). mask2mesh already does this, because fix_normals runs without multibody, but nothing tested it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
extract_surface_features built the mesh before the names reached a check, and extract_mesh_features validated the mesh geometry first. An unknown name with an open mesh therefore surfaced as "Mesh volume must be finite and positive" instead of the unknown-feature error, and a valid mask was meshed for nothing. Both entry points now resolve the names first, as extract_features already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
extract_features(img, []) still meshed and validated every label just to return an (n_labels, 0) matrix, and could raise a geometry error instead. It now returns that matrix straight away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Summary
A review of
cubic/feature/mesh.pyfound orientation-dependent shape features, curvature features that did not convert with thespacingunit, a per-label cost dominated by one feature that ran even when not requested, and a test file that never ran in CI. This PR fixes those and the smaller API issues. Feature values change for axis lengths, Extent and mean curvature.ScaleandBounding Box Volumeare removed, andAvg Gaussian Curvatureis replaced byEuler Characteristic(details below).Measurements are on voxelized synthetic shapes with trimesh 4.11.2, the locked version.
1. Only requested features are computed (
perf)extract_mesh_featuresused to compute all 20 features whatever was asked for. Mean curvature alone took 15.4 s of a 15.8 s per-label total on a 13k-vertex nucleus, soextract_features(img, ["Volume"])paid for it on every label. Each feature is now a registry entry evaluated on request. Default output from this commit is bitwise identical tomain.2. CI runs the mesh and plot tests (
ci)The test job synced only
--extra dev, so trimesh was missing andtests/feature/test_mesh.pyskipped at import (1 skippedlocally without trimesh).tests/test_plot_utils.pyskipped the same way for want of matplotlib. The job now adds--extra mesh --extra plot. cellpose stays out because it pulls in torch. The CI log showstests/feature/test_mesh.py .......................(23 passed).3. Axis lengths and Extent no longer defined on the image axes (
fix, values change)Min/Med/Max Axis Lengthwere the sorted extents of the axis-aligned bounding box. For an ellipsoid with axes (12, 16, 48), turning it 45° about z gave (12, 36, 36), and Extent fell from 0.52 to 0.31.axis_major_lengthconvention. On the 30° mask, regionprops gives 12.11 / 47.67 (minor / major) and the mesh gives 12.14 / 47.55.Scale(the axis-aligned box diagonal) andBounding Box Volume. Both are defined on the image axes, and the oriented box volume and the axis lengths already cover what they measured.4. Mean curvature as a surface average, Euler characteristic instead of Gaussian curvature; rtree dropped (
fix, values change)Both curvatures were vertex averages of trimesh's curvature measures over balls of radius 1 in the units of
spacing, so the radius did not follow the unit or the resolution:They are now surface averages:
Trimesh.integral_mean_curvature. trimesh signs the angles only from 4.6.12 on (4.6.11 returns 71.8 instead of 2π²R = 59.2 on a torus), so themeshextra now requirestrimesh>=4.6.12. The mesh tests pass on 4.6.12 and on the locked 4.11.2. On voxelized spheres of radius 4–24 it gives R·⟨H⟩ = 0.979–1.002, against 0.86–0.90 for the old ball measure. It converges under finer sampling (0.984 → 0.996 → 0.998).Both are vectorized. The full default set takes 0.28–0.30 s for the 13k-vertex nucleus (was 15.8 s) and 0.81–0.85 s for a 51k-vertex one (was 208–341 s), 3 runs each. Neither needs
mesh.face_adjacency_tree, sortreeleaves themeshextra.uv.lockwas regenerated with uv 0.9.4 to keep lock revision 3; only the rtree entries and the trimesh specifier change.I also prototyped a vectorized form of the old ball measure. It matched trimesh to ≤5.6e-16 relative, but at 0.1 µm voxels the fixed 1 µm radius spans ~10 voxels and it peaked at 8 GB RSS. The surface average replaces it.
Minor
ellipsoid_sphericity→sphericity: it is Wadell's formula for any body. No caller outsidemesh.py.ValueErrorup front and list the available names. Before, they raisedKeyErrorafter the first label was fully computed, and were silently accepted on images without labels.features=[]now gives(n, 0)instead of falling back to the defaults.mesh.extract_featuresreturns(labels, values, column_names), the same triple asvoxel.extract_features.cubic.feature.meshis reachable without an explicit submodule import. It loads on first access through a module__getattr__, ascubic.scipyalready does, soimport cubic.featuredoes not pay trimesh's 0.75–3 s import.meshstays out of__all__, sofrom cubic.feature import *does not load trimesh either.__getattr__is defined only outside type checking, with aTYPE_CHECKINGimport ofmesh. Otherwise mypy typed every unknown attribute ofcubic.featureas a module, typos included.featuresraisesTypeErrorinstead of being checked letter by letter.SupportsFloat. With trimesh installed, mypy rejected trimesh's numpyfloat64returns asfloat; CI's lint job runs without trimesh, so it didn't see this.No other repo in the workspace (VisCy, dynacell, cubic_paper, organelle_box_metrics, cp_measure) imports the mesh module, so the changes to names and return shapes have no known downstream callers.
Test plan
tests/feature/test_mesh.py: 23 passed with trimesh and without rtree, on trimesh 4.6.12 and 4.11.2 (it previously skipped entirely in CI)cubic.feature.meshaccess, and the star importruff check .,ruff format --check .,mypy --ignore-missing-imports cubic/(with and without trimesh installed)🤖 Generated with Claude Code
https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
Summary by Sourcery
Modernize mesh feature extraction with orientation-robust geometry, unit-consistent curvature, topology reporting, selective computation, and improved optional-dependency handling.
New Features:
Bug Fixes:
Enhancements:
Build:
CI:
Documentation:
Tests:
Chores: