├── .flake8 ├── .github ├── CONTRIBUTING.md ├── issue-branch.yml └── workflows │ ├── cd.yml │ ├── ci.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs ├── css │ └── extra.css ├── images │ ├── coco_sample_82680.jpg │ ├── ground_truth │ │ ├── gt_bb_centers.png │ │ ├── gt_bb_shapes.png │ │ └── gt_img_shapes.png │ ├── logo.svg │ ├── train-config-evaluation │ │ └── overlap.png │ └── train-config-generation │ │ ├── clusters.png │ │ ├── clusters_4_ratios.png │ │ └── clusters_4_scales.png ├── index.md ├── install.md └── reference │ ├── apps │ ├── coco-merge.md │ ├── coco-split.md │ ├── crops-merge.md │ ├── crops-split.md │ ├── evaluation.md │ ├── ground-truth.md │ ├── paint-annotations.md │ ├── train-config-evaluation.md │ └── train-config-generation.md │ ├── core │ ├── anchor_generator.md │ ├── boxes.md │ ├── clustering.md │ ├── crops.md │ ├── nms.md │ └── utils.md │ └── plots │ ├── boxes.md │ ├── clustering.md │ ├── common.md │ └── evaluation.md ├── mkdocs.yml ├── mypy.ini ├── pyodi ├── __init__.py ├── apps │ ├── __init__.py │ ├── coco │ │ ├── __init__.py │ │ ├── coco_merge.py │ │ └── coco_split.py │ ├── crops │ │ ├── __init__.py │ │ ├── crops_merge.py │ │ └── crops_split.py │ ├── evaluation.py │ ├── ground_truth.py │ ├── paint_annotations.py │ └── train_config │ │ ├── __init__.py │ │ ├── train_config_evaluation.py │ │ └── train_config_generation.py ├── cli.py ├── core │ ├── __init__.py │ ├── anchor_generator.py │ ├── boxes.py │ ├── clustering.py │ ├── crops.py │ ├── nms.py │ └── utils.py └── plots │ ├── __init__.py │ ├── boxes.py │ ├── clustering.py │ ├── common.py │ └── evaluation.py ├── pyproject.toml ├── setup.cfg ├── setup.py └── tests ├── apps ├── test_coco_merge.py ├── test_coco_split.py ├── test_crops_merge.py ├── test_crops_split.py ├── test_ground_truth.py └── test_paint_annotations.py ├── core ├── test_anchor_generator.py ├── test_boxes.py ├── test_clustering.py ├── test_crops.py ├── test_nms.py └── test_utils.py └── plots └── test_centroids_heatmap.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | max-complexity = 18 4 | docstring-convention = google 5 | ignore = 6 | # Exclude some missing docstrings errors 7 | D100, D101, D104, D105, D106, D107 8 | # Exclude errors conflicting with black 9 | W503, E501, E203 10 | per-file-ignores = tests/*: D1 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pyodi 2 | 3 | All kinds of contributions are welcome, including but not limited to the following. 4 | 5 | - Fixes (typo, bugs) 6 | - New features and components 7 | 8 | ## Workflow 9 | 10 | 1. fork and pull the latest pyodi version 11 | 2. checkout a new branch (do not use master branch for PRs) 12 | 3. commit your changes 13 | 4. create a PR 14 | 15 | Note 16 | - If you plan to add some new features that involve large changes, it is encouraged to open an issue for discussion first. 17 | 18 | 19 | ## Code style 20 | 21 | ### Python 22 | We adopt [PEP8](https://www.python.org/dev/peps/pep-0008/) as the preferred code style. 23 | 24 | We use the following tools for linting and formatting: 25 | - [flake8](http://flake8.pycqa.org/en/latest/): linter 26 | - [black](https://github.com/psf/black): formatter 27 | - [isort](https://github.com/timothycrosley/isort): sort imports 28 | 29 | Style configurations of black and isort can be found in [pyproject.toml](../.pyproject.toml). 30 | 31 | We use [pre-commit hook](https://pre-commit.com/) that checks and formats for `flake8`, `yapf`, `isort`, 32 | fixes `end-of-files`, automatically on every commit. 33 | The config for a pre-commit hook is stored in [.pre-commit-config](../.pre-commit-config.yaml). 34 | 35 | After you clone the repository, install pyodi and development requirements with: 36 | 37 | ```bash 38 | pip install -e .[dev] 39 | ``` 40 | 41 | Then, you will need to install initialize pre-commit hook: 42 | 43 | ```bash 44 | pre-commit install 45 | ``` 46 | 47 | After this on every commit check code linters and formatter will be enforced. 48 | 49 | ## Tests 50 | 51 | The test suite can be run using pytest: 52 | ```bash 53 | pytest tests/ 54 | ``` 55 | -------------------------------------------------------------------------------- /.github/issue-branch.yml: -------------------------------------------------------------------------------- 1 | silent: false 2 | branchName: full 3 | branches: 4 | - label: enhancement 5 | prefix: feature/ 6 | - label: bug 7 | prefix: bugfix/ 8 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Continuous Delivery 3 | 4 | on: 5 | release: 6 | types: [created, prereleased] 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Set up Python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: '3.8' 17 | - name: Deploy MkDocs 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | run: | 21 | pip install -e .[dev] 22 | remote_repo="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 23 | git remote rm origin 24 | git remote add origin "${remote_repo}" 25 | mkdocs gh-deploy --force 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install setuptools wheel twine 30 | - name: Build and publish 31 | env: 32 | TWINE_USERNAME: ${{ secrets.PYPI_USER }} 33 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 34 | run: | 35 | python setup.py sdist bdist_wheel 36 | twine upload dist/* 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | - name: Install pre-commit hooks 19 | run: | 20 | pip install pre-commit 21 | pre-commit install 22 | - name: Lint code 23 | run: pre-commit run --all-files 24 | 25 | build: 26 | runs-on: ${{ matrix.os }} 27 | strategy: 28 | matrix: 29 | os: [ubuntu-latest, windows-latest] 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Set up Python 3.8 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.8 37 | - name: Install pyodi 38 | run: pip install .[dev] 39 | - name: Test with pytest 40 | run: pytest 41 | - name: Generate docs 42 | run: mkdocs build 43 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main Worflow 2 | 3 | on: 4 | issues: 5 | types: [assigned] 6 | issue_comment: 7 | types: [created] 8 | 9 | jobs: 10 | create_issue_branch_job: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Create Issue Branch 14 | uses: robvanderleek/create-issue-branch@main 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDEs 132 | .vscode 133 | .idea 134 | 135 | data/ 136 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - repo: https://github.com/timothycrosley/isort 9 | rev: 5.12.0 10 | hooks: 11 | - id: isort 12 | additional_dependencies: [toml] 13 | - repo: https://github.com/pycqa/flake8.git 14 | rev: 6.0.0 15 | hooks: 16 | - id: flake8 17 | args: ['--config=.flake8'] 18 | additional_dependencies: 19 | - 'flake8-docstrings' 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v0.782 22 | hooks: 23 | - id: mypy 24 | additional_dependencies: 25 | - 'pydantic' 26 | - repo: https://github.com/psf/black 27 | rev: 23.3.0 28 | hooks: 29 | - id: black 30 | additional_dependencies: ['click==8.0.4'] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |
4 | 5 |
6 | Pyodi
7 |

8 | 9 | 10 |

11 | Python Object Detection Insights
12 |

13 | 14 |

15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |

28 | 29 | 30 | Documentation: https://gradiant.github.io/pyodi 31 | 32 | ## Introduction 33 | 34 | A simple tool for explore your object detection dataset. The goal of this library is to provide simple and intuitive visualizations from your dataset and automatically find the best parameters for generating a specific grid of anchors that can fit you data characteristics 35 | 36 | | Component | Description | 37 | |---|---| 38 | | [paint annotations](https://gradiant.github.io/pyodi/reference/apps/paint-annotations/) | paints COCO format annotations and predictions | 39 | | [ground-truth](https://gradiant.github.io/pyodi/reference/apps/ground-truth/) | explore your dataset ground truth characteristics | 40 | | [evaluation](https://gradiant.github.io/pyodi/reference/apps/evaluation/) | evaluates AP and AR for between predictions and ground truth | 41 | | [train-config generation](https://gradiant.github.io/pyodi/reference/apps/train-config-generation/) | automatically generate anchors for your data | 42 | | [train-config evaluation](https://gradiant.github.io/pyodi/reference/apps/train-config-evaluation/) | evaluate the fitness between you data and your anchors | 43 | | [coco merge](https://gradiant.github.io/pyodi/reference/apps/coco-merge/) | automatically merge COCO annotation files | 44 | | [coco split](https://gradiant.github.io/pyodi/reference/apps/coco-split/) | automatically split COCO annotation files in train and val subsets | 45 | | [crops split](https://gradiant.github.io/pyodi/reference/apps/crops-split/) | creates a new dataset by splitting images into crops and adapting the annotations file | 46 | | [crops merge](https://gradiant.github.io/pyodi/reference/apps/crops-merge/) | translate COCO ground truth or COCO predictions crops split into original image coordinates | 47 | 48 | 49 | ## Installation 50 | 51 | ```bash 52 | pip install pyodi 53 | ``` 54 | 55 | ## Usage 56 | 57 | Pyodi includes different applications that can help you to extract the most from your dataset. You can download our `TINY_COCO_ANIMAL` dataset [here](https://github.com/Gradiant/pyodi/releases/download/v0.0.1/TINY_COCO_ANIMAL.zip) in order to test the example commands. A classic flow could follow the following steps: 58 | 59 | ### 1. Annotation visualization 60 | 61 | With pyodi `paint_annotations` you can easily visualize in a beautiful format your object detection dataset. 62 | 63 | ```bash 64 | pyodi paint-annotations \ 65 | $TINY_COCO_ANIMAL/annotations/train.json \ 66 | $TINY_COCO_ANIMAL/sample_images \ 67 | $TINY_COCO_ANIMAL/painted_images 68 | ``` 69 | 70 | ![COCO image with painted annotations](docs/images/coco_sample_82680.jpg) 71 | 72 | ### 2. Ground truth exploration 73 | 74 | It is very recommended to intensively explore your dataset before starting training. The analysis of your images and annotations will allow you to optimize aspects as the optimum image input size for your network or the shape distribution of the bounding boxes. You can use the `ground_truth` app for this task: 75 | 76 | ```bash 77 | pyodi ground-truth $TINY_COCO_ANIMAL/annotations/train.json 78 | ``` 79 | 80 | ![Image shape distribution](docs/images/ground_truth/gt_img_shapes.png) 81 | 82 | ![Bbox distribution](docs/images/ground_truth/gt_bb_shapes.png) 83 | 84 | ![Bbox center distribution](docs/images/ground_truth/gt_bb_centers.png) 85 | 86 | ### 3. Train config generation 87 | 88 | The design of anchors is critical for the performance of one-stage detectors. Pyodi can help you to automatically design a set of anchors that fit your data distribution. 89 | 90 | ```bash 91 | pyodi train-config generation \ 92 | $TINY_COCO_ANIMAL/annotations/train.json \ 93 | --input-size [1280,720] \ 94 | --n-ratios 3 --n-scales 3 95 | ``` 96 | 97 | ![Anchor clustering plot](docs/images/train-config-generation/clusters.png) 98 | 99 | ### 4. Train config evaluation 100 | 101 | Pyodi evaluation app has been designed with the aim of providing a simple tool to understand how well are your anchors matching your dataset. It automatically runs by default after executing `train-config generation` but it can also be run independently with: 102 | 103 | ```bash 104 | pyodi train-config evaluation \ 105 | $TINY_COCO_ANIMAL/annotations/train.json \ 106 | $TINY_COCO_ANIMAL/resources/anchor_config.py \ 107 | --input-size [1280,720] 108 | ``` 109 | 110 | ![Anchor overlap plot](docs/images/train-config-evaluation/overlap.png) 111 | 112 | ## Contributing 113 | 114 | We appreciate all contributions to improve Pyodi. Please refer to [Contributing guide](.github/CONTRIBUTING.md) for more info. 115 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | p { text-align: justify;} 2 | 3 | img[src*='#center'] { 4 | display: block; 5 | margin: auto; 6 | max-height: 450px; 7 | } 8 | -------------------------------------------------------------------------------- /docs/images/coco_sample_82680.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/coco_sample_82680.jpg -------------------------------------------------------------------------------- /docs/images/ground_truth/gt_bb_centers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/ground_truth/gt_bb_centers.png -------------------------------------------------------------------------------- /docs/images/ground_truth/gt_bb_shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/ground_truth/gt_bb_shapes.png -------------------------------------------------------------------------------- /docs/images/ground_truth/gt_img_shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/ground_truth/gt_img_shapes.png -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/images/train-config-evaluation/overlap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/train-config-evaluation/overlap.png -------------------------------------------------------------------------------- /docs/images/train-config-generation/clusters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/train-config-generation/clusters.png -------------------------------------------------------------------------------- /docs/images/train-config-generation/clusters_4_ratios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/train-config-generation/clusters_4_ratios.png -------------------------------------------------------------------------------- /docs/images/train-config-generation/clusters_4_scales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/docs/images/train-config-generation/clusters_4_scales.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Python Object Detection Insights 2 | 3 | A library for exploring your object detection dataset. The goal of this library is to provide simple and intuitive visualizations from your dataset and automatically find the best parameters for generating a specific grid of anchors that can fit you data characteristics 4 | ## Commands 5 | 6 | * [`pyodi paint-annotations`](reference/apps/paint-annotations.md) - Paint COCO format annotations and predictions 7 | * [`pyodi ground-truth`](reference/apps/ground-truth.md) - Explore your dataset ground truth characteristics. 8 | * [`pyodi evaluation`](reference/apps/evaluation.md) - Evaluate the predictions of your model against your ground truth. 9 | * [`pyodi train-config generation`](reference/apps/train-config-generation.md) - Automatically generate a `train_config_file` using `ground_truth_file`. 10 | * [`pyodi train-config evaluation`](reference/apps/train-config-evaluation.md) - Evaluate the fitness between `ground_truth_file` and `train_config_file`.. 11 | * [`pyodi coco merge`](reference/apps/coco-merge.md) - Automatically merge COCO annotation files. 12 | * [`pyodi coco split`](reference/apps/coco-split.md) - Creates a new dataset by splitting images into crops and adapting the annotations file 13 | * [`pyodi crops split`](reference/apps/crops-split.md) - Creates a new dataset by splitting images into crops and adapting the annotations file 14 | * [`pyodi crops merge`](reference/apps/crops-merge.md) - Translate COCO ground truth or COCO predictions crops split into original image coordinates 15 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ### From pypi 4 | ```bash 5 | pip install pyodi 6 | ``` 7 | 8 | ### From source 9 | ```bash 10 | git clone https://github.com/Gradiant/pyodi.git 11 | cd pyodi/ 12 | pip install . # or "python setup.py install" 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/reference/apps/coco-merge.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.coco.coco_merge 2 | -------------------------------------------------------------------------------- /docs/reference/apps/coco-split.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.coco.coco_split 2 | -------------------------------------------------------------------------------- /docs/reference/apps/crops-merge.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.crops.crops_merge 2 | -------------------------------------------------------------------------------- /docs/reference/apps/crops-split.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.crops.crops_split 2 | -------------------------------------------------------------------------------- /docs/reference/apps/evaluation.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.evaluation 2 | -------------------------------------------------------------------------------- /docs/reference/apps/ground-truth.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.ground_truth 2 | -------------------------------------------------------------------------------- /docs/reference/apps/paint-annotations.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.paint_annotations 2 | -------------------------------------------------------------------------------- /docs/reference/apps/train-config-evaluation.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.train_config.train_config_evaluation 2 | -------------------------------------------------------------------------------- /docs/reference/apps/train-config-generation.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.apps.train_config.train_config_generation 2 | -------------------------------------------------------------------------------- /docs/reference/core/anchor_generator.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.anchor_generator 2 | -------------------------------------------------------------------------------- /docs/reference/core/boxes.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.boxes 2 | -------------------------------------------------------------------------------- /docs/reference/core/clustering.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.clustering 2 | -------------------------------------------------------------------------------- /docs/reference/core/crops.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.crops 2 | -------------------------------------------------------------------------------- /docs/reference/core/nms.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.nms 2 | -------------------------------------------------------------------------------- /docs/reference/core/utils.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.core.utils 2 | -------------------------------------------------------------------------------- /docs/reference/plots/boxes.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.plots.boxes 2 | -------------------------------------------------------------------------------- /docs/reference/plots/clustering.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.plots.clustering 2 | -------------------------------------------------------------------------------- /docs/reference/plots/common.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.plots.common 2 | -------------------------------------------------------------------------------- /docs/reference/plots/evaluation.md: -------------------------------------------------------------------------------- 1 | ::: pyodi.plots.evaluation 2 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Python Object Detection Insights 2 | site_url: "https://github.com/Gradiant/pyodi" 3 | 4 | theme: 5 | name: material 6 | logo: images/logo.svg 7 | favicon: images/logo.svg 8 | nav: 9 | - Home: index.md 10 | - Installation: install.md 11 | - Reference: 12 | - apps: 13 | - coco: 14 | - merge: reference/apps/coco-merge.md 15 | - split: reference/apps/coco-split.md 16 | - crops: 17 | - merge: reference/apps/crops-merge.md 18 | - split: reference/apps/crops-split.md 19 | - evaluation: reference/apps/evaluation.md 20 | - ground-truth: reference/apps/ground-truth.md 21 | - paint-annotations: reference/apps/paint-annotations.md 22 | - train-config: 23 | - evaluation: reference/apps/train-config-evaluation.md 24 | - generation: reference/apps/train-config-generation.md 25 | - core: 26 | - anchor_generator: reference/core/anchor_generator.md 27 | - boxes: reference/core/boxes.md 28 | - clustering: reference/core/clustering.md 29 | - crops: reference/core/crops.md 30 | - nms: reference/core/nms.md 31 | - utils: reference/core/utils.md 32 | - plots: 33 | - boxes: reference/plots/boxes.md 34 | - clustering: reference/plots/clustering.md 35 | - common: reference/plots/common.md 36 | - evaluation: reference/plots/evaluation.md 37 | 38 | extra_css: 39 | - css/extra.css 40 | plugins: 41 | - search 42 | - mkdocstrings: 43 | watch: 44 | - pyodi 45 | 46 | markdown_extensions: 47 | - codehilite 48 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | ignore_missing_imports = True 4 | disallow_untyped_defs = False 5 | 6 | [mypy-pyodi.*] 7 | ignore_missing_imports = True 8 | disallow_untyped_defs = True 9 | 10 | [mypy-pyodi.coco.cocoeval] 11 | disallow_untyped_defs = False 12 | -------------------------------------------------------------------------------- /pyodi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/pyodi/__init__.py -------------------------------------------------------------------------------- /pyodi/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/pyodi/apps/__init__.py -------------------------------------------------------------------------------- /pyodi/apps/coco/__init__.py: -------------------------------------------------------------------------------- 1 | from pyodi.apps.coco.coco_merge import coco_merge 2 | from pyodi.apps.coco.coco_split import property_split, random_split 3 | 4 | coco_app = { 5 | "merge": coco_merge, 6 | "random-split": random_split, 7 | "property-split": property_split, 8 | } 9 | -------------------------------------------------------------------------------- /pyodi/apps/coco/coco_merge.py: -------------------------------------------------------------------------------- 1 | """# Coco Merge App. 2 | 3 | The [`pyodi coco`][pyodi.apps.coco.coco_merge.coco_merge] app can be used to merge COCO annotation files. 4 | 5 | Example usage: 6 | 7 | ``` bash 8 | pyodi coco merge coco_1.json coco_2.json output.json 9 | ``` 10 | 11 | This app merges COCO annotation files by replacing original image and annotations ids with new ones 12 | and adding all existent categories. 13 | 14 | --- 15 | 16 | # API REFERENCE 17 | """ # noqa: E501 18 | import json 19 | from typing import Any, Dict, Optional 20 | 21 | from loguru import logger 22 | 23 | 24 | @logger.catch(reraise=True) 25 | def coco_merge( 26 | input_extend: str, 27 | input_add: str, 28 | output_file: str, 29 | indent: Optional[int] = None, 30 | ) -> str: 31 | """Merge COCO annotation files. 32 | 33 | Args: 34 | input_extend: Path to input file to be extended. 35 | input_add: Path to input file to be added. 36 | output_file : Path to output file with merged annotations. 37 | indent: Argument passed to `json.dump`. See https://docs.python.org/3/library/json.html#json.dump. 38 | """ 39 | with open(input_extend, "r") as f: 40 | data_extend = json.load(f) 41 | with open(input_add, "r") as f: 42 | data_add = json.load(f) 43 | 44 | output: Dict[str, Any] = { 45 | k: data_extend[k] for k in data_extend if k not in ("images", "annotations") 46 | } 47 | 48 | output["images"], output["annotations"] = [], [] 49 | 50 | for i, data in enumerate([data_extend, data_add]): 51 | logger.info( 52 | "Input {}: {} images, {} annotations".format( 53 | i + 1, len(data["images"]), len(data["annotations"]) 54 | ) 55 | ) 56 | 57 | cat_id_map = {} 58 | for new_cat in data["categories"]: 59 | new_id = None 60 | for output_cat in output["categories"]: 61 | if new_cat["name"] == output_cat["name"]: 62 | new_id = output_cat["id"] 63 | break 64 | 65 | if new_id is not None: 66 | cat_id_map[new_cat["id"]] = new_id 67 | else: 68 | new_cat_id = max(c["id"] for c in output["categories"]) + 1 69 | cat_id_map[new_cat["id"]] = new_cat_id 70 | new_cat["id"] = new_cat_id 71 | output["categories"].append(new_cat) 72 | 73 | img_id_map = {} 74 | for image in data["images"]: 75 | n_imgs = len(output["images"]) 76 | img_id_map[image["id"]] = n_imgs 77 | image["id"] = n_imgs 78 | 79 | output["images"].append(image) 80 | 81 | for annotation in data["annotations"]: 82 | n_anns = len(output["annotations"]) 83 | annotation["id"] = n_anns 84 | annotation["image_id"] = img_id_map[annotation["image_id"]] 85 | annotation["category_id"] = cat_id_map[annotation["category_id"]] 86 | 87 | output["annotations"].append(annotation) 88 | 89 | logger.info( 90 | "Result: {} images, {} annotations".format( 91 | len(output["images"]), len(output["annotations"]) 92 | ) 93 | ) 94 | 95 | with open(output_file, "w", encoding="utf-8") as f: 96 | json.dump(output, f, indent=indent, ensure_ascii=False) 97 | 98 | return output_file 99 | -------------------------------------------------------------------------------- /pyodi/apps/coco/coco_split.py: -------------------------------------------------------------------------------- 1 | """# Coco Split App. 2 | 3 | The [`pyodi coco split`][pyodi.apps.coco.coco_split] app can be used to split COCO 4 | annotation files in train and val annotations files. 5 | 6 | There are two modes: 'random' or 'property'. The 'random' mode splits randomly the COCO file, while 7 | the 'property' mode allows to customize the split operation based in the properties of the COCO 8 | annotations file. 9 | 10 | Example usage: 11 | 12 | ``` bash 13 | pyodi coco random-split ./coco.json ./random_coco_split --val-percentage 0.1 14 | ``` 15 | 16 | ``` bash 17 | pyodi coco property-split ./coco.json ./property_coco_split ./split_config.json 18 | ``` 19 | 20 | The split config file is a json file that has 2 keys: 'discard' and 'val', both with dictionary values. The keys of the 21 | dictionaries will be the properties of the images that we want to match, and the values can be either the regex string to 22 | match or, for human readability, a dictionary with keys (you can choose whatever you want) and values (the regex string). 23 | 24 | Split config example: 25 | ``` python 26 | { 27 | "discard": { 28 | "file_name": "people_video|crowd_video|multiple_people_video", 29 | "source": "Youtube People Dataset|Bad Dataset", 30 | }, 31 | "val": { 32 | "file_name": { 33 | "My Val Ground Vehicle Dataset": "val_car_video|val_bus_video|val_moto_video|val_bike_video", 34 | "My Val Flying Vehicle Dataset": "val_plane_video|val_drone_video|val_helicopter_video", 35 | }, 36 | "source": "Val Dataset", 37 | } 38 | } 39 | ``` 40 | --- 41 | 42 | # API REFERENCE 43 | """ # noqa: E501 44 | import json 45 | import re 46 | from copy import copy 47 | from pathlib import Path 48 | from typing import List 49 | 50 | import numpy as np 51 | from loguru import logger 52 | 53 | 54 | @logger.catch(reraise=True) # noqa: C901 55 | def property_split( 56 | annotations_file: str, 57 | output_filename: str, 58 | split_config_file: str, 59 | ) -> List[str]: 60 | """Split the annotations file in training and validation subsets by properties. 61 | 62 | Args: 63 | annotations_file: Path to annotations file. 64 | output_filename: Output filename. 65 | split_config_file: Path to configuration file. 66 | 67 | Returns: 68 | Output filenames. 69 | 70 | """ 71 | logger.info("Loading files...") 72 | split_config = json.load(open(Path(split_config_file))) 73 | split_list = [] 74 | 75 | # Transform split_config from human readable format to a more code efficient format 76 | for section in split_config: # sections: val / discard 77 | for property_name, property_value in split_config[section].items(): 78 | if isinstance(property_value, dict): 79 | property_value = "|".join(property_value.values()) 80 | split_list.append( 81 | dict( 82 | split=section, 83 | property_name=property_name, 84 | property_regex=property_value, 85 | ) 86 | ) 87 | 88 | data = json.load(open(annotations_file)) 89 | 90 | train_images, val_images = [], [] 91 | train_annotations, val_annotations = [], [] 92 | 93 | n_train_imgs, n_val_imgs = 0, 0 94 | n_train_anns, n_val_anns = 0, 0 95 | 96 | old_to_new_train_ids = dict() 97 | old_to_new_val_ids = dict() 98 | 99 | logger.info("Gathering images...") 100 | for img in data["images"]: 101 | i = 0 102 | while i < len(split_list) and not re.match( 103 | split_list[i]["property_regex"], img[split_list[i]["property_name"]] 104 | ): 105 | i += 1 106 | 107 | if i < len(split_list): # discard or val 108 | if split_list[i]["split"] == "val": 109 | old_to_new_val_ids[img["id"]] = n_val_imgs 110 | img["id"] = n_val_imgs 111 | val_images.append(img) 112 | n_val_imgs += 1 113 | else: # train 114 | old_to_new_train_ids[img["id"]] = n_train_imgs 115 | img["id"] = n_train_imgs 116 | train_images.append(img) 117 | n_train_imgs += 1 118 | 119 | logger.info("Gathering annotations...") 120 | for ann in data["annotations"]: 121 | if ann["image_id"] in old_to_new_val_ids: 122 | ann["image_id"] = old_to_new_val_ids[ann["image_id"]] 123 | ann["id"] = n_val_anns 124 | val_annotations.append(ann) 125 | n_val_anns += 1 126 | elif ann["image_id"] in old_to_new_train_ids: 127 | ann["image_id"] = old_to_new_train_ids[ann["image_id"]] 128 | ann["id"] = n_train_anns 129 | train_annotations.append(ann) 130 | n_train_anns += 1 131 | 132 | logger.info("Spliting data...") 133 | train_split = { 134 | "images": train_images, 135 | "annotations": train_annotations, 136 | "info": data.get("info", {}), 137 | "licenses": data.get("licenses", []), 138 | "categories": data["categories"], 139 | } 140 | val_split = { 141 | "images": val_images, 142 | "annotations": val_annotations, 143 | "info": data.get("info", {}), 144 | "licenses": data.get("licenses", []), 145 | "categories": data["categories"], 146 | } 147 | 148 | logger.info("Writing splited files...") 149 | output_files = [] 150 | for split_type, split in zip(["train", "val"], [train_split, val_split]): 151 | output_files.append(output_filename + f"_{split_type}.json") 152 | with open(output_files[-1], "w", encoding="utf-8") as f: 153 | json.dump(split, f, indent=2, ensure_ascii=False) 154 | 155 | return output_files 156 | 157 | 158 | @logger.catch(reraise=True) 159 | def random_split( 160 | annotations_file: str, 161 | output_filename: str, 162 | val_percentage: float = 0.25, 163 | seed: int = 47, 164 | ) -> List[str]: 165 | """Split the annotations file in training and validation subsets randomly. 166 | 167 | Args: 168 | annotations_file: Path to annotations file. 169 | output_filename: Output filename. 170 | val_percentage: Percentage of validation images. Defaults to 0.25. 171 | seed: Seed for the random generator. Defaults to 47. 172 | 173 | Returns: 174 | Output filenames. 175 | 176 | """ 177 | data = json.load(open(annotations_file)) 178 | train_images, val_images, val_ids = [], [], [] 179 | 180 | np.random.seed(seed) 181 | rand_values = np.random.rand(len(data["images"])) 182 | 183 | logger.info("Gathering images...") 184 | for i, image in enumerate(data["images"]): 185 | if rand_values[i] < val_percentage: 186 | val_images.append(copy(image)) 187 | val_ids.append(image["id"]) 188 | else: 189 | train_images.append(copy(image)) 190 | 191 | train_annotations, val_annotations = [], [] 192 | 193 | logger.info("Gathering annotations...") 194 | for annotation in data["annotations"]: 195 | if annotation["image_id"] in val_ids: 196 | val_annotations.append(copy(annotation)) 197 | else: 198 | train_annotations.append(copy(annotation)) 199 | 200 | train_split = { 201 | "images": train_images, 202 | "annotations": train_annotations, 203 | "info": data.get("info", {}), 204 | "licenses": data.get("licenses", []), 205 | "categories": data["categories"], 206 | } 207 | 208 | val_split = { 209 | "images": val_images, 210 | "annotations": val_annotations, 211 | "info": data.get("info", {}), 212 | "licenses": data.get("licenses", []), 213 | "categories": data["categories"], 214 | } 215 | 216 | logger.info("Saving splits to file...") 217 | output_files = [] 218 | for split_type, split in zip(["train", "val"], [train_split, val_split]): 219 | output_files.append(output_filename + f"_{split_type}.json") 220 | with open(output_files[-1], "w", encoding="utf-8") as f: 221 | json.dump(split, f, indent=2, ensure_ascii=False) 222 | 223 | return output_files 224 | -------------------------------------------------------------------------------- /pyodi/apps/crops/__init__.py: -------------------------------------------------------------------------------- 1 | from pyodi.apps.crops.crops_merge import crops_merge 2 | from pyodi.apps.crops.crops_split import crops_split 3 | 4 | crops_app = { 5 | "merge": crops_merge, 6 | "split": crops_split, 7 | } 8 | -------------------------------------------------------------------------------- /pyodi/apps/crops/crops_merge.py: -------------------------------------------------------------------------------- 1 | """# Crops Merge App. 2 | 3 | --- 4 | 5 | # API REFERENCE 6 | """ 7 | import json 8 | from pathlib import Path 9 | from typing import Optional 10 | 11 | from loguru import logger 12 | 13 | from pyodi.core.nms import nms_predictions 14 | 15 | 16 | @logger.catch(reraise=True) 17 | def crops_merge( 18 | ground_truth_file: str, 19 | output_file: str, 20 | predictions_file: Optional[str] = None, 21 | apply_nms: bool = True, 22 | score_thr: float = 0.0, 23 | iou_thr: float = 0.5, 24 | ) -> str: 25 | """Merge and translate `ground_truth_file` or `predictions` to `ground_truth`'s `old_images` coordinates. 26 | 27 | Args: 28 | ground_truth_file: Path to COCO ground truth file of crops. Generated with 29 | `crops_split`. 30 | output_file: Path where the merged annotations will be saved. 31 | predictions_file: Path to COCO predictions file over `ground_truth_file`. 32 | If not None, the annotations of predictions_file will be merged instead of ground_truth_file's. 33 | apply_nms: Whether to apply Non Maximum Supression to the merged predictions of 34 | each image. Defaults to True. 35 | score_thr: Predictions bellow `score_thr` will be filtered. Only used if 36 | `apply_nms`. Defaults to 0.0. 37 | iou_thr: None of the filtered predictions will have an iou above `iou_thr` to 38 | any other. Only used if `apply_nms`. Defaults to 0.5. 39 | 40 | """ 41 | ground_truth = json.load(open(ground_truth_file)) 42 | 43 | crop_id_to_filename = {x["id"]: x["file_name"] for x in ground_truth["images"]} 44 | 45 | stem_to_original_id = { 46 | Path(x["file_name"]).stem: x["id"] for x in ground_truth["old_images"] 47 | } 48 | stem_to_original_shape = { 49 | Path(x["file_name"]).stem: (x["width"], x["height"]) 50 | for x in ground_truth["old_images"] 51 | } 52 | 53 | if predictions_file is not None: 54 | annotations = json.load(open(predictions_file)) 55 | else: 56 | annotations = ground_truth["annotations"] 57 | 58 | for n, crop in enumerate(annotations): 59 | if not n % 10: 60 | logger.info(n) 61 | filename = crop_id_to_filename[crop["image_id"]] 62 | parts = Path(filename).stem.split("_") 63 | 64 | stem = "_".join(parts[:-2]) 65 | original_id = stem_to_original_id[stem] 66 | crop["image_id"] = original_id 67 | 68 | # Corners are encoded in crop's filename 69 | # See crops_split.py 70 | crop_row = int(parts[-1]) 71 | crop_col = int(parts[-2]) 72 | crop["bbox"][0] += crop_col 73 | crop["bbox"][1] += crop_row 74 | crop["original_image_shape"] = stem_to_original_shape[stem] 75 | 76 | if apply_nms: 77 | annotations = nms_predictions(annotations, score_thr=score_thr, iou_thr=iou_thr) 78 | output_file = str( 79 | Path(output_file).parent 80 | / f"{Path(output_file).stem}_{score_thr}_{iou_thr}.json" 81 | ) 82 | 83 | if predictions_file is not None: 84 | new_ground_truth = annotations 85 | else: 86 | new_ground_truth = { 87 | "info": ground_truth.get("info", []), 88 | "licenses": ground_truth.get("licenses", []), 89 | "categories": ground_truth.get("categories", []), 90 | "images": ground_truth.get("old_images"), 91 | "annotations": annotations, 92 | } 93 | 94 | with open(output_file, "w", encoding="utf-8") as f: 95 | json.dump(new_ground_truth, f, indent=2, ensure_ascii=False) 96 | 97 | return output_file 98 | -------------------------------------------------------------------------------- /pyodi/apps/crops/crops_split.py: -------------------------------------------------------------------------------- 1 | """# Crops Split App. 2 | 3 | --- 4 | 5 | # API REFERENCE 6 | """ 7 | import json 8 | from collections import defaultdict 9 | from pathlib import Path 10 | from typing import Dict, List 11 | 12 | from loguru import logger 13 | from PIL import Image 14 | 15 | from pyodi.core.crops import ( 16 | annotation_inside_crop, 17 | filter_annotation_by_area, 18 | get_annotation_in_crop, 19 | get_crops_corners, 20 | ) 21 | 22 | 23 | @logger.catch(reraise=True) 24 | def crops_split( 25 | ground_truth_file: str, 26 | image_folder: str, 27 | output_file: str, 28 | output_image_folder: str, 29 | crop_height: int, 30 | crop_width: int, 31 | row_overlap: int = 0, 32 | col_overlap: int = 0, 33 | min_area_threshold: float = 0.0, 34 | ) -> None: 35 | """Creates new dataset by splitting images into crops and adapting the annotations. 36 | 37 | Args: 38 | ground_truth_file: Path to a COCO ground_truth_file. 39 | image_folder: Path where the images of the ground_truth_file are stored. 40 | output_file: Path where the `new_ground_truth_file` will be saved. 41 | output_image_folder: Path where the crops will be saved. 42 | crop_height: Crop height. 43 | crop_width: Crop width 44 | row_overlap: Row overlap. Defaults to 0. 45 | col_overlap: Column overlap. Defaults to 0. 46 | min_area_threshold: Minimum area threshold ratio. If the cropped annotation area 47 | is smaller than the threshold, the annotation is filtered out. Defaults to 0. 48 | 49 | """ 50 | ground_truth = json.load(open(ground_truth_file)) 51 | 52 | image_id_to_annotations: Dict = defaultdict(list) 53 | for annotation in ground_truth["annotations"]: 54 | image_id_to_annotations[annotation["image_id"]].append(annotation) 55 | 56 | output_image_folder_path = Path(output_image_folder) 57 | output_image_folder_path.mkdir(exist_ok=True, parents=True) 58 | new_images: List = [] 59 | new_annotations: List = [] 60 | 61 | for image in ground_truth["images"]: 62 | file_name = Path(image["file_name"]) 63 | logger.info(file_name) 64 | image_pil = Image.open(Path(image_folder) / file_name.name) 65 | 66 | crops_corners = get_crops_corners( 67 | image_pil, crop_height, crop_width, row_overlap, col_overlap 68 | ) 69 | 70 | for crop_corners in crops_corners: 71 | logger.info(crop_corners) 72 | crop = image_pil.crop(crop_corners) 73 | 74 | crop_suffixes = "_".join(map(str, crop_corners)) 75 | crop_file_name = f"{file_name.stem}_{crop_suffixes}{file_name.suffix}" 76 | 77 | crop.save(output_image_folder_path / crop_file_name) 78 | 79 | crop_id = len(new_images) 80 | new_images.append( 81 | { 82 | "file_name": Path(crop_file_name).name, 83 | "height": int(crop_height), 84 | "width": int(crop_width), 85 | "id": int(crop_id), 86 | } 87 | ) 88 | for annotation in image_id_to_annotations[image["id"]]: 89 | if not annotation_inside_crop(annotation, crop_corners): 90 | continue 91 | new_annotation = get_annotation_in_crop(annotation, crop_corners) 92 | if filter_annotation_by_area( 93 | annotation, new_annotation, min_area_threshold 94 | ): 95 | continue 96 | new_annotation["id"] = len(new_annotations) 97 | new_annotation["image_id"] = crop_id 98 | new_annotations.append(new_annotation) 99 | 100 | new_ground_truth = { 101 | "images": new_images, 102 | "old_images": ground_truth["images"], 103 | "annotations": new_annotations, 104 | "categories": ground_truth["categories"], 105 | "licenses": ground_truth.get("licenses", []), 106 | "info": ground_truth.get("info"), 107 | } 108 | 109 | Path(output_file).parent.mkdir(exist_ok=True, parents=True) 110 | 111 | with open(output_file, "w") as f: 112 | json.dump(new_ground_truth, f, indent=2) 113 | -------------------------------------------------------------------------------- /pyodi/apps/evaluation.py: -------------------------------------------------------------------------------- 1 | """# Evaluation App. 2 | 3 | The [`pyodi evaluation`][pyodi.apps.evaluation.evaluation] app can be used to evaluate 4 | the predictions of an object detection dataset. 5 | 6 | Example usage: 7 | 8 | ``` bash 9 | pyodi evaluation "data/COCO/COCO_val2017.json" "data/COCO/COCO_val2017_predictions.json" 10 | ``` 11 | 12 | This app shows the Average Precision for different IoU values and different areas, the 13 | Average Recall for different IoU values and differents maximum detections. 14 | 15 | An example of the result of executing this app: 16 | ``` 17 | Average Precision (AP) @[ IoU=0.50:0.95 | area= all | maxDets=100 ] = 0.256 18 | Average Precision (AP) @[ IoU=0.50 | area= all | maxDets=100 ] = 0.438 19 | Average Precision (AP) @[ IoU=0.75 | area= all | maxDets=100 ] = 0.263 20 | Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.068 21 | Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.278 22 | Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.422 23 | Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets= 1 ] = 0.239 24 | Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets= 10 ] = 0.353 25 | Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets=100 ] = 0.375 26 | Average Recall (AR) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.122 27 | Average Recall (AR) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.416 28 | Average Recall (AR) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.586 29 | ``` 30 | --- 31 | 32 | # API REFERENCE 33 | """ # noqa: E501 34 | import json 35 | import re 36 | from typing import Optional 37 | 38 | from loguru import logger 39 | from pycocotools.cocoeval import COCOeval 40 | 41 | from pyodi.core.utils import load_coco_ground_truth_from_StringIO 42 | 43 | 44 | @logger.catch(reraise=True) 45 | def evaluation( 46 | ground_truth_file: str, predictions_file: str, string_to_match: Optional[str] = None 47 | ) -> None: 48 | """Evaluate the predictions of a dataset. 49 | 50 | Args: 51 | ground_truth_file: Path to COCO ground truth file. 52 | predictions_file: Path to COCO predictions file. 53 | string_to_match: If not None, only images whose file_name match this parameter 54 | will be evaluated. 55 | 56 | """ 57 | with open(ground_truth_file) as gt: 58 | coco_ground_truth = load_coco_ground_truth_from_StringIO(gt) 59 | with open(predictions_file) as pred: 60 | coco_predictions = coco_ground_truth.loadRes(json.load(pred)) 61 | 62 | coco_eval = COCOeval(coco_ground_truth, coco_predictions, "bbox") 63 | 64 | if string_to_match is not None: 65 | filtered_ids = [ 66 | k 67 | for k, v in coco_ground_truth.imgs.items() 68 | if re.match(string_to_match, v["file_name"]) 69 | ] 70 | logger.info("Number of filtered_ids: {}".format(len(filtered_ids))) 71 | else: 72 | filtered_ids = [k for k in coco_ground_truth.imgs.keys()] 73 | 74 | coco_eval.image_ids = filtered_ids 75 | coco_eval.evaluate() 76 | coco_eval.accumulate() 77 | coco_eval.summarize() 78 | -------------------------------------------------------------------------------- /pyodi/apps/ground_truth.py: -------------------------------------------------------------------------------- 1 | r"""# Ground Truth App. 2 | 3 | The [`pyodi ground-truth`][pyodi.apps.ground_truth.ground_truth] app can be used to 4 | explore the images and bounding boxes that compose an object detection dataset. 5 | 6 | The shape distribution of the images and bounding boxes and their locations are the key 7 | aspects to take in account when setting your training configuration. 8 | 9 | Example usage: 10 | 11 | ```bash 12 | pyodi ground-truth \\ 13 | $TINY_COCO_ANIMAL/annotations/train.json 14 | ``` 15 | 16 | The app is divided in three different sections: 17 | 18 | ## Images shape distribution 19 | 20 | Shows information related with the shape of the images present in the dataset. 21 | In this case we can clearly identify two main patterns in this dataset and if 22 | we have a look at the histogram, we can see how most of images have 640 pixels 23 | width, while as height is more distributed between different values. 24 | 25 | ![COCO Animals Image Shapes](../../images/ground_truth/gt_img_shapes.png) 26 | 27 | ## Bounding Boxes shape distribution 28 | 29 | We observe bounding box distribution, with the possibility of enabling 30 | filters by class or sets of classes. This dataset shows a tendency to rectangular 31 | bounding boxes with larger width than height and where most of them embrace 32 | areas below the 20% of the total image. 33 | 34 | ![Bbox distribution](../../images/ground_truth/gt_bb_shapes.png) 35 | 36 | ## Bounding Boxes center locations 37 | 38 | It is possible to check where centers of bounding boxes are most commonly 39 | found with respect to the image. This can help us distinguish ROIs in input images. 40 | In this case we observe that the objects usually appear in the center of the image. 41 | 42 | ![Bbox center distribution](../../images/ground_truth/gt_bb_centers.png) 43 | 44 | --- 45 | 46 | # API REFERENCE 47 | """ # noqa: E501 48 | from pathlib import Path 49 | from typing import Optional, Tuple 50 | 51 | from pyodi.core.boxes import add_centroids 52 | from pyodi.core.utils import coco_ground_truth_to_df 53 | from pyodi.plots.boxes import get_centroids_heatmap, plot_heatmap 54 | from pyodi.plots.common import plot_scatter_with_histograms 55 | 56 | 57 | def ground_truth( 58 | ground_truth_file: str, 59 | show: bool = True, 60 | output: Optional[str] = None, 61 | output_size: Tuple[int, int] = (1600, 900), 62 | ) -> None: 63 | """Explore the images and bounding boxes of a dataset. 64 | 65 | Args: 66 | ground_truth_file: Path to COCO ground truth file. 67 | show: Whether to show results or not. Defaults to True. 68 | output: Results will be saved under `output` dir. Defaults to None. 69 | output_size: Size of the saved images when output is defined. Defaults to 70 | (1600, 900). 71 | 72 | """ 73 | if output is not None: 74 | output = str(Path(output) / Path(ground_truth_file).stem) 75 | Path(output).mkdir(parents=True, exist_ok=True) 76 | 77 | df_annotations = coco_ground_truth_to_df(ground_truth_file) 78 | 79 | df_images = df_annotations.loc[ 80 | :, df_annotations.columns.str.startswith("img_") 81 | ].drop_duplicates() 82 | 83 | plot_scatter_with_histograms( 84 | df_images, 85 | x="img_width", 86 | y="img_height", 87 | title="Image_Shapes", 88 | show=show, 89 | output=output, 90 | output_size=output_size, 91 | histogram_xbins=dict(size=10), 92 | histogram_ybins=dict(size=10), 93 | ) 94 | 95 | df_annotations = add_centroids(df_annotations) 96 | 97 | df_annotations["absolute_height"] = ( 98 | df_annotations["height"] / df_annotations["img_height"] 99 | ) 100 | df_annotations["absolute_width"] = ( 101 | df_annotations["width"] / df_annotations["img_width"] 102 | ) 103 | 104 | plot_scatter_with_histograms( 105 | df_annotations, 106 | x="absolute_width", 107 | y="absolute_height", 108 | title="Bounding_Box_Shapes", 109 | show=show, 110 | output=output, 111 | output_size=output_size, 112 | xaxis_range=(-0.01, 1.01), 113 | yaxis_range=(-0.01, 1.01), 114 | histogram_xbins=dict(size=0.05), 115 | histogram_ybins=dict(size=0.05), 116 | ) 117 | 118 | plot_heatmap( 119 | get_centroids_heatmap(df_annotations), 120 | title="Bounding_Box_Centers", 121 | show=show, 122 | output=output, 123 | output_size=output_size, 124 | ) 125 | -------------------------------------------------------------------------------- /pyodi/apps/paint_annotations.py: -------------------------------------------------------------------------------- 1 | r"""# Paint Annotations App. 2 | 3 | The [`pyodi paint-annotations`][pyodi.apps.paint_annotations.paint_annotations] 4 | helps you to easily visualize in a beautiful format your object detection dataset. 5 | You can also use this function to visualize model predictions if they are in COCO predictions format. 6 | 7 | Example usage: 8 | 9 | ```bash 10 | pyodi paint-annotations \\ 11 | $TINY_COCO_ANIMAL/annotations/train.json \\ 12 | $TINY_COCO_ANIMAL/sample_images/ \\ 13 | $TINY_COCO_ANIMAL/painted_images/ 14 | ``` 15 | 16 | ![COCO image with painted annotations](../../images/coco_sample_82680.jpg) 17 | 18 | --- 19 | 20 | # API REFERENCE 21 | """ # noqa: E501 22 | 23 | import json 24 | from collections import defaultdict 25 | from pathlib import Path 26 | from typing import Dict, Optional 27 | 28 | import numpy as np 29 | from loguru import logger 30 | from matplotlib import cm as cm 31 | from matplotlib import pyplot as plt 32 | from matplotlib.collections import PatchCollection 33 | from matplotlib.patches import Polygon 34 | from PIL import Image, ImageOps 35 | 36 | 37 | @logger.catch(reraise=True) 38 | def paint_annotations( 39 | ground_truth_file: str, 40 | image_folder: str, 41 | output_folder: str, 42 | predictions_file: Optional[str] = None, 43 | score_thr: float = 0.0, 44 | color_key: str = "category_id", 45 | show_label: bool = True, 46 | filter_crowd: bool = True, 47 | first_n: Optional[int] = None, 48 | use_exif_orientation: bool = False, 49 | ) -> None: 50 | """Paint `ground_truth_file` or `predictions_file` annotations on `image_folder` images. 51 | 52 | Args: 53 | ground_truth_file: Path to COCO ground truth file. 54 | image_folder: Path to root folder where the images of `ground_truth_file` are. 55 | output_folder: Path to the folder where painted images will be saved. 56 | It will be created if it does not exist. 57 | predictions_file: Path to COCO predictions file. 58 | If not None, the annotations of predictions_file will be painted instead of ground_truth_file's. 59 | score_thr: Detections bellow this threshold will not be painted. 60 | Default 0.0. 61 | color_key: Choose the key in annotations on which the color will depend. Defaults to 'category_id'. 62 | show_label: Choose whether to show label and score threshold on image. Default True. 63 | filter_crowd: Filter out crowd annotations or not. Default True. 64 | first_n: Paint only first n annotations and stop after that. 65 | If None, all images will be painted. 66 | use_exif_orientation: If an image has an EXIF Orientation tag, other than 1, return a new image that 67 | is transposed accordingly. The new image will have the orientation data removed. 68 | """ 69 | Path(output_folder).mkdir(exist_ok=True, parents=True) 70 | 71 | ground_truth = json.load(open(ground_truth_file)) 72 | image_name_to_id = { 73 | Path(x["file_name"]).stem: x["id"] for x in ground_truth["images"] 74 | } 75 | 76 | category_id_to_label = { 77 | cat["id"]: cat["name"] for cat in ground_truth["categories"] 78 | } 79 | image_id_to_annotations: Dict = defaultdict(list) 80 | if predictions_file is not None: 81 | with open(predictions_file) as pred: 82 | annotations = json.load(pred) 83 | else: 84 | annotations = ground_truth["annotations"] 85 | 86 | n_colors = len(set(ann[color_key] for ann in annotations)) 87 | colormap = cm.rainbow(np.linspace(0, 1, n_colors)) 88 | 89 | for annotation in annotations: 90 | if not (filter_crowd and annotation.get("iscrowd", False)): 91 | image_id_to_annotations[annotation["image_id"]].append(annotation) 92 | 93 | image_data = ground_truth["images"] 94 | first_n = first_n or len(image_data) 95 | 96 | for image in image_data[:first_n]: 97 | image_filename = image["file_name"] 98 | image_id = image["id"] 99 | image_path = Path(image_folder) / image_filename 100 | 101 | logger.info(f"Loading {image_filename}") 102 | 103 | if Path(image_filename).stem not in image_name_to_id: 104 | logger.warning(f"{image_filename} not in ground_truth_file") 105 | 106 | if image_path.is_file(): 107 | image_pil = Image.open(image_path) 108 | if use_exif_orientation: 109 | image_pil = ImageOps.exif_transpose(image_pil) 110 | 111 | width, height = image_pil.size 112 | fig = plt.figure(frameon=False, figsize=(width / 96, height / 96)) 113 | 114 | ax = plt.Axes(fig, [0.0, 0.0, 1.0, 1.0]) 115 | ax.set_axis_off() 116 | fig.add_axes(ax) 117 | ax.imshow(image_pil, aspect="auto") 118 | 119 | polygons = [] 120 | colors = [] 121 | 122 | for annotation in image_id_to_annotations[image_id]: 123 | score = annotation.get("score", 1) 124 | if score < score_thr: 125 | continue 126 | bbox_left, bbox_top, bbox_width, bbox_height = annotation["bbox"] 127 | 128 | cat_id = annotation["category_id"] 129 | label = category_id_to_label[cat_id] 130 | color_id = annotation[color_key] 131 | color = colormap[color_id % len(colormap)] 132 | 133 | poly = [ 134 | [bbox_left, bbox_top], 135 | [bbox_left, bbox_top + bbox_height], 136 | [bbox_left + bbox_width, bbox_top + bbox_height], 137 | [bbox_left + bbox_width, bbox_top], 138 | ] 139 | polygons.append(Polygon(poly)) 140 | colors.append(color) 141 | 142 | if show_label: 143 | label_text = f"{label}" 144 | if predictions_file is not None: 145 | label_text += f": {score:.2f}" 146 | 147 | ax.text( 148 | bbox_left, 149 | bbox_top, 150 | label_text, 151 | va="top", 152 | ha="left", 153 | bbox=dict(facecolor="white", edgecolor=color, alpha=0.5, pad=0), 154 | ) 155 | 156 | p = PatchCollection(polygons, facecolor=colors, linewidths=0, alpha=0.3) 157 | ax.add_collection(p) 158 | 159 | p = PatchCollection( 160 | polygons, facecolor="none", edgecolors=colors, linewidths=1 161 | ) 162 | ax.add_collection(p) 163 | 164 | filename = Path(image_filename).stem 165 | file_extension = Path(image_filename).suffix 166 | output_file = Path(output_folder) / f"{filename}_result{file_extension}" 167 | logger.info(f"Saving {output_file}") 168 | 169 | plt.savefig(output_file) 170 | plt.close() 171 | -------------------------------------------------------------------------------- /pyodi/apps/train_config/__init__.py: -------------------------------------------------------------------------------- 1 | from pyodi.apps.train_config.train_config_evaluation import train_config_evaluation 2 | from pyodi.apps.train_config.train_config_generation import train_config_generation 3 | 4 | train_config_app = { 5 | "generation": train_config_generation, 6 | "evaluation": train_config_evaluation, 7 | } 8 | -------------------------------------------------------------------------------- /pyodi/apps/train_config/train_config_evaluation.py: -------------------------------------------------------------------------------- 1 | r"""# Train Config Evaluation App. 2 | 3 | The [`pyodi train-config evaluation`][pyodi.apps.train_config.train_config_evaluation.train_config_evaluation] 4 | app can be used to evaluate a given [mmdetection](https://github.com/open-mmlab/mmdetection) 5 | Anchor Generator Configuration to train your model using a specific training pipeline. 6 | 7 | ## Procedure 8 | 9 | Training performance of object detection model depends on how well generated anchors 10 | match with ground truth bounding boxes. This simple application provides intuitions 11 | about this, by recreating train preprocessing conditions such as image resizing or 12 | padding, and computing different metrics based on the largest Intersection over Union 13 | (IoU) between ground truth boxes and the provided anchors. 14 | 15 | Each bounding box is assigned with the anchor that shares a largest IoU with it. We call 16 | overlap, to the maximum IoU each ground truth box has with the generated anchor set. 17 | 18 | Example usage: 19 | 20 | ```bash 21 | pyodi train-config evaluation \\ 22 | $TINY_COCO_ANIMAL/annotations/train.json \\ 23 | $TINY_COCO_ANIMAL/resources/anchor_config.py \\ 24 | --input-size [1280,720] 25 | ``` 26 | 27 | The app provides four different plots: 28 | 29 | ![COCO scale_ratio](../../images/train-config-evaluation/overlap.png#center) 30 | 31 | 32 | ## Cumulative Overlap 33 | 34 | It shows a cumulative distribution function for the overlap distribution. This view 35 | helps to distinguish which percentage of bounding boxes have a very low overlap with 36 | generated anchors and viceversa. 37 | 38 | It can be very useful to determine positive and negative thresholds for your training, 39 | these are the values that determine is a ground truth bounding box will is going to be 40 | taken into account in the loss function or discarded and considered as background. 41 | 42 | ## Bounding Box Distribution 43 | 44 | It shows a scatter plot of bounding box width vs height. The color of each point 45 | represent the overlap value assigned to that bounding box. Thanks to this plot we 46 | can easily observe pattern such low overlap values for large bounding boxes. 47 | We could have this into account and generate larger anchors to improve this matching. 48 | 49 | ## Scale and Mean Overlap 50 | 51 | This plot contains a simple histogram with bins of similar scales and its mean overlap 52 | value. It help us to visualize how overlap decays when scale increases, as we said 53 | before. 54 | 55 | ## Log Ratio and Mean Overlap 56 | 57 | Similarly to previous plot, it shows an histogram of bounding box log ratios and its 58 | mean overlap values. It is useful to visualize this relation and see how certain box 59 | ratios might be having problems to match with generated anchors. In this example, boxes 60 | with negative log ratios, where width is much larger than height, overlaps are very 61 | small. See how this matches with patterns observed in bounding box distribution plot, 62 | where all boxes placed near to x axis, have low overlaps. 63 | 64 | --- 65 | 66 | # API REFERENCE 67 | """ # noqa: E501 68 | import sys 69 | from importlib import import_module 70 | from os import path as osp 71 | from pathlib import Path 72 | from shutil import copyfile 73 | from tempfile import TemporaryDirectory 74 | from typing import Any, Dict, Optional, Tuple, Union 75 | 76 | import numpy as np 77 | import pandas as pd 78 | from loguru import logger 79 | 80 | from pyodi.core.anchor_generator import AnchorGenerator 81 | from pyodi.core.boxes import ( 82 | filter_zero_area_bboxes, 83 | get_bbox_array, 84 | get_scale_and_ratio, 85 | scale_bbox_dimensions, 86 | ) 87 | from pyodi.core.clustering import get_max_overlap 88 | from pyodi.core.utils import coco_ground_truth_to_df 89 | from pyodi.plots.evaluation import plot_overlap_result 90 | 91 | 92 | def load_anchor_config_file(anchor_config_file: str) -> Dict[str, Any]: 93 | """Loads the `anchor_config_file`. 94 | 95 | Args: 96 | anchor_config_file: File with the anchor configuration. 97 | 98 | Returns: 99 | Dictionary with the training configuration. 100 | 101 | """ 102 | logger.info("Loading Train Config File") 103 | with TemporaryDirectory() as temp_config_dir: 104 | copyfile(anchor_config_file, osp.join(temp_config_dir, "_tempconfig.py")) 105 | sys.path.insert(0, temp_config_dir) 106 | mod = import_module("_tempconfig") 107 | sys.path.pop(0) 108 | train_config = { 109 | name: value 110 | for name, value in mod.__dict__.items() 111 | if not name.startswith("__") 112 | } 113 | # delete imported module 114 | del sys.modules["_tempconfig"] 115 | return train_config 116 | 117 | 118 | @logger.catch(reraise=True) 119 | def train_config_evaluation( 120 | ground_truth_file: Union[str, pd.DataFrame], 121 | anchor_config: str, 122 | input_size: Tuple[int, int] = (1280, 720), 123 | show: bool = True, 124 | output: Optional[str] = None, 125 | output_size: Tuple[int, int] = (1600, 900), 126 | keep_ratio: bool = False, 127 | ) -> None: 128 | """Evaluates the fitness between `ground_truth_file` and `anchor_config_file`. 129 | 130 | Args: 131 | ground_truth_file: Path to COCO ground truth file or coco df_annotations DataFrame 132 | to be used from 133 | [`pyodi train-config generation`][pyodi.apps.train_config.train_config_generation.train_config_generation] 134 | anchor_config: Path to MMDetection-like `anchor_generator` section. It can also be a 135 | dictionary with the required data. 136 | input_size: Model image input size. Defaults to (1333, 800). 137 | show: Show results or not. Defaults to True. 138 | output: Output directory where results going to be saved. Defaults to None. 139 | output_size: Size of saved images. Defaults to (1600, 900). 140 | keep_ratio: Whether to keep the aspect ratio or not. Defaults to False. 141 | 142 | Examples: 143 | ```python 144 | # faster_rcnn_r50_fpn.py: 145 | anchor_generator=dict( 146 | type='AnchorGenerator', 147 | scales=[8], 148 | ratios=[0.5, 1.0, 2.0], 149 | strides=[4, 8, 16, 32, 64] 150 | ) 151 | ``` 152 | """ 153 | if output is not None: 154 | Path(output).mkdir(parents=True, exist_ok=True) 155 | 156 | if isinstance(ground_truth_file, str): 157 | df_annotations = coco_ground_truth_to_df(ground_truth_file) 158 | 159 | df_annotations = filter_zero_area_bboxes(df_annotations) 160 | 161 | df_annotations = scale_bbox_dimensions( 162 | df_annotations, input_size=input_size, keep_ratio=keep_ratio 163 | ) 164 | 165 | df_annotations = get_scale_and_ratio(df_annotations, prefix="scaled") 166 | 167 | else: 168 | df_annotations = ground_truth_file 169 | 170 | df_annotations["log_scaled_ratio"] = np.log(df_annotations["scaled_ratio"]) 171 | 172 | if isinstance(anchor_config, str): 173 | anchor_config_data = load_anchor_config_file(anchor_config) 174 | elif isinstance(anchor_config, dict): 175 | anchor_config_data = anchor_config 176 | else: 177 | raise ValueError("anchor_config must be string or dictionary.") 178 | 179 | anchor_config_data["anchor_generator"].pop("type", None) 180 | anchor_generator = AnchorGenerator(**anchor_config_data["anchor_generator"]) 181 | 182 | if isinstance(anchor_config, str): 183 | logger.info(anchor_generator.to_string()) 184 | 185 | width, height = input_size 186 | featmap_sizes = [ 187 | (width // stride, height // stride) for stride in anchor_generator.strides 188 | ] 189 | anchors_per_level = anchor_generator.grid_anchors(featmap_sizes=featmap_sizes) 190 | 191 | bboxes = get_bbox_array( 192 | df_annotations, prefix="scaled", output_bbox_format="corners" 193 | ) 194 | 195 | overlaps = np.zeros(bboxes.shape[0]) 196 | max_overlap_level = np.zeros(bboxes.shape[0]) 197 | 198 | logger.info("Computing overlaps between anchors and ground truth ...") 199 | for i, anchor_level in enumerate(anchors_per_level): 200 | level_overlaps = get_max_overlap( 201 | bboxes.astype(np.float32), anchor_level.astype(np.float32) 202 | ) 203 | max_overlap_level[level_overlaps > overlaps] = i 204 | overlaps = np.maximum(overlaps, level_overlaps) 205 | 206 | df_annotations["overlaps"] = overlaps 207 | df_annotations["max_overlap_level"] = max_overlap_level 208 | 209 | logger.info("Plotting results ...") 210 | plot_overlap_result( 211 | df_annotations, show=show, output=output, output_size=output_size 212 | ) 213 | -------------------------------------------------------------------------------- /pyodi/apps/train_config/train_config_generation.py: -------------------------------------------------------------------------------- 1 | r"""# Train Config Generation App. 2 | 3 | The [`pyodi train-config generation`][pyodi.apps.train_config.train_config_generation.train_config_generation] 4 | app can be used to automatically generate a [mmdetection](https://github.com/open-mmlab/mmdetection) 5 | anchor configuration to train your model. 6 | 7 | The design of anchors is critical for the performance of one-stage detectors. Usually, published models 8 | such [Faster R-CNN](https://arxiv.org/abs/1506.01497) or [RetinaNet](https://arxiv.org/abs/1708.02002) 9 | include default anchors which has been designed to work with general object detection purpose as COCO dataset. 10 | Nevertheless, you might be envolved in different problems which data contains only a few different classes that 11 | share similar properties, as the object sizes or shapes, this would be the case for a drone detection dataset 12 | such [Drone vs Bird](https://wosdetc2020.wordpress.com/). You can exploit this knowledge by designing anchors 13 | that specially fit the distribution of your data, optimizing the probability of matching ground truth bounding 14 | boxes with generated anchors, which can result in an increase in the performance of your model. At the same time, 15 | you can reduce the number of anchors you use to boost inference and training time. 16 | 17 | ## Procedure 18 | 19 | The input size parameter determines the model input size and automatically reshapes images and annotations sizes to it. 20 | Ground truth boxes are assigned to the anchor base size that has highest Intersection 21 | over Union (IoU) score with them. This step, allow us to locate each ground truth 22 | bounding box in a feature level of the FPN pyramid. 23 | 24 | Once this is done, the ratio between the scales of ground truth boxes and the scales of 25 | their associated anchors is computed. A log transform is applied to it and they are clustered 26 | using kmeans algorithm, where the number of obtained clusters depends on `n_scales` input parameter. 27 | 28 | After this step, a similar procedure is followed to obtain the reference scale ratios of the 29 | dataset, computing log scales ratios of each box and clustering them with number of 30 | clusters equal to `n_ratios`. 31 | 32 | Example usage: 33 | ```bash 34 | pyodi train-config generation \\ 35 | $TINY_COCO_ANIMAL/annotations/train.json \\ 36 | --input-size [1280,720] \\ 37 | --n-ratios 3 --n-scales 3 38 | ``` 39 | 40 | The app shows two different plots: 41 | 42 | ![Anchor clustering plot](../../images/train-config-generation/clusters.png#center) 43 | 44 | 45 | ## Log Relative Scale vs Log Ratio 46 | 47 | In this graphic you can distinguish how your bounding boxes scales and ratios are 48 | distributed. The x axis represent the log scale of the ratio between the bounding box 49 | scales and the scale of their matched anchor base size. The y axis contains the bounding 50 | box log ratios. Centroids are the result of combinating the obtained scales and ratios 51 | obtained with kmeans. We can see how clusters appear in those areas where box distribution is more dense. 52 | 53 | We could increase the value of `n_ratios` from three to four, having into account that 54 | the number of anchors is goint to increase, which will influence training computational cost. 55 | 56 | ```bash 57 | pyodi train-config generation annotations/train.json --input-size [1280,720] --n-ratios 4 --n-scales 3 58 | ``` 59 | 60 | In plot below we can observe the result for `n_ratios` equal to four. 61 | 62 | ![Anchor clustering plot 4 ratios](../../images/train-config-generation/clusters_4_ratios.png#center) 63 | 64 | ## Bounding Box Distribution 65 | 66 | This plot is very useful to observe how the generated anchors fit you bounding box 67 | distribution. The number of anchors depends on: 68 | 69 | - The length of `base_sizes` which determines the number of FPN pyramid levels. 70 | - A total of `n_ratios` x `n_scales` anchors is generated per level 71 | 72 | We can now increase the number of `n_scales` and observe the effect on the bounding box distribution plot. 73 | 74 | ![Anchor clustering plot 4 scales](../../images/train-config-generation/clusters_4_scales.png#center) 75 | 76 | 77 | Proposed anchors are also attached in a Json file that follows 78 | [mmdetection anchors](https://github.com/open-mmlab/mmdetection/blob/master/mmdet/core/anchor/anchor_generator.py#L10) format: 79 | 80 | ```python 81 | anchor_generator=dict( 82 | type='AnchorGenerator', 83 | scales=[1.12, 3.13, 8.0], 84 | ratios=[0.33, 0.67, 1.4], 85 | strides=[4, 8, 16, 32, 64], 86 | base_sizes=[4, 8, 16, 32, 64], 87 | ) 88 | ``` 89 | 90 | By default, [`pyodi train-config evaluation`][pyodi.apps.train_config.train_config_evaluation.train_config_evaluation] is 91 | used after the generation of anchors in order to compare which generated anchor config suits better your data. 92 | You can disable this evaluation by setting to False the `evaluate` argument, but it is strongly advised to 93 | use the anchor evaluation module. 94 | 95 | 96 | --- 97 | 98 | # API REFERENCE 99 | """ # noqa: E501 100 | from pathlib import Path 101 | from typing import List, Optional, Tuple 102 | 103 | import numpy as np 104 | from loguru import logger 105 | 106 | from pyodi.apps.train_config.train_config_evaluation import train_config_evaluation 107 | from pyodi.core.anchor_generator import AnchorGenerator 108 | from pyodi.core.boxes import ( 109 | filter_zero_area_bboxes, 110 | get_bbox_array, 111 | get_scale_and_ratio, 112 | scale_bbox_dimensions, 113 | ) 114 | from pyodi.core.clustering import find_pyramid_level, kmeans_euclidean 115 | from pyodi.core.utils import coco_ground_truth_to_df 116 | from pyodi.plots.clustering import plot_clustering_results 117 | 118 | 119 | @logger.catch(reraise=True) 120 | def train_config_generation( 121 | ground_truth_file: str, 122 | input_size: Tuple[int, int] = (1280, 720), 123 | n_ratios: int = 3, 124 | n_scales: int = 3, 125 | strides: Optional[List[int]] = None, 126 | base_sizes: Optional[List[int]] = None, 127 | show: bool = True, 128 | output: Optional[str] = None, 129 | output_size: Tuple[int, int] = (1600, 900), 130 | keep_ratio: bool = False, 131 | evaluate: bool = True, 132 | ) -> None: 133 | """Computes optimal anchors for a given COCO dataset based on iou clustering. 134 | 135 | Args: 136 | ground_truth_file: Path to COCO ground truth file. 137 | input_size: Model image input size. Defaults to (1280, 720). 138 | n_ratios: Number of ratios. Defaults to 3. 139 | n_scales: Number of scales. Defaults to 3. 140 | strides: List of strides. Defatults to [4, 8, 16, 32, 64]. 141 | base_sizes: The basic sizes of anchors in multiple levels. 142 | If None is given, strides will be used as base_sizes. 143 | show: Show results or not. Defaults to True. 144 | output: Output directory where results going to be saved. Defaults to None. 145 | output_size: Size of saved images. Defaults to (1600, 900). 146 | keep_ratio: Whether to keep the aspect ratio or not. Defaults to False. 147 | evaluate: Whether to evaluate or not the anchors. Check 148 | [`pyodi train-config evaluation`][pyodi.apps.train_config.train_config_evaluation.train_config_evaluation] 149 | for more information. 150 | 151 | Returns: 152 | Anchor generator instance. 153 | """ 154 | if output is not None: 155 | Path(output).mkdir(parents=True, exist_ok=True) 156 | 157 | df_annotations = coco_ground_truth_to_df(ground_truth_file) 158 | 159 | df_annotations = filter_zero_area_bboxes(df_annotations) 160 | 161 | df_annotations = scale_bbox_dimensions( 162 | df_annotations, input_size=input_size, keep_ratio=keep_ratio 163 | ) 164 | 165 | df_annotations = get_scale_and_ratio(df_annotations, prefix="scaled") 166 | 167 | if strides is None: 168 | strides = [4, 8, 16, 32, 64] 169 | if base_sizes is None: 170 | base_sizes = strides 171 | 172 | # Assign fpn level 173 | df_annotations["fpn_level"] = find_pyramid_level( 174 | get_bbox_array(df_annotations, prefix="scaled")[:, 2:], base_sizes 175 | ) 176 | 177 | df_annotations["fpn_level_scale"] = df_annotations["fpn_level"].replace( 178 | {i: scale for i, scale in enumerate(base_sizes)} 179 | ) 180 | 181 | df_annotations["level_scale"] = ( 182 | df_annotations["scaled_scale"] / df_annotations["fpn_level_scale"] 183 | ) 184 | 185 | # Normalize to log scale 186 | df_annotations["log_ratio"] = np.log(df_annotations["scaled_ratio"]) 187 | df_annotations["log_level_scale"] = np.log(df_annotations["level_scale"]) 188 | 189 | # Cluster bboxes by scale and ratio independently 190 | clustering_results = [ 191 | kmeans_euclidean(df_annotations[value].to_numpy(), n_clusters=n_clusters) 192 | for i, (value, n_clusters) in enumerate( 193 | zip(["log_level_scale", "log_ratio"], [n_scales, n_ratios]) 194 | ) 195 | ] 196 | 197 | # Bring back from log scale 198 | scales = np.e ** clustering_results[0]["centroids"] 199 | ratios = np.e ** clustering_results[1]["centroids"] 200 | 201 | anchor_generator = AnchorGenerator( 202 | strides=strides, 203 | ratios=ratios, 204 | scales=scales, 205 | base_sizes=base_sizes, 206 | ) 207 | logger.info(f"Anchor configuration: \n{anchor_generator.to_string()}") 208 | 209 | plot_clustering_results( 210 | df_annotations, 211 | anchor_generator, 212 | show=show, 213 | output=output, 214 | output_size=output_size, 215 | title="COCO_anchor_generation", 216 | ) 217 | 218 | if evaluate: 219 | anchor_config = dict(anchor_generator=anchor_generator.to_dict()) 220 | train_config_evaluation( 221 | ground_truth_file=df_annotations, 222 | anchor_config=anchor_config, # type: ignore 223 | input_size=input_size, 224 | show=show, 225 | output=output, 226 | output_size=output_size, 227 | ) 228 | 229 | if output: 230 | output_file = Path(output) / "anchor_config.py" 231 | with open(output_file, "w") as f: 232 | f.write(anchor_generator.to_string()) 233 | -------------------------------------------------------------------------------- /pyodi/cli.py: -------------------------------------------------------------------------------- 1 | import fire 2 | 3 | from pyodi.apps.coco import coco_app 4 | from pyodi.apps.crops import crops_app 5 | from pyodi.apps.evaluation import evaluation 6 | from pyodi.apps.ground_truth import ground_truth 7 | from pyodi.apps.paint_annotations import paint_annotations 8 | from pyodi.apps.train_config import train_config_app 9 | 10 | 11 | def app() -> None: 12 | """Cli app.""" 13 | fire.Fire( 14 | { 15 | "evaluation": evaluation, 16 | "ground_truth": ground_truth, 17 | "paint_annotations": paint_annotations, 18 | "train-config": train_config_app, 19 | "crops": crops_app, 20 | "coco": coco_app, 21 | } 22 | ) 23 | 24 | 25 | if __name__ == "__main__": 26 | app() 27 | -------------------------------------------------------------------------------- /pyodi/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/pyodi/core/__init__.py -------------------------------------------------------------------------------- /pyodi/core/anchor_generator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Tuple 2 | 3 | import numpy as np 4 | from numpy import ndarray 5 | 6 | 7 | class AnchorGenerator(object): 8 | """Standard anchor generator for 2D anchor-based detectors. 9 | 10 | Args: 11 | strides (list[int]): Strides of anchors in multiple feture levels. 12 | ratios (list[float]): The list of ratios between the height and width 13 | of anchors in a single level. 14 | scales (list[int] | None): Anchor scales for anchors in a single level. 15 | It cannot be set at the same time if `octave_base_scale` and 16 | `scales_per_octave` are set. 17 | base_sizes (list[int] | None): The basic sizes of anchors in multiple 18 | levels. If None is given, strides will be used as base_sizes. 19 | scale_major (bool): Whether to multiply scales first when generating 20 | base anchors. If true, the anchors in the same row will have the 21 | same scales. By default it is True in V2.0 22 | octave_base_scale (int): The base scale of octave. 23 | scales_per_octave (int): Number of scales for each octave. 24 | `octave_base_scale` and `scales_per_octave` are usually used in 25 | retinanet and the `scales` should be None when they are set. 26 | centers (list[tuple[float, float]] | None): The centers of the anchor 27 | relative to the feature grid center in multiple feature levels. 28 | By default it is set to be None and not used. If a list of tuple of 29 | float is given, they will be used to shift the centers of anchors. 30 | center_offset (float): The offset of center in propotion to anchors' 31 | width and height. By default it is 0 in V2.0. 32 | 33 | Examples: 34 | >>> from mmdet.core import AnchorGenerator 35 | >>> self = AnchorGenerator([16], [1.], [1.], [9]) 36 | >>> all_anchors = self.grid_anchors([(2, 2)], device='cpu') 37 | >>> print(all_anchors) 38 | [tensor([[-4.5000, -4.5000, 4.5000, 4.5000], 39 | [11.5000, -4.5000, 20.5000, 4.5000], 40 | [-4.5000, 11.5000, 4.5000, 20.5000], 41 | [11.5000, 11.5000, 20.5000, 20.5000]])] 42 | >>> self = AnchorGenerator([16, 32], [1.], [1.], [9, 18]) 43 | >>> all_anchors = self.grid_anchors([(2, 2), (1, 1)], device='cpu') 44 | >>> print(all_anchors) 45 | [tensor([[-4.5000, -4.5000, 4.5000, 4.5000], 46 | [11.5000, -4.5000, 20.5000, 4.5000], 47 | [-4.5000, 11.5000, 4.5000, 20.5000], 48 | [11.5000, 11.5000, 20.5000, 20.5000]]), \ 49 | tensor([[-9., -9., 9., 9.]])] 50 | """ 51 | 52 | def __init__( 53 | self, 54 | strides: List[int], 55 | ratios: List[float], 56 | scales: Optional[List[float]] = None, 57 | base_sizes: Optional[List[int]] = None, 58 | scale_major: bool = True, 59 | octave_base_scale: Optional[int] = None, 60 | scales_per_octave: Optional[int] = None, 61 | centers: Optional[List[Tuple[float, float]]] = None, 62 | center_offset: float = 0.0, 63 | ) -> None: 64 | # check center and center_offset 65 | if center_offset != 0: 66 | assert ( 67 | centers is None 68 | ), "center cannot be set when center_offset" "!=0, {} is given.".format( 69 | centers 70 | ) 71 | if not (0 <= center_offset <= 1): 72 | raise ValueError( 73 | "center_offset should be in range [0, 1], {} is" 74 | " given.".format(center_offset) 75 | ) 76 | if centers is not None: 77 | assert len(centers) == len(strides), ( 78 | "The number of strides should be the same as centers, got " 79 | "{} and {}".format(strides, centers) 80 | ) 81 | 82 | # calculate base sizes of anchors 83 | self.strides = strides 84 | self.base_sizes = list(strides) if base_sizes is None else base_sizes 85 | assert len(self.base_sizes) == len(self.strides), ( 86 | "The number of strides should be the same as base sizes, got " 87 | "{} and {}".format(self.strides, self.base_sizes) 88 | ) 89 | 90 | # calculate scales of anchors 91 | assert (octave_base_scale is not None and scales_per_octave is not None) ^ ( 92 | scales is not None 93 | ), ( 94 | "scales and octave_base_scale with scales_per_octave cannot" 95 | " be set at the same time" 96 | ) 97 | if scales is not None: 98 | self.scales = np.array(scales) 99 | elif octave_base_scale is not None and scales_per_octave is not None: 100 | octave_scales = np.array( 101 | [2 ** (i / scales_per_octave) for i in range(scales_per_octave)] 102 | ) 103 | scales = octave_scales * octave_base_scale 104 | self.scales = np.array(scales) 105 | else: 106 | raise ValueError( 107 | "Either scales or octave_base_scale with " 108 | "scales_per_octave should be set" 109 | ) 110 | 111 | self.octave_base_scale = octave_base_scale 112 | self.scales_per_octave = scales_per_octave 113 | self.ratios = np.array(ratios) 114 | self.scale_major = scale_major 115 | self.centers = centers 116 | self.center_offset = center_offset 117 | self.base_anchors = self.gen_base_anchors() 118 | 119 | @property 120 | def num_levels(self) -> int: 121 | """Returns the number of levels. 122 | 123 | Returns: 124 | Number of levels. 125 | 126 | """ 127 | return len(self.strides) 128 | 129 | def gen_base_anchors(self) -> List[ndarray]: 130 | """Computes the anchors. 131 | 132 | Returns: 133 | List of arrays with the anchors. 134 | """ 135 | multi_level_base_anchors = [] 136 | for i, base_size in enumerate(self.base_sizes): 137 | center = None 138 | if self.centers is not None: 139 | center = self.centers[i] 140 | multi_level_base_anchors.append( 141 | self.gen_single_level_base_anchors( 142 | base_size, scales=self.scales, ratios=self.ratios, center=center 143 | ) 144 | ) 145 | return multi_level_base_anchors 146 | 147 | def gen_single_level_base_anchors( 148 | self, 149 | base_size: int, 150 | scales: ndarray, 151 | ratios: ndarray, 152 | center: Optional[Tuple[float, float]] = None, 153 | ) -> ndarray: 154 | """Computes the anchors of a single level. 155 | 156 | Args: 157 | base_size: Basic size of the anchors in a single level. 158 | scales: Anchor scales for anchors in a single level 159 | ratios: Ratios between height and width of anchors in a single level. 160 | center: Center of the anchor relative to the feature grid center in single 161 | level. 162 | 163 | Returns: 164 | Array with the anchors. 165 | 166 | """ 167 | w = base_size 168 | h = base_size 169 | if center is None: 170 | x_center = self.center_offset * w 171 | y_center = self.center_offset * h 172 | else: 173 | x_center, y_center = center 174 | 175 | h_ratios = np.sqrt(ratios) 176 | w_ratios = 1 / h_ratios 177 | if self.scale_major: 178 | ws = (w * w_ratios[:, None] * scales[None, :]).flatten() 179 | hs = (h * h_ratios[:, None] * scales[None, :]).flatten() 180 | else: 181 | ws = (w * scales[:, None] * w_ratios[None, :]).flatten() 182 | hs = (h * scales[:, None] * h_ratios[None, :]).flatten() 183 | 184 | # use float anchor and the anchor's center is aligned with the 185 | # pixel center 186 | base_anchors = [ 187 | x_center - 0.5 * ws, 188 | y_center - 0.5 * hs, 189 | x_center + 0.5 * ws, 190 | y_center + 0.5 * hs, 191 | ] 192 | base_anchors = np.stack(base_anchors, axis=-1) 193 | 194 | return base_anchors 195 | 196 | def _meshgrid( 197 | self, x: ndarray, y: ndarray, row_major: bool = True 198 | ) -> Tuple[ndarray, ndarray]: 199 | xx = np.tile(x, len(y)) 200 | # yy = y.view(-1, 1).repeat(1, len(x)).view(-1) 201 | yy = np.tile(np.reshape(y, [-1, 1]), (1, len(x))).flatten() 202 | if row_major: 203 | return xx, yy 204 | else: 205 | return yy, xx 206 | 207 | def grid_anchors(self, featmap_sizes: List[Tuple[int, int]]) -> List[ndarray]: 208 | """Generate grid anchors in multiple feature levels. 209 | 210 | Args: 211 | featmap_sizes: List of feature map sizes in multiple feature levels. 212 | 213 | Returns: 214 | Anchors in multiple feature levels. The sizes of each tensor should be 215 | [N, 4], where N = width * height * num_base_anchors, width and height are 216 | the sizes of the corresponding feature level, num_base_anchors is the 217 | number of anchors for that level. 218 | """ 219 | assert self.num_levels == len(featmap_sizes) 220 | multi_level_anchors = [] 221 | for i in range(self.num_levels): 222 | anchors = self.single_level_grid_anchors( 223 | self.base_anchors[i], 224 | featmap_sizes[i], 225 | self.strides[i], 226 | ) 227 | multi_level_anchors.append(anchors) 228 | return multi_level_anchors 229 | 230 | def single_level_grid_anchors( 231 | self, base_anchors: ndarray, featmap_size: Tuple[int, int], stride: int = 16 232 | ) -> ndarray: 233 | """Generate grid anchors in a single feature level. 234 | 235 | Args: 236 | base_anchors: Anchors in a single level. 237 | featmap_size: Feature map size in a single level. 238 | stride: Number of stride. Defaults to 16. 239 | 240 | Returns: 241 | Grid of anchors in a single feature level. 242 | 243 | """ 244 | feat_h, feat_w = featmap_size 245 | shift_x = np.arange(0, feat_w) * stride 246 | shift_y = np.arange(0, feat_h) * stride 247 | shift_xx, shift_yy = self._meshgrid(shift_x, shift_y) 248 | shifts = np.stack([shift_xx, shift_yy, shift_xx, shift_yy], axis=-1) 249 | shifts = shifts.astype(base_anchors.dtype) 250 | # first feat_w elements correspond to the first row of shifts 251 | # add A anchors (1, A, 4) to K shifts (K, 1, 4) to get 252 | # shifted anchors (K, A, 4), reshape to (K*A, 4) 253 | 254 | all_anchors = base_anchors[None, :, :] + shifts[:, None, :] 255 | all_anchors = np.reshape(all_anchors, [-1, 4]) 256 | # first A rows correspond to A anchors of (0, 0) in feature map, 257 | # then (0, 1), (0, 2), ... 258 | return all_anchors 259 | 260 | def __repr__(self) -> str: 261 | indent_str = " " 262 | repr_str = self.__class__.__name__ + "(\n" 263 | repr_str += "{}strides={},\n".format(indent_str, self.strides) 264 | repr_str += "{}ratios={},\n".format(indent_str, self.ratios) 265 | repr_str += "{}scales={},\n".format(indent_str, self.scales) 266 | repr_str += "{}base_sizes={},\n".format(indent_str, self.base_sizes) 267 | repr_str += "{}scale_major={},\n".format(indent_str, self.scale_major) 268 | repr_str += "{}octave_base_scale={},\n".format( 269 | indent_str, self.octave_base_scale 270 | ) 271 | repr_str += "{}scales_per_octave={},\n".format( 272 | indent_str, self.scales_per_octave 273 | ) 274 | repr_str += "{}num_levels={},\n".format(indent_str, self.num_levels) 275 | repr_str += "{}centers={},\n".format(indent_str, self.centers) 276 | repr_str += "{}center_offset={})".format(indent_str, self.center_offset) 277 | return repr_str 278 | 279 | def to_string(self) -> str: 280 | """Transforms configuration into string. 281 | 282 | Returns: 283 | String with config. 284 | 285 | """ 286 | anchor_config = self.to_dict() 287 | 288 | string = "anchor_generator=dict(\n" 289 | for k, v in anchor_config.items(): 290 | string += f"{' '* 4}{k}={v},\n" 291 | string += ")" 292 | 293 | return string 294 | 295 | def to_dict(self) -> Dict[str, Any]: 296 | """Transforms configuration into dictionary. 297 | 298 | Returns: 299 | Dictionary with config. 300 | """ 301 | anchor_config = dict( 302 | type="'AnchorGenerator'", 303 | scales=sorted(list(self.scales.ravel())), 304 | ratios=sorted(list(self.ratios.ravel())), 305 | strides=list(self.strides), 306 | base_sizes=list(self.base_sizes), 307 | ) 308 | 309 | return anchor_config 310 | -------------------------------------------------------------------------------- /pyodi/core/boxes.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Tuple 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from loguru import logger 6 | 7 | 8 | def check_bbox_formats(*args: Any) -> None: 9 | """Check if bounding boxes are in a valid format.""" 10 | for arg in args: 11 | if not (arg in ["coco", "corners"]): 12 | raise ValueError( 13 | f"Invalid format {arg}, only coco and corners format are allowed" 14 | ) 15 | 16 | 17 | def scale_bbox_dimensions( 18 | df: pd.DataFrame, 19 | input_size: Tuple[int, int] = (1280, 720), 20 | keep_ratio: bool = False, 21 | ) -> pd.DataFrame: 22 | """Resizes bboxes dimensions to model input size. 23 | 24 | Args: 25 | df: pd.DataFrame with COCO annotations. 26 | input_size: Model input size. Defaults to (1280, 720). 27 | keep_ratio: Whether to keep the aspect ratio or not. Defaults to False. 28 | 29 | Returns: 30 | pd.DataFrame with COCO annotations and scaled image sizes. 31 | 32 | """ 33 | if keep_ratio: 34 | scale_factor = pd.concat( 35 | [ 36 | max(input_size) / df[["img_height", "img_width"]].max(1), 37 | min(input_size) / df[["img_height", "img_width"]].min(1), 38 | ], 39 | axis=1, 40 | ).min(1) 41 | w_scale = np.round(df["img_width"] * scale_factor) / df["img_width"] 42 | h_scale = np.round(df["img_height"] * scale_factor) / df["img_height"] 43 | else: 44 | w_scale = input_size[0] / df["img_width"] 45 | h_scale = input_size[1] / df["img_height"] 46 | 47 | df["scaled_col_left"] = np.ceil(df["col_left"] * w_scale) 48 | df["scaled_row_top"] = np.ceil(df["row_top"] * h_scale) 49 | df["scaled_width"] = np.ceil(df["width"] * w_scale) 50 | df["scaled_height"] = np.ceil(df["height"] * h_scale) 51 | 52 | return df 53 | 54 | 55 | def get_scale_and_ratio(df: pd.DataFrame, prefix: str = None) -> pd.DataFrame: 56 | """Returns df with area and ratio per bbox measurements. 57 | 58 | Args: 59 | df: pd.DataFrame with COCO annotations. 60 | prefix: Prefix to apply to column names, use for scaled data. 61 | 62 | Returns: 63 | pd.DataFrame with new columns [prefix_]area/ratio 64 | 65 | """ 66 | columns = ["width", "height", "scale", "ratio"] 67 | 68 | if prefix: 69 | columns = [f"{prefix}_{col}" for col in columns] 70 | 71 | df[columns[2]] = np.sqrt(df[columns[0]] * df[columns[1]]) 72 | df[columns[3]] = df[columns[1]] / df[columns[0]] 73 | 74 | return df 75 | 76 | 77 | def add_centroids( 78 | df: pd.DataFrame, prefix: str = None, input_bbox_format: str = "coco" 79 | ) -> pd.DataFrame: 80 | """Computes bbox centroids. 81 | 82 | Args: 83 | df: pd.DataFrame with COCO annotations. 84 | prefix: Prefix to apply to column names, use for scaled data. Defaults to None. 85 | input_bbox_format: Input bounding box format. Can be "coco" or "corners". 86 | "coco" ["col_left", "row_top", "width", "height"] 87 | "corners" ["col_left", "row_top", "col_right", "row_bottom"] 88 | Defaults to "coco". 89 | 90 | Returns: 91 | pd.DataFrame with new columns [prefix_]row_centroid/col_centroid 92 | 93 | """ 94 | columns = ["col_centroid", "row_centroid"] 95 | bboxes = get_bbox_array(df, prefix=prefix, input_bbox_format=input_bbox_format) 96 | 97 | if prefix: 98 | columns = [f"{prefix}_{col}" for col in columns] 99 | 100 | df[columns[0]] = bboxes[:, 0] + bboxes[:, 2] // 2 101 | df[columns[1]] = bboxes[:, 1] + bboxes[:, 3] // 2 102 | 103 | return df 104 | 105 | 106 | def corners_to_coco(bboxes: np.ndarray) -> np.ndarray: 107 | """Transforms bboxes array from corners format to coco. 108 | 109 | Args: 110 | bboxes: Array with dimension N x 4 with bbox coordinates in corner format 111 | ["col_left", "row_top", "col_right", "row_bottom"] 112 | 113 | Returns: 114 | Array with dimension N x 4 with bbox coordinates in coco format 115 | [col_left, row_top, width, height]. 116 | 117 | """ 118 | bboxes = bboxes.copy() 119 | bboxes[..., 2:] = bboxes[..., 2:] - bboxes[..., :2] 120 | return bboxes 121 | 122 | 123 | def coco_to_corners(bboxes: np.ndarray) -> np.ndarray: 124 | """Transforms bboxes array from coco format to corners. 125 | 126 | Args: 127 | bboxes: Array with dimension N x 4 with bbox coordinates in corner format 128 | [col_left, row_top, width, height]. 129 | 130 | Returns: 131 | Array with dimension N x 4 with bbox coordinates in coco format 132 | ["col_left", "row_top", "col_right", "row_bottom"] 133 | 134 | """ 135 | bboxes = bboxes.copy() 136 | bboxes[..., 2:] = bboxes[..., :2] + bboxes[..., 2:] 137 | 138 | if (bboxes < 0).any(): 139 | logger.warning("Clipping bboxes to min corner 0, found negative value") 140 | bboxes = np.clip(bboxes, 0, None) 141 | return bboxes 142 | 143 | 144 | def normalize(bboxes: np.ndarray, image_width: int, image_height: int) -> np.ndarray: 145 | """Transforms bboxes array from pixels to (0, 1) range. 146 | 147 | Bboxes can be in both formats: 148 | "coco" ["col_left", "row_top", "width", "height"] 149 | "corners" ["col_left", "row_top", "col_right", "row_bottom"] 150 | 151 | Args: 152 | bboxes: Bounding boxes. 153 | image_width: Image width in pixels. 154 | image_height: Image height in pixels. 155 | 156 | Returns: 157 | Bounding boxes with coordinates in (0, 1) range. 158 | """ 159 | norms = np.array([image_width, image_height, image_width, image_height]) 160 | bboxes = bboxes * 1 / norms 161 | 162 | return bboxes 163 | 164 | 165 | def denormalize(bboxes: np.ndarray, image_width: int, image_height: int) -> np.ndarray: 166 | """Transforms bboxes array from (0, 1) range to pixels. 167 | 168 | Bboxes can be in both formats: 169 | "coco" ["col_left", "row_top", "width", "height"] 170 | "corners" ["col_left", "row_top", "col_right", "row_bottom"] 171 | 172 | Args: 173 | bboxes: Bounding boxes. 174 | image_width: Image width in pixels. 175 | image_height: Image height in pixels. 176 | 177 | Returns: 178 | Bounding boxes with coordinates in pixels. 179 | 180 | """ 181 | norms = np.array([image_width, image_height, image_width, image_height]) 182 | bboxes = bboxes * norms 183 | return bboxes 184 | 185 | 186 | def get_bbox_column_names(bbox_format: str, prefix: Optional[str] = None) -> List[str]: 187 | """Returns predefined column names for each format. 188 | 189 | When bbox_format is 'coco' column names are 190 | ["col_left", "row_top", "width", "height"], when 'corners' 191 | ["col_left", "row_top", "col_right", "row_bottom"]. 192 | 193 | Args: 194 | bbox_format: Bounding box format. Can be "coco" or "corners". 195 | prefix: Prefix to apply to column names, use for scaled data. Defaults to None. 196 | 197 | Returns: 198 | Column names for specified bbox format 199 | 200 | """ 201 | if bbox_format == "coco": 202 | columns = ["col_left", "row_top", "width", "height"] 203 | elif bbox_format == "corners": 204 | columns = ["col_left", "row_top", "col_right", "row_bottom"] 205 | else: 206 | raise ValueError(f"Invalid bbox format, {bbox_format} does not exist") 207 | 208 | if prefix: 209 | columns = [f"{prefix}_{col}" for col in columns] 210 | 211 | return columns 212 | 213 | 214 | def get_bbox_array( 215 | df: pd.DataFrame, 216 | prefix: Optional[str] = None, 217 | input_bbox_format: str = "coco", 218 | output_bbox_format: str = "coco", 219 | ) -> np.ndarray: 220 | """Returns array with bbox coordinates. 221 | 222 | Args: 223 | df: pd.DataFrame with COCO annotations. 224 | prefix: Prefix to apply to column names, use for scaled data. Defaults to None. 225 | input_bbox_format: Input bounding box format. Can be "coco" or "corners". 226 | Defaults to "coco". 227 | output_bbox_format: Output bounding box format. Can be "coco" or "corners". 228 | Defaults to "coco". 229 | 230 | Returns: 231 | Array with dimension N x 4 with bbox coordinates. 232 | 233 | Examples: 234 | `coco`: 235 | >>>[col_left, row_top, width, height] 236 | 237 | `corners`: 238 | >>>[col_left, row_top, col_right, row_bottom] 239 | 240 | """ 241 | check_bbox_formats(input_bbox_format, output_bbox_format) 242 | 243 | columns = get_bbox_column_names(input_bbox_format, prefix=prefix) 244 | bboxes = df[columns].to_numpy() 245 | 246 | if input_bbox_format != output_bbox_format: 247 | convert = globals()[f"{input_bbox_format}_to_{output_bbox_format}"] 248 | bboxes = convert(bboxes) 249 | 250 | return bboxes 251 | 252 | 253 | def get_df_from_bboxes( 254 | bboxes: np.ndarray, 255 | input_bbox_format: str = "coco", 256 | output_bbox_format: str = "corners", 257 | ) -> pd.DataFrame: 258 | """Creates pd.DataFrame of annotations in Coco format from array of bboxes. 259 | 260 | Args: 261 | bboxes: Array of bboxes of shape [n, 4]. 262 | input_bbox_format: Input bounding box format. Can be "coco" or "corners". 263 | Defaults to "coco". 264 | output_bbox_format: Output bounding box format. Can be "coco" or "corners". 265 | Defaults to "corners". 266 | 267 | Returns: 268 | pd.DataFrame with Coco annotations. 269 | 270 | """ 271 | check_bbox_formats(input_bbox_format, output_bbox_format) 272 | 273 | if input_bbox_format != output_bbox_format: 274 | convert = globals()[f"{input_bbox_format}_to_{output_bbox_format}"] 275 | bboxes = convert(bboxes) 276 | 277 | return pd.DataFrame(bboxes, columns=get_bbox_column_names(output_bbox_format)) 278 | 279 | 280 | def filter_zero_area_bboxes(df: pd.DataFrame) -> pd.DataFrame: 281 | """Filters those bboxes with height or width equal to zero. 282 | 283 | Args: 284 | df: pd.DataFrame with COCO annotations. 285 | 286 | Returns: 287 | Filtered pd.DataFrame with COCO annotations. 288 | 289 | """ 290 | cols = ["width", "height"] 291 | all_bboxes = len(df) 292 | df = df[(df[cols] > 0).all(axis=1)].reset_index() 293 | filtered_bboxes = len(df) 294 | 295 | n_filtered = all_bboxes - filtered_bboxes 296 | 297 | if n_filtered: 298 | logger.warning( 299 | f"A total of {n_filtered} bboxes have been filtered from your data " 300 | "for having area equal to zero." 301 | ) 302 | 303 | return df 304 | -------------------------------------------------------------------------------- /pyodi/core/clustering.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union 2 | 3 | import numpy as np 4 | from numba import float32, njit, prange 5 | from numpy import float64, ndarray 6 | from sklearn.cluster import KMeans 7 | from sklearn.metrics import silhouette_score 8 | 9 | 10 | def origin_iou(bboxes: ndarray, clusters: ndarray) -> ndarray: 11 | """Calculates the Intersection over Union (IoU) between a box and k clusters. 12 | 13 | Note: COCO format shifted to origin. 14 | 15 | Args: 16 | bboxes: Bboxes array with dimension [n, 2] in width-height order. 17 | clusters: Bbox array with dimension [n, 2] in width-height order. 18 | 19 | Returns: 20 | BBox array with centroids with dimensions [k, 2]. 21 | 22 | """ 23 | col = np.minimum(bboxes[:, None, 0], clusters[:, 0]) 24 | row = np.minimum(bboxes[:, None, 1], clusters[:, 1]) 25 | 26 | if np.count_nonzero(col == 0) > 0 or np.count_nonzero(row == 0) > 0: 27 | raise ValueError("Box has no area") 28 | 29 | intersection = col * row 30 | box_area = bboxes[:, 0] * bboxes[:, 1] 31 | cluster_area = clusters[:, 0] * clusters[:, 1] 32 | 33 | iou_ = intersection / (box_area[:, None] + cluster_area - intersection) 34 | 35 | return iou_ 36 | 37 | 38 | @njit(float32[:](float32[:, :], float32[:, :]), parallel=True) 39 | def get_max_overlap(boxes: ndarray, anchors: ndarray) -> ndarray: 40 | """Computes max intersection-over-union between box and anchors. 41 | 42 | Args: 43 | boxes: Array of bboxes with shape [n, 4]. In corner format. 44 | anchors: Array of bboxes with shape [m, 4]. In corner format. 45 | 46 | Returns: 47 | Max iou between box and anchors with shape [n, 1]. 48 | 49 | """ 50 | rows = boxes.shape[0] 51 | cols = anchors.shape[0] 52 | overlap = np.zeros(rows, dtype=np.float32) 53 | box_areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) 54 | anchors_areas = (anchors[:, 2] - anchors[:, 0]) * (anchors[:, 3] - anchors[:, 1]) 55 | 56 | for row in prange(rows): 57 | for col in range(cols): 58 | ymin = max(boxes[row, 0], anchors[col, 0]) 59 | xmin = max(boxes[row, 1], anchors[col, 1]) 60 | ymax = min(boxes[row, 2], anchors[col, 2]) 61 | xmax = min(boxes[row, 3], anchors[col, 3]) 62 | 63 | intersection = max(0, ymax - ymin) * max(0, xmax - xmin) 64 | union = box_areas[row] + anchors_areas[col] - intersection 65 | 66 | overlap[row] = max(intersection / union, overlap[row]) 67 | 68 | return overlap 69 | 70 | 71 | def kmeans_euclidean( 72 | values: ndarray, 73 | n_clusters: int = 3, 74 | silhouette_metric: bool = False, 75 | ) -> Dict[str, Union[ndarray, float64]]: 76 | """Computes k-means clustering with euclidean distance. 77 | 78 | Args: 79 | values: Data for the k-means algorithm. 80 | n_clusters: Number of clusters. 81 | silhouette_metric: Whether to compute the silhouette metric or not. Defaults 82 | to False. 83 | 84 | Returns: 85 | Clustering results. 86 | 87 | """ 88 | if len(values.shape) == 1: 89 | values = values[:, None] 90 | 91 | kmeans = KMeans(n_clusters=n_clusters) 92 | kmeans.fit(values) 93 | result = dict(centroids=kmeans.cluster_centers_, labels=kmeans.labels_) 94 | 95 | if silhouette_metric: 96 | result["silhouette"] = silhouette_score(values, labels=kmeans.labels_) 97 | 98 | return result 99 | 100 | 101 | def find_pyramid_level(bboxes: ndarray, base_sizes: List[int]) -> ndarray: 102 | """Matches bboxes with pyramid levels given their stride. 103 | 104 | Args: 105 | bboxes: Bbox array with dimension [n, 2] in width-height order. 106 | base_sizes: The basic sizes of anchors in multiple levels. 107 | 108 | Returns: 109 | Best match per bbox corresponding with index of stride. 110 | 111 | """ 112 | base_sizes = sorted(base_sizes) 113 | levels = np.tile(base_sizes, (2, 1)).T 114 | ious = origin_iou(bboxes, levels) 115 | return np.argmax(ious, axis=1) 116 | -------------------------------------------------------------------------------- /pyodi/core/crops.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from PIL import Image 4 | 5 | 6 | def get_crops_corners( 7 | image_pil: Image, 8 | crop_height: int, 9 | crop_width: int, 10 | row_overlap: int = 0, 11 | col_overlap: int = 0, 12 | ) -> List[List[int]]: 13 | """Divides `image_pil` in crops. 14 | 15 | The crops corners will be generated using the `crop_height`, `crop_width`, 16 | `row_overlap` and `col_overlap` arguments. 17 | 18 | Args: 19 | image_pil (PIL.Image): Instance of PIL.Image 20 | crop_height (int) 21 | crop_width (int) 22 | row_overlap (int, optional): Default 0. 23 | col_overlap (int, optional): Default 0. 24 | 25 | Returns: 26 | List[List[int]]: List of 4 corner coordinates for each crop of the N crops. 27 | [ 28 | [crop_0_left, crop_0_top, crop_0_right, crop_0_bottom], 29 | ... 30 | [crop_N_left, crop_N_top, crop_N_right, crop_N_bottom] 31 | ] 32 | """ 33 | crops_corners = [] 34 | row_max = row_min = 0 35 | width, height = image_pil.size 36 | while row_max - row_overlap < height: 37 | col_min = col_max = 0 38 | row_max = row_min + crop_height 39 | while col_max - col_overlap < width: 40 | col_max = col_min + crop_width 41 | if row_max > height or col_max > width: 42 | rmax = min(height, row_max) 43 | cmax = min(width, col_max) 44 | crops_corners.append( 45 | [cmax - crop_width, rmax - crop_height, cmax, rmax] 46 | ) 47 | else: 48 | crops_corners.append([col_min, row_min, col_max, row_max]) 49 | col_min = col_max - col_overlap 50 | row_min = row_max - row_overlap 51 | return crops_corners 52 | 53 | 54 | def annotation_inside_crop(annotation: Dict, crop_corners: List[int]) -> bool: 55 | """Check whether annotation coordinates lie inside crop coordinates. 56 | 57 | Args: 58 | annotation (Dict): Single annotation entry in COCO format. 59 | crop_corners (List[int]): Generated from `get_crop_corners`. 60 | 61 | Returns: 62 | bool: True if any annotation coordinate lies inside crop. 63 | """ 64 | left, top, width, height = annotation["bbox"] 65 | 66 | right = left + width 67 | bottom = top + height 68 | 69 | if left > crop_corners[2]: 70 | return False 71 | if top > crop_corners[3]: 72 | return False 73 | if right < crop_corners[0]: 74 | return False 75 | if bottom < crop_corners[1]: 76 | return False 77 | 78 | return True 79 | 80 | 81 | def filter_annotation_by_area( 82 | annotation: Dict, new_annotation: Dict, min_area_threshold: float 83 | ) -> bool: 84 | """Check whether cropped annotation area is smaller than minimum area size. 85 | 86 | Args: 87 | annotation: Single annotation entry in COCO format. 88 | new_annotation: Single annotation entry in COCO format. 89 | min_area_threshold: Minimum area threshold ratio. 90 | 91 | Returns: 92 | True if annotation area is smaller than the minimum area size. 93 | """ 94 | area = annotation["area"] 95 | new_area = new_annotation["area"] 96 | min_area = area * min_area_threshold 97 | 98 | if new_area > min_area: 99 | return False 100 | 101 | return True 102 | 103 | 104 | def get_annotation_in_crop(annotation: Dict, crop_corners: List[int]) -> Dict: 105 | """Translate annotation coordinates to crop coordinates. 106 | 107 | Args: 108 | annotation (Dict): Single annotation entry in COCO format. 109 | crop_corners (List[int]): Generated from `get_crop_corners`. 110 | 111 | Returns: 112 | Dict: Annotation entry with coordinates translated to crop coordinates. 113 | """ 114 | left, top, width, height = annotation["bbox"] 115 | right = left + width 116 | bottom = top + height 117 | 118 | new_left = max(left - crop_corners[0], 0) 119 | new_top = max(top - crop_corners[1], 0) 120 | new_right = min(right - crop_corners[0], crop_corners[2] - crop_corners[0]) 121 | new_bottom = min(bottom - crop_corners[1], crop_corners[3] - crop_corners[1]) 122 | 123 | new_width = new_right - new_left 124 | new_height = new_bottom - new_top 125 | 126 | new_bbox = [new_left, new_top, new_width, new_height] 127 | new_area = new_width * new_height 128 | new_segmentation = [ 129 | new_left, 130 | new_top, 131 | new_left, 132 | new_top + new_height, 133 | new_left + new_width, 134 | new_top + new_height, 135 | new_left + new_width, 136 | new_top, 137 | ] 138 | return { 139 | "bbox": new_bbox, 140 | "area": new_area, 141 | "segmentation": new_segmentation, 142 | "iscrowd": annotation["iscrowd"], 143 | "score": annotation.get("score", 1), 144 | "category_id": annotation["category_id"], 145 | } 146 | -------------------------------------------------------------------------------- /pyodi/core/nms.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Any, Dict, List, Tuple 3 | 4 | import numpy as np 5 | from loguru import logger 6 | from numba import jit 7 | 8 | from pyodi.core.boxes import coco_to_corners, corners_to_coco, denormalize, normalize 9 | 10 | 11 | @jit(nopython=True) 12 | def nms(dets: np.ndarray, scores: np.ndarray, iou_thr: float) -> np.ndarray: 13 | """Non Maximum supression algorithm from https://github.com/ZFTurbo/Weighted-Boxes-Fusion/blob/master/ensemble_boxes/ensemble_boxes_nms.py. 14 | 15 | Args: 16 | dets: Array of predictions in corner format. 17 | scores: Array of scores for each prediction. 18 | iou_thr: None of the filtered predictions will have an iou above `iou_thr` 19 | to any other. 20 | 21 | Returns: 22 | List of filtered predictions in COCO format. 23 | 24 | """ 25 | x1 = dets[:, 0] 26 | y1 = dets[:, 1] 27 | x2 = dets[:, 2] 28 | y2 = dets[:, 3] 29 | 30 | areas = (x2 - x1) * (y2 - y1) 31 | order = scores.argsort()[::-1] 32 | 33 | keep = [] 34 | while order.size > 0: 35 | i = order[0] 36 | keep.append(i) 37 | xx1 = np.maximum(x1[i], x1[order[1:]]) 38 | yy1 = np.maximum(y1[i], y1[order[1:]]) 39 | xx2 = np.minimum(x2[i], x2[order[1:]]) 40 | yy2 = np.minimum(y2[i], y2[order[1:]]) 41 | 42 | w = np.maximum(0.0, xx2 - xx1) 43 | h = np.maximum(0.0, yy2 - yy1) 44 | inter = w * h 45 | ovr = inter / (areas[i] + areas[order[1:]] - inter) 46 | inds = np.where(ovr <= iou_thr)[0] 47 | order = order[inds + 1] 48 | 49 | return keep 50 | 51 | 52 | def nms_predictions( 53 | predictions: List[Dict[Any, Any]], 54 | score_thr: float = 0.0, 55 | iou_thr: float = 0.5, 56 | ) -> List[Dict[Any, Any]]: 57 | """Apply Non Maximum supression to all the images in a COCO `predictions` list. 58 | 59 | Args: 60 | predictions: List of predictions in COCO format. 61 | score_thr: Predictions below `score_thr` will be filtered. Defaults to 0.0. 62 | iou_thr: None of the filtered predictions will have an iou above `iou_thr` 63 | to any other. Defaults to 0.5. 64 | 65 | Returns: 66 | List of filtered predictions in COCO format. 67 | 68 | """ 69 | new_predictions = [] 70 | image_id_to_all_boxes: Dict[str, List[List[float]]] = defaultdict(list) 71 | image_id_to_shape: Dict[str, Tuple[int, int]] = dict() 72 | 73 | for prediction in predictions: 74 | image_id_to_all_boxes[prediction["image_id"]].append( 75 | [*prediction["bbox"], prediction["score"], prediction["category_id"]] 76 | ) 77 | if prediction["image_id"] not in image_id_to_shape: 78 | image_id_to_shape[prediction["image_id"]] = prediction[ 79 | "original_image_shape" 80 | ] 81 | 82 | for image_id, all_boxes in image_id_to_all_boxes.items(): 83 | categories = np.array([box[-1] for box in all_boxes]) 84 | scores = np.array([box[-2] for box in all_boxes]) 85 | boxes = np.vstack([box[:-2] for box in all_boxes]) 86 | 87 | image_width, image_height = image_id_to_shape[image_id] 88 | 89 | boxes = normalize(coco_to_corners(boxes), image_width, image_height) 90 | 91 | logger.info(f"Before nms: {boxes.shape}") 92 | keep = nms(boxes, scores, iou_thr=iou_thr) 93 | # Filter out predictions 94 | boxes, categories, scores = boxes[keep], categories[keep], scores[keep] 95 | 96 | logger.info(f"After nms: {boxes.shape}") 97 | 98 | logger.info(f"Before score threshold: {boxes.shape}") 99 | above_thr = scores > score_thr 100 | boxes = boxes[above_thr] 101 | scores = scores[above_thr] 102 | categories = categories[above_thr] 103 | logger.info(f"After score threshold: {boxes.shape}") 104 | 105 | boxes = denormalize(corners_to_coco(boxes), image_width, image_height) 106 | 107 | for box, score, category in zip(boxes, scores, categories): 108 | new_predictions.append( 109 | { 110 | "image_id": image_id, 111 | "bbox": box.tolist(), 112 | "score": float(score), 113 | "category_id": int(category), 114 | } 115 | ) 116 | 117 | return new_predictions 118 | -------------------------------------------------------------------------------- /pyodi/core/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TextIO 3 | 4 | import pandas as pd 5 | from loguru import logger 6 | from pycocotools.coco import COCO 7 | 8 | 9 | def load_coco_ground_truth_from_StringIO(string_io: TextIO) -> COCO: 10 | """Returns COCO object from StringIO. 11 | 12 | Args: 13 | string_io: IO stream in text mode. 14 | 15 | Returns: 16 | COCO object. 17 | 18 | """ 19 | coco_ground_truth = COCO() 20 | coco_ground_truth.dataset = json.load(string_io) 21 | coco_ground_truth.createIndex() 22 | return coco_ground_truth 23 | 24 | 25 | def coco_ground_truth_to_df( 26 | ground_truth_file: str, max_images: int = 200000 27 | ) -> pd.DataFrame: 28 | """Load and transforms COCO ground truth data to pd.DataFrame object. 29 | 30 | Args: 31 | ground_truth_file: Path of ground truth file. 32 | max_images: Maximum number of images to process. 33 | 34 | Returns: 35 | pd.DataFrame with df_annotations keys and image sizes. 36 | 37 | """ 38 | logger.info("Loading Ground Truth File") 39 | with open(ground_truth_file) as gt: 40 | coco_ground_truth = json.load(gt) 41 | 42 | if len(coco_ground_truth["images"]) > max_images: 43 | logger.warning( 44 | f"Number of images {len(coco_ground_truth['images'])} exceeds maximum: " 45 | f"{max_images}.\nAll the exceeding images will be ignored." 46 | ) 47 | 48 | logger.info("Converting COCO Ground Truth to pd.DataFrame") 49 | df_images = pd.DataFrame(coco_ground_truth["images"][:max_images])[ 50 | ["id", "file_name", "width", "height"] 51 | ] 52 | df_images = df_images.add_prefix("img_") 53 | 54 | df_annotations = pd.DataFrame(coco_ground_truth["annotations"]) 55 | 56 | # Replace label with category name 57 | categories = {x["id"]: x["name"] for x in coco_ground_truth["categories"]} 58 | df_annotations["category"] = df_annotations["category_id"].replace(categories) 59 | 60 | # Add bbox columns 61 | bbox_columns = ["col_left", "row_top", "width", "height"] 62 | df_annotations[bbox_columns] = pd.DataFrame( 63 | df_annotations.bbox.tolist(), index=df_annotations.index 64 | ) 65 | 66 | # Filter columns by name 67 | column_names = ["image_id", "area", "id", "category"] + bbox_columns 68 | if "iscrowd" in df_annotations.columns: 69 | column_names.append("iscrowd") 70 | 71 | # Join with images 72 | df_annotations = df_annotations[column_names].join( 73 | df_images.set_index("img_id"), how="inner", on="image_id" 74 | ) 75 | 76 | return df_annotations 77 | -------------------------------------------------------------------------------- /pyodi/plots/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/pyodi/cf361782efadaecad69e56170792231ee684d363/pyodi/plots/__init__.py -------------------------------------------------------------------------------- /pyodi/plots/boxes.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | import numpy as np 4 | from pandas import DataFrame 5 | from plotly import graph_objects as go 6 | 7 | from pyodi.plots.common import save_figure 8 | 9 | 10 | def get_centroids_heatmap( 11 | df: DataFrame, n_rows: int = 9, n_cols: int = 9 12 | ) -> np.ndarray: 13 | """Returns centroids heatmap. 14 | 15 | Args: 16 | df: DataFrame with annotations. 17 | n_rows: Number of rows. 18 | n_cols: Number of columns. 19 | 20 | Returns: 21 | Centroids heatmap. With shape (`n_rows`, `n_cols`). 22 | 23 | """ 24 | rows = df["row_centroid"] / df["img_height"] 25 | cols = df["col_centroid"] / df["img_width"] 26 | heatmap = np.zeros((n_rows, n_cols)) 27 | for row, col in zip(rows, cols): 28 | heatmap[int(row * n_rows), int(col * n_cols)] += 1 29 | 30 | return heatmap 31 | 32 | 33 | def plot_heatmap( 34 | heatmap: np.ndarray, 35 | title: str = "", 36 | show: bool = True, 37 | output: Optional[str] = None, 38 | output_size: Tuple[int, int] = (1600, 900), 39 | ) -> go.Figure: 40 | """Plots heatmap figure. 41 | 42 | Args: 43 | heatmap: Heatmap (2D array) data to plot. 44 | title: Title of the figure. Defaults to "". 45 | show: Whether to show results or not. Defaults to True. 46 | output: Results will be saved under `output` dir. Defaults to None. 47 | output_size: Size of the saved images when output is defined. Defaults to 48 | (1600, 900). 49 | 50 | Returns: 51 | Heatmap figure. 52 | 53 | """ 54 | fig = go.Figure(data=go.Heatmap(z=heatmap)) 55 | 56 | fig.update_layout(title_text=title, title_font_size=20) 57 | 58 | fig.update_xaxes(showticklabels=False) 59 | fig.update_yaxes(showticklabels=False) 60 | 61 | if show: 62 | fig.show() 63 | 64 | if output: 65 | save_figure(fig, title, output, output_size) 66 | 67 | return fig 68 | -------------------------------------------------------------------------------- /pyodi/plots/clustering.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | import numpy as np 4 | from pandas.core.frame import DataFrame 5 | from plotly import graph_objects as go 6 | from plotly.colors import DEFAULT_PLOTLY_COLORS as COLORS 7 | from plotly.subplots import make_subplots 8 | 9 | from pyodi.core.anchor_generator import AnchorGenerator 10 | from pyodi.core.boxes import get_df_from_bboxes 11 | from pyodi.plots.common import plot_scatter_with_histograms, save_figure 12 | 13 | 14 | def plot_clustering_results( 15 | df_annotations: DataFrame, 16 | anchor_generator: AnchorGenerator, 17 | show: Optional[bool] = True, 18 | output: Optional[str] = None, 19 | output_size: Tuple[int, int] = (1600, 900), 20 | centroid_color: Optional[Tuple] = None, 21 | title: Optional[str] = None, 22 | ) -> None: 23 | """Plots cluster results in two different views, width vs height and area vs ratio. 24 | 25 | Args: 26 | df_annotations: COCO annotations generated DataFrame. 27 | anchor_generator: Anchor generator instance. 28 | show: Whether to show the figure or not. Defaults to True. 29 | output: Output path folder. Defaults to None. 30 | output_size: Size of saved images. Defaults to (1600, 900). 31 | centroid_color: Plotly rgb color format for painting centroids. Defaults to 32 | None. 33 | title: Plot title and filename if output is not None. Defaults to None. 34 | 35 | """ 36 | if centroid_color is None: 37 | centroid_color = COLORS[len(df_annotations.category.unique()) % len(COLORS)] 38 | 39 | fig = make_subplots( 40 | rows=1, 41 | cols=2, 42 | subplot_titles=[ 43 | "Relative Log Scale vs Log Ratio", 44 | "Scaled Width vs Scaled Height", 45 | ], 46 | ) 47 | 48 | plot_scatter_with_histograms( 49 | df_annotations, 50 | x="log_level_scale", 51 | y="log_ratio", 52 | legendgroup="classes", 53 | show=False, 54 | colors=COLORS, 55 | histogram=False, 56 | fig=fig, 57 | ) 58 | 59 | cluster_grid = np.array( 60 | np.meshgrid(np.log(anchor_generator.scales), np.log(anchor_generator.ratios)) 61 | ).T.reshape(-1, 2) 62 | 63 | fig.append_trace( 64 | go.Scattergl( 65 | x=cluster_grid[:, 0], 66 | y=cluster_grid[:, 1], 67 | mode="markers", 68 | legendgroup="centroids", 69 | name="centroids", 70 | marker=dict( 71 | color=centroid_color, 72 | size=10, 73 | line=dict(width=2, color="DarkSlateGrey"), 74 | ), 75 | ), 76 | row=1, 77 | col=1, 78 | ) 79 | 80 | plot_scatter_with_histograms( 81 | df_annotations, 82 | x="scaled_width", 83 | y="scaled_height", 84 | show=False, 85 | colors=COLORS, 86 | legendgroup="classes", 87 | histogram=False, 88 | showlegend=False, 89 | fig=fig, 90 | col=2, 91 | ) 92 | 93 | for anchor_level in anchor_generator.base_anchors: 94 | anchor_level = get_df_from_bboxes( 95 | anchor_level, input_bbox_format="corners", output_bbox_format="coco" 96 | ) 97 | fig.append_trace( 98 | go.Scattergl( 99 | x=anchor_level["width"], 100 | y=anchor_level["height"], 101 | mode="markers", 102 | legendgroup="centroids", 103 | name="centroids", 104 | showlegend=False, 105 | marker=dict( 106 | color=centroid_color, 107 | size=10, 108 | line=dict(width=2, color="DarkSlateGrey"), 109 | ), 110 | ), 111 | row=1, 112 | col=2, 113 | ) 114 | 115 | fig["layout"].update( 116 | title=title, 117 | xaxis2=dict(title="Scaled Width"), 118 | xaxis=dict(title="Log Relative Scale"), 119 | yaxis2=dict(title="Scaled Height"), 120 | yaxis=dict(title="Log Ratio"), 121 | ) 122 | 123 | if show: 124 | fig.show() 125 | 126 | if output: 127 | save_figure(fig, "clusters", output, output_size) 128 | -------------------------------------------------------------------------------- /pyodi/plots/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Dict, List, Optional, Tuple 3 | 4 | from loguru import logger 5 | from pandas import DataFrame 6 | from plotly import graph_objects as go 7 | from plotly.subplots import make_subplots 8 | 9 | 10 | def plot_scatter_with_histograms( 11 | df: DataFrame, 12 | x: str = "width", 13 | y: str = "height", 14 | title: Optional[str] = None, 15 | show: bool = True, 16 | output: Optional[str] = None, 17 | output_size: Tuple[int, int] = (1600, 900), 18 | histogram: bool = True, 19 | label: str = "category", 20 | colors: Optional[List] = None, 21 | legendgroup: Optional[str] = None, 22 | fig: Optional[go.Figure] = None, 23 | row: int = 1, 24 | col: int = 1, 25 | xaxis_range: Optional[Tuple[float, float]] = None, 26 | yaxis_range: Optional[Tuple[float, float]] = None, 27 | histogram_xbins: Optional[Dict[str, Any]] = None, 28 | histogram_ybins: Optional[Dict[str, Any]] = None, 29 | **kwargs: Any, 30 | ) -> go.Figure: 31 | """Allows to compare the relation between two variables of your COCO dataset. 32 | 33 | Args: 34 | df: COCO annotations generated DataFrame. 35 | x: Name of column that will be represented in x axis. Defaults to "width". 36 | y: Name of column that will be represented in y axis. Defaults to "height". 37 | title: Plot name. Defaults to None. 38 | show: Whether to show the figure or not. Defaults to True. 39 | output: Output path folder. Defaults to None. 40 | output_size: Size of saved images. Defaults to (1600, 900). 41 | histogram: Whether to draw a marginal histogram distribution of each axis or 42 | not. Defaults to True. 43 | label: Name of the column with class information in df_annotations. Defaults 44 | to 'category'. 45 | colors: List of rgb colors to use. If None default plotly colors are used. 46 | Defaults to None. 47 | legendgroup: When present legend is grouped by different categories 48 | (see https://plotly.com/python/legend/). 49 | fig: When figure is provided, trace is automatically added on it. Defaults to 50 | None. 51 | row: Subplot row to use when fig is provided. Defaults to 1. 52 | col: Subplot col to use when fig is provided. Defaults to 1. 53 | xaxis_range: range of values for the histogram's horizontal axis 54 | yaxis_range: range of values for the histogram's vertical axis 55 | histogram_xbins: number of bins for the histogram's horizontal axis 56 | histogram_ybins: number of bins for the histogram's vertical axis 57 | Returns: 58 | Plotly figure. 59 | 60 | """ 61 | logger.info("Plotting Scatter with Histograms") 62 | if not fig: 63 | fig = make_subplots(rows=1, cols=1) 64 | 65 | classes = [(0, None)] 66 | if label in df: 67 | classes = list(enumerate(sorted(df[label].unique()))) 68 | 69 | for i, c in classes: 70 | if c: 71 | filtered_df = df[df[label] == c] 72 | else: 73 | filtered_df = df 74 | scatter = go.Scattergl( 75 | x=filtered_df[x], 76 | y=filtered_df[y], 77 | mode="markers", 78 | name=str(c or "Images Shape"), 79 | text=filtered_df["img_file_name"], 80 | marker=dict(color=colors[i % len(colors)] if colors else None), 81 | legendgroup=f"legendgroup_{i}" if legendgroup else None, 82 | **kwargs, 83 | ) 84 | fig.add_trace(scatter, row=row, col=col) 85 | 86 | if histogram: 87 | fig.add_histogram( 88 | x=df[x], 89 | name=f"{x} distribution", 90 | yaxis="y2", 91 | marker=dict(color="rgb(246, 207, 113)"), 92 | histnorm="percent", 93 | xbins=histogram_xbins, 94 | ) 95 | fig.add_histogram( 96 | y=df[y], 97 | name=f"{y} distribution", 98 | xaxis="x2", 99 | marker=dict(color="rgb(102, 197, 204)"), 100 | histnorm="percent", 101 | ybins=histogram_ybins, 102 | ) 103 | 104 | fig.layout = dict( 105 | xaxis=dict( 106 | domain=[0, 0.84], showgrid=False, zeroline=False, range=xaxis_range 107 | ), 108 | yaxis=dict( 109 | domain=[0, 0.83], showgrid=False, zeroline=False, range=yaxis_range 110 | ), 111 | xaxis2=dict( 112 | domain=[0.85, 1], showgrid=False, zeroline=False, range=(0, 100) 113 | ), 114 | yaxis2=dict( 115 | domain=[0.85, 1], showgrid=False, zeroline=False, range=(0, 100) 116 | ), 117 | ) 118 | 119 | if title is None: 120 | title = f"{x} vs {y}" 121 | fig.update_layout( 122 | title_text=title, xaxis_title=f"{x}", yaxis_title=f"{y}", title_font_size=20 123 | ) 124 | 125 | if show: 126 | fig.show() 127 | 128 | if output: 129 | save_figure(fig, title, output, output_size) 130 | 131 | return fig 132 | 133 | 134 | def plot_histogram( 135 | df: DataFrame, 136 | column: str, 137 | title: Optional[str] = None, 138 | xrange: Optional[Tuple[int, int]] = None, 139 | yrange: Optional[Tuple[int, int]] = None, 140 | xbins: Optional[Dict[str, Any]] = None, 141 | histnorm: Optional[str] = "percent", 142 | show: bool = False, 143 | output: Optional[str] = None, 144 | output_size: Tuple[int, int] = (1600, 900), 145 | ) -> go.Figure: 146 | """Plot histogram figure. 147 | 148 | Args: 149 | df: Data to plot. 150 | column: DataFrame column to plot. 151 | title: Title of figure. Defaults to None. 152 | xrange: Range in axis X. Defaults to None. 153 | yrange: Range in axis Y. Defaults to None. 154 | xbins: Width of X bins. Defaults to None. 155 | histnorm: Histnorm. Defaults to "percent". 156 | show: Whether to show the figure or not. Defaults to False. 157 | output: Output path folder. Defaults to None. 158 | output_size: Size of saved images. Defaults to (1600, 900). 159 | 160 | Returns: 161 | Histogram figure. 162 | 163 | """ 164 | logger.info(f"Plotting {column} Histogram") 165 | fig = go.Figure( 166 | data=[ 167 | go.Histogram( 168 | x=df[column], histnorm=histnorm, hovertext=df["file_name"], xbins=xbins 169 | ) 170 | ] 171 | ) 172 | 173 | if xrange is not None: 174 | fig.update_xaxes(range=xrange) 175 | 176 | if yrange is not None: 177 | fig.update_yaxes(range=yrange) 178 | 179 | if title is None: 180 | title = f"{column} histogram" 181 | 182 | fig.update_layout(title_text=title, title_font_size=20) 183 | 184 | if show: 185 | fig.show() 186 | 187 | if output: 188 | save_figure(fig, title, output, output_size) 189 | 190 | return fig 191 | 192 | 193 | def save_figure( 194 | figure: go.Figure, output_name: str, output_dir: str, output_size: Tuple[int, int] 195 | ) -> None: 196 | """Saves figure into png image file. 197 | 198 | Args: 199 | figure: Figure to save. 200 | output_name: Output filename. 201 | output_dir: Output directory. 202 | output_size: Size of saved image. 203 | 204 | """ 205 | output = str(Path(output_dir) / (output_name.replace(" ", "_") + ".png")) 206 | figure.update_layout(width=output_size[0], height=output_size[1]) 207 | figure.write_image(output, engine="kaleido") 208 | -------------------------------------------------------------------------------- /pyodi/plots/evaluation.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | from pandas.core.frame import DataFrame 4 | from plotly import graph_objects as go 5 | from plotly.subplots import make_subplots 6 | 7 | from pyodi.plots.common import save_figure 8 | 9 | 10 | def plot_overlap_result( 11 | df: DataFrame, 12 | max_bins: int = 30, 13 | show: bool = True, 14 | output: Optional[str] = None, 15 | output_size: Tuple[int, int] = (1600, 900), 16 | ) -> None: 17 | """Generates plot for train config evaluation based on overlap. 18 | 19 | Args: 20 | df: COCO annotations generated DataFrame with overlap. 21 | max_bins: Max bins to use in histograms. Defaults to 30. 22 | show: Whether to show the figure or not. Defaults to True. 23 | output: Output path folder. Defaults to None. 24 | output_size: Size of saved images. Defaults to (1600, 900). 25 | 26 | """ 27 | fig = make_subplots( 28 | rows=2, 29 | cols=2, 30 | subplot_titles=( 31 | "Cumulative overlap distribution", 32 | "Bounding Box Distribution", 33 | "Scale and mean overlap", 34 | "Log Ratio and mean overlap", 35 | ), 36 | ) 37 | 38 | fig.append_trace( 39 | go.Histogram( 40 | x=df["overlaps"], 41 | histnorm="probability", 42 | cumulative_enabled=True, 43 | showlegend=False, 44 | ), 45 | row=1, 46 | col=1, 47 | ) 48 | 49 | fig.append_trace( 50 | go.Scattergl( 51 | x=df["scaled_width"], 52 | y=df["scaled_height"], 53 | mode="markers", 54 | showlegend=False, 55 | marker=dict( 56 | color=df["overlaps"], 57 | colorscale="Electric", 58 | cmin=0, 59 | cmax=1, 60 | showscale=True, 61 | colorbar=dict( 62 | title="Overlap value", lenmode="fraction", len=0.5, y=0.8 63 | ), 64 | ), 65 | ), 66 | row=1, 67 | col=2, 68 | ) 69 | 70 | for i, column in enumerate(["scaled_scale", "log_scaled_ratio"], 1): 71 | fig.append_trace( 72 | go.Histogram( 73 | x=df[column], 74 | y=df["overlaps"], 75 | histfunc="avg", 76 | nbinsx=max_bins, 77 | showlegend=False, 78 | ), 79 | row=2, 80 | col=i, 81 | ) 82 | 83 | fig["layout"].update( 84 | title="Train config evaluation", 85 | xaxis=dict(title="Overlap values"), 86 | xaxis2=dict(title="Scaled width"), 87 | xaxis3=dict(title="Scale"), 88 | xaxis4=dict(title="Log Ratio"), 89 | yaxis=dict(title="Accumulated percentage"), 90 | yaxis2=dict(title="Scaled heigh"), 91 | yaxis3=dict(title="Mean overlap"), 92 | yaxis4=dict(title="Mean overlap"), 93 | legend=dict(y=0.5), 94 | ) 95 | 96 | if show: 97 | fig.show() 98 | 99 | if output: 100 | save_figure(fig, "overlap", output, output_size) 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 63.2.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 88 7 | include = '\.pyi?$' 8 | exclude = ''' 9 | /( 10 | \.eggs 11 | | \.git 12 | | \.hg 13 | | \.mypy_cache 14 | | \.tox 15 | | \.venv 16 | | _build 17 | | buck-out 18 | | build 19 | | dist 20 | )/ 21 | ''' 22 | 23 | [tool.isort] 24 | profile = "black" 25 | known_third_party = ["PIL", "loguru", "matplotlib", "numba", "numpy", "pandas", "plotly", "pycocotools", "pytest", "setuptools", "sklearn", "typer"] 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pyodi 3 | version = 0.0.9 4 | author = Pyodi 5 | description = Object Detection Insights 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | url = https://github.com/Gradiant/pyodi 9 | 10 | [options] 11 | packages = find_namespace: 12 | python_requires = >=3.8 13 | install_requires = 14 | numpy==1.22.1 15 | loguru==0.6.0 16 | matplotlib==3.5.2 17 | numba>=0.56.0 18 | pandas==1.4.2 19 | pillow==9.2.0 20 | plotly==5.9.0 21 | pycocotools==2.0.6 22 | kaleido==v0.2.1 23 | scikit-learn==1.1.1 24 | fire==0.4.0 25 | 26 | [options.packages.find] 27 | exclude = 28 | build* 29 | docs* 30 | tests* 31 | 32 | [options.extras_require] 33 | dev = 34 | black==22.6.0 35 | flake8==4.0.1 36 | flake8-docstrings==1.6.0 37 | isort==5.10.1 38 | mkdocs==1.3.1 39 | mkdocstrings==0.19.0 40 | mkdocs-material==8.3.9 41 | mkdocstrings-python==0.7.1 42 | mock==4.0.3 43 | mypy==0.960 44 | pre-commit==2.20.0 45 | pydocstyle==6.1.1 46 | pymdown-extensions==9.5 47 | pytest==7.1.2 48 | 49 | [options.entry_points] 50 | console_scripts = 51 | pyodi = pyodi.cli:app 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | if __name__ == "__main__": 4 | setuptools.setup() 5 | -------------------------------------------------------------------------------- /tests/apps/test_coco_merge.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from mock import ANY, patch 5 | 6 | from pyodi.apps.coco.coco_merge import coco_merge 7 | 8 | 9 | def test_coco_merge(tmp_path): 10 | images = [{"id": 0, "file_name": "0.jpg"}, {"id": 2, "file_name": "1.jpg"}] 11 | 12 | anns1 = [ 13 | {"image_id": 0, "category_id": 1, "id": 0}, 14 | {"image_id": 2, "category_id": 2, "id": 1}, 15 | ] 16 | anns2 = [ 17 | {"image_id": 0, "category_id": 1, "id": 0}, 18 | {"image_id": 2, "category_id": 2, "id": 1}, 19 | ] 20 | anns3 = [ 21 | {"image_id": 0, "category_id": 1, "id": 0}, 22 | {"image_id": 2, "category_id": 2, "id": 1}, 23 | ] 24 | 25 | categories1 = [{"id": 1, "name": "drone"}, {"id": 2, "name": "bird"}] 26 | categories2 = [{"id": 1, "name": "drone"}, {"id": 2, "name": "plane"}] 27 | categories3 = [{"id": 1, "name": "plane"}, {"id": 2, "name": "drone"}] 28 | 29 | coco1 = dict(images=images, annotations=anns1, categories=categories1) 30 | coco2 = dict(images=images, annotations=anns2, categories=categories2) 31 | coco3 = dict(images=images, annotations=anns3, categories=categories3) 32 | 33 | tmp_files = [] 34 | for i, coco_data in enumerate([coco1, coco2, coco3]): 35 | tmp_files.append(tmp_path / f"{i}.json") 36 | with open(tmp_files[-1], "w") as f: 37 | json.dump(coco_data, f) 38 | 39 | result_file = coco_merge( 40 | input_extend=tmp_path / "0.json", 41 | input_add=tmp_path / "1.json", 42 | output_file=tmp_path / "result.json", 43 | ) 44 | result_file = coco_merge( 45 | input_extend=tmp_path / "result.json", 46 | input_add=tmp_path / "2.json", 47 | output_file=tmp_path / "result.json", 48 | ) 49 | 50 | data = json.load(open(result_file)) 51 | 52 | assert data["categories"] == [ 53 | {"id": 1, "name": "drone"}, 54 | {"id": 2, "name": "bird"}, 55 | {"id": 3, "name": "plane"}, 56 | ] 57 | assert [x["id"] for x in data["images"]] == list(range(len(data["images"]))) 58 | assert [x["id"] for x in data["annotations"]] == list( 59 | range(len(data["annotations"])) 60 | ) 61 | assert [i["category_id"] for i in data["annotations"]] == [1, 2, 1, 3, 3, 1] 62 | 63 | 64 | @pytest.mark.parametrize("indent", [None, 2]) 65 | def test_coco_merge_with_json_indent(tmp_path, indent): 66 | images = [{"id": 0, "file_name": "0.jpg"}] 67 | anns1 = [{"image_id": 0, "category_id": 0, "id": 0}] 68 | anns2 = [{"image_id": 0, "category_id": 1, "id": 0}] 69 | categories = [{"id": 0, "name": "excavator"}, {"id": 1, "name": "bus"}] 70 | 71 | coco1 = dict(images=images, annotations=anns1, categories=categories) 72 | coco2 = dict(images=images, annotations=anns2, categories=categories) 73 | 74 | tmp_files = [] 75 | for i, coco_data in enumerate([coco1, coco2]): 76 | tmp_files.append(tmp_path / f"{i}.json") 77 | with open(tmp_files[-1], "w") as f: 78 | json.dump(coco_data, f) 79 | 80 | with patch("json.dump") as mock: 81 | coco_merge( 82 | input_extend=tmp_path / "0.json", 83 | input_add=tmp_path / "1.json", 84 | output_file=tmp_path / "result.json", 85 | indent=indent, 86 | ) 87 | mock.assert_called_once_with(ANY, ANY, indent=indent, ensure_ascii=False) 88 | -------------------------------------------------------------------------------- /tests/apps/test_coco_split.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from pyodi.apps.coco.coco_split import property_split, random_split 7 | 8 | 9 | def get_coco_data(): 10 | categories = [{"id": 1, "name": "drone"}, {"id": 2, "name": "bird"}] 11 | 12 | images = [ 13 | {"id": 0, "file_name": "vidA-0.jpg", "source": "A", "video_name": "vidA"}, 14 | {"id": 1, "file_name": "vidA-1.jpg", "source": "A", "video_name": "vidA"}, 15 | {"id": 2, "file_name": "vidB-0.jpg", "source": "B", "video_name": "vidB"}, 16 | {"id": 3, "file_name": "vidC-0.jpg", "source": "C", "video_name": "vidC"}, 17 | {"id": 4, "file_name": "vidD-0.jpg", "source": "D", "video_name": "vidD"}, 18 | {"id": 5, "file_name": "vidD-66.jpg", "source": "badsrc", "video_name": "vidD"}, 19 | {"id": 6, "file_name": "badvid-13.jpg", "source": "E", "video_name": "badvid"}, 20 | {"id": 7, "file_name": "errvid-14.jpg", "source": "E", "video_name": "errvid"}, 21 | ] 22 | 23 | annotations = [ 24 | {"image_id": 0, "category_id": 1, "id": 0}, 25 | {"image_id": 0, "category_id": 2, "id": 1}, 26 | {"image_id": 1, "category_id": 1, "id": 2}, 27 | {"image_id": 1, "category_id": 1, "id": 3}, 28 | {"image_id": 1, "category_id": 2, "id": 4}, 29 | {"image_id": 2, "category_id": 1, "id": 5}, 30 | {"image_id": 3, "category_id": 2, "id": 6}, 31 | {"image_id": 3, "category_id": 2, "id": 7}, 32 | {"image_id": 4, "category_id": 1, "id": 8}, 33 | {"image_id": 5, "category_id": 1, "id": 9}, 34 | {"image_id": 5, "category_id": 2, "id": 10}, 35 | {"image_id": 5, "category_id": 2, "id": 11}, 36 | ] 37 | 38 | coco_data = dict( 39 | images=images, 40 | annotations=annotations, 41 | categories=categories, 42 | info={}, 43 | licenses={}, 44 | ) 45 | 46 | return coco_data 47 | 48 | 49 | def test_random_coco_split(tmpdir): 50 | tmpdir = Path(tmpdir) 51 | 52 | coco_data = get_coco_data() 53 | 54 | json.dump(coco_data, open(tmpdir / "coco.json", "w")) 55 | 56 | train_path, val_path = random_split( 57 | annotations_file=str(tmpdir / "coco.json"), 58 | output_filename=str(tmpdir / "random_coco_split"), 59 | val_percentage=0.25, 60 | seed=49, 61 | ) 62 | 63 | train_data = json.load(open(train_path)) 64 | val_data = json.load(open(val_path)) 65 | 66 | assert train_data["categories"] == coco_data["categories"] 67 | assert val_data["categories"] == coco_data["categories"] 68 | assert len(train_data["images"]) == 6 69 | assert len(val_data["images"]) == 2 70 | assert len(train_data["annotations"]) == 9 71 | assert len(val_data["annotations"]) == 3 72 | 73 | 74 | def test_property_coco_split(tmpdir): 75 | tmpdir = Path(tmpdir) 76 | 77 | coco_data = get_coco_data() 78 | 79 | json.dump(coco_data, open(tmpdir / "coco.json", "w")) 80 | 81 | config = { 82 | "discard": {"file_name": "badvid|errvid", "source": "badsrc"}, 83 | "val": { 84 | "file_name": {"frame 0": "vidA-0.jpg", "frame 1": "vidA-1.jpg"}, 85 | "source": {"example C": "C", "example D": "D"}, 86 | }, 87 | } 88 | 89 | json.dump(config, open(tmpdir / "split_config.json", "w")) 90 | 91 | train_path, val_path = property_split( 92 | annotations_file=str(tmpdir / "coco.json"), 93 | output_filename=str(tmpdir / "property_coco_split"), 94 | split_config_file=str(tmpdir / "split_config.json"), 95 | ) 96 | 97 | train_data = json.load(open(train_path)) 98 | val_data = json.load(open(val_path)) 99 | 100 | assert train_data["categories"] == coco_data["categories"] 101 | assert val_data["categories"] == coco_data["categories"] 102 | assert len(train_data["images"]) == 1 103 | assert len(val_data["images"]) == 4 104 | assert len(train_data["annotations"]) == 1 105 | assert len(val_data["annotations"]) == 8 106 | 107 | 108 | @pytest.mark.parametrize("split_type", ["random", "property"]) 109 | def test_split_without_info_and_licenses(tmpdir, split_type): 110 | tmpdir = Path(tmpdir) 111 | 112 | coco_data = get_coco_data() 113 | coco_data.pop("licenses") 114 | coco_data.pop("info") 115 | 116 | assert "licenses" not in coco_data 117 | assert "info" not in coco_data 118 | 119 | json.dump(coco_data, open(tmpdir / "coco.json", "w")) 120 | 121 | if split_type == "random": 122 | train_path, val_path = random_split( 123 | annotations_file=str(tmpdir / "coco.json"), 124 | output_filename=str(tmpdir / "random_coco_split"), 125 | val_percentage=0.25, 126 | seed=49, 127 | ) 128 | else: 129 | config = dict( 130 | val={"file_name": {"frame 0": "vidA-0.jpg", "frame 1": "vidA-1.jpg"}} 131 | ) 132 | json.dump(config, open(tmpdir / "split_config.json", "w")) 133 | 134 | train_path, val_path = property_split( 135 | annotations_file=str(tmpdir / "coco.json"), 136 | output_filename=str(tmpdir / "property_coco_split"), 137 | split_config_file=str(tmpdir / "split_config.json"), 138 | ) 139 | 140 | for path in [train_path, val_path]: 141 | data = json.load(open(path)) 142 | assert data["licenses"] == [] 143 | assert data["info"] == {} 144 | -------------------------------------------------------------------------------- /tests/apps/test_crops_merge.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import numpy as np 5 | 6 | from pyodi.apps.crops.crops_merge import crops_merge 7 | 8 | 9 | def test_crops_merge(tmpdir): 10 | # Test box is dicarded since iou between preds boxes is >.39 and final box coords are in original image coords 11 | tmpdir = Path(tmpdir) 12 | preds_path = tmpdir / "preds.json" 13 | gt_path = tmpdir / "gt.json" 14 | output_path = tmpdir / "output.json" 15 | iou_thr = 0.3 16 | 17 | preds = [ 18 | {"image_id": 0, "bbox": [10, 10, 5, 5], "score": 0.7, "category_id": 1}, 19 | {"image_id": 1, "bbox": [0, 0, 8, 8], "score": 0.9, "category_id": 1}, 20 | ] 21 | 22 | gt = { 23 | "images": [ 24 | {"id": 0, "file_name": "test_0_0.jpg"}, 25 | {"id": 1, "file_name": "test_10_10.jpg"}, 26 | ], 27 | "old_images": [{"id": 0, "file_name": "test.jpg", "width": 20, "height": 20}], 28 | } 29 | 30 | with open(preds_path, "w") as f: 31 | json.dump(preds, f) 32 | 33 | with open(gt_path, "w") as f: 34 | json.dump(gt, f) 35 | 36 | output_path = crops_merge(gt_path, output_path, preds_path, iou_thr=iou_thr) 37 | 38 | result = json.load(open(output_path)) 39 | 40 | assert len(result) == 1 41 | np.testing.assert_almost_equal(result[0]["bbox"], [10, 10, 8, 8]) 42 | 43 | 44 | def test_crops_merge_gt(tmpdir): 45 | # Test for crops merge (only ground_truth file) 46 | 47 | tmpdir = Path(tmpdir) 48 | gt_path = tmpdir / "gt.json" 49 | output_path = tmpdir / "output.json" 50 | 51 | gt = { 52 | "images": [ 53 | { 54 | "file_name": "gopro_001_102_0_0.png", 55 | "height": 720, 56 | "width": 720, 57 | "id": 0, 58 | }, 59 | { 60 | "file_name": "gopro_001_102_720_0.png", 61 | "height": 720, 62 | "width": 720, 63 | "id": 1, 64 | }, 65 | { 66 | "file_name": "gopro_001_102_1200_0.png", 67 | "height": 720, 68 | "width": 720, 69 | "id": 2, 70 | }, 71 | { 72 | "file_name": "gopro_001_102_0_360.png", 73 | "height": 720, 74 | "width": 720, 75 | "id": 3, 76 | }, 77 | { 78 | "file_name": "gopro_001_102_720_360.png", 79 | "height": 720, 80 | "width": 720, 81 | "id": 4, 82 | }, 83 | ], 84 | "old_images": [ 85 | { 86 | "id": 1, 87 | "width": 1920, 88 | "height": 1080, 89 | "file_name": "gopro_001/gopro_001_102.png", 90 | } 91 | ], 92 | "annotations": [ 93 | { 94 | "bbox": [483.00000000000006, 465.0, 19.999999999999943, 14.0], 95 | "score": 1, 96 | "category_id": 1, 97 | "id": 0, 98 | "image_id": 3, 99 | }, 100 | { 101 | "bbox": [433.0, 626.0, 19.0, 13.0], 102 | "score": 1, 103 | "category_id": 1, 104 | "id": 1, 105 | "image_id": 4, 106 | }, 107 | ], 108 | } 109 | 110 | with open(gt_path, "w") as f: 111 | json.dump(gt, f) 112 | 113 | output_path = crops_merge(gt_path, output_path) 114 | 115 | result = json.load(open(output_path)) 116 | 117 | assert len(result["images"]) == 1, "Error on images length" 118 | assert len(result["annotations"]) == 2, "Error on annotations length" 119 | 120 | np.testing.assert_almost_equal( 121 | result["annotations"][0]["bbox"], [1153, 986, 19, 13] 122 | ) 123 | np.testing.assert_almost_equal(result["annotations"][1]["bbox"], [483, 825, 20, 14]) 124 | -------------------------------------------------------------------------------- /tests/apps/test_crops_split.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | 5 | import numpy as np 6 | from PIL import Image 7 | 8 | from pyodi.apps.crops.crops_split import crops_split 9 | 10 | 11 | def create_image() -> Image: 12 | array = np.zeros(shape=(1080, 1920, 3), dtype=np.uint8) 13 | 14 | # Paint each future crop in a different color 15 | array[0:720, 0:720] = np.array([0, 0, 0], dtype=np.uint8) 16 | array[0:720, 720:1440] = np.array([255, 0, 0], dtype=np.uint8) 17 | array[0:720, 1200:1920] = np.array([0, 255, 0], dtype=np.uint8) 18 | array[360:1080, 0:720] = np.array([0, 0, 255], dtype=np.uint8) 19 | array[360:1080, 720:1440] = np.array([255, 255, 0], dtype=np.uint8) 20 | array[360:1080, 1200:1920] = np.array([255, 0, 255], dtype=np.uint8) 21 | 22 | img = Image.fromarray(array) 23 | return img 24 | 25 | 26 | def test_crops_split(tmpdir): 27 | gt_path = Path(tmpdir) / "gt.json" # Path to a COCO ground truth file 28 | img_folder_path = ( 29 | Path(tmpdir) / "img_folder/" 30 | ) # Path where the images of the ground_truth_file are stored 31 | output_path = ( 32 | Path(tmpdir) / "output.json" 33 | ) # Path where the `new_ground_truth_file` will be saved 34 | output_folder_path = ( 35 | Path(tmpdir) / "output_folder/" 36 | ) # Path where the crops will be saved 37 | crop_height = 720 38 | crop_width = 720 39 | 40 | tmpdir.mkdir("img_folder") # Create temporary folder to store ground truth images 41 | 42 | gt = { 43 | "categories": [{"id": 1, "name": "drone", "supercategory": "object"}], 44 | "images": [{"id": 1, "width": 1920, "height": 1080, "file_name": "img1.png"}], 45 | "annotations": [ 46 | { 47 | "id": 1, 48 | "image_id": 1, 49 | "segmentation": [ 50 | 1153.0, 51 | 986.0, 52 | 1153.0, 53 | 999.0, 54 | 1172.0, 55 | 999.0, 56 | 1172.0, 57 | 986.0, 58 | ], 59 | "area": 247.0, 60 | "category_id": 1, 61 | "bbox": [1153.0, 986.0, 19.0, 13.0], 62 | "score": 1, 63 | "iscrowd": 0, 64 | }, 65 | { 66 | "id": 2, 67 | "image_id": 1, 68 | "segmentation": [ 69 | 433.0, 70 | 626.0, 71 | 433.0, 72 | 639.0, 73 | 452.0, 74 | 639.0, 75 | 452.0, 76 | 626.0, 77 | ], 78 | "area": 247.0, 79 | "category_id": 1, 80 | "bbox": [433.0, 626.0, 19.0, 13.0], 81 | "score": 1, 82 | "iscrowd": 0, 83 | }, 84 | ], 85 | } 86 | 87 | with open(gt_path, "w") as f: 88 | json.dump(gt, f) 89 | 90 | img = create_image() 91 | img.save(img_folder_path / "img1.png") 92 | 93 | crops_split( 94 | gt_path, 95 | img_folder_path, 96 | output_path, 97 | output_folder_path, 98 | crop_height, 99 | crop_width, 100 | ) 101 | 102 | number_crops = len(os.listdir(output_folder_path)) 103 | result = json.load(open(output_path)) 104 | 105 | assert os.path.isdir(output_folder_path), "Output folder not created" 106 | assert number_crops == 6, "Error in number of crops in output folder" 107 | assert ( 108 | len(result["images"]) == 6 109 | ), "Error in number of crops in crops annotations file" 110 | assert ( 111 | len(result["old_images"]) == 1 112 | ), "Error in number of old images in crops annotations file" 113 | assert [x["id"] for x in result["images"]] == list(range(len(result["images"]))) 114 | assert [x["id"] for x in result["annotations"]] == list( 115 | range(len(result["annotations"])) 116 | ) 117 | assert [x["image_id"] for x in result["annotations"]] == [0, 3, 4] 118 | 119 | 120 | def test_crops_split_path(tmpdir): 121 | gt_path = Path(tmpdir) / "gt.json" 122 | img_folder_path = Path(tmpdir) / "img_folder/" 123 | output_path = Path(tmpdir) / "output.json" 124 | output_folder_path = Path(tmpdir) / "output_folder/" 125 | crop_height = 720 126 | crop_width = 720 127 | 128 | tmpdir.mkdir("img_folder") 129 | 130 | gt = { 131 | "categories": [{"id": 1, "name": "drone", "supercategory": "object"}], 132 | "images": [ 133 | {"id": 1, "width": 1920, "height": 1080, "file_name": "images/img1.png"}, 134 | ], 135 | "annotations": [ 136 | { 137 | "id": 1, 138 | "image_id": 1, 139 | "segmentation": [ 140 | 1153.0, 141 | 986.0, 142 | 1153.0, 143 | 999.0, 144 | 1172.0, 145 | 999.0, 146 | 1172.0, 147 | 986.0, 148 | ], 149 | "area": 247.0, 150 | "category_id": 1, 151 | "bbox": [1153.0, 986.0, 19.0, 13.0], 152 | "score": 1, 153 | "iscrowd": 0, 154 | }, 155 | ], 156 | } 157 | 158 | with open(gt_path, "w") as f: 159 | json.dump(gt, f) 160 | 161 | img = create_image() 162 | img.save(img_folder_path / "img1.png") 163 | 164 | crops_split( 165 | gt_path, 166 | img_folder_path, 167 | output_path, 168 | output_folder_path, 169 | crop_height, 170 | crop_width, 171 | ) 172 | 173 | number_crops = len(os.listdir(output_folder_path)) 174 | result = json.load(open(output_path)) 175 | 176 | assert os.path.isdir(output_folder_path), "Output folder not created" 177 | assert number_crops == 6, "Error in number of crops in output folder" 178 | assert ( 179 | len(result["images"]) == 6 180 | ), "Error in number of crops in crops annotations file" 181 | assert len(result["old_images"]) == 1, "Error in number of old images" 182 | 183 | 184 | def test_annotation_output_folder_created(tmpdir): 185 | gt = { 186 | "categories": [{"id": 1, "name": "drone"}], 187 | "images": [{"id": 1, "width": 1920, "height": 1080, "file_name": "img1.png"}], 188 | "annotations": [ 189 | { 190 | "id": 1, 191 | "image_id": 1, 192 | "area": 1, 193 | "category_id": 1, 194 | "bbox": [0, 0, 1, 1], 195 | "iscrowd": 0, 196 | }, 197 | ], 198 | } 199 | 200 | with open(Path(tmpdir) / "gt.json", "w") as f: 201 | json.dump(gt, f) 202 | 203 | img = np.zeros((10, 10, 3), dtype=np.uint8) 204 | Image.fromarray(img).save(Path(tmpdir) / "img1.png") 205 | 206 | crops_split( 207 | ground_truth_file=Path(tmpdir) / "gt.json", 208 | image_folder=tmpdir, 209 | output_file=Path(tmpdir) / "new_folder/gt.json", 210 | output_image_folder=Path(tmpdir) / "crops_folder", 211 | crop_height=5, 212 | crop_width=5, 213 | ) 214 | 215 | assert (Path(tmpdir) / "new_folder/gt.json").exists() 216 | -------------------------------------------------------------------------------- /tests/apps/test_ground_truth.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from pyodi.apps.ground_truth import ground_truth 5 | 6 | 7 | def test_ground_truth_saves_output_to_files(tmpdir): 8 | output = tmpdir.mkdir("results") 9 | 10 | categories = [{"id": 1, "name": "drone"}] 11 | images = [{"id": 0, "file_name": "image.jpg", "height": 10, "width": 10}] 12 | annotations = [ 13 | {"image_id": 0, "category_id": 1, "id": 0, "bbox": [0, 0, 5, 5], "area": 25} 14 | ] 15 | coco_data = dict( 16 | images=images, 17 | annotations=annotations, 18 | categories=categories, 19 | info={}, 20 | licenses={}, 21 | ) 22 | with open(tmpdir / "data.json", "w") as f: 23 | json.dump(coco_data, f) 24 | 25 | ground_truth(tmpdir / "data.json", show=False, output=output) 26 | 27 | assert len(list(Path(output / "data").iterdir())) == 3 28 | -------------------------------------------------------------------------------- /tests/apps/test_paint_annotations.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import numpy as np 5 | import pytest 6 | from matplotlib import cm 7 | from PIL import Image 8 | 9 | from pyodi.apps.paint_annotations import paint_annotations 10 | 11 | 12 | def test_image_annotations(tmp_path): 13 | images_folder = tmp_path / "images" 14 | Path(images_folder).mkdir(exist_ok=True, parents=True) 15 | 16 | output_folder = tmp_path / "test_result" 17 | Path(output_folder).mkdir(exist_ok=True, parents=True) 18 | 19 | images = [ 20 | {"id": 0, "width": 10, "height": 10, "file_name": "test_0.png"}, 21 | {"id": 1, "width": 10, "height": 10, "file_name": "test_1.png"}, 22 | {"id": 2, "width": 10, "height": 10, "file_name": "test_2.png"}, 23 | ] 24 | 25 | annotations = [ 26 | {"image_id": 0, "category_id": 0, "bbox": [0, 0, 2, 2], "score": 1}, 27 | {"image_id": 1, "category_id": 0, "bbox": [0, 0, 2, 2], "score": 1}, 28 | {"image_id": 1, "category_id": 1, "bbox": [3, 3, 2, 2], "score": 1}, 29 | ] 30 | 31 | categories = [ 32 | {"id": 0, "name": "", "supercategory": "object"}, 33 | {"id": 1, "name": "", "supercategory": "object"}, 34 | ] 35 | 36 | coco_data = dict(images=images, annotations=annotations, categories=categories) 37 | 38 | n_categories = len(categories) 39 | colormap = cm.rainbow(np.linspace(0, 1, n_categories)) 40 | 41 | color_1 = np.round(colormap[0] * 255) 42 | color_2 = np.round(colormap[1] * 255) 43 | 44 | for i, image_data in enumerate(images): 45 | image = np.zeros((image_data["height"], image_data["width"], 3), dtype=np.uint8) 46 | Image.fromarray(image).save(images_folder / f"test_{i}.png") 47 | 48 | with open(tmp_path / "test_annotation.json", "w") as json_file: 49 | json.dump(coco_data, json_file) 50 | 51 | paint_annotations( 52 | tmp_path / "test_annotation.json", 53 | images_folder, 54 | output_folder, 55 | show_label=False, 56 | ) 57 | 58 | result_image_0 = np.asarray(Image.open(output_folder / "test_0_result.png")) 59 | result_image_1 = np.asarray(Image.open(output_folder / "test_1_result.png")) 60 | 61 | assert np.array_equal(result_image_0[0, 1], color_1) 62 | assert np.array_equal(result_image_0[2, 3], color_1) 63 | 64 | assert np.array_equal(result_image_1[0, 1], color_1) 65 | assert np.array_equal(result_image_1[2, 3], color_1) 66 | 67 | assert np.array_equal(result_image_1[3, 4], color_2) 68 | assert np.array_equal(result_image_1[5, 6], color_2) 69 | 70 | 71 | def test_crowd_annotations_skipped_when_filter_crowd(tmp_path): 72 | images_folder = tmp_path / "images" 73 | Path(images_folder).mkdir(exist_ok=True, parents=True) 74 | 75 | output_folder = tmp_path / "test_result" 76 | Path(output_folder).mkdir(exist_ok=True, parents=True) 77 | 78 | images = [ 79 | {"id": 0, "width": 10, "height": 10, "file_name": "test_0.png"}, 80 | {"id": 1, "width": 10, "height": 10, "file_name": "test_1.png"}, 81 | {"id": 2, "width": 10, "height": 10, "file_name": "test_2.png"}, 82 | ] 83 | 84 | annotations = [ 85 | { 86 | "image_id": 0, 87 | "category_id": 0, 88 | "bbox": [0, 0, 2, 2], 89 | "score": 1, 90 | "iscrowd": 1, 91 | }, 92 | { 93 | "image_id": 1, 94 | "category_id": 0, 95 | "bbox": [0, 0, 2, 2], 96 | "score": 1, 97 | "iscrowd": 0, 98 | }, 99 | {"image_id": 1, "category_id": 1, "bbox": [3, 3, 2, 2], "score": 1}, 100 | ] 101 | 102 | categories = [ 103 | {"id": 0, "name": "", "supercategory": "object"}, 104 | {"id": 1, "name": "", "supercategory": "object"}, 105 | ] 106 | 107 | coco_data = dict(images=images, annotations=annotations, categories=categories) 108 | 109 | n_categories = len(categories) 110 | colormap = cm.rainbow(np.linspace(0, 1, n_categories)) 111 | 112 | orig_color = np.array([0, 0, 0, 255]) 113 | color_1 = np.round(colormap[0] * 255) 114 | color_2 = np.round(colormap[1] * 255) 115 | 116 | for i, image_data in enumerate(images): 117 | image = np.zeros((image_data["height"], image_data["width"], 3), dtype=np.uint8) 118 | Image.fromarray(image).save(images_folder / f"test_{i}.png") 119 | 120 | with open(tmp_path / "test_annotation.json", "w") as json_file: 121 | json.dump(coco_data, json_file) 122 | 123 | paint_annotations( 124 | tmp_path / "test_annotation.json", 125 | images_folder, 126 | output_folder, 127 | show_label=False, 128 | ) 129 | 130 | result_image_0 = np.asarray(Image.open(output_folder / "test_0_result.png")) 131 | result_image_1 = np.asarray(Image.open(output_folder / "test_1_result.png")) 132 | 133 | assert np.array_equal(result_image_0[0, 1], orig_color) 134 | assert np.array_equal(result_image_0[2, 3], orig_color) 135 | 136 | assert np.array_equal(result_image_1[0, 1], color_1) 137 | assert np.array_equal(result_image_1[2, 3], color_1) 138 | 139 | assert np.array_equal(result_image_1[3, 4], color_2) 140 | assert np.array_equal(result_image_1[5, 6], color_2) 141 | 142 | 143 | @pytest.mark.parametrize("first_n", [1, 3, None]) 144 | def test_first_n(tmp_path, first_n): 145 | images_folder = tmp_path / "images" 146 | Path(images_folder).mkdir(exist_ok=True, parents=True) 147 | 148 | output_folder = tmp_path / "test_result" 149 | Path(output_folder).mkdir(exist_ok=True, parents=True) 150 | 151 | images, annotations = [], [] 152 | for i in range(5): 153 | images.append( 154 | {"id": 0, "width": 10, "height": 10, "file_name": f"test_{i}.png"} 155 | ) 156 | image = np.zeros((10, 10, 3), dtype=np.uint8) 157 | Image.fromarray(image).save(images_folder / f"test_{i}.png") 158 | 159 | annotations.append( 160 | {"image_id": i, "category_id": 0, "bbox": [0, 0, 2, 2], "score": 1} 161 | ) 162 | 163 | categories = [{"id": 0, "name": "", "supercategory": "object"}] 164 | 165 | coco_data = dict(images=images, annotations=annotations, categories=categories) 166 | 167 | with open(tmp_path / "test_annotation.json", "w") as json_file: 168 | json.dump(coco_data, json_file) 169 | 170 | first_n = first_n or len(images) 171 | 172 | paint_annotations( 173 | tmp_path / "test_annotation.json", images_folder, output_folder, first_n=first_n 174 | ) 175 | 176 | assert len(list(Path(output_folder).iterdir())) == first_n 177 | -------------------------------------------------------------------------------- /tests/core/test_anchor_generator.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pyodi.core.anchor_generator import AnchorGenerator 5 | from pyodi.core.clustering import get_max_overlap 6 | 7 | 8 | @pytest.mark.parametrize("base_sizes", [[1], [9], [16]]) 9 | def test_anchor_base_generation(base_sizes): 10 | expected = np.array([-0.5, -0.5, 0.5, 0.5])[None, None, :] * base_sizes 11 | anchor_generator = AnchorGenerator( 12 | strides=[16], ratios=[1.0], scales=[1.0], base_sizes=base_sizes 13 | ) 14 | np.testing.assert_equal(anchor_generator.base_anchors, expected) 15 | 16 | 17 | @pytest.mark.parametrize("feature_maps", [[(4, 4)], [(9, 9)]]) 18 | def test_anchor_grid_for_different_feature_maps(feature_maps): 19 | """Test anchor grid for different feature maps. 20 | 21 | Creates anchors of size 2 over a feature map with size 'feature_maps' computed 22 | with stride 4. Each pixel from the feature map comes from applying a stride 4 to the 23 | original size. One anchor computed for each pixel. 24 | 25 | """ 26 | anchor_sizes = [2] 27 | strides = [4] 28 | anchor_generator = AnchorGenerator( 29 | strides=strides, ratios=[1.0], scales=[1.0], base_sizes=anchor_sizes 30 | ) 31 | base_anchor = np.array([-0.5, -0.5, 0.5, 0.5]) * anchor_sizes 32 | 33 | expected_anchors = [] 34 | for i in range(feature_maps[0][0]): 35 | for j in range(feature_maps[0][1]): 36 | new_anchor = [ 37 | base_anchor[0] + strides[0] * j, 38 | base_anchor[1] + strides[0] * i, 39 | base_anchor[2] + strides[0] * j, 40 | base_anchor[3] + strides[0] * i, 41 | ] 42 | expected_anchors.append(new_anchor) 43 | 44 | multi_level_anchors = anchor_generator.grid_anchors(feature_maps)[0] 45 | 46 | assert len(multi_level_anchors) == feature_maps[0][0] * feature_maps[0][1] 47 | np.testing.assert_equal(multi_level_anchors, np.stack(expected_anchors)) 48 | 49 | 50 | def test_iou_with_different_size_anchors(): 51 | """Create two grids of anchors (different sizes, same stride) and check overlap.""" 52 | strides = [2, 2] 53 | feature_maps = [(2, 2), (2, 2)] 54 | anchor_generator = AnchorGenerator( 55 | strides=strides, ratios=[1.0], scales=[1.0], base_sizes=[2, 4] 56 | ) 57 | multi_level_anchors = anchor_generator.grid_anchors(feature_maps) 58 | assert len(multi_level_anchors) == 2 59 | 60 | iou = get_max_overlap( 61 | multi_level_anchors[0].astype(np.float32), 62 | multi_level_anchors[1].astype(np.float32), 63 | ) 64 | np.testing.assert_equal(iou, np.ones(len(iou)) * 0.25) 65 | -------------------------------------------------------------------------------- /tests/core/test_boxes.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import pytest 4 | 5 | from pyodi.core.boxes import ( 6 | add_centroids, 7 | coco_to_corners, 8 | corners_to_coco, 9 | denormalize, 10 | filter_zero_area_bboxes, 11 | get_bbox_array, 12 | get_bbox_column_names, 13 | get_df_from_bboxes, 14 | get_scale_and_ratio, 15 | normalize, 16 | scale_bbox_dimensions, 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def get_simple_annotations_with_img_sizes(): 22 | def _get_fake_data(bboxes=None, bbox_format="coco"): 23 | if bboxes is None: 24 | bboxes = np.array([[0, 0, 10, 10, 100, 100], [20, 20, 50, 40, 100, 100]]) 25 | 26 | columns = get_bbox_column_names(bbox_format) + ["img_width", "img_height"] 27 | return pd.DataFrame(bboxes, columns=columns) 28 | 29 | return _get_fake_data 30 | 31 | 32 | def test_scale_bbox_dimensions(get_simple_annotations_with_img_sizes): 33 | df_annotations = get_simple_annotations_with_img_sizes() 34 | df_annotations = scale_bbox_dimensions(df_annotations, (1280, 720)) 35 | bboxes = get_bbox_array(df_annotations, prefix="scaled") 36 | expected_bboxes = np.array([[0, 0, 128, 72], [256, 144, 640, 288]], dtype=np.int32) 37 | np.testing.assert_equal(bboxes, expected_bboxes) 38 | 39 | 40 | def test_scale_bbox_dimensions_with_keep_ratio(get_simple_annotations_with_img_sizes): 41 | df_annotations = get_simple_annotations_with_img_sizes() 42 | df_annotations = scale_bbox_dimensions(df_annotations, (1280, 720), keep_ratio=True) 43 | bboxes = get_bbox_array(df_annotations, prefix="scaled") 44 | expected_bboxes = np.array([[0, 0, 72, 72], [144, 144, 360, 288]], dtype=np.int32) 45 | np.testing.assert_equal(bboxes, expected_bboxes) 46 | 47 | 48 | def test_get_area_and_ratio(get_simple_annotations_with_img_sizes): 49 | df_annotations = get_simple_annotations_with_img_sizes() 50 | df_annotations = get_scale_and_ratio(df_annotations) 51 | expected_scales = np.sqrt([100, 2000]) 52 | expected_ratios = np.array([1, 0.8], dtype=float) 53 | np.testing.assert_equal(df_annotations["scale"].to_numpy(), expected_scales) 54 | np.testing.assert_equal(df_annotations["ratio"].to_numpy(), expected_ratios) 55 | 56 | 57 | def test_get_bbox_matrix_corners(get_simple_annotations_with_img_sizes): 58 | df_annotations = get_simple_annotations_with_img_sizes() 59 | matrix = get_bbox_array(df_annotations, output_bbox_format="corners") 60 | expected_result = np.array([[0, 0, 10, 10], [20, 20, 70, 60]]) 61 | np.testing.assert_equal(matrix, expected_result) 62 | 63 | 64 | @pytest.mark.parametrize("bbox_format", (["corners", "coco"])) 65 | def test_get_df_from_bboxes(get_simple_annotations_with_img_sizes, bbox_format): 66 | bboxes = np.array([[20, 20, 5, 5, 100, 100], [40, 40, 20, 20, 100, 100]]) 67 | df_annotations = get_simple_annotations_with_img_sizes( 68 | bboxes, bbox_format=bbox_format 69 | ) 70 | matrix = get_bbox_array( 71 | df_annotations, input_bbox_format=bbox_format, output_bbox_format=bbox_format 72 | ) 73 | 74 | df = get_df_from_bboxes( 75 | matrix, input_bbox_format=bbox_format, output_bbox_format=bbox_format 76 | ) 77 | expected_result = df_annotations[get_bbox_column_names(bbox_format)] 78 | pd.testing.assert_frame_equal(df, expected_result) 79 | 80 | 81 | def test_filter_zero_area_bboxes(get_simple_annotations_with_img_sizes): 82 | bboxes = np.array([[20, 20, 5, 0, 100, 100], [40, 40, 20, 20, 100, 100]]) 83 | df_annotations = get_simple_annotations_with_img_sizes(bboxes, bbox_format="coco") 84 | df_annotations = filter_zero_area_bboxes(df_annotations) 85 | assert len(df_annotations) == 1 86 | 87 | 88 | def test_bboxes_transforms(): 89 | bboxes_coco = np.array([[0, 0, 10, 10], [0, 6, 3, 9]]) 90 | bboxes_corners = np.array([[0, 0, 10, 10], [0, 6, 3, 15]]) 91 | np.testing.assert_equal(bboxes_coco, corners_to_coco(bboxes_corners)) 92 | np.testing.assert_equal(bboxes_corners, coco_to_corners(bboxes_coco)) 93 | 94 | 95 | def test_add_centroids(get_simple_annotations_with_img_sizes): 96 | df_annotations = get_simple_annotations_with_img_sizes() 97 | centroids = add_centroids(df_annotations)[ 98 | ["col_centroid", "row_centroid"] 99 | ].to_numpy() 100 | expected_result = np.array([[5, 5], [45, 40]]) 101 | np.testing.assert_equal(centroids, expected_result) 102 | 103 | 104 | def test_normalization(): 105 | bboxes = np.array([[0, 0, 10, 10], [0, 0, 5, 20]]) 106 | normalized = normalize(bboxes, 100, 100) 107 | denormalized = denormalize(normalized, 100, 100) 108 | np.testing.assert_equal(denormalized, bboxes) 109 | -------------------------------------------------------------------------------- /tests/core/test_clustering.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pyodi.core.clustering import ( 5 | find_pyramid_level, 6 | get_max_overlap, 7 | kmeans_euclidean, 8 | origin_iou, 9 | ) 10 | 11 | 12 | @pytest.fixture 13 | def get_bboxes_matrices(): 14 | def _get_bboxes_matrices(): 15 | bboxes1 = np.array([[4.0, 3.0, 7.0, 5.0], [5.0, 6.0, 10.0, 7.0]]) 16 | bboxes2 = np.array( 17 | [[3.0, 4.0, 6.0, 8.0], [14.0, 14.0, 15.0, 15.0], [0.0, 0.0, 20.0, 20.0]] 18 | ) 19 | return bboxes1, bboxes2 20 | 21 | return _get_bboxes_matrices 22 | 23 | 24 | def test_max_overlap(get_bboxes_matrices): 25 | bboxes1, bboxes2 = get_bboxes_matrices() 26 | 27 | expected_result = np.array([2.0 / 16.0, 1.0 / 16.0]) 28 | iou_values = get_max_overlap(bboxes1.astype(np.float32), bboxes2.astype(np.float32)) 29 | 30 | np.testing.assert_equal(expected_result, iou_values) 31 | 32 | 33 | def test_origin_iou(get_bboxes_matrices): 34 | bboxes1, bboxes2 = get_bboxes_matrices() 35 | orig_iou = origin_iou(bboxes1[:, 2:], bboxes2[:, 2:]) 36 | bboxes1[:, :2] = 0 37 | bboxes2[:, :2] = 0 38 | max_overlap = get_max_overlap( 39 | bboxes1.astype(np.float32), bboxes2.astype(np.float32) 40 | ) 41 | np.testing.assert_almost_equal(orig_iou.max(1), max_overlap) 42 | 43 | 44 | def test_kmeans_scale_ratio(): 45 | X = np.array([[1, 2], [1, 4], [1, 0], [10, 2], [10, 4], [10, 0]]) 46 | result = kmeans_euclidean(X, n_clusters=2, silhouette_metric=True) 47 | np.testing.assert_almost_equal(result["silhouette"], 0.713, 3) 48 | 49 | 50 | def test_find_levels(): 51 | X = np.array([[1, 1], [10, 10], [64, 64]]) 52 | strides = [4, 8, 16, 32, 64] 53 | levels = find_pyramid_level(X, strides) 54 | np.testing.assert_equal(levels, np.array([0, 1, 4])) 55 | -------------------------------------------------------------------------------- /tests/core/test_crops.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from PIL import Image 3 | 4 | from pyodi.core.crops import ( 5 | annotation_inside_crop, 6 | filter_annotation_by_area, 7 | get_annotation_in_crop, 8 | get_crops_corners, 9 | ) 10 | 11 | 12 | def test_get_crop_corners_overllap(): 13 | image_pil = Image.fromarray(np.zeros((100, 100, 3), dtype=np.uint8)) 14 | no_overlap = get_crops_corners(image_pil, crop_height=10, crop_width=10) 15 | row_overlap = get_crops_corners( 16 | image_pil, crop_height=10, crop_width=10, row_overlap=5 17 | ) 18 | col_overlap = get_crops_corners( 19 | image_pil, crop_height=10, crop_width=10, col_overlap=5 20 | ) 21 | row_and_col_overlap = get_crops_corners( 22 | image_pil, crop_height=10, crop_width=10, row_overlap=5, col_overlap=5 23 | ) 24 | 25 | assert len(no_overlap) < len(row_overlap) < len(row_and_col_overlap) 26 | assert len(row_overlap) == len(col_overlap) 27 | 28 | 29 | def test_get_crop_corners_single_crop(): 30 | image_pil = Image.fromarray(np.zeros((100, 100, 3), dtype=np.uint8)) 31 | no_overlap = get_crops_corners(image_pil, crop_height=100, crop_width=100) 32 | 33 | assert len(no_overlap) == 1 34 | assert no_overlap[0][0] == 0 35 | assert no_overlap[0][1] == 0 36 | assert no_overlap[0][2] == 100 37 | assert no_overlap[0][3] == 100 38 | 39 | 40 | def test_get_crop_corners_coordinates(): 41 | image_pil = Image.fromarray(np.zeros((10, 10, 3), dtype=np.uint8)) 42 | no_overlap = get_crops_corners(image_pil, crop_height=5, crop_width=5) 43 | 44 | assert len(no_overlap) == 4 45 | assert tuple(no_overlap[0]) == (0, 0, 5, 5) 46 | assert tuple(no_overlap[1]) == (5, 0, 10, 5) 47 | assert tuple(no_overlap[2]) == (0, 5, 5, 10) 48 | assert tuple(no_overlap[3]) == (5, 5, 10, 10) 49 | 50 | 51 | def test_get_crop_corners_bounds(): 52 | image_pil = Image.fromarray(np.zeros((10, 10, 3), dtype=np.uint8)) 53 | no_overlap = get_crops_corners(image_pil, crop_height=6, crop_width=6) 54 | 55 | assert len(no_overlap) == 4 56 | assert tuple(no_overlap[0]) == (0, 0, 6, 6) 57 | assert tuple(no_overlap[1]) == (4, 0, 10, 6) 58 | assert tuple(no_overlap[2]) == (0, 4, 6, 10) 59 | assert tuple(no_overlap[3]) == (4, 4, 10, 10) 60 | 61 | 62 | def test_annotation_inside_crop(): 63 | annotation = {"bbox": [4, 4, 2, 2]} 64 | 65 | assert annotation_inside_crop(annotation, [0, 0, 5, 5]) 66 | assert annotation_inside_crop(annotation, [5, 0, 10, 5]) 67 | assert annotation_inside_crop(annotation, [0, 5, 5, 10]) 68 | assert annotation_inside_crop(annotation, [5, 5, 10, 10]) 69 | 70 | 71 | def test_annotation_outside_crop(): 72 | annotation = {"bbox": [2, 2, 2, 2]} 73 | 74 | assert annotation_inside_crop(annotation, [0, 0, 5, 5]) 75 | assert not annotation_inside_crop(annotation, [5, 0, 10, 5]) 76 | assert not annotation_inside_crop(annotation, [0, 5, 5, 10]) 77 | assert not annotation_inside_crop(annotation, [5, 5, 10, 10]) 78 | 79 | annotation = {"bbox": [6, 2, 2, 2]} 80 | 81 | assert not annotation_inside_crop(annotation, [0, 0, 5, 5]) 82 | assert annotation_inside_crop(annotation, [5, 0, 10, 5]) 83 | assert not annotation_inside_crop(annotation, [0, 5, 5, 10]) 84 | assert not annotation_inside_crop(annotation, [5, 5, 10, 10]) 85 | 86 | annotation = {"bbox": [2, 6, 2, 2]} 87 | 88 | assert not annotation_inside_crop(annotation, [0, 0, 5, 5]) 89 | assert not annotation_inside_crop(annotation, [5, 0, 10, 5]) 90 | assert annotation_inside_crop(annotation, [0, 5, 5, 10]) 91 | assert not annotation_inside_crop(annotation, [5, 5, 10, 10]) 92 | 93 | annotation = {"bbox": [6, 6, 2, 2]} 94 | 95 | assert not annotation_inside_crop(annotation, [0, 0, 5, 5]) 96 | assert not annotation_inside_crop(annotation, [5, 0, 10, 5]) 97 | assert not annotation_inside_crop(annotation, [0, 5, 5, 10]) 98 | assert annotation_inside_crop(annotation, [5, 5, 10, 10]) 99 | 100 | 101 | def test_get_annotation_in_crop(): 102 | annotation = {"bbox": [2, 2, 2, 2], "iscrowd": 0, "category_id": 0, "score": 1.0} 103 | 104 | new_annotation = get_annotation_in_crop(annotation, [0, 0, 5, 5]) 105 | assert tuple(new_annotation["bbox"]) == (2, 2, 2, 2) 106 | 107 | annotation = {"bbox": [4, 4, 2, 2], "iscrowd": 0, "category_id": 0, "score": 1.0} 108 | 109 | new_annotation = get_annotation_in_crop(annotation, [0, 0, 5, 5]) 110 | assert tuple(new_annotation["bbox"]) == (4, 4, 1, 1) 111 | new_annotation = get_annotation_in_crop(annotation, [5, 0, 10, 5]) 112 | assert tuple(new_annotation["bbox"]) == (0, 4, 1, 1) 113 | new_annotation = get_annotation_in_crop(annotation, [0, 5, 5, 10]) 114 | assert tuple(new_annotation["bbox"]) == (4, 0, 1, 1) 115 | new_annotation = get_annotation_in_crop(annotation, [5, 5, 10, 10]) 116 | assert tuple(new_annotation["bbox"]) == (0, 0, 1, 1) 117 | 118 | 119 | def test_annotation_larger_than_threshold(): 120 | annotation = { 121 | "bbox": [2, 2, 4, 5], 122 | "area": 20, 123 | "iscrowd": True, 124 | "score": 1.0, 125 | "category_id": 1, 126 | } 127 | 128 | new_annotation_tl = get_annotation_in_crop(annotation, [0, 0, 5, 5]) 129 | new_annotation_tr = get_annotation_in_crop(annotation, [5, 0, 10, 5]) 130 | new_annotation_bl = get_annotation_in_crop(annotation, [0, 5, 5, 10]) 131 | new_annotation_br = get_annotation_in_crop(annotation, [5, 5, 10, 10]) 132 | 133 | assert not filter_annotation_by_area(annotation, new_annotation_tl, 0.0) 134 | assert not filter_annotation_by_area(annotation, new_annotation_tr, 0.0) 135 | assert not filter_annotation_by_area(annotation, new_annotation_bl, 0.0) 136 | assert not filter_annotation_by_area(annotation, new_annotation_br, 0.0) 137 | 138 | assert not filter_annotation_by_area(annotation, new_annotation_tl, 0.1) 139 | assert not filter_annotation_by_area(annotation, new_annotation_tr, 0.1) 140 | assert not filter_annotation_by_area(annotation, new_annotation_bl, 0.1) 141 | assert filter_annotation_by_area(annotation, new_annotation_br, 0.1) 142 | 143 | assert not filter_annotation_by_area(annotation, new_annotation_tl, 0.25) 144 | assert filter_annotation_by_area(annotation, new_annotation_tr, 0.25) 145 | assert not filter_annotation_by_area(annotation, new_annotation_bl, 0.25) 146 | assert filter_annotation_by_area(annotation, new_annotation_br, 0.25) 147 | 148 | assert not filter_annotation_by_area(annotation, new_annotation_tl, 0.4) 149 | assert filter_annotation_by_area(annotation, new_annotation_tr, 0.4) 150 | assert filter_annotation_by_area(annotation, new_annotation_bl, 0.4) 151 | assert filter_annotation_by_area(annotation, new_annotation_br, 0.4) 152 | 153 | assert filter_annotation_by_area(annotation, new_annotation_tl, 0.5) 154 | assert filter_annotation_by_area(annotation, new_annotation_tr, 0.5) 155 | assert filter_annotation_by_area(annotation, new_annotation_bl, 0.5) 156 | assert filter_annotation_by_area(annotation, new_annotation_br, 0.5) 157 | -------------------------------------------------------------------------------- /tests/core/test_nms.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pyodi.core.nms import nms_predictions 4 | 5 | 6 | def get_random_boxes(score_low=0.0, score_hight=1.0): 7 | tops = np.random.randint(0, 99, size=100) 8 | lefts = np.random.randint(0, 99, size=100) 9 | heights = [np.random.randint(1, 100 - left) for left in lefts] 10 | widths = [np.random.randint(1, 100 - top) for top in tops] 11 | scores = np.random.uniform(low=score_low, high=score_hight, size=100) 12 | boxes = np.c_[lefts, tops, widths, heights, scores] 13 | return boxes 14 | 15 | 16 | def get_random_predictions(image_id): 17 | boxes = get_random_boxes() 18 | predictions = [] 19 | 20 | for box in boxes: 21 | predictions.append( 22 | { 23 | "image_id": image_id, 24 | "bbox": box[:-1], 25 | "score": box[-1], 26 | "iscrowd": 0, 27 | "category_id": 1, 28 | "original_image_shape": (100, 100), 29 | } 30 | ) 31 | return predictions 32 | 33 | 34 | def test_nms_predictions_score_thr(): 35 | predictions = [] 36 | for image_id in range(2): 37 | predictions.extend(get_random_predictions(image_id)) 38 | 39 | filtered = [] 40 | for score_thr in np.arange(0, 1.1, 0.1): 41 | filtered.append(nms_predictions(predictions, score_thr=score_thr, iou_thr=1.0)) 42 | 43 | for n in range(1, len(filtered)): 44 | # As score_thr increases, less boxes pass the filter 45 | assert len(filtered[n - 1]) >= len(filtered[n]) 46 | 47 | # At score_thr=0.0 no box is filtered 48 | assert len(filtered[0]) == len(predictions) 49 | 50 | 51 | def test_nms_predictions_iou_thr(): 52 | predictions = [] 53 | for image_id in range(2): 54 | predictions.extend(get_random_predictions(image_id)) 55 | 56 | filtered = [] 57 | for iou_thr in np.arange(0, 1.1, 0.1): 58 | filtered.append(nms_predictions(predictions, iou_thr=iou_thr)) 59 | 60 | for n in range(1, len(filtered)): 61 | # As iou_thr increases, more boxes pass the filter 62 | assert len(filtered[n - 1]) <= len(filtered[n]) 63 | 64 | # At iou_thr=1.0 no box is filtered 65 | assert len(filtered[-1]) == len(predictions) 66 | -------------------------------------------------------------------------------- /tests/core/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pyodi.core.utils import coco_ground_truth_to_df 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def annotations_file(tmpdir_factory): 11 | images = [ 12 | {"id": 1, "file_name": "1.png", "width": 1280, "height": 720}, 13 | {"id": 2, "file_name": "2.png", "width": 1280, "height": 720}, 14 | ] 15 | annotations = [ 16 | {"id": 1, "image_id": 1, "bbox": [0, 0, 2, 2], "area": 4, "category_id": 1}, 17 | {"id": 1, "image_id": 1, "bbox": [0, 0, 2, 2], "area": 4, "category_id": 2}, 18 | {"id": 1, "image_id": 2, "bbox": [0, 0, 2, 2], "area": 4, "category_id": 3}, 19 | ] 20 | categories = [ 21 | {"supercategory": "person", "id": 1, "name": "person"}, 22 | {"supercategory": "animal", "id": 2, "name": "cat"}, 23 | {"supercategory": "animal", "id": 3, "name": "dog"}, 24 | ] 25 | 26 | fn = tmpdir_factory.mktemp("data").join("ground_truth.json") 27 | data = dict(images=images, annotations=annotations, categories=categories) 28 | 29 | with open(str(fn), "w") as f: 30 | json.dump(data, f) 31 | 32 | return fn 33 | 34 | 35 | def test_coco_ground_truth_to_df(annotations_file): 36 | df_annotations = coco_ground_truth_to_df(annotations_file) 37 | assert len(df_annotations) == 3 38 | np.testing.assert_array_equal( 39 | df_annotations["col_left"].to_numpy(), np.array([0, 0, 0]) 40 | ) 41 | np.testing.assert_array_equal( 42 | df_annotations["category"].to_numpy(), np.array(["person", "cat", "dog"]) 43 | ) 44 | 45 | 46 | def test_coco_ground_truth_to_df_with_max_images(annotations_file): 47 | df_annotations = coco_ground_truth_to_df(annotations_file, max_images=1) 48 | assert len(df_annotations) == 2 49 | -------------------------------------------------------------------------------- /tests/plots/test_centroids_heatmap.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | 4 | from pyodi.plots.boxes import get_centroids_heatmap 5 | 6 | 7 | def test_centroids_heatmap_default(): 8 | df = pd.DataFrame( 9 | { 10 | "row_centroid": [5, 7], 11 | "col_centroid": [5, 7], 12 | "img_height": [10, 10], 13 | "img_width": [10, 10], 14 | } 15 | ) 16 | heatmap = get_centroids_heatmap(df) 17 | assert heatmap.sum() == 2 18 | assert heatmap[4, 4] == 1 19 | assert heatmap[6, 6] == 1 20 | 21 | 22 | @pytest.mark.parametrize("n_rows,n_cols", [(3, 3), (5, 5), (7, 7), (3, 7), (7, 3)]) 23 | def test_centroids_heatmap_n_rows_n_cols(n_rows, n_cols): 24 | df = pd.DataFrame( 25 | { 26 | "row_centroid": [0, 5, 9], 27 | "col_centroid": [0, 5, 9], 28 | "img_height": [10, 10, 10], 29 | "img_width": [10, 10, 10], 30 | } 31 | ) 32 | heatmap = get_centroids_heatmap(df, n_rows, n_cols) 33 | assert heatmap.shape == (n_rows, n_cols) 34 | assert heatmap[0, 0] == 1 35 | assert heatmap[n_rows // 2, n_cols // 2] == 1 36 | assert heatmap[-1, -1] == 1 37 | --------------------------------------------------------------------------------