diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5404549 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,20 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install package + run: python -m pip install -v ".[test]" + - name: Test import + run: python -c "import bioimage_cpp" + - name: Run tests + run: pytest -q diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..94e2103 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,22 @@ +name: wheels + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + wheels: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-13, macos-14, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: pypa/cibuildwheel@v2.18 + env: + CIBW_BUILD: "cp310-* cp311-* cp312-*" + CIBW_TEST_REQUIRES: pytest + CIBW_TEST_COMMAND: "python -c \"import bioimage_cpp\" && pytest {project}/tests" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..983b472 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python caches +__pycache__/ +*.py[cod] +*$py.class + +# Python packaging/build outputs +*.egg-info/ +build/ +dist/ +wheelhouse/ + +# scikit-build-core / CMake outputs +_skbuild/ +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +compile_commands.json + +# Test and coverage artifacts +.pytest_cache/ +.coverage +htmlcov/ + +# Local virtual environments +.venv/ +venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5279916 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,454 @@ +# AGENTS.md — bioimage-cpp + +This repository implements `bioimage-cpp`: a small C++/Python package for bioimage-analysis algorithms that are currently hard to install from existing C++-heavy libraries. The primary goal is to provide reliable PyPI wheels with a minimal dependency footprint. + +The package should be designed as a focused implementation of selected bioimage-analysis functionality, not as a compatibility layer for older libraries and not as a full reimplementation of large libraries such as `nifty` or `vigra`. + +## Core principles + +- Keep the package easy to build and easy to ship on PyPI. +- Prefer simple, dependency-light C++20 over large template libraries or external binary dependencies. +- Avoid dependencies that complicate wheels unless they are absolutely necessary. +- Do not introduce a C++ image container or multidimensional-array framework as a public dependency. +- Treat NumPy arrays as the Python-side data model and lightweight C++ array views as the C++-side data model. +- Keep I/O outside the C++ core. File formats such as TIFF, HDF5, zarr, N5, OME-NGFF, etc. should be handled by existing Python libraries, not by this package. +- Prefer explicit, narrow APIs over broad compatibility with old libraries. +- Keep the C++ algorithmic core independent of Python, NumPy, and nanobind. Nanobind should only be used in the binding layer. + +## Dependencies + +### Preferred dependencies + +- C++20 standard library. +- `nanobind` for Python bindings. +- `numpy` for array inputs and outputs. +- `scikit-build-core` as the Python build backend. +- `cmake` for configuring the C++ build. +- `pytest` for tests. +- `cibuildwheel` for wheel builds in CI. + +### Avoid in the core package + +Do not add these dependencies to the core package unless there is a strong reason and the wheel-building consequences have been considered carefully: + +- `xtensor` +- `Boost.Python` +- `pybind11` +- `vigra` +- `nifty` +- HDF5 +- z5 / N5 libraries +- libtiff, libpng, libjpeg, OpenEXR +- FFTW +- solver libraries with nontrivial binary dependencies +- conda-only dependencies +- OpenMP, unless the packaging implications are handled explicitly + +If optional functionality really requires a heavy dependency, isolate it behind an optional extension or a separate package. The default PyPI wheel should remain small and robust. + +## Suggested repository layout + +Use a `src` layout for the Python package and keep C++ headers, C++ implementation files, and Python bindings clearly separated. + +```text +bioimage-cpp/ +├── AGENTS.md +├── CMakeLists.txt +├── pyproject.toml +├── README.md +├── include/ +│ └── bioimage_cpp/ +│ ├── array_view.hxx +│ ├── dtype_dispatch.hxx +│ ├── errors.hxx +│ └── ... +├── src/ +│ ├── bioimage_cpp/ +│ │ ├── __init__.py +│ │ ├── _version.py +│ │ └── ... +│ ├── cpp/ +│ │ └── ... +│ └── bindings/ +│ ├── module.cxx +│ ├── array_conversions.cxx +│ └── ... +├── tests/ +│ ├── test_*.py +│ └── reference/ +│ └── ... +└── .github/ + └── workflows/ + ├── tests.yml + └── wheels.yml +``` + +The exact file names may change, but the separation should remain: + +- `include/bioimage_cpp/`: public or semi-public C++ headers. +- `src/cpp/`: C++ implementations. +- `src/bindings/`: Python binding code only. +- `src/bioimage_cpp/`: Python package code. +- `tests/`: Python-level tests and, where useful, C++ behavior tests exposed through Python. + +## Build system + +Use `scikit-build-core` with CMake. Avoid `setup.py`-based builds. + +A minimal `pyproject.toml` should look conceptually like this: + +```toml +[build-system] +requires = ["scikit-build-core", "nanobind"] +build-backend = "scikit_build_core.build" + +[project] +name = "bioimage-cpp" +requires-python = ">=3.10" +dependencies = ["numpy"] + +[tool.scikit-build] +cmake.version = ">=3.21" +wheel.packages = ["src/bioimage_cpp"] +``` + +The package name on PyPI should be `bioimage-cpp`. The import name should be `bioimage_cpp`. + +If support for Python 3.10 and 3.11 is dropped later, the project may switch to Python >=3.12 and nanobind stable-ABI wheels. Do not enable nanobind `STABLE_ABI` while the package still supports Python <3.12. + +## CMake guidelines + +- Build one main Python extension module, for example `bioimage_cpp._core`. +- Use C++20 consistently. +- Use nanobind, not pybind11. +- Keep CMake simple and portable. +- Avoid platform-specific compiler flags unless they are guarded carefully. +- Do not assume conda is available. +- Do not assume system libraries such as HDF5, TIFF, or Boost are available. +- Do not enable native architecture flags such as `-march=native` in wheel builds. +- Use hidden symbol visibility where appropriate. +- Keep the extension self-contained. + +A typical CMake structure should be: + +```cmake +cmake_minimum_required(VERSION 3.21) +project(bioimage_cpp LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(Python 3.10 COMPONENTS Interpreter Development.Module REQUIRED) +find_package(nanobind CONFIG REQUIRED) + +nanobind_add_module(_core + NB_STATIC + src/bindings/module.cxx + src/bindings/array_conversions.cxx + src/cpp/some_algorithm.cxx +) + +target_include_directories(_core PRIVATE include) +target_compile_features(_core PRIVATE cxx_std_20) +``` + +If the minimum Python version is raised to 3.12 or later, stable-ABI builds can be considered: + +```cmake +nanobind_add_module(_core + STABLE_ABI + NB_STATIC + src/bindings/module.cxx + src/bindings/array_conversions.cxx + src/cpp/some_algorithm.cxx +) +``` + +Adjust this as the project grows, but avoid turning the build into a large dependency-discovery system. + +## Wheel building + +Use `cibuildwheel` in GitHub Actions for binary wheels. + +The initial target platforms should be: + +- Linux x86_64, using manylinux wheels. +- macOS x86_64. +- macOS arm64. +- Windows x86_64. + +Later targets such as Linux aarch64 can be added once the core wheels are stable. + +Important wheel-building rules: + +- Wheels must not depend on conda. +- Wheels must not require users to have a local compiler at install time. +- Avoid external shared libraries unless they are bundled correctly and legally. +- Test the installed wheel, not only the source tree. +- Test importability with `python -c "import bioimage_cpp"`. +- Test core functions on small arrays after wheel installation. +- Do not use `-march=native`, unguarded OpenMP, or platform-specific compiler assumptions in release wheels. + +## Python API design + +The public Python API should be explicit and simple. Prefer functions such as: + +```python +import bioimage_cpp as bic + +result = bic.some_function(input_array, labels, ...) +``` + +Avoid exposing complex C++ classes unless they are genuinely needed. If a C++ object is exposed, its lifetime and ownership semantics must be obvious from Python. + +Do not implement a `vigra` or `nifty` compatibility namespace. The API should be designed for `bioimage-cpp` directly. + +## Multidimensional-array handling + +### General rule + +Use NumPy arrays at the Python boundary and lightweight C++ views internally. + +Do not use `xtensor`, `vigra::MultiArray`, or another multidimensional-array framework as a core dependency. + +### C++ array view + +Use a simple internal view type that stores: + +- data pointer +- shape +- strides +- number of dimensions +- dtype handled by template dispatch + +For example: + +```cpp +#include +#include + +template +struct ArrayView { + T* data = nullptr; + std::vector shape; + std::vector strides; + + std::ptrdiff_t ndim() const { + return static_cast(shape.size()); + } +}; +``` + +For read-only inputs, use `const T*` or a separate `ConstArrayView`. + +The C++ algorithm implementation must not depend directly on Python, NumPy, or nanobind. + +### Binding-layer responsibilities + +The Python binding layer is responsible for: + +- accepting `nanobind::ndarray` inputs +- checking dimensionality +- checking or converting dtype +- checking writability for output or in-place arguments +- checking shape compatibility between inputs +- checking C-contiguity if the C++ implementation requires it +- converting nanobind array metadata into `ArrayView` +- allocating output arrays +- dispatching to the correct C++ template instantiation + +Use nanobind only at this boundary. Do not pass `nanobind::ndarray` or other nanobind types into the algorithmic core. + +A typical binding file should use a local namespace alias: + +```cpp +#include +#include + +namespace nb = nanobind; +``` + +Example pattern: + +```cpp +nb::ndarray, nb::c_contig> +some_function( + nb::ndarray, nb::c_contig> labels +) { + // Validate Python-facing arguments here. + // Convert labels to ConstArrayView. + // Release the GIL. + // Run the C++ implementation. + // Return a newly allocated output array. +} +``` + +Prefer readable, explicit binding code over overly generic binding templates. + +### Contiguity policy + +Prefer a two-level policy: + +1. Fast C++ kernels assume C-contiguous arrays. +2. Python wrappers convert inputs with `np.ascontiguousarray` when copying is acceptable. + +For large arrays, avoid hidden expensive copies where possible. Public Python functions should make the copy policy clear, for example with an argument such as: + +```python +def some_function(array, *, copy=True): + ... +``` + +or by documenting that inputs are converted to contiguous arrays. + +If an algorithm supports arbitrary strides, this should be intentional and tested. Do not accidentally support some strided cases while failing on others. + +### Shape and axis conventions + +Use NumPy conventions throughout: + +- arrays are indexed in row-major order +- dimensions are ordered as they appear in the NumPy array +- spatial axes should not be silently reordered +- coordinate outputs should be in NumPy axis order + +For an array with shape `(z, y, x)`, coordinates should be reported as `(z, y, x)` unless a function explicitly documents otherwise. + +### Dtype policy + +Do not silently accept every dtype. Each function should define supported dtypes explicitly. + +Common policies: + +- label arrays: `uint32`, `uint64`, `int32`, `int64` +- image arrays: `uint8`, `uint16`, `uint32`, `float32`, `float64` +- output statistics: usually `float64` for numeric stability, unless there is a reason to preserve dtype +- index arrays: prefer `int64` or `uint64` at the Python boundary + +Use explicit dtype dispatch in the binding layer. Avoid large, uncontrolled template instantiation matrices. + +### Memory ownership and lifetime + +- Never store raw pointers to array data beyond the duration of the function call unless the owning Python object is kept alive explicitly. +- Do not return C++ views into temporary arrays. +- Do not return references to internal buffers unless ownership is clear and tested. +- Prefer returning newly allocated arrays. +- For in-place operations, require writable arrays and document mutation clearly. +- If a nanobind array wraps memory owned by C++, attach an explicit owner/capsule so the lifetime is correct. + +### GIL handling + +For long-running C++ kernels, release the Python GIL in the binding layer: + +```cpp +{ + nb::gil_scoped_release release; + run_algorithm(...); +} +``` + +Only release the GIL after all Python-object validation and array metadata extraction have completed. Do not touch Python objects while the GIL is released. + +### Error handling + +C++ code should throw standard exceptions such as `std::invalid_argument` or `std::runtime_error`. The binding layer should allow nanobind to translate these into Python exceptions. + +Error messages should include: + +- the offending argument name +- expected dimensionality or dtype +- actual dimensionality or dtype +- expected shape relationship, if applicable + +Example: + +```text +labels must have ndim >= 2, got ndim=1 +``` + +## Testing guidelines + +Tests should run against the installed Python package. + +For each public function, test: + +- small 2D arrays +- small 3D arrays where applicable +- nontrivial labels or regions +- background label behavior +- dtype variants +- non-contiguous inputs, if accepted +- incorrect shapes +- incorrect dtypes +- empty or degenerate inputs +- deterministic behavior + +Reference tests against older libraries may be useful during development, but they are not part of the public API contract. Do not require `vigra` or `nifty` for the default test suite, wheel tests, or source builds. + +## Coding style + +### C++ + +- Use clear, boring C++20. +- Prefer small functions over large template-heavy abstractions. +- Keep algorithm code independent of nanobind. +- Use `std::vector`, `std::array`, `std::span` where available and appropriate. +- Avoid clever metaprogramming unless it clearly reduces maintenance burden. +- Use explicit integer types where overflow matters. +- Be careful with signed/unsigned conversions for shapes, strides, and labels. + +### Python + +- Keep Python wrappers thin. +- Validate user-facing arguments in Python when this improves error messages. +- Use NumPy for lightweight preprocessing only. +- Avoid adding dependencies for small convenience features. + +## Performance guidelines + +- Start with correct, portable implementations. +- Benchmark before introducing complexity. +- Prefer algorithmic improvements over build-level optimizations. +- Avoid `-march=native` in distributed wheels. +- Consider releasing the GIL for expensive kernels. +- Consider optional threading only after the single-threaded implementation is stable. +- If threading is added, make it portable and controllable by the user. + +## Documentation expectations + +The README should explain: + +- what `bioimage-cpp` is +- what it intentionally is not +- how to install it from PyPI +- how to build it from source +- minimal examples for the public API +- the dependency-light design philosophy + +Public functions should have concise docstrings documenting: + +- input shapes +- supported dtypes +- output shapes and dtypes +- copy behavior +- background-label behavior, if relevant +- axis and coordinate conventions + +## What an agent should do when modifying this repository + +When adding or changing code: + +1. Keep the build dependency-light. +2. Do not add external C++ dependencies without strong justification. +3. Keep algorithm code separate from binding code. +4. Validate arrays at the Python/binding boundary. +5. Use nanobind only in the binding layer. +6. Use internal C++ array views rather than external multidimensional-array libraries. +7. Add tests for new behavior. +8. Preserve PyPI wheel portability. +9. Prefer clear error messages over permissive behavior. +10. Do not add I/O format support to the C++ core. +11. Do not add compatibility namespaces for older libraries. + +When unsure, choose the simpler design that is easier to build, test, and ship as wheels. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..388e439 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.21) +project(bioimage_cpp LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +find_package(Python 3.10 COMPONENTS Interpreter Development.Module REQUIRED) +find_package(nanobind CONFIG REQUIRED) + +nanobind_add_module(_core + NB_STATIC + src/bindings/module.cxx + src/bindings/utils.cxx + src/cpp/take_dict.cxx +) + +target_include_directories(_core PRIVATE include) +target_compile_features(_core PRIVATE cxx_std_20) + +install(TARGETS _core LIBRARY DESTINATION bioimage_cpp) diff --git a/README.md b/README.md index e86eea6..23c7e9b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # bioimage-cpp -Stand-alone implementation of image analysis and segmentaton functionality in C++ with minimal python bindings + +Stand-alone implementation of image analysis and segmentaton functionality in C++ with minimal python bindings. + +`bioimage-cpp` is a small C++/Python package for dependency-light bioimage-analysis algorithms that are difficult to install from larger C++ libraries. It exposes NumPy-based Python APIs backed by a focused C++20 core. + +## Install + +```bash +python -m pip install bioimage-cpp +``` + +## Build from source + +```bash +python -m pip install -v ".[test]" +pytest -q +``` + +## Example + +```python +import numpy as np +import bioimage_cpp as bic + +labels = np.array([1, 3, 2, 1], dtype=np.uint64) +relabeling = {1: 10, 2: 20, 3: 30} + +out = bic.utils.take_dict(relabeling, labels) +# array([10, 30, 20, 10], dtype=uint64) +``` + +## Scope + +The project is not a compatibility layer for `nifty`, `vigra`, or other large libraries. It keeps I/O and heavy dependencies out of the C++ core; callers should use existing Python packages for file formats and pass NumPy arrays into `bioimage-cpp`. diff --git a/include/bioimage_cpp/array_view.hxx b/include/bioimage_cpp/array_view.hxx new file mode 100644 index 0000000..6a17c78 --- /dev/null +++ b/include/bioimage_cpp/array_view.hxx @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace bioimage_cpp { + +template +struct ArrayView { + T *data = nullptr; + std::vector shape; + std::vector strides; + + [[nodiscard]] std::ptrdiff_t ndim() const { + return static_cast(shape.size()); + } +}; + +template +struct ConstArrayView { + const T *data = nullptr; + std::vector shape; + std::vector strides; + + [[nodiscard]] std::ptrdiff_t ndim() const { + return static_cast(shape.size()); + } +}; + +} // namespace bioimage_cpp diff --git a/include/bioimage_cpp/take_dict.hxx b/include/bioimage_cpp/take_dict.hxx new file mode 100644 index 0000000..ac1fca0 --- /dev/null +++ b/include/bioimage_cpp/take_dict.hxx @@ -0,0 +1,41 @@ +#pragma once + +#include "bioimage_cpp/array_view.hxx" + +#include +#include +#include +#include +#include + +namespace bioimage_cpp { + +template +void take_dict( + const std::unordered_map &relabeling, + const ConstArrayView &to_relabel, + const ArrayView &out +) { + if (to_relabel.shape != out.shape) { + throw std::invalid_argument( + "out shape must match to_relabel shape" + ); + } + + const auto n = std::accumulate( + to_relabel.shape.begin(), + to_relabel.shape.end(), + std::ptrdiff_t{1}, + [](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; } + ); + for (std::ptrdiff_t i = 0; i < n; ++i) { + const T value = to_relabel.data[i]; + const auto found = relabeling.find(value); + if (found == relabeling.end()) { + throw std::out_of_range("relabeling is missing key " + std::to_string(value)); + } + out.data[i] = found->second; + } +} + +} // namespace bioimage_cpp diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f3522ba --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["scikit-build-core>=0.8", "nanobind>=2.0"] +build-backend = "scikit_build_core.build" + +[project] +name = "bioimage-cpp" +version = "0.1.0" +description = "Dependency-light C++ bioimage analysis algorithms with Python bindings" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["numpy"] +license = { text = "MIT" } +authors = [ + { name = "bioimage-cpp contributors" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: C++", + "Programming Language :: C++ :: 20", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering :: Image Processing", +] + +[project.optional-dependencies] +test = ["pytest"] + +[tool.scikit-build] +cmake.version = ">=3.21" +wheel.packages = ["src/bioimage_cpp"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/bindings/module.cxx b/src/bindings/module.cxx new file mode 100644 index 0000000..bc3a0be --- /dev/null +++ b/src/bindings/module.cxx @@ -0,0 +1,10 @@ +#include "utils.hxx" + +#include + +namespace nb = nanobind; + +NB_MODULE(_core, m) { + m.doc() = "C++ extension module for bioimage_cpp."; + bioimage_cpp::bindings::bind_utils(m); +} diff --git a/src/bindings/utils.cxx b/src/bindings/utils.cxx new file mode 100644 index 0000000..b51de32 --- /dev/null +++ b/src/bindings/utils.cxx @@ -0,0 +1,113 @@ +#include "utils.hxx" + +#include "bioimage_cpp/array_view.hxx" +#include "bioimage_cpp/take_dict.hxx" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nb = nanobind; + +namespace bioimage_cpp::bindings { +namespace { + +template +using InputArray = nb::ndarray; + +template +using OutputArray = nb::ndarray; + +template +std::unordered_map dict_to_map(const nb::dict &relabeling) { + std::unordered_map result; + result.reserve(static_cast(nb::len(relabeling))); + + for (auto item : relabeling) { + const T key = nb::cast(item.first); + const T value = nb::cast(item.second); + result.emplace(key, value); + } + return result; +} + +template +OutputArray take_dict_t(const nb::dict &relabeling, InputArray to_relabel) { + std::vector ndarray_shape(to_relabel.ndim()); + std::vector view_shape(to_relabel.ndim()); + for (std::size_t axis = 0; axis < to_relabel.ndim(); ++axis) { + ndarray_shape[axis] = to_relabel.shape(axis); + view_shape[axis] = static_cast(to_relabel.shape(axis)); + } + + const auto n = std::accumulate( + view_shape.begin(), + view_shape.end(), + std::ptrdiff_t{1}, + [](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; } + ); + auto *data = new T[static_cast(n)](); + nb::capsule owner(data, [](void *p) noexcept { delete[] static_cast(p); }); + + ConstArrayView input{ + to_relabel.data(), + view_shape, + {}, + }; + ArrayView output{ + data, + view_shape, + {}, + }; + const auto map = dict_to_map(relabeling); + + { + nb::gil_scoped_release release; + take_dict(map, input, output); + } + + return OutputArray(data, ndarray_shape.size(), ndarray_shape.data(), owner); +} + +} // namespace + +void bind_utils(nb::module_ &m) { + m.def( + "_take_dict_uint32", + &take_dict_t, + nb::arg("relabeling"), + nb::arg("to_relabel"), + "Map a contiguous uint32 array through an integer dictionary." + ); + m.def( + "_take_dict_uint64", + &take_dict_t, + nb::arg("relabeling"), + nb::arg("to_relabel"), + "Map a contiguous uint64 array through an integer dictionary." + ); + m.def( + "_take_dict_int32", + &take_dict_t, + nb::arg("relabeling"), + nb::arg("to_relabel"), + "Map a contiguous int32 array through an integer dictionary." + ); + m.def( + "_take_dict_int64", + &take_dict_t, + nb::arg("relabeling"), + nb::arg("to_relabel"), + "Map a contiguous int64 array through an integer dictionary." + ); +} + +} // namespace bioimage_cpp::bindings diff --git a/src/bindings/utils.hxx b/src/bindings/utils.hxx new file mode 100644 index 0000000..82eb1ec --- /dev/null +++ b/src/bindings/utils.hxx @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace bioimage_cpp::bindings { + +void bind_utils(nanobind::module_ &m); + +} // namespace bioimage_cpp::bindings diff --git a/src/bioimage_cpp/__init__.py b/src/bioimage_cpp/__init__.py new file mode 100644 index 0000000..0b5ebdd --- /dev/null +++ b/src/bioimage_cpp/__init__.py @@ -0,0 +1,6 @@ +"""Dependency-light bioimage analysis algorithms backed by C++.""" + +from ._version import __version__ +from . import utils + +__all__ = ["__version__", "utils"] diff --git a/src/bioimage_cpp/_version.py b/src/bioimage_cpp/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/bioimage_cpp/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/bioimage_cpp/utils.py b/src/bioimage_cpp/utils.py new file mode 100644 index 0000000..798b680 --- /dev/null +++ b/src/bioimage_cpp/utils.py @@ -0,0 +1,69 @@ +"""Utility algorithms for array relabeling and related operations.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from . import _core + +_TAKE_DICT_BY_DTYPE = { + np.dtype("uint32"): _core._take_dict_uint32, + np.dtype("uint64"): _core._take_dict_uint64, + np.dtype("int32"): _core._take_dict_int32, + np.dtype("int64"): _core._take_dict_int64, +} + + +def take_dict(relabeling: Mapping[Any, Any], to_relabel: np.ndarray) -> np.ndarray: + """Map an integer array through a dictionary. + + Parameters + ---------- + relabeling: + Mapping from input labels to output labels. All keys present in + ``to_relabel`` must exist in the mapping. + to_relabel: + NumPy array with dtype ``uint32``, ``uint64``, ``int32``, or + ``int64``. Non-contiguous inputs are copied to a contiguous array + before calling the C++ kernel. + + Returns + ------- + np.ndarray + Array with the same shape and dtype as ``to_relabel``. + + Raises + ------ + TypeError + If ``relabeling`` is not a mapping or ``to_relabel`` has an + unsupported dtype. + IndexError + If a value in ``to_relabel`` is missing from ``relabeling``. + """ + if not isinstance(relabeling, Mapping): + raise TypeError("relabeling must be a mapping") + + array = np.asarray(to_relabel) + dtype = array.dtype + try: + take = _TAKE_DICT_BY_DTYPE[dtype] + except KeyError as error: + supported = ", ".join(str(dtype) for dtype in _TAKE_DICT_BY_DTYPE) + raise TypeError( + f"to_relabel must have one of dtypes ({supported}), got dtype={dtype}" + ) from error + + contiguous = np.ascontiguousarray(array) + try: + return take(dict(relabeling), contiguous) + except IndexError: + raise + except RuntimeError as error: + # nanobind translates std::out_of_range to IndexError in recent + # versions, but older builds may surface it as RuntimeError. + if "missing key" in str(error): + raise IndexError(str(error)) from error + raise diff --git a/src/cpp/take_dict.cxx b/src/cpp/take_dict.cxx new file mode 100644 index 0000000..f9dd407 --- /dev/null +++ b/src/cpp/take_dict.cxx @@ -0,0 +1,32 @@ +#include "bioimage_cpp/take_dict.hxx" + +#include +#include + +namespace bioimage_cpp { + +template void take_dict( + const std::unordered_map &, + const ConstArrayView &, + const ArrayView & +); + +template void take_dict( + const std::unordered_map &, + const ConstArrayView &, + const ArrayView & +); + +template void take_dict( + const std::unordered_map &, + const ConstArrayView &, + const ArrayView & +); + +template void take_dict( + const std::unordered_map &, + const ConstArrayView &, + const ArrayView & +); + +} // namespace bioimage_cpp diff --git a/tests/test_utils_take_dict.py b/tests/test_utils_take_dict.py new file mode 100644 index 0000000..45a7f32 --- /dev/null +++ b/tests/test_utils_take_dict.py @@ -0,0 +1,75 @@ +import numpy as np +import pytest + +import bioimage_cpp as bic + + +@pytest.mark.parametrize("dtype", [np.uint32, np.uint64, np.int32, np.int64]) +def test_take_dict(dtype): + to_relabel = np.array([1, 3, 2, 1], dtype=dtype) + relabeling = { + dtype(1).item(): dtype(10).item(), + dtype(2).item(): dtype(20).item(), + dtype(3).item(): dtype(30).item(), + } + + out = bic.utils.take_dict(relabeling, to_relabel) + + assert out.dtype == to_relabel.dtype + np.testing.assert_array_equal(out, np.array([10, 30, 20, 10], dtype=dtype)) + + +def test_take_dict_accepts_non_contiguous_input(): + to_relabel = np.array([0, 9, 1, 9, 2], dtype=np.uint64)[::2] + + out = bic.utils.take_dict({0: 5, 1: 6, 2: 7}, to_relabel) + + assert out.flags.c_contiguous + np.testing.assert_array_equal(out, np.array([5, 6, 7], dtype=np.uint64)) + + +def test_take_dict_preserves_2d_shape(): + to_relabel = np.array([[1, 2, 3], [3, 2, 1]], dtype=np.uint32) + + out = bic.utils.take_dict({1: 10, 2: 20, 3: 30}, to_relabel) + + assert out.shape == to_relabel.shape + assert out.dtype == to_relabel.dtype + np.testing.assert_array_equal( + out, np.array([[10, 20, 30], [30, 20, 10]], dtype=np.uint32) + ) + + +def test_take_dict_preserves_3d_shape(): + to_relabel = np.array([[[1, 2], [3, 1]], [[2, 3], [1, 2]]], dtype=np.int64) + + out = bic.utils.take_dict({1: -1, 2: -2, 3: -3}, to_relabel) + + assert out.shape == to_relabel.shape + np.testing.assert_array_equal( + out, np.array([[[-1, -2], [-3, -1]], [[-2, -3], [-1, -2]]], dtype=np.int64) + ) + + +def test_take_dict_accepts_non_contiguous_2d_input(): + to_relabel = np.array([[1, 9, 2], [3, 9, 1]], dtype=np.uint64)[:, ::2] + + out = bic.utils.take_dict({1: 10, 2: 20, 3: 30}, to_relabel) + + assert out.shape == (2, 2) + assert out.flags.c_contiguous + np.testing.assert_array_equal(out, np.array([[10, 20], [30, 10]], dtype=np.uint64)) + + +def test_take_dict_rejects_missing_key(): + with pytest.raises(IndexError, match="missing key 2"): + bic.utils.take_dict({1: 10}, np.array([1, 2], dtype=np.uint32)) + + +def test_take_dict_rejects_unsupported_dtype(): + with pytest.raises(TypeError, match="to_relabel must have one of dtypes"): + bic.utils.take_dict({1: 10}, np.array([1, 2], dtype=np.uint16)) + +def test_take_dict_rejects_non_mapping(): + with pytest.raises(TypeError, match="relabeling must be a mapping"): + bic.utils.take_dict([(1, 10)], np.array([1], dtype=np.uint64))