diff --git a/.agents/skills/mblt-vision/SKILL.md b/.agents/skills/mblt-vision/SKILL.md index 945aaf5..c92d023 100644 --- a/.agents/skills/mblt-vision/SKILL.md +++ b/.agents/skills/mblt-vision/SKILL.md @@ -87,6 +87,18 @@ description: >- the Model Zoo reference. - Rank WiderFace evaluation by Hard-set AP. Expose Medium-set then Easy-set AP as secondary metrics, and do not compute mean AP across difficulty splits. +- Treat face_detection as a single-class WiderFace task. Each YOLO head family gets a thin + YOLOFaceDetectionMixin subclass over its object-detection postprocessor, so only + evaluation-format conversion differs: nmsout2eval_face labels every row "face" and rejects a + class index other than 0 rather than using the COCO category-id table. build_postprocess + dispatches face_detection before object_detection on the same anchors/dflfree/nmsfree keys. + The anchor branch serves the YOLOv5*-face and YOLOv7*-face families, whose ONNX exports emit + three raw (batch, 3, H, W, 6) heads with landmarks stripped and use iou_thres: 0.5. +- Source face-detection pre_cfg/post_cfg from ../mblt-model-ops/models//pipeline.yaml. + Every shipped face model is 640x640 except YOLOv8m-face and YOLOv8l-face at 960x960, whose + checkpoints record imgsz: 960 in their own train_args. Changing an input size is a durable + model-behavior change: update AGENTS.md, both SKILL.md copies, and mblt_vision/README.md + together. - Rank NYU Depth evaluation by delta1. Expose abs_rel then RMSE (m) as secondary metrics, with median-aligned metrics averaged per image. diff --git a/.claude/skills/mblt-vision/SKILL.md b/.claude/skills/mblt-vision/SKILL.md index 945aaf5..c92d023 100644 --- a/.claude/skills/mblt-vision/SKILL.md +++ b/.claude/skills/mblt-vision/SKILL.md @@ -87,6 +87,18 @@ description: >- the Model Zoo reference. - Rank WiderFace evaluation by Hard-set AP. Expose Medium-set then Easy-set AP as secondary metrics, and do not compute mean AP across difficulty splits. +- Treat face_detection as a single-class WiderFace task. Each YOLO head family gets a thin + YOLOFaceDetectionMixin subclass over its object-detection postprocessor, so only + evaluation-format conversion differs: nmsout2eval_face labels every row "face" and rejects a + class index other than 0 rather than using the COCO category-id table. build_postprocess + dispatches face_detection before object_detection on the same anchors/dflfree/nmsfree keys. + The anchor branch serves the YOLOv5*-face and YOLOv7*-face families, whose ONNX exports emit + three raw (batch, 3, H, W, 6) heads with landmarks stripped and use iou_thres: 0.5. +- Source face-detection pre_cfg/post_cfg from ../mblt-model-ops/models//pipeline.yaml. + Every shipped face model is 640x640 except YOLOv8m-face and YOLOv8l-face at 960x960, whose + checkpoints record imgsz: 960 in their own train_args. Changing an input size is a durable + model-behavior change: update AGENTS.md, both SKILL.md copies, and mblt_vision/README.md + together. - Rank NYU Depth evaluation by delta1. Expose abs_rel then RMSE (m) as secondary metrics, with median-aligned metrics averaged per image. diff --git a/.gitignore b/.gitignore index 004b95e..0e981b3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,8 @@ Thumbs.db *.tgz *.egg -runs/ \ No newline at end of file +runs/ + +*.mxq +*.mblt +*.onnx \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index dc75a77..3cbace8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,26 @@ The current ownership boundary is deliberate: must remain lazy and report the appropriate package extra when unavailable. - For WiderFace evaluation, rank results by Hard-set AP and retain Medium-set then Easy-set AP as secondary metrics. Do not compute a mean across splits. +- `face_detection` is a single-class WiderFace task, not an 80-class COCO one. Each YOLO head + family reuses its own object-detection decode and NMS through a thin + `YOLOFaceDetectionMixin` subclass (`YOLOAnchorFaceDetectionPost`, + `YOLOAnchorlessFaceDetectionPost`, `YOLODFLFreeFaceDetectionPost`, + `YOLONMSFreeFaceDetectionPost`); only evaluation-format conversion differs, because + `nmsout2eval_face` labels every row `"face"` and rejects any class index other than `0` + instead of routing indices through the COCO category-id table. `build_postprocess` therefore + dispatches `face_detection` on its own branch, ahead of `object_detection`, using the same + `anchors` / `dflfree` / `nmsfree` `post_cfg` keys. The anchor-based branch serves the + `YOLOv5*-face` (deepcam-cn) and `YOLOv7*-face` (derronqi) families, whose published ONNX + exports emit three raw `(batch, 3, H, W, 6)` heads with the original repositories' five + landmark pairs stripped, so they decode through the shared anchor path with `nc = 1`. +- Take face-detection `pre_cfg`/`post_cfg` defaults from + `../mblt-model-ops/models//pipeline.yaml`, which is the source of truth for the + compiled artifacts. Face-detection input geometry is `640x640` for every shipped model except + `YOLOv8m-face` and `YOLOv8l-face`, which are `960x960` because those checkpoints' own embedded + `train_args` record `imgsz: 960`. The anchor-based families additionally use `iou_thres: 0.5` + where every other face model uses `0.7`. Do not normalize the exception away; a size change here is a + durable model-behavior change requiring the guide, both skill copies, and + `mblt_vision/README.md` to be updated in the same commit. - `eval_sav` requires already-binarized candidate masks, enumerated per dtype (bool, integer `{0, 1}`/`{0, 255}`, float `{0.0, 1.0}`). A weaker "single positive value" rule still admits a probability map such as `{0.0, 0.5}`, or a uniform `0.5` candidate that `astype(bool)` turns diff --git a/CLAUDE.md b/CLAUDE.md index 680ff48..90ee372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,10 @@ `AGENTS.md` is the canonical guide for this repository. Follow it for the Python-first implementation, PyPI-wheel, lifecycle, and mblt-model-zoo Vision compatibility requirements. This includes the WiderFace Hard-primary and Medium/Easy-secondary metric contract, -plus NYU Depth delta1-primary with abs_rel/RMSE secondary metrics. +the single-class `face_detection` postprocessing contract and its +`mblt-model-ops`-sourced input geometry (640x640, except `YOLOv8m-face` and +`YOLOv8l-face` at 960x960), plus NYU Depth delta1-primary with abs_rel/RMSE +secondary metrics. For focused model, preprocessing, postprocessing, and model-registry work, also read .claude/skills/mblt-vision/SKILL.md. diff --git a/mblt_vision/README.md b/mblt_vision/README.md index c4f923e..c45c554 100644 --- a/mblt_vision/README.md +++ b/mblt_vision/README.md @@ -226,6 +226,51 @@ WiderFace validation uses Hard-set AP as the primary metric. Medium-set AP and Easy-set AP are secondary metrics, in that order. Mean AP across the difficulty splits is not computed. +Face detection is a single-class task: every detection is labeled `face`, and no +COCO category mapping is applied. + +| Model | Input Size
(H,W,C) | Source | Note | +| --- | --- | --- | --- | +| YOLOv8n-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv8m-face | (960,960,3) | [Link](https://github.com/akanametov/yolo-face) | Trained at 960; see the note below. | +| YOLOv8l-face | (960,960,3) | [Link](https://github.com/akanametov/yolo-face) | Trained at 960; see the note below. | +| YOLO11n-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO11s-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO11m-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO11l-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO12n-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO12s-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO12m-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLO12l-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv10n-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv10s-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv10m-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv10l-face | (640,640,3) | [Link](https://github.com/akanametov/yolo-face) | | +| YOLOv5n-face | (640,640,3) | [Link](https://github.com/deepcam-cn/yolov5-face) | Anchor-based. | +| YOLOv5n-0.5-face | (640,640,3) | [Link](https://github.com/deepcam-cn/yolov5-face) | Anchor-based. | +| YOLOv5s-face | (640,640,3) | [Link](https://github.com/deepcam-cn/yolov5-face) | Anchor-based. | +| YOLOv5m-face | (640,640,3) | [Link](https://github.com/deepcam-cn/yolov5-face) | Anchor-based. | +| YOLOv7-lite-t-face | (640,640,3) | [Link](https://github.com/derronqi/yolov7-face) | Anchor-based. | +| YOLOv7-lite-s-face | (640,640,3) | [Link](https://github.com/derronqi/yolov7-face) | Anchor-based. | +| YOLOv7-tiny-face | (640,640,3) | [Link](https://github.com/derronqi/yolov7-face) | Anchor-based. | +| YOLOv7s-face | (640,640,3) | [Link](https://github.com/derronqi/yolov7-face) | Anchor-based. | +| YOLOv7-face | (640,640,3) | [Link](https://github.com/derronqi/yolov7-face) | Anchor-based. | + +Input geometry follows each checkpoint's own training resolution. Every face +model is trained and served at 640x640 except `YOLOv8m-face` and `YOLOv8l-face`, +whose released weights record `imgsz: 960` in their embedded `train_args`; those +two use 960x960 and must not be normalized down to 640. + +The `YOLOv5*-face` and `YOLOv7*-face` families are anchor-based and carry an +`anchors` list in `post_cfg` with `iou_thres: 0.5`; every other face model is +anchorless or NMS-free with `iou_thres: 0.7`. Their published ONNX exports emit +three raw detection heads of shape `(batch, 3, H, W, 6)` — the five landmark +pairs of the original repositories are stripped at export — so they decode +through the shared anchor detection path with `nc = 1`. + +WiderFace accuracy columns are omitted until the full validation split has been +measured for each artifact. + ### Depth Estimation NYU Depth V2 validation uses median-aligned delta1 as the primary metric. diff --git a/mblt_vision/face_detection/__init__.py b/mblt_vision/face_detection/__init__.py index 0e1ca62..483e220 100644 --- a/mblt_vision/face_detection/__init__.py +++ b/mblt_vision/face_detection/__init__.py @@ -17,8 +17,15 @@ "YOLOv10m_face", "YOLOv10n_face", "YOLOv10s_face", - "YOLOv6m_face", - "YOLOv6n_face", + "YOLOv5m_face", + "YOLOv5n_0_5_face", + "YOLOv5n_face", + "YOLOv5s_face", + "YOLOv7_face", + "YOLOv7_lite_s_face", + "YOLOv7_lite_t_face", + "YOLOv7_tiny_face", + "YOLOv7s_face", "YOLOv8l_face", "YOLOv8m_face", "YOLOv8n_face", @@ -36,8 +43,15 @@ YOLOv10m_face = create_model_class("YOLOv10m_face", __name__) YOLOv10n_face = create_model_class("YOLOv10n_face", __name__) YOLOv10s_face = create_model_class("YOLOv10s_face", __name__) -YOLOv6m_face = create_model_class("YOLOv6m_face", __name__) -YOLOv6n_face = create_model_class("YOLOv6n_face", __name__) +YOLOv5m_face = create_model_class("YOLOv5m_face", __name__) +YOLOv5n_0_5_face = create_model_class("YOLOv5n_0_5_face", __name__) +YOLOv5n_face = create_model_class("YOLOv5n_face", __name__) +YOLOv5s_face = create_model_class("YOLOv5s_face", __name__) +YOLOv7_face = create_model_class("YOLOv7_face", __name__) +YOLOv7_lite_s_face = create_model_class("YOLOv7_lite_s_face", __name__) +YOLOv7_lite_t_face = create_model_class("YOLOv7_lite_t_face", __name__) +YOLOv7_tiny_face = create_model_class("YOLOv7_tiny_face", __name__) +YOLOv7s_face = create_model_class("YOLOv7s_face", __name__) YOLOv8l_face = create_model_class("YOLOv8l_face", __name__) YOLOv8m_face = create_model_class("YOLOv8m_face", __name__) YOLOv8n_face = create_model_class("YOLOv8n_face", __name__) diff --git a/mblt_vision/models/YOLOv5m-face.yaml b/mblt_vision/models/YOLOv5m-face.yaml new file mode 100644 index 0000000..d76acb8 --- /dev/null +++ b/mblt_vision/models/YOLOv5m-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv5m-face + filename: yolov5m-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 8 + - 10 + - 13 + - 16 + - - 23 + - 29 + - 43 + - 55 + - 73 + - 105 + - - 146 + - 217 + - 231 + - 300 + - 335 + - 433 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv5n-0.5-face.yaml b/mblt_vision/models/YOLOv5n-0.5-face.yaml new file mode 100644 index 0000000..b58455e --- /dev/null +++ b/mblt_vision/models/YOLOv5n-0.5-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv5n-0.5-face + filename: yolov5n-0.5-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 8 + - 10 + - 13 + - 16 + - - 23 + - 29 + - 43 + - 55 + - 73 + - 105 + - - 146 + - 217 + - 231 + - 300 + - 335 + - 433 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv5n-face.yaml b/mblt_vision/models/YOLOv5n-face.yaml new file mode 100644 index 0000000..d79bcbd --- /dev/null +++ b/mblt_vision/models/YOLOv5n-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv5n-face + filename: yolov5n-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 8 + - 10 + - 13 + - 16 + - - 23 + - 29 + - 43 + - 55 + - 73 + - 105 + - - 146 + - 217 + - 231 + - 300 + - 335 + - 433 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv5s-face.yaml b/mblt_vision/models/YOLOv5s-face.yaml new file mode 100644 index 0000000..476c35d --- /dev/null +++ b/mblt_vision/models/YOLOv5s-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv5s-face + filename: yolov5s-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 8 + - 10 + - 13 + - 16 + - - 23 + - 29 + - 43 + - 55 + - 73 + - 105 + - - 146 + - 217 + - 231 + - 300 + - 335 + - 433 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv6m-face.yaml b/mblt_vision/models/YOLOv7-face.yaml similarity index 53% rename from mblt_vision/models/YOLOv6m-face.yaml rename to mblt_vision/models/YOLOv7-face.yaml index 5c56065..cc81056 100644 --- a/mblt_vision/models/YOLOv6m-face.yaml +++ b/mblt_vision/models/YOLOv7-face.yaml @@ -1,7 +1,7 @@ DEFAULT: file_cfg: - repo_id: mobilint/YOLOv6m-face - filename: yolov6m-face.mxq + repo_id: mobilint/YOLOv7-face + filename: yolov7-face.mxq revision: main pre_cfg: Reader: @@ -17,10 +17,27 @@ DEFAULT: post_cfg: task: face_detection dataset: widerface - nl: 3 - reg_max: 16 + anchors: + - - 4 + - 5 + - 6 + - 8 + - 10 + - 12 + - - 15 + - 19 + - 23 + - 30 + - 39 + - 52 + - - 72 + - 97 + - 123 + - 164 + - 209 + - 297 conf_thres: 0.001 - iou_thres: 0.7 + iou_thres: 0.5 TURBO: update: DEFAULT file_cfg: diff --git a/mblt_vision/models/YOLOv7-lite-s-face.yaml b/mblt_vision/models/YOLOv7-lite-s-face.yaml new file mode 100644 index 0000000..f8fbbc0 --- /dev/null +++ b/mblt_vision/models/YOLOv7-lite-s-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv7-lite-s-face + filename: yolov7-lite-s-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 6 + - 8 + - 10 + - 12 + - - 15 + - 19 + - 23 + - 30 + - 39 + - 52 + - - 72 + - 97 + - 123 + - 164 + - 209 + - 297 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv7-lite-t-face.yaml b/mblt_vision/models/YOLOv7-lite-t-face.yaml new file mode 100644 index 0000000..c115b61 --- /dev/null +++ b/mblt_vision/models/YOLOv7-lite-t-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv7-lite-t-face + filename: yolov7-lite-t-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 6 + - 8 + - 10 + - 12 + - - 15 + - 19 + - 23 + - 30 + - 39 + - 52 + - - 72 + - 97 + - 123 + - 164 + - 209 + - 297 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv7-tiny-face.yaml b/mblt_vision/models/YOLOv7-tiny-face.yaml new file mode 100644 index 0000000..d8127ce --- /dev/null +++ b/mblt_vision/models/YOLOv7-tiny-face.yaml @@ -0,0 +1,44 @@ +DEFAULT: + file_cfg: + repo_id: mobilint/YOLOv7-tiny-face + filename: yolov7-tiny-face.mxq + revision: main + pre_cfg: + Reader: + style: numpy + LetterBox: + img_size: + - 640 + - 640 + SetOrder: + shape: HWC + Normalize: + style: cv + post_cfg: + task: face_detection + dataset: widerface + anchors: + - - 4 + - 5 + - 6 + - 8 + - 10 + - 12 + - - 15 + - 19 + - 23 + - 30 + - 39 + - 52 + - - 72 + - 97 + - 123 + - 164 + - 209 + - 297 + conf_thres: 0.001 + iou_thres: 0.5 +TURBO: + update: DEFAULT + file_cfg: + revision: TURBO diff --git a/mblt_vision/models/YOLOv6n-face.yaml b/mblt_vision/models/YOLOv7s-face.yaml similarity index 53% rename from mblt_vision/models/YOLOv6n-face.yaml rename to mblt_vision/models/YOLOv7s-face.yaml index f1e7541..25c0299 100644 --- a/mblt_vision/models/YOLOv6n-face.yaml +++ b/mblt_vision/models/YOLOv7s-face.yaml @@ -1,7 +1,7 @@ DEFAULT: file_cfg: - repo_id: mobilint/YOLOv6n-face - filename: yolov6n-face.mxq + repo_id: mobilint/YOLOv7s-face + filename: yolov7s-face.mxq revision: main pre_cfg: Reader: @@ -17,10 +17,27 @@ DEFAULT: post_cfg: task: face_detection dataset: widerface - nl: 3 - reg_max: 16 + anchors: + - - 4 + - 5 + - 6 + - 8 + - 10 + - 12 + - - 15 + - 19 + - 23 + - 30 + - 39 + - 52 + - - 72 + - 97 + - 123 + - 164 + - 209 + - 297 conf_thres: 0.001 - iou_thres: 0.7 + iou_thres: 0.5 TURBO: update: DEFAULT file_cfg: diff --git a/mblt_vision/models/YOLOv8l-face.yaml b/mblt_vision/models/YOLOv8l-face.yaml index 12f7093..b8b527a 100644 --- a/mblt_vision/models/YOLOv8l-face.yaml +++ b/mblt_vision/models/YOLOv8l-face.yaml @@ -7,9 +7,11 @@ DEFAULT: Reader: style: numpy LetterBox: + # yolov8l-face.pt's own embedded train_args records imgsz: 960, unlike + # every other face-detection size/family, which train at 640. img_size: - - 640 - - 640 + - 960 + - 960 SetOrder: shape: HWC Normalize: diff --git a/mblt_vision/models/YOLOv8m-face.yaml b/mblt_vision/models/YOLOv8m-face.yaml index 135340c..0e09342 100644 --- a/mblt_vision/models/YOLOv8m-face.yaml +++ b/mblt_vision/models/YOLOv8m-face.yaml @@ -7,9 +7,11 @@ DEFAULT: Reader: style: numpy LetterBox: + # yolov8m-face.pt's own embedded train_args records imgsz: 960, unlike + # every other face-detection size/family, which train at 640. img_size: - - 640 - - 640 + - 960 + - 960 SetOrder: shape: HWC Normalize: diff --git a/mblt_vision/utils/postprocess/build_post.py b/mblt_vision/utils/postprocess/build_post.py index 305ac44..fe0097c 100644 --- a/mblt_vision/utils/postprocess/build_post.py +++ b/mblt_vision/utils/postprocess/build_post.py @@ -9,20 +9,26 @@ from .cls_post import ClsPost from .depth_post import DepthPost from .semantic_seg_post import SemanticSegPost -from .yolo_anchor_post import YOLOAnchorDetectionPost, YOLOAnchorSegPost +from .yolo_anchor_post import ( + YOLOAnchorDetectionPost, + YOLOAnchorFaceDetectionPost, + YOLOAnchorSegPost, +) from .yolo_anchorless_post import ( YOLOAnchorlessDetectionPost, + YOLOAnchorlessFaceDetectionPost, YOLOAnchorlessOBBPost, YOLOAnchorlessPosePost, YOLOAnchorlessSegPost, ) from .yolo_dflfree_post import ( YOLODFLFreeDetectionPost, + YOLODFLFreeFaceDetectionPost, YOLODFLFreeOBBPost, YOLODFLFreePosePost, YOLODFLFreeSegPost, ) -from .yolo_nmsfree_post import YOLONMSFreeDetectionPost +from .yolo_nmsfree_post import YOLONMSFreeDetectionPost, YOLONMSFreeFaceDetectionPost def build_postprocess( @@ -51,7 +57,31 @@ def build_postprocess( return DepthPost(pre_cfg, post_cfg) if task == "semantic_segmentation": return SemanticSegPost(pre_cfg, post_cfg) - if task in {"object_detection", "face_detection"}: + if task == "face_detection": + if post_cfg.get("anchors", False): + return YOLOAnchorFaceDetectionPost( + pre_cfg, + post_cfg, + **kwargs, + ) + if post_cfg.get("dflfree", False): # nms free is only available for detection + return YOLODFLFreeFaceDetectionPost( + pre_cfg, + post_cfg, + **kwargs, + ) + if post_cfg.get("nmsfree", False): + return YOLONMSFreeFaceDetectionPost( + pre_cfg, + post_cfg, + **kwargs, + ) + return YOLOAnchorlessFaceDetectionPost( + pre_cfg, + post_cfg, + **kwargs, + ) + if task == "object_detection": if post_cfg.get("anchors", False): return YOLOAnchorDetectionPost( pre_cfg, diff --git a/mblt_vision/utils/postprocess/common.py b/mblt_vision/utils/postprocess/common.py index dc6ed5a..632c3f2 100644 --- a/mblt_vision/utils/postprocess/common.py +++ b/mblt_vision/utils/postprocess/common.py @@ -1309,6 +1309,69 @@ def nmsout2eval( return labels_list, boxes_list, scores_list +def nmsout2eval_face( + nms_outs: list[torch.Tensor] | torch.Tensor, + img1_shape: tuple[int, int], + img0_shapes: tuple[int, int] | Sequence[tuple[int, int]], + ratio_pads: RatioPad | Sequence[RatioPad | None] | None = None, +) -> tuple[list[list[str]], list[list[list[float]]], list[list[float]]]: + """Converts single-class face-detection NMS output to evaluation format. + + WiderFace has exactly one class, so this mirrors :func:`nmsout2eval` without + routing label indices through COCO's 80-class category-id table: every row + is labeled ``"face"`` and its class index must be ``0``. + + Args: + nms_outs: NMS output of shape ``(n, 6)`` per image, where ``n`` is the + number of detected faces. + img1_shape: Processed image shape ``(H, W)``. + img0_shapes: Original image shape or shapes. + ratio_pads: Optional letterbox metadata. + + Returns: + tuple: A tuple containing: + - labels (list[list[str]]): ``"face"`` for every detection. + - boxes (list[list]): The bounding boxes (xywh) for each image. + - scores (list[list]): The confidence scores for each image. + """ + if not isinstance(nms_outs, list): + nms_outs = [nms_outs] + actual_img0_shapes = normalize_image_shapes(img0_shapes, len(nms_outs)) + actual_ratio_pads = normalize_ratio_pads(ratio_pads, len(nms_outs)) + labels_list: list[list[str]] = [] + boxes_list: list[list[list[float]]] = [] + scores_list: list[list[float]] = [] + for nms_out, img0_shape, ratio_pad in zip( + nms_outs, actual_img0_shapes, actual_ratio_pads + ): + boxes = nms_out[:, :4].clone() + scores = nms_out[:, 4] + labels = nms_out[:, 5] + valid_labels = ( + torch.isfinite(labels) & (labels == labels.round()) & (labels == 0) + ) + if not bool(valid_labels.all()): + invalid_labels = labels[~valid_labels].detach().cpu().tolist() + raise ValueError( + f"Face-detection class IDs must all be 0; got {invalid_labels}." + ) + boxes = scale_boxes( + img1_shape, boxes, img0_shape, ratio_pad=ratio_pad + ) # scale boxes to original image size + boxes[:, 2:] = boxes[:, 2:] - boxes[:, :2] # xyxy to xywh with corner xy + + boxes_tolist = [ + [round(float(value), 3) for value in box] for box in boxes.tolist() + ] + scores_tolist = [round(float(score), 5) for score in scores.tolist()] + + labels_list.append(["face"] * len(boxes_tolist)) + boxes_list.append(boxes_tolist) + scores_list.append(scores_tolist) + + return labels_list, boxes_list, scores_list + + def nmsout2eval_seg( nms_outs: Any, img1_shape: tuple[int, int], @@ -1493,6 +1556,41 @@ def nmsout2eval_obb( return labels_list, polygons_list, scores_list +class YOLOFaceDetectionMixin: + """Mixin class for single-class WiderFace face-detection postprocessing. + + Face detection reuses the object-detection decode/NMS pipeline of whatever + head family a model belongs to (anchor, anchorless, DFL-free, or NMS-free); + the only thing that differs is evaluation-format label conversion, since + WiderFace has one class and no COCO category-id mapping applies. Mix this + in over the matching detection postprocessor, for example:: + + class YOLOAnchorlessFaceDetectionPost( + YOLOFaceDetectionMixin, YOLOAnchorlessDetectionPost + ): + pass + """ + + def nmsout2eval( + self, + nms_out: Any, + img1_shape: tuple[int, int], + img0_shape: tuple[int, int] | list[tuple[int, int]], + ratio_pad: RatioPad | list[RatioPad | None] | None = None, + ) -> tuple[Any, ...]: + """Converts NMS output to evaluation format for face detection. + + Args: + nms_out: NMS output (single-class face detections). + img1_shape: Resized image shape. + img0_shape: List of original image shapes. + + Returns: + Tuple: (labels_list, boxes_list, scores_list). + """ + return nmsout2eval_face(nms_out, img1_shape, img0_shape, ratio_pads=ratio_pad) + + class YOLOSegPostMixin: """Mixin class for YOLO segmentation postprocessing.""" diff --git a/mblt_vision/utils/postprocess/yolo_anchor_post.py b/mblt_vision/utils/postprocess/yolo_anchor_post.py index 4888593..1894378 100644 --- a/mblt_vision/utils/postprocess/yolo_anchor_post.py +++ b/mblt_vision/utils/postprocess/yolo_anchor_post.py @@ -9,7 +9,7 @@ import torch from .base import YOLODetectionPostBase -from .common import YOLOSegPostMixin, non_max_suppression +from .common import YOLOFaceDetectionMixin, YOLOSegPostMixin, non_max_suppression class YOLOAnchorDetectionPost(YOLODetectionPostBase): @@ -487,3 +487,7 @@ def chop(self, npu_out: torch.Tensor, idx: int = 0) -> tuple[torch.Tensor, ...]: ) masks = masks * conf.sigmoid() return xy, wh, conf, scores, masks + + +class YOLOAnchorFaceDetectionPost(YOLOFaceDetectionMixin, YOLOAnchorDetectionPost): + """Postprocessing for anchor-based WiderFace face-detection models.""" diff --git a/mblt_vision/utils/postprocess/yolo_anchorless_post.py b/mblt_vision/utils/postprocess/yolo_anchorless_post.py index 7011dfe..1c1c213 100644 --- a/mblt_vision/utils/postprocess/yolo_anchorless_post.py +++ b/mblt_vision/utils/postprocess/yolo_anchorless_post.py @@ -12,6 +12,7 @@ from ..types import ListTensorLike, TensorLike from .base import YOLODetectionPostBase from .common import ( + YOLOFaceDetectionMixin, YOLOOBBPostMixin, YOLOPosePostMixin, YOLOSegPostMixin, @@ -1011,4 +1012,10 @@ def nms_multilabel( return self.nms(x) +class YOLOAnchorlessFaceDetectionPost( + YOLOFaceDetectionMixin, YOLOAnchorlessDetectionPost +): + """Postprocessing for anchorless WiderFace face-detection models.""" + + YOLOAnchorlessPost = YOLOAnchorlessDetectionPost diff --git a/mblt_vision/utils/postprocess/yolo_dflfree_post.py b/mblt_vision/utils/postprocess/yolo_dflfree_post.py index 0ba18d3..9c3c45b 100644 --- a/mblt_vision/utils/postprocess/yolo_dflfree_post.py +++ b/mblt_vision/utils/postprocess/yolo_dflfree_post.py @@ -7,6 +7,7 @@ from ..types import ListTensorLike, TensorLike from .base import YOLODetectionPostBase from .common import ( + YOLOFaceDetectionMixin, YOLOOBBPostMixin, YOLOPosePostMixin, YOLOSegPostMixin, @@ -1023,4 +1024,8 @@ def nms( return output +class YOLODFLFreeFaceDetectionPost(YOLOFaceDetectionMixin, YOLODFLFreeDetectionPost): + """Postprocessing for DFL-free WiderFace face-detection models.""" + + YOLODFLFreePost = YOLODFLFreeDetectionPost diff --git a/mblt_vision/utils/postprocess/yolo_nmsfree_post.py b/mblt_vision/utils/postprocess/yolo_nmsfree_post.py index f77ed9c..2392229 100644 --- a/mblt_vision/utils/postprocess/yolo_nmsfree_post.py +++ b/mblt_vision/utils/postprocess/yolo_nmsfree_post.py @@ -10,7 +10,7 @@ import numpy as np import torch -from .common import dist2bbox, dual_topk +from .common import YOLOFaceDetectionMixin, dist2bbox, dual_topk from .yolo_anchorless_post import YOLOAnchorlessDetectionPost, _AnchorlessNMSInput @@ -270,4 +270,8 @@ class per candidate. return [xi[xi[:, 4] > 0] for xi in x] +class YOLONMSFreeFaceDetectionPost(YOLOFaceDetectionMixin, YOLONMSFreeDetectionPost): + """Postprocessing for NMS-free WiderFace face-detection models (for example, YOLOv10-face).""" + + YOLONMSFreePost = YOLONMSFreeDetectionPost diff --git a/tests/test_face_detection.py b/tests/test_face_detection.py index 58a8091..1864980 100644 --- a/tests/test_face_detection.py +++ b/tests/test_face_detection.py @@ -2,25 +2,44 @@ from __future__ import annotations +from pathlib import Path from typing import Any, cast import cv2 import numpy as np import pytest import torch +import yaml + +import mblt_vision +from mblt_vision import YOLO11m_face, list_models +from mblt_vision.face_detection import YOLO11m_face as FaceDetectionYOLO11mFace from mblt_vision.utils.postprocess import build_postprocess from mblt_vision.utils.postprocess.base import YOLODetectionPostBase -from mblt_vision.utils.postprocess.yolo_anchor_post import YOLOAnchorDetectionPost +from mblt_vision.utils.postprocess.common import ( + YOLOFaceDetectionMixin, + nmsout2eval_face, +) +from mblt_vision.utils.postprocess.yolo_anchor_post import ( + YOLOAnchorDetectionPost, + YOLOAnchorFaceDetectionPost, +) from mblt_vision.utils.postprocess.yolo_anchorless_post import ( YOLOAnchorlessDetectionPost, + YOLOAnchorlessFaceDetectionPost, +) +from mblt_vision.utils.postprocess.yolo_dflfree_post import ( + YOLODFLFreeDetectionPost, + YOLODFLFreeFaceDetectionPost, +) +from mblt_vision.utils.postprocess.yolo_nmsfree_post import ( + YOLONMSFreeDetectionPost, + YOLONMSFreeFaceDetectionPost, ) -from mblt_vision.utils.postprocess.yolo_dflfree_post import YOLODFLFreeDetectionPost -from mblt_vision.utils.postprocess.yolo_nmsfree_post import YOLONMSFreeDetectionPost - -from mblt_vision import YOLO11m_face, list_models -from mblt_vision.face_detection import YOLO11m_face as FaceDetectionYOLO11mFace from mblt_vision.utils.results import Results +MODEL_CONFIG_DIR = Path(mblt_vision.__file__).parent / "models" + def _pre_cfg() -> dict[str, Any]: """Return a representative face preprocessing configuration.""" @@ -41,22 +60,47 @@ def _post_cfg(**overrides: Any) -> dict[str, Any]: @pytest.mark.parametrize( - ("post_cfg", "expected_type"), + ("post_cfg", "expected_type", "expected_family"), [ - ({"nl": 3, "reg_max": 16}, YOLOAnchorlessDetectionPost), - ({"nl": 3, "dflfree": True}, YOLODFLFreeDetectionPost), - ({"nl": 3, "nmsfree": True}, YOLONMSFreeDetectionPost), - ({"anchors": [[10, 13, 16, 30, 33, 23]]}, YOLOAnchorDetectionPost), + ( + {"nl": 3, "reg_max": 16}, + YOLOAnchorlessFaceDetectionPost, + YOLOAnchorlessDetectionPost, + ), + ( + {"nl": 3, "dflfree": True}, + YOLODFLFreeFaceDetectionPost, + YOLODFLFreeDetectionPost, + ), + ( + {"nl": 3, "nmsfree": True}, + YOLONMSFreeFaceDetectionPost, + YOLONMSFreeDetectionPost, + ), + ( + {"anchors": [[10, 13, 16, 30, 33, 23]]}, + YOLOAnchorFaceDetectionPost, + YOLOAnchorDetectionPost, + ), ], ) def test_face_detection_routes_postprocessors( - post_cfg: dict[str, Any], expected_type: type[YOLODetectionPostBase] + post_cfg: dict[str, Any], + expected_type: type[YOLODetectionPostBase], + expected_family: type[YOLODetectionPostBase], ) -> None: - """Route every supported face head family to its YOLO postprocessor.""" + """Route every supported face head family to its dedicated face postprocessor. + + Each dedicated class must inherit from both the matching detection-family + base (so it decodes/NMS-suppresses identically) and ``YOLOFaceDetectionMixin`` + (so it evaluates with a single ``"face"`` label instead of COCO categories). + """ postprocessor = build_postprocess(_pre_cfg(), _post_cfg(**post_cfg)) - assert isinstance(postprocessor, expected_type) + assert type(postprocessor) is expected_type + assert isinstance(postprocessor, expected_family) + assert isinstance(postprocessor, YOLOFaceDetectionMixin) assert cast(YOLODetectionPostBase, postprocessor).nc == 1 @@ -121,3 +165,66 @@ def test_face_detection_non_e2e_converted_and_raw_outputs() -> None: assert converted_result.shape == (1, 5, 3) assert isinstance(raw_result, torch.Tensor) assert raw_result.shape == (1, 5, 8400) + + +def test_nmsout2eval_face_labels_every_row_face() -> None: + """Convert single-class face rows without routing through COCO category IDs.""" + + detections = torch.tensor([[10.0, 10.0, 20.0, 20.0, 0.9, 0.0]]) + + labels, boxes, scores = nmsout2eval_face(detections, (100, 100), (100, 100)) + + assert labels == [["face"]] + assert scores == [[0.9]] + assert boxes[0][0] == pytest.approx([10.0, 10.0, 10.0, 10.0]) + + +def test_nmsout2eval_face_rejects_nonzero_class_ids() -> None: + """Reject any class id other than 0 instead of silently mislabeling it.""" + + detections = torch.tensor([[10.0, 10.0, 20.0, 20.0, 0.9, 1.0]]) + + with pytest.raises(ValueError, match="must all be 0"): + nmsout2eval_face(detections, (100, 100), (100, 100)) + + +ANCHOR_FACE_MODELS = ( + "YOLOv5n-face", + "YOLOv5n-0.5-face", + "YOLOv5s-face", + "YOLOv5m-face", + "YOLOv7-face", + "YOLOv7s-face", + "YOLOv7-tiny-face", + "YOLOv7-lite-s-face", + "YOLOv7-lite-t-face", +) + + +@pytest.mark.parametrize("model_name", ANCHOR_FACE_MODELS) +def test_anchor_face_yaml_builds_anchor_face_postprocessor(model_name: str) -> None: + """Route every shipped anchor-based face YAML to the anchor face postprocessor. + + The ``YOLOv5*-face`` and ``YOLOv7*-face`` families are the only shipped face + models that carry an ``anchors`` list, so they are what makes + ``YOLOAnchorFaceDetectionPost`` reachable from the registry rather than from + a synthetic ``post_cfg``. + """ + + config = yaml.safe_load( + (MODEL_CONFIG_DIR / f"{model_name}.yaml").read_text(encoding="utf-8") + )["DEFAULT"] + post_cfg = config["post_cfg"] + + postprocessor = build_postprocess(config["pre_cfg"], post_cfg) + + assert type(postprocessor) is YOLOAnchorFaceDetectionPost + assert config["pre_cfg"]["LetterBox"]["img_size"] == [640, 640] + assert post_cfg["dataset"] == "widerface" + assert post_cfg["iou_thres"] == 0.5 + assert len(post_cfg["anchors"]) == 3 + assert all(len(level) == 6 for level in post_cfg["anchors"]) + detection_post = cast(YOLODetectionPostBase, postprocessor) + assert detection_post.nc == 1 + assert detection_post.na == 3 + assert detection_post.nl == 3