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 + +[![PyPI version](https://badge.fury.io/py/apriltag-python.svg)](https://badge.fury.io/py/apriltag-python) +[![License](https://img.shields.io/badge/License-BSD%202--Clause-blue.svg)](LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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