From b99afcf3800b2ecd294389830f13eceb43681a7b Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 10:48:15 +0100 Subject: [PATCH 01/15] Adding Conan+Cmake+Pybind11 compilation --- .github/dependabot.yml | 11 ++ .github/workflows/conda.yml | 44 ++++++++ .github/workflows/enscripten.yaml | 33 ++++++ .github/workflows/pip.yml | 32 ++++++ .github/workflows/wheels.yml | 99 ++++++++++++++++++ .gitignore | 167 +++++++++++++++++++++++++----- .pre-commit-config.yaml | 65 ++++++++++++ CMakeLists.txt | 47 +++++++-- conanfile.txt | 23 ++++ conda.recipe/meta.yaml | 43 ++++++++ noxfile.py | 41 ++++++++ pyproject.toml | 74 +++++++++++++ src/PYAPI.cpp | 48 +++++++++ src/main.cpp | 9 +- src/pyfsg/__init__.py | 5 + src/pyfsg/__init__.pyi | 19 ++++ tests/test_basic.py | 15 +++ 17 files changed, 736 insertions(+), 39 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/conda.yml create mode 100644 .github/workflows/enscripten.yaml create mode 100644 .github/workflows/pip.yml create mode 100644 .github/workflows/wheels.yml create mode 100644 .pre-commit-config.yaml create mode 100644 conanfile.txt create mode 100644 conda.recipe/meta.yaml create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 src/PYAPI.cpp create mode 100644 src/pyfsg/__init__.py create mode 100644 src/pyfsg/__init__.pyi create mode 100644 tests/test_basic.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c4b369 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml new file mode 100644 index 0000000..88fe201 --- /dev/null +++ b/.github/workflows/conda.yml @@ -0,0 +1,44 @@ +name: Conda + +on: + workflow_dispatch: + push: + branches: + - master + pull_request: + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-12, windows-2019] + python-version: ["3.9", "3.11"] + + runs-on: ${{ matrix.platform }} + + # The setup-miniconda action needs this to activate miniconda + defaults: + run: + shell: "bash -l {0}" + + steps: + - uses: actions/checkout@v4 + + - name: Get conda + uses: conda-incubator/setup-miniconda@v3.1.0 + with: + python-version: ${{ matrix.python-version }} + channels: conda-forge + + - name: Prepare + run: conda install conda-build conda-verify pytest + + - name: Build + run: conda build conda.recipe + + - name: Install + run: conda install -c ${CONDA_PREFIX}/conda-bld/ pyfsg + + - name: Test + run: pytest tests diff --git a/.github/workflows/enscripten.yaml b/.github/workflows/enscripten.yaml new file mode 100644 index 0000000..55b3f82 --- /dev/null +++ b/.github/workflows/enscripten.yaml @@ -0,0 +1,33 @@ +name: WASM + +on: + workflow_dispatch: + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-wasm-emscripten: + name: Pyodide + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: pypa/cibuildwheel@v2.22 + env: + CIBW_PLATFORM: pyodide + + - uses: actions/upload-artifact@v4 + with: + path: dist/*.whl diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml new file mode 100644 index 0000000..c16fd69 --- /dev/null +++ b/.github/workflows/pip.yml @@ -0,0 +1,32 @@ +name: "Pip" + +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + +jobs: + build: + name: Build with Pip + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + platform: [windows-latest, macos-latest, ubuntu-latest] + python-version: ["3.9", "3.13", "pypy-3.10"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Build and install + run: pip install --verbose .[test] + + - name: Test + run: pytest diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..cf776f4 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,99 @@ +name: Wheels + +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + release: + types: + - published + +env: + FORCE_COLOR: 3 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_sdist: + name: Build SDist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Build SDist + run: pipx run build --sdist + + - name: Check metadata + run: pipx run twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: cibw-sdist + path: dist/*.tar.gz + + + build_wheels: + name: Wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-13, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: astral-sh/setup-uv@v4 + + - uses: pypa/cibuildwheel@v2.22 + env: + CIBW_ENABLE: cpython-prerelease + CIBW_ARCHS_WINDOWS: auto ARM64 + + - name: Verify clean directory + run: git diff --exit-code + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: cibw-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + + + upload_all: + name: Upload if release + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + environment: pypi + permissions: + id-token: write + attestations: write + + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - uses: actions/download-artifact@v4 + with: + pattern: cibw-* + merge-multiple: true + path: dist + + - name: Generate artifact attestation for sdist and wheels + uses: actions/attest-build-provenance@v1 + with: + subject-path: "dist/*" + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true diff --git a/.gitignore b/.gitignore index 9bf29e8..c9c52c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,148 @@ -# Prerequisites -*.d +# Using https://github.com/github/gitignore/blob/master/Python.gitignore -# Compiled Object files -*.slo -*.lo -*.o -*.obj +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -# Precompiled Headers -*.gch -*.pch - -# Compiled Dynamic libraries +# C extensions *.so -*.dylib -*.dll -# Fortran module files -*.mod -*.smod +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/_generate/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ -# Compiled Static libraries -*.lai -*.la -*.a -*.lib +# pytype static type analyzer +.pytype/ -# Executables -*.exe -*.out -*.app +# Cython debug symbols +cython_debug/ -# CLion files +_skbuild/ +.pyodide-xbuildenv/ .idea/* cmake-build-* -/build/* \ No newline at end of file +/build/* +/CMakeUserPresets.json \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e51ff2d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +# To use: +# +# pre-commit run -a +# +# Or: +# +# pre-commit install # (runs every time you commit in git) +# +# To update this file: +# +# pre-commit autoupdate +# +# See https://github.com/pre-commit/pre-commit + +ci: + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + +repos: +# Standard hooks +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + exclude: ^conda\.recipe/meta\.yaml$ + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: requirements-txt-fixer + - id: trailing-whitespace + +# Check linting and style issues +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.7.3" + hooks: + - id: ruff + args: ["--fix", "--show-fixes"] + - id: ruff-format + exclude: ^(docs) + +# Changes tabs to spaces +- repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.5 + hooks: + - id: remove-tabs + exclude: ^(docs) + +# CMake formatting +- repo: https://github.com/cheshirekow/cmake-format-precommit + rev: v0.6.13 + hooks: + - id: cmake-format + additional_dependencies: [pyyaml] + types: [file] + files: (\.cmake|CMakeLists.txt)(.in)?$ + +# Suggested hook if you add a .clang-format file +# - repo: https://github.com/pre-commit/mirrors-clang-format +# rev: v13.0.0 +# hooks: +# - id: clang-format diff --git a/CMakeLists.txt b/CMakeLists.txt index 5162b62..6378fff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,15 +1,48 @@ -cmake_minimum_required(VERSION 3.0.0) -project(FSG) +cmake_minimum_required(VERSION 3.15...3.27) + +project(${SKBUILD_PROJECT_NAME} VERSION ${SKBUILD_PROJECT_VERSION} LANGUAGES CXX) + +option(WITH_PYTHON "Compiles Pybind11 Python bindings" ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) message( STATUS "OpenCV_FOUND: " ${OpenCV_FOUND}) +message( STATUS "OpenCV_LIBS: " ${OpenCV_LIBS}) message( STATUS "OpenCV_INCLUDE_DIRS: " ${OpenCV_INCLUDE_DIRS}) -include_directories(${OpenCV_INCLUDE_DIRS}) -file(GLOB_RECURSE LIB_SOURCES "src/*.cpp" "src/*.h") -include_directories(src) -add_library(fsg ${LIB_SOURCES} src/main.cpp) +set(LIB_SOURCES + src/FastAcontrarioValidator.cpp + src/FastAcontrarioValidator.h + src/GreedyMerger.cpp + src/GreedyMerger.h + src/LsdOpenCV.cpp + src/LsdOpenCV.h + src/SegmentsGroup.cpp + src/SegmentsGroup.h + src/Utils.h) + +add_library(fsg STATIC ${LIB_SOURCES}) +target_include_directories(fsg PRIVATE src ${OpenCV_INCLUDE_DIRS}) target_link_libraries(fsg ${OpenCV_LIBS}) add_executable(fsg_main src/main.cpp) -target_link_libraries(fsg_main fsg ${OpenCV_LIBS}) \ No newline at end of file +target_link_libraries(fsg_main fsg ${OpenCV_LIBS}) + + +if(WITH_PYTHON) + # Find the module development requirements (requires FindPython from 3.17 or + # scikit-build-core's built-in backport) + find_package(Python REQUIRED COMPONENTS Interpreter Development.Module) + find_package(pybind11 CONFIG REQUIRED) + + python_add_library(_pyfsg MODULE src/PYAPI.cpp WITH_SOABI) + target_link_libraries(_pyfsg PRIVATE fsg pybind11::headers ${OpenCV_LIBS}) + + # This is passing in the version as a define just as an example + target_compile_definitions(_pyfsg PRIVATE VERSION_INFO=${PROJECT_VERSION}) + + # The install directory is the output (wheel) directory + install(TARGETS _pyfsg DESTINATION pyfsg) +endif() \ No newline at end of file diff --git a/conanfile.txt b/conanfile.txt new file mode 100644 index 0000000..aa34fe8 --- /dev/null +++ b/conanfile.txt @@ -0,0 +1,23 @@ +[requires] +opencv/3.4.20 +pybind11/2.10.4 + +[options] +opencv/*:shared=False +opencv/*:with_png=True +opencv/*:with_tiff=False +opencv/*:contrib=False +opencv/*:with_jasper=False +opencv/*:with_openexr=False +opencv/*:with_eigen=False +opencv/*:with_webp=False +opencv/*:with_gtk=False +opencv/*:nonfree=False + + +[generators] +CMakeDeps +CMakeToolchain + +[layout] +cmake_layout diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml new file mode 100644 index 0000000..871624c --- /dev/null +++ b/conda.recipe/meta.yaml @@ -0,0 +1,43 @@ +package: + name: pyfsg + version: 0.0.1 + +source: + path: .. + +build: + number: 0 + script: + - unset CMAKE_GENERATOR && {{ PYTHON }} -m pip install . -vv # [not win] + - {{ PYTHON }} -m pip install . -vv # [win] + +requirements: + build: + - python + - {{ compiler('cxx') }} + + host: + - cmake + - ninja + - python + - pip + - scikit-build-core + - pybind11 >=2.10.0 + + run: + - python + + +test: + imports: + - scikit_build_example + requires: + - pytest + source_files: + - tests + commands: + - pytest tests + +about: + summary: An example project built with pybind11 and scikit-build. + license_file: LICENSE diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..23f0526 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sys + +import nox + +nox.options.sessions = ["lint", "tests"] + + +@nox.session +def lint(session: nox.Session) -> None: + """ + Run the linter. + """ + session.install("pre-commit") + session.run("pre-commit", "run", "--all-files", *session.posargs) + + +@nox.session +def tests(session: nox.Session) -> None: + """ + Run the unit and regular tests. + """ + session.install(".[test]") + session.run("pytest", *session.posargs) + + +@nox.session(venv_backend="none") +def dev(session: nox.Session) -> None: + """ + Prepare a .venv folder. + """ + + session.run(sys.executable, "-m", "venv", ".venv") + session.run( + ".venv/bin/pip", + "install", + "-e.", + "-Ccmake.define.CMAKE_EXPORT_COMPILE_COMMANDS=1", + "-Cbuild-dir=build", + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..763dfc1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["scikit-build-core-conan>=0.5.3"] +build-backend = "scikit_build_core_conan.build" + +[project] +name = "pyfsg" +version = "0.0.1" +description="A minimal example package (with pybind11)" +readme = "README.md" +authors = [ + { name = "Iago Suárez" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache 2 License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "conan >= 2.11.0", + "numpy", +] + +[project.optional-dependencies] +test = ["pytest"] + + +[tool.scikit-build] +#wheel.expand-macos-universal-tags = true +#minimum-version = "build-system.requires" +cmake.args = ["-G Ninja"] +#cmake.build-type = "RelWithDebInfo" +#cmake.define = { BUILD_TESTING = "OFF" } +#wheel.packages = ["python/src/endstone"] +#wheel.license-files = ["LICENSE"] +#install.components = ["endstone_wheel"] +#install.strip = false +#metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" +#sdist.include = ["python/src/endstone/_internal/version.py"] + +[tool.scikit-build-core-conan] +config = ["tools.cmake.cmaketoolchain:generator=Ninja"] + +[[tool.scikit-build-core-conan.overrides]] +if.platform-system = "win32" +settings = ["compiler.cppstd=17"] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] +xfail_strict = true +log_cli_level = "INFO" +filterwarnings = [ + "error", + "ignore::pytest.PytestCacheWarning", +] +testpaths = ["tests"] + + +[tool.cibuildwheel] +build-frontend = "build[uv]" +test-command = "pytest {project}/tests" +test-extras = ["test"] + +[tool.cibuildwheel.pyodide] +build-frontend = {name = "build", args = ["--exports", "whole_archive"]} diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp new file mode 100644 index 0000000..7688bc8 --- /dev/null +++ b/src/PYAPI.cpp @@ -0,0 +1,48 @@ +#include +#include + +#define STRINGIFY(x) #x +#define MACRO_STRINGIFY(x) STRINGIFY(x) + +int add(int i, int j) { + cv::Vec2f v_i(i, 0); + cv::Vec2f v_j(j, 0); + cv::Vec2f sum = v_i + v_j; + std::cout << "Sum: " << sum << std::endl; + return sum(0); +} + +namespace py = pybind11; + +PYBIND11_MODULE(_pyfsg, m) { + m.doc() = R"pbdoc( + Pybind11 example plugin + ----------------------- + + .. currentmodule:: pyfsg + + .. autosummary:: + :toctree: _generate + + add + subtract + )pbdoc"; + + m.def("add", &add, R"pbdoc( + Add two numbers + + Some other explanation about the add function. + )pbdoc"); + + m.def("subtract", [](int i, int j) { return i - j; }, R"pbdoc( + Subtract two numbers + + Some other explanation about the subtract function. + )pbdoc"); + +#ifdef VERSION_INFO + m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); +#else + m.attr("__version__") = "dev"; +#endif +} diff --git a/src/main.cpp b/src/main.cpp index 0092b61..b96f21e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,7 @@ void drawClusters(cv::Mat &img, } int main() { + constexpr bool IMSHOW = false; std::cout << "******************************************************" << std::endl; std::cout << "******************** FSG main demo *******************" << std::endl; std::cout << "******************************************************" << std::endl; @@ -82,7 +83,7 @@ int main() { std::cout << "Detected " << detectedSegments.size() << " line segments with LSD" << std::endl; cv::segments(img, detectedSegments, CV_RGB(255, 0, 0), 1); - cv::imshow("Detected line segments", img); + if (IMSHOW) cv::imshow("Detected line segments", img); cv::imwrite("../Detected_line_segments.png", img); // Detect the segment clusters @@ -90,7 +91,7 @@ int main() { Segments mergedLines; merger.mergeSegments(detectedSegments, mergedLines, detectedClusters); drawClusters(img, detectedSegments, detectedClusters, 2); - cv::imshow("Segment groups", img); + if (IMSHOW) cv::imshow("Segment groups", img); cv::imwrite("../Segment_groups.png", img); // Get large lines from groups of segments @@ -98,8 +99,8 @@ int main() { filterSegments(detectedSegments, detectedClusters, filteredSegments, noisySegs); cv::segments(img2, filteredSegments, CV_RGB(0, 255, 0)); cv::segments(img2, noisySegs, CV_RGB(255, 0, 0)); - cv::imshow("Obtained lines", img2); + if (IMSHOW) cv::imshow("Obtained lines", img2); cv::imwrite("../Obtained_lines.png", img2); - cv::waitKey(); + if (IMSHOW) cv::waitKey(); } \ No newline at end of file diff --git a/src/pyfsg/__init__.py b/src/pyfsg/__init__.py new file mode 100644 index 0000000..93eb9a2 --- /dev/null +++ b/src/pyfsg/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._pyfsg import __doc__, __version__, add, subtract + +__all__ = ["__doc__", "__version__", "add", "subtract"] diff --git a/src/pyfsg/__init__.pyi b/src/pyfsg/__init__.pyi new file mode 100644 index 0000000..4a2adf7 --- /dev/null +++ b/src/pyfsg/__init__.pyi @@ -0,0 +1,19 @@ +""" +Pybind11 example plugin +----------------------- + +.. currentmodule:: pyfsg + +.. autosummary:: + :toctree: _generate + + add + subtract +""" + +def add(i: int, j: int) -> int: + """ + Add two numbers + + Some other explanation about the add function. + """ diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..3dbc996 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pyfsg as m + + +def test_version(): + assert m.__version__ == "0.0.1" + + +def test_add(): + assert m.add(1, 2) == 3 + + +def test_sub(): + assert m.subtract(1, 2) == -1 From 84386af7f5b9917d0d1c25bc9f57a234c842e098 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 10:57:11 +0100 Subject: [PATCH 02/15] Adding conan cache --- .github/workflows/pip.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index c16fd69..172ab0c 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -20,6 +20,14 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Cache Conan dependencies + uses: actions/cache@v3 + with: + path: ~/.conan2 + key: ${{ runner.OS }}-conan-cache + restore-keys: | + ${{ runner.OS }}-conan-cache + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} From 40e61392dda47e7ea4145003ca5c72ef77730bce Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 11:03:50 +0100 Subject: [PATCH 03/15] Restricting the OS to Ubuntu only and adding Conan cache. --- .github/workflows/conda.yml | 13 +++++++++++-- .github/workflows/pip.yml | 3 ++- .github/workflows/wheels.yml | 11 ++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml index 88fe201..66e44c4 100644 --- a/.github/workflows/conda.yml +++ b/.github/workflows/conda.yml @@ -12,7 +12,8 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, macos-12, windows-2019] +# platform: [ubuntu-latest, macos-12, windows-2019] + platform: [ubuntu-latest] python-version: ["3.9", "3.11"] runs-on: ${{ matrix.platform }} @@ -31,8 +32,16 @@ jobs: python-version: ${{ matrix.python-version }} channels: conda-forge + - name: Cache Conan dependencies + uses: actions/cache@v3 + with: + path: ~/.conan2 + key: ${{ runner.OS }}-conan-cache + restore-keys: | + ${{ runner.OS }}-conan-cache + - name: Prepare - run: conda install conda-build conda-verify pytest + run: conda install conda-build conda-verify pytest conan - name: Build run: conda build conda.recipe diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index 172ab0c..8ecdbc6 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -14,7 +14,8 @@ jobs: strategy: fail-fast: false matrix: - platform: [windows-latest, macos-latest, ubuntu-latest] +# platform: [windows-latest, macos-latest, ubuntu-latest] + platform: [ubuntu-latest] python-version: ["3.9", "3.13", "pypy-3.10"] steps: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cf776f4..d61887b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -44,13 +44,22 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-13, macos-latest, windows-latest] +# os: [ubuntu-latest, macos-13, macos-latest, windows-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 with: submodules: true + - name: Cache Conan dependencies + uses: actions/cache@v3 + with: + path: ~/.conan2 + key: ${{ runner.OS }}-conan-cache + restore-keys: | + ${{ runner.OS }}-conan-cache + - uses: astral-sh/setup-uv@v4 - uses: pypa/cibuildwheel@v2.22 From e9f8bdbbbf7d8435648ca529394f619e12558fc5 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 11:27:48 +0100 Subject: [PATCH 04/15] Adding Pybind wrappers for the class GreedyMerger and a very simple unit test --- .github/workflows/conda.yml | 53 -------------- CMakeLists.txt | 1 + src/PYAPI.cpp | 137 +++++++++++++++++++++++++++++++++--- src/pyfsg/__init__.py | 4 +- src/pyfsg/__init__.pyi | 19 ----- tests/test_basic.py | 20 ++++-- 6 files changed, 145 insertions(+), 89 deletions(-) delete mode 100644 .github/workflows/conda.yml delete mode 100644 src/pyfsg/__init__.pyi diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml deleted file mode 100644 index 66e44c4..0000000 --- a/.github/workflows/conda.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Conda - -on: - workflow_dispatch: - push: - branches: - - master - pull_request: - -jobs: - build: - strategy: - fail-fast: false - matrix: -# platform: [ubuntu-latest, macos-12, windows-2019] - platform: [ubuntu-latest] - python-version: ["3.9", "3.11"] - - runs-on: ${{ matrix.platform }} - - # The setup-miniconda action needs this to activate miniconda - defaults: - run: - shell: "bash -l {0}" - - steps: - - uses: actions/checkout@v4 - - - name: Get conda - uses: conda-incubator/setup-miniconda@v3.1.0 - with: - python-version: ${{ matrix.python-version }} - channels: conda-forge - - - name: Cache Conan dependencies - uses: actions/cache@v3 - with: - path: ~/.conan2 - key: ${{ runner.OS }}-conan-cache - restore-keys: | - ${{ runner.OS }}-conan-cache - - - name: Prepare - run: conda install conda-build conda-verify pytest conan - - - name: Build - run: conda build conda.recipe - - - name: Install - run: conda install -c ${CONDA_PREFIX}/conda-bld/ pyfsg - - - name: Test - run: pytest tests diff --git a/CMakeLists.txt b/CMakeLists.txt index 6378fff..7abe0ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,7 @@ option(WITH_PYTHON "Compiles Pybind11 Python bindings" ON) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(OpenCV REQUIRED) message( STATUS "OpenCV_FOUND: " ${OpenCV_FOUND}) diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp index 7688bc8..d847aba 100644 --- a/src/PYAPI.cpp +++ b/src/PYAPI.cpp @@ -1,5 +1,12 @@ #include -#include +#include +#include + +#include +#include "GreedyMerger.h" // your library's header + +namespace py = pybind11; +using namespace upm; #define STRINGIFY(x) #x #define MACRO_STRINGIFY(x) STRINGIFY(x) @@ -12,12 +19,45 @@ int add(int i, int j) { return sum(0); } -namespace py = pybind11; + +// A small helper struct to hold the 4 floats from a "Segment". +struct PySegment { + float x1, y1, x2, y2; + + PySegment() : x1(0.f), y1(0.f), x2(0.f), y2(0.f) { + } + + PySegment(float x1_, float y1_, float x2_, float y2_) + : x1(x1_), y1(y1_), x2(x2_), y2(y2_) { + } + + explicit PySegment(const Segment &seg) { + x1 = seg[0]; + y1 = seg[1]; + x2 = seg[2]; + y2 = seg[3]; + } + + [[nodiscard]] Segment toSegment() const { + Segment s; + s[0] = x1; + s[1] = y1; + s[2] = x2; + s[3] = y2; + return s; + } +}; + +using PySegments = std::vector; + +PYBIND11_MAKE_OPAQUE(std::vector); + +PYBIND11_MAKE_OPAQUE(std::vector>); PYBIND11_MODULE(_pyfsg, m) { m.doc() = R"pbdoc( - Pybind11 example plugin - ----------------------- + Python bindings for GreedyMerger via pybind11 + --------------------------------------------- .. currentmodule:: pyfsg @@ -28,21 +68,96 @@ PYBIND11_MODULE(_pyfsg, m) { subtract )pbdoc"; + py::class_(m, "Segment") + .def(py::init<>()) + .def(py::init(), + py::arg("x1"), py::arg("y1"), + py::arg("x2"), py::arg("y2")) + .def_readwrite("x1", &PySegment::x1) + .def_readwrite("y1", &PySegment::y1) + .def_readwrite("x2", &PySegment::x2) + .def_readwrite("y2", &PySegment::y2); + + // Wrap vector of PySegment as "Segments" + py::bind_vector >(m, "Segments"); + + // Wrap vector> as "SegmentClusters" + py::bind_vector > >(m, "SegmentClusters"); + + py::class_(m, "GreedyMerger") + .def(py::init([](int width, int height) { + return std::make_unique(cv::Size(width, height)); + }), + py::arg("width") = 800, py::arg("height") = 480) + .def("setImageSize", + [](GreedyMerger &self, int width, int height) { + self.setImageSize(cv::Size(width, height)); + }, + py::arg("width"), py::arg("height")) + .def("mergeSegments", + [](GreedyMerger &self, const std::vector &pySegs) { + Segments in; + in.reserve(pySegs.size()); + for (auto &ps: pySegs) in.push_back(ps.toSegment()); + + Segments out; + SegmentClusters clusters; + self.mergeSegments(in, out, clusters); + + // Convert output segments back to Python-friendly structure + std::vector pyOut; + pyOut.reserve(out.size()); + for (auto &seg: out) { + pyOut.emplace_back(seg); + } + // Return them as (mergedSegments, clusters) + return std::make_pair(pyOut, clusters); + }, + py::arg("segments")) + .def_static("getOrientationHistogram", + [](const std::vector &pySegs, int bins) { + Segments in; + in.reserve(pySegs.size()); + for (auto &ps: pySegs) in.push_back(ps.toSegment()); + return GreedyMerger::getOrientationHistogram(in, bins); + }, + py::arg("segments"), py::arg("bins") = 90) + .def_static("partialSortByLength", + [](const std::vector &pySegs, + int bins, int width, int height) { + Segments in; + in.reserve(pySegs.size()); + for (auto &ps: pySegs) in.push_back(ps.toSegment()); + return GreedyMerger::partialSortByLength( + in, bins, cv::Size(width, height) + ); + }, + py::arg("segments"), py::arg("bins"), + py::arg("width"), py::arg("height")) + .def_static("getTangentLineEqs", + [](const PySegment &pySeg, float radius) { + auto eq = GreedyMerger::getTangentLineEqs(pySeg.toSegment(), radius); + py::tuple l1 = py::make_tuple(eq.first[0], eq.first[1], eq.first[2]); + py::tuple l2 = py::make_tuple(eq.second[0], eq.second[1], eq.second[2]); + return py::make_tuple(l1, l2); + }, + py::arg("segment"), py::arg("radius")); + m.def("add", &add, R"pbdoc( - Add two numbers + Add two numbers - Some other explanation about the add function. - )pbdoc"); + Some other explanation about the add function. + )pbdoc"); m.def("subtract", [](int i, int j) { return i - j; }, R"pbdoc( - Subtract two numbers + Subtract two numbers - Some other explanation about the subtract function. - )pbdoc"); + Some other explanation about the subtract function. + )pbdoc"); #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); #else - m.attr("__version__") = "dev"; + m.attr("__version__") = "dev"; #endif } diff --git a/src/pyfsg/__init__.py b/src/pyfsg/__init__.py index 93eb9a2..ba38cbe 100644 --- a/src/pyfsg/__init__.py +++ b/src/pyfsg/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from ._pyfsg import __doc__, __version__, add, subtract +from ._pyfsg import __doc__, __version__, add, subtract, Segment, Segments, SegmentClusters, GreedyMerger -__all__ = ["__doc__", "__version__", "add", "subtract"] +__all__ = ["__doc__", "__version__", "add", "subtract", "Segment", "Segments", "SegmentClusters", "GreedyMerger", ] diff --git a/src/pyfsg/__init__.pyi b/src/pyfsg/__init__.pyi deleted file mode 100644 index 4a2adf7..0000000 --- a/src/pyfsg/__init__.pyi +++ /dev/null @@ -1,19 +0,0 @@ -""" -Pybind11 example plugin ------------------------ - -.. currentmodule:: pyfsg - -.. autosummary:: - :toctree: _generate - - add - subtract -""" - -def add(i: int, j: int) -> int: - """ - Add two numbers - - Some other explanation about the add function. - """ diff --git a/tests/test_basic.py b/tests/test_basic.py index 3dbc996..9a62f24 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,15 +1,27 @@ from __future__ import annotations -import pyfsg as m +import pyfsg def test_version(): - assert m.__version__ == "0.0.1" + assert pyfsg.__version__ == "0.0.1" def test_add(): - assert m.add(1, 2) == 3 + assert pyfsg.add(1, 2) == 3 def test_sub(): - assert m.subtract(1, 2) == -1 + assert pyfsg.subtract(1, 2) == -1 + + +def test_fsg(): + merger = pyfsg.GreedyMerger(width=640, height=480) + + segments = pyfsg.Segments() + segments.append(pyfsg.Segment(0, 0, 100, 100)) + segments.append(pyfsg.Segment(10, 20, 80, 90)) + + merged, clusters = merger.mergeSegments(segments) + print("Merged segments:", merged) + print("Clusters:", clusters) From 6d7acd51a89d4be8580a408aa1fa71a98814338a Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 12:08:12 +0100 Subject: [PATCH 05/15] Creating a more pytonic API based on Numpy --- src/PYAPI.cpp | 264 +++++++++++++++++++++++------------------- src/pyfsg/__init__.py | 4 +- tests/test_basic.py | 140 +++++++++++++++++++--- 3 files changed, 269 insertions(+), 139 deletions(-) diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp index d847aba..b484460 100644 --- a/src/PYAPI.cpp +++ b/src/PYAPI.cpp @@ -1,9 +1,10 @@ #include #include #include - +#include // for py::array_t #include -#include "GreedyMerger.h" // your library's header + +#include "GreedyMerger.h" // your library's header that defines upm::GreedyMerger namespace py = pybind11; using namespace upm; @@ -11,48 +12,67 @@ using namespace upm; #define STRINGIFY(x) #x #define MACRO_STRINGIFY(x) STRINGIFY(x) -int add(int i, int j) { - cv::Vec2f v_i(i, 0); - cv::Vec2f v_j(j, 0); - cv::Vec2f sum = v_i + v_j; - std::cout << "Sum: " << sum << std::endl; - return sum(0); -} - - -// A small helper struct to hold the 4 floats from a "Segment". -struct PySegment { - float x1, y1, x2, y2; - - PySegment() : x1(0.f), y1(0.f), x2(0.f), y2(0.f) { - } +namespace py = pybind11; +using namespace upm; - PySegment(float x1_, float y1_, float x2_, float y2_) - : x1(x1_), y1(y1_), x2(x2_), y2(y2_) { +//------------------------------------------------------------------------------ +// HELPER FUNCTIONS: Convert between NumPy arrays (N×4) and Segments +//------------------------------------------------------------------------------ + +/** + * @brief Convert a NumPy array (N x 4, dtype=float32 or float64) -> Segments. + * @param arr Python array, must be 2D with shape [N,4]. + * @throws std::runtime_error if shape is wrong. + */ +static Segments ndarrayToSegments(const py::array_t &arr) +{ + // Request buffer info from NumPy array + py::buffer_info buf = arr.request(); + + if (buf.ndim != 2 || buf.shape[1] != 4) { + throw std::runtime_error("Segments array must have shape (N,4)"); } - explicit PySegment(const Segment &seg) { - x1 = seg[0]; - y1 = seg[1]; - x2 = seg[2]; - y2 = seg[3]; + // Convert to Segments + size_t n = buf.shape[0]; + float *ptr = static_cast(buf.ptr); + + Segments segments(n); + for (size_t i = 0; i < n; ++i) { + // each row: (x1, y1, x2, y2) + segments[i] = cv::Vec4f(ptr[4*i + 0], + ptr[4*i + 1], + ptr[4*i + 2], + ptr[4*i + 3]); } + return segments; +} - [[nodiscard]] Segment toSegment() const { - Segment s; - s[0] = x1; - s[1] = y1; - s[2] = x2; - s[3] = y2; - return s; +/** + * @brief Convert Segments -> NumPy array (N x 4, dtype=float32). + * @param segments The C++ vector of segments. + * @return A pybind11 array with shape [N,4]. + */ +static py::array_t segmentsToNdarray(const Segments &segments) +{ + const size_t n = segments.size(); + // Create a new NumPy array of shape (n, 4) + py::array_t arr({static_cast(n), 4l}); + py::buffer_info buf = arr.request(); + float *ptr = static_cast(buf.ptr); + + for (size_t i = 0; i < n; ++i) { + ptr[4*i + 0] = segments[i][0]; + ptr[4*i + 1] = segments[i][1]; + ptr[4*i + 2] = segments[i][2]; + ptr[4*i + 3] = segments[i][3]; } -}; - -using PySegments = std::vector; - -PYBIND11_MAKE_OPAQUE(std::vector); + return arr; +} -PYBIND11_MAKE_OPAQUE(std::vector>); +//------------------------------------------------------------------------------ +// PYBIND11 MODULE DEFINITION +//------------------------------------------------------------------------------ PYBIND11_MODULE(_pyfsg, m) { m.doc() = R"pbdoc( @@ -68,92 +88,92 @@ PYBIND11_MODULE(_pyfsg, m) { subtract )pbdoc"; - py::class_(m, "Segment") - .def(py::init<>()) - .def(py::init(), - py::arg("x1"), py::arg("y1"), - py::arg("x2"), py::arg("y2")) - .def_readwrite("x1", &PySegment::x1) - .def_readwrite("y1", &PySegment::y1) - .def_readwrite("x2", &PySegment::x2) - .def_readwrite("y2", &PySegment::y2); - - // Wrap vector of PySegment as "Segments" - py::bind_vector >(m, "Segments"); - - // Wrap vector> as "SegmentClusters" - py::bind_vector > >(m, "SegmentClusters"); - py::class_(m, "GreedyMerger") - .def(py::init([](int width, int height) { - return std::make_unique(cv::Size(width, height)); - }), - py::arg("width") = 800, py::arg("height") = 480) - .def("setImageSize", - [](GreedyMerger &self, int width, int height) { - self.setImageSize(cv::Size(width, height)); - }, - py::arg("width"), py::arg("height")) - .def("mergeSegments", - [](GreedyMerger &self, const std::vector &pySegs) { - Segments in; - in.reserve(pySegs.size()); - for (auto &ps: pySegs) in.push_back(ps.toSegment()); - - Segments out; - SegmentClusters clusters; - self.mergeSegments(in, out, clusters); - - // Convert output segments back to Python-friendly structure - std::vector pyOut; - pyOut.reserve(out.size()); - for (auto &seg: out) { - pyOut.emplace_back(seg); - } - // Return them as (mergedSegments, clusters) - return std::make_pair(pyOut, clusters); - }, - py::arg("segments")) - .def_static("getOrientationHistogram", - [](const std::vector &pySegs, int bins) { - Segments in; - in.reserve(pySegs.size()); - for (auto &ps: pySegs) in.push_back(ps.toSegment()); - return GreedyMerger::getOrientationHistogram(in, bins); - }, - py::arg("segments"), py::arg("bins") = 90) - .def_static("partialSortByLength", - [](const std::vector &pySegs, - int bins, int width, int height) { - Segments in; - in.reserve(pySegs.size()); - for (auto &ps: pySegs) in.push_back(ps.toSegment()); - return GreedyMerger::partialSortByLength( - in, bins, cv::Size(width, height) - ); - }, - py::arg("segments"), py::arg("bins"), - py::arg("width"), py::arg("height")) - .def_static("getTangentLineEqs", - [](const PySegment &pySeg, float radius) { - auto eq = GreedyMerger::getTangentLineEqs(pySeg.toSegment(), radius); - py::tuple l1 = py::make_tuple(eq.first[0], eq.first[1], eq.first[2]); - py::tuple l2 = py::make_tuple(eq.second[0], eq.second[1], eq.second[2]); - return py::make_tuple(l1, l2); - }, - py::arg("segment"), py::arg("radius")); - - m.def("add", &add, R"pbdoc( - Add two numbers - - Some other explanation about the add function. - )pbdoc"); - - m.def("subtract", [](int i, int j) { return i - j; }, R"pbdoc( - Subtract two numbers - - Some other explanation about the subtract function. - )pbdoc"); + // Constructor from width, height + .def(py::init([](int width, int height) { + return std::make_unique(cv::Size(width, height)); + }), + py::arg("width") = 800, + py::arg("height") = 480, + "Construct a GreedyMerger for an image of size (width, height).") + + // setImageSize + .def("setImageSize", + [](GreedyMerger &self, int width, int height) { + self.setImageSize(cv::Size(width, height)); + }, + py::arg("width"), py::arg("height"), + "Change the internal image size used by the merger.") + + // mergeSegments + // + // In C++: void mergeSegments(const Segments &original, Segments &merged, SegmentClusters &clusters) + // We'll accept a NumPy array for `original` and return two things: + // 1) a NumPy array (N x 4) for `merged` + // 2) a Python list of lists of int for `clusters` + .def("mergeSegments", + [](GreedyMerger &self, const py::array_t &arr) { + // Convert from NumPy => Segments + Segments original = ndarrayToSegments(arr); + + // Prepare outputs + Segments merged; + SegmentClusters clusters; + + // Call the actual C++ method + self.mergeSegments(original, merged, clusters); + + // Convert merged back to a NumPy array + py::array_t mergedArr = segmentsToNdarray(merged); + + // Return a (mergedArr, clusters) tuple + return py::make_tuple(mergedArr, clusters); + }, + py::arg("segments"), + "Merge input line segments (Nx4 array) that belong to the same line. " + "Returns (merged_segments, segment_clusters).") + + // getOrientationHistogram (static) + .def_static("getOrientationHistogram", + [](const py::array_t &arr, int bins) { + Segments segs = ndarrayToSegments(arr); + auto clusters = GreedyMerger::getOrientationHistogram(segs, bins); + return clusters; // automatically converted to Python list-of-lists + }, + py::arg("segments"), py::arg("bins") = 90, + "Build an orientation histogram from an Nx4 array of segments. " + "Returns a list of lists of indices (SegmentClusters).") + + // partialSortByLength (static) + .def_static("partialSortByLength", + [](const py::array_t &arr, int bins, int width, int height) { + Segments segs = ndarrayToSegments(arr); + auto sortedIndices = GreedyMerger::partialSortByLength( + segs, bins, cv::Size(width, height)); + return sortedIndices; // automatically converted to Python list of int + }, + py::arg("segments"), py::arg("bins"), + py::arg("width"), py::arg("height"), + "Sort Nx4 segments by descending length. Returns list of sorted indices.") + + // getTangentLineEqs (static) + .def_static("getTangentLineEqs", + [](const py::array_t &arr, float radius) { + // Expect a single segment, i.e. shape (1,4) or something similar + // but we’ll just read the first row for demonstration + Segments segs = ndarrayToSegments(arr); + if (segs.empty()) { + throw std::runtime_error("Expected at least 1 segment in Nx4 array."); + } + // We'll just use the first one + auto eqs = GreedyMerger::getTangentLineEqs(segs[0], radius); + // eqs.first, eqs.second are cv::Vec3f => (a,b,c) + py::tuple line1 = py::make_tuple(eqs.first[0], eqs.first[1], eqs.first[2]); + py::tuple line2 = py::make_tuple(eqs.second[0], eqs.second[1], eqs.second[2]); + return py::make_tuple(line1, line2); + }, + py::arg("segment"), py::arg("radius"), + "Given a single segment (1x4 array) and a radius, returns 2 lines in (a,b,c) form."); #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); diff --git a/src/pyfsg/__init__.py b/src/pyfsg/__init__.py index ba38cbe..69c49c3 100644 --- a/src/pyfsg/__init__.py +++ b/src/pyfsg/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from ._pyfsg import __doc__, __version__, add, subtract, Segment, Segments, SegmentClusters, GreedyMerger +from ._pyfsg import __doc__, __version__, GreedyMerger -__all__ = ["__doc__", "__version__", "add", "subtract", "Segment", "Segments", "SegmentClusters", "GreedyMerger", ] +__all__ = ["__doc__", "__version__", "GreedyMerger"] diff --git a/tests/test_basic.py b/tests/test_basic.py index 9a62f24..e668f69 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,27 +1,137 @@ from __future__ import annotations +import numpy as np +import math +import pyfsg # Your pybind11 module with the new NumPy-based API -import pyfsg +def test_fsg(): + merger = pyfsg.GreedyMerger(width=640, height=480) -def test_version(): - assert pyfsg.__version__ == "0.0.1" + segs = np.array([ + [0, 0, 100, 100], + [10, 20, 80, 90] + ], dtype=np.float32) # shape (0,4) + merged, clusters = merger.mergeSegments(segs) + print("Merged segments:", merged) + print("Clusters:", clusters) -def test_add(): - assert pyfsg.add(1, 2) == 3 +def test_merge_empty_segments(): + """ + Merging an empty Nx4 array => empty Nx4 array and empty clusters. + """ + merger = pyfsg.GreedyMerger(640, 480) + segs = np.zeros((0, 4), dtype=np.float32) # shape (0,4) -def test_sub(): - assert pyfsg.subtract(1, 2) == -1 + merged, clusters = merger.mergeSegments(segs) + assert merged.shape == (0, 4), "Expected no merged segments." + assert len(clusters) == 0, "Expected no clusters for empty input." -def test_fsg(): - merger = pyfsg.GreedyMerger(width=640, height=480) +def test_merge_single_segment(): + """ + Merging one segment => single merged segment + one cluster with [0]. + """ + merger = pyfsg.GreedyMerger(640, 480) + segs = np.array([[10, 10, 20, 20]], dtype=np.float32) # shape (1,4) - segments = pyfsg.Segments() - segments.append(pyfsg.Segment(0, 0, 100, 100)) - segments.append(pyfsg.Segment(10, 20, 80, 90)) + merged, clusters = merger.mergeSegments(segs) + assert merged.shape == (1, 4), "Expected 1 merged segment." + assert len(clusters) == 1, "Expected 1 cluster." + assert len(clusters[0]) == 1, "Cluster should contain exactly one index." - merged, clusters = merger.mergeSegments(segments) - print("Merged segments:", merged) - print("Clusters:", clusters) + +def test_merge_two_collinear_segments(): + """ + Two collinear segments => single merged segment + single cluster with [0,1]. + """ + merger = pyfsg.GreedyMerger(640, 480) + segs = np.array([ + [10, 10, 20, 20], + [20, 20, 30, 30] + ], dtype=np.float32) # shape (2,4) + + merged, clusters = merger.mergeSegments(segs) + # Typically, collinear + adjacent => merged into one line + # Implementation detail: your code might combine them or not; this is the assumption. + assert merged.shape[0] == 1, "Expected 1 merged segment from two collinear segments." + assert len(clusters) == 1, "Expected 1 cluster." + assert len(clusters[0]) == 2, "Cluster should contain both segments." + + +def test_merge_two_non_collinear_segments(): + """ + Two perpendicular segments => remain separate, i.e. 2 merged segments, 2 clusters. + """ + merger = pyfsg.GreedyMerger(640, 480) + segs = np.array([ + [10, 10, 50, 10], # horizontal + [20, 20, 20, 60], # vertical + ], dtype=np.float32) + + merged, clusters = merger.mergeSegments(segs) + # Typically, these won't be merged (they're perpendicular). + assert merged.shape[0] == 2, "Expected 2 merged segments for perpendicular input." + assert len(clusters) == 2, "Expected 2 clusters." + for c in clusters: + assert len(c) == 1, "Each cluster should contain exactly one segment index." + + +def test_partial_sort_by_length(): + """ + partialSortByLength => return indices sorted by descending length. + """ + segs = np.array([ + [0, 0, 10, 0], # length 10 + [0, 0, 100, 0], # length 100 + [0, 0, 50, 0], # length 50 + ], dtype=np.float32) + + sorted_indices = pyfsg.GreedyMerger.partialSortByLength(segs, 1000, 640, 480) + # We expect them in descending order: 1 (len=100), 2 (len=50), 0 (len=10) + lengths = [math.hypot(segs[i, 2] - segs[i, 0], segs[i, 3] - segs[i, 1]) for i in sorted_indices] + + assert lengths == sorted(lengths, reverse=True), "Segments not in descending length order." + # Optional exact check: + assert sorted_indices[0] == 1, "Longest segment index should be 1." + assert sorted_indices[1] == 2, "2nd longest segment index should be 2." + assert sorted_indices[2] == 0, "Shortest segment index should be 0." + + +def test_get_orientation_histogram(): + """ + Check that near-horizontal segments go in the same bin, + near-vertical in a separate bin, etc. + """ + segs = np.array([ + [20, 20, 100, 21.5], # near-horizontal + [10, 10, 90, 15.], # near-horizontal + [50, 50, 49, 100], # near-vertical + ], dtype=np.float32) + + clusters = pyfsg.GreedyMerger().getOrientationHistogram(segs, bins=4) + # bins=4 => each bin ~ 45 degrees. + # Typically, the first 2 segments are near the same orientation bin, + # the 3rd is in a different bin. + assert len(clusters[0]) == 2 + assert (clusters[0][0] == 0) and (clusters[0][1] == 1) + assert len(clusters[2]) == 1 + assert clusters[2][0] == 2 + + +def test_get_tangent_line_eqs(): + """ + We pass in an array with at least one segment (1x4). + Expect two (a,b,c) lines for the conic around this segment. + """ + seg = np.array([[10, 10, 20, 20]], dtype=np.float32) + line1, line2 = pyfsg.GreedyMerger.getTangentLineEqs(seg, 5.0) + + # line1, line2 => (a,b,c) each. + assert len(line1) == 3, "Line eq must have 3 coefficients (a, b, c)." + assert len(line2) == 3, "Line eq must have 3 coefficients (a, b, c)." + + # Quick check that they are not all zeros + assert any(coeff != 0 for coeff in line1), "Line1 shouldn't be all zeros." + assert any(coeff != 0 for coeff in line2), "Line2 shouldn't be all zeros." From 7e6d613c4a1882d7b091f48853aa42f4bf988f55 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 12:19:10 +0100 Subject: [PATCH 06/15] Adding Unit tests that I had originally defined for C++ code --- tests/test_greedy_merger.py | 164 ++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_greedy_merger.py diff --git a/tests/test_greedy_merger.py b/tests/test_greedy_merger.py new file mode 100644 index 0000000..1b7a806 --- /dev/null +++ b/tests/test_greedy_merger.py @@ -0,0 +1,164 @@ +import math +import numpy as np + +import pyfsg # your pybind11 extension: from .cpp => PYBIND11_MODULE(pyfsg, ...) + + +def seg_close_to_any(s, arr, tol=2.0): + """ + s: (x1,y1,x2,y2) + arr: Nx4 of segments + tol: endpoint tolerance in pixels + """ + # We'll accept that each endpoint is within `tol` of any endpoint in `arr`. + p1 = np.array(s[0:2]) + p2 = np.array(s[2:4]) + for row in arr: + r1 = np.array(row[0:2]) + r2 = np.array(row[2:4]) + # Check if p1 matches r1 or r2, and p2 matches r1 or r2 (in any order) + p1_close = (np.linalg.norm(p1 - r1) < tol) or (np.linalg.norm(p1 - r2) < tol) + p2_close = (np.linalg.norm(p2 - r1) < tol) or (np.linalg.norm(p2 - r2) < tol) + if p1_close and p2_close: + return True + return False + + +# ------------------------------------------------------------------------------ +# Helper: compare a line eq (a,b,c) to expected values with some tolerance. +# ------------------------------------------------------------------------------ +def assert_line_eq(line, expected, tol=1e-6): + """ + line is a tuple (a, b, c) + expected is a tuple (a, b, c) + """ + assert len(line) == 3, "Line should have 3 coefficients" + for x, e in zip(line, expected): + assert math.isclose(x, e, abs_tol=tol), f"Expected ~{e}, got {x}" + + +# ------------------------------------------------------------------------------ +# 1) TEST: Sort segments by length +def test_sort_by_length(): + merger = pyfsg.GreedyMerger(500, 600) + segs = np.array([ + [10, 0, 20, 0], # length=10 + [30, 0, 50, 0], # length=20 + [20, -10, 20, -5], # length=5 + [500, 500, 600, 600], # length ~141.42 + ], dtype=np.float32) + + result = merger.partialSortByLength(segs, 1000, 500, 600) + assert result == [3, 1, 0, 2], "Segments not sorted as expected by descending length" + + +# ------------------------------------------------------------------------------ +# 2) TEST: Sort segments by angle => getOrientationHistogram + flatten +def test_sort_by_angle(): + merger = pyfsg.GreedyMerger(500, 600) + segs = np.array([ + [10, 0, 20, 0], # near horizontal (theta ~ -1.57 or +1.57) + [20, 0, 10, 2], # some angle + [20, 0, 10, 8], # another angle + [10, 10, 100, 100], # ~ -0.7853 + [100, 100, 10, 10], # also ~ -0.7853 + [200, 600, 200, 300], # vertical => ~ 0 + [0, 100, 0, 105], # vertical => ~ 0 + ], dtype=np.float32) + + histogram = merger.getOrientationHistogram(segs, bins=180) + # histogram is a list-of-lists of segment indices. The bin index corresponds to an angle bucket. + + # Flatten them like the C++ code: each bin is histogram[h_col] + flattened = [] + for h_col in range(len(histogram)): + flattened.extend(histogram[h_col]) + + # We expect exactly 7 segment indices total in some order + assert len(flattened) == 7, f"Expected 7 total indices in the histogram, got {len(flattened)}" + + # The exact ordering might differ slightly depending on your angle definitions. + # We'll do a simpler check: all indices {0,1,2,3,4,5,6} are present. + assert sorted(flattened) == [0, 1, 2, 3, 4, 5, 6], \ + f"All segments should appear in the histogram; got {flattened}" + + +# ------------------------------------------------------------------------------ +# 3) TEST: getTangentLineEqs +# C++ test: TEST(UnitGreedyMerger, GetTangentLines) +# ------------------------------------------------------------------------------ +def test_get_tangent_lines(): + # We'll replicate the C++ test: + # Segment(400,400,400,500) with radius=10 => line eq near [0.979795814, -0.200000003, 301.918365], etc. + + merger = pyfsg.GreedyMerger(500, 500) + + segs = np.array([[400, 400, 400, 500]], dtype=np.float32) # shape (1,4) + line1, line2 = merger.getTangentLineEqs(segs, 10.0) + # Compare to the expected values: + expected1 = (0.979795814, -0.200000003, 301.918365) + expected2 = (0.979795814, 0.200000003, 481.918335) + + # We'll use a small tolerance + tol = 1e-5 + assert_line_eq(line1, expected1, tol) + assert_line_eq(line2, expected2, tol) + + # Next case: Segment(200,200,300,300) radius=5 => + # expected near: + # first: (0.655336857, -0.755336821, -25.0000019) + # second: (0.755336821, -0.655336857, 25.0000019) + segs = np.array([[200, 200, 300, 300]], dtype=np.float32) + line1, line2 = merger.getTangentLineEqs(segs, 5.0) + + expected1 = (0.655336857, -0.755336821, -25.0000019) + expected2 = (0.755336821, -0.655336857, 25.0000019) + tol = 1e-5 + assert_line_eq(line1, expected1, tol) + assert_line_eq(line2, expected2, tol) + + +# ------------------------------------------------------------------------------ +# 4) TEST: MergeSegmentsHierarchicalSynthetic +# C++ test: TEST(UnitGreedyMerger, MergeSegmentsHierarchicalSynthetic) +# - This requires a method `mergeSegmentsHierarchical(...)` in Python, +# which may or may not exist in your binding. If not, skip or rename. +# ------------------------------------------------------------------------------ +def test_merge_segments_hierarchical_synthetic(): + # Using the same data as in the C++ test: + ELSs = np.array([ + [100, 100, 250, 100], + [300, 100, 400, 100], + [300, 102, 400, 102], + [300, 250, 400, 250], + [300, 170, 400, 170], + [600, 170, 700, 170], + [600, 200, 700, 200], + [10, 20, 40, 10], + [50, 50, 50, 150], + [52, 200, 52, 300], + ], dtype=np.float32) + + merger = pyfsg.GreedyMerger(800, 400) + merged, clusters = merger.mergeSegments(ELSs) + + # Compare to the expected merged segments + # from the C++ test: + expectedMergedSegs = np.array([ + [100, 100, 400, 100], + [300, 102, 400, 102], + [300, 250, 400, 250], + [300, 170, 700, 170], + [600, 200, 700, 200], + [10, 20, 40, 10], + [51, 50, 51, 300], # note: the test modifies x=50->51 if multiple segments + ], dtype=np.float32) + + # Check that merged has the same size as expected + assert merged.shape[0] == expectedMergedSegs.shape[0], \ + f"Mismatch in number of merged segments: got {merged.shape[0]}, expected {expectedMergedSegs.shape[0]}" + + # For each expected segment, ensure we find a match in `merged`. + for row in expectedMergedSegs: + assert seg_close_to_any(row, merged, tol=2.0), \ + f"Expected segment {row} not found in merged results." From 7847f35c8f657203a8fcd7db4648ea1eb24485c4 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 13:20:28 +0100 Subject: [PATCH 07/15] Adding line segment detection with old OpenCV LSD to the API --- src/PYAPI.cpp | 360 ++++++++++++++++++++++++++++----------- src/pyfsg/__init__.py | 4 +- tests/test_opencv_lsd.py | 27 +++ 3 files changed, 288 insertions(+), 103 deletions(-) create mode 100644 tests/test_opencv_lsd.py diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp index b484460..887b1cb 100644 --- a/src/PYAPI.cpp +++ b/src/PYAPI.cpp @@ -4,7 +4,8 @@ #include // for py::array_t #include -#include "GreedyMerger.h" // your library's header that defines upm::GreedyMerger +#include "GreedyMerger.h" +#include "LsdOpenCV.h" namespace py = pybind11; using namespace upm; @@ -18,14 +19,37 @@ using namespace upm; //------------------------------------------------------------------------------ // HELPER FUNCTIONS: Convert between NumPy arrays (N×4) and Segments //------------------------------------------------------------------------------ +/** + * @brief Helper to convert a Python grayscale image (NumPy array) to cv::Mat(CV_8UC1). + * @param imgArray A 2D or 3D NumPy array of shape (H, W) or (H, W, 1). + * @throw std::runtime_error if dtype not uint8 or shape mismatch. + */ +static cv::Mat numpyToGrayMat(const py::array_t &imgArray) { + py::buffer_info buf = imgArray.request(); + int ndims = buf.ndim; + + if (ndims < 2 || ndims > 3) { + throw std::runtime_error("Expected 2D or 3D array for the grayscale image."); + } + int height = buf.shape[0]; + int width = buf.shape[1]; + int channels = (ndims == 2) ? 1 : buf.shape[2]; + + if (channels != 1) { + throw std::runtime_error("Expected single-channel (grayscale) image array."); + } + + // Wrap in a cv::Mat without copying data (assuming row-major contiguous layout). + cv::Mat mat(height, width, CV_8UC1, (unsigned char *) buf.ptr); + return mat; +} /** * @brief Convert a NumPy array (N x 4, dtype=float32 or float64) -> Segments. * @param arr Python array, must be 2D with shape [N,4]. * @throws std::runtime_error if shape is wrong. */ -static Segments ndarrayToSegments(const py::array_t &arr) -{ +static Segments ndarrayToSegments(const py::array_t &arr) { // Request buffer info from NumPy array py::buffer_info buf = arr.request(); @@ -35,15 +59,15 @@ static Segments ndarrayToSegments(const py::array_t &arr) // Convert to Segments size_t n = buf.shape[0]; - float *ptr = static_cast(buf.ptr); + float *ptr = static_cast(buf.ptr); Segments segments(n); for (size_t i = 0; i < n; ++i) { // each row: (x1, y1, x2, y2) - segments[i] = cv::Vec4f(ptr[4*i + 0], - ptr[4*i + 1], - ptr[4*i + 2], - ptr[4*i + 3]); + segments[i] = cv::Vec4f(ptr[4 * i + 0], + ptr[4 * i + 1], + ptr[4 * i + 2], + ptr[4 * i + 3]); } return segments; } @@ -53,23 +77,115 @@ static Segments ndarrayToSegments(const py::array_t &arr) * @param segments The C++ vector of segments. * @return A pybind11 array with shape [N,4]. */ -static py::array_t segmentsToNdarray(const Segments &segments) -{ +static py::array_t segmentsToNdarray(const Segments &segments) { const size_t n = segments.size(); // Create a new NumPy array of shape (n, 4) - py::array_t arr({static_cast(n), 4l}); + py::array::ShapeContainer arr_shape{static_cast(n), 4l}; + py::array_t arr(arr_shape); py::buffer_info buf = arr.request(); - float *ptr = static_cast(buf.ptr); + float *ptr = static_cast(buf.ptr); for (size_t i = 0; i < n; ++i) { - ptr[4*i + 0] = segments[i][0]; - ptr[4*i + 1] = segments[i][1]; - ptr[4*i + 2] = segments[i][2]; - ptr[4*i + 3] = segments[i][3]; + ptr[4 * i + 0] = segments[i][0]; + ptr[4 * i + 1] = segments[i][1]; + ptr[4 * i + 2] = segments[i][2]; + ptr[4 * i + 3] = segments[i][3]; + } + return arr; +} + +/** + * @brief Helpers for optional arrays (width, prec, nfa) each of shape (N,) + */ +static py::array_t vectorToNdarray(const std::vector &vals) { + py::array_t arr(vals.size()); + auto buf = arr.request(); + double *ptr = static_cast(buf.ptr); + for (size_t i = 0; i < vals.size(); ++i) { + ptr[i] = vals[i]; } return arr; } + +/** + * A free function to run LSD detection. Returns either a NumPy array (N,4) + * or a dict with {"lines": Nx4, "width": Nx1, "prec": Nx1, "nfa": Nx1}. + */ +static py::object detectLinesOpencvLSD( + const py::array_t &imgArray, + int refine, + double scale, + double sigma_scale, + double quant, + double ang_th, + double log_eps, + double density_th, + int n_bins, + bool return_width, + bool return_prec, + bool return_nfa) { + // 1) Convert input image + cv::Mat gray = numpyToGrayMat(imgArray); + + // 2) Construct an LSD detector with the given params + LsdOpenCV lsd(refine, scale, sigma_scale, quant, ang_th, log_eps, density_th, n_bins); + + // 3) Prepare storage for lines and optionally the widths, prec, nfa + Segments lines; // std::vector + std::vector widths, precs, nfas; + + // 4) We can call LsdOpenCV::detect(...) in 2 ways: + // - either the overload with only lines + // - or the overload with lines + width + prec + nfa + if (!return_width && !return_prec && !return_nfa) { + // simpler call + lsd.detect(gray, lines); + } else { + // we want the extra arrays + cv::Mat wMat, pMat, nMat; // Output arrays + lsd.detect(gray, lines, wMat, pMat, nMat); + + if (return_width && !wMat.empty()) { + widths.resize(wMat.total()); + wMat.copyTo(cv::Mat(widths)); + } + if (return_prec && !pMat.empty()) { + precs.resize(pMat.total()); + pMat.copyTo(cv::Mat(precs)); + } + if (return_nfa && !nMat.empty()) { + nfas.resize(nMat.total()); + nMat.copyTo(cv::Mat(nfas)); + } + } + + // 5) Convert lines => Nx4 array + py::array_t linesArr = segmentsToNdarray(lines); + + // 6) Return + if (!return_width && !return_prec && !return_nfa) { + // Just return the Nx4 lines array + return py::cast(linesArr); + } + + // Otherwise, return a dict + py::dict result; + result["lines"] = linesArr; + + if (return_width) { + result["width"] = vectorToNdarray(widths); + } + if (return_prec) { + result["prec"] = vectorToNdarray(precs); + } + if (return_nfa) { + result["nfa"] = vectorToNdarray(nfas); + } + + return result; +} + //------------------------------------------------------------------------------ // PYBIND11 MODULE DEFINITION //------------------------------------------------------------------------------ @@ -89,92 +205,134 @@ PYBIND11_MODULE(_pyfsg, m) { )pbdoc"; py::class_(m, "GreedyMerger") - // Constructor from width, height - .def(py::init([](int width, int height) { - return std::make_unique(cv::Size(width, height)); - }), - py::arg("width") = 800, - py::arg("height") = 480, - "Construct a GreedyMerger for an image of size (width, height).") - - // setImageSize - .def("setImageSize", - [](GreedyMerger &self, int width, int height) { - self.setImageSize(cv::Size(width, height)); - }, - py::arg("width"), py::arg("height"), - "Change the internal image size used by the merger.") - - // mergeSegments - // - // In C++: void mergeSegments(const Segments &original, Segments &merged, SegmentClusters &clusters) - // We'll accept a NumPy array for `original` and return two things: - // 1) a NumPy array (N x 4) for `merged` - // 2) a Python list of lists of int for `clusters` - .def("mergeSegments", - [](GreedyMerger &self, const py::array_t &arr) { - // Convert from NumPy => Segments - Segments original = ndarrayToSegments(arr); - - // Prepare outputs - Segments merged; - SegmentClusters clusters; - - // Call the actual C++ method - self.mergeSegments(original, merged, clusters); - - // Convert merged back to a NumPy array - py::array_t mergedArr = segmentsToNdarray(merged); - - // Return a (mergedArr, clusters) tuple - return py::make_tuple(mergedArr, clusters); - }, - py::arg("segments"), - "Merge input line segments (Nx4 array) that belong to the same line. " - "Returns (merged_segments, segment_clusters).") - - // getOrientationHistogram (static) - .def_static("getOrientationHistogram", - [](const py::array_t &arr, int bins) { - Segments segs = ndarrayToSegments(arr); - auto clusters = GreedyMerger::getOrientationHistogram(segs, bins); - return clusters; // automatically converted to Python list-of-lists - }, - py::arg("segments"), py::arg("bins") = 90, - "Build an orientation histogram from an Nx4 array of segments. " - "Returns a list of lists of indices (SegmentClusters).") - - // partialSortByLength (static) - .def_static("partialSortByLength", - [](const py::array_t &arr, int bins, int width, int height) { - Segments segs = ndarrayToSegments(arr); - auto sortedIndices = GreedyMerger::partialSortByLength( - segs, bins, cv::Size(width, height)); - return sortedIndices; // automatically converted to Python list of int - }, - py::arg("segments"), py::arg("bins"), - py::arg("width"), py::arg("height"), - "Sort Nx4 segments by descending length. Returns list of sorted indices.") - - // getTangentLineEqs (static) - .def_static("getTangentLineEqs", - [](const py::array_t &arr, float radius) { - // Expect a single segment, i.e. shape (1,4) or something similar - // but we’ll just read the first row for demonstration - Segments segs = ndarrayToSegments(arr); - if (segs.empty()) { - throw std::runtime_error("Expected at least 1 segment in Nx4 array."); - } - // We'll just use the first one - auto eqs = GreedyMerger::getTangentLineEqs(segs[0], radius); - // eqs.first, eqs.second are cv::Vec3f => (a,b,c) - py::tuple line1 = py::make_tuple(eqs.first[0], eqs.first[1], eqs.first[2]); - py::tuple line2 = py::make_tuple(eqs.second[0], eqs.second[1], eqs.second[2]); - return py::make_tuple(line1, line2); - }, - py::arg("segment"), py::arg("radius"), - "Given a single segment (1x4 array) and a radius, returns 2 lines in (a,b,c) form."); + // Constructor from width, height + .def(py::init([](int width, int height) { + return std::make_unique(cv::Size(width, height)); + }), + py::arg("width") = 800, + py::arg("height") = 480, + "Construct a GreedyMerger for an image of size (width, height).") + // setImageSize + .def("setImageSize", + [](GreedyMerger &self, int width, int height) { + self.setImageSize(cv::Size(width, height)); + }, + py::arg("width"), py::arg("height"), + "Change the internal image size used by the merger.") + + // mergeSegments + // + // In C++: void mergeSegments(const Segments &original, Segments &merged, SegmentClusters &clusters) + // We'll accept a NumPy array for `original` and return two things: + // 1) a NumPy array (N x 4) for `merged` + // 2) a Python list of lists of int for `clusters` + .def("mergeSegments", + [](GreedyMerger &self, const py::array_t &arr) { + // Convert from NumPy => Segments + Segments original = ndarrayToSegments(arr); + + // Prepare outputs + Segments merged; + SegmentClusters clusters; + + // Call the actual C++ method + self.mergeSegments(original, merged, clusters); + + // Convert merged back to a NumPy array + py::array_t mergedArr = segmentsToNdarray(merged); + + // Return a (mergedArr, clusters) tuple + return py::make_tuple(mergedArr, clusters); + }, + py::arg("segments"), + "Merge input line segments (Nx4 array) that belong to the same line. " + "Returns (merged_segments, segment_clusters).") + + // getOrientationHistogram (static) + .def_static("getOrientationHistogram", + [](const py::array_t &arr, int bins) { + Segments segs = ndarrayToSegments(arr); + auto clusters = GreedyMerger::getOrientationHistogram(segs, bins); + return clusters; // automatically converted to Python list-of-lists + }, + py::arg("segments"), py::arg("bins") = 90, + "Build an orientation histogram from an Nx4 array of segments. " + "Returns a list of lists of indices (SegmentClusters).") + + // partialSortByLength (static) + .def_static("partialSortByLength", + [](const py::array_t &arr, int bins, int width, int height) { + Segments segs = ndarrayToSegments(arr); + auto sortedIndices = GreedyMerger::partialSortByLength( + segs, bins, cv::Size(width, height)); + return sortedIndices; // automatically converted to Python list of int + }, + py::arg("segments"), py::arg("bins"), + py::arg("width"), py::arg("height"), + "Sort Nx4 segments by descending length. Returns list of sorted indices.") + + // getTangentLineEqs (static) + .def_static("getTangentLineEqs", + [](const py::array_t &arr, float radius) { + // Expect a single segment, i.e. shape (1,4) or something similar + // but we’ll just read the first row for demonstration + Segments segs = ndarrayToSegments(arr); + if (segs.empty()) { + throw std::runtime_error("Expected at least 1 segment in Nx4 array."); + } + // We'll just use the first one + auto eqs = GreedyMerger::getTangentLineEqs(segs[0], radius); + // eqs.first, eqs.second are cv::Vec3f => (a,b,c) + py::tuple line1 = py::make_tuple(eqs.first[0], eqs.first[1], eqs.first[2]); + py::tuple line2 = py::make_tuple(eqs.second[0], eqs.second[1], eqs.second[2]); + return py::make_tuple(line1, line2); + }, + py::arg("segment"), py::arg("radius"), + "Given a single segment (1x4 array) and a radius, returns 2 lines in (a,b,c) form."); + + // ------------------------------------------------------------------------- + // 4) Define the FREE FUNCTION for LSD detection + // ------------------------------------------------------------------------- + m.def("detectLinesOpencvLSD", &detectLinesOpencvLSD, + py::arg("image"), + py::arg("refine") = 1, + py::arg("scale") = 0.8, + py::arg("sigma_scale") = 0.6, + py::arg("quant") = 2.0, + py::arg("ang_th") = 22.5, + py::arg("log_eps") = 0.0, + py::arg("density_th") = 0.7, + py::arg("n_bins") = 1024, + py::arg("return_width") = false, + py::arg("return_prec") = false, + py::arg("return_nfa") = false, + R"doc( +Run LSD detection in a single call. +Parameters: + image - grayscale uint8 array, shape (H,W) or (H,W,1) + refine - LSD refine mode (0=NONE, 1=STD, 2=ADV) + scale - LSD scale factor + sigma_scale - LSD sigma scale + quant - LSD quant + ang_th - LSD angle threshold in degrees + log_eps - LSD detection threshold + density_th - LSD minimal density + n_bins - LSD number of bins + return_width - whether to return line widths + return_prec - whether to return line precisions + return_nfa - whether to return line NFA + +Return: + If return_width/prec/nfa = False => Nx4 float array of line segments (x1,y1,x2,y2). + Else => dict with { + 'lines': Nx4 array, + 'width': Nx array (if return_width=True), + 'prec': Nx array (if return_prec=True), + 'nfa': Nx array (if return_nfa=True) + } +)doc" + ); #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); #else diff --git a/src/pyfsg/__init__.py b/src/pyfsg/__init__.py index 69c49c3..b9ef784 100644 --- a/src/pyfsg/__init__.py +++ b/src/pyfsg/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from ._pyfsg import __doc__, __version__, GreedyMerger +from ._pyfsg import __doc__, __version__, GreedyMerger, detectLinesOpencvLSD -__all__ = ["__doc__", "__version__", "GreedyMerger"] +__all__ = ["__doc__", "__version__", "GreedyMerger", "detectLinesOpencvLSD"] diff --git a/tests/test_opencv_lsd.py b/tests/test_opencv_lsd.py new file mode 100644 index 0000000..bb8259e --- /dev/null +++ b/tests/test_opencv_lsd.py @@ -0,0 +1,27 @@ +import pyfsg +import cv2 + +IMSHOW = False + +def test_opencv_lsd(): + # Load a grayscale image as uint8 + gray = cv2.imread("images/P1080079.jpg", cv2.IMREAD_GRAYSCALE) + + # 1) Basic usage: just get Nx4 lines + lines = pyfsg.detectLinesOpencvLSD(gray) + print("Detected lines:", lines.shape) # e.g. (150, 4) + + # color = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + # for line in lines.astype(int): + # cv2.line(color, (line[0], line[1]), (line[2], line[3]), (0, 255, 0)) + # + # cv2.imshow("Result", color.copy()) + # cv2.waitKey() + + # 2) Request optional arrays + res = pyfsg.detectLinesOpencvLSD(gray, return_width=True, return_prec=True, return_nfa=True) + + print("Detected lines shape:", res["lines"].shape) # (N,4) + print("Widths shape:", res["width"].shape) # (N,) + print("Precisions shape:", res["prec"].shape) # (N,) + print("NFA shape:", res["nfa"].shape) # (N,) From 13479ec7042cc152651efee4c9f18eac7f057879 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 13:36:21 +0100 Subject: [PATCH 08/15] Adding Opencv dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 763dfc1..1009292 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "conan >= 2.11.0", "numpy", + "opencv-python~=4.10.0" ] [project.optional-dependencies] From df767f0e3972d5149766ac833dfd545cbb11d0de Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 13:57:35 +0100 Subject: [PATCH 09/15] Fixing image-based test and adding a translation of main.cpp --- main.py | 132 +++++++++++++++++++++++++++++++++++++++ src/PYAPI.cpp | 111 ++++++++++++++++++++++++++++++++ src/pyfsg/__init__.py | 6 +- tests/test_basic.py | 36 +++++++++++ tests/test_opencv_lsd.py | 8 ++- 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..4b9b57b --- /dev/null +++ b/main.py @@ -0,0 +1,132 @@ +import cv2 +import random + +import numpy as np +import pyfsg + + +def draw_segments(img, segments, color, thickness=1, line_type=cv2.LINE_8, shift=0): + """ + Draws a list of segments onto an image. + segments is a NumPy array (N,4) or list of [x1,y1,x2,y2]. + """ + for seg in np.array(segments).astype(int): + x1, y1, x2, y2 = seg + cv2.line(img, (x1, y1), (x2, y2), color, thickness=thickness, lineType=line_type, shift=shift) + + +def draw_clusters(img, segments, clusters, + thickness=2, + color=(0, 0, 0), + draw_line_cluster=True, + line_type=cv2.LINE_AA, + shift=0): + """ + Draws each cluster in a random color (if color == (0,0,0)) or the given color. + If draw_line_cluster == True and a cluster has >1 segments, + we also draw the 'fitted' line through all of them (using totalLeastSquareFitSegmentEndPts). + """ + random_c = (color == (0, 0, 0)) + + for cluster in clusters: + # Possibly pick a random color + if random_c: + clr = random.randint(1, 255), random.randint(1, 255), random.randint(1, 255) + else: + clr = color + + if len(cluster) > 1 and draw_line_cluster: + # totalLeastSquareFitSegmentEndPts -> returns (a, b, c) for line eq or sometimes a cv::Vec3d + line_eq = pyfsg.totalLeastSquareFitSegmentEndPts(segments, cluster) + + # We'll replicate the logic of projecting each endpoint onto that line, + # tracking min & max along the x-axis of the line. + # min_p, max_p start as corners of the image: + max_p = [0, 0] + min_p = [img.shape[1], img.shape[0]] + + for idx in cluster: + x1, y1, x2, y2 = segments[idx] + # projectPointIntoLine(line_eq, (x,y)) -> returns (px,py) + p1 = pyfsg.projectPointIntoLine(line_eq, (x1, y1)) + p2 = pyfsg.projectPointIntoLine(line_eq, (x2, y2)) + + # Update max/min by comparing the x-coordinates (like in your C++ code) + if p1[0] > max_p[0]: + max_p = [p1[0], p1[1]] + if p1[0] < min_p[0]: + min_p = [p1[0], p1[1]] + if p2[0] > max_p[0]: + max_p = [p2[0], p2[1]] + if p2[0] < min_p[0]: + min_p = [p2[0], p2[1]] + + # Finally draw that line + cv2.line(img, + (int(min_p[0]), int(min_p[1])), + (int(max_p[0]), int(max_p[1])), + clr, + thickness=max(1, int(round(thickness / 3.0))), + lineType=line_type, + shift=shift) + + # Now draw the individual segments + segs_this_cluster = [segments[idx] for idx in cluster] + draw_segments(img, segs_this_cluster, clr, thickness, line_type, shift) + + +def main(): + # 1) Load the input image + img = cv2.imread("images/P1080079.jpg") + if img is None or img.size == 0: + print("Error: Cannot read input image.") + return + + # 2) Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # 3) Detect line segments with LSD + lines = pyfsg.detectLinesOpencvLSD(gray) # Nx4 float array + + print(f"Detected {lines.shape[0]} line segments with LSD.") + + # We'll draw them in red on a copy of the original image + img_detected = img.copy() + draw_segments(img_detected, lines, (255, 0, 0), thickness=1) + cv2.imwrite("Detected_line_segments.png", img_detected) + + # 4) Merge segments into clusters + # Create a GreedyMerger for the image size + h, w = gray.shape + merger = pyfsg.GreedyMerger(w, h) + + merged_segments, clusters = merger.mergeSegments(lines) # returns (Nx4 array, list-of-lists) + + # 5) Draw the clusters + img_clusters = img.copy() + draw_clusters(img_clusters, lines, clusters, thickness=2) + cv2.imwrite("Segment_groups.png", img_clusters) + + # Get large lines from groups of segments + filteredSegments, noisySegs = pyfsg.filterSegments(lines, clusters, 30.0) + + # 7) Draw the filtered (in green) vs. noisy (in red) on another copy + img_filtered = img.copy() + draw_segments(img_filtered, filteredSegments, (0, 255, 0), thickness=2) + draw_segments(img_filtered, noisySegs, (255, 0, 0), thickness=2) + cv2.imwrite("Obtained_lines.png", img_filtered) + + print("Done. Results saved to:") + print(" Detected_line_segments.png") + print(" Segment_groups.png") + print(" Obtained_lines.png") + + # If you want to visualize them on screen: + # cv2.imshow("Detected line segments", img_detected) + # cv2.imshow("Segment groups", img_clusters) + # cv2.imshow("Obtained lines", img_filtered) + # cv2.waitKey(0) + + +if __name__ == "__main__": + main() diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp index 887b1cb..d7044d0 100644 --- a/src/PYAPI.cpp +++ b/src/PYAPI.cpp @@ -186,6 +186,51 @@ static py::object detectLinesOpencvLSD( return result; } +/** + * @brief A free function that wraps totalLeastSquareFitSegmentEndPts. + * + * @param segsArr Nx4 float array of segments + * @param selected A list of segment indices to use (optional). + * @return (a, b, c) in a Python tuple + */ +static py::tuple totalLeastSquareFitSegmentEndPts_py( + const py::array_t &segsArr, + const std::vector &selected = {}) { + auto segs = ndarrayToSegments(segsArr); + cv::Vec3d lineEq = upm::totalLeastSquareFitSegmentEndPts(segs, selected); + return py::make_tuple(lineEq[0], lineEq[1], lineEq[2]); +} + +static std::pair projectPointIntoLine( + const std::array &line, // (a, b, c) + const std::array &point) // (x, y) +{ + // Wrap them in cv::Vec3f and cv::Point2f + cv::Vec3f l(line[0], line[1], line[2]); + cv::Point2f p(point[0], point[1]); + cv::Point2f projected = upm::getProjectionPtn(l, p); + return std::make_pair(projected.x, projected.y); +} + +static py::tuple filterSegments_py(const py::array_t &originalSegsArr, + const std::vector > &clusters, + double lenThres = 30.0) { + // Convert the Nx4 array -> std::vector + std::vector originalSegs = ndarrayToSegments(originalSegsArr); + + // Prepare outputs + std::vector filteredSegs; + std::vector noisySegs; + + // Call the actual C++ function + filterSegments(originalSegs, clusters, filteredSegs, noisySegs, lenThres); + + // Convert results back to NumPy arrays + py::array_t filteredArr = segmentsToNdarray(filteredSegs); + py::array_t noisyArr = segmentsToNdarray(noisySegs); + return py::make_tuple(filteredArr, noisyArr); +} + //------------------------------------------------------------------------------ // PYBIND11 MODULE DEFINITION //------------------------------------------------------------------------------ @@ -333,6 +378,72 @@ Run LSD detection in a single call. } )doc" ); + + m.def("totalLeastSquareFitSegmentEndPts", + &totalLeastSquareFitSegmentEndPts_py, + py::arg("segments"), + py::arg("selectedSegments") = std::vector{}, + R"doc( +Compute the line equation (a,b,c) using total-least-square fitting on the endpoints +of the given segments. + +Parameters +---------- +segments : numpy.ndarray + A float32 array of shape (N,4) with [x1,y1,x2,y2] for each segment. +selectedSegments : list of int, optional + Indices of which segments to include in the fitting. If empty, use all. + +Returns +------- +(a, b, c) : tuple of float + The line equation ax + by + c = 0. +)doc"); + + m.def("projectPointIntoLine", + &projectPointIntoLine, + py::arg("line"), + py::arg("point"), + R"doc( +Compute the projection of a 2D point onto the line ax + by + c = 0. + +Parameters +---------- +line : (a, b, c) + The line equation coefficients as a 3-element float array. +point : (x, y) + The 2D point to project. + +Returns +------- +projected_point : (px, py) + The coordinates of the projected point on the line. +)doc"); + + m.def("filterSegments", + &filterSegments_py, + py::arg("originalSegs"), + py::arg("clusters"), + py::arg("lenThres") = 30.0, + R"doc( +Filter line segments into 'filteredSegs' or 'noisySegs' based on length or cluster size. + +Parameters +---------- +originalSegs : numpy.ndarray + A float32 array of shape (N,4) with [x1,y1,x2,y2] for each segment. +clusters : list of list of int + A Python list of lists, each containing indices into originalSegs that form a cluster. +lenThres : float + The length threshold under which single segments are considered 'noisy'. + +Returns +------- +(filteredSegs, noisySegs) : (numpy.ndarray, numpy.ndarray) + Both arrays have shape (M,4) and (K,4) respectively, containing the filtered + and noisy segments in [x1,y1,x2,y2] format. +)doc"); + #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); #else diff --git a/src/pyfsg/__init__.py b/src/pyfsg/__init__.py index b9ef784..c46f388 100644 --- a/src/pyfsg/__init__.py +++ b/src/pyfsg/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations -from ._pyfsg import __doc__, __version__, GreedyMerger, detectLinesOpencvLSD +from ._pyfsg import (__doc__, __version__, GreedyMerger, detectLinesOpencvLSD, + totalLeastSquareFitSegmentEndPts, projectPointIntoLine, filterSegments) -__all__ = ["__doc__", "__version__", "GreedyMerger", "detectLinesOpencvLSD"] +__all__ = ["__doc__", "__version__", "GreedyMerger", "detectLinesOpencvLSD", + "totalLeastSquareFitSegmentEndPts", "projectPointIntoLine", "filterSegments"] diff --git a/tests/test_basic.py b/tests/test_basic.py index e668f69..db4fc40 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -135,3 +135,39 @@ def test_get_tangent_line_eqs(): # Quick check that they are not all zeros assert any(coeff != 0 for coeff in line1), "Line1 shouldn't be all zeros." assert any(coeff != 0 for coeff in line2), "Line2 shouldn't be all zeros." + + +def test_total_least_squares_and_project_ptn(): + # totalLeastSquareFitSegmentEndPts + segs = np.array([ + [10, 10, 20, 20], + [20, 20, 30, 30], + [50, 0, 60, 80], + ], dtype=np.float32) + + (a, b, c) = pyfsg.totalLeastSquareFitSegmentEndPts(segs) + print("Line eq for all segments: a=%.4f, b=%.4f, c=%.4f" % (a, b, c)) + + # getProjectionPtn + px, py = pyfsg.projectPointIntoLine([a, b, c], [15, 15]) + print("Projection of (15,15) on line = (%.2f, %.2f)" % (px, py)) + + +def test_filter_segments(): + # Suppose we have Nx4 segments + originalSegs = np.array([ + [10, 10, 20, 10], + [20, 10, 30, 10], + [20, 20, 40, 25], + [120, 120, 122, 125], + ], dtype=np.float32) + + clusters = [[0, 1], [2], [3]] + length_threshold = 15.0 + + filtered, noisy = pyfsg.filterSegments(originalSegs, clusters, length_threshold) + + expected_filtered = np.array([[10, 10, 30, 10], [20, 20, 40, 25]], dtype=np.float32) + expected_noisy = np.array([[120, 120, 122, 125]], dtype=np.float32) + np.testing.assert_allclose(filtered, expected_filtered) + np.testing.assert_allclose(noisy, expected_noisy) diff --git a/tests/test_opencv_lsd.py b/tests/test_opencv_lsd.py index bb8259e..f9fb22a 100644 --- a/tests/test_opencv_lsd.py +++ b/tests/test_opencv_lsd.py @@ -1,11 +1,17 @@ +from pathlib import Path + import pyfsg import cv2 IMSHOW = False + def test_opencv_lsd(): + img_path = str(Path(__file__).parent.parent / "images" / "P1080079.jpg") + print(f"Reading image from {img_path}") + # Load a grayscale image as uint8 - gray = cv2.imread("images/P1080079.jpg", cv2.IMREAD_GRAYSCALE) + gray = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 1) Basic usage: just get Nx4 lines lines = pyfsg.detectLinesOpencvLSD(gray) From 66c00d97fa9d2f2c8bb2e1592222d890d85bb22f Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 15:50:12 +0100 Subject: [PATCH 10/15] Making OpenCV dependency more relaxed --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1009292..21e971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "conan >= 2.11.0", "numpy", - "opencv-python~=4.10.0" + "opencv-python" ] [project.optional-dependencies] From 5265e63ce78ac62e8e15bcd64b972ac1ee5665f7 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 16:24:54 +0100 Subject: [PATCH 11/15] Using Pillow instead opencv for reading images --- pyproject.toml | 4 ++-- tests/test_opencv_lsd.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21e971f..3413713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["scikit-build-core-conan>=0.5.3"] +requires = ["scikit-build-core-conan>=0.5.3", "ninja"] build-backend = "scikit_build_core_conan.build" [project] @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "conan >= 2.11.0", "numpy", - "opencv-python" + "Pillow" ] [project.optional-dependencies] diff --git a/tests/test_opencv_lsd.py b/tests/test_opencv_lsd.py index f9fb22a..249e71c 100644 --- a/tests/test_opencv_lsd.py +++ b/tests/test_opencv_lsd.py @@ -1,22 +1,21 @@ from pathlib import Path import pyfsg -import cv2 - -IMSHOW = False - +import numpy as np +from PIL import Image def test_opencv_lsd(): img_path = str(Path(__file__).parent.parent / "images" / "P1080079.jpg") print(f"Reading image from {img_path}") # Load a grayscale image as uint8 - gray = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) + gray = np.asarray(Image.open(img_path).convert('L')) # 1) Basic usage: just get Nx4 lines lines = pyfsg.detectLinesOpencvLSD(gray) print("Detected lines:", lines.shape) # e.g. (150, 4) - + assert lines.ndim == 2 + assert len(lines) > 1000 # color = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) # for line in lines.astype(int): # cv2.line(color, (line[0], line[1]), (line[2], line[3]), (0, 255, 0)) @@ -26,6 +25,8 @@ def test_opencv_lsd(): # 2) Request optional arrays res = pyfsg.detectLinesOpencvLSD(gray, return_width=True, return_prec=True, return_nfa=True) + assert res["lines"].ndim == 2 + assert len(res["lines"]) > 1000 print("Detected lines shape:", res["lines"].shape) # (N,4) print("Widths shape:", res["width"].shape) # (N,) From ede3829184e4f5fb99ccd3cf87afaf63b7c69cc4 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 16:42:21 +0100 Subject: [PATCH 12/15] Fixing cast problem in some environments --- src/PYAPI.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PYAPI.cpp b/src/PYAPI.cpp index d7044d0..638c5b2 100644 --- a/src/PYAPI.cpp +++ b/src/PYAPI.cpp @@ -80,7 +80,7 @@ static Segments ndarrayToSegments(const py::array_t &arr) { static py::array_t segmentsToNdarray(const Segments &segments) { const size_t n = segments.size(); // Create a new NumPy array of shape (n, 4) - py::array::ShapeContainer arr_shape{static_cast(n), 4l}; + std::vector arr_shape = {static_cast(n), 4}; py::array_t arr(arr_shape); py::buffer_info buf = arr.request(); float *ptr = static_cast(buf.ptr); From 776c90ab559bfe05c786e48ff37e9ed742989c35 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 19:18:19 +0100 Subject: [PATCH 13/15] Moving Pillow to test dependencies --- pyproject.toml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3413713..dfbca56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["scikit-build-core-conan>=0.5.3", "ninja"] +requires = ["scikit-build-core-conan>=0.5.3", "ninja", "conan>=2.11.0"] build-backend = "scikit_build_core_conan.build" [project] @@ -25,13 +25,14 @@ classifiers = [ ] dependencies = [ - "conan >= 2.11.0", "numpy", - "Pillow" ] [project.optional-dependencies] -test = ["pytest"] +test = [ + "pytest", + "Pillow" +] [tool.scikit-build] From 18005b73d26241fec76188b2d89e4e86ab480251 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 20:03:06 +0100 Subject: [PATCH 14/15] Skipping LSD test if no pillow --- pyproject.toml | 1 - tests/test_opencv_lsd.py | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dfbca56..d78b1cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest", - "Pillow" ] diff --git a/tests/test_opencv_lsd.py b/tests/test_opencv_lsd.py index 249e71c..8677cf0 100644 --- a/tests/test_opencv_lsd.py +++ b/tests/test_opencv_lsd.py @@ -2,9 +2,27 @@ import pyfsg import numpy as np -from PIL import Image +import pytest + +def skip_if_no_pillow(test_func): + """ + Decorator that skips the test if the Pillow library is not installed. + """ + try: + import PIL + has_pillow = True + except ImportError: + has_pillow = False + return pytest.mark.skipif( + not has_pillow, + reason="Pillow (PIL) is not installed" + )(test_func) + + +@skip_if_no_pillow def test_opencv_lsd(): + from PIL import Image img_path = str(Path(__file__).parent.parent / "images" / "P1080079.jpg") print(f"Reading image from {img_path}") From 70a9fb8ea56ab6b6fd9ea20da0099402d043f969 Mon Sep 17 00:00:00 2001 From: Iago Date: Mon, 23 Dec 2024 20:22:25 +0100 Subject: [PATCH 15/15] Reducing tolerance during tests, needed by some systems. --- tests/test_greedy_merger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_greedy_merger.py b/tests/test_greedy_merger.py index 1b7a806..c1ec62b 100644 --- a/tests/test_greedy_merger.py +++ b/tests/test_greedy_merger.py @@ -100,7 +100,7 @@ def test_get_tangent_lines(): expected2 = (0.979795814, 0.200000003, 481.918335) # We'll use a small tolerance - tol = 1e-5 + tol = 1e-4 assert_line_eq(line1, expected1, tol) assert_line_eq(line2, expected2, tol) @@ -113,7 +113,7 @@ def test_get_tangent_lines(): expected1 = (0.655336857, -0.755336821, -25.0000019) expected2 = (0.755336821, -0.655336857, 25.0000019) - tol = 1e-5 + tol = 1e-4 assert_line_eq(line1, expected1, tol) assert_line_eq(line2, expected2, tol)