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.
@@ -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,13 @@
1
+ build/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.so
6
+ .pytest_cache/
7
+ .benchmarks/
8
+ _skbuild/
9
+ .venv/
10
+ # secrets — never commit a real token
11
+ .pypirc
12
+ .env
13
+ docs/
@@ -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)
@@ -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`.