diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..09be547
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+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 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/.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/README.md b/README.md
index 94ecc4f..8e1ac69 100644
--- a/README.md
+++ b/README.md
@@ -1,74 +1,173 @@
-# NMS (Non-maximum Suppression) Tools
+# FastVisionOps
+
+[](https://github.com/Som5ra/FastVisionOps/actions/workflows/ci.yml)
+[](https://www.python.org/)
+[](https://numpy.org/)
+
+**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 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
+ A["uint8 HWC / NHWC"] --> B["Layout + normalize"]
+ B --> C["Model inference"]
+ C --> D["Boxes, scores, masks"]
+ D --> E["Deterministic NMS"]
+```
+## Install
-
- Bounding Box NMS
-- Refer to ./bbox-nms/nms.py
-
+```bash
+python -m pip install .
+python -m fastvisionops.build
+```
-
- Bounding Box NMS - C language version
+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.
-## Bounding Box NMS - C language version
-### Benchmark (Single Batch / s)
+## Quick start
-- Each Single-Batch-Data have 2000 bounding boxes
-- Each test run 1000 times to obtain results
+### Preprocess images
-- Speed(ms) : including: preprocessing, nms
-- W/O processing (ms): only including: nms
+```python
+import numpy as np
-| 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 |
+from fastvisionops import NativeBackend, hwc_to_chw_normalize
-### Usage: Refer to batch_parallel_nms.py
+image = np.zeros((427, 640, 3), dtype=np.uint8)
+mean = [123.675, 116.28, 103.53]
+std = [58.395, 57.12, 57.375]
-```Python
-num_classes = 80
-score_thr = 0.5
-nms_thr = 0.5
+# NumPy reference: float32 CHW, shape (3, 427, 640)
+tensor = hwc_to_chw_normalize(image, mean, std, flip_rb=True)
-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)), ...]
+# Native fused path
+backend = NativeBackend()
+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)
+```
-nms_c = Batch_Parallel_Nms()
+Single-image HWC and batched NHWC inputs are supported. Outputs are contiguous
+CHW or NCHW arrays. Noncontiguous inputs are handled correctly.
+
+### Suppress detections
+
+```python
+from fastvisionops import nms
+
+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])
+
+keep = nms(
+ boxes,
+ scores,
+ score_threshold=0.50,
+ iou_threshold=0.50,
+)
+# array([0, 2])
+```
-# 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)
+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.
-# BATCH PARALLEL
-indices_to_keep, nms_out_cls = nms_c.batch_parallel_nms(batched_bboxes, batched_scores, score_thr, nms_thr)
-```
+## Measured performance
+
+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.
+
+| 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. Reproduce the measurements:
-### 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
+python -m benchmarks.benchmark_preprocess
+python -m benchmarks.benchmark_bbox
```
-
-
- Mask NMS
+Add `--format json` for machine-readable output. The
+[evaluation report](docs/evaluation.md) documents the complete methodology,
+environment, results, and limitations.
-### Mutli class mask NMS (class-aware)
+## API
-- Class-unaware: a proposal can belong to mutiple single class
+| API | Purpose | Backend |
+| --- | --- | --- |
+| `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/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 |
-- 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]
+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
```
-pip install numba
-pip install numpy
-```
-
+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 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` |
+
+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 currently targets deterministic CPU utilities. GPU kernels,
+resize/color conversion, Soft-NMS, DIoU-NMS, native mask kernels, and prebuilt
+wheels remain outside this release.
diff --git a/bbox-nms-c-version/README.md b/bbox-nms-c-version/README.md
index 01c1e52..3d469c8 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 `fastvisionops/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 fastvisionops.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 `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/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/compile.md b/bbox-nms-c-version/compile.md
index d9870a1..bbff340 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 fastvisionops.build
```
+Use a different compiler or output path when required:
+
+```bash
+python -m fastvisionops.build --compiler clang --output /tmp/libfastvisionops.so
+```
+The builder compiles `fastvisionops/csrc/vision_ops.c` with optimized,
+reproducible flags and reports compiler errors directly.
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 ed1a73e..0000000
Binary files a/bbox-nms-c-version/compiled/batch_parallel_nms.so and /dev/null differ
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/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..678fad4
--- /dev/null
+++ b/benchmarks/benchmark_bbox.py
@@ -0,0 +1,228 @@
+"""Reproducible NumPy-versus-C bounding-box NMS benchmark."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import platform
+import statistics
+import time
+
+import numpy as np
+
+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):
+ 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_native_backend()
+ backend = NativeBackend()
+ 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 run_batch_benchmark(
+ batch_size: int,
+ boxes_per_image: int,
+ *,
+ seed: int,
+ warmup: int,
+ repeats: int,
+ workers: int,
+):
+ backend = NativeBackend()
+ 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 |",
+ "| ---: | ---: | ---: | ---: | ---: |",
+ ]
+ lines.extend(
+ "| {boxes} | {kept} | {python_ms:.3f} | "
+ "{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)
+
+
+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("--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"),
+ 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")
+ 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,
+ seed=arguments.seed,
+ 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(
+ {
+ "python": platform.python_version(),
+ "platform": platform.platform(),
+ "numpy": np.__version__,
+ "results": results,
+ "batch_result": batch_result,
+ },
+ indent=2,
+ )
+ )
+ else:
+ print(render_markdown(results, batch_result))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
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())
diff --git a/docs/evaluation.md b/docs/evaluation.md
new file mode 100644
index 0000000..acaf803
--- /dev/null
+++ b/docs/evaluation.md
@@ -0,0 +1,179 @@
+# FastVisionOps Evaluation Report
+
+## Executive summary
+
+FastVisionOps unifies image preprocessing and non-maximum suppression behind
+one validated NumPy reference layer and one rebuildable C backend. The current
+suite has 41 named tests, including randomized native equivalence, malformed
+input handling, noncontiguous images, empty batches, deterministic score ties,
+and concurrent NMS execution.
+
+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 | 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 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 randomized native NMS matrix combines four input sizes, two coordinate
+offsets, four score thresholds, and four IoU thresholds:
+
+$$4 \times 2 \times 4 \times 4 = 128$$
+
+## Benchmark methodology
+
+Both benchmark runners:
+
+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`.
+
+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
+
+| 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 |
+| 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
+
+Configuration:
+
+- 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.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 native (ms) | Parallel native (ms) | Speedup |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 8 | 1,000 | 8 | 27.595 | 9.995 | 2.76x |
+
+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 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
+```
+
+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
new file mode 100644
index 0000000..a6e30d6
--- /dev/null
+++ b/fastvisionops/__init__.py
@@ -0,0 +1,48 @@
+"""Fast, validated CPU operations for computer-vision inference."""
+
+from typing import TYPE_CHECKING
+
+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,
+)
+
+if TYPE_CHECKING:
+ from .native import NativeBackend
+
+__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",
+ "NativeBackend",
+ "nms",
+]
+
+__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/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/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/fastvisionops/csrc/vision_ops.c b/fastvisionops/csrc/vision_ops.c
new file mode 100644
index 0000000..59f9e71
--- /dev/null
+++ b/fastvisionops/csrc/vision_ops.c
@@ -0,0 +1,153 @@
+#include
+#include
+#include
+#include
+#include
+
+#ifdef _OPENMP
+#include
+#endif
+
+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 fvo_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;
+}
+
+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/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/native.py b/fastvisionops/native.py
new file mode 100644
index 0000000..e9baa26
--- /dev/null
+++ b/fastvisionops/native.py
@@ -0,0 +1,311 @@
+"""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_max_detections,
+ 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)
+ max_detections = validate_max_detections(max_detections)
+ 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)
+ max_detections = validate_max_detections(max_detections)
+
+ 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)
+ 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 (
+ 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 []
+
+ 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
+ or threads > np.iinfo(np.int32).max
+ ):
+ 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
+ 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/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/mask-nms/README.md b/mask-nms/README.md
index 478f716..2285cc4 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 fastvisionops 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.
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..d558a1d
--- /dev/null
+++ b/nmss/_validation.py
@@ -0,0 +1,90 @@
+"""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_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,):
+ 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..bcbabfd
--- /dev/null
+++ b/nmss/bbox.py
@@ -0,0 +1,249 @@
+"""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_max_detections,
+ 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)
+ max_detections = validate_max_detections(max_detections)
+
+ 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)
+ max_detections = validate_max_detections(max_detections)
+
+ 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)
+ 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 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/build.py b/nmss/build.py
new file mode 100644
index 0000000..081f206
--- /dev/null
+++ b/nmss/build.py
@@ -0,0 +1,26 @@
+"""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__":
+ raise SystemExit(main())
diff --git a/nmss/c_backend.py b/nmss/c_backend.py
new file mode 100644
index 0000000..a757715
--- /dev/null
+++ b/nmss/c_backend.py
@@ -0,0 +1,22 @@
+"""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,
+)
+
+__all__ = [
+ "CBackend",
+ "NativeBackend",
+ "batch_multiclass_nms",
+ "load_backend",
+ "multiclass_nms",
+ "nms",
+]
diff --git a/nmss/mask.py b/nmss/mask.py
new file mode 100644
index 0000000..c5e7dff
--- /dev/null
+++ b/nmss/mask.py
@@ -0,0 +1,150 @@
+"""Boolean-mask non-maximum suppression."""
+
+from __future__ import annotations
+
+import numpy as np
+from numpy.typing import ArrayLike, NDArray
+
+from ._validation import (
+ validate_max_detections,
+ 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:]}"
+ )
+ 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)
+ 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)
+ max_detections = validate_max_detections(max_detections)
+
+ 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)
+ max_detections = validate_max_detections(max_detections)
+
+ 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..763c810
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "fastvisionops"
+version = "1.0.0"
+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",
+ "Intended Audience :: Science/Research",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "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"]
+
+[tool.setuptools.packages.find]
+include = ["fastvisionops*", "nmss*"]
+
+[tool.setuptools.package-data]
+fastvisionops = ["csrc/*.c"]
+
+[tool.pytest.ini_options]
+addopts = "-ra --strict-markers"
+testpaths = ["tests"]
diff --git a/tests/test_bbox.py b/tests/test_bbox.py
new file mode 100644
index 0000000..df5a077
--- /dev/null
+++ b/tests/test_bbox.py
@@ -0,0 +1,165 @@
+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_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),
+ 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_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)),
+ 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..e942377
--- /dev/null
+++ b/tests/test_c_backend.py
@@ -0,0 +1,133 @@
+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_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_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")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_mask.py b/tests/test_mask.py
new file mode 100644
index 0000000..cd61b52
--- /dev/null
+++ b/tests/test_mask.py
@@ -0,0 +1,75 @@
+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_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])
+
+ 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_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)
+
+ 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()
diff --git a/tests/test_native_preprocess.py b/tests/test_native_preprocess.py
new file mode 100644
index 0000000..88c09ca
--- /dev/null
+++ b/tests/test_native_preprocess.py
@@ -0,0 +1,118 @@
+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)
+ 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"):
+ NativeBackend("/definitely/missing/libfastvisionops.so")
+
+
+if __name__ == "__main__":
+ unittest.main()
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()