├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── copilot-instructions.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql.yml │ ├── codespell.yml │ ├── gitleaks.yml │ ├── issue-manager.yml │ ├── labeler.yml │ ├── publish.yml │ ├── release-please.yml │ ├── stale.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CITATION.cff ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── docs ├── api │ ├── core │ │ ├── configs.md │ │ ├── exceptions.md │ │ ├── logger.md │ │ └── types.md │ ├── datasets │ │ ├── helpers.md │ │ ├── schemas.md │ │ └── tabular.md │ ├── entities │ │ ├── images │ │ │ ├── interfaces.md │ │ │ ├── mock.md │ │ │ ├── rasterio_lib.md │ │ │ ├── spectral_lib.md │ │ │ └── spimage.md │ │ ├── imagesets.md │ │ ├── pixels.md │ │ ├── shapes │ │ │ ├── geometric_shapes.md │ │ │ └── shape.md │ │ └── signatures.md │ ├── features │ │ ├── features.md │ │ ├── helpers.md │ │ └── spectral_indices.md │ ├── optimizers │ │ ├── configs.md │ │ ├── evaluators.md │ │ ├── metrics.md │ │ ├── optimizers.md │ │ ├── parameters.md │ │ └── scorers.md │ ├── transformations │ │ ├── corregistrator.md │ │ └── image.md │ └── utils │ │ ├── image_validators.md │ │ ├── images.md │ │ ├── plots.md │ │ └── signatures.md ├── changelog.md ├── concepts │ ├── datasets.md │ ├── entities.md │ ├── images │ │ ├── entities_relations.png │ │ ├── entities_relations.svg │ │ ├── entities_schematics.drawio │ │ └── entities_schematics.svg │ ├── overview.md │ └── src │ │ ├── datasets_01.py │ │ ├── pixels_01.py │ │ ├── shapes_01.py │ │ ├── signals_01.py │ │ ├── signatures_01.py │ │ ├── spectral_image_01.py │ │ ├── spectral_image_02.py │ │ ├── spectral_image_03.py │ │ ├── spectral_image_04.py │ │ ├── spectral_image_05.py │ │ ├── spectral_image_set_01.py │ │ └── spectral_image_shapes_01.py ├── contributing.md ├── examples │ ├── case_study.md │ ├── data │ │ └── .gitkeep │ ├── external_sources.md │ ├── images │ │ ├── areas_selection.png │ │ └── pixels_selection.png │ └── src │ │ ├── spectral_image_01.py │ │ ├── spectral_image_02.py │ │ ├── spectral_imageset_01.py │ │ ├── spectral_imageset_load_01.py │ │ ├── transformations_01.py │ │ ├── transformations_02.py │ │ ├── transformations_03.py │ │ ├── visualization_01.py │ │ └── visualization_02.py ├── images │ ├── logo-text.png │ ├── logo-text.svg │ ├── logo.png │ └── logo.svg ├── index.md ├── install.md └── permit.md ├── mkdocs.yml ├── pdm.lock ├── pyproject.toml ├── scripts ├── clean.sh ├── compress-data.sh ├── format.sh ├── install-dev.sh ├── lint.sh ├── test.sh └── update-branches.sh ├── siapy-dev.code-workspace ├── siapy ├── __init__.py ├── __version__.py ├── core │ ├── __init__.py │ ├── configs.py │ ├── exceptions.py │ ├── logger.py │ └── types.py ├── datasets │ ├── __init__.py │ ├── helpers.py │ ├── schemas.py │ └── tabular.py ├── entities │ ├── __init__.py │ ├── images │ │ ├── __init__.py │ │ ├── interfaces.py │ │ ├── mock.py │ │ ├── rasterio_lib.py │ │ ├── spectral_lib.py │ │ └── spimage.py │ ├── imagesets.py │ ├── pixels.py │ ├── shapes │ │ ├── __init__.py │ │ ├── geometric_shapes.py │ │ └── shape.py │ └── signatures.py ├── features │ ├── __init__.py │ ├── features.py │ ├── helpers.py │ └── spectral_indices.py ├── optimizers │ ├── __init__.py │ ├── configs.py │ ├── evaluators.py │ ├── metrics.py │ ├── optimizers.py │ ├── parameters.py │ └── scorers.py ├── py.typed ├── transformations │ ├── __init__.py │ ├── corregistrator.py │ └── image.py └── utils │ ├── __init__.py │ ├── general.py │ ├── image_validators.py │ ├── images.py │ ├── plots.py │ └── signatures.py └── tests ├── __init__.py ├── conftest.py ├── data └── .gitkeep ├── data_manager.py ├── datasets ├── test_datasets_helpers.py ├── test_datasets_schemas.py └── test_datasets_tabular.py ├── entities ├── images │ ├── test_entities_images_mock.py │ ├── test_entities_images_rasteriolib.py │ ├── test_entities_images_spectrallib.py │ └── test_entities_images_spimage.py ├── shapes │ ├── test_entities_shapes_geometric.py │ └── test_entities_shapes_shape.py ├── test_entities_imagesets.py ├── test_entities_pixels.py └── test_entities_signatures.py ├── examples └── test_examples.py ├── features ├── test_features_features.py ├── test_features_helpers.py └── test_features_spectral_indices.py ├── optimizers ├── test_optimizers_configs.py ├── test_optimizers_evaluators.py ├── test_optimizers_optimizers.py ├── test_optimizers_parameters.py └── test_optimizers_scores.py ├── transformations ├── test_transformations_corregistrator.py └── test_transformations_image.py ├── utils.py └── utils ├── test_utils_general.py ├── test_utils_image_validators.py ├── test_utils_images.py ├── test_utils_plots.py └── test_utils_signatures.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [{*.md}] 12 | indent_style = unset 13 | indent_size = unset 14 | 15 | [*.{yml,yaml,js,json,sh}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - janezlapajne 3 | - siapy 4 | ko_fi: janezlapajne 5 | custom: buymeacoffee.com/janezlapajne 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem 4 | --- 5 | 6 | ## Description 7 | 8 | 9 | 10 | - System info 11 | - Operating system and version: 12 | - Browser and version: 13 | - Steps to reproduce: 14 | - Step 15 | - Next step 16 | 17 | 18 | 19 | ## Suggestions 20 | 21 | 22 | 23 | 24 | 25 | ## Related 26 | 27 | Relates to organization/repo#number 28 | 29 | - [ ] I have reviewed the [Guidelines for Contributing](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | ## Description 7 | 8 | 9 | 10 | 11 | 12 | ## Suggestions 13 | 14 | 15 | 16 | 17 | 18 | ## Related 19 | 20 | Relates to organization/repo#number 21 | 22 | - [ ] I have reviewed the [Guidelines for Contributing](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). 23 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # SiaPy Style Guide 2 | 3 | ## Type Hints & Python Version 4 | 5 | - Use Python 3.10+ type hints (prefer `x | y` over `Union[x, y]`) 6 | - Include type hints for all functions and class members 7 | 8 | ## Code Style 9 | 10 | - 4-space indentation (PEP 8) 11 | - No docstrings needed 12 | - Define `__all__` directly under imports 13 | 14 | ## Class Design 15 | 16 | - Use dataclasses for data-oriented classes 17 | - Use pydantic for data validation 18 | - Prefer property decorators for read-only access 19 | 20 | ## Method Naming 21 | 22 | - Properties: simple noun (e.g. `name`, `value`) 23 | - Expensive getters: prefix with `get_` (e.g. `get_statistics()`) 24 | - Constructors: prefix with `from_` (e.g. `from_point()`) 25 | - Data converters: prefix with `to_` (e.g. `to_numpy()`) 26 | - File loaders: prefix with `open_` (e.g. `open_shapefile()`) 27 | - File savers: prefix with `save_` (e.g. `save_to_csv()`) 28 | - Actions/operations: use verbs (e.g. `calculate()`, `process()`) 29 | - Batch operations: use plural nouns (e.g. `process_items()`) 30 | - Boolean queries: prefix with `is_`, `has_` or `can_` (e.g. `is_valid()`, `has_data()`) 31 | - Factory methods: prefix with `create_` (e.g. `create_instance()`) 32 | 33 | ## Class Organization 34 | 35 | 1. Dunder methods 36 | 2. Class methods 37 | 3. Properties 38 | 4. Instance methods 39 | 40 | ## Error Handling 41 | 42 | - Use custom exceptions from `siapy.core.exceptions`: 43 | - InvalidFilepathError 44 | - InvalidInputError 45 | - InvalidTypeError 46 | - ProcessingError 47 | - ConfigurationError 48 | - MethodNotImplementedError 49 | - DirectInitializationError 50 | 51 | ## Other 52 | 53 | - Use `from siapy.core import logger` for logging 54 | - Use Protocol/ABC for interfaces where appropriate 55 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | target-branch: "develop" 8 | commit-message: 9 | prefix: "chore:" 10 | open-pull-requests-limit: 10 11 | labels: 12 | - "dependencies" 13 | - "github-actions" 14 | 15 | - package-ecosystem: "pip" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | target-branch: "develop" 20 | commit-message: 21 | prefix: "chore:" 22 | open-pull-requests-limit: 10 23 | labels: 24 | - "dependencies" 25 | - "python" 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 8 | 9 | ## Checklist 10 | 11 | - [ ] I have reviewed the [Guidelines for Contributing](../CONTRIBUTING.md) and the [Code of Conduct](../CODE_OF_CONDUCT.md). 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL analysis 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 13 * * 1" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | analyze: 12 | permissions: 13 | actions: read 14 | contents: read 15 | security-events: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | - uses: github/codeql-action/init@v3 23 | with: 24 | languages: python 25 | setup-python-dependencies: false 26 | - uses: github/codeql-action/analyze@v3 27 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | # Codespell configuration is within .codespellrc 2 | --- 3 | name: Codespell 4 | 5 | on: [pull_request, push, workflow_dispatch] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | codespell: 12 | name: Check for spelling errors 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Codespell 18 | uses: codespell-project/actions-codespell@v2 19 | -------------------------------------------------------------------------------- /.github/workflows/gitleaks.yml: -------------------------------------------------------------------------------- 1 | name: Gitleaks 2 | 3 | on: [pull_request, push, workflow_dispatch] 4 | 5 | jobs: 6 | scan: 7 | name: gitleaks 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | - uses: gitleaks/gitleaks-action@v2 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE}} 17 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | - edited 10 | issues: 11 | types: 12 | - labeled 13 | workflow_dispatch: 14 | 15 | jobs: 16 | issue-manager: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: tiangolo/issue-manager@0.5.1 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | config: > 23 | { 24 | "answered": { 25 | "users": ["janezlapajne"], 26 | "delay": 864000, 27 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Issue and PR Labeler 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | issues: 7 | types: [opened, reopened] 8 | 9 | jobs: 10 | label-all-on-open: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: andymckay/labeler@1.0.4 14 | with: 15 | add-labels: "needs triage" 16 | ignore-if-labeled: false 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI and Deploy Docs 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - main 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | inputs: 11 | manual_publish: 12 | description: "Run publish and deploy manually" 13 | type: boolean 14 | default: false 15 | required: true 16 | 17 | jobs: 18 | check-pr-title: 19 | if: | 20 | github.event_name == 'pull_request_target' && 21 | github.event.pull_request.merged == true || 22 | github.event_name == 'workflow_dispatch' && 23 | github.event.inputs.manual_publish == 'true' 24 | runs-on: ubuntu-latest 25 | outputs: 26 | is_chore_main_release: ${{ steps.check_title.outputs.is_chore_main_release }} 27 | steps: 28 | - name: Check PR title 29 | id: check_title 30 | run: | 31 | if [[ "${{ github.event.pull_request.title }}" == "chore(main): release"* ]]; then 32 | echo "is_chore_main_release=true" >> $GITHUB_OUTPUT 33 | else 34 | echo "is_chore_main_release=false" >> $GITHUB_OUTPUT 35 | fi 36 | 37 | pypi-publish: 38 | needs: check-pr-title 39 | if: | 40 | needs.check-pr-title.outputs.is_chore_main_release == 'true' || 41 | github.event.inputs.manual_publish == 'true' 42 | name: Upload release to PyPI 43 | runs-on: ubuntu-latest 44 | permissions: 45 | contents: read 46 | id-token: write 47 | outputs: 48 | published: ${{ steps.publish.outputs.published }} 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: pdm-project/setup-pdm@v4 52 | with: 53 | python-version: "3.12" 54 | cache: true 55 | - name: Publish package distributions to PyPI 56 | run: pdm publish 57 | 58 | deploy-docs: 59 | needs: pypi-publish 60 | name: Deploy MkDocs 61 | runs-on: ubuntu-latest 62 | permissions: 63 | contents: write 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v4 67 | - name: Setup PDM 68 | uses: pdm-project/setup-pdm@v4 69 | with: 70 | python-version: "3.12" 71 | cache: true 72 | - name: Install dependencies 73 | run: pdm install -G docs 74 | - name: Build MkDocs site 75 | run: make generate-docs 76 | - name: Configure Git Credentials 77 | run: | 78 | git config user.name github-actions[bot] 79 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 80 | - name: Fetch gh-pages branch 81 | run: | 82 | git fetch origin gh-pages:gh-pages || true 83 | - name: Deploy docs 84 | run: | 85 | pdm run mike deploy --push --update-aliases $(pdm show --version) latest 86 | pdm run mike set-default --push latest 87 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Auto release maker 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - main 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | inputs: 11 | number: 12 | description: PR number 13 | required: true 14 | debug_enabled: 15 | type: boolean 16 | description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)" 17 | required: false 18 | default: false 19 | 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | 24 | jobs: 25 | release-please: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Setup tmate session 29 | uses: mxschmitt/action-tmate@v3 30 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 31 | - uses: googleapis/release-please-action@v4 32 | with: 33 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 34 | release-type: python 35 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | 6 | jobs: 7 | stale: 8 | name: Stale 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | repo-token: ${{ secrets.GITHUB_TOKEN }} 14 | stale-issue-message: "Issue is stale and will be closed in 14 days unless there is new activity." 15 | stale-pr-message: "PR is stale and will be closed in 14 days unless there is new activity." 16 | stale-issue-label: "stale" 17 | exempt-issue-labels: "stale-exempt,kind/feature-request" 18 | stale-pr-label: "stale" 19 | exempt-pr-labels: "stale-exempt" 20 | remove-stale-when-updated: "True" 21 | operations-per-run: 500 22 | days-before-stale: 180 23 | days-before-close: 14 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [develop, main] 6 | pull_request: 7 | branches: [develop, main] 8 | types: 9 | - opened 10 | - synchronize 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.10", "3.11", "3.12"] 17 | fail-fast: false 18 | 19 | name: Test Python ${{ matrix.python-version }} 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Setup PDM 24 | uses: pdm-project/setup-pdm@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | cache: true 28 | - name: Install dependencies 29 | run: pdm install 30 | - name: Lint code 31 | run: ./scripts/lint.sh 32 | - name: Run tests with coverage report 33 | # run: pdm run pytest --cov=./ --cov-report=html -m "not manual" 34 | run: ./scripts/test.sh "Coverage for ${{ github.sha }}" 35 | - name: Store coverage files 36 | uses: actions/upload-artifact@v4 37 | # Only upload for Python 3.12 38 | if: matrix.python-version == '3.12' 39 | with: 40 | name: coverage-html 41 | path: htmlcov 42 | 43 | # https://github.com/marketplace/actions/alls-green#why 44 | alls-green: # This job does nothing and is only used for the branch protection 45 | if: always() 46 | needs: 47 | - test 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Decide whether the needed jobs succeeded or failed 51 | uses: re-actors/alls-green@release/v1 52 | with: 53 | jobs: ${{ toJSON(needs) }} 54 | 55 | smokeshow: 56 | # if: ${{ github.event.workflow_run.conclusion == 'success' }} 57 | needs: 58 | - test 59 | runs-on: ubuntu-latest 60 | permissions: 61 | actions: read 62 | statuses: write 63 | 64 | steps: 65 | - uses: actions/setup-python@v5 66 | with: 67 | python-version: "3.9" 68 | - run: pip install smokeshow 69 | - uses: actions/download-artifact@v4 70 | with: 71 | name: coverage-html 72 | path: htmlcov 73 | github-token: ${{ secrets.GITHUB_TOKEN }} 74 | # run-id: ${{ github.event.workflow_run.id }} 75 | - run: smokeshow upload htmlcov 76 | env: 77 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} 78 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 50 79 | SMOKESHOW_GITHUB_CONTEXT: coverage 80 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 82 | SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv 3 | .venv 4 | .idea 5 | .ruff_cache 6 | .ipynb_checkpoints 7 | .mypy_cache 8 | .pdm-build 9 | .pytest_cache 10 | .pdm-python 11 | .coverage 12 | .vscode 13 | .history 14 | site 15 | dist 16 | tests/data/* 17 | docs/examples/data/* 18 | !.gitkeep 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: python3.10 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-toml 11 | - id: check-yaml 12 | args: 13 | - --unsafe 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.11.6 18 | hooks: 19 | - id: ruff 20 | args: 21 | - --fix 22 | - --config=pyproject.toml 23 | files: ^(siapy|tests)/ 24 | - id: ruff-format 25 | - repo: https://github.com/gitleaks/gitleaks 26 | rev: v8.24.3 27 | hooks: 28 | - id: gitleaks 29 | - repo: https://github.com/codespell-project/codespell 30 | rev: v2.4.1 31 | hooks: 32 | - id: codespell 33 | additional_dependencies: 34 | - tomli 35 | - repo: https://github.com/compilerla/conventional-pre-commit 36 | rev: v4.0.0 37 | hooks: 38 | - id: conventional-pre-commit 39 | stages: [commit-msg] 40 | args: 41 | [ 42 | --strict, 43 | feat, 44 | fix, 45 | perf, 46 | deps, 47 | revert, 48 | docs, 49 | style, 50 | chore, 51 | refactor, 52 | test, 53 | build, 54 | ci, 55 | ] 56 | ci: 57 | autofix_commit_msg: "chore: [pre-commit.ci] Auto format from pre-commit.com hooks" 58 | autoupdate_commit_msg: "chore: [pre-commit.ci] pre-commit autoupdate" 59 | autoupdate_branch: "develop" 60 | skip: [codespell] 61 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # This CITATION.cff file was generated with cffinit. 2 | # Visit https://bit.ly/cffinit to generate yours today! 3 | 4 | cff-version: 1.2.0 5 | title: SiaPy 6 | message: >- 7 | If you use this software, please cite it using the 8 | metadata from this file. 9 | type: software 10 | authors: 11 | - given-names: Janez 12 | family-names: Lapajne 13 | email: janez.lapajne@kis.si 14 | orcid: "https://orcid.org/0000-0003-2609-700X" 15 | identifiers: 16 | - type: url 17 | value: "https://doi.org/10.3920/978-90-8686-947-3_54" 18 | description: Conference paper 19 | - type: url 20 | value: "https://zenodo.org/doi/10.5281/zenodo.7409193" 21 | description: Zenodo repository 22 | repository-code: "https://github.com/siapy/siapy-lib" 23 | abstract: >- 24 | A tool for efficient processing of spectral images with 25 | Python. 26 | keywords: 27 | - siapy 28 | - spectral imaging analysis 29 | - python 30 | license: MIT 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 SiaPy, Agricultural institute of Slovenia 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. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: .pdm ## Check that PDM is installed 2 | .pdm: 3 | @pdm -V || echo 'Please install PDM: https://pdm.fming.dev/latest/#installation' 4 | 5 | .PHONY: .pre-commit ## Check that pre-commit is installed 6 | .pre-commit: 7 | @pdm run pre-commit -V || echo 'Please install pre-commit: https://pre-commit.com/' 8 | 9 | .PHONY: install ## Install the package, dependencies, and pre-commit for local development 10 | install: 11 | ./scripts/install-dev.sh 12 | 13 | .PHONY: refresh-lockfiles ## Sync lockfiles with requirements files. 14 | refresh-lockfiles: .pdm 15 | pdm update --update-reuse --group :all 16 | 17 | .PHONY: rebuild-lockfiles ## Rebuild lockfiles from scratch, updating all dependencies 18 | rebuild-lockfiles: .pdm 19 | pdm update --update-eager --group :all 20 | 21 | .PHONY: format ## Auto-format python source files 22 | format: .pdm 23 | ./scripts/format.sh 24 | 25 | .PHONY: lint ## Lint python source files 26 | lint: .pdm 27 | ./scripts/lint.sh 28 | 29 | .PHONY: test ## Run all tests 30 | test: .pdm 31 | ./scripts/test.sh "Develop" 32 | 33 | .PHONY: flt ## Run format, lint, and test 34 | flt: format lint test 35 | 36 | .PHONY: codespell ## Use Codespell to do spellchecking 37 | codespell: .pre-commit 38 | pdm run pre-commit run codespell --all-files 39 | 40 | .PHONY: testcov ## Run tests and generate a coverage report 41 | testcov: test 42 | @echo "building coverage html" 43 | @pdm run coverage html 44 | @echo "building coverage lcov" 45 | @pdm run coverage lcov 46 | 47 | .PHONY: update-branches ## Update local git branches after successful PR to develop or main branches 48 | update-branches: 49 | ./scripts/update-branches.sh 50 | 51 | .PHONY: clean ## Clear local caches and build artifacts 52 | clean: 53 | ./scripts/clean.sh 54 | 55 | .PHONY: generate-docs ## Generate the docs 56 | generate-docs: 57 | pdm run mkdocs build --strict 58 | 59 | .PHONY: serve-docs ## Serve the docs 60 | serve-docs: 61 | pdm run mkdocs serve 62 | 63 | .PHONY: serve-docs-mike ## Serve the docs using mike 64 | serve-docs-mike: 65 | pdm run mike serve 66 | 67 | .PHONY: version ## Check project version 68 | version: 69 | python -c "import siapy; print(siapy.__version__)" 70 | 71 | .PHONY: compress-data ## Compress the data files 72 | compress-data: 73 | ./scripts/compress-data.sh $(version) 74 | 75 | .PHONY: help ## Display this message 76 | help: 77 | @grep -E \ 78 | '^.PHONY: .*?## .*$$' $(MAKEFILE_LIST) | \ 79 | sort | \ 80 | awk 'BEGIN {FS = ".PHONY: |## "}; {printf "\033[36m%-19s\033[0m %s\n", $$2, $$3}' 81 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is very important for SiaPy and its community. 🔒 4 | 5 | Learn more about it below. 👇 6 | 7 | ## Versions 8 | 9 | The latest version of SiaPy is supported. 10 | 11 | You are encouraged to update your SiaPy version frequently. This way you will benefit from the latest features, bug fixes, and **security fixes**. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | We take the security of our project seriously. If you have discovered a security vulnerability, we appreciate your cooperation in disclosing it to us in a responsible manner. 16 | 17 | Please report any security vulnerabilities by emailing us at [janez.lapajne@kis.si](janez.lapajne@kis.si). 18 | 19 | We will acknowledge receipt of your vulnerability report, assess it for validity and severity, and decide on the next steps. We ask that you do not publicly disclose the vulnerability until we have had a chance to address it. 20 | 21 | ## What to include in your report 22 | 23 | To help us triage and prioritize the issue, please include as much information as possible, such as: 24 | 25 | - The version of our project you are using 26 | - A step-by-step description of how to reproduce the vulnerability 27 | - Any relevant logs or output 28 | - Any other information you think might be relevant 29 | 30 | ## Public Discussions 31 | 32 | Please refrain from publicly discussing a potential security vulnerability. 33 | 34 | Discussing vulnerabilities in public forums before they are properly assessed and fixed can significantly increase the risk to the project and its users. It's better to discuss issues privately and work together to find a solution first, to limit the potential impact as much as possible. We appreciate your cooperation and understanding in handling sensitive matters with discretion. 35 | 36 | --- 37 | 38 | Thank you for helping to keep SiaPy and its users safe. 🏅 39 | -------------------------------------------------------------------------------- /docs/api/core/configs.md: -------------------------------------------------------------------------------- 1 | 2 | ::: siapy.core.configs 3 | -------------------------------------------------------------------------------- /docs/api/core/exceptions.md: -------------------------------------------------------------------------------- 1 | 2 | ::: siapy.core.exceptions 3 | -------------------------------------------------------------------------------- /docs/api/core/logger.md: -------------------------------------------------------------------------------- 1 | ::: siapy.core.logger 2 | -------------------------------------------------------------------------------- /docs/api/core/types.md: -------------------------------------------------------------------------------- 1 | 2 | ::: siapy.core.types 3 | -------------------------------------------------------------------------------- /docs/api/datasets/helpers.md: -------------------------------------------------------------------------------- 1 | ::: siapy.datasets.helpers 2 | -------------------------------------------------------------------------------- /docs/api/datasets/schemas.md: -------------------------------------------------------------------------------- 1 | ::: siapy.datasets.schemas 2 | -------------------------------------------------------------------------------- /docs/api/datasets/tabular.md: -------------------------------------------------------------------------------- 1 | ::: siapy.datasets.tabular 2 | -------------------------------------------------------------------------------- /docs/api/entities/images/interfaces.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.images.interfaces 2 | -------------------------------------------------------------------------------- /docs/api/entities/images/mock.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.images.mock 2 | -------------------------------------------------------------------------------- /docs/api/entities/images/rasterio_lib.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.images.rasterio_lib 2 | -------------------------------------------------------------------------------- /docs/api/entities/images/spectral_lib.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.images.spectral_lib 2 | -------------------------------------------------------------------------------- /docs/api/entities/images/spimage.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.images.spimage 2 | -------------------------------------------------------------------------------- /docs/api/entities/imagesets.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.imagesets 2 | -------------------------------------------------------------------------------- /docs/api/entities/pixels.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.pixels 2 | -------------------------------------------------------------------------------- /docs/api/entities/shapes/geometric_shapes.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.shapes.geometric_shapes 2 | -------------------------------------------------------------------------------- /docs/api/entities/shapes/shape.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.shapes.shape 2 | -------------------------------------------------------------------------------- /docs/api/entities/signatures.md: -------------------------------------------------------------------------------- 1 | ::: siapy.entities.signatures 2 | -------------------------------------------------------------------------------- /docs/api/features/features.md: -------------------------------------------------------------------------------- 1 | ::: siapy.features.features 2 | -------------------------------------------------------------------------------- /docs/api/features/helpers.md: -------------------------------------------------------------------------------- 1 | ::: siapy.features.helpers 2 | -------------------------------------------------------------------------------- /docs/api/features/spectral_indices.md: -------------------------------------------------------------------------------- 1 | ::: siapy.features.spectral_indices 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/configs.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.configs 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/evaluators.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.evaluators 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/metrics.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.metrics 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/optimizers.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.optimizers 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/parameters.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.parameters 2 | -------------------------------------------------------------------------------- /docs/api/optimizers/scorers.md: -------------------------------------------------------------------------------- 1 | ::: siapy.optimizers.scorers 2 | -------------------------------------------------------------------------------- /docs/api/transformations/corregistrator.md: -------------------------------------------------------------------------------- 1 | ::: siapy.transformations.corregistrator 2 | -------------------------------------------------------------------------------- /docs/api/transformations/image.md: -------------------------------------------------------------------------------- 1 | ::: siapy.transformations.image 2 | -------------------------------------------------------------------------------- /docs/api/utils/image_validators.md: -------------------------------------------------------------------------------- 1 | ::: siapy.utils.image_validators 2 | -------------------------------------------------------------------------------- /docs/api/utils/images.md: -------------------------------------------------------------------------------- 1 | ::: siapy.utils.images 2 | -------------------------------------------------------------------------------- /docs/api/utils/plots.md: -------------------------------------------------------------------------------- 1 | ::: siapy.utils.plots 2 | -------------------------------------------------------------------------------- /docs/api/utils/signatures.md: -------------------------------------------------------------------------------- 1 | ::: siapy.utils.signatures 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | {{ external_markdown('https://raw.githubusercontent.com/siapy/siapy-lib/main/CHANGELOG.md', '') }} 2 | -------------------------------------------------------------------------------- /docs/concepts/datasets.md: -------------------------------------------------------------------------------- 1 | # Datasets 2 | 3 | ??? note "API Documentation" 4 | `siapy.datasets` 5 | 6 | The `datasets` module provides structured containers and utilities for transforming spectral image data into formats optimized for analysis and machine learning. It bridges the gap between raw spectral data and analytical workflows. 7 | 8 | ```python 9 | --8<-- "docs/concepts/src/datasets_01.py" 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/concepts/images/entities_relations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/concepts/images/entities_relations.png -------------------------------------------------------------------------------- /docs/concepts/overview.md: -------------------------------------------------------------------------------- 1 | # Library overview 2 | 3 | ## API design principles 4 | 5 | SiaPy follows consistent method naming conventions to make the API intuitive and predictable. Understanding these conventions helps you navigate the library more effectively and write code that aligns with the project's style. 6 | 7 | **Simple properties**: Noun form for direct attribute access 8 | 9 | - Examples: `image.geometric_shapes`, `pixels.df` 10 | - When to use: For quick, computationally inexpensive property access 11 | 12 | **Expensive computations**: Prefixed with `get_` for methods that require significant processing 13 | 14 | - Examples: `pixels.get_coordinate()` 15 | - When to use: When the operation involves complex calculations or data retrieval 16 | 17 | **Alternative constructors**: Prefixed with `from_` for methods that create objects from different data sources 18 | 19 | - Examples: `from_numpy()`, `from_dataframe()`, `from_shapefile()` 20 | - When to use: When creating an object from an existing data structure 21 | 22 | **Data converters**: Prefixed with `to_` for methods that transform data to another format 23 | 24 | - Examples: `to_numpy()`, `to_dataframe()`, `to_geojson()` 25 | - When to use: When exporting data to another representation 26 | 27 | **File operations**: 28 | 29 | Prefixed with `open_` for reading data from file 30 | 31 | - Examples: `open_envi()`, `open_shapefile()`, `open_csv()` 32 | 33 | Prefixed with `save_` for writing data to file 34 | 35 | - Examples: `save_to_csv()`, `save_to_geotiff()`, `save_as_json()` 36 | 37 | **Actions and processing**: 38 | 39 | Verbs for operations that modify data or perform calculations 40 | 41 | - Examples: `normalize()`, `calculate_ndvi()`, `extract_features()` 42 | 43 | Plural forms for batch operations on multiple items 44 | 45 | - Examples: `process_images()`, `extract_signatures()`, `calculate_indices()` 46 | 47 | **Boolean queries**: Prefixed with `is_`, `has_`, or `can_` for methods returning boolean values 48 | 49 | - Examples: `is_valid()`, `has_metadata()`, `can_transform()` 50 | - When to use: For methods that check conditions or properties 51 | 52 | **Factory methods**: Prefixed with `create_` for methods that generate new instances 53 | 54 | - Examples: `create_mask()`, `create_subset()`, `create_transformer()` 55 | - When to use: When creating new objects based on specific parameters 56 | 57 | ## Architecture 58 | 59 | SiaPy follows a modular architecture organized around key components that work together to provide a comprehensive toolkit for spectral image analysis. 60 | 61 | ### Core 62 | 63 | ??? note "API Documentation" 64 | `siapy.core` 65 | 66 | The foundation of the library providing essential functionality: 67 | 68 | - **Logging**: Centralized logging functionality 69 | - **Exception handling**: Custom exceptions for consistent error handling 70 | - **Type definitions**: Common types used throughout the library 71 | - **Configuration**: System paths and global configuration settings 72 | 73 | ### Entities 74 | 75 | ??? note "API Documentation" 76 | `siapy.entities` 77 | 78 | Fundamental data structures that represent spectral imaging data: 79 | 80 | - **Spectral image**: An abstraction for various image formats 81 | - **Spectral image set**: Collection of spectral images with batch operations 82 | - **Pixels**: Representation of pixel coordinates and groups 83 | - **Shapes**: Geometric shapes for images' regions selection and masking 84 | - **Signatures**: Spectral signatures extracted from images 85 | 86 | ### Datasets 87 | 88 | ??? note "API Documentation" 89 | `siapy.datasets` 90 | 91 | Tools for managing and working with datasets: 92 | 93 | - **Tabular datasets**: Handling tabular data with spectral information 94 | 95 | ### Features 96 | 97 | ??? note "API Documentation" 98 | `siapy.features` 99 | 100 | Functionality for working with spectral features: 101 | 102 | - **Features**: Abstractions for feature extraction and selection 103 | - **Spectral indices**: Calculation of various spectral indices 104 | 105 | ### Transformations 106 | 107 | ??? note "API Documentation" 108 | `siapy.transformations` 109 | 110 | Transformation capabilities: 111 | 112 | - **Co-registration**: Aligning images from different sources 113 | - **Image processing**: Functions for image manipulation 114 | 115 | ### Optimizers 116 | 117 | ??? note "API Documentation" 118 | `siapy.optimizers` 119 | 120 | Optimization, hyperparameter tuning and evaluation: 121 | 122 | - **Optimization**: Machine learning training and optimization of hyperparameters 123 | - **Evaluation metrics and scoring mechanisms**: Tools for assessing model performance 124 | 125 | ### Utils 126 | 127 | ??? note "API Documentation" 128 | `siapy.utils` 129 | 130 | Utility and plotting functions: 131 | 132 | - **Plotting**: Visualization tools for spectral data 133 | - **Image utilities**: Helper functions for image processing 134 | - **Signature utilities**: Functions for working with spectral signatures 135 | -------------------------------------------------------------------------------- /docs/concepts/src/datasets_01.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from rich import print 3 | 4 | from siapy.datasets.tabular import TabularDataset 5 | from siapy.entities import Shape, SpectralImage, SpectralImageSet 6 | 7 | # Create two mock spectral images with dimensions 100x100 pixels and 10 bands 8 | rng = np.random.default_rng(seed=42) 9 | image1 = SpectralImage.from_numpy(rng.random((100, 100, 10))) 10 | image2 = SpectralImage.from_numpy(rng.random((100, 100, 10))) 11 | 12 | # Define a region of interest as a rectangular shape (coordinates in pixel space) 13 | # This will select pixels from position (50,50) to (80,80) in both images 14 | rectangle = Shape.from_rectangle(x_min=50, y_min=50, x_max=80, y_max=80) 15 | image1.geometric_shapes.append(rectangle) 16 | image2.geometric_shapes.append(rectangle) 17 | 18 | # Combine the images into a SpectralImageSet for batch processing 19 | image_set = SpectralImageSet([image1, image2]) 20 | 21 | # Initialize the TabularDataset with our image set and process the data 22 | # This extracts pixel data from the regions defined by our shapes 23 | dataset = TabularDataset(image_set) 24 | dataset.process_image_data() 25 | 26 | # The dataset is now iterable - we can access each entity (processed region) 27 | for entity in dataset: 28 | print(f"Processing entity: {entity}") 29 | 30 | # Generate tabular data from our processed dataset 31 | # Setting mean_signatures=False preserves individual pixel values instead of averaging them 32 | dataset_data = dataset.generate_dataset_data(mean_signatures=False) 33 | 34 | # Convert the tabular data to a pandas DataFrame with multi-level indexing 35 | # This makes it easy to analyze and manipulate the data 36 | df = dataset_data.to_dataframe_multiindex() 37 | 38 | print(f"dataset_data: \n{dataset_data}") 39 | print(f"df: \n{df}") 40 | -------------------------------------------------------------------------------- /docs/concepts/src/pixels_01.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from siapy.entities import Pixels 5 | 6 | # Create from pandas DataFrame 7 | pixels1 = Pixels(pd.DataFrame({"x": [10, 20, 30], "y": [40, 50, 60]})) 8 | 9 | # Create from numpy array 10 | pixels2 = Pixels.from_iterable(np.array([[10, 40], [20, 50], [30, 60]])) 11 | 12 | # Create from list of coordinates 13 | pixels3 = Pixels.from_iterable([(10, 40), (20, 50), (30, 60)]) 14 | 15 | # Should be the same 16 | assert pixels1 == pixels2 == pixels3 17 | -------------------------------------------------------------------------------- /docs/concepts/src/shapes_01.py: -------------------------------------------------------------------------------- 1 | from siapy.entities import Pixels, Shape 2 | 3 | # Create a point 4 | point = Shape.from_point(10, 20) 5 | 6 | # Create a polygon from pixels 7 | pixels = Pixels.from_iterable([(0, 0), (10, 0), (10, 10), (0, 10)]) 8 | polygon = Shape.from_polygon(pixels) 9 | 10 | # Load from shapefile 11 | shape = Shape.open_shapefile("path/to/shapefile.shp") 12 | -------------------------------------------------------------------------------- /docs/concepts/src/signals_01.py: -------------------------------------------------------------------------------- 1 | from siapy.entities.signatures import Signals 2 | 3 | # Create from iterable (e.g. list, array) 4 | signals = Signals.from_iterable([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) 5 | -------------------------------------------------------------------------------- /docs/concepts/src/signatures_01.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:long] 2 | import pandas as pd 3 | from rich import print 4 | 5 | from siapy.entities import Pixels, Signatures 6 | from siapy.entities.signatures import Signals 7 | 8 | # Option 1: Step-by-step initialization 9 | # Initialize Pixels object from a DataFrame 10 | pixels_df = pd.DataFrame({"x": [10, 30], "y": [20, 40]}) 11 | pixels = Pixels(pixels_df) 12 | 13 | # Initialize Signals object from a DataFrame 14 | signals_df = pd.DataFrame([[1, 2, 3], [4, 5, 6]]) 15 | signals = Signals(signals_df) 16 | 17 | # Create Signatures object from Pixels and Signals objects 18 | signatures1 = Signatures(pixels, signals) 19 | # --8<-- [end:long] 20 | 21 | # --8<-- [start:short] 22 | # Option 2: Direct initialization with raw data 23 | # Initialize Signatures directly from raw signals and coordinates data 24 | signatures2 = Signatures.from_signals_and_pixels( 25 | signals=[[1, 2, 3], [4, 5, 6]], 26 | pixels=[[10, 20], [30, 40]], 27 | ) 28 | # --8<-- [end:short] 29 | 30 | # --8<-- [start:assert] 31 | # Verify that both approaches produce equivalent results 32 | assert signatures1 == signatures2 33 | 34 | # Print the DataFrame representation of Pixels and Signals 35 | df_multi = signatures2.to_dataframe_multiindex() 36 | print(f"MultiIndex DataFrame:\n{df_multi}") 37 | print(f"Signals DataFrame:\n{signatures2.signals.df}") 38 | print(f"Pixels DataFrame:\n{signatures2.pixels.df}") 39 | # --8<-- [end:assert] 40 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_01.py: -------------------------------------------------------------------------------- 1 | from siapy.entities import SpectralImage 2 | from siapy.entities.images import SpectralLibImage 3 | 4 | # Load from ENVI format (uses spectral python library) 5 | image_sp = SpectralLibImage.open( 6 | header_path="path/to/header.hdr", 7 | image_path="path/to/image.img", 8 | ) 9 | image = SpectralImage(image_sp) 10 | 11 | # Or you can use the class method to load the image directly 12 | image = SpectralImage.spy_open( 13 | header_path="path/to/header.hdr", 14 | image_path="path/to/image.img", 15 | ) 16 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_02.py: -------------------------------------------------------------------------------- 1 | from siapy.entities import SpectralImage 2 | from siapy.entities.images import RasterioLibImage 3 | 4 | # Load from GeoTIFF or other raster formats (uses rioxarray/rasterio python library) 5 | image_rio = RasterioLibImage.open( 6 | filepath="path/to/image.tif", 7 | ) 8 | image = SpectralImage(image_rio) 9 | 10 | # Or you can use the class method to load the image directly 11 | image = SpectralImage.rasterio_open(filepath="path/to/image.tif") 12 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_03.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from siapy.entities import SpectralImage 4 | 5 | # Create a SpectralImage from a numpy array - mostly for testing 6 | array = np.zeros((100, 100, 10)) # height, width, bands 7 | image = SpectralImage.from_numpy(array) 8 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_04.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Any, cast 3 | 4 | import numpy as np 5 | import xarray as xr 6 | from numpy.typing import NDArray 7 | from PIL import Image 8 | 9 | from siapy.core import logger 10 | from siapy.core.exceptions import InvalidFilepathError 11 | from siapy.entities import SpectralImage 12 | from siapy.entities.images import ImageBase 13 | 14 | if TYPE_CHECKING: 15 | from siapy.core.types import XarrayType 16 | 17 | 18 | class MyImage(ImageBase): 19 | """ 20 | # Create your own image class by extending ImageBase 21 | # This example demonstrates how to implement a custom image loader 22 | """ 23 | 24 | def __init__(self, data: NDArray[np.floating[Any]], file_path: Path) -> None: 25 | self._data = data 26 | self._filepath = file_path 27 | 28 | # Define metadata with required fields: 29 | # - camera_id: unique identifier for the imaging device 30 | # - wavelengths: list of spectral band centers in nanometers 31 | # - default_bands: which bands to use for RGB visualization 32 | self._meta: dict[str, Any] = { 33 | "camera_id": "my_camera", 34 | "wavelengths": [450.0, 550.0, 650.0], # RGB wavelengths in nm 35 | "default_bands": [0, 1, 2], # Band indices for RGB display 36 | } 37 | 38 | @classmethod 39 | def open(cls, filepath: str) -> "MyImage": 40 | """Load an image from a file path""" 41 | path = Path(filepath) 42 | if not path.exists(): 43 | raise InvalidFilepathError(f"File not found: {filepath}") 44 | 45 | try: 46 | # This is a simplified example - in a real implementation, 47 | # you would read the actual image data using an appropriate library 48 | # For example purposes, creating a small random array 49 | data = np.random.random((100, 100, 3)).astype(np.float32) 50 | return cls(data, path) 51 | except Exception as e: 52 | raise InvalidFilepathError(f"Failed to open {filepath}: {str(e)}") 53 | 54 | # Required properties (all must be implemented) 55 | 56 | @property 57 | def filepath(self) -> Path: 58 | """Path to the source file""" 59 | return self._filepath 60 | 61 | @property 62 | def metadata(self) -> dict[str, Any]: 63 | """Image metadata dictionary""" 64 | return self._meta 65 | 66 | @property 67 | def shape(self) -> tuple[int, int, int]: 68 | """Image dimensions as (height, width, bands)""" 69 | return cast(tuple[int, int, int], self._data.shape) 70 | 71 | @property 72 | def bands(self) -> int: 73 | """Number of spectral bands""" 74 | return self.shape[2] 75 | 76 | @property 77 | def default_bands(self) -> list[int]: 78 | """Indices of bands to use for RGB visualization""" 79 | return self._meta["default_bands"] 80 | 81 | @property 82 | def wavelengths(self) -> list[float]: 83 | """Center wavelengths of each band in nanometers""" 84 | return self._meta["wavelengths"] 85 | 86 | @property 87 | def camera_id(self) -> str: 88 | """Unique identifier for the imaging device""" 89 | return self._meta["camera_id"] 90 | 91 | # Required methods (all must be implemented) 92 | 93 | def to_display(self, equalize: bool = True) -> Image.Image: 94 | """Convert to PIL Image for display""" 95 | # Extract the default bands for RGB visualization 96 | rgb_data = self._data[:, :, self.default_bands].copy() 97 | 98 | if equalize: 99 | # Apply linear contrast stretching to each band 100 | for i in range(rgb_data.shape[2]): 101 | band = rgb_data[:, :, i] 102 | min_val = np.min(band) 103 | max_val = np.max(band) 104 | if max_val > min_val: 105 | rgb_data[:, :, i] = (band - min_val) / (max_val - min_val) 106 | 107 | # Convert to 8-bit for PIL 108 | rgb_uint8 = (rgb_data * 255).astype(np.uint8) 109 | return Image.fromarray(rgb_uint8) 110 | 111 | def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]: 112 | """Convert to numpy array""" 113 | result = self._data.copy() 114 | if nan_value is not None: 115 | result[np.isnan(result)] = nan_value 116 | return result 117 | 118 | def to_xarray(self) -> "XarrayType": 119 | """Convert to xarray DataArray with coordinates""" 120 | return xr.DataArray( 121 | self._data, 122 | dims=["y", "x", "band"], 123 | coords={ 124 | "y": np.arange(self.shape[0]), 125 | "x": np.arange(self.shape[1]), 126 | "band": self.wavelengths, 127 | }, 128 | attrs=self.metadata, 129 | ) 130 | 131 | 132 | # Example: Using your custom image class with SiaPy 133 | # 1. Create an instance of your custom image class 134 | custom_image = MyImage.open("path/to/your/image.dat") 135 | 136 | # 2. Wrap it in a SpectralImage for use with SiaPy's analysis tools 137 | spectral_image = SpectralImage(custom_image) 138 | 139 | # 3. Now you can use all SiaPy functionality 140 | # spectral_image.to_signatures(pixels) 141 | # etc. 142 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_05.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from rich import print 3 | 4 | from siapy.entities import Pixels, SpectralImage 5 | 6 | # Create mock image 7 | rng = np.random.default_rng(seed=42) 8 | array = rng.random((100, 100, 10)) # height, width, bands 9 | image = SpectralImage.from_numpy(array) 10 | 11 | # Define pixels 12 | iterable = [(1, 2), (3, 4), (2, 4)] 13 | pixels = Pixels.from_iterable(iterable) 14 | 15 | # Get signatures 16 | signatures = image.to_signatures(pixels) 17 | print(f"Signatures:\n{signatures}") 18 | 19 | # Get numpy array 20 | subarray = image.to_subarray(pixels) 21 | print(f"Subarray:\n{subarray}") 22 | """ 23 | ? Note: 24 | The extracted block has shape (3, 3, 10): a 3 × 3 pixel window across 10 spectral bands. 25 | Values are populated only at the requested pixel coordinates; all other elements are set to NaN. 26 | """ 27 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_set_01.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImageSet 4 | 5 | # Load multiple images 6 | header_paths = list(Path("data_dir").glob("*.hdr")) 7 | image_paths = list(Path("data_dir").glob("*.img")) 8 | image_set = SpectralImageSet.spy_open(header_paths=header_paths, image_paths=image_paths) 9 | 10 | # Iterate over the images 11 | for image in image_set: 12 | print(image) 13 | -------------------------------------------------------------------------------- /docs/concepts/src/spectral_image_shapes_01.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:init] 2 | import numpy as np 3 | from rich import print 4 | 5 | from siapy.entities import Shape, SpectralImage 6 | from siapy.entities.shapes import GeometricShapes 7 | 8 | # Create a mock spectral image 9 | rng = np.random.default_rng(seed=42) 10 | array = rng.random((100, 100, 10)) # height, width, bands 11 | image = SpectralImage.from_numpy(array) 12 | 13 | # SpectralImage automatically initializes GeometricShapes 14 | assert isinstance(image.geometric_shapes, GeometricShapes) 15 | # You can access the underlying list via the shapes property 16 | assert isinstance(image.geometric_shapes.shapes, list) 17 | 18 | # GeometricShapes implements common list operations directly, i.e. number of shapes: 19 | length_via_geometric_shapes = len(image.geometric_shapes) 20 | length_via_raw_list = len(image.geometric_shapes.shapes) 21 | # --8<-- [end:init] 22 | 23 | # --8<-- [start:operations] 24 | # Create two Shape objects with distinct spatial coordinates and semantic labels 25 | coords1 = [(1, 2), (3, 4), (2, 4)] 26 | shape1 = Shape.from_multipoint(coords1, label="coords1") 27 | coords2 = [(19, 20), (21, 22), (20, 22)] 28 | shape2 = Shape.from_multipoint(coords2, label="coords2") 29 | 30 | # Add multiple shapes to the SpectralImage's geometric_shapes container at once 31 | image.geometric_shapes.extend([shape1, shape2]) 32 | 33 | # Display the current collection of shapes stored in the image 34 | # Each shape will be shown with its type and label 35 | print(f"Shapes in GeometricShapes: {image.geometric_shapes}") 36 | 37 | # --8<-- [end:operations] 38 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | {{ external_markdown('https://raw.githubusercontent.com/siapy/siapy-lib/main/CONTRIBUTING.md', '') }} 2 | -------------------------------------------------------------------------------- /docs/examples/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/examples/data/.gitkeep -------------------------------------------------------------------------------- /docs/examples/external_sources.md: -------------------------------------------------------------------------------- 1 | ### 🚀 SiaPy command line tool (CLI) 2 | 3 | To facilitate the use of some of the SiaPy library's functionality, a command line interface (CLI) has been implemented. 4 | 5 | The CLI currently supports the following commands: 6 | 7 | - Display images from two cameras. 8 | - Co-register cameras and compute the transformation from one camera's space to another. 9 | - Select regions in images for training machine learning (ML) models. 10 | - Perform image segmentation using a pre-trained ML model. 11 | - Convert radiance images to reflectance by utilizing a reference panel. 12 | - Display spectral signatures for in-depth analysis. 13 | 14 | /// Info 15 | 16 | 💻 [Code Repository](https://github.com/siapy/siapy-cli) 17 | 18 | /// 19 | 20 | --- 21 | 22 | ### 🚀 Hyperspectral data utilization in research 23 | 24 | This use case demonstrates how to utilize extracted data from hyperspectral images in research. 25 | The project integrates a machine learning (ML) pipeline workflow with the SiaPy library to classify spectral signatures. 26 | 27 | Key features: 28 | 29 | - Provides a structured approach to train and test models. 30 | - Features an integrated modular architecture for easy modification of models and data. 31 | - Includes an optimization process with hyperparameter tuning. 32 | - Utilizes Explainable AI techniques to understand the model, the data on which the model is trained, and the most relevant spectral bands (important features) for the model. 33 | - Covers the entire process with visualization of results. 34 | 35 | /// Info 36 | 37 | 💻 [Code Repository](https://github.com/Manuscripts-code/Potato-plants-nemdetect--PP-2025) 38 | 39 | /// 40 | 41 | --- 42 | -------------------------------------------------------------------------------- /docs/examples/images/areas_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/examples/images/areas_selection.png -------------------------------------------------------------------------------- /docs/examples/images/pixels_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/examples/images/pixels_selection.png -------------------------------------------------------------------------------- /docs/examples/src/spectral_image_01.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import spectral as sp 4 | 5 | from siapy.entities import SpectralImage 6 | from siapy.entities.images import SpectralLibImage 7 | 8 | # Set the path to the directory containing the data 9 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 10 | data_dir = "./docs/examples/data" 11 | 12 | # Find all header and image files in the data directory 13 | header_paths = sorted(Path(data_dir).rglob("*.hdr")) 14 | image_paths = sorted(Path(data_dir).rglob("*.img")) 15 | 16 | header_path_img0 = header_paths[0] 17 | image_path_img0 = image_paths[0] 18 | 19 | # Load the image using spectral library and then wrap over SpectralImage object 20 | sp_file = sp.envi.open(file=header_path_img0, image=image_path_img0) 21 | assert not isinstance(sp_file, sp.io.envi.SpectralLibrary) 22 | image = SpectralImage(SpectralLibImage(sp_file)) 23 | 24 | # or you can do the same just by running 25 | image = SpectralImage.spy_open( 26 | header_path=header_path_img0, 27 | image_path=image_path_img0, 28 | ) 29 | 30 | # Now you can easily use various property and util functions of the SpectralImage object 31 | # Get the shape of the image 32 | print("Image shape:", image.shape) 33 | 34 | # Get the number of bands 35 | print("Number of bands:", image.bands) 36 | 37 | # Get the wavelength information 38 | print("Wavelengths:", image.wavelengths) 39 | 40 | # Get the file path 41 | print("File path:", image.filepath) 42 | 43 | # Get the metadata 44 | print("Metadata:", image.metadata) 45 | 46 | # Get the number of rows 47 | print("Number of rows:", image.image.rows) 48 | 49 | # Get the number of columns 50 | print("Number of columns:", image.image.cols) 51 | 52 | # Get the default bands 53 | print("Default bands:", image.default_bands) 54 | 55 | # Get the description 56 | print("Description:", image.image.description) 57 | 58 | # Get the camera ID 59 | print("Camera ID:", image.camera_id) 60 | 61 | # Get the geometric shapes 62 | print("Geometric shapes:", image.geometric_shapes) 63 | -------------------------------------------------------------------------------- /docs/examples/src/spectral_image_02.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import matplotlib.pyplot as plt 4 | 5 | from siapy.entities import Pixels, SpectralImage 6 | 7 | # Set the path to the directory containing the data 8 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 9 | data_dir = "./docs/examples/data" 10 | 11 | # Get first image 12 | header_path_img0 = sorted(Path(data_dir).rglob("*.hdr"))[1] 13 | image_path_img0 = sorted(Path(data_dir).rglob("*.img"))[1] 14 | 15 | # Load spectral image 16 | image = SpectralImage.spy_open( 17 | header_path=header_path_img0, 18 | image_path=image_path_img0, 19 | ) 20 | 21 | # Convert to numpy 22 | image_np = image.to_numpy(nan_value=0.0) 23 | print("Image shape:", image_np.shape) 24 | 25 | # Calculate mean 26 | mean_val = image.average_intensity(axis=(0, 1)) 27 | print("Mean value per band:", mean_val) 28 | 29 | # Create a Pixels object from an iterable with pixels coordinates 30 | # The iterable should be a list of tuples representing (x, y) coordinates 31 | # iterable == [(x1, y1), (x2, y2), ...] -> list of pixels 32 | iterable = [(1, 2), (3, 4), (5, 6)] 33 | pixels = Pixels.from_iterable(iterable) 34 | 35 | # Convert the pixel coordinates to spectral signatures 36 | signatures = image.to_signatures(pixels) 37 | print("Signatures:", signatures) 38 | 39 | # Extract a subarray from the image using the pixel coordinates 40 | subarray = image.to_subarray(pixels) 41 | print("Subarray shape:", subarray.shape) 42 | 43 | # Convert to displayable image 44 | display_image = image.to_display(equalize=True) 45 | 46 | # Display the image using matplotlib 47 | plt.figure() 48 | plt.imshow(display_image) 49 | plt.axis("off") # Hide axes for better visualization 50 | plt.show() 51 | -------------------------------------------------------------------------------- /docs/examples/src/spectral_imageset_01.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImageSet 4 | 5 | # Set the path to the directory containing the data 6 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 7 | data_dir = "./docs/examples/data" 8 | 9 | # Find all header and image files in the data directory 10 | header_paths = sorted(Path(data_dir).rglob("*.hdr")) 11 | image_paths = sorted(Path(data_dir).rglob("*.img")) 12 | 13 | # Create a SpectralImageSet from the found paths 14 | imageset = SpectralImageSet.spy_open( 15 | header_paths=header_paths, 16 | image_paths=image_paths, 17 | ) 18 | 19 | # Now you can easily use various properties and utility functions of the SpectralImageSet object. 20 | # First, let's sort the images: 21 | print("Unsorted: ", imageset.images) 22 | imageset.sort() 23 | print("Sorted: ", imageset.images) 24 | 25 | # Get the number of images in the set 26 | print("Number of images in the set:", len(imageset)) 27 | 28 | # Get the cameras ID 29 | print("Cameras ID:", imageset.cameras_id) 30 | 31 | # Iterate over images and print their shapes 32 | for idx, image in enumerate(imageset): 33 | print(f"Image {idx} shape:", image.shape) 34 | 35 | # Get images by camera ID 36 | camera_id = imageset.cameras_id[0] 37 | images_by_camera = imageset.images_by_camera_id(camera_id) 38 | print(f"Number of images by camera {camera_id}:", len(images_by_camera)) 39 | -------------------------------------------------------------------------------- /docs/examples/src/spectral_imageset_load_01.py: -------------------------------------------------------------------------------- 1 | try: 2 | from pathlib import Path 3 | 4 | from siapy.entities import SpectralImageSet 5 | 6 | print("Libraries detected successfully.") 7 | except ImportError as e: 8 | print(f"Error: {e}. Please ensure that the SiaPy library is installed and the environment is activated.") 9 | exit(1) 10 | 11 | # Set the path to the directory containing the data 12 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 13 | data_dir = "./docs/examples/data" 14 | 15 | # Find all header and image files in the data directory 16 | header_paths = sorted(Path(data_dir).rglob("*.hdr")) 17 | image_paths = sorted(Path(data_dir).rglob("*.img")) 18 | 19 | # Create a SpectralImageSet from the found paths 20 | image_set = SpectralImageSet.spy_open( 21 | header_paths=header_paths, 22 | image_paths=image_paths, 23 | ) 24 | 25 | # Check if the data was loaded correctly 26 | if len(image_set) > 0: 27 | print("Loading succeeded.") 28 | else: 29 | print("Loading did not succeed.") 30 | -------------------------------------------------------------------------------- /docs/examples/src/transformations_01.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImage 4 | from siapy.transformations import corregistrator 5 | from siapy.utils.plots import pixels_select_click 6 | 7 | # Set the path to the directory containing the data 8 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 9 | data_dir = "./docs/examples/data" 10 | 11 | # Get first image 12 | header_path_img0 = sorted(Path(data_dir).rglob("coregister*corr2_rad_f32.hdr"))[0] 13 | image_path_img0 = sorted(Path(data_dir).rglob("coregister*corr2_rad_f32.img"))[0] 14 | header_path_img1 = sorted(Path(data_dir).rglob("coregister*corr_rad_f32.hdr"))[0] 15 | image_path_img1 = sorted(Path(data_dir).rglob("coregister*corr_rad_f32.img"))[0] 16 | 17 | # Load VNIR and SWIR spectral images 18 | image_swir = SpectralImage.spy_open( 19 | header_path=header_path_img0, 20 | image_path=image_path_img0, 21 | ) 22 | image_vnir = SpectralImage.spy_open( 23 | header_path=header_path_img1, 24 | image_path=image_path_img1, 25 | ) 26 | 27 | # Select the same pixels in both images. 28 | # The more points you select, the better the transformation between image spaces will be. 29 | # Click enter to finish the selection. 30 | pixels_vnir = pixels_select_click(image_vnir) 31 | pixels_swir = pixels_select_click(image_swir) 32 | 33 | # Perform the transformation and transform the selected pixels from the VNIR image to the space of the SWIR image. 34 | matx, _ = corregistrator.align(pixels_swir, pixels_vnir, plot_progress=False) 35 | print("Transformation matrix:", matx) 36 | -------------------------------------------------------------------------------- /docs/examples/src/transformations_02.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | 5 | from siapy.entities import SpectralImage 6 | from siapy.transformations import corregistrator 7 | from siapy.utils.plots import display_multiple_images_with_areas, pixels_select_lasso 8 | 9 | # Set the path to the directory containing the data 10 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 11 | data_dir = "./docs/examples/data" 12 | 13 | # Get first image 14 | header_path_img0 = sorted(Path(data_dir).rglob("*.hdr"))[0] 15 | image_path_img0 = sorted(Path(data_dir).rglob("*.img"))[0] 16 | header_path_img1 = sorted(Path(data_dir).rglob("*.hdr"))[1] 17 | image_path_img1 = sorted(Path(data_dir).rglob("*.img"))[1] 18 | 19 | # Load VNIR and SWIR spectral images 20 | image_swir = SpectralImage.spy_open( 21 | header_path=header_path_img0, 22 | image_path=image_path_img0, 23 | ) 24 | image_vnir = SpectralImage.spy_open( 25 | header_path=header_path_img1, 26 | image_path=image_path_img1, 27 | ) 28 | 29 | # Transformation matrix was calculated in previous example 30 | matx = np.array( 31 | [ 32 | [5.10939099e-01, -3.05286868e-03, -1.48283389e00], 33 | [-2.15777211e-03, 5.17836773e-01, -2.50694723e01], 34 | [3.02412467e-18, 7.36518494e-18, 1.00000000e00], 35 | ] 36 | ) 37 | 38 | # Select area of the image 39 | # Click enter to finish the selection. 40 | selected_areas_vnir = pixels_select_lasso(image_vnir) 41 | 42 | # Transform the selected areas from the VNIR image to the space of the SWIR image. 43 | selected_areas_swir = [corregistrator.transform(pixels_vnir, matx) for pixels_vnir in selected_areas_vnir] 44 | 45 | # Display the selected areas in both images 46 | display_multiple_images_with_areas( 47 | [ 48 | (image_vnir, selected_areas_vnir), 49 | (image_swir, selected_areas_swir), 50 | ], 51 | plot_interactive_buttons=False, 52 | ) 53 | -------------------------------------------------------------------------------- /docs/examples/src/transformations_03.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImage 4 | from siapy.transformations.image import ( 5 | add_gaussian_noise, 6 | area_normalization, 7 | random_crop, 8 | random_mirror, 9 | random_rotation, 10 | rescale, 11 | ) 12 | 13 | # Set the path to the directory containing the data 14 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 15 | data_dir = "./docs/examples/data" 16 | 17 | # Get first image 18 | header_path_img0 = sorted(Path(data_dir).rglob("*.hdr"))[0] 19 | image_path_img0 = sorted(Path(data_dir).rglob("*.img"))[0] 20 | 21 | # Load VNIR and SWIR spectral images 22 | image_swir = SpectralImage.spy_open( 23 | header_path=header_path_img0, 24 | image_path=image_path_img0, 25 | ) 26 | 27 | # Convert image to numpy array 28 | image_swir_np = image_swir.to_numpy() 29 | 30 | # Apply transformations to image_swir 31 | # Add Gaussian noise 32 | noisy_image = add_gaussian_noise(image_swir_np, mean=0.0, std=1.0, clip_to_max=True) 33 | 34 | # Random crop 35 | cropped_image = random_crop(image_swir_np, output_size=(100, 100)) 36 | 37 | # Random mirror 38 | mirrored_image = random_mirror(image_swir_np) 39 | 40 | # Random rotation 41 | rotated_image = random_rotation(image_swir_np, angle=45) 42 | 43 | # Rescale 44 | rescaled_image = rescale(image_swir_np, output_size=(200, 200)) 45 | 46 | # Area normalization 47 | normalized_image = area_normalization(image_swir_np) 48 | -------------------------------------------------------------------------------- /docs/examples/src/visualization_01.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImage 4 | from siapy.utils.plots import pixels_select_click 5 | 6 | # Set the path to the directory containing the data 7 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 8 | data_dir = "./docs/examples/data" 9 | 10 | # Get arbitrary image 11 | header_path_img0 = sorted(Path(data_dir).rglob("*.hdr"))[1] 12 | image_path_img0 = sorted(Path(data_dir).rglob("*.img"))[1] 13 | 14 | # Load spectral image 15 | image = SpectralImage.spy_open( 16 | header_path=header_path_img0, 17 | image_path=image_path_img0, 18 | ) 19 | 20 | # Select pixels from the image 21 | pixels = pixels_select_click(image) 22 | # ? Press enter to finish the selection 23 | print("Pixels:", pixels.df) 24 | -------------------------------------------------------------------------------- /docs/examples/src/visualization_02.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from siapy.entities import SpectralImage 4 | from siapy.utils.plots import pixels_select_lasso 5 | 6 | # Set the path to the directory containing the data 7 | # !! ADJUST THIS PATH TO YOUR DATA DIRECTORY !! 8 | data_dir = "./docs/examples/data" 9 | 10 | # Get arbitrary image 11 | header_path_img0 = sorted(Path(data_dir).rglob("*.hdr"))[1] 12 | image_path_img0 = sorted(Path(data_dir).rglob("*.img"))[1] 13 | 14 | # Load spectral image 15 | image = SpectralImage.spy_open( 16 | header_path=header_path_img0, 17 | image_path=image_path_img0, 18 | ) 19 | 20 | # Select areas from the image 21 | areas = pixels_select_lasso(image) 22 | # ? Press enter to finish the selection 23 | 24 | # Print the selected areas 25 | for i, area in enumerate(areas): 26 | print(f"Area {i}:", area) 27 | -------------------------------------------------------------------------------- /docs/images/logo-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/images/logo-text.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/docs/images/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {{ external_markdown('https://raw.githubusercontent.com/siapy/siapy-lib/main/README.md', '') }} 2 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | Before installing `siapy` library, ensure you have the following prerequisites: 4 | 5 | - Python 3.10 or higher 6 | - `pip` (Python package installer) or any other installer (e.g. pdm, uv, poetry) 7 | 8 | ## Installation 9 | 10 | Installation is as simple as: 11 | 12 | ```bash 13 | pip install siapy 14 | ``` 15 | 16 | ### Alternative Installation Methods 17 | 18 | __Python package and dependency managers__ 19 | 20 | You can also install siapy using other popular Python package and dependency managers: 21 | 22 | - PDM: 23 | 24 | ```bash 25 | pdm add siapy 26 | ``` 27 | 28 | - Poetry: 29 | 30 | ```bash 31 | poetry add siapy 32 | ``` 33 | 34 | - uv: 35 | 36 | ```bash 37 | uv add siapy 38 | ``` 39 | 40 | __Manually__ 41 | 42 | If you prefer to install from the source, you can clone the repository and install it manually: 43 | 44 | ```bash 45 | git clone https://github.com/siapy/siapy-lib.git 46 | cd siapy 47 | make install 48 | ``` 49 | 50 | ### Verify Installation 51 | 52 | To verify that siapy has been installed correctly, you can run: 53 | 54 | ```bash 55 | python -c "import siapy; print(siapy.__version__)" 56 | ``` 57 | 58 | ## Troubleshooting 59 | 60 | If you encounter any issues during installation, consider the following solutions: 61 | 62 | - Ensure that you have the correct version of Python installed. 63 | - Check for any missing dependencies and install them manually. 64 | - Upgrade pip to the latest version: 65 | 66 | ```bash 67 | pip install --upgrade pip 68 | ``` 69 | 70 | For further assistance, please refer to the [documentation](https://siapy.github.io/siapy-lib/) or open an [issue](https://github.com/siapy/siapy-lib/issues/new/choose) on GitHub. 71 | -------------------------------------------------------------------------------- /docs/permit.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SiaPy, Agricultural institute of Slovenia 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. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "siapy" 3 | version = "0.9.5" 4 | description = "A python library for efficient processing of spectral images." 5 | authors = [{ name = "janezlapajne", email = "janez.lapajne@kis.si" }] 6 | requires-python = ">=3.10,<3.13" 7 | readme = "README.md" 8 | license = { text = "MIT" } 9 | 10 | classifiers = [ 11 | "Intended Audience :: Information Technology", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python", 15 | "Topic :: Internet", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | "Topic :: Software Development :: Libraries", 18 | "Topic :: Software Development", 19 | "Typing :: Typed", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | ] 27 | 28 | dependencies = [ 29 | "pandas>=2.2.2", 30 | "opencv-python>=4.10.0.82", 31 | "spectral>=0.23.1", 32 | "scikit-learn>=1.5.0", 33 | "pydantic>=2.7.3", 34 | "rich>=13.7.1", 35 | "scikit-image>=0.24.0", 36 | "matplotlib>=3.9.0", 37 | "optuna>=3.6.1", 38 | "spyndex>=0.6.0", 39 | "mlxtend>=0.23.1", 40 | "autofeat>=2.1.1", 41 | "setuptools>=72.1.0", 42 | "dask[dataframe]>=2024.7.1", 43 | "geopandas>=1.0.1", 44 | "rasterio>=1.4.3", 45 | "xarray>=2025.1.2", 46 | "rioxarray>=0.18.2", 47 | "shapely>=2.0.7", 48 | ] 49 | 50 | [dependency-groups] 51 | lint = [ 52 | "ruff>=0.4.8", 53 | "mypy>=1.10.1", 54 | "pandas-stubs>=2.2.2.240603", 55 | "types-shapely>=2.0.0.20250202", 56 | ] 57 | dev = ["pre-commit>=3.7.1", "tomli>=2.0.1", "codespell>=2.3.0"] 58 | test = [ 59 | "pytest>=8.2.2", 60 | "pytest-cov>=5.0.0", 61 | "pytest-xdist>=3.6.1", 62 | "pytest-mock>=3.14.0", 63 | "pytest-benchmark>=5.1.0", 64 | "pytest-pretty>=1.2.0", 65 | ] 66 | docs = [ 67 | "autoflake>=2.3.1", 68 | "mkdocs>=1.6.0", 69 | "mkdocs-embed-external-markdown>=3.0.2", 70 | "mkdocs-exclude>=1.0.2", 71 | "mkdocs-material>=9.5.31", 72 | "mkdocs-simple-hooks>=0.1.5", 73 | "mkdocstrings-python>=1.10.8", 74 | "mkdocs-redirects>=1.2.1", 75 | "griffe-typingdoc>=0.2.6", 76 | "mike>=2.1.3", 77 | "pymdown-extensions>=10.12", 78 | ] 79 | 80 | [project.urls] 81 | Homepage = "https://github.com/siapy/siapy-lib" 82 | Documentation = "https://github.com/siapy/siapy-lib/tree/main/docs" 83 | Repository = "https://github.com/siapy/siapy-lib" 84 | 85 | [build-system] 86 | requires = ["pdm-backend"] 87 | build-backend = "pdm.backend" 88 | 89 | [tool.pdm] 90 | distribution = true 91 | 92 | [tool.pdm.build] 93 | source-includes = ["tests/", "scripts/", "docs/"] 94 | 95 | [tool.pytest.ini_options] 96 | markers = ["manual: mark test as manual to run them only on demand."] 97 | 98 | [tool.mypy] 99 | mypy_path = "stubs" 100 | plugins = ["pydantic.mypy"] 101 | warn_redundant_casts = true 102 | disallow_untyped_defs = true 103 | allow_untyped_globals = false 104 | disallow_any_generics = false 105 | 106 | [tool.pydantic-mypy] 107 | init_forbid_extra = true 108 | init_typed = true 109 | warn_required_dynamic_aliases = true 110 | 111 | [[tool.mypy.overrides]] 112 | module = "tests.*" 113 | disallow_untyped_defs = false 114 | 115 | [[tool.mypy.overrides]] 116 | module = "spectral" 117 | ignore_missing_imports = true 118 | 119 | [[tool.mypy.overrides]] 120 | module = "sklearn.*" 121 | ignore_missing_imports = true 122 | 123 | [[tool.mypy.overrides]] 124 | module = "geopandas.*" 125 | ignore_missing_imports = true 126 | 127 | [[tool.mypy.overrides]] 128 | module = "rasterio.*" 129 | ignore_missing_imports = true 130 | 131 | [[tool.mypy.overrides]] 132 | module = "mlxtend.*" 133 | ignore_missing_imports = true 134 | 135 | [tool.ruff] 136 | line-length = 120 137 | extend-exclude = [] 138 | exclude = [] 139 | lint.ignore = ["F811"] 140 | 141 | [tool.ruff.lint.per-file-ignores] 142 | "__init__.py" = ["F401"] 143 | 144 | [tool.ruff.lint.pyupgrade] 145 | # Preserve types, even if a file imports `from __future__ import annotations`. 146 | keep-runtime-typing = true 147 | 148 | [tool.codespell] 149 | ignore-words-list = "janezlapajne" 150 | skip = 'dist/*, htmlcov, LICENCE, *.lock, *.toml, CHANGELOG.md, *.cff, *.svg' 151 | count = true 152 | check-hidden = false 153 | -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | rm -rf $(find . -name __pycache__) 7 | rm -f $(find . -type f -name '*.py[co]') 8 | rm -f $(find . -type f -name '*~') 9 | rm -f $(find . -type f -name '.*~') 10 | rm -rf .pdm-build 11 | rm -rf .mypy_cache 12 | rm -rf .cache 13 | rm -rf .pytest_cache 14 | rm -rf .ruff_cache 15 | rm -rf htmlcov 16 | rm -rf *.egg-info 17 | rm -f .coverage 18 | rm -f .coverage.* 19 | rm -rf build 20 | rm -rf dist 21 | rm -rf site 22 | rm -rf docs/_build 23 | rm -rf coverage.xml 24 | -------------------------------------------------------------------------------- /scripts/compress-data.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Check if a version number is provided and validate that it is an integer 6 | if [ -z "$1" ]; then 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | version="$1" 12 | if ! [[ "$version" =~ ^[0-9]+$ ]]; then 13 | echo "Error: Version must be an integer." 14 | exit 1 15 | fi 16 | 17 | set -x 18 | 19 | # Get the directory where the script is located and move there 20 | script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 21 | tests_dir="$script_dir/../tests" 22 | cd "$tests_dir" 23 | 24 | # Create a compressed archive using the provided version 25 | tar -czvf "testdata-v${version}.tar.gz" --exclude="*.tar.gz*" data/ 26 | mv "testdata-v${version}.tar.gz" "data/testdata-v${version}.tar.gz" 27 | 28 | # Add a checksum file to verify integrity 29 | sha256sum "data/testdata-v${version}.tar.gz" >"data/testdata-v${version}.tar.gz.sha256" 30 | 31 | ## -- Uncomment the following lines to push the tag to the remote repository -- 32 | # # Push tag to remote repository 33 | # git tag "testdata-v${version}" 34 | # git push origin "testdata-v${version}" 35 | 36 | set +x 37 | echo "Archive created at: $tests_dir/data/testdata-v${version}.tar.gz" 38 | echo "Checksum file created at: $tests_dir/data/testdata-v${version}.tar.gz.sha256" 39 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | docs_src="docs/examples/src" 7 | 8 | pdm run ruff check siapy tests scripts $docs_src --fix 9 | pdm run ruff format siapy tests $docs_src 10 | -------------------------------------------------------------------------------- /scripts/install-dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | # Install pdm 7 | curl -sSL https://pdm-project.org/install-pdm.py | python3 - 8 | 9 | # Install python libraries 10 | pdm install 11 | 12 | # Install pre-commit 13 | pdm run pre-commit uninstall 14 | pdm run pre-commit install # --hook-type commit-msg 15 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | docs_src="docs/examples/src" 7 | 8 | pdm run mypy siapy tests $docs_src 9 | pdm run ruff check siapy tests scripts $docs_src 10 | pdm run ruff format siapy tests $docs_src --check 11 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | pdm run coverage run --source=siapy -m pytest -m "not manual" 7 | pdm run coverage report --show-missing 8 | pdm run coverage html --title "${@-coverage}" 9 | -------------------------------------------------------------------------------- /scripts/update-branches.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | # Save the current branch name 7 | current_branch=$(git branch --show-current) 8 | 9 | # Update main 10 | git checkout main 11 | git pull origin main 12 | 13 | # Update develop 14 | git checkout develop 15 | git pull origin develop 16 | 17 | # Check if develop is behind main 18 | behind_count=$(git rev-list --left-right --count develop...main | cut -f2) 19 | 20 | if [ "$behind_count" -gt 0 ]; then 21 | git rebase main 22 | git push 23 | fi 24 | 25 | # Switch back to the original branch 26 | git checkout "$current_branch" 27 | 28 | # If the current branch is not main or develop, rebase it onto develop 29 | if [[ "$current_branch" != "main" && "$current_branch" != "develop" ]]; then 30 | git rebase develop 31 | fi 32 | 33 | echo "Branches have been updated and rebased accordingly." 34 | -------------------------------------------------------------------------------- /siapy-dev.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "editor.formatOnSave": true, 9 | "files.autoSave": "afterDelay", 10 | "python.languageServer": "Pylance", 11 | "python.defaultInterpreterPath": ".venv/bin/python", 12 | "python.testing.pytestEnabled": true, 13 | "python.testing.pytestArgs": [ 14 | "tests" 15 | ], 16 | "python.analysis.typeCheckingMode": "standard", 17 | "python.terminal.activateEnvironment": true, 18 | "[python]": { 19 | "editor.rulers": [ 20 | { 21 | "column": 80, 22 | "color": "#e2d8e21f" 23 | }, 24 | { 25 | "column": 100, 26 | "color": "#e9b2b2c4" 27 | } 28 | ], 29 | "editor.codeActionsOnSave": { 30 | "source.organizeImports": "always" 31 | }, 32 | "editor.defaultFormatter": "charliermarsh.ruff", 33 | }, 34 | "git.enableCommitSigning": true, 35 | "git.autofetch": true, 36 | "[git-commit]": { 37 | "editor.rulers": [ 38 | 50 39 | ] 40 | }, 41 | "cSpell.language": "en", 42 | }, 43 | "extensions": { 44 | "recommendations": [ 45 | "ms-python.python", 46 | "ms-python.vscode-pylance", 47 | "ms-python.mypy-type-checker", 48 | "ms-python.isort", 49 | "ms-python.pylint", 50 | "charliermarsh.ruff", 51 | "dchanco.vsc-invoke", 52 | "redhat.vscode-yaml", 53 | "streetsidesoftware.code-spell-checker", 54 | "aaron-bond.better-comments", 55 | "njpwerner.autodocstring", 56 | "EditorConfig.EditorConfig", 57 | "ms-python.debugpy", 58 | "sonarsource.sonarlint-vscode", 59 | "eamodio.gitlens", 60 | "github.vscode-codeql", 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /siapy/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __version__ 2 | 3 | __all__ = ["__version__"] 4 | -------------------------------------------------------------------------------- /siapy/__version__.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | try: 4 | __version__ = metadata.version("siapy") 5 | except metadata.PackageNotFoundError: 6 | __version__ = "0.0.0" 7 | -------------------------------------------------------------------------------- /siapy/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import logger 2 | 3 | __all__ = ["logger"] 4 | -------------------------------------------------------------------------------- /siapy/core/configs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | __all__ = [ 4 | "BASE_DIR", 5 | "SIAPY_DIR", 6 | "TEST_DATA_DIR", 7 | ] 8 | 9 | BASE_DIR = Path(__file__).parent.parent.parent.absolute() 10 | SIAPY_DIR = Path(BASE_DIR, "siapy") 11 | TEST_DATA_DIR = Path(BASE_DIR, "tests", "data") 12 | -------------------------------------------------------------------------------- /siapy/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | __all__ = [ 5 | "SiapyError", 6 | "InvalidFilepathError", 7 | "InvalidInputError", 8 | "InvalidTypeError", 9 | "ProcessingError", 10 | "ConfigurationError", 11 | "MethodNotImplementedError", 12 | "DirectInitializationError", 13 | ] 14 | 15 | 16 | class SiapyError(Exception): 17 | """Base exception for SiaPy library.""" 18 | 19 | def __init__(self, message: str, name: str = "SiaPy") -> None: 20 | self.message: str = message 21 | self.name: str = name 22 | super().__init__(self.message, self.name) 23 | 24 | 25 | class InvalidFilepathError(SiapyError): 26 | """Exception raised when a required file is not found.""" 27 | 28 | def __init__(self, filename: str | Path) -> None: 29 | self.filename: str = str(filename) 30 | super().__init__(f"File not found: {filename}") 31 | 32 | 33 | class InvalidInputError(SiapyError): 34 | """Exception raised for invalid input.""" 35 | 36 | def __init__(self, input_value: Any, message: str = "Invalid input") -> None: 37 | self.input_value: Any = input_value 38 | self.message: str = message 39 | super().__init__(f"{message}: {input_value}") 40 | 41 | 42 | class InvalidTypeError(SiapyError): 43 | """Exception raised for invalid type.""" 44 | 45 | def __init__( 46 | self, 47 | input_value: Any, 48 | allowed_types: type | tuple[type, ...], 49 | message: str = "Invalid type", 50 | ) -> None: 51 | self.input_value: Any = input_value 52 | self.input_type: Any = type(input_value) 53 | self.allowed_types: type | tuple[type, ...] = allowed_types 54 | self.message: str = message 55 | super().__init__(f"{message}: {input_value} (type: {self.input_type}). Allowed types: {allowed_types}") 56 | 57 | 58 | class ProcessingError(SiapyError): 59 | """Exception raised for errors during processing.""" 60 | 61 | def __init__(self, message: str = "An error occurred during processing") -> None: 62 | self.message: str = message 63 | super().__init__(message) 64 | 65 | 66 | class ConfigurationError(SiapyError): 67 | """Exception raised for configuration errors.""" 68 | 69 | def __init__(self, message: str = "Configuration error") -> None: 70 | self.message: str = message 71 | super().__init__(message) 72 | 73 | 74 | class MethodNotImplementedError(SiapyError): 75 | """Exception raised for not implemented methods.""" 76 | 77 | def __init__(self, class_name: str, method_name: str) -> None: 78 | self.class_name: str = class_name 79 | self.method_name: str = method_name 80 | super().__init__(f"Method '{method_name}' not implemented in class '{class_name}'") 81 | 82 | 83 | class DirectInitializationError(SiapyError): 84 | """Exception raised when a class method is required to create an instance.""" 85 | 86 | def __init__(self, class_: type) -> None: 87 | from siapy.utils.general import get_classmethods 88 | 89 | self.class_name: str = class_.__class__.__name__ 90 | self.class_methods: list[str] = get_classmethods(class_) 91 | super().__init__( 92 | f"Use any of the @classmethod to create a new instance of '{self.class_name}': {self.class_methods}" 93 | ) 94 | -------------------------------------------------------------------------------- /siapy/core/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __all__ = ["logger"] 4 | 5 | logger = logging.getLogger("siapy") 6 | -------------------------------------------------------------------------------- /siapy/core/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import spectral as sp 6 | import xarray as xr 7 | from numpy.typing import ArrayLike, NDArray 8 | from PIL.Image import Image 9 | 10 | from siapy.entities import SpectralImage, SpectralImageSet 11 | 12 | __all__ = [ 13 | "SpectralLibType", 14 | "XarrayType", 15 | "ImageType", 16 | "ImageSizeType", 17 | "ImageDataType", 18 | "ImageContainerType", 19 | "ArrayLike1dType", 20 | "ArrayLike2dType", 21 | ] 22 | 23 | SpectralLibType = sp.io.envi.BilFile | sp.io.envi.BipFile | sp.io.envi.BsqFile 24 | XarrayType = xr.DataArray | xr.Dataset 25 | ImageType = SpectralImage[Any] | NDArray[np.floating[Any]] | Image 26 | ImageSizeType = int | tuple[int, ...] 27 | ImageDataType = ( 28 | np.uint8 29 | | np.int16 30 | | np.int32 31 | | np.float32 32 | | np.float64 33 | | np.complex64 34 | | np.complex128 35 | | np.uint16 36 | | np.uint32 37 | | np.int64 38 | | np.uint64 39 | ) 40 | ImageContainerType = SpectralImage[Any] | SpectralImageSet 41 | ArrayLike1dType = NDArray[np.floating[Any]] | pd.Series | Sequence[Any] | ArrayLike 42 | ArrayLike2dType = NDArray[np.floating[Any]] | pd.DataFrame | Sequence[Any] | ArrayLike 43 | -------------------------------------------------------------------------------- /siapy/datasets/__init__.py: -------------------------------------------------------------------------------- 1 | from .tabular import TabularDataset 2 | 3 | 4 | __all__ = [ 5 | "TabularDataset", 6 | ] 7 | -------------------------------------------------------------------------------- /siapy/datasets/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pandas as pd 4 | 5 | if TYPE_CHECKING: 6 | from .schemas import ( 7 | ClassificationTarget, 8 | RegressionTarget, 9 | TabularDatasetData, 10 | ) 11 | 12 | __all__ = [ 13 | "generate_classification_target", 14 | "generate_regression_target", 15 | "merge_signals_from_multiple_cameras", 16 | ] 17 | 18 | 19 | def generate_classification_target( 20 | dataframe: pd.DataFrame, 21 | column_names: str | list[str], 22 | ) -> "ClassificationTarget": 23 | from .schemas import ( 24 | ClassificationTarget, # Local import to avoid circular dependency 25 | ) 26 | 27 | if isinstance(column_names, str): 28 | column_names = [column_names] 29 | # create one column labels from multiple columns 30 | label = dataframe[column_names].apply(tuple, axis=1) 31 | # Convert tuples to strings with '__' delimiter 32 | label = label.apply(lambda x: "__".join(x)) 33 | # encode to numbers 34 | encoded_np, encoding_np = pd.factorize(label) 35 | encoded = pd.Series(encoded_np, name="encoded") 36 | encoding = pd.Series(encoding_np, name="encoding") 37 | return ClassificationTarget(label=label, value=encoded, encoding=encoding) 38 | 39 | 40 | def generate_regression_target( 41 | dataframe: pd.DataFrame, 42 | column_name: str, 43 | ) -> "RegressionTarget": 44 | from .schemas import ( 45 | RegressionTarget, 46 | ) # Local import to avoid circular dependency 47 | 48 | return RegressionTarget(name=column_name, value=dataframe[column_name]) 49 | 50 | 51 | def merge_signals_from_multiple_cameras(data: "TabularDatasetData") -> None: 52 | # TODO: Implement the function to merge signals from multiple cameras 53 | pass 54 | -------------------------------------------------------------------------------- /siapy/datasets/tabular.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Iterator 4 | 5 | import pandas as pd 6 | from pydantic import BaseModel, ConfigDict 7 | 8 | from siapy.core.exceptions import InvalidInputError 9 | from siapy.core.types import ImageContainerType 10 | from siapy.datasets.schemas import TabularDatasetData 11 | from siapy.entities import Signatures, SpectralImage, SpectralImageSet 12 | from siapy.utils.signatures import get_signatures_within_convex_hull 13 | 14 | __all__ = [ 15 | "TabularDataset", 16 | ] 17 | 18 | 19 | class MetaDataEntity(BaseModel): 20 | image_idx: int 21 | image_filepath: Path 22 | camera_id: str 23 | shape_idx: int 24 | shape_type: str 25 | shape_label: str | None 26 | geometry_idx: int 27 | 28 | 29 | class TabularDataEntity(MetaDataEntity): 30 | model_config = ConfigDict(arbitrary_types_allowed=True) 31 | signatures: Signatures 32 | 33 | 34 | @dataclass 35 | class TabularDataset: 36 | def __init__(self, container: ImageContainerType): 37 | self._image_set = SpectralImageSet([container]) if isinstance(container, SpectralImage) else container 38 | self._data_entities: list[TabularDataEntity] = [] 39 | 40 | def __len__(self) -> int: 41 | return len(self.data_entities) 42 | 43 | def __str__(self) -> str: 44 | return f"<{self.__class__.__name__} object with {len(self)} data entities>" 45 | 46 | def __iter__(self) -> Iterator[TabularDataEntity]: 47 | self._check_data_entities() 48 | return iter(self.data_entities) 49 | 50 | def __getitem__(self, index: int) -> TabularDataEntity: 51 | self._check_data_entities() 52 | return self.data_entities[index] 53 | 54 | @property 55 | def image_set(self) -> SpectralImageSet: 56 | return self._image_set 57 | 58 | @property 59 | def data_entities(self) -> list[TabularDataEntity]: 60 | return self._data_entities 61 | 62 | def process_image_data(self) -> None: 63 | self.data_entities.clear() 64 | for image_idx, image in enumerate(self.image_set): 65 | for shape_idx, shape in enumerate(image.geometric_shapes.shapes): 66 | signatures_hull = get_signatures_within_convex_hull(image, shape) 67 | for geometry_idx, signatures in enumerate(signatures_hull): 68 | entity = TabularDataEntity( 69 | image_idx=image_idx, 70 | shape_idx=shape_idx, 71 | geometry_idx=geometry_idx, 72 | image_filepath=image.filepath, 73 | camera_id=image.camera_id, 74 | shape_type=shape.shape_type, 75 | shape_label=shape.label, 76 | signatures=signatures, 77 | ) 78 | self.data_entities.append(entity) 79 | 80 | def generate_dataset_data(self, mean_signatures: bool = True) -> TabularDatasetData: 81 | self._check_data_entities() 82 | signatures_dfs = [] 83 | metadata_dfs = [] 84 | for entity in self.data_entities: 85 | signatures_df = entity.signatures.to_dataframe().dropna() 86 | if mean_signatures: 87 | signatures_df = signatures_df.mean().to_frame().T 88 | 89 | signatures_len = len(signatures_df) 90 | metadata_df = pd.DataFrame( 91 | { 92 | "image_idx": [str(entity.image_idx)] * signatures_len, 93 | "image_filepath": [str(entity.image_filepath)] * signatures_len, 94 | "camera_id": [entity.camera_id] * signatures_len, 95 | "shape_idx": [str(entity.shape_idx)] * signatures_len, 96 | "shape_type": [entity.shape_type] * signatures_len, 97 | "shape_label": [entity.shape_label] * signatures_len, 98 | "geometry_idx": [str(entity.geometry_idx)] * signatures_len, 99 | } 100 | ) 101 | 102 | assert list(metadata_df.columns) == list(MetaDataEntity.model_fields.keys()), ( 103 | "Sanity check failed! The columns in metadata_df do not match MetaDataEntity fields." 104 | ) 105 | 106 | signatures_dfs.append(signatures_df) 107 | metadata_dfs.append(metadata_df) 108 | 109 | signatures_concat = pd.concat(signatures_dfs, ignore_index=True) 110 | metadata_concat = pd.concat(metadata_dfs, ignore_index=True) 111 | signatures = Signatures.from_dataframe(signatures_concat) 112 | return TabularDatasetData(signatures=signatures, metadata=metadata_concat) 113 | 114 | def _check_data_entities(self) -> None: 115 | if not self.data_entities: 116 | raise InvalidInputError( 117 | { 118 | "data_entities": self.data_entities, 119 | "required_action": f"Run {self.process_image_data.__name__}() to process image set.", 120 | }, 121 | "No data_entities! You need to process the image set first.", 122 | ) 123 | -------------------------------------------------------------------------------- /siapy/entities/__init__.py: -------------------------------------------------------------------------------- 1 | from .images import SpectralImage 2 | from .imagesets import SpectralImageSet 3 | from .pixels import Pixels 4 | from .shapes import Shape 5 | from .signatures import Signatures 6 | 7 | __all__ = [ 8 | "SpectralImage", 9 | "SpectralImageSet", 10 | "Pixels", 11 | "Signatures", 12 | "Shape", 13 | ] 14 | -------------------------------------------------------------------------------- /siapy/entities/images/__init__.py: -------------------------------------------------------------------------------- 1 | from .interfaces import ImageBase 2 | from .rasterio_lib import RasterioLibImage 3 | from .spectral_lib import SpectralLibImage 4 | from .spimage import SpectralImage 5 | 6 | __all__ = [ 7 | "ImageBase", 8 | "SpectralLibImage", 9 | "RasterioLibImage", 10 | "SpectralImage", 11 | ] 12 | -------------------------------------------------------------------------------- /siapy/entities/images/interfaces.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import numpy as np 6 | from numpy.typing import NDArray 7 | from PIL import Image 8 | 9 | if TYPE_CHECKING: 10 | from siapy.core.types import XarrayType 11 | 12 | __all__ = [ 13 | "ImageBase", 14 | ] 15 | 16 | 17 | class ImageBase(ABC): 18 | @classmethod 19 | @abstractmethod 20 | def open(cls: type["ImageBase"], *args: Any, **kwargs: Any) -> "ImageBase": 21 | pass 22 | 23 | @property 24 | @abstractmethod 25 | def filepath(self) -> Path: 26 | pass 27 | 28 | @property 29 | @abstractmethod 30 | def metadata(self) -> dict[str, Any]: 31 | pass 32 | 33 | @property 34 | @abstractmethod 35 | def shape(self) -> tuple[int, int, int]: 36 | pass 37 | 38 | @property 39 | @abstractmethod 40 | def bands(self) -> int: 41 | pass 42 | 43 | @property 44 | @abstractmethod 45 | def default_bands(self) -> list[int]: 46 | pass 47 | 48 | @property 49 | @abstractmethod 50 | def wavelengths(self) -> list[float]: 51 | pass 52 | 53 | @property 54 | @abstractmethod 55 | def camera_id(self) -> str: 56 | pass 57 | 58 | @abstractmethod 59 | def to_display(self, equalize: bool = True) -> Image.Image: 60 | pass 61 | 62 | @abstractmethod 63 | def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]: 64 | pass 65 | 66 | @abstractmethod 67 | def to_xarray(self) -> "XarrayType": 68 | pass 69 | -------------------------------------------------------------------------------- /siapy/entities/images/mock.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Any 3 | 4 | import numpy as np 5 | import xarray as xr 6 | from numpy.typing import NDArray 7 | from PIL import Image 8 | 9 | from siapy.core.exceptions import InvalidInputError 10 | from siapy.entities.images.interfaces import ImageBase 11 | 12 | if TYPE_CHECKING: 13 | from siapy.core.types import XarrayType 14 | 15 | 16 | class MockImage(ImageBase): 17 | def __init__( 18 | self, 19 | array: NDArray[np.floating[Any]], 20 | ) -> None: 21 | if len(array.shape) != 3: 22 | raise InvalidInputError( 23 | input_value=array.shape, 24 | message="Input array must be 3-dimensional (height, width, bands)", 25 | ) 26 | 27 | self._array = array.astype(np.float32) 28 | 29 | @classmethod 30 | def open(cls, array: NDArray[np.floating[Any]]) -> "MockImage": 31 | return cls(array=array) 32 | 33 | @property 34 | def filepath(self) -> Path: 35 | return Path() 36 | 37 | @property 38 | def metadata(self) -> dict[str, Any]: 39 | return {} 40 | 41 | @property 42 | def shape(self) -> tuple[int, int, int]: 43 | x = self._array.shape[1] 44 | y = self._array.shape[0] 45 | bands = self._array.shape[2] 46 | return (y, x, bands) 47 | 48 | @property 49 | def bands(self) -> int: 50 | return self._array.shape[2] 51 | 52 | @property 53 | def default_bands(self) -> list[int]: 54 | if self.bands >= 3: 55 | return [0, 1, 2] 56 | return list(range(min(3, self.bands))) 57 | 58 | @property 59 | def wavelengths(self) -> list[float]: 60 | return list(range(self.bands)) 61 | 62 | @property 63 | def camera_id(self) -> str: 64 | return "" 65 | 66 | def to_display(self, equalize: bool = True) -> Image.Image: 67 | if self.bands >= 3: 68 | display_bands = self._array[:, :, self.default_bands] 69 | else: 70 | display_bands = np.stack([self._array[:, :, 0]] * 3, axis=2) 71 | 72 | if equalize: 73 | for i in range(display_bands.shape[2]): 74 | band = display_bands[:, :, i] 75 | non_nan = ~np.isnan(band) 76 | if np.any(non_nan): 77 | min_val = np.nanmin(band) 78 | max_val = np.nanmax(band) 79 | if max_val > min_val: 80 | band = (band - min_val) / (max_val - min_val) * 255 81 | display_bands[:, :, i] = band 82 | 83 | display_array = np.nan_to_num(display_bands).astype(np.uint8) 84 | return Image.fromarray(display_array) 85 | 86 | def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]: 87 | if nan_value is not None: 88 | return np.nan_to_num(self._array, nan=nan_value) 89 | return self._array.copy() 90 | 91 | def to_xarray(self) -> "XarrayType": 92 | return xr.DataArray( 93 | self._array, 94 | dims=["y", "x", "band"], 95 | coords={ 96 | "band": self.wavelengths, 97 | "x": np.arange(self.shape[1]), 98 | "y": np.arange(self.shape[0]), 99 | }, 100 | attrs={ 101 | "camera_id": self.camera_id, 102 | }, 103 | ) 104 | -------------------------------------------------------------------------------- /siapy/entities/images/rasterio_lib.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import numpy as np 6 | import rioxarray 7 | from numpy.typing import NDArray 8 | from PIL import Image, ImageOps 9 | 10 | from siapy.core.exceptions import InvalidFilepathError, InvalidInputError 11 | 12 | from .interfaces import ImageBase 13 | 14 | if TYPE_CHECKING: 15 | from siapy.core.types import XarrayType 16 | 17 | __all__ = [ 18 | "RasterioLibImage", 19 | ] 20 | 21 | 22 | @dataclass 23 | class RasterioLibImage(ImageBase): 24 | def __init__(self, file: "XarrayType"): 25 | self._file = file 26 | 27 | @classmethod 28 | def open(cls, filepath: str | Path) -> "RasterioLibImage": 29 | filepath = Path(filepath) 30 | if not filepath.exists(): 31 | raise InvalidFilepathError(filepath) 32 | 33 | try: 34 | raster = rioxarray.open_rasterio(filepath) 35 | except Exception as e: 36 | raise InvalidInputError({"filepath": str(filepath)}, f"Failed to open raster file: {e}") from e 37 | 38 | if isinstance(raster, list): 39 | raise InvalidInputError({"file_type": type(raster).__name__}, "Expected DataArray or Dataset, got list") 40 | 41 | return cls(raster) 42 | 43 | @property 44 | def file(self) -> "XarrayType": 45 | return self._file 46 | 47 | @property 48 | def filepath(self) -> Path: 49 | return Path(self.file.encoding["source"]) 50 | 51 | @property 52 | def metadata(self) -> dict[str, Any]: 53 | return self.file.attrs 54 | 55 | @property 56 | def shape(self) -> tuple[int, int, int]: 57 | # rioxarray uses (band, y, x) ordering 58 | return (self.file.y.size, self.file.x.size, self.file.band.size) 59 | 60 | @property 61 | def rows(self) -> int: 62 | return self.file.y.size 63 | 64 | @property 65 | def cols(self) -> int: 66 | return self.file.x.size 67 | 68 | @property 69 | def bands(self) -> int: 70 | return self.file.band.size 71 | 72 | @property 73 | def default_bands(self) -> list[int]: 74 | # Most common RGB band combination for satellite imagery 75 | return list(range(1, min(3, self.bands) + 1)) 76 | 77 | @property 78 | def wavelengths(self) -> list[float]: 79 | return self.file.band.values 80 | 81 | @property 82 | def camera_id(self) -> str: 83 | # Todo: camera_id is not a standard metadata field, should be updated 84 | return self.metadata.get("camera_id", "") 85 | 86 | def to_display(self, equalize: bool = True) -> Image.Image: 87 | bands_data = self.file.sel(band=self.default_bands) 88 | image_3ch = bands_data.transpose("y", "x", "band").values 89 | image_3ch_clean = np.nan_to_num(np.asarray(image_3ch)) 90 | min_val = np.nanmin(image_3ch_clean) 91 | max_val = np.nanmax(image_3ch_clean) 92 | 93 | image_scaled = ((image_3ch_clean - min_val) * (255.0 / (max_val - min_val))).astype(np.uint8) 94 | 95 | image = Image.fromarray(image_scaled) 96 | if equalize: 97 | image = ImageOps.equalize(image) 98 | return image 99 | 100 | def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]: 101 | image = np.asarray(self.file.transpose("y", "x", "band").values) 102 | if nan_value is not None: 103 | image = np.nan_to_num(image, nan=nan_value) 104 | return image 105 | 106 | def to_xarray(self) -> "XarrayType": 107 | return self.file 108 | -------------------------------------------------------------------------------- /siapy/entities/imagesets.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Any, Iterator, Sequence 4 | 5 | import numpy as np 6 | from rich.progress import track 7 | 8 | from siapy.core import logger 9 | from siapy.core.exceptions import InvalidInputError 10 | from siapy.entities import SpectralImage 11 | 12 | __all__ = [ 13 | "SpectralImageSet", 14 | ] 15 | 16 | 17 | @dataclass 18 | class SpectralImageSet: 19 | def __init__(self, spectral_images: list[SpectralImage[Any]] | None = None): 20 | self._images = spectral_images if spectral_images is not None else [] 21 | 22 | def __len__(self) -> int: 23 | return len(self.images) 24 | 25 | def __str__(self) -> str: 26 | return f"<{self.__class__.__name__} object with {len(self)} spectral images>" 27 | 28 | def __iter__(self) -> Iterator[SpectralImage[Any]]: 29 | return iter(self.images) 30 | 31 | def __getitem__(self, index: int) -> SpectralImage[Any]: 32 | return self.images[index] 33 | 34 | @classmethod 35 | def spy_open( 36 | cls, 37 | *, 38 | header_paths: Sequence[str | Path], 39 | image_paths: Sequence[str | Path] | None = None, 40 | ) -> "SpectralImageSet": 41 | if image_paths is not None and len(header_paths) != len(image_paths): 42 | raise InvalidInputError( 43 | { 44 | "header_paths_length": len(header_paths), 45 | "image_paths_length": len(image_paths), 46 | }, 47 | "The length of hdr_paths and img_path must be equal.", 48 | ) 49 | 50 | if image_paths is None: 51 | spectral_images = [ 52 | SpectralImage.spy_open(header_path=hdr_path) 53 | for hdr_path in track(header_paths, description="Loading spectral images...") 54 | ] 55 | else: 56 | spectral_images = [ 57 | SpectralImage.spy_open(header_path=hdr_path, image_path=img_path) 58 | for hdr_path, img_path in track( 59 | zip(header_paths, image_paths), 60 | description="Loading spectral images...", 61 | ) 62 | ] 63 | logger.info("Spectral images loaded into memory.") 64 | return cls(spectral_images) 65 | 66 | @classmethod 67 | def rasterio_open( 68 | cls, 69 | *, 70 | filepaths: Sequence[str | Path], 71 | ) -> "SpectralImageSet": 72 | spectral_images = [ 73 | SpectralImage.rasterio_open(filepath) 74 | for filepath in track(filepaths, description="Loading raster images...") 75 | ] 76 | logger.info("Raster images loaded into memory.") 77 | return cls(spectral_images) 78 | 79 | @property 80 | def images(self) -> list[SpectralImage[Any]]: 81 | return self._images 82 | 83 | @property 84 | def cameras_id(self) -> list[str]: 85 | return list({image.camera_id for image in self.images}) 86 | 87 | def images_by_camera_id(self, camera_id: str) -> list[SpectralImage[Any]]: 88 | ids = np.array([image.camera_id for image in self.images]) 89 | indices = np.nonzero(ids == camera_id)[0] 90 | return [image for idx, image in enumerate(self.images) if idx in indices] 91 | 92 | def sort(self, key: Any = None, reverse: bool = False) -> None: 93 | self.images.sort(key=key, reverse=reverse) 94 | -------------------------------------------------------------------------------- /siapy/entities/shapes/__init__.py: -------------------------------------------------------------------------------- 1 | from .geometric_shapes import GeometricShapes 2 | from .shape import Shape, ShapeGeometryEnum 3 | 4 | __all__ = [ 5 | "GeometricShapes", 6 | "Shape", 7 | "ShapeGeometryEnum", 8 | ] 9 | -------------------------------------------------------------------------------- /siapy/entities/shapes/geometric_shapes.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass 3 | from typing import TYPE_CHECKING, Any, Iterable, Iterator, Optional 4 | 5 | from siapy.core.exceptions import InvalidInputError 6 | 7 | from .shape import Shape 8 | 9 | if TYPE_CHECKING: 10 | from siapy.entities import SpectralImage 11 | 12 | 13 | __all__ = [ 14 | "GeometricShapes", 15 | ] 16 | 17 | 18 | @dataclass 19 | class GeometricShapes: 20 | def __init__( 21 | self, 22 | image: "SpectralImage[Any]", 23 | geometric_shapes: list["Shape"] | None = None, 24 | ): 25 | self._image = image 26 | self._geometric_shapes = geometric_shapes if geometric_shapes is not None else [] 27 | _check_shape_type(self._geometric_shapes, is_list=True) 28 | 29 | def __repr__(self) -> str: 30 | return f"GeometricShapes(\n{self._geometric_shapes}\n)" 31 | 32 | def __iter__(self) -> Iterator["Shape"]: 33 | return iter(self.shapes) 34 | 35 | def __getitem__(self, index: int) -> "Shape": 36 | return self.shapes[index] 37 | 38 | def __setitem__(self, index: int, shape: "Shape") -> None: 39 | _check_shape_type(shape, is_list=False) 40 | self._geometric_shapes[index] = shape 41 | 42 | def __len__(self) -> int: 43 | return len(self._geometric_shapes) 44 | 45 | def __eq__(self, other: Any) -> bool: 46 | if not isinstance(other, GeometricShapes): 47 | raise InvalidInputError( 48 | { 49 | "other_type": type(other).__name__, 50 | }, 51 | "Comparison is only supported between GeometricShapes instances.", 52 | ) 53 | return self._geometric_shapes == other._geometric_shapes and self._image == other._image 54 | 55 | @property 56 | def shapes(self) -> list["Shape"]: 57 | return self._geometric_shapes.copy() 58 | 59 | @shapes.setter 60 | def shapes(self, shapes: list["Shape"]) -> None: 61 | _check_shape_type(shapes, is_list=True) 62 | self._geometric_shapes = shapes 63 | 64 | def append(self, shape: "Shape") -> None: 65 | _check_shape_type(shape, is_list=False) 66 | self._geometric_shapes.append(shape) 67 | 68 | def extend(self, shapes: Iterable["Shape"]) -> None: 69 | _check_shape_type(shapes, is_list=True) 70 | self._geometric_shapes.extend(shapes) 71 | 72 | def insert(self, index: int, shape: "Shape") -> None: 73 | _check_shape_type(shape, is_list=False) 74 | self._geometric_shapes.insert(index, shape) 75 | 76 | def remove(self, shape: "Shape") -> None: 77 | _check_shape_type(shape, is_list=False) 78 | self._geometric_shapes.remove(shape) 79 | 80 | def pop(self, index: int = -1) -> "Shape": 81 | return self._geometric_shapes.pop(index) 82 | 83 | def clear(self) -> None: 84 | self._geometric_shapes.clear() 85 | 86 | def index(self, shape: "Shape", start: int = 0, stop: int = sys.maxsize) -> int: 87 | _check_shape_type(shape, is_list=False) 88 | return self._geometric_shapes.index(shape, start, stop) 89 | 90 | def count(self, shape: "Shape") -> int: 91 | _check_shape_type(shape, is_list=False) 92 | return self._geometric_shapes.count(shape) 93 | 94 | def reverse(self) -> None: 95 | self._geometric_shapes.reverse() 96 | 97 | def sort(self, key: Any = None, reverse: bool = False) -> None: 98 | self._geometric_shapes.sort(key=key, reverse=reverse) 99 | 100 | def get_by_name(self, name: str) -> Optional["Shape"]: 101 | names = [shape.label for shape in self.shapes] 102 | if name in names: 103 | index = names.index(name) 104 | return self.shapes[index] 105 | return None 106 | 107 | 108 | def _check_shape_type(shapes: "Shape" | Iterable["Shape"], is_list: bool = False) -> None: 109 | if is_list and isinstance(shapes, Shape): 110 | raise InvalidInputError( 111 | { 112 | "shapes_type": type(shapes).__name__, 113 | }, 114 | "Expected an iterable of Shape instances, but got a single Shape instance.", 115 | ) 116 | 117 | if not is_list and isinstance(shapes, Shape): 118 | return 119 | 120 | if not isinstance(shapes, Iterable): 121 | raise InvalidInputError( 122 | { 123 | "shapes_type": type(shapes).__name__, 124 | }, 125 | "Shapes must be an instance of Shape or an iterable of Shape instances.", 126 | ) 127 | 128 | if not all(isinstance(shape, Shape) for shape in shapes): 129 | raise InvalidInputError( 130 | { 131 | "invalid_items": [type(shape).__name__ for shape in shapes if not isinstance(shape, Shape)], 132 | }, 133 | "All items must be instances of Shape subclass.", 134 | ) 135 | -------------------------------------------------------------------------------- /siapy/features/__init__.py: -------------------------------------------------------------------------------- 1 | from .features import ( 2 | AutoFeatClassification, 3 | AutoFeatRegression, 4 | AutoSpectralIndicesClassification, 5 | AutoSpectralIndicesRegression, 6 | ) 7 | 8 | __all__ = [ 9 | "AutoFeatClassification", 10 | "AutoFeatRegression", 11 | "AutoSpectralIndicesClassification", 12 | "AutoSpectralIndicesRegression", 13 | ] 14 | -------------------------------------------------------------------------------- /siapy/features/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Literal 2 | 3 | from mlxtend.feature_selection import SequentialFeatureSelector 4 | from pydantic import BaseModel, ConfigDict 5 | from sklearn.linear_model import Ridge, RidgeClassifier 6 | from sklearn.pipeline import Pipeline, make_pipeline 7 | from sklearn.preprocessing import RobustScaler 8 | 9 | from siapy.core.exceptions import InvalidInputError 10 | 11 | __all__ = [ 12 | "FeatureSelectorConfig", 13 | "feature_selector_factory", 14 | ] 15 | 16 | 17 | class FeatureSelectorConfig(BaseModel): 18 | k_features: Annotated[ 19 | int | str | tuple[int, ...], 20 | "can be: 'best' - most extensive, (1, n) - check range of features, n - exact number of features", 21 | ] = (1, 20) 22 | cv: int = 3 23 | forward: Annotated[bool, "selection in forward direction"] = True 24 | floating: Annotated[bool, "floating algorithm - can go back and remove features once added"] = True 25 | verbose: int = 2 26 | n_jobs: int = 1 27 | pre_dispatch: int | str = "2*n_jobs" 28 | 29 | model_config = ConfigDict( 30 | arbitrary_types_allowed=True, 31 | validate_assignment=True, 32 | ) 33 | 34 | 35 | def feature_selector_factory( 36 | problem_type: Literal["regression", "classification"], 37 | *, 38 | k_features: Annotated[ 39 | int | str | tuple[int, ...], 40 | "can be: 'best' - most extensive, (1, n) - check range of features, n - exact number of features", 41 | ] = (1, 20), 42 | cv: int = 3, 43 | forward: Annotated[bool, "selection in forward direction"] = True, 44 | floating: Annotated[bool, "floating algorithm - can go back and remove features once added"] = True, 45 | verbose: int = 2, 46 | n_jobs: int = 1, 47 | pre_dispatch: int | str = "2*n_jobs", 48 | config: Annotated[ 49 | FeatureSelectorConfig | None, 50 | "If provided, other arguments are overwritten by config values", 51 | ] = None, 52 | ) -> Pipeline: 53 | if config: 54 | k_features = config.k_features 55 | cv = config.cv 56 | forward = config.forward 57 | floating = config.floating 58 | verbose = config.verbose 59 | n_jobs = config.n_jobs 60 | pre_dispatch = config.pre_dispatch 61 | 62 | if problem_type == "regression": 63 | algo = Ridge() 64 | scoring = "neg_mean_squared_error" 65 | elif problem_type == "classification": 66 | algo = RidgeClassifier() 67 | scoring = "f1_weighted" 68 | else: 69 | raise InvalidInputError( 70 | problem_type, 71 | "Invalid problem type, possible values are: 'regression' or 'classification'", 72 | ) 73 | sfs = SequentialFeatureSelector( 74 | estimator=algo, 75 | k_features=k_features, # type: ignore # noqa 76 | forward=forward, 77 | floating=floating, 78 | verbose=verbose, 79 | scoring=scoring, 80 | cv=cv, 81 | n_jobs=n_jobs, 82 | pre_dispatch=pre_dispatch, # type: ignore # noqa 83 | ) 84 | return make_pipeline(RobustScaler(), sfs, memory=None) 85 | 86 | 87 | """ 88 | -- Check plot: performance vs number of features -- 89 | import matplotlib.pyplot as plt 90 | from mlxtend.plotting import plot_sequential_feature_selection as plot_sfs 91 | plot_sfs(self.selector.get_metric_dict(), kind='std_err', figsize=(30, 20)) 92 | plt.savefig('selection.png') 93 | plt.close() 94 | """ 95 | -------------------------------------------------------------------------------- /siapy/features/spectral_indices.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Any, Iterable 3 | 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from siapy.core.exceptions import InvalidInputError 8 | 9 | with warnings.catch_warnings(): 10 | warnings.filterwarnings( 11 | "ignore", 12 | category=DeprecationWarning, 13 | message="pkg_resources is deprecated as an API", 14 | ) 15 | import spyndex # type: ignore 16 | 17 | __all__ = [ 18 | "get_spectral_indices", 19 | "compute_spectral_indices", 20 | ] 21 | 22 | 23 | def _convert_str_to_list(bands_acronym: Any) -> Any: 24 | if isinstance(bands_acronym, str): 25 | bands_acronym = [bands_acronym] 26 | return bands_acronym 27 | 28 | 29 | def get_spectral_indices( 30 | bands_acronym: str | Iterable[str], 31 | ) -> dict[str, spyndex.axioms.SpectralIndex]: 32 | bands_acronym = _convert_str_to_list(bands_acronym) 33 | bands_acronym_set = set(bands_acronym) 34 | 35 | if not bands_acronym_set.issubset(list(spyndex.bands)): 36 | raise InvalidInputError( 37 | { 38 | "received_bands_acronym": bands_acronym_set, 39 | "valid_bands_acronym": list(spyndex.bands), 40 | }, 41 | "Invalid input argument for 'bands_acronym'. Please ensure that all elements in 'bands_acronym' are valid band acronyms.", 42 | ) 43 | 44 | spectral_indexes = {} 45 | for name in spyndex.indices.to_dict(): 46 | index = spyndex.indices[name] 47 | if set(index.bands).issubset(bands_acronym_set): 48 | spectral_indexes[name] = index 49 | 50 | return spectral_indexes 51 | 52 | 53 | def compute_spectral_indices( 54 | data: pd.DataFrame, 55 | spectral_indices: str | Iterable[str], 56 | bands_map: dict[str, str] | None = None, 57 | remove_nan_and_constants: bool = True, 58 | ) -> pd.DataFrame: 59 | spectral_indices = _convert_str_to_list(spectral_indices) 60 | 61 | params = {} 62 | for band in data.columns: 63 | if bands_map is not None and band in bands_map.keys(): 64 | if bands_map[band] not in list(spyndex.bands): 65 | raise InvalidInputError( 66 | { 67 | "received_band_mapping": bands_map[band], 68 | "valid_bands_acronym": list(spyndex.bands), 69 | }, 70 | f"Invalid band mapping is not a recognized band acronym. \n" 71 | f"Received mapping: {band} -> {bands_map[band]}. \n" 72 | "Please ensure that all values in 'bands_map' are valid band acronyms.", 73 | ) 74 | params[bands_map[band]] = data[band] 75 | else: 76 | if band not in list(spyndex.bands): 77 | raise InvalidInputError( 78 | { 79 | "received_band": band, 80 | "valid_bands_acronym": list(spyndex.bands), 81 | }, 82 | f"Invalid band: '{band}' is not a recognized band acronym. \n" 83 | "Please ensure that all columns in 'data' are valid band acronyms.", 84 | ) 85 | params[band] = data[band] 86 | 87 | df = spyndex.computeIndex(index=list(spectral_indices), params=params) 88 | if remove_nan_and_constants: 89 | # Drop columns with inf or NaN values 90 | df = df.drop(df.columns[df.isin([np.inf, -np.inf, np.nan]).any()], axis=1) 91 | # Drop columns with constant values 92 | df = df.drop(df.columns[df.nunique() == 1], axis=1) 93 | return df 94 | -------------------------------------------------------------------------------- /siapy/optimizers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/siapy/optimizers/__init__.py -------------------------------------------------------------------------------- /siapy/optimizers/configs.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Iterable, Literal 2 | 3 | import optuna 4 | from pydantic import BaseModel, ConfigDict 5 | 6 | from siapy.optimizers.parameters import TrialParameters 7 | from siapy.optimizers.scorers import Scorer 8 | 9 | __all__ = [ 10 | "CreateStudyConfig", 11 | "OptimizeStudyConfig", 12 | "TabularOptimizerConfig", 13 | ] 14 | 15 | 16 | class CreateStudyConfig(BaseModel): 17 | model_config = ConfigDict(arbitrary_types_allowed=True) 18 | storage: str | optuna.storages.BaseStorage | None = None 19 | sampler: optuna.samplers.BaseSampler | None = None 20 | pruner: optuna.pruners.BasePruner | None = None 21 | study_name: str | None = None 22 | direction: Literal["maximize", "minimize"] | optuna.study.StudyDirection | None = "minimize" 23 | load_if_exists: bool = False 24 | 25 | 26 | class OptimizeStudyConfig(BaseModel): 27 | model_config = ConfigDict(arbitrary_types_allowed=True) 28 | n_trials: int | None = None 29 | timeout: float | None = None 30 | n_jobs: int = -1 31 | catch: Iterable[type[Exception]] | type[Exception] = () 32 | callbacks: list[Callable[[optuna.study.Study, optuna.trial.FrozenTrial], None]] | None = None 33 | gc_after_trial: bool = False 34 | show_progress_bar: bool = True 35 | 36 | 37 | class TabularOptimizerConfig(BaseModel): 38 | model_config = ConfigDict(arbitrary_types_allowed=True) 39 | create_study: CreateStudyConfig = CreateStudyConfig() 40 | optimize_study: OptimizeStudyConfig = OptimizeStudyConfig() 41 | scorer: Scorer | None = None 42 | trial_parameters: TrialParameters | None = None 43 | -------------------------------------------------------------------------------- /siapy/optimizers/evaluators.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any, Callable, Iterable, Literal 2 | 3 | import numpy as np 4 | from numpy.typing import NDArray 5 | from sklearn.base import BaseEstimator 6 | from sklearn.metrics import get_scorer 7 | from sklearn.model_selection import ( 8 | BaseCrossValidator, 9 | cross_val_score, 10 | train_test_split, 11 | ) 12 | 13 | from siapy.core import logger 14 | from siapy.core.exceptions import ( 15 | InvalidInputError, 16 | MethodNotImplementedError, 17 | ) 18 | from siapy.core.types import ArrayLike1dType, ArrayLike2dType 19 | 20 | __all__ = [ 21 | "cross_validation", 22 | "hold_out_validation", 23 | ] 24 | 25 | ScorerFuncType = Callable[[BaseEstimator, ArrayLike2dType, ArrayLike1dType], float] 26 | 27 | 28 | def check_model_prediction_methods(model: BaseEstimator) -> None: 29 | required_methods = ["fit", "predict", "score"] 30 | for method in required_methods: 31 | if not hasattr(model, method): 32 | raise MethodNotImplementedError(model.__class__.__name__, method) 33 | 34 | 35 | def cross_validation( 36 | model: BaseEstimator, 37 | X: ArrayLike2dType, 38 | y: ArrayLike1dType, 39 | X_val: Annotated[ArrayLike2dType | None, "Not used, only for compatibility"] = None, 40 | y_val: Annotated[ArrayLike1dType | None, "Not used, only for compatibility"] = None, 41 | *, 42 | groups: ArrayLike1dType | None = None, 43 | scoring: str | ScorerFuncType | None = None, 44 | cv: int | BaseCrossValidator | Iterable[Any] | None = None, 45 | n_jobs: int | None = 1, 46 | verbose: int = 0, 47 | params: dict[str, Any] | None = None, 48 | pre_dispatch: int | str = 1, 49 | error_score: Literal["raise"] | int = 0, 50 | ) -> float: 51 | if X_val is not None or y_val is not None: 52 | logger.info("Specification of X_val and y_val is redundant for cross_validation.These parameters are ignored.") 53 | check_model_prediction_methods(model) 54 | score = cross_val_score( 55 | estimator=model, 56 | X=X, # type: ignore 57 | y=y, 58 | groups=groups, 59 | scoring=scoring, 60 | cv=cv, 61 | n_jobs=n_jobs, 62 | verbose=verbose, 63 | params=params, 64 | pre_dispatch=pre_dispatch, 65 | error_score=error_score, 66 | ) 67 | return score.mean() 68 | 69 | 70 | def hold_out_validation( 71 | model: BaseEstimator, 72 | X: ArrayLike2dType, 73 | y: ArrayLike1dType, 74 | X_val: ArrayLike2dType | None = None, 75 | y_val: ArrayLike1dType | None = None, 76 | *, 77 | scoring: str | ScorerFuncType | None = None, 78 | test_size: float | None = 0.2, 79 | random_state: int | None = None, 80 | shuffle: bool = True, 81 | stratify: NDArray[np.floating[Any]] | None = None, 82 | ) -> float: 83 | if X_val is not None and y_val is not None: 84 | x_train, x_test, y_train, y_test = X, X_val, y, y_val 85 | elif X_val is not None or y_val is not None: 86 | raise InvalidInputError( 87 | input_value={"X_val": X_val, "y_val": y_val}, 88 | message="To manually define validation set, both X_val and y_val must be specified.", 89 | ) 90 | else: 91 | x_train, x_test, y_train, y_test = train_test_split( 92 | X, 93 | y, 94 | test_size=test_size, 95 | random_state=random_state, 96 | shuffle=shuffle, 97 | stratify=stratify, 98 | ) 99 | check_model_prediction_methods(model) 100 | model.fit(x_train, y_train) # type: ignore 101 | 102 | if scoring: 103 | if isinstance(scoring, str): 104 | scoring_func = get_scorer(scoring) 105 | else: 106 | scoring_func = scoring 107 | score = scoring_func(model, x_test, y_test) 108 | else: 109 | score = model.score(x_test, y_test) # type: ignore 110 | return score 111 | -------------------------------------------------------------------------------- /siapy/optimizers/metrics.py: -------------------------------------------------------------------------------- 1 | # cSpell:disable 2 | from typing import Any, Literal, NamedTuple 3 | 4 | import numpy as np 5 | from numpy.typing import NDArray 6 | from sklearn.metrics import ( 7 | accuracy_score, 8 | f1_score, 9 | max_error, 10 | mean_absolute_error, 11 | mean_absolute_percentage_error, 12 | mean_squared_error, 13 | precision_score, 14 | r2_score, 15 | recall_score, 16 | root_mean_squared_error, 17 | ) 18 | 19 | from siapy.core.exceptions import InvalidInputError 20 | 21 | __all__ = [ 22 | "calculate_classification_metrics", 23 | "calculate_regression_metrics", 24 | ] 25 | 26 | 27 | def normalized_rmse( 28 | y_true: NDArray[np.floating[Any]], 29 | y_pred: NDArray[np.floating[Any]], 30 | normalize_by: Literal["range", "mean"] = "range", 31 | ) -> float: 32 | rmse = root_mean_squared_error(y_true, y_pred) 33 | if normalize_by == "range": 34 | normalizer = np.max(y_true) - np.min(y_true) 35 | elif normalize_by == "mean": 36 | normalizer = np.mean(y_true) 37 | else: 38 | raise InvalidInputError( 39 | input_value=normalize_by, 40 | message="Unknown normalizer. Possible values are: 'range' or 'mean'.", 41 | ) 42 | return float(rmse / normalizer) 43 | 44 | 45 | class ClassificationMetrics(NamedTuple): 46 | accuracy: float 47 | precision: float 48 | recall: float 49 | f1: float 50 | 51 | def __str__(self) -> str: 52 | return ( 53 | f"Accuracy: {self.accuracy:.2f}\n" 54 | f"Precision: {self.precision:.2f}\n" 55 | f"Recall: {self.recall:.2f}\n" 56 | f"F1: {self.f1:.2f}\n" 57 | ) 58 | 59 | def to_dict(self) -> dict[str, float]: 60 | return self._asdict() 61 | 62 | 63 | class RegressionMetrics(NamedTuple): 64 | mae: float 65 | mse: float 66 | rmse: float 67 | r2: float 68 | pe: float 69 | maxe: float 70 | nrmse_mean: float 71 | nrmse_range: float 72 | 73 | def __str__(self) -> str: 74 | return ( 75 | f"Mean absolute error: {self.mae:.2f}\n" 76 | f"Mean squared error: {self.mse:.2f}\n" 77 | f"Root mean squared error: {self.rmse:.2f}\n" 78 | f"R2 score: {self.r2:.2f}\n" 79 | f"Mean absolute percentage error: {self.pe:.2f}\n" 80 | f"Max error: {self.maxe:.2f}\n" 81 | f"Normalized root mean squared error (by mean): {self.nrmse_mean:.2f}\n" 82 | f"Normalized root mean squared error (by range): {self.nrmse_range:.2f}\n" 83 | ) 84 | 85 | def to_dict(self) -> dict[str, float]: 86 | return self._asdict() 87 | 88 | 89 | def calculate_classification_metrics( 90 | y_true: NDArray[np.floating[Any]], 91 | y_pred: NDArray[np.floating[Any]], 92 | average: Literal["micro", "macro", "samples", "weighted", "binary"] | None = "weighted", 93 | ) -> ClassificationMetrics: 94 | accuracy = float(accuracy_score(y_true, y_pred)) 95 | precision = float(precision_score(y_true, y_pred, average=average)) 96 | recall = float(recall_score(y_true, y_pred, average=average)) 97 | f1 = float(f1_score(y_true, y_pred, average=average)) 98 | return ClassificationMetrics( 99 | accuracy=accuracy, 100 | precision=precision, 101 | recall=recall, 102 | f1=f1, 103 | ) 104 | 105 | 106 | def calculate_regression_metrics( 107 | y_true: NDArray[np.floating[Any]], 108 | y_pred: NDArray[np.floating[Any]], 109 | ) -> RegressionMetrics: 110 | mae = float(mean_absolute_error(y_true, y_pred)) 111 | mse = float(mean_squared_error(y_true, y_pred)) 112 | rmse = float(root_mean_squared_error(y_true, y_pred)) 113 | r2 = float(r2_score(y_true, y_pred)) 114 | pe = float(mean_absolute_percentage_error(y_true, y_pred)) 115 | maxe = float(max_error(y_true, y_pred)) 116 | nrmse_mean = float(normalized_rmse(y_true, y_pred, normalize_by="mean")) 117 | nrmse_range = float(normalized_rmse(y_true, y_pred, normalize_by="range")) 118 | return RegressionMetrics( 119 | mae=mae, 120 | mse=mse, 121 | rmse=rmse, 122 | r2=r2, 123 | pe=pe, 124 | maxe=maxe, 125 | nrmse_mean=nrmse_mean, 126 | nrmse_range=nrmse_range, 127 | ) 128 | -------------------------------------------------------------------------------- /siapy/optimizers/parameters.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Annotated, Any, Sequence 3 | 4 | from pydantic import BaseModel 5 | 6 | __all__ = [ 7 | "FloatParameter", 8 | "IntParameter", 9 | "CategoricalParameter", 10 | "TrialParameters", 11 | ] 12 | 13 | 14 | class FloatParameter(BaseModel): 15 | name: str 16 | low: float 17 | high: float 18 | step: float | None = None 19 | log: bool = False 20 | 21 | 22 | class IntParameter(BaseModel): 23 | name: str 24 | low: int 25 | high: int 26 | step: int = 1 27 | log: bool = False 28 | 29 | 30 | class CategoricalParameter(BaseModel): 31 | name: str 32 | choices: Sequence[None | bool | int | float | str] 33 | 34 | 35 | ParametersDictType = dict[ 36 | Annotated[ 37 | str, 38 | "Can be one of: 'float_parameters', 'int_parameters', 'categorical_parameters'.", 39 | ], 40 | list[ 41 | Annotated[ 42 | dict[str, Any], 43 | "Dictionary of parameters, belonging to specific type of parameter.", 44 | ] 45 | ], 46 | ] 47 | 48 | 49 | @dataclass 50 | class TrialParameters: 51 | def __init__( 52 | self, 53 | float_parameters: list[FloatParameter] | None = None, 54 | int_parameters: list[IntParameter] | None = None, 55 | categorical_parameters: list[CategoricalParameter] | None = None, 56 | ): 57 | self._float_parameters = float_parameters or [] 58 | self._int_parameters = int_parameters or [] 59 | self._categorical_parameters = categorical_parameters or [] 60 | 61 | @classmethod 62 | def from_dict(cls, parameters: ParametersDictType) -> "TrialParameters": 63 | float_params = [FloatParameter(**fp) for fp in parameters.get("float_parameters", [])] 64 | int_params = [IntParameter(**ip) for ip in parameters.get("int_parameters", [])] 65 | cat_params = [CategoricalParameter(**cp) for cp in parameters.get("categorical_parameters", [])] 66 | return cls( 67 | float_parameters=float_params, 68 | int_parameters=int_params, 69 | categorical_parameters=cat_params, 70 | ) 71 | 72 | @property 73 | def float_parameters(self) -> list[FloatParameter]: 74 | return self._float_parameters 75 | 76 | @property 77 | def int_parameters(self) -> list[IntParameter]: 78 | return self._int_parameters 79 | 80 | @property 81 | def categorical_parameters(self) -> list[CategoricalParameter]: 82 | return self._categorical_parameters 83 | -------------------------------------------------------------------------------- /siapy/optimizers/scorers.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Annotated, Callable, Iterable, Literal, Any 3 | 4 | import numpy as np 5 | from numpy.typing import NDArray 6 | from sklearn import model_selection 7 | from sklearn.base import BaseEstimator 8 | 9 | from siapy.core.types import ArrayLike1dType, ArrayLike2dType 10 | from siapy.optimizers.evaluators import ( 11 | ScorerFuncType, 12 | cross_validation, 13 | hold_out_validation, 14 | ) 15 | from siapy.utils.general import initialize_object 16 | 17 | __all__ = [ 18 | "Scorer", 19 | ] 20 | 21 | 22 | class Scorer: 23 | def __init__(self, scorer: Callable[..., float]) -> None: 24 | self._scorer = scorer 25 | 26 | def __call__( 27 | self, 28 | model: BaseEstimator, 29 | X: ArrayLike2dType, 30 | y: ArrayLike1dType, 31 | X_val: ArrayLike2dType | None = None, 32 | y_val: ArrayLike1dType | None = None, 33 | ) -> float: 34 | return self._scorer(model, X, y, X_val, y_val) 35 | 36 | @classmethod 37 | def init_cross_validator_scorer( 38 | cls, 39 | scoring: str | ScorerFuncType | None = None, 40 | cv: int 41 | | model_selection.BaseCrossValidator 42 | | model_selection._split._RepeatedSplits 43 | | Iterable[int] 44 | | Literal["RepeatedKFold", "RepeatedStratifiedKFold"] 45 | | None = None, 46 | n_jobs: Annotated[ 47 | int | None, 48 | "Number of jobs to run in parallel. `-1` means using all processors.", 49 | ] = None, 50 | ) -> "Scorer": 51 | if isinstance(cv, str) and cv in [ 52 | "RepeatedKFold", 53 | "RepeatedStratifiedKFold", 54 | ]: 55 | cv = initialize_object( 56 | module=model_selection, 57 | module_name=cv, 58 | n_splits=3, 59 | n_repeats=5, 60 | random_state=0, 61 | ) 62 | scorer = partial( 63 | cross_validation, 64 | scoring=scoring, 65 | cv=cv, # type: ignore 66 | groups=None, 67 | n_jobs=n_jobs, 68 | verbose=0, 69 | params=None, 70 | pre_dispatch=1, 71 | error_score=0, 72 | ) 73 | return cls(scorer) 74 | 75 | @classmethod 76 | def init_hold_out_scorer( 77 | cls, 78 | scoring: str | ScorerFuncType | None = None, 79 | test_size: float | None = 0.2, 80 | stratify: NDArray[np.floating[Any]] | None = None, 81 | ) -> "Scorer": 82 | scorer = partial( 83 | hold_out_validation, 84 | scoring=scoring, 85 | test_size=test_size, 86 | random_state=0, 87 | shuffle=True, 88 | stratify=stratify, 89 | ) 90 | return cls(scorer) 91 | -------------------------------------------------------------------------------- /siapy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/siapy/py.typed -------------------------------------------------------------------------------- /siapy/transformations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/siapy/transformations/__init__.py -------------------------------------------------------------------------------- /siapy/transformations/corregistrator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable, Sequence 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pandas as pd 6 | from numpy.typing import NDArray 7 | 8 | from siapy.entities.pixels import CoordinateInput, Pixels, validate_pixel_input 9 | 10 | __all__ = [ 11 | "map_affine_approx_2d", 12 | "affine_matx_2d", 13 | "align", 14 | "transform", 15 | ] 16 | 17 | 18 | def map_affine_approx_2d( 19 | points_ref: NDArray[np.floating[Any]], points_mov: NDArray[np.floating[Any]] 20 | ) -> NDArray[np.floating[Any]]: 21 | """Affine transformation""" 22 | # U = T*X -> T = U*X'(X*X')^-1 23 | matx_2d = points_ref.transpose() @ points_mov @ np.linalg.inv(points_mov.transpose() @ points_mov) 24 | return matx_2d 25 | 26 | 27 | def affine_matx_2d( 28 | scale: tuple[float, float] | Sequence[float] = (1, 1), 29 | trans: tuple[float, float] | Sequence[float] = (0, 0), 30 | rot: float = 0, 31 | shear: tuple[float, float] | Sequence[float] = (0, 0), 32 | ) -> NDArray[np.floating[Any]]: 33 | """Create arbitrary affine transformation matrix""" 34 | rot = rot * np.pi / 180 35 | matx_scale = np.array(((scale[0], 0, 0), (0, scale[1], 0), (0, 0, 1))) 36 | matx_trans = np.array(((1, 0, trans[0]), (0, 1, trans[1]), (0, 0, 1))) 37 | matx_rot = np.array( 38 | ( 39 | (np.cos(rot), -np.sin(rot), 0), 40 | (np.sin(rot), np.cos(rot), 0), 41 | (0, 0, 1), 42 | ) 43 | ) 44 | matx_shear = np.array(((1, shear[0], 0), (shear[1], 1, 0), (0, 0, 1))) 45 | matx_2d = np.dot(matx_trans, np.dot(matx_shear, np.dot(matx_rot, matx_scale))) 46 | return matx_2d 47 | 48 | 49 | def align( 50 | pixels_ref: Pixels | pd.DataFrame | Iterable[CoordinateInput], 51 | pixels_mov: Pixels | pd.DataFrame | Iterable[CoordinateInput], 52 | *, 53 | eps: float = 1e-6, 54 | max_iter: int = 50, 55 | plot_progress: bool = False, 56 | ) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]: 57 | """Align interactive corresponding points""" 58 | pixels_ref = validate_pixel_input(pixels_ref) 59 | pixels_mov = validate_pixel_input(pixels_mov) 60 | 61 | points_ref = pixels_ref.df_homogenious().to_numpy() 62 | points_mov = pixels_mov.df_homogenious().to_numpy() 63 | 64 | matrices = [] 65 | errors = [] 66 | idx = 0 67 | if plot_progress: 68 | points_mov_orig = points_mov 69 | fig = plt.figure() 70 | ax = fig.add_subplot(111) 71 | 72 | while True: 73 | points_ref_corr = np.array(points_ref) 74 | points_mov_corr = np.array(points_mov) 75 | 76 | matx_2d_combined = map_affine_approx_2d(points_ref_corr, points_mov_corr) 77 | points_mov = np.dot(points_mov, matx_2d_combined.transpose()) 78 | 79 | matrices.append(matx_2d_combined) 80 | errors.append(np.sqrt(np.sum((points_ref_corr[:, :2] - points_mov_corr[:, :2]) ** 2))) 81 | idx = idx + 1 82 | 83 | # check for convergence 84 | matx_diff = np.abs(matx_2d_combined - affine_matx_2d()) 85 | if idx > max_iter or np.all(matx_diff < eps): 86 | break 87 | 88 | matx_2d_combined = affine_matx_2d() # initialize with identity matrix 89 | for matx_2d in matrices: 90 | if plot_progress: 91 | points_mov_corr = np.dot(points_mov_orig, matx_2d_combined.transpose()) 92 | ax.clear() 93 | ax.plot(points_ref[:, 0], points_ref[:, 1], "ob") 94 | ax.plot(points_mov_corr[:, 0], points_mov_corr[:, 1], "om") 95 | fig.canvas.draw() 96 | plt.pause(1) 97 | 98 | # multiply all matrices to get the final transformation 99 | matx_2d_combined = np.dot(matx_2d, matx_2d_combined) 100 | 101 | errors_np = np.array(errors) 102 | 103 | return matx_2d_combined, errors_np 104 | 105 | 106 | def transform( 107 | pixels: Pixels | pd.DataFrame | Iterable[CoordinateInput], transformation_matx: NDArray[np.floating[Any]] 108 | ) -> Pixels: 109 | """Transform pixels""" 110 | pixels = validate_pixel_input(pixels) 111 | points_transformed = np.dot(pixels.df_homogenious().to_numpy(), transformation_matx.transpose()) 112 | points_transformed = np.round(points_transformed[:, :2]).astype("int") 113 | return Pixels.from_iterable(points_transformed) 114 | -------------------------------------------------------------------------------- /siapy/transformations/image.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Any, Callable 3 | 4 | import numpy as np 5 | from numpy.typing import NDArray 6 | from skimage import transform 7 | 8 | from siapy.core.types import ImageSizeType, ImageType 9 | from siapy.utils.image_validators import validate_image_size, validate_image_to_numpy 10 | 11 | __all__ = [ 12 | "add_gaussian_noise", 13 | "random_crop", 14 | "random_mirror", 15 | "random_rotation", 16 | "rescale", 17 | "area_normalization", 18 | ] 19 | 20 | 21 | def add_gaussian_noise( 22 | image: ImageType, 23 | mean: float = 0.0, 24 | std: float = 1.0, 25 | clip_to_max: bool = True, 26 | ) -> NDArray[np.floating[Any]]: 27 | image_np = validate_image_to_numpy(image) 28 | rng = np.random.default_rng() 29 | noise = rng.normal(loc=mean, scale=std, size=image_np.shape) 30 | image_np = image_np + noise 31 | if clip_to_max: 32 | image_np = np.clip(image_np, 0, np.max(image_np)) 33 | return image_np 34 | 35 | 36 | def random_crop(image: ImageType, output_size: ImageSizeType) -> NDArray[np.floating[Any]]: 37 | image_np = validate_image_to_numpy(image) 38 | output_size = validate_image_size(output_size) 39 | h, w = image_np.shape[:2] 40 | new_h, new_w = output_size 41 | top = np.random.randint(0, h - new_h) 42 | left = np.random.randint(0, w - new_w) 43 | return image_np[top : top + new_h, left : left + new_w] 44 | 45 | 46 | def random_mirror(image: ImageType) -> NDArray[np.floating[Any]]: 47 | image_np = validate_image_to_numpy(image) 48 | axis = random.choices([0, 1, (0, 1), None])[0] 49 | if isinstance(axis, int) or isinstance(axis, tuple): 50 | image_np = np.flip(image_np, axis=axis) 51 | return image_np 52 | 53 | 54 | def random_rotation(image: ImageType, angle: float) -> NDArray[np.floating[Any]]: 55 | image_np = validate_image_to_numpy(image) 56 | rotated_image = transform.rotate(image_np, angle, preserve_range=True) 57 | return rotated_image 58 | 59 | 60 | def rescale(image: ImageType, output_size: ImageSizeType) -> NDArray[np.floating[Any]]: 61 | image_np = validate_image_to_numpy(image) 62 | output_size = validate_image_size(output_size) 63 | rescaled_image = transform.resize(image_np, output_size, preserve_range=True) 64 | return rescaled_image 65 | 66 | 67 | def area_normalization(image: ImageType) -> NDArray[np.floating[Any]]: 68 | image_np = validate_image_to_numpy(image) 69 | 70 | def _signal_normalize(signal: NDArray[np.floating[Any]]) -> NDArray[np.floating[Any]]: 71 | area = np.trapz(signal) 72 | if area == 0: 73 | return signal 74 | return signal / area 75 | 76 | def _image_normalization( 77 | image_np: NDArray[np.floating[Any]], func1d: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]] 78 | ) -> NDArray[np.floating[Any]]: 79 | return np.apply_along_axis(func1d, axis=2, arr=image_np) 80 | 81 | return _image_normalization(image_np, _signal_normalize) 82 | -------------------------------------------------------------------------------- /siapy/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/siapy/utils/__init__.py -------------------------------------------------------------------------------- /siapy/utils/general.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import multiprocessing 3 | import random 4 | import re 5 | import types 6 | from functools import partial 7 | from pathlib import Path 8 | from typing import Any, Callable, Generator, Iterable, Optional 9 | 10 | import numpy as np 11 | 12 | from siapy.core import logger 13 | from siapy.core.exceptions import InvalidInputError 14 | 15 | __all__ = [ 16 | "initialize_object", 17 | "initialize_function", 18 | "ensure_dir", 19 | "get_number_cpus", 20 | "dict_zip", 21 | "get_increasing_seq_indices", 22 | "set_random_seed", 23 | "get_classmethods", 24 | "match_iterable_items_by_regex", 25 | ] 26 | 27 | 28 | def initialize_object( 29 | module: types.ModuleType | Any, 30 | module_name: str, 31 | module_args: Optional[dict[str, Any]] = None, 32 | *args: Any, 33 | **kwargs: Any, 34 | ) -> Any: 35 | module_args = module_args or {} 36 | assert not set(kwargs).intersection(module_args), "Overwriting kwargs given in config file is not allowed" 37 | module_args.update(kwargs) 38 | return getattr(module, module_name)(*args, **module_args) 39 | 40 | 41 | def initialize_function( 42 | module: types.ModuleType | Any, 43 | module_name: str, 44 | module_args: Optional[dict[str, Any]] = None, 45 | *args: Any, 46 | **kwargs: Any, 47 | ) -> Callable[..., Any]: 48 | module_args = module_args or {} 49 | assert not set(kwargs).intersection(module_args), "Overwriting kwargs given in config file is not allowed" 50 | module_args.update(kwargs) 51 | return partial(getattr(module, module_name), *args, **module_args) 52 | 53 | 54 | def ensure_dir(dirname: str | Path) -> Path: 55 | dirname = Path(dirname) 56 | if not dirname.is_dir(): 57 | dirname.mkdir(parents=True, exist_ok=False) 58 | return dirname 59 | 60 | 61 | def get_number_cpus(parallelize: int = -1) -> int: 62 | num_cpus: int = multiprocessing.cpu_count() 63 | if parallelize == -1: 64 | parallelize = num_cpus 65 | elif 1 <= parallelize <= num_cpus: 66 | pass 67 | elif parallelize > num_cpus: 68 | parallelize = num_cpus 69 | else: 70 | raise InvalidInputError(input_value=parallelize, message="Define accurate number of CPUs.") 71 | return parallelize 72 | 73 | 74 | def dict_zip( 75 | *dicts: dict[str, Any], 76 | ) -> Generator[tuple[str, Any, Any], None, None]: 77 | if not dicts: 78 | return 79 | 80 | n = len(dicts[0]) 81 | if any(len(d) != n for d in dicts): 82 | raise InvalidInputError(input_value=dicts, message="Arguments must have the same length.") 83 | 84 | for key, first_val in dicts[0].items(): 85 | yield key, first_val, *(other[key] for other in dicts[1:]) 86 | 87 | 88 | def get_increasing_seq_indices(values_list: list[int]) -> list[int]: 89 | indices = [] 90 | last_value = 0 91 | for idx, value in enumerate(values_list): 92 | if value > last_value: 93 | last_value = value 94 | indices.append(idx) 95 | return indices 96 | 97 | 98 | def set_random_seed(seed: int | None) -> None: 99 | random.seed(seed) 100 | np.random.seed(seed) 101 | 102 | 103 | def get_classmethods(class_obj: Any) -> list[str]: 104 | return [ 105 | member[0] 106 | for member in inspect.getmembers(class_obj, predicate=inspect.ismethod) 107 | if member[1].__self__ == class_obj 108 | ] 109 | 110 | 111 | def match_iterable_items_by_regex( 112 | iterable1: Iterable[str], iterable2: Iterable[str], regex: str = r"" 113 | ) -> tuple[list[tuple[str, str]], list[tuple[int, int]]]: 114 | pattern = re.compile(regex) 115 | matches = [] 116 | indices = [] 117 | for idx1, item1 in enumerate(iterable1): 118 | match1 = pattern.search(item1) 119 | logger.debug("match1: %s", match1) 120 | if match1: 121 | substring1 = match1.group() 122 | for idx2, item2 in enumerate(iterable2): 123 | match2 = pattern.search(item2) 124 | logger.debug("match2: %s", match2) 125 | if match2 and substring1 == match2.group(): 126 | matches.append((item1, item2)) 127 | indices.append((idx1, idx2)) 128 | logger.info("Matched items: %s -> %s", item1, item2) 129 | return matches, indices 130 | -------------------------------------------------------------------------------- /siapy/utils/image_validators.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import numpy as np 4 | from numpy.typing import NDArray 5 | from PIL.Image import Image 6 | 7 | from siapy.core.exceptions import InvalidInputError, InvalidTypeError 8 | from siapy.core.types import ImageSizeType, ImageType 9 | from siapy.entities import SpectralImage 10 | 11 | __all__ = [ 12 | "validate_image_to_numpy_3channels", 13 | "validate_image_to_numpy", 14 | "validate_image_size", 15 | ] 16 | 17 | 18 | def validate_image_to_numpy_3channels(image: ImageType) -> NDArray[np.floating[Any]]: 19 | if isinstance(image, SpectralImage): 20 | image_display = np.array(image.to_display()) 21 | elif isinstance(image, Image): 22 | image_display = np.array(image) 23 | elif isinstance(image, np.ndarray) and len(image.shape) == 3 and image.shape[-1] == 3: 24 | image_display = image.copy() 25 | else: 26 | raise InvalidInputError( 27 | input_value=image, 28 | message="Argument image must be convertible to numpy array with 3 channels.", 29 | ) 30 | return image_display 31 | 32 | 33 | def validate_image_to_numpy(image: ImageType) -> NDArray[np.floating[Any]]: 34 | if isinstance(image, SpectralImage): 35 | image_np = image.to_numpy() 36 | elif isinstance(image, Image): 37 | image_np = np.array(image) 38 | elif isinstance(image, np.ndarray): 39 | image_np = image.copy() 40 | else: 41 | raise InvalidInputError( 42 | input_value=image, 43 | message="Argument image must be convertible to a numpy array.", 44 | ) 45 | return image_np 46 | 47 | 48 | def validate_image_size(output_size: ImageSizeType) -> tuple[int, int]: 49 | if not isinstance(output_size, (int, tuple)): 50 | raise InvalidTypeError( 51 | input_value=output_size, 52 | allowed_types=ImageSizeType, 53 | message="Argument output_size must be an int or a tuple.", 54 | ) 55 | if isinstance(output_size, int): 56 | output_size = (output_size, output_size) 57 | elif len(output_size) != 2 or not all([isinstance(el, int) for el in output_size]): 58 | raise InvalidInputError( 59 | input_value=output_size, 60 | message="Argument output_size tuple must have 2 elements and contain only integers.", 61 | ) 62 | return output_size 63 | -------------------------------------------------------------------------------- /siapy/utils/signatures.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from shapely.geometry import MultiPoint, Point 4 | from shapely.prepared import prep as shapely_prep 5 | 6 | from siapy.core.exceptions import InvalidTypeError 7 | from siapy.entities import Shape, Signatures, SpectralImage 8 | 9 | 10 | def get_signatures_within_convex_hull(image: SpectralImage, shape: Shape) -> list[Signatures]: 11 | image_xarr = image.to_xarray() 12 | signatures = [] 13 | 14 | if shape.is_point: 15 | for g in shape.geometry: 16 | if isinstance(g, MultiPoint): 17 | points = list(g.geoms) 18 | elif isinstance(g, Point): 19 | points = [g] 20 | else: 21 | raise InvalidTypeError( 22 | input_value=g, 23 | allowed_types=(Point, MultiPoint), 24 | message="Geometry must be Point or MultiPoint", 25 | ) 26 | signals = [] 27 | pixels = [] 28 | for p in points: 29 | signals.append(image_xarr.sel(x=p.x, y=p.y, method="nearest").values) 30 | pixels.append((p.x, p.y)) 31 | 32 | signatures.append(Signatures.from_signals_and_pixels(signals, pixels)) 33 | 34 | else: 35 | for hull in shape.convex_hull: 36 | minx, miny, maxx, maxy = hull.bounds 37 | 38 | x_coords = image_xarr.x[(image_xarr.x >= minx) & (image_xarr.x <= maxx)].values 39 | y_coords = image_xarr.y[(image_xarr.y >= miny) & (image_xarr.y <= maxy)].values 40 | 41 | if len(x_coords) == 0 or len(y_coords) == 0: 42 | continue 43 | 44 | # Create a prepared geometry for faster contains check 45 | prepared_hull = shapely_prep(hull) 46 | 47 | signals = [] 48 | pixels = [] 49 | for x, y in itertools.product(x_coords, y_coords): 50 | point = Point(x, y) 51 | # Check if point is: inside the hull or intersects with the hull 52 | if prepared_hull.contains(point) or prepared_hull.intersects(point): 53 | try: 54 | signal = image_xarr.sel(x=x, y=y).values 55 | except (KeyError, IndexError): 56 | continue 57 | signals.append(signal) 58 | pixels.append((x, y)) 59 | 60 | signatures.append(Signatures.from_signals_and_pixels(signals, pixels)) 61 | 62 | return signatures 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | import numpy as np 4 | import pytest 5 | from sklearn.datasets import make_classification 6 | 7 | from siapy.core.configs import TEST_DATA_DIR 8 | from siapy.datasets.tabular import TabularDataset, TabularDatasetData 9 | from siapy.entities import Pixels, SpectralImage, SpectralImageSet 10 | from siapy.entities.shapes import Shape 11 | from tests.data_manager import verify_testdata_integrity 12 | 13 | 14 | class PytestConfigs(SimpleNamespace): 15 | image_vnir_hdr_path = TEST_DATA_DIR / "hyspex" / "vnir.hdr" 16 | image_vnir_img_path = TEST_DATA_DIR / "hyspex" / "vnir.hyspex" 17 | image_swir_hdr_path = TEST_DATA_DIR / "hyspex" / "swir.hdr" 18 | image_swir_img_path = TEST_DATA_DIR / "hyspex" / "swir.hyspex" 19 | image_vnir_name = "VNIR_1600_SN0034" 20 | image_swir_name = "SWIR_384me_SN3109" 21 | 22 | image_micasense_blue = TEST_DATA_DIR / "micasense" / "blue.tif" 23 | image_micasense_green = TEST_DATA_DIR / "micasense" / "green.tif" 24 | image_micasense_red = TEST_DATA_DIR / "micasense" / "red.tif" 25 | image_micasense_nir = TEST_DATA_DIR / "micasense" / "nir.tif" 26 | image_micasense_rededge = TEST_DATA_DIR / "micasense" / "rededge.tif" 27 | image_micasense_merged = TEST_DATA_DIR / "micasense" / "merged.tif" 28 | 29 | shapefile_point = TEST_DATA_DIR / "micasense" / "point.shp" 30 | shapefile_buffer = TEST_DATA_DIR / "micasense" / "buffer.shp" 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def configs(): 35 | verify_testdata_integrity() 36 | return PytestConfigs() 37 | 38 | 39 | class SpectralImages(SimpleNamespace): 40 | vnir: SpectralImage 41 | swir: SpectralImage 42 | vnir_np: np.ndarray 43 | swir_np: np.ndarray 44 | 45 | 46 | @pytest.fixture(scope="module") 47 | def spectral_images(configs) -> SpectralImages: 48 | spectral_image_vnir = SpectralImage.spy_open( 49 | header_path=configs.image_vnir_hdr_path, 50 | image_path=configs.image_vnir_img_path, 51 | ) 52 | spectral_image_swir = SpectralImage.spy_open( 53 | header_path=configs.image_swir_hdr_path, 54 | image_path=configs.image_swir_img_path, 55 | ) 56 | spectral_image_vnir_np = spectral_image_vnir.to_numpy() 57 | spectral_image_swir_np = spectral_image_swir.to_numpy() 58 | return SpectralImages( 59 | vnir=spectral_image_vnir, 60 | swir=spectral_image_swir, 61 | vnir_np=spectral_image_vnir_np, 62 | swir_np=spectral_image_swir_np, 63 | ) 64 | 65 | 66 | class CorrespondingPixels(SimpleNamespace): 67 | vnir: Pixels 68 | swir: Pixels 69 | 70 | 71 | @pytest.fixture(scope="module") 72 | def corresponding_pixels() -> CorrespondingPixels: 73 | pixels_vnir = np.array( 74 | [ 75 | [1007, 620], 76 | [417, 1052], 77 | [439, 1582], 78 | [1100, 1866], 79 | [832, 1090], 80 | [1133, 1079], 81 | [854, 1407], 82 | [1138, 1413], 83 | ] 84 | ) 85 | pixels_swir = np.array( 86 | [ 87 | [252, 110], 88 | [99, 219], 89 | [107, 354], 90 | [268, 422], 91 | [207, 230], 92 | [279, 225], 93 | [210, 309], 94 | [283, 309], 95 | ] 96 | ) 97 | return CorrespondingPixels( 98 | vnir=Pixels.from_iterable(pixels_vnir), 99 | swir=Pixels.from_iterable(pixels_swir), 100 | ) 101 | 102 | 103 | @pytest.fixture(scope="module") 104 | def spectral_images_set(spectral_images): 105 | x_min = 10 106 | y_min = 15 107 | x_max = 60 108 | y_max = 66 109 | 110 | rectangle = Shape.from_rectangle(x_min=x_min, y_min=y_min, x_max=x_max, y_max=y_max) 111 | 112 | spectral_images.vnir.geometric_shapes.append(rectangle) 113 | spectral_images.swir.geometric_shapes.append(rectangle) 114 | 115 | images = [ 116 | spectral_images.vnir, 117 | spectral_images.swir, 118 | spectral_images.vnir, 119 | ] 120 | 121 | return SpectralImageSet(images) 122 | 123 | 124 | class TabularDatasetReturn(SimpleNamespace): 125 | dataset: TabularDataset 126 | dataset_data: TabularDatasetData 127 | 128 | 129 | @pytest.fixture(scope="module") 130 | def spectral_tabular_dataset(spectral_images_set): 131 | dataset = TabularDataset(spectral_images_set) 132 | dataset.process_image_data() 133 | dataset_data = dataset.generate_dataset_data() 134 | return TabularDatasetReturn(dataset=dataset, dataset_data=dataset_data) 135 | 136 | 137 | @pytest.fixture(scope="module") 138 | def mock_sklearn_dataset(): 139 | X, y = make_classification(n_samples=100, n_features=10, n_classes=2, random_state=0) 140 | return X, y 141 | -------------------------------------------------------------------------------- /tests/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siapy/siapy-lib/d4663078691123bf24f1a849d3ee7b045e27a881/tests/data/.gitkeep -------------------------------------------------------------------------------- /tests/data_manager.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import os 4 | import tarfile 5 | from pathlib import Path 6 | 7 | import requests # type: ignore[import] 8 | 9 | ################################################################# 10 | # TEST DATA CONFIGURATION # 11 | ################################################################# 12 | 13 | DATA_VERSION = "testdata-v1" 14 | 15 | ################################################################# 16 | 17 | base_url = "https://github.com/siapy/siapy-lib/releases/download" 18 | data_dir = Path(__file__).parent / "data" 19 | archive_name = f"{DATA_VERSION}.tar.gz" 20 | archive_path = data_dir / archive_name 21 | checksum_url = f"{base_url}/{DATA_VERSION}/{archive_name}.sha256" 22 | archive_url = f"{base_url}/{DATA_VERSION}/{archive_name}" 23 | 24 | logger = logging.getLogger(__name__) 25 | logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") 26 | os.makedirs(data_dir, exist_ok=True) 27 | 28 | 29 | def calculate_checksum(file_path: Path) -> str: 30 | """Calculate SHA-256 checksum of a file.""" 31 | sha256_hash = hashlib.sha256() 32 | with open(file_path, "rb") as f: 33 | for byte_block in iter(lambda: f.read(4096), b""): 34 | sha256_hash.update(byte_block) 35 | return sha256_hash.hexdigest() 36 | 37 | 38 | def download_file(url: str, save_path: Path) -> None: 39 | """Download a file from URL to the specified path.""" 40 | logger.info(f"Downloading {url} to {save_path}") 41 | response = requests.get(url, stream=True) 42 | response.raise_for_status() 43 | 44 | with open(save_path, "wb") as f: 45 | for chunk in response.iter_content(chunk_size=8192): 46 | f.write(chunk) 47 | 48 | 49 | def extract_archive(archive_path: Path, extract_dir: Path) -> None: 50 | """Extract the downloaded archive to the specified directory.""" 51 | logger.info(f"Extracting {archive_path} to {extract_dir}") 52 | with tarfile.open(archive_path, "r:gz") as tar: 53 | tar.extractall(path=extract_dir) 54 | 55 | 56 | def verify_testdata_integrity() -> bool: 57 | """Ensure test data is available, downloading and extracting if necessary.""" 58 | if data_dir.exists() and any(data_dir.iterdir()): 59 | try: 60 | response = requests.get(checksum_url) 61 | response.raise_for_status() 62 | expected_checksum = response.text.strip().split()[0] 63 | 64 | if archive_path.exists(): 65 | current_checksum = calculate_checksum(archive_path) 66 | if current_checksum == expected_checksum: 67 | logger.info("Test data is up-to-date") 68 | extract_archive(archive_path, data_dir.parent) 69 | logger.info("Test data successfully extracted") 70 | return True 71 | else: 72 | logger.info("Test data checksum mismatch, re-downloading...") 73 | else: 74 | logger.info("Archive not found, downloading...") 75 | except requests.RequestException: 76 | logger.warning("Could not verify remote checksum, using existing data") 77 | return False 78 | 79 | try: 80 | download_file(archive_url, archive_path) 81 | logger.info("Test data archive downloaded successfully") 82 | extract_archive(archive_path, data_dir.parent) 83 | logger.info("Test data successfully extracted") 84 | return True 85 | except requests.RequestException as e: 86 | logger.error(f"Failed to download test data: {e}") 87 | raise RuntimeError(f"Could not download test data: {e}") 88 | 89 | 90 | if __name__ == "__main__": 91 | verify_testdata_integrity() 92 | -------------------------------------------------------------------------------- /tests/datasets/test_datasets_helpers.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | import pytest 5 | 6 | from siapy.datasets.helpers import ( 7 | generate_classification_target, 8 | generate_regression_target, 9 | ) 10 | from siapy.datasets.schemas import ClassificationTarget, RegressionTarget 11 | from siapy.entities import Pixels 12 | 13 | 14 | @pytest.fixture(scope="module") 15 | def sample_dataframe() -> pd.DataFrame: 16 | data = { 17 | Pixels.coords.X: [1, 2], 18 | Pixels.coords.Y: [3, 4], 19 | "0": [5, 6], 20 | "1": [7, 8], 21 | "image_idx": [0, 1], 22 | "shape_idx": [3, 4], 23 | "image_filepath": [ 24 | Path("/path/to/image_a.tif"), 25 | Path("/path/to/image_b.tif"), 26 | ], 27 | "camera_id": ["camera_a", "camera_b"], 28 | "shape_type": ["rectangle", "circle"], 29 | "shape_label": ["c", "d"], 30 | } 31 | return pd.DataFrame(data) 32 | 33 | 34 | def test_dataframe_generate_classification_target_single_column( 35 | sample_dataframe, 36 | ): 37 | classification_target = generate_classification_target(sample_dataframe, "shape_type") 38 | assert isinstance(classification_target, ClassificationTarget) 39 | assert all(classification_target.label == pd.Series(["rectangle", "circle"], name="label")) 40 | assert classification_target.value.name == "encoded" 41 | assert classification_target.encoding.name == "encoding" 42 | assert list(classification_target.value) == [0, 1] 43 | assert classification_target.encoding.to_dict() == { 44 | 0: "rectangle", 45 | 1: "circle", 46 | } 47 | 48 | 49 | def test_dataframe_generate_classification_target_multiple_columns( 50 | sample_dataframe, 51 | ): 52 | classification_target = generate_classification_target(sample_dataframe, ["shape_type", "shape_label"]) 53 | assert isinstance(classification_target, ClassificationTarget) 54 | assert all(classification_target.label == pd.Series(["rectangle__c", "circle__d"], name="label")) 55 | assert list(classification_target.value) == [0, 1] 56 | assert classification_target.encoding.to_dict() == { 57 | 0: "rectangle__c", 58 | 1: "circle__d", 59 | } 60 | 61 | 62 | def test_dataframe_generate_regression_target(sample_dataframe): 63 | regression_target = generate_regression_target(sample_dataframe, "0") 64 | assert isinstance(regression_target, RegressionTarget) 65 | assert regression_target.name == "0" 66 | assert all(regression_target.value == sample_dataframe["0"]) 67 | -------------------------------------------------------------------------------- /tests/datasets/test_datasets_tabular.py: -------------------------------------------------------------------------------- 1 | from siapy.datasets.schemas import TabularDatasetData 2 | from siapy.datasets.tabular import TabularDataEntity, TabularDataset 3 | from siapy.entities import Shape, SpectralImage, SpectralImageSet 4 | 5 | 6 | def test_tabular_len(spectral_tabular_dataset): 7 | dataset = spectral_tabular_dataset.dataset 8 | assert len(dataset) == 3 9 | 10 | 11 | def test_tabular_str(spectral_tabular_dataset): 12 | dataset = spectral_tabular_dataset.dataset 13 | expected_str = "" 14 | assert str(dataset) == expected_str 15 | 16 | 17 | def test_tabular_iter(spectral_tabular_dataset): 18 | dataset = spectral_tabular_dataset.dataset 19 | for entity in dataset: 20 | assert isinstance(entity, TabularDataEntity) 21 | 22 | 23 | def test_tabular_getitem(spectral_tabular_dataset): 24 | dataset = spectral_tabular_dataset.dataset 25 | first_entity = dataset[0] 26 | assert isinstance(first_entity, TabularDataEntity) 27 | 28 | 29 | def test_tabular_image_set(spectral_tabular_dataset): 30 | dataset = spectral_tabular_dataset.dataset 31 | assert isinstance(dataset.image_set, SpectralImageSet) 32 | 33 | 34 | def test_tabular_data_entities(spectral_tabular_dataset): 35 | dataset = spectral_tabular_dataset.dataset 36 | data_entities = dataset.data_entities 37 | assert all(isinstance(entity, TabularDataEntity) for entity in data_entities) 38 | 39 | 40 | def test_tabular_process_image_data(spectral_tabular_dataset): 41 | dataset = spectral_tabular_dataset.dataset 42 | assert len(dataset.data_entities) > 0 43 | 44 | 45 | def test_tabular_generate_dataset(spectral_tabular_dataset): 46 | data = spectral_tabular_dataset.dataset_data 47 | assert isinstance(data, TabularDatasetData) 48 | assert not data.signatures.pixels.df.empty 49 | assert not data.signatures.signals.df.empty 50 | assert not data.metadata.empty 51 | assert data.target is None 52 | 53 | 54 | def test_tabular_rasterio(configs): 55 | raster = SpectralImage.rasterio_open(configs.image_micasense_merged) 56 | point = Shape.open_shapefile(configs.shapefile_point) 57 | buffer = Shape.open_shapefile(configs.shapefile_buffer) 58 | raster.geometric_shapes.shapes = [point, buffer] 59 | dataset = TabularDataset(raster) 60 | dataset.process_image_data() 61 | data = dataset.generate_dataset_data() 62 | assert isinstance(data, TabularDatasetData) 63 | -------------------------------------------------------------------------------- /tests/entities/images/test_entities_images_mock.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from PIL import Image 4 | 5 | from siapy.core.exceptions import InvalidInputError 6 | from siapy.core.types import XarrayType 7 | from siapy.entities.images.mock import MockImage 8 | 9 | 10 | def test_initialization(): 11 | # Valid 3D array 12 | valid_array = np.random.rand(100, 100, 3).astype(np.float32) 13 | mock_img = MockImage(array=valid_array) 14 | assert isinstance(mock_img, MockImage) 15 | 16 | # Invalid 2D array 17 | invalid_array = np.random.rand(100, 100).astype(np.float32) 18 | with pytest.raises(InvalidInputError): 19 | MockImage(array=invalid_array) 20 | 21 | 22 | def test_open(): 23 | test_array = np.random.rand(100, 100, 3).astype(np.float32) 24 | mock_img = MockImage.open(array=test_array) 25 | assert isinstance(mock_img, MockImage) 26 | 27 | 28 | def test_properties(): 29 | test_array = np.random.rand(50, 60, 5).astype(np.float32) 30 | mock_img = MockImage(array=test_array) 31 | 32 | assert mock_img.filepath.is_absolute() is False 33 | assert isinstance(mock_img.metadata, dict) 34 | assert len(mock_img.metadata) == 0 35 | assert mock_img.shape == (50, 60, 5) 36 | assert mock_img.bands == 5 37 | assert mock_img.default_bands == [0, 1, 2] 38 | assert mock_img.wavelengths == [0, 1, 2, 3, 4] 39 | assert mock_img.camera_id == "" 40 | 41 | 42 | def test_default_bands_fewer_than_three(): 43 | test_array = np.random.rand(50, 60, 2).astype(np.float32) 44 | mock_img = MockImage(array=test_array) 45 | assert mock_img.default_bands == [0, 1] 46 | 47 | 48 | def test_to_display(): 49 | # Test RGB case 50 | rgb_array = np.random.rand(50, 60, 3).astype(np.float32) 51 | mock_rgb = MockImage(array=rgb_array) 52 | 53 | img_eq = mock_rgb.to_display(equalize=True) 54 | assert isinstance(img_eq, Image.Image) 55 | assert img_eq.size == (60, 50) 56 | assert img_eq.mode == "RGB" 57 | 58 | img_no_eq = mock_rgb.to_display(equalize=False) 59 | assert isinstance(img_no_eq, Image.Image) 60 | 61 | # Test single band case 62 | single_band_array = np.random.rand(50, 60, 1).astype(np.float32) 63 | mock_single = MockImage(array=single_band_array) 64 | 65 | img_single = mock_single.to_display() 66 | assert isinstance(img_single, Image.Image) 67 | assert img_single.size == (60, 50) 68 | 69 | 70 | def test_to_display_with_nans(): 71 | array_with_nans = np.random.rand(50, 60, 3).astype(np.float32) 72 | array_with_nans[10:20, 10:20, :] = np.nan 73 | 74 | mock_img = MockImage(array=array_with_nans) 75 | img = mock_img.to_display() 76 | 77 | assert isinstance(img, Image.Image) 78 | # Image should be created successfully despite NaNs 79 | 80 | 81 | def test_to_numpy(): 82 | test_array = np.random.rand(50, 60, 3).astype(np.float32) 83 | mock_img = MockImage(array=test_array) 84 | 85 | # Without NaN handling 86 | result = mock_img.to_numpy() 87 | assert isinstance(result, np.ndarray) 88 | assert result.shape == (50, 60, 3) 89 | assert result.dtype == np.float32 90 | assert np.array_equal(result, test_array) 91 | 92 | # With NaN handling 93 | result_no_nans = mock_img.to_numpy(nan_value=0.0) 94 | assert isinstance(result_no_nans, np.ndarray) 95 | assert not np.any(np.isnan(result_no_nans)) 96 | 97 | 98 | def test_to_xarray(): 99 | test_array = np.random.rand(50, 60, 3).astype(np.float32) 100 | mock_img = MockImage(array=test_array) 101 | 102 | result = mock_img.to_xarray() 103 | assert isinstance(result, XarrayType) 104 | assert "y" in result.dims 105 | assert "x" in result.dims 106 | assert "band" in result.dims 107 | assert result.attrs["camera_id"] == "" 108 | assert result.shape == (50, 60, 3) 109 | 110 | np.testing.assert_array_equal(result.coords["band"].values, mock_img.wavelengths) 111 | np.testing.assert_array_equal(result.coords["x"].values, np.arange(mock_img.shape[1])) 112 | np.testing.assert_array_equal(result.coords["y"].values, np.arange(mock_img.shape[0])) 113 | -------------------------------------------------------------------------------- /tests/entities/images/test_entities_images_rasteriolib.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | import pytest 5 | from PIL import Image 6 | 7 | from siapy.core.exceptions import InvalidFilepathError 8 | from siapy.core.types import XarrayType 9 | from siapy.entities.images.rasterio_lib import RasterioLibImage 10 | 11 | 12 | def test_open_valid(configs): 13 | raster = RasterioLibImage.open(configs.image_micasense_merged) 14 | assert isinstance(raster, RasterioLibImage) 15 | 16 | 17 | def test_open_invalid(): 18 | with pytest.raises(InvalidFilepathError): 19 | RasterioLibImage.open("nonexistent.tif") 20 | 21 | 22 | def test_file(configs): 23 | raster = RasterioLibImage.open(configs.image_micasense_merged) 24 | file = raster.file 25 | assert isinstance(file, XarrayType) 26 | assert hasattr(file, "dims") 27 | assert hasattr(file, "values") 28 | assert hasattr(file, "attrs") 29 | assert "band" in file.dims 30 | assert "x" in file.dims 31 | assert "y" in file.dims 32 | 33 | 34 | def test_properties(configs): 35 | raster = RasterioLibImage.open(configs.image_micasense_merged) 36 | 37 | assert isinstance(raster.filepath, Path) 38 | assert isinstance(raster.metadata, dict) 39 | assert isinstance(raster.shape, tuple) 40 | assert len(raster.shape) == 3 41 | assert isinstance(raster.rows, int) 42 | assert isinstance(raster.cols, int) 43 | assert isinstance(raster.bands, int) 44 | assert isinstance(raster.default_bands, list) 45 | assert all(isinstance(x, int) for x in raster.default_bands) 46 | assert isinstance(raster.wavelengths, np.ndarray) 47 | assert isinstance(raster.camera_id, str) 48 | 49 | 50 | def test_shape_consistency(configs): 51 | raster = RasterioLibImage.open(configs.image_micasense_merged) 52 | shape = raster.shape 53 | assert shape[0] == raster.rows 54 | assert shape[1] == raster.cols 55 | assert shape[2] == raster.bands 56 | 57 | 58 | def test_to_display(configs): 59 | raster = RasterioLibImage.open(configs.image_micasense_merged) 60 | img_eq = raster.to_display(equalize=True) 61 | assert isinstance(img_eq, Image.Image) 62 | img_no_eq = raster.to_display(equalize=False) 63 | assert isinstance(img_no_eq, Image.Image) 64 | 65 | 66 | def test_numpy(configs): 67 | raster = RasterioLibImage.open(configs.image_micasense_merged) 68 | arr = raster.to_numpy() 69 | assert isinstance(arr, np.ndarray) 70 | assert arr.shape == raster.shape 71 | 72 | 73 | def test_to_numpy_with_nan_handling(configs): 74 | raster = RasterioLibImage.open(configs.image_micasense_merged) 75 | 76 | arr_no_nans = raster.to_numpy(nan_value=0.0) 77 | assert isinstance(arr_no_nans, np.ndarray) 78 | assert arr_no_nans.shape == raster.shape 79 | assert not np.any(np.isnan(arr_no_nans)) 80 | 81 | 82 | def test_to_xarray(configs): 83 | raster = RasterioLibImage.open(configs.image_micasense_merged) 84 | assert isinstance(raster.to_xarray(), XarrayType) 85 | -------------------------------------------------------------------------------- /tests/entities/test_entities_imagesets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from siapy.core.exceptions import InvalidInputError 4 | from siapy.entities import SpectralImage, SpectralImageSet 5 | from siapy.entities.images.rasterio_lib import RasterioLibImage 6 | 7 | 8 | def test_spy_open_valid(configs): 9 | header_paths = [ 10 | configs.image_vnir_hdr_path, 11 | configs.image_swir_hdr_path, 12 | ] 13 | image_paths = [ 14 | configs.image_vnir_img_path, 15 | configs.image_swir_img_path, 16 | ] 17 | image_set = SpectralImageSet.spy_open(header_paths=header_paths, image_paths=image_paths) 18 | assert len(image_set) == 2 19 | 20 | 21 | def test_spy_open_invalid(configs): 22 | header_paths = [ 23 | configs.image_vnir_hdr_path, 24 | configs.image_swir_hdr_path, 25 | ] 26 | image_paths = [configs.image_vnir_img_path] 27 | with pytest.raises(InvalidInputError): 28 | SpectralImageSet.spy_open(header_paths=header_paths, image_paths=image_paths) 29 | 30 | 31 | def test_rasterio_open(configs): 32 | filepaths = [ 33 | configs.image_micasense_merged, 34 | configs.image_micasense_red, 35 | configs.image_micasense_blue, 36 | ] 37 | image_set = SpectralImageSet.rasterio_open(filepaths=filepaths) 38 | assert len(image_set) == 3 39 | assert all(isinstance(img.image, RasterioLibImage) for img in image_set) 40 | 41 | 42 | def create_spectral_image(hdr_path, img_path): 43 | return SpectralImage.spy_open(header_path=hdr_path, image_path=img_path) 44 | 45 | 46 | def test_len(configs): 47 | image_set = SpectralImageSet() 48 | assert len(image_set) == 0 49 | 50 | vnir_image = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 51 | swir_image = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 52 | image_set = SpectralImageSet(spectral_images=[vnir_image, swir_image]) 53 | assert len(image_set) == 2 54 | 55 | 56 | def test_str(configs): 57 | image_set = SpectralImageSet() 58 | assert str(image_set) == "" 59 | 60 | vnir_image = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 61 | swir_image = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 62 | image_set = SpectralImageSet(spectral_images=[vnir_image, swir_image]) 63 | assert str(image_set) == "" 64 | 65 | 66 | def test_iter(configs): 67 | vnir_image = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 68 | swir_image = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 69 | image_set = SpectralImageSet(spectral_images=[vnir_image, swir_image]) 70 | assert list(image_set) == [ 71 | vnir_image, 72 | swir_image, 73 | ] 74 | 75 | 76 | def test_getitem(configs): 77 | vnir_image = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 78 | image_set = SpectralImageSet(spectral_images=[vnir_image]) 79 | assert image_set[0] == vnir_image 80 | 81 | 82 | def test_images_by_camera_id(configs): 83 | vnir_image1 = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 84 | vnir_image2 = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 85 | swir_image = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 86 | image_set = SpectralImageSet(spectral_images=[vnir_image1, vnir_image2, swir_image]) 87 | assert [swir_image] == image_set.images_by_camera_id(configs.image_swir_name) 88 | assert [vnir_image1, vnir_image2] == image_set.images_by_camera_id(configs.image_vnir_name) 89 | 90 | 91 | def test_sort(configs): 92 | vnir_image1 = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 93 | vnir_image2 = create_spectral_image(configs.image_vnir_hdr_path, configs.image_vnir_img_path) 94 | swir_image1 = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 95 | swir_image2 = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 96 | swir_image3 = create_spectral_image(configs.image_swir_hdr_path, configs.image_swir_img_path) 97 | 98 | unordered_set = [ 99 | vnir_image1, 100 | swir_image1, 101 | vnir_image2, 102 | swir_image2, 103 | swir_image3, 104 | ] 105 | ordered_set = [ 106 | swir_image1, 107 | swir_image2, 108 | swir_image3, 109 | vnir_image1, 110 | vnir_image2, 111 | ] 112 | 113 | image_set = SpectralImageSet(unordered_set.copy()) 114 | 115 | assert image_set.images == unordered_set != ordered_set 116 | assert sorted(image_set.images) == ordered_set != unordered_set 117 | assert image_set.images == unordered_set != ordered_set 118 | image_set.sort() 119 | assert image_set.images == ordered_set != unordered_set 120 | image_set = SpectralImageSet(unordered_set.copy()) 121 | image_set.images.sort() 122 | assert image_set.images == ordered_set != unordered_set 123 | -------------------------------------------------------------------------------- /tests/examples/test_examples.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | 6 | from siapy.core.configs import BASE_DIR 7 | 8 | EXAMPLES_DIR = BASE_DIR / "docs" / "examples" / "src" 9 | 10 | 11 | def get_example_files(): 12 | """Get all Python files from examples directory.""" 13 | return sorted(EXAMPLES_DIR.glob("*.py")) 14 | 15 | 16 | @pytest.mark.manual 17 | @pytest.mark.parametrize("example_path", get_example_files()) 18 | def test_example_script(example_path): 19 | """Test that example script runs without errors.""" 20 | try: 21 | # Run the example script in a subprocess 22 | result = subprocess.run( 23 | [sys.executable, str(example_path)], 24 | capture_output=True, 25 | text=True, 26 | timeout=60, # 60 second timeout 27 | ) 28 | assert result.returncode == 0, ( 29 | f"Example {example_path.name} failed with:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" 30 | ) 31 | except subprocess.TimeoutExpired: 32 | pytest.fail(f"Example {example_path.name} timed out after 60 seconds") 33 | -------------------------------------------------------------------------------- /tests/features/test_features_features.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from sklearn.datasets import make_classification, make_regression 3 | 4 | from siapy.features import ( 5 | AutoSpectralIndicesClassification, 6 | AutoSpectralIndicesRegression, 7 | ) 8 | from siapy.features.helpers import FeatureSelectorConfig 9 | from siapy.features.spectral_indices import ( 10 | compute_spectral_indices, 11 | get_spectral_indices, 12 | ) 13 | 14 | 15 | def test_auto_spectral_indices_classification(): 16 | columns = ["R", "G"] 17 | spectral_indices = get_spectral_indices(columns) 18 | X, y = make_classification(n_samples=100, n_features=2, n_classes=2, random_state=0, n_redundant=0) 19 | data = pd.DataFrame(X, columns=columns) 20 | target = pd.Series(y) 21 | df_direct = compute_spectral_indices(data, spectral_indices.keys()) 22 | 23 | config = FeatureSelectorConfig(k_features=5) 24 | auto_clf = AutoSpectralIndicesClassification(spectral_indices, selector_config=config, merge_with_original=False) 25 | df_selected = auto_clf.fit_transform(data, target) 26 | pd.testing.assert_frame_equal(df_selected, df_direct[df_selected.columns]) 27 | 28 | 29 | def test_auto_spectral_indices_regression(): 30 | columns = ["R", "G"] 31 | spectral_indices = get_spectral_indices(columns) 32 | X, y = make_regression(n_samples=100, n_features=2, noise=0.1, random_state=0) 33 | data = pd.DataFrame(X, columns=columns) 34 | target = pd.Series(y) 35 | df_direct = compute_spectral_indices(data, spectral_indices.keys()) 36 | 37 | config = FeatureSelectorConfig(k_features=5) 38 | auto_reg = AutoSpectralIndicesRegression(spectral_indices, selector_config=config, merge_with_original=False) 39 | df_selected = auto_reg.fit_transform(data, target) 40 | pd.testing.assert_frame_equal(df_selected, df_direct[df_selected.columns]) 41 | -------------------------------------------------------------------------------- /tests/features/test_features_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sklearn.pipeline import Pipeline 3 | 4 | from siapy.core.exceptions import InvalidInputError 5 | from siapy.features.helpers import ( 6 | FeatureSelectorConfig, 7 | feature_selector_factory, 8 | ) 9 | from tests.utils import assert_pipelines_parameters_equal 10 | 11 | 12 | def test_feature_selector_factory_regression_pipeline(): 13 | pipeline = feature_selector_factory(problem_type="regression") 14 | assert isinstance(pipeline, Pipeline) 15 | assert pipeline.named_steps["sequentialfeatureselector"].scoring == "neg_mean_squared_error" 16 | assert pipeline.named_steps["sequentialfeatureselector"].estimator.__class__.__name__ == "Ridge" 17 | 18 | 19 | def test_feature_selector_factory_classification_pipeline(): 20 | pipeline = feature_selector_factory(problem_type="classification") 21 | assert isinstance(pipeline, Pipeline) 22 | assert pipeline.named_steps["sequentialfeatureselector"].scoring == "f1_weighted" 23 | assert pipeline.named_steps["sequentialfeatureselector"].estimator.__class__.__name__ == "RidgeClassifier" 24 | 25 | 26 | def test_feature_selector_factory_invalid_problem_type(): 27 | with pytest.raises(InvalidInputError): 28 | feature_selector_factory(problem_type="invalid") 29 | 30 | 31 | def test_feature_selector_factory_custom_args(): 32 | pipeline = feature_selector_factory(problem_type="regression", k_features=5) 33 | assert pipeline.named_steps["sequentialfeatureselector"].k_features == 5 34 | 35 | pipeline = feature_selector_factory(problem_type="regression", cv=5) 36 | assert pipeline.named_steps["sequentialfeatureselector"].cv == 5 37 | 38 | pipeline = feature_selector_factory(problem_type="regression", forward=False) 39 | assert not pipeline.named_steps["sequentialfeatureselector"].forward 40 | 41 | pipeline = feature_selector_factory(problem_type="regression", floating=False) 42 | assert not pipeline.named_steps["sequentialfeatureselector"].floating 43 | 44 | pipeline = feature_selector_factory(problem_type="regression", verbose=0) 45 | assert pipeline.named_steps["sequentialfeatureselector"].verbose == 0 46 | 47 | pipeline = feature_selector_factory(problem_type="regression", n_jobs=2) 48 | assert pipeline.named_steps["sequentialfeatureselector"].n_jobs == 2 49 | 50 | 51 | def test_feature_selector_factory_config_vs_args(): 52 | config = FeatureSelectorConfig() 53 | pipeline_reg_with_args = feature_selector_factory(problem_type="regression") 54 | pipeline_reg_with_config = feature_selector_factory(problem_type="regression", config=config) 55 | pipeline_clf_with_args = feature_selector_factory(problem_type="classification") 56 | pipeline_clf_with_config = feature_selector_factory(problem_type="classification", config=config) 57 | 58 | assert assert_pipelines_parameters_equal(pipeline_reg_with_args, pipeline_reg_with_config) 59 | assert assert_pipelines_parameters_equal(pipeline_clf_with_args, pipeline_clf_with_config) 60 | assert not assert_pipelines_parameters_equal(pipeline_reg_with_args, pipeline_clf_with_args) 61 | assert not assert_pipelines_parameters_equal(pipeline_reg_with_config, pipeline_clf_with_config) 62 | 63 | config2 = FeatureSelectorConfig(cv=2) 64 | pipeline_reg_with_config = feature_selector_factory(problem_type="regression", config=config2) 65 | pipeline_clf_with_config = feature_selector_factory(problem_type="classification", config=config2) 66 | 67 | assert not assert_pipelines_parameters_equal(pipeline_reg_with_args, pipeline_reg_with_config) 68 | assert not assert_pipelines_parameters_equal(pipeline_clf_with_args, pipeline_clf_with_config) 69 | -------------------------------------------------------------------------------- /tests/features/test_features_spectral_indices.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import pytest 4 | 5 | from siapy.core.exceptions import InvalidInputError 6 | from siapy.features.spectral_indices import ( 7 | _convert_str_to_list, 8 | compute_spectral_indices, 9 | get_spectral_indices, 10 | ) 11 | 12 | 13 | def test_convert_str_to_list(): 14 | input_data = "R" 15 | expected_output = ["R"] 16 | assert _convert_str_to_list(input_data) == expected_output 17 | input_data = ["R", "G", "B"] 18 | expected_output = ["R", "G", "B"] 19 | assert _convert_str_to_list(input_data) == expected_output 20 | 21 | 22 | def test_get_spectral_indices_valid(): 23 | bands_acronym = ["R", "G", "B"] 24 | spectral_indices = get_spectral_indices(bands_acronym) 25 | for _, meta in spectral_indices.items(): 26 | assert set(meta.bands).issubset(set(bands_acronym)) 27 | 28 | 29 | def test_get_spectral_indices_invalid(): 30 | bands_acronym = ["not-correct"] 31 | with pytest.raises(InvalidInputError): 32 | get_spectral_indices(bands_acronym) 33 | 34 | 35 | def test_compute_spectral_indices(): 36 | columns = ["R", "G"] 37 | spectral_indices = get_spectral_indices(columns) 38 | data = pd.DataFrame(np.random.default_rng(seed=0).random((5, 2)), columns=columns) 39 | compute_spectral_indices(data, spectral_indices.keys()) 40 | columns[1] = "not-correct" 41 | data = pd.DataFrame(np.random.default_rng(seed=0).random((5, 2)), columns=columns) 42 | with pytest.raises(InvalidInputError): 43 | compute_spectral_indices( 44 | data, 45 | spectral_indices.keys(), 46 | ) 47 | 48 | 49 | def test_compute_spectral_indices_with_map(): 50 | data = pd.DataFrame( 51 | np.random.default_rng(seed=0).random((5, 2)), 52 | columns=["R", "not-correct"], 53 | ) 54 | spectral_indices = get_spectral_indices(["R", "G"]) 55 | with pytest.raises(InvalidInputError): 56 | compute_spectral_indices( 57 | data, 58 | spectral_indices.keys(), 59 | {"not-correct": "not-correct2"}, 60 | ) 61 | compute_spectral_indices(data, spectral_indices.keys(), {"not-correct": "G"}) 62 | -------------------------------------------------------------------------------- /tests/optimizers/test_optimizers_configs.py: -------------------------------------------------------------------------------- 1 | import optuna 2 | import pytest 3 | 4 | from siapy.optimizers.configs import ( 5 | CreateStudyConfig, 6 | OptimizeStudyConfig, 7 | TabularOptimizerConfig, 8 | ) 9 | from siapy.optimizers.parameters import TrialParameters 10 | from siapy.optimizers.scorers import Scorer 11 | 12 | 13 | def test_create_study_config_defaults(): 14 | config = CreateStudyConfig() 15 | assert config.storage is None 16 | assert config.sampler is None 17 | assert config.pruner is None 18 | assert config.study_name is None 19 | assert config.direction == "minimize" 20 | assert config.load_if_exists is False 21 | 22 | 23 | def test_optimize_study_config_defaults(): 24 | config = OptimizeStudyConfig() 25 | assert config.n_trials is None 26 | assert config.timeout is None 27 | assert config.n_jobs == -1 28 | assert config.catch == () 29 | assert config.callbacks is None 30 | assert config.gc_after_trial is False 31 | assert config.show_progress_bar is True 32 | 33 | 34 | def test_tabular_optimizer_config_defaults(): 35 | config = TabularOptimizerConfig() 36 | assert isinstance(config.create_study, CreateStudyConfig) 37 | assert isinstance(config.optimize_study, OptimizeStudyConfig) 38 | assert config.scorer is None 39 | assert config.trial_parameters is None 40 | 41 | 42 | def test_create_study_config_custom_values(): 43 | config = CreateStudyConfig( 44 | storage="sqlite:///example.db", 45 | sampler=optuna.samplers.RandomSampler(), 46 | pruner=optuna.pruners.MedianPruner(), 47 | study_name="test_study", 48 | direction="maximize", 49 | load_if_exists=True, 50 | ) 51 | assert config.storage == "sqlite:///example.db" 52 | assert isinstance(config.sampler, optuna.samplers.RandomSampler) 53 | assert isinstance(config.pruner, optuna.pruners.MedianPruner) 54 | assert config.study_name == "test_study" 55 | assert config.direction == "maximize" 56 | assert config.load_if_exists is True 57 | 58 | 59 | def test_optimize_study_config_custom_values(): 60 | config = OptimizeStudyConfig( 61 | n_trials=100, 62 | timeout=3600.0, 63 | n_jobs=4, 64 | catch=(ValueError,), 65 | callbacks=[lambda study, trial: None], 66 | gc_after_trial=True, 67 | show_progress_bar=False, 68 | ) 69 | assert config.n_trials == 100 70 | assert config.timeout == pytest.approx(3600) 71 | assert config.n_jobs == 4 72 | assert config.catch 73 | assert len(config.callbacks) == 1 74 | assert config.gc_after_trial is True 75 | assert config.show_progress_bar is False 76 | 77 | 78 | def test_tabular_optimizer_config_custom_values(): 79 | scorer = Scorer.init_cross_validator_scorer() 80 | trial_parameters = TrialParameters() 81 | config = TabularOptimizerConfig( 82 | create_study=CreateStudyConfig(study_name="custom_study"), 83 | optimize_study=OptimizeStudyConfig(n_trials=50), 84 | scorer=scorer, 85 | trial_parameters=trial_parameters, 86 | ) 87 | assert config.create_study.study_name == "custom_study" 88 | assert config.optimize_study.n_trials == 50 89 | assert config.scorer == scorer 90 | assert config.trial_parameters == trial_parameters 91 | -------------------------------------------------------------------------------- /tests/optimizers/test_optimizers_evaluators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from sklearn.base import BaseEstimator 5 | from sklearn.dummy import DummyClassifier 6 | from sklearn.metrics import accuracy_score, make_scorer 7 | from sklearn.model_selection import KFold, train_test_split 8 | from sklearn.svm import SVC 9 | 10 | from siapy.core.exceptions import ( 11 | InvalidInputError, 12 | MethodNotImplementedError, 13 | ) 14 | from siapy.optimizers.evaluators import check_model_prediction_methods, cross_validation, hold_out_validation 15 | 16 | 17 | class MockModelValid(BaseEstimator): 18 | def fit(self, X, y): 19 | pass 20 | 21 | def predict(self, X): 22 | pass 23 | 24 | def score(self, X, y): 25 | pass 26 | 27 | 28 | class MockModelInvalid(BaseEstimator): 29 | def fit(self, X, y): 30 | pass 31 | 32 | 33 | def test_check_model_prediction_methods_valid(): 34 | model = MockModelValid() 35 | check_model_prediction_methods(model) 36 | 37 | 38 | def test_check_model_prediction_methods_invalid(): 39 | model = MockModelInvalid() 40 | with pytest.raises( 41 | MethodNotImplementedError, 42 | # match="The model must have methods: 'fit', 'predict', and 'score'.", 43 | ): 44 | check_model_prediction_methods(model) 45 | 46 | 47 | def test_cross_validation(mock_sklearn_dataset): 48 | X, y = mock_sklearn_dataset 49 | mean_score = cross_validation(model=SVC(random_state=0), X=X, y=y) 50 | assert mean_score == pytest.approx(0.9) 51 | mean_score = cross_validation(model=DummyClassifier(), X=X, y=y) 52 | assert round(mean_score, 2) == pytest.approx(0.52) 53 | 54 | 55 | def test_cross_validation_with_kfold(mock_sklearn_dataset): 56 | X, y = mock_sklearn_dataset 57 | kf = KFold(n_splits=5, random_state=0, shuffle=True) 58 | mean_score = cross_validation(model=SVC(random_state=0), X=X, y=y, cv=kf) 59 | assert mean_score == pytest.approx(0.92) 60 | 61 | 62 | def test_cross_validation_with_custom_scorer(mock_sklearn_dataset): 63 | X, y = mock_sklearn_dataset 64 | custom_scorer = make_scorer(accuracy_score) 65 | mean_score = cross_validation(model=SVC(random_state=0), X=X, y=y, scoring=custom_scorer) 66 | assert mean_score == pytest.approx(0.9) 67 | 68 | 69 | def test_cross_validation_with_x_val_y_val(mock_sklearn_dataset, caplog): 70 | X, y = mock_sklearn_dataset 71 | caplog.set_level(logging.INFO) # Set the logging level to INFO 72 | mean_score = cross_validation(model=SVC(random_state=0), X=X, y=y, X_val=X) 73 | assert mean_score == pytest.approx(0.9) 74 | assert ( 75 | "Specification of X_val and y_val is redundant for cross_validation." 76 | "These parameters are ignored." in caplog.text 77 | ) 78 | 79 | 80 | def test_hold_out_validation(mock_sklearn_dataset): 81 | X, y = mock_sklearn_dataset 82 | score = hold_out_validation(model=SVC(random_state=0), X=X, y=y, random_state=0) 83 | assert score == pytest.approx(0.95) 84 | score = hold_out_validation(model=DummyClassifier(), X=X, y=y, random_state=0) 85 | assert score == pytest.approx(0.40) 86 | 87 | 88 | def test_hold_out_validation_with_manual_validation_set(mock_sklearn_dataset): 89 | X, y = mock_sklearn_dataset 90 | X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0) 91 | score = hold_out_validation( 92 | model=SVC(random_state=0), 93 | X=X_train, 94 | y=y_train, 95 | X_val=X_val, 96 | y_val=y_val, 97 | ) 98 | assert score == pytest.approx(0.95) 99 | 100 | 101 | def test_hold_out_validation_with_incomplete_manual_validation_set( 102 | mock_sklearn_dataset, 103 | ): 104 | X, y = mock_sklearn_dataset 105 | X_train, X_val, y_train, _ = train_test_split(X, y, test_size=0.2, random_state=0) 106 | with pytest.raises( 107 | InvalidInputError, 108 | match="To manually define validation set, both X_val and y_val must be specified.", 109 | ): 110 | hold_out_validation(model=SVC(random_state=0), X=X_train, y=y_train, X_val=X_val) 111 | 112 | 113 | def test_hold_out_validation_with_custom_scorer_func(mock_sklearn_dataset): 114 | X, y = mock_sklearn_dataset 115 | custom_scorer = make_scorer(accuracy_score) 116 | score = hold_out_validation( 117 | model=SVC(random_state=0), 118 | X=X, 119 | y=y, 120 | scoring=custom_scorer, 121 | random_state=0, 122 | ) 123 | assert score == pytest.approx(0.95) 124 | 125 | 126 | def test_hold_out_validation_with_custom_scorer_str(mock_sklearn_dataset): 127 | X, y = mock_sklearn_dataset 128 | score = hold_out_validation(model=SVC(random_state=0), X=X, y=y, scoring="accuracy", random_state=0) 129 | assert score == pytest.approx(0.95) 130 | 131 | 132 | def test_hold_out_validation_with_stratify(mock_sklearn_dataset): 133 | X, y = mock_sklearn_dataset 134 | score = hold_out_validation(model=SVC(random_state=0), X=X, y=y, stratify=y, random_state=0) 135 | assert score == pytest.approx(0.95) 136 | -------------------------------------------------------------------------------- /tests/optimizers/test_optimizers_parameters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from siapy.optimizers.parameters import ( 4 | CategoricalParameter, 5 | FloatParameter, 6 | IntParameter, 7 | TrialParameters, 8 | ) 9 | 10 | 11 | def test_init(): 12 | float_params = [FloatParameter(name="fp1", low=0.0, high=1.0)] 13 | int_params = [IntParameter(name="ip1", low=0, high=10)] 14 | cat_params = [CategoricalParameter(name="cp1", choices=[True, False])] 15 | trial_params = TrialParameters( 16 | float_parameters=float_params, 17 | int_parameters=int_params, 18 | categorical_parameters=cat_params, 19 | ) 20 | assert trial_params.float_parameters == float_params 21 | assert trial_params.int_parameters == int_params 22 | assert trial_params.categorical_parameters == cat_params 23 | 24 | 25 | def test_init_defaults(): 26 | trial_params = TrialParameters() 27 | assert trial_params.float_parameters == [] 28 | assert trial_params.int_parameters == [] 29 | assert trial_params.categorical_parameters == [] 30 | trial_params = TrialParameters.from_dict({}) 31 | assert trial_params.float_parameters == [] 32 | assert trial_params.int_parameters == [] 33 | assert trial_params.categorical_parameters == [] 34 | 35 | 36 | def test_from_dict(): 37 | parameters_dict = { 38 | "float_parameters": [{"name": "fp1", "low": 0.0, "high": 1.0}], 39 | "int_parameters": [{"name": "ip1", "low": 0, "high": 10}], 40 | "categorical_parameters": [{"name": "cp1", "choices": [True, False]}], 41 | } 42 | 43 | trial_params = TrialParameters.from_dict(parameters_dict) 44 | 45 | assert len(trial_params.float_parameters) == 1 46 | assert trial_params.float_parameters[0].name == "fp1" 47 | assert trial_params.float_parameters[0].low == pytest.approx(0.0, rel=1e-2) 48 | assert trial_params.float_parameters[0].high == pytest.approx(1, rel=1e-2) 49 | 50 | assert len(trial_params.int_parameters) == 1 51 | assert trial_params.int_parameters[0].name == "ip1" 52 | assert trial_params.int_parameters[0].low == 0 53 | assert trial_params.int_parameters[0].high == 10 54 | 55 | assert len(trial_params.categorical_parameters) == 1 56 | assert trial_params.categorical_parameters[0].name == "cp1" 57 | assert trial_params.categorical_parameters[0].choices == [True, False] 58 | -------------------------------------------------------------------------------- /tests/optimizers/test_optimizers_scores.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sklearn.model_selection import RepeatedKFold, train_test_split 3 | from sklearn.tree import DecisionTreeClassifier 4 | 5 | from siapy.optimizers.scorers import Scorer 6 | 7 | 8 | def test_init_cross_validator_scorer(mock_sklearn_dataset): 9 | X, y = mock_sklearn_dataset 10 | scorer = Scorer.init_cross_validator_scorer() 11 | score = scorer(DecisionTreeClassifier(random_state=0), X, y) 12 | assert score == pytest.approx(0.81) 13 | 14 | 15 | def test_init_cross_validator_scorer_repeated_kfold(mock_sklearn_dataset): 16 | X, y = mock_sklearn_dataset 17 | scorer1 = Scorer.init_cross_validator_scorer( 18 | scoring="f1", cv=RepeatedKFold(n_splits=3, n_repeats=5, random_state=0) 19 | ) 20 | scorer2 = Scorer.init_cross_validator_scorer(scoring="f1", cv="RepeatedKFold") 21 | score1 = scorer1(DecisionTreeClassifier(random_state=0), X, y) 22 | score2 = scorer2(DecisionTreeClassifier(random_state=0), X, y) 23 | assert score1 == score2 24 | 25 | 26 | def test_init_hold_out_scorer(mock_sklearn_dataset): 27 | X, y = mock_sklearn_dataset 28 | scorer = Scorer.init_hold_out_scorer(test_size=0.3) 29 | score = scorer(DecisionTreeClassifier(random_state=0), X, y) 30 | assert score == pytest.approx(0.87, rel=1e-2) 31 | 32 | 33 | def test_hold_out_validation_with_manual_split(mock_sklearn_dataset): 34 | X, y = mock_sklearn_dataset 35 | X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.3, random_state=0) 36 | scorer = Scorer.init_hold_out_scorer(scoring="f1") 37 | score = scorer(DecisionTreeClassifier(random_state=0), X_train, y_train, X_val, y_val) 38 | assert score == pytest.approx(0.87, rel=1e-2) 39 | -------------------------------------------------------------------------------- /tests/transformations/test_transformations_corregistrator.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from siapy.entities.pixels import Pixels 5 | from siapy.transformations import corregistrator 6 | from siapy.utils.plots import pixels_select_click # noqa: F401 7 | 8 | 9 | @pytest.mark.manual 10 | def test_pixels_select_click_manual(spectral_images, corresponding_pixels): 11 | # image_vnir = spectral_images.vnir 12 | # image_swir = spectral_images.swir 13 | # pixels_vnir = pixels_select_click(image_vnir) 14 | # pixels_swir = pixels_select_click(image_swir) 15 | pixels_vnir = corresponding_pixels.vnir 16 | pixels_swir = corresponding_pixels.swir 17 | 18 | matx, _ = corregistrator.align(pixels_swir, pixels_vnir, plot_progress=False) 19 | pixels_transformed = corregistrator.transform(pixels_vnir, matx) 20 | assert np.sqrt(np.sum((pixels_swir.to_numpy() - pixels_transformed.to_numpy()) ** 2)) < 10 21 | 22 | 23 | def test_transform(): 24 | pixels_ref = Pixels.from_iterable(np.array([[1, 2], [3, 4], [5, 6]])) 25 | transformation_matx = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) # Identity matrix 26 | transformed_pixels = corregistrator.transform(pixels_ref, transformation_matx) 27 | np.testing.assert_array_equal(pixels_ref.to_numpy(), transformed_pixels.to_numpy()) 28 | 29 | 30 | def test_affine_matx_2d_identity(): 31 | expected_identity = np.eye(3) 32 | identity_matx = corregistrator.affine_matx_2d() 33 | np.testing.assert_array_equal(identity_matx, expected_identity) 34 | 35 | 36 | def test_affine_matx_2d_transformations(): 37 | scale = (2, 3) 38 | trans = (1, -1) 39 | rot = 45 # degrees 40 | shear = (0.1, 0.2) 41 | matx_2d = corregistrator.affine_matx_2d(scale, trans, rot, shear) 42 | # This test checks if the matrix is created but does not validate its correctness 43 | assert matx_2d.shape == (3, 3) 44 | -------------------------------------------------------------------------------- /tests/transformations/test_transformations_image.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from siapy.transformations import image 4 | 5 | 6 | def test_add_gaussian_noise(spectral_images): 7 | image_vnir = spectral_images.vnir 8 | noisy_image = image.add_gaussian_noise( 9 | image_vnir, 10 | mean=0.0, 11 | std=1.0, 12 | clip_to_max=True, 13 | ) 14 | 15 | assert noisy_image.shape == image_vnir.shape 16 | assert np.any(noisy_image != image_vnir.to_numpy()) 17 | 18 | 19 | def test_random_crop(spectral_images): 20 | image_vnir = spectral_images.vnir 21 | cropped_image = image.random_crop(image_vnir, (50, 50)) 22 | assert cropped_image.shape == (50, 50, image_vnir.bands) 23 | 24 | 25 | def test_random_mirror(spectral_images): 26 | image_vnir = spectral_images.vnir 27 | mirrored_image = image.random_mirror(image_vnir) 28 | assert mirrored_image.shape == image_vnir.shape 29 | 30 | 31 | def test_random_rotation(spectral_images): 32 | image_vnir = spectral_images.vnir 33 | rotated_image = image.random_rotation(image_vnir, 45) 34 | assert rotated_image.shape == image_vnir.shape 35 | 36 | 37 | def test_rescale(spectral_images): 38 | image_vnir = spectral_images.vnir 39 | rescaled_image = image.rescale(image_vnir, (50, 50)) 40 | assert rescaled_image.shape == (50, 50, image_vnir.bands) 41 | 42 | 43 | def test_area_normalization(spectral_images): 44 | image_vnir = spectral_images.vnir 45 | normalized_image = image.area_normalization(image_vnir) 46 | assert normalized_image.shape == image_vnir.shape 47 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from sklearn.base import BaseEstimator 2 | from sklearn.pipeline import Pipeline 3 | 4 | from siapy.utils.general import dict_zip 5 | 6 | 7 | def assert_estimators_parameters_equal(estimator1: BaseEstimator, estimator2: BaseEstimator) -> bool: 8 | if len(estimator1.get_params()) != len(estimator2.get_params()): 9 | return False 10 | 11 | for params_key, params1_val, params2_val in dict_zip(estimator1.get_params(), estimator2.get_params()): 12 | if isinstance(params1_val, BaseEstimator) and isinstance(params2_val, BaseEstimator): 13 | if not assert_estimators_parameters_equal(params1_val, params2_val): 14 | return False 15 | elif params1_val != params2_val: 16 | print( 17 | f"Values not equal for key: {params_key}", 18 | f"Value1: {params1_val}, Value2: {params2_val}", 19 | ) 20 | return False 21 | 22 | return True 23 | 24 | 25 | def assert_pipelines_parameters_equal(pipeline1: Pipeline, pipeline2: Pipeline) -> bool: 26 | if len(pipeline1.steps) != len(pipeline2.steps): 27 | return False 28 | 29 | for step1, step2 in zip(pipeline1.steps, pipeline2.steps): 30 | if step1[0] != step2[0] or step1[1].__class__ != step2[1].__class__: 31 | return False 32 | 33 | # Check if the parameters of each step are the same 34 | for step_name, step1 in pipeline1.named_steps.items(): 35 | step2 = pipeline2.named_steps[step_name] 36 | if not assert_estimators_parameters_equal(step1, step2): 37 | return False 38 | 39 | return True 40 | -------------------------------------------------------------------------------- /tests/utils/test_utils_general.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import types 3 | from pathlib import Path 4 | from tempfile import TemporaryDirectory 5 | from unittest.mock import MagicMock 6 | 7 | import pytest 8 | 9 | from siapy.core.exceptions import InvalidInputError 10 | from siapy.utils.general import ( 11 | dict_zip, 12 | ensure_dir, 13 | get_classmethods, 14 | get_increasing_seq_indices, 15 | get_number_cpus, 16 | initialize_function, 17 | initialize_object, 18 | match_iterable_items_by_regex, 19 | ) 20 | 21 | 22 | class MockModule(types.ModuleType): 23 | TestClass = MagicMock(return_value="initialized_object") 24 | test_function = MagicMock(return_value="function_result") 25 | 26 | 27 | # Instantiate the mock module 28 | mock_module = MockModule("mock_module") 29 | 30 | 31 | def test_initialize_object(): 32 | obj = initialize_object(mock_module, "TestClass") 33 | assert obj == "initialized_object" 34 | mock_module.TestClass.assert_called_with() 35 | 36 | obj = initialize_object(mock_module, "TestClass", module_args={"arg1": 1}, arg2=2) 37 | mock_module.TestClass.assert_called_with(arg1=1, arg2=2) 38 | 39 | with pytest.raises(AssertionError): 40 | initialize_object(mock_module, "TestClass", module_args={"arg1": 1}, arg1=2) 41 | 42 | 43 | def test_initialize_function(): 44 | func = initialize_function(mock_module, "test_function") 45 | assert func() == "function_result" 46 | mock_module.test_function.assert_called_with() 47 | 48 | func = initialize_function(mock_module, "test_function", module_args={"arg1": 1}, arg2=2) 49 | assert func() == "function_result" 50 | mock_module.test_function.assert_called_with(arg1=1, arg2=2) 51 | 52 | with pytest.raises(AssertionError): 53 | initialize_function(mock_module, "test_function", module_args={"arg1": 1}, arg1=2) 54 | 55 | 56 | def test_ensure_dir(): 57 | with TemporaryDirectory() as tmpdirname: 58 | path = ensure_dir(tmpdirname) 59 | assert path.is_dir() 60 | with TemporaryDirectory() as tmpdirname: 61 | new_dir = Path(tmpdirname) / "new_dir" 62 | path = ensure_dir(new_dir) 63 | assert path.is_dir() and path == new_dir 64 | 65 | 66 | def test_get_number_cpus(): 67 | assert get_number_cpus() == multiprocessing.cpu_count() 68 | assert get_number_cpus(2) == 2 69 | assert get_number_cpus(multiprocessing.cpu_count() + 10) == multiprocessing.cpu_count() 70 | with pytest.raises(InvalidInputError): 71 | get_number_cpus(0) 72 | 73 | 74 | def test_dict_zip(): 75 | dict1 = {"a": 1, "b": 2} 76 | dict2 = {"a": 3, "b": 4} 77 | zipped = list(dict_zip(dict1, dict2)) 78 | assert zipped == [("a", 1, 3), ("b", 2, 4)] 79 | dict1 = {"a": 1, "b": 2} 80 | dict2 = {"a": 3} 81 | with pytest.raises(InvalidInputError): 82 | list(dict_zip(dict1, dict2)) 83 | assert list(dict_zip()) == [] 84 | 85 | 86 | def test_get_increasing_seq_indices(): 87 | values_list = [1, 3, 2, 5, 4] 88 | indices = get_increasing_seq_indices(values_list) 89 | assert indices == [0, 1, 3] 90 | 91 | 92 | class SampleClass: 93 | @classmethod 94 | def class_method1(cls): 95 | pass 96 | 97 | def instance_method(self): 98 | pass 99 | 100 | @staticmethod 101 | def static_method(): 102 | pass 103 | 104 | @classmethod 105 | def class_method2(cls): 106 | pass 107 | 108 | 109 | def test_get_class_methods(): 110 | expected_methods = ["class_method1", "class_method2"] 111 | actual_methods = get_classmethods(SampleClass) 112 | assert sorted(actual_methods) == sorted(expected_methods) 113 | 114 | 115 | def test_match_iterable_items_by_regex(): 116 | iterable1 = [ 117 | "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr_rad_f32.hdr", 118 | "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101527_corr_rad_f32.hdr", 119 | ] 120 | iterable2 = [ 121 | "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr", 122 | "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr", 123 | ] 124 | # Regex: Define the regex pattern r"^[^_]+_[^_]+_[^_]+__" to match the labels until __. 125 | regex = r"^[^_]+_[^_]+_[^_]+__" 126 | 127 | expected_matches = [ 128 | ( 129 | "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr_rad_f32.hdr", 130 | "KK-K-03_KS-K-01_KK-S-05__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr", 131 | ), 132 | ( 133 | "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101527_corr_rad_f32.hdr", 134 | "KK-K-04_KK-K-10_KK-K-13__ana-krompir-3-22_20000_us_2x_HSNR02_2022-05-25T101949_corr2_rad_f32.hdr", 135 | ), 136 | ] 137 | expected_indices = [(0, 1), (1, 0)] 138 | 139 | matches, indices = match_iterable_items_by_regex(iterable1, iterable2, regex) 140 | assert matches == expected_matches 141 | assert indices == expected_indices 142 | -------------------------------------------------------------------------------- /tests/utils/test_utils_image_validators.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from PIL import Image as PILImage 4 | 5 | from siapy.core.exceptions import ( 6 | InvalidInputError, 7 | InvalidTypeError, 8 | ) 9 | from siapy.utils.image_validators import ( 10 | validate_image_size, 11 | validate_image_to_numpy, 12 | validate_image_to_numpy_3channels, 13 | ) 14 | 15 | 16 | def test_validate_image_to_numpy_3channels_with_spectral_image(spectral_images): 17 | image_vnir = spectral_images.vnir 18 | image_vnir_np = validate_image_to_numpy_3channels(image_vnir) 19 | assert isinstance(image_vnir_np, np.ndarray) 20 | assert image_vnir_np.shape[2] == 3 21 | assert isinstance(validate_image_to_numpy_3channels(image_vnir.to_display()), np.ndarray) 22 | 23 | 24 | def test_validate_image_to_numpy_3channels_with_image(): 25 | mock_image = PILImage.new("RGB", (100, 100)) 26 | result = validate_image_to_numpy_3channels(mock_image) 27 | assert isinstance(result, np.ndarray) and result.shape == (100, 100, 3) 28 | 29 | 30 | def test_validate_image_to_numpy_3channels_with_numpy_array(): 31 | mock_numpy_array = np.random.rand(100, 100, 3) 32 | assert np.array_equal(validate_image_to_numpy_3channels(mock_numpy_array), mock_numpy_array) 33 | 34 | 35 | def test_validate_image_to_numpy_3channels_with_invalid_input(): 36 | mock_numpy_array = np.random.rand(100, 100, 4) 37 | with pytest.raises(InvalidInputError): 38 | validate_image_to_numpy_3channels(mock_numpy_array) 39 | 40 | 41 | def test_validate_image_to_numpy_with_spectral_image(spectral_images): 42 | image_vnir = spectral_images.vnir 43 | image_vnir_np = validate_image_to_numpy(image_vnir) 44 | assert isinstance(image_vnir_np, np.ndarray) 45 | assert image_vnir.shape == image_vnir_np.shape 46 | 47 | 48 | def test_validate_image_to_numpy_with_pil_image(): 49 | mock_image = PILImage.new("RGB", (100, 100)) 50 | result = validate_image_to_numpy(mock_image) 51 | assert isinstance(result, np.ndarray) and result.shape == (100, 100, 3) 52 | 53 | 54 | def test_validate_image_to_numpy_with_numpy_array(): 55 | mock_numpy_array = np.random.rand(100, 100, 5) 56 | result = validate_image_to_numpy(mock_numpy_array) 57 | assert np.array_equal(result, mock_numpy_array) 58 | 59 | 60 | def test_validate_image_to_numpy_with_invalid_input(): 61 | with pytest.raises(InvalidInputError): 62 | validate_image_to_numpy("invalid_input") 63 | 64 | 65 | def test_validate_image_size(): 66 | output_size_int = validate_image_size(100) 67 | assert output_size_int == (100, 100) 68 | 69 | output_size_tuple = validate_image_size((100, 150)) 70 | assert output_size_tuple == (100, 150) 71 | 72 | with pytest.raises(InvalidTypeError): 73 | validate_image_size("invalid") 74 | 75 | with pytest.raises(InvalidInputError): 76 | validate_image_size((100,)) 77 | 78 | with pytest.raises(InvalidInputError): 79 | validate_image_size((100, "150")) 80 | -------------------------------------------------------------------------------- /tests/utils/test_utils_plots.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from siapy.core.exceptions import InvalidInputError 4 | from siapy.datasets.schemas import ( 5 | RegressionTarget, 6 | TabularDatasetData, 7 | ) 8 | from siapy.utils.plots import ( 9 | InteractiveButtonsEnum, 10 | display_image_with_areas, 11 | display_multiple_images_with_areas, 12 | display_signals, 13 | pixels_select_click, 14 | pixels_select_lasso, 15 | ) 16 | 17 | 18 | @pytest.mark.manual 19 | def test_pixels_select_click_manual(spectral_images): 20 | image_vnir = spectral_images.vnir 21 | pixels_select_click(image_vnir.to_display()) 22 | 23 | 24 | @pytest.mark.manual 25 | def test_pixels_select_lasso_manual(spectral_images): 26 | image_vnir = spectral_images.vnir 27 | selected_areas = pixels_select_lasso(image_vnir, selector_props={"color": "blue"}) 28 | display_image_with_areas(image_vnir, selected_areas, color="blue") 29 | 30 | 31 | @pytest.mark.manual 32 | def test_display_multiple_images_with_areas(spectral_images, corresponding_pixels): 33 | image_vnir = spectral_images.vnir 34 | image_swir = spectral_images.swir 35 | selected_areas_vnir = corresponding_pixels.vnir 36 | selected_areas_swir = corresponding_pixels.swir 37 | # selected_areas_vnir = pixels_select_lasso(image_vnir) 38 | # selected_areas_swir = pixels_select_lasso(image_swir) 39 | out = display_multiple_images_with_areas( 40 | images_with_areas=[ 41 | (image_vnir, selected_areas_vnir), 42 | (image_swir, selected_areas_swir), 43 | ], 44 | color="blue", 45 | ) 46 | assert isinstance(out, InteractiveButtonsEnum) 47 | 48 | 49 | @pytest.mark.manual 50 | def test_display_signals(): 51 | data = { 52 | "pixels": { 53 | "x": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 54 | "y": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 55 | }, 56 | "signals": { 57 | "0": [1, 2, 3, 1, 1, 5, 7, 6, 7, 8], 58 | "1": [3, 4, 5, 6, 4, 10, 11, 12, 11, 12], 59 | }, 60 | "metadata": { 61 | "0": [ 62 | "meta1", 63 | "meta1", 64 | "meta1", 65 | "meta1", 66 | "meta1", 67 | "meta1", 68 | "meta1", 69 | "meta1", 70 | "meta1", 71 | "meta1", 72 | ], 73 | "1": [ 74 | "meta2", 75 | "meta2", 76 | "meta2", 77 | "meta2", 78 | "meta2", 79 | "meta2", 80 | "meta2", 81 | "meta2", 82 | "meta2", 83 | "meta2", 84 | ], 85 | }, 86 | "target": { 87 | "label": ["a", "a", "a", "a", "a", "b", "b", "b", "b", "b"], 88 | "value": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1], 89 | "encoding": ["x", "y"], 90 | }, 91 | } 92 | dataset = TabularDatasetData.from_dict(data) 93 | display_signals(dataset) 94 | dataset.target = RegressionTarget.from_iterable( 95 | [0, 0, 0, 0, 0, 1, 1, 1, 1, 1], 96 | ) 97 | with pytest.raises(InvalidInputError): 98 | display_signals(dataset) 99 | -------------------------------------------------------------------------------- /tests/utils/test_utils_signatures.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from siapy.entities import Pixels, Shape, SpectralImage 5 | from siapy.utils.signatures import get_signatures_within_convex_hull 6 | from siapy.utils.plots import display_image_with_areas 7 | 8 | 9 | def test_get_signatures_within_convex_hull(configs): 10 | raster = SpectralImage.rasterio_open(configs.image_micasense_merged) 11 | point_shape = Shape.open_shapefile(configs.shapefile_point) 12 | buffer_shape = Shape.open_shapefile(configs.shapefile_buffer) 13 | raster.geometric_shapes.shapes = [point_shape, buffer_shape] 14 | signatures_point = get_signatures_within_convex_hull(raster, point_shape) 15 | signatures_buffer = get_signatures_within_convex_hull(raster, buffer_shape) 16 | 17 | assert len(signatures_point) == 17 18 | assert all([len(sp) == 1 for sp in signatures_point]) 19 | assert len(signatures_buffer) == 17 20 | assert all([len(sb) > 1 for sb in signatures_buffer]) 21 | 22 | 23 | @pytest.mark.manual 24 | def test_convex_hull_visualization(): 25 | points = Pixels.from_iterable([(3, 4), (24, 8), (15, 23)]) 26 | shape = Shape.from_line(points) 27 | 28 | image_mock = SpectralImage.from_numpy(np.zeros((30, 30, 3))) 29 | signatures_within = get_signatures_within_convex_hull(image_mock, shape)[0] 30 | display_image_with_areas(image_mock, signatures_within.pixels, color="red") 31 | 32 | 33 | def test_get_signatures_within_rectangular_shape(): 34 | image_data = np.zeros((100, 100, 3)) 35 | image_mock = SpectralImage.from_numpy(image_data) 36 | 37 | shape = Shape.from_rectangle(10, 21, 12, 23) 38 | signatures = get_signatures_within_convex_hull(image_mock, shape)[0] 39 | 40 | pixels_list = signatures.pixels.to_list() 41 | expected_points = [[u, v] for u in range(10, 13) for v in range(21, 24)] 42 | assert len(pixels_list) == len(expected_points) 43 | 44 | # Convert to sets for comparison (order may differ) 45 | pixels_set = {tuple(p) for p in signatures.pixels.as_type(int).to_list()} 46 | expected_set = {tuple(p) for p in expected_points} 47 | assert pixels_set == expected_set 48 | 49 | 50 | def test_get_signatures_within_point_shape(): 51 | image_data = np.zeros((30, 30, 3)) 52 | image_mock = SpectralImage.from_numpy(image_data) 53 | 54 | points = [[10, 15], [12, 23]] 55 | shape = Shape.from_multipoint(Pixels.from_iterable(points)) 56 | 57 | signatures = get_signatures_within_convex_hull(image_mock, shape)[0] 58 | 59 | pixels_list = signatures.pixels.as_type(int).to_list() 60 | assert len(pixels_list) == 2 61 | 62 | # Convert to sets for comparison (order may differ) 63 | pixels_set = {tuple(p) for p in pixels_list} 64 | expected_set = {tuple(p) for p in points} 65 | assert pixels_set == expected_set 66 | 67 | 68 | def test_get_signatures_within_triangle_shape(): 69 | image_data = np.zeros((10, 10, 3)) 70 | image_mock = SpectralImage.from_numpy(image_data) 71 | 72 | points = Pixels.from_iterable([(2, 1), (3, 3), (2, 3)]) 73 | shape = Shape.from_polygon(points) 74 | 75 | signatures = get_signatures_within_convex_hull(image_mock, shape)[0] 76 | 77 | # Expected points in the triangle 78 | expected_points = [[2, 1], [2, 2], [2, 3], [3, 3]] 79 | 80 | # Convert to sets for comparison (order may differ) 81 | pixels_set = {tuple(p) for p in signatures.pixels.as_type(int).to_list()} 82 | expected_set = {tuple(p) for p in expected_points} 83 | assert pixels_set == expected_set 84 | 85 | 86 | def test_get_signatures_within_complex_shape(): 87 | image_data = np.zeros((3, 3, 3)) 88 | image_mock = SpectralImage.from_numpy(image_data) 89 | 90 | # Create an L-shaped polygon 91 | points = Pixels.from_iterable([(0, 0), (2, 0), (2, 1), (1, 1), (1, 2), (0, 2)]) 92 | shape = Shape.from_polygon(points) 93 | 94 | signatures = get_signatures_within_convex_hull(image_mock, shape)[0] 95 | 96 | # The convex hull should fill in the "L" shape 97 | # Expected points in the 3x3 grid 98 | expected_points = [ 99 | [0, 0], 100 | [0, 1], 101 | [0, 2], 102 | [1, 0], 103 | [1, 1], 104 | [1, 2], 105 | [2, 0], 106 | [2, 1], 107 | ] 108 | 109 | # Convert to sets for comparison (order may differ) 110 | pixels_set = {tuple(p) for p in signatures.pixels.as_type(int).to_list()} 111 | expected_set = {tuple(p) for p in expected_points} 112 | assert pixels_set == expected_set 113 | --------------------------------------------------------------------------------