8000 fix(feature): orientation-invariant mesh shape features, unit-consistent curvature, lazy computation by alxndrkalinin · Pull Request #61 · alxndrkalinin/cubic · GitHub
[go: up one dir, main page]

Skip to content

fix(feature): orientation-invariant mesh shape features, unit-consistent curvature, lazy computation - #61

Open
alxndrkalinin wants to merge 27 commits into
mainfrom
fix/mesh-features
Open

alxndrkalinin wants to merge 27 commits into
mainfrom
fix/mesh-features

Conversation

@alxndrkalinin
@alxndrkalinin alxndrkalinin commented Oct 29, 2026 •
Copy link
Copy Markdown
Owner

Summary

A review of cubic/feature/mesh.py found orientation-dependent shape features, curvature features that did not convert with the spacing unit, 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. Scale and Bounding Box Volume are removed, and Avg Gaussian Curvature is replaced by Euler 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_features used 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, so extract_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 to main.

2. CI runs the mesh and plot tests (ci)

The test job synced only --extra dev, so trimesh was missing and tests/feature/test_mesh.py skipped at import (1 skipped locally without trimesh). tests/test_plot_utils.py skipped 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 shows tests/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 Length were 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.

0° 30° 45°
axis lengths, before 12, 16, 48 12, 28, 42 12, 36, 36
axis lengths, after 12.02, 15.96, 47.70 12.14, 15.92, 47.55 12.12, 16.01, 47.66
Extent, before 0.518 0.340 0.310
Extent, after 0.551 0.504 0.522
  • The axis lengths are now the full axes of the ellipsoid with the same inertia, regionprops' axis_major_length convention. On the 30° mask, regionprops gives 12.11 / 47.67 (minor / major) and the mesh gives 12.14 / 47.55.
  • Extent now divides by the oriented bounding box volume (π/6 = 0.524 for an ideal ellipsoid). trimesh's oriented box comes from an approximate minimum search, so Extent still spans 0.50–0.55 across rotations; the docstring says so rather than calling it invariant.
  • Removed: Scale (the axis-aligned box diagonal) and Bounding 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:

  • The same voxels with spacing 0.1 instead of 1 changed them 77.8× (Gaussian) and 10.9× (mean). A change of unit alone should give 100× and 10×.
  • Sampling the same physical sphere twice as finely changed the Gaussian one 0.46×.

They are now surface averages:

  • Avg Mean Curvature = ∫H dA / A, with ∫H dA = ½ Σ edge length × signed dihedral angle (Cohen-Steiner and Morvan), via trimesh's 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 the mesh extra now requires trimesh>=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).
  • Euler Characteristic replaces Avg Gaussian Curvature. As a surface average, the Gaussian curvature is exactly 2π·χ / A (Gauss–Bonnet), so it was 4π / Area for every label without holes and added nothing beyond Area and the 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).
  • Changing the unit now scales mean curvature by exactly 10× and leaves χ unchanged.

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, so rtree leaves the mesh extra. uv.lock was 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 outside mesh.py.
  • Unknown feature names raise ValueError up front and list the available names. Before, they raised KeyError after 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_features returns (labels, values, column_names), the same triple as voxel.extract_features.
  • cubic.feature.mesh is reachable without an explicit submodule import. It loads on first access through a module __getattr__, as cubic.scipy already does, so import cubic.feature does not pay trimesh's 0.75–3 s import. mesh stays out of __all__, so from cubic.feature import * does not load trimesh either.
  • The package __getattr__ is defined only outside type checking, with a TYPE_CHECKING import of mesh. Otherwise mypy typed every unknown attribute of cubic.feature as a module, typos included.
  • A bare string as features raises TypeError instead of being checked letter by letter.
  • The feature registry is typed as returning SupportsFloat. With trimesh installed, mypy rejected trimesh's numpy float64 returns as float; CI's lint job runs without trimesh, so it didn't see this.
  • AGENTS.md describes the module.

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)
  • Each new behavioural test fails on the commit before its fix: orientation (30°, 45°), the three curvature tests, cubic.feature.mesh access, and the star import
  • Full suite with trimesh and matplotlib: 762 passed, 166 skipped (GPU tests; no CuPy on this host)
  • ruff check ., ruff format --check ., mypy --ignore-missing-imports cubic/ (with and without trimesh installed)
  • CI green on all three Python versions, with the mesh tests running (first push)

🤖 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:

  • Report Euler characteristic as the mesh topology feature.
  • Compute mesh features selectively based on the requested feature names.

Bug Fixes:

  • Make mesh axis lengths and extent substantially independent of image orientation.
  • Calculate mean curvature as a spacing-aware surface average and remove the rtree requirement.
  • Correct mesh feature API validation and return column names consistently with voxel feature extraction.

Enhancements:

  • Replace the image-axis-based mesh measurements with oriented or inertia-based geometric measurements and rename the sphericity helper generically.
  • Load the mesh feature module lazily so importing other feature modules does not eagerly import trimesh.

Build:

  • Require trimesh 4.6.12 or newer for mesh features and remove rtree from the mesh dependency extra.
  • Regenerate the dependency lockfile for the updated mesh dependencies.

CI:

  • Install mesh and plotting extras in the test workflow so their test suites run in CI.

Documentation:

  • Document the updated mesh feature definitions, units, and topology semantics.
  • Add contributor guidance for the mesh feature module.

Tests:

  • Expand mesh tests to cover orientation robustness, selective computation, curvature scaling, topology, API validation, lazy imports, and the updated feature set.

Chores:

  • Remove the Scale, Bounding Box Volume, and Avg Gaussian Curvature features in favor of the revised geometry and topology metrics.

alxndrkalinin and others added 10 commits October 29, 2026 15:03
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
@sourcery-ai
sourcery-ai Bot commented Oct 29, 2026 •
Copy link
Copy Markdown
Contributor

Reviewer's Guide

This 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 extraction

sequenceDiagram
    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
8000
Loading

Flow diagram for spacing-aware curvature calculation

flowchart 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
Loading

File-Level Changes

Change Details Files
Made mesh feature extraction request-driven through a feature registry, with early validation and API consistency improvements.
  • Compute only selected features and preserve default feature ordering.
  • Raise descriptive ValueError for unknown names, including empty-label inputs.
  • Return labels, values, and column names; make empty feature selections produce zero-column output.
  • Expose mesh lazily via cubic.feature and rename the generic sphericity helper.
cubic/feature/mesh.py
cubic/feature/__init__.py
tests/feature/test_mesh.py
Reworked geometric shape measurements to be orientation-invariant and removed redundant axis-aligned metrics.
  • Derive axis lengths from principal inertia to match equivalent-ellipsoid/regionprops conventions.
  • Compute Extent using the oriented bounding-box volume.
  • Remove Scale and Bounding Box Volume from the feature set.
cubic/feature/mesh.py
tests/feature/test_mesh.py
AGENTS.md
Replaced dependency-heavy local curvature measures with vectorized surface-average curvature calculations that honor physical spacing.
  • Compute Gaussian curvature from vertex angle defects and mean curvature from edge lengths and signed dihedral angles.
  • Ensure curvature scales as inverse length and inverse squared length with spacing.
  • Remove the rtree dependency from the mesh extra and update the lockfile.
cubic/feature/mesh.py
pyproject.toml
uv.lock
tests/feature/test_mesh.py
Enabled mesh tests in CI by installing the optional mesh dependencies.
  • Install both dev and mesh extras in the test workflow so trimesh-backed tests no longer skip.
.github/workflows/lint-format.yml
Expanded regression coverage for geometry, curvature, lazy imports, and the revised API.
  • Add orientation, selective-computation, curvature scaling/topology, sphericity, and lazy-import tests.
  • Update existing tests for removed features and the three-value extraction return.
tests/feature/test_mesh.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread cubic/feature/mesh.py Resolved
Comment thread cubic/feature/mesh.py Resolved
Comment thread cubic/feature/mesh.py Resolved
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

alxndrkalinin and others added 15 commits October 29, 2026 15:20
_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
alxndrkalinin and others added 2 commits October 29, 2026 16:05
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

This branch has not been deployed

No deployments
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

0