├── .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 | 
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 | 
81 |
82 | 
83 |
84 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------