diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09be547..5308003 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 8e1ac69..3602fea 100644 --- a/README.md +++ b/README.md @@ -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@" +``` ## Quick start @@ -139,6 +152,20 @@ 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 @@ -146,10 +173,11 @@ 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 diff --git a/fastvisionops/__init__.py b/fastvisionops/__init__.py index a6e30d6..6c74f75 100644 --- a/fastvisionops/__init__.py +++ b/fastvisionops/__init__.py @@ -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, diff --git a/fastvisionops/_validation.py b/fastvisionops/_validation.py new file mode 100644 index 0000000..7688db9 --- /dev/null +++ b/fastvisionops/_validation.py @@ -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)}" + ) diff --git a/fastvisionops/bbox.py b/fastvisionops/bbox.py index 2f8d969..4e8658d 100644 --- a/fastvisionops/bbox.py +++ b/fastvisionops/bbox.py @@ -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, diff --git a/fastvisionops/build.py b/fastvisionops/build.py index b488a8f..9de65d6 100644 --- a/fastvisionops/build.py +++ b/fastvisionops/build.py @@ -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()) diff --git a/fastvisionops/mask.py b/fastvisionops/mask.py index ea6780a..9a4ebda 100644 --- a/fastvisionops/mask.py +++ b/fastvisionops/mask.py @@ -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, diff --git a/fastvisionops/native/__init__.py b/fastvisionops/native/__init__.py new file mode 100644 index 0000000..40ec483 --- /dev/null +++ b/fastvisionops/native/__init__.py @@ -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", +] diff --git a/fastvisionops/native.py b/fastvisionops/native/backend.py similarity index 98% rename from fastvisionops/native.py rename to fastvisionops/native/backend.py index e9baa26..2240306 100644 --- a/fastvisionops/native.py +++ b/fastvisionops/native/backend.py @@ -13,7 +13,7 @@ from numpy.ctypeslib import ndpointer from numpy.typing import ArrayLike, NDArray -from nmss._validation import ( +from .._validation import ( validate_batch, validate_boxes, validate_max_detections, @@ -22,8 +22,8 @@ validate_threshold, ) +from ..preprocess import _validate_flip, _validate_image, _validate_statistics from .build import DEFAULT_OUTPUT -from .preprocess import _validate_flip, _validate_image, _validate_statistics class NativeBackend: diff --git a/fastvisionops/native/build.py b/fastvisionops/native/build.py new file mode 100644 index 0000000..60039fb --- /dev/null +++ b/fastvisionops/native/build.py @@ -0,0 +1,104 @@ +"""Build the optional FastVisionOps native backend.""" + +from __future__ import annotations + +import argparse +from importlib.machinery import EXTENSION_SUFFIXES +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +SOURCE = Path(__file__).resolve().parent / "csrc" / "vision_ops.c" +SOURCE_OUTPUT = PACKAGE_ROOT / "lib" / "libfastvisionops.so" + + +def _default_output() -> Path: + """Prefer an installed wheel library, then the source-tree build.""" + for suffix in EXTENSION_SUFFIXES: + installed_library = PACKAGE_ROOT / f"_native{suffix}" + if installed_library.is_file(): + return installed_library + return SOURCE_OUTPUT + + +DEFAULT_OUTPUT = _default_output() + + +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/native/csrc/vision_ops.c similarity index 100% rename from fastvisionops/csrc/vision_ops.c rename to fastvisionops/native/csrc/vision_ops.c diff --git a/fastvisionops/postprocess/__init__.py b/fastvisionops/postprocess/__init__.py new file mode 100644 index 0000000..91addf3 --- /dev/null +++ b/fastvisionops/postprocess/__init__.py @@ -0,0 +1,21 @@ +"""Detection and segmentation postprocessing operations.""" + +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", +] diff --git a/fastvisionops/postprocess/bbox.py b/fastvisionops/postprocess/bbox.py new file mode 100644 index 0000000..6d45744 --- /dev/null +++ b/fastvisionops/postprocess/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/fastvisionops/postprocess/mask.py b/fastvisionops/postprocess/mask.py new file mode 100644 index 0000000..83a754d --- /dev/null +++ b/fastvisionops/postprocess/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/fastvisionops/preprocess/__init__.py b/fastvisionops/preprocess/__init__.py new file mode 100644 index 0000000..1a18e7e --- /dev/null +++ b/fastvisionops/preprocess/__init__.py @@ -0,0 +1,18 @@ +"""Image preprocessing operations.""" + +from .numpy import ( + _validate_flip, + _validate_image, + _validate_statistics, + chw_channel_normalize, + hwc_to_chw, + hwc_to_chw_normalize, + hwc_to_chw_normalize_batched, +) + +__all__ = [ + "chw_channel_normalize", + "hwc_to_chw", + "hwc_to_chw_normalize", + "hwc_to_chw_normalize_batched", +] diff --git a/fastvisionops/preprocess.py b/fastvisionops/preprocess/numpy.py similarity index 100% rename from fastvisionops/preprocess.py rename to fastvisionops/preprocess/numpy.py diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..f8a3cbd --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,14 @@ +# Legacy adapters + +These directories preserve the original standalone scripts and call +signatures for reference. They delegate to the tested `fastvisionops` or +`nmss` packages and are not installed. + +| Original project area | Adapter | +| --- | --- | +| NumPy bounding-box NMS | `bbox-nms/` | +| Native and batched bounding-box NMS | `bbox-nms-c-version/` | +| Boolean-mask NMS | `mask-nms/` | + +New code should use the public `fastvisionops` API documented in the +[root README](../README.md). diff --git a/bbox-nms-c-version/.gitignore b/legacy/bbox-nms-c-version/.gitignore similarity index 71% rename from bbox-nms-c-version/.gitignore rename to legacy/bbox-nms-c-version/.gitignore index b28d88b..cafbd27 100644 --- a/bbox-nms-c-version/.gitignore +++ b/legacy/bbox-nms-c-version/.gitignore @@ -1,3 +1,3 @@ nms_c_version nms_test_gen.py -nms_test.py \ No newline at end of file +nms_test.py diff --git a/bbox-nms-c-version/README.md b/legacy/bbox-nms-c-version/README.md similarity index 73% rename from bbox-nms-c-version/README.md rename to legacy/bbox-nms-c-version/README.md index 3d469c8..acfe546 100644 --- a/bbox-nms-c-version/README.md +++ b/legacy/bbox-nms-c-version/README.md @@ -1,7 +1,7 @@ # Legacy C API This directory preserves the original `Batch_Parallel_Nms` import path. -Maintained native code now lives in `fastvisionops/csrc`, and the shared +Maintained native code now lives in `fastvisionops/native/csrc`, and the shared library is rebuilt locally: ```bash @@ -24,6 +24,6 @@ batch_indices, batch_class_ids = backend.batch_parallel_nms( ``` New code should use `fastvisionops.NativeBackend`. See the -[root README](../README.md) and -[evaluation report](../docs/evaluation.md) for the current API and verified +[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.py b/legacy/bbox-nms-c-version/batch_parallel_nms.py similarity index 100% rename from bbox-nms-c-version/batch_parallel_nms.py rename to legacy/bbox-nms-c-version/batch_parallel_nms.py diff --git a/bbox-nms-c-version/compile.md b/legacy/bbox-nms-c-version/compile.md similarity index 79% rename from bbox-nms-c-version/compile.md rename to legacy/bbox-nms-c-version/compile.md index bbff340..1ec4984 100644 --- a/bbox-nms-c-version/compile.md +++ b/legacy/bbox-nms-c-version/compile.md @@ -12,5 +12,5 @@ Use a different compiler or output path when required: python -m fastvisionops.build --compiler clang --output /tmp/libfastvisionops.so ``` -The builder compiles `fastvisionops/csrc/vision_ops.c` with optimized, +The builder compiles `fastvisionops/native/csrc/vision_ops.c` with optimized, reproducible flags and reports compiler errors directly. diff --git a/bbox-nms-c-version/nms.py b/legacy/bbox-nms-c-version/nms.py similarity index 100% rename from bbox-nms-c-version/nms.py rename to legacy/bbox-nms-c-version/nms.py diff --git a/bbox-nms/nms.py b/legacy/bbox-nms/nms.py similarity index 100% rename from bbox-nms/nms.py rename to legacy/bbox-nms/nms.py diff --git a/mask-nms/README.md b/legacy/mask-nms/README.md similarity index 85% rename from mask-nms/README.md rename to legacy/mask-nms/README.md index 2285cc4..7821c56 100644 --- a/mask-nms/README.md +++ b/legacy/mask-nms/README.md @@ -9,4 +9,4 @@ from fastvisionops import mask_nms, multiclass_mask_nms 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. +[root README](../../README.md) for examples and semantics. diff --git a/mask-nms/mask_nms.py b/legacy/mask-nms/mask_nms.py similarity index 100% rename from mask-nms/mask_nms.py rename to legacy/mask-nms/mask_nms.py diff --git a/nmss/_validation.py b/nmss/_validation.py index d558a1d..043dcf0 100644 --- a/nmss/_validation.py +++ b/nmss/_validation.py @@ -1,90 +1,21 @@ -"""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)}" - ) +"""Backward-compatible validation imports.""" + +from fastvisionops._validation import ( + validate_batch, + validate_boxes, + validate_masks, + validate_max_detections, + validate_offset, + validate_scores, + validate_threshold, +) + +__all__ = [ + "validate_batch", + "validate_boxes", + "validate_masks", + "validate_max_detections", + "validate_offset", + "validate_scores", + "validate_threshold", +] diff --git a/nmss/bbox.py b/nmss/bbox.py index bcbabfd..f12547a 100644 --- a/nmss/bbox.py +++ b/nmss/bbox.py @@ -1,249 +1,23 @@ -"""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, +"""Backward-compatible bounding-box NMS imports.""" + +from fastvisionops.postprocess.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, ) - -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, - ) +__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/nmss/mask.py b/nmss/mask.py index c5e7dff..2e7039d 100644 --- a/nmss/mask.py +++ b/nmss/mask.py @@ -1,150 +1,19 @@ -"""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, +"""Backward-compatible mask NMS imports.""" + +from fastvisionops.postprocess.mask import ( + mask_iou, + mask_nms, + mask_nms_cpu, + mask_overlap, + multiclass_mask_nms, + multiclass_mask_nms_class_aware_cpu, ) - -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, - ) +__all__ = [ + "mask_iou", + "mask_nms", + "mask_nms_cpu", + "mask_overlap", + "multiclass_mask_nms", + "multiclass_mask_nms_class_aware_cpu", +] diff --git a/pyproject.toml b/pyproject.toml index 763c810..0cc84c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dev = ["pytest>=7", "pytest-cov>=4"] include = ["fastvisionops*", "nmss*"] [tool.setuptools.package-data] -fastvisionops = ["csrc/*.c"] +"fastvisionops.native" = ["csrc/*.c"] [tool.pytest.ini_options] addopts = "-ra --strict-markers" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..77e5b65 --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +"""Setuptools hook that bundles the ctypes native library in wheels.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from setuptools import Extension, setup +from setuptools.command.build_ext import build_ext + + +PROJECT_ROOT = Path(__file__).resolve().parent +BUILD_HELPER = PROJECT_ROOT / "fastvisionops" / "native" / "build.py" + + +def _load_build_helper(): + spec = importlib.util.spec_from_file_location( + "_fastvisionops_build", + BUILD_HELPER, + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load native build helper at {BUILD_HELPER}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CtypesBuildExt(build_ext): + """Build the ctypes library at setuptools' platform-specific path.""" + + def build_extension(self, extension: Extension) -> None: + if extension.name != "fastvisionops._native": + super().build_extension(extension) + return + output = Path(self.get_ext_fullpath(extension.name)).resolve() + build_helper = _load_build_helper() + build_helper.build_native_backend(output) + + +setup( + ext_modules=[ + Extension( + "fastvisionops._native", + sources=["fastvisionops/native/csrc/vision_ops.c"], + ) + ], + cmdclass={"build_ext": CtypesBuildExt}, +) diff --git a/tests/test_legacy_adapters.py b/tests/test_legacy_adapters.py new file mode 100644 index 0000000..b27b229 --- /dev/null +++ b/tests/test_legacy_adapters.py @@ -0,0 +1,31 @@ +from pathlib import Path +import runpy +import unittest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class LegacyAdapterTests(unittest.TestCase): + def test_all_legacy_adapters_import(self): + adapters = { + "legacy/bbox-nms/nms.py": "nms_cpu", + "legacy/bbox-nms-c-version/nms.py": "nms_cpu", + "legacy/bbox-nms-c-version/batch_parallel_nms.py": ( + "Batch_Parallel_Nms" + ), + "legacy/mask-nms/mask_nms.py": "mask_nms_cpu", + } + for relative_path, expected_name in adapters.items(): + with self.subTest(path=relative_path): + namespace = runpy.run_path(PROJECT_ROOT / relative_path) + self.assertIn(expected_name, namespace) + + def test_obsolete_top_level_directories_are_removed(self): + for directory in ("bbox-nms", "bbox-nms-c-version", "mask-nms"): + with self.subTest(directory=directory): + self.assertFalse((PROJECT_ROOT / directory).exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package_layout.py b/tests/test_package_layout.py new file mode 100644 index 0000000..ead5ce9 --- /dev/null +++ b/tests/test_package_layout.py @@ -0,0 +1,78 @@ +from pathlib import Path +import unittest + +from fastvisionops import mask_nms as public_mask_nms +from fastvisionops import nms as public_nms +from fastvisionops.bbox import nms as bbox_compat_nms +from fastvisionops.build import SOURCE, build_native_backend +from fastvisionops.mask import mask_nms as mask_compat_nms +from fastvisionops.native import ( + DEFAULT_OUTPUT, + NativeBackend, + hwc_to_chw_normalize as native_hwc_to_chw_normalize, + hwc_to_chw_normalize_batched as native_hwc_to_chw_normalize_batched, +) +from fastvisionops.native.backend import ( + NativeBackend as BackendImplementation, + hwc_to_chw_normalize as native_hwc_to_chw_normalize_implementation, + hwc_to_chw_normalize_batched as native_batched_implementation, +) +from fastvisionops.native.build import ( + DEFAULT_OUTPUT as native_default_output, + build_native_backend as native_build_native_backend, +) +from fastvisionops.postprocess import mask_nms as postprocess_mask_nms +from fastvisionops.postprocess import nms as postprocess_nms +from fastvisionops.postprocess.bbox import nms as bbox_implementation +from fastvisionops.postprocess.mask import mask_nms as mask_implementation +from fastvisionops.preprocess import hwc_to_chw_normalize +from fastvisionops.preprocess.numpy import ( + hwc_to_chw_normalize as numpy_hwc_to_chw_normalize, +) +from nmss.bbox import nms as nmss_nms +from nmss.mask import mask_nms as nmss_mask_nms + + +class PackageLayoutTests(unittest.TestCase): + def test_bbox_import_paths_share_one_implementation(self): + functions = ( + public_nms, + bbox_compat_nms, + postprocess_nms, + bbox_implementation, + nmss_nms, + ) + self.assertTrue(all(function is bbox_implementation for function in functions)) + + def test_mask_import_paths_share_one_implementation(self): + functions = ( + public_mask_nms, + mask_compat_nms, + postprocess_mask_nms, + mask_implementation, + nmss_mask_nms, + ) + self.assertTrue(all(function is mask_implementation for function in functions)) + + def test_preprocess_and_native_packages_expose_implementations(self): + self.assertIs(hwc_to_chw_normalize, numpy_hwc_to_chw_normalize) + self.assertIs(NativeBackend, BackendImplementation) + self.assertIs( + native_hwc_to_chw_normalize, + native_hwc_to_chw_normalize_implementation, + ) + self.assertIs( + native_hwc_to_chw_normalize_batched, + native_batched_implementation, + ) + self.assertIs(DEFAULT_OUTPUT, native_default_output) + self.assertIs(build_native_backend, native_build_native_backend) + + def test_native_source_is_colocated_with_backend(self): + expected_parent = Path("fastvisionops/native/csrc") + self.assertEqual(SOURCE.parts[-3:-1], expected_parent.parts[-2:]) + self.assertTrue(SOURCE.is_file()) + + +if __name__ == "__main__": + unittest.main()