From c443160d4c378aaea257d02336b2887f7d81d946 Mon Sep 17 00:00:00 2001 From: jinman Date: Tue, 25 Aug 2026 16:53:56 +0900 Subject: [PATCH 1/2] refactor: remove YOLOv6 models and update YOLOv8 image sizes for consistency --- mblt_vision/face_detection/__init__.py | 4 ---- mblt_vision/models/YOLOv6m-face.yaml | 27 -------------------------- mblt_vision/models/YOLOv6n-face.yaml | 27 -------------------------- mblt_vision/models/YOLOv8l-face.yaml | 6 ++++-- mblt_vision/models/YOLOv8m-face.yaml | 6 ++++-- 5 files changed, 8 insertions(+), 62 deletions(-) delete mode 100644 mblt_vision/models/YOLOv6m-face.yaml delete mode 100644 mblt_vision/models/YOLOv6n-face.yaml diff --git a/mblt_vision/face_detection/__init__.py b/mblt_vision/face_detection/__init__.py index 0e1ca62..10bfc4b 100644 --- a/mblt_vision/face_detection/__init__.py +++ b/mblt_vision/face_detection/__init__.py @@ -17,8 +17,6 @@ "YOLOv10m_face", "YOLOv10n_face", "YOLOv10s_face", - "YOLOv6m_face", - "YOLOv6n_face", "YOLOv8l_face", "YOLOv8m_face", "YOLOv8n_face", @@ -36,8 +34,6 @@ 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__) 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/YOLOv6m-face.yaml b/mblt_vision/models/YOLOv6m-face.yaml deleted file mode 100644 index 5c56065..0000000 --- a/mblt_vision/models/YOLOv6m-face.yaml +++ /dev/null @@ -1,27 +0,0 @@ -DEFAULT: - file_cfg: - repo_id: mobilint/YOLOv6m-face - filename: yolov6m-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 - nl: 3 - reg_max: 16 - conf_thres: 0.001 - iou_thres: 0.7 -TURBO: - update: DEFAULT - file_cfg: - revision: TURBO diff --git a/mblt_vision/models/YOLOv6n-face.yaml b/mblt_vision/models/YOLOv6n-face.yaml deleted file mode 100644 index f1e7541..0000000 --- a/mblt_vision/models/YOLOv6n-face.yaml +++ /dev/null @@ -1,27 +0,0 @@ -DEFAULT: - file_cfg: - repo_id: mobilint/YOLOv6n-face - filename: yolov6n-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 - nl: 3 - reg_max: 16 - conf_thres: 0.001 - iou_thres: 0.7 -TURBO: - update: DEFAULT - file_cfg: - revision: TURBO 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: From f44d99b94c3313865bdb62af2f43e5a0da8ef454 Mon Sep 17 00:00:00 2001 From: jinman Date: Wed, 26 Aug 2026 09:30:09 +0900 Subject: [PATCH 2/2] feat: add face detection support for YOLO models with dedicated postprocessing classes --- mblt_vision/utils/postprocess/build_post.py | 36 ++++++- mblt_vision/utils/postprocess/common.py | 98 +++++++++++++++++++ .../utils/postprocess/yolo_anchor_post.py | 6 +- .../utils/postprocess/yolo_anchorless_post.py | 9 +- .../utils/postprocess/yolo_dflfree_post.py | 7 +- .../utils/postprocess/yolo_nmsfree_post.py | 6 +- tests/test_face_detection.py | 88 ++++++++++++++--- 7 files changed, 229 insertions(+), 21 deletions(-) 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 8b90d31..4282a3a 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, @@ -566,7 +567,7 @@ class YOLOAnchorlessPosePost(YOLOPosePostMixin, YOLOAnchorlessDetectionPost): def extract_final_outputs( self, x: TensorLike | ListTensorLike - ) -> tuple[list[torch.Tensor] | None, torch.Tensor | None]: + ) -> tuple[list[torch.Tensor] | torch.Tensor | None, torch.Tensor | None]: """Accept QBCompiler's decode-enabled candidate-first pose output. Decode-enabled MXQs emit ``(B, anchors, 5 + keypoints)`` containing @@ -1006,4 +1007,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 8cc52f2..54d32e1 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, @@ -558,7 +559,7 @@ class YOLODFLFreePosePost(YOLOPosePostMixin, YOLODFLFreeDetectionPost): def extract_final_outputs( self, x: TensorLike | ListTensorLike - ) -> tuple[list[torch.Tensor] | None, torch.Tensor | None]: + ) -> tuple[list[torch.Tensor] | torch.Tensor | None, torch.Tensor | None]: """Accept YOLO26's decode-enabled score, xyxy, and keypoint outputs.""" if self.e2e and isinstance(x, (list, tuple)) and len(x) == 4: tensors = [ @@ -1017,4 +1018,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..5f20aac 100644 --- a/tests/test_face_detection.py +++ b/tests/test_face_detection.py @@ -8,17 +8,31 @@ import numpy as np import pytest import torch + +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 @@ -41,22 +55,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 +160,24 @@ 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))