├── .clang-format ├── .github ├── ISSUE_TEMPLATE │ ├── 0-bug-report.yaml │ ├── 1-feature-request.yaml │ ├── 2-documentation.yaml │ ├── 3-refactor.yaml │ └── config.yaml └── workflows │ ├── tests.yml │ └── wheels.yml ├── .gitignore ├── .gitmodules ├── CMakeLists.txt ├── LICENSE ├── README.md ├── include ├── nn_search.hpp ├── pca.hpp └── pgeof.hpp ├── pyproject.toml ├── src ├── pgeof │ └── __init__.py └── pgeof_ext.cpp └── tests ├── __init__.py ├── bench_jakteristics.py ├── bench_knn.py ├── helpers.py └── test_pgeof.py /.clang-format: -------------------------------------------------------------------------------- 1 | Language: Cpp 2 | BasedOnStyle: Google 3 | # --- 4 | #AccessModifierOffset: -4 5 | AlignAfterOpenBracket: AlwaysBreak # Values: Align, DontAlign, AlwaysBreak 6 | AlignConsecutiveAssignments: true 7 | AlignConsecutiveDeclarations: true 8 | #AlignEscapedNewlinesLeft: true 9 | #AlignOperands: false 10 | AlignTrailingComments: false # Should be off, causes many dummy problems!! 11 | #AllowAllParametersOfDeclarationOnNextLine: true 12 | AllowShortBlocksOnASingleLine: true 13 | #AllowShortCaseLabelsOnASingleLine: false 14 | #AllowShortFunctionsOnASingleLine: Empty 15 | #AllowShortIfStatementsOnASingleLine: false 16 | #AllowShortLoopsOnASingleLine: false 17 | #AlwaysBreakAfterDefinitionReturnType: None 18 | #AlwaysBreakAfterReturnType: None 19 | #AlwaysBreakBeforeMultilineStrings: true 20 | #AlwaysBreakTemplateDeclarations: true 21 | #BinPackArguments: false 22 | #BinPackParameters: false 23 | #BraceWrapping: 24 | #AfterClass: false 25 | #AfterControlStatement: false 26 | #AfterEnum: false 27 | #AfterFunction: false 28 | #AfterNamespace: false 29 | #AfterObjCDeclaration: false 30 | #AfterStruct: false 31 | #AfterUnion: false 32 | #BeforeCatch: false 33 | #BeforeElse: true 34 | #IndentBraces: false 35 | #BreakBeforeBinaryOperators: None 36 | BreakBeforeBraces: Allman 37 | #BreakBeforeTernaryOperators: true 38 | #BreakConstructorInitializersBeforeComma: false 39 | ColumnLimit: 120 40 | #CommentPragmas: '' 41 | #ConstructorInitializerAllOnOneLineOrOnePerLine: true 42 | #ConstructorInitializerIndentWidth: 4 43 | #ContinuationIndentWidth: 4 44 | #Cpp11BracedListStyle: true 45 | #DerivePointerAlignment: false 46 | #DisableFormat: false 47 | #ExperimentalAutoDetectBinPacking: false 48 | ##FixNamespaceComments: true # Not applicable in 3.8 49 | #ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH ] 50 | #IncludeCategories: 51 | #- Regex: '.*' 52 | #Priority: 1 53 | IndentCaseLabels: true 54 | IndentWidth: 4 55 | IndentWrappedFunctionNames: true 56 | #KeepEmptyLinesAtTheStartOfBlocks: true 57 | #MacroBlockBegin: '' 58 | #MacroBlockEnd: '' 59 | MaxEmptyLinesToKeep: 1 60 | NamespaceIndentation: None 61 | #PenaltyBreakBeforeFirstCallParameter: 19 62 | #PenaltyBreakComment: 300 63 | #PenaltyBreakFirstLessLess: 120 64 | #PenaltyBreakString: 1000 65 | #PenaltyExcessCharacter: 1000000 66 | #PenaltyReturnTypeOnItsOwnLine: 200 67 | DerivePointerAlignment: false 68 | #PointerAlignment: Left 69 | ReflowComments: true # Should be true, otherwise clang-format doesn't touch comments 70 | SortIncludes: true 71 | #SpaceAfterCStyleCast: false 72 | SpaceBeforeAssignmentOperators: true 73 | #SpaceBeforeParens: ControlStatements 74 | #SpaceInEmptyParentheses: false 75 | #SpacesBeforeTrailingComments: 2 76 | #SpacesInAngles: false 77 | #SpacesInContainerLiterals: true 78 | #SpacesInCStyleCastParentheses: false 79 | #SpacesInParentheses: false 80 | #SpacesInSquareBrackets: false 81 | Standard: Cpp11 82 | TabWidth: 4 83 | UseTab: Never # Available options are Never, Always, ForIndentation 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/0-bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: Submit a report to help us reproduce and fix the bug 3 | title: Title of your bug report 4 | labels: ["bug"] 5 | 6 | body: 7 | 8 | - type: markdown 9 | attributes: 10 | value: Thanks for taking the time to fill out this bug report 🙏 ! 11 | 12 | - type: checkboxes 13 | attributes: 14 | label: ✅ Code of conduct checklist 15 | description: > 16 | Before submitting a bug, please make sure you went through the following 17 | steps. 18 | options: 19 | - label: "🌱 I am using the **_latest version_** of the [code](https://github.com/drprojects/point_geometric_features/tree/master)." 20 | required: true 21 | - label: "👩‍💻 I made sure the bug concerns the official [project's codebase](https://github.com/drprojects/point_geometric_features/tree/master) and not something I coded myself on top of it. (We only provide support for code we wrote and released ourselves)." 22 | required: true 23 | - label: "🔎 I took appropriate **_time_** to investigate the problem before filing an issue, but am unable to solve it myself." 24 | required: true 25 | - label: "📙 I **_thoroughly_** went through the [README](https://github.com/drprojects/point_geometric_features/blob/master/README.md), but could not find the solution there." 26 | required: true 27 | - label: "📜 I went through the **_docstrings_** and **_comments_** in the [source code](https://github.com/drprojects/point_geometric_features/tree/master) parts relevant to my problem, but could not find the solution there." 28 | required: true 29 | - label: "👩‍🔧 I searched for [**_similar issues_**](https://github.com/drprojects/point_geometric_features/issues), but could not find the solution there." 30 | required: true 31 | - label: "🔎 I made sure my bug is related to the [project's codebase](https://github.com/drprojects/point_geometric_features/tree/master) and is not in fact a sub-dependency issue." 32 | required: true 33 | - label: "⭐ Since I am showing interest in the project, I took the time to give the [repo](https://github.com/drprojects/point_geometric_features/tree/master) a ⭐ to show support. **Please do, it means a lot to us !**" 34 | required: true 35 | 36 | - type: textarea 37 | attributes: 38 | label: 🐛 Describe the bug 39 | description: > 40 | Please provide a _**clear and concise**_ description of the issue you 41 | are facing. If the code does not behave as anticipated, please describe 42 | the expected behavior. Include references to any relevant documentation 43 | or related issues. 44 | validations: 45 | required: true 46 | 47 | - type: textarea 48 | id: logs 49 | attributes: 50 | label: 📜 Log output 51 | description: > 52 | If relevant, please copy and paste any useful log output. If copying an 53 | error message, make sure you provide the _**full traceback**_. This will 54 | be automatically rendered as shell code, so no need for backticks. 55 | placeholder: | 56 | << Full error message >> 57 | render: shell 58 | 59 | - type: textarea 60 | attributes: 61 | label: 🤖 Steps to reproduce the bug 62 | description: > 63 | Please provide a _**minimal reproducible example**_ to help us 64 | investigate the bug. 65 | placeholder: | 66 | A step-by-step recipe for reproducing your bug. Use backticks as shown 67 | below to write and render code snippets. 68 | 69 | ```python 70 | # Some python code to reproduce the problem 71 | ``` 72 | 73 | ``` 74 | Some error message you got, with the full traceback. 75 | ``` 76 | validations: 77 | required: true 78 | 79 | - type: textarea 80 | attributes: 81 | label: 📚 Additional information 82 | description: > 83 | Please add any additional information that could help us diagnose the 84 | problem better. Provide screenshots if applicable. You may attach 85 | log files, generated wheel, or any other file that could be helpful. 86 | 87 | - type: textarea 88 | attributes: 89 | label: 🖥️ Environment 90 | description: | 91 | Please run the following and paste the output here. This will let us know more about your environment. 92 | ```sh 93 | curl -OL https://raw.githubusercontent.com/pytorch/pytorch/main/torch/utils/collect_env.py 94 | # For security purposes, please check the contents of collect_env.py before running it. 95 | python3 collect_env.py 96 | ``` 97 | render: shell 98 | placeholder: | 99 | << Copy the output of `collect_env.py` here >> 100 | validations: 101 | required: true 102 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: "🚀 Feature Request" 2 | description: Propose a new feature 3 | title: Title of your feature request 4 | labels: ["feature"] 5 | 6 | body: 7 | 8 | - type: markdown 9 | attributes: 10 | value: Thanks for taking the time to fill out this feature request report 🙏 ! 11 | 12 | - type: checkboxes 13 | attributes: 14 | label: ✅ Code of conduct checklist 15 | description: > 16 | Before submitting a feature request, please make sure you went through 17 | the following steps. 18 | options: 19 | - label: "🌱 I am using the **_latest version_** of the [code](https://github.com/drprojects/point_geometric_features/tree/master)." 20 | required: true 21 | - label: "📙 I **_thoroughly_** went through the [README](https://github.com/drprojects/point_geometric_features/blob/master/README.md), but could not find the feature I need there." 22 | required: true 23 | - label: "📜 I went through the **_docstrings_** and **_comments_** in the [source code](https://github.com/drprojects/point_geometric_features/tree/master) parts relevant to my problem, but could not find the feature I need there." 24 | required: true 25 | - label: "👩‍🔧 I searched for [**_similar issues_**](https://github.com/drprojects/point_geometric_features/issues), but could not find the feature I need there." 26 | required: true 27 | - label: "⭐ Since I am showing interest in the project, I took the time to give the [repo](https://github.com/drprojects/point_geometric_features/tree/master) a ⭐ to show support. **Please do, it means a lot to us !**" 28 | required: true 29 | 30 | - type: textarea 31 | attributes: 32 | label: 🚀 The feature, motivation and pitch 33 | description: > 34 | A clear and concise description of the feature proposal. Please outline 35 | the motivation for the proposal. Is your feature request related to a 36 | specific problem ? e.g., *"I'm working on X and would like Y to be 37 | possible"*. If this is related to another GitHub issue, please link here 38 | too. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: 🔀 Alternatives 45 | description: > 46 | A description of any alternative solutions or features you've 47 | considered, if any. 48 | 49 | - type: textarea 50 | attributes: 51 | label: 📚 Additional context 52 | description: > 53 | Add any other context or screenshots about the feature request. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-documentation.yaml: -------------------------------------------------------------------------------- 1 | name: "📚 Typos and Documentation Fixes" 2 | description: Tell us about how we can improve our documentation 3 | title: Title of your documentation/typo fix/request 4 | labels: ["documentation"] 5 | 6 | body: 7 | 8 | - type: markdown 9 | attributes: 10 | value: Thanks for taking the time to fill out this documentation report 🙏 ! 11 | 12 | - type: checkboxes 13 | attributes: 14 | label: ✅ Code of conduct checklist 15 | description: > 16 | Before submitting a bug, please make sure you went through the following 17 | steps. 18 | options: 19 | - label: "🌱 I am using the **_latest version_** of the [code](https://github.com/drprojects/point_geometric_features/tree/master)." 20 | required: true 21 | - label: "📙 I went through the [README](https://github.com/drprojects/point_geometric_features/blob/master/README.md), but could not find the appropriate documentation there." 22 | required: true 23 | - label: "📜 I went through the **_docstrings_** and **_comments_** in the [source code](https://github.com/drprojects/point_geometric_features/tree/master) parts relevant to my problem, but could not find the appropriate documentation there." 24 | required: true 25 | - label: "👩‍🔧 I searched for [**_similar issues_**](https://github.com/drprojects/point_geometric_features/issues), but could not find the appropriate documentation there." 26 | required: true 27 | - label: "⭐ Since I am showing interest in the project, I took the time to give the [repo](https://github.com/drprojects/point_geometric_features/tree/master) a ⭐ to show support. **Please do, it means a lot to us !**" 28 | required: true 29 | 30 | - type: textarea 31 | attributes: 32 | label: 📚 Describe the documentation issue 33 | description: | 34 | A clear and concise description of the issue. 35 | validations: 36 | required: true 37 | 38 | - type: textarea 39 | attributes: 40 | label: Suggest a potential alternative/fix 41 | description: | 42 | Tell us how we could improve the documentation in this regard. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-refactor.yaml: -------------------------------------------------------------------------------- 1 | name: "🛠 Refactor" 2 | description: Suggest a code refactor or deprecation 3 | title: Title of your refactor/deprecation report 4 | labels: refactor 5 | 6 | body: 7 | 8 | - type: markdown 9 | attributes: 10 | value: Thanks for taking the time to fill out this refactor report 🙏 ! 11 | 12 | - type: textarea 13 | attributes: 14 | label: 🛠 Proposed Refactor 15 | description: | 16 | A clear and concise description of the refactor proposal. Please outline 17 | the motivation for the proposal. If this is related to another GitHub 18 | issue, please link here too. 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | attributes: 24 | label: Suggest a potential alternative/fix 25 | description: | 26 | Tell us how we could improve the code in this regard. 27 | validations: 28 | required: true 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | name: Tests on ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-22.04, windows-2022, macos-14] 15 | python-version: ['3.9', '3.10', '3.11', '3.12'] 16 | exclude: 17 | - os: macos-14 18 | python-version: '3.9' #macos-14 & py39 not supported by setup-python@v5 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | submodules: True 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install tox tox-gh-actions 31 | - name: Test with tox 32 | run: tox -------------------------------------------------------------------------------- /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: build wheels 2 | 3 | # Trigger on PR and only if something is tagged on branches 4 | on: 5 | push: 6 | branches: ["*"] 7 | tags: ["*"] 8 | pull_request: 9 | branches: ["*"] 10 | 11 | 12 | jobs: 13 | build_wheels: 14 | name: Build wheels on ${{ matrix.os }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest, macos-13, macos-latest] 19 | fail-fast: false 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | submodules: True 25 | 26 | # Used to host cibuildwheel 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.13" 30 | 31 | - name: Install cibuildwheel 32 | run: python -m pip install cibuildwheel==2.22.0 33 | 34 | - name: Build wheels 35 | run: python -m cibuildwheel --output-dir wheelhouse 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} 40 | path: ./wheelhouse/*.whl 41 | 42 | publish: 43 | name: Publish to PyPI 44 | needs: build_wheels 45 | runs-on: ubuntu-latest 46 | # Only publish to PyPI when a commit is tagged 47 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 48 | steps: 49 | - uses: actions/checkout@v4 50 | with: 51 | submodules: True 52 | 53 | - uses: actions/setup-python@v5 54 | 55 | - uses: actions/download-artifact@v4 56 | with: 57 | pattern: cibw-* 58 | path: dist 59 | merge-multiple: true 60 | 61 | - name: Publish to PyPI 62 | env: 63 | TWINE_USERNAME: __token__ 64 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 65 | # construct the source package and upload src and wheels to PiPy 66 | run: | 67 | python -m pip install twine build --upgrade 68 | python -m build --sdist 69 | twine upload dist/* 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | ### VisualStudioCode 131 | .vscode/* 132 | !.vscode/settings.json 133 | !.vscode/tasks.json 134 | !.vscode/launch.json 135 | !.vscode/extensions.json 136 | *.code-workspace 137 | **/.vscode 138 | 139 | # JetBrains 140 | .idea/ 141 | 142 | # Data & Models 143 | *.h5 144 | *.tar 145 | *.tar.gz 146 | 147 | # Lightning-Hydra-Template 148 | configs/local/default.yaml 149 | data/ 150 | logs/ 151 | .env 152 | .autoenv 153 | 154 | 155 | # Point Geometric Features project 156 | src/CmakeFiles 157 | src/cmake_install.cmake 158 | src/Makefile 159 | src/CMakeCache.txt 160 | src/CMakeFiles 161 | *__pycache__* 162 | *.npy 163 | notebooks 164 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "eigen"] 2 | path = third_party/eigen 3 | url = https://gitlab.com/libeigen/eigen 4 | branch = 3.4 5 | [submodule "nanoflann"] 6 | path = third_party/nanoflann 7 | url = https://github.com/jlblancoc/nanoflann 8 | [submodule "taskflow"] 9 | path = third_party/taskflow 10 | url = https://github.com/taskflow/taskflow 11 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15...3.27) 2 | project(pgeof) 3 | find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED) 4 | 5 | if (NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 6 | set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE) 7 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "MinSizeRel" "RelWithDebInfo") 8 | endif() 9 | 10 | # Detect the installed nanobind package and import it into CMake 11 | execute_process( 12 | COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir 13 | OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE NB_DIR) 14 | list(APPEND CMAKE_PREFIX_PATH "${NB_DIR}") 15 | 16 | find_package(nanobind CONFIG REQUIRED) 17 | find_package(Threads REQUIRED) 18 | 19 | if(MSVC) 20 | # Static link of MSVC rt for optimal compatibility 21 | # It avoids to mess with embedded MSVC rt coming from other packages (see PyQT5) 22 | set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") 23 | # Same, enabling LTO (LTGC for MS) give less ABI compatibility, so we dissable it. 24 | # see https://learn.microsoft.com/en-us/cpp/porting/binary-compat-2015-2017?view=msvc-170 25 | # for more details. 26 | nanobind_add_module(pgeof_ext NOMINSIZE STABLE_ABI src/pgeof_ext.cpp) 27 | else() 28 | # we enable LTO on Linux and MacOS. 29 | nanobind_add_module(pgeof_ext NOMINSIZE STABLE_ABI LTO src/pgeof_ext.cpp) 30 | endif() 31 | 32 | target_link_libraries(pgeof_ext PRIVATE Threads::Threads) 33 | 34 | nanobind_add_stub( 35 | pgeof_ext_stub 36 | MODULE pgeof_ext 37 | OUTPUT pgeof_ext.pyi 38 | MARKER_FILE py.typed 39 | PYTHON_PATH $ 40 | DEPENDS pgeof_ext 41 | ) 42 | 43 | # All lib are header only. 44 | # it's faster to include like this than using exported targets 45 | # (i.e add_subdirectories(...)) 46 | target_include_directories(pgeof_ext PRIVATE "include" "third_party/eigen" "third_party/nanoflann/include" "third_party/taskflow") 47 | 48 | install(TARGETS pgeof_ext LIBRARY DESTINATION pgeof) 49 | install(FILES ${CMAKE_CURRENT_BINARY_DIR}/pgeof_ext.pyi ${CMAKE_CURRENT_BINARY_DIR}/py.typed DESTINATION pgeof) 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Damien Robert, Loic Landrieu, Romain Janvier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Point Geometric Features 4 | 5 | [![python](https://img.shields.io/badge/-Python_3.9_%7C_3.10_%7C_3.11_%7C_3.12-blue?logo=python&logoColor=white)](#) 6 | ![C++](https://img.shields.io/badge/c++-%2300599C.svg?style=for-the-badge&logo=c%2B%2B&logoColor=white) 7 | [![license](https://img.shields.io/badge/License-MIT-green.svg?labelColor=gray)](#) 8 | 9 | 10 |
11 | 12 | 13 | ## 📌 Description 14 | 15 | The `pgeof` library provides utilities for fast, parallelized computing ⚡ of **local geometric 16 | features for 3D point clouds** ☁️ **on CPU** . 17 | 18 |
19 | ️List of available features ️👇 20 | 21 | - linearity 22 | - planarity 23 | - scattering 24 | - verticality (two formulations) 25 | - normal_x 26 | - normal_y 27 | - normal_z 28 | - length 29 | - surface 30 | - volume 31 | - curvature 32 | - optimal neighborhood size 33 |
34 | 35 | `pgeof` allows computing features in multiple fashions: **on-the-fly subset of features** 36 | _a la_ [jakteristics](https://jakteristics.readthedocs.io), **array of features**, or 37 | **multiscale features**. Moreover, `pgeof` also offers functions for fast **K-NN** or 38 | **radius-NN** searches 🔍. 39 | 40 | Behind the scenes, the library is a Python wrapper around C++ utilities. 41 | The overall code is not intended to be DRY nor generic, it aims at providing efficient as 42 | possible implementations for some limited scopes and usages. 43 | 44 | ## 🧱 Installation 45 | 46 | ### From binaries 47 | 48 | ```bash 49 | python -m pip install pgeof 50 | ``` 51 | 52 | or 53 | 54 | ```bash 55 | python -m pip install git+https://github.com/drprojects/point_geometric_features 56 | ``` 57 | 58 | ### Building from sources 59 | 60 | `pgeof` depends on [Eigen library](https://eigen.tuxfamily.org/), [Taskflow](https://github.com/taskflow/taskflow), [nanoflann](https://github.com/jlblancoc/nanoflann) and [nanobind](https://github.com/wjakob/nanobind). 61 | The library adheres to [PEP 517](https://peps.python.org/pep-0517/) and uses [scikit-build-core](https://github.com/scikit-build/scikit-build-core) as build backend. 62 | Build dependencies (`nanobind`, `scikit-build-core`, ...) are fetched at build time. 63 | C++ third party libraries are embedded as submodules. 64 | 65 | 66 | ```bash 67 | # Clone project 68 | git clone --recurse-submodules https://github.com/drprojects/point_geometric_features.git 69 | cd point_geometric_features 70 | 71 | # Build and install the package 72 | python -m pip install . 73 | ``` 74 | 75 | ## 🚀 Using Point Geometric Features 76 | 77 | Here we summarize the very basics of `pgeof` usage. 78 | Users are invited to use `help(pgeof)` for further details on parameters. 79 | 80 | At its core `pgeof` provides three functions to compute a set of features given a 3D point cloud and 81 | some precomputed neighborhoods. 82 | 83 | ```python 84 | import pgeof 85 | 86 | # Compute a set of 11 predefined features per points 87 | pgeof.compute_features( 88 | xyz, # The point cloud. A numpy array of shape (n, 3) 89 | nn, # CSR data structure see below 90 | nn_ptr, # CSR data structure see below 91 | k_min = 1 # Minimum number of neighbors to consider for features computation 92 | verbose = false # Basic verbose output, for debug purposes 93 | ) 94 | ``` 95 | 96 | ```python 97 | # Sequence of n scales feature computation 98 | pgeof.compute_features_multiscale( 99 | ... 100 | k_scale # array of neighborhood size 101 | ) 102 | ``` 103 | 104 | ```python 105 | # Feature computation with optimal neighborhood selection as exposed in Weinmann et al., 2015 106 | # return a set of 12 features per points (11 + the optimal neighborhood size) 107 | pgeof.compute_features_optimal( 108 | ... 109 | k_min = 1, # Minimum number of neighbors to consider for features computation 110 | k_step = 1, # Step size to take when searching for the optimal neighborhood 111 | k_min_search = 1, # Starting size for searching the optimal neighborhood size. Should be >= k_min 112 | ) 113 | ``` 114 | 115 | ⚠️ Please note that for theses three functions the **neighbors are expected in CSR format**. 116 | This allows expressing neighborhoods of varying sizes with dense arrays (e.g. the output of a 117 | radius search). 118 | 119 | We provide very tiny and specialized **k-NN** and **radius-NN** search routines. 120 | They rely on `nanoflann` C++ library and should be **faster and lighter than `scipy` and 121 | `sklearn` alternatives**. 122 | 123 | Here are some examples of how to easily compute and convert typical k-NN or radius-NN neighborhoods to CSR format (`nn` and `nn_ptr` are two flat `uint32` arrays): 124 | 125 | ```python 126 | import pgeof 127 | import numpy as np 128 | 129 | # Generate a random synthetic point cloud and k-nearest neighbors 130 | num_points = 10000 131 | k = 20 132 | xyz = np.random.rand(num_points, 3).astype("float32") 133 | knn, _ = pgeof.knn_search(xyz, xyz, k) 134 | 135 | # Converting k-nearest neighbors to CSR format 136 | nn_ptr = np.arange(num_points + 1) * k 137 | nn = knn.flatten() 138 | 139 | # You may need to convert nn/nn_ptr to uint32 arrays 140 | nn_ptr = nn_ptr.astype("uint32") 141 | nn = nn.astype("uint32") 142 | 143 | features = pgeof.compute_features(xyz, nn, nn_ptr) 144 | ``` 145 | 146 | ```python 147 | import pgeof 148 | import numpy as np 149 | 150 | # Generate a random synthetic point cloud and k-nearest neighbors 151 | num_points = 10000 152 | radius = 0.2 153 | k = 20 154 | xyz = np.random.rand(num_points, 3).astype("float32") 155 | knn, _ = pgeof.radius_search(xyz, xyz, radius, k) 156 | 157 | # Converting radius neighbors to CSR format 158 | nn_ptr = np.r_[0, (knn >= 0).sum(axis=1).cumsum()] 159 | nn = knn[knn >= 0] 160 | 161 | # You may need to convert nn/nn_ptr to uint32 arrays 162 | nn_ptr = nn_ptr.astype("uint32") 163 | nn = nn.astype("uint32") 164 | 165 | features = pgeof.compute_features(xyz, nn, nn_ptr) 166 | ``` 167 | 168 | At last, and as a by-product, we also provide a function to **compute a subset of features on the fly**. 169 | It is inspired by the [jakteristics](https://jakteristics.readthedocs.io) python package (while 170 | being less complete but faster). 171 | The list of features to compute is given as an array of `EFeatureID`. 172 | 173 | ```python 174 | import pgeof 175 | from pgeof import EFeatureID 176 | import numpy as np 177 | 178 | # Generate a random synthetic point cloud and k-nearest neighbors 179 | num_points = 10000 180 | radius = 0.2 181 | k = 20 182 | xyz = np.random.rand(num_points, 3) 183 | 184 | # Compute verticality and curvature 185 | features = pgeof.compute_features_selected(xyz, radius, k, [EFeatureID.Verticality, EFeatureID.Curvature]) 186 | ``` 187 | 188 | ## Known limitations 189 | 190 | Some functions only accept `float` scalar types and `uint32` index types, and we avoid implicit 191 | cast / conversions. 192 | This could be a limitation in some situations (e.g. point clouds with `double` coordinates or 193 | involving very large big integer indices). 194 | Some C++ functions could be templated / to accept other types without conversion. 195 | For now, this feature is not enabled everywhere, to reduce compilation time and enhance code 196 | readability. 197 | Please let us know if you need this feature ! 198 | 199 | By convention, our normal vectors are forced to be oriented towards positive Z values. 200 | We make this design choice in order to return consistently-oriented normals. 201 | 202 | ## Testing 203 | 204 | Some basic tests and benchmarks are provided in the `tests` directory. 205 | Tests can be run in a clean and reproducible environments via `tox` (`tox run` and 206 | `tox run -e bench`). 207 | 208 | ## 💳 Credits 209 | This implementation was largely inspired from [Superpoint Graph](https://github.com/loicland/superpoint_graph). The main modifications here allow: 210 | - parallel computation on all points' local neighborhoods, with neighborhoods of varying sizes 211 | - more geometric features 212 | - optimal neighborhood search from this [paper](http://lareg.ensg.eu/labos/matis/pdf/articles_revues/2015/isprs_wjhm_15.pdf) 213 | - some corrections on geometric features computation 214 | 215 | Some heavy refactoring (port to nanobind, test, benchmarks), packaging, speed optimization, feature addition (NN search, on the fly feature computation...) were funded by: 216 | 217 | Centre of Wildfire Research of Swansea University (UK) in collaboration with the Research Institute of Biodiversity (CSIC, Spain) and the Department of Mining Exploitation of the University of Oviedo (Spain). 218 | 219 | Funding provided by the UK NERC project (NE/T001194/1): 220 | 221 | 'Advancing 3D Fuel Mapping for Wildfire Behaviour and Risk Mitigation Modelling' 222 | 223 | and by the Spanish Knowledge Generation project (PID2021-126790NB-I00): 224 | 225 | ‘Advancing carbon emission estimations from wildfires applying artificial intelligence to 3D terrestrial point clouds’. 226 | 227 | ## License 228 | 229 | Point Geometric Features is licensed under the MIT License. 230 | -------------------------------------------------------------------------------- /include/nn_search.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | #include "pca.hpp" 14 | namespace nb = nanobind; 15 | 16 | namespace pgeof 17 | { 18 | 19 | /** 20 | * Given two point clouds, compute for each point present in one of the point cloud 21 | * the N closest points in the other point cloud 22 | * 23 | * It should be faster than scipy.spatial.KDTree for this task. 24 | * 25 | * @param data the reference point cloud. 26 | * @param query the point cloud used for the queries. 27 | * @param knn the number of neighbors to take into account for each point. 28 | * @return a pair of nd::array, both of size (n_points x knn), the first one contains the indices of each neighbor, the 29 | * second one the square distances between the query point and each of its neighbors. 30 | */ 31 | template 32 | static std::pair>, nb::ndarray>> 33 | nanoflann_knn_search(RefCloud data, RefCloud query, const uint32_t knn) 34 | { 35 | using kd_tree_t = nanoflann::KDTreeEigenMatrixAdaptor, 3, nanoflann::metric_L2_Simple>; 36 | 37 | if (knn > data.rows()) { throw std::invalid_argument("knn size is greater than the data point cloud size"); } 38 | 39 | kd_tree_t kd_tree(3, data, 10, 0); 40 | const Eigen::Index n_points = query.rows(); 41 | uint32_t* indices = new uint32_t[knn * n_points]; 42 | nb::capsule owner_indices(indices, [](void* p) noexcept { delete[] (uint32_t*)p; }); 43 | 44 | real_t* sqr_dist = new real_t[knn * n_points]; 45 | nb::capsule owner_dist(sqr_dist, [](void* p) noexcept { delete[] (real_t*)p; }); 46 | 47 | tf::Executor executor; 48 | tf::Taskflow taskflow; 49 | taskflow.for_each_index( 50 | Eigen::Index(0), n_points, Eigen::Index(1), 51 | [&](Eigen::Index point_id) 52 | { 53 | nanoflann::KNNResultSet result_set(knn); 54 | 55 | const size_t id = point_id * knn; 56 | result_set.init(&indices[id], &sqr_dist[id]); 57 | kd_tree.index_->findNeighbors(result_set, query.row(point_id).data()); 58 | }), 59 | tf::StaticPartitioner(0); 60 | 61 | executor.run(taskflow).get(); 62 | 63 | const size_t shape[2] = {static_cast(n_points), static_cast(knn)}; 64 | return { 65 | nb::ndarray>(indices, 2, shape, owner_indices), 66 | nb::ndarray>(sqr_dist, 2, shape, owner_dist)}; 67 | }; 68 | 69 | /** 70 | * Search for the points within a specified sphere in a point cloud. 71 | * 72 | * It could be a fallback replacement for FRNN into SuperPointTransformer code base. 73 | * It should be faster than scipy.spatial.KDTree for this task. 74 | * 75 | * @param data the reference point cloud. 76 | * @param query the point cloud used for the queries (sphere centers) 77 | * @param search_radius the search radius. 78 | * @param max_knn the maximum number of neighbors to fetch inside the radius. (Fixing a 79 | * reasonable max number of neighbors prevents running OOM for large radius/dense point clouds.) 80 | * @return a pair of nd::array, both of size (n_points x knn), the first one contains the 'indices' of each neighbor, 81 | * the second one the 'square_distances' between the query point and each neighbor. Point having a number of neighbors < 82 | * 'max_knn' inside the 'search_radius' will have their 'indices' and and 'square_distances' filled respectively with 83 | * '-1' and 'O' for any missing neighbor. 84 | */ 85 | template 86 | static std::pair>, nb::ndarray>> 87 | nanoflann_radius_search( 88 | RefCloud data, RefCloud query, const real_t search_radius, const uint32_t max_knn) 89 | { 90 | using kd_tree_t = nanoflann::KDTreeEigenMatrixAdaptor, 3, nanoflann::metric_L2_Simple>; 91 | 92 | if (max_knn > data.rows()) 93 | { 94 | throw std::invalid_argument("max knn size is greater than the data point cloud size"); 95 | } 96 | 97 | kd_tree_t kd_tree(3, data, 10, 0); 98 | const real_t sq_search_radius = search_radius * search_radius; 99 | 100 | const Eigen::Index n_points = query.rows(); 101 | 102 | int32_t* indices = new int32_t[max_knn * n_points]; 103 | nb::capsule owner_indices(indices, [](void* p) noexcept { delete[] (int32_t*)p; }); 104 | std::fill(indices, indices + (max_knn * n_points), -1); 105 | 106 | real_t* sqr_dist = new real_t[max_knn * n_points]; 107 | nb::capsule owner_dist(sqr_dist, [](void* p) noexcept { delete[] (real_t*)p; }); 108 | std::fill(sqr_dist, sqr_dist + (max_knn * n_points), real_t(0.0)); 109 | 110 | tf::Executor executor; 111 | tf::Taskflow taskflow; 112 | 113 | taskflow.for_each_index( 114 | Eigen::Index(0), n_points, Eigen::Index(1), 115 | [&](Eigen::Index point_id) 116 | { 117 | nanoflann::RKNNResultSet result_set(max_knn, sq_search_radius); 118 | 119 | const size_t id = point_id * max_knn; 120 | 121 | result_set.init(&indices[id], &sqr_dist[id]); 122 | kd_tree.index_->findNeighbors(result_set, query.row(point_id).data()); 123 | }, 124 | tf::StaticPartitioner(0)); 125 | 126 | executor.run(taskflow).get(); 127 | 128 | const size_t shape[2] = {static_cast(n_points), static_cast(max_knn)}; 129 | return { 130 | nb::ndarray>(indices, 2, shape, owner_indices), 131 | nb::ndarray>(sqr_dist, 2, shape, owner_dist)}; 132 | }; 133 | 134 | } // namespace pgeof 135 | -------------------------------------------------------------------------------- /include/pca.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | namespace nb = nanobind; 9 | 10 | namespace pgeof 11 | { 12 | 13 | // Type definitions 14 | template 15 | using PointCloud = Eigen::Matrix; 16 | 17 | template 18 | using RefCloud = Eigen::Ref>; 19 | 20 | template 21 | using Vec3 = Eigen::RowVector; 22 | 23 | template 24 | using MatrixCloud = Eigen::Matrix; 25 | template 26 | using DRefMatrixCloud = nb::DRef>; 27 | 28 | // epsilon definition, for now same for float an double 29 | // the eps is meant to stabilize the division when the cloud's 3rd eigenvalue is near 0 30 | template 31 | constexpr real_t epsilon; 32 | template <> 33 | constexpr float epsilon = 1e-3f; 34 | template <> 35 | constexpr double epsilon = 1e-3; 36 | 37 | template 38 | struct PCAResult 39 | { 40 | Vec3 val; 41 | Vec3 v0; 42 | Vec3 v1; 43 | Vec3 v2; 44 | }; 45 | 46 | // enum of features 47 | typedef enum EFeatureID 48 | { 49 | Linearity = 0, 50 | Planarity, 51 | Scattering, 52 | VerticalityPGEOF, // Formula is different from the "classical" formula 53 | Normal_x, // Normal as the third eigenvector 54 | Normal_y, 55 | Normal_z, 56 | Length, 57 | Surface, 58 | Volume, 59 | Curvature, 60 | K_optimal, 61 | Verticality, // this is the "classical" verticality 62 | Eigentropy 63 | } EFeatureID; 64 | 65 | /** 66 | * Given A point cloud compute a PCAResult 67 | * 68 | * @param cloud the point cloud 69 | * @returns A PCAResult 70 | */ 71 | template 72 | static inline PCAResult pca_from_pointcloud(const PointCloud& cloud) 73 | { 74 | // Compute the (3, 3) covariance matrix 75 | const PointCloud centered_cloud = cloud.rowwise() - cloud.colwise().mean(); 76 | const Eigen::Matrix cov = (centered_cloud.transpose() * centered_cloud) / real_t(cloud.rows()); 77 | 78 | // Compute the eigenvalues and eigenvectors of the covariance 79 | Eigen::SelfAdjointEigenSolver> es(cov); 80 | 81 | // Sort the values and vectors in order of increasing eigenvalue 82 | const auto ev = es.eigenvalues().real(); 83 | 84 | std::array indices = {0, 1, 2}; 85 | 86 | std::sort( 87 | std::begin(indices), std::end(indices), [&](Eigen::Index i1, Eigen::Index i2) { return ev(i1) > ev(i2); }); 88 | 89 | Vec3 val = { 90 | (std::max(ev(indices[0]), real_t(0.))), (std::max(ev(indices[1]), real_t(0.))), 91 | (std::max(ev(indices[2]), real_t(0.)))}; 92 | Vec3 v0 = es.eigenvectors().col(indices[0]).real(); 93 | Vec3 v1 = es.eigenvectors().col(indices[1]).real(); 94 | Vec3 v2 = es.eigenvectors().col(indices[2]).real(); 95 | 96 | // To standardize the orientation of eigenvectors, we choose to enforce all eigenvectors 97 | // to be expressed in the Z+ half-space. 98 | // Only the third eigenvector (v2) needs to be reoriented because it is the 99 | // only one used in further computations. 100 | // TODO: In case we want to orient normal, this should be improved 101 | if (v2(2) < real_t(0.)) { v2 = real_t(-1.) * v2; } 102 | return {val, v0, v1, v2}; 103 | }; 104 | 105 | /** 106 | * Given A point cloud and a CSR definition of the neighboring information for each point, compute a PCAResult 107 | * 108 | * @param xyz the point cloud 109 | * @param nn Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 110 | * '-1' indices will either crash or silently compute incorrect 111 | * @param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 112 | * are 'nn[nn_ptr[i]:nn_ptr[i + 1]]' 113 | * @param i_point the index of the 'central point' or point 114 | * @param k_nnn the number of neighbors to take into account to compute the PCA. It's the caller responsibility 115 | * to ensure k_nn won't overflow nn_ptr array. 116 | * @returns A PCAResult 117 | */ 118 | template 119 | static PCAResult pca_from_neighborhood( 120 | RefCloud xyz, const index_t* nn, const index_t* nn_ptr, const size_t i_point, const size_t k_nn) 121 | { 122 | // Initialize the cloud (n_neighbors, 3) matrix holding the 123 | // points' neighbors XYZ coordinates 124 | PointCloud cloud(k_nn, 3); 125 | // Recover the neighbors' XYZ coordinates using nn and xyz 126 | for (size_t i_nei = 0; i_nei < k_nn; i_nei++) 127 | { 128 | // Recover the neighbor's position in the xyz vector 129 | const Eigen::Index idx_nei = static_cast(nn[nn_ptr[i_point] + i_nei]); 130 | // Recover the corresponding xyz coordinates 131 | cloud.row(i_nei) = xyz.row(idx_nei); 132 | } 133 | return pca_from_pointcloud(cloud); 134 | }; 135 | 136 | /** 137 | * Given a PCA result compute the eigentropy 138 | * 139 | * This Eigentropy is used for neighborhood size selection in some function 140 | * and could be used a an individual feature as well 141 | * 142 | * @param pca PCAResult 143 | * @return the eigentropy 144 | */ 145 | template 146 | static inline real_t compute_eigentropy(const PCAResult& pca) 147 | { 148 | // Compute the eigentropy as defined in: 149 | // http://lareg.ensg.eu/labos/matis/pdf/articles_revues/2015/isprs_wjhm_15.pdf 150 | const real_t val_sum = pca.val.sum() + epsilon; 151 | const Vec3 e = pca.val / val_sum; 152 | return ( 153 | -e(0) * std::log(e(0) + epsilon) - e(1) * std::log(e(1) + epsilon) - 154 | e(2) * std::log(e(2) + epsilon)); 155 | }; 156 | 157 | /** 158 | * Given a PCA result compute a full set of feature (the initial set of feature r) 159 | * 160 | * This function intends on mimicking the behavior of original PGEOF feature computation 161 | * 162 | * @param[in] pca PCAResult 163 | * @param[out] feature_results the array of resulting features. 164 | */ 165 | template 166 | static void compute_features(const PCAResult& pca, real_t* features) 167 | { 168 | constexpr real_t sq_eps = real_t(1e-6); 169 | constexpr real_t cub_eps = real_t(1e-9); 170 | constexpr real_t one_third = real_t(1.) / real_t(3.); 171 | 172 | // Compute the dimensionality features. The eps term is meant 173 | // to stabilize the division when the cloud's 3rd eigenvalue is 174 | // near 0 (points lie in 1D or 2D). Note we take the sqrt of the 175 | // eigenvalues since the PCA eigenvalues are homogeneous to m² 176 | const real_t val0 = std::sqrt(pca.val(0)); 177 | const real_t val1 = std::sqrt(pca.val(1)); 178 | const real_t val2 = std::sqrt(pca.val(2)); 179 | const real_t val0_fact = real_t(1.0) / (val0 + epsilon); 180 | 181 | features[EFeatureID::Normal_x] = pca.v2(0); 182 | features[EFeatureID::Normal_y] = pca.v2(1); 183 | features[EFeatureID::Normal_z] = pca.v2(2); 184 | features[EFeatureID::Linearity] = (val0 - val1) * val0_fact; 185 | features[EFeatureID::Planarity] = (val1 - val2) * val0_fact; 186 | features[EFeatureID::Scattering] = val2 * val0_fact; 187 | features[EFeatureID::Length] = val0; 188 | features[EFeatureID::Surface] = std::sqrt(val0 * val1 + sq_eps); 189 | features[EFeatureID::Volume] = std::pow(val0 * val1 * val2 + cub_eps, one_third); 190 | features[EFeatureID::Curvature] = val2 / (val0 + val1 + val2 + epsilon); 191 | 192 | // Compute the verticality. NB we account for the edge case 193 | // where all features are 0 194 | if (val0 > real_t(0.)) 195 | { 196 | const Vec3 unary_vector = { 197 | pca.val(0) * std::abs(pca.v0(0)) + pca.val(1) * std::abs(pca.v1(0)) + pca.val(2) * std::abs(pca.v2(0)), 198 | pca.val(0) * std::abs(pca.v0(1)) + pca.val(1) * std::abs(pca.v1(1)) + pca.val(2) * std::abs(pca.v2(1)), 199 | // pca.v2 is already absolute value (positive). but we keep the operation for now 200 | // since we can come with our own normal orientation or any other external normal orientation routine 201 | pca.val(0) * std::abs(pca.v0(2)) + pca.val(1) * std::abs(pca.v1(2)) + pca.val(2) * std::abs(pca.v2(2))}; 202 | 203 | features[EFeatureID::VerticalityPGEOF] = unary_vector(2) / unary_vector.norm(); 204 | } 205 | }; 206 | 207 | /** 208 | * Given a PCA result compute only a subset of features. 209 | * 210 | * This function intends on mimicking the behavior of Jakteristics 211 | * 212 | * @param[in] pca PCAResult 213 | * @param[in] selected_feature a vector of the type of features to compute 214 | * @param[out] feature_result the array of resulting features. Result are inserted sequentially the order is defined 215 | * by the selected_feature array. 216 | */ 217 | template 218 | void compute_selected_features( 219 | const PCAResult& pca, const std::vector& selected_feature, real_t* feature_results) 220 | { 221 | // Compute the dimensionality features. The 1e-3 term is meant 222 | // to stabilize the division when the cloud's 3rd eigenvalue is 223 | // near 0 (points lie in 1D or 2D). Note we take the sqrt of the 224 | // eigenvalues since the PCA eigenvalues are homogeneous to m² 225 | const real_t val0 = std::sqrt(pca.val(0)); 226 | const real_t val1 = std::sqrt(pca.val(1)); 227 | const real_t val2 = std::sqrt(pca.val(2)); 228 | const real_t val0_fact = real_t(1.0) / (val0 + epsilon); 229 | 230 | const auto compute_feature = 231 | [val0, val1, val2, val0_fact, &pca](const EFeatureID feature_id, const size_t output_id, auto* feature_results) 232 | { 233 | switch (feature_id) 234 | { 235 | case EFeatureID::Normal_x: 236 | feature_results[output_id] = pca.v2(0); 237 | break; 238 | case EFeatureID::Normal_y: 239 | feature_results[output_id] = pca.v2(1); 240 | break; 241 | case EFeatureID::Normal_z: 242 | feature_results[output_id] = pca.v2(2); 243 | break; 244 | case EFeatureID::Linearity: 245 | feature_results[output_id] = (val0 - val1) * val0_fact; 246 | break; 247 | case EFeatureID::Planarity: 248 | feature_results[output_id] = (val1 - val2) * val0_fact; 249 | break; 250 | case EFeatureID::Scattering: 251 | feature_results[output_id] = val2 * val0_fact; 252 | break; 253 | case EFeatureID::Length: 254 | feature_results[output_id] = val0; 255 | break; 256 | case EFeatureID::Surface: 257 | feature_results[output_id] = std::sqrt(val0 * val1 + 1e-6f); 258 | break; 259 | case EFeatureID::Volume: 260 | feature_results[output_id] = std::pow( 261 | val0 * val1 * val2 + real_t(1e-9), 262 | real_t(1.) / real_t(3.)); // 1e-9 eps is a too small value for float32 so we fallback to 1e-8 263 | break; 264 | case EFeatureID::Curvature: 265 | feature_results[output_id] = val2 / (val0 + val1 + val2 + epsilon); 266 | break; 267 | case EFeatureID::VerticalityPGEOF: 268 | // the verticality as defined in PGEOF 269 | if (val0 > real_t(0.)) 270 | { 271 | const Vec3 unary_vector = { 272 | pca.val(0) * std::abs(pca.v0(0)) + pca.val(1) * std::abs(pca.v1(0)) + 273 | pca.val(2) * std::abs(pca.v2(0)), 274 | pca.val(0) * std::abs(pca.v0(1)) + pca.val(1) * std::abs(pca.v1(1)) + 275 | pca.val(2) * std::abs(pca.v2(1)), 276 | // pca.v2 is already absolute value (positive). But we keep the operation for now 277 | // since we can come with our own normal orientation or any other external normal orientation 278 | // routine 279 | pca.val(0) * std::abs(pca.v0(2)) + pca.val(1) * std::abs(pca.v1(2)) + 280 | pca.val(2) * std::abs(pca.v2(2))}; 281 | 282 | feature_results[output_id] = unary_vector(2) / unary_vector.norm(); 283 | // TODO: Jakteristics compute this as feature_results[output_id] = real_t(1.0) - 284 | // std::abs(pca.v2(2)); 285 | // It seems to be the most common formula for the verticality in the literature 286 | } 287 | break; 288 | case EFeatureID::Verticality: 289 | // The verticality as defined in most of the papers 290 | // http://lareg.ensg.eu/labos/matis/pdf/articles_revues/2015/isprs_wjhm_15.pdf 291 | feature_results[output_id] = real_t(1.0) - std::abs(pca.v2(2)); 292 | break; 293 | case EFeatureID::Eigentropy: 294 | feature_results[output_id] = compute_eigentropy(pca); 295 | break; 296 | default: 297 | break; 298 | } 299 | }; 300 | 301 | for (size_t i = 0; i < selected_feature.size(); ++i) { compute_feature(selected_feature[i], i, feature_results); } 302 | } 303 | 304 | } // namespace pgeof 305 | -------------------------------------------------------------------------------- /include/pgeof.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | #include "pca.hpp" 17 | 18 | namespace nb = nanobind; 19 | 20 | namespace pgeof 21 | { 22 | 23 | namespace log 24 | { 25 | /** 26 | * Log a progress into the std::cout. It has multiple quirks (std::cout usage, race condition) 27 | * but it is mostly used for debugging purposes. 28 | * 29 | * @param progress_count a reference to the current count. it is incremented by one at each call of this function 30 | * @param progress_total the total expected count of iteration 31 | */ 32 | static inline void progress(size_t& progress_count, const size_t progress_total) 33 | { 34 | ++progress_count; 35 | // Print progress 36 | // NB: when in parallel progress_count behavior is undefined, but 37 | // gives a good indication of progress 38 | if (progress_count % 10000 == 0) 39 | { 40 | std::cout << progress_count << "% done \r" << std::flush; 41 | std::cout << std::ceil(progress_count * 100 / progress_total) << "% done \r" << std::flush; 42 | } 43 | }; 44 | 45 | /** 46 | * flush the logger 47 | */ 48 | static inline void flush() { std::cout << std::endl; }; 49 | } // namespace log 50 | 51 | /** 52 | * Compute a set of geometric features for a point cloud from a precomputed list of neighbors. 53 | * 54 | * * The following features are computed: 55 | * - linearity 56 | * - planarity 57 | * - scattering 58 | * - verticality 59 | * - normal vector (oriented towards positive z-coordinates) 60 | * - length 61 | * - surface 62 | * - volume 63 | * - curvature 64 | * 65 | * @param xyz The point cloud. 66 | * @param nn Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 67 | * '-1' indices will either crash or silently compute incorrect features. 68 | * @param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 69 | * are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 70 | * @param k_min Minimum number of neighbors to consider for features computation. If a point has less, 71 | * it features will be a set of '0' values. 72 | * @param verbose Whether computation progress should be printed out 73 | * @return the geometric features associated with each point's neighborhood in a (num_points, features_count) nd::array. 74 | */ 75 | template 76 | static nb::ndarray(feature_count)>> 77 | compute_geometric_features( 78 | RefCloud xyz, nb::ndarray> nn, 79 | nb::ndarray> nn_ptr, const size_t k_min, const bool verbose) 80 | { 81 | if (k_min < 1) { throw std::invalid_argument("k_min should be > 1"); } 82 | // Each point can be treated in parallel 83 | const size_t n_points = nn_ptr.size() - 1; // number of points is not determined by xyz 84 | size_t s_point = 0; 85 | const uint32_t* nn_data = nn.data(); 86 | const uint32_t* nn_ptr_data = nn_ptr.data(); 87 | 88 | real_t* features = (real_t*)calloc(n_points * feature_count, sizeof(real_t)); 89 | nb::capsule owner_features(features, [](void* f) noexcept { delete[] (real_t*)f; }); 90 | 91 | tf::Executor executor; 92 | tf::Taskflow taskflow; 93 | taskflow.for_each_index( 94 | size_t(0), size_t(n_points), size_t(1), 95 | [&](size_t i_point) 96 | { 97 | if (verbose) log::progress(s_point, n_points); 98 | 99 | // Recover the points' total number of neighbors 100 | const size_t k_nn = static_cast(nn_ptr_data[i_point + 1] - nn_ptr_data[i_point]); 101 | 102 | // If the cloud has less than k_min point, continue 103 | if (k_nn >= k_min) 104 | { 105 | const PCAResult pca = pca_from_neighborhood(xyz, nn_data, nn_ptr_data, i_point, k_nn); 106 | compute_features(pca, &features[i_point * feature_count]); 107 | } 108 | }, 109 | tf::StaticPartitioner(0)); 110 | executor.run(taskflow).get(); 111 | 112 | // Final print to start on a new line 113 | if (verbose) log::flush(); 114 | const size_t shape[2] = {n_points, feature_count}; 115 | return nb::ndarray(feature_count)>>( 116 | features, 2, shape, owner_features); 117 | } 118 | /** 119 | * Convenience function that check that scales are well ordered in increasing order. 120 | * 121 | * @param k_scales the list of scale size (number of neighbors). 122 | */ 123 | static bool check_scales(const std::vector& k_scales) 124 | { 125 | uint32_t previous_scale = 1; // minimal admissible k_min value is 1 126 | for (const auto& current_scale : k_scales) 127 | { 128 | if (current_scale < previous_scale) { return false; } 129 | previous_scale = current_scale; 130 | } 131 | return true; 132 | } 133 | 134 | /** 135 | * Compute a set of geometric features for a point cloud in a multiscale fashion. 136 | * 137 | * The following features are computed: 138 | * - linearity 139 | * - planarity 140 | * - scattering 141 | * - verticality 142 | * - normal vector (oriented towards positive z-coordinates) 143 | * - length 144 | * - surface 145 | * - volume 146 | * - curvature 147 | * 148 | * @param xyz The point cloud 149 | * @param nn Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 150 | * '-1' indices will either crash or silently compute incorrect features. 151 | * @param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 152 | * are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 153 | * @param k_scale Array of number of neighbors to consider for features computation. If a at a given scale, a point has 154 | * less features will be a set of '0' values. 155 | * @param verbose Whether computation progress should be printed out 156 | * @return Geometric features associated with each point's neighborhood in a (num_points, features_count, n_scales) 157 | * nd::array 158 | */ 159 | template 160 | static nb::ndarray(feature_count)>> 161 | compute_geometric_features_multiscale( 162 | RefCloud xyz, nb::ndarray> nn, 163 | nb::ndarray> nn_ptr, const std::vector& k_scales, const bool verbose) 164 | { 165 | if (!check_scales(k_scales)) 166 | { 167 | throw std::invalid_argument("k_scales should be > 1 and sorted in ascending order"); 168 | } 169 | const size_t n_points = nn_ptr.size() - 1; // number of points is not determined by xyz 170 | const size_t n_scales = k_scales.size(); 171 | size_t s_point = 0; 172 | const uint32_t* nn_data = nn.data(); 173 | const uint32_t* nn_ptr_data = nn_ptr.data(); 174 | 175 | real_t* features = (real_t*)calloc(n_points * n_scales * feature_count, sizeof(real_t)); 176 | nb::capsule owner_features(features, [](void* f) noexcept { delete[] (real_t*)f; }); 177 | 178 | // Each point can be treated in parallel 179 | tf::Executor executor; 180 | tf::Taskflow taskflow; 181 | taskflow.for_each_index( 182 | size_t(0), size_t(n_points), size_t(1), 183 | [&](size_t i_point) 184 | { 185 | if (verbose) log::progress(s_point, n_points); 186 | // Recover the points' total number of neighbors 187 | const size_t k_nn = static_cast(nn_ptr_data[i_point + 1] - nn_ptr_data[i_point]); 188 | 189 | for (size_t i_scale = 0; i_scale < n_scales; ++i_scale) 190 | { 191 | const size_t knn_scale = static_cast(k_scales[i_scale]); 192 | 193 | if (k_nn < knn_scale) 194 | break; // we assume scales are stored in increasing order, 195 | // so we could do an early break in case of k_nn < 196 | // knn_scale 197 | const PCAResult pca = pca_from_neighborhood(xyz, nn_data, nn_ptr_data, i_point, knn_scale); 198 | compute_features(pca, &features[(i_point * n_scales + i_scale) * feature_count]); 199 | } 200 | }, 201 | tf::StaticPartitioner(0)); 202 | 203 | executor.run(taskflow).get(); 204 | 205 | // Final print to start on a new line 206 | if (verbose) log::flush(); 207 | 208 | const size_t shape[3] = {n_points, n_scales, feature_count}; 209 | return nb::ndarray(feature_count)>>( 210 | features, 3, shape, owner_features); 211 | } 212 | 213 | /** 214 | * Compute a set of geometric features for a point cloud using the optimal neighborhood selection described in 215 | * http://lareg.ensg.eu/labos/matis/pdf/articles_revues/2015/isprs_wjhm_15.pdf 216 | * 217 | * * The following features are computed: 218 | * - linearity 219 | * - planarity 220 | * - scattering 221 | * - verticality 222 | * - normal vector (oriented towards positive z-coordinates) 223 | * - length 224 | * - surface 225 | * - volume 226 | * - curvature 227 | * - optimal_nn 228 | * 229 | * @param xyz The point cloud 230 | * @param nn Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 231 | * '-1' indices will either crash or silently compute incorrect features. 232 | * @param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 233 | * are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 234 | * @param k_min Minimum number of neighbors to consider for features computation. If a point has less, 235 | * its features will be a set of '0' values. 236 | * @param k_step Step size to take when searching for the optimal neighborhood, size for each point following 237 | * Weinmann, 2015 238 | * @param k_min_search Minimum neighborhood size at which to start when searching for the optimal neighborhood size for 239 | each point. It is advised to use a value of 10 or higher, for geometric features robustness. 240 | * @param verbose Whether computation progress should be printed out 241 | * @return Geometric features associated with each point's neighborhood in a (num_points, features_count) nd::array 242 | */ 243 | template 244 | static nb::ndarray(feature_count)>> 245 | compute_geometric_features_optimal( 246 | RefCloud xyz, nb::ndarray> nn, 247 | nb::ndarray> nn_ptr, const uint32_t k_min, const uint32_t k_step, 248 | const uint32_t k_min_search, const bool verbose) 249 | { 250 | if (k_min < 1 && k_min_search < 1) { throw std::invalid_argument("k_min and k_min_search should be > 1"); } 251 | // Each point can be treated in parallel 252 | const size_t n_points = nn_ptr.size() - 1; // number of points is not determined by xyz 253 | size_t s_point = 0; 254 | const uint32_t* nn_data = nn.data(); 255 | const uint32_t* nn_ptr_data = nn_ptr.data(); 256 | 257 | real_t* features = (real_t*)calloc(n_points * feature_count, sizeof(real_t)); 258 | nb::capsule owner_features(features, [](void* f) noexcept { delete[] (real_t*)f; }); 259 | 260 | tf::Executor executor; 261 | tf::Taskflow taskflow; 262 | taskflow.for_each_index( 263 | size_t(0), size_t(n_points), size_t(1), 264 | [&](size_t i_point) 265 | { 266 | if (verbose) log::progress(s_point, n_points); 267 | 268 | // Recover the points' total number of neighbors 269 | const size_t k_nn = static_cast(nn_ptr_data[i_point + 1] - nn_ptr_data[i_point]); 270 | 271 | // Process only if the cloud has the required number of point 272 | if (k_nn >= k_min && k_nn >= k_min_search) 273 | { 274 | size_t k0 = std::min(std::max(static_cast(k_min), static_cast(k_min_search)), k_nn); 275 | 276 | PCAResult pca_optimal; 277 | real_t eigenentropy_optimal = real_t(1.0); 278 | size_t k_optimal = k_nn; 279 | for (size_t k = k0; k <= k_nn; ++k) 280 | { 281 | // Only evaluate the neighborhood's PCA every 'k_step' 282 | // and at the boundary values: k0 and k_nn 283 | if ((k > k0) && (k % k_step != 0) && (k != k_nn)) { continue; } 284 | 285 | const PCAResult pca = pca_from_neighborhood(xyz, nn_data, nn_ptr_data, i_point, k); 286 | const real_t eigenentropy = compute_eigentropy(pca); 287 | // Keep track of the optimal neighborhood size with the 288 | // lowest eigenentropy 289 | if ((k == k0) || (eigenentropy < eigenentropy_optimal)) 290 | { 291 | eigenentropy_optimal = eigenentropy; 292 | k_optimal = k; 293 | pca_optimal = pca; 294 | } 295 | } 296 | compute_features(pca_optimal, &features[i_point * feature_count]); 297 | // Add best nn 298 | features[i_point * feature_count + 11] = real_t(k_optimal); 299 | } 300 | }, 301 | tf::StaticPartitioner(0)); 302 | 303 | executor.run(taskflow).get(); 304 | 305 | if (verbose) log::flush(); 306 | 307 | const size_t shape[2] = {n_points, feature_count}; 308 | return nb::ndarray(feature_count)>>( 309 | features, 2, shape, owner_features); 310 | } 311 | 312 | /** 313 | * Compute a selected set of geometric features for a point cloud via radius search. 314 | * 315 | * This function aims to mimic the behavior of jakteristics and provide an efficient way 316 | * to compute a limited set of features. 317 | * 318 | * @param xyz The point cloud 319 | * @param search_radius the search radius. 320 | * @param max_knn the maximum number of neighbors to fetch inside the radius. The central point is included. Fixing a 321 | * reasonable max number of neighbors prevents running OOM for large radius/dense point clouds. 322 | * @param selected_features the list of selected features. See pgeof::EFeatureID 323 | * @return Geometric features associated with each point's neighborhood in a (num_points, features_count) nd::array 324 | */ 325 | template 326 | static nb::ndarray> compute_geometric_features_selected( 327 | RefCloud xyz, const real_t search_radius, const uint32_t max_knn, 328 | const std::vector& selected_features) 329 | { 330 | using kd_tree_t = nanoflann::KDTreeEigenMatrixAdaptor, 3, nanoflann::metric_L2_Simple>; 331 | // TODO: where knn < num of points 332 | 333 | kd_tree_t kd_tree(3, xyz, 10, 0); 334 | const size_t feature_count = selected_features.size(); 335 | const Eigen::Index n_points = xyz.rows(); 336 | real_t sq_search_radius = search_radius * search_radius; 337 | 338 | real_t* features = (real_t*)calloc(n_points * feature_count, sizeof(real_t)); 339 | nb::capsule owner_features(features, [](void* f) noexcept { delete[] (real_t*)f; }); 340 | 341 | tf::Executor executor; 342 | tf::Taskflow taskflow; 343 | 344 | taskflow.for_each_index( 345 | Eigen::Index(0), n_points, Eigen::Index(1), 346 | [&](Eigen::Index point_id) 347 | { 348 | std::vector> result_set; 349 | 350 | nanoflann::RadiusResultSet radius_result_set(sq_search_radius, result_set); 351 | const auto num_found = 352 | kd_tree.index_->radiusSearchCustomCallback(xyz.row(point_id).data(), radius_result_set); 353 | 354 | // not enough point, no feature computation 355 | if (num_found < 2) return; 356 | 357 | // partial sort for max_knn 358 | if (num_found > max_knn) 359 | { 360 | std::partial_sort( 361 | result_set.begin(), result_set.begin() + max_knn, result_set.end(), nanoflann::IndexDist_Sorter()); 362 | } 363 | 364 | const size_t num_nn = std::min(static_cast(num_found), max_knn); 365 | 366 | PointCloud cloud(num_nn, 3); 367 | for (size_t id = 0; id < num_nn; ++id) { cloud.row(id) = xyz.row(result_set[id].first); } 368 | const PCAResult pca = pca_from_pointcloud(cloud); 369 | compute_selected_features(pca, selected_features, &features[point_id * feature_count]); 370 | }); 371 | executor.run(taskflow).get(); 372 | 373 | return nb::ndarray>( 374 | features, {static_cast(n_points), feature_count}, owner_features); 375 | } 376 | } // namespace pgeof 377 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "scikit-build-core >=0.4.3", 4 | "nanobind == 2.1.0", 5 | "typing_extensions;python_version < '3.11'", 6 | ] 7 | build-backend = "scikit_build_core.build" 8 | 9 | [project] 10 | name = "pgeof" 11 | version = "0.3.2" 12 | readme = "README.md" 13 | description = "Compute the geometric features associated with each point's neighborhood:" 14 | requires-python = ">=3.8,<3.14" 15 | license = { file = "LICENSE" } 16 | authors = [ 17 | { name = "Loic Landrieu", email = "loic.landrieu@enpc.fr" }, 18 | { name = "Damien Robert", email = "damien.robert@uzh.ch" }, 19 | ] 20 | keywords = ["point clouds", "features", "3D", "LiDAR"] 21 | classifiers = [ 22 | "Development Status :: 3 - Alpha", 23 | "Programming Language :: Python", 24 | "Topic :: Scientific/Engineering", 25 | ] 26 | dependencies = ["numpy >= 1.7"] 27 | 28 | [project.urls] 29 | homepage = "https://github.com/drprojects/point_geometric_features" 30 | repository = "https://github.com/drprojects/point_geometric_features" 31 | 32 | [tool.scikit-build] 33 | # Protect the configuration against future changes in scikit-build-core 34 | minimum-version = "0.4" 35 | 36 | # Setuptools-style build caching in a local directory 37 | build-dir = "build/{wheel_tag}" 38 | 39 | cmake.build-type = "Release" 40 | 41 | # make sdist a lot lighter by removing some useless files from third_party 42 | # ⚠️ be sure to keep copyrights and license file 43 | sdist.exclude = [ 44 | "third_party/eigen/bench", 45 | "third_party/eigen/demos", 46 | "third_party/eigen/doc", 47 | "third_party/taskflow/3rd-party", 48 | "third_party/taskflow/benchmarks", 49 | "third_party/taskflow/docs", 50 | "third_party/taskflow/doxygen", 51 | "third_party/taskflow/examples", 52 | "third_party/taskflow/sandbox", 53 | "third_party/taskflow/unittests", 54 | ] 55 | 56 | [tool.ruff] 57 | target-version = "py310" 58 | line-length = 120 59 | 60 | [tool.ruff.lint] 61 | # TODO Add D, PTH, RET, disabled for now as they collides with intial choices 62 | select = ["E", "W", "YTT", "NPY", "PYI", "Q", "F", "B", "I", "SIM", "RUF"] 63 | # TODO: for now we ignore "Line too long error (E501)" 64 | # because our comments are too longs 65 | # code formatting will take care of the line length in code anyway 66 | ignore = [ 67 | "E501", 68 | # Ignore docstring in public package and module 69 | "D100", 70 | "D104", 71 | # Blank line before class 72 | "D203", 73 | # multiline summary second line 74 | "D213", 75 | # yoda conditions 76 | "SIM300", 77 | ] 78 | 79 | [tool.ruff.lint.isort] 80 | known-first-party = ["pgeof"] 81 | 82 | [tool.tox] 83 | legacy_tox_ini = """ 84 | [tox] 85 | 86 | [gh-actions] 87 | python = 88 | 3.8: py39 89 | 3.9: py39 90 | 3.10: py310 91 | 3.11: py311 92 | 3.12: py312 93 | 3.13: py313 94 | 95 | [testenv] 96 | deps = 97 | pytest >= 7.4 98 | pytest-benchmark ~= 4.0 99 | numpy >= 1.7 100 | scipy 101 | jakteristics;platform_system=="Windows" or platform_system=="Linux" 102 | commands = pytest --basetemp="{envtmpdir}" {posargs} 103 | 104 | [testenv:bench] 105 | # globs/wildcards do not work with tox 106 | commands = pytest -s --basetemp="{envtmpdir}" {posargs:tests/bench_knn.py tests/bench_jakteristics.py} 107 | """ 108 | 109 | [tool.cibuildwheel] 110 | build = "cp3{8,9,10,11,12,13}-*" 111 | archs = ["auto64"] # limits to 64bits builds 112 | skip = "cp38-macosx_arm64" 113 | 114 | # Needed for full C++17 support 115 | [tool.cibuildwheel.macos.environment] 116 | MACOSX_DEPLOYMENT_TARGET = "11.0" 117 | -------------------------------------------------------------------------------- /src/pgeof/__init__.py: -------------------------------------------------------------------------------- 1 | from .pgeof_ext import ( 2 | EFeatureID, 3 | compute_features, 4 | compute_features_multiscale, 5 | compute_features_optimal, 6 | knn_search, 7 | radius_search, 8 | compute_features_selected 9 | ) 10 | -------------------------------------------------------------------------------- /src/pgeof_ext.cpp: -------------------------------------------------------------------------------- 1 | 2 | #include 3 | #include 4 | #include 5 | 6 | #include "nn_search.hpp" 7 | #include "pgeof.hpp" 8 | 9 | namespace nb = nanobind; 10 | using namespace nb::literals; 11 | 12 | NB_MODULE(pgeof_ext, m) 13 | { 14 | m.doc() = 15 | "Compute, for each point in a 3D point cloud, local geometric " 16 | "features " 17 | "in parallel on CPU"; 18 | nb::enum_(m, "EFeatureID") 19 | .value("Linearity", pgeof::EFeatureID::Linearity) 20 | .value("Planarity", pgeof::EFeatureID::Planarity) 21 | .value("Scattering", pgeof::EFeatureID::Scattering) 22 | .value("VerticalityPGEOF", pgeof::EFeatureID::VerticalityPGEOF) 23 | .value("Normal_x", pgeof::EFeatureID::Normal_x) 24 | .value("Normal_y", pgeof::EFeatureID::Normal_y) 25 | .value("Normal_z", pgeof::EFeatureID::Normal_z) 26 | .value("Length", pgeof::EFeatureID::Length) 27 | .value("Surface", pgeof::EFeatureID::Surface) 28 | .value("Volume", pgeof::EFeatureID::Volume) 29 | .value("Curvature", pgeof::EFeatureID::Curvature) 30 | .value("K_optimal", pgeof::EFeatureID::K_optimal) // TODO: remove or handle 31 | .value("Verticality", pgeof::EFeatureID::Verticality) 32 | .value("Eigentropy", pgeof::EFeatureID::Eigentropy) 33 | .export_values(); 34 | m.def( 35 | "compute_features", &pgeof::compute_geometric_features, "xyz"_a.noconvert(), "nn"_a.noconvert(), 36 | "nn_ptr"_a.noconvert(), "k_min"_a = 1, "verbose"_a = false, R"( 37 | Compute a set of geometric features for a point cloud from a precomputed list of neighbors. 38 | 39 | * The following features are computed: 40 | - linearity 41 | - planarity 42 | - scattering 43 | - verticality 44 | - normal vector (oriented towards positive z-coordinates) 45 | - length 46 | - surface 47 | - volume 48 | - curvature 49 | :param xyz: The point cloud. A numpy array of shape (n, 3). 50 | :param nn: Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 51 | '-1' indices will either crash or silently compute incorrect features. 52 | :param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 53 | are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 54 | :param k_min: Minimum number of neighbors to consider for features computation. If a point has less, 55 | its features will be a set of '0' values. 56 | :param verbose: Whether computation progress should be printed out 57 | :return: the geometric features associated with each point's neighborhood in a (num_points, features_count) numpy array. 58 | )"); 59 | m.def( 60 | "compute_features_multiscale", &pgeof::compute_geometric_features_multiscale, "xyz"_a.noconvert(), 61 | "nn"_a.noconvert(), "nn_ptr"_a.noconvert(), "k_scales"_a, "verbose"_a = false, R"( 62 | Compute a set of geometric features for a point cloud in a multiscale fashion. 63 | 64 | * The following features are computed: 65 | - linearity 66 | - planarity 67 | - scattering 68 | - verticality 69 | - normal vector (oriented towards positive z-coordinates) 70 | - length 71 | - surface 72 | - volume 73 | - curvature 74 | 75 | :param xyz: The point cloud. A numpy array of shape (n, 3). 76 | :param nn: Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 77 | '-1' indices will either crash or silently compute incorrect features. 78 | :param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 79 | are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 80 | :param k_scale: Array of number of neighbors to consider for features computation. If a at a given scale, a point has 81 | less features will be a set of '0' values. 82 | :param verbose: Whether computation progress should be printed out 83 | :return: Geometric features associated with each point's neighborhood in a (num_points, features_count, n_scales) 84 | numpy array. 85 | )"); 86 | m.def( 87 | "compute_features_optimal", &pgeof::compute_geometric_features_optimal, "xyz"_a.noconvert(), 88 | "nn"_a.noconvert(), "nn_ptr"_a.noconvert(), "k_min"_a = 1, "k_step"_a = 1, "k_min_search"_a = 1, 89 | "verbose"_a = false, R"( 90 | Compute a set of geometric features for a point cloud using the optimal neighborhood selection described in 91 | http://lareg.ensg.eu/labos/matis/pdf/articles_revues/2015/isprs_wjhm_15.pdf 92 | 93 | * The following features are computed: 94 | - linearity 95 | - planarity 96 | - scattering 97 | - verticality 98 | - normal vector (oriented towards positive z-coordinates) 99 | - length 100 | - surface 101 | - volume 102 | - curvature 103 | - optimal_nn 104 | :param xyz: the point cloud 105 | :param nn: Integer 1D array. Flattened neighbor indices. Make sure those are all positive, 106 | '-1' indices will either crash or silently compute incorrect features. 107 | :param nn_ptr: [n_points+1] Integer 1D array. Pointers wrt 'nn'. More specifically, the neighbors of point 'i' 108 | are 'nn[nn_ptr[i]:nn_ptr[i + 1]]'. 109 | :param k_min: Minimum number of neighbors to consider for features computation. If a point has less, 110 | its features will be a set of '0' values. 111 | :param k_step: Step size to take when searching for the optimal neighborhood, size for each point following 112 | Weinmann, 2015 113 | :param k_min_search: Minimum neighborhood size at which to start when searching for the optimal neighborhood size for 114 | each point. It is advised to use a value of 10 or higher, for geometric features robustness. 115 | :param verbose: Whether computation progress should be printed out 116 | :return: Geometric features associated with each point's neighborhood in a (num_points, features_count) numpy array. 117 | )"); 118 | m.def("knn_search", &pgeof::nanoflann_knn_search, "data"_a.noconvert(), "query"_a.noconvert(), "knn"_a, R"( 119 | Given two point clouds, compute for each point present in one of the point cloud 120 | the N closest points in the other point cloud 121 | 122 | It should be faster than scipy.spatial.KDTree for this task. 123 | 124 | :param data: the reference point cloud. A numpy array of shape (n, 3). 125 | :param query: the point cloud used for the queries. A numpy array of shape (n, 3). 126 | :param knn: the number of neighbors to take into account for each point. 127 | :return: a pair of arrays, both of size (n_points x knn), the first one contains the indices of each neighbor, the 128 | second one the square distances between the query point and each of its neighbors. 129 | )"); 130 | m.def( 131 | "radius_search", &pgeof::nanoflann_radius_search, "data"_a.noconvert(), "query"_a.noconvert(), 132 | "search_radius"_a, "max_knn"_a, R"( 133 | Search for the points within a specified sphere in a point cloud. 134 | 135 | It could be a fallback replacement for FRNN into SuperPointTransformer code base. 136 | It should be faster than scipy.spatial.KDTree for this task. 137 | 138 | :param data: the reference point cloud. A numpy array of shape (n, 3). 139 | :param query: the point cloud used for the queries (sphere centers). A numpy array of shape (n, 3). 140 | :param search_radius: the search radius. 141 | :param max_knn: the maximum number of neighbors to fetch inside the radius. The central point is included. Fixing a 142 | reasonable max number of neighbors prevents running OOM for large radius/dense point clouds. 143 | :return: a pair of arrays, both of size (n_points x knn), the first one contains the 'indices' of each neighbor, 144 | the second one the 'square_distances' between the query point and each neighbor. Point having a number of neighbors < 145 | 'max_knn' inside the 'search_radius' will have their 'indices' and and 'square_distances' filled respectively with 146 | '-1' and 'O' for any missing neighbor. 147 | )"); 148 | m.def( 149 | "compute_features_selected", &pgeof::compute_geometric_features_selected, "xyz"_a.noconvert(), 150 | "search_radius"_a, "max_knn"_a, "selected_features"_a, R"( 151 | Compute a selected set of geometric features for a point cloud via radius search. 152 | 153 | This function aims to mimick the behavior of jakteristics and provide an efficient way 154 | to compute a limited set of features (double precision version). 155 | 156 | :param xyz: the point cloud. A numpy array of shape (n, 3). 157 | :param search_radius: the search radius. A numpy array of shape (n, 3). 158 | :param max_knn: the maximum number of neighbors to fetch inside the sphere. The central point is included. Fixing a 159 | reasonable max number of neighbors prevents running OOM for large radius/dense point clouds. 160 | :param selected_features: List of selected features. See EFeatureID 161 | :return: Geometric features associated with each point's neighborhood in a (num_points, features_count) numpy array. 162 | )"); 163 | m.def( 164 | "compute_features_selected", &pgeof::compute_geometric_features_selected, "xyz"_a.noconvert(), 165 | "search_radius"_a, "max_knn"_a, "selected_features"_a, R"( 166 | Compute a selected set of geometric features for a point cloud via radius search. 167 | 168 | This function aims to mimic the behavior of jakteristics and provide an efficient way 169 | to compute a limited set of features (float precision version). 170 | 171 | :param xyz: the point cloud 172 | :param search_radius: the search radius. 173 | :param max_knn: the maximum number of neighbors to fetch inside the sphere. The central point is included. Fixing a 174 | reasonable max number of neighbors prevents running OOM for large radius/dense point clouds. 175 | :param selected_features: List of selected features. See EFeatureID 176 | :return: Geometric features associated with each point's neighborhood in a (num_points, features_count) numpy array. 177 | )"); 178 | } 179 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drprojects/point_geometric_features/4102aa9ff7812b5c9043a2a49abf2cced96d3703/tests/__init__.py -------------------------------------------------------------------------------- /tests/bench_jakteristics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import pgeof 5 | from pgeof import EFeatureID 6 | 7 | # Skip if jakteristics import fail 8 | # it should fail on darwin (macOS) systems 9 | jakteristics = pytest.importorskip("jakteristics") 10 | 11 | 12 | @pytest.fixture 13 | def random_point_cloud(): 14 | rng = np.random.default_rng() 15 | return rng.uniform(0.0, 200.0, size=(10000, 3)) 16 | 17 | @pytest.mark.benchmark(group="feature-computation-jak", disable_gc=True, warmup=True) 18 | def test_bench_jak(benchmark, random_point_cloud): 19 | knn = 50 20 | dist = 5.0 21 | 22 | def _to_bench_feat(): 23 | _ = jakteristics.compute_features( 24 | random_point_cloud, 25 | dist, 26 | kdtree=None, 27 | num_threads=-1, 28 | max_k_neighbors=knn, 29 | feature_names=["verticality"], 30 | ) 31 | 32 | benchmark(_to_bench_feat) 33 | 34 | 35 | @pytest.mark.benchmark(group="feature-computation-jak", disable_gc=True, warmup=True) 36 | def test_pgeof(benchmark, random_point_cloud): 37 | knn = 50 38 | dist = 5.0 39 | 40 | def _to_bench_feat(): 41 | _ = pgeof.compute_features_selected( 42 | random_point_cloud, dist, knn, [EFeatureID.Verticality] 43 | ) 44 | 45 | benchmark(_to_bench_feat) 46 | -------------------------------------------------------------------------------- /tests/bench_knn.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from scipy.spatial import KDTree 4 | 5 | import pgeof 6 | 7 | 8 | @pytest.fixture 9 | def random_point_cloud(): 10 | rng = np.random.default_rng() 11 | return rng.uniform(0.0, 200.0, size=(1000000, 3)).astype(np.float32) 12 | 13 | @pytest.mark.benchmark(group="knn", disable_gc=True, warmup=True) 14 | def test_knn_scipy(benchmark, random_point_cloud): 15 | knn = 50 16 | 17 | def _to_bench(): 18 | tree = KDTree(random_point_cloud) 19 | _ = tree.query(random_point_cloud, k=knn, workers=-1) 20 | 21 | benchmark(_to_bench) 22 | 23 | 24 | @pytest.mark.benchmark(group="knn", disable_gc=True, warmup=True) 25 | def test_knn_pgeof(benchmark, random_point_cloud): 26 | knn = 50 27 | 28 | def _to_bench(): 29 | _ = pgeof.knn_search(random_point_cloud, random_point_cloud, knn) 30 | 31 | benchmark(_to_bench) 32 | 33 | 34 | @pytest.mark.benchmark(group="radius-search", disable_gc=True, warmup=True) 35 | def test_radius_scipy(benchmark, random_point_cloud): 36 | max_knn = 30 37 | radius = 0.2 38 | 39 | def _to_bench(): 40 | tree = KDTree(random_point_cloud) 41 | _ = tree.query(random_point_cloud, k=max_knn, distance_upper_bound=radius, workers=-1) 42 | 43 | benchmark(_to_bench) 44 | 45 | 46 | @pytest.mark.benchmark(group="radius-search", disable_gc=True, warmup=True) 47 | def test_radius_pgeof(benchmark, random_point_cloud): 48 | max_knn = 30 49 | radius = 0.2 50 | 51 | def _to_bench(): 52 | _ = pgeof.radius_search(random_point_cloud, random_point_cloud, radius, max_knn) 53 | 54 | benchmark(_to_bench) 55 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.spatial import KDTree 3 | 4 | 5 | def random_nn(num_points, k): 6 | # Generate a random synthetic point cloud 7 | rng = np.random.default_rng() 8 | xyz = rng.uniform(0.0, 200.0, size=(num_points, 3)).astype(np.float32) 9 | 10 | # Converting k-nearest neighbors to CSR format 11 | kneigh = KDTree(xyz).query(xyz, k=k, workers=-1) 12 | nn_ptr = np.arange(num_points + 1) * k 13 | nn = kneigh[1].flatten() 14 | 15 | # Make sure xyz are float32 and nn and nn_ptr are uint32 16 | xyz = xyz.astype("float32") 17 | nn_ptr = nn_ptr.astype("uint32") 18 | nn = nn.astype("uint32") 19 | 20 | # Make sure arrays are contiguous (C-order) and not Fortran-order 21 | xyz = np.ascontiguousarray(xyz) 22 | nn_ptr = np.ascontiguousarray(nn_ptr) 23 | nn = np.ascontiguousarray(nn) 24 | return xyz, nn, nn_ptr 25 | -------------------------------------------------------------------------------- /tests/test_pgeof.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.spatial import KDTree 3 | 4 | import pgeof 5 | from tests.helpers import random_nn 6 | 7 | 8 | def test_knn(): 9 | knn = 10 10 | rng = np.random.default_rng() 11 | xyz = rng.uniform(0.0, 200.0, size=(1000, 3)).astype(np.float32) 12 | tree = KDTree(xyz) 13 | _, k_legacy = tree.query(xyz, k=knn, workers=-1) 14 | k_new, _ = pgeof.knn_search(xyz, xyz, knn) 15 | np.testing.assert_equal(k_legacy, k_new) 16 | 17 | 18 | def test_radius_search(): 19 | knn = 10 20 | radius = 0.2 21 | rng = np.random.default_rng() 22 | xyz = rng.random(size=(1000, 3), dtype=np.float32) 23 | tree = KDTree(xyz) 24 | _, k_legacy = tree.query(xyz, k=knn, distance_upper_bound=radius, workers=-1) 25 | k_legacy[k_legacy == xyz.shape[0]] = -1 26 | k_new, _ = pgeof.radius_search(xyz, xyz, radius, knn) 27 | np.testing.assert_equal(k_legacy, k_new) 28 | 29 | 30 | def test_pgeof_multiscale(): 31 | # Generate a random synthetic point cloud and NNs 32 | xyz, nn, nn_ptr = random_nn(10000, 50) 33 | 34 | # with pytest.raises(ValueError): 35 | # scales = np.array( 36 | # [20, 50] 37 | # ) # scales in decreasing order in order to raise the exception 38 | # multi = pgeof.compute_features_multiscale(xyz, nn, nn_ptr, scales, False) 39 | scales = np.array( 40 | [50, 20] 41 | ) # scales in decreasing order in order to raise the exception 42 | multi = pgeof.compute_features_multiscale(xyz, nn, nn_ptr, np.flip(scales), False) 43 | simple = pgeof.compute_features(xyz, nn, nn_ptr, 50, False) 44 | multi_simple = pgeof.compute_features_multiscale(xyz, nn, nn_ptr, [20], False) 45 | np.testing.assert_allclose(multi[:, 0], multi_simple[:, 0], 1e-1, 1e-5) 46 | np.testing.assert_allclose(multi[:, 1], simple, 1e-1, 1e-5) 47 | --------------------------------------------------------------------------------