nanofractal 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nanofractal-0.1.0/.github/workflows/ci.yml +23 -0
- nanofractal-0.1.0/.github/workflows/release.yml +48 -0
- nanofractal-0.1.0/.gitignore +13 -0
- nanofractal-0.1.0/CMakeLists.txt +47 -0
- nanofractal-0.1.0/DEV.md +33 -0
- nanofractal-0.1.0/PATCHES.md +39 -0
- nanofractal-0.1.0/PKG-INFO +218 -0
- nanofractal-0.1.0/PUBLISHING.md +42 -0
- nanofractal-0.1.0/README.md +206 -0
- nanofractal-0.1.0/ci/build-opencv.sh +43 -0
- nanofractal-0.1.0/pyproject.toml +38 -0
- nanofractal-0.1.0/src/_bindings.cpp +477 -0
- nanofractal-0.1.0/src/aruco_dicts.hpp +22 -0
- nanofractal-0.1.0/src/nanofractal/__init__.py +145 -0
- nanofractal-0.1.0/src/nanofractal/__init__.pyi +63 -0
- nanofractal-0.1.0/src/nanofractal/py.typed +0 -0
- nanofractal-0.1.0/src/ndarray_cv.hpp +98 -0
- nanofractal-0.1.0/tests/conftest.py +21 -0
- nanofractal-0.1.0/tests/test_aruco.py +89 -0
- nanofractal-0.1.0/tests/test_batch.py +96 -0
- nanofractal-0.1.0/tests/test_benchmarks.py +45 -0
- nanofractal-0.1.0/tests/test_bridge.py +69 -0
- nanofractal-0.1.0/tests/test_fractal.py +201 -0
- nanofractal-0.1.0/tests/test_smoke.py +11 -0
- nanofractal-0.1.0/third_party/aruco_nano_v6.h +322 -0
- nanofractal-0.1.0/third_party/nanofractal.h +1582 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: build & test (system OpenCV)
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Install OpenCV dev headers
|
|
19
|
+
run: sudo apt-get update && sudo apt-get install -y libopencv-dev
|
|
20
|
+
- name: Build and install
|
|
21
|
+
run: pip install -e ".[test]"
|
|
22
|
+
- name: Run tests
|
|
23
|
+
run: python -m pytest tests -q --ignore tests/test_benchmarks.py
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Build portable manylinux wheels + sdist and publish to PyPI on a version tag.
|
|
4
|
+
# Push a tag like v0.1.0 to trigger a release:
|
|
5
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
wheels:
|
|
13
|
+
name: Build wheels (manylinux x86_64)
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Build wheels (cibuildwheel + static OpenCV)
|
|
18
|
+
run: pipx run cibuildwheel --output-dir wheelhouse
|
|
19
|
+
- uses: actions/upload-artifact@v4
|
|
20
|
+
with:
|
|
21
|
+
name: cibw-wheels
|
|
22
|
+
path: wheelhouse/*.whl
|
|
23
|
+
|
|
24
|
+
sdist:
|
|
25
|
+
name: Build sdist
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- name: Build sdist
|
|
30
|
+
run: pipx run build --sdist
|
|
31
|
+
- uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: cibw-sdist
|
|
34
|
+
path: dist/*.tar.gz
|
|
35
|
+
|
|
36
|
+
publish:
|
|
37
|
+
name: Publish to PyPI
|
|
38
|
+
needs: [wheels, sdist]
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
path: dist
|
|
45
|
+
merge-multiple: true
|
|
46
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
47
|
+
with:
|
|
48
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.18)
|
|
2
|
+
project(nanofractal LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
5
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
6
|
+
set(CMAKE_CXX_EXTENSIONS OFF)
|
|
7
|
+
|
|
8
|
+
find_package(Python 3.9 COMPONENTS Interpreter Development.Module REQUIRED)
|
|
9
|
+
|
|
10
|
+
# Locate nanobind shipped with the build environment
|
|
11
|
+
execute_process(
|
|
12
|
+
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
|
|
13
|
+
OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT)
|
|
14
|
+
list(APPEND CMAKE_PREFIX_PATH "${nanobind_ROOT}")
|
|
15
|
+
find_package(nanobind CONFIG REQUIRED)
|
|
16
|
+
|
|
17
|
+
# OpenCV: system by default. CI (Plan B) overrides via -DOpenCV_DIR=<minimal build>
|
|
18
|
+
find_package(OpenCV REQUIRED COMPONENTS core imgproc calib3d features2d)
|
|
19
|
+
|
|
20
|
+
nanobind_add_module(_nanofractal NB_STATIC src/_bindings.cpp)
|
|
21
|
+
|
|
22
|
+
target_include_directories(_nanofractal PRIVATE
|
|
23
|
+
"${CMAKE_SOURCE_DIR}/third_party"
|
|
24
|
+
"${CMAKE_SOURCE_DIR}/src"
|
|
25
|
+
${OpenCV_INCLUDE_DIRS})
|
|
26
|
+
target_link_libraries(_nanofractal PRIVATE ${OpenCV_LIBS})
|
|
27
|
+
|
|
28
|
+
# Release already implies -O3 on GCC/Clang; keep it explicit but MSVC-safe.
|
|
29
|
+
target_compile_options(_nanofractal PRIVATE
|
|
30
|
+
$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-O3>)
|
|
31
|
+
|
|
32
|
+
# Single source of truth for the version: injected by scikit-build-core from
|
|
33
|
+
# pyproject.toml. Falls back for non-skbuild direct cmake builds.
|
|
34
|
+
if(DEFINED SKBUILD_PROJECT_VERSION)
|
|
35
|
+
set(_NF_VERSION "${SKBUILD_PROJECT_VERSION}")
|
|
36
|
+
else()
|
|
37
|
+
set(_NF_VERSION "0.0.0")
|
|
38
|
+
endif()
|
|
39
|
+
target_compile_definitions(_nanofractal PRIVATE NF_VERSION="${_NF_VERSION}")
|
|
40
|
+
|
|
41
|
+
include(CheckIPOSupported)
|
|
42
|
+
check_ipo_supported(RESULT _ipo_ok OUTPUT _ipo_msg)
|
|
43
|
+
if(_ipo_ok)
|
|
44
|
+
set_property(TARGET _nanofractal PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
|
|
45
|
+
endif()
|
|
46
|
+
|
|
47
|
+
install(TARGETS _nanofractal LIBRARY DESTINATION nanofractal)
|
nanofractal-0.1.0/DEV.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Development notes
|
|
2
|
+
|
|
3
|
+
## Environment
|
|
4
|
+
|
|
5
|
+
Use the project virtualenv at `.venv` for all builds and tests:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
.venv/bin/python -m pip install -e ".[test]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### IMPORTANT: strip PYTHONPATH when running Python/pytest
|
|
12
|
+
|
|
13
|
+
This machine sources a ROS (Jazzy) + Livox workspace in the shell, which sets a
|
|
14
|
+
`PYTHONPATH` pointing at `/opt/ros/jazzy/...` and a `ws_livox` workspace. Those
|
|
15
|
+
directories contain pytest plugins (ament/launch_testing_ros) that get
|
|
16
|
+
autoloaded and crash during collection (e.g. `ModuleNotFoundError: No module
|
|
17
|
+
named 'yaml'`). The fix is to unset `PYTHONPATH` for our commands:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
env -u PYTHONPATH .venv/bin/python -m pytest tests/ -v
|
|
21
|
+
env -u PYTHONPATH .venv/bin/python -m pip install -e ".[test]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Always prefix Python/pytest invocations with `env -u PYTHONPATH`.
|
|
25
|
+
|
|
26
|
+
## Build
|
|
27
|
+
|
|
28
|
+
The extension is built by scikit-build-core + CMake (nanobind). A normal
|
|
29
|
+
editable install recompiles after C++ changes:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
env -u PYTHONPATH .venv/bin/python -m pip install -e ".[test]"
|
|
33
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Patches to vendored headers
|
|
2
|
+
|
|
3
|
+
We vendor `third_party/aruco_nano_v6.h` and `third_party/nanofractal.h` from
|
|
4
|
+
upstream and keep them as close to upstream as possible. The unmodified upstream
|
|
5
|
+
of `nanofractal.h` is recoverable from git history (the commit immediately before
|
|
6
|
+
the patch described below). The only intentional divergences are listed here.
|
|
7
|
+
|
|
8
|
+
## third_party/nanofractal.h — guard the inner-point path against empty FAST keypoints
|
|
9
|
+
|
|
10
|
+
**Symptom:** `FractalMarkerDetector::detect(img, p3d, p2d)` (the `with_inner_points`
|
|
11
|
+
path) segfaulted on valid `uint8` images when `cv::FastFeatureDetector` returned
|
|
12
|
+
zero keypoints (e.g. clean/synthetic markers with no sub-cell texture).
|
|
13
|
+
|
|
14
|
+
**Root cause:** `_private::kfilter()` did `kpoints[0].response` without checking
|
|
15
|
+
for an empty vector; downstream, the picoflann kdtree `radiusSearch` would also
|
|
16
|
+
index an empty index.
|
|
17
|
+
|
|
18
|
+
**Patch (2 guards):**
|
|
19
|
+
1. In `kfilter()`: `if (kpoints.empty()) return;` before touching `kpoints[0]`.
|
|
20
|
+
2. In `detect(img, p3d, p2d)`, after `assignClass`: `if (kpoints.empty()) return detected;`
|
|
21
|
+
so the kdtree matching is skipped and the markers are returned with empty
|
|
22
|
+
`p2d`/`p3d`.
|
|
23
|
+
|
|
24
|
+
Both are marked inline with `// nanofractal patch:` comments. With no keypoints
|
|
25
|
+
there are simply no inner-corner correspondences, which is the correct result.
|
|
26
|
+
|
|
27
|
+
Regression tests: `tests/test_fractal.py::test_detect_with_inner_points_empty_is_safe`
|
|
28
|
+
(clean image → empty, no crash) and `::test_detect_with_inner_points_nonempty`
|
|
29
|
+
(noisy image → populated correspondences).
|
|
30
|
+
|
|
31
|
+
## Both headers — remove unused `#include <opencv2/highgui.hpp>`
|
|
32
|
+
|
|
33
|
+
`aruco_nano_v6.h` and `nanofractal.h` each `#include <opencv2/highgui.hpp>`, but
|
|
34
|
+
their detection/pose/draw code paths call **no** highgui functions (highgui only
|
|
35
|
+
appears in the example snippets in the file comments, e.g. `imread`/`imwrite`).
|
|
36
|
+
The include is removed (replaced by a `// nanofractal patch:` comment) so the
|
|
37
|
+
library builds against a **minimal OpenCV** without the `highgui` module — which
|
|
38
|
+
is how the portable wheels are built in CI (`ci/build-opencv.sh`). Builds against
|
|
39
|
+
a full system OpenCV are unaffected.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: nanofractal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance fiducial marker detection (ArUco Nano v6 + Fractal)
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: numpy>=1.21
|
|
8
|
+
Provides-Extra: test
|
|
9
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
10
|
+
Requires-Dist: pytest-benchmark>=4; extra == "test"
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# nanofractal
|
|
14
|
+
|
|
15
|
+
High-performance fiducial-marker detection for Python. `nanofractal` wraps two
|
|
16
|
+
compact, header-only C++ detectors with [nanobind](https://github.com/wjakob/nanobind):
|
|
17
|
+
|
|
18
|
+
- **ArUco Nano v6** — square markers (`ARUCO_MIP_36h12` and AprilTag `36h11`).
|
|
19
|
+
- **Fractal markers** — nested markers that stay detectable under heavy occlusion
|
|
20
|
+
and expose many inner corner correspondences for accurate, long-range pose.
|
|
21
|
+
|
|
22
|
+
It is built for speed: **zero-copy** NumPy ↔ `cv::Mat`, the **GIL is released**
|
|
23
|
+
during detection, and a **parallel batch** API scales across cores.
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
single-frame detect(): ~0.43 ms @ 640x480 ~1.3 ms @ 1280x720 ~2.9 ms @ 1920x1080
|
|
27
|
+
batch detect_batch(): ~3.2x throughput on 4 threads
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> Measured on a desktop CPU with `max_attempts=1`; your numbers will vary.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install nanofractal
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Wheels bundle a minimal OpenCV, so no system OpenCV is required at runtime.
|
|
41
|
+
|
|
42
|
+
### Build from source
|
|
43
|
+
|
|
44
|
+
You need a C++17 compiler, CMake ≥ 3.18 and a development OpenCV
|
|
45
|
+
(`core`, `imgproc`, `calib3d`, `features2d`):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Debian/Ubuntu
|
|
49
|
+
sudo apt-get install -y build-essential cmake libopencv-dev
|
|
50
|
+
|
|
51
|
+
pip install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
Inputs are plain NumPy `uint8` arrays — either `(H, W)` grayscale or `(H, W, 3)`
|
|
59
|
+
BGR, and **C-contiguous** (use `np.ascontiguousarray` if unsure). Any image loader
|
|
60
|
+
works; the examples use OpenCV.
|
|
61
|
+
|
|
62
|
+
### Detect ArUco / AprilTag markers
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import cv2
|
|
66
|
+
import nanofractal as nf
|
|
67
|
+
|
|
68
|
+
image = cv2.imread("scene.png") # (H, W, 3) uint8 BGR
|
|
69
|
+
det = nf.ArucoDetector(nf.Dict.ARUCO_MIP_36h12) # or nf.Dict.APRILTAG_36h11
|
|
70
|
+
|
|
71
|
+
res = det.detect(image)
|
|
72
|
+
print(res.ids) # int32 (N,) e.g. [ 7 42]
|
|
73
|
+
print(res.corners) # float32 (N, 4, 2) clockwise corners, subpixel
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Estimate pose
|
|
77
|
+
|
|
78
|
+
`estimate_pose` runs `solvePnP` (IPPE) for every detected marker at once.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import numpy as np
|
|
82
|
+
|
|
83
|
+
camera_matrix = np.array([[600, 0, 320],
|
|
84
|
+
[0, 600, 240],
|
|
85
|
+
[0, 0, 1]], dtype=np.float64)
|
|
86
|
+
dist_coeffs = np.zeros(5, dtype=np.float64)
|
|
87
|
+
|
|
88
|
+
rvecs, tvecs = det.estimate_pose(res.corners, camera_matrix, dist_coeffs,
|
|
89
|
+
marker_size=0.05) # marker side in metres
|
|
90
|
+
# rvecs, tvecs: float64 (N, 3) — rotation (Rodrigues) and translation per marker
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Detect fractal markers
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres (optional)
|
|
97
|
+
|
|
98
|
+
res = fdet.detect(image)
|
|
99
|
+
print(res.ids, res.corners.shape) # outer 4 corners of each fractal marker
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Fractal pose + visualization (occlusion-robust)
|
|
103
|
+
|
|
104
|
+
`FractalDetector.estimate_pose` returns one marker pose `(rvec, tvec, reproj_err)`
|
|
105
|
+
or `None`. It uses every visible inner **and** outer corner correspondence when
|
|
106
|
+
available (accurate, robust to occlusion) and otherwise falls back to the four
|
|
107
|
+
outer corners — so you never call `solvePnP` yourself or worry about the
|
|
108
|
+
empty-inner-points case. `reproj_err` (RMS pixels) lets you gate noisy poses.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres
|
|
112
|
+
|
|
113
|
+
res = fdet.detect(image, with_inner_points=True)
|
|
114
|
+
pose = fdet.estimate_pose(res, camera_matrix, dist_coeffs)
|
|
115
|
+
if pose is not None:
|
|
116
|
+
rvec, tvec, reproj_err = pose # rvec, tvec: float64 (3,); reproj_err: px
|
|
117
|
+
fdet.draw(image, res, camera_matrix, dist_coeffs, rvec, tvec) # corners + axes
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`draw(image, result, ...)` overlays marker outlines, ids and (given a pose) the
|
|
121
|
+
frame axes in place — no `cv2.polylines`/`drawFrameAxes` boilerplate. Without a
|
|
122
|
+
pose, `fdet.draw(image, res)` just draws the outlines.
|
|
123
|
+
|
|
124
|
+
The raw correspondences are still exposed if you prefer to run PnP yourself:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
res.points_2d # float32 (M, 2) image points (None unless with_inner_points=True)
|
|
128
|
+
res.points_3d # float32 (M, 3) object points (planar, z = 0)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Parallel batch (offline throughput)
|
|
132
|
+
|
|
133
|
+
Process many frames across a thread pool. The GIL is released, so it scales with
|
|
134
|
+
cores. `num_threads=0` uses all cores.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
frames = [cv2.imread(p) for p in paths] # list of uint8 arrays
|
|
138
|
+
results = det.detect_batch(frames, num_threads=0) # list[DetectionResult]
|
|
139
|
+
for r in results:
|
|
140
|
+
print(r.ids)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## API
|
|
146
|
+
|
|
147
|
+
### `ArucoDetector(dictionary=Dict.ARUCO_MIP_36h12, max_attempts=1)`
|
|
148
|
+
- `dictionary: Dict` — `ARUCO_MIP_36h12` or `APRILTAG_36h11`.
|
|
149
|
+
- `max_attempts: int` — retries per candidate with small corner jitter. `1` is
|
|
150
|
+
fastest (real-time default); raise (up to ~10) for harder images.
|
|
151
|
+
- `detect(image) -> DetectionResult`
|
|
152
|
+
- `detect_batch(images, num_threads=0) -> list[DetectionResult]`
|
|
153
|
+
- `estimate_pose(corners, camera_matrix, dist_coeffs, marker_size) -> (rvecs, tvecs)`
|
|
154
|
+
— `corners` is `(N, 4, 2)` float32; outputs are `(N, 3)` float64.
|
|
155
|
+
|
|
156
|
+
### `FractalDetector(config, marker_size=-1.0)`
|
|
157
|
+
- `config: str` — one of `FRACTAL_2L_6`, `FRACTAL_3L_6`, `FRACTAL_4L_6`,
|
|
158
|
+
`FRACTAL_5L_6`.
|
|
159
|
+
- `marker_size: float` — outer marker side in metres; if set, `points_3d` is
|
|
160
|
+
returned in metres (otherwise normalized).
|
|
161
|
+
- `detect(image, with_inner_points=False) -> DetectionResult`
|
|
162
|
+
- `detect_batch(images, num_threads=0) -> list[DetectionResult]`
|
|
163
|
+
- `estimate_pose(result, camera_matrix, dist_coeffs) -> (rvec, tvec, reproj_err) | None`
|
|
164
|
+
— single-marker pose; uses inner+outer points when ≥ 4, else the 4 outer
|
|
165
|
+
corners; `rvec`/`tvec` are float64 `(3,)`, `reproj_err` is RMS pixels.
|
|
166
|
+
- `draw(image, result, camera_matrix=None, dist_coeffs=None, rvec=None, tvec=None, axis_length=None) -> image`
|
|
167
|
+
— draw outlines + ids (and frame axes when a pose is given) in place; `image`
|
|
168
|
+
must be a writable BGR `uint8` array.
|
|
169
|
+
|
|
170
|
+
### `DetectionResult`
|
|
171
|
+
| field | dtype / shape | meaning |
|
|
172
|
+
|-------|---------------|---------|
|
|
173
|
+
| `ids` | int32 `(N,)` | marker ids |
|
|
174
|
+
| `corners` | float32 `(N, 4, 2)` | outer corners (subpixel, clockwise) |
|
|
175
|
+
| `points_2d` | float32 `(M, 2)` or `None` | inner+outer image points (fractal, `with_inner_points=True`) |
|
|
176
|
+
| `points_3d` | float32 `(M, 3)` or `None` | matching object points |
|
|
177
|
+
|
|
178
|
+
Empty results are returned as correctly-shaped empty arrays (`(0,)`, `(0, 4, 2)`),
|
|
179
|
+
never `None`.
|
|
180
|
+
|
|
181
|
+
### Errors
|
|
182
|
+
- Wrong dtype / non-contiguous input → `TypeError`.
|
|
183
|
+
- Unsupported shape, empty frame, invalid dictionary or fractal config → `ValueError`.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Performance notes
|
|
188
|
+
|
|
189
|
+
- **Zero-copy input.** A contiguous `uint8` array is wrapped as a `cv::Mat` over
|
|
190
|
+
the same buffer — no copy. Non-contiguous or wrong-dtype inputs raise instead of
|
|
191
|
+
silently copying.
|
|
192
|
+
- **GIL released** during the native detection, so other Python threads keep
|
|
193
|
+
running and `detect_batch` scales.
|
|
194
|
+
- **Thread safety.** The ArUco detector is stateless and shared across batch
|
|
195
|
+
workers. The fractal detector is not thread-safe, so `detect_batch` uses a pool
|
|
196
|
+
of independent detectors (one per worker). A single detector object is fine to
|
|
197
|
+
call from one thread at a time.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Citation
|
|
202
|
+
|
|
203
|
+
If you use this in research, please cite the original work:
|
|
204
|
+
|
|
205
|
+
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer,
|
|
206
|
+
*"Speeded up detection of squared fiducial markers"*, Image and Vision
|
|
207
|
+
Computing, 76, 2018.
|
|
208
|
+
- S. Garrido-Jurado, R. Muñoz-Salinas, F. J. Madrid-Cuevas, R. Medina-Carnicer,
|
|
209
|
+
*"Generation of fiducial marker dictionaries using mixed integer linear
|
|
210
|
+
programming"*, Pattern Recognition, 51, 2016.
|
|
211
|
+
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer,
|
|
212
|
+
*"Fractal Markers: A New Approach for Long-Range Marker Pose Estimation Under
|
|
213
|
+
Occlusion"*, IEEE Access, 7, 2019.
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
Apache-2.0. The vendored detectors (ArUco Nano, Fractal markers) are © their
|
|
218
|
+
authors and used under their terms; see `third_party/` and `PATCHES.md`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Publishing to PyPI
|
|
2
|
+
|
|
3
|
+
Releases are automated by GitHub Actions (`.github/workflows/release.yml`):
|
|
4
|
+
pushing a version tag builds portable `manylinux` wheels (with a minimal static
|
|
5
|
+
OpenCV linked in — see `ci/build-opencv.sh`) plus an sdist, and uploads them to
|
|
6
|
+
PyPI.
|
|
7
|
+
|
|
8
|
+
## One-time setup: PyPI token as a repo secret
|
|
9
|
+
|
|
10
|
+
The release workflow authenticates with a PyPI API token stored as the GitHub
|
|
11
|
+
Actions secret **`PYPI_API_TOKEN`**.
|
|
12
|
+
|
|
13
|
+
- GitHub → repo → Settings → Secrets and variables → Actions → New repository secret
|
|
14
|
+
- Name: `PYPI_API_TOKEN`
|
|
15
|
+
- Value: a token from https://pypi.org/manage/account/token/ (starts with `pypi-`)
|
|
16
|
+
|
|
17
|
+
(Or, instead of `gh`'s web UI: `gh secret set PYPI_API_TOKEN`.)
|
|
18
|
+
|
|
19
|
+
## Cut a release
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# bump version in pyproject.toml first if needed, commit, then:
|
|
23
|
+
git tag v0.1.0
|
|
24
|
+
git push origin main
|
|
25
|
+
git push origin v0.1.0 # this triggers the release workflow -> PyPI
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each release needs a **new version** — PyPI rejects re-uploading an existing one.
|
|
29
|
+
Pushing to `main` only runs the fast `ci` checks; it does **not** publish.
|
|
30
|
+
|
|
31
|
+
## Manual / local publish (fallback)
|
|
32
|
+
|
|
33
|
+
`.pypirc` at the repo root (git-ignored) holds your token for manual uploads:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
.venv/bin/python -m pip install build twine
|
|
37
|
+
.venv/bin/python -m build
|
|
38
|
+
.venv/bin/python -m twine upload --config-file .pypirc -r pypi dist/*
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Note: a locally built wheel links your **system OpenCV** and is not portable —
|
|
42
|
+
use it only for a TestPyPI smoke test. The CI wheels are the portable ones.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# nanofractal
|
|
2
|
+
|
|
3
|
+
High-performance fiducial-marker detection for Python. `nanofractal` wraps two
|
|
4
|
+
compact, header-only C++ detectors with [nanobind](https://github.com/wjakob/nanobind):
|
|
5
|
+
|
|
6
|
+
- **ArUco Nano v6** — square markers (`ARUCO_MIP_36h12` and AprilTag `36h11`).
|
|
7
|
+
- **Fractal markers** — nested markers that stay detectable under heavy occlusion
|
|
8
|
+
and expose many inner corner correspondences for accurate, long-range pose.
|
|
9
|
+
|
|
10
|
+
It is built for speed: **zero-copy** NumPy ↔ `cv::Mat`, the **GIL is released**
|
|
11
|
+
during detection, and a **parallel batch** API scales across cores.
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
single-frame detect(): ~0.43 ms @ 640x480 ~1.3 ms @ 1280x720 ~2.9 ms @ 1920x1080
|
|
15
|
+
batch detect_batch(): ~3.2x throughput on 4 threads
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> Measured on a desktop CPU with `max_attempts=1`; your numbers will vary.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install nanofractal
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Wheels bundle a minimal OpenCV, so no system OpenCV is required at runtime.
|
|
29
|
+
|
|
30
|
+
### Build from source
|
|
31
|
+
|
|
32
|
+
You need a C++17 compiler, CMake ≥ 3.18 and a development OpenCV
|
|
33
|
+
(`core`, `imgproc`, `calib3d`, `features2d`):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Debian/Ubuntu
|
|
37
|
+
sudo apt-get install -y build-essential cmake libopencv-dev
|
|
38
|
+
|
|
39
|
+
pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Inputs are plain NumPy `uint8` arrays — either `(H, W)` grayscale or `(H, W, 3)`
|
|
47
|
+
BGR, and **C-contiguous** (use `np.ascontiguousarray` if unsure). Any image loader
|
|
48
|
+
works; the examples use OpenCV.
|
|
49
|
+
|
|
50
|
+
### Detect ArUco / AprilTag markers
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import cv2
|
|
54
|
+
import nanofractal as nf
|
|
55
|
+
|
|
56
|
+
image = cv2.imread("scene.png") # (H, W, 3) uint8 BGR
|
|
57
|
+
det = nf.ArucoDetector(nf.Dict.ARUCO_MIP_36h12) # or nf.Dict.APRILTAG_36h11
|
|
58
|
+
|
|
59
|
+
res = det.detect(image)
|
|
60
|
+
print(res.ids) # int32 (N,) e.g. [ 7 42]
|
|
61
|
+
print(res.corners) # float32 (N, 4, 2) clockwise corners, subpixel
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Estimate pose
|
|
65
|
+
|
|
66
|
+
`estimate_pose` runs `solvePnP` (IPPE) for every detected marker at once.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import numpy as np
|
|
70
|
+
|
|
71
|
+
camera_matrix = np.array([[600, 0, 320],
|
|
72
|
+
[0, 600, 240],
|
|
73
|
+
[0, 0, 1]], dtype=np.float64)
|
|
74
|
+
dist_coeffs = np.zeros(5, dtype=np.float64)
|
|
75
|
+
|
|
76
|
+
rvecs, tvecs = det.estimate_pose(res.corners, camera_matrix, dist_coeffs,
|
|
77
|
+
marker_size=0.05) # marker side in metres
|
|
78
|
+
# rvecs, tvecs: float64 (N, 3) — rotation (Rodrigues) and translation per marker
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Detect fractal markers
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres (optional)
|
|
85
|
+
|
|
86
|
+
res = fdet.detect(image)
|
|
87
|
+
print(res.ids, res.corners.shape) # outer 4 corners of each fractal marker
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Fractal pose + visualization (occlusion-robust)
|
|
91
|
+
|
|
92
|
+
`FractalDetector.estimate_pose` returns one marker pose `(rvec, tvec, reproj_err)`
|
|
93
|
+
or `None`. It uses every visible inner **and** outer corner correspondence when
|
|
94
|
+
available (accurate, robust to occlusion) and otherwise falls back to the four
|
|
95
|
+
outer corners — so you never call `solvePnP` yourself or worry about the
|
|
96
|
+
empty-inner-points case. `reproj_err` (RMS pixels) lets you gate noisy poses.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres
|
|
100
|
+
|
|
101
|
+
res = fdet.detect(image, with_inner_points=True)
|
|
102
|
+
pose = fdet.estimate_pose(res, camera_matrix, dist_coeffs)
|
|
103
|
+
if pose is not None:
|
|
104
|
+
rvec, tvec, reproj_err = pose # rvec, tvec: float64 (3,); reproj_err: px
|
|
105
|
+
fdet.draw(image, res, camera_matrix, dist_coeffs, rvec, tvec) # corners + axes
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`draw(image, result, ...)` overlays marker outlines, ids and (given a pose) the
|
|
109
|
+
frame axes in place — no `cv2.polylines`/`drawFrameAxes` boilerplate. Without a
|
|
110
|
+
pose, `fdet.draw(image, res)` just draws the outlines.
|
|
111
|
+
|
|
112
|
+
The raw correspondences are still exposed if you prefer to run PnP yourself:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
res.points_2d # float32 (M, 2) image points (None unless with_inner_points=True)
|
|
116
|
+
res.points_3d # float32 (M, 3) object points (planar, z = 0)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Parallel batch (offline throughput)
|
|
120
|
+
|
|
121
|
+
Process many frames across a thread pool. The GIL is released, so it scales with
|
|
122
|
+
cores. `num_threads=0` uses all cores.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
frames = [cv2.imread(p) for p in paths] # list of uint8 arrays
|
|
126
|
+
results = det.detect_batch(frames, num_threads=0) # list[DetectionResult]
|
|
127
|
+
for r in results:
|
|
128
|
+
print(r.ids)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## API
|
|
134
|
+
|
|
135
|
+
### `ArucoDetector(dictionary=Dict.ARUCO_MIP_36h12, max_attempts=1)`
|
|
136
|
+
- `dictionary: Dict` — `ARUCO_MIP_36h12` or `APRILTAG_36h11`.
|
|
137
|
+
- `max_attempts: int` — retries per candidate with small corner jitter. `1` is
|
|
138
|
+
fastest (real-time default); raise (up to ~10) for harder images.
|
|
139
|
+
- `detect(image) -> DetectionResult`
|
|
140
|
+
- `detect_batch(images, num_threads=0) -> list[DetectionResult]`
|
|
141
|
+
- `estimate_pose(corners, camera_matrix, dist_coeffs, marker_size) -> (rvecs, tvecs)`
|
|
142
|
+
— `corners` is `(N, 4, 2)` float32; outputs are `(N, 3)` float64.
|
|
143
|
+
|
|
144
|
+
### `FractalDetector(config, marker_size=-1.0)`
|
|
145
|
+
- `config: str` — one of `FRACTAL_2L_6`, `FRACTAL_3L_6`, `FRACTAL_4L_6`,
|
|
146
|
+
`FRACTAL_5L_6`.
|
|
147
|
+
- `marker_size: float` — outer marker side in metres; if set, `points_3d` is
|
|
148
|
+
returned in metres (otherwise normalized).
|
|
149
|
+
- `detect(image, with_inner_points=False) -> DetectionResult`
|
|
150
|
+
- `detect_batch(images, num_threads=0) -> list[DetectionResult]`
|
|
151
|
+
- `estimate_pose(result, camera_matrix, dist_coeffs) -> (rvec, tvec, reproj_err) | None`
|
|
152
|
+
— single-marker pose; uses inner+outer points when ≥ 4, else the 4 outer
|
|
153
|
+
corners; `rvec`/`tvec` are float64 `(3,)`, `reproj_err` is RMS pixels.
|
|
154
|
+
- `draw(image, result, camera_matrix=None, dist_coeffs=None, rvec=None, tvec=None, axis_length=None) -> image`
|
|
155
|
+
— draw outlines + ids (and frame axes when a pose is given) in place; `image`
|
|
156
|
+
must be a writable BGR `uint8` array.
|
|
157
|
+
|
|
158
|
+
### `DetectionResult`
|
|
159
|
+
| field | dtype / shape | meaning |
|
|
160
|
+
|-------|---------------|---------|
|
|
161
|
+
| `ids` | int32 `(N,)` | marker ids |
|
|
162
|
+
| `corners` | float32 `(N, 4, 2)` | outer corners (subpixel, clockwise) |
|
|
163
|
+
| `points_2d` | float32 `(M, 2)` or `None` | inner+outer image points (fractal, `with_inner_points=True`) |
|
|
164
|
+
| `points_3d` | float32 `(M, 3)` or `None` | matching object points |
|
|
165
|
+
|
|
166
|
+
Empty results are returned as correctly-shaped empty arrays (`(0,)`, `(0, 4, 2)`),
|
|
167
|
+
never `None`.
|
|
168
|
+
|
|
169
|
+
### Errors
|
|
170
|
+
- Wrong dtype / non-contiguous input → `TypeError`.
|
|
171
|
+
- Unsupported shape, empty frame, invalid dictionary or fractal config → `ValueError`.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Performance notes
|
|
176
|
+
|
|
177
|
+
- **Zero-copy input.** A contiguous `uint8` array is wrapped as a `cv::Mat` over
|
|
178
|
+
the same buffer — no copy. Non-contiguous or wrong-dtype inputs raise instead of
|
|
179
|
+
silently copying.
|
|
180
|
+
- **GIL released** during the native detection, so other Python threads keep
|
|
181
|
+
running and `detect_batch` scales.
|
|
182
|
+
- **Thread safety.** The ArUco detector is stateless and shared across batch
|
|
183
|
+
workers. The fractal detector is not thread-safe, so `detect_batch` uses a pool
|
|
184
|
+
of independent detectors (one per worker). A single detector object is fine to
|
|
185
|
+
call from one thread at a time.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Citation
|
|
190
|
+
|
|
191
|
+
If you use this in research, please cite the original work:
|
|
192
|
+
|
|
193
|
+
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer,
|
|
194
|
+
*"Speeded up detection of squared fiducial markers"*, Image and Vision
|
|
195
|
+
Computing, 76, 2018.
|
|
196
|
+
- S. Garrido-Jurado, R. Muñoz-Salinas, F. J. Madrid-Cuevas, R. Medina-Carnicer,
|
|
197
|
+
*"Generation of fiducial marker dictionaries using mixed integer linear
|
|
198
|
+
programming"*, Pattern Recognition, 51, 2016.
|
|
199
|
+
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer,
|
|
200
|
+
*"Fractal Markers: A New Approach for Long-Range Marker Pose Estimation Under
|
|
201
|
+
Occlusion"*, IEEE Access, 7, 2019.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
Apache-2.0. The vendored detectors (ArUco Nano, Fractal markers) are © their
|
|
206
|
+
authors and used under their terms; see `third_party/` and `PATCHES.md`.
|