diff --git a/.github/workflows/build-multi-platform.yml b/.github/workflows/build-multi-platform.yml
new file mode 100644
index 0000000..a833d40
--- /dev/null
+++ b/.github/workflows/build-multi-platform.yml
@@ -0,0 +1,61 @@
+# This starter workflow is for a CMake project running on multiple platforms. There is a different starter workflow if you just want a single platform.
+# See: https://github.com/actions/starter-workflows/blob/main/ci/cmake-single-platform.yml
+name: build on multi-platform
+
+on:
+ push:
+ branches: [ "develop" ]
+ pull_request:
+ branches: [ "develop" ]
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ # Set fail-fast to false to ensure that feedback is delivered for all matrix combinations. Consider changing this to true when your workflow is stable.
+ fail-fast: false
+
+ # Matrix strategy: Creates a full combination of Python versions × Platform configs
+ # Total jobs: 5 Python versions × 10 os = 50 jobs
+ matrix:
+ os: [ubuntu-24.04, ubuntu-24.04-arm, ubuntu-22.04, ubuntu-22.04-arm, ubuntu-slim, macos-26, macos-15, macos-14, windows-2025, windows-2022]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - name: Set reusable strings
+ # Turn repeated input strings (such as the build output directory) into step outputs. These step outputs can be used throughout the workflow file.
+ id: strings
+ shell: bash
+ run: |
+ echo "build-output-dir=${{ github.workspace }}/build" >> "$GITHUB_OUTPUT"
+
+
+ - name: Display build configuration
+ shell: bash
+ run: |
+ echo "Building for:"
+ echo " OS: ${{ matrix.os }}"
+ echo " Python: ${{ matrix.python-version }}"
+ uname -m || echo "uname not available"
+
+ - name: setup Windows CL
+ uses: ilammy/msvc-dev-cmd@v1
+
+ - name: setup Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install apriltag-opencv
+ run: |
+ pip install .
+
+
+ - name: Test Python Module Import
+ run: |
+ python -c "import apriltag; print('Successfully imported apriltag'); detector = apriltag.apriltag(family='tag36h11'); print(f'Created detector: {detector}')"
diff --git a/.github/workflows/deploy_manylinux_aarch64.yml b/.github/workflows/deploy_manylinux_aarch64.yml
new file mode 100644
index 0000000..7c6068d
--- /dev/null
+++ b/.github/workflows/deploy_manylinux_aarch64.yml
@@ -0,0 +1,29 @@
+name: PyPI manylinux_aarch64 deployer
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ # Build and deploy manylinux_aarch64 wheels
+ Linux-aarch64-build:
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - name: Build manylinux_aarch64 wheels
+ uses: pypa/cibuildwheel@v3.3.0
+ env:
+ CIBW_ARCHS_LINUX: "aarch64"
+ CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-*
+ - name: Upload manylinux2014_aarch64 wheels to PyPI
+ env:
+ TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
+ run: |
+ ls -l ./wheelhouse
+ pip install -U packaging twine
+ twine upload --skip-existing ./wheelhouse/*.whl
\ No newline at end of file
diff --git a/.github/workflows/deploy_manylinux_x86_64.yml b/.github/workflows/deploy_manylinux_x86_64.yml
new file mode 100644
index 0000000..a9913f0
--- /dev/null
+++ b/.github/workflows/deploy_manylinux_x86_64.yml
@@ -0,0 +1,29 @@
+name: PyPI manylinux_x86_64 deployer
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ # Build and deploy manylinux_x86_64 wheels
+ # follow numpy schema: https://pypi.org/project/numpy/#files
+ Linux-x86-build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ - name: Build manylinux_x86_64 wheels
+ uses: pypa/cibuildwheel@v3.3.0
+ env:
+ CIBW_ARCHS_LINUX: "x86_64"
+ CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-*
+ - name: Upload manylinux2014_x86_64 wheels to PyPI
+ env:
+ TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
+ run: |
+ ls -l ./wheelhouse
+ pip install -U packaging twine
+ twine upload --skip-existing ./wheelhouse/*.whl
\ No newline at end of file
diff --git a/.github/workflows/deploy_source.yml b/.github/workflows/deploy_source.yml
new file mode 100644
index 0000000..7f2a7d2
--- /dev/null
+++ b/.github/workflows/deploy_source.yml
@@ -0,0 +1,30 @@
+name: PyPI source deployer
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ # Build and deploy source distribution
+ source-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ - name: Install Python 3.12
+ uses: actions/setup-python@v6
+ with:
+ python-version: 3.12
+ - name: Build source distribution
+ run: |
+ python -m pip install --upgrade build
+ python -m build --sdist
+ - name: Upload source package to PyPI
+ env:
+ TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
+ run: |
+ pip install twine
+ twine upload dist/*
\ No newline at end of file
diff --git a/.github/workflows/deploy_win_macos.yml b/.github/workflows/deploy_win_macos.yml
new file mode 100644
index 0000000..d9a5170
--- /dev/null
+++ b/.github/workflows/deploy_win_macos.yml
@@ -0,0 +1,39 @@
+name: PyPI win and macos deployer
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ # Build and deploy Windows AMD64, macOS x86 & macOS arm64 wheels
+ Matrix-build:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ - name: Install Python 3.x
+ uses: actions/setup-python@v6
+ with:
+ python-version: 3.x
+ - name: Build wheels
+ uses: pypa/cibuildwheel@v3.3.0
+ env:
+ CIBW_ARCHS_WINDOWS: "AMD64"
+ CIBW_ARCHS_MACOS: "x86_64 arm64 universal2"
+ # Does not fail at unsupported Python versions!
+ CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-*
+ - name: Upload wheels
+ env:
+ TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
+ run: |
+ ls -l ./wheelhouse
+ pip install twine
+ twine upload --skip-existing ./wheelhouse/*.whl
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b7faf40..3ec3010 100644
--- a/.gitignore
+++ b/.gitignore
@@ -205,3 +205,6 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
+
+.*
+!.github
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..8a5dc28
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "src/apriltag/apriltag"]
+ path = src/apriltag/apriltag
+ url = https://github.com/AprilRobotics/apriltag
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..0b8a3fc
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,39 @@
+foreach(X IN ITEMS apriltag_detect.docstring apriltag_estimate_tag_pose.docstring apriltag_pywrap.c CMakeLists.txt)
+ configure_file(
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/${X}
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/apriltag/apriltag/${X}
+ COPYONLY
+)
+endforeach()
+
+# Top-level CMakeLists.txt for scikit-build-core
+cmake_minimum_required(VERSION 3.16)
+project(${SKBUILD_PROJECT_NAME}
+ VERSION ${SKBUILD_PROJECT_VERSION}
+ DESCRIPTION "Python wrapper for the AprilTag visual fiducial detector"
+ LANGUAGES C)
+
+# Set rpath for shared libraries to be found at runtime
+if(APPLE)
+ set(CMAKE_INSTALL_RPATH "@loader_path")
+elseif(UNIX)
+ set(CMAKE_INSTALL_RPATH "$ORIGIN")
+endif()
+set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)
+
+
+# Find Python and NumPy
+find_package(Python3 REQUIRED COMPONENTS Development.Module NumPy)
+message(STATUS "Found Python ${Python3_VERSION}")
+
+add_subdirectory(src/apriltag/apriltag)
+
+# Install the Python module
+# scikit-build-core will automatically handle installation to the correct location
+install(TARGETS apriltag.${Python3_SOABI}
+ LIBRARY DESTINATION apriltag
+ RUNTIME DESTINATION apriltag)
+
+install(TARGETS apriltag
+ LIBRARY DESTINATION apriltag
+ RUNTIME DESTINATION apriltag)
diff --git a/README.md b/README.md
index 9c562f7..e9bcfe1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,246 @@
# apriltag-python
-A python wrapper of the AprilTag visual fiducial detector
+
+[](https://badge.fury.io/py/apriltag-python)
+[](LICENSE)
+[](https://www.python.org/downloads/)
+
+A Python wrapper for the [AprilTag visual fiducial detector](https://github.com/AprilRobotics/apriltag). This library provides fast and robust detection of AprilTag markers in images, along with pose estimation capabilities.
+
+## Features
+
+- **Fast Detection**: Optimized C implementation with Python bindings
+- **Multi-threading Support**: Parallel detection for better performance
+- **Multiple Tag Families**: Support for tag36h11, tag25h9, tag16h5, and more
+- **Pose Estimation**: 6-DOF pose estimation from detected tags
+- **Type Hints**: Full type annotations for better IDE support
+- **Cross-platform**: Works on Linux, macOS, and Windows
+
+## Installation
+
+Install from PyPI:
+
+```bash
+pip install apriltag-python
+```
+
+### Requirements
+
+- Python 3.10 or higher
+- NumPy
+
+## Quick Start
+
+```python
+import apriltag
+import cv2
+
+# Create detector for tag36h11 family
+detector = apriltag.apriltag('tag36h11', threads=4)
+
+# Load a grayscale image
+image = cv2.imread('image.jpg', cv2.IMREAD_GRAYSCALE)
+
+# Detect tags
+detections = detector.detect(image)
+
+# Process results
+for detection in detections:
+ print(f"Tag ID: {detection['id']}")
+ print(f"Center: {detection['center']}")
+ print(f"Corners: {detection['lb-rb-rt-lt']}")
+```
+
+## Usage
+
+### Basic Detection
+
+```python
+import apriltag
+import numpy as np
+
+# Create detector with custom parameters
+detector = apriltag.apriltag(
+ family='tag36h11', # Tag family
+ threads=4, # Number of threads
+ maxhamming=1, # Maximum hamming distance for error correction
+ decimate=2.0, # Image downsampling factor
+ blur=0.0, # Gaussian blur sigma
+ refine_edges=True, # Refine quad edges
+ debug=False # Debug mode
+)
+
+# Detect tags in a grayscale image
+image = np.zeros((480, 640), dtype=np.uint8) # Example: black image
+detections = detector.detect(image)
+```
+
+### Detection Results
+
+Each detection is a dictionary containing:
+
+- `id` (int): The decoded tag ID
+- `hamming` (int): Number of bit errors corrected
+- `margin` (float): Decision margin (higher values indicate better detection quality)
+- `center` (ndarray): Tag center coordinates [x, y], shape (2,)
+- `lb-rb-rt-lt` (ndarray): Four corner coordinates in order: left-bottom, right-bottom, right-top, left-top, shape (4, 2)
+- `homography` (ndarray): 3×3 homography matrix mapping tag coordinates to image pixels, shape (3, 3)
+
+### Pose Estimation
+
+Estimate the 6-DOF pose (position and orientation) of detected tags:
+
+```python
+# Camera calibration parameters
+fx, fy = 500.0, 500.0 # Focal lengths in pixels
+cx, cy = 320.0, 240.0 # Principal point (image center)
+tagsize = 0.16 # Physical tag size in meters (e.g., 16cm)
+
+# Detect tags
+detections = detector.detect(image)
+
+# Estimate pose for each detection
+for det in detections:
+ pose = detector.estimate_tag_pose(det, tagsize, fx, fy, cx, cy)
+
+ print(f"Tag {det['id']}:")
+ print(f" Position (meters): {pose['t'].T}")
+ print(f" Rotation matrix:\n{pose['R']}")
+ print(f" Reprojection error: {pose['error']}")
+```
+
+The pose result contains:
+
+- `R` (ndarray): 3×3 rotation matrix from tag frame to camera frame
+- `t` (ndarray): 3×1 translation vector from camera to tag in meters
+- `error` (float): Reprojection error (lower is better)
+
+### Coordinate Frames
+
+- **Tag Frame**: Origin at tag center, z-axis pointing out of the tag surface, x-axis to the right, y-axis pointing up (when viewed from the front)
+- **Camera Frame**: Standard computer vision convention with z-axis pointing forward
+
+## Supported Tag Families
+
+- `tag36h11` (Recommended): 36-bit tags with minimum Hamming distance of 11
+- `tag36h10`: 36-bit tags with minimum Hamming distance of 10
+- `tag25h9`: 25-bit tags with minimum Hamming distance of 9
+- `tag16h5`: 16-bit tags with minimum Hamming distance of 5
+- `tagCircle21h7`: Circular tags
+- `tagCircle49h12`: Circular tags
+- `tagStandard41h12`: Standard tags
+- `tagStandard52h13`: Standard tags
+- `tagCustom48h12`: Custom tags
+
+## Performance Tips
+
+1. **Use multiple threads**: Set `threads` to the number of CPU cores for parallel processing
+2. **Adjust decimation**: Increase `decimate` (e.g., 2.0-4.0) for faster detection on high-resolution images
+3. **Image preprocessing**: Ensure good contrast and lighting for better detection
+4. **Choose appropriate family**: `tag36h11` provides the best balance of robustness and variety
+
+## Complete Example with OpenCV
+
+```python
+import apriltag
+import cv2
+import numpy as np
+
+# Initialize detector
+detector = apriltag.apriltag('tag36h11', threads=4, decimate=2.0)
+
+# Camera parameters (replace with your calibration values)
+fx, fy = 500.0, 500.0
+cx, cy = 320.0, 240.0
+tagsize = 0.16 # 16cm tags
+
+# Capture from camera
+cap = cv2.VideoCapture(0)
+
+while True:
+ ret, frame = cap.read()
+ if not ret:
+ break
+
+ # Convert to grayscale
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
+
+ # Detect tags
+ detections = detector.detect(gray)
+
+ # Draw results
+ for det in detections:
+ # Draw corners
+ corners = det['lb-rb-rt-lt'].astype(int)
+ cv2.polylines(frame, [corners], True, (0, 255, 0), 2)
+
+ # Draw center
+ center = tuple(det['center'].astype(int))
+ cv2.circle(frame, center, 5, (0, 0, 255), -1)
+
+ # Draw ID
+ cv2.putText(frame, str(det['id']), center,
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
+
+ # Estimate and print pose
+ pose = detector.estimate_tag_pose(det, tagsize, fx, fy, cx, cy)
+ distance = np.linalg.norm(pose['t'])
+ print(f"Tag {det['id']} distance: {distance:.2f}m")
+
+ cv2.imshow('AprilTag Detection', frame)
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
+```
+
+## Building from Source
+
+```bash
+git clone --recursive https://github.com/chibai/apriltag-python.git
+cd apriltag-python
+pip install .
+```
+
+### Build Requirements
+
+- C compiler (GCC, Clang, or MSVC)
+- CMake 3.15+
+- Python development headers
+- NumPy
+
+## License
+
+This project is licensed under the BSD 2-Clause License - see the [LICENSE](LICENSE) file for details.
+
+The underlying AprilTag library is also BSD-licensed.
+
+## Credits
+
+- **AprilTag Library**: [AprilRobotics/apriltag](https://github.com/AprilRobotics/apriltag)
+- **Python Wrapper**: chibai (huangyibin1992@gmail.com)
+
+## Citation
+
+If you use this library in your research, please cite the original AprilTag paper:
+
+```bibtex
+@inproceedings{wang2016iros,
+ author = {John Wang and Edwin Olson},
+ title = {{AprilTag} 2: Efficient and robust fiducial detection},
+ booktitle = {Proceedings of the {IEEE/RSJ} International Conference on Intelligent Robots and Systems {(IROS)}},
+ year = {2016},
+ month = {October}
+}
+```
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+## Links
+
+- **PyPI**: https://pypi.org/project/apriltag-python/
+- **Source Code**: https://github.com/chibai/apriltag-python
+- **AprilTag Homepage**: https://april.eecs.umich.edu/software/apriltag
+- **Issues**: https://github.com/chibai/apriltag-python/issues
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..afb816a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,49 @@
+[build-system]
+requires = ["scikit-build-core", "numpy"]
+build-backend = "scikit_build_core.build"
+
+[project]
+name = "apriltag-python"
+version = "3.4.5"
+description = "Python wrapper for the AprilTag visual fiducial detector"
+readme = "README.md"
+license = {text = "BSD-2-Clause"}
+authors = [
+ {name = "chibai", email = "huangyibin1992@gmail.com"}
+]
+requires-python = ">=3.10"
+dependencies = [
+ "numpy",
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: BSD License",
+ "Programming Language :: C",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Scientific/Engineering :: Image Recognition",
+]
+
+[project.urls]
+Homepage = "https://github.com/AprilRobotics/apriltag"
+Repository = "https://github.com/chibai/apriltag-python"
+
+[tool.scikit-build]
+# CMake arguments for building (works on both Linux and Windows)
+cmake.args = [
+ "-DBUILD_SHARED_LIBS=ON",
+ "-DBUILD_EXAMPLES=OFF",
+ "-DBUILD_PYTHON_WRAPPER=ON",
+ "-DCMAKE_BUILD_TYPE=Release",
+ "-DBUILD_EXAMPLES=OFF"
+]
+
+# Include Python source files from src/
+wheel.packages = ["src/apriltag"]
+wheel.exclude = ["include", "lib", "share", "apriltag/apriltag"]
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
new file mode 100644
index 0000000..782603d
--- /dev/null
+++ b/src/CMakeLists.txt
@@ -0,0 +1,239 @@
+cmake_minimum_required(VERSION 3.16)
+project(apriltag VERSION 3.4.5 LANGUAGES C)
+
+if(POLICY CMP0077)
+ cmake_policy(SET CMP0077 NEW)
+endif()
+option(BUILD_SHARED_LIBS "Build shared libraries" ON)
+option(BUILD_EXAMPLES "Build example executables" ON)
+option(ASAN "Use AddressSanitizer for debug builds to detect memory issues" OFF)
+
+if (ASAN)
+ set(ASAN_FLAGS "\
+ -fsanitize=address \
+ -fsanitize=bool \
+ -fsanitize=bounds \
+ -fsanitize=enum \
+ -fsanitize=float-cast-overflow \
+ -fsanitize=float-divide-by-zero \
+ -fsanitize=nonnull-attribute \
+ -fsanitize=returns-nonnull-attribute \
+ -fsanitize=signed-integer-overflow \
+ -fsanitize=undefined \
+ -fsanitize=vla-bound \
+ -fno-sanitize=alignment \
+ -fsanitize=leak \
+ -fsanitize=object-size \
+ ")
+ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${ASAN_FLAGS}")
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${ASAN_FLAGS}")
+endif()
+
+# Set a default build type if none was specified
+set(default_build_type "Release")
+
+SET(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
+
+if(WIN32)
+ add_compile_definitions(WIN32_LEAN_AND_MEAN)
+endif()
+
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ message(STATUS "Setting build type to '${default_build_type}' as none was specified.")
+ set(CMAKE_BUILD_TYPE "${default_build_type}" CACHE STRING "Choose the type of build." FORCE)
+ # Set the possible values of build type for cmake-gui
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "MinSizeRel" "RelWithDebInfo")
+endif()
+
+if(CMAKE_COMPILER_IS_GNUCC OR CMAKE_C_COMPILER_ID MATCHES "Clang")
+ add_compile_options(-Wall -Wextra)
+ add_compile_options(-Wpedantic)
+ if(CMAKE_C_COMPILER_ID MATCHES "Clang")
+ add_compile_options(
+ -Wno-gnu-zero-variadic-macro-arguments
+ -Wno-strict-prototypes
+ -Wno-static-in-inline
+ )
+ endif()
+ add_compile_options(-Wno-shift-negative-value)
+ if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.24.0")
+ set(CMAKE_COMPILE_WARNING_AS_ERROR ON)
+ else()
+ add_compile_options(-Werror)
+ endif()
+endif()
+
+if(CMAKE_C_COMPILER_ID MATCHES "Clang" AND CMAKE_C_SIMULATE_ID MATCHES "MSVC")
+ # error: 'strdup' is deprecated: The POSIX name for this item is deprecated. Instead, use the ISO C and C++ conformant name: _strdup.
+ # "strdup" is standard since C23
+ add_definitions(-D _CRT_NONSTDC_NO_DEPRECATE)
+ # ignore "'fopen' is deprecated" and "'strncpy' is deprecated" warnings
+ add_definitions(-D _CRT_SECURE_NO_WARNINGS)
+endif()
+
+aux_source_directory(common COMMON_SRC)
+set(APRILTAG_SRCS apriltag.c apriltag_pose.c apriltag_quad_thresh.c)
+
+# Library
+file(GLOB TAG_FILES ${CMAKE_CURRENT_SOURCE_DIR}/tag*.c)
+add_library(${PROJECT_NAME} ${APRILTAG_SRCS} ${COMMON_SRC} ${TAG_FILES})
+set_property(TARGET ${PROJECT_NAME} PROPERTY POSITION_INDEPENDENT_CODE ON)
+
+if(CMAKE_C_COMPILER_ID MATCHES "Clang" AND NOT APPLE AND NOT CMAKE_C_SIMULATE_ID MATCHES "MSVC")
+ target_link_options(${PROJECT_NAME} PRIVATE "-Wl,-z,relro,-z,now,-z,defs")
+endif()
+
+if (MSVC)
+ add_compile_definitions("_CRT_SECURE_NO_WARNINGS")
+else()
+ find_package(Threads REQUIRED)
+ target_link_libraries(${PROJECT_NAME} PUBLIC Threads::Threads)
+endif()
+
+if (UNIX)
+ target_link_libraries(${PROJECT_NAME} PUBLIC m)
+endif()
+
+set_target_properties(${PROJECT_NAME} PROPERTIES SOVERSION 3 VERSION ${PROJECT_VERSION})
+set_target_properties(${PROJECT_NAME} PROPERTIES DEBUG_POSTFIX "d")
+set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99)
+
+
+include(GNUInstallDirs)
+target_include_directories(${PROJECT_NAME} PUBLIC
+ "$"
+ "$"
+ "$/apriltag")
+
+
+# install header file hierarchy
+file(GLOB HEADER_FILES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} *.h common/*.h)
+list(REMOVE_ITEM HEADER_FILES apriltag_detect.docstring.h apriltag_py_type.docstring.h)
+
+foreach(HEADER ${HEADER_FILES})
+ string(REGEX MATCH "(.*)[/\\]" DIR ${HEADER})
+ install(FILES ${HEADER} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}/${DIR})
+endforeach()
+
+# export library
+set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
+set(version_config "${generated_dir}/${PROJECT_NAME}ConfigVersion.cmake")
+set(project_config "${generated_dir}/${PROJECT_NAME}Config.cmake")
+set(targets_export_name "${PROJECT_NAME}Targets")
+set(config_install_dir "${CMAKE_INSTALL_LIBDIR}/${PROJECT_NAME}/cmake")
+
+# Include module with fuction 'write_basic_package_version_file'
+include(CMakePackageConfigHelpers)
+
+# Configure 'Config.cmake'
+# Use variables:
+# * targets_export_name
+# * PROJECT_NAME
+configure_package_config_file(
+ "CMake/apriltagConfig.cmake.in"
+ "${project_config}"
+ INSTALL_DESTINATION "${config_install_dir}"
+)
+
+# Configure 'ConfigVersion.cmake'
+# Note: PROJECT_VERSION is used as a VERSION
+write_basic_package_version_file("${version_config}" COMPATIBILITY SameMajorVersion)
+
+
+# install library
+install(TARGETS ${PROJECT_NAME} EXPORT ${targets_export_name}
+ LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
+ ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
+ RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
+ )
+
+install(EXPORT ${targets_export_name}
+ NAMESPACE apriltag::
+ DESTINATION ${config_install_dir})
+
+install(FILES ${project_config} ${version_config} DESTINATION ${config_install_dir})
+
+export(TARGETS apriltag
+ NAMESPACE apriltag::
+ FILE ${generated_dir}/${targets_export_name}.cmake)
+
+
+# install pkgconfig file
+configure_file(${PROJECT_NAME}.pc.in ${PROJECT_NAME}.pc @ONLY)
+install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.pc"
+ DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig")
+
+
+# Python wrapper
+option(BUILD_PYTHON_WRAPPER "Builds Python wrapper" ON)
+
+find_package(Python3 QUIET COMPONENTS Development.Module NumPy)
+
+if(BUILD_PYTHON_WRAPPER AND Python3_Development.Module_FOUND AND Python3_NumPy_FOUND)
+
+ include(CMake/vtkEncodeString.cmake)
+
+ foreach(X IN ITEMS detect py_type estimate_tag_pose)
+ vtk_encode_string(
+ INPUT ${CMAKE_CURRENT_SOURCE_DIR}/apriltag_${X}.docstring
+ NAME apriltag_${X}_docstring
+ )
+ endforeach()
+ add_custom_target(apriltag_py_docstrings DEPENDS
+ ${PROJECT_BINARY_DIR}/apriltag_detect_docstring.h
+ ${PROJECT_BINARY_DIR}/apriltag_py_type_docstring.h
+ ${PROJECT_BINARY_DIR}/apriltag_estimate_tag_pose_docstring.h
+ )
+
+ # set the SOABI manually since renaming the library via OUTPUT_NAME does not work on MSVC
+ set(apriltag_py_target "apriltag.${Python3_SOABI}")
+ Python3_add_library(${apriltag_py_target} MODULE ${CMAKE_CURRENT_SOURCE_DIR}/apriltag_pywrap.c)
+ add_dependencies(${apriltag_py_target} apriltag_py_docstrings)
+ # avoid linking against Python3::Python to prevent segmentation faults in Conda environments
+ # https://github.com/AprilRobotics/apriltag/issues/352
+ target_link_libraries(${apriltag_py_target} PRIVATE apriltag Python3::NumPy)
+ target_include_directories(${apriltag_py_target} PRIVATE ${PROJECT_BINARY_DIR})
+
+ set(PY_DEST ${CMAKE_INSTALL_PREFIX}/lib/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/)
+ install(TARGETS ${apriltag_py_target} LIBRARY DESTINATION ${PY_DEST})
+elseif(BUILD_PYTHON_WRAPPER)
+ message(WARNING
+ "Python bindings requested (BUILD_PYTHON_WRAPPER=ON) but Development and NumPy not found. "
+ "Python bindings will not be built. Set BUILD_PYTHON_WRAPPER=OFF to silent this warnings."
+ )
+endif()
+
+# Examples
+if (BUILD_EXAMPLES)
+ # apriltag_demo
+ add_executable(apriltag_demo example/apriltag_demo.c)
+ target_link_libraries(apriltag_demo ${PROJECT_NAME})
+
+ # opencv_demo
+ set(_OpenCV_REQUIRED_COMPONENTS core imgproc videoio highgui)
+ find_package(OpenCV COMPONENTS ${_OpenCV_REQUIRED_COMPONENTS} QUIET CONFIG)
+ if(OpenCV_FOUND)
+ enable_language(CXX)
+ # NB: contrib required for TickMeter in OpenCV 2.4. This is only required for 16.04 backwards compatibility and can be removed in the future.
+ # If we add it to the find_package initially, the demo won't build for newer OpenCV versions
+ if(OpenCV_VERSION VERSION_LESS "3.0.0")
+ list(APPEND _OpenCV_REQUIRED_COMPONENTS contrib)
+ find_package(OpenCV COMPONENTS ${_OpenCV_REQUIRED_COMPONENTS} CONFIG)
+ endif()
+
+ add_executable(opencv_demo example/opencv_demo.cc)
+ target_link_libraries(opencv_demo apriltag ${OpenCV_LIBRARIES})
+ set_target_properties(opencv_demo PROPERTIES CXX_STANDARD 11)
+ install(TARGETS opencv_demo RUNTIME DESTINATION bin)
+ else()
+ message(STATUS "OpenCV not found: Not building demo")
+ endif(OpenCV_FOUND)
+
+ # install example programs
+ install(TARGETS apriltag_demo RUNTIME DESTINATION bin)
+endif()
+
+if(BUILD_TESTING)
+ enable_testing()
+ add_subdirectory(test)
+endif()
diff --git a/src/apriltag/__init__.py b/src/apriltag/__init__.py
new file mode 100644
index 0000000..af5c3bb
--- /dev/null
+++ b/src/apriltag/__init__.py
@@ -0,0 +1,2 @@
+from .apriltag import *
+from .apriltag_extra import opencv_estimate_tag_pose_solvepnp
\ No newline at end of file
diff --git a/src/apriltag/apriltag b/src/apriltag/apriltag
new file mode 160000
index 0000000..94be783
--- /dev/null
+++ b/src/apriltag/apriltag
@@ -0,0 +1 @@
+Subproject commit 94be783968e5091bcc9972c72c84fd63efce2935
diff --git a/src/apriltag/apriltag.pyi b/src/apriltag/apriltag.pyi
new file mode 100644
index 0000000..b74a64a
--- /dev/null
+++ b/src/apriltag/apriltag.pyi
@@ -0,0 +1,249 @@
+"""
+Type stubs for apriltag module.
+
+AprilTag visual fiducial system detector.
+Auto-generated from C extension module.
+
+Note on type annotations:
+ Detection and Pose are defined as TypedDict to provide type hints for the
+ dictionaries returned by the C extension. While these are not enforced at
+ runtime (the C extension returns plain dict objects), they enable:
+ - IDE autocomplete and type checking
+ - Static type analysis with mypy/pyright
+ - Better documentation through type hints
+
+ This approach follows PEP 589 and is the standard practice for typing
+ C extension modules (e.g., numpy, opencv-python, etc.).
+"""
+
+from typing import Literal, TypedDict
+import numpy as np
+import numpy.typing as npt
+
+__version__: str
+
+class Detection(TypedDict):
+ """
+ AprilTag detection result (returned as dict from C extension).
+
+ Note:
+ This is a TypedDict definition for type checking purposes only.
+ The actual return value from detect() is a plain dict object.
+ See PEP 589 for details on TypedDict semantics.
+
+ Attributes:
+ id: The decoded tag ID
+ hamming: Number of error bits corrected
+ margin: Decision margin (higher is better, measure of detection quality)
+ center: Tag center coordinates [x, y]
+ lb-rb-rt-lt: 4x2 array of corner coordinates (left-bottom, right-bottom, right-top, left-top)
+ homography: 3x3 homography matrix from tag coordinates to image pixels
+ """
+ id: int
+ hamming: int
+ margin: float
+ center: npt.NDArray[np.float64] # Shape: (2,)
+ homography: npt.NDArray[np.float64] # Shape: (3, 3)
+
+class Pose(TypedDict):
+ """
+ Estimated 6-DOF pose of an AprilTag (returned as dict from C extension).
+
+ Note:
+ This is a TypedDict definition for type checking purposes only.
+ The actual return value from estimate_tag_pose() is a plain dict object.
+ See PEP 589 for details on TypedDict semantics.
+
+ Attributes:
+ R: 3x3 rotation matrix from tag frame to camera frame
+ t: 3x1 translation vector from camera to tag in meters
+ error: Reprojection error (lower is better)
+ """
+ R: npt.NDArray[np.float64] # Shape: (3, 3)
+ t: npt.NDArray[np.float64] # Shape: (3, 1)
+ error: float
+
+TagFamily = Literal[
+ 'tag36h11',
+ 'tag36h10',
+ 'tag25h9',
+ 'tag16h5',
+ 'tagCircle21h7',
+ 'tagCircle49h12',
+ 'tagStandard41h12',
+ 'tagStandard52h13',
+ 'tagCustom48h12'
+]
+
+class apriltag:
+ """
+ AprilTag detector.
+
+ Creates a detector for a specific tag family with configurable parameters.
+
+ Args:
+ family: Tag family name. Options:
+ - 'tag36h11': Recommended, 36-bit tags with min. Hamming distance of 11
+ - 'tag36h10': 36-bit tags with min. Hamming distance of 10
+ - 'tag25h9': 25-bit tags with min. Hamming distance of 9
+ - 'tag16h5': 16-bit tags with min. Hamming distance of 5
+ - 'tagCircle21h7': Circular tags
+ - 'tagCircle49h12': Circular tags
+ - 'tagStandard41h12': Standard tags
+ - 'tagStandard52h13': Standard tags
+ - 'tagCustom48h12': Custom tags
+
+ threads: Number of threads to use for detection (default: 1)
+ Set to number of CPU cores for best performance.
+
+ maxhamming: Maximum number of bit errors that can be corrected (default: 1)
+ Higher values allow detection of more damaged tags but increase
+ false positive rate. Range: 0-3.
+
+ decimate: Detection resolution downsampling factor (default: 2.0)
+ Detection is performed on a reduced-resolution image. Higher values
+ increase speed but reduce accuracy. Set to 1.0 for full resolution.
+
+ blur: Gaussian blur standard deviation in pixels (default: 0.0)
+ Can help with noisy images. 0 means no blur.
+
+ refine_edges: Refine quad edge positions for better accuracy (default: True)
+ Recommended to keep enabled.
+
+ debug: Enable debug output and save intermediate images (default: False)
+
+ Example:
+ >>> import apriltag
+ >>> import numpy as np
+ >>>
+ >>> # Create detector
+ >>> detector = apriltag.apriltag('tag36h11', threads=4)
+ >>>
+ >>> # Detect tags in grayscale image
+ >>> image = np.zeros((480, 640), dtype=np.uint8)
+ >>> detections = detector.detect(image)
+ >>>
+ >>> # Process results
+ >>> for detection in detections:
+ ... print(f"Tag ID: {detection['id']}")
+ ... print(f"Center: {detection['center']}")
+ """
+
+ def __init__(
+ self,
+ family: TagFamily,
+ threads: int = 1,
+ maxhamming: int = 1,
+ decimate: float = 2.0,
+ blur: float = 0.0,
+ refine_edges: bool = True,
+ debug: bool = False
+ ) -> None:
+ """
+ Initialize AprilTag detector.
+
+ Args:
+ family: Tag family name (required)
+ threads: Number of threads for detection (default: 1)
+ maxhamming: Maximum bit errors to correct (default: 1, range: 0-3)
+ decimate: Downsampling factor (default: 2.0)
+ blur: Gaussian blur sigma in pixels (default: 0.0)
+ refine_edges: Refine quad edges (default: True)
+ debug: Enable debug mode (default: False)
+
+ Raises:
+ RuntimeError: If family is not recognized or detector creation fails
+ ValueError: If maxhamming > 3 or other parameter validation fails
+ """
+ ...
+
+ def detect(
+ self,
+ image: npt.NDArray[np.uint8]
+ ) -> tuple[Detection, ...]:
+ """
+ Detect AprilTags in a grayscale image.
+
+ Args:
+ image: Grayscale image as a 2D NumPy array of uint8 values.
+ Shape should be (height, width).
+
+ Returns:
+ Tuple of detection dictionaries. Each detection contains:
+ - id (int): The decoded tag ID
+ - hamming (int): Number of error bits corrected
+ - margin (float): Decision margin (higher is better)
+ - center (ndarray): Tag center [x, y], shape (2,)
+ - 'lb-rb-rt-lt' (ndarray): Corner coordinates, shape (4, 2)
+ Order: left-bottom, right-bottom, right-top, left-top
+ - homography (ndarray): 3x3 homography matrix, shape (3, 3)
+
+ Raises:
+ RuntimeError: If image format is invalid or detection fails
+
+ Example:
+ >>> import cv2
+ >>> image = cv2.imread('tag.jpg', cv2.IMREAD_GRAYSCALE)
+ >>> detections = detector.detect(image)
+ >>> for det in detections:
+ ... print(f"Found tag {det['id']} at {det['center']}")
+ """
+ ...
+
+ def estimate_tag_pose(
+ self,
+ detection: Detection,
+ tagsize: float,
+ fx: float,
+ fy: float,
+ cx: float,
+ cy: float
+ ) -> Pose:
+ """
+ Estimate the 6-DOF pose of a detected AprilTag.
+
+ This method computes the 3D position and orientation of the tag relative
+ to the camera using the homography matrix from detection and camera parameters.
+
+ Args:
+ detection: Detection dictionary from detect() method (must include 'homography')
+ tagsize: Physical size of the tag in meters (side length of the black square)
+ fx: Camera focal length in pixels (x-axis)
+ fy: Camera focal length in pixels (y-axis)
+ cx: Camera principal point x-coordinate in pixels
+ cy: Camera principal point y-coordinate in pixels
+
+ Returns:
+ Dictionary containing:
+ - R (ndarray): 3x3 rotation matrix from tag frame to camera frame
+ - t (ndarray): 3x1 translation vector in meters (camera to tag)
+ - error (float): Reprojection error (lower is better)
+
+ Raises:
+ TypeError: If detection is not a dictionary
+ ValueError: If detection is missing required fields
+ RuntimeError: If pose estimation fails
+
+ Example:
+ >>> # Camera calibration parameters
+ >>> fx, fy = 500.0, 500.0 # focal lengths
+ >>> cx, cy = 320.0, 240.0 # principal point
+ >>> tagsize = 0.16 # 16cm tag
+ >>>
+ >>> # Detect and estimate pose
+ >>> detections = detector.detect(image)
+ >>> for det in detections:
+ ... pose = detector.estimate_tag_pose(det, tagsize, fx, fy, cx, cy)
+ ... print(f"Tag {det['id']} position: {pose['t'].T}")
+ ... print(f"Rotation matrix:\n{pose['R']}")
+ ... print(f"Reprojection error: {pose['error']}")
+
+ Note:
+ The rotation matrix R and translation vector t describe the transformation
+ from the tag coordinate frame to the camera coordinate frame.
+ Tag frame: origin at tag center, z-axis points out of tag, x-axis to the right,
+ y-axis pointing up when viewed from the front.
+ """
+ ...
+
+__all__ = ['apriltag', 'Detection', 'Pose', 'TagFamily']
\ No newline at end of file
diff --git a/src/apriltag/apriltag_extra.py b/src/apriltag/apriltag_extra.py
new file mode 100644
index 0000000..37ec551
--- /dev/null
+++ b/src/apriltag/apriltag_extra.py
@@ -0,0 +1,44 @@
+from typing import Dict
+import numpy as np
+
+try:
+ import cv2
+except Exception as e:
+ print("An error occurred while importing cv2:", e)
+ print(
+ "Please install opencv-python(-headless) to use opencv_estimate_tag_pose_solvepnp."
+ )
+
+
+def opencv_estimate_tag_pose_solvepnp(
+
+ detection: Dict[str, float | np.ndarray],
+ tagsize: float,
+ fx: float,
+ fy: float,
+ cx: float,
+ cy: float,
+ dist_coeffs: np.ndarray | list[float]| None = None,
+) -> Dict[str, np.ndarray] | None:
+ object_points = np.array(
+ [
+ [-tagsize / 2, tagsize / 2, 0],
+ [tagsize / 2, tagsize / 2, 0],
+ [tagsize / 2, -tagsize / 2, 0],
+ [-tagsize / 2, -tagsize / 2, 0],
+ ],
+ dtype=np.float64,
+ )
+ image_points = np.array(detection["lb-rb-rt-lt"], dtype=np.float64)
+ camera_matrix = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64)
+ ret, rvec, tvec = cv2.solvePnP(
+ object_points, image_points, camera_matrix, distCoeffs=dist_coeffs, flags=cv2.SOLVEPNP_IPPE_SQUARE
+ )
+ if not ret:
+ return None
+ projected_points, _ = cv2.projectPoints(
+ object_points, rvec, tvec, camera_matrix, distCoeffs=dist_coeffs)
+ reprojection_error = np.mean(
+ np.linalg.norm(projected_points.reshape(-1, 2) - image_points, axis=1)
+ )
+ return {"R": cv2.Rodrigues(rvec)[0], "t": tvec, "reprojection_error": float(reprojection_error)}
diff --git a/src/apriltag_detect.docstring b/src/apriltag_detect.docstring
new file mode 100644
index 0000000..5abbabf
--- /dev/null
+++ b/src/apriltag_detect.docstring
@@ -0,0 +1,67 @@
+AprilTag detector
+
+SYNOPSIS
+
+ import cv2
+ import numpy as np
+ from apriltag import apriltag
+
+ imagepath = '/tmp/tst.jpg'
+ image = cv2.imread(imagepath, cv2.IMREAD_GRAYSCALE)
+ detector = apriltag("tag36h11")
+
+ detections = detector.detect(image)
+
+ print("Saw tags {} at\n{}". \
+ format([d['id'] for d in detections],
+ np.array([d['center'] for d in detections])))
+
+ ----> Saw tags [3, 5, 7, 8, 10, 10, 14] at
+ [[582.42911184 172.90587335]
+ [703.32149701 271.50587376]
+ [288.1462089 227.01502779]
+ [463.63679264 227.91185418]
+ [ 93.88534443 241.61109765]
+ [121.94062798 237.97010936]
+ [356.46940849 260.20169159]]
+
+DESCRIPTION
+
+The AprilTags visual fiducial system project page is here:
+https://april.eecs.umich.edu/software/apriltag
+
+This is a Python class to provide AprilTags functionality in Python programs. To
+run the detector you
+
+1. Construct an object of type apriltag.apriltag()
+
+2. Invoke the detect() method on this object
+
+The detect() method takes a single argument: an image array. The return value is
+a tuple containing the detections. Each detection is a dict with keys:
+
+- id: integer identifying each detected tag
+
+- center: pixel coordinates of the center of each detection. NOTE: Please be
+ cautious regarding the image coordinate convention. Here, we define (0,0) as
+ the left-top corner (not the center point) of the left-top-most pixel.
+
+- lb-rb-rt-lt: pixel coordinates of the 4 corners of each detection. The order
+ is left-bottom, right-bottom, right-top, left-top
+
+- hamming: How many error bits were corrected? Note: accepting large numbers of
+ corrected errors leads to greatly increased false positive rates. NOTE: As of
+ this implementation, the detector cannot detect tags with a hamming distance
+ greater than 2.
+
+- margin: A measure of the quality of the binary decoding process: the average
+ difference between the intensity of a data bit versus the decision threshold.
+ Higher numbers roughly indicate better decodes. This is a reasonable measure
+ of detection accuracy only for very small tags-- not effective for larger tags
+ (where we could have sampled anywhere within a bit cell and still gotten a
+ good detection.)
+
+- homography: A 3x3 homography matrix that describes the projection from an
+ "ideal" tag (with corners at (-1,1), (1,1), (1,-1), and (-1,-1)) to pixels
+ in the image. This matrix can be used to map points from the tag's coordinate
+ system to the image coordinate system, and is useful for pose estimation.
\ No newline at end of file
diff --git a/src/apriltag_estimate_tag_pose.docstring b/src/apriltag_estimate_tag_pose.docstring
new file mode 100644
index 0000000..afafa63
--- /dev/null
+++ b/src/apriltag_estimate_tag_pose.docstring
@@ -0,0 +1,70 @@
+estimate_tag_pose(detection, tagsize, fx, fy, cx, cy) -> dict
+
+SYNOPSIS
+
+ import cv2
+ import numpy as np
+ from apriltag import apriltag
+
+ imagepath = '/tmp/tst.jpg'
+ image = cv2.imread(imagepath, cv2.IMREAD_GRAYSCALE)
+ detector = apriltag("tag36h11")
+
+ detections = detector.detect(image)
+ if detections:
+ # Estimate pose for the first detected tag
+ # tagsize is the physical size of the tag in meters
+ # fx, fy are focal lengths in pixels
+ # cx, cy are principal point coordinates in pixels
+ pose = detector.estimate_tag_pose(detections[0],
+ tagsize=0.16, # 16cm tag
+ fx=600, fy=600, # focal lengths
+ cx=320, cy=240) # principal point
+ print("Rotation matrix R:\n", pose['R'])
+ print("Translation vector t:", pose['t'])
+ print("Reprojection error:", pose['error'])
+
+DESCRIPTION
+
+The estimate_tag_pose() method estimates the 6-DOF pose (position and orientation)
+of a detected AprilTag in 3D space. This method requires the detection result from
+the detect() method, the physical size of the tag, and camera intrinsic parameters.
+
+The pose estimation uses the homography matrix from the detection result to
+compute the transformation from the tag's coordinate system to the camera's
+coordinate system.
+
+ARGUMENTS
+
+- detection: A dictionary containing detection information returned by the
+ detect() method. This dictionary must include the 'homography' key with the
+ 3x3 homography matrix.
+
+- tagsize: The physical side length of the AprilTag in meters. This is the real-
+ world size of the tag, which is necessary for computing the scale of the pose.
+
+- fx: Focal length in the x direction in pixels. This is a camera intrinsic
+ parameter that describes how the camera projects 3D points to 2D image space.
+
+- fy: Focal length in the y direction in pixels. This is a camera intrinsic
+ parameter that describes how the camera projects 3D points to 2D image space.
+
+- cx: Principal point x coordinate in pixels. This is the x coordinate of the
+ optical center of the camera in the image.
+
+- cy: Principal point y coordinate in pixels. This is the y coordinate of the
+ optical center of the camera in the image.
+
+RETURNED VALUE
+
+Returns a dictionary containing:
+
+- 'R': 3x3 rotation matrix as a numpy array that represents the orientation
+ of the tag in the camera coordinate system.
+
+- 't': 3x1 translation vector as a numpy array (in meters) that represents the
+ position of the tag in the camera coordinate system.
+
+- 'error': Reprojection error that indicates how well the estimated pose
+ matches the observed tag corners in the image. Lower values indicate better
+ pose estimates.
\ No newline at end of file
diff --git a/src/apriltag_pywrap.c b/src/apriltag_pywrap.c
new file mode 100644
index 0000000..975cea4
--- /dev/null
+++ b/src/apriltag_pywrap.c
@@ -0,0 +1,571 @@
+#define NPY_NO_DEPRECATED_API NPY_API_VERSION
+
+#include
+#include
+#ifndef Py_PYTHREAD_H
+#include
+#endif
+#include
+#include
+#include
+
+#include "apriltag.h"
+#include "apriltag_pose.h"
+#include "tag36h10.h"
+#include "tag36h11.h"
+#include "tag25h9.h"
+#include "tag16h5.h"
+#include "tagCircle21h7.h"
+#include "tagCircle49h12.h"
+#include "tagCustom48h12.h"
+#include "tagStandard41h12.h"
+#include "tagStandard52h13.h"
+
+
+#define SUPPORTED_TAG_FAMILIES(_) \
+ _(tag36h10) \
+ _(tag36h11) \
+ _(tag25h9) \
+ _(tag16h5) \
+ _(tagCircle21h7) \
+ _(tagCircle49h12) \
+ _(tagStandard41h12) \
+ _(tagStandard52h13) \
+ _(tagCustom48h12)
+
+#define TAG_CREATE_FAMILY(name) \
+ else if (0 == strcmp(family, #name)) self->tf = name ## _create();
+#define TAG_SET_DESTROY_FUNC(name) \
+ else if (0 == strcmp(family, #name)) self->destroy_func = name ## _destroy;
+#define FAMILY_STRING(name) " " #name "\n"
+
+
+// Python is silly. There's some nuance about signal handling where it sets a
+// SIGINT (ctrl-c) handler to just set a flag, and the python layer then reads
+// this flag and does the thing. Here I'm running C code, so SIGINT would set a
+// flag, but not quit, so I can't interrupt the solver. Thus I reset the SIGINT
+// handler to the default, and put it back to the python-specific version when
+// I'm done
+#define SET_SIGINT() struct sigaction sigaction_old; \
+do { \
+ if( 0 != sigaction(SIGINT, \
+ &(struct sigaction){ .sa_handler = SIG_DFL }, \
+ &sigaction_old) ) \
+ { \
+ PyErr_SetString(PyExc_RuntimeError, "sigaction() failed"); \
+ goto done; \
+ } \
+} while(0)
+#define RESET_SIGINT() do { \
+ if( 0 != sigaction(SIGINT, \
+ &sigaction_old, NULL )) \
+ PyErr_SetString(PyExc_RuntimeError, "sigaction-restore failed"); \
+} while(0)
+
+#define PYMETHODDEF_ENTRY(function_prefix, name, args) {#name, \
+ (PyCFunction)function_prefix ## name, \
+ args, \
+ function_prefix ## name ## _docstring}
+
+typedef struct {
+ PyObject_HEAD
+
+ apriltag_family_t* tf;
+ apriltag_detector_t* td;
+ PyThread_type_lock det_lock;
+ void (*destroy_func)(apriltag_family_t *tf);
+} apriltag_py_t;
+
+
+static PyObject *
+apriltag_new(PyTypeObject *type, PyObject *args, PyObject *kwargs)
+{
+ errno = 0;
+
+ bool success = false;
+
+ apriltag_py_t* self = (apriltag_py_t*)type->tp_alloc(type, 0);
+ if(self == NULL) goto done;
+
+ self->tf = NULL;
+ self->td = NULL;
+
+ self->det_lock = PyThread_allocate_lock();
+ if (self->det_lock == NULL) {
+ PyErr_SetString(PyExc_RuntimeError, "Unable to allocate detection lock");
+ goto done;
+ }
+
+ const char* family = NULL;
+ int Nthreads = 1;
+ int maxhamming = 1;
+ float decimate = 2.0;
+ float blur = 0.0;
+ bool refine_edges = true;
+ bool debug = false;
+ PyObject* py_refine_edges = NULL;
+ PyObject* py_debug = NULL;
+
+ char* keywords[] = {"family",
+ "threads",
+ "maxhamming",
+ "decimate",
+ "blur",
+ "refine_edges",
+ "debug",
+ NULL };
+
+ if(!PyArg_ParseTupleAndKeywords( args, kwargs, "s|iiffOO",
+ keywords,
+ &family,
+ &Nthreads,
+ &maxhamming,
+ &decimate,
+ &blur,
+ &py_refine_edges,
+ &py_debug ))
+ {
+ goto done;
+ }
+
+ if(py_refine_edges != NULL)
+ refine_edges = PyObject_IsTrue(py_refine_edges);
+ if(py_debug != NULL)
+ debug = PyObject_IsTrue(py_debug);
+
+
+ if(0) ; SUPPORTED_TAG_FAMILIES(TAG_SET_DESTROY_FUNC)
+ else
+ {
+ PyErr_Format(PyExc_RuntimeError, "Unrecognized tag family name: '%s'. Families I know about:\n%s",
+ family, SUPPORTED_TAG_FAMILIES(FAMILY_STRING));
+ goto done;
+ }
+
+ if(0) ; SUPPORTED_TAG_FAMILIES(TAG_CREATE_FAMILY);
+
+ self->td = apriltag_detector_create();
+ if(self->td == NULL)
+ {
+ PyErr_SetString(PyExc_RuntimeError, "apriltag_detector_create() failed!");
+ goto done;
+ }
+
+ apriltag_detector_add_family_bits(self->td, self->tf, maxhamming);
+ self->td->quad_decimate = decimate;
+ self->td->quad_sigma = blur;
+ self->td->nthreads = Nthreads;
+ self->td->refine_edges = refine_edges;
+ self->td->debug = debug;
+
+ switch(errno){
+ case EINVAL:
+ PyErr_SetString(PyExc_RuntimeError, "Unable to add family to detector. \"maxhamming\" parameter should not exceed 3");
+ break;
+ case ENOMEM:
+ PyErr_Format(PyExc_RuntimeError, "Unable to add family to detector due to insufficient memory to allocate the tag-family decoder. Try reducing \"maxhamming\" from %d or choose an alternative tag family",maxhamming);
+ break;
+ default:
+ success = true;
+ }
+
+ done:
+ if(!success)
+ {
+ if(self != NULL)
+ {
+ if(self->td != NULL)
+ {
+ apriltag_detector_destroy(self->td);
+ self->td = NULL;
+ }
+ if(self->tf != NULL)
+ {
+ self->destroy_func(self->tf);
+ self->tf = NULL;
+ }
+ if(self->det_lock != NULL)
+ {
+ PyThread_free_lock(self->det_lock);
+ self->det_lock = NULL;
+ }
+ Py_DECREF(self);
+ }
+ return NULL;
+ }
+
+ return (PyObject*)self;
+}
+
+static void apriltag_dealloc(apriltag_py_t* self)
+{
+ if(self == NULL)
+ return;
+ if(self->td != NULL)
+ {
+ apriltag_detector_destroy(self->td);
+ self->td = NULL;
+ }
+ if(self->tf != NULL)
+ {
+ self->destroy_func(self->tf);
+ self->tf = NULL;
+ }
+ if(self->det_lock != NULL)
+ {
+ PyThread_free_lock(self->det_lock);
+ self->det_lock = NULL;
+ }
+
+ Py_TYPE(self)->tp_free((PyObject*)self);
+}
+
+static PyObject* apriltag_detect(apriltag_py_t* self,
+ PyObject* args)
+{
+ errno = 0;
+
+ PyObject* result = NULL;
+ PyArrayObject* xy_c = NULL;
+ PyArrayObject* xy_lb_rb_rt_lt = NULL;
+ PyArrayObject* homography = NULL;
+ PyArrayObject* image = NULL;
+ PyObject* detections_tuple = NULL;
+
+#ifdef _POSIX_C_SOURCE
+ SET_SIGINT();
+#endif
+ if(!PyArg_ParseTuple( args, "O&",
+ PyArray_Converter, &image ))
+ goto done;
+
+ npy_intp* dims = PyArray_DIMS (image);
+ npy_intp* strides = PyArray_STRIDES(image);
+ int ndims = PyArray_NDIM (image);
+ if( ndims != 2 )
+ {
+ PyErr_Format(PyExc_RuntimeError, "The input image array must have exactly 2 dims; got %d",
+ ndims);
+ goto done;
+ }
+ if( PyArray_TYPE(image) != NPY_UINT8 )
+ {
+ PyErr_SetString(PyExc_RuntimeError, "The input image array must contain 8-bit unsigned data");
+ goto done;
+ }
+ if( strides[ndims-1] != 1 )
+ {
+ PyErr_SetString(PyExc_RuntimeError, "Image rows must live in contiguous memory");
+ goto done;
+ }
+
+
+ image_u8_t im = {.width = dims[1],
+ .height = dims[0],
+ .stride = strides[0],
+ .buf = PyArray_DATA(image)};
+
+ zarray_t *detections = NULL; // Declare detections variable outside the GIL macro block
+ Py_BEGIN_ALLOW_THREADS // Release the GIL to allow other Python threads to run
+ PyThread_acquire_lock(self->det_lock, 1); // Acquire the detection lock before running the detector (blocks until the lock is available)
+ detections = apriltag_detector_detect(self->td, &im); // Run detection
+ PyThread_release_lock(self->det_lock); // Release the detection lock
+ Py_END_ALLOW_THREADS // Acquire the GIL after releasing the detection lock
+
+ int N = zarray_size(detections);
+
+ if (N == 0 && errno == EAGAIN){
+ PyErr_Format(PyExc_RuntimeError, "Unable to create %d threads for detector", self->td->nthreads);
+ goto done;
+ }
+
+ detections_tuple = PyTuple_New(N);
+ if(detections_tuple == NULL)
+ {
+ PyErr_Format(PyExc_RuntimeError, "Error creating output tuple of size %d", N);
+ goto done;
+ }
+
+ for (int i=0; i < N; i++)
+ {
+ xy_c = (PyArrayObject*)PyArray_SimpleNew(1, ((npy_intp[]){2}), NPY_FLOAT64);
+ if(xy_c == NULL)
+ {
+ PyErr_SetString(PyExc_RuntimeError, "Could not allocate xy_c array");
+ goto done;
+ }
+ xy_lb_rb_rt_lt = (PyArrayObject*)PyArray_SimpleNew(2, ((npy_intp[]){4,2}), NPY_FLOAT64);
+ if(xy_lb_rb_rt_lt == NULL)
+ {
+ PyErr_SetString(PyExc_RuntimeError, "Could not allocate xy_lb_rb_rt_lt array");
+ goto done;
+ }
+
+ apriltag_detection_t* det;
+ zarray_get(detections, i, &det);
+
+ *(double*)PyArray_GETPTR1(xy_c, 0) = det->c[0];
+ *(double*)PyArray_GETPTR1(xy_c, 1) = det->c[1];
+
+ for(int j=0; j<4; j++)
+ {
+ *(double*)PyArray_GETPTR2(xy_lb_rb_rt_lt, j, 0) = det->p[j][0];
+ *(double*)PyArray_GETPTR2(xy_lb_rb_rt_lt, j, 1) = det->p[j][1];
+ }
+
+ // Add homography matrix (3x3)
+ homography = (PyArrayObject*)PyArray_SimpleNew(2, ((npy_intp[]){3,3}), NPY_FLOAT64);
+ if(homography == NULL)
+ {
+ Py_DECREF(xy_c);
+ Py_DECREF(xy_lb_rb_rt_lt);
+ PyErr_SetString(PyExc_RuntimeError, "Could not allocate homography array");
+ goto done;
+ }
+
+ for(int j=0; j<3; j++)
+ {
+ for(int k=0; k<3; k++)
+ {
+ *(double*)PyArray_GETPTR2(homography, j, k) = MATD_EL(det->H, j, k);
+ }
+ }
+
+ PyTuple_SET_ITEM(detections_tuple, i,
+ Py_BuildValue("{s:i,s:f,s:i,s:N,s:N,s:N}",
+ "hamming", det->hamming,
+ "margin", det->decision_margin,
+ "id", det->id,
+ "center", xy_c,
+ "lb-rb-rt-lt", xy_lb_rb_rt_lt,
+ "homography", homography));
+ xy_c = NULL;
+ xy_lb_rb_rt_lt = NULL;
+ }
+ apriltag_detections_destroy(detections);
+
+ result = detections_tuple;
+ detections_tuple = NULL;
+
+ done:
+ Py_XDECREF(xy_c);
+ Py_XDECREF(xy_lb_rb_rt_lt);
+ Py_XDECREF(image);
+ Py_XDECREF(detections_tuple);
+
+#ifdef _POSIX_C_SOURCE
+ RESET_SIGINT();
+#endif
+ return result;
+}
+
+static PyObject* apriltag_estimate_tag_pose(apriltag_py_t* self,
+ PyObject* args)
+{
+ PyObject* result = NULL;
+ PyObject* detection_dict = NULL;
+ PyArrayObject* R_array = NULL;
+ PyArrayObject* t_array = NULL;
+ matd_t* H_matrix = NULL;
+ double tagsize, fx, fy, cx, cy;
+
+ if(!PyArg_ParseTuple(args, "Oddddd",
+ &detection_dict,
+ &tagsize,
+ &fx, &fy, &cx, &cy))
+ return NULL;
+
+ if(!PyDict_Check(detection_dict))
+ {
+ PyErr_SetString(PyExc_TypeError, "First argument must be a detection dictionary");
+ return NULL;
+ }
+
+ // Extract detection information from the dictionary
+ PyObject* py_id = PyDict_GetItemString(detection_dict, "id");
+ PyObject* py_hamming = PyDict_GetItemString(detection_dict, "hamming");
+ PyObject* py_margin = PyDict_GetItemString(detection_dict, "margin");
+ PyObject* py_center = PyDict_GetItemString(detection_dict, "center");
+ PyObject* py_corners = PyDict_GetItemString(detection_dict, "lb-rb-rt-lt");
+ PyObject* py_homography = PyDict_GetItemString(detection_dict, "homography");
+
+ if(!py_id || !py_hamming || !py_margin || !py_center || !py_corners || !py_homography)
+ {
+ PyErr_SetString(PyExc_ValueError,
+ "Detection dictionary is missing required fields. "
+ "Make sure you're using a detection from the updated detect() method that includes 'homography'.");
+ return NULL;
+ }
+
+ // Create a temporary detection structure
+ apriltag_detection_t det;
+ det.family = self->tf;
+ det.id = PyLong_AsLong(py_id);
+ det.hamming = PyLong_AsLong(py_hamming);
+ det.decision_margin = PyFloat_AsDouble(py_margin);
+
+ // Extract center
+ PyArrayObject* center_array = (PyArrayObject*)py_center;
+ det.c[0] = *(double*)PyArray_GETPTR1(center_array, 0);
+ det.c[1] = *(double*)PyArray_GETPTR1(center_array, 1);
+
+ // Extract corners
+ PyArrayObject* corners_array = (PyArrayObject*)py_corners;
+ for(int i = 0; i < 4; i++)
+ {
+ det.p[i][0] = *(double*)PyArray_GETPTR2(corners_array, i, 0);
+ det.p[i][1] = *(double*)PyArray_GETPTR2(corners_array, i, 1);
+ }
+
+ // Extract and copy homography matrix
+ PyArrayObject* homography_array = (PyArrayObject*)py_homography;
+ H_matrix = matd_create(3, 3);
+ if(!H_matrix)
+ {
+ PyErr_SetString(PyExc_RuntimeError, "Could not allocate homography matrix");
+ return NULL;
+ }
+
+ for(int i = 0; i < 3; i++)
+ {
+ for(int j = 0; j < 3; j++)
+ {
+ MATD_EL(H_matrix, i, j) = *(double*)PyArray_GETPTR2(homography_array, i, j);
+ }
+ }
+ det.H = H_matrix;
+
+ // Setup detection info
+ apriltag_detection_info_t info;
+ info.det = &det;
+ info.tagsize = tagsize;
+ info.fx = fx;
+ info.fy = fy;
+ info.cx = cx;
+ info.cy = cy;
+
+ // Estimate pose
+ apriltag_pose_t pose;
+ double error = estimate_tag_pose(&info, &pose);
+
+ // Create numpy arrays for R and t
+ R_array = (PyArrayObject*)PyArray_SimpleNew(2, ((npy_intp[]){3, 3}), NPY_FLOAT64);
+ t_array = (PyArrayObject*)PyArray_SimpleNew(2, ((npy_intp[]){3, 1}), NPY_FLOAT64);
+
+ if(!R_array || !t_array)
+ {
+ PyErr_SetString(PyExc_RuntimeError, "Could not allocate output arrays");
+ goto cleanup;
+ }
+
+ // Copy rotation matrix
+ for(int i = 0; i < 3; i++)
+ {
+ for(int j = 0; j < 3; j++)
+ {
+ *(double*)PyArray_GETPTR2(R_array, i, j) = MATD_EL(pose.R, i, j);
+ }
+ }
+
+ // Copy translation vector
+ for(int i = 0; i < 3; i++)
+ {
+ *(double*)PyArray_GETPTR2(t_array, i, 0) = MATD_EL(pose.t, i, 0);
+ }
+
+ result = Py_BuildValue("{s:N,s:N,s:d}",
+ "R", R_array,
+ "t", t_array,
+ "error", error);
+ R_array = NULL;
+ t_array = NULL;
+
+cleanup:
+ if(H_matrix)
+ matd_destroy(H_matrix);
+ if(pose.R)
+ matd_destroy(pose.R);
+ if(pose.t)
+ matd_destroy(pose.t);
+ Py_XDECREF(R_array);
+ Py_XDECREF(t_array);
+
+ return result;
+}
+
+
+#include "apriltag_detect_docstring.h"
+#include "apriltag_py_type_docstring.h"
+#include "apriltag_estimate_tag_pose_docstring.h"
+
+static PyMethodDef apriltag_methods[] =
+ { PYMETHODDEF_ENTRY(apriltag_, detect, METH_VARARGS),
+ PYMETHODDEF_ENTRY(apriltag_, estimate_tag_pose, METH_VARARGS),
+ {NULL, NULL, 0, NULL}
+ };
+
+static PyTypeObject apriltagType =
+{
+ PyVarObject_HEAD_INIT(NULL, 0)
+ .tp_name = "apriltag",
+ .tp_basicsize = sizeof(apriltag_py_t),
+ .tp_new = apriltag_new,
+ .tp_dealloc = (destructor)apriltag_dealloc,
+ .tp_methods = apriltag_methods,
+ .tp_flags = Py_TPFLAGS_DEFAULT,
+ .tp_doc = apriltag_py_type_docstring
+};
+
+static PyMethodDef methods[] =
+ { {NULL, NULL, 0, NULL}
+ };
+
+
+#if PY_MAJOR_VERSION == 2
+
+PyMODINIT_FUNC initapriltag(void)
+{
+ if (PyType_Ready(&apriltagType) < 0)
+ return;
+
+ PyObject* module = Py_InitModule3("apriltag", methods,
+ "AprilTags visual fiducial system detector");
+
+ Py_INCREF(&apriltagType);
+ PyModule_AddObject(module, "apriltag", (PyObject *)&apriltagType);
+
+ import_array();
+}
+
+#else
+
+static struct PyModuleDef module_def =
+ {
+ PyModuleDef_HEAD_INIT,
+ "apriltag",
+ "AprilTags visual fiducial system detector",
+ -1,
+ methods,
+ 0,
+ 0,
+ 0,
+ 0
+ };
+
+PyMODINIT_FUNC PyInit_apriltag(void)
+{
+ if (PyType_Ready(&apriltagType) < 0)
+ return NULL;
+
+ PyObject* module =
+ PyModule_Create(&module_def);
+
+ Py_INCREF(&apriltagType);
+ PyModule_AddObject(module, "apriltag", (PyObject *)&apriltagType);
+
+ import_array();
+
+ return module;
+}
+
+#endif