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 + +[![CI](https://github.com/Som5ra/FastVisionOps/actions/workflows/ci.yml/badge.svg)](https://github.com/Som5ra/FastVisionOps/actions/workflows/ci.yml) +[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)](https://www.python.org/) +[![Backend](https://img.shields.io/badge/backend-NumPy%20%2B%20C-4D77CF.svg)](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()