├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── publish-package.yml │ └── test-package.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── src └── globox │ ├── __init__.py │ ├── __main__.py │ ├── annotation.py │ ├── annotationset.py │ ├── atomic.py │ ├── boundingbox.py │ ├── cli.py │ ├── errors.py │ ├── evaluation.py │ ├── file_utils.py │ ├── image_utils.py │ ├── thread_utils.py │ └── utils.py ├── tests ├── __init__.py ├── benchmark.py ├── constants.py ├── pycocotools_results.py ├── test_annotation.py ├── test_annotationset.py ├── test_bbox.py ├── test_coco_evaluation.py ├── test_conversion.py ├── test_evaluation.py └── test_parsing.py └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Environment (please complete the following information):** 24 | - OS 25 | - Globox version 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | with: 21 | version: "0.5.4" 22 | 23 | - name: Build distributions 24 | run: uv build 25 | 26 | - name: Publish the package 27 | run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test-package.yml: -------------------------------------------------------------------------------- 1 | name: Test Python Package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python: ["3.9", "3.10", "3.11", "3.12"] 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - name: Checkout with submodules 17 | uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | with: 24 | version: "0.5.4" 25 | enable-cache: true 26 | cache-dependency-glob: "uv.lock" 27 | 28 | - name: Setup Python ${{ matrix.python }} 29 | run: uv python install ${{ matrix.python }} 30 | 31 | - name: Install dependencies 32 | run: uv sync --dev 33 | 34 | - name: Run tests 35 | run: uv run pytest tests 36 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | Pipfile 96 | Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | # macOS General 142 | .DS_Store 143 | .AppleDouble 144 | .LSOverride 145 | 146 | # Icon must end with two \r 147 | Icon 148 | 149 | # Thumbnails 150 | ._* 151 | 152 | # Files that might appear in the root of a volume 153 | .DocumentRevisions-V100 154 | .fseventsd 155 | .Spotlight-V100 156 | .TemporaryItems 157 | .Trashes 158 | .VolumeIcon.icns 159 | .com.apple.timemachine.donotpresent 160 | 161 | # Directories potentially created on remote AFP share 162 | .AppleDB 163 | .AppleDesktop 164 | Network Trash Folder 165 | Temporary Items 166 | .apdisk 167 | 168 | # VSCode 169 | .vscode/* 170 | !.vscode/settings.json 171 | !.vscode/tasks.json 172 | !.vscode/launch.json 173 | !.vscode/extensions.json 174 | *.code-workspace 175 | 176 | # Local History for Visual Studio Code 177 | .history/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/globox_test_data"] 2 | path = tests/globox_test_data 3 | url = https://github.com/laclouis5/globox_test_data 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.6.2 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "notebook.codeActionsOnSave": { 3 | "source.fixAll": "explicit", 4 | "source.organizeImports": "explicit", 5 | }, 6 | "[python]": { 7 | "editor.formatOnSave": true, 8 | "editor.codeActionsOnSave": { 9 | "source.organizeImports": "explicit" 10 | }, 11 | "editor.defaultFormatter": "charliermarsh.ruff", 12 | }, 13 | "notebook.formatOnSave.enabled": false, 14 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | . 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | . Translations are available at 128 | . 129 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Louis Lac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Globox — Object Detection Toolbox 2 | 3 | This framework can: 4 | 5 | * parse all kinds of object detection datasets (ImageNet, COCO, YOLO, PascalVOC, OpenImage, CVAT, LabelMe, etc.) and show statistics, 6 | * convert them to other formats (ImageNet, COCO, YOLO, PascalVOC, OpenImage, CVAT, LabelMe, etc.), 7 | * and evaluate predictions using standard object detection metrics such as $AP_{[.5:.05:.95]}$, $AP_{50}$, $mAP$, $AR_{1}$, $AR_{10}$, $AR_{100}$. 8 | 9 | This framework can be used both as a library in your own code and as a command line tool. This tool is designed to be simple to use, fast and correct. 10 | 11 | ## Install 12 | 13 | You can install the package using pip: 14 | 15 | ```shell 16 | pip install globox 17 | ``` 18 | 19 | ## Use as a Library 20 | 21 | ### Parse Annotations 22 | 23 | The library has three main components: 24 | 25 | * `BoundingBox`: represents a bounding box with a label and an optional confidence score 26 | * `Annotation`: represent the bounding boxes annotations for one image 27 | * `AnnotationSet`: represents annotations for a set of images (a database) 28 | 29 | The `AnnotationSet` class contains static methods to read different dataset formats: 30 | 31 | ```python 32 | # COCO 33 | coco = AnnotationSet.from_coco(file_path="path/to/file.json") 34 | 35 | # YOLOv5 36 | yolo = AnnotationSet.from_yolo_v5( 37 | folder="path/to/files/", 38 | image_folder="path/to/images/" 39 | ) 40 | 41 | # Pascal VOC 42 | pascal = AnnotationSet.from_pascal_voc(folder="path/to/files/") 43 | ``` 44 | 45 | `Annotation` offers file-level granularity for compatible datasets: 46 | 47 | ```python 48 | annotation = Annotation.from_labelme(file_path="path/to/file.xml") 49 | ``` 50 | 51 | For more specific implementations the `BoundingBox` class contains lots of utilities to parse bounding boxes in different formats, like the `create()` method. 52 | 53 | `AnnotationsSets` are set-like objects. They can be combined and annotations can be added: 54 | 55 | ```python 56 | gts = coco | yolo 57 | gts.add(annotation) 58 | ``` 59 | 60 | ### Inspect Datasets 61 | 62 | Iterators and efficient lookup by `image_id`'s are easy to use: 63 | 64 | ```python 65 | if annotation in gts: 66 | print("This annotation is present.") 67 | 68 | if "image_123.jpg" in gts.image_ids: 69 | print("Annotation of image 'image_123.jpg' is present.") 70 | 71 | for box in gts.all_boxes: 72 | print(box.label, box.area, box.is_ground_truth) 73 | 74 | for annotation in gts: 75 | nb_boxes = len(annotation.boxes) 76 | print(f"{annotation.image_id}: {nb_boxes} boxes") 77 | ``` 78 | 79 | Datasets stats can printed to the console: 80 | 81 | ```python 82 | coco_gts.show_stats() 83 | ``` 84 | 85 | ```text 86 | Database Stats 87 | ┏━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┓ 88 | ┃ Label ┃ Images ┃ Boxes ┃ 89 | ┡━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━┩ 90 | │ aeroplane │ 10 │ 15 │ 91 | │ bicycle │ 7 │ 14 │ 92 | │ bird │ 4 │ 6 │ 93 | │ boat │ 7 │ 11 │ 94 | │ bottle │ 9 │ 13 │ 95 | │ bus │ 5 │ 6 │ 96 | │ car │ 6 │ 14 │ 97 | │ cat │ 4 │ 5 │ 98 | │ chair │ 9 │ 15 │ 99 | │ cow │ 6 │ 14 │ 100 | │ diningtable │ 7 │ 7 │ 101 | │ dog │ 6 │ 8 │ 102 | │ horse │ 7 │ 7 │ 103 | │ motorbike │ 3 │ 5 │ 104 | │ person │ 41 │ 91 │ 105 | │ pottedplant │ 6 │ 7 │ 106 | │ sheep │ 4 │ 10 │ 107 | │ sofa │ 10 │ 10 │ 108 | │ train │ 5 │ 6 │ 109 | │ tvmonitor │ 8 │ 9 │ 110 | ├─────────────┼────────┼───────┤ 111 | │ Total │ 100 │ 273 │ 112 | └─────────────┴────────┴───────┘ 113 | ``` 114 | 115 | ### Convert and Save to Many Formats 116 | 117 | Datasets can be converted to and saved in other formats: 118 | 119 | ```python 120 | # ImageNet 121 | gts.save_imagenet(save_dir="pascalVOC_db/") 122 | 123 | # YOLO Darknet 124 | gts.save_yolo_darknet( 125 | save_dir="yolo_train/", 126 | label_to_id={"cat": 0, "dog": 1, "racoon": 2} 127 | ) 128 | 129 | # YOLOv5 130 | gts.save_yolo_v5( 131 | save_dir="yolo_train/", 132 | label_to_id={"cat": 0, "dog": 1, "racoon": 2}, 133 | ) 134 | 135 | # CVAT 136 | gts.save_cvat(path="train.xml") 137 | ``` 138 | 139 | ### COCO Evaluation 140 | 141 | COCO Evaluation is also supported: 142 | 143 | ```python 144 | evaluator = COCOEvaluator( 145 | ground_truths=gts, 146 | predictions=dets 147 | ) 148 | 149 | ap = evaluator.ap() 150 | ar_100 = evaluator.ar_100() 151 | ap_75 = evaluator.ap_75() 152 | ap_small = evaluator.ap_small() 153 | ... 154 | ``` 155 | 156 | All COCO standard metrics can be displayed in a pretty printed table with: 157 | 158 | ```python 159 | evaluator.show_summary() 160 | ``` 161 | 162 | which outputs: 163 | 164 | ```text 165 | COCO Evaluation 166 | ┏━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┳...┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓ 167 | ┃ Label ┃ AP 50:95 ┃ AP 50 ┃ ┃ AR S ┃ AR M ┃ AR L ┃ 168 | ┡━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━╇...╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩ 169 | │ airplane │ 22.7% │ 25.2% │ │ nan% │ 90.0% │ 0.0% │ 170 | │ apple │ 46.4% │ 57.4% │ │ 48.5% │ nan% │ nan% │ 171 | │ backpack │ 54.8% │ 85.1% │ │ 100.0% │ 72.0% │ 0.0% │ 172 | │ banana │ 73.6% │ 96.4% │ │ nan% │ 100.0% │ 70.0% │ 173 | . . . . . . . . 174 | . . . . . . . . 175 | . . . . . . . . 176 | ├───────────┼──────────┼────────┼...┼────────┼────────┼────────┤ 177 | │ Total │ 50.3% │ 69.7% │ │ 65.4% │ 60.3% │ 55.3% │ 178 | └───────────┴──────────┴────────┴...┴────────┴────────┴────────┘ 179 | ``` 180 | 181 | The array of results can be saved in CSV format: 182 | 183 | ```python 184 | evaluator.save_csv("where/to/save/results.csv") 185 | ``` 186 | 187 | Custom evaluations can be achieved with: 188 | 189 | ```python 190 | evaluation = evaluator.evaluate( 191 | iou_threshold=0.33, 192 | max_detections=1_000, 193 | size_range=(0.0, 10_000) 194 | ) 195 | 196 | ap = evaluation.ap() 197 | cat_ar = evaluation["cat"].ar 198 | ``` 199 | 200 | Evaluations are cached by `(iou_threshold, max_detections, size_range)` keys. This means that repetead queries to the evaluator are fast! 201 | 202 | ## Use in Command Line 203 | 204 | If you only need to use Globox from the command line like an application, you can install the package through [pipx](https://pypa.github.io/pipx/): 205 | 206 | ```shell 207 | pipx install globox 208 | ``` 209 | 210 | Globox will then be in your shell path and usable from anywhere. 211 | 212 | ### Usage 213 | 214 | Get a summary of annotations for one dataset: 215 | 216 | ```shell 217 | globox summary /yolo/folder/ --format yolo 218 | ``` 219 | 220 | Convert annotations from one format to another one: 221 | 222 | ```shell 223 | globox convert input/yolo/folder/ output_coco_file_path.json --format yolo --save_fmt coco 224 | ``` 225 | 226 | Evaluate a set of detections with COCO metrics, display them and save them in a CSV file: 227 | 228 | ```shell 229 | globox evaluate groundtruths/ predictions.json --format yolo --format_dets coco -s results.csv 230 | ``` 231 | 232 | Show the help message for an exhaustive list of options: 233 | 234 | ```shell 235 | globox summary -h 236 | globox convert -h 237 | globox evaluate -h 238 | ``` 239 | 240 | ## Run Tests 241 | 242 | Clone the repo with its test data: 243 | 244 | ```shell 245 | git clone https://github.com/laclouis5/globox --recurse-submodules=tests/globox_test_data 246 | cd globox 247 | ``` 248 | 249 | Install dependencies with [uv](https://github.com/astral-sh/uv): 250 | 251 | ```shell 252 | uv sync --dev 253 | ``` 254 | 255 | Run the tests: 256 | 257 | ```shell 258 | uv run pytest tests 259 | ``` 260 | 261 | ## Speed Banchmarks 262 | 263 | Speed benchmark can be executed with: 264 | 265 | ```shell 266 | uv run python tests/benchmark.py -n 5 267 | ``` 268 | 269 | The following speed test is performed using Python 3.11 and `timeit` with 5 iterations on a 2021 MacBook Pro 14" (M1 Pro 8 Cores and 16 GB of RAM). The dataset is COCO 2017 Validation which comprises 5k images and 36 781 bounding boxes. 270 | 271 | Task |COCO |CVAT |OpenImage|LabelMe|PascalVOC|YOLO |TXT 272 | -------|-----|-----|---------|-------|---------|-----|----- 273 | Parsing|0.22s|0.12s|0.44s |0.60s |0.97s |1.45s|1.12s 274 | Saving |0.32s|0.17s|0.14s |1.06s |1.08s |0.91s|0.85s 275 | 276 | * `AnnotationSet.show_stats()`: 0.02 s 277 | * Evalaution: 0.30 s 278 | 279 | 280 | 281 | ## Todo 282 | 283 | * [x] Basic data structures and utilities 284 | * [x] Parsers (ImageNet, COCO, YOLO, Pascal, OpenImage, CVAT, LabelMe) 285 | * [x] Parser tests 286 | * [x] Database summary and stats 287 | * [x] Database converters 288 | * [x] Visualization options 289 | * [x] COCO Evaluation 290 | * [x] Tests with a huge load (5k images) 291 | * [x] CLI interface 292 | * [x] Make `image_size` optional and raise err when required (bbox conversion) 293 | * [x] Make file saving atomic with a temporary to avoid file corruption 294 | * [x] Pip package! 295 | * [ ] PascalVOC Evaluation 296 | * [ ] Parsers for TFRecord and TensorFlow 297 | * [ ] UI interface? 298 | 299 | ## Acknowledgement 300 | 301 | This repo is based on the work of [Rafael Padilla](https://github.com/rafaelpadilla/review_object_detection_metrics). 302 | 303 | ## Contribution 304 | 305 | Feel free to contribute, any help you can offer with this project is most welcome. 306 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "globox" 7 | version = "2.7.0" 8 | requires-python = ">=3.9" 9 | dependencies = [ 10 | "numpy>=1.26", 11 | "tqdm>=4.65", 12 | "rich>=13.3", 13 | ] 14 | authors = [{name = "Louis Lac", email = "lac.louis5@gmail.com"}] 15 | maintainers = [{name = "Louis Lac", email = "lac.louis5@gmail.com"}] 16 | description = "Globox is a package and command line interface to read and convert object detection databases (COCO, YOLO, PascalVOC, LabelMe, CVAT, OpenImage, ...) and evaluate them with COCO and PascalVOC." 17 | readme = "README.md" 18 | keywords = [ 19 | "annotation", 20 | "metrics", 21 | "object detection", 22 | "bounding boxes", 23 | "yolo", 24 | "openimages", 25 | "cvat", 26 | "coco", 27 | "pascal voc", 28 | "average precision", 29 | "mean average precision", 30 | ] 31 | classifiers = [ 32 | "License :: OSI Approved :: MIT License", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python :: 3 :: Only", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "Programming Language :: Python :: 3.12", 39 | "Programming Language :: Python :: Implementation :: CPython", 40 | "Topic :: Scientific/Engineering", 41 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 42 | ] 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/laclouis5/globox" 46 | Repository = "https://github.com/laclouis5/globox" 47 | Documentation = "https://github.com/laclouis5/globox/#readme" 48 | Issues = "https://github.com/laclouis5/globox/issues" 49 | 50 | [project.scripts] 51 | globox = "globox.cli:main" 52 | 53 | [dependency-groups] 54 | dev = [ 55 | "pytest>=7.3", 56 | "pycocotools>=2.0", 57 | "pillow>=10.2", 58 | "ruff>=0.6", 59 | "pre-commit>=3.8", 60 | ] 61 | 62 | [tool.hatch.build.targets.sdist] 63 | packages = ["src/globox"] 64 | 65 | [tool.ruff.lint] 66 | ignore = ["E741"] 67 | 68 | [tool.pytest.ini_options] 69 | testpaths = ["tests/"] -------------------------------------------------------------------------------- /src/globox/__init__.py: -------------------------------------------------------------------------------- 1 | from .annotation import Annotation 2 | from .annotationset import AnnotationSet 3 | from .boundingbox import BoundingBox, BoxFormat, Coordinates 4 | from .errors import FileParsingError, ParsingError, UnknownImageFormat 5 | from .evaluation import COCOEvaluator, Evaluation, EvaluationItem 6 | 7 | __all__ = [ 8 | "Annotation", 9 | "AnnotationSet", 10 | "BoundingBox", 11 | "BoxFormat", 12 | "Coordinates", 13 | "FileParsingError", 14 | "ParsingError", 15 | "UnknownImageFormat", 16 | "COCOEvaluator", 17 | "Evaluation", 18 | "EvaluationItem", 19 | ] 20 | -------------------------------------------------------------------------------- /src/globox/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /src/globox/annotation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import xml.etree.ElementTree as et 3 | from pathlib import Path 4 | from typing import Mapping, Optional, Union 5 | from warnings import warn 6 | 7 | from .atomic import open_atomic 8 | from .boundingbox import BoundingBox, BoxFormat 9 | from .errors import FileParsingError, ParsingError 10 | from .file_utils import PathLike 11 | 12 | 13 | class Annotation: 14 | """ 15 | The bounding boxes associated with a uniquely identified image. 16 | """ 17 | 18 | __slots__ = ("_image_id", "_image_size", "boxes") 19 | 20 | def __init__( 21 | self, 22 | image_id: str, 23 | image_size: Optional["tuple[int, int]"] = None, 24 | boxes: Optional["list[BoundingBox]"] = None, 25 | ) -> None: 26 | """ 27 | Create an `Annotation` for an image identified with a unique `str` tag and with 28 | the provided list of bounding boxes. 29 | 30 | The image size in pixels ((width, height) tuple) can be optionally specified and is required 31 | for some export formats. This value can be queried from an image file with the 32 | `get_image_size()` function if it cannot be retreived from the annotation file. 33 | """ 34 | if image_size is not None: 35 | img_w, img_h = image_size 36 | assert ( 37 | img_w > 0 and img_h > 0 38 | ), f"Image size '({img_h}, {img_w})' should be positive." 39 | assert ( 40 | int(img_w) == img_w and int(img_h) == img_h 41 | ), f"Image size '({img_h}, {img_w})' components should be integers." 42 | 43 | self._image_id = image_id 44 | self._image_size = image_size 45 | self.boxes = boxes or [] 46 | 47 | @property 48 | def image_id(self) -> str: 49 | """The unique identifier of the image for this annotation.""" 50 | return self._image_id 51 | 52 | def with_image_id(self, image_id: str) -> "Annotation": 53 | """Returns a new Annotation with the updated `image_id`.""" 54 | return Annotation( 55 | image_id=image_id, image_size=self.image_size, boxes=self.boxes 56 | ) 57 | 58 | @property 59 | def image_size(self) -> "Optional[tuple[int, int]]": 60 | """The image size in pixels ((width, height) tuple) if present.""" 61 | return self._image_size 62 | 63 | @image_size.setter 64 | def image_size(self, image_size: "Optional[tuple[int, int]]"): 65 | if image_size is not None: 66 | img_w, img_h = image_size 67 | assert ( 68 | img_w > 0 and img_h > 0 69 | ), f"Image size '({img_h}, {img_w})' should be positive." 70 | assert ( 71 | int(img_w) == img_w and int(img_h) == img_h 72 | ), f"Image size '({img_h}, {img_w})' components should be integers." 73 | self._image_size = image_size 74 | 75 | @property 76 | def image_width(self) -> Optional[int]: 77 | """The image width in pixels.""" 78 | return self.image_size[0] if self.image_size is not None else None 79 | 80 | @property 81 | def image_height(self) -> Optional[int]: 82 | """The image height in pixels.""" 83 | return self.image_size[1] if self.image_size is not None else None 84 | 85 | def add(self, box: BoundingBox): 86 | """Add a bounding box to the image annotation.""" 87 | self.boxes.append(box) 88 | 89 | def map_labels(self, mapping: Mapping[str, str]) -> "Annotation": 90 | """ 91 | Update all the bounding box labels according to the provided dictionary which maps former 92 | names to new names. If a label name is not present in the dictionary keys, then it won't 93 | be updated. 94 | """ 95 | for box in self.boxes: 96 | if box.label in mapping.keys(): 97 | box.label = mapping[box.label] 98 | return self 99 | 100 | def _labels(self) -> "set[str]": 101 | """The set of the different label names present in the annotation.""" 102 | return {b.label for b in self.boxes} 103 | 104 | @staticmethod 105 | def from_txt( 106 | file_path: PathLike, 107 | *, 108 | image_id: Optional[str] = None, 109 | image_extension: str = ".jpg", 110 | box_format: BoxFormat = BoxFormat.LTRB, 111 | relative: bool = False, 112 | image_size: Optional["tuple[int, int]"] = None, 113 | separator: Optional[str] = None, 114 | conf_last: bool = False, 115 | ) -> "Annotation": 116 | path = Path(file_path).expanduser().resolve() 117 | 118 | if image_id is None: 119 | assert image_extension.startswith( 120 | "." 121 | ), f"Image extension '{image_extension}' should start with a dot." 122 | image_id = path.with_suffix(image_extension).name 123 | 124 | try: 125 | lines = path.read_text().splitlines() 126 | except OSError: 127 | raise FileParsingError(path, reason="cannot read file") 128 | 129 | try: 130 | boxes = [ 131 | BoundingBox.from_txt( 132 | l, 133 | box_format=box_format, 134 | relative=relative, 135 | image_size=image_size, 136 | separator=separator, 137 | conf_last=conf_last, 138 | ) 139 | for l in lines 140 | ] 141 | except ParsingError as e: 142 | raise FileParsingError(path, e.reason) 143 | 144 | return Annotation(image_id, image_size, boxes) 145 | 146 | @staticmethod 147 | def _from_yolo( 148 | file_path: PathLike, 149 | *, 150 | image_size: "tuple[int, int]", 151 | image_id: Optional[str] = None, 152 | image_extension: str = ".jpg", 153 | conf_last: bool = False, 154 | ) -> "Annotation": 155 | return Annotation.from_txt( 156 | file_path, 157 | image_id=image_id, 158 | image_extension=image_extension, 159 | box_format=BoxFormat.XYWH, 160 | relative=True, 161 | image_size=image_size, 162 | separator=None, 163 | conf_last=conf_last, 164 | ) 165 | 166 | @staticmethod 167 | def from_yolo( 168 | file_path: PathLike, 169 | *, 170 | image_size: "tuple[int, int]", 171 | image_id: Optional[str] = None, 172 | image_extension: str = ".jpg", 173 | conf_last: bool = False, 174 | ) -> "Annotation": 175 | warn( 176 | "'from_yolo' is deprecated. Please use `from_yolo_darknet` or `from_yolo_v5`", 177 | category=DeprecationWarning, 178 | stacklevel=2, 179 | ) 180 | 181 | return Annotation._from_yolo( 182 | file_path, 183 | image_size=image_size, 184 | image_id=image_id, 185 | image_extension=image_extension, 186 | conf_last=conf_last, 187 | ) 188 | 189 | @staticmethod 190 | def from_yolo_darknet( 191 | file_path: PathLike, 192 | *, 193 | image_size: "tuple[int, int]", 194 | image_id: Optional[str] = None, 195 | image_extension: str = ".jpg", 196 | ) -> "Annotation": 197 | return Annotation._from_yolo( 198 | file_path, 199 | image_size=image_size, 200 | image_id=image_id, 201 | image_extension=image_extension, 202 | conf_last=False, 203 | ) 204 | 205 | @staticmethod 206 | def from_yolo_v5( 207 | file_path: PathLike, 208 | *, 209 | image_size: "tuple[int, int]", 210 | image_id: Optional[str] = None, 211 | image_extension: str = ".jpg", 212 | ) -> "Annotation": 213 | return Annotation._from_yolo( 214 | file_path, 215 | image_size=image_size, 216 | image_id=image_id, 217 | image_extension=image_extension, 218 | conf_last=True, 219 | ) 220 | 221 | @staticmethod 222 | def from_yolo_v7( 223 | file_path: PathLike, 224 | *, 225 | image_size: "tuple[int, int]", 226 | image_id: Optional[str] = None, 227 | image_extension: str = ".jpg", 228 | ) -> "Annotation": 229 | return Annotation.from_yolo_v5( 230 | file_path, 231 | image_size=image_size, 232 | image_id=image_id, 233 | image_extension=image_extension, 234 | ) 235 | 236 | @staticmethod 237 | def from_xml(file_path: PathLike) -> "Annotation": 238 | path = Path(file_path).expanduser().resolve() 239 | 240 | try: 241 | with path.open() as f: 242 | root = et.parse(f).getroot() 243 | except (OSError, et.ParseError): 244 | raise ParsingError("Syntax error in imagenet annotation file.") 245 | 246 | image_id = root.findtext("filename") 247 | size_node = root.find("size") 248 | 249 | if (image_id is None) or (size_node is None): 250 | raise ParsingError("Syntax error in imagenet annotation file.") 251 | 252 | width = size_node.findtext("width") 253 | height = size_node.findtext("height") 254 | 255 | if (width is None) or (height is None): 256 | raise ParsingError("Syntax error in imagenet annotation file.") 257 | 258 | try: 259 | image_size = int(width), int(height) 260 | except ValueError: 261 | raise ParsingError("Syntax error in imagenet annotation file.") 262 | 263 | boxes = [BoundingBox.from_xml(n) for n in root.iter("object")] 264 | 265 | return Annotation(image_id, image_size, boxes) 266 | 267 | @staticmethod 268 | def from_pascal_voc(file_path: PathLike) -> "Annotation": 269 | return Annotation.from_xml(file_path) 270 | 271 | @staticmethod 272 | def from_imagenet(file_path: PathLike) -> "Annotation": 273 | return Annotation.from_xml(file_path) 274 | 275 | @staticmethod 276 | def from_labelme(file_path: PathLike, include_poly: bool = False) -> "Annotation": 277 | path = Path(file_path).expanduser().resolve() 278 | 279 | shape_types = {"rectangle"} 280 | if include_poly: 281 | shape_types.add("polygon") 282 | 283 | try: 284 | with path.open() as f: 285 | content = json.load(f) 286 | if "imageData" in content: 287 | del content["imageData"] 288 | except (OSError, json.JSONDecodeError): 289 | raise ParsingError("Syntax error in labelme annotation file.") 290 | 291 | try: 292 | image_id = str(content["imagePath"]) 293 | width = int(content["imageWidth"]) 294 | height = int(content["imageHeight"]) 295 | boxes = [ 296 | BoundingBox.from_labelme(n) 297 | for n in content["shapes"] 298 | if n["shape_type"] in shape_types 299 | ] 300 | except (KeyError, ValueError): 301 | raise ParsingError("Syntax error in labelme annotation file.") 302 | 303 | return Annotation(image_id, image_size=(width, height), boxes=boxes) 304 | 305 | @staticmethod 306 | def _from_coco_partial(node: dict) -> "Annotation": 307 | try: 308 | image_id = str(node["file_name"]) 309 | image_size = int(node["width"]), int(node["height"]) 310 | except (ValueError, KeyError): 311 | raise ParsingError("Syntax error in COCO annotation file.") 312 | 313 | return Annotation(image_id, image_size) 314 | 315 | @staticmethod 316 | def from_cvat(node: et.Element) -> "Annotation": 317 | image_id = node.get("name") 318 | width, height = node.get("width"), node.get("height") 319 | 320 | if (image_id is None) or (width is None) or (height is None): 321 | raise ParsingError("Syntax error in CVAT annotation file.") 322 | 323 | try: 324 | img_size = int(width), int(height) 325 | except ValueError: 326 | raise ParsingError("Syntax error in CVAT annotation file.") 327 | 328 | boxes = [BoundingBox.from_cvat(n) for n in node.iter("box")] 329 | 330 | return Annotation(image_id, image_size=img_size, boxes=boxes) 331 | 332 | @staticmethod 333 | def from_via_json( 334 | annotation: dict, 335 | *, 336 | label_key: str = "label_id", 337 | confidence_key: str = "confidence", 338 | image_size: "Optional[tuple[int, int]]" = None, 339 | ) -> "Annotation": 340 | try: 341 | filename = annotation["filename"] 342 | regions = annotation["regions"] 343 | 344 | bboxes = [ 345 | BoundingBox.from_via_json( 346 | region, label_key=label_key, confidence_key=confidence_key 347 | ) 348 | for region in regions 349 | if region["shape_attributes"]["name"] == "rect" 350 | ] 351 | except KeyError: 352 | raise ParsingError("Syntax error in VIA JSON annotation file.") 353 | 354 | return Annotation( 355 | image_id=filename, 356 | image_size=image_size, 357 | boxes=bboxes, 358 | ) 359 | 360 | @staticmethod 361 | def from_yolo_seg( 362 | file_path: PathLike, 363 | *, 364 | image_id: Optional[str] = None, 365 | image_extension: str = ".jpg", 366 | image_size: Optional["tuple[int, int]"] = None, 367 | ) -> "Annotation": 368 | path = Path(file_path).expanduser().resolve() 369 | 370 | if image_id is None: 371 | assert image_extension.startswith( 372 | "." 373 | ), f"Image extension '{image_extension}' should start with a dot." 374 | image_id = path.with_suffix(image_extension).name 375 | 376 | try: 377 | lines = path.read_text().splitlines() 378 | except OSError: 379 | raise FileParsingError(path, reason="cannot read file") 380 | 381 | try: 382 | boxes = [ 383 | BoundingBox.from_yolo_seg( 384 | l, 385 | image_size=image_size, 386 | ) 387 | for l in lines 388 | ] 389 | except ParsingError as e: 390 | raise FileParsingError(path, e.reason) 391 | 392 | return Annotation(image_id, image_size, boxes) 393 | 394 | def to_txt( 395 | self, 396 | *, 397 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 398 | box_format: BoxFormat = BoxFormat.LTRB, 399 | relative=False, 400 | image_size: Optional["tuple[int, int]"] = None, 401 | separator: str = " ", 402 | conf_last: bool = False, 403 | ) -> str: 404 | image_size = image_size or self.image_size 405 | 406 | return "\n".join( 407 | box.to_txt( 408 | label_to_id=label_to_id, 409 | box_format=box_format, 410 | relative=relative, 411 | image_size=image_size, 412 | separator=separator, 413 | conf_last=conf_last, 414 | ) 415 | for box in self.boxes 416 | ) 417 | 418 | def _to_yolo( 419 | self, 420 | *, 421 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 422 | image_size: Optional["tuple[int, int]"] = None, 423 | conf_last: bool = False, 424 | ) -> str: 425 | image_size = image_size or self.image_size 426 | 427 | if image_size is None: 428 | raise ValueError( 429 | "Either `image_size` shoud be provided as argument or stored in the Annotation " 430 | "object for conversion to YOLO format." 431 | ) 432 | 433 | return "\n".join( 434 | box.to_yolo( 435 | image_size=image_size, label_to_id=label_to_id, conf_last=conf_last 436 | ) 437 | for box in self.boxes 438 | ) 439 | 440 | def to_yolo( 441 | self, 442 | *, 443 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 444 | image_size: Optional["tuple[int, int]"] = None, 445 | conf_last: bool = False, 446 | ) -> str: 447 | warn( 448 | "'to_yolo' is deprecated. Please use `to_yolo_darknet` or `to_yolo_v5`", 449 | category=DeprecationWarning, 450 | stacklevel=2, 451 | ) 452 | 453 | return self._to_yolo( 454 | label_to_id=label_to_id, image_size=image_size, conf_last=conf_last 455 | ) 456 | 457 | def to_yolo_darknet( 458 | self, 459 | *, 460 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 461 | image_size: Optional["tuple[int, int]"] = None, 462 | ) -> str: 463 | return self._to_yolo( 464 | label_to_id=label_to_id, image_size=image_size, conf_last=False 465 | ) 466 | 467 | def to_yolo_v5( 468 | self, 469 | *, 470 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 471 | image_size: Optional["tuple[int, int]"] = None, 472 | ) -> str: 473 | return self._to_yolo( 474 | label_to_id=label_to_id, image_size=image_size, conf_last=True 475 | ) 476 | 477 | def to_yolo_v7( 478 | self, 479 | *, 480 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 481 | image_size: Optional["tuple[int, int]"] = None, 482 | ) -> str: 483 | return self.to_yolo_v5(label_to_id=label_to_id, image_size=image_size) 484 | 485 | def save_txt( 486 | self, 487 | path: PathLike, 488 | *, 489 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 490 | box_format: BoxFormat = BoxFormat.LTRB, 491 | relative: bool = False, 492 | image_size: Optional["tuple[int, int]"] = None, 493 | separator: str = " ", 494 | conf_last: bool = False, 495 | ): 496 | content = self.to_txt( 497 | label_to_id=label_to_id, 498 | box_format=box_format, 499 | relative=relative, 500 | image_size=image_size, 501 | separator=separator, 502 | conf_last=conf_last, 503 | ) 504 | 505 | with open_atomic(path, "w") as f: 506 | f.write(content) 507 | 508 | def _save_yolo( 509 | self, 510 | path: PathLike, 511 | *, 512 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 513 | image_size: Optional["tuple[int, int]"] = None, 514 | conf_last: bool = False, 515 | ): 516 | content = self._to_yolo( 517 | label_to_id=label_to_id, image_size=image_size, conf_last=conf_last 518 | ) 519 | 520 | with open_atomic(path, "w") as f: 521 | f.write(content) 522 | 523 | def save_yolo( 524 | self, 525 | path: PathLike, 526 | *, 527 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 528 | image_size: Optional["tuple[int, int]"] = None, 529 | conf_last: bool = False, 530 | ): 531 | warn( 532 | "'save_yolo' is deprecated. Please use `save_yolo_darknet` or `save_yolo_v5`", 533 | category=DeprecationWarning, 534 | stacklevel=2, 535 | ) 536 | 537 | self._save_yolo( 538 | path, label_to_id=label_to_id, image_size=image_size, conf_last=conf_last 539 | ) 540 | 541 | def save_yolo_darknet( 542 | self, 543 | path: PathLike, 544 | *, 545 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 546 | image_size: Optional["tuple[int, int]"] = None, 547 | ): 548 | self._save_yolo( 549 | path, label_to_id=label_to_id, image_size=image_size, conf_last=False 550 | ) 551 | 552 | def save_yolo_v5( 553 | self, 554 | path: PathLike, 555 | *, 556 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 557 | image_size: Optional["tuple[int, int]"] = None, 558 | ): 559 | self._save_yolo( 560 | path, label_to_id=label_to_id, image_size=image_size, conf_last=True 561 | ) 562 | 563 | def save_yolo_v7( 564 | self, 565 | path: PathLike, 566 | *, 567 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 568 | image_size: Optional["tuple[int, int]"] = None, 569 | ): 570 | self.save_yolo_v5(path, label_to_id=label_to_id, image_size=image_size) 571 | 572 | def to_labelme(self, *, image_size: Optional["tuple[int, int]"] = None) -> dict: 573 | image_size = image_size or self.image_size 574 | assert ( 575 | image_size is not None 576 | ), "An image size should be provided either by argument or by `self.image_size`." 577 | 578 | return { 579 | "imagePath": self.image_id, 580 | "imageWidth": image_size[0], 581 | "imageHeight": image_size[1], 582 | "imageData": None, 583 | "shapes": [b.to_labelme() for b in self.boxes], 584 | } 585 | 586 | def save_labelme( 587 | self, path: PathLike, *, image_size: Optional["tuple[int, int]"] = None 588 | ): 589 | content = self.to_labelme(image_size=image_size) 590 | with open_atomic(path, "w") as f: 591 | json.dump(content, fp=f, allow_nan=False) 592 | 593 | def to_xml(self, *, image_size: Optional["tuple[int, int]"] = None) -> et.Element: 594 | image_size = image_size or self.image_size 595 | assert ( 596 | image_size is not None 597 | ), "An image size should be provided either by argument or by `self.image_size`." 598 | 599 | ann_node = et.Element("annotation") 600 | et.SubElement(ann_node, "filename").text = self.image_id 601 | 602 | size_node = et.SubElement(ann_node, "size") 603 | et.SubElement(size_node, "width").text = f"{image_size[0]}" 604 | et.SubElement(size_node, "height").text = f"{image_size[1]}" 605 | 606 | for box in self.boxes: 607 | ann_node.append(box.to_xml()) 608 | 609 | return ann_node 610 | 611 | def to_pascal_voc( 612 | self, *, image_size: Optional["tuple[int, int]"] = None 613 | ) -> et.Element: 614 | return self.to_xml(image_size=image_size) 615 | 616 | def to_imagenet( 617 | self, *, image_size: Optional["tuple[int, int]"] = None 618 | ) -> et.Element: 619 | return self.to_xml(image_size=image_size) 620 | 621 | def save_xml( 622 | self, path: PathLike, *, image_size: Optional["tuple[int, int]"] = None 623 | ): 624 | content = self.to_xml(image_size=image_size) 625 | content = et.tostring(content, encoding="unicode") 626 | 627 | with open_atomic(path, "w") as f: 628 | f.write(content) 629 | 630 | def save_pascal_voc( 631 | self, path: PathLike, *, image_size: Optional["tuple[int, int]"] = None 632 | ): 633 | self.save_xml(path, image_size=image_size) 634 | 635 | def save_imagenet( 636 | self, path: PathLike, *, image_size: Optional["tuple[int, int]"] = None 637 | ): 638 | self.save_xml(path, image_size=image_size) 639 | 640 | def to_cvat(self, *, image_size: Optional["tuple[int, int]"] = None) -> et.Element: 641 | image_size = image_size or self.image_size 642 | assert ( 643 | image_size is not None 644 | ), "An image size should be provided either by argument or by `self.image_size`." 645 | 646 | img_node = et.Element( 647 | "image", 648 | attrib={ 649 | "name": self.image_id, 650 | "width": f"{image_size[0]}", 651 | "height": f"{image_size[1]}", 652 | }, 653 | ) 654 | 655 | img_node.extend(box.to_cvat() for box in self.boxes) 656 | 657 | return img_node 658 | 659 | def to_via_json( 660 | self, 661 | *, 662 | image_folder: PathLike, 663 | label_key: str = "label_id", 664 | confidence_key: str = "confidence", 665 | ) -> dict: 666 | path = Path(image_folder).expanduser().resolve() 667 | 668 | assert path.is_dir(), f"Filepath '{path}' is not a folder or does not exist." 669 | 670 | image_id = self.image_id 671 | image_path = path / image_id 672 | file_size = image_path.stat().st_size 673 | 674 | regions = [ 675 | box.to_via_json(label_key=label_key, confidence_key=confidence_key) 676 | for box in self.boxes 677 | ] 678 | 679 | return {"filename": image_id, "size": file_size, "regions": regions} 680 | 681 | def __repr__(self) -> str: 682 | return ( 683 | f"Annotation(image_id: {self.image_id}, image_size: {self.image_size}, " 684 | f"boxes: {self.boxes})" 685 | ) 686 | -------------------------------------------------------------------------------- /src/globox/annotationset.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | import xml.etree.ElementTree as et 4 | from collections import defaultdict 5 | from functools import partial 6 | from pathlib import Path 7 | from typing import ( 8 | Any, 9 | Callable, 10 | Dict, 11 | Iterable, 12 | Iterator, 13 | Mapping, 14 | Optional, 15 | TypeVar, 16 | Union, 17 | ) 18 | from warnings import warn 19 | 20 | from tqdm import tqdm 21 | 22 | from .annotation import Annotation 23 | from .atomic import open_atomic 24 | from .boundingbox import BoundingBox, BoxFormat 25 | from .errors import ParsingError, UnknownImageFormat 26 | from .file_utils import PathLike, glob 27 | from .image_utils import IMAGE_EXTENSIONS, get_image_size 28 | from .thread_utils import thread_map 29 | 30 | T = TypeVar("T") 31 | 32 | 33 | class AnnotationSet: 34 | """ 35 | A set of annotations of multiple and distinct images, most commonly refered to a 'dataset'. 36 | """ 37 | 38 | def __init__( 39 | self, 40 | annotations: Optional[Iterable[Annotation]] = None, 41 | *, 42 | override=False, 43 | ): 44 | """ 45 | Create an `AnnotationSet` from multiple image annotations. Each annotation should be unique, 46 | i.e. multiple annotations for a single image (as idendified by its `image_id`) is not 47 | allowed. 48 | 49 | Parameters: 50 | 51 | * `annotation`: an iterable of image annotations. 52 | * `override`: if `True`, image annotations not unique are allowed and only the last one in 53 | the iterator will be kept, else an error is thrown. 54 | """ 55 | # TODO: Add optional addition of labels found during 56 | # parsing, for instance COCO names and YOLO `.names`. 57 | # Could also add a (lazy) computed accessor that 58 | # runs through all boxes to get labels. 59 | 60 | self._annotations: Dict[str, Annotation] = {} 61 | 62 | if annotations is not None: 63 | for annotation in annotations: 64 | self.add(annotation, override=override) 65 | 66 | self._id_to_label: Optional["dict[Any, str]"] = None 67 | self._id_to_imageid: Optional["dict[Any, str]"] = None 68 | 69 | def __getitem__(self, image_id: str) -> Annotation: 70 | """ 71 | Get the image annotation with the corresponding `image_id`. Will raise an exception 72 | if the image ID is not present in the dataset. 73 | """ 74 | return self._annotations[image_id] 75 | 76 | def get(self, image_id: str) -> Optional[Annotation]: 77 | """ 78 | Get the image annotation with the corresponding `image_id`, if present in the dataset 79 | (else `None` is returned). 80 | """ 81 | return self._annotations.get(image_id) 82 | 83 | def __len__(self) -> int: 84 | """The number of annotations in the dataset.""" 85 | return len(self._annotations) 86 | 87 | def __iter__(self): 88 | yield from self._annotations.values() 89 | 90 | def items(self): 91 | """A view on the image annotation items (key-value pairs).""" 92 | return self._annotations.items() 93 | 94 | def __contains__(self, annotation: Annotation) -> bool: 95 | """Return `True` if a given annotation is present in the dataset, else `False`.""" 96 | return annotation.image_id in self._annotations.keys() 97 | 98 | def add(self, annotation: Annotation, *, override=False): 99 | """ 100 | Add an annotation to the dataset. 101 | 102 | Parameters: 103 | 104 | * `annotation`: the annotation to add. 105 | * `override`: set to `True` if the annotation may already be in the dataset and the former 106 | it should be replaced by the new one. If `False` and the annotation is already in the 107 | dataset, an error is thrown. 108 | """ 109 | 110 | if not override: 111 | assert annotation.image_id not in self.image_ids, ( 112 | f"The annotation with id '{annotation.image_id}' is already present in the set " 113 | "(set `override` to True to remove this assertion)." 114 | ) 115 | self._annotations[annotation.image_id] = annotation 116 | 117 | def update(self, other: "AnnotationSet", *, override=False) -> "AnnotationSet": 118 | """ 119 | Add annotations from another datasetset to this one. 120 | 121 | Parameters: 122 | 123 | * `other`: the annotations to add. 124 | * `override`: if `True`, image annotations in `other` that aren't unique are allowed and 125 | only the last one in the iterator will be kept, else an error is thrown. 126 | """ 127 | 128 | if not override: 129 | assert self.image_ids.isdisjoint(other.image_ids), ( 130 | "some image ids are already in the set (set 'override' to True to remove " 131 | "this assertion)." 132 | ) 133 | self._annotations.update(other._annotations) 134 | return self 135 | 136 | def __ior__(self, other: "AnnotationSet") -> "AnnotationSet": 137 | return self.update(other) 138 | 139 | def __or__(self, other: "AnnotationSet") -> "AnnotationSet": 140 | return AnnotationSet().update(self).update(other) 141 | 142 | def map_labels(self, mapping: Mapping[str, str]) -> "AnnotationSet": 143 | """ 144 | Update all the bounding box labels according to the provided dictionary which maps former 145 | names to new names. If a label name is not present in the dictionary keys, then it won't 146 | be updated. 147 | """ 148 | for annotation in self: 149 | annotation.map_labels(mapping) 150 | return self 151 | 152 | @property 153 | def image_ids(self): 154 | """A view on the set of image IDs of this dataset.""" 155 | return self._annotations.keys() 156 | 157 | @property 158 | def all_boxes(self) -> Iterator[BoundingBox]: 159 | """An iterator of all the bounding boxes of the dataset.""" 160 | for annotation in self: 161 | yield from annotation.boxes 162 | 163 | def nb_boxes(self) -> int: 164 | """The number of bounding boxes in the dataset.""" 165 | return sum(len(ann.boxes) for ann in self) 166 | 167 | def _labels(self) -> "set[str]": 168 | """The set of the different label names present in the dataset.""" 169 | return {b.label for b in self.all_boxes} 170 | 171 | def filter(self, predicate: Callable[[Annotation], bool]) -> "AnnotationSet": 172 | return AnnotationSet(annotations=[a for a in self if predicate(a)]) 173 | 174 | def map(self, func: Callable[[Annotation], Annotation]) -> "AnnotationSet": 175 | return AnnotationSet(annotations=[func(a) for a in self]) 176 | 177 | @staticmethod 178 | def from_iter( 179 | parser: Callable[[T], Annotation], 180 | iterable: Iterable[T], 181 | *, 182 | verbose: bool = False, 183 | ) -> "AnnotationSet": 184 | annotations = thread_map(parser, iterable, desc="Parsing", verbose=verbose) 185 | return AnnotationSet(annotations) 186 | 187 | @staticmethod 188 | def from_folder( 189 | folder: PathLike, 190 | *, 191 | extension: str, 192 | parser: Callable[[Path], Annotation], 193 | recursive=False, 194 | verbose: bool = False, 195 | ) -> "AnnotationSet": 196 | folder = Path(folder).expanduser().resolve() 197 | 198 | assert ( 199 | folder.is_dir() 200 | ), f"Filepath '{folder}' is not a folder or does not exist." 201 | 202 | files = list(glob(folder, extension, recursive=recursive)) 203 | return AnnotationSet.from_iter(parser, files, verbose=verbose) 204 | 205 | @staticmethod 206 | def from_txt( 207 | folder: PathLike, 208 | *, 209 | image_folder: Optional[PathLike] = None, 210 | box_format=BoxFormat.LTRB, 211 | relative=False, 212 | file_extension: str = ".txt", 213 | image_extension: str = ".jpg", 214 | separator: Optional[str] = None, 215 | conf_last: bool = False, 216 | verbose: bool = False, 217 | ) -> "AnnotationSet": 218 | """This method won't try to retreive the image sizes by default. Specify `image_folder` if you need them. 219 | `image_folder` is required when `relative` is True.""" 220 | # TODO: Add error handling 221 | 222 | folder = Path(folder).expanduser().resolve() 223 | 224 | assert folder.is_dir() 225 | assert image_extension.startswith(".") 226 | 227 | if relative: 228 | assert ( 229 | image_folder is not None 230 | ), "When `relative` is set to True, `image_folder` must be provided to read image sizes." 231 | 232 | if image_folder is not None: 233 | image_folder = Path(image_folder).expanduser().resolve() 234 | assert image_folder.is_dir() 235 | 236 | def _get_annotation(file: Path) -> Annotation: 237 | if image_folder is not None: 238 | image_path: Path | None = None 239 | 240 | for image_ext in IMAGE_EXTENSIONS: 241 | image_id = file.with_suffix(image_ext).name 242 | path = image_folder / image_id # type: ignore 243 | 244 | if path.is_file(): 245 | image_path = path 246 | break 247 | 248 | assert ( 249 | image_path is not None 250 | ), f"Image {file.name} does not exist, unable to read the image size." 251 | 252 | image_id = image_path.name 253 | 254 | try: 255 | image_size = get_image_size(image_path) 256 | except UnknownImageFormat: 257 | raise ParsingError( 258 | f"Unable to read image size of file {image_path}. " 259 | f"The file may be corrupted or the file format not supported." 260 | ) 261 | else: 262 | image_size = None 263 | image_id = file.with_suffix(image_extension).name 264 | 265 | return Annotation.from_txt( 266 | file_path=file, 267 | image_id=image_id, 268 | box_format=box_format, 269 | relative=relative, 270 | image_size=image_size, 271 | separator=separator, 272 | conf_last=conf_last, 273 | ) 274 | 275 | return AnnotationSet.from_folder( 276 | folder, 277 | extension=file_extension, 278 | parser=_get_annotation, 279 | verbose=verbose, 280 | ) 281 | 282 | @staticmethod 283 | def _from_yolo( 284 | folder: PathLike, 285 | *, 286 | image_folder: PathLike, 287 | image_extension=".jpg", 288 | conf_last: bool = False, 289 | verbose: bool = False, 290 | ) -> "AnnotationSet": 291 | return AnnotationSet.from_txt( 292 | folder, 293 | image_folder=image_folder, 294 | box_format=BoxFormat.XYWH, 295 | relative=True, 296 | image_extension=image_extension, 297 | separator=None, 298 | conf_last=conf_last, 299 | verbose=verbose, 300 | ) 301 | 302 | @staticmethod 303 | def from_yolo( 304 | folder: PathLike, 305 | *, 306 | image_folder: PathLike, 307 | image_extension=".jpg", 308 | conf_last: bool = False, 309 | verbose: bool = False, 310 | ) -> "AnnotationSet": 311 | warn( 312 | "'from_yolo' is deprecated. Please use `from_yolo_darknet` or `from_yolo_v5`", 313 | category=DeprecationWarning, 314 | stacklevel=2, 315 | ) 316 | 317 | return AnnotationSet._from_yolo( 318 | folder, 319 | image_folder=image_folder, 320 | image_extension=image_extension, 321 | conf_last=conf_last, 322 | verbose=verbose, 323 | ) 324 | 325 | @staticmethod 326 | def from_yolo_darknet( 327 | folder: PathLike, 328 | *, 329 | image_folder: PathLike, 330 | image_extension=".jpg", 331 | verbose: bool = False, 332 | ) -> "AnnotationSet": 333 | return AnnotationSet._from_yolo( 334 | folder, 335 | image_folder=image_folder, 336 | image_extension=image_extension, 337 | conf_last=False, 338 | verbose=verbose, 339 | ) 340 | 341 | @staticmethod 342 | def from_yolo_v5( 343 | folder: PathLike, 344 | *, 345 | image_folder: PathLike, 346 | image_extension=".jpg", 347 | verbose: bool = False, 348 | ) -> "AnnotationSet": 349 | return AnnotationSet._from_yolo( 350 | folder, 351 | image_folder=image_folder, 352 | image_extension=image_extension, 353 | conf_last=True, 354 | verbose=verbose, 355 | ) 356 | 357 | @staticmethod 358 | def from_yolo_v7( 359 | folder: PathLike, 360 | *, 361 | image_folder: PathLike, 362 | image_extension=".jpg", 363 | verbose: bool = False, 364 | ) -> "AnnotationSet": 365 | return AnnotationSet.from_yolo_v5( 366 | folder, 367 | image_folder=image_folder, 368 | image_extension=image_extension, 369 | verbose=verbose, 370 | ) 371 | 372 | @staticmethod 373 | def from_xml(folder: PathLike, *, verbose: bool = False) -> "AnnotationSet": 374 | return AnnotationSet.from_folder( 375 | folder, extension=".xml", parser=Annotation.from_xml, verbose=verbose 376 | ) 377 | 378 | @staticmethod 379 | def from_pascal_voc(folder: PathLike, *, verbose: bool = False) -> "AnnotationSet": 380 | return AnnotationSet.from_xml(folder, verbose=verbose) 381 | 382 | @staticmethod 383 | def from_imagenet(folder: PathLike, *, verbose: bool = False) -> "AnnotationSet": 384 | return AnnotationSet.from_xml(folder, verbose=verbose) 385 | 386 | @staticmethod 387 | def from_openimage( 388 | file_path: PathLike, 389 | *, 390 | image_folder: PathLike, 391 | verbose: bool = False, 392 | ) -> "AnnotationSet": 393 | file_path = Path(file_path).expanduser().resolve() 394 | assert ( 395 | file_path.is_file() and file_path.suffix == ".csv" 396 | ), f"OpenImage annotation file {file_path} must be a csv file." 397 | 398 | image_folder = Path(image_folder).expanduser().resolve() 399 | assert ( 400 | image_folder.is_dir() 401 | ), f"Image folder {image_folder} must be a valid directory." 402 | 403 | # TODO: Error handling. 404 | # OSError, DictReader error, Key/Value Error 405 | 406 | annotations = AnnotationSet() 407 | 408 | with file_path.open(newline="") as f: 409 | reader = csv.DictReader(f) 410 | 411 | for row in tqdm(reader, desc="Parsing", disable=not verbose): 412 | image_id = row["ImageID"] 413 | label = row["LabelName"] 414 | coords = (float(row[r]) for r in ("XMin", "YMin", "XMax", "YMax")) 415 | confidence = row.get("Confidence") 416 | 417 | if confidence is not None and confidence != "": 418 | confidence = float(confidence) 419 | else: 420 | confidence = None 421 | 422 | if image_id not in annotations.image_ids: 423 | image_path = image_folder / image_id 424 | image_size = get_image_size(image_path) 425 | annotations.add( 426 | Annotation(image_id=image_id, image_size=image_size) 427 | ) 428 | 429 | annotation = annotations[image_id] 430 | annotation.add( 431 | BoundingBox.create( 432 | label=label, 433 | coords=tuple(coords), 434 | confidence=confidence, 435 | relative=True, 436 | image_size=annotation.image_size, 437 | ) 438 | ) 439 | 440 | return annotations 441 | 442 | @staticmethod 443 | def from_labelme( 444 | folder: PathLike, *, include_poly: bool = False, verbose: bool = False 445 | ) -> "AnnotationSet": 446 | parser = partial(Annotation.from_labelme, include_poly=include_poly) 447 | return AnnotationSet.from_folder( 448 | folder, extension=".json", parser=parser, verbose=verbose 449 | ) 450 | 451 | @staticmethod 452 | def from_coco(file_path: PathLike, *, verbose: bool = False) -> "AnnotationSet": 453 | file_path = Path(file_path).expanduser().resolve() 454 | assert ( 455 | file_path.is_file() and file_path.suffix == ".json" 456 | ), f"COCO annotation file {file_path} must be a json file." 457 | 458 | # TODO: Error handling. 459 | # OSError, JsonDecoderError, Key/ValueError 460 | with file_path.open() as f: 461 | content = json.load(f) 462 | 463 | id_to_label = {d["id"]: str(d["name"]) for d in content["categories"]} 464 | 465 | id_to_annotation = { 466 | d["id"]: Annotation._from_coco_partial(d) for d in content["images"] 467 | } 468 | 469 | elements = content["annotations"] 470 | 471 | for element in tqdm(elements, desc="Parsing", disable=not verbose): 472 | annotation = id_to_annotation[element["image_id"]] 473 | label = id_to_label[int(element["category_id"])] 474 | coords = tuple(float(c) for c in element["bbox"]) 475 | confidence = element.get("score") 476 | 477 | if confidence is not None: 478 | confidence = float(confidence) 479 | 480 | annotation.add( 481 | BoundingBox.create( 482 | label=label, 483 | coords=coords, 484 | confidence=confidence, 485 | box_format=BoxFormat.LTWH, 486 | ) 487 | ) 488 | 489 | annotation_set = AnnotationSet(id_to_annotation.values()) 490 | annotation_set._id_to_label = id_to_label 491 | annotation_set._id_to_imageid = { 492 | idx: ann.image_id for idx, ann in id_to_annotation.items() 493 | } 494 | 495 | return annotation_set 496 | 497 | def from_results( 498 | self, file_path: PathLike, *, verbose: bool = False 499 | ) -> "AnnotationSet": 500 | file_path = Path(file_path).expanduser().resolve() 501 | # TODO: Error handling. 502 | assert ( 503 | file_path.is_file() and file_path.suffix == ".json" 504 | ), f"COCO annotation file {file_path} must be a json file." 505 | 506 | id_to_label = self._id_to_label 507 | id_to_imageid = self._id_to_imageid 508 | 509 | assert id_to_label is not None and id_to_imageid is not None, ( 510 | "The AnnotationSet instance should have been created with `AnnotationSet.from_coco()` " 511 | "or should have `self.id_to_label` and `self.id_to_image_id` populated. If not the " 512 | "case use the static method `AnnotationSet.from_coco_results()` instead." 513 | ) 514 | 515 | id_to_annotation = {} 516 | 517 | with file_path.open() as f: 518 | annotations = json.load(f) 519 | 520 | # TODO: Factorize this with `Self.from_coco()`? 521 | for element in tqdm(annotations, desc="Parsing", disable=not verbose): 522 | image_id = id_to_imageid[element["image_id"]] 523 | gt_ann = self[image_id] 524 | 525 | if image_id not in id_to_annotation: 526 | annotation = Annotation(image_id, image_size=gt_ann.image_size) 527 | id_to_annotation[image_id] = annotation 528 | else: 529 | annotation = id_to_annotation[image_id] 530 | 531 | label = id_to_label[int(element["category_id"])] 532 | coords = tuple(float(c) for c in element["bbox"]) 533 | confidence = float(element["score"]) 534 | 535 | annotation.add( 536 | BoundingBox.create( 537 | label=label, 538 | coords=coords, 539 | confidence=confidence, 540 | box_format=BoxFormat.LTWH, 541 | ) 542 | ) 543 | 544 | annotation_set = AnnotationSet(id_to_annotation.values()) 545 | annotation_set._id_to_label = id_to_label 546 | annotation_set._id_to_imageid = id_to_imageid 547 | 548 | return annotation_set 549 | 550 | @staticmethod 551 | def from_coco_results( 552 | file_path: PathLike, 553 | *, 554 | id_to_label: "dict[int, str]", 555 | id_to_imageid: "dict[int, str]", 556 | verbose: bool = False, 557 | ) -> "AnnotationSet": 558 | # TODO: Error handling. 559 | 560 | file_path = Path(file_path).expanduser().resolve() 561 | assert ( 562 | file_path.is_file() and file_path.suffix == ".json" 563 | ), f"COCO annotation file {file_path} must be a json file." 564 | 565 | id_to_annotation = {} 566 | 567 | with file_path.open() as f: 568 | annotations = json.load(f) 569 | 570 | # TODO: Factorize this with `Self.from_coco()`? 571 | for element in tqdm(annotations, desc="Parsing", disable=not verbose): 572 | image_id = id_to_imageid[element["image_id"]] 573 | 574 | if image_id not in id_to_annotation: 575 | annotation = Annotation(image_id) 576 | id_to_annotation[image_id] = annotation 577 | else: 578 | annotation = id_to_annotation[image_id] 579 | 580 | label = id_to_label[int(element["category_id"])] 581 | coords = tuple(float(c) for c in element["bbox"]) 582 | confidence = float(element["score"]) 583 | 584 | annotation.add( 585 | BoundingBox.create( 586 | label=label, 587 | coords=coords, 588 | confidence=confidence, 589 | box_format=BoxFormat.LTWH, 590 | ) 591 | ) 592 | 593 | annotation_set = AnnotationSet(id_to_annotation.values()) 594 | annotation_set._id_to_label = id_to_label 595 | annotation_set._id_to_imageid = id_to_imageid 596 | 597 | return annotation_set 598 | 599 | @staticmethod 600 | def from_cvat(file_path: PathLike, *, verbose: bool = False) -> "AnnotationSet": 601 | file_path = Path(file_path).expanduser().resolve() 602 | assert ( 603 | file_path.is_file() and file_path.suffix == ".xml" 604 | ), f"CVAT annotation file {file_path} must be a xml file." 605 | 606 | # TODO: Error handling. 607 | with file_path.open() as f: 608 | root = et.parse(f).getroot() 609 | 610 | image_nodes = list(root.iter("image")) 611 | 612 | return AnnotationSet.from_iter( 613 | Annotation.from_cvat, image_nodes, verbose=verbose 614 | ) 615 | 616 | @staticmethod 617 | def from_via_json( 618 | file_path: PathLike, 619 | *, 620 | label_key: str = "label_id", 621 | confidence_key: str = "confidence", 622 | image_folder: Optional[PathLike] = None, 623 | ) -> "AnnotationSet": 624 | file_path = Path(file_path).expanduser().resolve() 625 | 626 | if image_folder is not None: 627 | image_folder = Path(image_folder).expanduser().resolve() 628 | 629 | if not image_folder.is_dir(): 630 | raise ParsingError("Invalid `image_folder`: not a directory.") 631 | 632 | if not file_path.is_file() or not file_path.suffix == ".json": 633 | raise ParsingError( 634 | f"VIA JSON annotation file {file_path} must be a valid json file." 635 | ) 636 | 637 | with file_path.open() as f: 638 | content: dict = json.load(f) 639 | 640 | img_anns: dict = content.get("_via_img_metadata", content) 641 | 642 | annotations = AnnotationSet() 643 | 644 | for img_ann in img_anns.values(): 645 | annotation = Annotation.from_via_json( 646 | img_ann, label_key=label_key, confidence_key=confidence_key 647 | ) 648 | 649 | if image_folder is not None: 650 | img_path = image_folder / annotation.image_id 651 | image_size = get_image_size(img_path) 652 | annotation.image_size = image_size 653 | 654 | annotations.add(annotation) 655 | 656 | return annotations 657 | 658 | @staticmethod 659 | def from_yolo_seg( 660 | folder: PathLike, 661 | *, 662 | image_folder: Optional[PathLike] = None, 663 | relative=False, 664 | file_extension: str = ".txt", 665 | image_extension: str = ".jpg", 666 | verbose: bool = False, 667 | ) -> "AnnotationSet": 668 | """This method won't try to retreive the image sizes by default. Specify `image_folder` if you need them. 669 | `image_folder` is required when `relative` is True.""" 670 | # TODO: Add error handling 671 | 672 | folder = Path(folder).expanduser().resolve() 673 | 674 | assert folder.is_dir() 675 | assert image_extension.startswith(".") 676 | 677 | if relative: 678 | assert ( 679 | image_folder is not None 680 | ), "When `relative` is set to True, `image_folder` must be provided to read image sizes." 681 | 682 | if image_folder is not None: 683 | image_folder = Path(image_folder).expanduser().resolve() 684 | assert image_folder.is_dir() 685 | 686 | def _get_annotation(file: Path) -> Annotation: 687 | if image_folder is not None: 688 | image_path: Path | None = None 689 | 690 | for image_ext in IMAGE_EXTENSIONS: 691 | image_id = file.with_suffix(image_ext).name 692 | path = image_folder / image_id # type: ignore 693 | 694 | if path.is_file(): 695 | image_path = path 696 | break 697 | 698 | assert ( 699 | image_path is not None 700 | ), f"Image {file.name} does not exist, unable to read the image size." 701 | 702 | image_id = image_path.name 703 | 704 | try: 705 | image_size = get_image_size(image_path) 706 | except UnknownImageFormat: 707 | raise ParsingError( 708 | f"Unable to read image size of file {image_path}. " 709 | f"The file may be corrupted or the file format not supported." 710 | ) 711 | else: 712 | image_size = None 713 | image_id = file.with_suffix(image_extension).name 714 | 715 | return Annotation.from_yolo_seg( 716 | file_path=file, image_id=image_id, image_size=image_size 717 | ) 718 | 719 | return AnnotationSet.from_folder( 720 | folder, 721 | extension=file_extension, 722 | parser=_get_annotation, 723 | verbose=verbose, 724 | ) 725 | 726 | def save_from_it( 727 | self, save_fn: Callable[[Annotation], None], *, verbose: bool = False 728 | ): 729 | thread_map(save_fn, self, desc="Saving", verbose=verbose) 730 | 731 | def save_txt( 732 | self, 733 | save_dir: PathLike, 734 | *, 735 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 736 | box_format: BoxFormat = BoxFormat.LTRB, 737 | relative: bool = False, 738 | separator: str = " ", 739 | file_extension: str = ".txt", 740 | conf_last: bool = False, 741 | verbose: bool = False, 742 | ): 743 | save_dir = Path(save_dir).expanduser().resolve() 744 | save_dir.mkdir(exist_ok=True) 745 | 746 | def _save(annotation: Annotation): 747 | image_id = annotation.image_id 748 | path = (save_dir / image_id).with_suffix(file_extension) 749 | 750 | annotation.save_txt( 751 | path, 752 | label_to_id=label_to_id, 753 | box_format=box_format, 754 | relative=relative, 755 | separator=separator, 756 | conf_last=conf_last, 757 | ) 758 | 759 | self.save_from_it(_save, verbose=verbose) 760 | 761 | def _save_yolo( 762 | self, 763 | save_dir: PathLike, 764 | *, 765 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 766 | conf_last: bool = False, 767 | verbose: bool = False, 768 | ): 769 | save_dir = Path(save_dir).expanduser().resolve() 770 | save_dir.mkdir(exist_ok=True) 771 | 772 | def _save(annotation: Annotation): 773 | path = save_dir / Path(annotation.image_id).with_suffix(".txt") 774 | annotation._save_yolo(path, label_to_id=label_to_id, conf_last=conf_last) 775 | 776 | self.save_from_it(_save, verbose=verbose) 777 | 778 | def save_yolo( 779 | self, 780 | save_dir: PathLike, 781 | *, 782 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 783 | conf_last: bool = False, 784 | verbose: bool = False, 785 | ): 786 | warn( 787 | "'save_yolo' is deprecated. Please use `save_yolo_darknet` or `save_yolo_v5`", 788 | category=DeprecationWarning, 789 | stacklevel=2, 790 | ) 791 | 792 | self._save_yolo( 793 | save_dir, label_to_id=label_to_id, conf_last=conf_last, verbose=verbose 794 | ) 795 | 796 | def save_yolo_darknet( 797 | self, 798 | save_dir: PathLike, 799 | *, 800 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 801 | verbose: bool = False, 802 | ): 803 | self._save_yolo( 804 | save_dir, label_to_id=label_to_id, conf_last=False, verbose=verbose 805 | ) 806 | 807 | def save_yolo_v5( 808 | self, 809 | save_dir: PathLike, 810 | *, 811 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 812 | verbose: bool = False, 813 | ): 814 | self._save_yolo( 815 | save_dir, label_to_id=label_to_id, conf_last=True, verbose=verbose 816 | ) 817 | 818 | def save_yolo_v7( 819 | self, 820 | save_dir: PathLike, 821 | *, 822 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 823 | verbose: bool = False, 824 | ): 825 | self.save_yolo_v5(save_dir, label_to_id=label_to_id, verbose=verbose) 826 | 827 | def save_labelme(self, save_dir: PathLike, *, verbose: bool = False): 828 | save_dir = Path(save_dir).expanduser().resolve() 829 | save_dir.mkdir(exist_ok=True) 830 | 831 | def _save(annotation: Annotation): 832 | path = save_dir / Path(annotation.image_id).with_suffix(".json") 833 | annotation.save_labelme(path) 834 | 835 | self.save_from_it(_save, verbose=verbose) 836 | 837 | def save_xml(self, save_dir: PathLike, *, verbose: bool = False): 838 | save_dir = Path(save_dir).expanduser().resolve() 839 | save_dir.mkdir(exist_ok=True) 840 | 841 | def _save(annotation: Annotation): 842 | path = save_dir / Path(annotation.image_id).with_suffix(".xml") 843 | annotation.save_xml(path) 844 | 845 | self.save_from_it(_save, verbose=verbose) 846 | 847 | def save_pascal_voc(self, save_dir: PathLike, *, verbose: bool = False): 848 | self.save_xml(save_dir, verbose=verbose) 849 | 850 | def save_imagenet(self, save_dir: PathLike, *, verbose: bool = False): 851 | self.save_xml(save_dir, verbose=verbose) 852 | 853 | def to_coco( 854 | self, 855 | *, 856 | label_to_id: Optional["dict[str, int]"] = None, 857 | imageid_to_id: Optional["dict[str, int]"] = None, 858 | auto_ids: bool = False, 859 | verbose: bool = False, 860 | ) -> dict: 861 | if (label_to_id is not None) and (imageid_to_id is not None): 862 | pass 863 | elif (self._id_to_label is not None) and (self._id_to_imageid is not None): 864 | label_to_id = {v: k for k, v in self._id_to_label.items()} 865 | imageid_to_id = {v: k for k, v in self._id_to_imageid.items()} 866 | elif auto_ids: 867 | label_to_id = {l: i for i, l in enumerate(sorted(self._labels()))} 868 | imageid_to_id = {im: i for i, im in enumerate(sorted(self.image_ids))} 869 | else: 870 | # TODO: Convert to ConversionError. 871 | raise ValueError( 872 | "For COCO, mappings from labels and image ids to integer ids are required. " 873 | "They can be provided either by argument or automatically by the `AnnotationSet` " 874 | "instance if it was created with `AnnotationSet.from_coco()` or " 875 | "`AnnotationSet.from_coco_results()`. You can also set `auto_ids` to True to " 876 | "automatically create image and label ids (warning: this could cause unexpected " 877 | "compatibility issues with other COCO datasets)." 878 | ) 879 | 880 | annotations = [] 881 | ann_id_count = 0 882 | for annotation in tqdm(self, desc="Saving", disable=not verbose): 883 | for box in annotation.boxes: 884 | box_annotation = { 885 | "iscrowd": 0, 886 | "ignore": 0, 887 | "image_id": imageid_to_id[annotation.image_id], 888 | "bbox": box.ltwh, 889 | "area": box.area, 890 | "segmentation": [], 891 | "category_id": label_to_id[box.label], 892 | "id": ann_id_count, 893 | } 894 | 895 | if box.is_detection: 896 | box_annotation["score"] = box.confidence 897 | 898 | annotations.append(box_annotation) 899 | 900 | ann_id_count += 1 901 | 902 | images = [ 903 | { 904 | "id": imageid_to_id[a.image_id], 905 | "file_name": a.image_id, 906 | "width": a.image_width, 907 | "height": a.image_height, 908 | } 909 | for a in self 910 | ] 911 | 912 | categories = [ 913 | {"supercategory": "none", "id": i, "name": l} 914 | for l, i in label_to_id.items() 915 | ] 916 | 917 | return {"images": images, "annotations": annotations, "categories": categories} 918 | 919 | def save_coco( 920 | self, 921 | path: PathLike, 922 | *, 923 | label_to_id: Optional["dict[str, int]"] = None, 924 | imageid_to_id: Optional["dict[str, int]"] = None, 925 | auto_ids: bool = False, 926 | verbose: bool = False, 927 | ): 928 | path = Path(path).expanduser().resolve() 929 | 930 | if path.suffix == "": 931 | path = path.with_suffix(".json") 932 | 933 | assert path.suffix == ".json", f"Path '{path}' suffix should be '.json'." 934 | 935 | content = self.to_coco( 936 | label_to_id=label_to_id, 937 | imageid_to_id=imageid_to_id, 938 | auto_ids=auto_ids, 939 | verbose=verbose, 940 | ) 941 | 942 | with open_atomic(path, "w") as f: 943 | json.dump(content, fp=f, allow_nan=False) 944 | 945 | def save_openimage(self, path: PathLike, *, verbose: bool = False): 946 | path = Path(path).expanduser().resolve() 947 | 948 | if path.suffix == "": 949 | path = path.with_suffix(".csv") 950 | 951 | assert path.suffix == ".csv", f"Path '{path}' suffix should be '.csv'." 952 | 953 | fields = ( 954 | "ImageID", 955 | "Source", 956 | "LabelName", 957 | "Confidence", 958 | "XMin", 959 | "XMax", 960 | "YMin", 961 | "YMax", 962 | "IsOccluded", 963 | "IsTruncated", 964 | "IsGroupOf", 965 | "IsDepiction", 966 | "IsInside", 967 | ) 968 | 969 | with open_atomic(path, "w", newline="") as f: 970 | writer = csv.DictWriter(f, fieldnames=fields, restval="") 971 | writer.writeheader() 972 | 973 | for annotation in tqdm(self, desc="Saving", disable=not verbose): 974 | image_id = annotation.image_id 975 | image_size = annotation.image_size 976 | 977 | if image_size is None: 978 | raise ValueError( 979 | "The image size should be present in the annotation for `save_openimage`. " 980 | "One should parse the annotations specifying the image folder or populate " 981 | "the `image_size` attribute." 982 | ) 983 | 984 | for box in annotation.boxes: 985 | label = box.label 986 | 987 | if "," in label: 988 | raise ValueError( 989 | f"The box label '{label}' contains the character ',' which is the same " 990 | "as the separtor character used for BoundingBox representation in " 991 | "OpenImage format (CSV). This will corrupt the saved annotation file " 992 | "and likely make it unreadable. Use another character in the label " 993 | "name, e.g. use and underscore instead of a comma." 994 | ) 995 | 996 | xmin, ymin, xmax, ymax = BoundingBox.abs_to_rel( 997 | coords=box.ltrb, size=image_size 998 | ) 999 | 1000 | row = { 1001 | "ImageID": image_id, 1002 | "LabelName": label, 1003 | "XMin": xmin, 1004 | "XMax": xmax, 1005 | "YMin": ymin, 1006 | "YMax": ymax, 1007 | } 1008 | 1009 | if box.confidence is not None: 1010 | row["Confidence"] = box.confidence 1011 | 1012 | writer.writerow(row) # type: ignore 1013 | 1014 | def to_cvat(self, *, verbose: bool = False) -> et.Element: 1015 | def _create_node(annotation: Annotation) -> et.Element: 1016 | return annotation.to_cvat() 1017 | 1018 | sub_nodes = thread_map(_create_node, self, desc="Saving", verbose=verbose) 1019 | node = et.Element("annotations") 1020 | node.extend(sub_nodes) 1021 | 1022 | return node 1023 | 1024 | def save_cvat(self, path: PathLike, *, verbose: bool = False): 1025 | path = Path(path).expanduser().resolve() 1026 | 1027 | if path.suffix == "": 1028 | path = path.with_suffix(".xml") 1029 | 1030 | assert path.suffix == ".xml", f"Path '{path}' suffix should be '.xml'." 1031 | 1032 | content = self.to_cvat(verbose=verbose) 1033 | content = et.tostring(content, encoding="unicode") 1034 | 1035 | with open_atomic(path, "w") as f: 1036 | f.write(content) 1037 | 1038 | def to_via_json( 1039 | self, 1040 | *, 1041 | image_folder: Path, 1042 | label_key: str = "label_id", 1043 | confidence_key: str = "confidence", 1044 | verbose: bool = False, 1045 | ) -> dict: 1046 | output = {} 1047 | 1048 | for annotation in tqdm(self, desc="Saving", disable=not verbose): 1049 | ann_dict = annotation.to_via_json( 1050 | image_folder=image_folder, 1051 | label_key=label_key, 1052 | confidence_key=confidence_key, 1053 | ) 1054 | 1055 | key = f"{ann_dict['filename']}{ann_dict['size']}" 1056 | output[key] = ann_dict 1057 | 1058 | return output 1059 | 1060 | def save_via_json( 1061 | self, 1062 | path: PathLike, 1063 | *, 1064 | image_folder: Path, 1065 | label_key: str = "label_id", 1066 | confidence_key: str = "confidence", 1067 | verbose: bool = False, 1068 | ): 1069 | path = Path(path).expanduser().resolve() 1070 | 1071 | if path.suffix == "": 1072 | path = path.with_suffix(".json") 1073 | 1074 | assert path.suffix == ".json", f"Path '{path}' suffix should be '.json'." 1075 | 1076 | output = self.to_via_json( 1077 | image_folder=image_folder, 1078 | label_key=label_key, 1079 | confidence_key=confidence_key, 1080 | verbose=verbose, 1081 | ) 1082 | 1083 | with open_atomic(path, "w") as f: 1084 | json.dump(output, fp=f) 1085 | 1086 | @staticmethod 1087 | def parse_names_file(path: PathLike) -> "dict[str, str]": 1088 | """Parse .names file. 1089 | 1090 | Parameters: 1091 | - path: the path to the .names file. 1092 | 1093 | Returns: 1094 | - A dictionary mapping label number with label names.""" 1095 | # TODO: Add error handling 1096 | path = Path(path).expanduser().resolve() 1097 | 1098 | return {str(i): v for i, v in enumerate(path.read_text().splitlines())} 1099 | 1100 | @staticmethod 1101 | def parse_mid_file(path: PathLike) -> "dict[str, str]": 1102 | path = Path(path).expanduser().resolve() 1103 | 1104 | with path.open() as f: 1105 | reader = csv.DictReader(f, fieldnames=("LabelName", "DisplayName")) 1106 | return {l["LabelName"]: l["DisplayName"] for l in reader} 1107 | 1108 | def show_stats(self, *, verbose: bool = False): 1109 | """ 1110 | Print in the console a synthetic view of the dataset annotations (distribution of 1111 | bounding boxes and images by label). 1112 | """ 1113 | from rich import print as rprint 1114 | from rich.table import Table 1115 | 1116 | box_by_label = defaultdict(int) 1117 | im_by_label = defaultdict(int) 1118 | 1119 | for annotation in tqdm(self, desc="Stats", disable=not verbose): 1120 | for box in annotation.boxes: 1121 | box_by_label[box.label] += 1 1122 | 1123 | labels = annotation._labels() 1124 | 1125 | for label in labels: 1126 | im_by_label[label] += 1 1127 | 1128 | if len(labels) == 0: 1129 | im_by_label[""] += 1 1130 | 1131 | tot_box = sum(box_by_label.values()) 1132 | tot_im = len(self) 1133 | 1134 | table = Table(title="Database Stats", show_footer=True) 1135 | table.add_column("Label", footer="Total") 1136 | table.add_column("Images", footer=f"{tot_im}", justify="right") 1137 | table.add_column("Boxes", footer=f"{tot_box}", justify="right") 1138 | 1139 | for label in sorted(im_by_label.keys()): 1140 | nb_im = im_by_label[label] 1141 | nb_box = box_by_label[label] 1142 | table.add_row(label, f"{nb_im}", f"{nb_box}") 1143 | 1144 | rprint(table) 1145 | 1146 | def __repr__(self) -> str: 1147 | return f"AnnotationSet(annotations: {self._annotations})" 1148 | -------------------------------------------------------------------------------- /src/globox/atomic.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile as tmp 3 | from contextlib import contextmanager 4 | from typing import Optional 5 | 6 | from .file_utils import PathLike 7 | 8 | 9 | @contextmanager 10 | def _tempfile(suffix: str = "~", dir: Optional[PathLike] = None): 11 | tmp_file = tmp.NamedTemporaryFile(delete=False, suffix=suffix, dir=dir) 12 | tmp_name = tmp_file.name 13 | tmp_file.file.close() 14 | 15 | try: 16 | yield tmp_name 17 | finally: 18 | try: 19 | os.remove(tmp_name) 20 | except OSError as e: 21 | if e.errno == 2: 22 | pass 23 | else: 24 | raise 25 | 26 | 27 | @contextmanager 28 | def open_atomic(file_path: PathLike, *args, **kwargs): 29 | fsync = kwargs.pop("fsync", False) 30 | 31 | with _tempfile(dir=os.path.dirname(os.path.abspath(file_path))) as tmp_path: 32 | with open(tmp_path, *args, **kwargs) as file: 33 | try: 34 | yield file 35 | finally: 36 | if fsync: 37 | file.flush() 38 | os.fsync(file.fileno()) 39 | 40 | os.replace(tmp_path, file_path) 41 | -------------------------------------------------------------------------------- /src/globox/boundingbox.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as et 2 | from enum import Enum, auto 3 | from typing import Any, Mapping, Optional, Tuple, Union 4 | 5 | from .errors import ParsingError 6 | 7 | Coordinates = Tuple[float, float, float, float] 8 | """ 9 | The four raw coordinates of a `BoundingBox` with no assigned format. The coordinates at indices 10 | 0 and 2 must be on the X-axis (horizontal) and the ones at indices 1 and 3 must be on 11 | the Y-axis (vertical). 12 | """ 13 | 14 | 15 | class BoxFormat(Enum): 16 | """ 17 | The coordinate format of a `BoundingBox`, i.e. the order and defining coordinates. 18 | 19 | It can be one of: 20 | 21 | * `BoxFormat.LTRB`: [xmin, ymin, xmax, ymax], 22 | * `BoxFormat.LTWH`: [xmin, ymin, width, height], 23 | * `BoxFormat.XYWH`: [xmid, ymid, width, height]. 24 | 25 | See the `BoundingBox` documentation for more detail on the coordinate format. 26 | """ 27 | 28 | LTRB = auto() 29 | """[xmin, ymin, xmax, ymax]""" 30 | 31 | LTWH = auto() 32 | """[xmin, ymin, width, height]""" 33 | 34 | XYWH = auto() 35 | """[xmid, ymid, width, height]""" 36 | 37 | @classmethod 38 | def from_string(cls, string: str) -> "BoxFormat": 39 | """ 40 | Create a `BoxFormat` from a raw string. 41 | 42 | The raw string can be either "ltrb", "ltwh" or "xywh". 43 | """ 44 | if string == "ltrb": 45 | return BoxFormat.LTRB 46 | elif string == "ltwh": 47 | return BoxFormat.LTWH 48 | elif string == "xywh": 49 | return BoxFormat.XYWH 50 | else: 51 | raise ValueError(f"Invalid BoxFormat string '{string}'") 52 | 53 | 54 | class BoundingBox: 55 | """ 56 | A rectangular bounding box with a label and an optional confidence score. Coordinates are 57 | absolute (i.e. in pixels) and stored internally in `BoxFormat.LTRB` format. 58 | 59 | Spatial layout: 60 | 61 | ``` 62 | xmin xmid xmax 63 | ymin ╆╍╍╍╍╍╍┿╍╍╍╍╍╍┪ 64 | ╏ ┆ ╏ 65 | ymid ╂┄┄┄┄┄┄┼┄┄┄┄┄┄┨ 66 | ╏ ┆ ╏ 67 | ymax ┺╍╍╍╍╍╍┴╍╍╍╍╍╍┛ 68 | ``` 69 | """ 70 | 71 | __slots__ = ("label", "_xmin", "_ymin", "_xmax", "_ymax", "_confidence") 72 | 73 | def __init__( 74 | self, 75 | *, 76 | label: str, 77 | xmin: float, 78 | ymin: float, 79 | xmax: float, 80 | ymax: float, 81 | confidence: Optional[float] = None, 82 | ) -> None: 83 | """ 84 | Create a `BoundingBox` from the top-left and bottom-right corner coordinates. 85 | 86 | Use `BoundingBox.create()` to instantiate a `BoundingBox` from other 87 | coordinate formats. 88 | """ 89 | assert xmin <= xmax, "`xmax` must be greater than `xmin`." 90 | assert ymin <= ymax, "`ymax` must be greater than `ymin`." 91 | 92 | if confidence is not None: 93 | assert ( 94 | 0.0 <= confidence <= 1.0 95 | ), f"Confidence ({confidence}) should be in [0, 1]." 96 | 97 | self.label = label 98 | self._xmin = xmin 99 | self._ymin = ymin 100 | self._xmax = xmax 101 | self._ymax = ymax 102 | self._confidence = confidence 103 | 104 | @property 105 | def confidence(self) -> Optional[float]: 106 | """ 107 | The bounding box optional confidence score. If present, it is a `float` 108 | between 0 and 1. 109 | """ 110 | return self._confidence 111 | 112 | @confidence.setter 113 | def confidence(self, confidence: Optional[float]): 114 | if confidence is not None: 115 | assert ( 116 | 0.0 <= confidence <= 1.0 117 | ), f"Confidence ({confidence}) should be in [0, 1]." 118 | self._confidence = confidence 119 | 120 | @property 121 | def xmin(self) -> float: 122 | """The `x` value in pixels of the bounding box top-left corner (horizontal axis).""" 123 | return self._xmin 124 | 125 | @property 126 | def ymin(self) -> float: 127 | """The `y` value in pixels of the bounding box top-left corner (vertical axis).""" 128 | return self._ymin 129 | 130 | @property 131 | def xmax(self) -> float: 132 | """The `x` value in pixels of the bounding box bottom-right corner (horizontal axis).""" 133 | return self._xmax 134 | 135 | @property 136 | def ymax(self) -> float: 137 | """The `y` value in pixels of the bounding box top-left corner (vertical axis).""" 138 | return self._ymax 139 | 140 | @property 141 | def xmid(self) -> float: 142 | """The `x` value in pixels of the bounding box center point (horizontal axis).""" 143 | return (self._xmin + self._xmax) / 2.0 144 | 145 | @property 146 | def ymid(self) -> float: 147 | """The `y` value in pixels of the bounding box center point (vertical axis).""" 148 | return (self._ymin + self._ymax) / 2.0 149 | 150 | @property 151 | def width(self) -> float: 152 | """The bounding box width in pixels.""" 153 | return self._xmax - self._xmin 154 | 155 | @property 156 | def height(self) -> float: 157 | """The bounding box height in pixels.""" 158 | return self._ymax - self._ymin 159 | 160 | @property 161 | def area(self) -> float: 162 | """The area of the bounding box in pixels.""" 163 | return self.width * self.height 164 | 165 | def _area_in(self, range_: "tuple[float, float]") -> bool: 166 | """Returns `True` if the bounding box area is in a given range.""" 167 | lower_bound, upper_bound = range_ 168 | return lower_bound <= self.area <= upper_bound 169 | 170 | @property 171 | def pascal_area(self) -> int: 172 | """The bounding box PascalVOC area in pixels.""" 173 | width = int(self._xmax) - int(self._xmin) + 1 174 | height = int(self._ymax) - int(self._ymin) + 1 175 | 176 | return width * height 177 | 178 | def iou(self, other: "BoundingBox") -> float: 179 | """The Intersection over Union (IoU) between two bounding boxes.""" 180 | xmin = max(self._xmin, other._xmin) 181 | ymin = max(self._ymin, other._ymin) 182 | xmax = min(self._xmax, other._xmax) 183 | ymax = min(self._ymax, other._ymax) 184 | 185 | if xmax < xmin or ymax < ymin: 186 | return 0.0 187 | 188 | intersection = (xmax - xmin) * (ymax - ymin) 189 | union = self.area + other.area - intersection 190 | 191 | if union == 0.0: 192 | return 1.0 193 | 194 | return intersection / union 195 | 196 | def pascal_iou(self, other: "BoundingBox") -> float: 197 | """The Pascal VOC Intersection over Union (IoU) between two bounding boxes.""" 198 | xmin = max(int(self._xmin), int(other._xmin)) 199 | ymin = max(int(self._ymin), int(other._ymin)) 200 | xmax = min(int(self._xmax), int(other._xmax)) 201 | ymax = min(int(self._ymax), int(other._ymax)) 202 | 203 | if xmax < xmin or ymax < ymin: 204 | return 0.0 205 | 206 | intersection = (xmax - xmin + 1) * (ymax - ymin + 1) 207 | union = self.pascal_area + other.pascal_area - intersection 208 | 209 | if union == 0: 210 | return 1.0 211 | 212 | return intersection / union 213 | 214 | @property 215 | def is_detection(self) -> bool: 216 | """Return `True` if the bounding box confidence score is not `None`.""" 217 | return self._confidence is not None 218 | 219 | @property 220 | def is_ground_truth(self) -> bool: 221 | """Return `True` if the bounding box confidence score is `None`.""" 222 | return self._confidence is None 223 | 224 | @staticmethod 225 | def rel_to_abs(coords: Coordinates, size: "tuple[int, int]") -> Coordinates: 226 | """ 227 | Convert coordinates from relative (between 0 and 1) to absolute (pixels) form. 228 | 229 | The coordinates at indices 0 and 2 must be on the X-axis (horizontal) and the ones 230 | at indices 1 and 3 must be on the Y-axis (vertical). 231 | 232 | The image size should be a `(width, height)` tuple expressed in pixels. 233 | """ 234 | a, b, c, d = coords 235 | w, h = size 236 | return a * w, b * h, c * w, d * h 237 | 238 | @staticmethod 239 | def abs_to_rel(coords: Coordinates, size: "tuple[int, int]") -> Coordinates: 240 | """ 241 | Convert coordinates from absolute (pixels) to relative (between 0 and 1) form. 242 | 243 | The coordinates at indices 0 and 2 must be on the X-axis (horizontal) and the ones 244 | at indices 1 and 3 must be on the Y-axis (vertical). 245 | 246 | The image size should be a `(width, height)` tuple expressed in pixels. 247 | """ 248 | a, b, c, d = coords 249 | w, h = size 250 | return a / w, b / h, c / w, d / h 251 | 252 | @staticmethod 253 | def ltwh_to_ltrb(coords: Coordinates) -> Coordinates: 254 | """ 255 | Convert coordinates from `BoxFormat.LTWH` to `BoxFormat.LTRB` format. 256 | 257 | The coordinates can be either in relative (between 0 and 1) or absolute (pixels) form. 258 | """ 259 | xmin, ymin, width, height = coords 260 | return xmin, ymin, xmin + width, ymin + height 261 | 262 | @staticmethod 263 | def xywh_to_ltrb(coords: Coordinates) -> Coordinates: 264 | """ 265 | Convert coordinates from `BoxFormat.XYWH` to `BoxFormat.LTRB` format. 266 | 267 | The coordinates can be either in relative (between 0 and 1) or absolute (pixels) form. 268 | """ 269 | xmid, ymid, width, height = coords 270 | w_h, h_h = width / 2, height / 2 271 | return xmid - w_h, ymid - h_h, xmid + w_h, ymid + h_h 272 | 273 | @property 274 | def ltrb(self) -> Coordinates: 275 | """The bounding box coordinates in `BoxFormat.LTRB` format and absolute form (pixels).""" 276 | return self._xmin, self._ymin, self._xmax, self._ymax 277 | 278 | @property 279 | def ltwh(self) -> Coordinates: 280 | """The bounding box coordinates in `BoxFormat.LTWH` format and absolute form (pixels).""" 281 | return self._xmin, self._ymin, self.width, self.height 282 | 283 | @property 284 | def xywh(self) -> Coordinates: 285 | """The bounding box coordinates in `BoxFormat.XYWH` format and absolute form (pixels).""" 286 | return self.xmid, self.ymid, self.width, self.height 287 | 288 | @classmethod 289 | def create( 290 | cls, 291 | *, 292 | label: str, 293 | coords: Coordinates, 294 | confidence: Optional[float] = None, 295 | box_format=BoxFormat.LTRB, 296 | relative=False, 297 | image_size: Optional["tuple[int, int]"] = None, 298 | ) -> "BoundingBox": 299 | """ 300 | Create a `BoundingBox` from different coordinate formats. 301 | 302 | The image size should be provided if the coordinates are given in the relative form 303 | (values between 0 and 1). 304 | """ 305 | if relative: 306 | assert ( 307 | image_size is not None 308 | ), "For relative coordinates `image_size` should be provided." 309 | coords = cls.rel_to_abs(coords, image_size) 310 | 311 | if box_format is BoxFormat.LTWH: 312 | coords = cls.ltwh_to_ltrb(coords) 313 | elif box_format is BoxFormat.XYWH: 314 | coords = cls.xywh_to_ltrb(coords) 315 | elif box_format is BoxFormat.LTRB: 316 | pass 317 | else: 318 | raise ValueError(f"Unknown BoxFormat '{box_format}'.") 319 | 320 | xmin, ymin, xmax, ymax = coords 321 | 322 | return cls( 323 | label=label, 324 | xmin=xmin, 325 | ymin=ymin, 326 | xmax=xmax, 327 | ymax=ymax, 328 | confidence=confidence, 329 | ) 330 | 331 | @staticmethod 332 | def from_txt( 333 | string: str, 334 | *, 335 | box_format=BoxFormat.LTRB, 336 | relative=False, 337 | image_size: Optional["tuple[int, int]"] = None, 338 | separator: Optional[str] = None, 339 | conf_last: bool = False, 340 | ) -> "BoundingBox": 341 | values = string.strip().split(separator) 342 | 343 | if len(values) == 5: 344 | label, *coords = values 345 | confidence = None 346 | elif len(values) == 6: 347 | if conf_last: 348 | label, *coords, confidence = values 349 | else: 350 | label, confidence, *coords = values 351 | else: 352 | raise ParsingError("Syntax error in txt annotation file.") 353 | 354 | try: 355 | coords = tuple(float(c) for c in coords) 356 | if confidence is not None: 357 | confidence = float(confidence) 358 | except ValueError: 359 | raise ParsingError("Syntax error in txt annotation file.") 360 | 361 | return BoundingBox.create( 362 | label=label, 363 | coords=coords, 364 | confidence=confidence, 365 | box_format=box_format, 366 | relative=relative, 367 | image_size=image_size, 368 | ) 369 | 370 | @staticmethod 371 | def from_yolo( 372 | string: str, *, image_size: "tuple[int, int]", conf_last: bool = False 373 | ) -> "BoundingBox": 374 | return BoundingBox.from_txt( 375 | string, 376 | box_format=BoxFormat.XYWH, 377 | relative=True, 378 | image_size=image_size, 379 | separator=None, 380 | conf_last=conf_last, 381 | ) 382 | 383 | @staticmethod 384 | def from_yolo_darknet( 385 | string: str, 386 | *, 387 | image_size: "tuple[int, int]", 388 | ) -> "BoundingBox": 389 | return BoundingBox.from_yolo(string, image_size=image_size, conf_last=False) 390 | 391 | @staticmethod 392 | def from_yolo_v5( 393 | string: str, 394 | *, 395 | image_size: "tuple[int, int]", 396 | ) -> "BoundingBox": 397 | return BoundingBox.from_yolo(string, image_size=image_size, conf_last=True) 398 | 399 | @staticmethod 400 | def from_yolo_v7( 401 | string: str, 402 | *, 403 | image_size: "tuple[int, int]", 404 | ) -> "BoundingBox": 405 | return BoundingBox.from_yolo_v5(string, image_size=image_size) 406 | 407 | @staticmethod 408 | def from_xml(node: et.Element) -> "BoundingBox": 409 | label = node.findtext("name") 410 | box_node = node.find("bndbox") 411 | 412 | if label is None or box_node is None: 413 | raise ValueError("Syntax error in imagenet annotation format") 414 | 415 | l, t, r, b = ( 416 | box_node.findtext("xmin"), 417 | box_node.findtext("ymin"), 418 | box_node.findtext("xmax"), 419 | box_node.findtext("ymax"), 420 | ) 421 | 422 | if (l is None) or (t is None) or (r is None) or (b is None): 423 | raise ValueError("Syntax error in imagenet annotation format") 424 | 425 | try: 426 | coords = tuple(float(c) for c in (l, t, r, b)) 427 | except ValueError: 428 | raise ParsingError("Syntax error in imagenet annotation format") 429 | 430 | return BoundingBox.create(label=label, coords=tuple(coords)) 431 | 432 | @staticmethod 433 | def from_labelme(node: dict) -> "BoundingBox": 434 | try: 435 | label = str(node["label"]) 436 | xs, ys = zip(*node["points"]) 437 | xmin, ymin = min(xs), min(ys) 438 | xmax, ymax = max(xs), max(ys) 439 | coords = tuple(float(c) for c in (xmin, ymin, xmax, ymax)) 440 | except (ValueError, KeyError): 441 | raise ParsingError("Syntax error in labelme annotation file.") 442 | 443 | return BoundingBox.create(label=label, coords=coords) 444 | 445 | @staticmethod 446 | def from_cvat(node: et.Element) -> "BoundingBox": 447 | label = node.get("label") 448 | l, t, r, b = node.get("xtl"), node.get("ytl"), node.get("xbr"), node.get("ybr") 449 | 450 | if (label is None) or (l is None) or (t is None) or (r is None) or (b is None): 451 | raise ParsingError("Syntax error in CVAT annotation file.") 452 | 453 | try: 454 | coords = tuple(float(c) for c in (l, t, r, b)) 455 | except ValueError: 456 | raise ParsingError("Syntax error in CVAT annotation file.") 457 | 458 | return BoundingBox.create(label=label, coords=coords) 459 | 460 | @staticmethod 461 | def from_via_json( 462 | region: dict, *, label_key: str = "label_id", confidence_key: str = "confidence" 463 | ) -> "BoundingBox": 464 | try: 465 | region_attrs = region["region_attributes"] 466 | label = region_attrs[label_key] 467 | confidence = region_attrs.get(confidence_key) 468 | 469 | shape_attrs = region["shape_attributes"] 470 | xmin, ymin = shape_attrs["x"], shape_attrs["y"] 471 | width, height = shape_attrs["width"], shape_attrs["height"] 472 | except KeyError: 473 | raise ParsingError("Syntax error in VIA JSON annotation file.") 474 | 475 | return BoundingBox.create( 476 | label=label, 477 | coords=(xmin, ymin, width, height), 478 | confidence=confidence, 479 | box_format=BoxFormat.LTWH, 480 | ) 481 | 482 | @staticmethod 483 | def from_yolo_seg( 484 | string: str, 485 | *, 486 | image_size: "tuple[int, int]", 487 | ) -> "BoundingBox": 488 | values = string.strip().split() 489 | 490 | if len(values) < 7: 491 | raise ParsingError( 492 | "Syntax error in yolo_seg annotation file. There should be at least 7 values." 493 | ) 494 | elif len(values) % 2 != 1: 495 | raise ParsingError( 496 | "Syntax error in yolo_seg annotation file. There should be an odd number of values." 497 | ) 498 | else: 499 | label = str(values[0]) 500 | coords_x = tuple(float(value) for value in values[1::2]) 501 | coords_y = tuple(float(value) for value in values[2::2]) 502 | coords = min(coords_x), min(coords_y), max(coords_x), max(coords_y) 503 | 504 | return BoundingBox.create( 505 | label=label, 506 | coords=coords, 507 | box_format=BoxFormat.LTRB, 508 | relative=True, 509 | image_size=image_size, 510 | ) 511 | 512 | def to_txt( 513 | self, 514 | *, 515 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 516 | box_format: BoxFormat = BoxFormat.LTRB, 517 | relative=False, 518 | image_size: Optional["tuple[int, int]"] = None, 519 | separator: str = " ", 520 | conf_last: bool = False, 521 | ) -> str: 522 | assert ( 523 | "\n" not in separator 524 | ), "The newline character '\\n' cannot be used as the separator character." 525 | 526 | if box_format is BoxFormat.LTRB: 527 | coords = self.ltrb 528 | elif box_format is BoxFormat.XYWH: 529 | coords = self.xywh 530 | elif box_format is BoxFormat.LTWH: 531 | coords = self.ltwh 532 | else: 533 | raise ValueError(f"Unknown BoxFormat '{box_format}'") 534 | 535 | if relative: 536 | assert ( 537 | image_size is not None 538 | ), "For relative coordinates, `image_size` should be provided." 539 | coords = BoundingBox.abs_to_rel(coords, image_size) 540 | 541 | label = self.label 542 | if label_to_id is not None: 543 | label = label_to_id[label] 544 | 545 | if isinstance(label, str): 546 | assert separator not in label, ( 547 | f"The box label '{label}' contains the character '{separator}' which is the same " 548 | "as the separtor character used for BoundingBox representation in TXT/YOLO format. " 549 | "This will corrupt the saved annotation file and likely make it unreadable. " 550 | "Use another character in the label name or `label_to_id` mapping, e.g. use and " 551 | "underscore instead of a whitespace." 552 | ) 553 | 554 | if self.is_ground_truth: 555 | line = (label, *coords) 556 | elif conf_last: 557 | line = (label, *coords, self._confidence) 558 | else: 559 | line = (label, self._confidence, *coords) 560 | 561 | return separator.join(f"{v}" for v in line) 562 | 563 | def to_yolo( 564 | self, 565 | *, 566 | image_size: "tuple[int, int]", 567 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 568 | conf_last: bool = False, 569 | ) -> str: 570 | return self.to_txt( 571 | label_to_id=label_to_id, 572 | box_format=BoxFormat.XYWH, 573 | relative=True, 574 | image_size=image_size, 575 | separator=" ", 576 | conf_last=conf_last, 577 | ) 578 | 579 | def to_yolo_darknet( 580 | self, 581 | *, 582 | image_size: "tuple[int, int]", 583 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 584 | ) -> str: 585 | return self.to_yolo( 586 | image_size=image_size, label_to_id=label_to_id, conf_last=False 587 | ) 588 | 589 | def to_yolo_v5( 590 | self, 591 | *, 592 | image_size: "tuple[int, int]", 593 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 594 | ) -> str: 595 | return self.to_yolo( 596 | image_size=image_size, label_to_id=label_to_id, conf_last=True 597 | ) 598 | 599 | def to_yolo_v7( 600 | self, 601 | *, 602 | image_size: "tuple[int, int]", 603 | label_to_id: Optional[Mapping[str, Union[int, str]]] = None, 604 | ) -> str: 605 | return self.to_yolo_v5(image_size=image_size, label_to_id=label_to_id) 606 | 607 | def to_labelme(self) -> dict: 608 | xmin, ymin, xmax, ymax = self.ltrb 609 | 610 | return { 611 | "label": self.label, 612 | "points": [[xmin, ymin], [xmax, ymax]], 613 | "shape_type": "rectangle", 614 | } 615 | 616 | def to_xml(self) -> et.Element: 617 | obj_node = et.Element("object") 618 | et.SubElement(obj_node, "name").text = self.label 619 | box_node = et.SubElement(obj_node, "bndbox") 620 | 621 | for tag, coord in zip(("xmin", "ymin", "xmax", "ymax"), self.ltrb): 622 | et.SubElement(box_node, tag).text = f"{coord}" 623 | 624 | return obj_node 625 | 626 | def to_cvat(self) -> et.Element: 627 | xtl, ytl, xbr, ybr = self.ltrb 628 | return et.Element( 629 | "box", 630 | attrib={ 631 | "label": self.label, 632 | "xtl": f"{xtl}", 633 | "ytl": f"{ytl}", 634 | "xbr": f"{xbr}", 635 | "ybr": f"{ybr}", 636 | }, 637 | ) 638 | 639 | def to_via_json( 640 | self, *, label_key: str = "label_id", confidence_key: str = "confidence" 641 | ) -> dict: 642 | assert ( 643 | label_key != confidence_key 644 | ), f"Label key '{label_key}' and confidence key '{confidence_key}' should be different." 645 | 646 | shape_attributes = { 647 | "name": "rect", 648 | "x": self.xmin, 649 | "y": self.ymin, 650 | "width": self.width, 651 | "height": self.height, 652 | } 653 | 654 | region_attributes: dict[str, Any] = {label_key: self.label} 655 | 656 | if self.confidence is not None: 657 | region_attributes[confidence_key] = self.confidence 658 | 659 | return { 660 | "shape_attributes": shape_attributes, 661 | "region_attributes": region_attributes, 662 | } 663 | 664 | def __eq__(self, other): 665 | if not isinstance(other, BoundingBox): 666 | raise NotImplementedError 667 | 668 | return ( 669 | self.label == other.label 670 | and self.xmin == other.xmin 671 | and self.ymin == other.ymin 672 | and self.xmax == other.xmax 673 | and self.ymax == other.ymax 674 | and self.confidence == other.confidence 675 | ) 676 | 677 | def __repr__(self) -> str: 678 | return ( 679 | f"BoundingBox(label: {self.label}, xmin: {self._xmin}, ymin: {self._ymin}, " 680 | f"xmax: {self._xmax}, ymax: {self._ymax}, confidence: {self._confidence})" 681 | ) 682 | -------------------------------------------------------------------------------- /src/globox/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from .annotationset import AnnotationSet 7 | from .boundingbox import BoxFormat 8 | from .evaluation import COCOEvaluator 9 | 10 | PARSE_CHOICES = { 11 | "coco", 12 | "labelme", 13 | "pascalvoc", 14 | "openimage", 15 | "txt", 16 | "cvat", 17 | "yolov5", 18 | "yolov7", 19 | "yolo-darknet", 20 | "via-json", 21 | "imagenet", 22 | } 23 | PARSE_CHOICES_EXT = {*PARSE_CHOICES, "coco_result"} 24 | SAVE_CHOICES = {*PARSE_CHOICES} 25 | 26 | 27 | def parse_args(): 28 | parser = argparse.ArgumentParser() 29 | 30 | parser.add_argument("--quiet", "-q", action="store_true") 31 | parser.add_argument("--threads", "-j", default=None, type=int) 32 | 33 | subparsers = parser.add_subparsers(dest="mode") 34 | convert_parser = subparsers.add_parser("convert") 35 | stats_parser = subparsers.add_parser("summary") 36 | eval_parser = subparsers.add_parser("evaluate") 37 | 38 | add_convert_args(convert_parser) 39 | add_stats_args(stats_parser) 40 | add_eval_args(eval_parser) 41 | 42 | return parser.parse_args() 43 | 44 | 45 | def add_parse_args( 46 | parser: argparse.ArgumentParser, 47 | metavar: str = "input", 48 | label: Optional[str] = None, 49 | ): 50 | parser.add_argument("input", type=Path, metavar=metavar) 51 | 52 | group = parser.add_argument_group("Parse options" if label is None else label) 53 | group.add_argument( 54 | "--format", "-f", type=str, choices=PARSE_CHOICES, dest="format_in" 55 | ) 56 | group.add_argument("--img_folder", "-d", type=Path, default=None) 57 | group.add_argument("--mapping", "-m", type=Path, default=None, dest="mapping_in") 58 | group.add_argument( 59 | "--bb_fmt", 60 | "-b", 61 | type=str, 62 | choices=("ltrb", "ltwh", "xywh"), 63 | default="ltrb", 64 | dest="bb_fmt_in", 65 | ) 66 | group.add_argument( 67 | "--norm", "-n", type=str, choices=("abs", "rel"), default="abs", dest="norm_in" 68 | ) 69 | group.add_argument("--ext", "-e", type=str, default=".txt", dest="ext_in") 70 | group.add_argument("--img_ext", "-g", type=str, default=".jpg", dest="img_ext_in") 71 | group.add_argument("--sep", "-p", type=str, default=" ", dest="sep_in") 72 | 73 | 74 | def add_parse_dets_args(parser: argparse.ArgumentParser): 75 | parser.add_argument("predictions", type=Path) 76 | 77 | group = parser.add_argument_group("Predictions parse options") 78 | group.add_argument("--format_dets", "-F", type=str, choices=PARSE_CHOICES_EXT) 79 | group.add_argument("--mapping_dets", "-M", type=Path, default=None) 80 | group.add_argument( 81 | "--bb_fmt_dets", 82 | "-B", 83 | type=str, 84 | choices=("ltrb", "ltwh", "xywh"), 85 | default="ltrb", 86 | dest="bb_fmt_dets", 87 | ) 88 | group.add_argument( 89 | "--norm_dets", 90 | "-N", 91 | type=str, 92 | choices=("abs", "rel"), 93 | default="abs", 94 | dest="norm_in_dets", 95 | ) 96 | group.add_argument("--ext_dets", "-E", type=str, default=".txt") 97 | group.add_argument("--img_ext_dets", "-G", type=str, default=".jpg") 98 | group.add_argument("--sep_dets", "-P", type=str, default=" ") 99 | 100 | 101 | def add_save_args(parser: argparse.ArgumentParser): 102 | parser.add_argument("output", type=Path) 103 | 104 | group = parser.add_argument_group("Save options") 105 | group.add_argument( 106 | "--save_fmt", "-F", type=str, choices=SAVE_CHOICES, dest="format_out" 107 | ) # TODO: add PARSE_CHOICES_EXT 108 | group.add_argument( 109 | "--bb_fmt_out", "-B", type=str, choices=("ltrb", "ltwh", "xywh"), default="ltrb" 110 | ) 111 | group.add_argument( 112 | "--norm_out", "-N", type=str, choices=("abs", "rel"), default="abs" 113 | ) 114 | group.add_argument("--sep_out", "-P", type=str, default=" ") 115 | group.add_argument("--ext_out", "-E", type=str, default=".txt") 116 | group.add_argument("--coco_auto_ids", "-A", action="store_true") 117 | 118 | mapping_group = group.add_mutually_exclusive_group() 119 | mapping_group.add_argument("--mapping_out", "-M", type=Path, default=None) 120 | mapping_group.add_argument("--reverse_mapping_out", "-R", type=Path, default=None) 121 | 122 | 123 | def add_stats_args(parser: argparse.ArgumentParser): 124 | add_parse_args(parser) 125 | 126 | 127 | def add_convert_args(parser: argparse.ArgumentParser): 128 | add_parse_args(parser) 129 | add_save_args(parser) 130 | 131 | 132 | def add_eval_args(parser: argparse.ArgumentParser): 133 | add_parse_args(parser, metavar="groundtruths", label="Ground-truths parse options") 134 | add_parse_dets_args(parser) 135 | 136 | parser.add_argument("--save", "-s", type=Path, default=None, dest="save_csv_path") 137 | 138 | # parser.add_argument("--ap", action="append", dest="metrics") 139 | # parser.add_argument("--ap50", action="append", dest="metrics") 140 | # parser.add_argument("--iou", type=int, default=None) # mutually_exclusive_group() 141 | # etc... 142 | 143 | 144 | def parse_annotations(args: argparse.Namespace) -> AnnotationSet: 145 | input: Path = args.input.expanduser().resolve() 146 | format_in: str = args.format_in 147 | verbose: bool = not args.quiet 148 | 149 | if format_in == "coco": 150 | return AnnotationSet.from_coco(input, verbose=verbose) 151 | elif format_in == "pascalvoc" or format_in == "imagenet": 152 | return AnnotationSet.from_xml(input, verbose=verbose) 153 | elif format_in == "openimage": 154 | assert ( 155 | args.img_folder is not None 156 | ), "The image directory must be provided for openimage format (required for reading the image size)." 157 | img_dir: Path = args.img_folder.expanduser().resolve() 158 | return AnnotationSet.from_openimage( 159 | input, image_folder=img_dir, verbose=verbose 160 | ) 161 | elif format_in == "labelme": 162 | return AnnotationSet.from_labelme(input, verbose=verbose) 163 | elif format_in == "cvat": 164 | return AnnotationSet.from_cvat(input, verbose=verbose) 165 | elif format_in == "via-json": 166 | img_dir: Optional[Path] = args.img_folder 167 | return AnnotationSet.from_via_json(input, image_folder=img_dir) 168 | else: 169 | img_ext: str = args.img_ext_in 170 | image_dir: Optional[Path] = None 171 | 172 | if args.img_folder is not None: 173 | image_dir = args.img_folder.expanduser().resolve() 174 | 175 | if format_in == "yolo-darknet": 176 | annotations = AnnotationSet.from_yolo_darknet( 177 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 178 | ) 179 | elif format_in == "yolov5": 180 | annotations = AnnotationSet.from_yolo_v5( 181 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 182 | ) 183 | elif format_in == "yolov7": 184 | annotations = AnnotationSet.from_yolo_v7( 185 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 186 | ) 187 | elif format_in == "txt": 188 | format = BoxFormat.from_string(args.bb_fmt_in) 189 | relative: bool = args.norm_in == "rel" 190 | extension: str = args.ext_in 191 | sep: str = args.sep_in 192 | 193 | annotations = AnnotationSet.from_txt( 194 | input, 195 | image_folder=image_dir, 196 | box_format=format, 197 | relative=relative, 198 | file_extension=extension, 199 | image_extension=img_ext, 200 | separator=sep, 201 | verbose=verbose, 202 | ) 203 | else: 204 | raise ValueError(f"Input format '{format_in}' unknown") 205 | 206 | if args.mapping_in is not None: 207 | map_path: Path = args.mapping_in.expanduser().resolve() 208 | mapping = AnnotationSet.parse_names_file(map_path) 209 | annotations.map_labels(mapping) 210 | 211 | return annotations 212 | 213 | 214 | def parse_dets_annotations( 215 | args: argparse.Namespace, 216 | coco_gts: Optional[AnnotationSet] = None, 217 | ) -> AnnotationSet: 218 | input: Path = args.predictions.expanduser().resolve() 219 | format_dets: str = args.format_dets 220 | verbose: bool = not args.quiet 221 | 222 | if format_dets == "coco": 223 | return AnnotationSet.from_coco(input, verbose=verbose) 224 | if format_dets == "coco_result": 225 | if coco_gts is None: 226 | raise ValueError( 227 | "When using 'COCO results', the parsed ground truths must be in 'COCO' format." 228 | ) 229 | return coco_gts.from_results(input, verbose=verbose) 230 | elif format_dets == "pascalvoc" or format_dets == "imagenet": 231 | return AnnotationSet.from_xml(input, verbose=verbose) 232 | elif format_dets == "openimage": 233 | assert ( 234 | args.img_folder is not None 235 | ), "The image directory must be provided for openimage format (required for reading the image size)." 236 | img_dir: Path = args.img_folder.expanduser().resolve() 237 | return AnnotationSet.from_openimage( 238 | input, image_folder=img_dir, verbose=verbose 239 | ) 240 | elif format_dets == "labelme": 241 | return AnnotationSet.from_labelme(input, verbose=verbose) 242 | elif format_dets == "cvat": 243 | return AnnotationSet.from_cvat(input, verbose=verbose) 244 | elif format_dets == "via-json": 245 | img_dir: Optional[Path] = args.img_folder 246 | return AnnotationSet.from_via_json(input, image_folder=img_dir) 247 | else: 248 | img_ext: str = args.img_ext_dets 249 | image_dir: Optional[Path] = None 250 | 251 | if args.img_folder is not None: 252 | image_dir = args.img_folder.expanduser().resolve() 253 | 254 | if format_dets == "yolo-darknet": 255 | annotations = AnnotationSet.from_yolo_darknet( 256 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 257 | ) 258 | elif format_dets == "yolov5": 259 | annotations = AnnotationSet.from_yolo_v5( 260 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 261 | ) 262 | elif format_dets == "yolov7": 263 | annotations = AnnotationSet.from_yolo_v7( 264 | input, image_folder=image_dir, image_extension=img_ext, verbose=verbose 265 | ) 266 | elif format_dets == "txt": 267 | bb_fmt = BoxFormat.from_string(args.bb_fmt_dets) 268 | relative: bool = args.norm_in_dets == "rel" 269 | extension: str = args.ext_dets 270 | sep: str = args.sep_dets 271 | 272 | annotations = AnnotationSet.from_txt( 273 | input, 274 | image_folder=image_dir, 275 | box_format=bb_fmt, 276 | relative=relative, 277 | file_extension=extension, 278 | image_extension=img_ext, 279 | separator=sep, 280 | verbose=verbose, 281 | ) 282 | else: 283 | raise ValueError(f"Groundtruth format '{format_dets}' unknown") 284 | 285 | if args.mapping_dets is not None: 286 | map_path: Path = args.mapping_dets.expanduser().resolve() 287 | mapping = AnnotationSet.parse_names_file(map_path) 288 | annotations.map_labels(mapping) 289 | 290 | return annotations 291 | 292 | 293 | # TODO: Add "coco_result" 294 | def save_annotations(args: argparse.Namespace, annotations: AnnotationSet): 295 | output: Path = args.output.expanduser().resolve() 296 | format_out: str = args.format_out 297 | verbose: bool = not args.quiet 298 | 299 | if args.mapping_out is not None: # Takes precedence 300 | map_path: Path = args.mapping_out.expanduser().resolve() 301 | mapping = AnnotationSet.parse_names_file(map_path) 302 | annotations.map_labels(mapping) 303 | elif args.reverse_mapping_out is not None: 304 | map_path: Path = args.reverse_mapping_out.expanduser().resolve() 305 | mapping = AnnotationSet.parse_names_file(map_path) 306 | mapping = {v: k for k, v in mapping.items()} 307 | annotations.map_labels(mapping) 308 | 309 | if format_out == "coco": 310 | annotations.save_coco(output, auto_ids=args.coco_auto_ids, verbose=verbose) 311 | elif format_out == "pascalvoc": 312 | annotations.save_xml(output, verbose=verbose) 313 | elif format_out == "openimage": 314 | annotations.save_openimage(output, verbose=verbose) 315 | elif format_out == "labelme": 316 | annotations.save_labelme(output, verbose=verbose) 317 | elif format_out == "cvat": 318 | annotations.save_cvat(output, verbose=verbose) 319 | elif format_out == "via-json": 320 | image_folder: Optional[Path] = args.img_folder 321 | assert ( 322 | image_folder is not None 323 | ), "The image folder must be provided with `--img_folder` for via-json conversion." 324 | annotations.save_via_json(output, image_folder=image_folder, verbose=verbose) 325 | else: 326 | if format_out == "yolo-darknet": 327 | annotations.save_yolo_darknet(output, verbose=verbose) 328 | elif format_out == "yolov5": 329 | annotations.save_yolo_v5(output, verbose=verbose) 330 | elif format_out == "yolov7": 331 | annotations.save_yolo_v7(output, verbose=verbose) 332 | elif format_out == "txt": 333 | bb_fmt = BoxFormat.from_string(args.bb_fmt_out) 334 | relative: bool = args.norm_out == "rel" 335 | extension: str = args.ext_out 336 | sep: str = args.sep_out 337 | 338 | annotations.save_txt( 339 | output, 340 | label_to_id=None, 341 | box_format=bb_fmt, 342 | relative=relative, 343 | separator=sep, 344 | file_extension=extension, 345 | verbose=verbose, 346 | ) 347 | else: 348 | raise ValueError(f"Save format '{format_out}' unknown") 349 | 350 | 351 | def evaluate( 352 | args: argparse.Namespace, groundtruths: AnnotationSet, predictions: AnnotationSet 353 | ): 354 | verbose: bool = not args.quiet 355 | 356 | evaluator = COCOEvaluator(ground_truths=groundtruths, predictions=predictions) 357 | 358 | evaluator.show_summary(verbose=verbose) 359 | 360 | if args.save_csv_path is not None: 361 | path = args.save_csv_path.expanduser().resolve() 362 | evaluator.save_csv(path, verbose=False) 363 | if verbose: 364 | print(f"Evaluation saved to '{args.save_csv_path}'.", file=sys.stderr) 365 | 366 | 367 | def main(): 368 | args = parse_args() 369 | 370 | assert ( 371 | args.threads is None or args.threads > 0 372 | ), f"The number of threads '{args.threads}' should either be None or be greater than 0." 373 | 374 | mode = args.mode 375 | if mode == "convert": 376 | annotations = parse_annotations(args) 377 | save_annotations(args, annotations) 378 | elif mode == "summary": 379 | annotations = parse_annotations(args) 380 | annotations.show_stats() 381 | elif mode == "evaluate": 382 | groundtruths = parse_annotations(args) 383 | coco_gts = groundtruths if args.format_dets == "coco_result" else None 384 | predictions = parse_dets_annotations(args, coco_gts=coco_gts) 385 | evaluate(args, groundtruths, predictions) 386 | else: 387 | raise ValueError(f"Sub-command '{mode}' not recognized.") 388 | 389 | 390 | if __name__ == "__main__": 391 | main() 392 | -------------------------------------------------------------------------------- /src/globox/errors.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class UnknownImageFormat(Exception): 5 | pass 6 | 7 | 8 | class ParsingError(Exception): 9 | def __init__(self, reason: str) -> None: 10 | self.reason = reason 11 | 12 | def __str__(self) -> str: 13 | return self.reason 14 | 15 | 16 | class FileParsingError(ParsingError): 17 | def __init__(self, file: Path, reason: str) -> None: 18 | self.file = file 19 | self.reason = reason 20 | 21 | def __str__(self) -> str: 22 | return f"Error while reading file '{self.file}': {self.reason}" 23 | -------------------------------------------------------------------------------- /src/globox/evaluation.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from collections import defaultdict 3 | from copy import copy 4 | from enum import Enum, auto 5 | from functools import lru_cache 6 | from itertools import chain, product 7 | from math import isnan 8 | from typing import Any, DefaultDict, Dict, Iterable, Mapping, Optional, Sequence 9 | 10 | import numpy as np 11 | from tqdm import tqdm 12 | 13 | from .annotation import Annotation 14 | from .annotationset import AnnotationSet 15 | from .atomic import open_atomic 16 | from .boundingbox import BoundingBox 17 | from .file_utils import PathLike 18 | from .utils import all_equal, grouping, mean 19 | 20 | 21 | class RecallSteps(Enum): 22 | ELEVEN = auto() 23 | ALL = auto() 24 | 25 | 26 | class PartialEvaluationItem: 27 | def __init__( 28 | self, 29 | tps: Optional["list[bool]"] = None, 30 | scores: Optional["list[float]"] = None, 31 | npos: int = 0, 32 | ) -> None: 33 | self._tps = tps or [] 34 | self._scores = scores or [] 35 | self._npos = npos 36 | self._cache: dict[str, Any] = {} 37 | 38 | assert self._npos >= 0, f"'npos' ({npos}) should be greater than or equal to 0." 39 | assert ( 40 | len(self._tps) == len(self._scores) 41 | ), f"The number of 'tps' ({len(self._tps)}) should be equal to the number of 'scores' ({len(self._scores)})." 42 | 43 | def __iadd__(self, other: "PartialEvaluationItem") -> "PartialEvaluationItem": 44 | self._tps += other._tps 45 | self._scores += other._scores 46 | self._npos += other._npos 47 | self.clear_cache() 48 | return self 49 | 50 | def __add__(self, other: "PartialEvaluationItem") -> "PartialEvaluationItem": 51 | copy_ = copy(self) 52 | copy_ += other 53 | return copy_ 54 | 55 | @property 56 | def ndet(self) -> int: 57 | return len(self._tps) 58 | 59 | @property 60 | def npos(self) -> int: 61 | return self._npos 62 | 63 | def tp(self) -> int: 64 | tp = self._cache.get("tp", sum(self._tps)) 65 | self._cache["tp"] = tp 66 | assert tp <= self._npos 67 | return tp 68 | 69 | def ap(self) -> float: 70 | ap = self._cache.get("ap") 71 | if ap is not None: 72 | return ap 73 | ap = COCOEvaluator._compute_ap(self._scores, self._tps, self._npos) 74 | self._cache["ap"] = ap 75 | return ap 76 | 77 | def ar(self) -> Optional[float]: 78 | ar = self._cache.get("ar") 79 | if ar is not None: 80 | return ar 81 | tp = self.tp() 82 | ar = tp / self._npos if self._npos != 0 else float("nan") 83 | self._cache["ar"] = ar 84 | return ar 85 | 86 | def clear_cache(self): 87 | self._cache.clear() 88 | 89 | def evaluate(self) -> "EvaluationItem": 90 | return EvaluationItem(self.tp(), self.ndet, self._npos, self.ap(), self.ar()) 91 | 92 | 93 | class EvaluationItem: 94 | """Evaluation of COCO metrics for one label.""" 95 | 96 | __slots__ = ("tp", "ndet", "npos", "ap", "ar") 97 | 98 | def __init__( 99 | self, tp: int, ndet: int, npos: int, ap: Optional[float], ar: Optional[float] 100 | ) -> None: 101 | self.tp = tp 102 | self.ndet = ndet 103 | self.npos = npos 104 | self.ap = ap 105 | self.ar = ar 106 | 107 | 108 | class PartialEvaluation(DefaultDict[str, PartialEvaluationItem]): 109 | """Do not mutate this excepted with defined methods.""" 110 | 111 | def __init__(self, items: Optional[Mapping[str, PartialEvaluationItem]] = None): 112 | super().__init__(lambda: PartialEvaluationItem()) 113 | if items is not None: 114 | self.update(items) 115 | self._cache = {} 116 | 117 | def __iadd__(self, other: "PartialEvaluation") -> "PartialEvaluation": 118 | for key, value in other.items(): 119 | self[key] += value 120 | self.clear_cache() 121 | return self 122 | 123 | def __add__(self, other: "PartialEvaluation") -> "PartialEvaluation": 124 | copy_ = copy(self) 125 | copy_ += other 126 | return copy_ 127 | 128 | def ap(self) -> float: 129 | ap = self._cache.get("ap") 130 | if ap is not None: 131 | return ap 132 | ap = mean(a for a in (ev.ap() for ev in self.values()) if not isnan(a)) 133 | self._cache["ap"] = ap 134 | return ap 135 | 136 | def ar(self) -> Optional[float]: 137 | ar = self._cache.get("ar") 138 | if ar is not None: 139 | return ar 140 | ar = mean(a for a in (ev.ar() for ev in self.values()) if not isnan(a)) 141 | self._cache["ar"] = ar 142 | return ar 143 | 144 | def clear_cache(self): 145 | self._cache.clear() 146 | 147 | def evaluate(self) -> "Evaluation": 148 | return Evaluation(self) 149 | 150 | 151 | class Evaluation(Dict[str, EvaluationItem]): 152 | """Evaluation of COCO metrics for multiple labels.""" 153 | 154 | def __init__(self, evaluation: PartialEvaluation) -> None: 155 | super().__init__() 156 | self.update({label: ev.evaluate() for label, ev in evaluation.items()}) 157 | 158 | self._ap = evaluation.ap() 159 | self._ar = evaluation.ar() 160 | 161 | def ap(self) -> float: 162 | return self._ap 163 | 164 | def ar(self) -> float: 165 | return self._ar 166 | 167 | 168 | class MultiThresholdEvaluation(Dict[str, Dict[str, float]]): 169 | """ 170 | Evaluation of COCO metrics for multiple labels and multiple 171 | IoU thresholds. 172 | """ 173 | 174 | def __init__(self, evaluations: "list[Evaluation]") -> None: 175 | result: "defaultdict[str, list[EvaluationItem]]" = defaultdict(list) 176 | 177 | for evaluation in evaluations: 178 | for label, ev_item in evaluation.items(): 179 | result[label].append(ev_item) 180 | 181 | super().__init__( 182 | { 183 | label: { 184 | "ap": mean(ev.ap for ev in evs if not isnan(ev.ap)), 185 | "ar": mean(ev.ar for ev in evs if not isnan(ev.ar)), 186 | } 187 | for label, evs in result.items() 188 | } 189 | ) 190 | 191 | def ap(self) -> float: 192 | return mean(ev["ap"] for ev in self.values() if not isnan(ev["ap"])) 193 | 194 | def ar(self) -> float: 195 | return mean(ev["ar"] for ev in self.values() if not isnan(ev["ar"])) 196 | 197 | 198 | class COCOEvaluator: 199 | """ 200 | COCO metrics evaluator. 201 | 202 | Once instantiated, the standard COCO metrics can be queried using the dedicated methods: 203 | 204 | * `COCOEvaluator.ap()`, 205 | * `COCOEvaluator.ap_50()`, 206 | * `COCOEvaluator.ar_medium()`, 207 | * etc. 208 | 209 | Custom evaluations can be performed with `COCOEvaluator.evaluate()` and the standard 210 | COCO metrics can be printed to the console with `COCOEvaluator.show_summary()`. 211 | """ 212 | 213 | AP_THRESHOLDS = np.linspace(0.5, 0.95, 10) 214 | """The ten COCO AP thresholds: [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95].""" 215 | 216 | SMALL_RANGE = (0.0, 32.0**2) 217 | """The COCO 'small range' (0, 1024) of bounding box areas (in pixels).""" 218 | 219 | MEDIUM_RANGE = (32.0**2, 96.0**2) 220 | """The COCO 'medium range' (1024, 9216) of bounding box areas (in pixels).""" 221 | 222 | LARGE_RANGE = (96.0**2, float("inf")) 223 | """The COCO 'large range' (9216, inf) of bounding box areas (in pixels).""" 224 | 225 | ALL_RANGE = (0.0, float("inf")) 226 | """The range (0, inf) of any bounding box areas possible (in pixels).""" 227 | 228 | RECALL_STEPS = np.linspace(0.0, 1.0, 101) 229 | """The 101 COCO recall steps used to compute the AP score.""" 230 | 231 | CSV_HEADERS = ( 232 | "label", 233 | "AP 50:95", 234 | "AP 50", 235 | "AP 75", 236 | "AP S", 237 | "AP M", 238 | "AP L", 239 | "AR 1", 240 | "AR 10", 241 | "AR 100", 242 | "AR S", 243 | "AR M", 244 | "AR L", 245 | ) 246 | 247 | def __init__( 248 | self, 249 | *, 250 | ground_truths: AnnotationSet, 251 | predictions: AnnotationSet, 252 | labels: Optional[Iterable[str]] = None, 253 | ) -> None: 254 | """ 255 | Intantiate a `COCOEvaluator` with the to-be-evaluated dataset and the ground truth 256 | reference one. 257 | 258 | All the bounding box annotations from the prediction dataset must have a confidence score 259 | and all the ones from the ground truth one must not. 260 | 261 | If labels are provided, only those will be evaluated, else every label will. 262 | 263 | The evaluated datasets must not be modified during the lifetime of the `COCOEvaluator` or 264 | the results will be wrong. 265 | """ 266 | self._predictions = predictions 267 | self._ground_truths = ground_truths 268 | 269 | if labels is None: 270 | self.labels = [] 271 | else: 272 | self.labels = list(labels) 273 | 274 | self._cached_evaluate = lru_cache(maxsize=60 * 4)(self.__evaluate) 275 | 276 | def evaluate( 277 | self, 278 | *, 279 | iou_threshold: float, 280 | max_detections: int = 100, 281 | size_range: Optional["tuple[float, float]"] = None, 282 | ) -> Evaluation: 283 | """ 284 | Evaluate COCO metrics for custom parameters. 285 | 286 | Parameters: 287 | 288 | * `iou_threshold`: the bounding box iou threshold to consider a ground-truth to 289 | detection association valid. 290 | * `max_detections`: the maximum number of detections taken into account (sorted by 291 | descreasing confidence). 292 | * `size_range`: the range of size (bounding box area) to consider. 293 | """ 294 | return self._cached_evaluate( 295 | iou_threshold=iou_threshold, 296 | max_detections=max_detections, 297 | size_range=size_range, 298 | ) 299 | 300 | def __evaluate( 301 | self, 302 | *, 303 | iou_threshold: float, 304 | max_detections: int = 100, 305 | size_range: Optional["tuple[float, float]"] = None, 306 | ) -> Evaluation: 307 | if size_range is None: 308 | size_range = COCOEvaluator.ALL_RANGE 309 | 310 | self._assert_params(iou_threshold, max_detections, size_range) 311 | 312 | return self.evaluate_annotations( 313 | self._predictions, 314 | self._ground_truths, 315 | iou_threshold, 316 | max_detections, 317 | size_range, 318 | self.labels, 319 | ).evaluate() 320 | 321 | def ap(self) -> float: 322 | return self.ap_evaluation().ap() 323 | 324 | def ap_50(self) -> float: 325 | return self.ap_50_evaluation().ap() 326 | 327 | def ap_75(self) -> float: 328 | return self.ap_75_evaluation().ap() 329 | 330 | def ap_small(self) -> float: 331 | return self.small_evaluation().ap() 332 | 333 | def ap_medium(self) -> float: 334 | return self.medium_evaluation().ap() 335 | 336 | def ap_large(self) -> float: 337 | return self.large_evaluation().ap() 338 | 339 | def ar_1(self) -> float: 340 | return self.ndets_1_evaluation().ar() 341 | 342 | def ar_10(self) -> float: 343 | return self.ndets_10_evaluation().ar() 344 | 345 | def ar_100(self) -> float: 346 | return self.ndets_100_evaluation().ar() 347 | 348 | def ar_small(self) -> float: 349 | return self.small_evaluation().ar() 350 | 351 | def ar_medium(self) -> float: 352 | return self.medium_evaluation().ar() 353 | 354 | def ar_large(self) -> float: 355 | return self.large_evaluation().ar() 356 | 357 | def ap_evaluation(self) -> MultiThresholdEvaluation: 358 | evaluations = [ 359 | self.evaluate( 360 | iou_threshold=t, max_detections=100, size_range=self.ALL_RANGE 361 | ) 362 | for t in self.AP_THRESHOLDS 363 | ] 364 | 365 | return MultiThresholdEvaluation(evaluations) 366 | 367 | def ap_50_evaluation(self) -> Evaluation: 368 | return self.evaluate( 369 | iou_threshold=0.5, max_detections=100, size_range=self.ALL_RANGE 370 | ) 371 | 372 | def ap_75_evaluation(self) -> Evaluation: 373 | return self.evaluate( 374 | iou_threshold=0.75, max_detections=100, size_range=self.ALL_RANGE 375 | ) 376 | 377 | def small_evaluation(self) -> MultiThresholdEvaluation: 378 | return self._range_evalation(self.SMALL_RANGE) 379 | 380 | def medium_evaluation(self) -> MultiThresholdEvaluation: 381 | return self._range_evalation(self.MEDIUM_RANGE) 382 | 383 | def large_evaluation(self) -> MultiThresholdEvaluation: 384 | return self._range_evalation(self.LARGE_RANGE) 385 | 386 | def ndets_1_evaluation(self) -> MultiThresholdEvaluation: 387 | return self._ndets_evaluation(1) 388 | 389 | def ndets_10_evaluation(self) -> MultiThresholdEvaluation: 390 | return self._ndets_evaluation(10) 391 | 392 | def ndets_100_evaluation(self) -> MultiThresholdEvaluation: 393 | return self._ndets_evaluation(100) 394 | 395 | def _range_evalation( 396 | self, range_: "tuple[float, float]" 397 | ) -> MultiThresholdEvaluation: 398 | evaluations = [ 399 | self.evaluate(iou_threshold=t, max_detections=100, size_range=range_) 400 | for t in self.AP_THRESHOLDS 401 | ] 402 | 403 | return MultiThresholdEvaluation(evaluations) 404 | 405 | def _ndets_evaluation(self, max_dets: int) -> MultiThresholdEvaluation: 406 | evaluations = [ 407 | self.evaluate( 408 | iou_threshold=t, max_detections=max_dets, size_range=self.ALL_RANGE 409 | ) 410 | for t in self.AP_THRESHOLDS 411 | ] 412 | 413 | return MultiThresholdEvaluation(evaluations) 414 | 415 | @classmethod 416 | def evaluate_annotations( 417 | cls, 418 | predictions: AnnotationSet, 419 | ground_truths: AnnotationSet, 420 | iou_threshold: float, 421 | max_detections: int, 422 | size_range: "tuple[float, float]", 423 | labels: Optional[Sequence[str]] = None, 424 | ) -> PartialEvaluation: 425 | image_ids = ground_truths.image_ids | predictions.image_ids 426 | evaluation = PartialEvaluation() 427 | 428 | for image_id in sorted(image_ids): # Sorted to ensure reproductibility 429 | gt = ground_truths.get(image_id) or Annotation(image_id) 430 | pred = predictions.get(image_id) or Annotation(image_id) 431 | 432 | evaluation += cls.evaluate_annotation( 433 | pred, gt, iou_threshold, max_detections, size_range, labels 434 | ) 435 | 436 | return evaluation 437 | 438 | @classmethod 439 | def evaluate_annotation( 440 | cls, 441 | prediction: Annotation, 442 | ground_truth: Annotation, 443 | iou_threshold: float, 444 | max_detections: int, 445 | size_range: "tuple[float, float]", 446 | labels: Optional[Iterable[str]] = None, 447 | ) -> PartialEvaluation: 448 | assert ( 449 | prediction.image_id == ground_truth.image_id 450 | ), f"The prediction image id '{prediction.image_id}' should be the same as the ground truth image id '{ground_truth.image_id}'." 451 | 452 | preds = grouping(prediction.boxes, lambda box: box.label) 453 | refs = grouping(ground_truth.boxes, lambda box: box.label) 454 | labels = labels or set(preds.keys()).union(refs.keys()) 455 | evaluation = PartialEvaluation() 456 | 457 | for label in labels: 458 | dets = preds.get(label, []) 459 | gts = refs.get(label, []) 460 | 461 | evaluation[label] += cls.evaluate_boxes( 462 | dets, gts, iou_threshold, max_detections, size_range 463 | ) 464 | 465 | return evaluation 466 | 467 | @classmethod 468 | def evaluate_boxes( 469 | cls, 470 | predictions: "list[BoundingBox]", 471 | ground_truths: "list[BoundingBox]", 472 | iou_threshold: float, 473 | max_detections: int, 474 | size_range: "tuple[float, float]", 475 | ) -> PartialEvaluationItem: 476 | assert all( 477 | p.is_detection for p in predictions 478 | ), "Prediction annotations should not contain ground truth annotations." 479 | assert all( 480 | g.is_ground_truth for g in ground_truths 481 | ), "Ground truth annotations should not contain prediction annotations." 482 | assert all_equal( 483 | p.label for p in predictions 484 | ), "The prediction boxes should have the same label." 485 | assert all_equal( 486 | g.label for g in ground_truths 487 | ), "The ground truth boxes should have the same label." 488 | 489 | cls._assert_params(iou_threshold, max_detections, size_range) 490 | 491 | dets = sorted(predictions, key=lambda box: box._confidence, reverse=True) # type: ignore 492 | dets = dets[:max_detections] 493 | 494 | gts = sorted(ground_truths, key=lambda box: not box._area_in(size_range)) 495 | gt_ignore = [not g._area_in(size_range) for g in gts] # Redundant `area_in` 496 | 497 | gt_matches = {} 498 | dt_matches = {} 499 | 500 | for idx_dt, det in enumerate(dets): 501 | best_iou = 0.0 502 | idx_best = -1 503 | 504 | for idx_gt, gt in enumerate(gts): 505 | if idx_gt in gt_matches: 506 | continue 507 | if idx_best > -1 and not gt_ignore[idx_best] and gt_ignore[idx_gt]: 508 | break 509 | 510 | iou = det.iou(gt) 511 | if iou < best_iou: 512 | continue 513 | 514 | best_iou = iou 515 | idx_best = idx_gt 516 | 517 | if idx_best == -1 or best_iou < iou_threshold: 518 | continue 519 | 520 | dt_matches[idx_dt] = idx_best 521 | gt_matches[idx_best] = idx_dt 522 | 523 | # This is overly complicated 524 | dt_ignore = [ 525 | gt_ignore[dt_matches[i]] if i in dt_matches else not d._area_in(size_range) 526 | for i, d in enumerate(dets) 527 | ] 528 | 529 | scores = [d._confidence for i, d in enumerate(dets) if not dt_ignore[i]] 530 | matches = [i in dt_matches for i in range(len(dets)) if not dt_ignore[i]] 531 | npos = sum(1 for i in range(len(gts)) if not gt_ignore[i]) 532 | 533 | return PartialEvaluationItem(matches, scores, npos) 534 | 535 | @staticmethod 536 | def _assert_params( 537 | iou_threshold: float, max_detections: int, size_range: "tuple[float, float]" 538 | ): 539 | assert ( 540 | 0.0 <= iou_threshold <= 1.0 541 | ), f"the IoU threshold ({iou_threshold}) should be between 0 and 1." 542 | assert ( 543 | max_detections >= 0 544 | ), f"The maximum number of detections ({max_detections}) should be positive." 545 | 546 | low, high = size_range 547 | assert ( 548 | low >= 0.0 and high >= low 549 | ), f"The size range '({low}, {high})' should be positive and non-empty, i.e. 0 < size_range[0] <= size_range[1]." 550 | 551 | def clear_cache(self): 552 | self._cached_evaluate.cache_clear() 553 | 554 | def _evaluate_all(self, *, verbose: bool = False): 555 | params = chain( 556 | product( 557 | self.AP_THRESHOLDS, 558 | (100,), 559 | (self.SMALL_RANGE, self.MEDIUM_RANGE, self.LARGE_RANGE, self.ALL_RANGE), 560 | ), 561 | product(self.AP_THRESHOLDS, (1, 10), (self.ALL_RANGE,)), 562 | ) 563 | 564 | for t, d, r in tqdm(params, desc="Evaluation", total=60, disable=not verbose): 565 | self.evaluate(iou_threshold=t, max_detections=d, size_range=r) 566 | 567 | def show_summary(self, *, verbose: bool = False): 568 | """Compute and show the standard COCO metrics.""" 569 | from rich import print as pprint 570 | from rich.table import Table 571 | 572 | self._evaluate_all(verbose=verbose) 573 | 574 | table = Table(title="COCO Evaluation", show_footer=True) 575 | table.add_column("Label", footer="Total") 576 | 577 | metrics = { 578 | "AP 50:95": self.ap(), 579 | "AP 50": self.ap_50(), 580 | "AP 75": self.ap_75(), 581 | "AP S": self.ap_small(), 582 | "AP M": self.ap_medium(), 583 | "AP L": self.ap_large(), 584 | "AR 1": self.ar_1(), 585 | "AR 10": self.ar_10(), 586 | "AR 100": self.ar_100(), 587 | "AR S": self.ar_small(), 588 | "AR M": self.ar_medium(), 589 | "AR L": self.ar_large(), 590 | } 591 | 592 | for metric_name, metric in metrics.items(): 593 | table.add_column(metric_name, justify="right", footer=f"{metric:.2%}") 594 | 595 | labels = self.labels or sorted(self.ap_evaluation().keys()) 596 | 597 | for label in labels: 598 | ap = self.ap_evaluation()[label]["ap"] 599 | ap_50 = self.ap_50_evaluation()[label].ap 600 | ap_75 = self.ap_75_evaluation()[label].ap 601 | 602 | ap_s = self.small_evaluation()[label]["ap"] 603 | ap_m = self.medium_evaluation()[label]["ap"] 604 | ap_l = self.large_evaluation()[label]["ap"] 605 | 606 | ar_1 = self.ndets_1_evaluation()[label]["ap"] 607 | ar_10 = self.ndets_10_evaluation()[label]["ap"] 608 | ar_100 = self.ndets_100_evaluation()[label]["ap"] 609 | 610 | ar_s = self.small_evaluation()[label]["ar"] 611 | ar_m = self.medium_evaluation()[label]["ar"] 612 | ar_l = self.large_evaluation()[label]["ar"] 613 | 614 | table.add_row( 615 | label, 616 | f"{ap:.2%}", 617 | f"{ap_50:.2%}", 618 | f"{ap_75:.2%}", 619 | f"{ap_s:.2%}", 620 | f"{ap_m:.2%}", 621 | f"{ap_l:.2%}", 622 | f"{ar_1:.2%}", 623 | f"{ar_10:.2%}", 624 | f"{ar_100:.2%}", 625 | f"{ar_s:.2%}", 626 | f"{ar_m:.2%}", 627 | f"{ar_l:.2%}", 628 | ) 629 | 630 | table.header_style = "bold" 631 | table.footer_style = "bold" 632 | table.row_styles = ["none", "dim"] 633 | 634 | for c in table.columns[1:4]: 635 | c.style = "red" 636 | c.header_style = "red" 637 | c.footer_style = "red" 638 | 639 | for c in table.columns[4:7]: 640 | c.style = "magenta" 641 | c.header_style = "magenta" 642 | c.footer_style = "magenta" 643 | 644 | for c in table.columns[7:10]: 645 | c.style = "blue" 646 | c.header_style = "blue" 647 | c.footer_style = "blue" 648 | 649 | for c in table.columns[10:13]: 650 | c.style = "green" 651 | c.header_style = "green" 652 | c.footer_style = "green" 653 | 654 | pprint(table) 655 | 656 | def save_csv(self, path: PathLike, *, verbose: bool = False): 657 | self._evaluate_all(verbose=verbose) 658 | labels = self.labels or sorted(self.ap_evaluation().keys()) 659 | 660 | with open_atomic(path, "w") as f: 661 | writer = csv.writer(f) 662 | writer.writerow(COCOEvaluator.CSV_HEADERS) 663 | 664 | for label in labels: 665 | ap = self.ap_evaluation()[label]["ap"] 666 | ap_50 = self.ap_50_evaluation()[label].ap 667 | ap_75 = self.ap_75_evaluation()[label].ap 668 | 669 | ap_s = self.small_evaluation()[label]["ap"] 670 | ap_m = self.medium_evaluation()[label]["ap"] 671 | ap_l = self.large_evaluation()[label]["ap"] 672 | 673 | ar_1 = self.ndets_1_evaluation()[label]["ap"] 674 | ar_10 = self.ndets_10_evaluation()[label]["ap"] 675 | ar_100 = self.ndets_100_evaluation()[label]["ap"] 676 | 677 | ar_s = self.small_evaluation()[label]["ar"] 678 | ar_m = self.medium_evaluation()[label]["ar"] 679 | ar_l = self.large_evaluation()[label]["ar"] 680 | 681 | row = ( 682 | label, 683 | ap, 684 | ap_50, 685 | ap_75, 686 | ap_s, 687 | ap_m, 688 | ap_l, 689 | ar_1, 690 | ar_10, 691 | ar_100, 692 | ar_s, 693 | ar_m, 694 | ar_l, 695 | ) 696 | 697 | writer.writerow(row) 698 | 699 | @classmethod 700 | def _compute_ap( 701 | cls, scores: "list[float]", matched: "list[bool]", NP: int 702 | ) -> float: 703 | """ 704 | This curve tracing method has some quirks that do not appear when only unique confidence 705 | thresholds are used (i.e. Scikit-learn's implementation), however, in order to be 706 | consistent, the COCO's method is reproduced. 707 | 708 | Copyrights: https://github.com/rafaelpadilla/review_object_detection_metrics 709 | """ 710 | if NP == 0: 711 | return float("nan") 712 | 713 | # sort in descending score order 714 | scores_ = np.array(scores, dtype=float) 715 | matched_ = np.array(matched, dtype=bool) 716 | inds = np.argsort(-scores_, kind="stable") 717 | 718 | scores_ = scores_[inds] 719 | matched_ = matched_[inds] 720 | 721 | tp = np.cumsum(matched_) 722 | 723 | rc = tp / NP 724 | pr = tp / np.arange(1, matched_.size + 1) 725 | # make precision monotonically decreasing 726 | i_pr = np.maximum.accumulate(pr[::-1])[::-1] 727 | rec_idx = np.searchsorted(rc, cls.RECALL_STEPS, side="left") 728 | 729 | sum_ = i_pr[rec_idx[rec_idx < i_pr.size]].sum() 730 | 731 | return sum_ / rec_idx.size 732 | -------------------------------------------------------------------------------- /src/globox/file_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Iterable, Union 3 | 4 | PathLike = Union[str, Path] 5 | 6 | 7 | def glob( 8 | folder: PathLike, extensions: Union[str, Iterable[str]], recursive: bool = False 9 | ) -> Iterable[Path]: 10 | """Glob files by providing extensions to match.""" 11 | if isinstance(extensions, str): 12 | extensions = {extensions} 13 | else: 14 | extensions = set(extensions) 15 | 16 | assert all( 17 | e.startswith(".") for e in extensions 18 | ), f"Parameter `extensions' ({extensions}) should all start with a dot." 19 | 20 | path = Path(folder).expanduser().resolve() 21 | 22 | files = path.glob("**/*") if recursive else path.glob("*") 23 | 24 | return (f for f in files if f.suffix in extensions and not f.name.startswith(".")) 25 | -------------------------------------------------------------------------------- /src/globox/image_utils.py: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/scardine/image_size 2 | 3 | from os import path 4 | from pathlib import Path 5 | from struct import error as struct_error 6 | from struct import unpack 7 | 8 | from .errors import UnknownImageFormat 9 | from .file_utils import PathLike 10 | 11 | IMAGE_EXTENSIONS = [ 12 | ".jpg", 13 | ".jpeg", 14 | ".png", 15 | ".bmp", 16 | ".jpe", 17 | ".tif", 18 | ".tiff", 19 | ".JPG", 20 | ".JPEG", 21 | ".PNG", 22 | ".BMP", 23 | ".JPE", 24 | ".TIF", 25 | ".TIFF", 26 | ] 27 | 28 | 29 | def get_image_size(file_path: PathLike) -> "tuple[int, int]": 30 | """ 31 | Compute the size of an image without loading into memory, which could result in faster speed. 32 | 33 | Parameters: 34 | 35 | * `file_path`: path to an image file. 36 | 37 | Returns: 38 | 39 | * The image size (width, height). 40 | """ 41 | file_path = Path(file_path).expanduser().resolve() 42 | 43 | size = path.getsize(file_path) 44 | 45 | # be explicit with open arguments - we need binary mode 46 | with file_path.open("rb") as input: 47 | try: 48 | return _get_image_metadata_from_bytesio(input, size) 49 | except Exception as e: 50 | raise UnknownImageFormat(str(e)) 51 | 52 | 53 | def _get_image_metadata_from_bytesio(input, size: int) -> "tuple[int, int]": 54 | """ 55 | Args: 56 | input (io.IOBase): io object support read & seek 57 | size (int): size of buffer in byte 58 | file_path (str): path to an image file 59 | Returns: 60 | Image: (path, type, file_size, width, height) 61 | """ 62 | height = -1 63 | width = -1 64 | data = input.read(26) 65 | msg = " raised while trying to decode as JPEG." 66 | 67 | if (size >= 10) and data[:6] in (b"GIF87a", b"GIF89a"): 68 | # GIFs 69 | w, h = unpack("= 24) 74 | and data.startswith(b"\211PNG\r\n\032\n") 75 | and (data[12:16] == b"IHDR") 76 | ): 77 | # PNGs 78 | w, h = unpack(">LL", data[16:24]) 79 | width = int(w) 80 | height = int(h) 81 | elif (size >= 16) and data.startswith(b"\211PNG\r\n\032\n"): 82 | # older PNGs 83 | w, h = unpack(">LL", data[8:16]) 84 | width = int(w) 85 | height = int(h) 86 | elif (size >= 2) and data.startswith(b"\377\330"): 87 | # JPEG 88 | input.seek(0) 89 | input.read(2) 90 | b = input.read(1) 91 | try: 92 | while b and ord(b) != 0xDA: 93 | while ord(b) != 0xFF: 94 | b = input.read(1) 95 | while ord(b) == 0xFF: 96 | b = input.read(1) 97 | if ord(b) >= 0xC0 and ord(b) <= 0xC3: 98 | input.read(3) 99 | h, w = unpack(">HH", input.read(4)) 100 | width = int(w) 101 | height = int(h) 102 | break 103 | else: 104 | input.read(int(unpack(">H", input.read(2))[0]) - 2) 105 | b = input.read(1) 106 | except struct_error: 107 | raise UnknownImageFormat("StructError" + msg) 108 | except ValueError: 109 | raise UnknownImageFormat("ValueError" + msg) 110 | except Exception as e: 111 | raise UnknownImageFormat(e.__class__.__name__ + msg) 112 | elif (size >= 26) and data.startswith(b"BM"): 113 | # BMP 114 | headersize = unpack("= 40: 120 | w, h = unpack("= 8) and data[:4] in (b"II\052\000", b"MM\000\052"): 127 | # Standard TIFF, big- or little-endian 128 | # BigTIFF and other different but TIFF-like formats are not 129 | # supported currently 130 | byteOrder = data[:2] 131 | boChar = ">" if byteOrder == "MM" else "<" 132 | # maps TIFF type id to size (in bytes) 133 | # and python format char for struct 134 | tiffTypes = { 135 | 1: (1, boChar + "B"), # BYTE 136 | 2: (1, boChar + "c"), # ASCII 137 | 3: (2, boChar + "H"), # SHORT 138 | 4: (4, boChar + "L"), # LONG 139 | 5: (8, boChar + "LL"), # RATIONAL 140 | 6: (1, boChar + "b"), # SBYTE 141 | 7: (1, boChar + "c"), # UNDEFINED 142 | 8: (2, boChar + "h"), # SSHORT 143 | 9: (4, boChar + "l"), # SLONG 144 | 10: (8, boChar + "ll"), # SRATIONAL 145 | 11: (4, boChar + "f"), # FLOAT 146 | 12: (8, boChar + "d"), # DOUBLE 147 | } 148 | ifdOffset = unpack(boChar + "L", data[4:8])[0] 149 | try: 150 | countSize = 2 151 | input.seek(ifdOffset) 152 | ec = input.read(countSize) 153 | ifdEntryCount = unpack(boChar + "H", ec)[0] 154 | # 2 bytes: TagId + 2 bytes: type + 4 bytes: count of values + 4 155 | # bytes: value offset 156 | ifdEntrySize = 12 157 | for i in range(ifdEntryCount): 158 | entryOffset = ifdOffset + countSize + i * ifdEntrySize 159 | input.seek(entryOffset) 160 | tag = input.read(2) 161 | tag = unpack(boChar + "H", tag)[0] 162 | if tag == 256 or tag == 257: 163 | # if type indicates that value fits into 4 bytes, value 164 | # offset is not an offset but value itself 165 | type = input.read(2) 166 | type = unpack(boChar + "H", type)[0] 167 | if type not in tiffTypes: 168 | raise UnknownImageFormat("Unkown TIFF field type:" + str(type)) 169 | typeSize = tiffTypes[type][0] 170 | typeChar = tiffTypes[type][1] 171 | input.seek(entryOffset + 8) 172 | value = input.read(typeSize) 173 | value = int(unpack(typeChar, value)[0]) 174 | if tag == 256: 175 | width = value 176 | else: 177 | height = value 178 | if width > -1 and height > -1: 179 | break 180 | except Exception as e: 181 | raise UnknownImageFormat(str(e)) 182 | elif size >= 2: 183 | # see http://en.wikipedia.org/wiki/ICO_(file_format) 184 | input.seek(0) 185 | reserved = input.read(2) 186 | if 0 != unpack(" 1: 193 | import warnings 194 | 195 | warnings.warn("ICO File contains more than one image") 196 | # http://msdn.microsoft.com/en-us/library/ms997538.aspx 197 | w = input.read(1) 198 | h = input.read(1) 199 | width = ord(w) 200 | height = ord(h) 201 | else: 202 | raise UnknownImageFormat("Sorry, don't know how to get size for this file") 203 | 204 | return width, height 205 | -------------------------------------------------------------------------------- /src/globox/thread_utils.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | from concurrent.futures import ThreadPoolExecutor 3 | from operator import length_hint 4 | from typing import Callable, Iterable, Optional, TypeVar 5 | 6 | from tqdm import tqdm 7 | 8 | SHARED_THREAD_POOL = ThreadPoolExecutor() 9 | 10 | 11 | def at_exit(): 12 | SHARED_THREAD_POOL.shutdown() 13 | 14 | 15 | atexit.register(at_exit) 16 | 17 | 18 | U = TypeVar("U") 19 | V = TypeVar("V") 20 | 21 | 22 | def thread_map( 23 | fn: Callable[[U], V], 24 | it: Iterable[U], 25 | desc: Optional[str] = None, 26 | total: Optional[int] = None, 27 | unit: str = "it", 28 | verbose: bool = False, 29 | ) -> "list[V]": 30 | disable = not verbose 31 | total = total or length_hint(it) 32 | results = [] 33 | with tqdm(desc=desc, total=total, unit=unit, disable=disable) as pbar: 34 | futures = SHARED_THREAD_POOL.map(fn, it) 35 | for result in futures: 36 | results.append(result) 37 | pbar.update() 38 | return results 39 | -------------------------------------------------------------------------------- /src/globox/utils.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from itertools import groupby 3 | from typing import Callable, Hashable, Iterable, TypeVar 4 | 5 | U = TypeVar("U") 6 | V = TypeVar("V", bound=Hashable) 7 | 8 | 9 | def grouping(it: Iterable[U], by_key: Callable[[U], V]) -> "dict[V, list[U]]": 10 | result = defaultdict(list) 11 | for item in it: 12 | result[by_key(item)].append(item) 13 | return result 14 | 15 | 16 | def all_equal(iterable: Iterable) -> bool: 17 | """https://stackoverflow.com/a/3844948/6324055""" 18 | g = groupby(iterable) 19 | return next(g, True) and not next(g, False) 20 | 21 | 22 | def mean(it: Iterable[float]) -> float: 23 | sum_ = 0.0 24 | count = 0 25 | 26 | for value in it: 27 | sum_ += value 28 | count += 1 29 | 30 | if count == 0: 31 | return float("nan") 32 | 33 | return sum_ / count 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laclouis5/globox/1a7185088ad7603507aba452417facabeb608bf5/tests/__init__.py -------------------------------------------------------------------------------- /tests/benchmark.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from pathlib import Path 3 | from tempfile import TemporaryDirectory 4 | from time import perf_counter 5 | from timeit import timeit 6 | 7 | from rich import print as rich_print 8 | from rich.table import Table 9 | 10 | import globox 11 | 12 | from . import constants as C 13 | 14 | 15 | def benchmark(repetitions: int = 5): 16 | base = (Path(__file__).parent / "globox_test_data/coco_val_5k/").resolve() 17 | coco = base / "coco.json" 18 | images = base / "images" 19 | 20 | gts = globox.AnnotationSet.from_coco(coco) 21 | labels = sorted(gts._labels()) 22 | label_to_id = {label: i for i, label in enumerate(labels)} 23 | 24 | coco_gt = globox.AnnotationSet.from_coco(C.coco_gts_path) 25 | coco_det = coco_gt.from_results(C.coco_results_path) 26 | 27 | evaluator = globox.COCOEvaluator( 28 | ground_truths=coco_gt, 29 | predictions=coco_det, 30 | ) 31 | 32 | with TemporaryDirectory() as tmp: 33 | tmp = Path(tmp) 34 | 35 | coco_out = tmp / "coco_out.json" 36 | cvat = tmp / "cvat.xml" 37 | oi = tmp / "openimage.csv" 38 | labelme = tmp / "labelme" 39 | xml = tmp / "xml" 40 | yolo = tmp / "yolo" 41 | txt = tmp / "txt" 42 | 43 | start = perf_counter() 44 | 45 | coco_s = timeit(lambda: gts.save_coco(coco_out), number=repetitions) 46 | cvat_s = timeit(lambda: gts.save_cvat(cvat), number=repetitions) 47 | oi_s = timeit(lambda: gts.save_openimage(oi), number=repetitions) 48 | labelme_s = timeit(lambda: gts.save_labelme(labelme), number=repetitions) 49 | xml_s = timeit(lambda: gts.save_xml(xml), number=repetitions) 50 | yolo_s = timeit( 51 | lambda: gts.save_yolo_darknet(yolo, label_to_id=label_to_id), 52 | number=repetitions, 53 | ) 54 | txt_s = timeit( 55 | lambda: gts.save_txt(txt, label_to_id=label_to_id), number=repetitions 56 | ) 57 | 58 | coco_p = timeit( 59 | lambda: globox.AnnotationSet.from_coco(coco), number=repetitions 60 | ) 61 | cvat_p = timeit( 62 | lambda: globox.AnnotationSet.from_cvat(cvat), number=repetitions 63 | ) 64 | oi_p = timeit( 65 | lambda: globox.AnnotationSet.from_openimage(oi, image_folder=images), 66 | number=repetitions, 67 | ) 68 | labelme_p = timeit( 69 | lambda: globox.AnnotationSet.from_labelme(labelme), number=repetitions 70 | ) 71 | xml_p = timeit(lambda: globox.AnnotationSet.from_xml(xml), number=repetitions) 72 | yolo_p = timeit( 73 | lambda: globox.AnnotationSet.from_yolo_darknet(yolo, image_folder=images), 74 | number=repetitions, 75 | ) 76 | txt_p = timeit( 77 | lambda: globox.AnnotationSet.from_txt(txt, image_folder=images), 78 | number=repetitions, 79 | ) 80 | 81 | def _eval(): 82 | evaluator.clear_cache() 83 | evaluator._evaluate_all() 84 | 85 | eval_t = ( 86 | timeit( 87 | lambda: _eval(), 88 | number=repetitions, 89 | ) 90 | / repetitions 91 | ) 92 | 93 | stats_t = timeit(lambda: gts.show_stats(), number=repetitions) / repetitions 94 | 95 | stop = perf_counter() 96 | 97 | headers = ["COCO", "CVAT", "Open Image", "LabelMe", "Pascal VOC", "YOLO", "Txt"] 98 | 99 | parse_times = ( 100 | f"{(t / repetitions):.2f}s" 101 | for t in (coco_p, cvat_p, oi_p, labelme_p, xml_p, yolo_p, txt_p) 102 | ) 103 | save_times = ( 104 | f"{(t / repetitions):.2f}s" 105 | for t in (coco_s, cvat_s, oi_s, labelme_s, xml_s, yolo_s, txt_s) 106 | ) 107 | 108 | table = Table(title=f"Benchmark ({len(gts)} images, {gts.nb_boxes()} boxes)") 109 | table.add_column("") 110 | for header in headers: 111 | table.add_column(header, justify="right") 112 | 113 | table.add_row("Parsing", *parse_times) 114 | table.add_row("Saving", *save_times) 115 | 116 | rich_print(table) 117 | 118 | print(f"Show stats time: {stats_t:.2f} s") 119 | print(f"Evaluation time: {eval_t:.2f} s") 120 | print(f"Total benchmark duration: {(stop - start):.2f} s") 121 | 122 | 123 | if __name__ == "__main__": 124 | parser = ArgumentParser() 125 | parser.add_argument( 126 | "--repetitions", 127 | "-n", 128 | default=5, 129 | type=int, 130 | help="Number of repetitions for timeit.", 131 | ) 132 | args = parser.parse_args() 133 | benchmark(repetitions=args.repetitions) 134 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from globox import AnnotationSet 4 | 5 | data_path = (Path(__file__).parent / "globox_test_data/").resolve() 6 | gts_path = data_path / "gts/" 7 | dets_path = data_path / "dets/" 8 | image_folder = data_path / "images/" 9 | names_file = gts_path / "yolo_format/obj.names" 10 | 11 | coco_str_id_path = gts_path / "coco_format_v3/instances_v3.json" 12 | coco1_path = gts_path / "coco_format_v1/instances_default.json" 13 | coco2_path = gts_path / "coco_format_v2/instances_v2.json" 14 | coco_gts_path = data_path / "coco_eval/ground_truths.json" 15 | yolo_path = gts_path / "yolo_format/obj_train_data" 16 | yolo_seg_path = gts_path / "yolo_seg_format/obj_train_data" 17 | cvat_path = gts_path / "cvat_format/annotations.xml" 18 | imagenet_path = gts_path / "imagenet_format/Annotations/" 19 | labelme_path = gts_path / "labelme_format/" 20 | labelme_poly_path = gts_path / "labelme_poly_format/" 21 | openimage_path = gts_path / "openimages_format/all_bounding_boxes.csv" 22 | pascal_path = gts_path / "pascalvoc_format/" 23 | via_json_path = gts_path / "via/annotations.json" 24 | 25 | coco_results_path = data_path / "coco_eval/results.json" 26 | abs_ltrb = dets_path / "abs_ltrb/" 27 | abs_ltwh = dets_path / "abs_ltwh/" 28 | rel_ltwh = dets_path / "rel_ltwh/" 29 | 30 | id_to_label = AnnotationSet.parse_names_file(names_file) 31 | labels = set(id_to_label.values()) 32 | label_to_id = {v: k for k, v in id_to_label.items()} 33 | -------------------------------------------------------------------------------- /tests/pycocotools_results.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pycocotools.coco import COCO 4 | from pycocotools.cocoeval import COCOeval 5 | 6 | 7 | def main(): 8 | gt_path = Path("data/coco/ground_truths.json").resolve() 9 | det_path = Path("data/coco/results.json").resolve() 10 | 11 | gts = COCO(str(gt_path)) 12 | dets = gts.loadRes(str(det_path)) 13 | 14 | evaluator = COCOeval(gts, dets, "bbox") 15 | evaluator.evaluate() 16 | evaluator.accumulate() 17 | evaluator.summarize() 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /tests/test_annotation.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from globox import Annotation, BoundingBox 7 | 8 | 9 | def test_init(): 10 | a = Annotation(image_id="a") 11 | assert len(a.boxes) == 0 12 | assert a.image_size is None 13 | 14 | b = Annotation(image_id="b") 15 | b1 = BoundingBox(label="b1", xmin=0, ymin=0, xmax=0, ymax=0) 16 | b.add(b1) 17 | assert len(b.boxes) == 1 18 | 19 | b2 = BoundingBox(label="b2", xmin=5, ymin=0, xmax=10, ymax=10) 20 | c = Annotation(image_id="c", image_size=(20, 10), boxes=[b1, b2]) 21 | assert c.image_width == 20 and c.image_height == 10 22 | assert len(c.boxes) == 2 23 | 24 | 25 | def test_image_size(): 26 | with pytest.raises(AssertionError): 27 | _ = Annotation(image_id="_a", image_size=(1, 0)) 28 | 29 | with pytest.raises(AssertionError): 30 | _ = Annotation(image_id="_a", image_size=(-1, 10)) 31 | 32 | with pytest.raises(AssertionError): 33 | _ = Annotation(image_id="_a", image_size=(10, 10.2)) # type: ignore 34 | 35 | 36 | def test_map_labels(): 37 | b1 = BoundingBox(label="b1", xmin=0, ymin=0, xmax=0, ymax=0) 38 | b2 = BoundingBox(label="b2", xmin=5, ymin=0, xmax=10, ymax=10) 39 | annotation = Annotation(image_id="image", boxes=[b1, b2]) 40 | annotation.map_labels({"b1": "B1", "b2": "B2"}) 41 | b1, b2 = annotation.boxes 42 | 43 | assert b1.label == "B1" 44 | assert b2.label == "B2" 45 | 46 | annotation.map_labels({"B1": "B"}) 47 | 48 | assert b1.label == "B" 49 | assert b2.label == "B2" 50 | 51 | 52 | def test_from_txt_conf_first(tmp_path: Path): 53 | file_path = tmp_path / "txt_1.txt" 54 | content = """label 0.25 10 20 30 40""" 55 | file_path.write_text(content) 56 | 57 | annotation = Annotation.from_txt(file_path, image_id="txt_1.jpg") 58 | 59 | assert len(annotation.boxes) == 1 60 | assert annotation.boxes[0].confidence == 0.25 61 | 62 | 63 | def test_from_txt_conf_last(tmp_path: Path): 64 | file_path = tmp_path / "txt_2.txt" 65 | content = """label 10 20 30 40 0.25""" 66 | file_path.write_text(content) 67 | 68 | annotation = Annotation.from_txt(file_path, image_id="txt_2.jpg", conf_last=True) 69 | 70 | assert len(annotation.boxes) == 1 71 | assert annotation.boxes[0].confidence == 0.25 72 | 73 | 74 | def test_from_yolo_darknet(tmp_path: Path): 75 | path = tmp_path / "annotation.txt" 76 | path.write_text("label 0.25 0.25 0.25 0.5 0.5") 77 | 78 | annotation = Annotation.from_yolo_darknet(path, image_size=(100, 100)) 79 | 80 | assert len(annotation.boxes) == 1 81 | assert annotation.image_id == "annotation.jpg" 82 | assert annotation.image_size == (100, 100) 83 | 84 | bbox = annotation.boxes[0] 85 | 86 | assert bbox.label == "label" 87 | assert bbox.confidence == 0.25 88 | 89 | (xmin, ymin, xmax, ymax) = bbox.ltrb 90 | 91 | assert isclose(xmin, 0.0) 92 | assert isclose(ymin, 0.0) 93 | assert isclose(xmax, 50.0) 94 | assert isclose(ymax, 50.0) 95 | 96 | assert isclose(bbox.confidence, 0.25) 97 | 98 | 99 | def test_from_yolo_v5(tmp_path: Path): 100 | path = tmp_path / "annotation.txt" 101 | path.write_text("label 0.25 0.25 0.5 0.5 0.25") 102 | 103 | annotation = Annotation.from_yolo_v5(path, image_size=(100, 100)) 104 | 105 | assert len(annotation.boxes) == 1 106 | assert annotation.image_id == "annotation.jpg" 107 | assert annotation.image_size == (100, 100) 108 | 109 | bbox = annotation.boxes[0] 110 | 111 | assert bbox.label == "label" 112 | assert bbox.confidence == 0.25 113 | 114 | (xmin, ymin, xmax, ymax) = bbox.ltrb 115 | 116 | assert isclose(xmin, 0.0) 117 | assert isclose(ymin, 0.0) 118 | assert isclose(xmax, 50.0) 119 | assert isclose(ymax, 50.0) 120 | 121 | assert isclose(bbox.confidence, 0.25) 122 | 123 | 124 | def test_from_yolo_seg(tmp_path: Path): 125 | path = tmp_path / "annotation.txt" 126 | path.write_text("0 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.1") 127 | 128 | annotation = Annotation.from_yolo_seg(path, image_size=(100, 100)) 129 | 130 | assert len(annotation.boxes) == 1 131 | assert annotation.image_id == "annotation.jpg" 132 | assert annotation.image_size == (100, 100) 133 | 134 | bbox = annotation.boxes[0] 135 | 136 | assert bbox.label == "0" 137 | 138 | (xmin, ymin, xmax, ymax) = bbox.ltrb 139 | 140 | assert isclose(xmin, 10.0) 141 | assert isclose(ymin, 10.0) 142 | assert isclose(xmax, 20.0) 143 | assert isclose(ymax, 20.0) 144 | 145 | 146 | def test_save_txt_conf_first(tmp_path: Path): 147 | file_path = tmp_path / "txt_first.txt" 148 | 149 | annotation = Annotation( 150 | image_id="image_id", 151 | boxes=[ 152 | BoundingBox( 153 | label="label", xmin=10, ymin=20, xmax=30, ymax=40, confidence=0.25 154 | ), 155 | ], 156 | ) 157 | 158 | annotation.save_txt(file_path) 159 | content = file_path.read_text() 160 | 161 | assert content == "label 0.25 10 20 30 40" 162 | 163 | 164 | def test_save_txt_conf_last(tmp_path: Path): 165 | file_path = tmp_path / "txt_first.txt" 166 | 167 | annotation = Annotation( 168 | image_id="image_id", 169 | boxes=[ 170 | BoundingBox( 171 | label="label", xmin=10, ymin=20, xmax=30, ymax=40, confidence=0.25 172 | ), 173 | ], 174 | ) 175 | 176 | annotation.save_txt(file_path, conf_last=True) 177 | content = file_path.read_text() 178 | 179 | assert content == "label 10 20 30 40 0.25" 180 | 181 | 182 | def test_to_yolo_darknet(): 183 | bbox = BoundingBox( 184 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 185 | ) 186 | annotation = Annotation( 187 | image_id="annotation.jpg", image_size=(100, 100), boxes=[bbox] 188 | ) 189 | 190 | content = annotation.to_yolo_darknet() 191 | 192 | assert content == "label 0.25 0.25 0.25 0.5 0.5" 193 | 194 | 195 | def test_to_yolo_v5(): 196 | bbox = BoundingBox( 197 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 198 | ) 199 | annotation = Annotation( 200 | image_id="annotation.jpg", image_size=(100, 100), boxes=[bbox] 201 | ) 202 | 203 | content = annotation.to_yolo_v5() 204 | 205 | assert content == "label 0.25 0.25 0.5 0.5 0.25" 206 | 207 | 208 | def test_save_yolo_darknet(tmp_path: Path): 209 | bbox = BoundingBox( 210 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 211 | ) 212 | annotation = Annotation( 213 | image_id="annotation.jpg", image_size=(100, 100), boxes=[bbox] 214 | ) 215 | 216 | path = tmp_path / "annotation.txt" 217 | annotation.save_yolo_darknet(path) 218 | 219 | content = path.read_text() 220 | 221 | assert content == "label 0.25 0.25 0.25 0.5 0.5" 222 | 223 | 224 | def test_save_yolo_v5(tmp_path: Path): 225 | bbox = BoundingBox( 226 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 227 | ) 228 | annotation = Annotation( 229 | image_id="annotation.jpg", image_size=(100, 100), boxes=[bbox] 230 | ) 231 | 232 | path = tmp_path / "annotation.txt" 233 | annotation.save_yolo_v5(path) 234 | 235 | content = path.read_text() 236 | 237 | assert content == "label 0.25 0.25 0.5 0.5 0.25" 238 | -------------------------------------------------------------------------------- /tests/test_annotationset.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | from pathlib import Path 3 | 4 | import pytest 5 | from PIL import Image 6 | 7 | from globox import Annotation, AnnotationSet, BoundingBox 8 | from globox.file_utils import glob 9 | 10 | from . import constants as C 11 | 12 | 13 | def test_annotationset(): 14 | a = Annotation(image_id="a") 15 | b = Annotation(image_id="b") 16 | c = Annotation(image_id="c") 17 | 18 | annotations = AnnotationSet(annotations=(a, b, c)) 19 | 20 | assert annotations.image_ids == {"a", "b", "c"} 21 | assert len(annotations) == 3 22 | # assert annotations._id_to_label is None 23 | # assert annotations._id_to_imageid is None 24 | 25 | assert annotations["b"].image_id == "b" 26 | assert a in annotations 27 | assert annotations["b"] is b 28 | 29 | with pytest.raises(KeyError): 30 | _ = annotations["d"] 31 | 32 | assert annotations.get("d") is None 33 | 34 | with pytest.raises(AssertionError): 35 | annotations.add(a) 36 | 37 | annotations.add(Annotation(image_id="a", image_size=(100, 100)), override=True) 38 | assert annotations["a"].image_size is not None 39 | 40 | 41 | def test_annotation_set_2(): 42 | files = list(glob(C.pascal_path, ".xml")) 43 | set1 = AnnotationSet(Annotation.from_xml(f) for f in files[:50]) 44 | set2 = AnnotationSet(Annotation.from_xml(f) for f in files[50:]) 45 | set3 = set1 | set2 46 | annotation = Annotation.from_xml(files[0]) 47 | 48 | assert set3.image_ids == (set(set1.image_ids).union(set(set2.image_ids))) 49 | 50 | with pytest.raises(AssertionError): 51 | set3 |= set1 52 | 53 | with pytest.raises(AssertionError): 54 | set3.add(annotation) 55 | 56 | 57 | def test_openimage_conversion(tmp_path: Path): 58 | box = BoundingBox.create(label="dining,table", coords=(0, 0, 10, 10)) 59 | annotation = Annotation(image_id="", boxes=[box]) 60 | annotationset = AnnotationSet(annotations=[annotation]) 61 | 62 | # "," in label 63 | with pytest.raises(ValueError): 64 | _ = annotationset.save_openimage(tmp_path / "cvat.csv") 65 | 66 | box.label = "dining_table" 67 | 68 | # No image_size specified 69 | with pytest.raises(ValueError): 70 | _ = annotationset.save_openimage(tmp_path / "cvat.csv") 71 | 72 | annotation.image_size = (640, 480) 73 | _ = annotationset.save_openimage(tmp_path / "cvat.csv") 74 | 75 | 76 | def test_save_txt_conf_first(tmp_path: Path): 77 | annotation = Annotation( 78 | image_id="image_id", 79 | boxes=[ 80 | BoundingBox( 81 | label="label", xmin=10, ymin=20, xmax=30, ymax=40, confidence=0.25 82 | ), 83 | ], 84 | ) 85 | 86 | annotationset = AnnotationSet(annotations=[annotation]) 87 | annotationset.save_txt(tmp_path) 88 | 89 | content = (tmp_path / "image_id.txt").read_text() 90 | 91 | assert content == "label 0.25 10 20 30 40" 92 | 93 | 94 | def test_save_yolo_darknet(tmp_path: Path): 95 | bboxes = [ 96 | BoundingBox( 97 | label="label_1", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 98 | ), 99 | BoundingBox( 100 | label="label_2", xmin=25.0, ymin=25.0, xmax=75.0, ymax=75.0, confidence=0.75 101 | ), 102 | ] 103 | 104 | annotation = Annotation( 105 | image_id="annotation.jpg", image_size=(100, 100), boxes=bboxes 106 | ) 107 | annotations = AnnotationSet([annotation]) 108 | annotations.save_yolo_darknet(tmp_path) 109 | 110 | path = tmp_path / "annotation.txt" 111 | 112 | content = path.read_text() 113 | 114 | assert content == "label_1 0.25 0.25 0.25 0.5 0.5\nlabel_2 0.75 0.5 0.5 0.5 0.5" 115 | 116 | 117 | def test_save_yolo_v5(tmp_path: Path): 118 | bboxes = [ 119 | BoundingBox( 120 | label="label_1", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 121 | ), 122 | BoundingBox( 123 | label="label_2", xmin=25.0, ymin=25.0, xmax=75.0, ymax=75.0, confidence=0.75 124 | ), 125 | ] 126 | 127 | annotation = Annotation( 128 | image_id="annotation.jpg", image_size=(100, 100), boxes=bboxes 129 | ) 130 | annotations = AnnotationSet([annotation]) 131 | annotations.save_yolo_v5(tmp_path) 132 | 133 | path = tmp_path / "annotation.txt" 134 | 135 | content = path.read_text() 136 | 137 | assert content == "label_1 0.25 0.25 0.5 0.5 0.25\nlabel_2 0.5 0.5 0.5 0.5 0.75" 138 | 139 | 140 | def test_from_txt_conf_first(tmp_path: Path): 141 | file_path = tmp_path / "txt_1.txt" 142 | content = """label 0.25 10 20 30 40""" 143 | file_path.write_text(content) 144 | 145 | annotationset = AnnotationSet.from_txt(tmp_path) 146 | assert len(annotationset) == 1 147 | 148 | annotation = annotationset["txt_1.jpg"] 149 | assert len(annotation.boxes) == 1 150 | 151 | box = annotation.boxes[0] 152 | assert box.confidence == 0.25 153 | 154 | 155 | def test_from_txt_conf_last(tmp_path: Path): 156 | file_path = tmp_path / "txt_2.txt" 157 | content = """label 10 20 30 40 0.25""" 158 | file_path.write_text(content) 159 | 160 | annotationset = AnnotationSet.from_txt(tmp_path, conf_last=True) 161 | assert len(annotationset) == 1 162 | 163 | annotation = annotationset["txt_2.jpg"] 164 | assert len(annotation.boxes) == 1 165 | 166 | box = annotation.boxes[0] 167 | assert box.confidence == 0.25 168 | 169 | 170 | def test_from_coco_id_string(): 171 | gts = AnnotationSet.from_coco(C.coco_str_id_path) 172 | 173 | assert len(gts) == 100 # images defined in coco_file 174 | assert gts["2007_001585.jpg"] is not None 175 | 176 | gts_box = gts["2007_001585.jpg"].boxes[0] 177 | 178 | coco_box = BoundingBox( 179 | label="bottle", xmin=58.0, ymin=158.0, xmax=58.0 + 14.0, ymax=158 + 33.0 180 | ) 181 | 182 | assert gts_box == coco_box 183 | 184 | 185 | def test_from_yolo_darknet(tmp_path: Path): 186 | path = tmp_path / "annotation.txt" 187 | path.write_text("label 0.25 0.25 0.25 0.5 0.5\nlabel_2 0.25 0.25 0.25 0.5 0.5") 188 | 189 | img_path = tmp_path / "annotation.jpg" 190 | img = Image.new(mode="1", size=(100, 100)) 191 | img.save(img_path) 192 | 193 | annotations = AnnotationSet.from_yolo_darknet(tmp_path, image_folder=tmp_path) 194 | 195 | assert len(annotations) == 1 196 | 197 | annotation = annotations["annotation.jpg"] 198 | 199 | assert len(annotation.boxes) == 2 200 | assert annotation.image_id == "annotation.jpg" 201 | assert annotation.image_size == (100, 100) 202 | 203 | bbox = annotation.boxes[0] 204 | 205 | assert bbox.label == "label" 206 | assert bbox.confidence == 0.25 207 | 208 | (xmin, ymin, xmax, ymax) = bbox.ltrb 209 | 210 | assert isclose(xmin, 0.0) 211 | assert isclose(ymin, 0.0) 212 | assert isclose(xmax, 50.0) 213 | assert isclose(ymax, 50.0) 214 | 215 | assert isclose(bbox.confidence, 0.25) 216 | 217 | 218 | def test_from_yolo_v5(tmp_path: Path): 219 | path = tmp_path / "annotation.txt" 220 | path.write_text("label 0.25 0.25 0.5 0.5 0.25\nlabel_2 0.25 0.25 0.5 0.5 0.25") 221 | 222 | img_path = tmp_path / "annotation.jpg" 223 | img = Image.new(mode="1", size=(100, 100)) 224 | img.save(img_path) 225 | 226 | annotations = AnnotationSet.from_yolo_v5(tmp_path, image_folder=tmp_path) 227 | 228 | assert len(annotations) == 1 229 | 230 | annotation = annotations["annotation.jpg"] 231 | 232 | assert len(annotation.boxes) == 2 233 | assert annotation.image_id == "annotation.jpg" 234 | assert annotation.image_size == (100, 100) 235 | 236 | bbox = annotation.boxes[0] 237 | 238 | assert bbox.label == "label" 239 | assert bbox.confidence == 0.25 240 | 241 | (xmin, ymin, xmax, ymax) = bbox.ltrb 242 | 243 | assert isclose(xmin, 0.0) 244 | assert isclose(ymin, 0.0) 245 | assert isclose(xmax, 50.0) 246 | assert isclose(ymax, 50.0) 247 | 248 | assert isclose(bbox.confidence, 0.25) 249 | -------------------------------------------------------------------------------- /tests/test_bbox.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | 3 | import pytest 4 | 5 | from globox import BoundingBox, BoxFormat 6 | 7 | 8 | def test_init(): 9 | # xmax < xmin 10 | with pytest.raises(AssertionError): 11 | box = BoundingBox(label="", xmin=10, ymin=10, xmax=5, ymax=10) 12 | 13 | # ymax < ymin 14 | with pytest.raises(AssertionError): 15 | box = BoundingBox(label="", xmin=10, ymin=10, xmax=15, ymax=9) 16 | 17 | # Negative confidence 18 | with pytest.raises(AssertionError): 19 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0, confidence=-0.1) 20 | 21 | # Confidence > 1.0 22 | with pytest.raises(AssertionError): 23 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0, confidence=1.1) 24 | 25 | box = BoundingBox(label="", xmin=-1, ymin=0, xmax=1, ymax=2) 26 | assert box.xmin == -1 27 | assert box.ymin == 0 28 | assert box.xmax == 1 29 | assert box.ymax == 2 30 | assert box.xmid == 0.0 31 | assert box.ymid == 1.0 32 | assert box.width == 2.0 33 | assert box.height == 2.0 34 | assert box.area == 4.0 35 | assert box.is_ground_truth 36 | 37 | assert box.ltrb == (-1, 0, 1, 2) 38 | assert box.ltwh == (-1, 0, 2, 2) 39 | assert box.xywh == (0, 1, 2, 2) 40 | 41 | 42 | def test_confidence(): 43 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0, confidence=0.25) 44 | assert box.confidence == 0.25 45 | assert box.is_detection 46 | 47 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0, confidence=0.0) 48 | assert box.confidence == 0.0 49 | 50 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0, confidence=1.0) 51 | assert box.confidence == 1.0 52 | 53 | box = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0) 54 | assert box.confidence is None 55 | assert box.is_ground_truth 56 | 57 | 58 | def test_iou(): 59 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=10, ymax=10) 60 | b2 = BoundingBox(label="", xmin=5, ymin=0, xmax=10, ymax=10) 61 | assert b1.iou(b2) == 0.5 62 | 63 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=10, ymax=10) 64 | b2 = BoundingBox(label="", xmin=10, ymin=10, xmax=15, ymax=15) 65 | assert b1.iou(b2) == 0.0 66 | 67 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=10, ymax=10) 68 | b2 = BoundingBox(label="", xmin=20, ymin=20, xmax=30, ymax=30) 69 | assert b1.iou(b2) == 0.0 70 | 71 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0) 72 | b2 = BoundingBox(label="", xmin=5, ymin=5, xmax=15, ymax=15) 73 | assert b1.iou(b2) == 0.0 74 | 75 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0) 76 | b2 = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0) 77 | assert b1.iou(b2) == 1.0 78 | 79 | b1 = BoundingBox(label="", xmin=0, ymin=0, xmax=0, ymax=0) 80 | b2 = BoundingBox(label="", xmin=1, ymin=1, xmax=1, ymax=1) 81 | assert b1.iou(b2) == 0.0 82 | 83 | 84 | def test_create(): 85 | with pytest.raises(AssertionError): 86 | box = BoundingBox.create(label="", coords=(0, 0, 0, 0), relative=True) 87 | 88 | box = BoundingBox.create( 89 | label="", coords=(0, 0, 0.75, 1.0), relative=True, image_size=(100, 200) 90 | ) 91 | assert isclose(box.xmin, 0) 92 | assert isclose(box.ymin, 0) 93 | assert isclose(box.xmax, 75) 94 | assert isclose(box.ymax, 200) 95 | assert isclose(box.xmid, 37.5) 96 | assert isclose(box.ymid, 100) 97 | assert isclose(box.width, 75) 98 | assert isclose(box.height, 200) 99 | 100 | box = BoundingBox.create( 101 | label="", 102 | coords=(0.5, 0.5, 0.5, 1.0), 103 | box_format=BoxFormat.XYWH, 104 | relative=True, 105 | image_size=(100, 200), 106 | ) 107 | assert isclose(box.xmin, 25) 108 | assert isclose(box.ymin, 0) 109 | assert isclose(box.xmax, 75) 110 | assert isclose(box.ymax, 200) 111 | assert isclose(box.xmid, 50) 112 | assert isclose(box.ymid, 100) 113 | assert isclose(box.width, 50) 114 | assert isclose(box.height, 200) 115 | 116 | box = BoundingBox.create( 117 | label="", 118 | coords=(0, 0, 0.5, 1.0), 119 | box_format=BoxFormat.LTWH, 120 | relative=True, 121 | image_size=(100, 200), 122 | ) 123 | assert isclose(box.xmin, 0) 124 | assert isclose(box.ymin, 0) 125 | assert isclose(box.xmax, 50) 126 | assert isclose(box.ymax, 200) 127 | assert isclose(box.xmid, 25) 128 | assert isclose(box.ymid, 100) 129 | assert isclose(box.width, 50) 130 | assert isclose(box.height, 200) 131 | 132 | 133 | def test_txt_conversion(): 134 | box = BoundingBox.create(label="dining table", coords=(0, 0, 10, 10)) 135 | with pytest.raises(AssertionError): 136 | _ = box.to_txt() 137 | box.to_txt(label_to_id={"dining table": "dining_table"}) 138 | 139 | box = BoundingBox.create(label="dining_table", coords=(0, 0, 10, 10)) 140 | with pytest.raises(AssertionError): 141 | _ = box.to_txt(separator="\n") 142 | 143 | 144 | def test_to_txt_conf_last(): 145 | box = BoundingBox.create(label="label", coords=(0, 0, 10, 10), confidence=0.5) 146 | 147 | line = box.to_txt(conf_last=True) 148 | assert line == "label 0 0 10 10 0.5" 149 | 150 | line = box.to_txt() 151 | assert line == "label 0.5 0 0 10 10" 152 | 153 | 154 | def test_to_yolo_conf_last(): 155 | box = BoundingBox.create(label="label", coords=(0, 0, 10, 10), confidence=0.25) 156 | 157 | line = box.to_yolo(image_size=(10, 10), conf_last=True) 158 | assert line == "label 0.5 0.5 1.0 1.0 0.25" 159 | 160 | line = box.to_yolo(image_size=(10, 10)) 161 | assert line == "label 0.25 0.5 0.5 1.0 1.0" 162 | 163 | 164 | def test_from_txt_conf_last(): 165 | line = "label 10 20 30 40 0.25" 166 | box = BoundingBox.from_txt(line, conf_last=True) 167 | assert box.confidence == 0.25 168 | 169 | line = "label 0.25 10 20 30 40" 170 | box = BoundingBox.from_txt(line) 171 | assert box.confidence == 0.25 172 | 173 | 174 | def test_from_txt_tab_sep(): 175 | line = "label\t10\t20\t30\t40\t0.25" 176 | box = BoundingBox.from_txt(line, conf_last=True) 177 | assert box.confidence == 0.25 178 | 179 | line = "label 0.25 10 20 30 40" 180 | box = BoundingBox.from_txt(line) 181 | assert box.confidence == 0.25 182 | 183 | 184 | def test_from_yolo_v5(): 185 | line = "label 0.25 0.25 0.5 0.5 0.25" 186 | bbox = BoundingBox.from_yolo_v5(line, image_size=(100, 100)) 187 | 188 | assert bbox.label == "label" 189 | assert bbox.confidence == 0.25 190 | 191 | (xmin, ymin, xmax, ymax) = bbox.ltrb 192 | 193 | assert isclose(xmin, 0.0) 194 | assert isclose(ymin, 0.0) 195 | assert isclose(xmax, 50.0) 196 | assert isclose(ymax, 50.0) 197 | 198 | assert isclose(bbox.confidence, 0.25) 199 | 200 | 201 | def test_from_yolo_v7(): 202 | line = "label 0.25 0.25 0.5 0.5 0.25" 203 | bbox = BoundingBox.from_yolo_v7(line, image_size=(100, 100)) 204 | 205 | assert bbox.label == "label" 206 | assert bbox.confidence == 0.25 207 | 208 | (xmin, ymin, xmax, ymax) = bbox.ltrb 209 | 210 | assert isclose(xmin, 0.0) 211 | assert isclose(ymin, 0.0) 212 | assert isclose(xmax, 50.0) 213 | assert isclose(ymax, 50.0) 214 | 215 | assert isclose(bbox.confidence, 0.25) 216 | 217 | 218 | def test_from_yolo_v7_tab_sep(): 219 | line = "label 0.25\t0.25\t0.5\t0.5\t0.25" 220 | bbox = BoundingBox.from_yolo_v7(line, image_size=(100, 100)) 221 | 222 | assert bbox.label == "label" 223 | assert bbox.confidence == 0.25 224 | 225 | (xmin, ymin, xmax, ymax) = bbox.ltrb 226 | 227 | assert isclose(xmin, 0.0) 228 | assert isclose(ymin, 0.0) 229 | assert isclose(xmax, 50.0) 230 | assert isclose(ymax, 50.0) 231 | 232 | assert isclose(bbox.confidence, 0.25) 233 | 234 | 235 | def test_to_yolo_darknet(): 236 | bbox = BoundingBox( 237 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 238 | ) 239 | string = bbox.to_yolo_darknet(image_size=(100, 100)) 240 | 241 | assert string == "label 0.25 0.25 0.25 0.5 0.5" 242 | 243 | 244 | def test_to_yolo_v5(): 245 | bbox = BoundingBox( 246 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 247 | ) 248 | string = bbox.to_yolo_v5(image_size=(100, 100)) 249 | 250 | assert string == "label 0.25 0.25 0.5 0.5 0.25" 251 | 252 | 253 | def test_to_yolo_v7(): 254 | bbox = BoundingBox( 255 | label="label", xmin=0.0, ymin=0.0, xmax=50.0, ymax=50.0, confidence=0.25 256 | ) 257 | string = bbox.to_yolo_v7(image_size=(100, 100)) 258 | 259 | assert string == "label 0.25 0.25 0.5 0.5 0.25" 260 | 261 | 262 | def test_eq(): 263 | box = BoundingBox( 264 | label="image_0.jpg", xmin=1.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=0.5 265 | ) 266 | 267 | assert box == box 268 | 269 | # Same 270 | b1 = BoundingBox( 271 | label="image_0.jpg", xmin=1.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=0.5 272 | ) 273 | 274 | assert box == b1 275 | 276 | # Different label 277 | b2 = BoundingBox( 278 | label="image_1.jpg", xmin=1.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=0.5 279 | ) 280 | 281 | assert box != b2 282 | 283 | # Different coords 284 | b3 = BoundingBox( 285 | label="image_0.jpg", xmin=0.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=0.5 286 | ) 287 | 288 | assert box != b3 289 | 290 | # Different confidence 291 | b4 = BoundingBox( 292 | label="image_0.jpg", xmin=1.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=0.25 293 | ) 294 | 295 | assert box != b4 296 | 297 | # No confidence 298 | b5 = BoundingBox( 299 | label="image_0.jpg", xmin=1.0, ymin=2.0, xmax=4.0, ymax=8.0, confidence=None 300 | ) 301 | 302 | assert box != b5 303 | 304 | # Different object 305 | with pytest.raises(NotImplementedError): 306 | _ = box == "Different object" 307 | -------------------------------------------------------------------------------- /tests/test_coco_evaluation.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | 3 | import pytest 4 | 5 | from globox import Annotation, AnnotationSet, BoundingBox, COCOEvaluator 6 | 7 | from . import constants as C 8 | 9 | 10 | @pytest.fixture 11 | def coco_evaluator() -> COCOEvaluator: 12 | coco_gt = AnnotationSet.from_coco(C.coco_gts_path) 13 | coco_det = coco_gt.from_results(C.coco_results_path) 14 | 15 | return COCOEvaluator( 16 | ground_truths=coco_gt, 17 | predictions=coco_det, 18 | ) 19 | 20 | 21 | def test_evaluation(coco_evaluator: COCOEvaluator): 22 | # Official figures returned by pycocotools (see pycocotools_results.py) 23 | assert isclose(coco_evaluator.ap(), 0.503647, abs_tol=1e-6) 24 | assert isclose(coco_evaluator.ap_50(), 0.696973, abs_tol=1e-6) 25 | assert isclose(coco_evaluator.ap_75(), 0.571667, abs_tol=1e-6) 26 | 27 | assert isclose(coco_evaluator.ap_small(), 0.593252, abs_tol=1e-6) 28 | assert isclose(coco_evaluator.ap_medium(), 0.557991, abs_tol=1e-6) 29 | assert isclose(coco_evaluator.ap_large(), 0.489363, abs_tol=1e-6) 30 | 31 | assert isclose(coco_evaluator.ar_1(), 0.386813, abs_tol=1e-6) 32 | assert isclose(coco_evaluator.ar_10(), 0.593680, abs_tol=1e-6) 33 | assert isclose(coco_evaluator.ar_100(), 0.595353, abs_tol=1e-6) 34 | 35 | assert isclose(coco_evaluator.ar_small(), 0.654764, abs_tol=1e-6) 36 | assert isclose(coco_evaluator.ar_medium(), 0.603130, abs_tol=1e-6) 37 | assert isclose(coco_evaluator.ar_large(), 0.553744, abs_tol=1e-6) 38 | 39 | assert coco_evaluator._cached_evaluate.cache_info().currsize == 60 40 | 41 | 42 | def test_clear_cache(coco_evaluator: COCOEvaluator): 43 | assert coco_evaluator._cached_evaluate.cache_info().currsize == 0 44 | 45 | _ = coco_evaluator.ap_50() 46 | assert coco_evaluator._cached_evaluate.cache_info().currsize != 0 47 | 48 | coco_evaluator.clear_cache() 49 | assert coco_evaluator._cached_evaluate.cache_info().currsize == 0 50 | 51 | 52 | def test_evaluate_defaults(coco_evaluator: COCOEvaluator): 53 | ev1 = coco_evaluator.evaluate(iou_threshold=0.5) 54 | ev2 = coco_evaluator.ap_50_evaluation() 55 | 56 | # Equality instead of `isclose` since the evaluation default 57 | # should exactly be `ap_50_evaluation` and should be retreived 58 | # from the `COCOEvaluator` cache. 59 | assert ev1.ap() == ev2.ap() 60 | assert ev1.ar() == ev2.ar() 61 | 62 | 63 | def test_evaluation_invariance_to_bboxes_order(): 64 | gts = AnnotationSet( 65 | annotations=[ 66 | Annotation( 67 | "img_1", 68 | image_size=(100, 100), 69 | boxes=[ 70 | BoundingBox(label="cat", xmin=0, ymin=0, xmax=3, ymax=3), 71 | BoundingBox(label="cat", xmin=1, ymin=0, xmax=4, ymax=3), 72 | ], 73 | ) 74 | ] 75 | ) 76 | 77 | dets_1 = AnnotationSet( 78 | annotations=[ 79 | Annotation( 80 | "img_1", 81 | image_size=(100, 100), 82 | boxes=[ 83 | BoundingBox( 84 | label="cat", xmin=-1, ymin=0, xmax=2, ymax=3, confidence=0.6 85 | ), 86 | BoundingBox( 87 | label="cat", xmin=0, ymin=0, xmax=3, ymax=3, confidence=0.5 88 | ), 89 | ], 90 | ) 91 | ] 92 | ) 93 | 94 | dets_2 = AnnotationSet( 95 | annotations=[ 96 | Annotation( 97 | "img_1", 98 | image_size=(100, 100), 99 | boxes=[ 100 | BoundingBox( 101 | label="cat", xmin=0, ymin=0, xmax=3, ymax=3, confidence=0.5 102 | ), 103 | BoundingBox( 104 | label="cat", xmin=-1, ymin=0, xmax=2, ymax=3, confidence=0.6 105 | ), 106 | ], 107 | ) 108 | ] 109 | ) 110 | 111 | evaluator_1 = COCOEvaluator(ground_truths=gts, predictions=dets_1) 112 | ap_1 = evaluator_1.ap_50() 113 | evaluator_2 = COCOEvaluator(ground_truths=gts, predictions=dets_2) 114 | ap_2 = evaluator_2.ap_50() 115 | 116 | assert isclose(ap_1, ap_2) 117 | 118 | 119 | def test_predictions_missing_confidence(): 120 | gts = AnnotationSet( 121 | annotations=[ 122 | Annotation( 123 | "img_1", 124 | image_size=(100, 100), 125 | boxes=[ 126 | BoundingBox(label="cat", xmin=0, ymin=0, xmax=3, ymax=3), 127 | ], 128 | ) 129 | ] 130 | ) 131 | 132 | dets = AnnotationSet( 133 | annotations=[ 134 | Annotation( 135 | "img_1", 136 | image_size=(100, 100), 137 | boxes=[ 138 | BoundingBox(label="cat", xmin=0, ymin=0, xmax=3, ymax=3), 139 | ], 140 | ) 141 | ] 142 | ) 143 | 144 | with pytest.raises(AssertionError): 145 | evaluator = COCOEvaluator(ground_truths=gts, predictions=dets) 146 | _ = evaluator.ap_50() 147 | 148 | 149 | def test_evaluator_params(coco_evaluator: COCOEvaluator): 150 | with pytest.raises(AssertionError): 151 | coco_evaluator.evaluate(iou_threshold=1.1) 152 | 153 | with pytest.raises(AssertionError): 154 | coco_evaluator.evaluate(iou_threshold=-0.1) 155 | 156 | with pytest.raises(AssertionError): 157 | coco_evaluator.evaluate( 158 | iou_threshold=0.5, 159 | max_detections=-1, 160 | ) 161 | 162 | with pytest.raises(AssertionError): 163 | coco_evaluator.evaluate(iou_threshold=0.5, size_range=(2.0, 1.0)) 164 | 165 | with pytest.raises(AssertionError): 166 | coco_evaluator.evaluate(iou_threshold=0.5, size_range=(-1.0, 10.0)) 167 | -------------------------------------------------------------------------------- /tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from globox import AnnotationSet 4 | from globox.utils import all_equal 5 | 6 | from .constants import coco2_path, id_to_label, image_folder, label_to_id, labels 7 | 8 | 9 | def test_conversion(tmp_path: Path): 10 | txt_dir = tmp_path / "txt/" 11 | yolo_dir = tmp_path / "yolo/" 12 | xml_dir = tmp_path / "xml/" 13 | cvat_path = tmp_path / "cvat.xml" 14 | coco_path = tmp_path / "coco.json" 15 | labelme_dir = tmp_path / "labelme" 16 | openimage_path = tmp_path / "openimage.csv" 17 | via_json_path = tmp_path / "via_json.json" 18 | 19 | boxes = AnnotationSet.from_coco(file_path=coco2_path) 20 | 21 | boxes.save_txt(txt_dir) 22 | boxes.save_yolo_darknet(yolo_dir, label_to_id=label_to_id) 23 | boxes.save_xml(xml_dir) 24 | boxes.save_cvat(cvat_path) 25 | boxes.save_coco(coco_path) 26 | boxes.save_labelme(labelme_dir) 27 | boxes.save_openimage(openimage_path) 28 | boxes.save_via_json(via_json_path, image_folder=image_folder) 29 | 30 | dets_sets = [ 31 | AnnotationSet.from_txt(txt_dir, image_folder=image_folder), 32 | AnnotationSet.from_yolo_darknet(yolo_dir, image_folder=image_folder).map_labels( 33 | id_to_label 34 | ), 35 | AnnotationSet.from_xml(xml_dir), 36 | AnnotationSet.from_cvat(cvat_path), 37 | AnnotationSet.from_coco(coco_path), 38 | AnnotationSet.from_labelme(labelme_dir), 39 | AnnotationSet.from_openimage(openimage_path, image_folder=image_folder), 40 | AnnotationSet.from_via_json(via_json_path, image_folder=image_folder), 41 | ] 42 | 43 | all_sets = dets_sets 44 | 45 | assert all_equal(s.image_ids for s in dets_sets) 46 | assert all_equal(len(s) for s in dets_sets) 47 | 48 | for image_id in all_sets[0].image_ids: 49 | assert all_equal(len(d[image_id].boxes) for d in dets_sets) 50 | assert all_equal(d[image_id].image_size for d in dets_sets) 51 | 52 | for i, s in enumerate(all_sets): 53 | assert s._labels() == labels, f"{i}" 54 | for annotation in s: 55 | assert isinstance(annotation.image_id, str) 56 | assert isinstance(annotation.boxes, list) 57 | for box in annotation.boxes: 58 | assert isinstance(box.label, str) 59 | assert all(isinstance(c, float) for c in box.ltrb) 60 | -------------------------------------------------------------------------------- /tests/test_evaluation.py: -------------------------------------------------------------------------------- 1 | # Test if inveting gts & dets raises an error 2 | # Test things with labels 3 | # Etc... 4 | # Test EvaluationItem and similar for invariants 5 | # Test AP computation 6 | 7 | 8 | def test_evaluation_item(): ... 9 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | from globox import AnnotationSet, BoxFormat 2 | from globox.utils import all_equal 3 | 4 | from . import constants as C 5 | 6 | 7 | def tests_parsing(): 8 | coco1_set = AnnotationSet.from_coco(C.coco1_path) 9 | coco2_set = AnnotationSet.from_coco(C.coco2_path) 10 | coco3_set = AnnotationSet.from_coco(C.coco_str_id_path) 11 | coco_gts_set = AnnotationSet.from_coco(C.coco_gts_path) 12 | yolo_set = AnnotationSet.from_yolo_darknet( 13 | C.yolo_path, image_folder=C.image_folder 14 | ).map_labels(C.id_to_label) 15 | yolo_seg_set = AnnotationSet.from_yolo_seg( 16 | folder=C.yolo_seg_path, image_folder=C.image_folder 17 | ).map_labels(C.id_to_label) 18 | cvat_set = AnnotationSet.from_cvat(C.cvat_path) 19 | imagenet_set = AnnotationSet.from_imagenet(C.imagenet_path) 20 | labelme_set = AnnotationSet.from_labelme(C.labelme_path) 21 | labelme_poly_set = AnnotationSet.from_labelme( 22 | C.labelme_poly_path, include_poly=True 23 | ) 24 | openimage_set = AnnotationSet.from_openimage( 25 | C.openimage_path, image_folder=C.image_folder 26 | ) 27 | pascal_set = AnnotationSet.from_pascal_voc(C.pascal_path) 28 | via_json_set = AnnotationSet.from_via_json( 29 | C.via_json_path, image_folder=C.image_folder 30 | ) 31 | 32 | abs_ltrb_set = AnnotationSet.from_txt( 33 | C.abs_ltrb, image_folder=C.image_folder 34 | ).map_labels(C.id_to_label) 35 | abs_ltwh_set = AnnotationSet.from_txt( 36 | C.abs_ltwh, image_folder=C.image_folder, box_format=BoxFormat.LTWH 37 | ).map_labels(C.id_to_label) 38 | rel_ltwh_set = AnnotationSet.from_txt( 39 | C.rel_ltwh, 40 | image_folder=C.image_folder, 41 | box_format=BoxFormat.LTWH, 42 | relative=True, 43 | ).map_labels(C.id_to_label) 44 | _ = coco_gts_set.from_results(C.coco_results_path) 45 | 46 | dets_sets = [abs_ltrb_set, abs_ltwh_set, rel_ltwh_set] 47 | gts_sets = [ 48 | coco1_set, 49 | coco2_set, 50 | coco3_set, 51 | yolo_set, 52 | yolo_seg_set, 53 | cvat_set, 54 | imagenet_set, 55 | labelme_set, 56 | labelme_poly_set, 57 | openimage_set, 58 | pascal_set, 59 | via_json_set, 60 | ] 61 | all_sets = dets_sets + gts_sets 62 | 63 | assert all_equal(s.image_ids for s in gts_sets) 64 | assert all_equal(s.image_ids for s in dets_sets) 65 | assert all_equal(len(s) for s in dets_sets) 66 | assert all_equal(len(s) for s in gts_sets) 67 | 68 | for image_id in all_sets[0].image_ids: 69 | assert all_equal(len(d[image_id].boxes) for d in dets_sets) 70 | assert all_equal(d[image_id].image_size for d in dets_sets) 71 | assert all_equal(len(d[image_id].boxes) for d in gts_sets) 72 | assert all_equal(d[image_id].image_size for d in gts_sets) 73 | 74 | for i, s in enumerate(all_sets): 75 | assert s._labels() == C.labels 76 | for annotation in s: 77 | assert isinstance(annotation.image_id, str) 78 | assert isinstance(annotation.boxes, list) 79 | for box in annotation.boxes: 80 | assert isinstance(box.label, str) 81 | assert all(isinstance(c, float) for c in box.ltrb) 82 | 83 | for s in dets_sets: 84 | for b in s.all_boxes: 85 | assert isinstance(b._confidence, float) and 0 <= b._confidence <= 1 86 | 87 | for i, s in enumerate(gts_sets): 88 | for b in s.all_boxes: 89 | assert b._confidence is None, f"dataset: {i}, Conf: {type(b._confidence)}" 90 | --------------------------------------------------------------------------------