Skip to content

fix: accept single-class end2end YOLOv10 exports in NMS-free postprocessing - #8

Merged
parkjinman98 merged 11 commits into
mainfrom
jm/face
Sep 3, 2026
Merged

parkjinman98 merged 11 commits into
mainfrom
jm/face

Conversation

@parkjinman98

Copy link
Copy Markdown
Contributor

Problem

YOLOv10*-face ONNX artifacts could not be postprocessed at all. Every one of
the four models raised:

ValueError: Wrong shape of input: torch.Size([1, 1, 300, 5])

build_postprocess dispatches these correctly to
YOLONMSFreeFaceDetectionPost, and extract_final_outputs already handles
qbcompiler's decode-enabled output triplet. But a single already-decoded
end2end tensor was only recognised at six columns, because
base._normalize_final_detection_tensor is called with a hardcoded
final_det_dim = 6 + n_extra (xyxy, confidence, class id).

A model with nc == 1 has a class column that is constant 0, and
mblt-model-ops strips it at export, so the shipped *-face ONNX emits
(1, N, 5). That never matched, fell through to raw split-head decoding, and
was rejected. The six-column object-detection form (YOLOv10m, YOLOv10s)
was unaffected and worked throughout.

Fix

YOLONMSFreeDetectionPost.extract_final_outputs now restores the implicit
class column when a single-class model presents the five-column form, then
delegates to the existing six-column path rather than duplicating any decode
logic.

At nc == 1 five columns are also the width of a converted xywh candidate
tensor (4 + nc), which is a different thing and belongs to non_e2e. The two
are separated by row count: an end2end export is top-k selected and never
exceeds max_det, while a converted tensor carries one row per anchor (8400 at
640x640). Only the bounded form is treated as decoded detections.

Validation

  • All 24 *-face models in mblt-model-ops now agree with their model-local
    common/local_inference.py on identical inputs: 24/24 on ONNX and 24/24 on
    MXQ
    across all three decode families (9 anchor, 11 anchorless, 4 nmsfree),
    scores within 0.005.
  • Before the fix the four nmsfree models failed 4/4 on ONNX; anchor and
    anchorless were already 20/20, so the change is scoped to the broken path.
  • Six-column end2end is unchanged: YOLOv10m still returns 160 multi-class
    detections, YOLOv10s 183.
  • Package suite: 766 passed. The 3 remaining failures
    (test_mxq_inference.py::test_mxq_classification_runs_with_shared_npu_options
    and two test_wrapper.py dflfree-pose cases) fail identically with this
    change reverted.
  • mblt-model-ops can now drop face_detection from its parity harness
    EXCLUDED_TASKS once a release carrying this fix is pinned; with it applied,
    parity covers 350 models including all 24 face models.

One open question, not addressed here

While validating, a second divergence surfaced that is a product decision
rather than a bug fix, so it is deliberately left out of this PR.

mblt-vision caps NMS output at max_det=300 for every task. Every face model's
common/local_inference.py in mblt-model-ops is uncapped, as is
datasets/widerface/evaluator.py. On WiderFace this is reachable and material:
at the committed conf_thres: 0.001, 5 of 12 sampled 0--Parade images exceed
300 detections and one reaches 1277. Because the Hard subset supplying the
primary metric is exactly the dense, tiny-face regime, a 300-detection cap
discards real recall and will report a lower Hard AP than the repository does on
the same artifact.

The NMS-free path is unaffected — its selection is architecturally top-300, so
both sides already agree there.

Worth deciding whether YOLOFaceDetectionMixin should raise or remove the cap
for WiderFace, or whether the repository should adopt the 300 cap instead. The
parity adapters currently pin the difference to the cap alone: both sides must
agree on every detection mblt-vision returns, and the repository's surplus rows
must be strictly the lowest-scoring ones, so a genuine decode disagreement still
fails.

parkjinman98 and others added 9 commits August 25, 2026 16:53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l-ops

Document the single-class face_detection postprocessing contract and the
face-model input geometry taken from mblt-model-ops pipeline.yaml, including
the YOLOv8m-face/YOLOv8l-face 960x960 exception, across AGENTS.md, CLAUDE.md,
both SKILL.md copies, and mblt_vision/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
model-ops carries 24 face models; only 15 were listed here. Add the nine
missing anchor-based configs from ../mblt-model-ops/models: YOLOv5{n,n-0.5,s,m}-face
(deepcam-cn) and YOLOv7{,s,-tiny,-lite-s,-lite-t}-face (derronqi), taking
pre_cfg/post_cfg verbatim from each model's pipeline.yaml.

These are the first shipped YAMLs that reach YOLOAnchorFaceDetectionPost. Their
published ONNX exports emit three raw (batch, 3, H, W, 6) heads with the
upstream landmark pairs stripped, so they decode through the shared anchor path
with nc = 1 and iou_thres 0.5.

Verified end to end against both the model-ops model.onnx files and the
Hub-resolved exports: all nine decode, agree with the shipped YOLOv8n-face
reference to within ~2px on the same face, restore into original image
coordinates in bounds, and label every row "face".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At nc == 1 a five-column tensor is ambiguous: it is both the end2end
export layout (xyxy + confidence, class column stripped) and the width of
a converted xywh candidate tensor (4 + nc), which belongs to non_e2e.

Separate them by row count. An end2end export is top-k selected and never
exceeds max_det rows, while a converted tensor carries one row per anchor
(8400 at 640x640), so only the bounded form is treated as already-decoded
detections.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-03T01:03:35.690732Z 449cd7b Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 7ddcd64064

ℹ️ 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".

Three issues from the Codex review of 7ddcd64:

- Row count is not provenance. A small single-class configuration produces
  fewer candidates than max_det (a 64x64 three-head model yields 84), so a
  converted (B, 84, 5) tensor passed the bound and skipped filter_conversion
  and dual_topk. Separate the two by the one exact property available: a
  converted tensor carries precisely one row per anchor, and the
  configuration knows its own anchor count. Ties are read as converted, so a
  genuine export of that shape fails loudly instead of silently skipping
  candidate selection.

- Squeezing whichever leading axis happened to be 1 dropped the batch for a
  (B, 1, N, 5) compiler output with B > 1, which then fell back to raw-head
  decoding and raised the very error this restore exists to prevent. Squeeze
  the compiler's axis 1 specifically.

- With e2e disabled the restored tensor returned a per-image list, bypassing
  the non_e2e export contract the CLI promises. Stack it to (B, max_det, 6),
  matching the decoded-triplet branch above.
@parkjinman98

Copy link
Copy Markdown
Contributor Author

@codex review

All three P2 findings from the 7ddcd64 review are fixed in 452d509, and the
threads are resolved. Please review 452d509 on its own terms rather than
re-deriving the earlier design — a summary of what changed and, more usefully,
which properties are now load-bearing, so the same three points are not
re-raised against code that no longer works that way.

What changed

  1. Row count is gone as a discriminator. You were right that candidate
    count is not provenance. The tensor.shape[1] > self.max_det bound is
    deleted. The two five-column layouts are now separated by an exact
    property rather than a heuristic: a converted tensor carries precisely one
    row per anchor, and the configuration already knows its anchor count via
    anchors_as_tensor().shape[-1]. A tensor of exactly that height is returned
    as None and left to the conversion path.

    Your 64x64 three-head example is the direct regression case: anchors resolve
    to 84, and (B, 84, 5) is now left to filter_conversion/dual_topk, where
    the old bound accepted it because 84 <= 300.

  2. Batch axis preserved. The old loop squeezed whichever leading axis was
    1, which dropped the batch when B > 1. It now squeezes axis 1 specifically.
    (2, 1, 300, 5) -> (2, 300, 6).

  3. Non-E2E export contract honored. When self.e2e is false the restored
    tensor is passed through _stack_topk_outputs, matching the decoded-triplet
    branch above, so callers get (B, max_det, 6) rather than a per-image list.

Deliberate decisions — please do not re-raise these as defects

  • Ties are read as converted. When a configuration's anchor count equals an
    export's row count the tensor is treated as converted. This is the
    conservative direction on purpose: the full decode path runs, and a genuine
    export of that shape fails loudly on raw-head decoding rather than silently
    skipping candidate selection. Preferring the opposite would trade a loud
    failure for a silent behavior change.

  • The restore is gated on self.nc == 1. Multi-class models are untouched;
    six-column end2end handling is entirely unchanged. YOLOv10m still returns
    160 multi-class detections and YOLOv10s 183.

  • The max_det=300 NMS cap is out of scope for this PR. It is described in
    the PR body as an open question, not an oversight. mblt-vision caps every
    task at 300 while mblt-model-ops' face inference and its WiderFace evaluator
    are uncapped, which is material on crowd images. Deciding that changes
    published accuracy numbers, so it is deliberately not bundled into a crash
    fix. The NMS-free path is unaffected because its selection is already
    top-300.

Validation already performed, if useful as context

  • Package suite: 766 passed. The 3 failures
    (test_mxq_inference.py::test_mxq_classification_runs_with_shared_npu_options
    and two test_wrapper.py dflfree-pose cases) reproduce identically with this
    branch's changes reverted, so they are pre-existing and unrelated.
  • All 24 *-face models in mblt-model-ops agree with their model-local
    inference on identical inputs: 24/24 on ONNX and 24/24 on MXQ, across all
    three decode families (9 anchor, 11 anchorless, 4 nmsfree).
  • mblt-model-ops parity now covers 350 models including all 24 face models,
    green against this branch.

The most valuable thing to scrutinize is point 1: whether anchor count is a
sound invariant for identifying a converted tensor in every configuration this
class supports, including any where anchors_as_tensor() is unavailable (that
path returns None and the restore is skipped).

@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: 452d5099fd

ℹ️ 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".

`tensor.shape[1] == self._anchor_grid_size()` is false for every integer row
count when the grid size is None, so a configuration with unresolved anchors
lost the guard entirely and had every ambiguous five-column tensor restored as
a final export -- the exact silent bypass of conversion and candidate
selection the guard exists to prevent.

Resolve the grid size first and skip restoration when it is unavailable.
Without an anchor count there is nothing to separate a stripped export from a
converted candidate tensor, so an unrecognized tensor now fails loudly on
raw-head decoding instead. This does not affect the shipped path: anchors
resolve at construction, before any inference.
@parkjinman98

Copy link
Copy Markdown
Contributor Author

@codex review

Fixed in 449cd7b; thread resolved. Please review 449cd7b.

You were right, and the finding also caught me asserting something false: my
previous comment claimed the unresolved-anchor path "returns None and the
restore is skipped". It did not — tensor.shape[1] == None is false for every
integer row count, so the guard disappeared instead of skipping. I reproduced it
before fixing: with anchors forced unresolvable, a converted (1, 8400, 5)
tensor (an exact anchor-count match, the clearest possible converted input) was
restored to (1, 8400, 6) and treated as final.

_anchor_grid_size() is now resolved into a local first, and restoration is
skipped when it is None, so the guard is fail-closed and consistent with the
tie-breaking rationale already in the docstring.

Verified behavior after the fix

Input anchors resolvable Result
converted (1, 8400, 5) no None — skipped
export (1, 300, 5) no None — skipped
export (1, 300, 5) yes restored (1, 300, 6)
converted (1, 8400, 5) yes None — left to conversion
converted (1, 84, 5) @ 64x64 yes None — left to conversion

The shipped path is unaffected: anchors resolve at construction, before any
inference, so this is defensive only.

Still deliberate, please do not re-raise as defects

Restating these because they are settled decisions, not oversights, and the
previous round confirmed the resolved threads are staying resolved:

  • Ties and unresolved anchors both resolve toward "converted." In both
    cases the full decode path runs and a genuine export of that shape fails
    loudly on raw-head decoding. This is chosen over the opposite direction,
    which would trade a loud failure for a silent behavior change.
  • The restore is gated on self.nc == 1. Multi-class is untouched and
    six-column end2end handling is unchanged (YOLOv10m 160 detections,
    YOLOv10s 183).
  • The max_det=300 NMS cap is out of scope, documented as an open question
    in the PR body. mblt-vision caps every task at 300 while mblt-model-ops' face
    inference and its WiderFace evaluator are uncapped, which is material on
    crowd images. Deciding it changes published accuracy numbers, so it is not
    bundled into a crash fix. The NMS-free path is unaffected — its selection is
    already top-300.
  • The 3 failing tests in this repo are pre-existing.
    test_mxq_inference.py::test_mxq_classification_runs_with_shared_npu_options
    and two test_wrapper.py dflfree-pose cases reproduce identically with this
    branch's changes reverted. 766 pass.

Re-validated at 449cd7b

  • Package suite: 766 passed, 3 pre-existing failures.
  • All 24 *-face models in mblt-model-ops agree with their model-local
    inference: 24/24 on ONNX across the 9 anchor / 11 anchorless / 4 nmsfree
    families.
  • mblt-model-ops parity green across 350 models, including all 24 face models.

If a further finding is a style or structural preference rather than a
behavioral defect, please say so explicitly so it can be weighed against the
above rather than treated as a blocker.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 449cd7b53b

ℹ️ 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".

@parkjinman98
parkjinman98 merged commit 667b8a8 into main Sep 3, 2026
9 checks passed
@parkjinman98
parkjinman98 deleted the jm/face branch September 3, 2026 01:04
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