From eb264063056ff0855a9faf0259124ab22b6b5c3b Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 14:55:28 +0800 Subject: [PATCH 01/14] feat: establish validated Python NMS core --- .gitignore | 9 ++ bbox-nms-c-version/nms.py | 110 ++--------------- bbox-nms/nms.py | 113 +++-------------- mask-nms/mask_nms.py | 77 ++---------- nmss/__init__.py | 23 ++++ nmss/_validation.py | 78 ++++++++++++ nmss/bbox.py | 246 ++++++++++++++++++++++++++++++++++++++ nmss/mask.py | 149 +++++++++++++++++++++++ pyproject.toml | 29 +++++ 9 files changed, 576 insertions(+), 258 deletions(-) create mode 100644 .gitignore create mode 100644 nmss/__init__.py create mode 100644 nmss/_validation.py create mode 100644 nmss/bbox.py create mode 100644 nmss/mask.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec2dea6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.so +.pytest_cache/ +.coverage +build/ +dist/ +*.egg-info/ +.venv/ diff --git a/bbox-nms-c-version/nms.py b/bbox-nms-c-version/nms.py index 1b1b4f6..9006b0f 100644 --- a/bbox-nms-c-version/nms.py +++ b/bbox-nms-c-version/nms.py @@ -1,97 +1,13 @@ -import numpy as np - -def nms_cpu(boxes, scores, score_thr, nms_thr): - """ - Single class NMS - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, 1) in [0,1] - - output: - NDArray of indices to keep - """ - - raw_indices = np.arange(0, scores.shape[0]) - score_thr_mask = scores >= score_thr - boxes = boxes[score_thr_mask] - scores = scores[score_thr_mask] - raw_indices = raw_indices[score_thr_mask] - - - x1 = boxes[:, 0] - y1 = boxes[:, 1] - x2 = boxes[:, 2] - y2 = boxes[:, 3] - - areas = (x2 - x1 + 1) * (y2 - y1 + 1) - order = scores.argsort()[::-1] - - keep = [] - while order.size > 0: - i = order[0] - keep.append(raw_indices[i]) - xx1 = np.maximum(x1[i], x1[order[1:]]) - yy1 = np.maximum(y1[i], y1[order[1:]]) - xx2 = np.minimum(x2[i], x2[order[1:]]) - yy2 = np.minimum(y2[i], y2[order[1:]]) - - w = np.maximum(0.0, xx2 - xx1 + 1) - h = np.maximum(0.0, yy2 - yy1 + 1) - inter = w * h - ovr = inter / (areas[i] + areas[order[1:]] - inter) - - inds = np.where(ovr <= nms_thr)[0] - order = order[inds + 1] - - return np.array(keep) - - -def multiclass_nms_class_unaware_cpu(boxes, scores, score_thr, nms_thr): - """ - Mutli class NMS (class-unaware) - - Class-unaware: a proposal can only belong to a single class - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, num_classes) in [0, 1] - - output: - [NDArray of indices to keep, NDArray of class id] - """ - - cls_inds = scores.argmax(1) - cls_scores = scores[np.arange( scores.shape[0]), cls_inds] - - valid_idx = nms_cpu(boxes=boxes, scores=cls_scores, score_thr=score_thr, nms_thr=nms_thr) - valid_idx_class_id = np.take(cls_inds, valid_idx) - - return valid_idx, valid_idx_class_id - - -def multiclass_nms_class_aware_cpu(boxes, scores, score_thr, nms_thr): - """ - Mutli class NMS (class-aware) - - Class-unaware: a proposal can belong to mutiple single class - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, num_classes) in [0, 1] - - output: - [NDArray of indices to keep, NDArray of class id] - """ - - valid_idx = [] - valid_idx_class_id = [] - - num_classes = scores.shape[-1] - for cls_id in range(num_classes): - class_valid_idx = nms_cpu(boxes, scores[:, cls_id], score_thr=score_thr, nms_thr=nms_thr) - valid_idx.extend(class_valid_idx) - valid_idx_class_id.extend([ cls_id for _ in range(len(class_valid_idx))]) - - - return np.array(valid_idx), np.array(valid_idx_class_id) +"""Compatibility wrapper for the original Python reference implementation.""" + +from nmss.bbox import ( + multiclass_nms_class_aware_cpu, + multiclass_nms_class_unaware_cpu, + nms_cpu, +) + +__all__ = [ + "multiclass_nms_class_aware_cpu", + "multiclass_nms_class_unaware_cpu", + "nms_cpu", +] diff --git a/bbox-nms/nms.py b/bbox-nms/nms.py index ad0074e..e5c63d4 100644 --- a/bbox-nms/nms.py +++ b/bbox-nms/nms.py @@ -1,97 +1,16 @@ -import numpy as np - -def nms_cpu(boxes, scores, score_thr, nms_thr): - """ - Single class NMS - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, 1) in [0,1] - - output: - NDArray of indices to keep - """ - - raw_indices = np.arange(0, scores.shape[0]) - score_thr_mask = scores >= score_thr - boxes = boxes[score_thr_mask] - scores = scores[score_thr_mask] - raw_indices = raw_indices[score_thr_mask] - - - x1 = boxes[:, 0] - y1 = boxes[:, 1] - x2 = boxes[:, 2] - y2 = boxes[:, 3] - - areas = (x2 - x1 + 1) * (y2 - y1 + 1) - order = scores.argsort()[::-1] - - keep = [] - while order.size > 0: - i = order[0] - keep.append(raw_indices[i]) - xx1 = np.maximum(x1[i], x1[order[1:]]) - yy1 = np.maximum(y1[i], y1[order[1:]]) - xx2 = np.minimum(x2[i], x2[order[1:]]) - yy2 = np.minimum(y2[i], y2[order[1:]]) - - w = np.maximum(0.0, xx2 - xx1 + 1) - h = np.maximum(0.0, yy2 - yy1 + 1) - inter = w * h - ovr = inter / (areas[i] + areas[order[1:]] - inter) - - inds = np.where(ovr <= nms_thr)[0] - order = order[inds + 1] - - return np.array(keep) - - -def multiclass_nms_class_unaware_cpu(boxes, scores, score_thr, nms_thr): - """ - Mutli class NMS (class-unaware) - - Class-unaware: a proposal can only belong to a single class - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, num_classes) in [0, 1] - - output: - [NDArray of indices to keep, NDArray of class id] - """ - - cls_inds = scores.argmax(1) - cls_scores = scores[np.arange( scores.shape[0]), cls_inds] - - valid_idx = nms_cpu(boxes=boxes, scores=cls_scores, score_thr=score_thr, nms_thr=nms_thr) - valid_idx_class_id = np.take(cls_inds, valid_idx) - - return valid_idx, valid_idx_class_id - - -def multiclass_nms_class_aware_cpu(boxes, scores, score_thr, nms_thr): - """ - Mutli class NMS (class-aware) - - Class-unaware: a proposal can belong to mutiple single class - - inputs: - boxes: NDArray (num_boxes, 4) in xyxy - scores: NDArray (num_boxes, num_classes) in [0, 1] - - output: - [NDArray of indices to keep, NDArray of class id] - """ - - valid_idx = [] - valid_idx_class_id = [] - - num_classes = scores.shape[-1] - for cls_id in range(num_classes): - class_valid_idx = nms_cpu(boxes, scores[:, cls_id], score_thr=score_thr, nms_thr=nms_thr) - valid_idx.extend(class_valid_idx) - valid_idx_class_id.extend([ cls_id for _ in range(len(class_valid_idx))]) - - - return np.array(valid_idx), np.array(valid_idx_class_id) \ No newline at end of file +"""Compatibility wrapper for the original module path. + +New code should import these functions from :mod:`nmss`. +""" + +from nmss.bbox import ( + multiclass_nms_class_aware_cpu, + multiclass_nms_class_unaware_cpu, + nms_cpu, +) + +__all__ = [ + "multiclass_nms_class_aware_cpu", + "multiclass_nms_class_unaware_cpu", + "nms_cpu", +] diff --git a/mask-nms/mask_nms.py b/mask-nms/mask_nms.py index e0c4b28..2b1fd08 100644 --- a/mask-nms/mask_nms.py +++ b/mask-nms/mask_nms.py @@ -1,64 +1,13 @@ -import numba -from numba import njit -from numba.typed import List as NList -from numba.types import int64 as nb_int64 - -import numpy as np - -@njit -def mask_overlap(mask1, mask2): - _union = np.count_nonzero(np.bitwise_or(mask1, mask2)) - if _union == 0: - return 0 - _inter = np.count_nonzero(np.bitwise_and(mask1, mask2)) - return _inter / _union - - -@njit -def mask_nms_cpu(masks, scores, score_thr = 0.5, nms_thr = 0.5): - - raw_indices = np.arange(0, scores.shape[0]) - score_thr_mask = (scores >= score_thr) - masks = masks[score_thr_mask] - scores = scores[score_thr_mask] - raw_indices = raw_indices[score_thr_mask] - - order = scores.argsort()[::-1] - keep = NList.empty_list(nb_int64) - while order.size > 0: - i = order[0] - keep.append(raw_indices[i]) - - ovr = np.asarray([mask_overlap(masks[i], masks[_order]) for _order in order[1:]]) - - inds = np.where(ovr <= nms_thr)[0] - order = order[inds + 1] - return keep - -def multiclass_mask_nms_class_aware_cpu(masks, scores, score_thr, nms_thr): - """ - Mutli class mask NMS (class-aware) - - Class-unaware: a proposal can belong to mutiple single class - - inputs: - masks: NDArray (num_masks, W, H) (type: Boolean) - scores: NDArray (num_masks, num_classes) in [0, 1] - - output: - [NDArray of indices to keep, NDArray of class id] - """ - - valid_idx = [] - valid_idx_class_id = [] - - if np.bool_ != masks.dtype: - raise Exception("Masks must be boolean type") - - num_classes = scores.shape[-1] - for cls_id in range(num_classes): - class_valid_idx = mask_nms_cpu(masks, scores[:, cls_id], score_thr=score_thr, nms_thr=nms_thr) - valid_idx.extend(class_valid_idx) - valid_idx_class_id.extend([ cls_id for _ in range(len(class_valid_idx))]) - - return np.array(valid_idx), np.array(valid_idx_class_id) +"""Compatibility wrapper for the original module path.""" + +from nmss.mask import ( + mask_nms_cpu, + mask_overlap, + multiclass_mask_nms_class_aware_cpu, +) + +__all__ = [ + "mask_nms_cpu", + "mask_overlap", + "multiclass_mask_nms_class_aware_cpu", +] diff --git a/nmss/__init__.py b/nmss/__init__.py new file mode 100644 index 0000000..130336b --- /dev/null +++ b/nmss/__init__.py @@ -0,0 +1,23 @@ +"""Fast, dependency-light non-maximum suppression utilities.""" + +from .bbox import ( + bbox_iou, + multiclass_nms, + multiclass_nms_class_aware, + multiclass_nms_class_unaware, + nms, +) +from .mask import mask_iou, mask_nms, multiclass_mask_nms + +__all__ = [ + "bbox_iou", + "mask_iou", + "mask_nms", + "multiclass_mask_nms", + "multiclass_nms", + "multiclass_nms_class_aware", + "multiclass_nms_class_unaware", + "nms", +] + +__version__ = "1.0.0" diff --git a/nmss/_validation.py b/nmss/_validation.py new file mode 100644 index 0000000..81f1f56 --- /dev/null +++ b/nmss/_validation.py @@ -0,0 +1,78 @@ +"""Shared validation helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def validate_threshold(name: str, value: float) -> float: + value = float(value) + if not np.isfinite(value) or not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be finite and in [0, 1], got {value!r}") + return value + + +def validate_offset(offset: float) -> float: + offset = float(offset) + if offset not in (0.0, 1.0): + raise ValueError(f"offset must be 0 or 1, got {offset!r}") + return offset + + +def validate_boxes(boxes: ArrayLike) -> NDArray[np.float64]: + result = np.ascontiguousarray(boxes, dtype=np.float64) + if result.ndim != 2 or result.shape[1:] != (4,): + raise ValueError(f"boxes must have shape (N, 4), got {result.shape}") + if not np.isfinite(result).all(): + raise ValueError("boxes must contain only finite values") + if result.size and ( + np.any(result[:, 2] < result[:, 0]) + or np.any(result[:, 3] < result[:, 1]) + ): + raise ValueError("each box must satisfy x2 >= x1 and y2 >= y1") + return result + + +def validate_scores( + scores: ArrayLike, + num_items: int, + *, + ndim: int, +) -> NDArray[np.float64]: + result = np.ascontiguousarray(scores, dtype=np.float64) + if result.ndim != ndim: + shape = "(N,)" if ndim == 1 else "(N, C)" + raise ValueError(f"scores must have shape {shape}, got {result.shape}") + if result.shape[0] != num_items: + raise ValueError( + "boxes/masks and scores must contain the same number of items, " + f"got {num_items} and {result.shape[0]}" + ) + if ndim == 2 and result.shape[1] == 0: + raise ValueError("scores must contain at least one class") + if not np.isfinite(result).all(): + raise ValueError("scores must contain only finite values") + return result + + +def validate_masks(masks: ArrayLike) -> NDArray[np.bool_]: + result = np.asarray(masks) + if result.ndim < 2: + raise ValueError(f"masks must have shape (N, ...), got {result.shape}") + if result.dtype != np.bool_: + raise TypeError(f"masks must have boolean dtype, got {result.dtype}") + return np.ascontiguousarray(result) + + +def validate_batch( + boxes: Sequence[ArrayLike], + scores: Sequence[ArrayLike], +) -> None: + if len(boxes) != len(scores): + raise ValueError( + "boxes and scores batches must have equal length, " + f"got {len(boxes)} and {len(scores)}" + ) diff --git a/nmss/bbox.py b/nmss/bbox.py new file mode 100644 index 0000000..fe06349 --- /dev/null +++ b/nmss/bbox.py @@ -0,0 +1,246 @@ +"""Bounding-box non-maximum suppression.""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ._validation import ( + validate_boxes, + validate_offset, + validate_scores, + validate_threshold, +) + + +def bbox_iou( + box: ArrayLike, + boxes: ArrayLike, + *, + offset: float = 0.0, +) -> NDArray[np.float64]: + """Return IoU between one ``xyxy`` box and an array of ``xyxy`` boxes.""" + offset = validate_offset(offset) + box_array = np.asarray(box, dtype=np.float64) + if box_array.shape != (4,) or not np.isfinite(box_array).all(): + raise ValueError("box must contain four finite xyxy coordinates") + boxes_array = validate_boxes(boxes) + if box_array[2] < box_array[0] or box_array[3] < box_array[1]: + raise ValueError("box must satisfy x2 >= x1 and y2 >= y1") + + top_left = np.maximum(box_array[:2], boxes_array[:, :2]) + bottom_right = np.minimum(box_array[2:], boxes_array[:, 2:]) + intersection_size = np.maximum(0.0, bottom_right - top_left + offset) + intersection = intersection_size[:, 0] * intersection_size[:, 1] + + box_size = box_array[2:] - box_array[:2] + offset + boxes_size = boxes_array[:, 2:] - boxes_array[:, :2] + offset + box_area = box_size[0] * box_size[1] + boxes_area = boxes_size[:, 0] * boxes_size[:, 1] + union = box_area + boxes_area - intersection + + return np.divide( + intersection, + union, + out=np.zeros_like(intersection), + where=union > 0.0, + ) + + +def nms( + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, +) -> NDArray[np.int64]: + """Run deterministic single-class greedy NMS. + + Scores equal to ``score_threshold`` are retained. Equal-score boxes are + processed in original index order. + """ + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=1) + score_threshold = validate_threshold("score_threshold", score_threshold) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + candidate_indices = np.flatnonzero(scores_array >= score_threshold) + if candidate_indices.size == 0 or max_detections == 0: + return np.empty(0, dtype=np.int64) + + # lexsort uses the last key as primary: descending score, then index. + order = np.lexsort( + (candidate_indices, -scores_array[candidate_indices]) + ) + candidate_indices = candidate_indices[order] + + keep: list[int] = [] + while candidate_indices.size: + current = int(candidate_indices[0]) + keep.append(current) + if ( + candidate_indices.size == 1 + or (max_detections is not None and len(keep) >= max_detections) + ): + break + remaining = candidate_indices[1:] + overlaps = bbox_iou( + boxes_array[current], + boxes_array[remaining], + offset=offset, + ) + candidate_indices = remaining[overlaps <= iou_threshold] + + return np.asarray(keep, dtype=np.int64) + + +def multiclass_nms_class_aware( + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Run NMS independently per class and sort all results by score.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=2) + score_threshold = validate_threshold("score_threshold", score_threshold) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + box_parts: list[NDArray[np.int64]] = [] + class_parts: list[NDArray[np.int64]] = [] + score_parts: list[NDArray[np.float64]] = [] + for class_id in range(scores_array.shape[1]): + kept = nms( + boxes_array, + scores_array[:, class_id], + score_threshold, + iou_threshold, + offset=offset, + ) + if kept.size: + box_parts.append(kept) + class_parts.append(np.full(kept.size, class_id, dtype=np.int64)) + score_parts.append(scores_array[kept, class_id]) + + if not box_parts or max_detections == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.copy() + + box_indices = np.concatenate(box_parts) + class_ids = np.concatenate(class_parts) + kept_scores = np.concatenate(score_parts) + order = np.lexsort((class_ids, box_indices, -kept_scores)) + if max_detections is not None: + order = order[:max_detections] + return box_indices[order], class_ids[order] + + +def multiclass_nms_class_unaware( + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Assign each box to its best class, then suppress across all classes.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=2) + if len(boxes_array) == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.copy() + class_ids = np.argmax(scores_array, axis=1).astype(np.int64, copy=False) + best_scores = scores_array[np.arange(len(scores_array)), class_ids] + kept = nms( + boxes_array, + best_scores, + score_threshold, + iou_threshold, + offset=offset, + max_detections=max_detections, + ) + return kept, class_ids[kept] + + +def multiclass_nms( + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + class_aware: bool = True, + offset: float = 0.0, + max_detections: int | None = None, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Run class-aware or class-unaware bounding-box NMS.""" + implementation = ( + multiclass_nms_class_aware + if class_aware + else multiclass_nms_class_unaware + ) + return implementation( + boxes, + scores, + score_threshold, + iou_threshold, + offset=offset, + max_detections=max_detections, + ) + + +# Backwards-compatible call signatures used by the original scripts. +def nms_cpu( + boxes: ArrayLike, + scores: ArrayLike, + score_thr: float, + nms_thr: float, +) -> NDArray[np.int64]: + return nms( + boxes, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + offset=1.0, + ) + + +def multiclass_nms_class_aware_cpu( + boxes: ArrayLike, + scores: ArrayLike, + score_thr: float, + nms_thr: float, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + return multiclass_nms_class_aware( + boxes, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + offset=1.0, + ) + + +def multiclass_nms_class_unaware_cpu( + boxes: ArrayLike, + scores: ArrayLike, + score_thr: float, + nms_thr: float, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + return multiclass_nms_class_unaware( + boxes, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + offset=1.0, + ) diff --git a/nmss/mask.py b/nmss/mask.py new file mode 100644 index 0000000..133ae5a --- /dev/null +++ b/nmss/mask.py @@ -0,0 +1,149 @@ +"""Boolean-mask non-maximum suppression.""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ._validation import ( + validate_masks, + validate_scores, + validate_threshold, +) + + +def mask_iou(mask: ArrayLike, masks: ArrayLike) -> NDArray[np.float64]: + """Return IoU between one boolean mask and a batch of boolean masks.""" + mask_array = np.asarray(mask) + masks_array = validate_masks(masks) + if mask_array.dtype != np.bool_: + raise TypeError(f"mask must have boolean dtype, got {mask_array.dtype}") + if mask_array.shape != masks_array.shape[1:]: + raise ValueError( + "mask spatial shape must match masks, " + f"got {mask_array.shape} and {masks_array.shape[1:]}" + ) + flattened = masks_array.reshape(len(masks_array), -1) + mask_flattened = mask_array.reshape(-1) + intersection = np.count_nonzero(flattened & mask_flattened, axis=1) + union = np.count_nonzero(flattened | mask_flattened, axis=1) + return np.divide( + intersection, + union, + out=np.zeros(len(masks_array), dtype=np.float64), + where=union > 0, + ) + + +def mask_nms( + masks: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + max_detections: int | None = None, +) -> NDArray[np.int64]: + """Run deterministic single-class NMS over boolean masks.""" + masks_array = validate_masks(masks) + scores_array = validate_scores(scores, len(masks_array), ndim=1) + score_threshold = validate_threshold("score_threshold", score_threshold) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + candidates = np.flatnonzero(scores_array >= score_threshold) + if candidates.size == 0 or max_detections == 0: + return np.empty(0, dtype=np.int64) + order = np.lexsort((candidates, -scores_array[candidates])) + candidates = candidates[order] + + keep: list[int] = [] + while candidates.size: + current = int(candidates[0]) + keep.append(current) + if ( + candidates.size == 1 + or (max_detections is not None and len(keep) >= max_detections) + ): + break + remaining = candidates[1:] + overlaps = mask_iou(masks_array[current], masks_array[remaining]) + candidates = remaining[overlaps <= iou_threshold] + return np.asarray(keep, dtype=np.int64) + + +def multiclass_mask_nms( + masks: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + max_detections: int | None = None, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Run mask NMS independently per class and sort by score.""" + masks_array = validate_masks(masks) + scores_array = validate_scores(scores, len(masks_array), ndim=2) + score_threshold = validate_threshold("score_threshold", score_threshold) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + mask_parts: list[NDArray[np.int64]] = [] + class_parts: list[NDArray[np.int64]] = [] + score_parts: list[NDArray[np.float64]] = [] + for class_id in range(scores_array.shape[1]): + kept = mask_nms( + masks_array, + scores_array[:, class_id], + score_threshold, + iou_threshold, + ) + if kept.size: + mask_parts.append(kept) + class_parts.append(np.full(kept.size, class_id, dtype=np.int64)) + score_parts.append(scores_array[kept, class_id]) + + if not mask_parts or max_detections == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.copy() + + mask_indices = np.concatenate(mask_parts) + class_ids = np.concatenate(class_parts) + kept_scores = np.concatenate(score_parts) + order = np.lexsort((class_ids, mask_indices, -kept_scores)) + if max_detections is not None: + order = order[:max_detections] + return mask_indices[order], class_ids[order] + + +# Backwards-compatible call signatures used by the original script. +def mask_overlap(mask1: ArrayLike, mask2: ArrayLike) -> float: + return float(mask_iou(mask1, np.asarray([mask2]))[0]) + + +def mask_nms_cpu( + masks: ArrayLike, + scores: ArrayLike, + score_thr: float = 0.5, + nms_thr: float = 0.5, +) -> NDArray[np.int64]: + return mask_nms( + masks, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + ) + + +def multiclass_mask_nms_class_aware_cpu( + masks: ArrayLike, + scores: ArrayLike, + score_thr: float, + nms_thr: float, +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + return multiclass_mask_nms( + masks, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..473f703 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "nmss" +version = "1.0.0" +description = "Fast NumPy and C implementations of bounding-box and mask NMS" +readme = "README.md" +requires-python = ">=3.9" +dependencies = ["numpy>=1.23"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.optional-dependencies] +dev = ["pytest>=7", "pytest-cov>=4"] + +[tool.setuptools.packages.find] +include = ["nmss*"] + +[tool.pytest.ini_options] +addopts = "-ra --strict-markers" +testpaths = ["tests"] From b2b843ab82e28f04bcf20dbebdd014fa7e2abbe8 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:00:26 +0800 Subject: [PATCH 02/14] feat: add reproducible native NMS backend --- bbox-nms-c-version/batch_parallel_nms.c | 288 ------------------ bbox-nms-c-version/batch_parallel_nms.py | 262 ++-------------- .../compiled/batch_parallel_nms.so | Bin 16424 -> 0 bytes nmss/build.py | 67 ++++ nmss/c_backend.py | 198 ++++++++++++ nmss/csrc/nms.c | 110 +++++++ pyproject.toml | 3 + 7 files changed, 410 insertions(+), 518 deletions(-) delete mode 100644 bbox-nms-c-version/batch_parallel_nms.c delete mode 100755 bbox-nms-c-version/compiled/batch_parallel_nms.so create mode 100644 nmss/build.py create mode 100644 nmss/c_backend.py create mode 100644 nmss/csrc/nms.c diff --git a/bbox-nms-c-version/batch_parallel_nms.c b/bbox-nms-c-version/batch_parallel_nms.c deleted file mode 100644 index da02ab3..0000000 --- a/bbox-nms-c-version/batch_parallel_nms.c +++ /dev/null @@ -1,288 +0,0 @@ -#include -#include -#include -#include -#include - -#define MIN(a,b) (((a)<(b))?(a):(b)) -#define MAX(a,b) (((a)>(b))?(a):(b)) - -typedef unsigned long int ulong; -typedef unsigned int uint; - -void batch_parallel_nms( - ulong* batch_bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* batch_bboxes_shape, // (num_boxes, 4) - double* batch_scores, // scores: NDArray (num_boxes, num_classes) in [0, 1] - ulong* batch_scores_shape, // (num_boxes, num_classes) - ulong* batch_num_recorder, - ulong batch_size, - double score_thr, - double nms_thr, - ulong* batch_valid_idx, - ulong* batch_valid_idx_class_id, - ulong* batch_result_length -); - - -bool* nms_cpu( - ulong* bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* bboxes_shape, // (num_boxes, 4) - double* scores, // scores: NDArray (num_boxes, 1) in [0, 1] - ulong* scores_shape, // (num_boxes, 1) - double score_thr, - double nms_thr -); - - -void multiclass_nms_class_aware_cpu( - ulong* bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* bboxes_shape, // (num_boxes, 4) - double* scores, // scores: NDArray (num_boxes, num_classes) in [0, 1] - ulong* scores_shape, // (num_boxes, num_classes) - double score_thr, - double nms_thr, - - ulong* valid_idx, - ulong* valid_idx_class_id, - ulong* result_length -); - -void batch_parallel_nms( - ulong* batch_bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* batch_bboxes_shape, // (num_boxes, 4) - double* batch_scores, // scores: NDArray ( num_boxes, num_classes) in [0, 1] - ulong* batch_scores_shape, // (num_boxes, num_classes) - - ulong* batch_num_recorder, - ulong batch_size, - - double score_thr, - double nms_thr, - - ulong* batch_valid_idx, - ulong* batch_valid_idx_class_id, - ulong* batch_result_length -){ - - ulong* batch_end_pos = malloc(sizeof(ulong) * batch_size); - ulong tmp_recorder = 0; - for(size_t i = 0; i < batch_size; i++){ - tmp_recorder += batch_num_recorder[i]; - batch_end_pos[i] = tmp_recorder; - } - - #pragma omp parallel for - for(size_t batch_idx = 0; batch_idx < batch_size; batch_idx++){ - - ulong this_batch_size = batch_num_recorder[batch_idx]; - ulong this_batch_start_pos = 0; - if (batch_idx != 0){ - this_batch_start_pos = batch_end_pos[batch_idx - 1]; - } - - ulong this_batch_end_pos = batch_end_pos[batch_idx]; - - ulong bboxes_shape[] = {this_batch_size, 4}; - ulong scores_shape[] = {this_batch_size, batch_scores_shape[1]}; - - ulong* bboxes = malloc(sizeof(ulong) * this_batch_size * 4); - memcpy(bboxes, batch_bboxes + this_batch_start_pos * 4, sizeof(ulong) * (this_batch_end_pos - this_batch_start_pos) * 4); - - - double* scores = malloc(sizeof(double) * this_batch_size * batch_scores_shape[1]); - memcpy(scores, batch_scores + this_batch_start_pos * batch_scores_shape[1], sizeof(double) * (this_batch_end_pos - this_batch_start_pos) * batch_scores_shape[1]); - - ulong* valid_idx = batch_valid_idx + this_batch_start_pos * batch_scores_shape[1]; - ulong* valid_idx_class_id = batch_valid_idx_class_id + this_batch_start_pos * batch_scores_shape[1]; - ulong* result_length = batch_result_length + batch_idx; - multiclass_nms_class_aware_cpu( - bboxes, - bboxes_shape, - scores, - scores_shape, - score_thr, - nms_thr, - - valid_idx, - valid_idx_class_id, - result_length - ); - - - free(bboxes); - free(scores); - } - free(batch_end_pos); -} - - - - -void multiclass_nms_class_aware_cpu( - ulong* bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* bboxes_shape, // (num_boxes, 4) - double* scores, // scores: NDArray (num_boxes, num_classes) in [0, 1] - ulong* scores_shape, // (num_boxes, num_classes) - double score_thr, - double nms_thr, - - ulong* valid_idx, - ulong* valid_idx_class_id, - ulong* result_length -){ - int bboxes_length = bboxes_shape[0]; - int scores_length = scores_shape[0]; - - ulong num_classes = scores_shape[1]; - for(size_t cls_id = 0; cls_id < num_classes; cls_id++){ - double* scores_cls = malloc(sizeof(double) * scores_length); - int scores_cls_pointer = 0; - - for(size_t scores_cls_offset = cls_id; scores_cls_pointer < scores_length; scores_cls_offset += num_classes){ - scores_cls[scores_cls_pointer++] = scores[scores_cls_offset]; - - } - ulong scores_cls_shape[] = {scores_shape[0], 1}; - bool* valid_idx_mask = nms_cpu(bboxes, bboxes_shape, scores_cls, scores_cls_shape, score_thr, nms_thr); - for(size_t keep_idx = 0; keep_idx < bboxes_shape[0]; keep_idx++){ - if (valid_idx_mask[keep_idx] == true){ - valid_idx[result_length[0]] = keep_idx; - valid_idx_class_id[result_length[0]++] = cls_id; - } - } - free(scores_cls); - free(valid_idx_mask); - } -} - - - -// NMS Implementation of C. -bool* nms_cpu( - ulong* bboxes, // boxes: NDArray (num_boxes, 4) in xyxy - ulong* bboxes_shape, // (num_boxes, 4) - double* scores, // scores: NDArray (num_boxes, 1) in [0, 1] - ulong* scores_shape, // (num_boxes, 1) - double score_thr, - double nms_thr -){ - - - - bool* score_thr_mask = malloc(sizeof(bool) * scores_shape[0]); - int valid_bboxes_count = 0; - for(size_t score_thr_mask_id = 0; score_thr_mask_id < scores_shape[0]; score_thr_mask_id++){ - if(scores[score_thr_mask_id] > score_thr) - valid_bboxes_count += 1; - } - - ulong* valid_boxes = malloc(sizeof(ulong) * valid_bboxes_count * bboxes_shape[1]); - ulong* x1 = malloc(sizeof(ulong) * valid_bboxes_count); - ulong* y1 = malloc(sizeof(ulong) * valid_bboxes_count); - ulong* x2 = malloc(sizeof(ulong) * valid_bboxes_count); - ulong* y2 = malloc(sizeof(ulong) * valid_bboxes_count); - - double* valid_scores = malloc(sizeof(double) * valid_bboxes_count); - ulong* valid_raw_indices = malloc(sizeof(ulong) * valid_bboxes_count); - - int valid_box_idx = 0; - // #pragma omp parallel for - for(size_t score_thr_mask_id = 0; score_thr_mask_id < scores_shape[0]; score_thr_mask_id++){ - if(scores[score_thr_mask_id] > score_thr){ - - for(size_t box_offset = 0; box_offset < bboxes_shape[1]; box_offset++){ - valid_boxes[valid_box_idx * bboxes_shape[1] + box_offset] = bboxes[score_thr_mask_id * bboxes_shape[1] + box_offset]; - - if(box_offset == 0) x1[valid_box_idx] = bboxes[score_thr_mask_id * bboxes_shape[1] + box_offset]; - else if(box_offset == 1) y1[valid_box_idx] = bboxes[score_thr_mask_id * bboxes_shape[1] + box_offset]; - else if(box_offset == 2) x2[valid_box_idx] = bboxes[score_thr_mask_id * bboxes_shape[1] + box_offset]; - else y2[valid_box_idx] = bboxes[score_thr_mask_id * bboxes_shape[1] + box_offset]; - } - valid_scores[valid_box_idx] = scores[score_thr_mask_id]; - valid_raw_indices[valid_box_idx] = score_thr_mask_id; - valid_box_idx += 1; - } - } - - - bool* keep = malloc(sizeof(bool) * valid_bboxes_count); - // #pragma omp parallel for - for(size_t keep_id = 0; keep_id < valid_bboxes_count; keep_id++){ - keep[keep_id] = false; - } - - double* areas = malloc(sizeof(double) * valid_bboxes_count); - // #pragma omp parallel for - for(size_t areas_id = 0; areas_id < valid_bboxes_count; areas_id++){ - areas[areas_id] = (x2[areas_id] - x1[areas_id] + 1) * (y2[areas_id] - y1[areas_id] + 1); - } - - - int num_left_bboxes = valid_bboxes_count; - bool* left_bboxes = malloc(sizeof(bool) * num_left_bboxes); - // #pragma omp parallel for - for(size_t left_bboxes_id = 0; left_bboxes_id < valid_bboxes_count; left_bboxes_id++){ - left_bboxes[left_bboxes_id] = true; - } - - - while(num_left_bboxes > 0){ - int best_index = -1; - double highest_score = -1; - for(size_t i = 0; i < valid_bboxes_count; i++){ - if (keep[i] == true || left_bboxes[i] == false) continue; - if (valid_scores[i] > highest_score){ - best_index = i; - highest_score = valid_scores[i]; - } - } - - keep[best_index] = true; - left_bboxes[best_index] = false; - num_left_bboxes -= 1; - for(size_t i = 0; i < valid_bboxes_count; i++){ - if(keep[i] == true || left_bboxes[i] == false) - continue; - ulong xx1 = MAX(x1[best_index], x1[i]); - ulong yy1 = MAX(y1[best_index], y1[i]); - ulong xx2 = MIN(x2[best_index], x2[i]); - ulong yy2 = MIN(y2[best_index], y2[i]); - - double w = MAX(0.0, xx2 - xx1 + 1); - double h = MAX(0.0, yy2 - yy1 + 1); - double inter = w * h; - double ovr = inter / (areas[best_index] + areas[i] - inter); - // printf("%ld %ld overlapping: %lf\n", valid_raw_indices[best_index], valid_raw_indices[i], ovr); - if (ovr > nms_thr){ - left_bboxes[i] = false; - num_left_bboxes -= 1; - } - } - } - - bool* result = malloc(sizeof(bool) * scores_shape[0]); - // #pragma omp parallel for - for(size_t result_idx = 0; result_idx < scores_shape[0]; result_idx++){ - result[result_idx] = false; - } - for(size_t keep_idx = 0; keep_idx < valid_bboxes_count; keep_idx++){ - if (keep[keep_idx] == true){ - result[valid_raw_indices[keep_idx]] = true; - } - } - - free(score_thr_mask); - free(valid_boxes); - free(x1); - free(y1); - free(x2); - free(y2); - free(valid_scores); - free(valid_raw_indices); - free(areas); - free(left_bboxes); - free(keep); - - return result; -} diff --git a/bbox-nms-c-version/batch_parallel_nms.py b/bbox-nms-c-version/batch_parallel_nms.py index bfba297..bcae3a1 100644 --- a/bbox-nms-c-version/batch_parallel_nms.py +++ b/bbox-nms-c-version/batch_parallel_nms.py @@ -1,238 +1,40 @@ -import os -import numpy as np +"""Compatibility adapter for the original accelerated API.""" -from ctypes import * -from numpy .ctypeslib import ndpointer +from __future__ import annotations -import time +from nmss.c_backend import CBackend - -TIME1 = 0 -TIME2 = 0 - class Batch_Parallel_Nms: - def __init__(self, dll:str = None) -> None: - if dll is None: - dll = os.path.join(os.path.realpath(os.path.dirname(__file__)), 'compiled/batch_parallel_nms.so') - self.dll = CDLL(dll) - - - self.dll.batch_parallel_nms.argtypes = [ - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # bboxes - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - ndpointer(c_double, flags="C_CONTIGUOUS"), # scores - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - c_uint64, - - c_double, # score_thr - c_double, # nms_thr - - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # batch_valid_indices - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # batch_valid_indices_cls_id - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - ] + """Deprecated adapter around :class:`nmss.c_backend.CBackend`.""" - self.dll.multiclass_nms_class_aware_cpu.argtypes = [ - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # bboxes - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - ndpointer(c_double, flags="C_CONTIGUOUS"), # scores - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - c_double, # score_thr - c_double, # nms_thr - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # valid_indices - ndpointer(c_uint64, flags="C_CONTIGUOUS"), # valid_indices_cls_id - ndpointer(c_uint64, flags="C_CONTIGUOUS"), - ] - - def batch_parallel_nms(self, bboxes, scores, score_thr, nms_thr): - global TIME2 - batch_size = len(bboxes) - batch_num_recorder = np.zeros(batch_size, dtype=np.uint64, order='C') - for i in range(batch_size): - batch_num_recorder[i] = len(bboxes[i]) - # bboxes = np.ascontiguousarray(np.vstack(bboxes, dtype=np.uint64)) - # scores = np.ascontiguousarray(np.vstack(scores, dtype=np.float64)) - bboxes = np.ascontiguousarray(np.vstack(bboxes), dtype=np.uint64) - scores = np.ascontiguousarray(np.vstack(scores), dtype=np.float64) - batch_valid_indices = np.full(scores.shape[0] * scores.shape[1], fill_value=0, dtype=np.uint64, order='C') - batch_valid_indices_cls_id = np.zeros(scores.shape[0] * scores.shape[1], dtype=np.uint64, order='C') - res_length = np.array([0] * batch_size, dtype=np.uint64, order='C') - - time1 = time.time() - ret = self.dll.batch_parallel_nms(bboxes, - np.array(bboxes.shape, dtype=np.uint64), - scores, - np.array(scores.shape, dtype=np.uint64), - - batch_num_recorder, - batch_size, - - score_thr, - nms_thr, - - batch_valid_indices, - batch_valid_indices_cls_id, - res_length) - TIME2 += time.time() - time1 - cur = 0 - indices_to_keeps = [] - nms_out_clss = [] - for i in range(batch_size): - _length = int(batch_num_recorder[i] * scores.shape[1]) - indices_to_keep = batch_valid_indices[cur: cur + _length][: res_length[i]] - indices_to_keeps.append(indices_to_keep) - nms_out_cls = batch_valid_indices_cls_id[cur: cur + _length][: res_length[i]] - nms_out_clss.append(nms_out_cls) - cur += _length - - return indices_to_keeps, nms_out_clss + def __init__(self, dll=None) -> None: + self._backend = CBackend(dll) if dll else CBackend() def nms(self, bboxes, scores, score_thr, nms_thr): - global TIME1 - valid_indices = np.full(scores.shape[0] * scores.shape[1], fill_value=0, dtype=np.uint64, order='C') - valid_indices_cls_id = np.zeros(scores.shape[0] * scores.shape[1], dtype=np.uint64, order='C') - res_length = np.array([0], dtype=np.uint64,) - time1 = time.time() - ret = self.dll.multiclass_nms_class_aware_cpu(bboxes, - np.array(bboxes.shape, dtype=np.uint64), - scores, - np.array(scores.shape, dtype=np.uint64), - score_thr, - nms_thr, - valid_indices, - valid_indices_cls_id, - res_length) - TIME1 += time.time() - time1 - return valid_indices[: res_length[0]], valid_indices_cls_id[: res_length[0]] - - - -def batch_parallel_nms_example(): - ''' - Input: - bboxes: [(num_boxes1, 4), (num_boxes2, 4), (num_boxes3, 4), (num_boxes4, 4)...] - scores: [(num_boxes1, num_classes), (num_boxes2, num_classes), (num_boxes3, num_classes), (num_boxes4, num_classes)...] - Output: - Incices_to_keep: [ - [bbox_idx1, bbox_idx2, bbox_idx3, bbox_idx4...], - [bbox_idx1, bbox_idx2, ...], - [bbox_idx1, bbox_idx2, bbox_idx3, bbox_idx4...], - [bbox_idx1, bbox_idx2, bbox_idx3...], - ] - nms_out_cls: [ - [0, 0, 0, 4...], - [0, 1, ...], - [0, 0, 5, 6...], - [0, 1, 3...], - ] - ''' - - import json - - test_bboxes_json_file = '/media/risksis/HDD_1/railway_safety_2023_movement/test_bboxes.json' - - with open(test_bboxes_json_file, 'r') as fp: - data = json.load(fp) - - - - bboxes = [data["bounding boxes"], data["bounding boxes"][: 10], data["bounding boxes"][: 5]] - scores = [data["scores"], data["scores"][: 10], data["scores"][: 5]] - - score_thr = 0.5 - nms_thr = 0.5 - - nms_c = Batch_Parallel_Nms() - indices_to_keep, nms_out_cls = nms_c.batch_parallel_nms(bboxes, scores, score_thr, nms_thr) - print("bounding boxes indices: ",indices_to_keep) - print("bounding boxes cls: ", nms_out_cls) - -def nms_example(): - ''' - Input: - bboxes: (num_boxes, 4) - scores: (num_boxes, num_classes) - Output: - Incices_to_keep: [bbox_idx1, bbox_idx2, bbox_idx3, bbox_idx4,...] - - nms_out_cls: [0, 0, 0, 4,...] - ''' - - import json - - test_bboxes_json_file = '/media/risksis/HDD_1/railway_safety_2023_movement/test_bboxes.json' - - with open(test_bboxes_json_file, 'r') as fp: - data = json.load(fp) - - - - bboxes = np.asarray(data["bounding boxes"], order='C', dtype=np.uint64) - scores = np.asarray(data["scores"], order='C', dtype=np.float64) - - score_thr = 0.5 - nms_thr = 0.5 - - nms_c = Batch_Parallel_Nms() - indices_to_keep, nms_out_cls = nms_c.nms(bboxes, scores, score_thr, nms_thr) - print("bounding boxes indices: ", indices_to_keep) - print("bounding boxes cls: ", nms_out_cls) - - - -def nms_performance_compare(batch_num = 100, run_times = 1000): - score_thr = 0.5 - nms_thr = 0.5 - - import json - from tqdm import trange - from models.boundingbox_detector.nms import multiclass_nms_class_aware_cpu - - test_bboxes_json_file = '/media/risksis/HDD_1/railway_safety_2023_movement/test_bboxes.json' - - with open(test_bboxes_json_file, 'r') as fp: - data = json.load(fp) - - - - data_bboxes = np.asarray(data["bounding boxes"], order='C', dtype=np.uint64) - data_scores = np.asarray(data["scores"], order='C', dtype=np.float64) - - - batched_bboxes = [data_bboxes] * batch_num - batched_scores = [data_scores] * batch_num - - nms_c = Batch_Parallel_Nms() - - time1 = time.time() - for i in trange(run_times): - for boxes, scores in zip(batched_bboxes, batched_scores): - indices_to_keep, nms_out_cls = multiclass_nms_class_aware_cpu(boxes, scores, score_thr, nms_thr) - time2 = time.time() - print("nms python consumption: ",(time2 - time1) / batch_num / run_times * 1000) - - for i in trange(run_times): - indices_to_keep, nms_out_cls = nms_c.batch_parallel_nms(batched_bboxes, batched_scores, score_thr, nms_thr) - time3 = time.time() - print("batched parallel consumption: ",(time3 - time2) / batch_num / run_times * 1000) - - for i in trange(run_times): - for boxes, scores in zip(batched_bboxes, batched_scores): - indices_to_keep, nms_out_cls = nms_c.nms(boxes, scores, score_thr, nms_thr) - time4 = time.time() - print("nms c version consumption: ", (time4 - time3) / batch_num / run_times * 1000) - - - print("pure c nms: ", TIME1 / batch_num / run_times * 1000) - print("pure c batched nms: ", TIME2 / batch_num / run_times * 1000) - -if __name__ == '__main__': - # batch_parallel_nms_example() - # nms_example() - nms_performance_compare() - - - + return self._backend.multiclass_nms( + bboxes, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + offset=1.0, + ) + + def batch_parallel_nms( + self, + bboxes, + scores, + score_thr, + nms_thr, + ): + results = self._backend.batch_multiclass_nms( + bboxes, + scores, + score_threshold=score_thr, + iou_threshold=nms_thr, + offset=1.0, + ) + return ( + [indices for indices, _ in results], + [class_ids for _, class_ids in results], + ) diff --git a/bbox-nms-c-version/compiled/batch_parallel_nms.so b/bbox-nms-c-version/compiled/batch_parallel_nms.so deleted file mode 100755 index ed1a73e847a153a5445fc2806f34b54e5ec81afc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16424 zcmeHOeQ;aVm4EUFD8c9{)MO1wK%0GK9a4-H%wngZNQs?Xr7}s0!=@#mD7F-PD}U6M zg41EaV29zY5QOQ+GL-4iUH%BOJ41H1yQMQMmGfaIP!=aNA0)rdBf-6fL4vkBx_BCyi(lno{*jdjK#H-gX z!S_|#0x4TIS@k*0=d@W2HMdPu8|8#5x)}bOZp$&`deW2adF* zU5{?sMZtchH=rQv5lMP(rRP@i498Tk7^?0{AIZ<1%3d+_ppJ@1ersB>-hQQ53=JjE zP{snhb??O6{*?CCDm{;Kq!>1;_84+|-+-Qsr>g%Vz{7l-YHzx6&^Z1Is_@Eqk`0)*nCgn+0G`S^gH>%ciIyA3am>-vVzc`3I)qKQj&gAEx2IG7X>l z>A|PC{0NMx{Ar(t{}u2(_!O6JFs8Dz68we5!k~9ad6!l@&#pX04x+5b=Q51UCS~k8 z#s8k-@Awi)Y1$JP+fWOIwsdxPh5F**-gqdag#s+!*emkID&Vs%s+=g`Uy_&Bw6g_#IG!(6J{zU! z$gUH|drpxo|Mm5nCbp;JCAl#%xYxNy_9u7an_y>iUw#Wfzgi=CdPdN)nKyE|+zv@o z0+h`h%+r)`Wi!Khni8ID=3nwOB|6#6Px3S+IN8i&d72WNZ07zvO-V;K^PN0RiCZ=k z&eN3GWHaB$)0EI;Gk&Jw{iV|TMosz;dO;C=c@bSKUtUyy4*w3&ub#Abx`_UL5&cdP z{dy67xQKq4X_315AaIc?f001L+4$ucs%r?xP|eDX>sJq;x7r>BA^#I8f8!Gb7M&O7 zgcyGR7BPIbR5(V&!SnG-7&v{MZNQlukIH_qK8@$V&38gqODtY525&wDNllpV#4AMd z=C6ToJ##*nv)T~ck@6csJMM&TzJ1cq+>J`*57GYhQN40FD)g?S^!2X4g}{MmK<}D} z>aoM9j{WEA)UubUBhBXCbwTqbasPWeLA8lw*_W%O2Sdi=fZlX4`On5UFnv*@;rUR% z_i1BHQZ0sCpZm0NOcE7_zNpQpwNmhv+Id}UKOWV)D-3G+EHFq%^=eoU=01>+g%iJx zU+F#MJ)mFh(*mUxzY=EfuqFm~J4C~)iTAw+yoW?8Zuo?`(b$AIzkz9|&uKII^*K)( z4^gvo8jW^+QH$Zz7X^*ARxKJAN!#x>vDofN;&O5SUh0}KPu+2+f204-O#3+m^n3VH z>&ySw{@eZQ{VnT-d2XEwgh)07leasCd5=*gw&(OAdaM$uRk?sUhK`Jj!Lz0Mg8{4* zF?^~797Oiiabb=L^HcQiIs-{NM&XPYezQF2*o{FSf)B)V*F$;)pCe*0?TPBUM;&1H zLx1;CV8;=j(||7Y3~7%+=w2eD)-r~U%yxX`(a1WaEg?0z@}3)ytK82gd5#X?}4ap z3%r8r67*eG-)L96^VM*?)bWa4y=UuOySmDowzboaQL0*idOg(G#hCkk7o5b z7%8DY`-fnv?scjLr_B&Sv&E=yHa`-8wbrW7a=D=SanSs!v4bo?x!PJ{Glf}ZSl8Hi zz#O&aF}lxkOY}s*9I?u6w8{H6DZ8!He}Kj0d_$P;3-d`iv#G-4O;dd8gJ#U=2`1rV zFc~wdvbtOPw9)7fn$H^p(oHuU_nV{S6ivQhGH%qKbO#3aI)aWf%?SsK6~``=|hj;bQ6%JnL&Z5wW?%yfywUFW7{yxFw!F@uZZrzs!V5v zel3D9rwl7jZZ7daAlZ0W3nZ7(8gz)%8b@Gp*{gxY6-O}`!|30(QDF`bj$=KZN{fRT zF>9EN3}^KX7Z^(XOnNJu#>B3-QlG$C^EJDV=4im&Yb^zfI!@j#IEM|x_L9#J43nEy zPW7IjV;9d2+=Ih8G0#7+tW1knLbMWl$Ms`bJv64T+7Gea_E79e92Tty24jZP;Z2V; zUaC3#BV`rXjNT}1PE}meoT}8DQ(y;D%Uq(g!Z?|*&mE4(ZjrnxKx5f}j;%TpNG_q+ z1RCl_1Nw@(QL!kiKeP)GhWsJw`k}1;P3Y7aaA^+0P&ty-H^QyE$i3mMv!y3+p$zke zrpg|D=rO3<-FeUM&O3o*Z6MjQ4|7H}>Q#;Ip+@&mqvjn5f-sK^zNupWzL<4{96);; z(wUX=q(dGMFfYq&!4@7czmy5dt=!&U^Qwo$K~_Bi)kB%DLN&Sr^U}FJtv@;>&kF3R zG=-ej;g9L)Xfdk1X9PBt2PGNA6vn_g17_NrR^uf*6rY719XuK*XoIe%^{gzG zN)y-ztxwKi0+>1L0|`%A83{kO-T)*mw{=uu9_wX^Eis^J7SmP42H^^V*agViM;rpx? z6oxJLUnNQt`WavxmrOYgSHWs@T7NgGb01K7{y+^2#p)*7e9eyW!a_4IU^qrGG?+3- zIWo6k&Dir*<};J$^JzMtY5x?-@5sX$BlNtHCI+@j+7BCPq0w3_NM#z0evx$0_KFD2 z7u(P1LswuI3YbSk>)4>>5W~MK6{)g))I9Q`X7gn)j&kgoBDKsu!%~e%L2#=1f@a)s z3w&)L0kMMy&}aSdG|j{jOs@Ic)AueAgUbwuzI{Ua*#a{-E8IBHth*pbTO;;!+Stu3 z&Lev}z&AV>h(Wu(Kca{CP_~4ItaRypeGt$>^&k6)E1oRgL@oSL|UZTc?V&G*uHVp&o}h#i{*eKVv`wE z1appJUNGnK?b|!*i~0k7&^!4f84vUqn_3WwF#5e=b*kc~XbtB-=w}Jmj8izW7$)CY z(Xc;pGGk%*EeAZ2&o}vej0XET8N`sMU^s`2onYeVrbBfe9aZKz>%$LoIX`CSKLG|> zKf)oA!hEDzl5B!MbgUnlun%G#(fn zafqZ(I^fjjVjby=?DnkBvbkKdIWElK1QuD?+5+ajpt)P_QdE46-&cm{w^=HV@7|_4`rVFs6&E{qpw0kqK0c41%H`64n#;Y?Rkd2b z>9{+77XI6wtjmMda_D zUt}-sD2+R&w6~h~X0%|QIF2}j8tQ?}4p z`0E#J&y<-6%tT-&0y7bqiNH(*W+E^Xftd)*MBx8T1o-_rexFV~uE}@)sFO_7JAG8} zGm>4@L)fK?+_}!u6-m=emiWpywJC{P$Uw=jfjd zDE!WwhkjE=;dj?=QU3AMOO;(c`L3PMW+=0oQ_1slDgDGu<KvF@T@QW_b7S%7fRCHE*?`QeO7f0kA#v|-BDKm_W-}gUfg1>8egA+YZct2V2^?W z3hq$wDFt^am{xF1L4H4T)ru8MJ=NpVt9v-2}v;U7_{+Y>t{X+6u|9)Oa4- zwVL)fK*&C?E9P&N@_31v?;WU$9{^6}|I^d(e-1v`_bjjt$mf5c)5U>)<8$E6>dJ+- zJbzEuF4a%v&)eXa;0<|R=WOR)AP#Mg=J7DchUcc?&w-;3txDs0z*_WvutU3CE1r*k zNgbQQ{!(R!$A#^zlzgmWA9HNDQSv|UXU{bJ?ckTt?xN-`TT6m}850HYNy-2G`|Hnw z?E#waM50L-9_$QjZt@`|w(RTX$$ntb21f z7HW%k_x6RtiGHmWZ_URd@kkq9j-OJ3E&+6e!o9uWZJ|h4ymuR3@(*`LLT!o8&TUXB zphA$0Um)5YjmEn1%D(dJ^VbOA>=eupz2P72k}j>h&EMPA^Ry5Jo%DlF|?ofNUtBr0tG=B3|e{*03BykBQ)FeW-UD0?OShy6yEvyQzS?&*p z)~sB)uBj!|;$I$YLc4`6hT`_!i9C;*WF|#G-h%M)O#`}A@cAzO(Q6reUxK1QcONG2 zIr#V*(&ffC!5H^#HStJ4NI6|=db``g@vv4CX;+J-y$#lwvTfL_#@1jQO@%uuHCVTuSdInj|4&3I52aiV+lnkZ6~fS?_zXDDW13!wE7aew^cfl~sGv$$ z^IShL{J`AJ?Q?$1u(n9Axc#rAo^o5(=lqx9<2WCvP+CbS>vLYZ4m`>+S)cQ1hMYe` zL>6U^N$n0`l*_U{=dTPGDoO4)%Q0+6J>|wsbAHUQN9mLQB*XqIwstoVvd#LOk2Bn< z1i5|I=l={K^%(wRkv`|u44sr%7ZlcG)-yl~ z{pUQAAwNg5d~y7qRr);snny`0$fV-t$4S|6SbvKGYQIw|i_+ zhP$Y;pz!=*)+<0LMEU1Deh#3G1*KS?X@8VF>^H*+R2AxLJ|$ag!yZL4U_FMPprTNppC5XZ zKFbyBGffYOB*Xsmb3^|$`qWloDFcIeN<3f1`it`%8)5xI$aAkybSJm3CLH}gg!U5d p5AR>}e+KrwP=8mQO}$%WoZDqtdfc5#|B0Jy3A)c&Sc*vPzW`17e+&Qs diff --git a/nmss/build.py b/nmss/build.py new file mode 100644 index 0000000..1da28a0 --- /dev/null +++ b/nmss/build.py @@ -0,0 +1,67 @@ +"""Build the optional native backend.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +PACKAGE_ROOT = Path(__file__).resolve().parent +SOURCE = PACKAGE_ROOT / "csrc" / "nms.c" +DEFAULT_OUTPUT = PACKAGE_ROOT / "lib" / "libnmss.so" + + +def build_c_backend( + output: str | os.PathLike[str] | None = None, + *, + compiler: str | None = None, +) -> Path: + """Compile and return the path to the shared C library.""" + output_path = Path(output).resolve() if output else DEFAULT_OUTPUT + compiler = compiler or os.environ.get("CC", "cc") + if shutil.which(compiler) is None: + raise RuntimeError( + f"C compiler {compiler!r} was not found; install GCC or Clang " + "or set the CC environment variable" + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + command = [ + compiler, + "-O3", + "-std=c11", + "-DNDEBUG", + "-fPIC", + "-shared", + str(SOURCE), + "-lm", + "-o", + str(output_path), + ] + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"native backend build failed: {detail}") + return output_path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compile the optional nmss C backend." + ) + parser.add_argument("--output", help="custom output library path") + parser.add_argument("--compiler", help="C compiler executable") + arguments = parser.parse_args(argv) + try: + output = build_c_backend(arguments.output, compiler=arguments.compiler) + except RuntimeError as error: + parser.exit(1, f"error: {error}\n") + print(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nmss/c_backend.py b/nmss/c_backend.py new file mode 100644 index 0000000..69c84ab --- /dev/null +++ b/nmss/c_backend.py @@ -0,0 +1,198 @@ +"""ctypes bindings for the optional C bounding-box NMS backend.""" + +from __future__ import annotations + +from collections.abc import Sequence +import ctypes +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +import os +from pathlib import Path + +import numpy as np +from numpy.ctypeslib import ndpointer +from numpy.typing import ArrayLike, NDArray + +from ._validation import ( + validate_batch, + validate_boxes, + validate_offset, + validate_scores, + validate_threshold, +) +from .build import DEFAULT_OUTPUT + + +class CBackend: + """Loaded native backend with validated NumPy-facing methods.""" + + def __init__(self, library: str | os.PathLike[str] = DEFAULT_OUTPUT) -> None: + library_path = Path(library).resolve() + if not library_path.is_file(): + raise FileNotFoundError( + f"native backend not found at {library_path}; " + "run `python -m nmss.build` first" + ) + self.library_path = library_path + self._library = ctypes.CDLL(str(library_path)) + self._library.nmss_nms.argtypes = [ + ndpointer(np.float64, ndim=2, flags="C_CONTIGUOUS"), + ndpointer(np.float64, ndim=1, flags="C_CONTIGUOUS"), + ctypes.c_size_t, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ndpointer(np.int64, ndim=1, flags="C_CONTIGUOUS"), + ] + self._library.nmss_nms.restype = ctypes.c_size_t + + def nms( + self, + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + ) -> NDArray[np.int64]: + """Run single-class NMS in C.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=1) + score_threshold = validate_threshold( + "score_threshold", score_threshold + ) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + if len(boxes_array) == 0 or max_detections == 0: + return np.empty(0, dtype=np.int64) + + output = np.empty(len(boxes_array), dtype=np.int64) + result_size = self._library.nmss_nms( + boxes_array, + scores_array, + len(boxes_array), + score_threshold, + iou_threshold, + offset, + output, + ) + if result_size == ctypes.c_size_t(-1).value: + raise MemoryError("native NMS could not allocate working memory") + if max_detections is not None: + result_size = min(result_size, max_detections) + return output[:result_size].copy() + + def multiclass_nms( + self, + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Run class-aware NMS in C and globally sort the detections.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=2) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + box_parts: list[NDArray[np.int64]] = [] + class_parts: list[NDArray[np.int64]] = [] + score_parts: list[NDArray[np.float64]] = [] + for class_id in range(scores_array.shape[1]): + kept = self.nms( + boxes_array, + scores_array[:, class_id], + score_threshold, + iou_threshold, + offset=offset, + ) + if kept.size: + box_parts.append(kept) + class_parts.append( + np.full(kept.size, class_id, dtype=np.int64) + ) + score_parts.append(scores_array[kept, class_id]) + if not box_parts or max_detections == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.copy() + + box_indices = np.concatenate(box_parts) + class_ids = np.concatenate(class_parts) + kept_scores = np.concatenate(score_parts) + order = np.lexsort((class_ids, box_indices, -kept_scores)) + if max_detections is not None: + order = order[:max_detections] + return box_indices[order], class_ids[order] + + def batch_multiclass_nms( + self, + boxes: Sequence[ArrayLike], + scores: Sequence[ArrayLike], + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + workers: int | None = None, + ) -> list[tuple[NDArray[np.int64], NDArray[np.int64]]]: + """Run independent images concurrently.""" + validate_batch(boxes, scores) + if not boxes: + return [] + if workers is None: + workers = min(len(boxes), os.cpu_count() or 1) + if workers < 1: + raise ValueError("workers must be at least 1") + + def run(item: tuple[ArrayLike, ArrayLike]): + image_boxes, image_scores = item + return self.multiclass_nms( + image_boxes, + image_scores, + score_threshold, + iou_threshold, + offset=offset, + max_detections=max_detections, + ) + + if workers == 1: + return [run(item) for item in zip(boxes, scores)] + with ThreadPoolExecutor(max_workers=workers) as executor: + return list(executor.map(run, zip(boxes, scores))) + + +@lru_cache(maxsize=None) +def load_backend( + library: str | os.PathLike[str] = DEFAULT_OUTPUT, +) -> CBackend: + """Load and cache a native backend instance.""" + return CBackend(library) + + +def nms(*args, library: str | os.PathLike[str] = DEFAULT_OUTPUT, **kwargs): + """Run native single-class NMS with the default or requested library.""" + return load_backend(library).nms(*args, **kwargs) + + +def multiclass_nms( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native class-aware NMS with the default or requested library.""" + return load_backend(library).multiclass_nms(*args, **kwargs) + + +def batch_multiclass_nms( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native class-aware NMS for a batch.""" + return load_backend(library).batch_multiclass_nms(*args, **kwargs) diff --git a/nmss/csrc/nms.c b/nmss/csrc/nms.c new file mode 100644 index 0000000..94d1ec7 --- /dev/null +++ b/nmss/csrc/nms.c @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include + +typedef struct { + size_t index; + double score; +} candidate_t; + +static int compare_candidates(const void *left_ptr, const void *right_ptr) { + const candidate_t *left = (const candidate_t *)left_ptr; + const candidate_t *right = (const candidate_t *)right_ptr; + if (left->score > right->score) { + return -1; + } + if (left->score < right->score) { + return 1; + } + if (left->index < right->index) { + return -1; + } + if (left->index > right->index) { + return 1; + } + return 0; +} + +static double box_iou( + const double *left, + const double *right, + double offset +) { + const double intersection_width = + fmax(0.0, fmin(left[2], right[2]) - fmax(left[0], right[0]) + offset); + const double intersection_height = + fmax(0.0, fmin(left[3], right[3]) - fmax(left[1], right[1]) + offset); + const double intersection = intersection_width * intersection_height; + const double left_area = + (left[2] - left[0] + offset) * (left[3] - left[1] + offset); + const double right_area = + (right[2] - right[0] + offset) * (right[3] - right[1] + offset); + const double union_area = left_area + right_area - intersection; + return union_area > 0.0 ? intersection / union_area : 0.0; +} + +size_t nmss_nms( + const double *boxes, + const double *scores, + size_t count, + double score_threshold, + double iou_threshold, + double offset, + int64_t *output +) { + if (count == 0) { + return 0; + } + + candidate_t *candidates = malloc(count * sizeof(*candidates)); + bool *suppressed = calloc(count, sizeof(*suppressed)); + if (candidates == NULL || suppressed == NULL) { + free(candidates); + free(suppressed); + return SIZE_MAX; + } + + size_t candidate_count = 0; + for (size_t index = 0; index < count; ++index) { + if (scores[index] >= score_threshold) { + candidates[candidate_count].index = index; + candidates[candidate_count].score = scores[index]; + ++candidate_count; + } + } + qsort( + candidates, + candidate_count, + sizeof(*candidates), + compare_candidates + ); + + size_t output_count = 0; + for (size_t position = 0; position < candidate_count; ++position) { + if (suppressed[position]) { + continue; + } + const size_t current_index = candidates[position].index; + output[output_count++] = (int64_t)current_index; + const double *current_box = boxes + current_index * 4; + + for (size_t other = position + 1; other < candidate_count; ++other) { + if (suppressed[other]) { + continue; + } + const size_t other_index = candidates[other].index; + if ( + box_iou(current_box, boxes + other_index * 4, offset) + > iou_threshold + ) { + suppressed[other] = true; + } + } + } + + free(candidates); + free(suppressed); + return output_count; +} diff --git a/pyproject.toml b/pyproject.toml index 473f703..60fa6cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dev = ["pytest>=7", "pytest-cov>=4"] [tool.setuptools.packages.find] include = ["nmss*"] +[tool.setuptools.package-data] +nmss = ["csrc/*.c"] + [tool.pytest.ini_options] addopts = "-ra --strict-markers" testpaths = ["tests"] From a8af3aaf3113c40ddc495282ee1ac9992e899a93 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:03:50 +0800 Subject: [PATCH 03/14] test: add correctness suite and benchmarks --- .github/workflows/ci.yml | 31 ++++++++ benchmarks/__init__.py | 1 + benchmarks/benchmark_bbox.py | 130 +++++++++++++++++++++++++++++++++ tests/test_bbox.py | 137 +++++++++++++++++++++++++++++++++++ tests/test_c_backend.py | 103 ++++++++++++++++++++++++++ tests/test_mask.py | 60 +++++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/benchmark_bbox.py create mode 100644 tests/test_bbox.py create mode 100644 tests/test_c_backend.py create mode 100644 tests/test_mask.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8042940 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install package + run: python -m pip install --upgrade pip && python -m pip install . + - name: Build native backend + run: python -m nmss.build + - name: Run test suite + run: python -m unittest discover -s tests -v diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..2b08cfd --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Benchmark entry points for repository development.""" diff --git a/benchmarks/benchmark_bbox.py b/benchmarks/benchmark_bbox.py new file mode 100644 index 0000000..e66b8fb --- /dev/null +++ b/benchmarks/benchmark_bbox.py @@ -0,0 +1,130 @@ +"""Reproducible NumPy-versus-C bounding-box NMS benchmark.""" + +from __future__ import annotations + +import argparse +import json +import platform +import statistics +import time + +import numpy as np + +from nmss.bbox import nms as python_nms +from nmss.build import DEFAULT_OUTPUT, build_c_backend +from nmss.c_backend import CBackend + + +def generate_inputs(count: int, seed: int): + generator = np.random.default_rng(seed) + centers = generator.uniform(0, 640, size=(count, 2)) + sizes = generator.uniform(10, 160, size=(count, 2)) + boxes = np.column_stack((centers - sizes / 2, centers + sizes / 2)) + scores = generator.random(count) + return boxes, scores + + +def median_milliseconds(function, *, warmup: int, repeats: int) -> float: + for _ in range(warmup): + function() + timings = [] + for _ in range(repeats): + started = time.perf_counter_ns() + function() + timings.append((time.perf_counter_ns() - started) / 1_000_000) + return statistics.median(timings) + + +def run_benchmark( + sizes: list[int], + *, + seed: int, + warmup: int, + repeats: int, +): + if not DEFAULT_OUTPUT.is_file(): + build_c_backend() + backend = CBackend() + results = [] + for position, count in enumerate(sizes): + boxes, scores = generate_inputs(count, seed + position) + expected = python_nms(boxes, scores, 0.25, 0.5) + actual = backend.nms(boxes, scores, 0.25, 0.5) + np.testing.assert_array_equal(actual, expected) + python_ms = median_milliseconds( + lambda: python_nms(boxes, scores, 0.25, 0.5), + warmup=warmup, + repeats=repeats, + ) + native_ms = median_milliseconds( + lambda: backend.nms(boxes, scores, 0.25, 0.5), + warmup=warmup, + repeats=repeats, + ) + results.append( + { + "boxes": count, + "kept": len(expected), + "python_ms": python_ms, + "native_ms": native_ms, + "speedup": python_ms / native_ms, + } + ) + return results + + +def render_markdown(results) -> str: + lines = [ + "| Boxes | Kept | NumPy (ms) | C (ms) | Speedup |", + "| ---: | ---: | ---: | ---: | ---: |", + ] + lines.extend( + "| {boxes} | {kept} | {python_ms:.3f} | " + "{native_ms:.3f} | {speedup:.2f}x |".format(**result) + for result in results + ) + return "\n".join(lines) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sizes", nargs="+", type=int, default=[250, 1000, 2500]) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--warmup", type=int, default=2) + parser.add_argument("--repeats", type=int, default=9) + parser.add_argument( + "--format", + choices=("markdown", "json"), + default="markdown", + ) + arguments = parser.parse_args(argv) + if any(size < 1 for size in arguments.sizes): + parser.error("all sizes must be positive") + if arguments.warmup < 0 or arguments.repeats < 1: + parser.error("warmup must be non-negative and repeats must be positive") + + results = run_benchmark( + arguments.sizes, + seed=arguments.seed, + warmup=arguments.warmup, + repeats=arguments.repeats, + ) + if arguments.format == "json": + print( + json.dumps( + { + "python": platform.python_version(), + "platform": platform.platform(), + "numpy": np.__version__, + "results": results, + }, + indent=2, + ) + ) + else: + print(render_markdown(results)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_bbox.py b/tests/test_bbox.py new file mode 100644 index 0000000..08c515c --- /dev/null +++ b/tests/test_bbox.py @@ -0,0 +1,137 @@ +import unittest + +import numpy as np + +from nmss.bbox import ( + bbox_iou, + multiclass_nms_class_aware, + multiclass_nms_class_unaware, + nms, +) + + +class BoundingBoxNmsTests(unittest.TestCase): + def setUp(self): + self.boxes = np.array( + [ + [0.0, 0.0, 10.0, 10.0], + [1.0, 1.0, 9.0, 9.0], + [20.0, 20.0, 30.0, 30.0], + ] + ) + self.scores = np.array([0.9, 0.8, 0.7]) + + def test_nms_suppresses_overlap(self): + actual = nms(self.boxes, self.scores, 0.5, 0.5) + np.testing.assert_array_equal(actual, [0, 2]) + + def test_score_threshold_is_inclusive(self): + actual = nms(self.boxes, self.scores, 0.8, 0.5) + np.testing.assert_array_equal(actual, [0]) + + def test_equal_scores_are_stable(self): + boxes = np.array( + [[0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50]] + ) + actual = nms(boxes, np.ones(3), 0.0, 0.5) + np.testing.assert_array_equal(actual, [0, 1, 2]) + + def test_coordinate_offset_changes_pixel_box_semantics(self): + boxes = np.zeros((2, 4)) + scores = np.array([1.0, 0.5]) + np.testing.assert_array_equal( + nms(boxes, scores, offset=0.0), [0, 1] + ) + np.testing.assert_array_equal( + nms(boxes, scores, offset=1.0), [0] + ) + + def test_max_detections_limits_output(self): + actual = nms( + self.boxes, + self.scores, + 0.0, + 1.0, + max_detections=2, + ) + np.testing.assert_array_equal(actual, [0, 1]) + self.assertEqual( + nms(self.boxes, self.scores, max_detections=0).size, + 0, + ) + + def test_bbox_iou_handles_zero_area(self): + actual = bbox_iou( + np.zeros(4), + np.array([[0, 0, 0, 0], [0, 0, 1, 1]]), + ) + np.testing.assert_array_equal(actual, [0.0, 0.0]) + + def test_class_aware_nms_sorts_globally(self): + scores = np.array( + [[0.9, 0.6], [0.8, 0.95], [0.7, 0.65]] + ) + indices, classes = multiclass_nms_class_aware( + self.boxes, + scores, + 0.5, + 0.5, + ) + np.testing.assert_array_equal(indices, [1, 0, 2, 2]) + np.testing.assert_array_equal(classes, [1, 0, 0, 1]) + + def test_class_unaware_assigns_best_class_first(self): + scores = np.array( + [[0.9, 0.1], [0.8, 0.95], [0.2, 0.7]] + ) + indices, classes = multiclass_nms_class_unaware( + self.boxes, + scores, + 0.5, + 0.5, + ) + np.testing.assert_array_equal(indices, [1, 2]) + np.testing.assert_array_equal(classes, [1, 1]) + + def test_empty_input(self): + actual = nms(np.empty((0, 4)), np.empty(0)) + self.assertEqual(actual.dtype, np.int64) + self.assertEqual(actual.size, 0) + indices, classes = multiclass_nms_class_aware( + np.empty((0, 4)), + np.empty((0, 2)), + ) + self.assertEqual(indices.size, 0) + self.assertEqual(classes.size, 0) + + def test_invalid_input_is_rejected(self): + invalid_cases = [ + lambda: nms(np.zeros((2, 5)), np.ones(2)), + lambda: nms(np.array([[1, 0, 0, 1]]), np.ones(1)), + lambda: nms(np.full((1, 4), np.nan), np.ones(1)), + lambda: nms(np.zeros((2, 4)), np.ones(1)), + lambda: nms(np.zeros((1, 4)), np.ones(1), -0.1), + lambda: nms( + np.zeros((1, 4)), + np.ones(1), + iou_threshold=1.1, + ), + lambda: nms( + np.zeros((1, 4)), + np.ones(1), + offset=0.5, + ), + lambda: nms( + np.zeros((1, 4)), + np.ones(1), + max_detections=-1, + ), + ] + for invalid_case in invalid_cases: + with self.subTest(case=invalid_case): + with self.assertRaises(ValueError): + invalid_case() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_c_backend.py b/tests/test_c_backend.py new file mode 100644 index 0000000..b9ec24e --- /dev/null +++ b/tests/test_c_backend.py @@ -0,0 +1,103 @@ +from pathlib import Path +import tempfile +import unittest + +import numpy as np + +from nmss.bbox import multiclass_nms_class_aware, nms as python_nms +from nmss.build import build_c_backend +from nmss.c_backend import CBackend + + +class CBackendTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary_directory = tempfile.TemporaryDirectory() + library = Path(cls.temporary_directory.name) / "libnmss.so" + build_c_backend(library) + cls.backend = CBackend(library) + + @classmethod + def tearDownClass(cls): + cls.temporary_directory.cleanup() + + def test_randomized_equivalence(self): + generator = np.random.default_rng(20260723) + for count in (0, 1, 32, 257): + starts = generator.uniform(-50, 500, size=(count, 2)) + sizes = generator.uniform(0, 120, size=(count, 2)) + boxes = np.column_stack((starts, starts + sizes)) + scores = generator.random(count) + for offset in (0.0, 1.0): + for score_threshold in (0.0, 0.25, 0.8, 1.0): + for iou_threshold in (0.0, 0.3, 0.7, 1.0): + with self.subTest( + count=count, + offset=offset, + score_threshold=score_threshold, + iou_threshold=iou_threshold, + ): + expected = python_nms( + boxes, + scores, + score_threshold, + iou_threshold, + offset=offset, + ) + actual = self.backend.nms( + boxes, + scores, + score_threshold, + iou_threshold, + offset=offset, + ) + np.testing.assert_array_equal(actual, expected) + + def test_multiclass_equivalence(self): + generator = np.random.default_rng(7) + starts = generator.uniform(0, 500, size=(128, 2)) + boxes = np.column_stack( + (starts, starts + generator.uniform(1, 100, size=(128, 2))) + ) + scores = generator.random((128, 5)) + expected = multiclass_nms_class_aware(boxes, scores, 0.3, 0.5) + actual = self.backend.multiclass_nms(boxes, scores, 0.3, 0.5) + np.testing.assert_array_equal(actual[0], expected[0]) + np.testing.assert_array_equal(actual[1], expected[1]) + + def test_parallel_batch_matches_serial_batch(self): + generator = np.random.default_rng(11) + boxes_batch = [] + scores_batch = [] + for count in (20, 31, 42, 53): + starts = generator.uniform(0, 200, size=(count, 2)) + boxes_batch.append( + np.column_stack( + ( + starts, + starts + generator.uniform(1, 50, size=(count, 2)), + ) + ) + ) + scores_batch.append(generator.random((count, 3))) + serial = self.backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + workers=1, + ) + parallel = self.backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + workers=4, + ) + for serial_item, parallel_item in zip(serial, parallel): + np.testing.assert_array_equal(serial_item[0], parallel_item[0]) + np.testing.assert_array_equal(serial_item[1], parallel_item[1]) + + def test_missing_library_has_actionable_error(self): + with self.assertRaisesRegex(FileNotFoundError, "nmss.build"): + CBackend("/definitely/missing/libnmss.so") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mask.py b/tests/test_mask.py new file mode 100644 index 0000000..61c9ae0 --- /dev/null +++ b/tests/test_mask.py @@ -0,0 +1,60 @@ +import unittest + +import numpy as np + +from nmss.mask import mask_iou, mask_nms, multiclass_mask_nms + + +class MaskNmsTests(unittest.TestCase): + def setUp(self): + self.masks = np.zeros((3, 8, 8), dtype=bool) + self.masks[0, :4, :4] = True + self.masks[1, :4, :4] = True + self.masks[2, 5:, 5:] = True + self.scores = np.array([0.9, 0.8, 0.7]) + + def test_mask_iou(self): + actual = mask_iou(self.masks[0], self.masks) + np.testing.assert_allclose(actual, [1.0, 1.0, 0.0]) + + def test_empty_masks_have_zero_iou(self): + masks = np.zeros((2, 3, 3), dtype=bool) + np.testing.assert_array_equal(mask_iou(masks[0], masks), [0.0, 0.0]) + + def test_mask_nms_suppresses_overlap(self): + actual = mask_nms(self.masks, self.scores, 0.5, 0.5) + np.testing.assert_array_equal(actual, [0, 2]) + + def test_multiclass_mask_nms(self): + scores = np.array( + [[0.9, 0.6], [0.8, 0.95], [0.7, 0.65]] + ) + indices, classes = multiclass_mask_nms( + self.masks, + scores, + 0.5, + 0.5, + ) + np.testing.assert_array_equal(indices, [1, 0, 2, 2]) + np.testing.assert_array_equal(classes, [1, 0, 0, 1]) + + def test_max_detections(self): + actual = mask_nms( + self.masks, + self.scores, + iou_threshold=1.0, + max_detections=2, + ) + np.testing.assert_array_equal(actual, [0, 1]) + + def test_non_boolean_masks_are_rejected(self): + with self.assertRaises(TypeError): + mask_nms(self.masks.astype(np.uint8), self.scores) + + def test_spatial_shape_must_match(self): + with self.assertRaises(ValueError): + mask_iou(np.zeros((4, 4), dtype=bool), self.masks) + + +if __name__ == "__main__": + unittest.main() From 0ea6e9e69af0e35e0fd235590b5e97de1e5726f9 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:05:54 +0800 Subject: [PATCH 04/14] perf: record reproducible acceleration results --- benchmarks/benchmark_bbox.py | 102 ++++++++++++++++++++++++++++++- docs/evaluation.md | 114 +++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/evaluation.md diff --git a/benchmarks/benchmark_bbox.py b/benchmarks/benchmark_bbox.py index e66b8fb..285592d 100644 --- a/benchmarks/benchmark_bbox.py +++ b/benchmarks/benchmark_bbox.py @@ -4,6 +4,7 @@ import argparse import json +import os import platform import statistics import time @@ -73,7 +74,74 @@ def run_benchmark( return results -def render_markdown(results) -> str: +def run_batch_benchmark( + batch_size: int, + boxes_per_image: int, + *, + seed: int, + warmup: int, + repeats: int, + workers: int, +): + backend = CBackend() + boxes_batch = [] + scores_batch = [] + for image_index in range(batch_size): + boxes, scores = generate_inputs(boxes_per_image, seed + image_index) + boxes_batch.append(boxes) + scores_batch.append(scores[:, None]) + + serial = backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + 0.25, + 0.5, + workers=1, + ) + parallel = backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + 0.25, + 0.5, + workers=workers, + ) + for serial_item, parallel_item in zip(serial, parallel): + np.testing.assert_array_equal(serial_item[0], parallel_item[0]) + np.testing.assert_array_equal(serial_item[1], parallel_item[1]) + + serial_ms = median_milliseconds( + lambda: backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + 0.25, + 0.5, + workers=1, + ), + warmup=warmup, + repeats=repeats, + ) + parallel_ms = median_milliseconds( + lambda: backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + 0.25, + 0.5, + workers=workers, + ), + warmup=warmup, + repeats=repeats, + ) + return { + "batch_size": batch_size, + "boxes_per_image": boxes_per_image, + "workers": workers, + "serial_ms": serial_ms, + "parallel_ms": parallel_ms, + "speedup": serial_ms / parallel_ms, + } + + +def render_markdown(results, batch_result) -> str: lines = [ "| Boxes | Kept | NumPy (ms) | C (ms) | Speedup |", "| ---: | ---: | ---: | ---: | ---: |", @@ -83,6 +151,18 @@ def render_markdown(results) -> str: "{native_ms:.3f} | {speedup:.2f}x |".format(**result) for result in results ) + lines.extend( + [ + "", + "| Batch | Boxes/image | Workers | Serial C (ms) | " + "Parallel C (ms) | Speedup |", + "| ---: | ---: | ---: | ---: | ---: | ---: |", + "| {batch_size} | {boxes_per_image} | {workers} | " + "{serial_ms:.3f} | {parallel_ms:.3f} | {speedup:.2f}x |".format( + **batch_result + ), + ] + ) return "\n".join(lines) @@ -92,6 +172,13 @@ def main(argv=None) -> int: parser.add_argument("--seed", type=int, default=42) parser.add_argument("--warmup", type=int, default=2) parser.add_argument("--repeats", type=int, default=9) + parser.add_argument("--batch-size", type=int, default=8) + parser.add_argument("--batch-boxes", type=int, default=1000) + parser.add_argument( + "--workers", + type=int, + default=min(8, os.cpu_count() or 1), + ) parser.add_argument( "--format", choices=("markdown", "json"), @@ -102,6 +189,8 @@ def main(argv=None) -> int: parser.error("all sizes must be positive") if arguments.warmup < 0 or arguments.repeats < 1: parser.error("warmup must be non-negative and repeats must be positive") + if min(arguments.batch_size, arguments.batch_boxes, arguments.workers) < 1: + parser.error("batch size, batch boxes, and workers must be positive") results = run_benchmark( arguments.sizes, @@ -109,6 +198,14 @@ def main(argv=None) -> int: warmup=arguments.warmup, repeats=arguments.repeats, ) + batch_result = run_batch_benchmark( + arguments.batch_size, + arguments.batch_boxes, + seed=arguments.seed + len(arguments.sizes), + warmup=arguments.warmup, + repeats=arguments.repeats, + workers=arguments.workers, + ) if arguments.format == "json": print( json.dumps( @@ -117,12 +214,13 @@ def main(argv=None) -> int: "platform": platform.platform(), "numpy": np.__version__, "results": results, + "batch_result": batch_result, }, indent=2, ) ) else: - print(render_markdown(results)) + print(render_markdown(results, batch_result)) return 0 diff --git a/docs/evaluation.md b/docs/evaluation.md new file mode 100644 index 0000000..07c092f --- /dev/null +++ b/docs/evaluation.md @@ -0,0 +1,114 @@ +# NMSs Evaluation Report + +## Executive summary + +The repository now has one validated NumPy reference implementation, one +rebuildable C backend, deterministic output rules, and automated coverage for +bounding-box, mask, multiclass, empty-input, invalid-input, and batch behavior. + +On the evaluation host, the native implementation was **4.87x to 17.16x +faster** than the NumPy reference for 250 to 2,500 boxes. Processing eight +images concurrently was **1.72x faster** than serial C execution in the recorded +run. Every native result was checked against the Python reference before its +timing was accepted. + +## What was evaluated + +| Area | Evaluation | +| --- | --- | +| Single-class bbox NMS | Known examples, stable ties, inclusive score threshold, both coordinate offsets | +| Multiclass bbox NMS | Class-aware and class-unaware behavior, global score ordering | +| Mask NMS | IoU, suppression, empty masks, multiclass output | +| Native backend | 128 randomized parameter combinations against NumPy | +| Batch execution | Serial and concurrent native outputs compared item by item | +| Defensive behavior | Invalid shapes, coordinates, thresholds, dtypes, and library paths | + +The test suite contains 21 named tests. The randomized native equivalence test +combines: + +- four input sizes: 0, 1, 32, and 257 boxes; +- both continuous (`offset=0`) and inclusive-pixel (`offset=1`) coordinates; +- four score thresholds: 0.0, 0.25, 0.8, and 1.0; and +- four IoU thresholds: 0.0, 0.3, 0.7, and 1.0. + +## Performance methodology + +The benchmark uses deterministic synthetic `xyxy` boxes: + +- random seed: 42; +- image extent: 640 × 640; +- box sizes: uniformly sampled from 10 to 160; +- score threshold: 0.25; +- IoU threshold: 0.5; +- warm-up iterations: 2; and +- measured iterations: 9, reported as the median wall-clock duration. + +Both measurements include public API validation and array preparation. The +script checks that C and NumPy return identical indices before measuring. +Native code is rebuilt from `nmss/csrc/nms.c` using: + +```text +-O3 -std=c11 -DNDEBUG -fPIC -shared -lm +``` + +### Evaluation environment + +| Component | Value | +| --- | --- | +| CPU | Intel Xeon Platinum 8573C, 9 available vCPUs | +| Architecture | x86_64 | +| OS | Linux 6.12.13, glibc 2.39 | +| Python | 3.12.13 | +| NumPy | 2.3.5 | +| Compiler | GCC 13.3.0 | + +### Recorded results + +| Boxes | Boxes kept | NumPy (ms) | C (ms) | C speedup | +| ---: | ---: | ---: | ---: | ---: | +| 250 | 178 | 4.745 | 0.277 | 17.16x | +| 1,000 | 607 | 28.493 | 3.437 | 8.29x | +| 2,500 | 1,284 | 96.306 | 19.763 | 4.87x | + +| Batch | Boxes/image | Workers | Serial C (ms) | Parallel C (ms) | Speedup | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 8 | 1,000 | 8 | 30.243 | 17.619 | 1.72x | + +Timings are host-dependent. The committed benchmark is the source of truth and +should be rerun on the deployment machine rather than treating these values as +a universal guarantee. + +## Reproduction + +From the repository root: + +```bash +python -m nmss.build +python -m unittest discover -s tests -v +python -m benchmarks.benchmark_bbox +python -m benchmarks.benchmark_bbox --format json +``` + +The last two commands produce Markdown and machine-readable results, +respectively. + +## Findings and trade-offs + +1. **The original binary was not reproducible.** A checked-in `.so` is tied to + an unknown compiler and ABI. It has been replaced with a source build. +2. **The original C and Python thresholds disagreed.** C used `>` while Python + used `>=`. Both backends now retain scores exactly equal to the threshold. +3. **Unsigned coordinates could underflow.** The native backend now accepts + `float64`, allowing negative and fractional coordinates safely. +4. **Output was not deterministic for score ties.** Both backends now prefer the + lower original index when scores are equal. +5. **Batch parallelism helps when each item is large enough.** Small items can + be dominated by thread scheduling, so `workers=1` remains available. + +## Current limits + +- The native backend currently targets Linux systems with GCC or Clang. +- Greedy NMS remains quadratic in the worst case. +- Mask NMS is vectorized NumPy only; there is no native mask backend yet. +- GPU backends, Soft-NMS, DIoU-NMS, and prebuilt wheels are outside this + repository's current scope. From edb1cf288017d9218aa8802e3d3392d7765f4c3f Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:08:11 +0800 Subject: [PATCH 05/14] docs: deliver polished usage and evaluation guide --- README.md | 266 +++++++++++++++++++++++++++------- bbox-nms-c-version/README.md | 57 +++----- bbox-nms-c-version/compile.md | 13 +- mask-nms/README.md | 25 ++-- 4 files changed, 259 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 94ecc4f..aff2b17 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,240 @@ -# NMS (Non-maximum Suppression) Tools +# NMSs +[![CI](https://github.com/Som5ra/NMSs/actions/workflows/ci.yml/badge.svg)](https://github.com/Som5ra/NMSs/actions/workflows/ci.yml) +[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)](https://www.python.org/) +[![NumPy](https://img.shields.io/badge/backend-NumPy%20%2B%20C-4D77CF.svg)](https://numpy.org/) -
- Bounding Box NMS -- Refer to ./bbox-nms/nms.py -
+**A compact, deterministic non-maximum suppression toolkit for bounding boxes +and boolean masks.** -
- Bounding Box NMS - C language version +NMSs provides a validated NumPy reference implementation and an optional +rebuildable C backend. It supports single-class NMS, class-aware and +class-unaware multiclass NMS, boolean-mask NMS, and concurrent batch execution +without requiring a deep-learning framework. -## Bounding Box NMS - C language version -### Benchmark (Single Batch / s) +## Why NMSs -- Each Single-Batch-Data have 2000 bounding boxes -- Each test run 1000 times to obtain results +- **Correct by construction:** shape, coordinate, dtype, finiteness, and + threshold checks fail early with actionable messages. +- **Deterministic:** equal scores are resolved by original input index. +- **Coordinate-safe:** negative and fractional `xyxy` coordinates work in both + Python and C. +- **Fast:** the recorded native speedup is **4.87x–17.16x** for 250–2,500 + boxes on the evaluation host. +- **Reproducible:** the C library is built from source instead of shipping an + opaque platform-specific binary. +- **Framework-independent:** NumPy is the only runtime dependency. -- Speed(ms) : including: preprocessing, nms -- W/O processing (ms): only including: nms +## Installation -| Algo / Paramters | Python | C | C | Batch Pallel C | Batch Pallel C | -|------------------|-----------|---------------|---------------------|----------------|---------------------| -| Batch Num | Speed(ms)| Speed(ms) | W/O processing (ms)| Speed(ms) | W/O processing (ms)| -| 1 | 0.611 | **0.258** | 0.211 | 0.834 | 0.735 | -| 10 | 0.610 | **0.256** | 0.211 | 0.343 | 0.175 | -| 100 | 0.603 | **0.260** | 0.214 | 0.354 | 0.094 | +Clone the repository and install the package: -### Usage: Refer to batch_parallel_nms.py +```bash +python -m pip install . +``` + +The NumPy bbox and mask implementations are immediately available. To compile +the optional native bbox backend on Linux: + +```bash +python -m nmss.build +``` + +The native build requires GCC or Clang. Set `CC` or pass `--compiler` to select +a specific compiler. + +## Quick start -```Python -num_classes = 80 -score_thr = 0.5 -nms_thr = 0.5 +### Bounding-box NMS -batched_bboxes = [np.ones((2000, 4)), np.ones((123, 4)), np.ones((321, 4)), ...] -batched_scores = [np.ones((2000, num_classes)), np.ones((123, num_classes)), np.ones((321, num_classes)), ...] +```python +import numpy as np -nms_c = Batch_Parallel_Nms() +from nmss import nms -# NMS -for boxes, scores in zip(batched_bboxes, batched_scores): - indices_to_keep, nms_out_cls = nms_c.nms(boxes, scores, score_thr, nms_thr) +boxes = np.array( + [ + [0.0, 0.0, 10.0, 10.0], + [1.0, 1.0, 9.0, 9.0], + [20.0, 20.0, 30.0, 30.0], + ] +) +scores = np.array([0.90, 0.80, 0.70]) -# BATCH PARALLEL -indices_to_keep, nms_out_cls = nms_c.batch_parallel_nms(batched_bboxes, batched_scores, score_thr, nms_thr) +keep = nms( + boxes, + scores, + score_threshold=0.50, + iou_threshold=0.50, +) +# array([0, 2]) ``` -### If there is any modified -```bash -gcc -O3 -msse2 -mfpmath=sse -ftree-vectorizer-verbose=5 -fopenmp -fPIC -shared -o c/compiled/batch_parallel_nms.so c/batch_parallel_nms.c +### Multiclass NMS + +```python +from nmss import multiclass_nms + +scores_by_class = np.array( + [ + [0.90, 0.60], + [0.80, 0.95], + [0.70, 0.65], + ] +) + +box_indices, class_ids = multiclass_nms( + boxes, + scores_by_class, + score_threshold=0.50, + iou_threshold=0.50, + class_aware=True, + max_detections=100, +) +``` + +Class-aware mode suppresses boxes independently for every class. Class-unaware +mode first assigns each box to its highest-scoring class, then suppresses +across the combined set. + +### Mask NMS + +```python +from nmss import mask_nms + +masks = np.zeros((3, 64, 64), dtype=bool) +masks[0, :20, :20] = True +masks[1, 2:18, 2:18] = True +masks[2, 40:, 40:] = True + +keep = mask_nms(masks, scores, iou_threshold=0.50) +# array([0, 2]) +``` + +Masks must use boolean dtype. They may have any spatial rank as long as their +shapes match. + +## Native acceleration + +Build once, then use the API-compatible C backend: + +```python +from nmss.c_backend import CBackend + +backend = CBackend() + +keep = backend.nms( + boxes, + scores, + score_threshold=0.50, + iou_threshold=0.50, +) ``` -
-
- Mask NMS +For independent images, native calls can run concurrently because the C call +releases Python's global interpreter lock: + +```python +results = backend.batch_multiclass_nms( + boxes_batch, + scores_batch, + score_threshold=0.50, + iou_threshold=0.50, + workers=8, +) +``` + +Each result is an `(indices, class_ids)` tuple. Use `workers=1` when +deterministic single-thread execution or minimal scheduling overhead is more +important than batch throughput. + +## Coordinate convention + +Boxes use `xyxy` order. Choose the geometry explicitly: + +| `offset` | Convention | Width | +| ---: | --- | --- | +| `0` | Continuous coordinates, default | `x2 - x1` | +| `1` | Inclusive integer pixel coordinates | `x2 - x1 + 1` | + +All scores equal to `score_threshold` are retained. A candidate is suppressed +only when `IoU > iou_threshold`; equality is retained. + +## Performance + +The benchmark checks C output against NumPy before timing. It uses two warm-up +runs and the median of nine measured runs. -### Mutli class mask NMS (class-aware) +| Boxes | Boxes kept | NumPy (ms) | C (ms) | Speedup | +| ---: | ---: | ---: | ---: | ---: | +| 250 | 178 | 4.745 | 0.277 | **17.16x** | +| 1,000 | 607 | 28.493 | 3.437 | **8.29x** | +| 2,500 | 1,284 | 96.306 | 19.763 | **4.87x** | -- Class-unaware: a proposal can belong to mutiple single class +Recorded on Linux x86_64 with Python 3.12.13, NumPy 2.3.5, GCC 13.3, and an +Intel Xeon Platinum 8573C host. Performance depends on hardware, box +distribution, suppression rate, compiler, and system load. -- inputs: - - masks: NDArray (num_masks, W, H) (type: Boolean) - - scores: NDArray (num_masks, num_classes) in [0, 1] - - score_thr: float (score threshold of bounding box) - - nms_thr: float (intersection threshold of mask) -- output: - - [NDArray of indices to keep, NDArray of class id] +Reproduce the measurements: +```bash +python -m benchmarks.benchmark_bbox +python -m benchmarks.benchmark_bbox --format json +``` + +See the [evaluation report](docs/evaluation.md) for the complete methodology, +batch results, environment, and limitations. + +## Validation + +Run the complete suite: + +```bash +python -m nmss.build +python -m unittest discover -s tests -v ``` -pip install numba -pip install numpy + +Coverage includes: + +- bbox and mask IoU behavior; +- class-aware and class-unaware suppression; +- score/IoU boundaries and coordinate offsets; +- empty inputs and stable score ties; +- malformed input rejection; +- randomized Python/C equivalence; and +- serial/concurrent batch equivalence. + +CI executes the suite on Python 3.9, 3.12, and 3.13. + +## API overview + +| API | Purpose | Backend | +| --- | --- | --- | +| `nmss.nms` | Single-class bbox NMS | NumPy | +| `nmss.multiclass_nms` | Aware or unaware bbox NMS | NumPy | +| `nmss.mask_nms` | Single-class boolean-mask NMS | NumPy | +| `nmss.multiclass_mask_nms` | Class-aware boolean-mask NMS | NumPy | +| `nmss.c_backend.CBackend.nms` | Single-class bbox NMS | C | +| `CBackend.multiclass_nms` | Class-aware bbox NMS | C | +| `CBackend.batch_multiclass_nms` | Concurrent image batches | C | + +## Repository layout + +```text +nmss/ Maintained Python package and C source +tests/ Correctness and native-equivalence suite +benchmarks/ Reproducible performance runner +docs/evaluation.md Methodology, evidence, and limitations +bbox-nms*/ mask-nms/ Backward-compatible import paths +.github/workflows/ Python-version CI matrix ``` -
+The legacy modules retain their original public function names and use +`offset=1` to preserve the old inclusive-pixel behavior. New integrations should +import directly from `nmss`. + +## Scope + +NMSs currently focuses on greedy CPU NMS. GPU kernels, Soft-NMS, DIoU-NMS, +native mask kernels, and prebuilt platform wheels are intentionally left for +future releases. diff --git a/bbox-nms-c-version/README.md b/bbox-nms-c-version/README.md index 01c1e52..7271cde 100644 --- a/bbox-nms-c-version/README.md +++ b/bbox-nms-c-version/README.md @@ -1,42 +1,29 @@ -## NMS(Non-maximum Suppression) Acceleration +# Legacy C API +This directory preserves the original `Batch_Parallel_Nms` import path. +Maintained native code now lives in `nmss/csrc`, and the shared library is +rebuilt locally: -### Benchmark (Single Batch / s) - -- Each Single-Batch-Data have 2000 bounding boxes -- Each test run 1000 times to obtain results - -- Speed(ms) : including: preprocessing, nms -- W/O processing (ms): only including: nms - -| Algo / Paramters | Python | C | C | Batch Pallel C | Batch Pallel C | -|------------------|-----------|---------------|---------------------|----------------|---------------------| -| Batch Num | Speed(ms)| Speed(ms) | W/O processing (ms)| Speed(ms) | W/O processing (ms)| -| 1 | 0.611 | **0.258** | 0.211 | 0.834 | 0.735 | -| 10 | 0.610 | **0.256** | 0.211 | 0.343 | 0.175 | -| 100 | 0.603 | **0.260** | 0.214 | 0.354 | 0.094 | - -### Usage: Refer to batch_parallel_nms.py - -```Python -num_classes = 80 -score_thr = 0.5 -nms_thr = 0.5 - -batched_bboxes = [np.ones((2000, 4)), np.ones((123, 4)), np.ones((321, 4)), ...] -batched_scores = [np.ones((2000, num_classes)), np.ones((123, num_classes)), np.ones((321, num_classes)), ...] +```bash +python -m nmss.build +``` -nms_c = Batch_Parallel_Nms() +Existing calls continue to work: -# NMS -for boxes, scores in zip(batched_bboxes, batched_scores): - indices_to_keep, nms_out_cls = nms_c.nms(boxes, scores, score_thr, nms_thr) +```python +from batch_parallel_nms import Batch_Parallel_Nms -# BATCH PARALLEL -indices_to_keep, nms_out_cls = nms_c.batch_parallel_nms(batched_bboxes, batched_scores, score_thr, nms_thr) +backend = Batch_Parallel_Nms() +indices, class_ids = backend.nms(boxes, scores, 0.5, 0.5) +batch_indices, batch_class_ids = backend.batch_parallel_nms( + boxes_batch, + scores_batch, + 0.5, + 0.5, +) ``` -### If there is any modified -```bash -gcc -O3 -msse2 -mfpmath=sse -ftree-vectorizer-verbose=5 -fopenmp -fPIC -shared -o c/compiled/batch_parallel_nms.so c/batch_parallel_nms.c -``` +New code should use `nmss.c_backend.CBackend`. See the +[root README](../README.md) and +[evaluation report](../docs/evaluation.md) for the current API and verified +benchmark. diff --git a/bbox-nms-c-version/compile.md b/bbox-nms-c-version/compile.md index d9870a1..ff60c3a 100644 --- a/bbox-nms-c-version/compile.md +++ b/bbox-nms-c-version/compile.md @@ -1,5 +1,16 @@ +# Native build + +The supported build entry point is: + ```bash -gcc -O3 -msse2 -mfpmath=sse -ftree-vectorizer-verbose=5 -fopenmp -fPIC -shared -o c/compiled/batch_parallel_nms.so c/batch_parallel_nms.c +python -m nmss.build ``` +Use a different compiler or output path when required: + +```bash +python -m nmss.build --compiler clang --output /tmp/libnmss.so +``` +The builder compiles `nmss/csrc/nms.c` with optimized, reproducible flags and +reports compiler errors directly. diff --git a/mask-nms/README.md b/mask-nms/README.md index 478f716..3168997 100644 --- a/mask-nms/README.md +++ b/mask-nms/README.md @@ -1,19 +1,12 @@ +# Legacy mask NMS API +This directory preserves the original mask NMS function names. The maintained +implementation is `nmss.mask` and requires only NumPy. - -### Mutli class mask NMS (class-aware) - -- Class-unaware: a proposal can belong to mutiple single class - -- inputs: - - masks: NDArray (num_masks, W, H) (type: Boolean) - - scores: NDArray (num_masks, num_classes) in [0, 1] - - score_thr: float (score threshold of bounding box) - - nms_thr: float (intersection threshold of mask) -- output: - - [NDArray of indices to keep, NDArray of class id] - +```python +from nmss import mask_nms, multiclass_mask_nms ``` -pip install numba -pip install numpy -``` \ No newline at end of file + +Inputs use shape `(num_masks, ...)`, boolean dtype, and scores shaped +`(num_masks,)` or `(num_masks, num_classes)`. See the +[root README](../README.md) for examples and semantics. From cfa644ce5ca8cf5c0c9ceb0cd7a80d6c661b8f4b Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:24:51 +0800 Subject: [PATCH 06/14] fix: handle empty NMS API inputs --- nmss/bbox.py | 5 +++++ nmss/mask.py | 2 ++ tests/test_bbox.py | 18 ++++++++++++++++++ tests/test_mask.py | 7 +++++++ 4 files changed, 32 insertions(+) diff --git a/nmss/bbox.py b/nmss/bbox.py index fe06349..07ae7ee 100644 --- a/nmss/bbox.py +++ b/nmss/bbox.py @@ -158,6 +158,11 @@ def multiclass_nms_class_unaware( """Assign each box to its best class, then suppress across all classes.""" boxes_array = validate_boxes(boxes) scores_array = validate_scores(scores, len(boxes_array), ndim=2) + score_threshold = validate_threshold("score_threshold", score_threshold) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") if len(boxes_array) == 0: empty = np.empty(0, dtype=np.int64) return empty, empty.copy() diff --git a/nmss/mask.py b/nmss/mask.py index 133ae5a..c8bd063 100644 --- a/nmss/mask.py +++ b/nmss/mask.py @@ -23,6 +23,8 @@ def mask_iou(mask: ArrayLike, masks: ArrayLike) -> NDArray[np.float64]: "mask spatial shape must match masks, " f"got {mask_array.shape} and {masks_array.shape[1:]}" ) + if len(masks_array) == 0: + return np.empty(0, dtype=np.float64) flattened = masks_array.reshape(len(masks_array), -1) mask_flattened = mask_array.reshape(-1) intersection = np.count_nonzero(flattened & mask_flattened, axis=1) diff --git a/tests/test_bbox.py b/tests/test_bbox.py index 08c515c..254cb91 100644 --- a/tests/test_bbox.py +++ b/tests/test_bbox.py @@ -104,6 +104,24 @@ def test_empty_input(self): self.assertEqual(indices.size, 0) self.assertEqual(classes.size, 0) + def test_empty_class_unaware_input_still_validates_arguments(self): + boxes = np.empty((0, 4)) + scores = np.empty((0, 2)) + invalid_cases = [ + {"score_threshold": -0.1}, + {"iou_threshold": 1.1}, + {"offset": 0.5}, + {"max_detections": -1}, + ] + for arguments in invalid_cases: + with self.subTest(arguments=arguments): + with self.assertRaises(ValueError): + multiclass_nms_class_unaware( + boxes, + scores, + **arguments, + ) + def test_invalid_input_is_rejected(self): invalid_cases = [ lambda: nms(np.zeros((2, 5)), np.ones(2)), diff --git a/tests/test_mask.py b/tests/test_mask.py index 61c9ae0..f98e516 100644 --- a/tests/test_mask.py +++ b/tests/test_mask.py @@ -21,6 +21,13 @@ def test_empty_masks_have_zero_iou(self): masks = np.zeros((2, 3, 3), dtype=bool) np.testing.assert_array_equal(mask_iou(masks[0], masks), [0.0, 0.0]) + def test_empty_comparison_batch_returns_empty_iou(self): + mask = np.zeros((3, 3), dtype=bool) + masks = np.empty((0, 3, 3), dtype=bool) + actual = mask_iou(mask, masks) + self.assertEqual(actual.dtype, np.float64) + self.assertEqual(actual.size, 0) + def test_mask_nms_suppresses_overlap(self): actual = mask_nms(self.masks, self.scores, 0.5, 0.5) np.testing.assert_array_equal(actual, [0, 2]) From 7495bb943b5c752a1ab8d6fcdb2219d08ea051ce Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:29:41 +0800 Subject: [PATCH 07/14] feat: introduce unified FastVisionOps package --- fastvisionops/__init__.py | 34 +++++++++ fastvisionops/bbox.py | 23 ++++++ fastvisionops/mask.py | 19 +++++ fastvisionops/preprocess.py | 136 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 6 +- tests/test_preprocess.py | 121 ++++++++++++++++++++++++++++++++ 6 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 fastvisionops/__init__.py create mode 100644 fastvisionops/bbox.py create mode 100644 fastvisionops/mask.py create mode 100644 fastvisionops/preprocess.py create mode 100644 tests/test_preprocess.py diff --git a/fastvisionops/__init__.py b/fastvisionops/__init__.py new file mode 100644 index 0000000..8e97c05 --- /dev/null +++ b/fastvisionops/__init__.py @@ -0,0 +1,34 @@ +"""Fast, validated CPU operations for computer-vision inference.""" + +from nmss.bbox import ( + bbox_iou, + multiclass_nms, + multiclass_nms_class_aware, + multiclass_nms_class_unaware, + nms, +) +from nmss.mask import mask_iou, mask_nms, multiclass_mask_nms + +from .preprocess import ( + chw_channel_normalize, + hwc_to_chw, + hwc_to_chw_normalize, + hwc_to_chw_normalize_batched, +) + +__all__ = [ + "bbox_iou", + "chw_channel_normalize", + "hwc_to_chw", + "hwc_to_chw_normalize", + "hwc_to_chw_normalize_batched", + "mask_iou", + "mask_nms", + "multiclass_mask_nms", + "multiclass_nms", + "multiclass_nms_class_aware", + "multiclass_nms_class_unaware", + "nms", +] + +__version__ = "1.0.0" diff --git a/fastvisionops/bbox.py b/fastvisionops/bbox.py new file mode 100644 index 0000000..2f8d969 --- /dev/null +++ b/fastvisionops/bbox.py @@ -0,0 +1,23 @@ +"""Bounding-box operations exposed under the FastVisionOps namespace.""" + +from nmss.bbox import ( + bbox_iou, + multiclass_nms, + multiclass_nms_class_aware, + multiclass_nms_class_aware_cpu, + multiclass_nms_class_unaware, + multiclass_nms_class_unaware_cpu, + nms, + nms_cpu, +) + +__all__ = [ + "bbox_iou", + "multiclass_nms", + "multiclass_nms_class_aware", + "multiclass_nms_class_aware_cpu", + "multiclass_nms_class_unaware", + "multiclass_nms_class_unaware_cpu", + "nms", + "nms_cpu", +] diff --git a/fastvisionops/mask.py b/fastvisionops/mask.py new file mode 100644 index 0000000..ea6780a --- /dev/null +++ b/fastvisionops/mask.py @@ -0,0 +1,19 @@ +"""Mask operations exposed under the FastVisionOps namespace.""" + +from nmss.mask import ( + mask_iou, + mask_nms, + mask_nms_cpu, + mask_overlap, + multiclass_mask_nms, + multiclass_mask_nms_class_aware_cpu, +) + +__all__ = [ + "mask_iou", + "mask_nms", + "mask_nms_cpu", + "mask_overlap", + "multiclass_mask_nms", + "multiclass_mask_nms_class_aware_cpu", +] diff --git a/fastvisionops/preprocess.py b/fastvisionops/preprocess.py new file mode 100644 index 0000000..0c79152 --- /dev/null +++ b/fastvisionops/preprocess.py @@ -0,0 +1,136 @@ +"""Validated NumPy image preprocessing operations.""" + +from __future__ import annotations + +from typing import Tuple + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def _validate_image( + image: ArrayLike, + *, + ndim: int, + layout: str, +) -> NDArray[np.uint8]: + result = np.asarray(image) + if result.dtype != np.uint8: + raise TypeError(f"{layout} input must have uint8 dtype, got {result.dtype}") + if result.ndim != ndim: + raise ValueError( + f"{layout} input must be {ndim}D, got shape {result.shape}" + ) + if result.shape[-1 if layout in {"HWC", "NHWC"} else 0] == 0: + raise ValueError(f"{layout} input must contain at least one channel") + return result + + +def _validate_statistics( + mean: ArrayLike, + std: ArrayLike, + channels: int, +) -> Tuple[NDArray[np.float32], NDArray[np.float32]]: + mean_array = np.asarray(mean, dtype=np.float32) + std_array = np.asarray(std, dtype=np.float32) + expected_shape = (channels,) + if mean_array.shape != expected_shape or std_array.shape != expected_shape: + raise ValueError( + "mean and std must each have shape " + f"{expected_shape}, got {mean_array.shape} and {std_array.shape}" + ) + if not np.isfinite(mean_array).all() or not np.isfinite(std_array).all(): + raise ValueError("mean and std must contain only finite values") + if np.any(std_array == 0): + raise ValueError("std values must be non-zero") + return ( + np.ascontiguousarray(mean_array), + np.ascontiguousarray(std_array), + ) + + +def _validate_flip(flip_rb: bool, channels: int) -> bool: + if not isinstance(flip_rb, (bool, np.bool_)): + raise TypeError("flip_rb must be a boolean") + if flip_rb and channels != 3: + raise ValueError("flip_rb requires exactly three channels") + return bool(flip_rb) + + +def hwc_to_chw( + image: ArrayLike, + *, + flip_rb: bool = False, +) -> NDArray[np.uint8]: + """Convert one uint8 image from HWC to contiguous CHW layout.""" + image_array = _validate_image(image, ndim=3, layout="HWC") + flip_rb = _validate_flip(flip_rb, image_array.shape[2]) + result = image_array.transpose(2, 0, 1) + if flip_rb: + result = result[::-1] + return np.ascontiguousarray(result) + + +def chw_channel_normalize( + image: ArrayLike, + mean: ArrayLike, + std: ArrayLike, + *, + flip_rb: bool = False, +) -> NDArray[np.float32]: + """Normalize one uint8 CHW image, optionally reversing three channels.""" + image_array = _validate_image(image, ndim=3, layout="CHW") + channels = image_array.shape[0] + mean_array, std_array = _validate_statistics(mean, std, channels) + flip_rb = _validate_flip(flip_rb, channels) + result = ( + image_array.astype(np.float32) + - mean_array[:, np.newaxis, np.newaxis] + ) / std_array[:, np.newaxis, np.newaxis] + if flip_rb: + result = result[::-1] + return np.ascontiguousarray(result) + + +def hwc_to_chw_normalize( + image: ArrayLike, + mean: ArrayLike, + std: ArrayLike, + *, + flip_rb: bool = False, +) -> NDArray[np.float32]: + """Fuse uint8 HWC-to-CHW conversion and per-channel normalization.""" + image_array = _validate_image(image, ndim=3, layout="HWC") + channels = image_array.shape[2] + mean_array, std_array = _validate_statistics(mean, std, channels) + flip_rb = _validate_flip(flip_rb, channels) + result = ( + image_array.astype(np.float32) + - mean_array[np.newaxis, np.newaxis, :] + ) / std_array[np.newaxis, np.newaxis, :] + result = result.transpose(2, 0, 1) + if flip_rb: + result = result[::-1] + return np.ascontiguousarray(result) + + +def hwc_to_chw_normalize_batched( + images: ArrayLike, + mean: ArrayLike, + std: ArrayLike, + *, + flip_rb: bool = False, +) -> NDArray[np.float32]: + """Fuse uint8 NHWC-to-NCHW conversion and channel normalization.""" + image_array = _validate_image(images, ndim=4, layout="NHWC") + channels = image_array.shape[3] + mean_array, std_array = _validate_statistics(mean, std, channels) + flip_rb = _validate_flip(flip_rb, channels) + result = ( + image_array.astype(np.float32) + - mean_array[np.newaxis, np.newaxis, np.newaxis, :] + ) / std_array[np.newaxis, np.newaxis, np.newaxis, :] + result = result.transpose(0, 3, 1, 2) + if flip_rb: + result = result[:, ::-1] + return np.ascontiguousarray(result) diff --git a/pyproject.toml b/pyproject.toml index 60fa6cc..baf6733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "nmss" +name = "fastvisionops" version = "1.0.0" -description = "Fast NumPy and C implementations of bounding-box and mask NMS" +description = "Validated NumPy and native operations for vision inference" readme = "README.md" requires-python = ">=3.9" dependencies = ["numpy>=1.23"] @@ -22,7 +22,7 @@ classifiers = [ dev = ["pytest>=7", "pytest-cov>=4"] [tool.setuptools.packages.find] -include = ["nmss*"] +include = ["fastvisionops*", "nmss*"] [tool.setuptools.package-data] nmss = ["csrc/*.c"] diff --git a/tests/test_preprocess.py b/tests/test_preprocess.py new file mode 100644 index 0000000..797a0f7 --- /dev/null +++ b/tests/test_preprocess.py @@ -0,0 +1,121 @@ +import unittest + +import numpy as np + +from fastvisionops.preprocess import ( + chw_channel_normalize, + hwc_to_chw, + hwc_to_chw_normalize, + hwc_to_chw_normalize_batched, +) + + +class PreprocessTests(unittest.TestCase): + def setUp(self): + self.image = np.arange(4 * 5 * 3, dtype=np.uint8).reshape(4, 5, 3) + self.mean = np.array([10.0, 20.0, 30.0], dtype=np.float32) + self.std = np.array([2.0, 4.0, 5.0], dtype=np.float32) + + def reference(self, image): + normalized = ( + image.astype(np.float32) - self.mean[np.newaxis, np.newaxis, :] + ) / self.std[np.newaxis, np.newaxis, :] + return np.ascontiguousarray(normalized.transpose(2, 0, 1)) + + def test_hwc_to_chw(self): + actual = hwc_to_chw(self.image) + expected = np.ascontiguousarray(self.image.transpose(2, 0, 1)) + np.testing.assert_array_equal(actual, expected) + self.assertTrue(actual.flags.c_contiguous) + + def test_fused_normalization_matches_numpy(self): + actual = hwc_to_chw_normalize( + self.image, + self.mean, + self.std, + ) + np.testing.assert_allclose(actual, self.reference(self.image)) + self.assertEqual(actual.dtype, np.float32) + self.assertTrue(actual.flags.c_contiguous) + + def test_chw_normalization_matches_fused(self): + chw = hwc_to_chw(self.image) + actual = chw_channel_normalize(chw, self.mean, self.std) + np.testing.assert_allclose(actual, self.reference(self.image)) + + def test_flip_rb_reverses_normalized_channels(self): + expected = self.reference(self.image)[::-1] + actual = hwc_to_chw_normalize( + self.image, + self.mean, + self.std, + flip_rb=True, + ) + np.testing.assert_allclose(actual, expected) + + def test_batch_matches_individual_calls(self): + batch = np.stack([self.image, self.image + 1]) + actual = hwc_to_chw_normalize_batched( + batch, + self.mean, + self.std, + ) + expected = np.stack( + [ + hwc_to_chw_normalize(image, self.mean, self.std) + for image in batch + ] + ) + np.testing.assert_allclose(actual, expected) + + def test_non_contiguous_input_is_supported(self): + image = self.image[:, ::-1, :] + self.assertFalse(image.flags.c_contiguous) + actual = hwc_to_chw_normalize(image, self.mean, self.std) + np.testing.assert_allclose(actual, self.reference(image)) + + def test_empty_batch(self): + batch = np.empty((0, 4, 5, 3), dtype=np.uint8) + actual = hwc_to_chw_normalize_batched( + batch, + self.mean, + self.std, + ) + self.assertEqual(actual.shape, (0, 3, 4, 5)) + self.assertEqual(actual.dtype, np.float32) + + def test_invalid_inputs_are_rejected(self): + invalid_cases = [ + lambda: hwc_to_chw(self.image.astype(np.float32)), + lambda: hwc_to_chw(self.image[0]), + lambda: hwc_to_chw(self.image[:, :, :2], flip_rb=True), + lambda: hwc_to_chw_normalize( + self.image, + self.mean[:2], + self.std, + ), + lambda: hwc_to_chw_normalize( + self.image, + self.mean, + [1.0, 0.0, 1.0], + ), + lambda: hwc_to_chw_normalize( + self.image, + [0.0, np.nan, 0.0], + self.std, + ), + lambda: hwc_to_chw_normalize( + self.image, + self.mean, + self.std, + flip_rb=1, + ), + ] + for invalid_case in invalid_cases: + with self.subTest(case=invalid_case): + with self.assertRaises((TypeError, ValueError)): + invalid_case() + + +if __name__ == "__main__": + unittest.main() From 63adc3b6348450a3d4c415b97f0a2ce65453705a Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:39:18 +0800 Subject: [PATCH 08/14] feat: unify native vision operations --- .github/workflows/ci.yml | 2 +- fastvisionops/build.py | 91 ++++++ .../nms.c => fastvisionops/csrc/vision_ops.c | 45 ++- fastvisionops/native.py | 297 ++++++++++++++++++ nmss/build.py | 87 ++--- nmss/c_backend.py | 216 ++----------- pyproject.toml | 2 +- tests/test_c_backend.py | 2 +- tests/test_native_preprocess.py | 116 +++++++ 9 files changed, 594 insertions(+), 264 deletions(-) create mode 100644 fastvisionops/build.py rename nmss/csrc/nms.c => fastvisionops/csrc/vision_ops.c (69%) create mode 100644 fastvisionops/native.py create mode 100644 tests/test_native_preprocess.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8042940..73c6f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,6 @@ jobs: - name: Install package run: python -m pip install --upgrade pip && python -m pip install . - name: Build native backend - run: python -m nmss.build + run: python -m fastvisionops.build - name: Run test suite run: python -m unittest discover -s tests -v diff --git a/fastvisionops/build.py b/fastvisionops/build.py new file mode 100644 index 0000000..b488a8f --- /dev/null +++ b/fastvisionops/build.py @@ -0,0 +1,91 @@ +"""Build the optional FastVisionOps native backend.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +PACKAGE_ROOT = Path(__file__).resolve().parent +SOURCE = PACKAGE_ROOT / "csrc" / "vision_ops.c" +DEFAULT_OUTPUT = PACKAGE_ROOT / "lib" / "libfastvisionops.so" + + +def _compile(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, capture_output=True) + + +def build_native_backend( + output: str | os.PathLike[str] | None = None, + *, + compiler: str | None = None, + openmp: bool = True, +) -> Path: + """Compile the shared C library and return its path. + + OpenMP is attempted by default. If the compiler does not support it, the + same source is rebuilt as a portable single-threaded library. + """ + output_path = Path(output).resolve() if output else DEFAULT_OUTPUT + compiler = compiler or os.environ.get("CC", "cc") + if shutil.which(compiler) is None: + raise RuntimeError( + f"C compiler {compiler!r} was not found; install GCC or Clang " + "or set the CC environment variable" + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + base_command = [ + compiler, + "-O3", + "-std=c11", + "-DNDEBUG", + "-fPIC", + "-shared", + str(SOURCE), + "-lm", + "-o", + str(output_path), + ] + command = base_command[:1] + (["-fopenmp"] if openmp else []) + base_command[1:] + result = _compile(command) + if result.returncode and openmp: + result = _compile(base_command) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"native backend build failed: {detail}") + return output_path + + +build_c_backend = build_native_backend + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compile the optional FastVisionOps C backend." + ) + parser.add_argument("--output", help="custom output library path") + parser.add_argument("--compiler", help="C compiler executable") + parser.add_argument( + "--no-openmp", + action="store_true", + help="build a portable single-threaded backend", + ) + arguments = parser.parse_args(argv) + try: + output = build_native_backend( + arguments.output, + compiler=arguments.compiler, + openmp=not arguments.no_openmp, + ) + except RuntimeError as error: + parser.exit(1, f"error: {error}\n") + print(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nmss/csrc/nms.c b/fastvisionops/csrc/vision_ops.c similarity index 69% rename from nmss/csrc/nms.c rename to fastvisionops/csrc/vision_ops.c index 94d1ec7..59f9e71 100644 --- a/nmss/csrc/nms.c +++ b/fastvisionops/csrc/vision_ops.c @@ -4,6 +4,10 @@ #include #include +#ifdef _OPENMP +#include +#endif + typedef struct { size_t index; double score; @@ -45,7 +49,7 @@ static double box_iou( return union_area > 0.0 ? intersection / union_area : 0.0; } -size_t nmss_nms( +size_t fvo_nms( const double *boxes, const double *scores, size_t count, @@ -108,3 +112,42 @@ size_t nmss_nms( free(suppressed); return output_count; } + +void fvo_hwc_to_chw_normalize_u8( + const uint8_t *input, + size_t batch, + size_t height, + size_t width, + size_t channels, + const float *mean, + const float *std, + int flip_rb, + size_t num_threads, + float *output +) { + const size_t pixels = batch * height * width; + +#ifdef _OPENMP + const int thread_count = + num_threads > 0 ? (int)num_threads : omp_get_max_threads(); +#pragma omp parallel for schedule(static) num_threads(thread_count) +#else + (void)num_threads; +#endif + for (size_t pixel = 0; pixel < pixels; ++pixel) { + const size_t batch_index = pixel / (height * width); + const size_t spatial_index = pixel % (height * width); + for (size_t source_channel = 0; source_channel < channels; + ++source_channel) { + const size_t destination_channel = + flip_rb ? channels - source_channel - 1 : source_channel; + const size_t input_index = pixel * channels + source_channel; + const size_t output_index = + (batch_index * channels + destination_channel) + * height * width + spatial_index; + output[output_index] = + ((float)input[input_index] - mean[source_channel]) + / std[source_channel]; + } + } +} diff --git a/fastvisionops/native.py b/fastvisionops/native.py new file mode 100644 index 0000000..9b2972f --- /dev/null +++ b/fastvisionops/native.py @@ -0,0 +1,297 @@ +"""ctypes bindings for FastVisionOps' optional native backend.""" + +from __future__ import annotations + +from collections.abc import Sequence +import ctypes +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +import os +from pathlib import Path + +import numpy as np +from numpy.ctypeslib import ndpointer +from numpy.typing import ArrayLike, NDArray + +from nmss._validation import ( + validate_batch, + validate_boxes, + validate_offset, + validate_scores, + validate_threshold, +) + +from .build import DEFAULT_OUTPUT +from .preprocess import _validate_flip, _validate_image, _validate_statistics + + +class NativeBackend: + """Loaded native backend with validated NumPy-facing methods.""" + + def __init__(self, library: str | os.PathLike[str] = DEFAULT_OUTPUT) -> None: + library_path = Path(library).resolve() + if not library_path.is_file(): + raise FileNotFoundError( + f"native backend not found at {library_path}; " + "run `python -m fastvisionops.build` first" + ) + self.library_path = library_path + self._library = ctypes.CDLL(str(library_path)) + self._library.fvo_nms.argtypes = [ + ndpointer(np.float64, ndim=2, flags="C_CONTIGUOUS"), + ndpointer(np.float64, ndim=1, flags="C_CONTIGUOUS"), + ctypes.c_size_t, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ndpointer(np.int64, ndim=1, flags="C_CONTIGUOUS"), + ] + self._library.fvo_nms.restype = ctypes.c_size_t + self._library.fvo_hwc_to_chw_normalize_u8.argtypes = [ + ndpointer(np.uint8, ndim=4, flags="C_CONTIGUOUS"), + ctypes.c_size_t, + ctypes.c_size_t, + ctypes.c_size_t, + ctypes.c_size_t, + ndpointer(np.float32, ndim=1, flags="C_CONTIGUOUS"), + ndpointer(np.float32, ndim=1, flags="C_CONTIGUOUS"), + ctypes.c_int, + ctypes.c_size_t, + ndpointer(np.float32, ndim=4, flags="C_CONTIGUOUS"), + ] + self._library.fvo_hwc_to_chw_normalize_u8.restype = None + + def nms( + self, + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + ) -> NDArray[np.int64]: + """Run single-class NMS in C.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=1) + score_threshold = validate_threshold( + "score_threshold", score_threshold + ) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + if len(boxes_array) == 0 or max_detections == 0: + return np.empty(0, dtype=np.int64) + + output = np.empty(len(boxes_array), dtype=np.int64) + result_size = self._library.fvo_nms( + boxes_array, + scores_array, + len(boxes_array), + score_threshold, + iou_threshold, + offset, + output, + ) + if result_size == ctypes.c_size_t(-1).value: + raise MemoryError("native NMS could not allocate working memory") + if max_detections is not None: + result_size = min(result_size, max_detections) + return output[:result_size].copy() + + def multiclass_nms( + self, + boxes: ArrayLike, + scores: ArrayLike, + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Run class-aware NMS in C and globally sort the detections.""" + boxes_array = validate_boxes(boxes) + scores_array = validate_scores(scores, len(boxes_array), ndim=2) + if max_detections is not None and max_detections < 0: + raise ValueError("max_detections must be non-negative or None") + + box_parts: list[NDArray[np.int64]] = [] + class_parts: list[NDArray[np.int64]] = [] + score_parts: list[NDArray[np.float64]] = [] + for class_id in range(scores_array.shape[1]): + kept = self.nms( + boxes_array, + scores_array[:, class_id], + score_threshold, + iou_threshold, + offset=offset, + ) + if kept.size: + box_parts.append(kept) + class_parts.append( + np.full(kept.size, class_id, dtype=np.int64) + ) + score_parts.append(scores_array[kept, class_id]) + if not box_parts or max_detections == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.copy() + + box_indices = np.concatenate(box_parts) + class_ids = np.concatenate(class_parts) + kept_scores = np.concatenate(score_parts) + order = np.lexsort((class_ids, box_indices, -kept_scores)) + if max_detections is not None: + order = order[:max_detections] + return box_indices[order], class_ids[order] + + def batch_multiclass_nms( + self, + boxes: Sequence[ArrayLike], + scores: Sequence[ArrayLike], + score_threshold: float = 0.0, + iou_threshold: float = 0.5, + *, + offset: float = 0.0, + max_detections: int | None = None, + workers: int | None = None, + ) -> list[tuple[NDArray[np.int64], NDArray[np.int64]]]: + """Run independent images concurrently.""" + validate_batch(boxes, scores) + if not boxes: + return [] + if workers is None: + workers = min(len(boxes), os.cpu_count() or 1) + if workers < 1: + raise ValueError("workers must be at least 1") + + def run(item: tuple[ArrayLike, ArrayLike]): + image_boxes, image_scores = item + return self.multiclass_nms( + image_boxes, + image_scores, + score_threshold, + iou_threshold, + offset=offset, + max_detections=max_detections, + ) + + if workers == 1: + return [run(item) for item in zip(boxes, scores)] + with ThreadPoolExecutor(max_workers=workers) as executor: + return list(executor.map(run, zip(boxes, scores))) + + def hwc_to_chw_normalize_batched( + self, + images: ArrayLike, + mean: ArrayLike, + std: ArrayLike, + *, + flip_rb: bool = False, + threads: int | None = None, + ) -> NDArray[np.float32]: + """Fuse uint8 NHWC conversion and normalization in native code.""" + image_array = _validate_image(images, ndim=4, layout="NHWC") + channels = image_array.shape[3] + mean_array, std_array = _validate_statistics(mean, std, channels) + flip_rb = _validate_flip(flip_rb, channels) + if threads is None: + threads = 0 + if ( + isinstance(threads, (bool, np.bool_)) + or not isinstance(threads, (int, np.integer)) + or threads < 0 + ): + raise ValueError("threads must be a non-negative integer or None") + + contiguous_input = np.ascontiguousarray(image_array) + batch, height, width, channels = contiguous_input.shape + output = np.empty( + (batch, channels, height, width), + dtype=np.float32, + ) + if output.size: + self._library.fvo_hwc_to_chw_normalize_u8( + contiguous_input, + batch, + height, + width, + channels, + mean_array, + std_array, + int(flip_rb), + threads, + output, + ) + return output + + def hwc_to_chw_normalize( + self, + image: ArrayLike, + mean: ArrayLike, + std: ArrayLike, + *, + flip_rb: bool = False, + threads: int | None = None, + ) -> NDArray[np.float32]: + """Preprocess one HWC image in native code.""" + image_array = _validate_image(image, ndim=3, layout="HWC") + return self.hwc_to_chw_normalize_batched( + image_array[np.newaxis], + mean, + std, + flip_rb=flip_rb, + threads=threads, + )[0] + + +CBackend = NativeBackend + + +@lru_cache(maxsize=None) +def load_backend( + library: str | os.PathLike[str] = DEFAULT_OUTPUT, +) -> NativeBackend: + """Load and cache a native backend instance.""" + return NativeBackend(library) + + +def nms(*args, library: str | os.PathLike[str] = DEFAULT_OUTPUT, **kwargs): + """Run native single-class NMS with the requested library.""" + return load_backend(library).nms(*args, **kwargs) + + +def multiclass_nms( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native class-aware NMS with the requested library.""" + return load_backend(library).multiclass_nms(*args, **kwargs) + + +def batch_multiclass_nms( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native class-aware NMS for a batch.""" + return load_backend(library).batch_multiclass_nms(*args, **kwargs) + + +def hwc_to_chw_normalize( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native fused preprocessing for one image.""" + return load_backend(library).hwc_to_chw_normalize(*args, **kwargs) + + +def hwc_to_chw_normalize_batched( + *args, + library: str | os.PathLike[str] = DEFAULT_OUTPUT, + **kwargs, +): + """Run native fused preprocessing for an image batch.""" + return load_backend(library).hwc_to_chw_normalize_batched(*args, **kwargs) diff --git a/nmss/build.py b/nmss/build.py index 1da28a0..081f206 100644 --- a/nmss/build.py +++ b/nmss/build.py @@ -1,67 +1,26 @@ -"""Build the optional native backend.""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path -import shutil -import subprocess -import sys - - -PACKAGE_ROOT = Path(__file__).resolve().parent -SOURCE = PACKAGE_ROOT / "csrc" / "nms.c" -DEFAULT_OUTPUT = PACKAGE_ROOT / "lib" / "libnmss.so" - - -def build_c_backend( - output: str | os.PathLike[str] | None = None, - *, - compiler: str | None = None, -) -> Path: - """Compile and return the path to the shared C library.""" - output_path = Path(output).resolve() if output else DEFAULT_OUTPUT - compiler = compiler or os.environ.get("CC", "cc") - if shutil.which(compiler) is None: - raise RuntimeError( - f"C compiler {compiler!r} was not found; install GCC or Clang " - "or set the CC environment variable" - ) - output_path.parent.mkdir(parents=True, exist_ok=True) - command = [ - compiler, - "-O3", - "-std=c11", - "-DNDEBUG", - "-fPIC", - "-shared", - str(SOURCE), - "-lm", - "-o", - str(output_path), - ] - result = subprocess.run(command, text=True, capture_output=True) - if result.returncode: - detail = result.stderr.strip() or result.stdout.strip() - raise RuntimeError(f"native backend build failed: {detail}") - return output_path - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Compile the optional nmss C backend." - ) - parser.add_argument("--output", help="custom output library path") - parser.add_argument("--compiler", help="C compiler executable") - arguments = parser.parse_args(argv) - try: - output = build_c_backend(arguments.output, compiler=arguments.compiler) - except RuntimeError as error: - parser.exit(1, f"error: {error}\n") - print(output) - return 0 +"""Backward-compatible native build API. + +Use :mod:`fastvisionops.build` in new code. +""" + +from fastvisionops.build import ( + DEFAULT_OUTPUT, + PACKAGE_ROOT, + SOURCE, + build_c_backend, + build_native_backend, + main, +) + +__all__ = [ + "DEFAULT_OUTPUT", + "PACKAGE_ROOT", + "SOURCE", + "build_c_backend", + "build_native_backend", + "main", +] if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/nmss/c_backend.py b/nmss/c_backend.py index 69c84ab..a757715 100644 --- a/nmss/c_backend.py +++ b/nmss/c_backend.py @@ -1,198 +1,22 @@ -"""ctypes bindings for the optional C bounding-box NMS backend.""" - -from __future__ import annotations - -from collections.abc import Sequence -import ctypes -from concurrent.futures import ThreadPoolExecutor -from functools import lru_cache -import os -from pathlib import Path - -import numpy as np -from numpy.ctypeslib import ndpointer -from numpy.typing import ArrayLike, NDArray - -from ._validation import ( - validate_batch, - validate_boxes, - validate_offset, - validate_scores, - validate_threshold, +"""Backward-compatible native API. + +Use :mod:`fastvisionops.native` in new code. +""" + +from fastvisionops.native import ( + CBackend, + NativeBackend, + batch_multiclass_nms, + load_backend, + multiclass_nms, + nms, ) -from .build import DEFAULT_OUTPUT - - -class CBackend: - """Loaded native backend with validated NumPy-facing methods.""" - - def __init__(self, library: str | os.PathLike[str] = DEFAULT_OUTPUT) -> None: - library_path = Path(library).resolve() - if not library_path.is_file(): - raise FileNotFoundError( - f"native backend not found at {library_path}; " - "run `python -m nmss.build` first" - ) - self.library_path = library_path - self._library = ctypes.CDLL(str(library_path)) - self._library.nmss_nms.argtypes = [ - ndpointer(np.float64, ndim=2, flags="C_CONTIGUOUS"), - ndpointer(np.float64, ndim=1, flags="C_CONTIGUOUS"), - ctypes.c_size_t, - ctypes.c_double, - ctypes.c_double, - ctypes.c_double, - ndpointer(np.int64, ndim=1, flags="C_CONTIGUOUS"), - ] - self._library.nmss_nms.restype = ctypes.c_size_t - - def nms( - self, - boxes: ArrayLike, - scores: ArrayLike, - score_threshold: float = 0.0, - iou_threshold: float = 0.5, - *, - offset: float = 0.0, - max_detections: int | None = None, - ) -> NDArray[np.int64]: - """Run single-class NMS in C.""" - boxes_array = validate_boxes(boxes) - scores_array = validate_scores(scores, len(boxes_array), ndim=1) - score_threshold = validate_threshold( - "score_threshold", score_threshold - ) - iou_threshold = validate_threshold("iou_threshold", iou_threshold) - offset = validate_offset(offset) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") - if len(boxes_array) == 0 or max_detections == 0: - return np.empty(0, dtype=np.int64) - - output = np.empty(len(boxes_array), dtype=np.int64) - result_size = self._library.nmss_nms( - boxes_array, - scores_array, - len(boxes_array), - score_threshold, - iou_threshold, - offset, - output, - ) - if result_size == ctypes.c_size_t(-1).value: - raise MemoryError("native NMS could not allocate working memory") - if max_detections is not None: - result_size = min(result_size, max_detections) - return output[:result_size].copy() - - def multiclass_nms( - self, - boxes: ArrayLike, - scores: ArrayLike, - score_threshold: float = 0.0, - iou_threshold: float = 0.5, - *, - offset: float = 0.0, - max_detections: int | None = None, - ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: - """Run class-aware NMS in C and globally sort the detections.""" - boxes_array = validate_boxes(boxes) - scores_array = validate_scores(scores, len(boxes_array), ndim=2) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") - - box_parts: list[NDArray[np.int64]] = [] - class_parts: list[NDArray[np.int64]] = [] - score_parts: list[NDArray[np.float64]] = [] - for class_id in range(scores_array.shape[1]): - kept = self.nms( - boxes_array, - scores_array[:, class_id], - score_threshold, - iou_threshold, - offset=offset, - ) - if kept.size: - box_parts.append(kept) - class_parts.append( - np.full(kept.size, class_id, dtype=np.int64) - ) - score_parts.append(scores_array[kept, class_id]) - if not box_parts or max_detections == 0: - empty = np.empty(0, dtype=np.int64) - return empty, empty.copy() - - box_indices = np.concatenate(box_parts) - class_ids = np.concatenate(class_parts) - kept_scores = np.concatenate(score_parts) - order = np.lexsort((class_ids, box_indices, -kept_scores)) - if max_detections is not None: - order = order[:max_detections] - return box_indices[order], class_ids[order] - - def batch_multiclass_nms( - self, - boxes: Sequence[ArrayLike], - scores: Sequence[ArrayLike], - score_threshold: float = 0.0, - iou_threshold: float = 0.5, - *, - offset: float = 0.0, - max_detections: int | None = None, - workers: int | None = None, - ) -> list[tuple[NDArray[np.int64], NDArray[np.int64]]]: - """Run independent images concurrently.""" - validate_batch(boxes, scores) - if not boxes: - return [] - if workers is None: - workers = min(len(boxes), os.cpu_count() or 1) - if workers < 1: - raise ValueError("workers must be at least 1") - - def run(item: tuple[ArrayLike, ArrayLike]): - image_boxes, image_scores = item - return self.multiclass_nms( - image_boxes, - image_scores, - score_threshold, - iou_threshold, - offset=offset, - max_detections=max_detections, - ) - - if workers == 1: - return [run(item) for item in zip(boxes, scores)] - with ThreadPoolExecutor(max_workers=workers) as executor: - return list(executor.map(run, zip(boxes, scores))) - - -@lru_cache(maxsize=None) -def load_backend( - library: str | os.PathLike[str] = DEFAULT_OUTPUT, -) -> CBackend: - """Load and cache a native backend instance.""" - return CBackend(library) - - -def nms(*args, library: str | os.PathLike[str] = DEFAULT_OUTPUT, **kwargs): - """Run native single-class NMS with the default or requested library.""" - return load_backend(library).nms(*args, **kwargs) - - -def multiclass_nms( - *args, - library: str | os.PathLike[str] = DEFAULT_OUTPUT, - **kwargs, -): - """Run native class-aware NMS with the default or requested library.""" - return load_backend(library).multiclass_nms(*args, **kwargs) - -def batch_multiclass_nms( - *args, - library: str | os.PathLike[str] = DEFAULT_OUTPUT, - **kwargs, -): - """Run native class-aware NMS for a batch.""" - return load_backend(library).batch_multiclass_nms(*args, **kwargs) +__all__ = [ + "CBackend", + "NativeBackend", + "batch_multiclass_nms", + "load_backend", + "multiclass_nms", + "nms", +] diff --git a/pyproject.toml b/pyproject.toml index baf6733..4e5effb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dev = ["pytest>=7", "pytest-cov>=4"] include = ["fastvisionops*", "nmss*"] [tool.setuptools.package-data] -nmss = ["csrc/*.c"] +fastvisionops = ["csrc/*.c"] [tool.pytest.ini_options] addopts = "-ra --strict-markers" diff --git a/tests/test_c_backend.py b/tests/test_c_backend.py index b9ec24e..b132f99 100644 --- a/tests/test_c_backend.py +++ b/tests/test_c_backend.py @@ -95,7 +95,7 @@ def test_parallel_batch_matches_serial_batch(self): np.testing.assert_array_equal(serial_item[1], parallel_item[1]) def test_missing_library_has_actionable_error(self): - with self.assertRaisesRegex(FileNotFoundError, "nmss.build"): + with self.assertRaisesRegex(FileNotFoundError, "fastvisionops.build"): CBackend("/definitely/missing/libnmss.so") diff --git a/tests/test_native_preprocess.py b/tests/test_native_preprocess.py new file mode 100644 index 0000000..a5d66e9 --- /dev/null +++ b/tests/test_native_preprocess.py @@ -0,0 +1,116 @@ +from pathlib import Path +import tempfile +import unittest + +import numpy as np + +from fastvisionops.build import build_native_backend +from fastvisionops.native import NativeBackend +from fastvisionops.preprocess import ( + hwc_to_chw_normalize, + hwc_to_chw_normalize_batched, +) + + +class NativePreprocessTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary_directory = tempfile.TemporaryDirectory() + library = ( + Path(cls.temporary_directory.name) / "libfastvisionops.so" + ) + build_native_backend(library) + cls.backend = NativeBackend(library) + + @classmethod + def tearDownClass(cls): + cls.temporary_directory.cleanup() + + def test_randomized_single_image_equivalence(self): + generator = np.random.default_rng(20260723) + mean = np.array([123.675, 116.28, 103.53], dtype=np.float32) + std = np.array([58.395, 57.12, 57.375], dtype=np.float32) + for shape in ((1, 1, 3), (7, 11, 3), (64, 96, 3)): + image = generator.integers(0, 256, shape, dtype=np.uint8) + for flip_rb in (False, True): + with self.subTest(shape=shape, flip_rb=flip_rb): + expected = hwc_to_chw_normalize( + image, + mean, + std, + flip_rb=flip_rb, + ) + actual = self.backend.hwc_to_chw_normalize( + image, + mean, + std, + flip_rb=flip_rb, + threads=2, + ) + np.testing.assert_allclose( + actual, + expected, + rtol=1e-6, + atol=1e-6, + ) + + def test_randomized_batch_equivalence(self): + generator = np.random.default_rng(19) + images = generator.integers( + 0, + 256, + (5, 37, 53, 4), + dtype=np.uint8, + ) + mean = [-1.0, 20.0, 127.5, 250.0] + std = [1.0, 17.0, 55.0, -2.0] + expected = hwc_to_chw_normalize_batched(images, mean, std) + actual = self.backend.hwc_to_chw_normalize_batched( + images, + mean, + std, + threads=3, + ) + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-6) + + def test_noncontiguous_input_is_supported(self): + generator = np.random.default_rng(23) + image = generator.integers(0, 256, (20, 30, 3), dtype=np.uint8) + image = image[::2, ::2] + self.assertFalse(image.flags.c_contiguous) + expected = hwc_to_chw_normalize(image, [1, 2, 3], [4, 5, 6]) + actual = self.backend.hwc_to_chw_normalize( + image, + [1, 2, 3], + [4, 5, 6], + ) + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-6) + self.assertTrue(actual.flags.c_contiguous) + + def test_empty_batch(self): + images = np.empty((0, 20, 30, 3), dtype=np.uint8) + actual = self.backend.hwc_to_chw_normalize_batched( + images, + [0, 0, 0], + [1, 1, 1], + ) + self.assertEqual(actual.shape, (0, 3, 20, 30)) + self.assertEqual(actual.dtype, np.float32) + + def test_invalid_threads_are_rejected(self): + image = np.zeros((1, 1, 3), dtype=np.uint8) + with self.assertRaisesRegex(ValueError, "threads"): + self.backend.hwc_to_chw_normalize( + image, + [0, 0, 0], + [1, 1, 1], + threads=-1, + ) + + def test_missing_library_has_actionable_error(self): + with self.assertRaisesRegex(FileNotFoundError, "fastvisionops.build"): + NativeBackend("/definitely/missing/libfastvisionops.so") + + +if __name__ == "__main__": + unittest.main() From 667c590e6cfda93b6389708aea9ceca83bf13b0c Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:41:41 +0800 Subject: [PATCH 09/14] perf: add preprocessing acceleration benchmark --- .github/workflows/ci.yml | 4 + benchmarks/benchmark_bbox.py | 12 +-- benchmarks/benchmark_preprocess.py | 167 +++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 benchmarks/benchmark_preprocess.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c6f26..09be547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,7 @@ jobs: run: python -m fastvisionops.build - name: Run test suite run: python -m unittest discover -s tests -v + - name: Smoke-test benchmark runners + run: | + python -m benchmarks.benchmark_bbox --sizes 16 64 --warmup 0 --repeats 1 --batch-size 2 --batch-boxes 32 --workers 2 --format json + python -m benchmarks.benchmark_preprocess --batches 1 2 --height 32 --width 32 --warmup 0 --repeats 1 --threads 2 --format json diff --git a/benchmarks/benchmark_bbox.py b/benchmarks/benchmark_bbox.py index 285592d..678fad4 100644 --- a/benchmarks/benchmark_bbox.py +++ b/benchmarks/benchmark_bbox.py @@ -11,9 +11,9 @@ import numpy as np -from nmss.bbox import nms as python_nms -from nmss.build import DEFAULT_OUTPUT, build_c_backend -from nmss.c_backend import CBackend +from fastvisionops.bbox import nms as python_nms +from fastvisionops.build import DEFAULT_OUTPUT, build_native_backend +from fastvisionops.native import NativeBackend def generate_inputs(count: int, seed: int): @@ -44,8 +44,8 @@ def run_benchmark( repeats: int, ): if not DEFAULT_OUTPUT.is_file(): - build_c_backend() - backend = CBackend() + build_native_backend() + backend = NativeBackend() results = [] for position, count in enumerate(sizes): boxes, scores = generate_inputs(count, seed + position) @@ -83,7 +83,7 @@ def run_batch_benchmark( repeats: int, workers: int, ): - backend = CBackend() + backend = NativeBackend() boxes_batch = [] scores_batch = [] for image_index in range(batch_size): diff --git a/benchmarks/benchmark_preprocess.py b/benchmarks/benchmark_preprocess.py new file mode 100644 index 0000000..95ff71d --- /dev/null +++ b/benchmarks/benchmark_preprocess.py @@ -0,0 +1,167 @@ +"""Reproducible NumPy-versus-native image preprocessing benchmark.""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import statistics +import time + +import numpy as np + +from fastvisionops.build import DEFAULT_OUTPUT, build_native_backend +from fastvisionops.native import NativeBackend +from fastvisionops.preprocess import hwc_to_chw_normalize_batched + + +def generate_inputs(batch: int, height: int, width: int, seed: int): + generator = np.random.default_rng(seed) + images = generator.integers( + 0, + 256, + size=(batch, height, width, 3), + dtype=np.uint8, + ) + mean = np.array([123.675, 116.28, 103.53], dtype=np.float32) + std = np.array([58.395, 57.12, 57.375], dtype=np.float32) + return images, mean, std + + +def median_milliseconds(function, *, warmup: int, repeats: int) -> float: + for _ in range(warmup): + function() + timings = [] + for _ in range(repeats): + started = time.perf_counter_ns() + function() + timings.append((time.perf_counter_ns() - started) / 1_000_000) + return statistics.median(timings) + + +def run_benchmark( + batches: list[int], + *, + height: int, + width: int, + seed: int, + warmup: int, + repeats: int, + threads: int, +): + if not DEFAULT_OUTPUT.is_file(): + build_native_backend() + backend = NativeBackend() + results = [] + for position, batch in enumerate(batches): + images, mean, std = generate_inputs( + batch, + height, + width, + seed + position, + ) + expected = hwc_to_chw_normalize_batched(images, mean, std) + actual = backend.hwc_to_chw_normalize_batched( + images, + mean, + std, + threads=threads, + ) + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-6) + numpy_ms = median_milliseconds( + lambda: hwc_to_chw_normalize_batched(images, mean, std), + warmup=warmup, + repeats=repeats, + ) + native_ms = median_milliseconds( + lambda: backend.hwc_to_chw_normalize_batched( + images, + mean, + std, + threads=threads, + ), + warmup=warmup, + repeats=repeats, + ) + results.append( + { + "batch": batch, + "shape": f"{height}x{width}x3", + "threads": threads, + "numpy_ms": numpy_ms, + "native_ms": native_ms, + "speedup": numpy_ms / native_ms, + } + ) + return results + + +def render_markdown(results) -> str: + lines = [ + "| Batch | Image shape | Threads | NumPy (ms) | Native (ms) | Speedup |", + "| ---: | --- | ---: | ---: | ---: | ---: |", + ] + lines.extend( + "| {batch} | {shape} | {threads} | {numpy_ms:.3f} | " + "{native_ms:.3f} | {speedup:.2f}x |".format(**result) + for result in results + ) + return "\n".join(lines) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--batches", nargs="+", type=int, default=[1, 8, 32]) + parser.add_argument("--height", type=int, default=427) + parser.add_argument("--width", type=int, default=640) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--warmup", type=int, default=2) + parser.add_argument("--repeats", type=int, default=9) + parser.add_argument( + "--threads", + type=int, + default=min(8, os.cpu_count() or 1), + ) + parser.add_argument( + "--format", + choices=("markdown", "json"), + default="markdown", + ) + arguments = parser.parse_args(argv) + if any(batch < 1 for batch in arguments.batches): + parser.error("all batch sizes must be positive") + if min(arguments.height, arguments.width, arguments.threads) < 1: + parser.error("height, width, and threads must be positive") + if arguments.warmup < 0 or arguments.repeats < 1: + parser.error("warmup must be non-negative and repeats must be positive") + + results = run_benchmark( + arguments.batches, + height=arguments.height, + width=arguments.width, + seed=arguments.seed, + warmup=arguments.warmup, + repeats=arguments.repeats, + threads=arguments.threads, + ) + if arguments.format == "json": + print( + json.dumps( + { + "python": platform.python_version(), + "platform": platform.platform(), + "numpy": np.__version__, + "cpu_count": os.cpu_count(), + "results": results, + }, + indent=2, + ) + ) + else: + print(render_markdown(results)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d1a51131a9d3ebaa6b869fa31fe78a1c0afc1f5e Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 15:46:05 +0800 Subject: [PATCH 10/14] docs: present the unified FastVisionOps toolkit --- README.md | 300 ++++++++++++++++++---------------- bbox-nms-c-version/README.md | 8 +- bbox-nms-c-version/compile.md | 8 +- docs/evaluation.md | 205 +++++++++++++++-------- fastvisionops/__init__.py | 2 + mask-nms/README.md | 2 +- pyproject.toml | 6 + 7 files changed, 310 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index aff2b17..e85a8b2 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,132 @@ -# NMSs +# FastVisionOps -[![CI](https://github.com/Som5ra/NMSs/actions/workflows/ci.yml/badge.svg)](https://github.com/Som5ra/NMSs/actions/workflows/ci.yml) +[![CI](https://github.com/Som5ra/FastVisionOps/actions/workflows/ci.yml/badge.svg)](https://github.com/Som5ra/FastVisionOps/actions/workflows/ci.yml) [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)](https://www.python.org/) -[![NumPy](https://img.shields.io/badge/backend-NumPy%20%2B%20C-4D77CF.svg)](https://numpy.org/) - -**A compact, deterministic non-maximum suppression toolkit for bounding boxes -and boolean masks.** - -NMSs provides a validated NumPy reference implementation and an optional -rebuildable C backend. It supports single-class NMS, class-aware and -class-unaware multiclass NMS, boolean-mask NMS, and concurrent batch execution -without requiring a deep-learning framework. - -## Why NMSs - -- **Correct by construction:** shape, coordinate, dtype, finiteness, and - threshold checks fail early with actionable messages. -- **Deterministic:** equal scores are resolved by original input index. -- **Coordinate-safe:** negative and fractional `xyxy` coordinates work in both - Python and C. -- **Fast:** the recorded native speedup is **4.87x–17.16x** for 250–2,500 - boxes on the evaluation host. -- **Reproducible:** the C library is built from source instead of shipping an - opaque platform-specific binary. -- **Framework-independent:** NumPy is the only runtime dependency. +[![Backend](https://img.shields.io/badge/backend-NumPy%20%2B%20C-4D77CF.svg)](https://numpy.org/) + +**A compact, validated CPU toolkit for the preprocessing and postprocessing +stages of vision inference.** + +FastVisionOps combines the former FastPreProcess and NMSs projects behind one +Python package and one rebuildable native library. It handles uint8 image +layout conversion and normalization, bounding-box NMS, boolean-mask NMS, +multiclass suppression, and batched execution without requiring a +deep-learning framework. + +## Why FastVisionOps + +- **One inference utility layer:** preprocessing and NMS share one install, + validation policy, test suite, native build, and benchmark workflow. +- **Measured acceleration:** the fused native preprocessor completed a + 427×640 image in **0.249 ms** versus **4.492 ms** for NumPy on the evaluation + host. +- **Correct before fast:** every benchmark checks native output against the + NumPy reference before accepting a timing. +- **Defensive inputs:** dtype, dimensionality, channel statistics, coordinates, + thresholds, and empty inputs are validated with actionable errors. +- **Deterministic NMS:** equal scores are resolved by original input index. +- **Reproducible native code:** the repository ships portable C source, not an + opaque ABI-specific binary. + +```mermaid +flowchart LR + A["uint8 HWC / NHWC"] --> B["Layout + normalize"] + B --> C["Model inference"] + C --> D["Boxes, scores, masks"] + D --> E["Deterministic NMS"] +``` ## Installation -Clone the repository and install the package: +Install the NumPy implementation: ```bash python -m pip install . ``` -The NumPy bbox and mask implementations are immediately available. To compile -the optional native bbox backend on Linux: +Compile the optional native backend: ```bash -python -m nmss.build +python -m fastvisionops.build ``` -The native build requires GCC or Clang. Set `CC` or pass `--compiler` to select -a specific compiler. +The build requires GCC or Clang. OpenMP is enabled when supported and the +builder automatically falls back to portable single-threaded C. Use `CC` or +`--compiler` to choose a compiler; use `--no-openmp` for an explicit portable +build. ## Quick start -### Bounding-box NMS +### Image preprocessing ```python import numpy as np -from nmss import nms +from fastvisionops import hwc_to_chw_normalize -boxes = np.array( - [ - [0.0, 0.0, 10.0, 10.0], - [1.0, 1.0, 9.0, 9.0], - [20.0, 20.0, 30.0, 30.0], - ] +image = np.zeros((427, 640, 3), dtype=np.uint8) +mean = [123.675, 116.28, 103.53] +std = [58.395, 57.12, 57.375] + +tensor = hwc_to_chw_normalize( + image, + mean, + std, + flip_rb=True, ) -scores = np.array([0.90, 0.80, 0.70]) +# float32, shape (3, 427, 640), C-contiguous +``` -keep = nms( - boxes, - scores, - score_threshold=0.50, - iou_threshold=0.50, +For the accelerated path: + +```python +from fastvisionops import NativeBackend + +backend = NativeBackend() +tensor = backend.hwc_to_chw_normalize( + image, + mean, + std, + flip_rb=True, + threads=8, ) -# array([0, 2]) ``` -### Multiclass NMS +Both single-image HWC and batched NHWC inputs are supported. `flip_rb=True` +normalizes using the source-channel statistics and reverses the three output +channels. Noncontiguous inputs are handled correctly. + +### Bounding-box NMS ```python -from nmss import multiclass_nms +from fastvisionops import nms -scores_by_class = np.array( +boxes = np.array( [ - [0.90, 0.60], - [0.80, 0.95], - [0.70, 0.65], + [0.0, 0.0, 10.0, 10.0], + [1.0, 1.0, 9.0, 9.0], + [20.0, 20.0, 30.0, 30.0], ] ) +scores = np.array([0.90, 0.80, 0.70]) -box_indices, class_ids = multiclass_nms( +keep = nms( boxes, - scores_by_class, + scores, score_threshold=0.50, iou_threshold=0.50, - class_aware=True, - max_detections=100, ) +# array([0, 2]) ``` -Class-aware mode suppresses boxes independently for every class. Class-unaware -mode first assigns each box to its highest-scoring class, then suppresses -across the combined set. +Use `NativeBackend.nms` for native execution. `multiclass_nms` supports both +class-aware and class-unaware suppression and returns globally score-ordered +box and class indices. -### Mask NMS +### Boolean-mask NMS ```python -from nmss import mask_nms +from fastvisionops import mask_nms masks = np.zeros((3, 64, 64), dtype=bool) masks[0, :20, :20] = True @@ -111,130 +137,120 @@ keep = mask_nms(masks, scores, iou_threshold=0.50) # array([0, 2]) ``` -Masks must use boolean dtype. They may have any spatial rank as long as their -shapes match. - -## Native acceleration - -Build once, then use the API-compatible C backend: - -```python -from nmss.c_backend import CBackend - -backend = CBackend() - -keep = backend.nms( - boxes, - scores, - score_threshold=0.50, - iou_threshold=0.50, -) -``` +Masks must use boolean dtype and share the same spatial shape. -For independent images, native calls can run concurrently because the C call -releases Python's global interpreter lock: - -```python -results = backend.batch_multiclass_nms( - boxes_batch, - scores_batch, - score_threshold=0.50, - iou_threshold=0.50, - workers=8, -) -``` - -Each result is an `(indices, class_ids)` tuple. Use `workers=1` when -deterministic single-thread execution or minimal scheduling overhead is more -important than batch throughput. - -## Coordinate convention +## Performance -Boxes use `xyxy` order. Choose the geometry explicitly: +The benchmark includes public API validation, allocation, and array +preparation. It performs two warm-ups and reports the median of nine measured +wall-clock runs. -| `offset` | Convention | Width | -| ---: | --- | --- | -| `0` | Continuous coordinates, default | `x2 - x1` | -| `1` | Inclusive integer pixel coordinates | `x2 - x1 + 1` | +### Fused image preprocessing -All scores equal to `score_threshold` are retained. A candidate is suppressed -only when `IoU > iou_threshold`; equality is retained. +Image shape: 427×640×3, 8 native threads. -## Performance +| Batch | NumPy (ms) | Native (ms) | Speedup | +| ---: | ---: | ---: | ---: | +| 1 | 4.492 | 0.249 | **18.07x** | +| 8 | 26.263 | 1.888 | **13.91x** | +| 32 | 139.105 | 9.758 | **14.25x** | -The benchmark checks C output against NumPy before timing. It uses two warm-up -runs and the median of nine measured runs. +### Bounding-box NMS -| Boxes | Boxes kept | NumPy (ms) | C (ms) | Speedup | +| Boxes | Kept | NumPy (ms) | Native (ms) | Speedup | | ---: | ---: | ---: | ---: | ---: | -| 250 | 178 | 4.745 | 0.277 | **17.16x** | -| 1,000 | 607 | 28.493 | 3.437 | **8.29x** | -| 2,500 | 1,284 | 96.306 | 19.763 | **4.87x** | +| 250 | 178 | 4.798 | 0.272 | **17.66x** | +| 1,000 | 607 | 23.257 | 3.340 | **6.96x** | +| 2,500 | 1,284 | 75.396 | 17.121 | **4.40x** | -Recorded on Linux x86_64 with Python 3.12.13, NumPy 2.3.5, GCC 13.3, and an -Intel Xeon Platinum 8573C host. Performance depends on hardware, box -distribution, suppression rate, compiler, and system load. +Recorded on Linux x86_64 with Python 3.12.13, NumPy 2.3.5, GCC 13.3, and +9 available Intel Xeon Platinum 8573C vCPUs. Results vary with hardware, +compiler, input distribution, suppression rate, and system load. Reproduce the measurements: ```bash +python -m benchmarks.benchmark_preprocess python -m benchmarks.benchmark_bbox -python -m benchmarks.benchmark_bbox --format json ``` -See the [evaluation report](docs/evaluation.md) for the complete methodology, -batch results, environment, and limitations. +Add `--format json` for machine-readable output. See the +[evaluation report](docs/evaluation.md) for methodology, environment, test +evidence, and limitations. -## Validation +## Correctness and validation Run the complete suite: ```bash -python -m nmss.build +python -m fastvisionops.build python -m unittest discover -s tests -v ``` -Coverage includes: +The 37 tests cover: +- exact and randomized preprocessing equivalence; +- RGB/BGR reversal, batches, empty batches, and noncontiguous images; - bbox and mask IoU behavior; - class-aware and class-unaware suppression; -- score/IoU boundaries and coordinate offsets; -- empty inputs and stable score ties; +- thresholds, coordinate offsets, stable ties, and empty detections; - malformed input rejection; -- randomized Python/C equivalence; and +- randomized NumPy/native NMS equivalence; and - serial/concurrent batch equivalence. -CI executes the suite on Python 3.9, 3.12, and 3.13. +CI runs the suite and benchmark smoke tests on Python 3.9, 3.12, and 3.13. -## API overview +## API map | API | Purpose | Backend | | --- | --- | --- | -| `nmss.nms` | Single-class bbox NMS | NumPy | -| `nmss.multiclass_nms` | Aware or unaware bbox NMS | NumPy | -| `nmss.mask_nms` | Single-class boolean-mask NMS | NumPy | -| `nmss.multiclass_mask_nms` | Class-aware boolean-mask NMS | NumPy | -| `nmss.c_backend.CBackend.nms` | Single-class bbox NMS | C | -| `CBackend.multiclass_nms` | Class-aware bbox NMS | C | -| `CBackend.batch_multiclass_nms` | Concurrent image batches | C | +| `hwc_to_chw` | HWC → CHW layout conversion | NumPy | +| `chw_channel_normalize` | Per-channel CHW normalization | NumPy | +| `hwc_to_chw_normalize` | Fused HWC → normalized CHW | NumPy | +| `hwc_to_chw_normalize_batched` | Fused NHWC → normalized NCHW | NumPy | +| `NativeBackend.hwc_to_chw_normalize` | Fused single-image preprocessing | C / OpenMP | +| `NativeBackend.hwc_to_chw_normalize_batched` | Fused batch preprocessing | C / OpenMP | +| `nms` | Single-class bbox NMS | NumPy | +| `multiclass_nms` | Aware or unaware bbox NMS | NumPy | +| `mask_nms` / `multiclass_mask_nms` | Boolean-mask NMS | NumPy | +| `NativeBackend.nms` / `multiclass_nms` | Bounding-box NMS | C | +| `NativeBackend.batch_multiclass_nms` | Concurrent image batches | C | + +Standalone transpose and normalization remain NumPy operations because the +fused path is the useful native hot path and avoids unnecessary intermediate +arrays. + +## Migration + +New integrations should import from `fastvisionops`. + +| Previous project | Previous API | FastVisionOps API | +| --- | --- | --- | +| FastPreProcess | `fastpreprocess.hwc_to_chw_normalize` | `fastvisionops.hwc_to_chw_normalize` | +| FastPreProcess | `fastpreprocess.hwc_to_chw_normalize_batched` | `fastvisionops.hwc_to_chw_normalize_batched` | +| FastPreProcess | compiled fused functions | `fastvisionops.NativeBackend` methods | +| NMSs | `nmss.nms` and related imports | `fastvisionops.nms` and related imports | +| NMSs | `nmss.c_backend.CBackend` | `fastvisionops.NativeBackend` | + +The `nmss` package remains as a backward-compatible namespace and points to the +same maintained implementation. The old unsafe FastPreProcess binary is not +shipped; the replacement validates inputs, owns memory through NumPy, supports +noncontiguous arrays, and removes the unused OpenCV and pybind11 dependencies. ## Repository layout ```text -nmss/ Maintained Python package and C source +fastvisionops/ Primary package, native bindings, and C source +nmss/ Backward-compatible NMS namespace tests/ Correctness and native-equivalence suite -benchmarks/ Reproducible performance runner +benchmarks/ Reproducible elapsed-time benchmarks docs/evaluation.md Methodology, evidence, and limitations -bbox-nms*/ mask-nms/ Backward-compatible import paths +bbox-nms*/ mask-nms/ Legacy import adapters .github/workflows/ Python-version CI matrix ``` -The legacy modules retain their original public function names and use -`offset=1` to preserve the old inclusive-pixel behavior. New integrations should -import directly from `nmss`. - ## Scope -NMSs currently focuses on greedy CPU NMS. GPU kernels, Soft-NMS, DIoU-NMS, -native mask kernels, and prebuilt platform wheels are intentionally left for -future releases. +FastVisionOps targets deterministic CPU inference utilities. GPU kernels, +resize/color conversion, Soft-NMS, DIoU-NMS, native mask kernels, and prebuilt +platform wheels are outside the current release. diff --git a/bbox-nms-c-version/README.md b/bbox-nms-c-version/README.md index 7271cde..3d469c8 100644 --- a/bbox-nms-c-version/README.md +++ b/bbox-nms-c-version/README.md @@ -1,11 +1,11 @@ # Legacy C API This directory preserves the original `Batch_Parallel_Nms` import path. -Maintained native code now lives in `nmss/csrc`, and the shared library is -rebuilt locally: +Maintained native code now lives in `fastvisionops/csrc`, and the shared +library is rebuilt locally: ```bash -python -m nmss.build +python -m fastvisionops.build ``` Existing calls continue to work: @@ -23,7 +23,7 @@ batch_indices, batch_class_ids = backend.batch_parallel_nms( ) ``` -New code should use `nmss.c_backend.CBackend`. See the +New code should use `fastvisionops.NativeBackend`. See the [root README](../README.md) and [evaluation report](../docs/evaluation.md) for the current API and verified benchmark. diff --git a/bbox-nms-c-version/compile.md b/bbox-nms-c-version/compile.md index ff60c3a..bbff340 100644 --- a/bbox-nms-c-version/compile.md +++ b/bbox-nms-c-version/compile.md @@ -3,14 +3,14 @@ The supported build entry point is: ```bash -python -m nmss.build +python -m fastvisionops.build ``` Use a different compiler or output path when required: ```bash -python -m nmss.build --compiler clang --output /tmp/libnmss.so +python -m fastvisionops.build --compiler clang --output /tmp/libfastvisionops.so ``` -The builder compiles `nmss/csrc/nms.c` with optimized, reproducible flags and -reports compiler errors directly. +The builder compiles `fastvisionops/csrc/vision_ops.c` with optimized, +reproducible flags and reports compiler errors directly. diff --git a/docs/evaluation.md b/docs/evaluation.md index 07c092f..34d9554 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -1,55 +1,61 @@ -# NMSs Evaluation Report +# FastVisionOps Evaluation Report ## Executive summary -The repository now has one validated NumPy reference implementation, one -rebuildable C backend, deterministic output rules, and automated coverage for -bounding-box, mask, multiclass, empty-input, invalid-input, and batch behavior. +FastVisionOps unifies image preprocessing and non-maximum suppression behind +one validated NumPy reference layer and one rebuildable C backend. The current +suite has 37 named tests, including randomized native equivalence, malformed +input handling, noncontiguous images, empty batches, deterministic score ties, +and concurrent NMS execution. -On the evaluation host, the native implementation was **4.87x to 17.16x -faster** than the NumPy reference for 250 to 2,500 boxes. Processing eight -images concurrently was **1.72x faster** than serial C execution in the recorded -run. Every native result was checked against the Python reference before its -timing was accepted. +On the evaluation host: + +- fused preprocessing of one 427×640×3 image took **4.492 ms in NumPy** and + **0.249 ms natively**, an **18.07x speedup**; +- a batch of 32 images took **139.105 ms in NumPy** and **9.758 ms natively**, + a **14.25x speedup**; +- native bbox NMS was **4.40x to 17.66x faster** for 250 to 2,500 boxes; and +- eight 1,000-box images took **27.595 ms serially** and **9.995 ms with eight + workers**, a **2.76x throughput speedup**. + +Every native result was compared with its NumPy reference before timing. ## What was evaluated -| Area | Evaluation | +| Area | Evidence | | --- | --- | +| Image preprocessing | Exact NumPy reference, randomized shapes, RGB/BGR reversal, single and batched calls | +| Image memory behavior | C-contiguous output, noncontiguous input, empty batch | +| Preprocessing validation | uint8 dtype, dimensions, channel count, mean/std shape and finiteness, nonzero std, thread count | | Single-class bbox NMS | Known examples, stable ties, inclusive score threshold, both coordinate offsets | | Multiclass bbox NMS | Class-aware and class-unaware behavior, global score ordering | -| Mask NMS | IoU, suppression, empty masks, multiclass output | -| Native backend | 128 randomized parameter combinations against NumPy | -| Batch execution | Serial and concurrent native outputs compared item by item | -| Defensive behavior | Invalid shapes, coordinates, thresholds, dtypes, and library paths | +| Mask NMS | IoU, suppression, empty comparison batches, empty masks, multiclass output | +| Native NMS | 128 randomized parameter combinations against NumPy | +| Batch NMS | Serial and concurrent native outputs compared item by item | +| Packaging | Source wheel build and bundled native C source | +| C quality | GCC build with `-Wall -Wextra -Werror` | -The test suite contains 21 named tests. The randomized native equivalence test -combines: +The randomized native NMS matrix combines four input sizes, two coordinate +offsets, four score thresholds, and four IoU thresholds: -- four input sizes: 0, 1, 32, and 257 boxes; -- both continuous (`offset=0`) and inclusive-pixel (`offset=1`) coordinates; -- four score thresholds: 0.0, 0.25, 0.8, and 1.0; and -- four IoU thresholds: 0.0, 0.3, 0.7, and 1.0. +$$4 \times 2 \times 4 \times 4 = 128$$ -## Performance methodology +## Benchmark methodology -The benchmark uses deterministic synthetic `xyxy` boxes: +Both benchmark runners: -- random seed: 42; -- image extent: 640 × 640; -- box sizes: uniformly sampled from 10 to 160; -- score threshold: 0.25; -- IoU threshold: 0.5; -- warm-up iterations: 2; and -- measured iterations: 9, reported as the median wall-clock duration. +1. generate deterministic inputs from a fixed seed; +2. execute NumPy and native implementations; +3. assert equivalent output; +4. perform two untimed warm-up iterations; and +5. report the median of nine wall-clock measurements using + `time.perf_counter_ns`. -Both measurements include public API validation and array preparation. The -script checks that C and NumPy return identical indices before measuring. -Native code is rebuilt from `nmss/csrc/nms.c` using: - -```text --O3 -std=c11 -DNDEBUG -fPIC -shared -lm -``` +The reported durations include public API validation, output allocation, and +necessary array preparation. They are operation latency, not kernel-only time. +No speedup assertion is used in CI because shared-runner timing thresholds are +inherently noisy; CI smoke-tests both runners, while correctness is enforced by +the test suite. ### Evaluation environment @@ -61,54 +67,113 @@ Native code is rebuilt from `nmss/csrc/nms.c` using: | Python | 3.12.13 | | NumPy | 2.3.5 | | Compiler | GCC 13.3.0 | +| Native optimization | `-O3 -DNDEBUG`, OpenMP enabled | + +The native source is compiled with: + +```text +-O3 -std=c11 -DNDEBUG -fPIC -shared -fopenmp -lm +``` + +If OpenMP compilation fails, the builder retries without `-fopenmp`. + +## Recorded preprocessing results -### Recorded results +Configuration: -| Boxes | Boxes kept | NumPy (ms) | C (ms) | C speedup | +- input dtype and layout: contiguous uint8 NHWC; +- image shape: 427×640×3; +- batch sizes: 1, 8, and 32; +- native threads: 8; +- mean: `[123.675, 116.28, 103.53]`; +- std: `[58.395, 57.12, 57.375]`; and +- seed: 42, incremented once per batch-size case. + +| Batch | NumPy median (ms) | Native median (ms) | Speedup | +| ---: | ---: | ---: | ---: | +| 1 | 4.492 | 0.249 | 18.07x | +| 8 | 26.263 | 1.888 | 13.91x | +| 32 | 139.105 | 9.758 | 14.25x | + +The benchmark measures the fused HWC-to-CHW conversion and channel +normalization path. This is the operation that avoids an intermediate +transposed array and benefits from native parallel execution. + +## Recorded bbox NMS results + +Configuration: + +- random seed: 42; +- image extent: 640×640; +- box sizes: uniformly sampled from 10 to 160; +- score threshold: 0.25; and +- IoU threshold: 0.5. + +| Boxes | Boxes kept | NumPy median (ms) | Native median (ms) | Speedup | | ---: | ---: | ---: | ---: | ---: | -| 250 | 178 | 4.745 | 0.277 | 17.16x | -| 1,000 | 607 | 28.493 | 3.437 | 8.29x | -| 2,500 | 1,284 | 96.306 | 19.763 | 4.87x | +| 250 | 178 | 4.798 | 0.272 | 17.66x | +| 1,000 | 607 | 23.257 | 3.340 | 6.96x | +| 2,500 | 1,284 | 75.396 | 17.121 | 4.40x | -| Batch | Boxes/image | Workers | Serial C (ms) | Parallel C (ms) | Speedup | +| Batch | Boxes/image | Workers | Serial native (ms) | Parallel native (ms) | Speedup | | ---: | ---: | ---: | ---: | ---: | ---: | -| 8 | 1,000 | 8 | 30.243 | 17.619 | 1.72x | +| 8 | 1,000 | 8 | 27.595 | 9.995 | 2.76x | -Timings are host-dependent. The committed benchmark is the source of truth and -should be rerun on the deployment machine rather than treating these values as -a universal guarantee. +The declining single-image NMS speedup at larger input sizes is expected: +greedy NMS remains quadratic in the worst case, while Python/NumPy validation +and dispatch overhead matter proportionally less as the native comparison loop +grows. ## Reproduction From the repository root: ```bash -python -m nmss.build +python -m fastvisionops.build python -m unittest discover -s tests -v +python -m benchmarks.benchmark_preprocess python -m benchmarks.benchmark_bbox +``` + +Machine-readable runs: + +```bash +python -m benchmarks.benchmark_preprocess --format json python -m benchmarks.benchmark_bbox --format json ``` -The last two commands produce Markdown and machine-readable results, -respectively. - -## Findings and trade-offs - -1. **The original binary was not reproducible.** A checked-in `.so` is tied to - an unknown compiler and ABI. It has been replaced with a source build. -2. **The original C and Python thresholds disagreed.** C used `>` while Python - used `>=`. Both backends now retain scores exactly equal to the threshold. -3. **Unsigned coordinates could underflow.** The native backend now accepts - `float64`, allowing negative and fractional coordinates safely. -4. **Output was not deterministic for score ties.** Both backends now prefer the - lower original index when scores are equal. -5. **Batch parallelism helps when each item is large enough.** Small items can - be dominated by thread scheduling, so `workers=1` remains available. - -## Current limits - -- The native backend currently targets Linux systems with GCC or Clang. -- Greedy NMS remains quadratic in the worst case. -- Mask NMS is vectorized NumPy only; there is no native mask backend yet. -- GPU backends, Soft-NMS, DIoU-NMS, and prebuilt wheels are outside this - repository's current scope. +Useful benchmark controls include `--warmup`, `--repeats`, `--threads`, +`--batches`, `--sizes`, and `--workers`. Run on the deployment host for +capacity planning rather than treating the recorded values as universal. + +## Improvements over the original repositories + +1. **Unsafe allocation was removed.** FastPreProcess allocated arrays with + `new[]` but released them with scalar `delete`, which is undefined behavior. + FastVisionOps allocates output through NumPy and writes into owned buffers. +2. **Input contracts are explicit.** Shape, dtype, statistics, channel + reversal, coordinates, scores, and thresholds are validated before native + execution. +3. **Noncontiguous images are correct.** Inputs are made contiguous only when + the native backend requires it. +4. **Nested OpenMP was removed.** The fused preprocessor uses one parallel loop + across batch and spatial positions. +5. **The build is reproducible.** Hard-coded Python 3.9 paths, the broken + pybind11 gitlink, unused OpenCV, and checked-in build output are not part of + the combined package. +6. **NMS semantics match.** NumPy and C retain scores equal to the threshold, + support negative/fractional coordinates, and resolve score ties identically. +7. **Measured time is reported.** Every speedup table includes NumPy and native + milliseconds, not only a ratio. + +## Limits and interpretation + +- Timings are host-specific and sensitive to CPU frequency, memory bandwidth, + compiler, process contention, input distribution, and suppression rate. +- The native build currently targets Unix-like systems with GCC or Clang. +- The OpenMP fallback remains correct but is single-threaded and will have a + different performance profile. +- Greedy NMS is quadratic in the worst case. +- Mask NMS is vectorized NumPy only. +- GPU kernels, resize/color conversion, Soft-NMS, DIoU-NMS, and prebuilt wheels + are outside this release. diff --git a/fastvisionops/__init__.py b/fastvisionops/__init__.py index 8e97c05..bd2eb81 100644 --- a/fastvisionops/__init__.py +++ b/fastvisionops/__init__.py @@ -9,6 +9,7 @@ ) from nmss.mask import mask_iou, mask_nms, multiclass_mask_nms +from .native import NativeBackend from .preprocess import ( chw_channel_normalize, hwc_to_chw, @@ -28,6 +29,7 @@ "multiclass_nms", "multiclass_nms_class_aware", "multiclass_nms_class_unaware", + "NativeBackend", "nms", ] diff --git a/mask-nms/README.md b/mask-nms/README.md index 3168997..2285cc4 100644 --- a/mask-nms/README.md +++ b/mask-nms/README.md @@ -4,7 +4,7 @@ This directory preserves the original mask NMS function names. The maintained implementation is `nmss.mask` and requires only NumPy. ```python -from nmss import mask_nms, multiclass_mask_nms +from fastvisionops import mask_nms, multiclass_mask_nms ``` Inputs use shape `(num_masks, ...)`, boolean dtype, and scores shaped diff --git a/pyproject.toml b/pyproject.toml index 4e5effb..763c810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Validated NumPy and native operations for vision inference" readme = "README.md" requires-python = ">=3.9" dependencies = ["numpy>=1.23"] +keywords = ["computer-vision", "inference", "nms", "numpy", "preprocessing"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -18,6 +19,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] +[project.urls] +Documentation = "https://github.com/Som5ra/FastVisionOps#readme" +Issues = "https://github.com/Som5ra/FastVisionOps/issues" +Repository = "https://github.com/Som5ra/FastVisionOps" + [project.optional-dependencies] dev = ["pytest>=7", "pytest-cov>=4"] From e6516e79841c4bf330b212daceb766b9d283a179 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 16:37:51 +0800 Subject: [PATCH 11/14] fix: validate execution limits consistently --- fastvisionops/__init__.py | 14 +++++++++++++- fastvisionops/native.py | 26 +++++++++++++++++--------- nmss/_validation.py | 12 ++++++++++++ nmss/bbox.py | 10 ++++------ nmss/mask.py | 7 +++---- tests/test_bbox.py | 10 ++++++++++ tests/test_c_backend.py | 14 ++++++++++++++ tests/test_mask.py | 8 ++++++++ tests/test_native_preprocess.py | 16 +++++++++------- 9 files changed, 90 insertions(+), 27 deletions(-) diff --git a/fastvisionops/__init__.py b/fastvisionops/__init__.py index bd2eb81..a6e30d6 100644 --- a/fastvisionops/__init__.py +++ b/fastvisionops/__init__.py @@ -1,5 +1,7 @@ """Fast, validated CPU operations for computer-vision inference.""" +from typing import TYPE_CHECKING + from nmss.bbox import ( bbox_iou, multiclass_nms, @@ -9,7 +11,6 @@ ) from nmss.mask import mask_iou, mask_nms, multiclass_mask_nms -from .native import NativeBackend from .preprocess import ( chw_channel_normalize, hwc_to_chw, @@ -17,6 +18,9 @@ hwc_to_chw_normalize_batched, ) +if TYPE_CHECKING: + from .native import NativeBackend + __all__ = [ "bbox_iou", "chw_channel_normalize", @@ -34,3 +38,11 @@ ] __version__ = "1.0.0" + + +def __getattr__(name: str): + if name == "NativeBackend": + from .native import NativeBackend + + return NativeBackend + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fastvisionops/native.py b/fastvisionops/native.py index 9b2972f..1353fea 100644 --- a/fastvisionops/native.py +++ b/fastvisionops/native.py @@ -16,6 +16,7 @@ from nmss._validation import ( validate_batch, validate_boxes, + validate_max_detections, validate_offset, validate_scores, validate_threshold, @@ -79,8 +80,7 @@ def nms( ) iou_threshold = validate_threshold("iou_threshold", iou_threshold) offset = validate_offset(offset) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) if len(boxes_array) == 0 or max_detections == 0: return np.empty(0, dtype=np.int64) @@ -113,8 +113,7 @@ def multiclass_nms( """Run class-aware NMS in C and globally sort the detections.""" boxes_array = validate_boxes(boxes) scores_array = validate_scores(scores, len(boxes_array), ndim=2) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) box_parts: list[NDArray[np.int64]] = [] class_parts: list[NDArray[np.int64]] = [] @@ -158,12 +157,17 @@ def batch_multiclass_nms( ) -> list[tuple[NDArray[np.int64], NDArray[np.int64]]]: """Run independent images concurrently.""" validate_batch(boxes, scores) + if workers is None: + workers = min(max(len(boxes), 1), os.cpu_count() or 1) + if ( + isinstance(workers, (bool, np.bool_)) + or not isinstance(workers, (int, np.integer)) + or workers < 1 + ): + raise ValueError("workers must be a positive integer or None") + workers = int(workers) if not boxes: return [] - if workers is None: - workers = min(len(boxes), os.cpu_count() or 1) - if workers < 1: - raise ValueError("workers must be at least 1") def run(item: tuple[ArrayLike, ArrayLike]): image_boxes, image_scores = item @@ -201,8 +205,12 @@ def hwc_to_chw_normalize_batched( isinstance(threads, (bool, np.bool_)) or not isinstance(threads, (int, np.integer)) or threads < 0 + or threads > np.iinfo(np.int32).max ): - raise ValueError("threads must be a non-negative integer or None") + raise ValueError( + "threads must be an integer between 0 and 2147483647 or None" + ) + threads = int(threads) contiguous_input = np.ascontiguousarray(image_array) batch, height, width, channels = contiguous_input.shape diff --git a/nmss/_validation.py b/nmss/_validation.py index 81f1f56..d558a1d 100644 --- a/nmss/_validation.py +++ b/nmss/_validation.py @@ -22,6 +22,18 @@ def validate_offset(offset: float) -> float: return offset +def validate_max_detections(value: int | None) -> int | None: + if value is None: + return None + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or value < 0 + ): + raise ValueError("max_detections must be a non-negative integer or None") + return int(value) + + def validate_boxes(boxes: ArrayLike) -> NDArray[np.float64]: result = np.ascontiguousarray(boxes, dtype=np.float64) if result.ndim != 2 or result.shape[1:] != (4,): diff --git a/nmss/bbox.py b/nmss/bbox.py index 07ae7ee..bcbabfd 100644 --- a/nmss/bbox.py +++ b/nmss/bbox.py @@ -7,6 +7,7 @@ from ._validation import ( validate_boxes, + validate_max_detections, validate_offset, validate_scores, validate_threshold, @@ -66,8 +67,7 @@ def nms( score_threshold = validate_threshold("score_threshold", score_threshold) iou_threshold = validate_threshold("iou_threshold", iou_threshold) offset = validate_offset(offset) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) candidate_indices = np.flatnonzero(scores_array >= score_threshold) if candidate_indices.size == 0 or max_detections == 0: @@ -114,8 +114,7 @@ def multiclass_nms_class_aware( score_threshold = validate_threshold("score_threshold", score_threshold) iou_threshold = validate_threshold("iou_threshold", iou_threshold) offset = validate_offset(offset) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) box_parts: list[NDArray[np.int64]] = [] class_parts: list[NDArray[np.int64]] = [] @@ -161,8 +160,7 @@ def multiclass_nms_class_unaware( score_threshold = validate_threshold("score_threshold", score_threshold) iou_threshold = validate_threshold("iou_threshold", iou_threshold) offset = validate_offset(offset) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) if len(boxes_array) == 0: empty = np.empty(0, dtype=np.int64) return empty, empty.copy() diff --git a/nmss/mask.py b/nmss/mask.py index c8bd063..c5e7dff 100644 --- a/nmss/mask.py +++ b/nmss/mask.py @@ -6,6 +6,7 @@ from numpy.typing import ArrayLike, NDArray from ._validation import ( + validate_max_detections, validate_masks, validate_scores, validate_threshold, @@ -50,8 +51,7 @@ def mask_nms( scores_array = validate_scores(scores, len(masks_array), ndim=1) score_threshold = validate_threshold("score_threshold", score_threshold) iou_threshold = validate_threshold("iou_threshold", iou_threshold) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) candidates = np.flatnonzero(scores_array >= score_threshold) if candidates.size == 0 or max_detections == 0: @@ -87,8 +87,7 @@ def multiclass_mask_nms( scores_array = validate_scores(scores, len(masks_array), ndim=2) score_threshold = validate_threshold("score_threshold", score_threshold) iou_threshold = validate_threshold("iou_threshold", iou_threshold) - if max_detections is not None and max_detections < 0: - raise ValueError("max_detections must be non-negative or None") + max_detections = validate_max_detections(max_detections) mask_parts: list[NDArray[np.int64]] = [] class_parts: list[NDArray[np.int64]] = [] diff --git a/tests/test_bbox.py b/tests/test_bbox.py index 254cb91..df5a077 100644 --- a/tests/test_bbox.py +++ b/tests/test_bbox.py @@ -60,6 +60,16 @@ def test_max_detections_limits_output(self): 0, ) + def test_max_detections_requires_an_integer(self): + for value in (True, 1.5, "1"): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "max_detections"): + nms( + self.boxes, + self.scores, + max_detections=value, + ) + def test_bbox_iou_handles_zero_area(self): actual = bbox_iou( np.zeros(4), diff --git a/tests/test_c_backend.py b/tests/test_c_backend.py index b132f99..306f2af 100644 --- a/tests/test_c_backend.py +++ b/tests/test_c_backend.py @@ -94,6 +94,20 @@ def test_parallel_batch_matches_serial_batch(self): np.testing.assert_array_equal(serial_item[0], parallel_item[0]) np.testing.assert_array_equal(serial_item[1], parallel_item[1]) + def test_workers_require_a_positive_integer(self): + boxes = [np.array([[0.0, 0.0, 1.0, 1.0]])] + scores = [np.array([[1.0]])] + for workers in (True, 1.5, 0): + with self.subTest(workers=workers): + with self.assertRaisesRegex(ValueError, "workers"): + self.backend.batch_multiclass_nms( + boxes, + scores, + workers=workers, + ) + with self.assertRaisesRegex(ValueError, "workers"): + self.backend.batch_multiclass_nms([], [], workers=0) + def test_missing_library_has_actionable_error(self): with self.assertRaisesRegex(FileNotFoundError, "fastvisionops.build"): CBackend("/definitely/missing/libnmss.so") diff --git a/tests/test_mask.py b/tests/test_mask.py index f98e516..cd61b52 100644 --- a/tests/test_mask.py +++ b/tests/test_mask.py @@ -54,6 +54,14 @@ def test_max_detections(self): ) np.testing.assert_array_equal(actual, [0, 1]) + def test_max_detections_requires_an_integer(self): + with self.assertRaisesRegex(ValueError, "max_detections"): + mask_nms( + self.masks, + self.scores, + max_detections=1.5, + ) + def test_non_boolean_masks_are_rejected(self): with self.assertRaises(TypeError): mask_nms(self.masks.astype(np.uint8), self.scores) diff --git a/tests/test_native_preprocess.py b/tests/test_native_preprocess.py index a5d66e9..88c09ca 100644 --- a/tests/test_native_preprocess.py +++ b/tests/test_native_preprocess.py @@ -99,13 +99,15 @@ def test_empty_batch(self): def test_invalid_threads_are_rejected(self): image = np.zeros((1, 1, 3), dtype=np.uint8) - with self.assertRaisesRegex(ValueError, "threads"): - self.backend.hwc_to_chw_normalize( - image, - [0, 0, 0], - [1, 1, 1], - threads=-1, - ) + for threads in (-1, True, 1.5, 2**31): + with self.subTest(threads=threads): + with self.assertRaisesRegex(ValueError, "threads"): + self.backend.hwc_to_chw_normalize( + image, + [0, 0, 0], + [1, 1, 1], + threads=threads, + ) def test_missing_library_has_actionable_error(self): with self.assertRaisesRegex(FileNotFoundError, "fastvisionops.build"): From 5e71fc3ac16a38468c8da0fefb8f0ae438ecbb05 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 16:38:53 +0800 Subject: [PATCH 12/14] docs: refresh final validation evidence --- README.md | 2 +- docs/evaluation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e85a8b2..49844de 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ python -m fastvisionops.build python -m unittest discover -s tests -v ``` -The 37 tests cover: +The 40 tests cover: - exact and randomized preprocessing equivalence; - RGB/BGR reversal, batches, empty batches, and noncontiguous images; diff --git a/docs/evaluation.md b/docs/evaluation.md index 34d9554..491be85 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -4,7 +4,7 @@ FastVisionOps unifies image preprocessing and non-maximum suppression behind one validated NumPy reference layer and one rebuildable C backend. The current -suite has 37 named tests, including randomized native equivalence, malformed +suite has 40 named tests, including randomized native equivalence, malformed input handling, noncontiguous images, empty batches, deterministic score ties, and concurrent NMS execution. From c1818db99d39e4d896810ced92dde4828fca0e49 Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 16:51:39 +0800 Subject: [PATCH 13/14] fix: validate empty native NMS batches --- fastvisionops/native.py | 6 ++++++ tests/test_c_backend.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/fastvisionops/native.py b/fastvisionops/native.py index 1353fea..e9baa26 100644 --- a/fastvisionops/native.py +++ b/fastvisionops/native.py @@ -157,6 +157,12 @@ def batch_multiclass_nms( ) -> list[tuple[NDArray[np.int64], NDArray[np.int64]]]: """Run independent images concurrently.""" validate_batch(boxes, scores) + score_threshold = validate_threshold( + "score_threshold", score_threshold + ) + iou_threshold = validate_threshold("iou_threshold", iou_threshold) + offset = validate_offset(offset) + max_detections = validate_max_detections(max_detections) if workers is None: workers = min(max(len(boxes), 1), os.cpu_count() or 1) if ( diff --git a/tests/test_c_backend.py b/tests/test_c_backend.py index 306f2af..e942377 100644 --- a/tests/test_c_backend.py +++ b/tests/test_c_backend.py @@ -108,6 +108,22 @@ def test_workers_require_a_positive_integer(self): with self.assertRaisesRegex(ValueError, "workers"): self.backend.batch_multiclass_nms([], [], workers=0) + def test_empty_batch_still_validates_nms_arguments(self): + invalid_cases = [ + {"score_threshold": -0.1}, + {"iou_threshold": 1.1}, + {"offset": 0.5}, + {"max_detections": 1.5}, + ] + for arguments in invalid_cases: + with self.subTest(arguments=arguments): + with self.assertRaises(ValueError): + self.backend.batch_multiclass_nms( + [], + [], + **arguments, + ) + def test_missing_library_has_actionable_error(self): with self.assertRaisesRegex(FileNotFoundError, "fastvisionops.build"): CBackend("/definitely/missing/libnmss.so") From 53afe12190d059ad67ccb1028b889e64b38c14fb Mon Sep 17 00:00:00 2001 From: Sombra Date: Thu, 23 Jul 2026 16:52:23 +0800 Subject: [PATCH 14/14] docs: tighten FastVisionOps overview --- README.md | 231 +++++++++++++++------------------------------ docs/evaluation.md | 2 +- 2 files changed, 75 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 49844de..8e1ac69 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,20 @@ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)](https://www.python.org/) [![Backend](https://img.shields.io/badge/backend-NumPy%20%2B%20C-4D77CF.svg)](https://numpy.org/) -**A compact, validated CPU toolkit for the preprocessing and postprocessing -stages of vision inference.** +**Validated, framework-independent CPU operations for vision inference.** FastVisionOps combines the former FastPreProcess and NMSs projects behind one -Python package and one rebuildable native library. It handles uint8 image -layout conversion and normalization, bounding-box NMS, boolean-mask NMS, -multiclass suppression, and batched execution without requiring a -deep-learning framework. - -## Why FastVisionOps - -- **One inference utility layer:** preprocessing and NMS share one install, - validation policy, test suite, native build, and benchmark workflow. -- **Measured acceleration:** the fused native preprocessor completed a - 427×640 image in **0.249 ms** versus **4.492 ms** for NumPy on the evaluation - host. -- **Correct before fast:** every benchmark checks native output against the - NumPy reference before accepting a timing. -- **Defensive inputs:** dtype, dimensionality, channel statistics, coordinates, - thresholds, and empty inputs are validated with actionable errors. -- **Deterministic NMS:** equal scores are resolved by original input index. -- **Reproducible native code:** the repository ships portable C source, not an - opaque ABI-specific binary. +Python package and one rebuildable native library. It covers image layout +conversion and normalization, bounding-box NMS, boolean-mask NMS, multiclass +suppression, and batched execution. + +- **Unified:** one install, validation policy, native build, and test suite. +- **Measured:** benchmarks report absolute latency and speedup after checking + native output against NumPy. +- **Defensive:** malformed images, statistics, boxes, scores, and controls fail + early with actionable errors. +- **Reproducible:** portable C source replaces opaque platform binaries and + falls back cleanly when OpenMP is unavailable. ```mermaid flowchart LR @@ -36,67 +27,50 @@ flowchart LR D --> E["Deterministic NMS"] ``` -## Installation - -Install the NumPy implementation: +## Install ```bash python -m pip install . -``` - -Compile the optional native backend: - -```bash python -m fastvisionops.build ``` -The build requires GCC or Clang. OpenMP is enabled when supported and the -builder automatically falls back to portable single-threaded C. Use `CC` or -`--compiler` to choose a compiler; use `--no-openmp` for an explicit portable -build. +The NumPy APIs work immediately after installation. The second command builds +the optional native backend with GCC or Clang. It uses OpenMP when supported +and otherwise retries as portable single-threaded C. Use `CC`, `--compiler`, +or `--no-openmp` to control the build. ## Quick start -### Image preprocessing +### Preprocess images ```python import numpy as np -from fastvisionops import hwc_to_chw_normalize +from fastvisionops import NativeBackend, hwc_to_chw_normalize image = np.zeros((427, 640, 3), dtype=np.uint8) mean = [123.675, 116.28, 103.53] std = [58.395, 57.12, 57.375] -tensor = hwc_to_chw_normalize( - image, - mean, - std, - flip_rb=True, -) -# float32, shape (3, 427, 640), C-contiguous -``` - -For the accelerated path: - -```python -from fastvisionops import NativeBackend +# NumPy reference: float32 CHW, shape (3, 427, 640) +tensor = hwc_to_chw_normalize(image, mean, std, flip_rb=True) +# Native fused path backend = NativeBackend() -tensor = backend.hwc_to_chw_normalize( +fast_tensor = backend.hwc_to_chw_normalize( image, mean, std, flip_rb=True, threads=8, ) +np.testing.assert_allclose(fast_tensor, tensor, rtol=1e-6, atol=1e-6) ``` -Both single-image HWC and batched NHWC inputs are supported. `flip_rb=True` -normalizes using the source-channel statistics and reverses the three output -channels. Noncontiguous inputs are handled correctly. +Single-image HWC and batched NHWC inputs are supported. Outputs are contiguous +CHW or NCHW arrays. Noncontiguous inputs are handled correctly. -### Bounding-box NMS +### Suppress detections ```python from fastvisionops import nms @@ -119,88 +93,36 @@ keep = nms( # array([0, 2]) ``` -Use `NativeBackend.nms` for native execution. `multiclass_nms` supports both -class-aware and class-unaware suppression and returns globally score-ordered -box and class indices. - -### Boolean-mask NMS - -```python -from fastvisionops import mask_nms - -masks = np.zeros((3, 64, 64), dtype=bool) -masks[0, :20, :20] = True -masks[1, 2:18, 2:18] = True -masks[2, 40:, 40:] = True - -keep = mask_nms(masks, scores, iou_threshold=0.50) -# array([0, 2]) -``` - -Masks must use boolean dtype and share the same spatial shape. - -## Performance - -The benchmark includes public API validation, allocation, and array -preparation. It performs two warm-ups and reports the median of nine measured -wall-clock runs. +Use `NativeBackend.nms` for native bbox execution. `multiclass_nms` supports +class-aware and class-unaware suppression. `mask_nms` and +`multiclass_mask_nms` operate on boolean masks. -### Fused image preprocessing +## Measured performance -Image shape: 427×640×3, 8 native threads. +Each row reports the median duration of one complete public API call after two +warm-ups and nine measured runs. Validation and allocation are included. -| Batch | NumPy (ms) | Native (ms) | Speedup | -| ---: | ---: | ---: | ---: | -| 1 | 4.492 | 0.249 | **18.07x** | -| 8 | 26.263 | 1.888 | **13.91x** | -| 32 | 139.105 | 9.758 | **14.25x** | - -### Bounding-box NMS - -| Boxes | Kept | NumPy (ms) | Native (ms) | Speedup | -| ---: | ---: | ---: | ---: | ---: | -| 250 | 178 | 4.798 | 0.272 | **17.66x** | -| 1,000 | 607 | 23.257 | 3.340 | **6.96x** | -| 2,500 | 1,284 | 75.396 | 17.121 | **4.40x** | +| Workload | Baseline | Optimized | Speedup | +| --- | ---: | ---: | ---: | +| Preprocess 1 × 427×640×3 | NumPy 4.492 ms | Native 0.249 ms | **18.07x** | +| Preprocess 32 × 427×640×3 | NumPy 139.105 ms | Native 9.758 ms | **14.25x** | +| NMS, 250 boxes | NumPy 4.798 ms | Native 0.272 ms | **17.66x** | +| NMS, 2,500 boxes | NumPy 75.396 ms | Native 17.121 ms | **4.40x** | +| 8 images × 1,000 boxes | Serial C 27.595 ms | Parallel C 9.995 ms | **2.76x** | Recorded on Linux x86_64 with Python 3.12.13, NumPy 2.3.5, GCC 13.3, and -9 available Intel Xeon Platinum 8573C vCPUs. Results vary with hardware, -compiler, input distribution, suppression rate, and system load. - -Reproduce the measurements: +9 available Intel Xeon Platinum 8573C vCPUs. Reproduce the measurements: ```bash python -m benchmarks.benchmark_preprocess python -m benchmarks.benchmark_bbox ``` -Add `--format json` for machine-readable output. See the -[evaluation report](docs/evaluation.md) for methodology, environment, test -evidence, and limitations. - -## Correctness and validation - -Run the complete suite: - -```bash -python -m fastvisionops.build -python -m unittest discover -s tests -v -``` - -The 40 tests cover: - -- exact and randomized preprocessing equivalence; -- RGB/BGR reversal, batches, empty batches, and noncontiguous images; -- bbox and mask IoU behavior; -- class-aware and class-unaware suppression; -- thresholds, coordinate offsets, stable ties, and empty detections; -- malformed input rejection; -- randomized NumPy/native NMS equivalence; and -- serial/concurrent batch equivalence. +Add `--format json` for machine-readable output. The +[evaluation report](docs/evaluation.md) documents the complete methodology, +environment, results, and limitations. -CI runs the suite and benchmark smoke tests on Python 3.9, 3.12, and 3.13. - -## API map +## API | API | Purpose | Backend | | --- | --- | --- | @@ -208,49 +130,44 @@ CI runs the suite and benchmark smoke tests on Python 3.9, 3.12, and 3.13. | `chw_channel_normalize` | Per-channel CHW normalization | NumPy | | `hwc_to_chw_normalize` | Fused HWC → normalized CHW | NumPy | | `hwc_to_chw_normalize_batched` | Fused NHWC → normalized NCHW | NumPy | -| `NativeBackend.hwc_to_chw_normalize` | Fused single-image preprocessing | C / OpenMP | -| `NativeBackend.hwc_to_chw_normalize_batched` | Fused batch preprocessing | C / OpenMP | -| `nms` | Single-class bbox NMS | NumPy | -| `multiclass_nms` | Aware or unaware bbox NMS | NumPy | +| `NativeBackend.hwc_to_chw_normalize*` | Fused single/batch preprocessing | C / OpenMP | +| `nms` / `multiclass_nms` | Bounding-box NMS | NumPy | | `mask_nms` / `multiclass_mask_nms` | Boolean-mask NMS | NumPy | | `NativeBackend.nms` / `multiclass_nms` | Bounding-box NMS | C | | `NativeBackend.batch_multiclass_nms` | Concurrent image batches | C | -Standalone transpose and normalization remain NumPy operations because the -fused path is the useful native hot path and avoids unnecessary intermediate -arrays. +Standalone transpose and normalization remain NumPy operations; the fused +native path avoids intermediate arrays and accelerates the useful hot path. + +## Validation + +```bash +python -m fastvisionops.build +python -m unittest discover -s tests -v +``` + +The 41 tests cover exact and randomized NumPy/native equivalence, empty and +noncontiguous inputs, channel reversal, deterministic ties, multiclass +behavior, malformed controls, portable builds, and serial/concurrent batches. +CI runs the suite and benchmark smoke tests on Python 3.9, 3.12, and 3.13. ## Migration New integrations should import from `fastvisionops`. -| Previous project | Previous API | FastVisionOps API | -| --- | --- | --- | -| FastPreProcess | `fastpreprocess.hwc_to_chw_normalize` | `fastvisionops.hwc_to_chw_normalize` | -| FastPreProcess | `fastpreprocess.hwc_to_chw_normalize_batched` | `fastvisionops.hwc_to_chw_normalize_batched` | -| FastPreProcess | compiled fused functions | `fastvisionops.NativeBackend` methods | -| NMSs | `nmss.nms` and related imports | `fastvisionops.nms` and related imports | -| NMSs | `nmss.c_backend.CBackend` | `fastvisionops.NativeBackend` | - -The `nmss` package remains as a backward-compatible namespace and points to the -same maintained implementation. The old unsafe FastPreProcess binary is not -shipped; the replacement validates inputs, owns memory through NumPy, supports -noncontiguous arrays, and removes the unused OpenCV and pybind11 dependencies. - -## Repository layout - -```text -fastvisionops/ Primary package, native bindings, and C source -nmss/ Backward-compatible NMS namespace -tests/ Correctness and native-equivalence suite -benchmarks/ Reproducible elapsed-time benchmarks -docs/evaluation.md Methodology, evidence, and limitations -bbox-nms*/ mask-nms/ Legacy import adapters -.github/workflows/ Python-version CI matrix -``` +| Previous API | FastVisionOps API | +| --- | --- | +| `fastpreprocess.hwc_to_chw_normalize` | `fastvisionops.hwc_to_chw_normalize` | +| `fastpreprocess.hwc_to_chw_normalize_batched` | `fastvisionops.hwc_to_chw_normalize_batched` | +| FastPreProcess compiled functions | `fastvisionops.NativeBackend` methods | +| `nmss.nms` and related imports | `fastvisionops.nms` and related imports | +| `nmss.c_backend.CBackend` | `fastvisionops.NativeBackend` | -## Scope +The `nmss` namespace remains backward compatible and points to the maintained +implementation. The unsafe FastPreProcess binary is not shipped; its +replacement validates inputs, uses NumPy-owned memory, supports noncontiguous +arrays, and removes the unused OpenCV and pybind11 dependencies. -FastVisionOps targets deterministic CPU inference utilities. GPU kernels, +FastVisionOps currently targets deterministic CPU utilities. GPU kernels, resize/color conversion, Soft-NMS, DIoU-NMS, native mask kernels, and prebuilt -platform wheels are outside the current release. +wheels remain outside this release. diff --git a/docs/evaluation.md b/docs/evaluation.md index 491be85..acaf803 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -4,7 +4,7 @@ FastVisionOps unifies image preprocessing and non-maximum suppression behind one validated NumPy reference layer and one rebuildable C backend. The current -suite has 40 named tests, including randomized native equivalence, malformed +suite has 41 named tests, including randomized native equivalence, malformed input handling, noncontiguous images, empty batches, deterministic score ties, and concurrent NMS execution.