diff --git a/.github/workflows/build-nanobind.yml b/.github/workflows/build-nanobind.yml deleted file mode 100644 index 478c017..0000000 --- a/.github/workflows/build-nanobind.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: Build Nanobind - -on: - push: - branches: [ main ] - tags: [ 'v*-nb' ] - pull_request: - branches: [ main ] - -jobs: - build_wheels_nanobind: - name: Build nanobind wheels on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - - steps: - - uses: actions/checkout@v4 - - - name: Use nanobind pyproject.toml - run: | - python -c "import shutil; shutil.copy('pyproject.nanobind.toml', 'pyproject.toml')" - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Build wheels - uses: pypa/cibuildwheel@v2.21.0 - env: - CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* - # Skip 32-bit, PyPy, and musl builds - CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* pp*" - # Use modern manylinux_2_28 - CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 - # Install nanobind build dependencies - CIBW_BEFORE_BUILD: pip install nanobind scikit-build-core cmake ninja numpy - # Linux: Install C++ compiler and CMake - CIBW_BEFORE_ALL_LINUX: | - if command -v dnf >/dev/null; then - dnf install -y gcc-c++ cmake ninja-build - else - yum install -y gcc-c++ cmake ninja-build - fi - # Windows: Use delvewheel for repair - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: pip install delvewheel && delvewheel repair -w {dest_dir} {wheel} - # Test that the nanobind backend is imported - CIBW_TEST_COMMAND: >- - python -c "import fastbbox; print('Backend:', fastbbox.__backend__); - from fastbbox import bbox_overlaps; import numpy as np; - boxes = np.array([[0,0,10,10]], dtype=np.float32); - result = bbox_overlaps(boxes, boxes); - assert result[0,0] > 0.99, 'IoU test failed'" - # nanobind requires macos 10.13 or higher - CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=10.13 - - - uses: actions/upload-artifact@v4 - with: - name: wheels-nanobind-${{ matrix.os }} - path: ./wheelhouse/*.whl - - build_sdist_nanobind: - name: Build nanobind source distribution - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - name: Use nanobind pyproject.toml - run: cp pyproject.nanobind.toml pyproject.toml - - - name: Build sdist - run: pipx run build --sdist - - - uses: actions/upload-artifact@v4 - with: - name: sdist-nanobind - path: dist/*.tar.gz - - upload_testpypi_nanobind: - needs: [build_wheels_nanobind, build_sdist_nanobind] - runs-on: ubuntu-latest - timeout-minutes: 15 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - environment: - name: testpypi - url: https://test.pypi.org/p/fastbbox-nanobind - permissions: - id-token: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: dist - merge-multiple: true - - - name: Publish to TestPyPI - run: | - pip install twine - twine upload --repository testpypi dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} - - upload_pypi_nanobind: - needs: [build_wheels_nanobind, build_sdist_nanobind] - runs-on: ubuntu-latest - timeout-minutes: 15 - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - environment: - name: pypi - url: https://pypi.org/p/fastbbox-nanobind - permissions: - id-token: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: dist - merge-multiple: true - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d05948..b76dd4a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build Cython (Stable) +name: Build and Publish on: push: @@ -8,76 +8,83 @@ on: branches: [ main ] jobs: - build_wheels_cython: - name: Build Cython wheels on ${{ matrix.os }} + build_wheels: + name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 30 strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Use Cython pyproject.toml - run: | - cp pyproject.cython.toml pyproject.toml - shell: bash + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' - - name: Build Cython wheels + - name: Build wheels uses: pypa/cibuildwheel@v2.21.0 env: CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* # Skip 32-bit, PyPy, and musl builds CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* pp*" - # Use modern manylinux_2_28 (default in v3.1+) + # Use modern manylinux_2_28 CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 # Install build dependencies - CIBW_BEFORE_BUILD: pip install numpy cython - # Install compiler (fixed command) - CIBW_BEFORE_ALL_LINUX: yum install -y gcc-c++ - # Windows-specific: Disable wheel repair for now - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "" - # Simple test command - CIBW_TEST_COMMAND: python -c "import fastbbox; print('Import successful')" + CIBW_BEFORE_BUILD: pip install nanobind scikit-build-core cmake ninja numpy + # Linux: Install C++ compiler and CMake + CIBW_BEFORE_ALL_LINUX: | + if command -v dnf >/dev/null; then + dnf install -y gcc-c++ cmake ninja-build + else + yum install -y gcc-c++ cmake ninja-build + fi + # Windows: Use delvewheel for repair + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: pip install delvewheel && delvewheel repair -w {dest_dir} {wheel} + # Smoke test + CIBW_TEST_COMMAND: >- + python -c "from fastbbox import bbox_overlaps; import numpy as np; + boxes = np.array([[0,0,10,10]], dtype=np.float32); + result = bbox_overlaps(boxes, boxes); + assert result[0,0] > 0.99, 'IoU test failed'; + print('fastbbox import OK')" + # nanobind requires macos 10.13 or higher + CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=10.13 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: wheels-${{ matrix.os }} path: ./wheelhouse/*.whl - build_sdist_cython: - name: Build Cython source distribution + build_sdist: + name: Build source distribution runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Use Cython pyproject.toml - run: cp pyproject.cython.toml pyproject.toml - - - name: Build Cython sdist + - name: Build sdist run: pipx run build --sdist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: sdist path: dist/*.tar.gz - upload_testpypi_cython: - needs: [build_wheels_cython, build_sdist_cython] + upload_testpypi: + needs: [build_wheels, build_sdist] runs-on: ubuntu-latest timeout-minutes: 15 if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: name: testpypi url: https://test.pypi.org/p/fastbbox - permissions: - id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: dist merge-multiple: true @@ -90,8 +97,8 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} - upload_pypi_cython: - needs: [build_wheels_cython, build_sdist_cython] + upload_pypi: + needs: [build_wheels, build_sdist] runs-on: ubuntu-latest timeout-minutes: 15 if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') @@ -102,10 +109,10 @@ jobs: id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: dist merge-multiple: true - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index f0f0243..4cf9a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -129,12 +129,12 @@ dmypy.json # Pyre type checker .pyre/ -# Cython/nanobind generated files (exclude auto-generated C/C++) +# Exclude auto-generated C/C++ files *.c *.cpp *.html -# Except nanobind C++ sources which are intentional committed sources -!fastbbox/*_nb.cpp +# Track nanobind C++ sources (intentional committed sources, not generated) +!src/fastbbox/*_nb.cpp # IDE files .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c94c81..9d633c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,10 +25,10 @@ find_package(nanobind CONFIG REQUIRED) # Build bbox module # Use NOMINSIZE since our binding layer contains significant computational logic # that benefits from speed optimization rather than size optimization -nanobind_add_module(bbox NOMINSIZE ${CMAKE_CURRENT_SOURCE_DIR}/fastbbox/bbox_nb.cpp) +nanobind_add_module(bbox NOMINSIZE ${CMAKE_CURRENT_SOURCE_DIR}/src/fastbbox/bbox_nb.cpp) # Build obb_bbox module -nanobind_add_module(obb_bbox NOMINSIZE ${CMAKE_CURRENT_SOURCE_DIR}/fastbbox/obb_bbox_nb.cpp) +nanobind_add_module(obb_bbox NOMINSIZE ${CMAKE_CURRENT_SOURCE_DIR}/src/fastbbox/obb_bbox_nb.cpp) # Install the modules install(TARGETS bbox obb_bbox LIBRARY DESTINATION fastbbox) diff --git a/MANIFEST.in b/MANIFEST.in index ddbb661..92c5015 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,15 +1,6 @@ include README.md include LICENSE -include BUILD.md -include pyproject.toml -include pyproject.cython.toml -include pyproject.nanobind.toml include CMakeLists.txt -recursive-include fastbbox *.pyx *.pxd *.pxi -recursive-include fastbbox *_nb.cpp +recursive-include src/fastbbox *_nb.cpp global-exclude *.pyc global-exclude *.so -global-exclude *.c -# Exclude generated C++ from Cython, but keep *_nb.cpp for nanobind -global-exclude bbox.cpp obb_bbox.cpp - diff --git a/README.md b/README.md index 651d06c..a4f99f8 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,48 @@ # FastBBox -Fast IoU/overlap computations for axis-aligned and oriented bounding boxes. +Fast IoU/overlap computations for axis-aligned and oriented bounding boxes, powered by nanobind C++ extensions. -## Installation - -FastBBox is available in two implementations with **identical APIs** - you can use either version with the same import statements and function calls. - -### Quick Install (Stable Cython Version - Recommended) - -```bash -pip install fastbbox +## Benchmark +``` +Function Python (ms) FastBBox (ms) Speedup +-------------------------------------------------- +IoU 2037.59 4.29 474.9x +GIoU 3712.32 7.98 465.2x +DIoU 6751.35 8.97 752.3x +CIoU 12025.54 36.65 328.1x +EIoU 8753.35 10.56 829.3x +NWD 4386.10 21.59 203.1x +OBB 55237.81 60.39 914.7x +-------------------------------------------------- +TOTAL 92904.06 150.43 617.6x ``` -| Feature | Details | -|---------|---------| -| **Python support** | 3.8, 3.9, 3.10, 3.11, 3.12 | -| **Package name** | `fastbbox` | -| **Backend** | Cython | -| **Status** | Stable, production-ready | -| **Binary size** | Standard | -| **Best for** | Production environments | - -### Alternative (Experimental nanobind Version) +## Installation ```bash -pip install fastbbox-nanobind +pip install fastbbox ``` -| Feature | Details | -|---------|---------| -| **Python support** | 3.9, 3.10, 3.11, 3.12 | -| **Package name** | `fastbbox-nanobind` | -| **Backend** | nanobind | -| **Status** | Experimental | -| **Binary size** | Smaller (~30-40% reduction) | -| **Best for** | Testing, size-constrained environments, newer Python versions | - -### Which Version Should I Use? - -- **Use Cython version** (`fastbbox`) if: - - You need Python 3.8 support - - You want the most stable, tested version - - You're deploying to production - -- **Use nanobind version** (`fastbbox-nanobind`) if: - - You're using Python 3.9+ - - You need smaller binary sizes (good for Docker images, Lambda functions) - - You want to test the latest binding technology - -**Both versions have identical functionality and APIs!** The code examples work the same for both. - ### Building from Source -The repository contains two separate config files: -- `pyproject.cython.toml` - Cython build configuration -- `pyproject.nanobind.toml` - nanobind build configuration -- `pyproject.toml` - Default (symlinked to Cython by default) +Requires CMake 3.15+ and a C++17 compiler. -**Cython version (stable)**: ```bash git clone https://github.com/gerrylwk/fastbbox cd fastbbox pip install . ``` -**Nanobind version (experimental)**: -```bash -git clone https://github.com/gerrylwk/fastbbox -cd fastbbox - -# Use nanobind config -cp pyproject.nanobind.toml pyproject.toml -pip install . - -# Optional: restore Cython config as default -cp pyproject.cython.toml pyproject.toml -``` - -**Note**: Building nanobind requires CMake 3.15+ installed on your system. See [BUILD.md](BUILD.md) for detailed instructions and troubleshooting. - -### Checking Your Backend - -Both versions install as `fastbbox` module. To check which backend you're using: - -```python -import fastbbox -print(f"Backend: {fastbbox.__backend__}") # Output: 'cython' or 'nanobind' -print(f"Version: {fastbbox.__version__}") -``` - ## Usage -**Note**: The following code works identically with both `fastbbox` (Cython) and `fastbbox-nanobind` (nanobind) versions. Simply install either package and use the same imports! - ### Axis-Aligned Bounding Boxes ```python import numpy as np -from fastbbox import (bbox_overlaps, generalized_iou, distance_iou, +from fastbbox import (bbox_overlaps, generalized_iou, distance_iou, complete_iou, efficient_iou, normalized_wasserstein_distance) -# Check which backend you're using (optional) -import fastbbox -print(f"Using backend: {fastbbox.__backend__}") - -# Create some example bounding boxes [x1, y1, x2, y2] +# Bounding boxes in [x1, y1, x2, y2] format boxes = np.array([ [0, 0, 10, 10], [5, 5, 15, 15], @@ -116,20 +54,12 @@ query_boxes = np.array([ [12, 12, 22, 22] ], dtype=np.float32) -# Compute all IoU variants and distance metrics iou = bbox_overlaps(boxes, query_boxes) giou = generalized_iou(boxes, query_boxes) diou = distance_iou(boxes, query_boxes) ciou = complete_iou(boxes, query_boxes) eiou = efficient_iou(boxes, query_boxes) nwd = normalized_wasserstein_distance(boxes, query_boxes) - -print("IoU: ", iou) -print("GIoU:", giou) -print("DIoU:", diou) -print("CIoU:", ciou) -print("EIoU:", eiou) -print("NWD: ", nwd) ``` ### Oriented Bounding Boxes (OBB) @@ -139,233 +69,117 @@ import numpy as np import math from fastbbox import bbox_overlaps_obb -# Create oriented bounding boxes [center_x, center_y, width, height, angle_radians] +# Oriented bounding boxes in [center_x, center_y, width, height, angle_radians] format obb_boxes = np.array([ - [0, 0, 4, 2, 0], # Axis-aligned box - [1, 0, 4, 2, math.pi/4], # 45° rotated box - [0, 0, 2, 2, math.pi/6], # 30° rotated box + [0, 0, 4, 2, 0], + [1, 0, 4, 2, math.pi/4], + [0, 0, 2, 2, math.pi/6], ], dtype=np.float32) obb_query_boxes = np.array([ - [0, 0, 4, 2, 0], # Same as first box - [2, 2, 2, 2, 0], # Offset box + [0, 0, 4, 2, 0], + [2, 2, 2, 2, 0], ], dtype=np.float32) -# Calculate OBB IoU matrix obb_iou = bbox_overlaps_obb(obb_boxes, obb_query_boxes) -print("OBB IoU:", obb_iou) - -# Helper function for degrees -def create_obb(cx, cy, width, height, angle_degrees=0): - """Create OBB with angle in degrees.""" - angle_radians = math.radians(angle_degrees) - return np.array([cx, cy, width, height, angle_radians], dtype=np.float32) - -# Usage with degrees -box = create_obb(0, 0, 4, 2, 45) # 45 degree rotation ``` ## Features ### Axis-Aligned Bounding Box IoU Variants -All variants support standard axis-aligned bounding boxes in `[x1, y1, x2, y2]` format: +All variants accept boxes in `[x1, y1, x2, y2]` top-left-bottom-right format: -- **Standard IoU**: Classic Intersection over Union calculation -- **Generalized IoU (GIoU)**: Addresses limitations of IoU by considering enclosing area - - Provides meaningful gradients even when boxes don't overlap - - Values in range [-1, 1] where -1 indicates boxes are far apart +- **Standard IoU**: Classic Intersection over Union +- **Generalized IoU (GIoU)**: Considers enclosing area; values in `[-1, 1]` - Reference: [Generalized Intersection over Union](https://arxiv.org/abs/1902.09630) - -- **Distance IoU (DIoU)**: Considers center point distance between boxes - - Penalizes boxes with far-apart centers even if they overlap - - Formula: `DIoU = IoU - ρ²(b, b_gt) / c²` - - Better for object detection training than GIoU +- **Distance IoU (DIoU)**: Penalizes center point distance - Reference: [Distance-IoU Loss](https://arxiv.org/abs/1911.08287) - -- **Complete IoU (CIoU)**: Most comprehensive metric considering overlap, center distance, and aspect ratio - - Adds aspect ratio consistency penalty to DIoU - - Formula: `CIoU = DIoU - α * v` (where v measures aspect ratio consistency) - - Best for bounding box regression in object detection +- **Complete IoU (CIoU)**: Adds aspect ratio consistency penalty to DIoU - Reference: [Distance-IoU Loss](https://arxiv.org/abs/1911.08287) - -- **Efficient IoU (EIoU)**: Separates width and height penalties for more efficient training - - Formula: `EIoU = IoU - ρ²(b, b_gt) / c² - ρ²(w, w_gt) / c_w² - ρ²(h, h_gt) / c_h²` - - Directly penalizes width and height differences separately - - Faster convergence than CIoU in many cases +- **Efficient IoU (EIoU)**: Separate width/height penalties for faster convergence - Reference: [Focal and Efficient IOU Loss](https://arxiv.org/abs/2101.08158) - -- **Normalized Wasserstein Distance (NWD)**: Similarity metric based on optimal transport theory - - Treats bounding boxes as 2D Gaussian distributions with Σ = diag(w²/4, h²/4) - - Formula: `NWD = exp(-√(W₂²) / τ)` where W₂² is Wasserstein-2 distance squared - - Especially effective for tiny object detection - - Returns values in [0, 1] where 1 = identical boxes, 0 = very different +- **Normalized Wasserstein Distance (NWD)**: Optimal transport-based similarity, values in `[0, 1]` + - Effective for tiny object detection - Reference: [Normalized Gaussian Wasserstein Distance](https://arxiv.org/abs/2110.13389) ### Oriented Bounding Box (OBB) IoU -For rotated/oriented bounding boxes in `[center_x, center_y, width, height, angle_radians]` format: +Accepts boxes in `[center_x, center_y, width, height, angle_radians]` format: -- **OBB IoU**: Intersection over Union for oriented bounding boxes - - **Exact calculation** for axis-aligned bounding boxes (angle = 0) - - **Approximation-based calculation** for rotated bounding boxes - - Uses AABB intersection with angle-based scaling factor - - Scaling factor: `cos(|angle1|) * cos(|angle2|)` with minimum 50% retention - - **Batch processing** support for multiple OBBs - - Particularly useful for text detection, aerial imagery, and rotated object detection - - **Performance**: ~310x speedup over pure Python implementation +- Exact calculation for axis-aligned boxes (angle = 0) +- Approximation-based calculation for rotated boxes using AABB intersection with angle-based scaling +- Batch processing support ## Performance -Both Cython and nanobind implementations provide significant speedup over pure Python implementations, especially for large numbers of bounding boxes. - -### Performance Characteristics - -| Aspect | Cython Version | nanobind Version | -|--------|---------------|------------------| -| **Computation speed** | Fast (optimized C++) | Fast (optimized C++) | -| **Import time** | Standard | Potentially faster | -| **Binary size** | Larger | Smaller (30-40% reduction) | -| **Memory usage** | Similar | Similar | +FastBBox provides significant speedup over pure Python implementations, especially for large numbers of bounding boxes. Run `python benchmark_fastbbox.py` to benchmark on your system. ### When to Use Each Variant -- **Use IoU** for: Standard evaluation metrics, NMS (Non-Maximum Suppression), simple overlap measurement -- **Use GIoU** for: Training loss when you want to consider spatial relationships of non-overlapping boxes -- **Use DIoU** for: Object detection training where center point distance matters, anchor-free detectors -- **Use CIoU** for: Advanced bounding box regression, when aspect ratio consistency is important -- **Use EIoU** for: Fast training convergence, when you need separate width/height penalties -- **Use NWD** for: Tiny object detection, when traditional IoU variants are insufficient -- **Use OBB IoU** for: Rotated object detection, text detection, aerial imagery analysis, scene text recognition - -### Metric Relationships - -For any pair of bounding boxes, the following relationships generally hold: -``` -Axis-aligned IoU metrics: EIoU ≤ CIoU ≤ DIoU ≤ IoU (for overlapping boxes) - GIoU ≤ IoU - -IoU variants ∈ [-1, 1] where 1 = perfect match, 0 = no overlap, negative = penalty for separation - -NWD ∈ [0, 1] where 1 = identical boxes, 0 = very different (similarity measure) - -OBB IoU ∈ [0, 1] where 1 = perfect match, 0 = no overlap (exact for axis-aligned, approximation for rotated) -``` +- **IoU**: Standard evaluation, NMS (Non-Maximum Suppression) +- **GIoU**: Training loss for non-overlapping boxes +- **DIoU**: Object detection training where center distance matters +- **CIoU**: Bounding box regression with aspect ratio consistency +- **EIoU**: Fast convergence with separate width/height penalties +- **NWD**: Tiny object detection +- **OBB IoU**: Rotated object detection, text detection, aerial imagery ## OBB Implementation Details -### Algorithm Strategy - The OBB IoU implementation uses different strategies based on box orientations: -1. **Axis-Aligned Boxes** (angle = 0): Exact intersection calculation using rectangle overlap formulas -2. **Rotated Boxes**: Approximation using axis-aligned bounding box (AABB) intersection with angle-based scaling - -### Approximation Method - -For rotated boxes, the algorithm: -1. Converts OBBs to corner coordinates -2. Finds axis-aligned bounding boxes of both OBBs -3. Calculates AABB intersection -4. Applies scaling factor: `cos(|angle1|) * cos(|angle2|)` -5. Ensures minimum 50% of AABB intersection is retained - -### Performance Characteristics - -- ✅ **Exact results** for axis-aligned boxes (angle = 0) -- ✅ **Good approximations** for small rotation angles -- ✅ **Reasonable approximations** for larger rotations -- ✅ **Fast computation** due to Cython implementation (~310x speedup) +1. **Axis-Aligned Boxes** (angle = 0): Exact intersection calculation +2. **Rotated Boxes**: AABB intersection with scaling factor `cos(|angle1|) * cos(|angle2|)`, minimum 50% retention -### Limitations - -1. **Approximation for rotated boxes**: Not exact for arbitrary rotations -2. **Scaling factor heuristic**: The angle-based scaling is a heuristic approximation -3. **No advanced polygon intersection**: Uses AABB approximation rather than exact polygon clipping - -For applications requiring exact rotated box IoU, consider implementing: -- Exact polygon intersection (Sutherland-Hodgman clipping) -- Separating Axes Theorem (SAT) for precise overlap detection +For exact rotated box IoU, consider implementing Sutherland-Hodgman clipping or the Separating Axes Theorem (SAT). ## Development ### Building Locally -**Cython version**: ```bash -pip install build -python -m build -``` - -**nanobind version**: -```bash -# Swap to nanobind config first (see "Building from Source" above) -pip install build scikit-build-core cmake +pip install build scikit-build-core cmake nanobind python -m build ``` ### Testing Your Build -After installing either version: - ```bash -# Check which backend is active -python -c "import fastbbox; print(f'Backend: {fastbbox.__backend__}')" - -# Run correctness tests (compares fastbbox against Python reference implementations) +# Correctness tests (compares fastbbox against Python reference implementations) python test_fastbbox.py # Summary output python test_fastbbox.py --verbose # Detailed output with values python test_fastbbox.py -f iou giou # Test specific functions -# Run performance benchmarks (measures speedup vs Python) -python benchmark_fastbbox.py # Summary output +# Performance benchmarks +python benchmark_fastbbox.py # Summary output python benchmark_fastbbox.py --verbose # Detailed timing per run python benchmark_fastbbox.py --size 1000 # Test with 1000 boxes -python benchmark_fastbbox.py --runs 10 # Run 10 iterations for accuracy +python benchmark_fastbbox.py --runs 10 # 10 iterations ``` #### Test File Options **test_fastbbox.py** - Correctness validation: -- `--verbose, -v`: Show detailed test output with expected/actual values +- `--verbose, -v`: Show detailed output with expected/actual values - `--function, -f`: Test specific functions (iou, giou, diou, ciou, eiou, nwd, obb) -- `--tolerance, -t`: Custom tolerance threshold (default: 1e-5) +- `--tolerance, -t`: Tolerance threshold (default: 1e-5) +- `--obb-tolerance`: OBB-specific tolerance (default: 1e-3) - `--size, -s`: Number of test boxes (default: 100) **benchmark_fastbbox.py** - Performance benchmarks: - `--verbose, -v`: Show individual run times and statistics - `--function, -f`: Benchmark specific functions -- `--size, -s`: Number of boxes to test (default: 500) -- `--runs, -r`: Number of benchmark iterations (default: 5) - -### Comparing Both Versions - -To compare Cython vs nanobind performance: - -```bash -# Test Cython -pip install fastbbox -python -c "import fastbbox; print(f'Testing {fastbbox.__backend__}')" -python benchmark_fastbbox.py > benchmark_cython.txt - -# Test nanobind -pip uninstall -y fastbbox -pip install fastbbox-nanobind -python -c "import fastbbox; print(f'Testing {fastbbox.__backend__}')" -python benchmark_fastbbox.py > benchmark_nanobind.txt - -# Compare results -# (Both should show similar computation speeds, but different import times and binary sizes) -``` +- `--size, -s`: Number of boxes (default: 500) +- `--runs, -r`: Number of iterations (default: 5) ## Requirements -- Python 3.8+ +- Python 3.9+ - NumPy >= 1.19.0 ## License MIT License - see LICENSE file for details. - diff --git a/benchmark_fastbbox.py b/benchmark_fastbbox.py index 839bf6f..74d1e2f 100644 --- a/benchmark_fastbbox.py +++ b/benchmark_fastbbox.py @@ -349,7 +349,7 @@ def main(): parser.add_argument("--function", "-f", nargs="+", choices=["iou", "giou", "diou", "ciou", "eiou", "nwd", "obb"], help="Benchmark specific function(s)") - parser.add_argument("--size", "-s", type=int, default=10000, + parser.add_argument("--size", "-s", type=int, default=1000, help="Number of boxes to test (default: 500)") parser.add_argument("--runs", "-r", type=int, default=1, help="Number of benchmark runs (default: 1)") @@ -364,9 +364,7 @@ def main(): from fastbbox import (bbox_overlaps, generalized_iou, distance_iou, complete_iou, efficient_iou, normalized_wasserstein_distance, bbox_overlaps_obb) - import fastbbox - backend = getattr(fastbbox, '__backend__', 'unknown') - print(f"Backend: {backend}") + print(f"Backend: Nanobind") except ImportError as e: print(f"ERROR: Could not import fastbbox: {e}") return 1 diff --git a/fastbbox/__init__.py b/fastbbox/__init__.py deleted file mode 100644 index eff0df3..0000000 --- a/fastbbox/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from .bbox import bbox_overlaps, generalized_iou, distance_iou, complete_iou, efficient_iou, normalized_wasserstein_distance -from .obb_bbox import bbox_overlaps_obb - -# Detect which backend is being used -try: - from . import bbox as _bbox_module - # Check the module's docstring which identifies the backend - if _bbox_module.__doc__ and 'nanobind' in _bbox_module.__doc__: - _backend = 'nanobind' - elif _bbox_module.__doc__ and ('cython' in _bbox_module.__doc__.lower() or 'pyx' in str(_bbox_module.__file__)): - _backend = 'cython' - else: - # Fallback: check file extension and patterns - if hasattr(_bbox_module, '__file__') and _bbox_module.__file__: - file_str = str(_bbox_module.__file__) - if '_nb' in file_str or 'nanobind' in file_str: - _backend = 'nanobind' - else: - _backend = 'cython' - else: - _backend = 'unknown' -except Exception: - _backend = 'unknown' - -__version__ = "0.1.0" -__backend__ = _backend -__all__ = [ - "bbox_overlaps", "generalized_iou", "distance_iou", "complete_iou", - "efficient_iou", "normalized_wasserstein_distance", - "bbox_overlaps_obb", - "__backend__" -] - - diff --git a/fastbbox/bbox.pyx b/fastbbox/bbox.pyx deleted file mode 100644 index 5bc16c6..0000000 --- a/fastbbox/bbox.pyx +++ /dev/null @@ -1,527 +0,0 @@ -# distutils: language = c++ -# cython: boundscheck=False, wraparound=False, nonecheck=False, cdivision=True, initializedcheck=False - -import numpy as np -cimport numpy as np -from libc.math cimport atan2, sqrt, exp - -# Define type -ctypedef np.float32_t DTYPE_t - -def bbox_overlaps(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute IoU overlaps between two sets of boxes. - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - - Returns - ------- - overlaps: (N, K) float32 array of IoU - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] overlaps = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t iw, ih, inter, ua - cdef DTYPE_t box_area, query_area - - for k in range(K): - query_area = (query_boxes[k, 2] - query_boxes[k, 0]) * \ - (query_boxes[k, 3] - query_boxes[k, 1]) - - for n in range(N): - iw = min(boxes[n, 2], query_boxes[k, 2]) - \ - max(boxes[n, 0], query_boxes[k, 0]) - if iw > 0: - ih = min(boxes[n, 3], query_boxes[k, 3]) - \ - max(boxes[n, 1], query_boxes[k, 1]) - if ih > 0: - inter = iw * ih - box_area = (boxes[n, 2] - boxes[n, 0]) * \ - (boxes[n, 3] - boxes[n, 1]) - ua = box_area + query_area - inter - overlaps[n, k] = inter / ua - return overlaps - - -def generalized_iou(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute Generalized IoU (GIoU) between two sets of boxes. - - GIoU = IoU - |C - (A ∪ B)| / |C| - where C is the smallest enclosing box that covers both A and B. - - Paper: "Generalized Intersection over Union: A Metric and A Loss for Bounding Box Regression" - https://arxiv.org/abs/1902.09630 - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - - Returns - ------- - giou: (N, K) float32 array of Generalized IoU values - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] giou_matrix = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t iw, ih, inter, ua, iou - cdef DTYPE_t box_area, query_area - cdef DTYPE_t enclosing_x1, enclosing_y1, enclosing_x2, enclosing_y2 - cdef DTYPE_t enclosing_area, coverage_ratio, giou_val - - for k in range(K): - query_area = (query_boxes[k, 2] - query_boxes[k, 0]) * \ - (query_boxes[k, 3] - query_boxes[k, 1]) - - for n in range(N): - # Calculate intersection - iw = min(boxes[n, 2], query_boxes[k, 2]) - \ - max(boxes[n, 0], query_boxes[k, 0]) - if iw > 0: - ih = min(boxes[n, 3], query_boxes[k, 3]) - \ - max(boxes[n, 1], query_boxes[k, 1]) - if ih > 0: - inter = iw * ih - else: - inter = 0 - else: - inter = 0 - - # Calculate union and IoU - box_area = (boxes[n, 2] - boxes[n, 0]) * \ - (boxes[n, 3] - boxes[n, 1]) - ua = box_area + query_area - inter - - if ua > 0: - iou = inter / ua - else: - iou = 0 - - # Calculate smallest enclosing box - enclosing_x1 = min(boxes[n, 0], query_boxes[k, 0]) - enclosing_y1 = min(boxes[n, 1], query_boxes[k, 1]) - enclosing_x2 = max(boxes[n, 2], query_boxes[k, 2]) - enclosing_y2 = max(boxes[n, 3], query_boxes[k, 3]) - - enclosing_area = (enclosing_x2 - enclosing_x1) * \ - (enclosing_y2 - enclosing_y1) - - # Calculate GIoU - if enclosing_area > 0: - coverage_ratio = (enclosing_area - ua) / enclosing_area - giou_val = iou - coverage_ratio - else: - giou_val = iou - - giou_matrix[n, k] = giou_val - - return giou_matrix - - -def distance_iou(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute Distance IoU (DIoU) between two sets of boxes. - - DIoU = IoU - ρ²(b, b_gt) / c² - where: - - ρ²(b, b_gt) is the squared Euclidean distance between box centers - - c² is the squared diagonal length of the smallest enclosing box - - Paper: "Distance-IoU Loss: Faster and Better Learning for Bounding Box Regression" - https://arxiv.org/abs/1911.08287 - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - - Returns - ------- - diou: (N, K) float32 array of Distance IoU values - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] diou_matrix = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t iw, ih, inter, ua, iou - cdef DTYPE_t box_area, query_area - cdef DTYPE_t box_cx, box_cy, query_cx, query_cy - cdef DTYPE_t center_distance_sq, diagonal_sq - cdef DTYPE_t enclosing_x1, enclosing_y1, enclosing_x2, enclosing_y2 - cdef DTYPE_t diou_val - - for k in range(K): - query_area = (query_boxes[k, 2] - query_boxes[k, 0]) * \ - (query_boxes[k, 3] - query_boxes[k, 1]) - query_cx = (query_boxes[k, 0] + query_boxes[k, 2]) / 2.0 - query_cy = (query_boxes[k, 1] + query_boxes[k, 3]) / 2.0 - - for n in range(N): - # Calculate intersection - iw = min(boxes[n, 2], query_boxes[k, 2]) - \ - max(boxes[n, 0], query_boxes[k, 0]) - if iw > 0: - ih = min(boxes[n, 3], query_boxes[k, 3]) - \ - max(boxes[n, 1], query_boxes[k, 1]) - if ih > 0: - inter = iw * ih - else: - inter = 0 - else: - inter = 0 - - # Calculate union and IoU - box_area = (boxes[n, 2] - boxes[n, 0]) * \ - (boxes[n, 3] - boxes[n, 1]) - ua = box_area + query_area - inter - - if ua > 0: - iou = inter / ua - else: - iou = 0 - - # Calculate box centers - box_cx = (boxes[n, 0] + boxes[n, 2]) / 2.0 - box_cy = (boxes[n, 1] + boxes[n, 3]) / 2.0 - - # Calculate center distance squared - center_distance_sq = (box_cx - query_cx) * (box_cx - query_cx) + \ - (box_cy - query_cy) * (box_cy - query_cy) - - # Calculate smallest enclosing box diagonal squared - enclosing_x1 = min(boxes[n, 0], query_boxes[k, 0]) - enclosing_y1 = min(boxes[n, 1], query_boxes[k, 1]) - enclosing_x2 = max(boxes[n, 2], query_boxes[k, 2]) - enclosing_y2 = max(boxes[n, 3], query_boxes[k, 3]) - - diagonal_sq = (enclosing_x2 - enclosing_x1) * (enclosing_x2 - enclosing_x1) + \ - (enclosing_y2 - enclosing_y1) * (enclosing_y2 - enclosing_y1) - - # Calculate DIoU - if diagonal_sq > 0: - diou_val = iou - center_distance_sq / diagonal_sq - else: - diou_val = iou - - diou_matrix[n, k] = diou_val - - return diou_matrix - - -def complete_iou(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute Complete IoU (CIoU) between two sets of boxes. - - CIoU = IoU - ρ²(b, b_gt) / c² - α * v - where: - - ρ²(b, b_gt) is the squared Euclidean distance between box centers - - c² is the squared diagonal length of the smallest enclosing box - - v measures the consistency of aspect ratio - - α is a positive trade-off parameter - - Paper: "Enhancing Geometric Factors in Model Learning and Inference for Object Detection and Instance Segmentation" - https://arxiv.org/abs/1911.08287 - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - - Returns - ------- - ciou: (N, K) float32 array of Complete IoU values - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] ciou_matrix = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t iw, ih, inter, ua, iou - cdef DTYPE_t box_area, query_area - cdef DTYPE_t box_cx, box_cy, query_cx, query_cy - cdef DTYPE_t box_w, box_h, query_w, query_h - cdef DTYPE_t center_distance_sq, diagonal_sq - cdef DTYPE_t enclosing_x1, enclosing_y1, enclosing_x2, enclosing_y2 - cdef DTYPE_t v, alpha, ciou_val - cdef DTYPE_t pi = 3.14159265359 - cdef DTYPE_t atan_diff, aspect_ratio_penalty - - for k in range(K): - query_area = (query_boxes[k, 2] - query_boxes[k, 0]) * \ - (query_boxes[k, 3] - query_boxes[k, 1]) - query_cx = (query_boxes[k, 0] + query_boxes[k, 2]) / 2.0 - query_cy = (query_boxes[k, 1] + query_boxes[k, 3]) / 2.0 - query_w = query_boxes[k, 2] - query_boxes[k, 0] - query_h = query_boxes[k, 3] - query_boxes[k, 1] - - for n in range(N): - # Calculate intersection - iw = min(boxes[n, 2], query_boxes[k, 2]) - \ - max(boxes[n, 0], query_boxes[k, 0]) - if iw > 0: - ih = min(boxes[n, 3], query_boxes[k, 3]) - \ - max(boxes[n, 1], query_boxes[k, 1]) - if ih > 0: - inter = iw * ih - else: - inter = 0 - else: - inter = 0 - - # Calculate union and IoU - box_area = (boxes[n, 2] - boxes[n, 0]) * \ - (boxes[n, 3] - boxes[n, 1]) - ua = box_area + query_area - inter - - if ua > 0: - iou = inter / ua - else: - iou = 0 - - # Calculate box centers and dimensions - box_cx = (boxes[n, 0] + boxes[n, 2]) / 2.0 - box_cy = (boxes[n, 1] + boxes[n, 3]) / 2.0 - box_w = boxes[n, 2] - boxes[n, 0] - box_h = boxes[n, 3] - boxes[n, 1] - - # Calculate center distance squared - center_distance_sq = (box_cx - query_cx) * (box_cx - query_cx) + \ - (box_cy - query_cy) * (box_cy - query_cy) - - # Calculate smallest enclosing box diagonal squared - enclosing_x1 = min(boxes[n, 0], query_boxes[k, 0]) - enclosing_y1 = min(boxes[n, 1], query_boxes[k, 1]) - enclosing_x2 = max(boxes[n, 2], query_boxes[k, 2]) - enclosing_y2 = max(boxes[n, 3], query_boxes[k, 3]) - - diagonal_sq = (enclosing_x2 - enclosing_x1) * (enclosing_x2 - enclosing_x1) + \ - (enclosing_y2 - enclosing_y1) * (enclosing_y2 - enclosing_y1) - - # Calculate aspect ratio consistency v - if query_w > 0 and query_h > 0 and box_w > 0 and box_h > 0: - # Use atan2 for more stable computation - atan_diff = atan2(query_w, query_h) - atan2(box_w, box_h) - v = (4.0 / (pi * pi)) * atan_diff * atan_diff - else: - v = 0 - - # Calculate alpha parameter - if iou > 0: - alpha = v / (1 - iou + v + 1e-8) # Add small epsilon for numerical stability - else: - alpha = 0 - - # Calculate CIoU - if diagonal_sq > 0: - ciou_val = iou - center_distance_sq / diagonal_sq - alpha * v - else: - ciou_val = iou - - ciou_matrix[n, k] = ciou_val - - return ciou_matrix - - -def efficient_iou(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute Efficient IoU (EIoU) between two sets of boxes. - - EIoU = IoU - ρ²(b, b_gt) / c² - ρ²(w, w_gt) / c_w² - ρ²(h, h_gt) / c_h² - where: - - ρ²(b, b_gt) is the squared Euclidean distance between box centers - - c² is the squared diagonal length of the smallest enclosing box - - ρ²(w, w_gt) is the squared difference between widths - - ρ²(h, h_gt) is the squared difference between heights - - c_w², c_h² are the squared widths and heights of the enclosing box - - Paper: "Focal and Efficient IOU Loss for Accurate Bounding Box Regression" - https://arxiv.org/abs/2101.08158 - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - - Returns - ------- - eiou: (N, K) float32 array of Efficient IoU values - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] eiou_matrix = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t iw, ih, inter, ua, iou - cdef DTYPE_t box_area, query_area - cdef DTYPE_t box_cx, box_cy, query_cx, query_cy - cdef DTYPE_t box_w, box_h, query_w, query_h - cdef DTYPE_t center_distance_sq, diagonal_sq - cdef DTYPE_t width_diff_sq, height_diff_sq - cdef DTYPE_t enclosing_x1, enclosing_y1, enclosing_x2, enclosing_y2 - cdef DTYPE_t enclosing_w, enclosing_h, enclosing_w_sq, enclosing_h_sq - cdef DTYPE_t eiou_val - - for k in range(K): - query_area = (query_boxes[k, 2] - query_boxes[k, 0]) * \ - (query_boxes[k, 3] - query_boxes[k, 1]) - query_cx = (query_boxes[k, 0] + query_boxes[k, 2]) / 2.0 - query_cy = (query_boxes[k, 1] + query_boxes[k, 3]) / 2.0 - query_w = query_boxes[k, 2] - query_boxes[k, 0] - query_h = query_boxes[k, 3] - query_boxes[k, 1] - - for n in range(N): - # Calculate intersection - iw = min(boxes[n, 2], query_boxes[k, 2]) - \ - max(boxes[n, 0], query_boxes[k, 0]) - if iw > 0: - ih = min(boxes[n, 3], query_boxes[k, 3]) - \ - max(boxes[n, 1], query_boxes[k, 1]) - if ih > 0: - inter = iw * ih - else: - inter = 0 - else: - inter = 0 - - # Calculate union and IoU - box_area = (boxes[n, 2] - boxes[n, 0]) * \ - (boxes[n, 3] - boxes[n, 1]) - ua = box_area + query_area - inter - - if ua > 0: - iou = inter / ua - else: - iou = 0 - - # Calculate box centers and dimensions - box_cx = (boxes[n, 0] + boxes[n, 2]) / 2.0 - box_cy = (boxes[n, 1] + boxes[n, 3]) / 2.0 - box_w = boxes[n, 2] - boxes[n, 0] - box_h = boxes[n, 3] - boxes[n, 1] - - # Calculate center distance squared - center_distance_sq = (box_cx - query_cx) * (box_cx - query_cx) + \ - (box_cy - query_cy) * (box_cy - query_cy) - - # Calculate smallest enclosing box - enclosing_x1 = min(boxes[n, 0], query_boxes[k, 0]) - enclosing_y1 = min(boxes[n, 1], query_boxes[k, 1]) - enclosing_x2 = max(boxes[n, 2], query_boxes[k, 2]) - enclosing_y2 = max(boxes[n, 3], query_boxes[k, 3]) - - diagonal_sq = (enclosing_x2 - enclosing_x1) * (enclosing_x2 - enclosing_x1) + \ - (enclosing_y2 - enclosing_y1) * (enclosing_y2 - enclosing_y1) - - enclosing_w = enclosing_x2 - enclosing_x1 - enclosing_h = enclosing_y2 - enclosing_y1 - enclosing_w_sq = enclosing_w * enclosing_w - enclosing_h_sq = enclosing_h * enclosing_h - - # Calculate width and height differences squared - width_diff_sq = (box_w - query_w) * (box_w - query_w) - height_diff_sq = (box_h - query_h) * (box_h - query_h) - - # Calculate EIoU - eiou_val = iou - if diagonal_sq > 0: - eiou_val -= center_distance_sq / diagonal_sq - if enclosing_w_sq > 0: - eiou_val -= width_diff_sq / enclosing_w_sq - if enclosing_h_sq > 0: - eiou_val -= height_diff_sq / enclosing_h_sq - - eiou_matrix[n, k] = eiou_val - - return eiou_matrix - - -def normalized_wasserstein_distance(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes, - DTYPE_t tau=1.0): - """ - Compute Normalized Wasserstein Distance (NWD) between two sets of boxes. - - CORRECTED implementation based on: - "A Normalized Gaussian Wasserstein Distance for Tiny Object Detection" - https://arxiv.org/abs/2110.13389 - - For a box [x1, y1, x2, y2], the corresponding 2D Gaussian has: - - Mean: μ = (center_x, center_y) - - Covariance: Σ = diag(w²/4, h²/4) where w,h are width and height - - W₂²(Na, Nb) = ||μa - μb||₂² + ||Σa^(1/2) - Σb^(1/2)||F² - = (cx_a - cx_b)² + (cy_a - cy_b)² + (wa/2 - wb/2)² + (ha/2 - hb/2)² - - NWD(Na, Nb) = exp(-√(W₂²(Na, Nb)) / τ) - - Parameters - ---------- - boxes: (N, 4) float32 array [x1, y1, x2, y2] - query_boxes: (K, 4) float32 array [x1, y1, x2, y2] - tau: float, normalization constant (default: 1.0) - - Returns - ------- - nwd: (N, K) float32 array of NWD values in [0, 1] where 1 = identical, 0 = very different - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] nwd_matrix = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef DTYPE_t box_cx, box_cy, query_cx, query_cy - cdef DTYPE_t box_w_half, box_h_half, query_w_half, query_h_half - cdef DTYPE_t mean_diff_sq, cov_diff_sq, wasserstein_sq - cdef DTYPE_t nwd_val - cdef DTYPE_t tau_val = tau - - for k in range(K): - query_cx = (query_boxes[k, 0] + query_boxes[k, 2]) / 2.0 - query_cy = (query_boxes[k, 1] + query_boxes[k, 3]) / 2.0 - query_w_half = (query_boxes[k, 2] - query_boxes[k, 0]) / 2.0 - query_h_half = (query_boxes[k, 3] - query_boxes[k, 1]) / 2.0 - - for n in range(N): - # Calculate box centers and half-dimensions - box_cx = (boxes[n, 0] + boxes[n, 2]) / 2.0 - box_cy = (boxes[n, 1] + boxes[n, 3]) / 2.0 - box_w_half = (boxes[n, 2] - boxes[n, 0]) / 2.0 - box_h_half = (boxes[n, 3] - boxes[n, 1]) / 2.0 - - # Mean difference squared: ||μ₁ - μ₂||² - mean_diff_sq = (box_cx - query_cx) * (box_cx - query_cx) + \ - (box_cy - query_cy) * (box_cy - query_cy) - - # Covariance difference squared: ||Σ₁^(1/2) - Σ₂^(1/2)||F² - # For diagonal Σ = diag(w²/4, h²/4): ||Σ₁^(1/2) - Σ₂^(1/2)||F² = (w₁/2 - w₂/2)² + (h₁/2 - h₂/2)² - cov_diff_sq = (box_w_half - query_w_half) * (box_w_half - query_w_half) + \ - (box_h_half - query_h_half) * (box_h_half - query_h_half) - - # Wasserstein-2 distance squared - wasserstein_sq = mean_diff_sq + cov_diff_sq - - # Apply exponential normalization: NWD = exp(-√(W₂²) / τ) - if wasserstein_sq >= 0: - nwd_val = exp(-sqrt(wasserstein_sq) / tau_val) - else: - nwd_val = 1.0 # Safety check - - nwd_matrix[n, k] = nwd_val - - return nwd_matrix - - diff --git a/fastbbox/obb_bbox.pyx b/fastbbox/obb_bbox.pyx deleted file mode 100644 index 2670d88..0000000 --- a/fastbbox/obb_bbox.pyx +++ /dev/null @@ -1,261 +0,0 @@ -# distutils: language = c++ -# cython: boundscheck=False, wraparound=False, nonecheck=False, cdivision=True, initializedcheck=False - -""" -Oriented Bounding Box (OBB) IoU calculations using Cython. - -This implementation uses a robust approach for OBB intersection calculation. -OBB Format: [center_x, center_y, width, height, angle] where angle is in radians. -""" - -import numpy as np -cimport numpy as np -from libc.math cimport cos, sin, sqrt, fabs, fmax, fmin, pi - -# Define types -ctypedef np.float32_t DTYPE_t - -cdef struct Point: - DTYPE_t x - DTYPE_t y - -cdef struct OBB: - DTYPE_t cx, cy # Center coordinates - DTYPE_t width, height # Dimensions - DTYPE_t angle # Rotation angle in radians - - -cdef inline Point make_point(DTYPE_t x, DTYPE_t y): - """Create a Point struct""" - cdef Point p - p.x = x - p.y = y - return p - - -cdef inline OBB make_obb(DTYPE_t cx, DTYPE_t cy, DTYPE_t width, DTYPE_t height, DTYPE_t angle): - """Create an OBB struct""" - cdef OBB box - box.cx = cx - box.cy = cy - box.width = width - box.height = height - box.angle = angle - return box - - -cdef void obb_to_corners(OBB box, Point* corners): - """Convert OBB to 4 corner points in counter-clockwise order.""" - cdef DTYPE_t cos_a = cos(box.angle) - cdef DTYPE_t sin_a = sin(box.angle) - cdef DTYPE_t hw = box.width * 0.5 - cdef DTYPE_t hh = box.height * 0.5 - - # Local corner coordinates (counter-clockwise from bottom-left) - cdef DTYPE_t local_x[4] - cdef DTYPE_t local_y[4] - local_x[0] = -hw; local_y[0] = -hh # Bottom-left - local_x[1] = hw; local_y[1] = -hh # Bottom-right - local_x[2] = hw; local_y[2] = hh # Top-right - local_x[3] = -hw; local_y[3] = hh # Top-left - - # Transform to world coordinates - cdef int i - for i in range(4): - corners[i].x = box.cx + local_x[i] * cos_a - local_y[i] * sin_a - corners[i].y = box.cy + local_x[i] * sin_a + local_y[i] * cos_a - - -cdef DTYPE_t polygon_area(Point* vertices, int n): - """Calculate area of a polygon using the shoelace formula.""" - if n < 3: - return 0.0 - - cdef DTYPE_t area = 0.0 - cdef int i, j - - for i in range(n): - j = (i + 1) % n - area += vertices[i].x * vertices[j].y - area -= vertices[j].x * vertices[i].y - - return fabs(area) * 0.5 - - -cdef int line_intersect(Point p1, Point p2, Point p3, Point p4, Point* intersection): - """ - Find intersection of two line segments. - Returns 1 if intersection exists, 0 otherwise. - """ - cdef DTYPE_t denom = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) - - if fabs(denom) < 1e-10: - return 0 # Lines are parallel - - cdef DTYPE_t t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / denom - cdef DTYPE_t u = -((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / denom - - if t >= 0 and t <= 1 and u >= 0 and u <= 1: - intersection.x = p1.x + t * (p2.x - p1.x) - intersection.y = p1.y + t * (p2.y - p1.y) - return 1 - - return 0 - - -cdef int point_in_polygon(Point point, Point* polygon, int n): - """Check if a point is inside a polygon using ray casting.""" - cdef int inside = 0 - cdef int i, j = n - 1 - - for i in range(n): - if ((polygon[i].y > point.y) != (polygon[j].y > point.y)) and \ - (point.x < (polygon[j].x - polygon[i].x) * (point.y - polygon[i].y) / (polygon[j].y - polygon[i].y) + polygon[i].x): - inside = 1 - inside - j = i - - return inside - - -cdef DTYPE_t obb_intersection_area_robust(OBB box1, OBB box2): - """ - Calculate intersection area between two OBBs using a robust method. - This implementation handles axis-aligned boxes exactly and provides - reasonable approximations for rotated boxes. - """ - # Declare all variables at the top - cdef DTYPE_t x1_min, x1_max, y1_min, y1_max - cdef DTYPE_t x2_min, x2_max, y2_min, y2_max - cdef DTYPE_t inter_x_min, inter_x_max, inter_y_min, inter_y_max - cdef Point corners1[4] - cdef Point corners2[4] - cdef DTYPE_t min_x1, max_x1, min_y1, max_y1 - cdef DTYPE_t min_x2, max_x2, min_y2, max_y2 - cdef int i - cdef DTYPE_t aabb_intersection, angle_factor - - # Special case: both boxes are axis-aligned - if fabs(box1.angle) < 1e-6 and fabs(box2.angle) < 1e-6: - # Exact calculation for axis-aligned boxes - x1_min = box1.cx - box1.width * 0.5 - x1_max = box1.cx + box1.width * 0.5 - y1_min = box1.cy - box1.height * 0.5 - y1_max = box1.cy + box1.height * 0.5 - - x2_min = box2.cx - box2.width * 0.5 - x2_max = box2.cx + box2.width * 0.5 - y2_min = box2.cy - box2.height * 0.5 - y2_max = box2.cy + box2.height * 0.5 - - inter_x_min = fmax(x1_min, x2_min) - inter_x_max = fmin(x1_max, x2_max) - inter_y_min = fmax(y1_min, y2_min) - inter_y_max = fmin(y1_max, y2_max) - - if inter_x_min >= inter_x_max or inter_y_min >= inter_y_max: - return 0.0 - - return (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min) - - # For rotated boxes, use a simplified approximation - # This is not exact but provides reasonable results - obb_to_corners(box1, corners1) - obb_to_corners(box2, corners2) - - # Find axis-aligned bounding boxes and use intersection as approximation - # Box 1 AABB - min_x1 = max_x1 = corners1[0].x - min_y1 = max_y1 = corners1[0].y - for i in range(1, 4): - min_x1 = fmin(min_x1, corners1[i].x) - max_x1 = fmax(max_x1, corners1[i].x) - min_y1 = fmin(min_y1, corners1[i].y) - max_y1 = fmax(max_y1, corners1[i].y) - - # Box 2 AABB - min_x2 = max_x2 = corners2[0].x - min_y2 = max_y2 = corners2[0].y - for i in range(1, 4): - min_x2 = fmin(min_x2, corners2[i].x) - max_x2 = fmax(max_x2, corners2[i].x) - min_y2 = fmin(min_y2, corners2[i].y) - max_y2 = fmax(max_y2, corners2[i].y) - - # Calculate AABB intersection - inter_x_min = fmax(min_x1, min_x2) - inter_x_max = fmin(max_x1, max_x2) - inter_y_min = fmax(min_y1, min_y2) - inter_y_max = fmin(max_y1, max_y2) - - if inter_x_min >= inter_x_max or inter_y_min >= inter_y_max: - return 0.0 - - # For rotated boxes, scale down the AABB intersection as an approximation - aabb_intersection = (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min) - - # Apply a scaling factor based on rotation angles - angle_factor = cos(fabs(box1.angle)) * cos(fabs(box2.angle)) - angle_factor = fmax(0.5, angle_factor) # Minimum 50% of AABB intersection - - return aabb_intersection * angle_factor - - -cdef inline DTYPE_t obb_area(OBB box): - """Calculate area of an OBB""" - return box.width * box.height - - -def bbox_overlaps_obb(np.ndarray[DTYPE_t, ndim=2] boxes, - np.ndarray[DTYPE_t, ndim=2] query_boxes): - """ - Compute IoU overlaps between two sets of oriented bounding boxes. - - Parameters - ---------- - boxes: (N, 5) float32 array [cx, cy, width, height, angle] - query_boxes: (K, 5) float32 array [cx, cy, width, height, angle] - where angle is in radians - - Returns - ------- - overlaps: (N, K) float32 array of IoU values - """ - cdef unsigned int N = boxes.shape[0] - cdef unsigned int K = query_boxes.shape[0] - cdef np.ndarray[DTYPE_t, ndim=2] overlaps = np.zeros((N, K), dtype=np.float32) - - cdef unsigned int n, k - cdef OBB box1, box2 - cdef DTYPE_t intersection_area, union_area, area1, area2, iou - - for k in range(K): - box2 = make_obb( - query_boxes[k, 0], query_boxes[k, 1], - query_boxes[k, 2], query_boxes[k, 3], - query_boxes[k, 4] - ) - area2 = obb_area(box2) - - for n in range(N): - box1 = make_obb( - boxes[n, 0], boxes[n, 1], - boxes[n, 2], boxes[n, 3], - boxes[n, 4] - ) - area1 = obb_area(box1) - - # Calculate intersection area - intersection_area = obb_intersection_area_robust(box1, box2) - - # Calculate union area - union_area = area1 + area2 - intersection_area - - # Calculate IoU - if union_area > 0: - iou = intersection_area / union_area - else: - iou = 0.0 - - overlaps[n, k] = iou - - return overlaps diff --git a/pyproject.cython.toml b/pyproject.cython.toml deleted file mode 100644 index ef26053..0000000 --- a/pyproject.cython.toml +++ /dev/null @@ -1,59 +0,0 @@ -# CYTHON BUILD CONFIGURATION -# This pyproject.toml is for the stable Cython-based build of fastbbox. -# For the nanobind build, see pyproject.nanobind.toml - -[build-system] -requires = ["setuptools>=45", "wheel", "cython>=0.29", "numpy"] -build-backend = "setuptools.build_meta" - -[project] -name = "fastbbox" -version = "0.1.0" -description = "Fast IoU/overlap computations for bounding boxes using Cython" -readme = "README.md" -license = {text = "MIT"} -authors = [ - {name = "Gerry Lim", email = "gerrylimwk@gmail.com"} -] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Cython", - "Topic :: Scientific/Engineering", -] -requires-python = ">=3.8" -dependencies = [ - "numpy>=1.19.0", -] - -[project.urls] -Homepage = "https://github.com/gerrylwk/fastbbox" -Repository = "https://github.com/gerrylwk/fastbbox" -Issues = "https://github.com/gerrylwk/fastbbox/issues" - -[tool.setuptools.packages.find] -include = ["fastbbox*"] - -[tool.cibuildwheel] -# Build for Python 3.8+ -build = "cp38-* cp39-* cp310-* cp311-* cp312-*" -# Skip 32-bit builds and PyPy -skip = "*-win32 *-manylinux_i686 pp*" - -[tool.cibuildwheel.linux] -before-all = "yum install -y gcc-c++ || apt-get update && apt-get install -y g++" - -[tool.cibuildwheel.windows] -before-build = "pip install delvewheel" -repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" - -[tool.cibuildwheel.macos] -archs = ["x86_64", "arm64"] - diff --git a/pyproject.nanobind.toml b/pyproject.nanobind.toml deleted file mode 100644 index bf1db74..0000000 --- a/pyproject.nanobind.toml +++ /dev/null @@ -1,67 +0,0 @@ -[build-system] -requires = ["scikit-build-core>=0.5.0", "nanobind>=1.0.0", "numpy>=1.19.0"] -build-backend = "scikit_build_core.build" - -[project] -name = "fastbbox-nanobind" -version = "0.1.0" -description = "Fast IoU/overlap computations for bounding boxes using nanobind" -readme = "README.md" -license = {text = "MIT"} -authors = [ - {name = "Gerry Lim", email = "gerrylimwk@gmail.com"} -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: C++", - "Topic :: Scientific/Engineering", -] -requires-python = ">=3.9" -dependencies = [ - "numpy>=1.19.0", -] - -[project.urls] -Homepage = "https://github.com/gerrylwk/fastbbox" -Repository = "https://github.com/gerrylwk/fastbbox" -Issues = "https://github.com/gerrylwk/fastbbox/issues" - -[project.optional-dependencies] -dev = [ - "pytest>=7.0", - "build>=0.10", - "scikit-build-core>=0.5.0", - "nanobind>=1.0.0", - "cmake>=3.15", -] - -[tool.scikit-build] -# Specify CMake source directory -cmake.source-dir = "." -# Build directory -build-dir = "build/{wheel_tag}" -# Ensure the fastbbox package is included -wheel.packages = ["fastbbox"] - -[tool.cibuildwheel] -# Build for Python 3.9+ -build = "cp39-* cp310-* cp311-* cp312-*" -# Skip 32-bit builds, PyPy, and musl -skip = "*-win32 *-manylinux_i686 *-musllinux_* pp*" - -[tool.cibuildwheel.linux] -before-all = "yum install -y gcc-c++ cmake ninja-build || dnf install -y gcc-c++ cmake ninja-build" - -[tool.cibuildwheel.windows] -before-build = "pip install delvewheel" -repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" - -[tool.cibuildwheel.macos] -archs = ["x86_64", "arm64"] diff --git a/pyproject.toml b/pyproject.toml index bf1db74..cecf76a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,16 +3,16 @@ requires = ["scikit-build-core>=0.5.0", "nanobind>=1.0.0", "numpy>=1.19.0"] build-backend = "scikit_build_core.build" [project] -name = "fastbbox-nanobind" +name = "fastbbox" version = "0.1.0" -description = "Fast IoU/overlap computations for bounding boxes using nanobind" +description = "Fast IoU/overlap computations for bounding boxes" readme = "README.md" license = {text = "MIT"} authors = [ {name = "Gerry Lim", email = "gerrylimwk@gmail.com"} ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -47,8 +47,10 @@ dev = [ cmake.source-dir = "." # Build directory build-dir = "build/{wheel_tag}" -# Ensure the fastbbox package is included -wheel.packages = ["fastbbox"] + +# Src layout: map import name -> source directory (dist name is fastbbox-nanobind) +[tool.scikit-build.wheel.packages] +fastbbox = "src/fastbbox" [tool.cibuildwheel] # Build for Python 3.9+ diff --git a/setup.py b/setup.py deleted file mode 100644 index cbe057c..0000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -# CYTHON BUILD CONFIGURATION -# This setup.py is for the stable Cython-based build of fastbbox. -# For the nanobind build, use: pip install . -C--config-file=pyproject.nanobind.toml -# See BUILD.md for more details. - -from setuptools import setup, Extension -from Cython.Build import cythonize -import numpy - -# Define the extension modules -extensions = [ - Extension( - "fastbbox.bbox", - ["fastbbox/bbox.pyx"], - include_dirs=[numpy.get_include()], - language="c++", - extra_compile_args=["-O3"], - ), - Extension( - "fastbbox.obb_bbox", - ["fastbbox/obb_bbox.pyx"], - include_dirs=[numpy.get_include()], - language="c++", - extra_compile_args=["-O3"], - ) -] - -setup( - ext_modules=cythonize(extensions, compiler_directives={ - 'boundscheck': False, - 'wraparound': False, - 'nonecheck': False, - 'language_level': 3 - }), - zip_safe=False, -) - diff --git a/src/fastbbox/__init__.py b/src/fastbbox/__init__.py new file mode 100644 index 0000000..b76cfb9 --- /dev/null +++ b/src/fastbbox/__init__.py @@ -0,0 +1,9 @@ +from .bbox import bbox_overlaps, generalized_iou, distance_iou, complete_iou, efficient_iou, normalized_wasserstein_distance +from .obb_bbox import bbox_overlaps_obb + +__version__ = "0.1.0" +__all__ = [ + "bbox_overlaps", "generalized_iou", "distance_iou", "complete_iou", + "efficient_iou", "normalized_wasserstein_distance", + "bbox_overlaps_obb", +] diff --git a/fastbbox/bbox_nb.cpp b/src/fastbbox/bbox_nb.cpp similarity index 100% rename from fastbbox/bbox_nb.cpp rename to src/fastbbox/bbox_nb.cpp diff --git a/fastbbox/obb_bbox_nb.cpp b/src/fastbbox/obb_bbox_nb.cpp similarity index 100% rename from fastbbox/obb_bbox_nb.cpp rename to src/fastbbox/obb_bbox_nb.cpp diff --git a/test_fastbbox.py b/test_fastbbox.py index 2e21845..a1358f2 100644 --- a/test_fastbbox.py +++ b/test_fastbbox.py @@ -463,9 +463,7 @@ def main(): from fastbbox import (bbox_overlaps, generalized_iou, distance_iou, complete_iou, efficient_iou, normalized_wasserstein_distance, bbox_overlaps_obb) - import fastbbox - backend = getattr(fastbbox, '__backend__', 'unknown') - print(f"Backend: {backend}") + print(f"Backend: Nanobind") except ImportError as e: print(f"ERROR: Could not import fastbbox: {e}") return 1