High-performance fiducial marker detection (ArUco Nano v6 + Fractal)
Project description
nanofractal
High-performance fiducial-marker detection for Python. nanofractal wraps two
compact, header-only C++ detectors with nanobind:
- ArUco Nano v6 — square markers: all standard OpenCV ArUco dictionaries
(4×4, 5×5, 6×6, 7×7) plus
ARUCO_MIP_36h12and AprilTag36h11. - Fractal markers — nested markers that stay detectable under heavy occlusion and expose many inner corner correspondences for accurate, long-range pose.
It is built for speed: zero-copy NumPy ↔ cv::Mat, the GIL is released
during detection, and a parallel batch API scales across cores.
single-frame detect(): ~0.43 ms @ 640×480 ~1.0 ms @ 1280×720 ~3.1 ms @ 1920×1080
detection_scale=0.5: ~4× faster on the threshold/contour stage (corners refined at full res)
batch detect_batch(): ~3.2× throughput on 4 threads
Measured on a desktop CPU with
max_attempts=1; your numbers will vary.
Installation
pip install nanofractal
Wheels bundle a minimal OpenCV, so no system OpenCV is required at runtime.
Build from source
You need a C++17 compiler, CMake ≥ 3.18 and a development OpenCV
(core, imgproc, calib3d, features2d):
# Debian/Ubuntu
sudo apt-get install -y build-essential cmake libopencv-dev
pip install .
Local dev build with CPU tuning
# Enable -march=native + -ffast-math for maximum local performance:
NF_NATIVE=1 pip install -e . --no-build-isolation
Quick start
Inputs are plain NumPy uint8 arrays — either (H, W) grayscale or (H, W, 3)
BGR, and C-contiguous (use np.ascontiguousarray if unsure). Any image loader
works; the examples use OpenCV.
Detect ArUco markers
import cv2
import nanofractal as nf
image = cv2.imread("scene.png") # (H, W, 3) uint8 BGR
# Standard 4×4 dictionary (50 unique markers)
det = nf.ArucoDetector(nf.Dict.DICT_4X4_50)
# Or the legacy / AprilTag dictionaries:
# det = nf.ArucoDetector(nf.Dict.ARUCO_MIP_36h12)
# det = nf.ArucoDetector(nf.Dict.APRILTAG_36h11)
res = det.detect(image)
print(res.ids) # int32 (N,) e.g. [ 7 42]
print(res.corners) # float32 (N, 4, 2) clockwise corners, subpixel
Tune detection parameters
params = nf.DetectorParams()
params.min_contour_size = 30 # detect smaller markers (default: 50)
params.adaptive_block_size = 11 # adaptive threshold window (must be odd, ≥3)
params.adaptive_c = 7.0 # threshold constant (default: 7)
params.approx_poly_rate = 0.05 # polygon approx rate (default: 0.05)
det = nf.ArucoDetector(nf.Dict.DICT_5X5_100, params=params)
# Or change params after creation:
det.params.min_contour_size = 80
For high-resolution input with reasonably large markers, detection_scale is the
single biggest speed lever — the dominant cost (adaptiveThreshold + findContours)
is already SIMD-optimized inside OpenCV, so the win comes from feeding it fewer
pixels. Corners are still refined at full resolution, and it works for both
ArucoDetector and FractalDetector:
params = nf.DetectorParams()
params.detection_scale = 0.5 # ~4x faster threshold/contour stage @1080p
det = nf.ArucoDetector(nf.Dict.DICT_4X4_50, params=params)
fdet = nf.FractalDetector("FRACTAL_5L_6", params=params)
FractalDetector also supports all the same parameters plus two extras:
fparams = nf.DetectorParams()
fparams.subpix_win_size = 4 # corner sub-pixel half-window (0 = off)
fparams.kfilter_min_dist = 10.0 # min pixel distance between FAST keypoints
Estimate pose
estimate_pose runs solvePnP (IPPE) for every detected marker at once.
import numpy as np
camera_matrix = np.array([[600, 0, 320],
[0, 600, 240],
[0, 0, 1]], dtype=np.float64)
dist_coeffs = np.zeros(5, dtype=np.float64)
rvecs, tvecs = det.estimate_pose(res.corners, camera_matrix, dist_coeffs,
marker_size=0.05) # marker side in metres
# rvecs, tvecs: float64 (N, 3) — rotation (Rodrigues) and translation per marker
Detect fractal markers
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres (optional)
res = fdet.detect(image)
print(res.ids, res.corners.shape) # outer 4 corners of each fractal marker
Fractal pose + visualization (occlusion-robust)
FractalDetector.estimate_pose returns one marker pose (rvec, tvec, reproj_err)
or None. It uses every visible inner and outer corner correspondence when
available (accurate, robust to occlusion) and otherwise falls back to the four
outer corners — so you never call solvePnP yourself or worry about the
empty-inner-points case. reproj_err (RMS pixels) lets you gate noisy poses.
fdet = nf.FractalDetector("FRACTAL_5L_6", marker_size=0.85) # size in metres
res = fdet.detect(image, with_inner_points=True)
pose = fdet.estimate_pose(res, camera_matrix, dist_coeffs)
if pose is not None:
rvec, tvec, reproj_err = pose # rvec, tvec: float64 (3,); reproj_err: px
fdet.draw(image, res, camera_matrix, dist_coeffs, rvec, tvec) # corners + axes
draw(image, result, ...) overlays marker outlines, ids and (given a pose) the
frame axes in place — no cv2.polylines/drawFrameAxes boilerplate. Without a
pose, fdet.draw(image, res) just draws the outlines.
The raw correspondences are still exposed if you prefer to run PnP yourself:
res.points_2d # float32 (M, 2) image points (None unless with_inner_points=True)
res.points_3d # float32 (M, 3) object points (planar, z = 0)
Parallel batch (offline throughput)
Process many frames across a thread pool. The GIL is released, so it scales with
cores. num_threads=0 uses all cores.
frames = [cv2.imread(p) for p in paths] # list of uint8 arrays
results = det.detect_batch(frames, num_threads=0) # list[DetectionResult]
for r in results:
print(r.ids)
API
Dict — marker dictionaries
| Name | Markers | Inner bits | Notes |
|---|---|---|---|
DICT_4X4_50 … DICT_4X4_1000 |
50–1000 | 4×4 | fewest bits, fastest matching |
DICT_5X5_50 … DICT_5X5_1000 |
50–1000 | 5×5 | |
DICT_6X6_50 … DICT_6X6_1000 |
50–1000 | 6×6 | |
DICT_7X7_50 … DICT_7X7_1000 |
50–1000 | 7×7 | most bits, best error detection |
ARUCO_MIP_36h12 |
250 | 6×6 | legacy ArUco MIP dictionary |
APRILTAG_36h11 |
587 | 6×6 | AprilTag 36h11 |
All dictionaries are identical to their OpenCV counterparts — markers printed
with cv2.aruco.generateImageMarker are detected directly.
ArucoDetector(dictionary=Dict.ARUCO_MIP_36h12, max_attempts=1, params=None)
dictionary: Dict— anyDictenum value.max_attempts: int— retries per candidate with small corner jitter.1is fastest (real-time default); raise (up to ~10) for harder images.params: DetectorParams | None— tuning parameters (see below).Noneuses defaults..params— read/write access to theDetectorParamsafter creation.detect(image) -> DetectionResultdetect_batch(images, num_threads=0) -> list[DetectionResult]estimate_pose(corners, camera_matrix, dist_coeffs, marker_size) -> (rvecs, tvecs)—cornersis(N, 4, 2)float32; outputs are(N, 3)float64.
FractalDetector(config, marker_size=-1.0, params=None)
config: str— one ofFRACTAL_2L_6,FRACTAL_3L_6,FRACTAL_4L_6,FRACTAL_5L_6.marker_size: float— outer marker side in metres; if set,points_3dis returned in metres (otherwise normalized).params: DetectorParams | None— tuning parameters.Noneuses defaults..params— read/write access to theDetectorParamsafter creation.detect(image, with_inner_points=False) -> DetectionResultdetect_batch(images, num_threads=0) -> list[DetectionResult]estimate_pose(result, camera_matrix, dist_coeffs) -> (rvec, tvec, reproj_err) | None— single-marker pose; uses inner+outer points when ≥ 4, else the 4 outer corners;rvec/tvecare float64(3,),reproj_erris RMS pixels.draw(image, result, camera_matrix=None, dist_coeffs=None, rvec=None, tvec=None, axis_length=None) -> image— draw outlines + ids (and frame axes when a pose is given) in place;imagemust be a writable BGRuint8array.
DetectorParams
Shared by both detectors. All fields are optional — defaults reproduce the original hard-coded behaviour so existing code needs no changes.
| Field | Default | Description |
|---|---|---|
min_contour_size |
-1 (auto) |
Minimum contour perimeter in pixels. ArUco default: 50, Fractal: 120. |
adaptive_block_size |
-1 (auto) |
Adaptive threshold block size (odd, ≥ 3). ArUco default: 13; Fractal: scales with image width. |
adaptive_c |
7.0 |
Constant subtracted from the local threshold mean. |
approx_poly_rate |
0.05 |
Polygon approximation: epsilon = perimeter × rate. |
subpix_win_size |
-1 (auto=4) |
Corner sub-pixel half-window (Fractal only); 0 to disable. |
kfilter_min_dist |
10.0 |
Minimum distance (px) between FAST keypoints (Fractal only). |
detection_scale |
1.0 |
Downscale factor for the detection stage (both detectors). 0.5 runs threshold/contour/decode on ¼ the pixels (≈ 4× faster); corners are mapped back and sub-pixel refined at full resolution. min_contour_size stays in original-image pixels. |
DetectionResult
| field | dtype / shape | meaning |
|---|---|---|
ids |
int32 (N,) |
marker ids |
corners |
float32 (N, 4, 2) |
outer corners (subpixel, clockwise) |
points_2d |
float32 (M, 2) or None |
inner+outer image points (fractal, with_inner_points=True) |
points_3d |
float32 (M, 3) or None |
matching object points |
Empty results are returned as correctly-shaped empty arrays ((0,), (0, 4, 2)),
never None.
Errors
- Wrong dtype / non-contiguous input →
TypeError. - Unsupported shape, empty frame, invalid dictionary or fractal config →
ValueError.
Performance notes
- Zero-copy input. A contiguous
uint8array is wrapped as acv::Matover the same buffer — no copy. Non-contiguous or wrong-dtype inputs raise instead of silently copying. - GIL released during the native detection, so other Python threads keep
running and
detect_batchscales. - Thread safety. The ArUco detector is stateless and shared across batch
workers. The fractal detector is not thread-safe, so
detect_batchuses a pool of independent detectors (one per worker). A single detector object is fine to call from one thread at a time.
Changelog
0.2.0
detection_scale— opt-in downscale of the threshold/contour/decode stage for both detectors (~4× faster at 1080p; corners refined at full resolution).- Lower-overhead decode — ArUco dictionary tables are cached per detector instead of rebuilt every frame; marker-id matching and rotation now run on stack buffers with no per-candidate heap allocations (both detectors).
- Pinned SIMD — CI wheels build OpenCV with an explicit
SSE4_2baseline andAVX/AVX2/AVX512runtime dispatch.
0.1.x
- Initial release: ArUco Nano v6 (all standard OpenCV dictionaries plus
ARUCO_MIP_36h12/ AprilTag36h11), Fractal markers, pose, parallel batch, andDetectorParamstuning.
Citation
If you use this in research, please cite the original work:
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer, "Speeded up detection of squared fiducial markers", Image and Vision Computing, 76, 2018.
- S. Garrido-Jurado, R. Muñoz-Salinas, F. J. Madrid-Cuevas, R. Medina-Carnicer, "Generation of fiducial marker dictionaries using mixed integer linear programming", Pattern Recognition, 51, 2016.
- F. J. Romero-Ramirez, R. Muñoz-Salinas, R. Medina-Carnicer, "Fractal Markers: A New Approach for Long-Range Marker Pose Estimation Under Occlusion", IEEE Access, 7, 2019.
License
Apache-2.0. The vendored detectors (ArUco Nano, Fractal markers) are © their
authors and used under their terms; see third_party/ and PATCHES.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nanofractal-0.2.0.tar.gz.
File metadata
- Download URL: nanofractal-0.2.0.tar.gz
- Upload date:
- Size: 90.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8dbf197a3bfae81c6ec1ef7139aee6c18ab3e0166b42d4d83561e7d3fcd41db1
|
|
| MD5 |
87ddc4b671dd4422bc7d8f3cc5d9b090
|
|
| BLAKE2b-256 |
d594e9d44265e2259618f4603b2c3845ac07047573eb6667999d005adb2b3d5e
|
File details
Details for the file nanofractal-0.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: nanofractal-0.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.0 MB
- Tags: CPython 3.13, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77dfda74b0afab87220868e3aa18375d219bdefa966f2ce7a3d67ed07507fdba
|
|
| MD5 |
137ab55955422d9df0250d175a527ebf
|
|
| BLAKE2b-256 |
12c143c77b9d669d953617cb3bb245a3e2a1d36184da641a370ba71a3856531e
|
File details
Details for the file nanofractal-0.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: nanofractal-0.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.0 MB
- Tags: CPython 3.12, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
79eec029ea5d9e98cdc650b986a655a1aa04e13203f0ec22a652866312abbccc
|
|
| MD5 |
444bfa9faef101cfc4283cf714f66cd0
|
|
| BLAKE2b-256 |
90df71216439fff667fba36eebb1bc17a7c27f4854cf00b54e10b6ec4051a732
|
File details
Details for the file nanofractal-0.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: nanofractal-0.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.0 MB
- Tags: CPython 3.11, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efb67d9d465bc0c5d5d03b3b5ffa9ab4dbaedfb9df19688194bd3796f509400a
|
|
| MD5 |
432eefb633581cae477089c58ad4019d
|
|
| BLAKE2b-256 |
6eba4bfd8fbdc8ca3ea7c6c17aa0d9e41f82e0f725b833628fd144411b98d92d
|
File details
Details for the file nanofractal-0.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: nanofractal-0.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.0 MB
- Tags: CPython 3.10, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fffc071a565249e181c38f3dbd192933c23e991caa9241b729f8f606de33a324
|
|
| MD5 |
52dd1d7548715d658e397a379c6ac017
|
|
| BLAKE2b-256 |
69a9b60277e2783b946542307654ada3661f993b09a91e1aa62d366473dc1ed0
|
File details
Details for the file nanofractal-0.2.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: nanofractal-0.2.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.0 MB
- Tags: CPython 3.9, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d4da948487e3bd02da40f6e4638c33042c22bf917a1e1552e728e6066e5a074f
|
|
| MD5 |
37be951d08b3e3573d9d1725eae5f8e8
|
|
| BLAKE2b-256 |
d93e9049041289f9c7376752f850ce3c3b212b050f27b7ef3af30180738a5719
|