Skip to content

fix(#410): segmentation works in-app — sampling bug + flat model (3.13.1)#774

Closed
fernandotonon wants to merge 1 commit into
masterfrom
fix/segment-flat-model-and-sampling
Closed

fix(#410): segmentation works in-app — sampling bug + flat model (3.13.1)#774
fernandotonon wants to merge 1 commit into
masterfrom
fix/segment-flat-model-and-sampling

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Problem

The mesh part segmentation shipped in 3.13.0 (#771) was effectively broken in the app — it scored ≈0.33 per-vertex accuracy even on a correctly-oriented rigged mesh (the model itself was fine: 0.98 in Python). Two independent root causes, found by feeding the C++ path's exact points back through Python ORT:

1. Inverted point sampling (MeshSegmenter::predict)

For meshes with fewer vertices than samplePoints (4096 — i.e. most meshes), the sampling loop drew every point at random instead of covering verts 0..vc-1 in order. The nearest-point scatter then only labelled the randomly-hit verts (~63% coverage) and left the rest at the Torso default. Fixed: the first min(N, vc) points are verts in order (1:1 coverage), and N is capped at the vertex count so small meshes aren't padded with noisy duplicates.

2. In-graph kNN incompatible with the linked ONNX Runtime

The model used an in-graph kNN (cdist/topk/gather). Those ops did not execute correctly in the app's ONNX Runtime build — identical model + input gave 0.98 in Python ORT but 0.33 in the C++ app. Reworked the exported model to a flat PointNet segmentation network (per-point MLP → global max-pool → per-point head) using only Linear/GELU/max, which every ORT build runs identically.

Verified in the C++ app: 0.33 → 0.99 on a correctly-oriented rigged mesh.

Model — meshseg v1.2.0 (flat)

Retrained on synthetic + ~89 CC0 rigged characters (Quaternius CC0, Khronos glTF-Sample-Assets, ToxSam/Polygonal-Mind xyz CC0). Hosted on the HF repos (combined + dedicated). Training script gains: flat arch, --real-data/--real-weight rebalance with per-source (synth vs real) val reporting, and full 360° yaw augmentation.

Known limitation (documented): left/right limb assignment is inherently orientation-dependent — best on upright, front-facing meshes; a backward-facing mesh may mislabel L/R. Rigged meshes are unaffected — they use the exact rig-prior path, not the model.

Version

Bumps to 3.13.1 (3.13.0 shipped the broken model).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Mesh segmentation now labels small meshes more reliably, reducing missing or default labels.
    • Training and validation workflows now better distinguish real and synthetic data, with support for weighting real samples more heavily.
  • Bug Fixes

    • Improved point sampling so it no longer exceeds the number of vertices.
    • Updated ONNX export behavior and reporting for more consistent results.
  • Documentation

    • Bumped release and CI examples to version 3.13.1 throughout the docs and website fallback settings.

…odel

The shipped segmentation was broken in the app (≈0.33 acc even on a correctly
oriented mesh). Two root causes, both fixed:

1. **Inverted point sampling (MeshSegmenter::predict).** For meshes with fewer
   verts than `samplePoints` (the COMMON case), the sample loop drew EVERY
   point at random instead of covering verts 0..vc-1 in order. The scatter then
   only labelled the randomly-hit verts (~63% coverage) and left the rest at
   the Torso default — wrecking the output. Now the first min(N,vc) points are
   verts in order (1:1 coverage); N is also capped at the vertex count so a
   small mesh isn't padded with noisy random duplicates.

2. **Model architecture incompatible with the linked ONNX Runtime.** The model
   used an in-graph kNN (cdist/topk/gather); those ops did not execute
   correctly in the app's ONNX Runtime build (Python ORT gave 0.98 on the same
   model+input, the C++ app gave 0.33). Reworked the exported model to a flat
   PointNet segmentation network (per-point MLP + global max-pool + per-point
   head) using only Linear/GELU/max — ops every ORT build runs identically.
   Verified in the C++ app: 0.33 → 0.99 on a correctly-oriented rigged mesh.

Training (scripts/export-meshseg-onnx.py, dev-only, not shipped):
- flat PointSeg architecture (no kNN).
- `--real-data` loader + `--real-weight` rebalance: mix CC0-mined real rigs
  (rig-prior labels) with synthetic at a controlled ratio; per-source val acc
  reporting (synth vs real) so real-mesh quality is visible, not hidden in a
  blended average.
- full 360° yaw augmentation (was ±0.25rad) so facing direction matters less.

Model: meshseg v1.2.0 (flat) trained on synthetic + ~89 CC0 rigged characters
(Quaternius CC0 + Khronos glTF-Sample-Assets + ToxSam/Polygonal-Mind xyz CC0),
hosted on the HF repos. **Known limitation:** left/right limb assignment is
inherently orientation-dependent — best on upright, front-facing meshes; a
backward-facing mesh can mislabel L/R. Rigged meshes are unaffected (the exact
rig-prior path is used for them).

Bumps version to 3.13.1 (3.13.0 shipped the broken model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Bumps QtMeshEditor from 3.13.0 to 3.13.1 in CMakeLists.txt, README.md, and the website hook. Fixes a vertex-sampling inversion bug in MeshSegmenter::predict(). Rewrites the ONNX export training script with full-360° yaw augmentation, a --real-weight oversampling option, a flat PointNet model replacing the kNN architecture, and per-source validation metrics.

Changes

Version bump to 3.13.1

Layer / File(s) Summary
Version string across build, docs, and website
CMakeLists.txt, website/src/hooks/useQtmeshActionRef.js, README.md
project() version, the website fallback QTMESH_ACTION_REF_FALLBACK constant, and all README CI workflow snippets updated from 3.13.0 to 3.13.1.

MeshSegmenter fix and ONNX training improvements

Layer / File(s) Summary
MeshSegmenter::predict() vertex sampling cap
src/MeshSegmenter.cpp
N is now capped to min(max(256, samplePoints), vertexCount), fixing an inverted sampling path that left vertices unlabeled when vertexCount < samplePoints.
Augmentation and real-data oversampling
scripts/export-meshseg-onnx.py
Yaw augmentation in make_humanoid and _augment_upright changed to full 0..2π. Dataset builder gains --real-weight CLI arg and explicit src labels; real samples are oversampled and re-augmented.
PointSeg model rewrite and per-source validation
scripts/export-meshseg-onnx.py
PointSeg replaced from a kNN/PointNet++ architecture to a flat PointNet with per-point MLPs and global max-pooled context. Validation now reports separate synthetic and real point accuracies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇 A bunny hops from .0 to .1,
Vertices counted, sampling undone,
The kNN graph — retired at last,
Real meshes weighted, training recast,
Version bumped clean, the fix is spun! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main fix: in-app mesh segmentation, including the sampling bug and flat model update.
Description check ✅ Passed The description is detailed and covers the problem, technical fixes, model update, and version bump, though it does not follow the template headings exactly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/segment-flat-model-and-sampling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba74197ee9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# the old ±0.25rad yaw made the model brittle — a 180°-yawed mesh got its
# left/right mirrored and head mislabelled). We keep it UPRIGHT (only a tiny
# tilt) and DO NOT mirror, so true left/right handedness is preserved.
yaw = rng.uniform(0.0, 2*np.pi)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve a fixed facing frame for side labels

When retraining with the default synthetic data, this full-360° yaw rotates the primitive humanoid while leaving left_*/right_* labels unchanged. Because these generated bodies have no consistent front/back signal, integrating yaw over [0, 2π) makes the left- and right-side point distributions identical (a π yaw puts the right arm where the left arm normally is), so the labels become contradictory and the exported ONNX model cannot learn stable left/right segmentation; in-app results can arbitrarily swap arms/legs. Keep yaw within a fixed facing range or add a reliable orientation cue before using full yaw for side-labeled data.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
scripts/export-meshseg-onnx.py (1)

286-286: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the unused B in the shape unpack. N is the only value used here, so simplify this line to _, N, _ = f.shape (or N = f.shape[1]).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export-meshseg-onnx.py` at line 286, The shape unpack in the mesh
segmentation export code is assigning an unused batch dimension, so simplify the
unpack in the relevant block of the export script by dropping the unused B and
keeping only the needed N value; update the assignment near the f.shape handling
in the export logic to use a form like _, N, _ = f.shape or equivalent so the
code matches the actual usage.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/export-meshseg-onnx.py`:
- Line 216: The --knn CLI option is still presented as an active model parameter
even though PointSeg ignores k, so update the argument handling in
export-meshseg-onnx.py to mark --knn as deprecated/no-op. Adjust the argparse
definition and any related status output around the point where the model
configuration is printed so it no longer suggests the value affects
training/export, and ensure the same treatment is applied wherever the knn
setting is surfaced.
- Around line 249-257: The current shuffle-and-index split in
export-meshseg-onnx.py can leak augmented copies of the same real mesh across
train and validation. Update the dataset construction around the combined `P`,
`L`, and `src` arrays so that samples are grouped by source mesh before any
augmentation/oversampling, or keep a group identifier through `srcT` and perform
a group-aware split before `nval` is computed. Make sure the split logic in the
block that prints the combined dataset stats no longer relies on a random
permutation alone, and that all siblings from one real mesh stay in the same
partition.
- Around line 242-246: The oversampling path in load_real_data() appends
augmented real replicas from _augment_upright() directly into extraP without
re-centering/re-scaling them, so they can drift from the normalized distribution
used at inference. Normalize each augmented replica immediately after
_augment_upright() and before appending it to extraP, keeping the existing label
handling in extraL unchanged.

In `@src/MeshSegmenter.cpp`:
- Around line 469-486: The new capped sampling logic in
MeshSegmenter::segmentMesh (the point-selection loop that fills pts/srcVert) is
a significant user-visible operation but still lacks Sentry breadcrumb tracking.
Add a SentryReporter::addBreadcrumb(...) call in this sampling path using the
established breadcrumb category/message pattern so regressions in the new
inference-path behavior are traceable in production.

---

Nitpick comments:
In `@scripts/export-meshseg-onnx.py`:
- Line 286: The shape unpack in the mesh segmentation export code is assigning
an unused batch dimension, so simplify the unpack in the relevant block of the
export script by dropping the unused B and keeping only the needed N value;
update the assignment near the f.shape handling in the export logic to use a
form like _, N, _ = f.shape or equivalent so the code matches the actual usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 108f39e7-1d94-4242-817d-2e40ca6912f0

📥 Commits

Reviewing files that changed from the base of the PR and between fa29255 and ba74197.

📒 Files selected for processing (5)
  • CMakeLists.txt
  • README.md
  • scripts/export-meshseg-onnx.py
  • src/MeshSegmenter.cpp
  • website/src/hooks/useQtmeshActionRef.js

help="oversample factor for real samples so they aren't drowned "
"out by synthetic. 0 = auto-balance to ~50/50 real:synthetic")
ap.add_argument("--dim", type=int, default=128, help="model feature width")
ap.add_argument("--knn", type=int, default=12, help="local kNN neighbours")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mark --knn as deprecated/no-op.

PointSeg now ignores k, but the CLI help and status print still present --knn as an active model parameter. That can mislead training runs.

Proposed cleanup
-    ap.add_argument("--knn", type=int, default=12, help="local kNN neighbours")
+    ap.add_argument("--knn", type=int, default=12,
+                    help="deprecated/no-op; accepted for compatibility with older commands")
...
-    print(f"data N={n} points={N} dev={dev} dim={a.dim} knn={a.knn}")
+    print(f"data N={n} points={N} dev={dev} dim={a.dim} flat-PointNet (--knn ignored)")

Also applies to: 259-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export-meshseg-onnx.py` at line 216, The --knn CLI option is still
presented as an active model parameter even though PointSeg ignores k, so update
the argument handling in export-meshseg-onnx.py to mark --knn as
deprecated/no-op. Adjust the argparse definition and any related status output
around the point where the model configuration is printed so it no longer
suggests the value affects training/export, and ensure the same treatment is
applied wherever the knn setting is surfaced.

Comment on lines +242 to +246
for _ in range(reps_i - 1):
for i in range(len(rP)):
extraP.append(_augment_upright(rP[i], rng)); extraL.append(rL[i])
rP = np.concatenate([rP, np.stack(extraP)])
rL = np.concatenate([rL, np.stack(extraL)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Normalize re-augmented real replicas.

load_real_data() re-centers/re-scales augmented real samples, but this oversampling path rotates/jitters replicas and appends them directly. Normalize each replica after _augment_upright() so training matches the app’s normalized inference distribution.

Proposed fix
                 extraP, extraL = [], []
                 for _ in range(reps_i - 1):
                     for i in range(len(rP)):
-                        extraP.append(_augment_upright(rP[i], rng)); extraL.append(rL[i])
+                        pp = _augment_upright(rP[i], rng)
+                        c = 0.5 * (pp.min(0) + pp.max(0))
+                        h = float(np.max(0.5 * (pp.max(0) - pp.min(0))))
+                        extraP.append(((pp - c) / (h + 1e-9)).astype(np.float32))
+                        extraL.append(rL[i])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _ in range(reps_i - 1):
for i in range(len(rP)):
extraP.append(_augment_upright(rP[i], rng)); extraL.append(rL[i])
rP = np.concatenate([rP, np.stack(extraP)])
rL = np.concatenate([rL, np.stack(extraL)])
extraP, extraL = [], []
for _ in range(reps_i - 1):
for i in range(len(rP)):
pp = _augment_upright(rP[i], rng)
c = 0.5 * (pp.min(0) + pp.max(0))
h = float(np.max(0.5 * (pp.max(0) - pp.min(0))))
extraP.append(((pp - c) / (h + 1e-9)).astype(np.float32))
extraL.append(rL[i])
rP = np.concatenate([rP, np.stack(extraP)])
rL = np.concatenate([rL, np.stack(extraL)])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export-meshseg-onnx.py` around lines 242 - 246, The oversampling path
in load_real_data() appends augmented real replicas from _augment_upright()
directly into extraP without re-centering/re-scaling them, so they can drift
from the normalized distribution used at inference. Normalize each augmented
replica immediately after _augment_upright() and before appending it to extraP,
keeping the existing label handling in extraL unchanged.

Comment on lines 249 to 257
sh = np.random.default_rng(a.seed + 1).permutation(len(P))
P = P[sh]; L = L[sh]
P = P[sh]; L = L[sh]; src = src[sh]
print(f"combined dataset: {len(P)} samples "
f"({a.samples} synthetic + {len(rP)} real)")
f"({a.samples} synthetic + {len(rP)} real @ {reps_i}x = "
f"{100*len(rP)/len(P):.0f}% real)")
P = torch.tensor(P); L = torch.tensor(L)
srcT = torch.tensor(src)
n = P.shape[0]; N = P.shape[1]
nval = max(1, n // 10)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Avoid train/validation leakage for real meshes.

Real samples include augmented copies from the same source mesh, then the combined sample list is shuffled and split by index. This can put sibling augmentations of one real mesh into both train and validation, making the new real accuracy optimistic. Split real data by source mesh before augmentation/oversampling, or carry group IDs and group-split before computing nval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export-meshseg-onnx.py` around lines 249 - 257, The current
shuffle-and-index split in export-meshseg-onnx.py can leak augmented copies of
the same real mesh across train and validation. Update the dataset construction
around the combined `P`, `L`, and `src` arrays so that samples are grouped by
source mesh before any augmentation/oversampling, or keep a group identifier
through `srcT` and perform a group-aware split before `nval` is computed. Make
sure the split logic in the block that prints the combined dataset stats no
longer relies on a random permutation alone, and that all siblings from one real
mesh stay in the same partition.

Comment thread src/MeshSegmenter.cpp
Comment on lines +469 to +486
// Cap N at the vertex count: padding a small mesh up to samplePoints with
// random duplicate points only adds noise to the model's global max-pool
// and never improves coverage (every real vert is already point i). Sample
// DOWN only when the mesh is larger than samplePoints.
const int N = std::min(std::max(256, opts.samplePoints), vertexCount);
// Deterministic point sample (with replacement if the mesh is small).
std::mt19937 rng(0x5e6u); // NOSONAR — non-crypto, fixed for reproducibility
std::uniform_int_distribution<int> pick(0, vertexCount - 1);
std::vector<float> pts(static_cast<size_t>(N) * 3);
std::vector<int> srcVert(N);
for (int i = 0; i < N; ++i) {
const int v = (vertexCount >= N) ? (i < vertexCount ? i : pick(rng)) : pick(rng);
// The first min(N, vertexCount) points are verts 0..vc-1 IN ORDER so
// every vertex is covered 1:1 (the scatter's `vertexCount <= N`
// branch relies on this). Only the padding beyond vc (when N > vc) is
// random. (Previously this was inverted: when vc < N — the common
// case — every point was random, so ~37% of verts never got a real
// label and fell back to the Torso default, wrecking the result.)
const int v = (i < vertexCount) ? i : pick(rng);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a breadcrumb for this inference-path sampling change.

This alters a significant, user-visible operation, but the new capped-sampling path still has no SentryReporter::addBreadcrumb(...) instrumentation. That makes production regressions on small meshes much harder to diagnose.

As per coding guidelines, "Track all user-facing actions and significant operations with SentryReporter::addBreadcrumb(category, message) using the established breadcrumb categories."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MeshSegmenter.cpp` around lines 469 - 486, The new capped sampling logic
in MeshSegmenter::segmentMesh (the point-selection loop that fills pts/srcVert)
is a significant user-visible operation but still lacks Sentry breadcrumb
tracking. Add a SentryReporter::addBreadcrumb(...) call in this sampling path
using the established breadcrumb category/message pattern so regressions in the
new inference-path behavior are traceable in production.

Source: Coding guidelines

@sonarqubecloud

Copy link
Copy Markdown

@fernandotonon

Copy link
Copy Markdown
Owner Author

Closing — after extensive experimentation (8 model iterations + connectivity/smoothing/auto-rig + a Mixamo-vs-CC0 training comparison), we concluded the segmentation rework wasn't a clear improvement over the shipped model, and reverting to keep the known-good state. Key findings documented for the future: the in-app model load path is nested (QtMeshEditor/QtMeshEditor/), the sampling loop had an inversion bug worth revisiting, and (settled empirically) Mixamo training data does NOT beat CC0/synthetic. Parking this rather than shipping a regression.

@fernandotonon fernandotonon deleted the fix/segment-flat-model-and-sampling branch June 28, 2026 23:24
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