Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,58 @@ jobs:
cache: pip
- name: Install package
run: python -m pip install --upgrade pip && python -m pip install .
- name: Verify installed native package
working-directory: ${{ runner.temp }}
run: |
python - <<'PY'
import numpy as np

from fastvisionops import NativeBackend

backend = NativeBackend()
assert backend.library_path.name.startswith("_native")
np.testing.assert_array_equal(
backend.nms(
[[0, 0, 10, 10], [1, 1, 9, 9], [20, 20, 30, 30]],
[0.9, 0.8, 0.7],
),
[0, 2],
)
output = backend.hwc_to_chw_normalize(
np.zeros((4, 5, 3), dtype=np.uint8),
[0, 0, 0],
[1, 1, 1],
threads=2,
)
assert output.shape == (3, 4, 5)
PY
- name: Build native backend
run: python -m fastvisionops.build
- name: Verify portable native build
run: |
python -m fastvisionops.build \
--no-openmp \
--output "${RUNNER_TEMP}/libfastvisionops-portable.so"
python - <<'PY'
import os
import numpy as np

from fastvisionops import NativeBackend

backend = NativeBackend(
os.path.join(
os.environ["RUNNER_TEMP"],
"libfastvisionops-portable.so",
)
)
np.testing.assert_array_equal(
backend.nms(
[[0, 0, 10, 10], [1, 1, 9, 9], [20, 20, 30, 30]],
[0.9, 0.8, 0.7],
),
[0, 2],
)
PY
- name: Run test suite
run: python -m unittest discover -s tests -v
- name: Smoke-test benchmark runners
Expand Down
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,28 @@ flowchart LR

## Install

Install directly from GitHub over SSH:

```bash
python -m pip install .
python -m fastvisionops.build
python -m pip install "git+ssh://git@github.com/Som5ra/FastVisionOps.git"
```

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.
The equivalent public HTTPS command does not require an SSH key:

```bash
python -m pip install "git+https://github.com/Som5ra/FastVisionOps.git"
```

For a local checkout, use `python -m pip install .`. Each command installs
NumPy when needed and compiles the native backend into the wheel, so
`NativeBackend()` works immediately. GCC or Clang is required; OpenMP is used
when supported and otherwise falls back to portable single-threaded C.

To pin a branch, tag, or commit, append its ref:

```bash
python -m pip install "git+https://github.com/Som5ra/FastVisionOps.git@<ref>"
```

## Quick start

Expand Down Expand Up @@ -139,17 +152,32 @@ environment, results, and limitations.
Standalone transpose and normalization remain NumPy operations; the fused
native path avoids intermediate arrays and accelerates the useful hot path.

## Repository layout

| Path | Responsibility |
| --- | --- |
| `fastvisionops/preprocess/` | Validated NumPy layout conversion and normalization |
| `fastvisionops/postprocess/` | Bounding-box and boolean-mask suppression |
| `fastvisionops/native/` | ctypes bindings, builder, and colocated C source |
| `fastvisionops/{bbox,mask,build}.py` | Stable compatibility import paths |
| `nmss/` | Backward-compatible namespace for existing users |
| `legacy/` | Original standalone adapters; excluded from installation |

The maintained implementation flows one way: public APIs delegate to the
stage package, while compatibility modules only re-export those functions.

## Validation

```bash
python -m fastvisionops.build
python -m unittest discover -s tests -v
```

The 41 tests cover exact and randomized NumPy/native equivalence, empty and
The 47 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.
behavior, malformed controls, compatibility imports, package layout, portable
builds, and serial/concurrent batches. CI runs the suite and benchmark smoke
tests on Python 3.9, 3.12, and 3.13.

## Migration

Expand Down
4 changes: 2 additions & 2 deletions fastvisionops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

from typing import TYPE_CHECKING

from nmss.bbox import (
from .postprocess.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 .postprocess.mask import mask_iou, mask_nms, multiclass_mask_nms

from .preprocess import (
chw_channel_normalize,
Expand Down
90 changes: 90 additions & 0 deletions fastvisionops/_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Shared validation helpers for FastVisionOps operations."""

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)}"
)
4 changes: 2 additions & 2 deletions fastvisionops/bbox.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Bounding-box operations exposed under the FastVisionOps namespace."""
"""Compatibility import for :mod:`fastvisionops.postprocess.bbox`."""

from nmss.bbox import (
from .postprocess.bbox import (
bbox_iou,
multiclass_nms,
multiclass_nms_class_aware,
Expand Down
112 changes: 24 additions & 88 deletions fastvisionops/build.py
Original file line number Diff line number Diff line change
@@ -1,91 +1,27 @@
"""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
"""Public native build API.

The implementation lives under :mod:`fastvisionops.native` alongside the
backend and C source. This module preserves the original command and imports.
"""

from .native.build import (
DEFAULT_OUTPUT,
PACKAGE_ROOT,
SOURCE,
build_c_backend,
build_native_backend,
main,
)

__all__ = [
"DEFAULT_OUTPUT",
"PACKAGE_ROOT",
"SOURCE",
"build_c_backend",
"build_native_backend",
"main",
]


if __name__ == "__main__":
sys.exit(main())
raise SystemExit(main())
4 changes: 2 additions & 2 deletions fastvisionops/mask.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Mask operations exposed under the FastVisionOps namespace."""
"""Compatibility import for :mod:`fastvisionops.postprocess.mask`."""

from nmss.mask import (
from .postprocess.mask import (
mask_iou,
mask_nms,
mask_nms_cpu,
Expand Down
25 changes: 25 additions & 0 deletions fastvisionops/native/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Compiled backend and build helpers."""

from .backend import (
CBackend,
NativeBackend,
batch_multiclass_nms,
hwc_to_chw_normalize,
hwc_to_chw_normalize_batched,
load_backend,
multiclass_nms,
nms,
)
from .build import DEFAULT_OUTPUT

__all__ = [
"CBackend",
"DEFAULT_OUTPUT",
"NativeBackend",
"batch_multiclass_nms",
"hwc_to_chw_normalize",
"hwc_to_chw_normalize_batched",
"load_backend",
"multiclass_nms",
"nms",
]
Loading
Loading