From 09a9509278dc8f6255a742a1392080042d4f6fd7 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 11 Sep 2020 17:07:15 +0300 Subject: [PATCH] Install Datumaro as package (#2163) * Remove Datumaro sources * Install Datumaro as package * Update Datumaro links * fix * remove unnecessary dependencies * Update travis * update coverage config --- .coveragerc | 3 - .travis.yml | 4 +- .vscode/python.env | 1 - .vscode/settings.json | 6 - Dockerfile | 5 - Dockerfile.ci | 2 +- README.md | 5 +- .../formats/datumaro/__init__.py | 11 +- .../datumaro/export_templates/README.md | 4 +- cvat/requirements/base.txt | 3 +- cvat/settings/base.py | 3 - datumaro/.gitignore | 1 - datumaro/CONTRIBUTING.md | 75 - datumaro/LICENSE | 22 - datumaro/README.md | 205 --- datumaro/datum.py | 8 - datumaro/datumaro/__init__.py | 4 - datumaro/datumaro/__main__.py | 12 - datumaro/datumaro/cli/__init__.py | 4 - datumaro/datumaro/cli/__main__.py | 125 -- datumaro/datumaro/cli/commands/__init__.py | 6 - datumaro/datumaro/cli/commands/add.py | 8 - datumaro/datumaro/cli/commands/convert.py | 137 -- datumaro/datumaro/cli/commands/create.py | 8 - datumaro/datumaro/cli/commands/explain.py | 183 -- datumaro/datumaro/cli/commands/export.py | 8 - datumaro/datumaro/cli/commands/merge.py | 124 -- datumaro/datumaro/cli/commands/remove.py | 8 - datumaro/datumaro/cli/contexts/__init__.py | 6 - .../datumaro/cli/contexts/item/__init__.py | 36 - .../datumaro/cli/contexts/model/__init__.py | 183 -- .../datumaro/cli/contexts/project/__init__.py | 826 --------- .../datumaro/cli/contexts/project/diff.py | 290 ---- .../datumaro/cli/contexts/source/__init__.py | 273 --- datumaro/datumaro/cli/util/__init__.py | 74 - datumaro/datumaro/cli/util/project.py | 39 - datumaro/datumaro/components/__init__.py | 5 - .../components/algorithms/__init__.py | 5 - .../datumaro/components/algorithms/rise.py | 203 --- datumaro/datumaro/components/cli_plugin.py | 44 - datumaro/datumaro/components/config.py | 237 --- datumaro/datumaro/components/config_model.py | 63 - datumaro/datumaro/components/converter.py | 79 - .../datumaro/components/dataset_filter.py | 261 --- datumaro/datumaro/components/extractor.py | 621 ------- datumaro/datumaro/components/launcher.py | 67 - datumaro/datumaro/components/operations.py | 1504 ----------------- datumaro/datumaro/components/project.py | 850 ---------- datumaro/datumaro/plugins/__init__.py | 0 .../accuracy_checker_plugin/__init__.py | 4 - .../accuracy_checker_plugin/details/ac.py | 116 -- .../details/representation.py | 62 - .../accuracy_checker_plugin/launcher.py | 37 - .../datumaro/plugins/coco_format/__init__.py | 0 .../datumaro/plugins/coco_format/converter.py | 596 ------- .../datumaro/plugins/coco_format/extractor.py | 261 --- .../datumaro/plugins/coco_format/format.py | 23 - .../datumaro/plugins/coco_format/importer.py | 95 -- .../datumaro/plugins/cvat_format/__init__.py | 0 .../datumaro/plugins/cvat_format/converter.py | 331 ---- .../datumaro/plugins/cvat_format/extractor.py | 316 ---- .../datumaro/plugins/cvat_format/format.py | 9 - .../datumaro/plugins/cvat_format/importer.py | 51 - .../plugins/datumaro_format/__init__.py | 0 .../plugins/datumaro_format/converter.py | 261 --- .../plugins/datumaro_format/extractor.py | 157 -- .../plugins/datumaro_format/format.py | 12 - .../plugins/datumaro_format/importer.py | 56 - datumaro/datumaro/plugins/image_dir.py | 76 - datumaro/datumaro/plugins/labelme_format.py | 437 ----- datumaro/datumaro/plugins/mot_format.py | 314 ---- .../datumaro/plugins/openvino_launcher.py | 188 --- .../tf_detection_api_format/__init__.py | 0 .../tf_detection_api_format/converter.py | 217 --- .../tf_detection_api_format/extractor.py | 195 --- .../plugins/tf_detection_api_format/format.py | 13 - .../tf_detection_api_format/importer.py | 52 - datumaro/datumaro/plugins/transforms.py | 524 ------ .../datumaro/plugins/voc_format/__init__.py | 0 .../datumaro/plugins/voc_format/converter.py | 590 ------- .../datumaro/plugins/voc_format/extractor.py | 302 ---- .../datumaro/plugins/voc_format/format.py | 206 --- .../datumaro/plugins/voc_format/importer.py | 56 - .../datumaro/plugins/yolo_format/__init__.py | 0 .../datumaro/plugins/yolo_format/converter.py | 108 -- .../datumaro/plugins/yolo_format/extractor.py | 201 --- .../datumaro/plugins/yolo_format/format.py | 11 - .../datumaro/plugins/yolo_format/importer.py | 46 - datumaro/datumaro/util/__init__.py | 93 - datumaro/datumaro/util/annotation_util.py | 212 --- datumaro/datumaro/util/attrs_util.py | 33 - datumaro/datumaro/util/command_targets.py | 113 -- datumaro/datumaro/util/image.py | 246 --- datumaro/datumaro/util/image_cache.py | 42 - datumaro/datumaro/util/log_utils.py | 16 - datumaro/datumaro/util/mask_tools.py | 289 ---- datumaro/datumaro/util/os_util.py | 17 - datumaro/datumaro/util/test_utils.py | 121 -- datumaro/datumaro/util/tf_util.py | 80 - datumaro/datumaro/version.py | 1 - datumaro/docs/cli_design.mm | 65 - datumaro/docs/design.md | 185 -- datumaro/docs/developer_guide.md | 200 --- datumaro/docs/images/cli_design.png | Bin 35845 -> 0 bytes datumaro/docs/images/mvvm.png | Bin 30318 -> 0 bytes datumaro/docs/user_manual.md | 1003 ----------- datumaro/requirements.txt | 12 - datumaro/setup.py | 73 - datumaro/tests/__init__.py | 0 .../annotations/instances_val.json | 59 - .../coco_dataset/images/val/000000000001.jpg | Bin 631 -> 0 bytes .../cvat_dataset/for_images/images/img0.jpg | Bin 631 -> 0 bytes .../cvat_dataset/for_images/images/img1.jpg | Bin 631 -> 0 bytes .../assets/cvat_dataset/for_images/train.xml | 45 - .../cvat_dataset/for_video/annotations.xml | 92 - .../for_video/images/frame_000010.png | Bin 111 -> 0 bytes .../for_video/images/frame_000013.png | Bin 111 -> 0 bytes .../labelme_dataset/Masks/img1_mask_1.png | Bin 211 -> 0 bytes .../labelme_dataset/Masks/img1_mask_5.png | Bin 388 -> 0 bytes .../Scribbles/img1_scribble_1.png | Bin 206 -> 0 bytes .../Scribbles/img1_scribble_5.png | Bin 387 -> 0 bytes .../tests/assets/labelme_dataset/img1.png | Bin 215 -> 0 bytes .../tests/assets/labelme_dataset/img1.xml | 1 - datumaro/tests/assets/mot_dataset/gt/gt.txt | 1 - .../tests/assets/mot_dataset/gt/labels.txt | 10 - .../tests/assets/mot_dataset/img1/000001.jpg | Bin 631 -> 0 bytes .../tests/assets/pytorch_launcher/__init__.py | 0 .../assets/pytorch_launcher/model_config.yml | 37 - .../assets/pytorch_launcher/samplenet.pth | Bin 249564 -> 0 bytes .../assets/pytorch_launcher/samplenet.py | 38 - .../tf_detection_api_dataset/label_map.pbtxt | 50 - .../tf_detection_api_dataset/test.tfrecord | Bin 803 -> 0 bytes .../tf_detection_api_dataset/train.tfrecord | Bin 1067 -> 0 bytes .../tf_detection_api_dataset/val.tfrecord | Bin 1022 -> 0 bytes .../voc_dataset/Annotations/2007_000001.xml | 54 - .../voc_dataset/ImageSets/Action/test.txt | 1 - .../voc_dataset/ImageSets/Action/train.txt | 1 - .../voc_dataset/ImageSets/Layout/test.txt | 1 - .../voc_dataset/ImageSets/Layout/train.txt | 1 - .../ImageSets/Main/aeroplane_train.txt | 1 - .../ImageSets/Main/background_train.txt | 1 - .../ImageSets/Main/bicycle_train.txt | 1 - .../voc_dataset/ImageSets/Main/bird_train.txt | 1 - .../voc_dataset/ImageSets/Main/boat_train.txt | 1 - .../ImageSets/Main/bottle_train.txt | 1 - .../voc_dataset/ImageSets/Main/bus_train.txt | 1 - .../voc_dataset/ImageSets/Main/car_train.txt | 1 - .../voc_dataset/ImageSets/Main/cat_train.txt | 1 - .../ImageSets/Main/chair_train.txt | 1 - .../voc_dataset/ImageSets/Main/cow_train.txt | 1 - .../ImageSets/Main/diningtable_train.txt | 1 - .../voc_dataset/ImageSets/Main/dog_train.txt | 1 - .../ImageSets/Main/horse_train.txt | 1 - .../ImageSets/Main/ignored_train.txt | 1 - .../ImageSets/Main/motorbike_train.txt | 1 - .../ImageSets/Main/person_train.txt | 1 - .../ImageSets/Main/pottedplant_train.txt | 1 - .../ImageSets/Main/sheep_train.txt | 1 - .../voc_dataset/ImageSets/Main/sofa_train.txt | 1 - .../voc_dataset/ImageSets/Main/test.txt | 1 - .../voc_dataset/ImageSets/Main/train.txt | 1 - .../ImageSets/Main/train_train.txt | 1 - .../ImageSets/Main/tvmonitor_train.txt | 1 - .../ImageSets/Segmentation/test.txt | 1 - .../ImageSets/Segmentation/train.txt | 1 - .../voc_dataset/JPEGImages/2007_000002.jpg | Bin 635 -> 0 bytes .../SegmentationClass/2007_000001.png | Bin 87 -> 0 bytes .../SegmentationObject/2007_000001.png | Bin 82 -> 0 bytes datumaro/tests/assets/yolo_dataset/obj.data | 4 - datumaro/tests/assets/yolo_dataset/obj.names | 10 - .../assets/yolo_dataset/obj_train_data/1.jpg | Bin 631 -> 0 bytes .../assets/yolo_dataset/obj_train_data/1.txt | 2 - datumaro/tests/assets/yolo_dataset/train.txt | 1 - datumaro/tests/test_RISE.py | 231 --- datumaro/tests/test_coco_format.py | 479 ------ datumaro/tests/test_command_targets.py | 128 -- datumaro/tests/test_cvat_format.py | 278 --- datumaro/tests/test_datumaro_format.py | 108 -- datumaro/tests/test_diff.py | 251 --- datumaro/tests/test_image.py | 64 - datumaro/tests/test_image_dir_format.py | 48 - datumaro/tests/test_images.py | 81 - datumaro/tests/test_labelme_format.py | 206 --- datumaro/tests/test_masks.py | 197 --- datumaro/tests/test_mot_format.py | 136 -- datumaro/tests/test_ops.py | 451 ----- datumaro/tests/test_project.py | 549 ------ datumaro/tests/test_tfrecord_format.py | 210 --- datumaro/tests/test_transforms.py | 415 ----- datumaro/tests/test_voc_format.py | 677 -------- datumaro/tests/test_yolo_format.py | 140 -- 191 files changed, 11 insertions(+), 20447 deletions(-) delete mode 100644 .vscode/python.env delete mode 100644 datumaro/.gitignore delete mode 100644 datumaro/CONTRIBUTING.md delete mode 100644 datumaro/LICENSE delete mode 100644 datumaro/README.md delete mode 100755 datumaro/datum.py delete mode 100644 datumaro/datumaro/__init__.py delete mode 100644 datumaro/datumaro/__main__.py delete mode 100644 datumaro/datumaro/cli/__init__.py delete mode 100644 datumaro/datumaro/cli/__main__.py delete mode 100644 datumaro/datumaro/cli/commands/__init__.py delete mode 100644 datumaro/datumaro/cli/commands/add.py delete mode 100644 datumaro/datumaro/cli/commands/convert.py delete mode 100644 datumaro/datumaro/cli/commands/create.py delete mode 100644 datumaro/datumaro/cli/commands/explain.py delete mode 100644 datumaro/datumaro/cli/commands/export.py delete mode 100644 datumaro/datumaro/cli/commands/merge.py delete mode 100644 datumaro/datumaro/cli/commands/remove.py delete mode 100644 datumaro/datumaro/cli/contexts/__init__.py delete mode 100644 datumaro/datumaro/cli/contexts/item/__init__.py delete mode 100644 datumaro/datumaro/cli/contexts/model/__init__.py delete mode 100644 datumaro/datumaro/cli/contexts/project/__init__.py delete mode 100644 datumaro/datumaro/cli/contexts/project/diff.py delete mode 100644 datumaro/datumaro/cli/contexts/source/__init__.py delete mode 100644 datumaro/datumaro/cli/util/__init__.py delete mode 100644 datumaro/datumaro/cli/util/project.py delete mode 100644 datumaro/datumaro/components/__init__.py delete mode 100644 datumaro/datumaro/components/algorithms/__init__.py delete mode 100644 datumaro/datumaro/components/algorithms/rise.py delete mode 100644 datumaro/datumaro/components/cli_plugin.py delete mode 100644 datumaro/datumaro/components/config.py delete mode 100644 datumaro/datumaro/components/config_model.py delete mode 100644 datumaro/datumaro/components/converter.py delete mode 100644 datumaro/datumaro/components/dataset_filter.py delete mode 100644 datumaro/datumaro/components/extractor.py delete mode 100644 datumaro/datumaro/components/launcher.py delete mode 100644 datumaro/datumaro/components/operations.py delete mode 100644 datumaro/datumaro/components/project.py delete mode 100644 datumaro/datumaro/plugins/__init__.py delete mode 100644 datumaro/datumaro/plugins/accuracy_checker_plugin/__init__.py delete mode 100644 datumaro/datumaro/plugins/accuracy_checker_plugin/details/ac.py delete mode 100644 datumaro/datumaro/plugins/accuracy_checker_plugin/details/representation.py delete mode 100644 datumaro/datumaro/plugins/accuracy_checker_plugin/launcher.py delete mode 100644 datumaro/datumaro/plugins/coco_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/coco_format/converter.py delete mode 100644 datumaro/datumaro/plugins/coco_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/coco_format/format.py delete mode 100644 datumaro/datumaro/plugins/coco_format/importer.py delete mode 100644 datumaro/datumaro/plugins/cvat_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/cvat_format/converter.py delete mode 100644 datumaro/datumaro/plugins/cvat_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/cvat_format/format.py delete mode 100644 datumaro/datumaro/plugins/cvat_format/importer.py delete mode 100644 datumaro/datumaro/plugins/datumaro_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/datumaro_format/converter.py delete mode 100644 datumaro/datumaro/plugins/datumaro_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/datumaro_format/format.py delete mode 100644 datumaro/datumaro/plugins/datumaro_format/importer.py delete mode 100644 datumaro/datumaro/plugins/image_dir.py delete mode 100644 datumaro/datumaro/plugins/labelme_format.py delete mode 100644 datumaro/datumaro/plugins/mot_format.py delete mode 100644 datumaro/datumaro/plugins/openvino_launcher.py delete mode 100644 datumaro/datumaro/plugins/tf_detection_api_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/tf_detection_api_format/converter.py delete mode 100644 datumaro/datumaro/plugins/tf_detection_api_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/tf_detection_api_format/format.py delete mode 100644 datumaro/datumaro/plugins/tf_detection_api_format/importer.py delete mode 100644 datumaro/datumaro/plugins/transforms.py delete mode 100644 datumaro/datumaro/plugins/voc_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/voc_format/converter.py delete mode 100644 datumaro/datumaro/plugins/voc_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/voc_format/format.py delete mode 100644 datumaro/datumaro/plugins/voc_format/importer.py delete mode 100644 datumaro/datumaro/plugins/yolo_format/__init__.py delete mode 100644 datumaro/datumaro/plugins/yolo_format/converter.py delete mode 100644 datumaro/datumaro/plugins/yolo_format/extractor.py delete mode 100644 datumaro/datumaro/plugins/yolo_format/format.py delete mode 100644 datumaro/datumaro/plugins/yolo_format/importer.py delete mode 100644 datumaro/datumaro/util/__init__.py delete mode 100644 datumaro/datumaro/util/annotation_util.py delete mode 100644 datumaro/datumaro/util/attrs_util.py delete mode 100644 datumaro/datumaro/util/command_targets.py delete mode 100644 datumaro/datumaro/util/image.py delete mode 100644 datumaro/datumaro/util/image_cache.py delete mode 100644 datumaro/datumaro/util/log_utils.py delete mode 100644 datumaro/datumaro/util/mask_tools.py delete mode 100644 datumaro/datumaro/util/os_util.py delete mode 100644 datumaro/datumaro/util/test_utils.py delete mode 100644 datumaro/datumaro/util/tf_util.py delete mode 100644 datumaro/datumaro/version.py delete mode 100644 datumaro/docs/cli_design.mm delete mode 100644 datumaro/docs/design.md delete mode 100644 datumaro/docs/developer_guide.md delete mode 100644 datumaro/docs/images/cli_design.png delete mode 100644 datumaro/docs/images/mvvm.png delete mode 100644 datumaro/docs/user_manual.md delete mode 100644 datumaro/requirements.txt delete mode 100644 datumaro/setup.py delete mode 100644 datumaro/tests/__init__.py delete mode 100644 datumaro/tests/assets/coco_dataset/annotations/instances_val.json delete mode 100644 datumaro/tests/assets/coco_dataset/images/val/000000000001.jpg delete mode 100644 datumaro/tests/assets/cvat_dataset/for_images/images/img0.jpg delete mode 100644 datumaro/tests/assets/cvat_dataset/for_images/images/img1.jpg delete mode 100644 datumaro/tests/assets/cvat_dataset/for_images/train.xml delete mode 100644 datumaro/tests/assets/cvat_dataset/for_video/annotations.xml delete mode 100644 datumaro/tests/assets/cvat_dataset/for_video/images/frame_000010.png delete mode 100644 datumaro/tests/assets/cvat_dataset/for_video/images/frame_000013.png delete mode 100644 datumaro/tests/assets/labelme_dataset/Masks/img1_mask_1.png delete mode 100644 datumaro/tests/assets/labelme_dataset/Masks/img1_mask_5.png delete mode 100644 datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_1.png delete mode 100644 datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png delete mode 100644 datumaro/tests/assets/labelme_dataset/img1.png delete mode 100644 datumaro/tests/assets/labelme_dataset/img1.xml delete mode 100644 datumaro/tests/assets/mot_dataset/gt/gt.txt delete mode 100644 datumaro/tests/assets/mot_dataset/gt/labels.txt delete mode 100644 datumaro/tests/assets/mot_dataset/img1/000001.jpg delete mode 100644 datumaro/tests/assets/pytorch_launcher/__init__.py delete mode 100644 datumaro/tests/assets/pytorch_launcher/model_config.yml delete mode 100644 datumaro/tests/assets/pytorch_launcher/samplenet.pth delete mode 100644 datumaro/tests/assets/pytorch_launcher/samplenet.py delete mode 100644 datumaro/tests/assets/tf_detection_api_dataset/label_map.pbtxt delete mode 100644 datumaro/tests/assets/tf_detection_api_dataset/test.tfrecord delete mode 100644 datumaro/tests/assets/tf_detection_api_dataset/train.tfrecord delete mode 100644 datumaro/tests/assets/tf_detection_api_dataset/val.tfrecord delete mode 100644 datumaro/tests/assets/voc_dataset/Annotations/2007_000001.xml delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Action/test.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Action/train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Layout/test.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Layout/train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/aeroplane_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/background_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/bicycle_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/bird_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/boat_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/bottle_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/bus_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/car_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/cat_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/chair_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/cow_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/diningtable_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/dog_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/horse_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/ignored_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/motorbike_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/person_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/pottedplant_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/sheep_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/sofa_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/test.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/train_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Main/tvmonitor_train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/test.txt delete mode 100644 datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/train.txt delete mode 100644 datumaro/tests/assets/voc_dataset/JPEGImages/2007_000002.jpg delete mode 100644 datumaro/tests/assets/voc_dataset/SegmentationClass/2007_000001.png delete mode 100644 datumaro/tests/assets/voc_dataset/SegmentationObject/2007_000001.png delete mode 100644 datumaro/tests/assets/yolo_dataset/obj.data delete mode 100644 datumaro/tests/assets/yolo_dataset/obj.names delete mode 100644 datumaro/tests/assets/yolo_dataset/obj_train_data/1.jpg delete mode 100644 datumaro/tests/assets/yolo_dataset/obj_train_data/1.txt delete mode 100644 datumaro/tests/assets/yolo_dataset/train.txt delete mode 100644 datumaro/tests/test_RISE.py delete mode 100644 datumaro/tests/test_coco_format.py delete mode 100644 datumaro/tests/test_command_targets.py delete mode 100644 datumaro/tests/test_cvat_format.py delete mode 100644 datumaro/tests/test_datumaro_format.py delete mode 100644 datumaro/tests/test_diff.py delete mode 100644 datumaro/tests/test_image.py delete mode 100644 datumaro/tests/test_image_dir_format.py delete mode 100644 datumaro/tests/test_images.py delete mode 100644 datumaro/tests/test_labelme_format.py delete mode 100644 datumaro/tests/test_masks.py delete mode 100644 datumaro/tests/test_mot_format.py delete mode 100644 datumaro/tests/test_ops.py delete mode 100644 datumaro/tests/test_project.py delete mode 100644 datumaro/tests/test_tfrecord_format.py delete mode 100644 datumaro/tests/test_transforms.py delete mode 100644 datumaro/tests/test_voc_format.py delete mode 100644 datumaro/tests/test_yolo_format.py diff --git a/.coveragerc b/.coveragerc index f174f846..6e95757c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,13 +3,10 @@ branch = true # relative_files = true # does not work? source = - datumaro/datumaro/ cvat/apps/ utils/cli/ omit = - datumaro/datumaro/__main__.py - datumaro/datumaro/version.py cvat/settings/* */tests/* */test_* diff --git a/.travis.yml b/.travis.yml index 9acef6b6..665ca9ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,3 @@ -sudo: required - language: python python: @@ -36,7 +34,7 @@ before_script: script: # FIXME: Git package and application name conflict in PATH and try to leave only one python test execution - - docker-compose -f docker-compose.yml -f docker-compose.ci.yml run cvat_ci /bin/bash -c 'coverage run -a manage.py test cvat/apps && coverage run -a manage.py test --pattern="_test*.py" cvat/apps/dataset_manager/tests cvat/apps/engine/tests utils/cli && coverage run -a manage.py test datumaro/ && mv .coverage ${CONTAINER_COVERAGE_DATA_DIR}' + - docker-compose -f docker-compose.yml -f docker-compose.ci.yml run cvat_ci /bin/bash -c 'coverage run -a manage.py test cvat/apps && coverage run -a manage.py test --pattern="_test*.py" cvat/apps/dataset_manager/tests cvat/apps/engine/tests utils/cli && mv .coverage ${CONTAINER_COVERAGE_DATA_DIR}' - docker-compose -f docker-compose.yml -f docker-compose.ci.yml run cvat_ci /bin/bash -c 'cd cvat-data && npm install && cd ../cvat-core && npm install && npm run test && coveralls-lcov -v -n ./reports/coverage/lcov.info > ${CONTAINER_COVERAGE_DATA_DIR}/coverage.json' # Up all containers - docker-compose up -d diff --git a/.vscode/python.env b/.vscode/python.env deleted file mode 100644 index a624ab13..00000000 --- a/.vscode/python.env +++ /dev/null @@ -1 +0,0 @@ -PYTHONPATH="datumaro/:$PYTHONPATH" \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b796a0b..506e3105 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,13 +25,7 @@ } ], "python.linting.pylintEnabled": true, - "python.envFile": "${workspaceFolder}/.vscode/python.env", "python.testing.unittestEnabled": true, - "python.testing.unittestArgs": [ - "-v", - "-s", - "./datumaro", - ], "licenser.license": "Custom", "licenser.customHeader": "Copyright (C) @YEAR@ Intel Corporation\n\nSPDX-License-Identifier: MIT", "files.trimTrailingWhitespace": true diff --git a/Dockerfile b/Dockerfile index 18fb7f75..844a18c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,8 +73,6 @@ COPY components /tmp/components COPY cvat/requirements/ /tmp/requirements/ COPY supervisord.conf mod_wsgi.conf wait-for-it.sh manage.py ${HOME}/ RUN python3 -m pip install --no-cache-dir -r /tmp/requirements/${DJANGO_CONFIGURATION}.txt -# pycocotools package is impossible to install with its dependencies by one pip install command -RUN python3 -m pip install --no-cache-dir pycocotools==2.0.0 ARG CLAM_AV ENV CLAM_AV=${CLAM_AV} @@ -95,9 +93,6 @@ COPY cvat/ ${HOME}/cvat COPY cvat-core/ ${HOME}/cvat-core COPY cvat-data/ ${HOME}/cvat-data COPY tests ${HOME}/tests -COPY datumaro/ ${HOME}/datumaro - -RUN python3 -m pip install --no-cache-dir -r ${HOME}/datumaro/requirements.txt RUN chown -R ${USER}:${USER} . diff --git a/Dockerfile.ci b/Dockerfile.ci index f65cf36c..b51268dd 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -9,7 +9,7 @@ RUN apt-get update && \ apt-utils \ build-essential \ python3-dev \ - ruby \ + ruby \ && \ curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \ diff --git a/README.md b/README.md index a82492ec..e6e55788 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ annotation team. Try it online [cvat.org](https://cvat.org). ## Supported annotation formats Format selection is possible after clicking on the Upload annotation -and Dump annotation buttons. [Datumaro](datumaro/README.md) dataset +and Dump annotation buttons. +[Datumaro](https://github.com/openvinotoolkit/datumaro/README.md) dataset framework allows additional dataset transformations via its command line tool and Python library. @@ -49,7 +50,7 @@ via its command line tool and Python library. | ------------------------------------------------------------------------------------------ | ------ | ------ | | [CVAT for images](cvat/apps/documentation/xml_format.md#annotation) | X | X | | [CVAT for a video](cvat/apps/documentation/xml_format.md#interpolation) | X | X | -| [Datumaro](datumaro/README.md) | | X | +| [Datumaro](https://github.com/openvinotoolkit/datumaro) | | X | | [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/) | X | X | | Segmentation masks from [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/) | X | X | | [YOLO](https://pjreddie.com/darknet/yolo/) | X | X | diff --git a/cvat/apps/dataset_manager/formats/datumaro/__init__.py b/cvat/apps/dataset_manager/formats/datumaro/__init__.py index 9dd3f9ab..3e5b1e6c 100644 --- a/cvat/apps/dataset_manager/formats/datumaro/__init__.py +++ b/cvat/apps/dataset_manager/formats/datumaro/__init__.py @@ -11,7 +11,7 @@ from tempfile import TemporaryDirectory from cvat.apps.dataset_manager.bindings import (CvatTaskDataExtractor, import_dm_annotations) from cvat.apps.dataset_manager.util import make_zip_archive -from cvat.settings.base import BASE_DIR, DATUMARO_PATH +from cvat.settings.base import BASE_DIR from datumaro.components.project import Project from ..registry import dm_env, exporter @@ -79,14 +79,7 @@ class DatumaroProjectExporter: osp.join(templates_dir, self._REMOTE_IMAGES_EXTRACTOR + '.py'), osp.join(target_dir, self._REMOTE_IMAGES_EXTRACTOR + '.py')) - # Make Datumaro and CVAT CLI modules available to the user - shutil.copytree(DATUMARO_PATH, osp.join(save_dir, 'datumaro'), - ignore=lambda src, names: ['__pycache__'] + [ - n for n in names - if sum([int(n.endswith(ext)) for ext in - ['.pyx', '.pyo', '.pyd', '.pyc']]) - ]) - + # Make CVAT CLI module available to the user cvat_utils_dst_dir = osp.join(save_dir, 'cvat', 'utils') os.makedirs(cvat_utils_dst_dir) shutil.copytree(osp.join(BASE_DIR, 'utils', 'cli'), diff --git a/cvat/apps/dataset_manager/formats/datumaro/export_templates/README.md b/cvat/apps/dataset_manager/formats/datumaro/export_templates/README.md index a375bbdc..9d1e0097 100644 --- a/cvat/apps/dataset_manager/formats/datumaro/export_templates/README.md +++ b/cvat/apps/dataset_manager/formats/datumaro/export_templates/README.md @@ -6,7 +6,7 @@ python -m virtualenv .venv . .venv/bin/activate # install dependencies -pip install -e datumaro/ +pip install 'git+https://github.com/openvinotoolkit/datumaro' pip install -r cvat/utils/cli/requirements.txt # set up environment @@ -17,4 +17,4 @@ export PYTHONPATH datum --help ``` -Check Datumaro [docs](datumaro/README.md) for more info. +Check [Datumaro docs](https://github.com/openvinotoolkit/datumaro/README.md) for more info. diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index d4457948..09fa5fe8 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -44,4 +44,5 @@ tensorflow==2.2.0 # Optional requirement of Datumaro # The package is used by pyunpack as a command line tool to support multiple # archives. Don't use as a python module because it has GPL license. patool==1.12 -diskcache==5.0.2 \ No newline at end of file +diskcache==5.0.2 +git+https://github.com/openvinotoolkit/datumaro@v0.1.0 \ No newline at end of file diff --git a/cvat/settings/base.py b/cvat/settings/base.py index c803b9f3..d05ca390 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -409,9 +409,6 @@ DATA_UPLOAD_MAX_NUMBER_FIELDS = None # this django check disabled LOCAL_LOAD_MAX_FILES_COUNT = 500 LOCAL_LOAD_MAX_FILES_SIZE = 512 * 1024 * 1024 # 512 MB -DATUMARO_PATH = os.path.join(BASE_DIR, 'datumaro') -sys.path.append(DATUMARO_PATH) - RESTRICTIONS = { 'user_agreements': [], diff --git a/datumaro/.gitignore b/datumaro/.gitignore deleted file mode 100644 index 17c3ea8b..00000000 --- a/datumaro/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/datumaro.egg-info diff --git a/datumaro/CONTRIBUTING.md b/datumaro/CONTRIBUTING.md deleted file mode 100644 index f9a1afc1..00000000 --- a/datumaro/CONTRIBUTING.md +++ /dev/null @@ -1,75 +0,0 @@ -## Table of Contents - -- [Installation](#installation) -- [Usage](#usage) -- [Testing](#testing) -- [Design](#design-and-code-structure) - -## Installation - -### Prerequisites - -- Python (3.5+) -- OpenVINO (optional) - -``` bash -git clone https://github.com/opencv/cvat -``` - -Optionally, install a virtual environment: - -``` bash -python -m pip install virtualenv -python -m virtualenv venv -. venv/bin/activate -``` - -Then install all dependencies: - -``` bash -while read -r p; do pip install $p; done < requirements.txt -``` - -If you're working inside CVAT environment: -``` bash -. .env/bin/activate -while read -r p; do pip install $p; done < datumaro/requirements.txt -``` - -## Usage - -> The directory containing Datumaro should be in the `PYTHONPATH` -> environment variable or `cvat/datumaro/` should be the current directory. - -``` bash -datum --help -python -m datumaro --help -python datumaro/ --help -python datum.py --help -``` - -``` python -import datumaro -``` - -## Testing - -It is expected that all Datumaro functionality is covered and checked by -unit tests. Tests are placed in `tests/` directory. - -To run tests use: - -``` bash -python -m unittest discover -s tests -``` - -If you're working inside CVAT environment, you can also use: - -``` bash -python manage.py test datumaro/ -``` - -## Design and code structure - -- [Design document](docs/design.md) -- [Developer guide](docs/developer_guide.md) \ No newline at end of file diff --git a/datumaro/LICENSE b/datumaro/LICENSE deleted file mode 100644 index ae9cf710..00000000 --- a/datumaro/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (C) 2019-2020 Intel Corporation -  -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: -  -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. -  -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. -  diff --git a/datumaro/README.md b/datumaro/README.md deleted file mode 100644 index 2d83cc4d..00000000 --- a/datumaro/README.md +++ /dev/null @@ -1,205 +0,0 @@ -# Dataset Management Framework (Datumaro) - -A framework to build, transform, and analyze datasets. - - -``` -CVAT annotations -- ---> Annotation tool - \ / -COCO-like dataset -----> Datumaro ---> dataset ------> Model training - / \ -VOC-like dataset -- ---> Publication etc. -``` - - -## Contents - -- [Documentation](#documentation) -- [Features](#features) -- [Installation](#installation) -- [Usage](#usage) -- [Examples](#examples) -- [Contributing](#contributing) - -## Documentation - -- [User manual](docs/user_manual.md) -- [Design document](docs/design.md) -- [Contributing](CONTRIBUTING.md) - -## Features - -- Dataset format conversions: - - COCO (`image_info`, `instances`, `person_keypoints`, `captions`, `labels`*) - - [Format specification](http://cocodataset.org/#format-data) - - [Dataset example](tests/assets/coco_dataset) - - `labels` are our extension - like `instances` with only `category_id` - - PASCAL VOC (`classification`, `detection`, `segmentation` (class, instances), `action_classification`, `person_layout`) - - [Format specification](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/htmldoc/index.html) - - [Dataset example](tests/assets/voc_dataset) - - YOLO (`bboxes`) - - [Format specification](https://github.com/AlexeyAB/darknet#how-to-train-pascal-voc-data) - - [Dataset example](tests/assets/yolo_dataset) - - TF Detection API (`bboxes`, `masks`) - - Format specifications: [bboxes](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/using_your_own_dataset.md), [masks](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/instance_segmentation.md) - - [Dataset example](tests/assets/tf_detection_api_dataset) - - MOT sequences - - [Format specification](https://arxiv.org/pdf/1906.04567.pdf) - - [Dataset example](tests/assets/mot_dataset) - - CVAT - - [Format specification](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) - - [Dataset example](tests/assets/cvat_dataset) - - LabelMe - - [Format specification](http://labelme.csail.mit.edu/Release3.0) - - [Dataset example](tests/assets/labelme_dataset) -- Dataset building operations: - - Merging multiple datasets into one - - Dataset filtering with custom conditions, for instance: - - remove polygons of a certain class - - remove images without a specific class - - remove `occluded` annotations from images - - keep only vertically-oriented images - - remove small area bounding boxes from annotations - - Annotation conversions, for instance: - - polygons to instance masks and vise-versa - - apply a custom colormap for mask annotations - - rename or remove dataset labels -- Dataset comparison -- Model integration: - - Inference (OpenVINO and custom models) - - Explainable AI ([RISE algorithm](https://arxiv.org/abs/1806.07421)) - -> Check the [design document](docs/design.md) for a full list of features - -## Installation - -Optionally, create a virtual environment: - -``` bash -python -m pip install virtualenv -python -m virtualenv venv -. venv/bin/activate -``` - -Install Datumaro package: - -``` bash -pip install 'git+https://github.com/opencv/cvat#egg=datumaro&subdirectory=datumaro' -``` - -## Usage - -There are several options available: -- [A standalone command-line tool](#standalone-tool) -- [A python module](#python-module) - -### Standalone tool - - -``` - User - | - v -+------------------+ -| CVAT | -+--------v---------+ +------------------+ +--------------+ -| Datumaro module | ----> | Datumaro project | <---> | Datumaro CLI | <--- User -+------------------+ +------------------+ +--------------+ -``` - - -``` bash -datum --help -python -m datumaro --help -``` - -### Python module - -Datumaro can be used in custom scripts as a library in the following way: - -``` python -from datumaro.components.project import Project # project-related things -import datumaro.components.extractor # annotations and high-level interfaces -# etc. -project = Project.load('directory') -``` - -## Examples - - - - -- Convert [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/index.html#data) to COCO, keep only images with `cat` class presented: - ```bash - # Download VOC dataset: - # http://host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-May-2012.tar - datum convert --input-format voc --input-path \ - --output-format coco --filter '/item[annotation/label="cat"]' - ``` - -- Convert only non-occluded annotations from a CVAT-annotated project to TFrecord: - ```bash - # export Datumaro dataset in CVAT UI, extract somewhere, go to the project dir - datum project extract --filter '/item/annotation[occluded="False"]' \ - --mode items+anno --output-dir not_occluded - datum project export --project not_occluded \ - --format tf_detection_api -- --save-images - ``` - -- Annotate COCO, extract image subset, re-annotate it in CVAT, update old dataset: - ```bash - # Download COCO dataset http://cocodataset.org/#download - # Put images to coco/images/ and annotations to coco/annotations/ - datum project import --format coco --input-path - datum project export --filter '/image[images_I_dont_like]' --format cvat \ - --output-dir reannotation - # import dataset and images to CVAT, re-annotate - # export Datumaro project, extract to 'reannotation-upd' - datum project project merge reannotation-upd - datum project export --format coco - ``` - -- Annotate instance polygons in CVAT, export as masks in COCO: - ```bash - datum convert --input-format cvat --input-path \ - --output-format coco -- --segmentation-mode masks - ``` - -- Apply an OpenVINO detection model to some COCO-like dataset, - then compare annotations with ground truth and visualize in TensorBoard: - ```bash - datum project import --format coco --input-path - # create model results interpretation script - datum model add mymodel openvino \ - --weights model.bin --description model.xml \ - --interpretation-script parse_results.py - datum model run --model mymodel --output-dir mymodel_inference/ - datum project diff mymodel_inference/ --format tensorboard --output-dir diff - ``` - -- Change colors in PASCAL VOC-like `.png` masks: - ```bash - datum project import --format voc --input-path - - # Create a color map file with desired colors: - # - # label : color_rgb : parts : actions - # cat:0,0,255:: - # dog:255,0,0:: - # - # Save as mycolormap.txt - - datum project export --format voc_segmentation -- --label-map mycolormap.txt - # add "--apply-colormap=0" to save grayscale (indexed) masks - # check "--help" option for more info - # use "datum --loglevel debug" for extra conversion info - ``` - - - - -## Contributing - -Feel free to [open an Issue](https://github.com/opencv/cvat/issues/new) if you -think something needs to be changed. You are welcome to participate in development, -development instructions are available in our [developer manual](CONTRIBUTING.md). diff --git a/datumaro/datum.py b/datumaro/datum.py deleted file mode 100755 index 12c150bd..00000000 --- a/datumaro/datum.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python -import sys - -from datumaro.cli.__main__ import main - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/datumaro/datumaro/__init__.py b/datumaro/datumaro/__init__.py deleted file mode 100644 index eb864e52..00000000 --- a/datumaro/datumaro/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT diff --git a/datumaro/datumaro/__main__.py b/datumaro/datumaro/__main__.py deleted file mode 100644 index be1cb092..00000000 --- a/datumaro/datumaro/__main__.py +++ /dev/null @@ -1,12 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import sys - -from datumaro.cli.__main__ import main - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/datumaro/datumaro/cli/__init__.py b/datumaro/datumaro/cli/__init__.py deleted file mode 100644 index eb864e52..00000000 --- a/datumaro/datumaro/cli/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT diff --git a/datumaro/datumaro/cli/__main__.py b/datumaro/datumaro/cli/__main__.py deleted file mode 100644 index 80a8805f..00000000 --- a/datumaro/datumaro/cli/__main__.py +++ /dev/null @@ -1,125 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import sys - -from . import contexts, commands -from .util import CliException, add_subparser -from ..version import VERSION - - -_log_levels = { - 'debug': log.DEBUG, - 'info': log.INFO, - 'warning': log.WARNING, - 'error': log.ERROR, - 'critical': log.CRITICAL -} - -def loglevel(name): - return _log_levels[name] - -class _LogManager: - @classmethod - def init_logger(cls, args=None): - # Define minimalistic parser only to obtain loglevel - parser = argparse.ArgumentParser(add_help=False) - cls._define_loglevel_option(parser) - args, _ = parser.parse_known_args(args) - - log.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', - level=args.loglevel) - - @staticmethod - def _define_loglevel_option(parser): - parser.add_argument('--loglevel', type=loglevel, default='info', - help="Logging level (options: %s; default: %s)" % \ - (', '.join(_log_levels.keys()), "%(default)s")) - return parser - - -def _make_subcommands_help(commands, help_line_start=0): - desc = "" - for command_name, _, command_help in commands: - desc += (" %-" + str(max(0, help_line_start - 2 - 1)) + "s%s\n") % \ - (command_name, command_help) - return desc - -def make_parser(): - parser = argparse.ArgumentParser(prog="datumaro", - description="Dataset Framework", - formatter_class=argparse.RawDescriptionHelpFormatter) - - parser.add_argument('--version', action='version', version=VERSION) - _LogManager._define_loglevel_option(parser) - - known_contexts = [ - ('project', contexts.project, "Actions on projects (datasets)"), - ('source', contexts.source, "Actions on data sources"), - ('model', contexts.model, "Actions on models"), - ] - known_commands = [ - ('create', commands.create, "Create project"), - ('add', commands.add, "Add source to project"), - ('remove', commands.remove, "Remove source from project"), - ('export', commands.export, "Export project"), - ('explain', commands.explain, "Run Explainable AI algorithm for model"), - ('merge', commands.merge, "Merge datasets"), - ('convert', commands.convert, "Convert dataset"), - ] - - # Argparse doesn't support subparser groups: - # https://stackoverflow.com/questions/32017020/grouping-argparse-subparser-arguments - help_line_start = max((len(e[0]) for e in known_contexts + known_commands), - default=0) - help_line_start = max((2 + help_line_start) // 4 + 1, 6) * 4 # align to tabs - subcommands_desc = "" - if known_contexts: - subcommands_desc += "Contexts:\n" - subcommands_desc += _make_subcommands_help(known_contexts, - help_line_start) - if known_commands: - if subcommands_desc: - subcommands_desc += "\n" - subcommands_desc += "Commands:\n" - subcommands_desc += _make_subcommands_help(known_commands, - help_line_start) - if subcommands_desc: - subcommands_desc += \ - "\nRun '%s COMMAND --help' for more information on a command." % \ - parser.prog - - subcommands = parser.add_subparsers(title=subcommands_desc, - description="", help=argparse.SUPPRESS) - for command_name, command, _ in known_contexts + known_commands: - add_subparser(subcommands, command_name, command.build_parser) - - return parser - - -def main(args=None): - _LogManager.init_logger(args) - - parser = make_parser() - args = parser.parse_args(args) - - if 'command' not in args: - parser.print_help() - return 1 - - try: - return args.command(args) - except CliException as e: - log.error(e) - return 1 - except Exception as e: - log.error(e) - raise - - -if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/__init__.py b/datumaro/datumaro/cli/commands/__init__.py deleted file mode 100644 index fe74bc2b..00000000 --- a/datumaro/datumaro/cli/commands/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from . import add, create, explain, export, remove, merge, convert diff --git a/datumaro/datumaro/cli/commands/add.py b/datumaro/datumaro/cli/commands/add.py deleted file mode 100644 index 288d7c04..00000000 --- a/datumaro/datumaro/cli/commands/add.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-import - -from ..contexts.source import build_add_parser as build_parser diff --git a/datumaro/datumaro/cli/commands/convert.py b/datumaro/datumaro/cli/commands/convert.py deleted file mode 100644 index 6398bac7..00000000 --- a/datumaro/datumaro/cli/commands/convert.py +++ /dev/null @@ -1,137 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp - -from datumaro.components.project import Environment - -from ..contexts.project import FilterModes -from ..util import CliException, MultilineFormatter, make_file_name -from ..util.project import generate_next_file_name - - -def build_parser(parser_ctor=argparse.ArgumentParser): - builtin_importers = sorted(Environment().importers.items) - builtin_converters = sorted(Environment().converters.items) - - parser = parser_ctor(help="Convert an existing dataset to another format", - description=""" - Converts a dataset from one format to another. - You can add your own formats using a project.|n - |n - Supported input formats: %s|n - |n - Supported output formats: %s|n - |n - Examples:|n - - Export a dataset as a PASCAL VOC dataset, include images:|n - |s|sconvert -i src/path -f voc -- --save-images|n - |n - - Export a dataset as a COCO dataset to a specific directory:|n - |s|sconvert -i src/path -f coco -o path/I/like/ - """ % (', '.join(builtin_importers), ', '.join(builtin_converters)), - formatter_class=MultilineFormatter) - - parser.add_argument('-i', '--input-path', default='.', dest='source', - help="Path to look for a dataset") - parser.add_argument('-if', '--input-format', - help="Input dataset format. Will try to detect, if not specified.") - parser.add_argument('-f', '--output-format', required=True, - help="Output format") - parser.add_argument('-o', '--output-dir', dest='dst_dir', - help="Directory to save output (default: a subdir in the current one)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-e', '--filter', - help="Filter expression for dataset items") - parser.add_argument('--filter-mode', default=FilterModes.i.name, - type=FilterModes.parse, - help="Filter mode (options: %s; default: %s)" % \ - (', '.join(FilterModes.list_options()) , '%(default)s')) - parser.add_argument('extra_args', nargs=argparse.REMAINDER, - help="Additional arguments for output format (pass '-- -h' for help)") - parser.set_defaults(command=convert_command) - - return parser - -def convert_command(args): - env = Environment() - - try: - converter = env.converters.get(args.output_format) - except KeyError: - raise CliException("Converter for format '%s' is not found" % \ - args.output_format) - extra_args = converter.from_cmdline(args.extra_args) - def converter_proxy(extractor, save_dir): - return converter.convert(extractor, save_dir, **extra_args) - - filter_args = FilterModes.make_filter_args(args.filter_mode) - - if not args.input_format: - matches = [] - for format_name in env.importers.items: - log.debug("Checking '%s' format...", format_name) - importer = env.make_importer(format_name) - try: - match = importer.detect(args.source) - if match: - log.debug("format matched") - matches.append((format_name, importer)) - except NotImplementedError: - log.debug("Format '%s' does not support auto detection.", - format_name) - - if len(matches) == 0: - log.error("Failed to detect dataset format. " - "Try to specify format with '-if/--input-format' parameter.") - return 1 - elif len(matches) != 1: - log.error("Multiple formats match the dataset: %s. " - "Try to specify format with '-if/--input-format' parameter.", - ', '.join(m[0] for m in matches)) - return 2 - - format_name, importer = matches[0] - args.input_format = format_name - log.info("Source dataset format detected as '%s'", args.input_format) - else: - try: - importer = env.make_importer(args.input_format) - if hasattr(importer, 'from_cmdline'): - extra_args = importer.from_cmdline() - except KeyError: - raise CliException("Importer for format '%s' is not found" % \ - args.input_format) - - source = osp.abspath(args.source) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-%s' % \ - (osp.basename(source), make_file_name(args.output_format))) - dst_dir = osp.abspath(dst_dir) - - project = importer(source) - dataset = project.make_dataset() - - log.info("Exporting the dataset") - dataset.export_project( - save_dir=dst_dir, - converter=converter_proxy, - filter_expr=args.filter, - **filter_args) - - log.info("Dataset exported to '%s' as '%s'" % \ - (dst_dir, args.output_format)) - - return 0 diff --git a/datumaro/datumaro/cli/commands/create.py b/datumaro/datumaro/cli/commands/create.py deleted file mode 100644 index 97e3c9b4..00000000 --- a/datumaro/datumaro/cli/commands/create.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-import - -from ..contexts.project import build_create_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/explain.py b/datumaro/datumaro/cli/commands/explain.py deleted file mode 100644 index 4d5d16b2..00000000 --- a/datumaro/datumaro/cli/commands/explain.py +++ /dev/null @@ -1,183 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp - -from datumaro.components.project import Project -from datumaro.util.command_targets import (TargetKinds, target_selector, - ProjectTarget, SourceTarget, ImageTarget, is_project_path) -from datumaro.util.image import load_image, save_image -from ..util import MultilineFormatter -from ..util.project import load_project - - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Run Explainable AI algorithm", - description="Runs an explainable AI algorithm for a model.") - - parser.add_argument('-m', '--model', required=True, - help="Model to use for inference") - parser.add_argument('-t', '--target', default=None, - help="Inference target - image, source, project " - "(default: current dir)") - parser.add_argument('-o', '--output-dir', dest='save_dir', default=None, - help="Directory to save output (default: display only)") - - method_sp = parser.add_subparsers(dest='algorithm') - - rise_parser = method_sp.add_parser('rise', - description=""" - RISE: Randomized Input Sampling for - Explanation of Black-box Models algorithm|n - |n - See explanations at: https://arxiv.org/pdf/1806.07421.pdf - """, - formatter_class=MultilineFormatter) - rise_parser.add_argument('-s', '--max-samples', default=None, type=int, - help="Number of algorithm iterations (default: mask size ^ 2)") - rise_parser.add_argument('--mw', '--mask-width', - dest='mask_width', default=7, type=int, - help="Mask width (default: %(default)s)") - rise_parser.add_argument('--mh', '--mask-height', - dest='mask_height', default=7, type=int, - help="Mask height (default: %(default)s)") - rise_parser.add_argument('--prob', default=0.5, type=float, - help="Mask pixel inclusion probability (default: %(default)s)") - rise_parser.add_argument('--iou', '--iou-thresh', - dest='iou_thresh', default=0.9, type=float, - help="IoU match threshold for detections (default: %(default)s)") - rise_parser.add_argument('--nms', '--nms-iou-thresh', - dest='nms_iou_thresh', default=0.0, type=float, - help="IoU match threshold in Non-maxima suppression (default: no NMS)") - rise_parser.add_argument('--conf', '--det-conf-thresh', - dest='det_conf_thresh', default=0.0, type=float, - help="Confidence threshold for detections (default: include all)") - rise_parser.add_argument('-b', '--batch-size', default=1, type=int, - help="Inference batch size (default: %(default)s)") - rise_parser.add_argument('--display', action='store_true', - help="Visualize results during computations") - - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=explain_command) - - return parser - -def explain_command(args): - project_path = args.project_dir - if is_project_path(project_path): - project = Project.load(project_path) - else: - project = None - args.target = target_selector( - ProjectTarget(is_default=True, project=project), - SourceTarget(project=project), - ImageTarget() - )(args.target) - if args.target[0] == TargetKinds.project: - if is_project_path(args.target[1]): - args.project_dir = osp.dirname(osp.abspath(args.target[1])) - - - import cv2 - from matplotlib import cm - - project = load_project(args.project_dir) - - model = project.make_executable_model(args.model) - - if str(args.algorithm).lower() != 'rise': - raise NotImplementedError() - - from datumaro.components.algorithms.rise import RISE - rise = RISE(model, - max_samples=args.max_samples, - mask_width=args.mask_width, - mask_height=args.mask_height, - prob=args.prob, - iou_thresh=args.iou_thresh, - nms_thresh=args.nms_iou_thresh, - det_conf_thresh=args.det_conf_thresh, - batch_size=args.batch_size) - - if args.target[0] == TargetKinds.image: - image_path = args.target[1] - image = load_image(image_path) - - log.info("Running inference explanation for '%s'" % image_path) - heatmap_iter = rise.apply(image, progressive=args.display) - - image = image / 255.0 - file_name = osp.splitext(osp.basename(image_path))[0] - if args.display: - for i, heatmaps in enumerate(heatmap_iter): - for j, heatmap in enumerate(heatmaps): - hm_painted = cm.jet(heatmap)[:, :, 2::-1] - disp = (image + hm_painted) / 2 - cv2.imshow('heatmap-%s' % j, hm_painted) - cv2.imshow(file_name + '-heatmap-%s' % j, disp) - cv2.waitKey(10) - print("Iter", i, "of", args.max_samples, end='\r') - else: - heatmaps = next(heatmap_iter) - - if args.save_dir is not None: - log.info("Saving inference heatmaps at '%s'" % args.save_dir) - os.makedirs(args.save_dir, exist_ok=True) - - for j, heatmap in enumerate(heatmaps): - save_path = osp.join(args.save_dir, - file_name + '-heatmap-%s.png' % j) - save_image(save_path, heatmap * 255.0) - else: - for j, heatmap in enumerate(heatmaps): - disp = (image + cm.jet(heatmap)[:, :, 2::-1]) / 2 - cv2.imshow(file_name + '-heatmap-%s' % j, disp) - cv2.waitKey(0) - elif args.target[0] == TargetKinds.source or \ - args.target[0] == TargetKinds.project: - if args.target[0] == TargetKinds.source: - source_name = args.target[1] - dataset = project.make_source_project(source_name).make_dataset() - log.info("Running inference explanation for '%s'" % source_name) - else: - project_name = project.config.project_name - dataset = project.make_dataset() - log.info("Running inference explanation for '%s'" % project_name) - - for item in dataset: - image = item.image.data - if image is None: - log.warn( - "Dataset item %s does not have image data. Skipping." % \ - (item.id)) - continue - - heatmap_iter = rise.apply(image) - - image = image / 255.0 - heatmaps = next(heatmap_iter) - - if args.save_dir is not None: - log.info("Saving inference heatmaps to '%s'" % args.save_dir) - os.makedirs(args.save_dir, exist_ok=True) - - for j, heatmap in enumerate(heatmaps): - save_image(osp.join(args.save_dir, - item.id + '-heatmap-%s.png' % j), - heatmap * 255.0, create_dir=True) - - if not args.save_dir or args.display: - for j, heatmap in enumerate(heatmaps): - disp = (image + cm.jet(heatmap)[:, :, 2::-1]) / 2 - cv2.imshow(item.id + '-heatmap-%s' % j, disp) - cv2.waitKey(0) - else: - raise NotImplementedError() - - return 0 diff --git a/datumaro/datumaro/cli/commands/export.py b/datumaro/datumaro/cli/commands/export.py deleted file mode 100644 index be47245d..00000000 --- a/datumaro/datumaro/cli/commands/export.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-import - -from ..contexts.project import build_export_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/merge.py b/datumaro/datumaro/cli/commands/merge.py deleted file mode 100644 index 2583cd86..00000000 --- a/datumaro/datumaro/cli/commands/merge.py +++ /dev/null @@ -1,124 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import json -import logging as log -import os.path as osp -from collections import OrderedDict - -from datumaro.components.project import Project -from datumaro.components.operations import (IntersectMerge, - QualityError, MergeError) - -from ..util import at_least, MultilineFormatter, CliException -from ..util.project import generate_next_file_name, load_project - - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Merge few projects", - description=""" - Merges multiple datasets into one. This can be useful if you - have few annotations and wish to merge them, - taking into consideration potential overlaps and conflicts. - This command can try to find a common ground by voting or - return a list of conflicts.|n - |n - Examples:|n - - Merge annotations from 3 (or more) annotators:|n - |s|smerge project1/ project2/ project3/|n - - Check groups of the merged dataset for consistence:|n - |s|s|slook for groups consising of 'person', 'hand' 'head', 'foot'|n - |s|smerge project1/ project2/ -g 'person,hand?,head,foot?' - """, - formatter_class=MultilineFormatter) - - def _group(s): - return s.split(',') - - parser.add_argument('project', nargs='+', action=at_least(2), - help="Path to a project (repeatable)") - parser.add_argument('-iou', '--iou-thresh', default=0.25, type=float, - help="IoU match threshold for segments (default: %(default)s)") - parser.add_argument('-oconf', '--output-conf-thresh', - default=0.0, type=float, - help="Confidence threshold for output " - "annotations (default: %(default)s)") - parser.add_argument('--quorum', default=0, type=int, - help="Minimum count for a label and attribute voting " - "results to be counted (default: %(default)s)") - parser.add_argument('-g', '--groups', action='append', type=_group, - default=[], - help="A comma-separated list of labels in " - "annotation groups to check. '?' postfix can be added to a label to" - "make it optional in the group (repeatable)") - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Output directory (default: current project's dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.set_defaults(command=merge_command) - - return parser - -def merge_command(args): - source_projects = [load_project(p) for p in args.project] - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('merged') - - source_datasets = [] - for p in source_projects: - log.debug("Loading project '%s' dataset", p.config.project_name) - source_datasets.append(p.make_dataset()) - - merger = IntersectMerge(conf=IntersectMerge.Conf( - pairwise_dist=args.iou_thresh, groups=args.groups, - output_conf_thresh=args.output_conf_thresh, quorum=args.quorum - )) - merged_dataset = merger(source_datasets) - - merged_project = Project() - output_dataset = merged_project.make_dataset() - output_dataset.define_categories(merged_dataset.categories()) - merged_dataset = output_dataset.update(merged_dataset) - merged_dataset.save(save_dir=dst_dir) - - report_path = osp.join(dst_dir, 'merge_report.json') - save_merge_report(merger, report_path) - - dst_dir = osp.abspath(dst_dir) - log.info("Merge results have been saved to '%s'" % dst_dir) - log.info("Report has been saved to '%s'" % report_path) - - return 0 - -def save_merge_report(merger, path): - item_errors = OrderedDict() - source_errors = OrderedDict() - all_errors = [] - - for e in merger.errors: - if isinstance(e, QualityError): - item_errors[str(e.item_id)] = item_errors.get(str(e.item_id), 0) + 1 - elif isinstance(e, MergeError): - for s in e.sources: - source_errors[s] = source_errors.get(s, 0) + 1 - item_errors[str(e.item_id)] = item_errors.get(str(e.item_id), 0) + 1 - - all_errors.append(str(e)) - - errors = OrderedDict([ - ('Item errors', item_errors), - ('Source errors', source_errors), - ('All errors', all_errors), - ]) - - with open(path, 'w') as f: - json.dump(errors, f, indent=4) \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/remove.py b/datumaro/datumaro/cli/commands/remove.py deleted file mode 100644 index 7b9c0d3a..00000000 --- a/datumaro/datumaro/cli/commands/remove.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-import - -from ..contexts.source import build_remove_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/contexts/__init__.py b/datumaro/datumaro/cli/contexts/__init__.py deleted file mode 100644 index 433efe9b..00000000 --- a/datumaro/datumaro/cli/contexts/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from . import project, source, model, item \ No newline at end of file diff --git a/datumaro/datumaro/cli/contexts/item/__init__.py b/datumaro/datumaro/cli/contexts/item/__init__.py deleted file mode 100644 index 8f74826d..00000000 --- a/datumaro/datumaro/cli/contexts/item/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - -from ...util import add_subparser - - -def build_export_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - return parser - -def build_stats_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - return parser - -def build_diff_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - return parser - -def build_edit_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - return parser - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - subparsers = parser.add_subparsers() - add_subparser(subparsers, 'export', build_export_parser) - add_subparser(subparsers, 'stats', build_stats_parser) - add_subparser(subparsers, 'diff', build_diff_parser) - add_subparser(subparsers, 'edit', build_edit_parser) - - return parser diff --git a/datumaro/datumaro/cli/contexts/model/__init__.py b/datumaro/datumaro/cli/contexts/model/__init__.py deleted file mode 100644 index 69b7da1e..00000000 --- a/datumaro/datumaro/cli/contexts/model/__init__.py +++ /dev/null @@ -1,183 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp -import re - -from datumaro.components.config import DEFAULT_FORMAT -from datumaro.components.project import Environment - -from ...util import CliException, MultilineFormatter, add_subparser -from ...util.project import load_project, \ - generate_next_name, generate_next_file_name - - -def build_add_parser(parser_ctor=argparse.ArgumentParser): - builtins = sorted(Environment().launchers.items) - - parser = parser_ctor(help="Add model to project", - description=""" - Registers an executable model into a project. A model requires - a launcher to be executed. Each launcher has its own options, which - are passed after '--' separator, pass '-- -h' for more info. - |n - List of builtin launchers: %s - """ % ', '.join(builtins), - formatter_class=MultilineFormatter) - - parser.add_argument('-l', '--launcher', required=True, - help="Model launcher") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, - help="Additional arguments for converter (pass '-- -h' for help)") - parser.add_argument('--copy', action='store_true', - help="Copy the model to the project") - parser.add_argument('-n', '--name', default=None, - help="Name of the model to be added (default: generate automatically)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite if exists") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=add_command) - - return parser - -def add_command(args): - project = load_project(args.project_dir) - - if args.name: - if not args.overwrite and args.name in project.config.models: - raise CliException("Model '%s' already exists " - "(pass --overwrite to overwrite)" % args.name) - else: - args.name = generate_next_name( - project.config.models, 'model', '-', default=0) - assert args.name not in project.config.models, args.name - - try: - launcher = project.env.launchers.get(args.launcher) - except KeyError: - raise CliException("Launcher '%s' is not found" % args.launcher) - - cli_plugin = getattr(launcher, 'cli_plugin', launcher) - model_args = cli_plugin.from_cmdline(args.extra_args) - - if args.copy: - log.info("Copying model data") - - model_dir = project.local_model_dir(args.name) - os.makedirs(model_dir, exist_ok=False) - - try: - cli_plugin.copy_model(model_dir, model_args) - except (AttributeError, NotImplementedError): - log.error("Can't copy: copying is not available for '%s' models" % \ - args.launcher) - - log.info("Checking the model") - project.add_model(args.name, { - 'launcher': args.launcher, - 'options': model_args, - }) - project.make_executable_model(args.name) - - project.save() - - log.info("Model '%s' with launcher '%s' has been added to project '%s'" % \ - (args.name, args.launcher, project.config.project_name)) - - return 0 - -def build_remove_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - parser.add_argument('name', - help="Name of the model to be removed") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=remove_command) - - return parser - -def remove_command(args): - project = load_project(args.project_dir) - - project.remove_model(args.name) - project.save() - - return 0 - -def build_run_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - parser.add_argument('-o', '--output-dir', dest='dst_dir', - help="Directory to save output") - parser.add_argument('-m', '--model', dest='model_name', required=True, - help="Model to apply to the project") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite if exists") - parser.set_defaults(command=run_command) - - return parser - -def run_command(args): - project = load_project(args.project_dir) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-inference' % \ - project.config.project_name) - - project.make_dataset().apply_model( - save_dir=osp.abspath(dst_dir), - model=args.model_name) - - log.info("Inference results have been saved to '%s'" % dst_dir) - - return 0 - -def build_info_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - parser.add_argument('-n', '--name', - help="Model name") - parser.add_argument('-v', '--verbose', action='store_true', - help="Show details") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=info_command) - - return parser - -def info_command(args): - project = load_project(args.project_dir) - - if args.name: - model = project.get_model(args.name) - print(model) - else: - for name, conf in project.config.models.items(): - print(name) - if args.verbose: - print(dict(conf)) - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - subparsers = parser.add_subparsers() - add_subparser(subparsers, 'add', build_add_parser) - add_subparser(subparsers, 'remove', build_remove_parser) - add_subparser(subparsers, 'run', build_run_parser) - add_subparser(subparsers, 'info', build_info_parser) - - return parser diff --git a/datumaro/datumaro/cli/contexts/project/__init__.py b/datumaro/datumaro/cli/contexts/project/__init__.py deleted file mode 100644 index bab5da6f..00000000 --- a/datumaro/datumaro/cli/contexts/project/__init__.py +++ /dev/null @@ -1,826 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import json -import logging as log -import os -import os.path as osp -import shutil -from enum import Enum - -from datumaro.components.cli_plugin import CliPlugin -from datumaro.components.dataset_filter import DatasetItemEncoder -from datumaro.components.extractor import AnnotationType -from datumaro.components.operations import (DistanceComparator, - ExactComparator, compute_ann_statistics, compute_image_statistics, mean_std) -from datumaro.components.project import \ - PROJECT_DEFAULT_CONFIG as DEFAULT_CONFIG -from datumaro.components.project import Environment, Project - -from ...util import (CliException, MultilineFormatter, add_subparser, - make_file_name) -from ...util.project import generate_next_file_name, load_project -from .diff import DiffVisualizer - - -def build_create_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Create empty project", - description=""" - Create a new empty project.|n - |n - Examples:|n - - Create a project in the current directory:|n - |s|screate -n myproject|n - |n - - Create a project in other directory:|n - |s|screate -o path/I/like/ - """, - formatter_class=MultilineFormatter) - - parser.add_argument('-o', '--output-dir', default='.', dest='dst_dir', - help="Save directory for the new project (default: current dir") - parser.add_argument('-n', '--name', default=None, - help="Name of the new project (default: same as project dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.set_defaults(command=create_command) - - return parser - -def create_command(args): - project_dir = osp.abspath(args.dst_dir) - - project_env_dir = osp.join(project_dir, DEFAULT_CONFIG.env_dir) - if osp.isdir(project_env_dir) and os.listdir(project_env_dir): - if not args.overwrite: - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % project_env_dir) - else: - shutil.rmtree(project_env_dir, ignore_errors=True) - - own_dataset_dir = osp.join(project_dir, DEFAULT_CONFIG.dataset_dir) - if osp.isdir(own_dataset_dir) and os.listdir(own_dataset_dir): - if not args.overwrite: - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % own_dataset_dir) - else: - # NOTE: remove the dir to avoid using data from previous project - shutil.rmtree(own_dataset_dir) - - project_name = args.name - if project_name is None: - project_name = osp.basename(project_dir) - - log.info("Creating project at '%s'" % project_dir) - - Project.generate(project_dir, { - 'project_name': project_name, - }) - - log.info("Project has been created at '%s'" % project_dir) - - return 0 - -def build_import_parser(parser_ctor=argparse.ArgumentParser): - builtins = sorted(Environment().importers.items) - - parser = parser_ctor(help="Create project from existing dataset", - description=""" - Creates a project from an existing dataset. The source can be:|n - - a dataset in a supported format (check 'formats' section below)|n - - a Datumaro project|n - |n - Formats:|n - Datasets come in a wide variety of formats. Each dataset - format defines its own data structure and rules on how to - interpret the data. For example, the following data structure - is used in COCO format:|n - /dataset/|n - - /images/.jpg|n - - /annotations/|n - |n - In Datumaro dataset formats are supported by - Extractor-s and Importer-s. - An Extractor produces a list of dataset items corresponding - to the dataset. An Importer creates a project from the - data source location. - It is possible to add a custom Extractor and Importer. - To do this, you need to put an Extractor and - Importer implementation scripts to - /.datumaro/extractors - and /.datumaro/importers.|n - |n - List of builtin dataset formats: %s|n - |n - Examples:|n - - Create a project from VOC dataset in the current directory:|n - |s|simport -f voc -i path/to/voc|n - |n - - Create a project from COCO dataset in other directory:|n - |s|simport -f coco -i path/to/coco -o path/I/like/ - """ % ', '.join(builtins), - formatter_class=MultilineFormatter) - - parser.add_argument('-o', '--output-dir', default='.', dest='dst_dir', - help="Directory to save the new project to (default: current dir)") - parser.add_argument('-n', '--name', default=None, - help="Name of the new project (default: same as project dir)") - parser.add_argument('--copy', action='store_true', - help="Copy the dataset instead of saving source links") - parser.add_argument('--skip-check', action='store_true', - help="Skip source checking") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-i', '--input-path', required=True, dest='source', - help="Path to import project from") - parser.add_argument('-f', '--format', - help="Source project format. Will try to detect, if not specified.") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, - help="Additional arguments for importer (pass '-- -h' for help)") - parser.set_defaults(command=import_command) - - return parser - -def import_command(args): - project_dir = osp.abspath(args.dst_dir) - - project_env_dir = osp.join(project_dir, DEFAULT_CONFIG.env_dir) - if osp.isdir(project_env_dir) and os.listdir(project_env_dir): - if not args.overwrite: - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % project_env_dir) - else: - shutil.rmtree(project_env_dir, ignore_errors=True) - - own_dataset_dir = osp.join(project_dir, DEFAULT_CONFIG.dataset_dir) - if osp.isdir(own_dataset_dir) and os.listdir(own_dataset_dir): - if not args.overwrite: - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % own_dataset_dir) - else: - # NOTE: remove the dir to avoid using data from previous project - shutil.rmtree(own_dataset_dir) - - project_name = args.name - if project_name is None: - project_name = osp.basename(project_dir) - - env = Environment() - log.info("Importing project from '%s'" % args.source) - - extra_args = {} - if not args.format: - if args.extra_args: - raise CliException("Extra args can not be used without format") - - log.info("Trying to detect dataset format...") - - matches = [] - for format_name in env.importers.items: - log.debug("Checking '%s' format...", format_name) - importer = env.make_importer(format_name) - try: - match = importer.detect(args.source) - if match: - log.debug("format matched") - matches.append((format_name, importer)) - except NotImplementedError: - log.debug("Format '%s' does not support auto detection.", - format_name) - - if len(matches) == 0: - log.error("Failed to detect dataset format automatically. " - "Try to specify format with '-f/--format' parameter.") - return 1 - elif len(matches) != 1: - log.error("Multiple formats match the dataset: %s. " - "Try to specify format with '-f/--format' parameter.", - ', '.join(m[0] for m in matches)) - return 2 - - format_name, importer = matches[0] - args.format = format_name - else: - try: - importer = env.make_importer(args.format) - if hasattr(importer, 'from_cmdline'): - extra_args = importer.from_cmdline(args.extra_args) - except KeyError: - raise CliException("Importer for format '%s' is not found" % \ - args.format) - - log.info("Importing project as '%s'" % args.format) - - source = osp.abspath(args.source) - project = importer(source, **extra_args) - project.config.project_name = project_name - project.config.project_dir = project_dir - - if not args.skip_check or args.copy: - log.info("Checking the dataset...") - dataset = project.make_dataset() - if args.copy: - log.info("Cloning data...") - dataset.save(merge=True, save_images=True) - else: - project.save() - - log.info("Project has been created at '%s'" % project_dir) - - return 0 - - -class FilterModes(Enum): - # primary - items = 1 - annotations = 2 - items_annotations = 3 - - # shortcuts - i = 1 - a = 2 - i_a = 3 - a_i = 3 - annotations_items = 3 - - @staticmethod - def parse(s): - s = s.lower() - s = s.replace('+', '_') - return FilterModes[s] - - @classmethod - def make_filter_args(cls, mode): - if mode == cls.items: - return {} - elif mode == cls.annotations: - return { - 'filter_annotations': True - } - elif mode == cls.items_annotations: - return { - 'filter_annotations': True, - 'remove_empty': True, - } - else: - raise NotImplementedError() - - @classmethod - def list_options(cls): - return [m.name.replace('_', '+') for m in cls] - -def build_export_parser(parser_ctor=argparse.ArgumentParser): - builtins = sorted(Environment().converters.items) - - parser = parser_ctor(help="Export project", - description=""" - Exports the project dataset in some format. Optionally, a filter - can be passed, check 'filter' command description for more info. - Each dataset format has its own options, which - are passed after '--' separator (see examples), pass '-- -h' - for more info. If not stated otherwise, by default - only annotations are exported, to include images pass - '--save-images' parameter.|n - |n - Formats:|n - In Datumaro dataset formats are supported by Converter-s. - A Converter produces a dataset of a specific format - from dataset items. It is possible to add a custom Converter. - To do this, you need to put a Converter - definition script to /.datumaro/converters.|n - |n - List of builtin dataset formats: %s|n - |n - Examples:|n - - Export project as a VOC-like dataset, include images:|n - |s|sexport -f voc -- --save-images|n - |n - - Export project as a COCO-like dataset in other directory:|n - |s|sexport -f coco -o path/I/like/ - """ % ', '.join(builtins), - formatter_class=MultilineFormatter) - - parser.add_argument('-e', '--filter', default=None, - help="Filter expression for dataset items") - parser.add_argument('--filter-mode', default=FilterModes.i.name, - type=FilterModes.parse, - help="Filter mode (options: %s; default: %s)" % \ - (', '.join(FilterModes.list_options()) , '%(default)s')) - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Directory to save output (default: a subdir in the current one)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('-f', '--format', required=True, - help="Output format") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, - help="Additional arguments for converter (pass '-- -h' for help)") - parser.set_defaults(command=export_command) - - return parser - -def export_command(args): - project = load_project(args.project_dir) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-%s' % \ - (project.config.project_name, make_file_name(args.format))) - dst_dir = osp.abspath(dst_dir) - - try: - converter = project.env.converters.get(args.format) - except KeyError: - raise CliException("Converter for format '%s' is not found" % \ - args.format) - - extra_args = converter.from_cmdline(args.extra_args) - def converter_proxy(extractor, save_dir): - return converter.convert(extractor, save_dir, **extra_args) - - filter_args = FilterModes.make_filter_args(args.filter_mode) - - log.info("Loading the project...") - dataset = project.make_dataset() - - log.info("Exporting the project...") - dataset.export_project( - save_dir=dst_dir, - converter=converter_proxy, - filter_expr=args.filter, - **filter_args) - log.info("Project exported to '%s' as '%s'" % \ - (dst_dir, args.format)) - - return 0 - -def build_filter_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Extract subproject", - description=""" - Extracts a subproject that contains only items matching filter. - A filter is an XPath expression, which is applied to XML - representation of a dataset item. Check '--dry-run' parameter - to see XML representations of the dataset items.|n - |n - To filter annotations use the mode ('-m') parameter.|n - Supported modes:|n - - 'i', 'items'|n - - 'a', 'annotations'|n - - 'i+a', 'a+i', 'items+annotations', 'annotations+items'|n - When filtering annotations, use the 'items+annotations' - mode to point that annotation-less dataset items should be - removed. To select an annotation, write an XPath that - returns 'annotation' elements (see examples).|n - |n - Examples:|n - - Filter images with width < height:|n - |s|sextract -e '/item[image/width < image/height]'|n - |n - - Filter images with large-area bboxes:|n - |s|sextract -e '/item[annotation/type="bbox" and - annotation/area>2000]'|n - |n - - Filter out all irrelevant annotations from items:|n - |s|sextract -m a -e '/item/annotation[label = "person"]'|n - |n - - Filter out all irrelevant annotations from items:|n - |s|sextract -m a -e '/item/annotation[label="cat" and - area > 99.5]'|n - |n - - Filter occluded annotations and items, if no annotations left:|n - |s|sextract -m i+a -e '/item/annotation[occluded="True"]' - """, - formatter_class=MultilineFormatter) - - parser.add_argument('-e', '--filter', default=None, - help="XML XPath filter expression for dataset items") - parser.add_argument('-m', '--mode', default=FilterModes.i.name, - type=FilterModes.parse, - help="Filter mode (options: %s; default: %s)" % \ - (', '.join(FilterModes.list_options()) , '%(default)s')) - parser.add_argument('--dry-run', action='store_true', - help="Print XML representations to be filtered and exit") - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Output directory (default: update current project)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=filter_command) - - return parser - -def filter_command(args): - project = load_project(args.project_dir) - - if not args.dry_run: - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-filter' % \ - project.config.project_name) - dst_dir = osp.abspath(dst_dir) - - dataset = project.make_dataset() - - filter_args = FilterModes.make_filter_args(args.mode) - - if args.dry_run: - dataset = dataset.filter(expr=args.filter, **filter_args) - for item in dataset: - encoded_item = DatasetItemEncoder.encode(item, dataset.categories()) - xml_item = DatasetItemEncoder.to_string(encoded_item) - print(xml_item) - return 0 - - if not args.filter: - raise CliException("Expected a filter expression ('-e' argument)") - - dataset.filter_project(save_dir=dst_dir, expr=args.filter, **filter_args) - - log.info("Subproject has been extracted to '%s'" % dst_dir) - - return 0 - -def build_merge_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Merge two projects", - description=""" - Updates items of the current project with items - from other project.|n - |n - Examples:|n - - Update a project with items from other project:|n - |s|smerge -p path/to/first/project path/to/other/project - """, - formatter_class=MultilineFormatter) - - parser.add_argument('other_project_dir', - help="Path to a project") - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Output directory (default: current project's dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=merge_command) - - return parser - -def merge_command(args): - first_project = load_project(args.project_dir) - second_project = load_project(args.other_project_dir) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - - first_dataset = first_project.make_dataset() - second_dataset = second_project.make_dataset() - - first_dataset.update(second_dataset) - first_dataset.save(save_dir=dst_dir) - - if dst_dir is None: - dst_dir = first_project.config.project_dir - dst_dir = osp.abspath(dst_dir) - log.info("Merge results have been saved to '%s'" % dst_dir) - - return 0 - -def build_diff_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Compare projects", - description=""" - Compares two projects, match annotations by distance.|n - |n - Examples:|n - - Compare two projects, match boxes if IoU > 0.7,|n - |s|s|s|sprint results to Tensorboard: - |s|sdiff path/to/other/project -o diff/ -v tensorboard --iou-thresh 0.7 - """, - formatter_class=MultilineFormatter) - - parser.add_argument('other_project_dir', - help="Directory of the second project to be compared") - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Directory to save comparison results (default: do not save)") - parser.add_argument('-v', '--visualizer', - default=DiffVisualizer.DEFAULT_FORMAT, - choices=[f.name for f in DiffVisualizer.Format], - help="Output format (default: %(default)s)") - parser.add_argument('--iou-thresh', default=0.5, type=float, - help="IoU match threshold for detections (default: %(default)s)") - parser.add_argument('--conf-thresh', default=0.5, type=float, - help="Confidence threshold for detections (default: %(default)s)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the first project to be compared (default: current dir)") - parser.set_defaults(command=diff_command) - - return parser - -def diff_command(args): - first_project = load_project(args.project_dir) - second_project = load_project(args.other_project_dir) - - comparator = DistanceComparator(iou_threshold=args.iou_thresh) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-%s-diff' % ( - first_project.config.project_name, - second_project.config.project_name) - ) - dst_dir = osp.abspath(dst_dir) - log.info("Saving diff to '%s'" % dst_dir) - - dst_dir_existed = osp.exists(dst_dir) - try: - visualizer = DiffVisualizer(save_dir=dst_dir, comparator=comparator, - output_format=args.visualizer) - visualizer.save_dataset_diff( - first_project.make_dataset(), - second_project.make_dataset()) - except BaseException: - if not dst_dir_existed and osp.isdir(dst_dir): - shutil.rmtree(dst_dir, ignore_errors=True) - raise - - return 0 - -def build_ediff_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Compare projects for equality", - description=""" - Compares two projects for equality.|n - |n - Examples:|n - - Compare two projects, exclude annotation group |n - |s|s|sand the 'is_crowd' attribute from comparison:|n - |s|sediff other/project/ -if group -ia is_crowd - """, - formatter_class=MultilineFormatter) - - parser.add_argument('other_project_dir', - help="Directory of the second project to be compared") - parser.add_argument('-iia', '--ignore-item-attr', action='append', - help="Ignore item attribute (repeatable)") - parser.add_argument('-ia', '--ignore-attr', action='append', - help="Ignore annotation attribute (repeatable)") - parser.add_argument('-if', '--ignore-field', - action='append', default=['id', 'group'], - help="Ignore annotation field (repeatable, default: %(default)s)") - parser.add_argument('--match-images', action='store_true', - help='Match dataset items by images instead of ids') - parser.add_argument('--all', action='store_true', - help="Include matches in the output") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the first project to be compared (default: current dir)") - parser.set_defaults(command=ediff_command) - - return parser - -def ediff_command(args): - first_project = load_project(args.project_dir) - second_project = load_project(args.other_project_dir) - - comparator = ExactComparator( - match_images=args.match_images, - ignored_fields=args.ignore_field, - ignored_attrs=args.ignore_attr, - ignored_item_attrs=args.ignore_item_attr) - matches, mismatches, a_extra, b_extra, errors = \ - comparator.compare_datasets( - first_project.make_dataset(), second_project.make_dataset()) - output = { - "mismatches": mismatches, - "a_extra_items": sorted(a_extra), - "b_extra_items": sorted(b_extra), - "errors": errors, - } - if args.all: - output["matches"] = matches - - output_file = generate_next_file_name('diff', ext='.json') - with open(output_file, 'w') as f: - json.dump(output, f, indent=4, sort_keys=True) - - print("Found:") - print("The first project has %s unmatched items" % len(a_extra)) - print("The second project has %s unmatched items" % len(b_extra)) - print("%s item conflicts" % len(errors)) - print("%s matching annotations" % len(matches)) - print("%s mismatching annotations" % len(mismatches)) - - log.info("Output has been saved to '%s'" % output_file) - - return 0 - -def build_transform_parser(parser_ctor=argparse.ArgumentParser): - builtins = sorted(Environment().transforms.items) - - parser = parser_ctor(help="Transform project", - description=""" - Applies some operation to dataset items in the project - and produces a new project.|n - |n - Builtin transforms: %s|n - |n - Examples:|n - - Convert instance polygons to masks:|n - |s|stransform -t polygons_to_masks - """ % ', '.join(builtins), - formatter_class=MultilineFormatter) - - parser.add_argument('-t', '--transform', required=True, - help="Transform to apply to the project") - parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, - help="Directory to save output (default: current dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, - help="Additional arguments for transformation (pass '-- -h' for help)") - parser.set_defaults(command=transform_command) - - return parser - -def transform_command(args): - project = load_project(args.project_dir) - - dst_dir = args.dst_dir - if dst_dir: - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - raise CliException("Directory '%s' already exists " - "(pass --overwrite to overwrite)" % dst_dir) - else: - dst_dir = generate_next_file_name('%s-%s' % \ - (project.config.project_name, make_file_name(args.transform))) - dst_dir = osp.abspath(dst_dir) - - try: - transform = project.env.transforms.get(args.transform) - except KeyError: - raise CliException("Transform '%s' is not found" % args.transform) - - extra_args = {} - if hasattr(transform, 'from_cmdline'): - extra_args = transform.from_cmdline(args.extra_args) - - log.info("Loading the project...") - dataset = project.make_dataset() - - log.info("Transforming the project...") - dataset.transform_project( - method=transform, - save_dir=dst_dir, - **extra_args - ) - - log.info("Transform results have been saved to '%s'" % dst_dir) - - return 0 - -def build_stats_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Get project statistics", - description=""" - Outputs various project statistics like image mean and std, - annotations count etc. - """, - formatter_class=MultilineFormatter) - - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=stats_command) - - return parser - -def stats_command(args): - project = load_project(args.project_dir) - - dataset = project.make_dataset() - stats = {} - stats.update(compute_image_statistics(dataset)) - stats.update(compute_ann_statistics(dataset)) - - dst_file = generate_next_file_name('statistics', ext='.json') - log.info("Writing project statistics to '%s'" % dst_file) - with open(dst_file, 'w') as f: - json.dump(stats, f, indent=4, sort_keys=True) - -def build_info_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Get project info", - description=""" - Outputs project info. - """, - formatter_class=MultilineFormatter) - - parser.add_argument('--all', action='store_true', - help="Print all information") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=info_command) - - return parser - -def info_command(args): - project = load_project(args.project_dir) - config = project.config - env = project.env - dataset = project.make_dataset() - - print("Project:") - print(" name:", config.project_name) - print(" location:", config.project_dir) - print("Plugins:") - print(" importers:", ', '.join(env.importers.items)) - print(" extractors:", ', '.join(env.extractors.items)) - print(" converters:", ', '.join(env.converters.items)) - print(" launchers:", ', '.join(env.launchers.items)) - - print("Sources:") - for source_name, source in config.sources.items(): - print(" source '%s':" % source_name) - print(" format:", source.format) - print(" url:", source.url) - print(" location:", project.local_source_dir(source_name)) - - def print_extractor_info(extractor, indent=''): - print("%slength:" % indent, len(extractor)) - - categories = extractor.categories() - print("%scategories:" % indent, ', '.join(c.name for c in categories)) - - for cat_type, cat in categories.items(): - print("%s %s:" % (indent, cat_type.name)) - if cat_type == AnnotationType.label: - print("%s count:" % indent, len(cat.items)) - - count_threshold = 10 - if args.all: - count_threshold = len(cat.items) - labels = ', '.join(c.name for c in cat.items[:count_threshold]) - if count_threshold < len(cat.items): - labels += " (and %s more)" % ( - len(cat.items) - count_threshold) - print("%s labels:" % indent, labels) - - print("Dataset:") - print_extractor_info(dataset, indent=" ") - - subsets = dataset.subsets() - print(" subsets:", ', '.join(subsets)) - for subset_name in subsets: - subset = dataset.get_subset(subset_name) - print(" subset '%s':" % subset_name) - print_extractor_info(subset, indent=" ") - - print("Models:") - for model_name, model in config.models.items(): - print(" model '%s':" % model_name) - print(" type:", model.launcher) - - return 0 - - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor( - description=""" - Manipulate projects.|n - |n - By default, the project to be operated on is searched for - in the current directory. An additional '-p' argument can be - passed to specify project location. - """, - formatter_class=MultilineFormatter) - - subparsers = parser.add_subparsers() - add_subparser(subparsers, 'create', build_create_parser) - add_subparser(subparsers, 'import', build_import_parser) - add_subparser(subparsers, 'export', build_export_parser) - add_subparser(subparsers, 'filter', build_filter_parser) - add_subparser(subparsers, 'merge', build_merge_parser) - add_subparser(subparsers, 'diff', build_diff_parser) - add_subparser(subparsers, 'ediff', build_ediff_parser) - add_subparser(subparsers, 'transform', build_transform_parser) - add_subparser(subparsers, 'info', build_info_parser) - add_subparser(subparsers, 'stats', build_stats_parser) - - return parser diff --git a/datumaro/datumaro/cli/contexts/project/diff.py b/datumaro/datumaro/cli/contexts/project/diff.py deleted file mode 100644 index 358f3860..00000000 --- a/datumaro/datumaro/cli/contexts/project/diff.py +++ /dev/null @@ -1,290 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import Counter -from enum import Enum -import numpy as np -import os -import os.path as osp - -_formats = ['simple'] - -import warnings -with warnings.catch_warnings(): - warnings.simplefilter("ignore") - import tensorboardX as tb - _formats.append('tensorboard') - -from datumaro.components.extractor import AnnotationType -from datumaro.util.image import save_image - - -Format = Enum('Formats', _formats) - -class DiffVisualizer: - Format = Format - DEFAULT_FORMAT = Format.simple - - _UNMATCHED_LABEL = -1 - - - def __init__(self, comparator, save_dir, output_format=DEFAULT_FORMAT): - self.comparator = comparator - - if isinstance(output_format, str): - output_format = Format[output_format] - assert output_format in Format - self.output_format = output_format - - self.save_dir = save_dir - if output_format is Format.tensorboard: - logdir = osp.join(self.save_dir, 'logs', 'diff') - self.file_writer = tb.SummaryWriter(logdir) - if output_format is Format.simple: - self.label_diff_writer = None - - self.categories = {} - - self.label_confusion_matrix = Counter() - self.bbox_confusion_matrix = Counter() - - def save_dataset_diff(self, extractor_a, extractor_b): - if self.save_dir: - os.makedirs(self.save_dir, exist_ok=True) - - if len(extractor_a) != len(extractor_b): - print("Datasets have different lengths: %s vs %s" % \ - (len(extractor_a), len(extractor_b))) - - self.categories = {} - - label_mismatch = self.comparator. \ - compare_dataset_labels(extractor_a, extractor_b) - if label_mismatch is None: - print("Datasets have no label information") - elif len(label_mismatch) != 0: - print("Datasets have mismatching labels:") - for a_label, b_label in label_mismatch: - if a_label is None: - print(" > %s" % b_label.name) - elif b_label is None: - print(" < %s" % a_label.name) - else: - print(" %s != %s" % (a_label.name, b_label.name)) - else: - self.categories.update(extractor_a.categories()) - self.categories.update(extractor_b.categories()) - - self.label_confusion_matrix = Counter() - self.bbox_confusion_matrix = Counter() - - if self.output_format is Format.tensorboard: - self.file_writer.reopen() - - ids_a = set((item.id, item.subset) for item in extractor_a) - ids_b = set((item.id, item.subset) for item in extractor_b) - ids = ids_a & ids_b - - if len(ids) != len(ids_a): - print("Unmatched items in the first dataset: ") - print(ids_a - ids) - if len(ids) != len(ids_b): - print("Unmatched items in the second dataset: ") - print(ids_b - ids) - - for item_id, item_subset in ids: - item_a = extractor_a.get(item_id, item_subset) - item_b = extractor_a.get(item_id, item_subset) - - label_diff = self.comparator.compare_item_labels(item_a, item_b) - self.update_label_confusion(label_diff) - - bbox_diff = self.comparator.compare_item_bboxes(item_a, item_b) - self.update_bbox_confusion(bbox_diff) - - self.save_item_label_diff(item_a, item_b, label_diff) - self.save_item_bbox_diff(item_a, item_b, bbox_diff) - - if len(self.label_confusion_matrix) != 0: - self.save_conf_matrix(self.label_confusion_matrix, - 'labels_confusion.png') - if len(self.bbox_confusion_matrix) != 0: - self.save_conf_matrix(self.bbox_confusion_matrix, - 'bbox_confusion.png') - - if self.output_format is Format.tensorboard: - self.file_writer.flush() - self.file_writer.close() - elif self.output_format is Format.simple: - if self.label_diff_writer: - self.label_diff_writer.flush() - self.label_diff_writer.close() - - def update_label_confusion(self, label_diff): - matches, a_unmatched, b_unmatched = label_diff - for label in matches: - self.label_confusion_matrix[(label, label)] += 1 - for a_label in a_unmatched: - self.label_confusion_matrix[(a_label, self._UNMATCHED_LABEL)] += 1 - for b_label in b_unmatched: - self.label_confusion_matrix[(self._UNMATCHED_LABEL, b_label)] += 1 - - def update_bbox_confusion(self, bbox_diff): - matches, mispred, a_unmatched, b_unmatched = bbox_diff - for a_bbox, b_bbox in matches: - self.bbox_confusion_matrix[(a_bbox.label, b_bbox.label)] += 1 - for a_bbox, b_bbox in mispred: - self.bbox_confusion_matrix[(a_bbox.label, b_bbox.label)] += 1 - for a_bbox in a_unmatched: - self.bbox_confusion_matrix[(a_bbox.label, self._UNMATCHED_LABEL)] += 1 - for b_bbox in b_unmatched: - self.bbox_confusion_matrix[(self._UNMATCHED_LABEL, b_bbox.label)] += 1 - - @classmethod - def draw_text_with_background(cls, frame, text, origin, - font=None, scale=1.0, - color=(0, 0, 0), thickness=1, bgcolor=(1, 1, 1)): - import cv2 - - if not font: - font = cv2.FONT_HERSHEY_SIMPLEX - - text_size, baseline = cv2.getTextSize(text, font, scale, thickness) - cv2.rectangle(frame, - tuple((origin + (0, baseline)).astype(int)), - tuple((origin + (text_size[0], -text_size[1])).astype(int)), - bgcolor, cv2.FILLED) - cv2.putText(frame, text, - tuple(origin.astype(int)), - font, scale, color, thickness) - return text_size, baseline - - def draw_detection_roi(self, frame, x, y, w, h, label, conf, color): - import cv2 - - cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) - - text = '%s %.2f%%' % (label, 100.0 * conf) - text_scale = 0.5 - font = cv2.FONT_HERSHEY_SIMPLEX - text_size = cv2.getTextSize(text, font, text_scale, 1) - line_height = np.array([0, text_size[0][1]]) - self.draw_text_with_background(frame, text, - np.array([x, y]) - line_height * 0.5, - font, scale=text_scale, color=[255 - c for c in color]) - - def get_label(self, label_id): - cat = self.categories.get(AnnotationType.label) - if cat is None: - return str(label_id) - return cat.items[label_id].name - - def draw_bbox(self, img, shape, color): - x, y, w, h = shape.get_bbox() - self.draw_detection_roi(img, int(x), int(y), int(w), int(h), - self.get_label(shape.label), shape.attributes.get('score', 1), - color) - - def get_label_diff_file(self): - if self.label_diff_writer is None: - self.label_diff_writer = \ - open(osp.join(self.save_dir, 'label_diff.txt'), 'w') - return self.label_diff_writer - - def save_item_label_diff(self, item_a, item_b, diff): - _, a_unmatched, b_unmatched = diff - - if 0 < len(a_unmatched) + len(b_unmatched): - if self.output_format is Format.simple: - f = self.get_label_diff_file() - f.write(item_a.id + '\n') - for a_label in a_unmatched: - f.write(' >%s\n' % self.get_label(a_label)) - for b_label in b_unmatched: - f.write(' <%s\n' % self.get_label(b_label)) - elif self.output_format is Format.tensorboard: - tag = item_a.id - for a_label in a_unmatched: - self.file_writer.add_text(tag, - '>%s\n' % self.get_label(a_label)) - for b_label in b_unmatched: - self.file_writer.add_text(tag, - '<%s\n' % self.get_label(b_label)) - - def save_item_bbox_diff(self, item_a, item_b, diff): - _, mispred, a_unmatched, b_unmatched = diff - - if 0 < len(a_unmatched) + len(b_unmatched) + len(mispred): - img_a = item_a.image.data.copy() - img_b = img_a.copy() - for a_bbox, b_bbox in mispred: - self.draw_bbox(img_a, a_bbox, (0, 255, 0)) - self.draw_bbox(img_b, b_bbox, (0, 0, 255)) - for a_bbox in a_unmatched: - self.draw_bbox(img_a, a_bbox, (255, 255, 0)) - for b_bbox in b_unmatched: - self.draw_bbox(img_b, b_bbox, (255, 255, 0)) - - img = np.hstack([img_a, img_b]) - - path = osp.join(self.save_dir, item_a.id) - - if self.output_format is Format.simple: - save_image(path + '.png', img, create_dir=True) - elif self.output_format is Format.tensorboard: - self.save_as_tensorboard(img, path) - - def save_as_tensorboard(self, img, name): - img = img[:, :, ::-1] # to RGB - img = np.transpose(img, (2, 0, 1)) # to (C, H, W) - img = img.astype(dtype=np.uint8) - self.file_writer.add_image(name, img) - - def save_conf_matrix(self, conf_matrix, filename): - import matplotlib.pyplot as plt - - classes = None - label_categories = self.categories.get(AnnotationType.label) - if label_categories is not None: - classes = { id: c.name for id, c in enumerate(label_categories.items) } - if classes is None: - classes = { c: 'label_%s' % c for c, _ in conf_matrix } - classes[self._UNMATCHED_LABEL] = 'unmatched' - - class_idx = { id: i for i, id in enumerate(classes.keys()) } - matrix = np.zeros((len(classes), len(classes)), dtype=int) - for idx_pair in conf_matrix: - index = (class_idx[idx_pair[0]], class_idx[idx_pair[1]]) - matrix[index] = conf_matrix[idx_pair] - - labels = [label for id, label in classes.items()] - - fig = plt.figure() - fig.add_subplot(111) - table = plt.table( - cellText=matrix, - colLabels=labels, - rowLabels=labels, - loc ='center') - table.auto_set_font_size(False) - table.set_fontsize(8) - table.scale(3, 3) - # Removing ticks and spines enables you to get the figure only with table - plt.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=False) - plt.tick_params(axis='y', which='both', right=False, left=False, labelleft=False) - for pos in ['right','top','bottom','left']: - plt.gca().spines[pos].set_visible(False) - - for idx_pair in conf_matrix: - i = class_idx[idx_pair[0]] - j = class_idx[idx_pair[1]] - if conf_matrix[idx_pair] != 0: - if i != j: - table._cells[(i + 1, j)].set_facecolor('#FF0000') - else: - table._cells[(i + 1, j)].set_facecolor('#00FF00') - - plt.savefig(osp.join(self.save_dir, filename), - bbox_inches='tight', pad_inches=0.05) diff --git a/datumaro/datumaro/cli/contexts/source/__init__.py b/datumaro/datumaro/cli/contexts/source/__init__.py deleted file mode 100644 index 45dbdb1b..00000000 --- a/datumaro/datumaro/cli/contexts/source/__init__.py +++ /dev/null @@ -1,273 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp -import shutil - -from datumaro.components.project import Environment -from ...util import add_subparser, CliException, MultilineFormatter -from ...util.project import load_project - - -def build_add_parser(parser_ctor=argparse.ArgumentParser): - builtins = sorted(Environment().extractors.items) - - base_parser = argparse.ArgumentParser(add_help=False) - base_parser.add_argument('-n', '--name', default=None, - help="Name of the new source") - base_parser.add_argument('-f', '--format', required=True, - help="Source dataset format") - base_parser.add_argument('--skip-check', action='store_true', - help="Skip source checking") - base_parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - - parser = parser_ctor(help="Add data source to project", - description=""" - Adds a data source to a project. The source can be:|n - - a dataset in a supported format (check 'formats' section below)|n - - a Datumaro project|n - |n - The source can be either a local directory or a remote - git repository. Each source type has its own parameters, which can - be checked by:|n - '%s'.|n - |n - Formats:|n - Datasets come in a wide variety of formats. Each dataset - format defines its own data structure and rules on how to - interpret the data. For example, the following data structure - is used in COCO format:|n - /dataset/|n - - /images/.jpg|n - - /annotations/|n - |n - In Datumaro dataset formats are supported by Extractor-s. - An Extractor produces a list of dataset items corresponding - to the dataset. It is possible to add a custom Extractor. - To do this, you need to put an Extractor - definition script to /.datumaro/extractors.|n - |n - List of builtin source formats: %s|n - |n - Examples:|n - - Add a local directory with VOC-like dataset:|n - |s|sadd path path/to/voc -f voc_detection|n - - Add a local file with CVAT annotations, call it 'mysource'|n - |s|s|s|sto the project somewhere else:|n - |s|sadd path path/to/cvat.xml -f cvat -n mysource -p somewhere/else/ - """ % ('%(prog)s SOURCE_TYPE --help', ', '.join(builtins)), - formatter_class=MultilineFormatter, - add_help=False) - parser.set_defaults(command=add_command) - - sp = parser.add_subparsers(dest='source_type', metavar='SOURCE_TYPE', - help="The type of the data source " - "(call '%s SOURCE_TYPE --help' for more info)" % parser.prog) - - dir_parser = sp.add_parser('path', help="Add local path as source", - parents=[base_parser]) - dir_parser.add_argument('url', - help="Path to the source") - dir_parser.add_argument('--copy', action='store_true', - help="Copy the dataset instead of saving source links") - - repo_parser = sp.add_parser('git', help="Add git repository as source", - parents=[base_parser]) - repo_parser.add_argument('url', - help="URL of the source git repository") - repo_parser.add_argument('-b', '--branch', default='master', - help="Branch of the source repository (default: %(default)s)") - repo_parser.add_argument('--checkout', action='store_true', - help="Do branch checkout") - - # NOTE: add common parameters to the parent help output - # the other way could be to use parse_known_args() - display_parser = argparse.ArgumentParser( - parents=[base_parser, parser], - prog=parser.prog, usage="%(prog)s [-h] SOURCE_TYPE ...", - description=parser.description, formatter_class=MultilineFormatter) - class HelpAction(argparse._HelpAction): - def __call__(self, parser, namespace, values, option_string=None): - display_parser.print_help() - parser.exit() - - parser.add_argument('-h', '--help', action=HelpAction, - help='show this help message and exit') - - # TODO: needed distinction on how to add an extractor or a remote source - - return parser - -def add_command(args): - project = load_project(args.project_dir) - - if args.source_type == 'git': - name = args.name - if name is None: - name = osp.splitext(osp.basename(args.url))[0] - - if project.env.git.has_submodule(name): - raise CliException("Git submodule '%s' already exists" % name) - - try: - project.get_source(name) - raise CliException("Source '%s' already exists" % name) - except KeyError: - pass - - rel_local_dir = project.local_source_dir(name) - local_dir = osp.join(project.config.project_dir, rel_local_dir) - url = args.url - project.env.git.create_submodule(name, local_dir, - url=url, branch=args.branch, no_checkout=not args.checkout) - elif args.source_type == 'path': - url = osp.abspath(args.url) - if not osp.exists(url): - raise CliException("Source path '%s' does not exist" % url) - - name = args.name - if name is None: - name = osp.splitext(osp.basename(url))[0] - - if project.env.git.has_submodule(name): - raise CliException("Git submodule '%s' already exists" % name) - - try: - project.get_source(name) - raise CliException("Source '%s' already exists" % name) - except KeyError: - pass - - rel_local_dir = project.local_source_dir(name) - local_dir = osp.join(project.config.project_dir, rel_local_dir) - - if args.copy: - log.info("Copying from '%s' to '%s'" % (url, local_dir)) - if osp.isdir(url): - # copytree requires destination dir not to exist - shutil.copytree(url, local_dir) - url = rel_local_dir - elif osp.isfile(url): - os.makedirs(local_dir) - shutil.copy2(url, local_dir) - url = osp.join(rel_local_dir, osp.basename(url)) - else: - raise Exception("Expected file or directory") - else: - os.makedirs(local_dir) - - project.add_source(name, { 'url': url, 'format': args.format }) - - if not args.skip_check: - log.info("Checking the source...") - try: - project.make_source_project(name).make_dataset() - except Exception: - shutil.rmtree(local_dir, ignore_errors=True) - raise - - project.save() - - log.info("Source '%s' has been added to the project, location: '%s'" \ - % (name, rel_local_dir)) - - return 0 - -def build_remove_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(help="Remove source from project", - description="Remove a source from a project.") - - parser.add_argument('-n', '--name', required=True, - help="Name of the source to be removed") - parser.add_argument('--force', action='store_true', - help="Ignore possible errors during removal") - parser.add_argument('--keep-data', action='store_true', - help="Do not remove source data") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=remove_command) - - return parser - -def remove_command(args): - project = load_project(args.project_dir) - - name = args.name - if not name: - raise CliException("Expected source name") - try: - project.get_source(name) - except KeyError: - if not args.force: - raise CliException("Source '%s' does not exist" % name) - - if project.env.git.has_submodule(name): - if args.force: - log.warning("Forcefully removing the '%s' source..." % name) - - project.env.git.remove_submodule(name, force=args.force) - - source_dir = osp.join(project.config.project_dir, - project.local_source_dir(name)) - project.remove_source(name) - project.save() - - if not args.keep_data: - shutil.rmtree(source_dir, ignore_errors=True) - - log.info("Source '%s' has been removed from the project" % name) - - return 0 - -def build_info_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor() - - parser.add_argument('-n', '--name', - help="Source name") - parser.add_argument('-v', '--verbose', action='store_true', - help="Show details") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.set_defaults(command=info_command) - - return parser - -def info_command(args): - project = load_project(args.project_dir) - - if args.name: - source = project.get_source(args.name) - print(source) - else: - for name, conf in project.config.sources.items(): - print(name) - if args.verbose: - print(dict(conf)) - -def build_parser(parser_ctor=argparse.ArgumentParser): - parser = parser_ctor(description=""" - Manipulate data sources inside of a project.|n - |n - A data source is a source of data for a project. - The project combines multiple data sources into one dataset. - The role of a data source is to provide dataset items - images - and/or annotations.|n - |n - By default, the project to be operated on is searched for - in the current directory. An additional '-p' argument can be - passed to specify project location. - """, - formatter_class=MultilineFormatter) - - subparsers = parser.add_subparsers() - add_subparser(subparsers, 'add', build_add_parser) - add_subparser(subparsers, 'remove', build_remove_parser) - add_subparser(subparsers, 'info', build_info_parser) - - return parser diff --git a/datumaro/datumaro/cli/util/__init__.py b/datumaro/datumaro/cli/util/__init__.py deleted file mode 100644 index 4ee0b72b..00000000 --- a/datumaro/datumaro/cli/util/__init__.py +++ /dev/null @@ -1,74 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import textwrap - - -class CliException(Exception): pass - -def add_subparser(subparsers, name, builder): - return builder(lambda **kwargs: subparsers.add_parser(name, **kwargs)) - -class MultilineFormatter(argparse.HelpFormatter): - """ - Keeps line breaks introduced with '|n' separator - and spaces introduced with '|s'. - """ - - def __init__(self, keep_natural=False, **kwargs): - super().__init__(**kwargs) - self._keep_natural = keep_natural - - def _fill_text(self, text, width, indent): - text = self._whitespace_matcher.sub(' ', text).strip() - text = text.replace('|s', ' ') - - paragraphs = text.split('|n ') - if self._keep_natural: - paragraphs = sum((p.split('\n ') for p in paragraphs), []) - - multiline_text = '' - for paragraph in paragraphs: - formatted_paragraph = textwrap.fill(paragraph, width, - initial_indent=indent, subsequent_indent=indent) + '\n' - multiline_text += formatted_paragraph - return multiline_text - -def required_count(nmin=0, nmax=0): - assert 0 <= nmin and 0 <= nmax and nmin or nmax - - class RequiredCount(argparse.Action): - def __call__(self, parser, args, values, option_string=None): - k = len(values) - if not ((nmin and (nmin <= k) or not nmin) and \ - (nmax and (k <= nmax) or not nmax)): - msg = "Argument '%s' requires" % self.dest - if nmin and nmax: - msg += " from %s to %s arguments" % (nmin, nmax) - elif nmin: - msg += " at least %s arguments" % nmin - else: - msg += " no more %s arguments" % nmax - raise argparse.ArgumentTypeError(msg) - setattr(args, self.dest, values) - return RequiredCount - -def at_least(n): - return required_count(n, 0) - -def make_file_name(s): - # adapted from - # https://docs.djangoproject.com/en/2.1/_modules/django/utils/text/#slugify - """ - Normalizes string, converts to lowercase, removes non-alpha characters, - and converts spaces to hyphens. - """ - import unicodedata, re - s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore') - s = s.decode() - s = re.sub(r'[^\w\s-]', '', s).strip().lower() - s = re.sub(r'[-\s]+', '-', s) - return s \ No newline at end of file diff --git a/datumaro/datumaro/cli/util/project.py b/datumaro/datumaro/cli/util/project.py deleted file mode 100644 index 56590a4d..00000000 --- a/datumaro/datumaro/cli/util/project.py +++ /dev/null @@ -1,39 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import os -import re - -from datumaro.components.project import Project -from datumaro.util import cast - - -def load_project(project_dir): - return Project.load(project_dir) - -def generate_next_file_name(basename, basedir='.', sep='.', ext=''): - """ - If basedir does not contain basename, returns basename, - otherwise generates a name by appending sep to the basename - and the number, next to the last used number in the basedir for - files with basename prefix. Optionally, appends ext. - """ - - return generate_next_name(os.listdir(basedir), basename, sep, ext) - -def generate_next_name(names, basename, sep='.', suffix='', default=None): - pattern = re.compile(r'%s(?:%s(\d+))?%s' % \ - tuple(map(re.escape, [basename, sep, suffix]))) - matches = [match for match in (pattern.match(n) for n in names) if match] - - max_idx = max([cast(match[1], int, 0) for match in matches], default=None) - if max_idx is None: - if default is not None: - idx = sep + str(default) - else: - idx = '' - else: - idx = sep + str(max_idx + 1) - return basename + idx + suffix \ No newline at end of file diff --git a/datumaro/datumaro/components/__init__.py b/datumaro/datumaro/components/__init__.py deleted file mode 100644 index 5a1ec10f..00000000 --- a/datumaro/datumaro/components/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/datumaro/datumaro/components/algorithms/__init__.py b/datumaro/datumaro/components/algorithms/__init__.py deleted file mode 100644 index 5a1ec10f..00000000 --- a/datumaro/datumaro/components/algorithms/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/datumaro/datumaro/components/algorithms/rise.py b/datumaro/datumaro/components/algorithms/rise.py deleted file mode 100644 index 3fb9a895..00000000 --- a/datumaro/datumaro/components/algorithms/rise.py +++ /dev/null @@ -1,203 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-variable - -import numpy as np -from math import ceil - -from datumaro.components.extractor import AnnotationType -from datumaro.util.annotation_util import nms - - -def flatmatvec(mat): - return np.reshape(mat, (len(mat), -1)) - -def expand(array, axis=None): - if axis is None: - axis = len(array.shape) - return np.expand_dims(array, axis=axis) - -class RISE: - """ - Implements RISE: Randomized Input Sampling for - Explanation of Black-box Models algorithm - See explanations at: https://arxiv.org/pdf/1806.07421.pdf - """ - - def __init__(self, model, - max_samples=None, mask_width=7, mask_height=7, prob=0.5, - iou_thresh=0.9, nms_thresh=0.0, det_conf_thresh=0.0, - batch_size=1): - self.model = model - self.max_samples = max_samples - self.mask_height = mask_height - self.mask_width = mask_width - self.prob = prob - self.iou_thresh = iou_thresh - self.nms_thresh = nms_thresh - self.det_conf_thresh = det_conf_thresh - self.batch_size = batch_size - - @staticmethod - def split_outputs(annotations): - labels = [] - bboxes = [] - for r in annotations: - if r.type is AnnotationType.label: - labels.append(r) - elif r.type is AnnotationType.bbox: - bboxes.append(r) - return labels, bboxes - - def normalize_hmaps(self, heatmaps, counts): - eps = np.finfo(heatmaps.dtype).eps - mhmaps = flatmatvec(heatmaps) - mhmaps /= expand(counts * self.prob + eps) - mhmaps -= expand(np.min(mhmaps, axis=1)) - mhmaps /= expand(np.max(mhmaps, axis=1) + eps) - return np.reshape(mhmaps, heatmaps.shape) - - def apply(self, image, progressive=False): - import cv2 - - assert len(image.shape) in [2, 3], \ - "Expected an input image in (H, W, C) format" - if len(image.shape) == 3: - assert image.shape[2] in [3, 4], "Expected BGR or BGRA input" - image = image[:, :, :3].astype(np.float32) - - model = self.model - iou_thresh = self.iou_thresh - - image_size = np.array((image.shape[:2])) - mask_size = np.array((self.mask_height, self.mask_width)) - cell_size = np.ceil(image_size / mask_size) - upsampled_size = np.ceil((mask_size + 1) * cell_size) - - rng = lambda shape=None: np.random.rand(*shape) - samples = np.prod(image_size) - if self.max_samples is not None: - samples = min(self.max_samples, samples) - batch_size = self.batch_size - - result = next(iter(model.launch(expand(image, 0)))) - result_labels, result_bboxes = self.split_outputs(result) - if 0 < self.det_conf_thresh: - result_bboxes = [b for b in result_bboxes \ - if self.det_conf_thresh <= b.attributes['score']] - if 0 < self.nms_thresh: - result_bboxes = nms(result_bboxes, self.nms_thresh) - - predicted_labels = set() - if len(result_labels) != 0: - predicted_label = max(result_labels, - key=lambda r: r.attributes['score']).label - predicted_labels.add(predicted_label) - if len(result_bboxes) != 0: - for bbox in result_bboxes: - predicted_labels.add(bbox.label) - predicted_labels = { label: idx \ - for idx, label in enumerate(predicted_labels) } - - predicted_bboxes = result_bboxes - - heatmaps_count = len(predicted_labels) + len(predicted_bboxes) - heatmaps = np.zeros((heatmaps_count, *image_size), dtype=np.float32) - total_counts = np.zeros(heatmaps_count, dtype=np.int32) - confs = np.zeros(heatmaps_count, dtype=np.float32) - - heatmap_id = 0 - - label_heatmaps = None - label_total_counts = None - label_confs = None - if len(predicted_labels) != 0: - step = len(predicted_labels) - label_heatmaps = heatmaps[heatmap_id : heatmap_id + step] - label_total_counts = total_counts[heatmap_id : heatmap_id + step] - label_confs = confs[heatmap_id : heatmap_id + step] - heatmap_id += step - - bbox_heatmaps = None - bbox_total_counts = None - bbox_confs = None - if len(predicted_bboxes) != 0: - step = len(predicted_bboxes) - bbox_heatmaps = heatmaps[heatmap_id : heatmap_id + step] - bbox_total_counts = total_counts[heatmap_id : heatmap_id + step] - bbox_confs = confs[heatmap_id : heatmap_id + step] - heatmap_id += step - - ups_mask = np.empty(upsampled_size.astype(int), dtype=np.float32) - masks = np.empty((batch_size, *image_size), dtype=np.float32) - - full_batch_inputs = np.empty((batch_size, *image.shape), dtype=np.float32) - current_heatmaps = np.empty_like(heatmaps) - for b in range(ceil(samples / batch_size)): - batch_pos = b * batch_size - current_batch_size = min(samples - batch_pos, batch_size) - - batch_masks = masks[: current_batch_size] - for i in range(current_batch_size): - mask = (rng(mask_size) < self.prob).astype(np.float32) - cv2.resize(mask, (int(upsampled_size[1]), int(upsampled_size[0])), - ups_mask) - - offsets = np.round(rng((2,)) * cell_size) - mask = ups_mask[ - int(offsets[0]):int(image_size[0] + offsets[0]), - int(offsets[1]):int(image_size[1] + offsets[1]) ] - batch_masks[i] = mask - - batch_inputs = full_batch_inputs[:current_batch_size] - np.multiply(expand(batch_masks), expand(image, 0), out=batch_inputs) - - results = model.launch(batch_inputs) - for mask, result in zip(batch_masks, results): - result_labels, result_bboxes = self.split_outputs(result) - - confs.fill(0) - if len(predicted_labels) != 0: - for r in result_labels: - idx = predicted_labels.get(r.label, None) - if idx is not None: - label_total_counts[idx] += 1 - label_confs[idx] += r.attributes['score'] - for r in result_bboxes: - idx = predicted_labels.get(r.label, None) - if idx is not None: - label_total_counts[idx] += 1 - label_confs[idx] += r.attributes['score'] - - if len(predicted_bboxes) != 0 and len(result_bboxes) != 0: - if 0 < self.det_conf_thresh: - result_bboxes = [b for b in result_bboxes \ - if self.det_conf_thresh <= b.attributes['score']] - if 0 < self.nms_thresh: - result_bboxes = nms(result_bboxes, self.nms_thresh) - - for detection in result_bboxes: - for pred_idx, pred in enumerate(predicted_bboxes): - if pred.label != detection.label: - continue - - iou = pred.iou(detection) - assert iou == -1 or 0 <= iou and iou <= 1 - if iou < iou_thresh: - continue - - bbox_total_counts[pred_idx] += 1 - - conf = detection.attributes['score'] - bbox_confs[pred_idx] += conf - - np.multiply.outer(confs, mask, out=current_heatmaps) - heatmaps += current_heatmaps - - if progressive: - yield self.normalize_hmaps(heatmaps.copy(), total_counts) - - yield self.normalize_hmaps(heatmaps, total_counts) \ No newline at end of file diff --git a/datumaro/datumaro/components/cli_plugin.py b/datumaro/datumaro/components/cli_plugin.py deleted file mode 100644 index e85f5c4f..00000000 --- a/datumaro/datumaro/components/cli_plugin.py +++ /dev/null @@ -1,44 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - -from datumaro.cli.util import MultilineFormatter -from datumaro.util import to_snake_case - - -class CliPlugin: - @staticmethod - def _get_name(cls): - return getattr(cls, 'NAME', - remove_plugin_type(to_snake_case(cls.__name__))) - - @staticmethod - def _get_doc(cls): - return getattr(cls, '__doc__', "") - - @classmethod - def build_cmdline_parser(cls, **kwargs): - args = { - 'prog': cls._get_name(cls), - 'description': cls._get_doc(cls), - 'formatter_class': MultilineFormatter, - } - args.update(kwargs) - - return argparse.ArgumentParser(**args) - - @classmethod - def from_cmdline(cls, args=None): - if args and args[0] == '--': - args = args[1:] - parser = cls.build_cmdline_parser() - args = parser.parse_args(args) - return vars(args) - -def remove_plugin_type(s): - for t in {'transform', 'extractor', 'converter', 'launcher', 'importer'}: - s = s.replace('_' + t, '') - return s diff --git a/datumaro/datumaro/components/config.py b/datumaro/datumaro/components/config.py deleted file mode 100644 index a79cda15..00000000 --- a/datumaro/datumaro/components/config.py +++ /dev/null @@ -1,237 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import yaml - - -class Schema: - class Item: - def __init__(self, ctor, internal=False): - self.ctor = ctor - self.internal = internal - - def __call__(self, *args, **kwargs): - return self.ctor(*args, **kwargs) - - def __init__(self, items=None, fallback=None): - self._items = {} - if items is not None: - self._items.update(items) - self._fallback = fallback - - def _get_items(self, allow_fallback=True): - all_items = {} - - if allow_fallback and self._fallback is not None: - all_items.update(self._fallback) - all_items.update(self._items) - - return all_items - - def items(self, allow_fallback=True): - return self._get_items(allow_fallback=allow_fallback).items() - - def keys(self, allow_fallback=True): - return self._get_items(allow_fallback=allow_fallback).keys() - - def values(self, allow_fallback=True): - return self._get_items(allow_fallback=allow_fallback).values() - - def __contains__(self, key): - return key in self.keys() - - def __len__(self): - return len(self._get_items()) - - def __iter__(self): - return iter(self._get_items()) - - def __getitem__(self, key): - default = object() - value = self.get(key, default=default) - if value is default: - raise KeyError('Key "%s" does not exist' % (key)) - return value - - def get(self, key, default=None): - found = self._items.get(key, default) - if found is not default: - return found - - if self._fallback is not None: - return self._fallback.get(key, default) - -class SchemaBuilder: - def __init__(self): - self._items = {} - - def add(self, name, ctor=str, internal=False): - if name in self._items: - raise KeyError('Key "%s" already exists' % (name)) - - self._items[name] = Schema.Item(ctor, internal=internal) - return self - - def build(self): - return Schema(self._items) - -class Config: - def __init__(self, config=None, fallback=None, schema=None, mutable=True): - # schema should be established first - self.__dict__['_schema'] = schema - self.__dict__['_mutable'] = True - - self.__dict__['_config'] = {} - if fallback is not None: - for k, v in fallback.items(allow_fallback=False): - self.set(k, v) - if config is not None: - self.update(config) - - self.__dict__['_mutable'] = mutable - - def _items(self, allow_fallback=True, allow_internal=True): - all_config = {} - if allow_fallback and self._schema is not None: - for key, item in self._schema.items(): - all_config[key] = item() - all_config.update(self._config) - - if not allow_internal and self._schema is not None: - for key, item in self._schema.items(): - if item.internal: - all_config.pop(key) - return all_config - - def items(self, allow_fallback=True, allow_internal=True): - return self._items( - allow_fallback=allow_fallback, - allow_internal=allow_internal - ).items() - - def keys(self, allow_fallback=True, allow_internal=True): - return self._items( - allow_fallback=allow_fallback, - allow_internal=allow_internal - ).keys() - - def values(self, allow_fallback=True, allow_internal=True): - return self._items( - allow_fallback=allow_fallback, - allow_internal=allow_internal - ).values() - - def __contains__(self, key): - return key in self.keys() - - def __len__(self): - return len(self.items()) - - def __iter__(self): - return iter(self.keys()) - - def __getitem__(self, key): - default = object() - value = self.get(key, default=default) - if value is default: - raise KeyError('Key "%s" does not exist' % (key)) - return value - - def __setitem__(self, key, value): - return self.set(key, value) - - def __getattr__(self, key): - return self.get(key) - - def __setattr__(self, key, value): - return self.set(key, value) - - def __eq__(self, other): - try: - for k, my_v in self.items(allow_internal=False): - other_v = other[k] - if my_v != other_v: - return False - return True - except Exception: - return False - - def update(self, other): - for k, v in other.items(): - self.set(k, v) - - def remove(self, key): - if not self._mutable: - raise Exception("Cannot set value of immutable object") - - self._config.pop(key, None) - - def get(self, key, default=None): - found = self._config.get(key, default) - if found is not default: - return found - - if self._schema is not None: - found = self._schema.get(key, default) - if found is not default: - # ignore mutability - found = found() - self._config[key] = found - return found - - return found - - def set(self, key, value): - if not self._mutable: - raise Exception("Cannot set value of immutable object") - - if self._schema is not None: - if key not in self._schema: - raise Exception("Can not set key '%s' - schema mismatch" % (key)) - - schema_entry = self._schema[key] - schema_entry_instance = schema_entry() - if not isinstance(value, type(schema_entry_instance)): - if isinstance(value, dict) and \ - isinstance(schema_entry_instance, Config): - schema_entry_instance.update(value) - value = schema_entry_instance - else: - raise Exception("Can not set key '%s' - schema mismatch" % (key)) - - self._config[key] = value - return value - - @staticmethod - def parse(path): - with open(path, 'r') as f: - return Config(yaml.safe_load(f)) - - @staticmethod - def yaml_representer(dumper, value): - return dumper.represent_data( - value._items(allow_internal=False, allow_fallback=False)) - - def dump(self, path): - with open(path, 'w+') as f: - yaml.dump(self, f) - -yaml.add_multi_representer(Config, Config.yaml_representer) - - -class DefaultConfig(Config): - def __init__(self, default=None): - super().__init__() - self.__dict__['_default'] = default - - def set(self, key, value): - if key not in self.keys(allow_fallback=False): - value = self._default(value) - return super().set(key, value) - else: - return super().set(key, value) - - -DEFAULT_FORMAT = 'datumaro' \ No newline at end of file diff --git a/datumaro/datumaro/components/config_model.py b/datumaro/datumaro/components/config_model.py deleted file mode 100644 index c6f65179..00000000 --- a/datumaro/datumaro/components/config_model.py +++ /dev/null @@ -1,63 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from datumaro.components.config import Config, \ - DefaultConfig as _DefaultConfig, \ - SchemaBuilder as _SchemaBuilder - - -SOURCE_SCHEMA = _SchemaBuilder() \ - .add('url', str) \ - .add('format', str) \ - .add('options', dict) \ - .build() - -class Source(Config): - def __init__(self, config=None): - super().__init__(config, schema=SOURCE_SCHEMA) - - -MODEL_SCHEMA = _SchemaBuilder() \ - .add('launcher', str) \ - .add('options', dict) \ - .build() - -class Model(Config): - def __init__(self, config=None): - super().__init__(config, schema=MODEL_SCHEMA) - - -PROJECT_SCHEMA = _SchemaBuilder() \ - .add('project_name', str) \ - .add('format_version', int) \ - \ - .add('subsets', list) \ - .add('sources', lambda: _DefaultConfig( - lambda v=None: Source(v))) \ - .add('models', lambda: _DefaultConfig( - lambda v=None: Model(v))) \ - \ - .add('models_dir', str, internal=True) \ - .add('plugins_dir', str, internal=True) \ - .add('sources_dir', str, internal=True) \ - .add('dataset_dir', str, internal=True) \ - .add('project_filename', str, internal=True) \ - .add('project_dir', str, internal=True) \ - .add('env_dir', str, internal=True) \ - .build() - -PROJECT_DEFAULT_CONFIG = Config({ - 'project_name': 'undefined', - 'format_version': 1, - - 'sources_dir': 'sources', - 'dataset_dir': 'dataset', - 'models_dir': 'models', - 'plugins_dir': 'plugins', - - 'project_filename': 'config.yaml', - 'project_dir': '', - 'env_dir': '.datumaro', -}, mutable=False, schema=PROJECT_SCHEMA) diff --git a/datumaro/datumaro/components/converter.py b/datumaro/datumaro/components/converter.py deleted file mode 100644 index 05dedb48..00000000 --- a/datumaro/datumaro/components/converter.py +++ /dev/null @@ -1,79 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -import os -import os.path as osp -import shutil - -from datumaro.components.cli_plugin import CliPlugin -from datumaro.util.image import save_image - - -class IConverter: - @classmethod - def convert(cls, extractor, save_dir, **options): - raise NotImplementedError("Should be implemented in a subclass") - -class Converter(IConverter, CliPlugin): - DEFAULT_IMAGE_EXT = None - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('--save-images', action='store_true', - help="Save images (default: %(default)s)") - parser.add_argument('--image-ext', default=None, - help="Image extension (default: keep or use format default%s)" % \ - (' ' + cls.DEFAULT_IMAGE_EXT if cls.DEFAULT_IMAGE_EXT else '')) - - return parser - - @classmethod - def convert(cls, extractor, save_dir, **options): - converter = cls(extractor, save_dir, **options) - return converter.apply() - - def apply(self): - raise NotImplementedError("Should be implemented in a subclass") - - def __init__(self, extractor, save_dir, save_images=False, - image_ext=None, default_image_ext=None): - default_image_ext = default_image_ext or self.DEFAULT_IMAGE_EXT - assert default_image_ext - self._default_image_ext = default_image_ext - - self._save_images = save_images - self._image_ext = image_ext - - self._extractor = extractor - self._save_dir = save_dir - - def _find_image_ext(self, item): - src_ext = None - if item.has_image: - src_ext = osp.splitext(osp.basename(item.image.path))[1] - - return self._image_ext or src_ext or self._default_image_ext - - def _make_image_filename(self, item): - return item.id + self._find_image_ext(item) - - def _save_image(self, item, path=None): - image = item.image.data - if image is None: - log.warning("Item '%s' has no image", item.id) - return item.image.path - - path = path or self._make_image_filename(item) - - src_ext = osp.splitext(osp.basename(item.image.path))[1] - dst_ext = osp.splitext(osp.basename(path))[1] - - os.makedirs(osp.dirname(path), exist_ok=True) - if src_ext == dst_ext and osp.isfile(item.image.path): - shutil.copyfile(item.image.path, path) - else: - save_image(path, image) diff --git a/datumaro/datumaro/components/dataset_filter.py b/datumaro/datumaro/components/dataset_filter.py deleted file mode 100644 index 2fe1443d..00000000 --- a/datumaro/datumaro/components/dataset_filter.py +++ /dev/null @@ -1,261 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -from lxml import etree as ET # lxml has proper XPath implementation -from datumaro.components.extractor import (Transform, - Annotation, AnnotationType, - Label, Mask, Points, Polygon, PolyLine, Bbox, Caption, -) - - -class DatasetItemEncoder: - @classmethod - def encode(cls, item, categories=None): - item_elem = ET.Element('item') - ET.SubElement(item_elem, 'id').text = str(item.id) - ET.SubElement(item_elem, 'subset').text = str(item.subset) - ET.SubElement(item_elem, 'path').text = str('/'.join(item.path)) - - image = item.image - if image is not None: - item_elem.append(cls.encode_image(image)) - - for ann in item.annotations: - item_elem.append(cls.encode_annotation(ann, categories)) - - return item_elem - - @classmethod - def encode_image(cls, image): - image_elem = ET.Element('image') - - size = image.size - if size is not None: - h, w = size - else: - h = 'unknown' - w = h - ET.SubElement(image_elem, 'width').text = str(w) - ET.SubElement(image_elem, 'height').text = str(h) - - ET.SubElement(image_elem, 'has_data').text = '%d' % int(image.has_data) - ET.SubElement(image_elem, 'path').text = image.path - - return image_elem - - @classmethod - def encode_annotation_base(cls, annotation): - assert isinstance(annotation, Annotation) - ann_elem = ET.Element('annotation') - ET.SubElement(ann_elem, 'id').text = str(annotation.id) - ET.SubElement(ann_elem, 'type').text = str(annotation.type.name) - - for k, v in annotation.attributes.items(): - ET.SubElement(ann_elem, k.replace(' ', '-')).text = str(v) - - ET.SubElement(ann_elem, 'group').text = str(annotation.group) - - return ann_elem - - @staticmethod - def _get_label(label_id, categories): - label = '' - if label_id is None: - return '' - if categories is not None: - label_cat = categories.get(AnnotationType.label) - if label_cat is not None: - label = label_cat.items[label_id].name - return label - - @classmethod - def encode_label_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - - return ann_elem - - @classmethod - def encode_mask_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - - return ann_elem - - @classmethod - def encode_bbox_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - ET.SubElement(ann_elem, 'x').text = str(obj.x) - ET.SubElement(ann_elem, 'y').text = str(obj.y) - ET.SubElement(ann_elem, 'w').text = str(obj.w) - ET.SubElement(ann_elem, 'h').text = str(obj.h) - ET.SubElement(ann_elem, 'area').text = str(obj.get_area()) - - return ann_elem - - @classmethod - def encode_points_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - - x, y, w, h = obj.get_bbox() - area = w * h - bbox_elem = ET.SubElement(ann_elem, 'bbox') - ET.SubElement(bbox_elem, 'x').text = str(x) - ET.SubElement(bbox_elem, 'y').text = str(y) - ET.SubElement(bbox_elem, 'w').text = str(w) - ET.SubElement(bbox_elem, 'h').text = str(h) - ET.SubElement(bbox_elem, 'area').text = str(area) - - points = obj.points - for i in range(0, len(points), 2): - point_elem = ET.SubElement(ann_elem, 'point') - ET.SubElement(point_elem, 'x').text = str(points[i]) - ET.SubElement(point_elem, 'y').text = str(points[i + 1]) - ET.SubElement(point_elem, 'visible').text = \ - str(obj.visibility[i // 2].name) - - return ann_elem - - @classmethod - def encode_polygon_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - - x, y, w, h = obj.get_bbox() - area = w * h - bbox_elem = ET.SubElement(ann_elem, 'bbox') - ET.SubElement(bbox_elem, 'x').text = str(x) - ET.SubElement(bbox_elem, 'y').text = str(y) - ET.SubElement(bbox_elem, 'w').text = str(w) - ET.SubElement(bbox_elem, 'h').text = str(h) - ET.SubElement(bbox_elem, 'area').text = str(area) - - points = obj.points - for i in range(0, len(points), 2): - point_elem = ET.SubElement(ann_elem, 'point') - ET.SubElement(point_elem, 'x').text = str(points[i]) - ET.SubElement(point_elem, 'y').text = str(points[i + 1]) - - return ann_elem - - @classmethod - def encode_polyline_object(cls, obj, categories): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'label').text = \ - str(cls._get_label(obj.label, categories)) - ET.SubElement(ann_elem, 'label_id').text = str(obj.label) - - x, y, w, h = obj.get_bbox() - area = w * h - bbox_elem = ET.SubElement(ann_elem, 'bbox') - ET.SubElement(bbox_elem, 'x').text = str(x) - ET.SubElement(bbox_elem, 'y').text = str(y) - ET.SubElement(bbox_elem, 'w').text = str(w) - ET.SubElement(bbox_elem, 'h').text = str(h) - ET.SubElement(bbox_elem, 'area').text = str(area) - - points = obj.points - for i in range(0, len(points), 2): - point_elem = ET.SubElement(ann_elem, 'point') - ET.SubElement(point_elem, 'x').text = str(points[i]) - ET.SubElement(point_elem, 'y').text = str(points[i + 1]) - - return ann_elem - - @classmethod - def encode_caption_object(cls, obj): - ann_elem = cls.encode_annotation_base(obj) - - ET.SubElement(ann_elem, 'caption').text = str(obj.caption) - - return ann_elem - - @classmethod - def encode_annotation(cls, o, categories=None): - if isinstance(o, Label): - return cls.encode_label_object(o, categories) - if isinstance(o, Mask): - return cls.encode_mask_object(o, categories) - if isinstance(o, Bbox): - return cls.encode_bbox_object(o, categories) - if isinstance(o, Points): - return cls.encode_points_object(o, categories) - if isinstance(o, PolyLine): - return cls.encode_polyline_object(o, categories) - if isinstance(o, Polygon): - return cls.encode_polygon_object(o, categories) - if isinstance(o, Caption): - return cls.encode_caption_object(o) - raise NotImplementedError("Unexpected annotation object passed: %s" % o) - - @staticmethod - def to_string(encoded_item): - return ET.tostring(encoded_item, encoding='unicode', pretty_print=True) - -def XPathDatasetFilter(extractor, xpath=None): - if xpath is None: - return extractor - try: - xpath = ET.XPath(xpath) - except Exception: - log.error("Failed to create XPath from expression '%s'", xpath) - raise - f = lambda item: bool(xpath( - DatasetItemEncoder.encode(item, extractor.categories()))) - return extractor.select(f) - -class XPathAnnotationsFilter(Transform): - def __init__(self, extractor, xpath=None, remove_empty=False): - super().__init__(extractor) - - if xpath is not None: - try: - xpath = ET.XPath(xpath) - except Exception: - log.error("Failed to create XPath from expression '%s'", xpath) - raise - self._filter = xpath - - self._remove_empty = remove_empty - - def __iter__(self): - for item in self._extractor: - item = self.transform_item(item) - if item is not None: - yield item - - def transform_item(self, item): - if self._filter is None: - return item - - encoded = DatasetItemEncoder.encode(item, self._extractor.categories()) - filtered = self._filter(encoded) - filtered = [elem for elem in filtered if elem.tag == 'annotation'] - - encoded = encoded.findall('annotation') - annotations = [item.annotations[encoded.index(e)] for e in filtered] - - if self._remove_empty and len(annotations) == 0: - return None - return self.wrap_item(item, annotations=annotations) \ No newline at end of file diff --git a/datumaro/datumaro/components/extractor.py b/datumaro/datumaro/components/extractor.py deleted file mode 100644 index dcb7b036..00000000 --- a/datumaro/datumaro/components/extractor.py +++ /dev/null @@ -1,621 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import namedtuple -from enum import Enum -import numpy as np - -import attr -from attr import attrs, attrib - -from datumaro.util.image import Image -from datumaro.util.attrs_util import not_empty, default_if_none - - -AnnotationType = Enum('AnnotationType', - [ - 'label', - 'mask', - 'points', - 'polygon', - 'polyline', - 'bbox', - 'caption', - ]) - -_COORDINATE_ROUNDING_DIGITS = 2 - -@attrs(kw_only=True) -class Annotation: - id = attrib(default=0, validator=default_if_none(int)) - attributes = attrib(factory=dict, validator=default_if_none(dict)) - group = attrib(default=0, validator=default_if_none(int)) - - def __attrs_post_init__(self): - assert isinstance(self.type, AnnotationType) - - @property - def type(self) -> AnnotationType: - return self._type # must be set in subclasses - - def wrap(self, **kwargs): - return attr.evolve(self, **kwargs) - -@attrs(kw_only=True) -class Categories: - attributes = attrib(factory=set, validator=default_if_none(set), eq=False) - -@attrs -class LabelCategories(Categories): - @attrs(repr_ns='LabelCategories') - class Category: - name = attrib(converter=str, validator=not_empty) - parent = attrib(default='', validator=default_if_none(str)) - attributes = attrib(factory=set, validator=default_if_none(set)) - - items = attrib(factory=list, validator=default_if_none(list)) - _indices = attrib(factory=dict, init=False, eq=False) - - @classmethod - def from_iterable(cls, iterable): - """Generation of LabelCategories from iterable object - - Args: - iterable ([type]): This iterable object can be: - 1)simple str - will generate one Category with str as name - 2)list of str - will interpreted as list of Category names - 3)list of positional argumetns - will generate Categories - with this arguments - - - Returns: - LabelCategories: LabelCategories object - """ - temp_categories = cls() - - if isinstance(iterable, str): - iterable = [[iterable]] - - for category in iterable: - if isinstance(category, str): - category = [category] - temp_categories.add(*category) - - return temp_categories - - def __attrs_post_init__(self): - self._reindex() - - def _reindex(self): - indices = {} - for index, item in enumerate(self.items): - assert item.name not in self._indices - indices[item.name] = index - self._indices = indices - - def add(self, name: str, parent: str = None, attributes: dict = None): - assert name not in self._indices, name - - index = len(self.items) - self.items.append(self.Category(name, parent, attributes)) - self._indices[name] = index - return index - - def find(self, name: str): - index = self._indices.get(name) - if index is not None: - return index, self.items[index] - return index, None - -@attrs -class Label(Annotation): - _type = AnnotationType.label - label = attrib(converter=int) - -@attrs(eq=False) -class MaskCategories(Categories): - colormap = attrib(factory=dict, validator=default_if_none(dict)) - _inverse_colormap = attrib(default=None, - validator=attr.validators.optional(dict)) - - @property - def inverse_colormap(self): - from datumaro.util.mask_tools import invert_colormap - if self._inverse_colormap is None: - if self.colormap is not None: - self._inverse_colormap = invert_colormap(self.colormap) - return self._inverse_colormap - - def __eq__(self, other): - if not super().__eq__(other): - return False - if not isinstance(other, __class__): - return False - for label_id, my_color in self.colormap.items(): - other_color = other.colormap.get(label_id) - if not np.array_equal(my_color, other_color): - return False - return True - -@attrs(eq=False) -class Mask(Annotation): - _type = AnnotationType.mask - _image = attrib() - label = attrib(converter=attr.converters.optional(int), - default=None, kw_only=True) - z_order = attrib(default=0, validator=default_if_none(int), kw_only=True) - - @property - def image(self): - if callable(self._image): - return self._image() - return self._image - - def as_class_mask(self, label_id=None): - if label_id is None: - label_id = self.label - return self.image * label_id - - def as_instance_mask(self, instance_id): - return self.image * instance_id - - def get_area(self): - return np.count_nonzero(self.image) - - def get_bbox(self): - from datumaro.util.mask_tools import find_mask_bbox - return find_mask_bbox(self.image) - - def paint(self, colormap): - from datumaro.util.mask_tools import paint_mask - return paint_mask(self.as_class_mask(), colormap) - - def __eq__(self, other): - if not super().__eq__(other): - return False - if not isinstance(other, __class__): - return False - return \ - (self.label == other.label) and \ - (self.z_order == other.z_order) and \ - (np.array_equal(self.image, other.image)) - -@attrs(eq=False) -class RleMask(Mask): - rle = attrib() - _image = attrib(default=attr.Factory( - lambda self: self._lazy_decode(self.rle), - takes_self=True), init=False) - - @staticmethod - def _lazy_decode(rle): - from pycocotools import mask as mask_utils - return lambda: mask_utils.decode(rle).astype(np.bool) - - def get_area(self): - from pycocotools import mask as mask_utils - return mask_utils.area(self.rle) - - def get_bbox(self): - from pycocotools import mask as mask_utils - return mask_utils.toBbox(self.rle) - - def __eq__(self, other): - if not isinstance(other, __class__): - return super().__eq__(other) - return self.rle == other.rle - -class CompiledMask: - @staticmethod - def from_instance_masks(instance_masks, - instance_ids=None, instance_labels=None): - from datumaro.util.mask_tools import merge_masks - - if instance_ids is not None: - assert len(instance_ids) == len(instance_masks) - else: - instance_ids = [None] * len(instance_masks) - - if instance_labels is not None: - assert len(instance_labels) == len(instance_masks) - else: - instance_labels = [None] * len(instance_masks) - - instance_masks = sorted( - zip(instance_masks, instance_ids, instance_labels), - key=lambda m: m[0].z_order) - - instance_mask = [m.as_instance_mask(id if id is not None else 1 + idx) - for idx, (m, id, _) in enumerate(instance_masks)] - instance_mask = merge_masks(instance_mask) - - cls_mask = [m.as_class_mask(c) for m, _, c in instance_masks] - cls_mask = merge_masks(cls_mask) - return __class__(class_mask=cls_mask, instance_mask=instance_mask) - - def __init__(self, class_mask=None, instance_mask=None): - self._class_mask = class_mask - self._instance_mask = instance_mask - - @staticmethod - def _get_image(image): - if callable(image): - return image() - return image - - @property - def class_mask(self): - return self._get_image(self._class_mask) - - @property - def instance_mask(self): - return self._get_image(self._instance_mask) - - @property - def instance_count(self): - return int(self.instance_mask.max()) - - def get_instance_labels(self): - class_shift = 16 - m = (self.class_mask.astype(np.uint32) << class_shift) \ - + self.instance_mask.astype(np.uint32) - keys = np.unique(m) - instance_labels = {k & ((1 << class_shift) - 1): k >> class_shift - for k in keys if k & ((1 << class_shift) - 1) != 0 - } - return instance_labels - - def extract(self, instance_id): - return self.instance_mask == instance_id - - def lazy_extract(self, instance_id): - return lambda: self.extract(instance_id) - -@attrs -class _Shape(Annotation): - points = attrib(converter=lambda x: - [round(p, _COORDINATE_ROUNDING_DIGITS) for p in x]) - label = attrib(converter=attr.converters.optional(int), - default=None, kw_only=True) - z_order = attrib(default=0, validator=default_if_none(int), kw_only=True) - - def get_area(self): - raise NotImplementedError() - - def get_bbox(self): - points = self.points - if not points: - return None - - xs = [p for p in points[0::2]] - ys = [p for p in points[1::2]] - x0 = min(xs) - x1 = max(xs) - y0 = min(ys) - y1 = max(ys) - return [x0, y0, x1 - x0, y1 - y0] - -@attrs -class PolyLine(_Shape): - _type = AnnotationType.polyline - - def as_polygon(self): - return self.points[:] - - def get_area(self): - return 0 - -@attrs -class Polygon(_Shape): - _type = AnnotationType.polygon - - def __attrs_post_init__(self): - super().__attrs_post_init__() - # keep the message on a single line to produce informative output - assert len(self.points) % 2 == 0 and 3 <= len(self.points) // 2, "Wrong polygon points: %s" % self.points - - def get_area(self): - import pycocotools.mask as mask_utils - - x, y, w, h = self.get_bbox() - rle = mask_utils.frPyObjects([self.points], y + h, x + w) - area = mask_utils.area(rle)[0] - return area - -@attrs -class Bbox(_Shape): - _type = AnnotationType.bbox - - # will be overridden by attrs, then will be overridden again by us - # attrs' method will be renamed to __attrs_init__ - def __init__(self, x, y, w, h, *args, **kwargs): - kwargs.pop('points', None) # comes from wrap() - self.__attrs_init__([x, y, x + w, y + h], *args, **kwargs) - __actual_init__ = __init__ # save pointer - - @property - def x(self): - return self.points[0] - - @property - def y(self): - return self.points[1] - - @property - def w(self): - return self.points[2] - self.points[0] - - @property - def h(self): - return self.points[3] - self.points[1] - - def get_area(self): - return self.w * self.h - - def get_bbox(self): - return [self.x, self.y, self.w, self.h] - - def as_polygon(self): - x, y, w, h = self.get_bbox() - return [ - x, y, - x + w, y, - x + w, y + h, - x, y + h - ] - - def iou(self, other): - from datumaro.util.annotation_util import bbox_iou - return bbox_iou(self.get_bbox(), other.get_bbox()) - - def wrap(item, **kwargs): - d = {'x': item.x, 'y': item.y, 'w': item.w, 'h': item.h} - d.update(kwargs) - return attr.evolve(item, **d) - -assert not hasattr(Bbox, '__attrs_init__') # hopefully, it will be supported -setattr(Bbox, '__attrs_init__', Bbox.__init__) -setattr(Bbox, '__init__', Bbox.__actual_init__) - -@attrs -class PointsCategories(Categories): - @attrs(repr_ns="PointsCategories") - class Category: - labels = attrib(factory=list, validator=default_if_none(list)) - joints = attrib(factory=set, validator=default_if_none(set)) - - items = attrib(factory=dict, validator=default_if_none(dict)) - - @classmethod - def from_iterable(cls, iterable): - """Generation of PointsCategories from iterable object - - Args: - iterable ([type]): This iterable object can be: - 1) list of positional argumetns - will generate Categories - with these arguments - - Returns: - PointsCategories: PointsCategories object - """ - temp_categories = cls() - - for category in iterable: - temp_categories.add(*category) - return temp_categories - - def add(self, label_id, labels=None, joints=None): - if joints is None: - joints = [] - joints = set(map(tuple, joints)) - self.items[label_id] = self.Category(labels, joints) - -@attrs -class Points(_Shape): - Visibility = Enum('Visibility', [ - ('absent', 0), - ('hidden', 1), - ('visible', 2), - ]) - _type = AnnotationType.points - - visibility = attrib(type=list, default=None) - @visibility.validator - def _visibility_validator(self, attribute, visibility): - if visibility is None: - visibility = [self.Visibility.visible] * (len(self.points) // 2) - else: - for i, v in enumerate(visibility): - if not isinstance(v, self.Visibility): - visibility[i] = self.Visibility(v) - assert len(visibility) == len(self.points) // 2 - self.visibility = visibility - - def __attrs_post_init__(self): - super().__attrs_post_init__() - assert len(self.points) % 2 == 0, self.points - - def get_area(self): - return 0 - - def get_bbox(self): - xs = [p for p, v in zip(self.points[0::2], self.visibility) - if v != __class__.Visibility.absent] - ys = [p for p, v in zip(self.points[1::2], self.visibility) - if v != __class__.Visibility.absent] - x0 = min(xs, default=0) - x1 = max(xs, default=0) - y0 = min(ys, default=0) - y1 = max(ys, default=0) - return [x0, y0, x1 - x0, y1 - y0] - -@attrs -class Caption(Annotation): - _type = AnnotationType.caption - caption = attrib(converter=str) - -@attrs -class DatasetItem: - id = attrib(converter=lambda x: str(x).replace('\\', '/'), - type=str, validator=not_empty) - annotations = attrib(factory=list, validator=default_if_none(list)) - subset = attrib(default='', validator=default_if_none(str)) - path = attrib(factory=list, validator=default_if_none(list)) - - image = attrib(type=Image, default=None) - @image.validator - def _image_validator(self, attribute, image): - if callable(image) or isinstance(image, np.ndarray): - image = Image(data=image) - elif isinstance(image, str): - image = Image(path=image) - assert image is None or isinstance(image, Image) - self.image = image - - attributes = attrib(factory=dict, validator=default_if_none(dict)) - - @property - def has_image(self): - return self.image is not None - - def wrap(item, **kwargs): - return attr.evolve(item, **kwargs) - -class IExtractor: - def __iter__(self): - raise NotImplementedError() - - def __len__(self): - raise NotImplementedError() - - def subsets(self): - raise NotImplementedError() - - def get_subset(self, name): - raise NotImplementedError() - - def categories(self): - raise NotImplementedError() - - def select(self, pred): - raise NotImplementedError() - -class _DatasetFilter: - def __init__(self, iterable, predicate): - self.iterable = iterable - self.predicate = predicate - - def __iter__(self): - return filter(self.predicate, self.iterable) - -class _ExtractorBase(IExtractor): - def __init__(self, length=None, subsets=None): - self._length = length - self._subsets = subsets - - def _init_cache(self): - subsets = set() - length = -1 - for length, item in enumerate(self): - subsets.add(item.subset) - length += 1 - - if self._length is None: - self._length = length - if self._subsets is None: - self._subsets = subsets - - def __len__(self): - if self._length is None: - self._init_cache() - return self._length - - def subsets(self): - if self._subsets is None: - self._init_cache() - return list(self._subsets) - - def get_subset(self, name): - if name in self.subsets(): - return self.select(lambda item: item.subset == name) - else: - raise Exception("Unknown subset '%s' requested" % name) - - def transform(self, method, *args, **kwargs): - return method(self, *args, **kwargs) - -class DatasetIteratorWrapper(_ExtractorBase): - def __init__(self, iterable, categories, subsets=None): - super().__init__(length=None, subsets=subsets) - self._iterable = iterable - self._categories = categories - - def __iter__(self): - return iter(self._iterable) - - def categories(self): - return self._categories - - def select(self, pred): - return DatasetIteratorWrapper( - _DatasetFilter(self, pred), self.categories(), self.subsets()) - -class Extractor(_ExtractorBase): - def __init__(self, length=None): - super().__init__(length=None) - - def categories(self): - return {} - - def select(self, pred): - return DatasetIteratorWrapper( - _DatasetFilter(self, pred), self.categories(), self.subsets()) - -DEFAULT_SUBSET_NAME = 'default' - - -class SourceExtractor(Extractor): - def __init__(self, length=None, subset=None): - super().__init__(length=length) - - if subset == DEFAULT_SUBSET_NAME: - subset = None - self._subset = subset - - def subsets(self): - return [self._subset] - - def get_subset(self, name): - if name != self._subset: - raise Exception("Unknown subset '%s' requested" % name) - return self - -class Importer: - @classmethod - def detect(cls, path): - raise NotImplementedError() - - def __call__(self, path, **extra_params): - raise NotImplementedError() - -class Transform(Extractor): - @staticmethod - def wrap_item(item, **kwargs): - return item.wrap(**kwargs) - - def __init__(self, extractor): - super().__init__() - - self._extractor = extractor - - def __iter__(self): - for item in self._extractor: - yield self.transform_item(item) - - def categories(self): - return self._extractor.categories() - - def transform_item(self, item: DatasetItem) -> DatasetItem: - raise NotImplementedError() diff --git a/datumaro/datumaro/components/launcher.py b/datumaro/datumaro/components/launcher.py deleted file mode 100644 index adc31fb5..00000000 --- a/datumaro/datumaro/components/launcher.py +++ /dev/null @@ -1,67 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import numpy as np - -from datumaro.components.extractor import (Transform, LabelCategories, - AnnotationType) -from datumaro.util import take_by - - -# pylint: disable=no-self-use -class Launcher: - def __init__(self, model_dir=None): - pass - - def launch(self, inputs): - raise NotImplementedError() - - def categories(self): - return None -# pylint: enable=no-self-use - -class ModelTransform(Transform): - def __init__(self, extractor, launcher, batch_size=1): - super().__init__(extractor) - self._launcher = launcher - self._batch_size = batch_size - - def __iter__(self): - for batch in take_by(self._extractor, self._batch_size): - inputs = np.array([item.image.data for item in batch]) - inference = self._launcher.launch(inputs) - - for item, annotations in zip(batch, inference): - self._check_annotations(annotations) - yield self.wrap_item(item, annotations=annotations) - - def get_subset(self, name): - subset = self._extractor.get_subset(name) - return __class__(subset, self._launcher, self._batch_size) - - def categories(self): - launcher_override = self._launcher.categories() - if launcher_override is not None: - return launcher_override - return self._extractor.categories() - - def transform_item(self, item): - inputs = np.expand_dims(item.image, axis=0) - annotations = self._launcher.launch(inputs)[0] - return self.wrap_item(item, annotations=annotations) - - def _check_annotations(self, annotations): - labels_count = len(self.categories().get( - AnnotationType.label, LabelCategories()).items) - - for ann in annotations: - label = getattr(ann, 'label') - if label is None: - continue - - if label not in range(labels_count): - raise Exception("Annotation has unexpected label id %s, " - "while there is only %s defined labels." % \ - (label, labels_count)) \ No newline at end of file diff --git a/datumaro/datumaro/components/operations.py b/datumaro/datumaro/components/operations.py deleted file mode 100644 index d887add9..00000000 --- a/datumaro/datumaro/components/operations.py +++ /dev/null @@ -1,1504 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -from copy import deepcopy -import hashlib -import logging as log - -import attr -import cv2 -import numpy as np -from attr import attrib, attrs -from unittest import TestCase - -from datumaro.components.cli_plugin import CliPlugin -from datumaro.components.extractor import (AnnotationType, Bbox, Label, - LabelCategories, PointsCategories, MaskCategories) -from datumaro.components.project import Dataset -from datumaro.util import find, filter_dict -from datumaro.util.attrs_util import ensure_cls, default_if_none -from datumaro.util.annotation_util import (segment_iou, bbox_iou, - mean_bbox, OKS, find_instances, max_bbox, smooth_line) - -def get_ann_type(anns, t): - return [a for a in anns if a.type == t] - -def match_annotations_equal(a, b): - matches = [] - a_unmatched = a[:] - b_unmatched = b[:] - for a_ann in a: - for b_ann in b_unmatched: - if a_ann != b_ann: - continue - - matches.append((a_ann, b_ann)) - a_unmatched.remove(a_ann) - b_unmatched.remove(b_ann) - break - - return matches, a_unmatched, b_unmatched - -def merge_annotations_equal(a, b): - matches, a_unmatched, b_unmatched = match_annotations_equal(a, b) - return [ann_a for (ann_a, _) in matches] + a_unmatched + b_unmatched - -def merge_categories(sources): - categories = {} - for source in sources: - categories.update(source) - for source in sources: - for cat_type, source_cat in source.items(): - if not categories[cat_type] == source_cat: - raise NotImplementedError( - "Merging of datasets with different categories is " - "only allowed in 'merge' command.") - return categories - -class MergingStrategy(CliPlugin): - @classmethod - def merge(cls, sources, **options): - instance = cls(**options) - return instance(sources) - - def __init__(self, **options): - super().__init__(**options) - self.__dict__['_sources'] = None - - def __call__(self, sources): - raise NotImplementedError() - - -@attrs -class DatasetError: - item_id = attrib() - -@attrs -class QualityError(DatasetError): - pass - -@attrs -class TooCloseError(QualityError): - a = attrib() - b = attrib() - distance = attrib() - - def __str__(self): - return "Item %s: annotations are too close: %s, %s, distance = %s" % \ - (self.item_id, self.a, self.b, self.distance) - -@attrs -class WrongGroupError(QualityError): - found = attrib(converter=set) - expected = attrib(converter=set) - group = attrib(converter=list) - - def __str__(self): - return "Item %s: annotation group has wrong labels: " \ - "found %s, expected %s, group %s" % \ - (self.item_id, self.found, self.expected, self.group) - -@attrs -class MergeError(DatasetError): - sources = attrib(converter=set) - -@attrs -class NoMatchingAnnError(MergeError): - ann = attrib() - - def __str__(self): - return "Item %s: can't find matching annotation " \ - "in sources %s, annotation is %s" % \ - (self.item_id, self.sources, self.ann) - -@attrs -class NoMatchingItemError(MergeError): - def __str__(self): - return "Item %s: can't find matching item in sources %s" % \ - (self.item_id, self.sources) - -@attrs -class FailedLabelVotingError(MergeError): - votes = attrib() - ann = attrib(default=None) - - def __str__(self): - return "Item %s: label voting failed%s, votes %s, sources %s" % \ - (self.item_id, 'for ann %s' % self.ann if self.ann else '', - self.votes, self.sources) - -@attrs -class FailedAttrVotingError(MergeError): - attr = attrib() - votes = attrib() - ann = attrib() - - def __str__(self): - return "Item %s: attribute voting failed " \ - "for ann %s, votes %s, sources %s" % \ - (self.item_id, self.ann, self.votes, self.sources) - -@attrs -class IntersectMerge(MergingStrategy): - @attrs(repr_ns='IntersectMerge', kw_only=True) - class Conf: - pairwise_dist = attrib(converter=float, default=0.5) - sigma = attrib(converter=list, factory=list) - - output_conf_thresh = attrib(converter=float, default=0) - quorum = attrib(converter=int, default=0) - ignored_attributes = attrib(converter=set, factory=set) - - def _groups_conveter(value): - result = [] - for group in value: - rg = set() - for label in group: - optional = label.endswith('?') - name = label if not optional else label[:-1] - rg.add((name, optional)) - result.append(rg) - return result - groups = attrib(converter=_groups_conveter, factory=list) - close_distance = attrib(converter=float, default=0.75) - conf = attrib(converter=ensure_cls(Conf), factory=Conf) - - # Error trackers: - errors = attrib(factory=list, init=False) - def add_item_error(self, error, *args, **kwargs): - self.errors.append(error(self._item_id, *args, **kwargs)) - - # Indexes: - _dataset_map = attrib(init=False) # id(dataset) -> (dataset, index) - _item_map = attrib(init=False) # id(item) -> (item, id(dataset)) - _ann_map = attrib(init=False) # id(ann) -> (ann, id(item)) - _item_id = attrib(init=False) - _item = attrib(init=False) - - # Misc. - _categories = attrib(init=False) # merged categories - - def __call__(self, datasets): - self._categories = self._merge_categories( - [d.categories() for d in datasets]) - merged = Dataset(categories=self._categories) - - self._check_groups_definition() - - item_matches, item_map = self.match_items(datasets) - self._item_map = item_map - self._dataset_map = { id(d): (d, i) for i, d in enumerate(datasets) } - - for item_id, items in item_matches.items(): - self._item_id = item_id - - if len(items) < len(datasets): - missing_sources = set(id(s) for s in datasets) - set(items) - missing_sources = [self._dataset_map[s][1] - for s in missing_sources] - self.add_item_error(NoMatchingItemError, missing_sources) - merged.put(self.merge_items(items)) - - return merged - - def get_ann_source(self, ann_id): - return self._item_map[self._ann_map[ann_id][1]][1] - - def merge_items(self, items): - self._item = next(iter(items.values())) - - self._ann_map = {} - sources = [] - for item in items.values(): - self._ann_map.update({ id(a): (a, id(item)) - for a in item.annotations }) - sources.append(item.annotations) - log.debug("Merging item %s: source annotations %s" % \ - (self._item_id, list(map(len, sources)))) - - annotations = self.merge_annotations(sources) - - annotations = [a for a in annotations - if self.conf.output_conf_thresh <= a.attributes.get('score', 1)] - - return self._item.wrap(annotations=annotations) - - def merge_annotations(self, sources): - self._make_mergers(sources) - - clusters = self._match_annotations(sources) - - joined_clusters = sum(clusters.values(), []) - group_map = self._find_cluster_groups(joined_clusters) - - annotations = [] - for t, clusters in clusters.items(): - for cluster in clusters: - self._check_cluster_sources(cluster) - - merged_clusters = self._merge_clusters(t, clusters) - - for merged_ann, cluster in zip(merged_clusters, clusters): - attributes = self._find_cluster_attrs(cluster, merged_ann) - attributes = { k: v for k, v in attributes.items() - if k not in self.conf.ignored_attributes } - attributes.update(merged_ann.attributes) - merged_ann.attributes = attributes - - new_group_id = find(enumerate(group_map), - lambda e: id(cluster) in e[1][0]) - if new_group_id is None: - new_group_id = 0 - else: - new_group_id = new_group_id[0] + 1 - merged_ann.group = new_group_id - - if self.conf.close_distance: - self._check_annotation_distance(t, merged_clusters) - - annotations += merged_clusters - - if self.conf.groups: - self._check_groups(annotations) - - return annotations - - @staticmethod - def match_items(datasets): - item_ids = set((item.id, item.subset) for d in datasets for item in d) - - item_map = {} # id(item) -> (item, id(dataset)) - - matches = OrderedDict() - for (item_id, item_subset) in sorted(item_ids, key=lambda e: e[0]): - items = {} - for d in datasets: - try: - item = d.get(item_id, subset=item_subset) - items[id(d)] = item - item_map[id(item)] = (item, id(d)) - except KeyError: - pass - matches[(item_id, item_subset)] = items - - return matches, item_map - - def _merge_label_categories(self, sources): - same = True - common = None - for src_categories in sources: - src_cat = src_categories.get(AnnotationType.label) - if common is None: - common = src_cat - elif common != src_cat: - same = False - break - - if same: - return common - - dst_cat = LabelCategories() - for src_id, src_categories in enumerate(sources): - src_cat = src_categories.get(AnnotationType.label) - if src_cat is None: - continue - - for src_label in src_cat.items: - dst_label = dst_cat.find(src_label.name)[1] - if dst_label is not None: - if dst_label != src_label: - if src_label.parent and dst_label.parent and \ - src_label.parent != dst_label.parent: - raise ValueError("Can't merge label category " - "%s (from #%s): " - "parent label conflict: %s vs. %s" % \ - (src_label.name, src_id, - src_label.parent, dst_label.parent) - ) - dst_label.parent = dst_label.parent or src_label.parent - dst_label.attributes |= src_label.attributes - else: - pass - else: - dst_cat.add(src_label.name, - src_label.parent, src_label.attributes) - - return dst_cat - - def _merge_point_categories(self, sources, label_cat): - dst_point_cat = PointsCategories() - - for src_id, src_categories in enumerate(sources): - src_label_cat = src_categories.get(AnnotationType.label) - src_point_cat = src_categories.get(AnnotationType.points) - if src_label_cat is None or src_point_cat is None: - continue - - for src_label_id, src_cat in src_point_cat.items.items(): - src_label = src_label_cat.items[src_label_id].name - dst_label_id = label_cat.find(src_label)[0] - dst_cat = dst_point_cat.items.get(dst_label_id) - if dst_cat is not None: - if dst_cat != src_cat: - raise ValueError("Can't merge point category for label " - "%s (from #%s): %s vs. %s" % \ - (src_label, src_id, src_cat, dst_cat) - ) - else: - pass - else: - dst_point_cat.add(dst_label_id, - src_cat.labels, src_cat.joints) - - if len(dst_point_cat.items) == 0: - return None - - return dst_point_cat - - def _merge_mask_categories(self, sources, label_cat): - dst_mask_cat = MaskCategories() - - for src_id, src_categories in enumerate(sources): - src_label_cat = src_categories.get(AnnotationType.label) - src_mask_cat = src_categories.get(AnnotationType.mask) - if src_label_cat is None or src_mask_cat is None: - continue - - for src_label_id, src_cat in src_mask_cat.colormap.items(): - src_label = src_label_cat.items[src_label_id].name - dst_label_id = label_cat.find(src_label)[0] - dst_cat = dst_mask_cat.colormap.get(dst_label_id) - if dst_cat is not None: - if dst_cat != src_cat: - raise ValueError("Can't merge mask category for label " - "%s (from #%s): %s vs. %s" % \ - (src_label, src_id, src_cat, dst_cat) - ) - else: - pass - else: - dst_mask_cat.colormap[dst_label_id] = src_cat - - if len(dst_mask_cat.colormap) == 0: - return None - - return dst_mask_cat - - def _merge_categories(self, sources): - dst_categories = {} - - label_cat = self._merge_label_categories(sources) - if label_cat is None: - return dst_categories - - dst_categories[AnnotationType.label] = label_cat - - points_cat = self._merge_point_categories(sources, label_cat) - if points_cat is not None: - dst_categories[AnnotationType.points] = points_cat - - mask_cat = self._merge_mask_categories(sources, label_cat) - if mask_cat is not None: - dst_categories[AnnotationType.mask] = mask_cat - - return dst_categories - - def _match_annotations(self, sources): - all_by_type = {} - for s in sources: - src_by_type = {} - for a in s: - src_by_type.setdefault(a.type, []).append(a) - for k, v in src_by_type.items(): - all_by_type.setdefault(k, []).append(v) - - clusters = {} - for k, v in all_by_type.items(): - clusters.setdefault(k, []).extend(self._match_ann_type(k, v)) - - return clusters - - def _make_mergers(self, sources): - def _make(c, **kwargs): - kwargs.update(attr.asdict(self.conf)) - fields = attr.fields_dict(c) - return c(**{ k: v for k, v in kwargs.items() if k in fields }, - context=self) - - def _for_type(t, **kwargs): - if t is AnnotationType.label: - return _make(LabelMerger, **kwargs) - elif t is AnnotationType.bbox: - return _make(BboxMerger, **kwargs) - elif t is AnnotationType.mask: - return _make(MaskMerger, **kwargs) - elif t is AnnotationType.polygon: - return _make(PolygonMerger, **kwargs) - elif t is AnnotationType.polyline: - return _make(LineMerger, **kwargs) - elif t is AnnotationType.points: - return _make(PointsMerger, **kwargs) - elif t is AnnotationType.caption: - return _make(CaptionsMerger, **kwargs) - else: - raise NotImplementedError("Type %s is not supported" % t) - - instance_map = {} - for s in sources: - s_instances = find_instances(s) - for inst in s_instances: - inst_bbox = max_bbox([a for a in inst if a.type in - {AnnotationType.polygon, - AnnotationType.mask, AnnotationType.bbox} - ]) - for ann in inst: - instance_map[id(ann)] = [inst, inst_bbox] - - self._mergers = { t: _for_type(t, instance_map=instance_map) - for t in AnnotationType } - - def _match_ann_type(self, t, sources): - return self._mergers[t].match_annotations(sources) - - def _merge_clusters(self, t, clusters): - return self._mergers[t].merge_clusters(clusters) - - @staticmethod - def _find_cluster_groups(clusters): - cluster_groups = [] - visited = set() - for a_idx, cluster_a in enumerate(clusters): - if a_idx in visited: - continue - visited.add(a_idx) - - cluster_group = { id(cluster_a) } - - # find segment groups in the cluster group - a_groups = set(ann.group for ann in cluster_a) - for cluster_b in clusters[a_idx+1 :]: - b_groups = set(ann.group for ann in cluster_b) - if a_groups & b_groups: - a_groups |= b_groups - - # now we know all the segment groups in this cluster group - # so we can find adjacent clusters - for b_idx, cluster_b in enumerate(clusters[a_idx+1 :]): - b_idx = a_idx + 1 + b_idx - b_groups = set(ann.group for ann in cluster_b) - if a_groups & b_groups: - cluster_group.add( id(cluster_b) ) - visited.add(b_idx) - - if a_groups == {0}: - continue # skip annotations without a group - cluster_groups.append( (cluster_group, a_groups) ) - return cluster_groups - - def _find_cluster_attrs(self, cluster, ann): - quorum = self.conf.quorum or 0 - - # TODO: when attribute types are implemented, add linear - # interpolation for contiguous values - - attr_votes = {} # name -> { value: score , ... } - for s in cluster: - for name, value in s.attributes.items(): - votes = attr_votes.get(name, {}) - votes[value] = 1 + votes.get(value, 0) - attr_votes[name] = votes - - attributes = {} - for name, votes in attr_votes.items(): - winner, count = max(votes.items(), key=lambda e: e[1]) - if count < quorum: - if sum(votes.values()) < quorum: - # blame provokers - missing_sources = set( - self.get_ann_source(id(a)) for a in cluster - if s.attributes.get(name) == winner) - else: - # blame outliers - missing_sources = set( - self.get_ann_source(id(a)) for a in cluster - if s.attributes.get(name) != winner) - missing_sources = [self._dataset_map[s][1] - for s in missing_sources] - self.add_item_error(FailedAttrVotingError, - missing_sources, name, votes, ann) - continue - attributes[name] = winner - - return attributes - - def _check_cluster_sources(self, cluster): - if len(cluster) == len(self._dataset_map): - return - - def _has_item(s): - try: - item =self._dataset_map[s][0].get(*self._item_id) - if len(item.annotations) == 0: - return False - return True - except KeyError: - return False - - missing_sources = set(self._dataset_map) - \ - set(self.get_ann_source(id(a)) for a in cluster) - missing_sources = [self._dataset_map[s][1] for s in missing_sources - if _has_item(s)] - if missing_sources: - self.add_item_error(NoMatchingAnnError, missing_sources, cluster[0]) - - def _check_annotation_distance(self, t, annotations): - for a_idx, a_ann in enumerate(annotations): - for b_ann in annotations[a_idx+1:]: - d = self._mergers[t].distance(a_ann, b_ann) - if self.conf.close_distance < d: - self.add_item_error(TooCloseError, a_ann, b_ann, d) - - def _check_groups(self, annotations): - check_groups = [] - for check_group_raw in self.conf.groups: - check_group = set(l[0] for l in check_group_raw) - optional = set(l[0] for l in check_group_raw if l[1]) - check_groups.append((check_group, optional)) - - def _check_group(group_labels, group): - for check_group, optional in check_groups: - common = check_group & group_labels - real_miss = check_group - common - optional - extra = group_labels - check_group - if common and (extra or real_miss): - self.add_item_error(WrongGroupError, group_labels, - check_group, group) - break - - groups = find_instances(annotations) - for group in groups: - group_labels = set() - for ann in group: - if not hasattr(ann, 'label'): - continue - label = self._get_label_name(ann.label) - - if ann.group: - group_labels.add(label) - else: - _check_group({label}, [ann]) - - if not group_labels: - continue - _check_group(group_labels, group) - - def _get_label_name(self, label_id): - if label_id is None: - return None - return self._categories[AnnotationType.label].items[label_id].name - - def _get_label_id(self, label): - return self._categories[AnnotationType.label].find(label)[0] - - def _get_src_label_name(self, ann, label_id): - if label_id is None: - return None - item_id = self._ann_map[id(ann)][1] - dataset_id = self._item_map[item_id][1] - return self._dataset_map[dataset_id][0] \ - .categories()[AnnotationType.label].items[label_id].name - - def _get_any_label_name(self, ann, label_id): - if label_id is None: - return None - try: - return self._get_src_label_name(ann, label_id) - except KeyError: - return self._get_label_name(label_id) - - def _check_groups_definition(self): - for group in self.conf.groups: - for label, _ in group: - _, entry = self._categories[AnnotationType.label].find(label) - if entry is None: - raise ValueError("Datasets do not contain " - "label '%s', available labels %s" % \ - (label, [i.name for i in - self._categories[AnnotationType.label].items]) - ) - -@attrs(kw_only=True) -class AnnotationMatcher: - _context = attrib(type=IntersectMerge, default=None) - - def match_annotations(self, sources): - raise NotImplementedError() - -@attrs -class LabelMatcher(AnnotationMatcher): - def distance(self, a, b): - a_label = self._context._get_any_label_name(a, a.label) - b_label = self._context._get_any_label_name(b, b.label) - return a_label == b_label - - def match_annotations(self, sources): - return [sum(sources, [])] - -@attrs(kw_only=True) -class _ShapeMatcher(AnnotationMatcher): - pairwise_dist = attrib(converter=float, default=0.9) - cluster_dist = attrib(converter=float, default=-1.0) - - def match_annotations(self, sources): - distance = self.distance - label_matcher = self.label_matcher - pairwise_dist = self.pairwise_dist - cluster_dist = self.cluster_dist - - if cluster_dist < 0: cluster_dist = pairwise_dist - - id_segm = { id(a): (a, id(s)) for s in sources for a in s } - - def _is_close_enough(cluster, extra_id): - # check if whole cluster IoU will not be broken - # when this segment is added - b = id_segm[extra_id][0] - for a_id in cluster: - a = id_segm[a_id][0] - if distance(a, b) < cluster_dist: - return False - return True - - def _has_same_source(cluster, extra_id): - b = id_segm[extra_id][1] - for a_id in cluster: - a = id_segm[a_id][1] - if a == b: - return True - return False - - # match segments in sources, pairwise - adjacent = { i: [] for i in id_segm } # id(sgm) -> [id(adj_sgm1), ...] - for a_idx, src_a in enumerate(sources): - for src_b in sources[a_idx+1 :]: - matches, _, _, _ = match_segments(src_a, src_b, - dist_thresh=pairwise_dist, - distance=distance, label_matcher=label_matcher) - for a, b in matches: - adjacent[id(a)].append(id(b)) - - # join all segments into matching clusters - clusters = [] - visited = set() - for cluster_idx in adjacent: - if cluster_idx in visited: - continue - - cluster = set() - to_visit = { cluster_idx } - while to_visit: - c = to_visit.pop() - cluster.add(c) - visited.add(c) - - for i in adjacent[c]: - if i in visited: - continue - if 0 < cluster_dist and not _is_close_enough(cluster, i): - continue - if _has_same_source(cluster, i): - continue - - to_visit.add(i) - - clusters.append([id_segm[i][0] for i in cluster]) - - return clusters - - @staticmethod - def distance(a, b): - return segment_iou(a, b) - - def label_matcher(self, a, b): - a_label = self._context._get_any_label_name(a, a.label) - b_label = self._context._get_any_label_name(b, b.label) - return a_label == b_label - -@attrs -class BboxMatcher(_ShapeMatcher): - pass - -@attrs -class PolygonMatcher(_ShapeMatcher): - pass - -@attrs -class MaskMatcher(_ShapeMatcher): - pass - -@attrs(kw_only=True) -class PointsMatcher(_ShapeMatcher): - sigma = attrib(type=list, default=None) - instance_map = attrib(converter=dict) - - def distance(self, a, b): - a_bbox = self.instance_map[id(a)][1] - b_bbox = self.instance_map[id(b)][1] - if bbox_iou(a_bbox, b_bbox) <= 0: - return 0 - bbox = mean_bbox([a_bbox, b_bbox]) - return OKS(a, b, sigma=self.sigma, bbox=bbox) - -@attrs -class LineMatcher(_ShapeMatcher): - @staticmethod - def distance(a, b): - a_bbox = a.get_bbox() - b_bbox = b.get_bbox() - bbox = max_bbox([a_bbox, b_bbox]) - area = bbox[2] * bbox[3] - if not area: - return 1 - - # compute inter-line area, normalize by common bbox - point_count = max(max(len(a.points) // 2, len(b.points) // 2), 5) - a, sa = smooth_line(a.points, point_count) - b, sb = smooth_line(b.points, point_count) - dists = np.linalg.norm(a - b, axis=1) - dists = (dists[:-1] + dists[1:]) * 0.5 - s = np.sum(dists) * 0.5 * (sa + sb) / area - return abs(1 - s) - -@attrs -class CaptionsMatcher(AnnotationMatcher): - def match_annotations(self, sources): - raise NotImplementedError() - - -@attrs(kw_only=True) -class AnnotationMerger: - def merge_clusters(self, clusters): - raise NotImplementedError() - -@attrs(kw_only=True) -class LabelMerger(AnnotationMerger, LabelMatcher): - quorum = attrib(converter=int, default=0) - - def merge_clusters(self, clusters): - assert len(clusters) <= 1 - if len(clusters) == 0: - return [] - - votes = {} # label -> score - for ann in clusters[0]: - label = self._context._get_src_label_name(ann, ann.label) - votes[label] = 1 + votes.get(label, 0) - - merged = [] - for label, count in votes.items(): - if count < self.quorum: - sources = set(self.get_ann_source(id(a)) for a in clusters[0] - if label not in [self._context._get_src_label_name(l, l.label) - for l in a]) - sources = [self._context._dataset_map[s][1] for s in sources] - self._context.add_item_error(FailedLabelVotingError, - sources, votes) - continue - - merged.append(Label(self._context._get_label_id(label), attributes={ - 'score': count / len(self._context._dataset_map) - })) - - return merged - -@attrs(kw_only=True) -class _ShapeMerger(AnnotationMerger, _ShapeMatcher): - quorum = attrib(converter=int, default=0) - - def merge_clusters(self, clusters): - merged = [] - for cluster in clusters: - label, label_score = self.find_cluster_label(cluster) - shape, shape_score = self.merge_cluster_shape(cluster) - - shape.z_order = max(cluster, key=lambda a: a.z_order).z_order - shape.label = label - shape.attributes['score'] = label_score * shape_score \ - if label is not None else shape_score - - merged.append(shape) - - return merged - - def find_cluster_label(self, cluster): - votes = {} - for s in cluster: - label = self._context._get_src_label_name(s, s.label) - state = votes.setdefault(label, [0, 0]) - state[0] += s.attributes.get('score', 1.0) - state[1] += 1 - - label, (score, count) = max(votes.items(), key=lambda e: e[1][0]) - if count < self.quorum: - self._context.add_item_error(FailedLabelVotingError, votes) - label = None - score = score / len(self._context._dataset_map) - label = self._context._get_label_id(label) - return label, score - - @staticmethod - def _merge_cluster_shape_mean_box_nearest(cluster): - mbbox = Bbox(*mean_bbox(cluster)) - dist = (segment_iou(mbbox, s) for s in cluster) - nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) - return cluster[nearest_pos] - - def merge_cluster_shape(self, cluster): - shape = self._merge_cluster_shape_mean_box_nearest(cluster) - shape_score = sum(max(0, self.distance(shape, s)) - for s in cluster) / len(cluster) - return shape, shape_score - -@attrs -class BboxMerger(_ShapeMerger, BboxMatcher): - pass - -@attrs -class PolygonMerger(_ShapeMerger, PolygonMatcher): - pass - -@attrs -class MaskMerger(_ShapeMerger, MaskMatcher): - pass - -@attrs -class PointsMerger(_ShapeMerger, PointsMatcher): - pass - -@attrs -class LineMerger(_ShapeMerger, LineMatcher): - pass - -@attrs -class CaptionsMerger(AnnotationMerger, CaptionsMatcher): - pass - -def match_segments(a_segms, b_segms, distance=segment_iou, dist_thresh=1.0, - label_matcher=lambda a, b: a.label == b.label): - assert callable(distance), distance - assert callable(label_matcher), label_matcher - - a_segms.sort(key=lambda ann: 1 - ann.attributes.get('score', 1)) - b_segms.sort(key=lambda ann: 1 - ann.attributes.get('score', 1)) - - # a_matches: indices of b_segms matched to a bboxes - # b_matches: indices of a_segms matched to b bboxes - a_matches = -np.ones(len(a_segms), dtype=int) - b_matches = -np.ones(len(b_segms), dtype=int) - - distances = np.array([[distance(a, b) for b in b_segms] for a in a_segms]) - - # matches: boxes we succeeded to match completely - # mispred: boxes we succeeded to match, having label mismatch - matches = [] - mispred = [] - - for a_idx, a_segm in enumerate(a_segms): - if len(b_segms) == 0: - break - matched_b = -1 - max_dist = -1 - b_indices = np.argsort([not label_matcher(a_segm, b_segm) - for b_segm in b_segms], - kind='stable') # prioritize those with same label, keep score order - for b_idx in b_indices: - if 0 <= b_matches[b_idx]: # assign a_segm with max conf - continue - d = distances[a_idx, b_idx] - if d < dist_thresh or d <= max_dist: - continue - max_dist = d - matched_b = b_idx - - if matched_b < 0: - continue - a_matches[a_idx] = matched_b - b_matches[matched_b] = a_idx - - b_segm = b_segms[matched_b] - - if label_matcher(a_segm, b_segm): - matches.append( (a_segm, b_segm) ) - else: - mispred.append( (a_segm, b_segm) ) - - # *_umatched: boxes of (*) we failed to match - a_unmatched = [a_segms[i] for i, m in enumerate(a_matches) if m < 0] - b_unmatched = [b_segms[i] for i, m in enumerate(b_matches) if m < 0] - - return matches, mispred, a_unmatched, b_unmatched - -def mean_std(dataset): - """ - Computes unbiased mean and std. dev. for dataset images, channel-wise. - """ - # Use an online algorithm to: - # - handle different image sizes - # - avoid cancellation problem - if len(dataset) == 0: - return [0, 0, 0], [0, 0, 0] - - stats = np.empty((len(dataset), 2, 3), dtype=np.double) - counts = np.empty(len(dataset), dtype=np.uint32) - - mean = lambda i, s: s[i][0] - var = lambda i, s: s[i][1] - - for i, item in enumerate(dataset): - counts[i] = np.prod(item.image.size) - - image = item.image.data - if len(image.shape) == 2: - image = image[:, :, np.newaxis] - else: - image = image[:, :, :3] - # opencv is much faster than numpy here - cv2.meanStdDev(image.astype(np.double) / 255, - mean=mean(i, stats), stddev=var(i, stats)) - - # make variance unbiased - np.multiply(np.square(stats[:, 1]), - (counts / (counts - 1))[:, np.newaxis], - out=stats[:, 1]) - - _, mean, var = StatsCounter().compute_stats(stats, counts, mean, var) - return mean * 255, np.sqrt(var) * 255 - -class StatsCounter: - # Implements online parallel computation of sample variance - # https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Parallel_algorithm - - # Needed do avoid catastrophic cancellation in floating point computations - @staticmethod - def pairwise_stats(count_a, mean_a, var_a, count_b, mean_b, var_b): - delta = mean_b - mean_a - m_a = var_a * (count_a - 1) - m_b = var_b * (count_b - 1) - M2 = m_a + m_b + delta ** 2 * count_a * count_b / (count_a + count_b) - return ( - count_a + count_b, - mean_a * 0.5 + mean_b * 0.5, - M2 / (count_a + count_b - 1) - ) - - # stats = float array of shape N, 2 * d, d = dimensions of values - # count = integer array of shape N - # mean_accessor = function(idx, stats) to retrieve element mean - # variance_accessor = function(idx, stats) to retrieve element variance - # Recursively computes total count, mean and variance, does O(log(N)) calls - @staticmethod - def compute_stats(stats, counts, mean_accessor, variance_accessor): - m = mean_accessor - v = variance_accessor - n = len(stats) - if n == 1: - return counts[0], m(0, stats), v(0, stats) - if n == 2: - return __class__.pairwise_stats( - counts[0], m(0, stats), v(0, stats), - counts[1], m(1, stats), v(1, stats) - ) - h = n // 2 - return __class__.pairwise_stats( - *__class__.compute_stats(stats[:h], counts[:h], m, v), - *__class__.compute_stats(stats[h:], counts[h:], m, v) - ) - -def compute_image_statistics(dataset): - stats = { - 'dataset': {}, - 'subsets': {} - } - - def _extractor_stats(extractor): - available = True - for item in extractor: - if not (item.has_image and item.image.has_data): - available = False - log.warn("Item %s has no image. Image stats won't be computed", - item.id) - break - - stats = { - 'images count': len(extractor), - } - - if available: - mean, std = mean_std(extractor) - stats.update({ - 'image mean': [float(n) for n in mean[::-1]], - 'image std': [float(n) for n in std[::-1]], - }) - else: - stats.update({ - 'image mean': 'n/a', - 'image std': 'n/a', - }) - return stats - - stats['dataset'].update(_extractor_stats(dataset)) - - subsets = dataset.subsets() or [None] - if subsets and 0 < len([s for s in subsets if s]): - for subset_name in subsets: - stats['subsets'][subset_name] = _extractor_stats( - dataset.get_subset(subset_name)) - - return stats - -def compute_ann_statistics(dataset): - labels = dataset.categories().get(AnnotationType.label) - def get_label(ann): - return labels.items[ann.label].name if ann.label is not None else None - - stats = { - 'images count': len(dataset), - 'annotations count': 0, - 'unannotated images count': 0, - 'unannotated images': [], - 'annotations by type': { t.name: { - 'count': 0, - } for t in AnnotationType }, - 'annotations': {}, - } - by_type = stats['annotations by type'] - - attr_template = { - 'count': 0, - 'values count': 0, - 'values present': set(), - 'distribution': {}, # value -> (count, total%) - } - label_stat = { - 'count': 0, - 'distribution': { l.name: [0, 0] for l in labels.items - }, # label -> (count, total%) - - 'attributes': {}, - } - stats['annotations']['labels'] = label_stat - segm_stat = { - 'avg. area': 0, - 'area distribution': [], # a histogram with 10 bins - # (min, min+10%), ..., (min+90%, max) -> (count, total%) - - 'pixel distribution': { l.name: [0, 0] for l in labels.items - }, # label -> (count, total%) - } - stats['annotations']['segments'] = segm_stat - segm_areas = [] - pixel_dist = segm_stat['pixel distribution'] - total_pixels = 0 - - for item in dataset: - if len(item.annotations) == 0: - stats['unannotated images'].append(item.id) - continue - - for ann in item.annotations: - by_type[ann.type.name]['count'] += 1 - - if not hasattr(ann, 'label') or ann.label is None: - continue - - if ann.type in {AnnotationType.mask, - AnnotationType.polygon, AnnotationType.bbox}: - area = ann.get_area() - segm_areas.append(area) - pixel_dist[get_label(ann)][0] += int(area) - - label_stat['count'] += 1 - label_stat['distribution'][get_label(ann)][0] += 1 - - for name, value in ann.attributes.items(): - if name.lower() in { 'occluded', 'visibility', 'score', - 'id', 'track_id' }: - continue - attrs_stat = label_stat['attributes'].setdefault(name, - deepcopy(attr_template)) - attrs_stat['count'] += 1 - attrs_stat['values present'].add(str(value)) - attrs_stat['distribution'] \ - .setdefault(str(value), [0, 0])[0] += 1 - - stats['annotations count'] = sum(t['count'] for t in - stats['annotations by type'].values()) - stats['unannotated images count'] = len(stats['unannotated images']) - - for label_info in label_stat['distribution'].values(): - label_info[1] = label_info[0] / label_stat['count'] - - for label_attr in label_stat['attributes'].values(): - label_attr['values count'] = len(label_attr['values present']) - label_attr['values present'] = sorted(label_attr['values present']) - for attr_info in label_attr['distribution'].values(): - attr_info[1] = attr_info[0] / label_attr['count'] - - # numpy.sum might be faster, but could overflow with large datasets. - # Python's int can transparently mutate to be of indefinite precision (long) - total_pixels = sum(int(a) for a in segm_areas) - - segm_stat['avg. area'] = total_pixels / (len(segm_areas) or 1.0) - - for label_info in segm_stat['pixel distribution'].values(): - label_info[1] = label_info[0] / total_pixels - - if len(segm_areas) != 0: - hist, bins = np.histogram(segm_areas) - segm_stat['area distribution'] = [{ - 'min': float(bin_min), 'max': float(bin_max), - 'count': int(c), 'percent': int(c) / len(segm_areas) - } for c, (bin_min, bin_max) in zip(hist, zip(bins[:-1], bins[1:]))] - - return stats - -@attrs -class DistanceComparator: - iou_threshold = attrib(converter=float, default=0.5) - - @staticmethod - def match_datasets(a, b): - a_items = set((item.id, item.subset) for item in a) - b_items = set((item.id, item.subset) for item in b) - - matches = a_items & b_items - a_unmatched = a_items - b_items - b_unmatched = b_items - a_items - return matches, a_unmatched, b_unmatched - - @staticmethod - def match_classes(a, b): - a_label_cat = a.categories().get(AnnotationType.label, LabelCategories()) - b_label_cat = b.categories().get(AnnotationType.label, LabelCategories()) - - a_labels = set(c.name for c in a_label_cat) - b_labels = set(c.name for c in b_label_cat) - - matches = a_labels & b_labels - a_unmatched = a_labels - b_labels - b_unmatched = b_labels - a_labels - return matches, a_unmatched, b_unmatched - - def match_annotations(self, item_a, item_b): - return { t: self._match_ann_type(t, item_a, item_b) } - - def _match_ann_type(self, t, *args): - # pylint: disable=no-value-for-parameter - if t == AnnotationType.label: - return self.match_labels(*args) - elif t == AnnotationType.bbox: - return self.match_boxes(*args) - elif t == AnnotationType.polygon: - return self.match_polygons(*args) - elif t == AnnotationType.mask: - return self.match_masks(*args) - elif t == AnnotationType.points: - return self.match_points(*args) - elif t == AnnotationType.polyline: - return self.match_lines(*args) - # pylint: enable=no-value-for-parameter - else: - raise NotImplementedError("Unexpected annotation type %s" % t) - - @staticmethod - def _get_ann_type(t, item): - return get_ann_type(item.annotations, t) - - def match_labels(self, item_a, item_b): - a_labels = set(a.label for a in - self._get_ann_type(AnnotationType.label, item_a)) - b_labels = set(a.label for a in - self._get_ann_type(AnnotationType.label, item_b)) - - matches = a_labels & b_labels - a_unmatched = a_labels - b_labels - b_unmatched = b_labels - a_labels - return matches, a_unmatched, b_unmatched - - def _match_segments(self, t, item_a, item_b): - a_boxes = self._get_ann_type(t, item_a) - b_boxes = self._get_ann_type(t, item_b) - return match_segments(a_boxes, b_boxes, dist_thresh=self.iou_threshold) - - def match_polygons(self, item_a, item_b): - return self._match_segments(AnnotationType.polygon, item_a, item_b) - - def match_masks(self, item_a, item_b): - return self._match_segments(AnnotationType.mask, item_a, item_b) - - def match_boxes(self, item_a, item_b): - return self._match_segments(AnnotationType.bbox, item_a, item_b) - - def match_points(self, item_a, item_b): - a_points = self._get_ann_type(AnnotationType.points, item_a) - b_points = self._get_ann_type(AnnotationType.points, item_b) - - instance_map = {} - for s in [item_a.annotations, item_b.annotations]: - s_instances = find_instances(s) - for inst in s_instances: - inst_bbox = max_bbox(inst) - for ann in inst: - instance_map[id(ann)] = [inst, inst_bbox] - matcher = PointsMatcher(instance_map=instance_map) - - return match_segments(a_points, b_points, - dist_thresh=self.iou_threshold, distance=matcher.distance) - - def match_lines(self, item_a, item_b): - a_lines = self._get_ann_type(AnnotationType.polyline, item_a) - b_lines = self._get_ann_type(AnnotationType.polyline, item_b) - - matcher = LineMatcher() - - return match_segments(a_lines, b_lines, - dist_thresh=self.iou_threshold, distance=matcher.distance) - -def match_items_by_id(a, b): - a_items = set((item.id, item.subset) for item in a) - b_items = set((item.id, item.subset) for item in b) - - matches = a_items & b_items - matches = [([m], [m]) for m in matches] - a_unmatched = a_items - b_items - b_unmatched = b_items - a_items - return matches, a_unmatched, b_unmatched - -def match_items_by_image_hash(a, b): - def _hash(item): - if not item.image.has_data: - log.warning("Image (%s, %s) has no image " - "data, counted as unmatched", item.id, item.subset) - return None - return hashlib.md5(item.image.data.tobytes()).hexdigest() - - def _build_hashmap(source): - d = {} - for item in source: - h = _hash(item) - if h is None: - h = str(id(item)) # anything unique - d.setdefault(h, []).append((item.id, item.subset)) - return d - - a_hash = _build_hashmap(a) - b_hash = _build_hashmap(b) - - a_items = set(a_hash) - b_items = set(b_hash) - - matches = a_items & b_items - a_unmatched = a_items - b_items - b_unmatched = b_items - a_items - - matches = [(a_hash[h], b_hash[h]) for h in matches] - a_unmatched = set(i for h in a_unmatched for i in a_hash[h]) - b_unmatched = set(i for h in b_unmatched for i in b_hash[h]) - - return matches, a_unmatched, b_unmatched - -@attrs -class ExactComparator: - match_images = attrib(kw_only=True, type=bool, default=False) - ignored_fields = attrib(kw_only=True, - factory=set, validator=default_if_none(set)) - ignored_attrs = attrib(kw_only=True, - factory=set, validator=default_if_none(set)) - ignored_item_attrs = attrib(kw_only=True, - factory=set, validator=default_if_none(set)) - - _test = attrib(init=False, type=TestCase) - errors = attrib(init=False, type=list) - - def __attrs_post_init__(self): - self._test = TestCase() - self._test.maxDiff = None - - - def _match_items(self, a, b): - if self.match_images: - return match_items_by_image_hash(a, b) - else: - return match_items_by_id(a, b) - - def _compare_categories(self, a, b): - test = self._test - errors = self.errors - - try: - test.assertEqual( - sorted(a, key=lambda t: t.value), - sorted(b, key=lambda t: t.value) - ) - except AssertionError as e: - errors.append({'type': 'categories', 'message': str(e)}) - - if AnnotationType.label in a: - try: - test.assertEqual( - a[AnnotationType.label].items, - b[AnnotationType.label].items, - ) - except AssertionError as e: - errors.append({'type': 'labels', 'message': str(e)}) - if AnnotationType.mask in a: - try: - test.assertEqual( - a[AnnotationType.mask].colormap, - b[AnnotationType.mask].colormap, - ) - except AssertionError as e: - errors.append({'type': 'colormap', 'message': str(e)}) - if AnnotationType.points in a: - try: - test.assertEqual( - a[AnnotationType.points].items, - b[AnnotationType.points].items, - ) - except AssertionError as e: - errors.append({'type': 'points', 'message': str(e)}) - - def _compare_annotations(self, a, b): - ignored_fields = self.ignored_fields - ignored_attrs = self.ignored_attrs - - a_fields = { k: None for k in vars(a) if k in ignored_fields } - b_fields = { k: None for k in vars(b) if k in ignored_fields } - if 'attributes' not in ignored_fields: - a_fields['attributes'] = filter_dict(a.attributes, ignored_attrs) - b_fields['attributes'] = filter_dict(b.attributes, ignored_attrs) - - result = a.wrap(**a_fields) == b.wrap(**b_fields) - - return result - - def _compare_items(self, item_a, item_b): - test = self._test - - a_id = (item_a.id, item_a.subset) - b_id = (item_b.id, item_b.subset) - - matched = [] - unmatched = [] - errors = [] - - try: - test.assertEqual( - filter_dict(item_a.attributes, self.ignored_item_attrs), - filter_dict(item_b.attributes, self.ignored_item_attrs) - ) - except AssertionError as e: - errors.append({'type': 'item_attr', - 'a_item': a_id, 'b_item': b_id, 'message': str(e)}) - - b_annotations = item_b.annotations[:] - for ann_a in item_a.annotations: - ann_b_candidates = [x for x in item_b.annotations - if x.type == ann_a.type] - - ann_b = find(enumerate(self._compare_annotations(ann_a, x) - for x in ann_b_candidates), lambda x: x[1]) - if ann_b is None: - unmatched.append({ - 'item': a_id, 'source': 'a', 'ann': str(ann_a), - }) - continue - else: - ann_b = ann_b_candidates[ann_b[0]] - - b_annotations.remove(ann_b) # avoid repeats - matched.append({'a_item': a_id, 'b_item': b_id, - 'a': str(ann_a), 'b': str(ann_b)}) - - for ann_b in b_annotations: - unmatched.append({'item': b_id, 'source': 'b', 'ann': str(ann_b)}) - - return matched, unmatched, errors - - def compare_datasets(self, a, b): - self.errors = [] - errors = self.errors - - self._compare_categories(a.categories(), b.categories()) - - matched = [] - unmatched = [] - - matches, a_unmatched, b_unmatched = self._match_items(a, b) - - if a.categories().get(AnnotationType.label) != \ - b.categories().get(AnnotationType.label): - return matched, unmatched, a_unmatched, b_unmatched, errors - - _dist = lambda s: len(s[1]) + len(s[2]) - for a_ids, b_ids in matches: - # build distance matrix - match_status = {} # (a_id, b_id): [matched, unmatched, errors] - a_matches = { a_id: None for a_id in a_ids } - b_matches = { b_id: None for b_id in b_ids } - - for a_id in a_ids: - item_a = a.get(*a_id) - candidates = {} - - for b_id in b_ids: - item_b = b.get(*b_id) - - i_m, i_um, i_err = self._compare_items(item_a, item_b) - candidates[b_id] = [i_m, i_um, i_err] - - if len(i_um) == 0: - a_matches[a_id] = b_id - b_matches[b_id] = a_id - matched.extend(i_m) - errors.extend(i_err) - break - - match_status[a_id] = candidates - - # assign - for a_id in a_ids: - if len(b_ids) == 0: - break - - # find the closest, ignore already assigned - matched_b = a_matches[a_id] - if matched_b is not None: - continue - min_dist = -1 - for b_id in b_ids: - if b_matches[b_id] is not None: - continue - d = _dist(match_status[a_id][b_id]) - if d < min_dist and 0 <= min_dist: - continue - min_dist = d - matched_b = b_id - - if matched_b is None: - continue - a_matches[a_id] = matched_b - b_matches[matched_b] = a_id - - m = match_status[a_id][matched_b] - matched.extend(m[0]) - unmatched.extend(m[1]) - errors.extend(m[2]) - - a_unmatched |= set(a_id for a_id, m in a_matches.items() if not m) - b_unmatched |= set(b_id for b_id, m in b_matches.items() if not m) - - return matched, unmatched, a_unmatched, b_unmatched, errors \ No newline at end of file diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py deleted file mode 100644 index 07f8f019..00000000 --- a/datumaro/datumaro/components/project.py +++ /dev/null @@ -1,850 +0,0 @@ -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict, defaultdict -from functools import reduce -from glob import glob -from typing import Iterable, Union, Dict, List -import git -import importlib -import inspect -import logging as log -import os -import os.path as osp -import shutil -import sys - -from datumaro.components.config import Config, DEFAULT_FORMAT -from datumaro.components.config_model import (Model, Source, - PROJECT_DEFAULT_CONFIG, PROJECT_SCHEMA) -from datumaro.components.extractor import Extractor, LabelCategories,\ - AnnotationType, DatasetItem -from datumaro.components.launcher import ModelTransform -from datumaro.components.dataset_filter import \ - XPathDatasetFilter, XPathAnnotationsFilter - - -def import_foreign_module(name, path, package=None): - module = None - default_path = sys.path.copy() - try: - sys.path = [ osp.abspath(path), ] + default_path - sys.modules.pop(name, None) # remove from cache - module = importlib.import_module(name, package=package) - sys.modules.pop(name) # remove from cache - except Exception: - raise - finally: - sys.path = default_path - return module - - -class Registry: - def __init__(self, config=None, item_type=None): - self.item_type = item_type - - self.items = {} - - if config is not None: - self.load(config) - - def load(self, config): - pass - - def register(self, name, value): - if self.item_type: - value = self.item_type(value) - self.items[name] = value - return value - - def unregister(self, name): - return self.items.pop(name, None) - - def get(self, key): - return self.items[key] # returns a class / ctor - - -class ModelRegistry(Registry): - def __init__(self, config=None): - super().__init__(config, item_type=Model) - - def load(self, config): - # TODO: list default dir, insert values - if 'models' in config: - for name, model in config.models.items(): - self.register(name, model) - - -class SourceRegistry(Registry): - def __init__(self, config=None): - super().__init__(config, item_type=Source) - - def load(self, config): - # TODO: list default dir, insert values - if 'sources' in config: - for name, source in config.sources.items(): - self.register(name, source) - -class PluginRegistry(Registry): - def __init__(self, config=None, builtin=None, local=None): - super().__init__(config) - - from datumaro.components.cli_plugin import CliPlugin - - if builtin is not None: - for v in builtin: - k = CliPlugin._get_name(v) - self.register(k, v) - if local is not None: - for v in local: - k = CliPlugin._get_name(v) - self.register(k, v) - -class GitWrapper: - def __init__(self, config=None): - self.repo = None - - if config is not None and config.project_dir: - self.init(config.project_dir) - - @staticmethod - def _git_dir(base_path): - return osp.join(base_path, '.git') - - @classmethod - def spawn(cls, path): - spawn = not osp.isdir(cls._git_dir(path)) - repo = git.Repo.init(path=path) - if spawn: - repo.config_writer().set_value("user", "name", "User") \ - .set_value("user", "email", "user@nowhere.com") \ - .release() - # gitpython does not support init, use git directly - repo.git.init() - repo.git.commit('-m', 'Initial commit', '--allow-empty') - return repo - - def init(self, path): - self.repo = self.spawn(path) - return self.repo - - def is_initialized(self): - return self.repo is not None - - def create_submodule(self, name, dst_dir, **kwargs): - self.repo.create_submodule(name, dst_dir, **kwargs) - - def has_submodule(self, name): - return name in [submodule.name for submodule in self.repo.submodules] - - def remove_submodule(self, name, **kwargs): - return self.repo.submodule(name).remove(**kwargs) - -def load_project_as_dataset(url): - # symbol forward declaration - raise NotImplementedError() - -class Environment: - _builtin_plugins = None - PROJECT_EXTRACTOR_NAME = 'datumaro_project' - - def __init__(self, config=None): - config = Config(config, - fallback=PROJECT_DEFAULT_CONFIG, schema=PROJECT_SCHEMA) - - self.models = ModelRegistry(config) - self.sources = SourceRegistry(config) - - self.git = GitWrapper(config) - - env_dir = osp.join(config.project_dir, config.env_dir) - builtin = self._load_builtin_plugins() - custom = self._load_plugins2(osp.join(env_dir, config.plugins_dir)) - select = lambda seq, t: [e for e in seq if issubclass(e, t)] - from datumaro.components.extractor import Transform - from datumaro.components.extractor import SourceExtractor - from datumaro.components.extractor import Importer - from datumaro.components.converter import Converter - from datumaro.components.launcher import Launcher - self.extractors = PluginRegistry( - builtin=select(builtin, SourceExtractor), - local=select(custom, SourceExtractor) - ) - self.extractors.register(self.PROJECT_EXTRACTOR_NAME, - load_project_as_dataset) - - self.importers = PluginRegistry( - builtin=select(builtin, Importer), - local=select(custom, Importer) - ) - self.launchers = PluginRegistry( - builtin=select(builtin, Launcher), - local=select(custom, Launcher) - ) - self.converters = PluginRegistry( - builtin=select(builtin, Converter), - local=select(custom, Converter) - ) - self.transforms = PluginRegistry( - builtin=select(builtin, Transform), - local=select(custom, Transform) - ) - - @staticmethod - def _find_plugins(plugins_dir): - plugins = [] - if not osp.exists(plugins_dir): - return plugins - - for plugin_name in os.listdir(plugins_dir): - p = osp.join(plugins_dir, plugin_name) - if osp.isfile(p) and p.endswith('.py'): - plugins.append((plugins_dir, plugin_name, None)) - elif osp.isdir(p): - plugins += [(plugins_dir, - osp.splitext(plugin_name)[0] + '.' + osp.basename(p), - osp.splitext(plugin_name)[0] - ) - for p in glob(osp.join(p, '*.py'))] - return plugins - - @classmethod - def _import_module(cls, module_dir, module_name, types, package=None): - module = import_foreign_module(osp.splitext(module_name)[0], module_dir, - package=package) - - exports = [] - if hasattr(module, 'exports'): - exports = module.exports - else: - for symbol in dir(module): - if symbol.startswith('_'): - continue - exports.append(getattr(module, symbol)) - - exports = [s for s in exports - if inspect.isclass(s) and issubclass(s, types) and not s in types] - - return exports - - @classmethod - def _load_plugins(cls, plugins_dir, types): - types = tuple(types) - - plugins = cls._find_plugins(plugins_dir) - - all_exports = [] - for module_dir, module_name, package in plugins: - try: - exports = cls._import_module(module_dir, module_name, types, - package) - except Exception as e: - module_search_error = ImportError - try: - module_search_error = ModuleNotFoundError # python 3.6+ - except NameError: - pass - - message = ["Failed to import module '%s': %s", module_name, e] - if isinstance(e, module_search_error): - log.debug(*message) - else: - log.warning(*message) - continue - - log.debug("Imported the following symbols from %s: %s" % \ - ( - module_name, - ', '.join(s.__name__ for s in exports) - ) - ) - all_exports.extend(exports) - - return all_exports - - @classmethod - def _load_builtin_plugins(cls): - if not cls._builtin_plugins: - plugins_dir = osp.join( - __file__[: __file__.rfind(osp.join('datumaro', 'components'))], - osp.join('datumaro', 'plugins') - ) - assert osp.isdir(plugins_dir), plugins_dir - cls._builtin_plugins = cls._load_plugins2(plugins_dir) - return cls._builtin_plugins - - @classmethod - def _load_plugins2(cls, plugins_dir): - from datumaro.components.extractor import Transform - from datumaro.components.extractor import SourceExtractor - from datumaro.components.extractor import Importer - from datumaro.components.converter import Converter - from datumaro.components.launcher import Launcher - types = [SourceExtractor, Converter, Importer, Launcher, Transform] - - return cls._load_plugins(plugins_dir, types) - - def make_extractor(self, name, *args, **kwargs): - return self.extractors.get(name)(*args, **kwargs) - - def make_importer(self, name, *args, **kwargs): - return self.importers.get(name)(*args, **kwargs) - - def make_launcher(self, name, *args, **kwargs): - return self.launchers.get(name)(*args, **kwargs) - - def make_converter(self, name, *args, **kwargs): - return self.converters.get(name)(*args, **kwargs) - - def register_model(self, name, model): - self.models.register(name, model) - - def unregister_model(self, name): - self.models.unregister(name) - - -class Dataset(Extractor): - class Subset(Extractor): - def __init__(self, parent): - self.parent = parent - self.items = OrderedDict() - - def __iter__(self): - yield from self.items.values() - - def __len__(self): - return len(self.items) - - def categories(self): - return self.parent.categories() - - @classmethod - def from_iterable(cls, iterable: Iterable[DatasetItem], - categories: Union[Dict, List[str]] = None): - if isinstance(categories, list): - categories = { AnnotationType.label: - LabelCategories.from_iterable(categories) - } - - if not categories: - categories = {} - - class _extractor(Extractor): - def __iter__(self): - return iter(iterable) - - def categories(self): - return categories - - return cls.from_extractors(_extractor()) - - @classmethod - def from_extractors(cls, *sources): - categories = cls._merge_categories(s.categories() for s in sources) - dataset = Dataset(categories=categories) - - # merge items - subsets = defaultdict(lambda: cls.Subset(dataset)) - for source in sources: - for item in source: - existing_item = subsets[item.subset].items.get(item.id) - if existing_item is not None: - path = existing_item.path - if item.path != path: - path = None - item = cls._merge_items(existing_item, item, path=path) - - subsets[item.subset].items[item.id] = item - - dataset._subsets = dict(subsets) - return dataset - - def __init__(self, categories=None): - super().__init__() - - self._subsets = {} - - if not categories: - categories = {} - self._categories = categories - - def __iter__(self): - for subset in self._subsets.values(): - for item in subset: - yield item - - def __len__(self): - if self._length is None: - self._length = reduce(lambda s, x: s + len(x), - self._subsets.values(), 0) - return self._length - - def get_subset(self, name): - return self._subsets[name] - - def subsets(self): - return list(self._subsets) - - def categories(self): - return self._categories - - def get(self, item_id, subset=None, path=None): - if path: - raise KeyError("Requested dataset item path is not found") - item_id = str(item_id) - subset = subset or '' - subset = self._subsets[subset] - return subset.items[item_id] - - def put(self, item, item_id=None, subset=None, path=None): - if path: - raise KeyError("Requested dataset item path is not found") - - if item_id is None: - item_id = item.id - if subset is None: - subset = item.subset - - item = item.wrap(id=item_id, subset=subset, path=None) - if subset not in self._subsets: - self._subsets[subset] = self.Subset(self) - self._subsets[subset].items[item_id] = item - self._length = None - - return item - - def filter(self, expr, filter_annotations=False, remove_empty=False): - if filter_annotations: - return self.transform(XPathAnnotationsFilter, expr, remove_empty) - else: - return self.transform(XPathDatasetFilter, expr) - - def update(self, items): - for item in items: - self.put(item) - return self - - def define_categories(self, categories): - assert not self._categories - self._categories = categories - - @staticmethod - def _lazy_image(item): - # NOTE: avoid https://docs.python.org/3/faq/programming.html#why-do-lambdas-defined-in-a-loop-with-different-values-all-return-the-same-result - return lambda: item.image - - @classmethod - def _merge_items(cls, existing_item, current_item, path=None): - return existing_item.wrap(path=path, - image=cls._merge_images(existing_item, current_item), - annotations=cls._merge_anno( - existing_item.annotations, current_item.annotations)) - - @staticmethod - def _merge_images(existing_item, current_item): - image = None - if existing_item.has_image and current_item.has_image: - if existing_item.image.has_data: - image = existing_item.image - else: - image = current_item.image - - if existing_item.image.path != current_item.image.path: - if not existing_item.image.path: - image._path = current_item.image.path - - if all([existing_item.image._size, current_item.image._size]): - assert existing_item.image._size == current_item.image._size, "Image info differs for item '%s'" % existing_item.id - elif existing_item.image._size: - image._size = existing_item.image._size - else: - image._size = current_item.image._size - elif existing_item.has_image: - image = existing_item.image - else: - image = current_item.image - - return image - - @staticmethod - def _merge_anno(a, b): - # TODO: implement properly with merging and annotations remapping - from .operations import merge_annotations_equal - return merge_annotations_equal(a, b) - - @staticmethod - def _merge_categories(sources): - # TODO: implement properly with merging and annotations remapping - from .operations import merge_categories - return merge_categories(sources) - -class ProjectDataset(Dataset): - def __init__(self, project): - super().__init__() - - self._project = project - config = self.config - env = self.env - - sources = {} - for s_name, source in config.sources.items(): - s_format = source.format or env.PROJECT_EXTRACTOR_NAME - options = {} - options.update(source.options) - - url = source.url - if not source.url: - url = osp.join(config.project_dir, config.sources_dir, s_name) - sources[s_name] = env.make_extractor(s_format, url, **options) - self._sources = sources - - own_source = None - own_source_dir = osp.join(config.project_dir, config.dataset_dir) - if config.project_dir and osp.isdir(own_source_dir): - log.disable(log.INFO) - own_source = env.make_importer(DEFAULT_FORMAT)(own_source_dir) \ - .make_dataset() - log.disable(log.NOTSET) - - # merge categories - # TODO: implement properly with merging and annotations remapping - categories = self._merge_categories(s.categories() - for s in self._sources.values()) - # ovewrite with own categories - if own_source is not None and (not categories or len(own_source) != 0): - categories.update(own_source.categories()) - self._categories = categories - - # merge items - subsets = defaultdict(lambda: self.Subset(self)) - for source_name, source in self._sources.items(): - log.debug("Loading '%s' source contents..." % source_name) - for item in source: - existing_item = subsets[item.subset].items.get(item.id) - if existing_item is not None: - path = existing_item.path - if item.path != path: - path = None # NOTE: move to our own dataset - item = self._merge_items(existing_item, item, path=path) - else: - s_config = config.sources[source_name] - if s_config and \ - s_config.format != env.PROJECT_EXTRACTOR_NAME: - # NOTE: consider imported sources as our own dataset - path = None - else: - path = [source_name] + (item.path or []) - item = item.wrap(path=path) - - subsets[item.subset].items[item.id] = item - - # override with our items, fallback to existing images - if own_source is not None: - log.debug("Loading own dataset...") - for item in own_source: - existing_item = subsets[item.subset].items.get(item.id) - if existing_item is not None: - item = item.wrap(path=None, - image=self._merge_images(existing_item, item)) - - subsets[item.subset].items[item.id] = item - - # TODO: implement subset remapping when needed - subsets_filter = config.subsets - if len(subsets_filter) != 0: - subsets = { k: v for k, v in subsets.items() if k in subsets_filter} - self._subsets = dict(subsets) - - self._length = None - - def iterate_own(self): - return self.select(lambda item: not item.path) - - def get(self, item_id, subset=None, path=None): - if path: - source = path[0] - rest_path = path[1:] - return self._sources[source].get( - item_id=item_id, subset=subset, path=rest_path) - return super().get(item_id, subset) - - def put(self, item, item_id=None, subset=None, path=None): - if path is None: - path = item.path - - if path: - source = path[0] - rest_path = path[1:] - # TODO: reverse remapping - self._sources[source].put(item, - item_id=item_id, subset=subset, path=rest_path) - - if item_id is None: - item_id = item.id - if subset is None: - subset = item.subset - - item = item.wrap(path=path) - if subset not in self._subsets: - self._subsets[subset] = self.Subset(self) - self._subsets[subset].items[item_id] = item - self._length = None - - return item - - def save(self, save_dir=None, merge=False, recursive=True, - save_images=False): - if save_dir is None: - assert self.config.project_dir - save_dir = self.config.project_dir - project = self._project - else: - merge = True - - if merge: - project = Project(Config(self.config)) - project.config.remove('sources') - - save_dir = osp.abspath(save_dir) - dataset_save_dir = osp.join(save_dir, project.config.dataset_dir) - - converter_kwargs = { - 'save_images': save_images, - } - - save_dir_existed = osp.exists(save_dir) - try: - os.makedirs(save_dir, exist_ok=True) - os.makedirs(dataset_save_dir, exist_ok=True) - - if merge: - # merge and save the resulting dataset - self.env.converters.get(DEFAULT_FORMAT).convert( - self, dataset_save_dir, **converter_kwargs) - else: - if recursive: - # children items should already be updated - # so we just save them recursively - for source in self._sources.values(): - if isinstance(source, ProjectDataset): - source.save(**converter_kwargs) - - self.env.converters.get(DEFAULT_FORMAT).convert( - self.iterate_own(), dataset_save_dir, **converter_kwargs) - - project.save(save_dir) - except BaseException: - if not save_dir_existed and osp.isdir(save_dir): - shutil.rmtree(save_dir, ignore_errors=True) - raise - - @property - def env(self): - return self._project.env - - @property - def config(self): - return self._project.config - - @property - def sources(self): - return self._sources - - def _save_branch_project(self, extractor, save_dir=None): - extractor = Dataset.from_extractors(extractor) # apply lazy transforms - - # NOTE: probably this function should be in the ViewModel layer - save_dir = osp.abspath(save_dir) - if save_dir: - dst_project = Project() - else: - if not self.config.project_dir: - raise Exception("Either a save directory or a project " - "directory should be specified") - save_dir = self.config.project_dir - - dst_project = Project(Config(self.config)) - dst_project.config.remove('project_dir') - dst_project.config.remove('sources') - dst_project.config.project_name = osp.basename(save_dir) - - dst_dataset = dst_project.make_dataset() - dst_dataset.define_categories(extractor.categories()) - dst_dataset.update(extractor) - - dst_dataset.save(save_dir=save_dir, merge=True) - - def transform_project(self, method, save_dir=None, **method_kwargs): - # NOTE: probably this function should be in the ViewModel layer - if isinstance(method, str): - method = self.env.make_transform(method) - - transformed = self.transform(method, **method_kwargs) - self._save_branch_project(transformed, save_dir=save_dir) - - def apply_model(self, model, save_dir=None, batch_size=1): - # NOTE: probably this function should be in the ViewModel layer - if isinstance(model, str): - launcher = self._project.make_executable_model(model) - - self.transform_project(ModelTransform, launcher=launcher, - save_dir=save_dir, batch_size=batch_size) - - def export_project(self, save_dir, converter, - filter_expr=None, filter_annotations=False, remove_empty=False): - # NOTE: probably this function should be in the ViewModel layer - dataset = self - if filter_expr: - dataset = dataset.filter(filter_expr, - filter_annotations=filter_annotations, - remove_empty=remove_empty) - - save_dir = osp.abspath(save_dir) - save_dir_existed = osp.exists(save_dir) - try: - os.makedirs(save_dir, exist_ok=True) - converter(dataset, save_dir) - except BaseException: - if not save_dir_existed: - shutil.rmtree(save_dir) - raise - - def filter_project(self, filter_expr, filter_annotations=False, - save_dir=None, remove_empty=False): - # NOTE: probably this function should be in the ViewModel layer - dataset = self - if filter_expr: - dataset = dataset.filter(filter_expr, - filter_annotations=filter_annotations, - remove_empty=remove_empty) - self._save_branch_project(dataset, save_dir=save_dir) - -class Project: - @classmethod - def load(cls, path): - path = osp.abspath(path) - config_path = osp.join(path, PROJECT_DEFAULT_CONFIG.env_dir, - PROJECT_DEFAULT_CONFIG.project_filename) - config = Config.parse(config_path) - config.project_dir = path - config.project_filename = osp.basename(config_path) - return Project(config) - - def save(self, save_dir=None): - config = self.config - - if save_dir is None: - assert config.project_dir - project_dir = config.project_dir - else: - project_dir = save_dir - - env_dir = osp.join(project_dir, config.env_dir) - save_dir = osp.abspath(env_dir) - - project_dir_existed = osp.exists(project_dir) - env_dir_existed = osp.exists(env_dir) - try: - os.makedirs(save_dir, exist_ok=True) - - config_path = osp.join(save_dir, config.project_filename) - config.dump(config_path) - except BaseException: - if not env_dir_existed: - shutil.rmtree(save_dir, ignore_errors=True) - if not project_dir_existed: - shutil.rmtree(project_dir, ignore_errors=True) - raise - - @staticmethod - def generate(save_dir, config=None): - config = Config(config) - config.project_dir = save_dir - project = Project(config) - project.save(save_dir) - return project - - @staticmethod - def import_from(path, dataset_format, env=None, **kwargs): - if env is None: - env = Environment() - importer = env.make_importer(dataset_format) - return importer(path, **kwargs) - - def __init__(self, config=None): - self.config = Config(config, - fallback=PROJECT_DEFAULT_CONFIG, schema=PROJECT_SCHEMA) - self.env = Environment(self.config) - - def make_dataset(self): - return ProjectDataset(self) - - def add_source(self, name, value=None): - if value is None or isinstance(value, (dict, Config)): - value = Source(value) - self.config.sources[name] = value - self.env.sources.register(name, value) - - def remove_source(self, name): - self.config.sources.remove(name) - self.env.sources.unregister(name) - - def get_source(self, name): - try: - return self.config.sources[name] - except KeyError: - raise KeyError("Source '%s' is not found" % name) - - def get_subsets(self): - return self.config.subsets - - def set_subsets(self, value): - if not value: - self.config.remove('subsets') - else: - self.config.subsets = value - - def add_model(self, name, value=None): - if value is None or isinstance(value, (dict, Config)): - value = Model(value) - self.env.register_model(name, value) - self.config.models[name] = value - - def get_model(self, name): - try: - return self.env.models.get(name) - except KeyError: - raise KeyError("Model '%s' is not found" % name) - - def remove_model(self, name): - self.config.models.remove(name) - self.env.unregister_model(name) - - def make_executable_model(self, name): - model = self.get_model(name) - return self.env.make_launcher(model.launcher, - **model.options, model_dir=self.local_model_dir(name)) - - def make_source_project(self, name): - source = self.get_source(name) - - config = Config(self.config) - config.remove('sources') - config.remove('subsets') - project = Project(config) - project.add_source(name, source) - return project - - def local_model_dir(self, model_name): - return osp.join( - self.config.env_dir, self.config.models_dir, model_name) - - def local_source_dir(self, source_name): - return osp.join(self.config.sources_dir, source_name) - -# pylint: disable=function-redefined -def load_project_as_dataset(url): - # implement the function declared above - return Project.load(url).make_dataset() -# pylint: enable=function-redefined diff --git a/datumaro/datumaro/plugins/__init__.py b/datumaro/datumaro/plugins/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/accuracy_checker_plugin/__init__.py b/datumaro/datumaro/plugins/accuracy_checker_plugin/__init__.py deleted file mode 100644 index fdd6d291..00000000 --- a/datumaro/datumaro/plugins/accuracy_checker_plugin/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/datumaro/datumaro/plugins/accuracy_checker_plugin/details/ac.py b/datumaro/datumaro/plugins/accuracy_checker_plugin/details/ac.py deleted file mode 100644 index 4fc2ffb5..00000000 --- a/datumaro/datumaro/plugins/accuracy_checker_plugin/details/ac.py +++ /dev/null @@ -1,116 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from datumaro.util.tf_util import import_tf -import_tf() # prevent TF loading and potential interpeter crash - -from itertools import groupby - -from accuracy_checker.adapters import create_adapter -from accuracy_checker.data_readers import DataRepresentation -from accuracy_checker.launcher import InputFeeder, create_launcher -from accuracy_checker.postprocessor import PostprocessingExecutor -from accuracy_checker.preprocessor import PreprocessingExecutor -from accuracy_checker.utils import extract_image_representations - -from datumaro.components.extractor import AnnotationType, LabelCategories - -from .representation import import_predictions - - -class _FakeDataset: - def __init__(self, metadata=None): - self.metadata = metadata or {} - -class GenericAcLauncher: - @staticmethod - def from_config(config): - launcher_config = config['launcher'] - launcher = create_launcher(launcher_config) - - dataset = _FakeDataset() - adapter_config = config.get('adapter') or launcher_config.get('adapter') - label_config = adapter_config.get('labels') \ - if isinstance(adapter_config, dict) else None - if label_config: - assert isinstance(label_config, (list, dict)) - if isinstance(label_config, list): - label_config = dict(enumerate(label_config)) - - dataset.metadata = {'label_map': { - int(key): label for key, label in label_config.items() - }} - adapter = create_adapter(adapter_config, launcher, dataset) - - preproc_config = config.get('preprocessing') - preproc = None - if preproc_config: - preproc = PreprocessingExecutor(preproc_config, - dataset_meta=dataset.metadata, - input_shapes=launcher.inputs_info_for_meta() - ) - - postproc_config = config.get('postprocessing') - postproc = None - if postproc_config: - postproc = PostprocessingExecutor(postproc_config, - dataset_meta=dataset.metadata, - ) - - return __class__(launcher, - adapter=adapter, preproc=preproc, postproc=postproc) - - def __init__(self, launcher, adapter=None, - preproc=None, postproc=None, input_feeder=None): - self._launcher = launcher - self._input_feeder = input_feeder or InputFeeder( - launcher.config.get('inputs', []), launcher.inputs, - launcher.fit_to_input, launcher.default_layout - ) - self._adapter = adapter - self._preproc = preproc - self._postproc = postproc - - self._categories = self._init_categories() - - def launch_raw(self, inputs): - ids = range(len(inputs)) - inputs = [DataRepresentation(inp, identifier=id) - for id, inp in zip(ids, inputs)] - _, batch_meta = extract_image_representations(inputs) - - if self._preproc: - inputs = self._preproc.process(inputs) - - inputs = self._input_feeder.fill_inputs(inputs) - outputs = self._launcher.predict(inputs, batch_meta) - - if self._adapter: - outputs = self._adapter.process(outputs, ids, batch_meta) - - if self._postproc: - outputs = self._postproc.process(outputs) - - return outputs - - def launch(self, inputs): - outputs = self.launch_raw(inputs) - return [import_predictions(g) for _, g in - groupby(outputs, key=lambda o: o.identifier)] - - def categories(self): - return self._categories - - def _init_categories(self): - if self._adapter is None or self._adapter.label_map is None: - return None - - label_map = sorted(self._adapter.label_map.items(), key=lambda e: e[0]) - - label_cat = LabelCategories() - for _, label in label_map: - label_cat.add(label) - - return { AnnotationType.label: label_cat } diff --git a/datumaro/datumaro/plugins/accuracy_checker_plugin/details/representation.py b/datumaro/datumaro/plugins/accuracy_checker_plugin/details/representation.py deleted file mode 100644 index d7007806..00000000 --- a/datumaro/datumaro/plugins/accuracy_checker_plugin/details/representation.py +++ /dev/null @@ -1,62 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from datumaro.util.tf_util import import_tf -import_tf() # prevent TF loading and potential interpeter crash - -import accuracy_checker.representation as ac - -import datumaro.components.extractor as dm -from datumaro.util.annotation_util import softmax - -def import_predictions(predictions): - # Convert Accuracy checker predictions to Datumaro annotations - - anns = [] - - for pred in predictions: - anns.extend(import_prediction(pred)) - - return anns - -def import_prediction(pred): - if isinstance(pred, ac.ClassificationPrediction): - scores = softmax(pred.scores) - return (dm.Label(label_id, attributes={'score': float(score)}) - for label_id, score in enumerate(scores)) - elif isinstance(pred, ac.ArgMaxClassificationPrediction): - return (dm.Label(int(pred.label)), ) - elif isinstance(pred, ac.CharacterRecognitionPrediction): - return (dm.Label(int(pred.label)), ) - elif isinstance(pred, (ac.DetectionPrediction, ac.ActionDetectionPrediction)): - return (dm.Bbox(x0, y0, x1 - x0, y1 - y0, int(label_id), - attributes={'score': float(score)}) - for label, score, x0, y0, x1, y1 in zip(pred.labels, pred.scores, - pred.x_mins, pred.y_mins, pred.x_maxs, pred.y_maxs) - ) - elif isinstance(pred, ac.DepthEstimationPrediction): - return (dm.Mask(pred.depth_map), ) # 2d floating point mask - # elif isinstance(pred, ac.HitRatioPrediction): - # - - elif isinstance(pred, ac.ImageInpaintingPrediction): - return (dm.Mask(pred.value), ) # an image - # elif isinstance(pred, ac.MultiLabelRecognitionPrediction): - # - - # elif isinstance(pred, ac.MachineTranslationPrediction): - # - - # elif isinstance(pred, ac.QuestionAnsweringPrediction): - # - - # elif isinstance(pred, ac.PoseEstimation3dPrediction): - # - - # elif isinstance(pred, ac.PoseEstimationPrediction): - # - - # elif isinstance(pred, ac.RegressionPrediction): - # - - else: - raise NotImplementedError("Can't convert %s" % type(pred)) - - - - diff --git a/datumaro/datumaro/plugins/accuracy_checker_plugin/launcher.py b/datumaro/datumaro/plugins/accuracy_checker_plugin/launcher.py deleted file mode 100644 index 15251108..00000000 --- a/datumaro/datumaro/plugins/accuracy_checker_plugin/launcher.py +++ /dev/null @@ -1,37 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import os.path as osp -import yaml - -from datumaro.components.cli_plugin import CliPlugin -from datumaro.components.launcher import Launcher - -from .details.ac import GenericAcLauncher as _GenericAcLauncher - - -class AcLauncher(Launcher, CliPlugin): - """ - Generic model launcher with Accuracy Checker backend. - """ - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-c', '--config', type=osp.abspath, required=True, - help="Path to the launcher configuration file (.yml)") - return parser - - def __init__(self, config, model_dir=None): - model_dir = model_dir or '' - with open(osp.join(model_dir, config), 'r') as f: - config = yaml.safe_load(f) - self._launcher = _GenericAcLauncher.from_config(config) - - def launch(self, inputs): - return self._launcher.launch(inputs) - - def categories(self): - return self._launcher.categories() diff --git a/datumaro/datumaro/plugins/coco_format/__init__.py b/datumaro/datumaro/plugins/coco_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/coco_format/converter.py b/datumaro/datumaro/plugins/coco_format/converter.py deleted file mode 100644 index 27cdd087..00000000 --- a/datumaro/datumaro/plugins/coco_format/converter.py +++ /dev/null @@ -1,596 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import json -import logging as log -import os -import os.path as osp -from enum import Enum -from itertools import groupby - -import pycocotools.mask as mask_utils - -import datumaro.util.annotation_util as anno_tools -import datumaro.util.mask_tools as mask_tools -from datumaro.components.converter import Converter -from datumaro.components.extractor import (_COORDINATE_ROUNDING_DIGITS, - DEFAULT_SUBSET_NAME, AnnotationType, Points) -from datumaro.util import cast, find, str_to_bool - -from .format import CocoPath, CocoTask - -SegmentationMode = Enum('SegmentationMode', ['guess', 'polygons', 'mask']) - -class _TaskConverter: - def __init__(self, context): - self._min_ann_id = 1 - self._context = context - - data = { - 'licenses': [], - 'info': {}, - 'categories': [], - 'images': [], - 'annotations': [] - } - - data['licenses'].append({ - 'name': '', - 'id': 0, - 'url': '' - }) - - data['info'] = { - 'contributor': '', - 'date_created': '', - 'description': '', - 'url': '', - 'version': '', - 'year': '' - } - self._data = data - - def is_empty(self): - return len(self._data['annotations']) == 0 - - def _get_image_id(self, item): - return self._context._get_image_id(item) - - def save_image_info(self, item, filename): - if item.has_image: - h, w = item.image.size - else: - h = 0 - w = 0 - - self._data['images'].append({ - 'id': self._get_image_id(item), - 'width': int(w), - 'height': int(h), - 'file_name': cast(filename, str, ''), - 'license': 0, - 'flickr_url': '', - 'coco_url': '', - 'date_captured': 0, - }) - - def save_categories(self, dataset): - raise NotImplementedError() - - def save_annotations(self, item): - raise NotImplementedError() - - def write(self, path): - next_id = self._min_ann_id - for ann in self.annotations: - if ann['id'] is None: - ann['id'] = next_id - next_id += 1 - - with open(path, 'w') as outfile: - json.dump(self._data, outfile) - - @property - def annotations(self): - return self._data['annotations'] - - @property - def categories(self): - return self._data['categories'] - - def _get_ann_id(self, annotation): - ann_id = annotation.id - if ann_id: - self._min_ann_id = max(ann_id, self._min_ann_id) - return ann_id - - @staticmethod - def _convert_attributes(ann): - return { k: v for k, v in ann.attributes.items() - if k not in {'is_crowd', 'score'} - } - -class _ImageInfoConverter(_TaskConverter): - def is_empty(self): - return len(self._data['images']) == 0 - - def save_categories(self, dataset): - pass - - def save_annotations(self, item): - pass - -class _CaptionsConverter(_TaskConverter): - def save_categories(self, dataset): - pass - - def save_annotations(self, item): - for ann_idx, ann in enumerate(item.annotations): - if ann.type != AnnotationType.caption: - continue - - elem = { - 'id': self._get_ann_id(ann), - 'image_id': self._get_image_id(item), - 'category_id': 0, # NOTE: workaround for a bug in cocoapi - 'caption': ann.caption, - } - if 'score' in ann.attributes: - try: - elem['score'] = float(ann.attributes['score']) - except Exception as e: - log.warning("Item '%s', ann #%s: failed to convert " - "attribute 'score': %e" % (item.id, ann_idx, e)) - if self._context._allow_attributes: - elem['attributes'] = self._convert_attributes(ann) - - self.annotations.append(elem) - -class _InstancesConverter(_TaskConverter): - def save_categories(self, dataset): - label_categories = dataset.categories().get(AnnotationType.label) - if label_categories is None: - return - - for idx, cat in enumerate(label_categories.items): - self.categories.append({ - 'id': 1 + idx, - 'name': cast(cat.name, str, ''), - 'supercategory': cast(cat.parent, str, ''), - }) - - @classmethod - def crop_segments(cls, instances, img_width, img_height): - instances = sorted(instances, key=lambda x: x[0].z_order) - - segment_map = [] - segments = [] - for inst_idx, (_, polygons, mask, _) in enumerate(instances): - if polygons: - segment_map.extend(inst_idx for p in polygons) - segments.extend(polygons) - elif mask is not None: - segment_map.append(inst_idx) - segments.append(mask) - - segments = mask_tools.crop_covered_segments( - segments, img_width, img_height) - - for inst_idx, inst in enumerate(instances): - new_segments = [s for si_id, s in zip(segment_map, segments) - if si_id == inst_idx] - - if not new_segments: - inst[1] = [] - inst[2] = None - continue - - if inst[1]: - inst[1] = sum(new_segments, []) - else: - mask = mask_tools.merge_masks(new_segments) - inst[2] = mask_tools.mask_to_rle(mask) - - return instances - - def find_instance_parts(self, group, img_width, img_height): - boxes = [a for a in group if a.type == AnnotationType.bbox] - polygons = [a for a in group if a.type == AnnotationType.polygon] - masks = [a for a in group if a.type == AnnotationType.mask] - - anns = boxes + polygons + masks - leader = anno_tools.find_group_leader(anns) - bbox = anno_tools.max_bbox(anns) - mask = None - polygons = [p.points for p in polygons] - - if self._context._segmentation_mode == SegmentationMode.guess: - use_masks = True == leader.attributes.get('is_crowd', - find(masks, lambda x: x.label == leader.label) is not None) - elif self._context._segmentation_mode == SegmentationMode.polygons: - use_masks = False - elif self._context._segmentation_mode == SegmentationMode.mask: - use_masks = True - else: - raise NotImplementedError("Unexpected segmentation mode '%s'" % \ - self._context._segmentation_mode) - - if use_masks: - if polygons: - mask = mask_tools.rles_to_mask(polygons, img_width, img_height) - - if masks: - if mask is not None: - masks += [mask] - mask = mask_tools.merge_masks([m.image for m in masks]) - - if mask is not None: - mask = mask_tools.mask_to_rle(mask) - polygons = [] - else: - if masks: - mask = mask_tools.merge_masks([m.image for m in masks]) - polygons += mask_tools.mask_to_polygons(mask) - mask = None - - return [leader, polygons, mask, bbox] - - @staticmethod - def find_instance_anns(annotations): - return [a for a in annotations - if a.type in { AnnotationType.bbox, - AnnotationType.polygon, AnnotationType.mask } - ] - - @classmethod - def find_instances(cls, annotations): - return anno_tools.find_instances(cls.find_instance_anns(annotations)) - - def save_annotations(self, item): - instances = self.find_instances(item.annotations) - if not instances: - return - - if not item.has_image: - log.warn("Item '%s': skipping writing instances " - "since no image info available" % item.id) - return - h, w = item.image.size - instances = [self.find_instance_parts(i, w, h) for i in instances] - - if self._context._crop_covered: - instances = self.crop_segments(instances, w, h) - - for instance in instances: - elem = self.convert_instance(instance, item) - if elem: - self.annotations.append(elem) - - def convert_instance(self, instance, item): - ann, polygons, mask, bbox = instance - - is_crowd = mask is not None - if is_crowd: - segmentation = { - 'counts': list(int(c) for c in mask['counts']), - 'size': list(int(c) for c in mask['size']) - } - else: - segmentation = [list(map(float, p)) for p in polygons] - - area = 0 - if segmentation: - if item.has_image: - h, w = item.image.size - else: - # NOTE: here we can guess the image size as - # it is only needed for the area computation - w = bbox[0] + bbox[2] - h = bbox[1] + bbox[3] - - rles = mask_utils.frPyObjects(segmentation, h, w) - if is_crowd: - rles = [rles] - else: - rles = mask_utils.merge(rles) - area = mask_utils.area(rles) - else: - _, _, w, h = bbox - segmentation = [] - area = w * h - - elem = { - 'id': self._get_ann_id(ann), - 'image_id': self._get_image_id(item), - 'category_id': cast(ann.label, int, -1) + 1, - 'segmentation': segmentation, - 'area': float(area), - 'bbox': [round(float(n), _COORDINATE_ROUNDING_DIGITS) for n in bbox], - 'iscrowd': int(is_crowd), - } - if 'score' in ann.attributes: - try: - elem['score'] = float(ann.attributes['score']) - except Exception as e: - log.warning("Item '%s': failed to convert attribute " - "'score': %e" % (item.id, e)) - if self._context._allow_attributes: - elem['attributes'] = self._convert_attributes(ann) - - return elem - -class _KeypointsConverter(_InstancesConverter): - def save_categories(self, dataset): - label_categories = dataset.categories().get(AnnotationType.label) - if label_categories is None: - return - point_categories = dataset.categories().get(AnnotationType.points) - - for idx, label_cat in enumerate(label_categories.items): - cat = { - 'id': 1 + idx, - 'name': cast(label_cat.name, str, ''), - 'supercategory': cast(label_cat.parent, str, ''), - 'keypoints': [], - 'skeleton': [], - } - - if point_categories is not None: - kp_cat = point_categories.items.get(idx) - if kp_cat is not None: - cat.update({ - 'keypoints': [str(l) for l in kp_cat.labels], - 'skeleton': [list(map(int, j)) for j in kp_cat.joints], - }) - self.categories.append(cat) - - def save_annotations(self, item): - point_annotations = [a for a in item.annotations - if a.type == AnnotationType.points] - if not point_annotations: - return - - # Create annotations for solitary keypoints annotations - for points in self.find_solitary_points(item.annotations): - instance = [points, [], None, points.get_bbox()] - elem = super().convert_instance(instance, item) - elem.update(self.convert_points_object(points)) - self.annotations.append(elem) - - # Create annotations for complete instance + keypoints annotations - super().save_annotations(item) - - @classmethod - def find_solitary_points(cls, annotations): - annotations = sorted(annotations, key=lambda a: a.group) - solitary_points = [] - - for g_id, group in groupby(annotations, lambda a: a.group): - if not g_id or g_id and not cls.find_instance_anns(group): - group = [a for a in group if a.type == AnnotationType.points] - solitary_points.extend(group) - - return solitary_points - - @staticmethod - def convert_points_object(ann): - keypoints = [] - points = ann.points - visibility = ann.visibility - for index in range(0, len(points), 2): - kp = points[index : index + 2] - state = visibility[index // 2].value - keypoints.extend([*kp, state]) - - num_annotated = len([v for v in visibility \ - if v != Points.Visibility.absent]) - - return { - 'keypoints': keypoints, - 'num_keypoints': num_annotated, - } - - def convert_instance(self, instance, item): - points_ann = find(item.annotations, lambda x: \ - x.type == AnnotationType.points and \ - instance[0].group and x.group == instance[0].group) - if not points_ann: - return None - - elem = super().convert_instance(instance, item) - elem.update(self.convert_points_object(points_ann)) - - return elem - -class _LabelsConverter(_TaskConverter): - def save_categories(self, dataset): - label_categories = dataset.categories().get(AnnotationType.label) - if label_categories is None: - return - - for idx, cat in enumerate(label_categories.items): - self.categories.append({ - 'id': 1 + idx, - 'name': cast(cat.name, str, ''), - 'supercategory': cast(cat.parent, str, ''), - }) - - def save_annotations(self, item): - for ann in item.annotations: - if ann.type != AnnotationType.label: - continue - - elem = { - 'id': self._get_ann_id(ann), - 'image_id': self._get_image_id(item), - 'category_id': int(ann.label) + 1, - } - if 'score' in ann.attributes: - try: - elem['score'] = float(ann.attributes['score']) - except Exception as e: - log.warning("Item '%s': failed to convert attribute " - "'score': %e" % (item.id, e)) - if self._context._allow_attributes: - elem['attributes'] = self._convert_attributes(ann) - - self.annotations.append(elem) - -class CocoConverter(Converter): - @staticmethod - def _split_tasks_string(s): - return [CocoTask[i.strip()] for i in s.split(',')] - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('--segmentation-mode', - choices=[m.name for m in SegmentationMode], - default=SegmentationMode.guess.name, - help=""" - Save mode for instance segmentation:|n - - '{sm.guess.name}': guess the mode for each instance,|n - |s|suse 'is_crowd' attribute as hint|n - - '{sm.polygons.name}': save polygons,|n - |s|smerge and convert masks, prefer polygons|n - - '{sm.mask.name}': save masks,|n - |s|smerge and convert polygons, prefer masks|n - Default: %(default)s. - """.format(sm=SegmentationMode)) - parser.add_argument('--crop-covered', action='store_true', - help="Crop covered segments so that background objects' " - "segmentation was more accurate (default: %(default)s)") - parser.add_argument('--allow-attributes', - type=str_to_bool, default=True, - help="Allow export of attributes (default: %(default)s)") - parser.add_argument('--tasks', type=cls._split_tasks_string, - help="COCO task filter, comma-separated list of {%s} " - "(default: all)" % ', '.join(t.name for t in CocoTask)) - return parser - - DEFAULT_IMAGE_EXT = CocoPath.IMAGE_EXT - - _TASK_CONVERTER = { - CocoTask.image_info: _ImageInfoConverter, - CocoTask.instances: _InstancesConverter, - CocoTask.person_keypoints: _KeypointsConverter, - CocoTask.captions: _CaptionsConverter, - CocoTask.labels: _LabelsConverter, - } - - def __init__(self, extractor, save_dir, - tasks=None, segmentation_mode=None, crop_covered=False, - allow_attributes=True, **kwargs): - super().__init__(extractor, save_dir, **kwargs) - - assert tasks is None or isinstance(tasks, (CocoTask, list, str)) - if isinstance(tasks, CocoTask): - tasks = [tasks] - elif isinstance(tasks, str): - tasks = [CocoTask[tasks]] - elif tasks: - for i, t in enumerate(tasks): - if isinstance(t, str): - tasks[i] = CocoTask[t] - else: - assert t in CocoTask, t - self._tasks = tasks - - assert segmentation_mode is None or \ - isinstance(segmentation_mode, str) or \ - segmentation_mode in SegmentationMode - if segmentation_mode is None: - segmentation_mode = SegmentationMode.guess - if isinstance(segmentation_mode, str): - segmentation_mode = SegmentationMode[segmentation_mode] - self._segmentation_mode = segmentation_mode - - self._crop_covered = crop_covered - self._allow_attributes = allow_attributes - - self._image_ids = {} - - def _make_dirs(self): - self._images_dir = osp.join(self._save_dir, CocoPath.IMAGES_DIR) - os.makedirs(self._images_dir, exist_ok=True) - - self._ann_dir = osp.join(self._save_dir, CocoPath.ANNOTATIONS_DIR) - os.makedirs(self._ann_dir, exist_ok=True) - - def _make_task_converter(self, task): - if task not in self._TASK_CONVERTER: - raise NotImplementedError() - return self._TASK_CONVERTER[task](self) - - def _make_task_converters(self): - return { task: self._make_task_converter(task) - for task in (self._tasks or self._TASK_CONVERTER) } - - def _get_image_id(self, item): - image_id = self._image_ids.get(item.id) - if image_id is None: - image_id = cast(item.attributes.get('id'), int, - len(self._image_ids) + 1) - self._image_ids[item.id] = image_id - return image_id - - def _save_image(self, item, path=None): - super()._save_image(item, - osp.join(self._images_dir, self._make_image_filename(item))) - - def apply(self): - self._make_dirs() - - for subset_name in self._extractor.subsets() or [None]: - if subset_name: - subset = self._extractor.get_subset(subset_name) - else: - subset_name = DEFAULT_SUBSET_NAME - subset = self._extractor - - task_converters = self._make_task_converters() - for task_conv in task_converters.values(): - task_conv.save_categories(subset) - for item in subset: - if self._save_images: - if item.has_image: - self._save_image(item) - else: - log.debug("Item '%s' has no image info", item.id) - for task_conv in task_converters.values(): - task_conv.save_image_info(item, - self._make_image_filename(item)) - task_conv.save_annotations(item) - - for task, task_conv in task_converters.items(): - if task_conv.is_empty() and not self._tasks: - continue - task_conv.write(osp.join(self._ann_dir, - '%s_%s.json' % (task.name, subset_name))) - -class CocoInstancesConverter(CocoConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = CocoTask.instances - super().__init__(*args, **kwargs) - -class CocoImageInfoConverter(CocoConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = CocoTask.image_info - super().__init__(*args, **kwargs) - -class CocoPersonKeypointsConverter(CocoConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = CocoTask.person_keypoints - super().__init__(*args, **kwargs) - -class CocoCaptionsConverter(CocoConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = CocoTask.captions - super().__init__(*args, **kwargs) - -class CocoLabelsConverter(CocoConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = CocoTask.labels - super().__init__(*args, **kwargs) diff --git a/datumaro/datumaro/plugins/coco_format/extractor.py b/datumaro/datumaro/plugins/coco_format/extractor.py deleted file mode 100644 index 73e78820..00000000 --- a/datumaro/datumaro/plugins/coco_format/extractor.py +++ /dev/null @@ -1,261 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -import logging as log -import os.path as osp - -from pycocotools.coco import COCO -import pycocotools.mask as mask_utils - -from datumaro.components.extractor import (SourceExtractor, - DEFAULT_SUBSET_NAME, DatasetItem, - AnnotationType, Label, RleMask, Points, Polygon, Bbox, Caption, - LabelCategories, PointsCategories -) -from datumaro.util.image import Image - -from .format import CocoTask, CocoPath - - -class _CocoExtractor(SourceExtractor): - def __init__(self, path, task, merge_instance_polygons=False): - assert osp.isfile(path), path - - subset = osp.splitext(osp.basename(path))[0].rsplit('_', maxsplit=1) - subset = subset[1] if len(subset) == 2 else None - super().__init__(subset=subset) - - rootpath = '' - if path.endswith(osp.join(CocoPath.ANNOTATIONS_DIR, osp.basename(path))): - rootpath = path.rsplit(CocoPath.ANNOTATIONS_DIR, maxsplit=1)[0] - images_dir = '' - if rootpath and osp.isdir(osp.join(rootpath, CocoPath.IMAGES_DIR)): - images_dir = osp.join(rootpath, CocoPath.IMAGES_DIR) - if osp.isdir(osp.join(images_dir, subset or DEFAULT_SUBSET_NAME)): - images_dir = osp.join(images_dir, subset or DEFAULT_SUBSET_NAME) - self._images_dir = images_dir - self._task = task - - self._merge_instance_polygons = merge_instance_polygons - - loader = self._make_subset_loader(path) - self._load_categories(loader) - self._items = self._load_items(loader) - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items.values(): - yield item - - def __len__(self): - return len(self._items) - - @staticmethod - def _make_subset_loader(path): - # COCO API has an 'unclosed file' warning - coco_api = COCO() - with open(path, 'r') as f: - import json - dataset = json.load(f) - - coco_api.dataset = dataset - coco_api.createIndex() - return coco_api - - def _load_categories(self, loader): - self._categories = {} - - if self._task in [CocoTask.instances, CocoTask.labels, - CocoTask.person_keypoints, - # TODO: Task.stuff, CocoTask.panoptic - ]: - label_categories, label_map = self._load_label_categories(loader) - self._categories[AnnotationType.label] = label_categories - self._label_map = label_map - - if self._task == CocoTask.person_keypoints: - person_kp_categories = self._load_person_kp_categories(loader) - self._categories[AnnotationType.points] = person_kp_categories - - # pylint: disable=no-self-use - def _load_label_categories(self, loader): - catIds = loader.getCatIds() - cats = loader.loadCats(catIds) - - categories = LabelCategories() - label_map = {} - for idx, cat in enumerate(cats): - label_map[cat['id']] = idx - categories.add(name=cat['name'], parent=cat['supercategory']) - - return categories, label_map - # pylint: enable=no-self-use - - def _load_person_kp_categories(self, loader): - catIds = loader.getCatIds() - cats = loader.loadCats(catIds) - - categories = PointsCategories() - for cat in cats: - label_id = self._label_map[cat['id']] - categories.add(label_id=label_id, - labels=cat['keypoints'], joints=cat['skeleton'] - ) - - return categories - - def _load_items(self, loader): - items = OrderedDict() - - for img_id in loader.getImgIds(): - image_info = loader.loadImgs(img_id)[0] - image_path = osp.join(self._images_dir, image_info['file_name']) - image_size = (image_info.get('height'), image_info.get('width')) - if all(image_size): - image_size = (int(image_size[0]), int(image_size[1])) - else: - image_size = None - image = Image(path=image_path, size=image_size) - - anns = loader.getAnnIds(imgIds=img_id) - anns = loader.loadAnns(anns) - anns = sum((self._load_annotations(a, image_info) for a in anns), []) - - items[img_id] = DatasetItem( - id=osp.splitext(image_info['file_name'])[0], - subset=self._subset, image=image, annotations=anns, - attributes={'id': img_id}) - - return items - - def _get_label_id(self, ann): - cat_id = ann.get('category_id') - if cat_id in [0, None]: - return None - return self._label_map[cat_id] - - def _load_annotations(self, ann, image_info=None): - parsed_annotations = [] - - ann_id = ann.get('id') - - attributes = {} - if 'attributes' in ann: - try: - attributes.update(ann['attributes']) - except Exception as e: - log.debug("item #%s: failed to read annotation attributes: %s", - image_info['id'], e) - if 'score' in ann: - attributes['score'] = ann['score'] - - group = ann_id # make sure all tasks' annotations are merged - - if self._task in [CocoTask.instances, CocoTask.person_keypoints]: - x, y, w, h = ann['bbox'] - label_id = self._get_label_id(ann) - - is_crowd = bool(ann['iscrowd']) - attributes['is_crowd'] = is_crowd - - if self._task is CocoTask.person_keypoints: - keypoints = ann['keypoints'] - points = [p for i, p in enumerate(keypoints) if i % 3 != 2] - visibility = keypoints[2::3] - parsed_annotations.append( - Points(points, visibility, label=label_id, - id=ann_id, attributes=attributes, group=group) - ) - - segmentation = ann.get('segmentation') - if segmentation and segmentation != [[]]: - rle = None - - if isinstance(segmentation, list): - if not self._merge_instance_polygons: - # polygon - a single object can consist of multiple parts - for polygon_points in segmentation: - parsed_annotations.append(Polygon( - points=polygon_points, label=label_id, - id=ann_id, attributes=attributes, group=group - )) - else: - # merge all parts into a single mask RLE - img_h = image_info['height'] - img_w = image_info['width'] - rles = mask_utils.frPyObjects(segmentation, img_h, img_w) - rle = mask_utils.merge(rles) - elif isinstance(segmentation['counts'], list): - # uncompressed RLE - img_h = image_info['height'] - img_w = image_info['width'] - mask_h, mask_w = segmentation['size'] - if img_h == mask_h and img_w == mask_w: - rle = mask_utils.frPyObjects( - [segmentation], mask_h, mask_w)[0] - else: - log.warning("item #%s: mask #%s " - "does not match image size: %s vs. %s. " - "Skipping this annotation.", - image_info['id'], ann_id, - (mask_h, mask_w), (img_h, img_w) - ) - else: - # compressed RLE - rle = segmentation - - if rle is not None: - parsed_annotations.append(RleMask(rle=rle, label=label_id, - id=ann_id, attributes=attributes, group=group - )) - else: - parsed_annotations.append( - Bbox(x, y, w, h, label=label_id, - id=ann_id, attributes=attributes, group=group) - ) - elif self._task is CocoTask.labels: - label_id = self._get_label_id(ann) - parsed_annotations.append( - Label(label=label_id, - id=ann_id, attributes=attributes, group=group) - ) - elif self._task is CocoTask.captions: - caption = ann['caption'] - parsed_annotations.append( - Caption(caption, - id=ann_id, attributes=attributes, group=group) - ) - else: - raise NotImplementedError() - - return parsed_annotations - -class CocoImageInfoExtractor(_CocoExtractor): - def __init__(self, path, **kwargs): - kwargs['task'] = CocoTask.image_info - super().__init__(path, **kwargs) - -class CocoCaptionsExtractor(_CocoExtractor): - def __init__(self, path, **kwargs): - kwargs['task'] = CocoTask.captions - super().__init__(path, **kwargs) - -class CocoInstancesExtractor(_CocoExtractor): - def __init__(self, path, **kwargs): - kwargs['task'] = CocoTask.instances - super().__init__(path, **kwargs) - -class CocoPersonKeypointsExtractor(_CocoExtractor): - def __init__(self, path, **kwargs): - kwargs['task'] = CocoTask.person_keypoints - super().__init__(path, **kwargs) - -class CocoLabelsExtractor(_CocoExtractor): - def __init__(self, path, **kwargs): - kwargs['task'] = CocoTask.labels - super().__init__(path, **kwargs) diff --git a/datumaro/datumaro/plugins/coco_format/format.py b/datumaro/datumaro/plugins/coco_format/format.py deleted file mode 100644 index 5129d49d..00000000 --- a/datumaro/datumaro/plugins/coco_format/format.py +++ /dev/null @@ -1,23 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from enum import Enum - - -CocoTask = Enum('CocoTask', [ - 'instances', - 'person_keypoints', - 'captions', - 'labels', # extension, does not exist in the original COCO format - 'image_info', - # 'panoptic', - # 'stuff', -]) - -class CocoPath: - IMAGES_DIR = 'images' - ANNOTATIONS_DIR = 'annotations' - - IMAGE_EXT = '.jpg' diff --git a/datumaro/datumaro/plugins/coco_format/importer.py b/datumaro/datumaro/plugins/coco_format/importer.py deleted file mode 100644 index 3896b725..00000000 --- a/datumaro/datumaro/plugins/coco_format/importer.py +++ /dev/null @@ -1,95 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import defaultdict -from glob import glob -import logging as log -import os.path as osp - -from datumaro.components.extractor import Importer -from datumaro.util.log_utils import logging_disabled - -from .format import CocoTask - - -class CocoImporter(Importer): - _COCO_EXTRACTORS = { - CocoTask.instances: 'coco_instances', - CocoTask.person_keypoints: 'coco_person_keypoints', - CocoTask.captions: 'coco_captions', - CocoTask.labels: 'coco_labels', - CocoTask.image_info: 'coco_image_info', - } - - @classmethod - def detect(cls, path): - with logging_disabled(log.WARN): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subsets = self.find_subsets(path) - - if len(subsets) == 0: - raise Exception("Failed to find 'coco' dataset at '%s'" % path) - - # TODO: should be removed when proper label merging is implemented - conflicting_types = {CocoTask.instances, - CocoTask.person_keypoints, CocoTask.labels} - ann_types = set(t for s in subsets.values() for t in s) \ - & conflicting_types - if 1 <= len(ann_types): - selected_ann_type = sorted(ann_types, key=lambda x: x.name)[0] - if 1 < len(ann_types): - log.warning("Not implemented: " - "Found potentially conflicting source types with labels: %s. " - "Only one type will be used: %s" \ - % (", ".join(t.name for t in ann_types), selected_ann_type.name)) - - for ann_files in subsets.values(): - for ann_type, ann_file in ann_files.items(): - if ann_type in conflicting_types: - if ann_type is not selected_ann_type: - log.warning("Not implemented: " - "conflicting source '%s' is skipped." % ann_file) - continue - log.info("Found a dataset at '%s'" % ann_file) - - source_name = osp.splitext(osp.basename(ann_file))[0] - project.add_source(source_name, { - 'url': ann_file, - 'format': self._COCO_EXTRACTORS[ann_type], - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_subsets(path): - if path.endswith('.json') and osp.isfile(path): - subset_paths = [path] - else: - subset_paths = glob(osp.join(path, '**', '*_*.json'), - recursive=True) - - subsets = defaultdict(dict) - for subset_path in subset_paths: - name_parts = osp.splitext(osp.basename(subset_path))[0] \ - .rsplit('_', maxsplit=1) - - ann_type = name_parts[0] - try: - ann_type = CocoTask[ann_type] - except KeyError: - log.warn("Skipping '%s': unknown subset " - "type '%s', the only known are: %s" % \ - (subset_path, ann_type, - ', '.join([e.name for e in CocoTask]))) - continue - subset_name = name_parts[1] - subsets[subset_name][ann_type] = subset_path - return dict(subsets) diff --git a/datumaro/datumaro/plugins/cvat_format/__init__.py b/datumaro/datumaro/plugins/cvat_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/cvat_format/converter.py b/datumaro/datumaro/plugins/cvat_format/converter.py deleted file mode 100644 index 4849619b..00000000 --- a/datumaro/datumaro/plugins/cvat_format/converter.py +++ /dev/null @@ -1,331 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -import os -import os.path as osp -from collections import OrderedDict -from xml.sax.saxutils import XMLGenerator - -from datumaro.components.converter import Converter -from datumaro.components.extractor import DEFAULT_SUBSET_NAME, AnnotationType -from datumaro.util import cast, pairs - -from .format import CvatPath - - -class XmlAnnotationWriter: - VERSION = '1.1' - - def __init__(self, f): - self.xmlgen = XMLGenerator(f, 'utf-8') - self._level = 0 - - def _indent(self, newline = True): - if newline: - self.xmlgen.ignorableWhitespace('\n') - self.xmlgen.ignorableWhitespace(' ' * self._level) - - def _add_version(self): - self._indent() - self.xmlgen.startElement('version', {}) - self.xmlgen.characters(self.VERSION) - self.xmlgen.endElement('version') - - def open_root(self): - self.xmlgen.startDocument() - self.xmlgen.startElement('annotations', {}) - self._level += 1 - self._add_version() - - def _add_meta(self, meta): - self._level += 1 - for k, v in meta.items(): - if isinstance(v, OrderedDict): - self._indent() - self.xmlgen.startElement(k, {}) - self._add_meta(v) - self._indent() - self.xmlgen.endElement(k) - elif isinstance(v, list): - self._indent() - self.xmlgen.startElement(k, {}) - for tup in v: - self._add_meta(OrderedDict([tup])) - self._indent() - self.xmlgen.endElement(k) - else: - self._indent() - self.xmlgen.startElement(k, {}) - self.xmlgen.characters(v) - self.xmlgen.endElement(k) - self._level -= 1 - - def write_meta(self, meta): - self._indent() - self.xmlgen.startElement('meta', {}) - self._add_meta(meta) - self._indent() - self.xmlgen.endElement('meta') - - def open_track(self, track): - self._indent() - self.xmlgen.startElement('track', track) - self._level += 1 - - def open_image(self, image): - self._indent() - self.xmlgen.startElement('image', image) - self._level += 1 - - def open_box(self, box): - self._indent() - self.xmlgen.startElement('box', box) - self._level += 1 - - def open_polygon(self, polygon): - self._indent() - self.xmlgen.startElement('polygon', polygon) - self._level += 1 - - def open_polyline(self, polyline): - self._indent() - self.xmlgen.startElement('polyline', polyline) - self._level += 1 - - def open_points(self, points): - self._indent() - self.xmlgen.startElement('points', points) - self._level += 1 - - def open_tag(self, tag): - self._indent() - self.xmlgen.startElement("tag", tag) - self._level += 1 - - def add_attribute(self, attribute): - self._indent() - self.xmlgen.startElement('attribute', {'name': attribute['name']}) - self.xmlgen.characters(attribute['value']) - self.xmlgen.endElement('attribute') - - def _close_element(self, element): - self._level -= 1 - self._indent() - self.xmlgen.endElement(element) - - def close_box(self): - self._close_element('box') - - def close_polygon(self): - self._close_element('polygon') - - def close_polyline(self): - self._close_element('polyline') - - def close_points(self): - self._close_element('points') - - def close_tag(self): - self._close_element('tag') - - def close_image(self): - self._close_element('image') - - def close_track(self): - self._close_element('track') - - def close_root(self): - self._close_element('annotations') - self.xmlgen.endDocument() - -class _SubsetWriter: - def __init__(self, file, name, extractor, context): - self._writer = XmlAnnotationWriter(file) - self._name = name - self._extractor = extractor - self._context = context - - def write(self): - self._writer.open_root() - self._write_meta() - - for index, item in enumerate(self._extractor): - self._write_item(item, index) - - self._writer.close_root() - - def _write_item(self, item, index): - image_info = OrderedDict([ - ("id", str(cast(item.attributes.get('frame'), int, index))), - ]) - filename = item.id + CvatPath.IMAGE_EXT - image_info["name"] = filename - if item.has_image: - size = item.image.size - if size: - h, w = size - image_info["width"] = str(w) - image_info["height"] = str(h) - - if self._context._save_images: - self._context._save_image(item, - osp.join(self._context._images_dir, filename)) - else: - log.debug("Item '%s' has no image info", item.id) - self._writer.open_image(image_info) - - for ann in item.annotations: - if ann.type in {AnnotationType.points, AnnotationType.polyline, - AnnotationType.polygon, AnnotationType.bbox}: - self._write_shape(ann) - elif ann.type == AnnotationType.label: - self._write_tag(ann) - else: - continue - - self._writer.close_image() - - def _write_meta(self): - label_cat = self._extractor.categories()[AnnotationType.label] - meta = OrderedDict([ - ("task", OrderedDict([ - ("id", ""), - ("name", self._name), - ("size", str(len(self._extractor))), - ("mode", "annotation"), - ("overlap", ""), - ("start_frame", "0"), - ("stop_frame", str(len(self._extractor))), - ("frame_filter", ""), - ("z_order", "True"), - - ("labels", [ - ("label", OrderedDict([ - ("name", label.name), - ("attributes", [ - ("attribute", OrderedDict([ - ("name", attr), - ("mutable", "True"), - ("input_type", "text"), - ("default_value", ""), - ("values", ""), - ])) for attr in label.attributes - ]) - ])) for label in label_cat.items - ]), - ])), - ]) - self._writer.write_meta(meta) - - def _get_label(self, label_id): - label_cat = self._extractor.categories()[AnnotationType.label] - return label_cat.items[label_id] - - def _write_shape(self, shape): - if shape.label is None: - return - - shape_data = OrderedDict([ - ("label", self._get_label(shape.label).name), - ("occluded", str(int(shape.attributes.get('occluded', False)))), - ]) - - if shape.type == AnnotationType.bbox: - shape_data.update(OrderedDict([ - ("xtl", "{:.2f}".format(shape.points[0])), - ("ytl", "{:.2f}".format(shape.points[1])), - ("xbr", "{:.2f}".format(shape.points[2])), - ("ybr", "{:.2f}".format(shape.points[3])) - ])) - else: - shape_data.update(OrderedDict([ - ("points", ';'.join(( - ','.join(( - "{:.2f}".format(x), - "{:.2f}".format(y) - )) for x, y in pairs(shape.points)) - )), - ])) - - shape_data['z_order'] = str(int(shape.z_order)) - if shape.group: - shape_data['group_id'] = str(shape.group) - - if shape.type == AnnotationType.bbox: - self._writer.open_box(shape_data) - elif shape.type == AnnotationType.polygon: - self._writer.open_polygon(shape_data) - elif shape.type == AnnotationType.polyline: - self._writer.open_polyline(shape_data) - elif shape.type == AnnotationType.points: - self._writer.open_points(shape_data) - else: - raise NotImplementedError("unknown shape type") - - for attr_name, attr_value in shape.attributes.items(): - if isinstance(attr_value, bool): - attr_value = 'true' if attr_value else 'false' - if attr_name in self._get_label(shape.label).attributes: - self._writer.add_attribute(OrderedDict([ - ("name", str(attr_name)), - ("value", str(attr_value)), - ])) - - if shape.type == AnnotationType.bbox: - self._writer.close_box() - elif shape.type == AnnotationType.polygon: - self._writer.close_polygon() - elif shape.type == AnnotationType.polyline: - self._writer.close_polyline() - elif shape.type == AnnotationType.points: - self._writer.close_points() - else: - raise NotImplementedError("unknown shape type") - - def _write_tag(self, label): - if label.label is None: - return - - tag_data = OrderedDict([ - ('label', self._get_label(label.label).name), - ]) - if label.group: - tag_data['group_id'] = str(label.group) - self._writer.open_tag(tag_data) - - for attr_name, attr_value in label.attributes.items(): - if isinstance(attr_value, bool): - attr_value = 'true' if attr_value else 'false' - if attr_name in self._get_label(label.label).attributes: - self._writer.add_attribute(OrderedDict([ - ("name", str(attr_name)), - ("value", str(attr_value)), - ])) - - self._writer.close_tag() - -class CvatConverter(Converter): - DEFAULT_IMAGE_EXT = CvatPath.IMAGE_EXT - - def apply(self): - images_dir = osp.join(self._save_dir, CvatPath.IMAGES_DIR) - os.makedirs(images_dir, exist_ok=True) - self._images_dir = images_dir - - subsets = self._extractor.subsets() - if len(subsets) == 0: - subsets = [ None ] - - for subset_name in subsets: - if subset_name: - subset = self._extractor.get_subset(subset_name) - else: - subset_name = DEFAULT_SUBSET_NAME - subset = self._extractor - - with open(osp.join(self._save_dir, '%s.xml' % subset_name), 'w') as f: - writer = _SubsetWriter(f, subset_name, subset, self) - writer.write() diff --git a/datumaro/datumaro/plugins/cvat_format/extractor.py b/datumaro/datumaro/plugins/cvat_format/extractor.py deleted file mode 100644 index 7e37c2dd..00000000 --- a/datumaro/datumaro/plugins/cvat_format/extractor.py +++ /dev/null @@ -1,316 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -import os.path as osp -from defusedxml import ElementTree - -from datumaro.components.extractor import (SourceExtractor, DatasetItem, - AnnotationType, Points, Polygon, PolyLine, Bbox, Label, - LabelCategories -) -from datumaro.util.image import Image - -from .format import CvatPath - - -class CvatExtractor(SourceExtractor): - _SUPPORTED_SHAPES = ('box', 'polygon', 'polyline', 'points') - - def __init__(self, path): - assert osp.isfile(path), path - rootpath = osp.dirname(path) - images_dir = '' - if osp.isdir(osp.join(rootpath, CvatPath.IMAGES_DIR)): - images_dir = osp.join(rootpath, CvatPath.IMAGES_DIR) - self._images_dir = images_dir - self._path = path - - super().__init__(subset=osp.splitext(osp.basename(path))[0]) - - items, categories = self._parse(path) - self._items = self._load_items(items) - self._categories = categories - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items.values(): - yield item - - def __len__(self): - return len(self._items) - - @classmethod - def _parse(cls, path): - context = ElementTree.iterparse(path, events=("start", "end")) - context = iter(context) - - categories, frame_size = cls._parse_meta(context) - - items = OrderedDict() - - track = None - shape = None - tag = None - attributes = None - image = None - for ev, el in context: - if ev == 'start': - if el.tag == 'track': - track = { - 'id': el.attrib['id'], - 'label': el.attrib.get('label'), - 'group': int(el.attrib.get('group_id', 0)), - 'height': frame_size[0], - 'width': frame_size[1], - } - elif el.tag == 'image': - image = { - 'name': el.attrib.get('name'), - 'frame': el.attrib['id'], - 'width': el.attrib.get('width'), - 'height': el.attrib.get('height'), - } - elif el.tag in cls._SUPPORTED_SHAPES and (track or image): - attributes = {} - shape = { - 'type': None, - 'attributes': attributes, - } - if track: - shape.update(track) - shape['track_id'] = int(track['id']) - if image: - shape.update(image) - elif el.tag == 'tag' and image: - attributes = {} - tag = { - 'frame': image['frame'], - 'attributes': attributes, - 'group': int(el.attrib.get('group_id', 0)), - 'label': el.attrib['label'], - } - elif ev == 'end': - if el.tag == 'attribute' and attributes is not None: - attr_value = el.text - if el.text in ['true', 'false']: - attr_value = attr_value == 'true' - else: - try: - attr_value = float(attr_value) - except ValueError: - pass - attributes[el.attrib['name']] = attr_value - elif el.tag in cls._SUPPORTED_SHAPES: - if track is not None: - shape['frame'] = el.attrib['frame'] - shape['outside'] = (el.attrib.get('outside') == '1') - shape['keyframe'] = (el.attrib.get('keyframe') == '1') - if image is not None: - shape['label'] = el.attrib.get('label') - shape['group'] = int(el.attrib.get('group_id', 0)) - - shape['type'] = el.tag - shape['occluded'] = (el.attrib.get('occluded') == '1') - shape['z_order'] = int(el.attrib.get('z_order', 0)) - - if el.tag == 'box': - shape['points'] = list(map(float, [ - el.attrib['xtl'], el.attrib['ytl'], - el.attrib['xbr'], el.attrib['ybr'], - ])) - else: - shape['points'] = [] - for pair in el.attrib['points'].split(';'): - shape['points'].extend(map(float, pair.split(','))) - - frame_desc = items.get(shape['frame'], {'annotations': []}) - frame_desc['annotations'].append( - cls._parse_shape_ann(shape, categories)) - items[shape['frame']] = frame_desc - shape = None - - elif el.tag == 'tag': - frame_desc = items.get(tag['frame'], {'annotations': []}) - frame_desc['annotations'].append( - cls._parse_tag_ann(tag, categories)) - items[tag['frame']] = frame_desc - tag = None - elif el.tag == 'track': - track = None - elif el.tag == 'image': - frame_desc = items.get(image['frame'], {'annotations': []}) - frame_desc.update({ - 'name': image.get('name'), - 'height': image.get('height'), - 'width': image.get('width'), - }) - items[image['frame']] = frame_desc - image = None - el.clear() - - return items, categories - - @staticmethod - def _parse_meta(context): - ev, el = next(context) - if not (ev == 'start' and el.tag == 'annotations'): - raise Exception("Unexpected token ") - - categories = {} - - frame_size = None - mode = None - labels = OrderedDict() - label = None - - # Recursive descent parser - el = None - states = ['annotations'] - def accepted(expected_state, tag, next_state=None): - state = states[-1] - if state == expected_state and el is not None and el.tag == tag: - if not next_state: - next_state = tag - states.append(next_state) - return True - return False - def consumed(expected_state, tag): - state = states[-1] - if state == expected_state and el is not None and el.tag == tag: - states.pop() - return True - return False - - for ev, el in context: - if ev == 'start': - if accepted('annotations', 'meta'): pass - elif accepted('meta', 'task'): pass - elif accepted('task', 'mode'): pass - elif accepted('task', 'original_size'): - frame_size = [None, None] - elif accepted('original_size', 'height', next_state='frame_height'): pass - elif accepted('original_size', 'width', next_state='frame_width'): pass - elif accepted('task', 'labels'): pass - elif accepted('labels', 'label'): - label = { 'name': None, 'attributes': set() } - elif accepted('label', 'name', next_state='label_name'): pass - elif accepted('label', 'attributes'): pass - elif accepted('attributes', 'attribute'): pass - elif accepted('attribute', 'name', next_state='attr_name'): pass - elif accepted('annotations', 'image') or \ - accepted('annotations', 'track') or \ - accepted('annotations', 'tag'): - break - else: - pass - elif ev == 'end': - if consumed('meta', 'meta'): - break - elif consumed('task', 'task'): pass - elif consumed('mode', 'mode'): - mode = el.text - elif consumed('original_size', 'original_size'): pass - elif consumed('frame_height', 'height'): - frame_size[0] = int(el.text) - elif consumed('frame_width', 'width'): - frame_size[1] = int(el.text) - elif consumed('label_name', 'name'): - label['name'] = el.text - elif consumed('attr_name', 'name'): - label['attributes'].add(el.text) - elif consumed('attribute', 'attribute'): pass - elif consumed('attributes', 'attributes'): pass - elif consumed('label', 'label'): - labels[label['name']] = label['attributes'] - label = None - elif consumed('labels', 'labels'): pass - else: - pass - - assert len(states) == 1 and states[0] == 'annotations', \ - "Expected 'meta' section in the annotation file, path: %s" % states - - common_attrs = ['occluded'] - if mode == 'interpolation': - common_attrs.append('keyframe') - common_attrs.append('outside') - common_attrs.append('track_id') - - label_cat = LabelCategories(attributes=common_attrs) - for label, attrs in labels.items(): - label_cat.add(label, attributes=attrs) - - categories[AnnotationType.label] = label_cat - - return categories, frame_size - - @classmethod - def _parse_shape_ann(cls, ann, categories): - ann_id = ann.get('id', 0) - ann_type = ann['type'] - - attributes = ann.get('attributes') or {} - if 'occluded' in categories[AnnotationType.label].attributes: - attributes['occluded'] = ann.get('occluded', False) - if 'outside' in ann: - attributes['outside'] = ann['outside'] - if 'keyframe' in ann: - attributes['keyframe'] = ann['keyframe'] - if 'track_id' in ann: - attributes['track_id'] = ann['track_id'] - - group = ann.get('group') - - label = ann.get('label') - label_id = categories[AnnotationType.label].find(label)[0] - - z_order = ann.get('z_order', 0) - points = ann.get('points', []) - - if ann_type == 'polyline': - return PolyLine(points, label=label_id, z_order=z_order, - id=ann_id, attributes=attributes, group=group) - - elif ann_type == 'polygon': - return Polygon(points, label=label_id, z_order=z_order, - id=ann_id, attributes=attributes, group=group) - - elif ann_type == 'points': - return Points(points, label=label_id, z_order=z_order, - id=ann_id, attributes=attributes, group=group) - - elif ann_type == 'box': - x, y = points[0], points[1] - w, h = points[2] - x, points[3] - y - return Bbox(x, y, w, h, label=label_id, z_order=z_order, - id=ann_id, attributes=attributes, group=group) - - else: - raise NotImplementedError("Unknown annotation type '%s'" % ann_type) - - @classmethod - def _parse_tag_ann(cls, ann, categories): - label = ann.get('label') - label_id = categories[AnnotationType.label].find(label)[0] - group = ann.get('group') - attributes = ann.get('attributes') - return Label(label_id, attributes=attributes, group=group) - - def _load_items(self, parsed): - for frame_id, item_desc in parsed.items(): - name = item_desc.get('name', 'frame_%06d.png' % int(frame_id)) - image = osp.join(self._images_dir, name) - image_size = (item_desc.get('height'), item_desc.get('width')) - if all(image_size): - image = Image(path=image, size=tuple(map(int, image_size))) - - parsed[frame_id] = DatasetItem(id=osp.splitext(name)[0], - subset=self._subset, image=image, - annotations=item_desc.get('annotations'), - attributes={'frame': int(frame_id)}) - return parsed diff --git a/datumaro/datumaro/plugins/cvat_format/format.py b/datumaro/datumaro/plugins/cvat_format/format.py deleted file mode 100644 index e5572a89..00000000 --- a/datumaro/datumaro/plugins/cvat_format/format.py +++ /dev/null @@ -1,9 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -class CvatPath: - IMAGES_DIR = 'images' - - IMAGE_EXT = '.jpg' diff --git a/datumaro/datumaro/plugins/cvat_format/importer.py b/datumaro/datumaro/plugins/cvat_format/importer.py deleted file mode 100644 index a3a83757..00000000 --- a/datumaro/datumaro/plugins/cvat_format/importer.py +++ /dev/null @@ -1,51 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from glob import glob -import logging as log -import os.path as osp - -from datumaro.components.extractor import Importer - - -class CvatImporter(Importer): - EXTRACTOR_NAME = 'cvat' - - @classmethod - def detect(cls, path): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subset_paths = self.find_subsets(path) - - if len(subset_paths) == 0: - raise Exception("Failed to find 'cvat' dataset at '%s'" % path) - - for subset_path in subset_paths: - if not osp.isfile(subset_path): - continue - - log.info("Found a dataset at '%s'" % subset_path) - - subset_name = osp.splitext(osp.basename(subset_path))[0] - - project.add_source(subset_name, { - 'url': subset_path, - 'format': self.EXTRACTOR_NAME, - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_subsets(path): - if path.endswith('.xml') and osp.isfile(path): - subset_paths = [path] - else: - subset_paths = glob(osp.join(path, '**', '*.xml'), recursive=True) - return subset_paths \ No newline at end of file diff --git a/datumaro/datumaro/plugins/datumaro_format/__init__.py b/datumaro/datumaro/plugins/datumaro_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/datumaro_format/converter.py b/datumaro/datumaro/plugins/datumaro_format/converter.py deleted file mode 100644 index 2d862094..00000000 --- a/datumaro/datumaro/plugins/datumaro_format/converter.py +++ /dev/null @@ -1,261 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=no-self-use - -import json -import numpy as np -import os -import os.path as osp - -from datumaro.components.converter import Converter -from datumaro.components.extractor import ( - DEFAULT_SUBSET_NAME, Annotation, _Shape, - Label, Mask, RleMask, Points, Polygon, PolyLine, Bbox, Caption, - LabelCategories, MaskCategories, PointsCategories -) -from datumaro.util import cast -import pycocotools.mask as mask_utils - -from .format import DatumaroPath - - -class _SubsetWriter: - def __init__(self, name, context): - self._name = name - self._context = context - - self._data = { - 'info': {}, - 'categories': {}, - 'items': [], - } - - @property - def categories(self): - return self._data['categories'] - - @property - def items(self): - return self._data['items'] - - def write_item(self, item): - annotations = [] - item_desc = { - 'id': item.id, - 'annotations': annotations, - } - if item.attributes: - item_desc['attr'] = item.attributes - if item.path: - item_desc['path'] = item.path - if item.has_image: - path = item.image.path - if self._context._save_images: - path = self._context._make_image_filename(item) - self._context._save_image(item, path) - - item_desc['image'] = { - 'size': item.image.size, - 'path': path, - } - self.items.append(item_desc) - - for ann in item.annotations: - if isinstance(ann, Label): - converted_ann = self._convert_label_object(ann) - elif isinstance(ann, Mask): - converted_ann = self._convert_mask_object(ann) - elif isinstance(ann, Points): - converted_ann = self._convert_points_object(ann) - elif isinstance(ann, PolyLine): - converted_ann = self._convert_polyline_object(ann) - elif isinstance(ann, Polygon): - converted_ann = self._convert_polygon_object(ann) - elif isinstance(ann, Bbox): - converted_ann = self._convert_bbox_object(ann) - elif isinstance(ann, Caption): - converted_ann = self._convert_caption_object(ann) - else: - raise NotImplementedError() - annotations.append(converted_ann) - - def write_categories(self, categories): - for ann_type, desc in categories.items(): - if isinstance(desc, LabelCategories): - converted_desc = self._convert_label_categories(desc) - elif isinstance(desc, MaskCategories): - converted_desc = self._convert_mask_categories(desc) - elif isinstance(desc, PointsCategories): - converted_desc = self._convert_points_categories(desc) - else: - raise NotImplementedError() - self.categories[ann_type.name] = converted_desc - - def write(self, save_dir): - with open(osp.join(save_dir, '%s.json' % (self._name)), 'w') as f: - json.dump(self._data, f) - - def _convert_annotation(self, obj): - assert isinstance(obj, Annotation) - - ann_json = { - 'id': cast(obj.id, int), - 'type': cast(obj.type.name, str), - 'attributes': obj.attributes, - 'group': cast(obj.group, int, 0), - } - return ann_json - - def _convert_label_object(self, obj): - converted = self._convert_annotation(obj) - - converted.update({ - 'label_id': cast(obj.label, int), - }) - return converted - - def _convert_mask_object(self, obj): - converted = self._convert_annotation(obj) - - if isinstance(obj, RleMask): - rle = obj.rle - else: - rle = mask_utils.encode( - np.require(obj.image, dtype=np.uint8, requirements='F')) - - converted.update({ - 'label_id': cast(obj.label, int), - 'rle': { - # serialize as compressed COCO mask - 'counts': rle['counts'].decode('ascii'), - 'size': list(int(c) for c in rle['size']), - }, - 'z_order': obj.z_order, - }) - return converted - - def _convert_shape_object(self, obj): - assert isinstance(obj, _Shape) - converted = self._convert_annotation(obj) - - converted.update({ - 'label_id': cast(obj.label, int), - 'points': [float(p) for p in obj.points], - 'z_order': obj.z_order, - }) - return converted - - def _convert_polyline_object(self, obj): - return self._convert_shape_object(obj) - - def _convert_polygon_object(self, obj): - return self._convert_shape_object(obj) - - def _convert_bbox_object(self, obj): - converted = self._convert_shape_object(obj) - converted.pop('points', None) - converted['bbox'] = [float(p) for p in obj.get_bbox()] - return converted - - def _convert_points_object(self, obj): - converted = self._convert_shape_object(obj) - - converted.update({ - 'visibility': [int(v.value) for v in obj.visibility], - }) - return converted - - def _convert_caption_object(self, obj): - converted = self._convert_annotation(obj) - - converted.update({ - 'caption': cast(obj.caption, str), - }) - return converted - - def _convert_label_categories(self, obj): - converted = { - 'labels': [], - } - for label in obj.items: - converted['labels'].append({ - 'name': cast(label.name, str), - 'parent': cast(label.parent, str), - }) - return converted - - def _convert_mask_categories(self, obj): - converted = { - 'colormap': [], - } - for label_id, color in obj.colormap.items(): - converted['colormap'].append({ - 'label_id': int(label_id), - 'r': int(color[0]), - 'g': int(color[1]), - 'b': int(color[2]), - }) - return converted - - def _convert_points_categories(self, obj): - converted = { - 'items': [], - } - for label_id, item in obj.items.items(): - converted['items'].append({ - 'label_id': int(label_id), - 'labels': [cast(label, str) for label in item.labels], - 'joints': [list(map(int, j)) for j in item.joints], - }) - return converted - -class DatumaroConverter(Converter): - DEFAULT_IMAGE_EXT = DatumaroPath.IMAGE_EXT - - def apply(self): - os.makedirs(self._save_dir, exist_ok=True) - - images_dir = osp.join(self._save_dir, DatumaroPath.IMAGES_DIR) - os.makedirs(images_dir, exist_ok=True) - self._images_dir = images_dir - - annotations_dir = osp.join(self._save_dir, DatumaroPath.ANNOTATIONS_DIR) - os.makedirs(annotations_dir, exist_ok=True) - self._annotations_dir = annotations_dir - - subsets = self._extractor.subsets() or [None] - subsets = [n or DEFAULT_SUBSET_NAME for n in subsets] - subsets = { name: _SubsetWriter(name, self) for name in subsets } - - for subset, writer in subsets.items(): - writer.write_categories(self._extractor.categories()) - - for item in self._extractor: - subset = item.subset or DEFAULT_SUBSET_NAME - writer = subsets[subset] - - writer.write_item(item) - - for subset, writer in subsets.items(): - writer.write(annotations_dir) - - def _save_image(self, item, path=None): - super()._save_image(item, - osp.join(self._images_dir, self._make_image_filename(item))) - -class DatumaroProjectConverter(Converter): - @classmethod - def convert(cls, extractor, save_dir, **kwargs): - os.makedirs(save_dir, exist_ok=True) - - from datumaro.components.project import Project - project = Project.generate(save_dir, - config=kwargs.pop('project_config', None)) - - DatumaroConverter.convert(extractor, - save_dir=osp.join( - project.config.project_dir, project.config.dataset_dir), - **kwargs) \ No newline at end of file diff --git a/datumaro/datumaro/plugins/datumaro_format/extractor.py b/datumaro/datumaro/plugins/datumaro_format/extractor.py deleted file mode 100644 index c1ae40d4..00000000 --- a/datumaro/datumaro/plugins/datumaro_format/extractor.py +++ /dev/null @@ -1,157 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import json -import os.path as osp - -from datumaro.components.extractor import (SourceExtractor, DatasetItem, - AnnotationType, Label, RleMask, Points, Polygon, PolyLine, Bbox, Caption, - LabelCategories, MaskCategories, PointsCategories -) -from datumaro.util.image import Image - -from .format import DatumaroPath - - -class DatumaroExtractor(SourceExtractor): - def __init__(self, path): - assert osp.isfile(path), path - rootpath = '' - if path.endswith(osp.join(DatumaroPath.ANNOTATIONS_DIR, osp.basename(path))): - rootpath = path.rsplit(DatumaroPath.ANNOTATIONS_DIR, maxsplit=1)[0] - images_dir = '' - if rootpath and osp.isdir(osp.join(rootpath, DatumaroPath.IMAGES_DIR)): - images_dir = osp.join(rootpath, DatumaroPath.IMAGES_DIR) - self._images_dir = images_dir - - super().__init__(subset=osp.splitext(osp.basename(path))[0]) - - with open(path, 'r') as f: - parsed_anns = json.load(f) - self._categories = self._load_categories(parsed_anns) - self._items = self._load_items(parsed_anns) - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items: - yield item - - def __len__(self): - return len(self._items) - - @staticmethod - def _load_categories(parsed): - categories = {} - - parsed_label_cat = parsed['categories'].get(AnnotationType.label.name) - if parsed_label_cat: - label_categories = LabelCategories() - for item in parsed_label_cat['labels']: - label_categories.add(item['name'], parent=item['parent']) - - categories[AnnotationType.label] = label_categories - - parsed_mask_cat = parsed['categories'].get(AnnotationType.mask.name) - if parsed_mask_cat: - colormap = {} - for item in parsed_mask_cat['colormap']: - colormap[int(item['label_id'])] = \ - (item['r'], item['g'], item['b']) - - mask_categories = MaskCategories(colormap=colormap) - categories[AnnotationType.mask] = mask_categories - - parsed_points_cat = parsed['categories'].get(AnnotationType.points.name) - if parsed_points_cat: - point_categories = PointsCategories() - for item in parsed_points_cat['items']: - point_categories.add(int(item['label_id']), - item['labels'], joints=item['joints']) - - categories[AnnotationType.points] = point_categories - - return categories - - def _load_items(self, parsed): - items = [] - for item_desc in parsed['items']: - item_id = item_desc['id'] - - image = None - image_info = item_desc.get('image') - if image_info: - image_path = image_info.get('path') or \ - item_id + DatumaroPath.IMAGE_EXT - image_path = osp.join(self._images_dir, image_path) - image = Image(path=image_path, size=image_info.get('size')) - - annotations = self._load_annotations(item_desc) - - item = DatasetItem(id=item_id, subset=self._subset, - annotations=annotations, image=image, - attributes=item_desc.get('attr')) - - items.append(item) - - return items - - @staticmethod - def _load_annotations(item): - parsed = item['annotations'] - loaded = [] - - for ann in parsed: - ann_id = ann.get('id') - ann_type = AnnotationType[ann['type']] - attributes = ann.get('attributes') - group = ann.get('group') - - label_id = ann.get('label_id') - z_order = ann.get('z_order') - points = ann.get('points') - - if ann_type == AnnotationType.label: - loaded.append(Label(label=label_id, - id=ann_id, attributes=attributes, group=group)) - - elif ann_type == AnnotationType.mask: - rle = ann['rle'] - rle['counts'] = rle['counts'].encode('ascii') - loaded.append(RleMask(rle=rle, label=label_id, - id=ann_id, attributes=attributes, group=group, - z_order=z_order)) - - elif ann_type == AnnotationType.polyline: - loaded.append(PolyLine(points, label=label_id, - id=ann_id, attributes=attributes, group=group, - z_order=z_order)) - - elif ann_type == AnnotationType.polygon: - loaded.append(Polygon(points, label=label_id, - id=ann_id, attributes=attributes, group=group, - z_order=z_order)) - - elif ann_type == AnnotationType.bbox: - x, y, w, h = ann['bbox'] - loaded.append(Bbox(x, y, w, h, label=label_id, - id=ann_id, attributes=attributes, group=group, - z_order=z_order)) - - elif ann_type == AnnotationType.points: - loaded.append(Points(points, label=label_id, - id=ann_id, attributes=attributes, group=group, - z_order=z_order)) - - elif ann_type == AnnotationType.caption: - caption = ann.get('caption') - loaded.append(Caption(caption, - id=ann_id, attributes=attributes, group=group)) - - else: - raise NotImplementedError() - - return loaded diff --git a/datumaro/datumaro/plugins/datumaro_format/format.py b/datumaro/datumaro/plugins/datumaro_format/format.py deleted file mode 100644 index 501c100b..00000000 --- a/datumaro/datumaro/plugins/datumaro_format/format.py +++ /dev/null @@ -1,12 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -class DatumaroPath: - IMAGES_DIR = 'images' - ANNOTATIONS_DIR = 'annotations' - MASKS_DIR = 'masks' - - IMAGE_EXT = '.jpg' - MASK_EXT = '.png' diff --git a/datumaro/datumaro/plugins/datumaro_format/importer.py b/datumaro/datumaro/plugins/datumaro_format/importer.py deleted file mode 100644 index dbb90f86..00000000 --- a/datumaro/datumaro/plugins/datumaro_format/importer.py +++ /dev/null @@ -1,56 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from glob import glob -import logging as log -import os.path as osp - -from datumaro.components.extractor import Importer - -from .format import DatumaroPath - - -class DatumaroImporter(Importer): - EXTRACTOR_NAME = 'datumaro' - - @classmethod - def detect(cls, path): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subset_paths = self.find_subsets(path) - if len(subset_paths) == 0: - raise Exception("Failed to find 'datumaro' dataset at '%s'" % path) - - for subset_path in subset_paths: - if not osp.isfile(subset_path): - continue - - log.info("Found a dataset at '%s'" % subset_path) - - subset_name = osp.splitext(osp.basename(subset_path))[0] - - project.add_source(subset_name, { - 'url': subset_path, - 'format': self.EXTRACTOR_NAME, - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_subsets(path): - if path.endswith('.json') and osp.isfile(path): - subset_paths = [path] - else: - subset_paths = glob(osp.join(path, '*.json')) - - if osp.basename(osp.normpath(path)) != DatumaroPath.ANNOTATIONS_DIR: - path = osp.join(path, DatumaroPath.ANNOTATIONS_DIR) - subset_paths += glob(osp.join(path, '*.json')) - return subset_paths \ No newline at end of file diff --git a/datumaro/datumaro/plugins/image_dir.py b/datumaro/datumaro/plugins/image_dir.py deleted file mode 100644 index 062387e1..00000000 --- a/datumaro/datumaro/plugins/image_dir.py +++ /dev/null @@ -1,76 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -import os -import os.path as osp - -from datumaro.components.extractor import DatasetItem, SourceExtractor, Importer -from datumaro.components.converter import Converter -from datumaro.util.image import Image - - -class ImageDirImporter(Importer): - EXTRACTOR_NAME = 'image_dir' - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - if not osp.isdir(path): - raise Exception("Can't find a directory at '%s'" % path) - - source_name = osp.basename(osp.normpath(path)) - project.add_source(source_name, { - 'url': source_name, - 'format': self.EXTRACTOR_NAME, - 'options': dict(extra_params), - }) - - return project - - -class ImageDirExtractor(SourceExtractor): - def __init__(self, url): - super().__init__() - - assert osp.isdir(url), url - - items = [] - for dirpath, _, filenames in os.walk(url): - for name in filenames: - path = osp.join(dirpath, name) - try: - image = Image(path) - # force loading - image.data # pylint: disable=pointless-statement - except Exception: - continue - - item_id = osp.relpath(osp.splitext(path)[0], url) - items.append(DatasetItem(id=item_id, image=image)) - - self._items = items - - def __iter__(self): - for item in self._items: - yield item - - def __len__(self): - return len(self._items) - - -class ImageDirConverter(Converter): - DEFAULT_IMAGE_EXT = '.jpg' - - def apply(self): - os.makedirs(self._save_dir, exist_ok=True) - - for item in self._extractor: - if item.has_image: - self._save_image(item, - osp.join(self._save_dir, self._make_image_filename(item))) - else: - log.debug("Item '%s' has no image info", item.id) \ No newline at end of file diff --git a/datumaro/datumaro/plugins/labelme_format.py b/datumaro/datumaro/plugins/labelme_format.py deleted file mode 100644 index e037afba..00000000 --- a/datumaro/datumaro/plugins/labelme_format.py +++ /dev/null @@ -1,437 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import defaultdict -from defusedxml import ElementTree -import logging as log -import numpy as np -import os -import os.path as osp - -from datumaro.components.extractor import (SourceExtractor, DEFAULT_SUBSET_NAME, - DatasetItem, AnnotationType, Mask, Bbox, Polygon, LabelCategories -) -from datumaro.components.extractor import Importer -from datumaro.components.converter import Converter -from datumaro.util.image import Image, save_image -from datumaro.util.mask_tools import load_mask, find_mask_bbox - - -class LabelMePath: - MASKS_DIR = 'Masks' - IMAGE_EXT = '.jpg' - -class LabelMeExtractor(SourceExtractor): - def __init__(self, path, subset_name=None): - assert osp.isdir(path), path - super().__init__(subset=subset_name) - - items, categories = self._parse(path) - self._categories = categories - self._items = items - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items: - yield item - - def __len__(self): - return len(self._items) - - def _parse(self, path): - categories = { - AnnotationType.label: LabelCategories(attributes={ - 'occluded', 'username' - }) - } - - items = [] - for p in sorted(p for p in os.listdir(path) if p.endswith('.xml')): - root = ElementTree.parse(osp.join(path, p)) - - image_path = osp.join(path, root.find('filename').text) - image_size = None - imagesize_elem = root.find('imagesize') - if imagesize_elem is not None: - width_elem = imagesize_elem.find('ncols') - height_elem = imagesize_elem.find('nrows') - image_size = (int(height_elem.text), int(width_elem.text)) - image = Image(path=image_path, size=image_size) - - annotations = self._parse_annotations(root, path, categories) - - items.append(DatasetItem(id=osp.splitext(p)[0], - subset=self._subset, image=image, annotations=annotations)) - return items, categories - - @classmethod - def _parse_annotations(cls, xml_root, dataset_root, categories): - def parse_attributes(attr_str): - parsed = [] - if not attr_str: - return parsed - - for attr in [a.strip() for a in attr_str.split(',') if a.strip()]: - if '=' in attr: - name, value = attr.split('=', maxsplit=1) - if value.lower() in {'true', 'false'}: - value = value.lower() == 'true' - else: - try: - value = float(value) - except ValueError: - pass - parsed.append((name, value)) - else: - parsed.append((attr, True)) - - return parsed - - label_cat = categories[AnnotationType.label] - def get_label_id(label): - if not label: - return None - idx, _ = label_cat.find(label) - if idx is None: - idx = label_cat.add(label) - return idx - - image_annotations = [] - - parsed_annotations = dict() - group_assignments = dict() - root_annotations = set() - for obj_elem in xml_root.iter('object'): - obj_id = int(obj_elem.find('id').text) - - ann_items = [] - - label = get_label_id(obj_elem.find('name').text) - - attributes = [] - attributes_elem = obj_elem.find('attributes') - if attributes_elem is not None and attributes_elem.text: - attributes = parse_attributes(attributes_elem.text) - - occluded = False - occluded_elem = obj_elem.find('occluded') - if occluded_elem is not None and occluded_elem.text: - occluded = (occluded_elem.text == 'yes') - attributes.append(('occluded', occluded)) - - deleted = False - deleted_elem = obj_elem.find('deleted') - if deleted_elem is not None and deleted_elem.text: - deleted = bool(int(deleted_elem.text)) - - user = '' - - poly_elem = obj_elem.find('polygon') - segm_elem = obj_elem.find('segm') - type_elem = obj_elem.find('type') # the only value is 'bounding_box' - if poly_elem is not None: - user_elem = poly_elem.find('username') - if user_elem is not None and user_elem.text: - user = user_elem.text - attributes.append(('username', user)) - - points = [] - for point_elem in poly_elem.iter('pt'): - x = float(point_elem.find('x').text) - y = float(point_elem.find('y').text) - points.append(x) - points.append(y) - - if type_elem is not None and type_elem.text == 'bounding_box': - xmin = min(points[::2]) - xmax = max(points[::2]) - ymin = min(points[1::2]) - ymax = max(points[1::2]) - ann_items.append(Bbox(xmin, ymin, xmax - xmin, ymax - ymin, - label=label, attributes=attributes, id=obj_id, - )) - else: - ann_items.append(Polygon(points, - label=label, attributes=attributes, id=obj_id, - )) - elif segm_elem is not None: - user_elem = segm_elem.find('username') - if user_elem is not None and user_elem.text: - user = user_elem.text - attributes.append(('username', user)) - - mask_path = osp.join(dataset_root, LabelMePath.MASKS_DIR, - segm_elem.find('mask').text) - if not osp.isfile(mask_path): - raise Exception("Can't find mask at '%s'" % mask_path) - mask = load_mask(mask_path) - mask = np.any(mask, axis=2) - ann_items.append(Mask(image=mask, label=label, id=obj_id, - attributes=attributes)) - - if not deleted: - parsed_annotations[obj_id] = ann_items - - # Find parents and children - parts_elem = obj_elem.find('parts') - if parts_elem is not None: - children_ids = [] - hasparts_elem = parts_elem.find('hasparts') - if hasparts_elem is not None and hasparts_elem.text: - children_ids = [int(c) for c in hasparts_elem.text.split(',')] - - parent_ids = [] - ispartof_elem = parts_elem.find('ispartof') - if ispartof_elem is not None and ispartof_elem.text: - parent_ids = [int(c) for c in ispartof_elem.text.split(',')] - - if children_ids and not parent_ids and hasparts_elem.text: - root_annotations.add(obj_id) - group_assignments[obj_id] = [None, children_ids] - - # assign single group to all grouped annotations - current_group_id = 0 - annotations_to_visit = list(root_annotations) - while annotations_to_visit: - ann_id = annotations_to_visit.pop() - ann_assignment = group_assignments[ann_id] - group_id, children_ids = ann_assignment - if group_id: - continue - - if ann_id in root_annotations: - current_group_id += 1 # start a new group - - group_id = current_group_id - ann_assignment[0] = group_id - - # continue with children - annotations_to_visit.extend(children_ids) - - assert current_group_id == len(root_annotations) - - for ann_id, ann_items in parsed_annotations.items(): - group_id = 0 - if ann_id in group_assignments: - ann_assignment = group_assignments[ann_id] - group_id = ann_assignment[0] - - for ann_item in ann_items: - if group_id: - ann_item.group = group_id - - image_annotations.append(ann_item) - - return image_annotations - - -class LabelMeImporter(Importer): - _EXTRACTOR_NAME = 'label_me' - - @classmethod - def detect(cls, path): - if not osp.isdir(path): - return False - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subset_paths = self.find_subsets(path) - if len(subset_paths) == 0: - raise Exception("Failed to find 'label_me' dataset at '%s'" % path) - - for subset_path, subset_name in subset_paths: - params = {} - if subset_name: - params['subset_name'] = subset_name - params.update(extra_params) - - source_name = osp.splitext(osp.basename(subset_path))[0] - project.add_source(source_name, { - 'url': subset_path, - 'format': self._EXTRACTOR_NAME, - 'options': params, - }) - - return project - - @staticmethod - def find_subsets(path): - subset_paths = [] - if not osp.isdir(path): - raise Exception("Expected directory path, got '%s'" % path) - - path = osp.normpath(path) - - def has_annotations(d): - return len([p for p in os.listdir(d) if p.endswith('.xml')]) != 0 - - if has_annotations(path): - subset_paths = [(path, None)] - else: - for d in os.listdir(path): - subset = d - d = osp.join(path, d) - if osp.isdir(d) and has_annotations(d): - subset_paths.append((d, subset)) - return subset_paths - - -class LabelMeConverter(Converter): - DEFAULT_IMAGE_EXT = LabelMePath.IMAGE_EXT - - def apply(self): - for subset_name in self._extractor.subsets() or [None]: - if subset_name: - subset = self._extractor.get_subset(subset_name) - else: - subset_name = DEFAULT_SUBSET_NAME - subset = self._extractor - - subset_dir = osp.join(self._save_dir, subset_name) - os.makedirs(subset_dir, exist_ok=True) - os.makedirs(osp.join(subset_dir, LabelMePath.MASKS_DIR), - exist_ok=True) - - for item in subset: - self._save_item(item, subset_dir) - - def _get_label(self, label_id): - if label_id is None: - return '' - return self._extractor.categories()[AnnotationType.label] \ - .items[label_id].name - - def _save_item(self, item, subset_dir): - from lxml import etree as ET - - log.debug("Converting item '%s'", item.id) - - if '/' in item.id: - raise Exception("Can't export item '%s': " - "LabelMe format only supports flat image layout" % item.id) - - image_filename = self._make_image_filename(item) - if self._save_images: - if item.has_image and item.image.has_data: - self._save_image(item, osp.join(subset_dir, image_filename)) - else: - log.debug("Item '%s' has no image", item.id) - - root_elem = ET.Element('annotation') - ET.SubElement(root_elem, 'filename').text = image_filename - ET.SubElement(root_elem, 'folder').text = '' - - source_elem = ET.SubElement(root_elem, 'source') - ET.SubElement(source_elem, 'sourceImage').text = '' - ET.SubElement(source_elem, 'sourceAnnotation').text = 'Datumaro' - - if item.has_image: - image_elem = ET.SubElement(root_elem, 'imagesize') - image_size = item.image.size - ET.SubElement(image_elem, 'nrows').text = str(image_size[0]) - ET.SubElement(image_elem, 'ncols').text = str(image_size[1]) - - groups = defaultdict(list) - - obj_id = 0 - for ann in item.annotations: - if not ann.type in { AnnotationType.polygon, - AnnotationType.bbox, AnnotationType.mask }: - continue - - obj_elem = ET.SubElement(root_elem, 'object') - ET.SubElement(obj_elem, 'name').text = self._get_label(ann.label) - ET.SubElement(obj_elem, 'deleted').text = '0' - ET.SubElement(obj_elem, 'verified').text = '0' - ET.SubElement(obj_elem, 'occluded').text = \ - 'yes' if ann.attributes.pop('occluded', '') == True else 'no' - ET.SubElement(obj_elem, 'date').text = '' - ET.SubElement(obj_elem, 'id').text = str(obj_id) - - parts_elem = ET.SubElement(obj_elem, 'parts') - if ann.group: - groups[ann.group].append((obj_id, parts_elem)) - else: - ET.SubElement(parts_elem, 'hasparts').text = '' - ET.SubElement(parts_elem, 'ispartof').text = '' - - if ann.type == AnnotationType.bbox: - ET.SubElement(obj_elem, 'type').text = 'bounding_box' - - poly_elem = ET.SubElement(obj_elem, 'polygon') - x0, y0, x1, y1 = ann.points - points = [ (x0, y0), (x1, y0), (x1, y1), (x0, y1) ] - for x, y in points: - point_elem = ET.SubElement(poly_elem, 'pt') - ET.SubElement(point_elem, 'x').text = '%.2f' % x - ET.SubElement(point_elem, 'y').text = '%.2f' % y - - ET.SubElement(poly_elem, 'username').text = \ - str(ann.attributes.pop('username', '')) - elif ann.type == AnnotationType.polygon: - poly_elem = ET.SubElement(obj_elem, 'polygon') - for x, y in zip(ann.points[::2], ann.points[1::2]): - point_elem = ET.SubElement(poly_elem, 'pt') - ET.SubElement(point_elem, 'x').text = '%.2f' % x - ET.SubElement(point_elem, 'y').text = '%.2f' % y - - ET.SubElement(poly_elem, 'username').text = \ - str(ann.attributes.pop('username', '')) - elif ann.type == AnnotationType.mask: - mask_filename = '%s_mask_%s.png' % (item.id, obj_id) - save_image(osp.join(subset_dir, LabelMePath.MASKS_DIR, - mask_filename), - self._paint_mask(ann.image)) - - segm_elem = ET.SubElement(obj_elem, 'segm') - ET.SubElement(segm_elem, 'mask').text = mask_filename - - bbox = find_mask_bbox(ann.image) - box_elem = ET.SubElement(segm_elem, 'box') - ET.SubElement(box_elem, 'xmin').text = '%.2f' % bbox[0] - ET.SubElement(box_elem, 'ymin').text = '%.2f' % bbox[1] - ET.SubElement(box_elem, 'xmax').text = \ - '%.2f' % (bbox[0] + bbox[2]) - ET.SubElement(box_elem, 'ymax').text = \ - '%.2f' % (bbox[1] + bbox[3]) - - ET.SubElement(segm_elem, 'username').text = \ - str(ann.attributes.pop('username', '')) - else: - raise NotImplementedError("Unknown shape type '%s'" % ann.type) - - attrs = [] - for k, v in ann.attributes.items(): - attrs.append('%s=%s' % (k, v)) - ET.SubElement(obj_elem, 'attributes').text = ', '.join(attrs) - - obj_id += 1 - - for _, group in groups.items(): - leader_id, leader_parts_elem = group[0] - leader_parts = [str(o_id) for o_id, _ in group[1:]] - ET.SubElement(leader_parts_elem, 'hasparts').text = \ - ','.join(leader_parts) - ET.SubElement(leader_parts_elem, 'ispartof').text = '' - - for obj_id, parts_elem in group[1:]: - ET.SubElement(parts_elem, 'hasparts').text = '' - ET.SubElement(parts_elem, 'ispartof').text = str(leader_id) - - xml_path = osp.join(subset_dir, '%s.xml' % item.id) - with open(xml_path, 'w', encoding='utf-8') as f: - xml_data = ET.tostring(root_elem, encoding='unicode', - pretty_print=True) - f.write(xml_data) - - @staticmethod - def _paint_mask(mask): - # TODO: check if mask colors are random - return np.array([[0, 0, 0, 0], [255, 203, 0, 153]], - dtype=np.uint8)[mask.astype(np.uint8)] \ No newline at end of file diff --git a/datumaro/datumaro/plugins/mot_format.py b/datumaro/datumaro/plugins/mot_format.py deleted file mode 100644 index 12d3d07c..00000000 --- a/datumaro/datumaro/plugins/mot_format.py +++ /dev/null @@ -1,314 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# The Multiple Object Tracking Benchmark challenge format support -# Format description: https://arxiv.org/pdf/1906.04567.pdf -# Another description: https://motchallenge.net/instructions - -from collections import OrderedDict -import csv -from enum import Enum -import logging as log -import os -import os.path as osp - -from datumaro.components.extractor import (SourceExtractor, - DatasetItem, AnnotationType, Bbox, LabelCategories -) -from datumaro.components.extractor import Importer -from datumaro.components.converter import Converter -from datumaro.util import cast -from datumaro.util.image import Image - - -MotLabel = Enum('MotLabel', [ - ('pedestrian', 1), - ('person on vehicle', 2), - ('car', 3), - ('bicycle', 4), - ('motorbike', 5), - ('non motorized vehicle', 6), - ('static person', 7), - ('distractor', 8), - ('occluder', 9), - ('occluder on the ground', 10), - ('occluder full', 11), - ('reflection', 12), -]) - -class MotPath: - IMAGE_DIR = 'img1' - SEQINFO_FILE = 'seqinfo.ini' - LABELS_FILE = 'labels.txt' - GT_FILENAME = 'gt.txt' - DET_FILENAME = 'det.txt' - - IMAGE_EXT = '.jpg' - - FIELDS = [ - 'frame_id', - 'track_id', - 'x', - 'y', - 'w', - 'h', - 'confidence', # or 'not ignored' flag for GT anns - 'class_id', - 'visibility' - ] - - -class MotSeqExtractor(SourceExtractor): - def __init__(self, path, labels=None, occlusion_threshold=0, is_gt=None): - super().__init__() - - assert osp.isfile(path) - seq_root = osp.dirname(osp.dirname(path)) - self._image_dir = '' - if osp.isdir(osp.join(seq_root, MotPath.IMAGE_DIR)): - self._image_dir = osp.join(seq_root, MotPath.IMAGE_DIR) - - seq_info = osp.join(seq_root, MotPath.SEQINFO_FILE) - if osp.isfile(seq_info): - seq_info = self._parse_seq_info(seq_info) - self._image_dir = osp.join(seq_root, seq_info['imdir']) - else: - seq_info = None - self._seq_info = seq_info - - self._occlusion_threshold = float(occlusion_threshold) - - assert is_gt in {None, True, False} - if is_gt is None: - if osp.basename(path) == MotPath.DET_FILENAME: - is_gt = False - else: - is_gt = True - self._is_gt = is_gt - - if labels is None: - labels = osp.join(osp.dirname(path), MotPath.LABELS_FILE) - if not osp.isfile(labels): - labels = [lbl.name for lbl in MotLabel] - if isinstance(labels, str): - labels = self._parse_labels(labels) - elif isinstance(labels, list): - assert all(isinstance(lbl, str) for lbl in labels), labels - else: - raise TypeError("Unexpected type of 'labels' argument: %s" % labels) - self._categories = self._load_categories(labels) - self._items = self._load_items(path) - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items.values(): - yield item - - def __len__(self): - return len(self._items) - - @staticmethod - def _parse_labels(path): - with open(path, encoding='utf-8') as labels_file: - return [s.strip() for s in labels_file] - - def _load_categories(self, labels): - attributes = ['track_id'] - if self._is_gt: - attributes += ['occluded', 'visibility', 'ignored'] - else: - attributes += ['score'] - label_cat = LabelCategories(attributes=attributes) - for label in labels: - label_cat.add(label) - - return { AnnotationType.label: label_cat } - - def _load_items(self, path): - labels_count = len(self._categories[AnnotationType.label].items) - items = OrderedDict() - - if self._seq_info: - for frame_id in range(self._seq_info['seqlength']): - items[frame_id] = DatasetItem( - id=frame_id, - subset=self._subset, - image=Image( - path=osp.join(self._image_dir, - '%06d%s' % (frame_id, self._seq_info['imext'])), - size=(self._seq_info['imheight'], self._seq_info['imwidth']) - ) - ) - elif osp.isdir(self._image_dir): - for p in os.listdir(self._image_dir): - if p.endswith(MotPath.IMAGE_EXT): - frame_id = int(osp.splitext(p)[0]) - items[frame_id] = DatasetItem( - id=frame_id, - subset=self._subset, - image=osp.join(self._image_dir, p), - ) - - with open(path, newline='', encoding='utf-8') as csv_file: - # NOTE: Different MOT files have different count of fields - # (7, 9 or 10). This is handled by reader: - # - all extra fields go to a separate field - # - all unmet fields have None values - for row in csv.DictReader(csv_file, fieldnames=MotPath.FIELDS): - frame_id = int(row['frame_id']) - item = items.get(frame_id) - if item is None: - item = DatasetItem(id=frame_id, subset=self._subset) - annotations = item.annotations - - x, y = float(row['x']), float(row['y']) - w, h = float(row['w']), float(row['h']) - label_id = row.get('class_id') - if label_id and label_id != '-1': - label_id = int(label_id) - 1 - assert label_id < labels_count, label_id - else: - label_id = None - - attributes = {} - - # Annotations for detection task are not related to any track - track_id = int(row['track_id']) - if 0 < track_id: - attributes['track_id'] = track_id - - confidence = cast(row.get('confidence'), float, 1) - visibility = cast(row.get('visibility'), float, 1) - if self._is_gt: - attributes['visibility'] = visibility - attributes['occluded'] = \ - visibility <= self._occlusion_threshold - attributes['ignored'] = confidence == 0 - else: - attributes['score'] = float(confidence) - - annotations.append(Bbox(x, y, w, h, label=label_id, - attributes=attributes)) - - items[frame_id] = item - return items - - @classmethod - def _parse_seq_info(cls, path): - fields = {} - with open(path, encoding='utf-8') as f: - for line in f: - entry = line.lower().strip().split('=', maxsplit=1) - if len(entry) == 2: - fields[entry[0]] = entry[1] - cls._check_seq_info(fields) - for k in { 'framerate', 'seqlength', 'imwidth', 'imheight' }: - fields[k] = int(fields[k]) - return fields - - @staticmethod - def _check_seq_info(seq_info): - assert set(seq_info) == {'name', 'imdir', 'framerate', 'seqlength', 'imwidth', 'imheight', 'imext'}, seq_info - -class MotSeqImporter(Importer): - _EXTRACTOR_NAME = 'mot_seq' - - @classmethod - def detect(cls, path): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subsets = self.find_subsets(path) - if len(subsets) == 0: - raise Exception("Failed to find 'mot' dataset at '%s'" % path) - - for ann_file in subsets: - log.info("Found a dataset at '%s'" % ann_file) - - source_name = osp.splitext(osp.basename(ann_file))[0] - project.add_source(source_name, { - 'url': ann_file, - 'format': self._EXTRACTOR_NAME, - 'options': extra_params, - }) - - return project - - @staticmethod - def find_subsets(path): - subsets = [] - if path.endswith('.txt') and osp.isfile(path): - subsets = [path] - elif osp.isdir(path): - p = osp.join(path, 'gt', MotPath.GT_FILENAME) - if osp.isfile(p): - subsets.append(p) - return subsets - -class MotSeqGtConverter(Converter): - DEFAULT_IMAGE_EXT = MotPath.IMAGE_EXT - - def apply(self): - extractor = self._extractor - - images_dir = osp.join(self._save_dir, MotPath.IMAGE_DIR) - os.makedirs(images_dir, exist_ok=True) - self._images_dir = images_dir - - anno_dir = osp.join(self._save_dir, 'gt') - os.makedirs(anno_dir, exist_ok=True) - anno_file = osp.join(anno_dir, MotPath.GT_FILENAME) - with open(anno_file, 'w', encoding="utf-8") as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=MotPath.FIELDS) - - track_id_mapping = {-1: -1} - for idx, item in enumerate(extractor): - log.debug("Converting item '%s'", item.id) - - frame_id = cast(item.id, int, 1 + idx) - - for anno in item.annotations: - if anno.type != AnnotationType.bbox: - continue - - track_id = int(anno.attributes.get('track_id', -1)) - if track_id not in track_id_mapping: - track_id_mapping[track_id] = len(track_id_mapping) - track_id = track_id_mapping[track_id] - - writer.writerow({ - 'frame_id': frame_id, - 'track_id': track_id, - 'x': anno.x, - 'y': anno.y, - 'w': anno.w, - 'h': anno.h, - 'confidence': int(anno.attributes.get('ignored') != True), - 'class_id': 1 + cast(anno.label, int, -2), - 'visibility': float( - anno.attributes.get('visibility', - 1 - float( - anno.attributes.get('occluded', False) - ) - ) - ) - }) - - if self._save_images: - if item.has_image and item.image.has_data: - self._save_image(item, osp.join(self._images_dir, - '%06d%s' % (frame_id, self._find_image_ext(item)))) - else: - log.debug("Item '%s' has no image", item.id) - - labels_file = osp.join(anno_dir, MotPath.LABELS_FILE) - with open(labels_file, 'w', encoding='utf-8') as f: - f.write('\n'.join(l.name - for l in extractor.categories()[AnnotationType.label].items) - ) diff --git a/datumaro/datumaro/plugins/openvino_launcher.py b/datumaro/datumaro/plugins/openvino_launcher.py deleted file mode 100644 index abdaa0fc..00000000 --- a/datumaro/datumaro/plugins/openvino_launcher.py +++ /dev/null @@ -1,188 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=exec-used - -import cv2 -import logging as log -import numpy as np -import os.path as osp -import shutil - -from openvino.inference_engine import IECore - -from datumaro.components.cli_plugin import CliPlugin -from datumaro.components.launcher import Launcher - - -class OpenVinoImporter(CliPlugin): - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-d', '--description', required=True, - help="Path to the model description file (.xml)") - parser.add_argument('-w', '--weights', required=True, - help="Path to the model weights file (.bin)") - parser.add_argument('-i', '--interpreter', required=True, - help="Path to the network output interprter script (.py)") - parser.add_argument('--device', default='CPU', - help="Target device (default: %(default)s)") - return parser - - @staticmethod - def copy_model(model_dir, model): - shutil.copy(model['description'], - osp.join(model_dir, osp.basename(model['description']))) - model['description'] = osp.basename(model['description']) - - shutil.copy(model['weights'], - osp.join(model_dir, osp.basename(model['weights']))) - model['weights'] = osp.basename(model['weights']) - - shutil.copy(model['interpreter'], - osp.join(model_dir, osp.basename(model['interpreter']))) - model['interpreter'] = osp.basename(model['interpreter']) - - -class InterpreterScript: - def __init__(self, path): - with open(path, 'r') as f: - script = f.read() - - context = {} - exec(script, context, context) - - process_outputs = context.get('process_outputs') - if not callable(process_outputs): - raise Exception("Can't find 'process_outputs' function in " - "the interpreter script") - self.__dict__['process_outputs'] = process_outputs - - get_categories = context.get('get_categories') - assert get_categories is None or callable(get_categories) - if get_categories: - self.__dict__['get_categories'] = get_categories - - @staticmethod - def get_categories(): - return None - - @staticmethod - def process_outputs(inputs, outputs): - raise NotImplementedError( - "Function should be implemented in the interpreter script") - - -class OpenVinoLauncher(Launcher): - cli_plugin = OpenVinoImporter - - def __init__(self, description, weights, interpreter, - plugins_path=None, device=None, model_dir=None): - model_dir = model_dir or '' - if not osp.isfile(description): - description = osp.join(model_dir, description) - if not osp.isfile(description): - raise Exception('Failed to open model description file "%s"' % \ - (description)) - - if not osp.isfile(weights): - weights = osp.join(model_dir, weights) - if not osp.isfile(weights): - raise Exception('Failed to open model weights file "%s"' % \ - (weights)) - - if not osp.isfile(interpreter): - interpreter = osp.join(model_dir, interpreter) - if not osp.isfile(interpreter): - raise Exception('Failed to open model interpreter script file "%s"' % \ - (interpreter)) - - self._interpreter = InterpreterScript(interpreter) - - self._device = device or 'CPU' - - self._ie = IECore() - if hasattr(self._ie, 'read_network'): - self._network = self._ie.read_network(description, weights) - else: # backward compatibility - from openvino.inference_engine import IENetwork - self._network = IENetwork.from_ir(description, weights) - self._check_model_support(self._network, self._device) - self._load_executable_net() - - def _check_model_support(self, net, device): - supported_layers = set(self._ie.query_network(net, device)) - not_supported_layers = set(net.layers) - supported_layers - if len(not_supported_layers) != 0: - log.error("The following layers are not supported " \ - "by the plugin for device '%s': %s." % \ - (device, ', '.join(not_supported_layers))) - raise NotImplementedError( - "Some layers are not supported on the device") - - def _load_executable_net(self, batch_size=1): - network = self._network - - iter_inputs = iter(network.inputs) - self._input_blob_name = next(iter_inputs) - self._output_blob_name = next(iter(network.outputs)) - - # NOTE: handling for the inclusion of `image_info` in OpenVino2019 - self._require_image_info = 'image_info' in network.inputs - if self._input_blob_name == 'image_info': - self._input_blob_name = next(iter_inputs) - - input_type = network.inputs[self._input_blob_name] - self._input_layout = input_type if isinstance(input_type, list) else input_type.shape - - self._input_layout[0] = batch_size - network.reshape({self._input_blob_name: self._input_layout}) - self._batch_size = batch_size - - self._net = self._ie.load_network(network=network, num_requests=1, - device_name=self._device) - - def infer(self, inputs): - assert len(inputs.shape) == 4, \ - "Expected an input image in (N, H, W, C) format, got %s" % \ - (inputs.shape) - assert inputs.shape[3] == 3, "Expected BGR input, got %s" % inputs.shape - - n, c, h, w = self._input_layout - if inputs.shape[1:3] != (h, w): - resized_inputs = np.empty((n, h, w, c), dtype=inputs.dtype) - for inp, resized_input in zip(inputs, resized_inputs): - cv2.resize(inp, (w, h), resized_input) - inputs = resized_inputs - inputs = inputs.transpose((0, 3, 1, 2)) # NHWC to NCHW - inputs = {self._input_blob_name: inputs} - if self._require_image_info: - info = np.zeros([1, 3]) - info[0, 0] = h - info[0, 1] = w - info[0, 2] = 1.0 # scale - inputs['image_info'] = info - - results = self._net.infer(inputs) - if len(results) == 1: - return results[self._output_blob_name] - else: - return results - - def launch(self, inputs): - batch_size = len(inputs) - if self._batch_size < batch_size: - self._load_executable_net(batch_size) - - outputs = self.infer(inputs) - results = self.process_outputs(inputs, outputs) - return results - - def categories(self): - return self._interpreter.get_categories() - - def process_outputs(self, inputs, outputs): - return self._interpreter.process_outputs(inputs, outputs) - diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/__init__.py b/datumaro/datumaro/plugins/tf_detection_api_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/converter.py b/datumaro/datumaro/plugins/tf_detection_api_format/converter.py deleted file mode 100644 index a178bdba..00000000 --- a/datumaro/datumaro/plugins/tf_detection_api_format/converter.py +++ /dev/null @@ -1,217 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import codecs -from collections import OrderedDict -import hashlib -import logging as log -import os -import os.path as osp -import string - -from datumaro.components.extractor import (AnnotationType, DEFAULT_SUBSET_NAME, - LabelCategories -) -from datumaro.components.converter import Converter -from datumaro.util.image import encode_image -from datumaro.util.annotation_util import (max_bbox, - find_group_leader, find_instances) -from datumaro.util.mask_tools import merge_masks -from datumaro.util.tf_util import import_tf as _import_tf - -from .format import DetectionApiPath -tf = _import_tf() - - -# filter out non-ASCII characters, otherwise training will crash -_printable = set(string.printable) -def _make_printable(s): - return ''.join(filter(lambda x: x in _printable, s)) - -def int64_feature(value): - return tf.train.Feature(int64_list=tf.train.Int64List(value=[value])) - -def int64_list_feature(value): - return tf.train.Feature(int64_list=tf.train.Int64List(value=value)) - -def bytes_feature(value): - return tf.train.Feature(bytes_list=tf.train.BytesList(value=[value])) - -def bytes_list_feature(value): - return tf.train.Feature(bytes_list=tf.train.BytesList(value=value)) - -def float_list_feature(value): - return tf.train.Feature(float_list=tf.train.FloatList(value=value)) - -class TfDetectionApiConverter(Converter): - DEFAULT_IMAGE_EXT = DetectionApiPath.DEFAULT_IMAGE_EXT - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('--save-masks', action='store_true', - help="Include instance masks (default: %(default)s)") - return parser - - def __init__(self, extractor, save_dir, save_masks=False, **kwargs): - super().__init__(extractor, save_dir, **kwargs) - - self._save_masks = save_masks - - def apply(self): - os.makedirs(self._save_dir, exist_ok=True) - - label_categories = self._extractor.categories().get(AnnotationType.label, - LabelCategories()) - get_label = lambda label_id: label_categories.items[label_id].name \ - if label_id is not None else '' - label_ids = OrderedDict((label.name, 1 + idx) - for idx, label in enumerate(label_categories.items)) - map_label_id = lambda label_id: label_ids.get(get_label(label_id), 0) - self._get_label = get_label - self._get_label_id = map_label_id - - subsets = self._extractor.subsets() - if len(subsets) == 0: - subsets = [ None ] - - for subset_name in subsets: - if subset_name: - subset = self._extractor.get_subset(subset_name) - else: - subset_name = DEFAULT_SUBSET_NAME - subset = self._extractor - - labelmap_path = osp.join(self._save_dir, DetectionApiPath.LABELMAP_FILE) - with codecs.open(labelmap_path, 'w', encoding='utf8') as f: - for label, idx in label_ids.items(): - f.write( - 'item {\n' + - ('\tid: %s\n' % (idx)) + - ("\tname: '%s'\n" % (label)) + - '}\n\n' - ) - - anno_path = osp.join(self._save_dir, '%s.tfrecord' % (subset_name)) - with tf.io.TFRecordWriter(anno_path) as writer: - for item in subset: - tf_example = self._make_tf_example(item) - writer.write(tf_example.SerializeToString()) - - @staticmethod - def _find_instances(annotations): - return find_instances(a for a in annotations - if a.type in { AnnotationType.bbox, AnnotationType.mask }) - - def _find_instance_parts(self, group, img_width, img_height): - boxes = [a for a in group if a.type == AnnotationType.bbox] - masks = [a for a in group if a.type == AnnotationType.mask] - - anns = boxes + masks - leader = find_group_leader(anns) - bbox = max_bbox(anns) - - mask = None - if self._save_masks: - mask = merge_masks([m.image for m in masks]) - - return [leader, mask, bbox] - - def _export_instances(self, instances, width, height): - xmins = [] # List of normalized left x coordinates of bounding boxes (1 per box) - xmaxs = [] # List of normalized right x coordinates of bounding boxes (1 per box) - ymins = [] # List of normalized top y coordinates of bounding boxes (1 per box) - ymaxs = [] # List of normalized bottom y coordinates of bounding boxes (1 per box) - classes_text = [] # List of class names of bounding boxes (1 per box) - classes = [] # List of class ids of bounding boxes (1 per box) - masks = [] # List of PNG-encoded instance masks (1 per box) - - for leader, mask, box in instances: - label = _make_printable(self._get_label(leader.label)) - classes_text.append(label.encode('utf-8')) - classes.append(self._get_label_id(leader.label)) - - xmins.append(box[0] / width) - xmaxs.append((box[0] + box[2]) / width) - ymins.append(box[1] / height) - ymaxs.append((box[1] + box[3]) / height) - - if self._save_masks: - if mask is not None: - mask = encode_image(mask, '.png') - else: - mask = b'' - masks.append(mask) - - result = {} - if classes: - result = { - 'image/object/bbox/xmin': float_list_feature(xmins), - 'image/object/bbox/xmax': float_list_feature(xmaxs), - 'image/object/bbox/ymin': float_list_feature(ymins), - 'image/object/bbox/ymax': float_list_feature(ymaxs), - 'image/object/class/text': bytes_list_feature(classes_text), - 'image/object/class/label': int64_list_feature(classes), - } - if masks: - result['image/object/mask'] = bytes_list_feature(masks) - return result - - def _make_tf_example(self, item): - features = { - 'image/source_id': bytes_feature( - str(item.attributes.get('source_id') or '').encode('utf-8') - ), - } - - filename = self._make_image_filename(item) - features['image/filename'] = bytes_feature(filename.encode('utf-8')) - - if not item.has_image: - raise Exception("Failed to export dataset item '%s': " - "item has no image info" % item.id) - height, width = item.image.size - - features.update({ - 'image/height': int64_feature(height), - 'image/width': int64_feature(width), - }) - - features.update({ - 'image/encoded': bytes_feature(b''), - 'image/format': bytes_feature(b''), - 'image/key/sha256': bytes_feature(b''), - }) - if self._save_images: - if item.has_image and item.image.has_data: - buffer, fmt = self._save_image(item, filename) - key = hashlib.sha256(buffer).hexdigest() - - features.update({ - 'image/encoded': bytes_feature(buffer), - 'image/format': bytes_feature(fmt.encode('utf-8')), - 'image/key/sha256': bytes_feature(key.encode('utf8')), - }) - else: - log.warning("Item '%s' has no image" % item.id) - - instances = self._find_instances(item.annotations) - instances = [self._find_instance_parts(i, width, height) for i in instances] - features.update(self._export_instances(instances, width, height)) - - tf_example = tf.train.Example( - features=tf.train.Features(feature=features)) - - return tf_example - - def _save_image(self, item, path=None): - dst_ext = osp.splitext(osp.basename(path))[1] - fmt = DetectionApiPath.IMAGE_EXT_FORMAT.get(dst_ext) - if not fmt: - log.warning("Item '%s': can't find format string for the '%s' " - "image extension, the corresponding field will be empty." % \ - (item.id, dst_ext)) - buffer = encode_image(item.image.data, dst_ext) - return buffer, fmt \ No newline at end of file diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/extractor.py b/datumaro/datumaro/plugins/tf_detection_api_format/extractor.py deleted file mode 100644 index 6962d3c0..00000000 --- a/datumaro/datumaro/plugins/tf_detection_api_format/extractor.py +++ /dev/null @@ -1,195 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -import numpy as np -import os.path as osp -import re - -from datumaro.components.extractor import (SourceExtractor, DatasetItem, - AnnotationType, Bbox, Mask, LabelCategories -) -from datumaro.util.image import Image, decode_image, lazy_image -from datumaro.util.tf_util import import_tf as _import_tf - -from .format import DetectionApiPath -tf = _import_tf() - - -def clamp(value, _min, _max): - return max(min(_max, value), _min) - -class TfDetectionApiExtractor(SourceExtractor): - def __init__(self, path): - assert osp.isfile(path), path - images_dir = '' - root_dir = osp.dirname(osp.abspath(path)) - if osp.basename(root_dir) == DetectionApiPath.ANNOTATIONS_DIR: - root_dir = osp.dirname(root_dir) - images_dir = osp.join(root_dir, DetectionApiPath.IMAGES_DIR) - if not osp.isdir(images_dir): - images_dir = '' - - super().__init__(subset=osp.splitext(osp.basename(path))[0]) - - items, labels = self._parse_tfrecord_file(path, self._subset, images_dir) - self._items = items - self._categories = self._load_categories(labels) - - def categories(self): - return self._categories - - def __iter__(self): - for item in self._items: - yield item - - def __len__(self): - return len(self._items) - - @staticmethod - def _load_categories(labels): - label_categories = LabelCategories() - labels = sorted(labels.items(), key=lambda item: item[1]) - for label, _ in labels: - label_categories.add(label) - return { - AnnotationType.label: label_categories - } - - @classmethod - def _parse_labelmap(cls, text): - id_pattern = r'(?:id\s*:\s*(?P\d+))' - name_pattern = r'(?:name\s*:\s*[\'\"](?P.*?)[\'\"])' - entry_pattern = r'(\{(?:[\s\n]*(?:%(id)s|%(name)s)[\s\n]*){2}\})+' % \ - {'id': id_pattern, 'name': name_pattern} - matches = re.finditer(entry_pattern, text) - - labelmap = {} - for match in matches: - label_id = match.group('id') - label_name = match.group('name') - if label_id is not None and label_name is not None: - labelmap[label_name] = int(label_id) - - return labelmap - - @classmethod - def _parse_tfrecord_file(cls, filepath, subset, images_dir): - dataset = tf.data.TFRecordDataset(filepath) - features = { - 'image/filename': tf.io.FixedLenFeature([], tf.string), - 'image/source_id': tf.io.FixedLenFeature([], tf.string), - 'image/height': tf.io.FixedLenFeature([], tf.int64), - 'image/width': tf.io.FixedLenFeature([], tf.int64), - 'image/encoded': tf.io.FixedLenFeature([], tf.string), - 'image/format': tf.io.FixedLenFeature([], tf.string), - - # use varlen to avoid errors when this field is missing - 'image/key/sha256': tf.io.VarLenFeature(tf.string), - - # Object boxes and classes. - 'image/object/bbox/xmin': tf.io.VarLenFeature(tf.float32), - 'image/object/bbox/xmax': tf.io.VarLenFeature(tf.float32), - 'image/object/bbox/ymin': tf.io.VarLenFeature(tf.float32), - 'image/object/bbox/ymax': tf.io.VarLenFeature(tf.float32), - 'image/object/class/label': tf.io.VarLenFeature(tf.int64), - 'image/object/class/text': tf.io.VarLenFeature(tf.string), - 'image/object/mask': tf.io.VarLenFeature(tf.string), - } - - dataset_labels = OrderedDict() - labelmap_path = osp.join(osp.dirname(filepath), - DetectionApiPath.LABELMAP_FILE) - if osp.exists(labelmap_path): - with open(labelmap_path, 'r', encoding='utf-8') as f: - labelmap_text = f.read() - dataset_labels.update({ label: id - 1 - for label, id in cls._parse_labelmap(labelmap_text).items() - }) - - dataset_items = [] - - for record in dataset: - parsed_record = tf.io.parse_single_example(record, features) - frame_id = parsed_record['image/source_id'].numpy().decode('utf-8') - frame_filename = \ - parsed_record['image/filename'].numpy().decode('utf-8') - frame_height = tf.cast( - parsed_record['image/height'], tf.int64).numpy().item() - frame_width = tf.cast( - parsed_record['image/width'], tf.int64).numpy().item() - frame_image = parsed_record['image/encoded'].numpy() - xmins = tf.sparse.to_dense( - parsed_record['image/object/bbox/xmin']).numpy() - ymins = tf.sparse.to_dense( - parsed_record['image/object/bbox/ymin']).numpy() - xmaxs = tf.sparse.to_dense( - parsed_record['image/object/bbox/xmax']).numpy() - ymaxs = tf.sparse.to_dense( - parsed_record['image/object/bbox/ymax']).numpy() - label_ids = tf.sparse.to_dense( - parsed_record['image/object/class/label']).numpy() - labels = tf.sparse.to_dense( - parsed_record['image/object/class/text'], - default_value=b'').numpy() - masks = tf.sparse.to_dense( - parsed_record['image/object/mask'], - default_value=b'').numpy() - - for label, label_id in zip(labels, label_ids): - label = label.decode('utf-8') - if not label: - continue - if label_id <= 0: - continue - if label in dataset_labels: - continue - dataset_labels[label] = label_id - 1 - - item_id = osp.splitext(frame_filename)[0] - - annotations = [] - for shape_id, shape in enumerate( - np.dstack((labels, xmins, ymins, xmaxs, ymaxs))[0]): - label = shape[0].decode('utf-8') - - mask = None - if len(masks) != 0: - mask = masks[shape_id] - - if mask is not None: - if isinstance(mask, bytes): - mask = lazy_image(mask, decode_image) - annotations.append(Mask(image=mask, - label=dataset_labels.get(label) - )) - else: - x = clamp(shape[1] * frame_width, 0, frame_width) - y = clamp(shape[2] * frame_height, 0, frame_height) - w = clamp(shape[3] * frame_width, 0, frame_width) - x - h = clamp(shape[4] * frame_height, 0, frame_height) - y - annotations.append(Bbox(x, y, w, h, - label=dataset_labels.get(label) - )) - - image_size = None - if frame_height and frame_width: - image_size = (frame_height, frame_width) - - image_params = {} - if frame_image: - image_params['data'] = lazy_image(frame_image, decode_image) - if frame_filename: - image_params['path'] = osp.join(images_dir, frame_filename) - - image = None - if image_params: - image = Image(**image_params, size=image_size) - - dataset_items.append(DatasetItem(id=item_id, subset=subset, - image=image, annotations=annotations, - attributes={'source_id': frame_id})) - - return dataset_items, dataset_labels diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/format.py b/datumaro/datumaro/plugins/tf_detection_api_format/format.py deleted file mode 100644 index f4a879a6..00000000 --- a/datumaro/datumaro/plugins/tf_detection_api_format/format.py +++ /dev/null @@ -1,13 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -class DetectionApiPath: - IMAGES_DIR = 'images' - ANNOTATIONS_DIR = 'annotations' - - DEFAULT_IMAGE_EXT = '.jpg' - IMAGE_EXT_FORMAT = {'.jpg': 'jpeg', '.png': 'png'} - - LABELMAP_FILE = 'label_map.pbtxt' \ No newline at end of file diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/importer.py b/datumaro/datumaro/plugins/tf_detection_api_format/importer.py deleted file mode 100644 index b3d8a47d..00000000 --- a/datumaro/datumaro/plugins/tf_detection_api_format/importer.py +++ /dev/null @@ -1,52 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from glob import glob -import logging as log -import os.path as osp - -from datumaro.components.extractor import Importer - - -class TfDetectionApiImporter(Importer): - EXTRACTOR_NAME = 'tf_detection_api' - - @classmethod - def detect(cls, path): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subset_paths = self.find_subsets(path) - if len(subset_paths) == 0: - raise Exception( - "Failed to find 'tf_detection_api' dataset at '%s'" % path) - - for subset_path in subset_paths: - if not osp.isfile(subset_path): - continue - - log.info("Found a dataset at '%s'" % subset_path) - - subset_name = osp.splitext(osp.basename(subset_path))[0] - - project.add_source(subset_name, { - 'url': subset_path, - 'format': self.EXTRACTOR_NAME, - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_subsets(path): - if path.endswith('.tfrecord') and osp.isfile(path): - subset_paths = [path] - else: - subset_paths = glob(osp.join(path, '**', '*.tfrecord'), - recursive=True) - return subset_paths \ No newline at end of file diff --git a/datumaro/datumaro/plugins/transforms.py b/datumaro/datumaro/plugins/transforms.py deleted file mode 100644 index 7e7cea8b..00000000 --- a/datumaro/datumaro/plugins/transforms.py +++ /dev/null @@ -1,524 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from enum import Enum -import logging as log -import os.path as osp -import random -import re - -import pycocotools.mask as mask_utils - -from datumaro.components.extractor import (Transform, AnnotationType, - RleMask, Polygon, Bbox, - LabelCategories, MaskCategories, PointsCategories -) -from datumaro.components.cli_plugin import CliPlugin -import datumaro.util.mask_tools as mask_tools -from datumaro.util.annotation_util import find_group_leader, find_instances - - -class CropCoveredSegments(Transform, CliPlugin): - def transform_item(self, item): - annotations = [] - segments = [] - for ann in item.annotations: - if ann.type in {AnnotationType.polygon, AnnotationType.mask}: - segments.append(ann) - else: - annotations.append(ann) - if not segments: - return item - - if not item.has_image: - raise Exception("Image info is required for this transform") - h, w = item.image.size - segments = self.crop_segments(segments, w, h) - - annotations += segments - return self.wrap_item(item, annotations=annotations) - - @classmethod - def crop_segments(cls, segment_anns, img_width, img_height): - segment_anns = sorted(segment_anns, key=lambda x: x.z_order) - - segments = [] - for s in segment_anns: - if s.type == AnnotationType.polygon: - segments.append(s.points) - elif s.type == AnnotationType.mask: - if isinstance(s, RleMask): - rle = s.rle - else: - rle = mask_tools.mask_to_rle(s.image) - segments.append(rle) - - segments = mask_tools.crop_covered_segments( - segments, img_width, img_height) - - new_anns = [] - for ann, new_segment in zip(segment_anns, segments): - fields = {'z_order': ann.z_order, 'label': ann.label, - 'id': ann.id, 'group': ann.group, 'attributes': ann.attributes - } - if ann.type == AnnotationType.polygon: - if fields['group'] is None: - fields['group'] = cls._make_group_id( - segment_anns + new_anns, fields['id']) - for polygon in new_segment: - new_anns.append(Polygon(points=polygon, **fields)) - else: - rle = mask_tools.mask_to_rle(new_segment) - rle = mask_utils.frPyObjects(rle, *rle['size']) - new_anns.append(RleMask(rle=rle, **fields)) - - return new_anns - - @staticmethod - def _make_group_id(anns, ann_id): - if ann_id: - return ann_id - max_gid = max(anns, default=0, key=lambda x: x.group) - return max_gid + 1 - -class MergeInstanceSegments(Transform, CliPlugin): - """ - Replaces instance masks and, optionally, polygons with a single mask. - """ - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('--include-polygons', action='store_true', - help="Include polygons") - return parser - - def __init__(self, extractor, include_polygons=False): - super().__init__(extractor) - - self._include_polygons = include_polygons - - def transform_item(self, item): - annotations = [] - segments = [] - for ann in item.annotations: - if ann.type in {AnnotationType.polygon, AnnotationType.mask}: - segments.append(ann) - else: - annotations.append(ann) - if not segments: - return item - - if not item.has_image: - raise Exception("Image info is required for this transform") - h, w = item.image.size - instances = self.find_instances(segments) - segments = [self.merge_segments(i, w, h, self._include_polygons) - for i in instances] - segments = sum(segments, []) - - annotations += segments - return self.wrap_item(item, annotations=annotations) - - @classmethod - def merge_segments(cls, instance, img_width, img_height, - include_polygons=False): - polygons = [a for a in instance if a.type == AnnotationType.polygon] - masks = [a for a in instance if a.type == AnnotationType.mask] - if not polygons and not masks: - return [] - - leader = find_group_leader(polygons + masks) - instance = [] - - # Build the resulting mask - mask = None - - if include_polygons and polygons: - polygons = [p.points for p in polygons] - mask = mask_tools.rles_to_mask(polygons, img_width, img_height) - else: - instance += polygons # keep unused polygons - - if masks: - masks = [m.image for m in masks] - if mask is not None: - masks += [mask] - mask = mask_tools.merge_masks(masks) - - if mask is None: - return instance - - mask = mask_tools.mask_to_rle(mask) - mask = mask_utils.frPyObjects(mask, *mask['size']) - instance.append( - RleMask(rle=mask, label=leader.label, z_order=leader.z_order, - id=leader.id, attributes=leader.attributes, group=leader.group - ) - ) - return instance - - @staticmethod - def find_instances(annotations): - return find_instances(a for a in annotations - if a.type in {AnnotationType.polygon, AnnotationType.mask}) - -class PolygonsToMasks(Transform, CliPlugin): - def transform_item(self, item): - annotations = [] - for ann in item.annotations: - if ann.type == AnnotationType.polygon: - if not item.has_image: - raise Exception("Image info is required for this transform") - h, w = item.image.size - annotations.append(self.convert_polygon(ann, h, w)) - else: - annotations.append(ann) - - return self.wrap_item(item, annotations=annotations) - - @staticmethod - def convert_polygon(polygon, img_h, img_w): - rle = mask_utils.frPyObjects([polygon.points], img_h, img_w)[0] - - return RleMask(rle=rle, label=polygon.label, z_order=polygon.z_order, - id=polygon.id, attributes=polygon.attributes, group=polygon.group) - -class BoxesToMasks(Transform, CliPlugin): - def transform_item(self, item): - annotations = [] - for ann in item.annotations: - if ann.type == AnnotationType.bbox: - if not item.has_image: - raise Exception("Image info is required for this transform") - h, w = item.image.size - annotations.append(self.convert_bbox(ann, h, w)) - else: - annotations.append(ann) - - return self.wrap_item(item, annotations=annotations) - - @staticmethod - def convert_bbox(bbox, img_h, img_w): - rle = mask_utils.frPyObjects([bbox.as_polygon()], img_h, img_w)[0] - - return RleMask(rle=rle, label=bbox.label, z_order=bbox.z_order, - id=bbox.id, attributes=bbox.attributes, group=bbox.group) - -class MasksToPolygons(Transform, CliPlugin): - def transform_item(self, item): - annotations = [] - for ann in item.annotations: - if ann.type == AnnotationType.mask: - polygons = self.convert_mask(ann) - if not polygons: - log.debug("[%s]: item %s: " - "Mask conversion to polygons resulted in too " - "small polygons, which were discarded" % \ - (self._get_name(__class__), item.id)) - annotations.extend(polygons) - else: - annotations.append(ann) - - return self.wrap_item(item, annotations=annotations) - - @staticmethod - def convert_mask(mask): - polygons = mask_tools.mask_to_polygons(mask.image) - - return [ - Polygon(points=p, label=mask.label, z_order=mask.z_order, - id=mask.id, attributes=mask.attributes, group=mask.group) - for p in polygons - ] - -class ShapesToBoxes(Transform, CliPlugin): - def transform_item(self, item): - annotations = [] - for ann in item.annotations: - if ann.type in { AnnotationType.mask, AnnotationType.polygon, - AnnotationType.polyline, AnnotationType.points, - }: - annotations.append(self.convert_shape(ann)) - else: - annotations.append(ann) - - return self.wrap_item(item, annotations=annotations) - - @staticmethod - def convert_shape(shape): - bbox = shape.get_bbox() - return Bbox(*bbox, label=shape.label, z_order=shape.z_order, - id=shape.id, attributes=shape.attributes, group=shape.group) - -class Reindex(Transform, CliPlugin): - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-s', '--start', type=int, default=1, - help="Start value for item ids") - return parser - - def __init__(self, extractor, start=1): - super().__init__(extractor) - - self._start = start - - def __iter__(self): - for i, item in enumerate(self._extractor): - yield self.wrap_item(item, id=i + self._start) - -class MapSubsets(Transform, CliPlugin): - @staticmethod - def _mapping_arg(s): - parts = s.split(':') - if len(parts) != 2: - import argparse - raise argparse.ArgumentTypeError() - return parts - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-s', '--subset', action='append', - type=cls._mapping_arg, dest='mapping', - help="Subset mapping of the form: 'src:dst' (repeatable)") - return parser - - def __init__(self, extractor, mapping=None): - super().__init__(extractor) - - if mapping is None: - mapping = {} - elif not isinstance(mapping, dict): - mapping = dict(tuple(m) for m in mapping) - self._mapping = mapping - - def transform_item(self, item): - return self.wrap_item(item, - subset=self._mapping.get(item.subset, item.subset)) - -class RandomSplit(Transform, CliPlugin): - """ - Joins all subsets into one and splits the result into few parts. - It is expected that item ids are unique and subset ratios sum up to 1.|n - |n - Example:|n - |s|s%(prog)s --subset train:.67 --subset test:.33 - """ - - @staticmethod - def _split_arg(s): - parts = s.split(':') - if len(parts) != 2: - import argparse - raise argparse.ArgumentTypeError() - return (parts[0], float(parts[1])) - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-s', '--subset', action='append', - type=cls._split_arg, dest='splits', - default=[('train', 0.67), ('test', 0.33)], - help="Subsets in the form of: ':' (repeatable)") - parser.add_argument('--seed', type=int, help="Random seed") - return parser - - def __init__(self, extractor, splits, seed=None): - super().__init__(extractor) - - assert 0 < len(splits), "Expected at least one split" - assert all(0.0 <= r and r <= 1.0 for _, r in splits), \ - "Ratios are expected to be in the range [0; 1], but got %s" % splits - - total_ratio = sum(s[1] for s in splits) - if not abs(total_ratio - 1.0) <= 1e-7: - raise Exception( - "Sum of ratios is expected to be 1, got %s, which is %s" % - (splits, total_ratio)) - - dataset_size = len(extractor) - indices = list(range(dataset_size)) - - random.seed(seed) - random.shuffle(indices) - parts = [] - s = 0 - for subset, ratio in splits: - s += ratio - boundary = int(s * dataset_size) - parts.append((boundary, subset)) - - self._parts = parts - - def _find_split(self, index): - for boundary, subset in self._parts: - if index < boundary: - return subset - return subset # all the possible remainder goes to the last split - - def __iter__(self): - for i, item in enumerate(self._extractor): - yield self.wrap_item(item, subset=self._find_split(i)) - -class IdFromImageName(Transform, CliPlugin): - def transform_item(self, item): - if item.has_image and item.image.path: - name = osp.splitext(osp.basename(item.image.path))[0] - return self.wrap_item(item, id=name) - else: - log.debug("Can't change item id for item '%s': " - "item has no image info" % item.id) - return item - -class Rename(Transform, CliPlugin): - """ - Renames items in the dataset. Supports regular expressions. - The first character in the expression is a delimiter for - the pattern and replacement parts. Replacement part can also - contain string.format tokens with 'item' object available.|n - |n - Examples:|n - - Replace 'pattern' with 'replacement':|n - |s|srename -e '|pattern|replacement|'|n - - Remove 'frame_' from item ids:|n - |s|srename -e '|frame_(\d+)|\\1|' - """ - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-e', '--regex', - help="Regex for renaming.") - return parser - - def __init__(self, extractor, regex): - super().__init__(extractor) - - assert regex and isinstance(regex, str) - parts = regex.split(regex[0], maxsplit=3) - regex, sub = parts[1:3] - self._re = re.compile(regex) - self._sub = sub - - def transform_item(self, item): - return self.wrap_item(item, id=self._re.sub(self._sub, item.id) \ - .format(item=item)) - -class RemapLabels(Transform, CliPlugin): - """ - Changes labels in the dataset.|n - Examples:|n - - Rename 'person' to 'car' and 'cat' to 'dog', keep 'bus', remove others:|n - |s|sremap_labels -l person:car -l bus:bus -l cat:dog --default delete - """ - - DefaultAction = Enum('DefaultAction', ['keep', 'delete']) - - @staticmethod - def _split_arg(s): - parts = s.split(':') - if len(parts) != 2: - import argparse - raise argparse.ArgumentTypeError() - return (parts[0], parts[1]) - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-l', '--label', action='append', - type=cls._split_arg, dest='mapping', - help="Label in the form of: ':' (repeatable)") - parser.add_argument('--default', - choices=[a.name for a in cls.DefaultAction], - default=cls.DefaultAction.keep.name, - help="Action for unspecified labels (default: %(default)s)") - return parser - - def __init__(self, extractor, mapping, default=None): - super().__init__(extractor) - - assert isinstance(default, (str, self.DefaultAction)) - if isinstance(default, str): - default = self.DefaultAction[default] - - assert isinstance(mapping, (dict, list)) - if isinstance(mapping, list): - mapping = dict(mapping) - - self._categories = {} - - src_label_cat = self._extractor.categories().get(AnnotationType.label) - if src_label_cat is not None: - self._make_label_id_map(src_label_cat, mapping, default) - - src_mask_cat = self._extractor.categories().get(AnnotationType.mask) - if src_mask_cat is not None: - assert src_label_cat is not None - dst_mask_cat = MaskCategories(attributes=src_mask_cat.attributes) - dst_mask_cat.colormap = { - id: src_mask_cat.colormap[id] - for id, _ in enumerate(src_label_cat.items) - if self._map_id(id) or id == 0 - } - self._categories[AnnotationType.mask] = dst_mask_cat - - src_points_cat = self._extractor.categories().get(AnnotationType.points) - if src_points_cat is not None: - assert src_label_cat is not None - dst_points_cat = PointsCategories(attributes=src_points_cat.attributes) - dst_points_cat.items = { - id: src_points_cat.items[id] - for id, item in enumerate(src_label_cat.items) - if self._map_id(id) or id == 0 - } - self._categories[AnnotationType.points] = dst_points_cat - - def _make_label_id_map(self, src_label_cat, label_mapping, default_action): - dst_label_cat = LabelCategories(attributes=src_label_cat.attributes) - id_mapping = {} - for src_index, src_label in enumerate(src_label_cat.items): - dst_label = label_mapping.get(src_label.name) - if not dst_label and default_action == self.DefaultAction.keep: - dst_label = src_label.name # keep unspecified as is - if not dst_label: - continue - - dst_index = dst_label_cat.find(dst_label)[0] - if dst_index is None: - dst_index = dst_label_cat.add(dst_label, - src_label.parent, src_label.attributes) - id_mapping[src_index] = dst_index - - if log.getLogger().isEnabledFor(log.DEBUG): - log.debug("Label mapping:") - for src_id, src_label in enumerate(src_label_cat.items): - if id_mapping.get(src_id): - log.debug("#%s '%s' -> #%s '%s'", - src_id, src_label.name, id_mapping[src_id], - dst_label_cat.items[id_mapping[src_id]].name - ) - else: - log.debug("#%s '%s' -> ", src_id, src_label.name) - - self._map_id = lambda src_id: id_mapping.get(src_id, None) - self._categories[AnnotationType.label] = dst_label_cat - - def categories(self): - return self._categories - - def transform_item(self, item): - annotations = [] - for ann in item.annotations: - if ann.type in { AnnotationType.label, AnnotationType.mask, - AnnotationType.points, AnnotationType.polygon, - AnnotationType.polyline, AnnotationType.bbox - } and ann.label is not None: - conv_label = self._map_id(ann.label) - if conv_label is not None: - annotations.append(ann.wrap(label=conv_label)) - else: - annotations.append(ann.wrap()) - return item.wrap(annotations=annotations) \ No newline at end of file diff --git a/datumaro/datumaro/plugins/voc_format/__init__.py b/datumaro/datumaro/plugins/voc_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/voc_format/converter.py b/datumaro/datumaro/plugins/voc_format/converter.py deleted file mode 100644 index 65e586d8..00000000 --- a/datumaro/datumaro/plugins/voc_format/converter.py +++ /dev/null @@ -1,590 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -import os -import os.path as osp -from collections import OrderedDict, defaultdict -from enum import Enum -from itertools import chain - -from lxml import etree as ET - -from datumaro.components.converter import Converter -from datumaro.components.extractor import (DEFAULT_SUBSET_NAME, AnnotationType, - CompiledMask, LabelCategories) -from datumaro.util import find, str_to_bool -from datumaro.util.image import save_image -from datumaro.util.mask_tools import paint_mask, remap_mask - -from .format import (VocTask, VocPath, VocInstColormap, - parse_label_map, make_voc_label_map, make_voc_categories, write_label_map -) - - -def _convert_attr(name, attributes, type_conv, default=None, warn=True): - d = object() - value = attributes.get(name, d) - if value is d: - return default - - try: - return type_conv(value) - except Exception as e: - log.warning("Failed to convert attribute '%s'='%s': %s" % \ - (name, value, e)) - return default - -def _write_xml_bbox(bbox, parent_elem): - x, y, w, h = bbox - bbox_elem = ET.SubElement(parent_elem, 'bndbox') - ET.SubElement(bbox_elem, 'xmin').text = str(x) - ET.SubElement(bbox_elem, 'ymin').text = str(y) - ET.SubElement(bbox_elem, 'xmax').text = str(x + w) - ET.SubElement(bbox_elem, 'ymax').text = str(y + h) - return bbox_elem - - -LabelmapType = Enum('LabelmapType', ['voc', 'source']) - -class VocConverter(Converter): - DEFAULT_IMAGE_EXT = VocPath.IMAGE_EXT - - @staticmethod - def _split_tasks_string(s): - return [VocTask[i.strip()] for i in s.split(',')] - - @staticmethod - def _get_labelmap(s): - if osp.isfile(s): - return s - try: - return LabelmapType[s].name - except KeyError: - import argparse - raise argparse.ArgumentTypeError() - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - - parser.add_argument('--apply-colormap', type=str_to_bool, default=True, - help="Use colormap for class and instance masks " - "(default: %(default)s)") - parser.add_argument('--label-map', type=cls._get_labelmap, default=None, - help="Labelmap file path or one of %s" % \ - ', '.join(t.name for t in LabelmapType)) - parser.add_argument('--allow-attributes', - type=str_to_bool, default=True, - help="Allow export of attributes (default: %(default)s)") - parser.add_argument('--tasks', type=cls._split_tasks_string, - help="VOC task filter, comma-separated list of {%s} " - "(default: all)" % ', '.join(t.name for t in VocTask)) - - return parser - - def __init__(self, extractor, save_dir, - tasks=None, apply_colormap=True, label_map=None, - allow_attributes=True, **kwargs): - super().__init__(extractor, save_dir, **kwargs) - - assert tasks is None or isinstance(tasks, (VocTask, list, set)) - if tasks is None: - tasks = set(VocTask) - elif isinstance(tasks, VocTask): - tasks = {tasks} - else: - tasks = set(t if t in VocTask else VocTask[t] for t in tasks) - self._tasks = tasks - - self._apply_colormap = apply_colormap - self._allow_attributes = allow_attributes - - if label_map is None: - label_map = LabelmapType.source - self._load_categories(label_map) - - def apply(self): - self.make_dirs() - self.save_subsets() - self.save_label_map() - - def make_dirs(self): - save_dir = self._save_dir - subsets_dir = osp.join(save_dir, VocPath.SUBSETS_DIR) - cls_subsets_dir = osp.join(subsets_dir, - VocPath.TASK_DIR[VocTask.classification]) - action_subsets_dir = osp.join(subsets_dir, - VocPath.TASK_DIR[VocTask.action_classification]) - layout_subsets_dir = osp.join(subsets_dir, - VocPath.TASK_DIR[VocTask.person_layout]) - segm_subsets_dir = osp.join(subsets_dir, - VocPath.TASK_DIR[VocTask.segmentation]) - ann_dir = osp.join(save_dir, VocPath.ANNOTATIONS_DIR) - img_dir = osp.join(save_dir, VocPath.IMAGES_DIR) - segm_dir = osp.join(save_dir, VocPath.SEGMENTATION_DIR) - inst_dir = osp.join(save_dir, VocPath.INSTANCES_DIR) - images_dir = osp.join(save_dir, VocPath.IMAGES_DIR) - - os.makedirs(subsets_dir, exist_ok=True) - os.makedirs(ann_dir, exist_ok=True) - os.makedirs(img_dir, exist_ok=True) - os.makedirs(segm_dir, exist_ok=True) - os.makedirs(inst_dir, exist_ok=True) - os.makedirs(images_dir, exist_ok=True) - - self._subsets_dir = subsets_dir - self._cls_subsets_dir = cls_subsets_dir - self._action_subsets_dir = action_subsets_dir - self._layout_subsets_dir = layout_subsets_dir - self._segm_subsets_dir = segm_subsets_dir - self._ann_dir = ann_dir - self._img_dir = img_dir - self._segm_dir = segm_dir - self._inst_dir = inst_dir - self._images_dir = images_dir - - def get_label(self, label_id): - return self._extractor. \ - categories()[AnnotationType.label].items[label_id].name - - def save_subsets(self): - for subset_name in self._extractor.subsets() or [None]: - if subset_name: - subset = self._extractor.get_subset(subset_name) - else: - subset_name = DEFAULT_SUBSET_NAME - subset = self._extractor - - class_lists = OrderedDict() - clsdet_list = OrderedDict() - action_list = OrderedDict() - layout_list = OrderedDict() - segm_list = OrderedDict() - - for item in subset: - log.debug("Converting item '%s'", item.id) - - image_filename = self._make_image_filename(item) - if self._save_images: - if item.has_image and item.image.has_data: - self._save_image(item, - osp.join(self._images_dir, image_filename)) - else: - log.debug("Item '%s' has no image", item.id) - - labels = [] - bboxes = [] - masks = [] - for a in item.annotations: - if a.type == AnnotationType.label: - labels.append(a) - elif a.type == AnnotationType.bbox: - bboxes.append(a) - elif a.type == AnnotationType.mask: - masks.append(a) - - if self._tasks is None and bboxes or \ - self._tasks & {VocTask.detection, VocTask.person_layout, - VocTask.action_classification}: - root_elem = ET.Element('annotation') - if '_' in item.id: - folder = item.id[ : item.id.find('_')] - else: - folder = '' - ET.SubElement(root_elem, 'folder').text = folder - ET.SubElement(root_elem, 'filename').text = image_filename - - source_elem = ET.SubElement(root_elem, 'source') - ET.SubElement(source_elem, 'database').text = 'Unknown' - ET.SubElement(source_elem, 'annotation').text = 'Unknown' - ET.SubElement(source_elem, 'image').text = 'Unknown' - - if item.has_image: - h, w = item.image.size - if item.image.has_data: - image_shape = item.image.data.shape - c = 1 if len(image_shape) == 2 else image_shape[2] - else: - c = 3 - size_elem = ET.SubElement(root_elem, 'size') - ET.SubElement(size_elem, 'width').text = str(w) - ET.SubElement(size_elem, 'height').text = str(h) - ET.SubElement(size_elem, 'depth').text = str(c) - - item_segmented = 0 < len(masks) - ET.SubElement(root_elem, 'segmented').text = \ - str(int(item_segmented)) - - objects_with_parts = [] - objects_with_actions = defaultdict(dict) - - main_bboxes = [] - layout_bboxes = [] - for bbox in bboxes: - label = self.get_label(bbox.label) - if self._is_part(label): - layout_bboxes.append(bbox) - elif self._is_label(label): - main_bboxes.append(bbox) - - for new_obj_id, obj in enumerate(main_bboxes): - attr = obj.attributes - - obj_elem = ET.SubElement(root_elem, 'object') - - obj_label = self.get_label(obj.label) - ET.SubElement(obj_elem, 'name').text = obj_label - - if 'pose' in attr: - ET.SubElement(obj_elem, 'pose').text = \ - str(attr['pose']) - - if 'truncated' in attr: - truncated = _convert_attr('truncated', attr, int, 0) - ET.SubElement(obj_elem, 'truncated').text = \ - '%d' % truncated - - if 'difficult' in attr: - difficult = _convert_attr('difficult', attr, int, 0) - ET.SubElement(obj_elem, 'difficult').text = \ - '%d' % difficult - - if 'occluded' in attr: - occluded = _convert_attr('occluded', attr, int, 0) - ET.SubElement(obj_elem, 'occluded').text = \ - '%d' % occluded - - bbox = obj.get_bbox() - if bbox is not None: - _write_xml_bbox(bbox, obj_elem) - - for part_bbox in filter( - lambda x: obj.group and obj.group == x.group, - layout_bboxes): - part_elem = ET.SubElement(obj_elem, 'part') - ET.SubElement(part_elem, 'name').text = \ - self.get_label(part_bbox.label) - _write_xml_bbox(part_bbox.get_bbox(), part_elem) - - objects_with_parts.append(new_obj_id) - - label_actions = self._get_actions(obj_label) - actions_elem = ET.Element('actions') - for action in label_actions: - present = 0 - if action in attr: - present = _convert_attr(action, attr, - lambda v: int(v == True), 0) - ET.SubElement(actions_elem, action).text = \ - '%d' % present - - objects_with_actions[new_obj_id][action] = present - if len(actions_elem) != 0: - obj_elem.append(actions_elem) - - if self._allow_attributes: - native_attrs = {'difficult', 'pose', - 'truncated', 'occluded' } - native_attrs.update(label_actions) - - attrs_elem = ET.Element('attributes') - for k, v in attr.items(): - if k in native_attrs: - continue - attr_elem = ET.SubElement(attrs_elem, 'attribute') - ET.SubElement(attr_elem, 'name').text = str(k) - ET.SubElement(attr_elem, 'value').text = str(v) - if len(attrs_elem): - obj_elem.append(attrs_elem) - - if self._tasks & {VocTask.detection, VocTask.person_layout, - VocTask.action_classification}: - ann_path = osp.join(self._ann_dir, item.id + '.xml') - os.makedirs(osp.dirname(ann_path), exist_ok=True) - with open(ann_path, 'w') as f: - f.write(ET.tostring(root_elem, - encoding='unicode', pretty_print=True)) - - clsdet_list[item.id] = True - layout_list[item.id] = objects_with_parts - action_list[item.id] = objects_with_actions - - for label_ann in labels: - label = self.get_label(label_ann.label) - if not self._is_label(label): - continue - class_list = class_lists.get(item.id, set()) - class_list.add(label_ann.label) - class_lists[item.id] = class_list - - clsdet_list[item.id] = True - - if masks: - compiled_mask = CompiledMask.from_instance_masks(masks, - instance_labels=[self._label_id_mapping(m.label) - for m in masks]) - - self.save_segm( - osp.join(self._segm_dir, item.id + VocPath.SEGM_EXT), - compiled_mask.class_mask) - self.save_segm( - osp.join(self._inst_dir, item.id + VocPath.SEGM_EXT), - compiled_mask.instance_mask, - colormap=VocInstColormap) - - segm_list[item.id] = True - - if len(item.annotations) == 0: - clsdet_list[item.id] = None - layout_list[item.id] = None - action_list[item.id] = None - segm_list[item.id] = None - - if self._tasks & {VocTask.classification, VocTask.detection, - VocTask.action_classification, VocTask.person_layout}: - self.save_clsdet_lists(subset_name, clsdet_list) - if self._tasks & {VocTask.classification}: - self.save_class_lists(subset_name, class_lists) - if self._tasks & {VocTask.action_classification}: - self.save_action_lists(subset_name, action_list) - if self._tasks & {VocTask.person_layout}: - self.save_layout_lists(subset_name, layout_list) - if self._tasks & {VocTask.segmentation}: - self.save_segm_lists(subset_name, segm_list) - - def save_action_lists(self, subset_name, action_list): - if not action_list: - return - - os.makedirs(self._action_subsets_dir, exist_ok=True) - - ann_file = osp.join(self._action_subsets_dir, subset_name + '.txt') - with open(ann_file, 'w') as f: - for item in action_list: - f.write('%s\n' % item) - - if len(action_list) == 0: - return - - all_actions = set(chain(*(self._get_actions(l) - for l in self._label_map))) - for action in all_actions: - ann_file = osp.join(self._action_subsets_dir, - '%s_%s.txt' % (action, subset_name)) - with open(ann_file, 'w') as f: - for item, objs in action_list.items(): - if not objs: - continue - for obj_id, obj_actions in objs.items(): - presented = obj_actions[action] - f.write('%s %s % d\n' % \ - (item, 1 + obj_id, 1 if presented else -1)) - - def save_class_lists(self, subset_name, class_lists): - if not class_lists: - return - - os.makedirs(self._cls_subsets_dir, exist_ok=True) - - for label in self._label_map: - ann_file = osp.join(self._cls_subsets_dir, - '%s_%s.txt' % (label, subset_name)) - with open(ann_file, 'w') as f: - for item, item_labels in class_lists.items(): - if not item_labels: - continue - item_labels = [self.get_label(l) for l in item_labels] - presented = label in item_labels - f.write('%s % d\n' % (item, 1 if presented else -1)) - - def save_clsdet_lists(self, subset_name, clsdet_list): - if not clsdet_list: - return - - os.makedirs(self._cls_subsets_dir, exist_ok=True) - - ann_file = osp.join(self._cls_subsets_dir, subset_name + '.txt') - with open(ann_file, 'w') as f: - for item in clsdet_list: - f.write('%s\n' % item) - - def save_segm_lists(self, subset_name, segm_list): - if not segm_list: - return - - os.makedirs(self._segm_subsets_dir, exist_ok=True) - - ann_file = osp.join(self._segm_subsets_dir, subset_name + '.txt') - with open(ann_file, 'w') as f: - for item in segm_list: - f.write('%s\n' % item) - - def save_layout_lists(self, subset_name, layout_list): - if not layout_list: - return - - os.makedirs(self._layout_subsets_dir, exist_ok=True) - - ann_file = osp.join(self._layout_subsets_dir, subset_name + '.txt') - with open(ann_file, 'w') as f: - for item, item_layouts in layout_list.items(): - if item_layouts: - for obj_id in item_layouts: - f.write('%s % d\n' % (item, 1 + obj_id)) - else: - f.write('%s\n' % (item)) - - def save_segm(self, path, mask, colormap=None): - if self._apply_colormap: - if colormap is None: - colormap = self._categories[AnnotationType.mask].colormap - mask = paint_mask(mask, colormap) - save_image(path, mask, create_dir=True) - - def save_label_map(self): - path = osp.join(self._save_dir, VocPath.LABELMAP_FILE) - write_label_map(path, self._label_map) - - def _load_categories(self, label_map_source): - if label_map_source == LabelmapType.voc.name: - # use the default VOC colormap - label_map = make_voc_label_map() - - elif label_map_source == LabelmapType.source.name and \ - AnnotationType.mask not in self._extractor.categories(): - # generate colormap for input labels - labels = self._extractor.categories() \ - .get(AnnotationType.label, LabelCategories()) - label_map = OrderedDict((item.name, [None, [], []]) - for item in labels.items) - - elif label_map_source == LabelmapType.source.name and \ - AnnotationType.mask in self._extractor.categories(): - # use source colormap - labels = self._extractor.categories()[AnnotationType.label] - colors = self._extractor.categories()[AnnotationType.mask] - label_map = OrderedDict() - for idx, item in enumerate(labels.items): - color = colors.colormap.get(idx) - if color is not None: - label_map[item.name] = [color, [], []] - - elif isinstance(label_map_source, dict): - label_map = OrderedDict( - sorted(label_map_source.items(), key=lambda e: e[0])) - - elif isinstance(label_map_source, str) and osp.isfile(label_map_source): - label_map = parse_label_map(label_map_source) - - else: - raise Exception("Wrong labelmap specified, " - "expected one of %s or a file path" % \ - ', '.join(t.name for t in LabelmapType)) - - # There must always be a label with color (0, 0, 0) at index 0 - bg_label = find(label_map.items(), lambda x: x[1][0] == (0, 0, 0)) - if bg_label is not None: - bg_label = bg_label[0] - else: - bg_label = 'background' - if bg_label not in label_map: - has_colors = any(v[0] is not None for v in label_map.values()) - color = (0, 0, 0) if has_colors else None - label_map[bg_label] = [color, [], []] - label_map.move_to_end(bg_label, last=False) - - self._categories = make_voc_categories(label_map) - - # Update colors with assigned values - colormap = self._categories[AnnotationType.mask].colormap - for label_id, color in colormap.items(): - label_desc = label_map[ - self._categories[AnnotationType.label].items[label_id].name] - label_desc[0] = color - - self._label_map = label_map - self._label_id_mapping = self._make_label_id_map() - - def _is_label(self, s): - return self._label_map.get(s) is not None - - def _is_part(self, s): - for label_desc in self._label_map.values(): - if s in label_desc[1]: - return True - return False - - def _is_action(self, label, s): - return s in self._get_actions(label) - - def _get_actions(self, label): - label_desc = self._label_map.get(label) - if not label_desc: - return [] - return label_desc[2] - - def _make_label_id_map(self): - source_labels = { - id: label.name for id, label in - enumerate(self._extractor.categories().get( - AnnotationType.label, LabelCategories()).items) - } - target_labels = { - label.name: id for id, label in - enumerate(self._categories[AnnotationType.label].items) - } - id_mapping = { - src_id: target_labels.get(src_label, 0) - for src_id, src_label in source_labels.items() - } - - void_labels = [src_label for src_id, src_label in source_labels.items() - if src_label not in target_labels] - if void_labels: - log.warning("The following labels are remapped to background: %s" % - ', '.join(void_labels)) - log.debug("Saving segmentations with the following label mapping: \n%s" % - '\n'.join(["#%s '%s' -> #%s '%s'" % - ( - src_id, src_label, id_mapping[src_id], - self._categories[AnnotationType.label] \ - .items[id_mapping[src_id]].name - ) - for src_id, src_label in source_labels.items() - ]) - ) - - def map_id(src_id): - return id_mapping.get(src_id, 0) - return map_id - - def _remap_mask(self, mask): - return remap_mask(mask, self._label_id_mapping) - -class VocClassificationConverter(VocConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = VocTask.classification - super().__init__(*args, **kwargs) - -class VocDetectionConverter(VocConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = VocTask.detection - super().__init__(*args, **kwargs) - -class VocLayoutConverter(VocConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = VocTask.person_layout - super().__init__(*args, **kwargs) - -class VocActionConverter(VocConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = VocTask.action_classification - super().__init__(*args, **kwargs) - -class VocSegmentationConverter(VocConverter): - def __init__(self, *args, **kwargs): - kwargs['tasks'] = VocTask.segmentation - super().__init__(*args, **kwargs) diff --git a/datumaro/datumaro/plugins/voc_format/extractor.py b/datumaro/datumaro/plugins/voc_format/extractor.py deleted file mode 100644 index 0fe667d3..00000000 --- a/datumaro/datumaro/plugins/voc_format/extractor.py +++ /dev/null @@ -1,302 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import defaultdict -import logging as log -import numpy as np -import os.path as osp -from defusedxml import ElementTree - -from datumaro.components.extractor import (SourceExtractor, DatasetItem, - AnnotationType, Label, Mask, Bbox, CompiledMask -) -from datumaro.util import dir_items -from datumaro.util.image import Image -from datumaro.util.mask_tools import lazy_mask, invert_colormap - -from .format import ( - VocTask, VocPath, VocInstColormap, parse_label_map, make_voc_categories -) - - -_inverse_inst_colormap = invert_colormap(VocInstColormap) - -class _VocExtractor(SourceExtractor): - def __init__(self, path): - assert osp.isfile(path), path - self._path = path - self._dataset_dir = osp.dirname(osp.dirname(osp.dirname(path))) - - super().__init__(subset=osp.splitext(osp.basename(path))[0]) - - self._categories = self._load_categories(self._dataset_dir) - - label_color = lambda label_idx: \ - self._categories[AnnotationType.mask].colormap.get(label_idx, None) - log.debug("Loaded labels: %s" % ', '.join( - "'%s' %s" % (l.name, ('(%s, %s, %s)' % c) if c else '') - for i, l, c in ((i, l, label_color(i)) for i, l in enumerate( - self._categories[AnnotationType.label].items - )) - )) - self._items = self._load_subset_list(path) - - def categories(self): - return self._categories - - def __len__(self): - return len(self._items) - - def _get_label_id(self, label): - label_id, _ = self._categories[AnnotationType.label].find(label) - assert label_id is not None, label - return label_id - - @staticmethod - def _load_categories(dataset_path): - label_map = None - label_map_path = osp.join(dataset_path, VocPath.LABELMAP_FILE) - if osp.isfile(label_map_path): - label_map = parse_label_map(label_map_path) - return make_voc_categories(label_map) - - @staticmethod - def _load_subset_list(subset_path): - with open(subset_path) as f: - return [line.split()[0] for line in f] - -class VocClassificationExtractor(_VocExtractor): - def __iter__(self): - raw_anns = self._load_annotations() - for item_id in self._items: - log.debug("Reading item '%s'" % item_id) - image = osp.join(self._dataset_dir, VocPath.IMAGES_DIR, - item_id + VocPath.IMAGE_EXT) - anns = self._parse_annotations(raw_anns, item_id) - yield DatasetItem(id=item_id, subset=self._subset, - image=image, annotations=anns) - - def _load_annotations(self): - annotations = defaultdict(list) - task_dir = osp.dirname(self._path) - anno_files = [s for s in dir_items(task_dir, '.txt') - if s.endswith('_' + osp.basename(self._path))] - for ann_filename in anno_files: - with open(osp.join(task_dir, ann_filename)) as f: - label = ann_filename[:ann_filename.rfind('_')] - label_id = self._get_label_id(label) - for line in f: - item, present = line.split() - if present == '1': - annotations[item].append(label_id) - - return dict(annotations) - - @staticmethod - def _parse_annotations(raw_anns, item_id): - return [Label(label_id) for label_id in raw_anns.get(item_id, [])] - -class _VocXmlExtractor(_VocExtractor): - def __init__(self, path, task): - super().__init__(path) - self._task = task - - def __iter__(self): - anno_dir = osp.join(self._dataset_dir, VocPath.ANNOTATIONS_DIR) - - for item_id in self._items: - log.debug("Reading item '%s'" % item_id) - image = item_id + VocPath.IMAGE_EXT - height, width = 0, 0 - - anns = [] - ann_file = osp.join(anno_dir, item_id + '.xml') - if osp.isfile(ann_file): - root_elem = ElementTree.parse(ann_file) - height = root_elem.find('size/height') - if height is not None: - height = int(height.text) - width = root_elem.find('size/width') - if width is not None: - width = int(width.text) - filename_elem = root_elem.find('filename') - if filename_elem is not None: - image = filename_elem.text - anns = self._parse_annotations(root_elem) - - image = osp.join(self._dataset_dir, VocPath.IMAGES_DIR, image) - if height and width: - image = Image(path=image, size=(height, width)) - - yield DatasetItem(id=item_id, subset=self._subset, - image=image, annotations=anns) - - def _parse_annotations(self, root_elem): - item_annotations = [] - - for obj_id, object_elem in enumerate(root_elem.findall('object')): - obj_id += 1 - attributes = {} - group = obj_id - - obj_label_id = None - label_elem = object_elem.find('name') - if label_elem is not None: - obj_label_id = self._get_label_id(label_elem.text) - - obj_bbox = self._parse_bbox(object_elem) - - if obj_label_id is None or obj_bbox is None: - continue - - difficult_elem = object_elem.find('difficult') - attributes['difficult'] = difficult_elem is not None and \ - difficult_elem.text == '1' - - truncated_elem = object_elem.find('truncated') - attributes['truncated'] = truncated_elem is not None and \ - truncated_elem.text == '1' - - occluded_elem = object_elem.find('occluded') - attributes['occluded'] = occluded_elem is not None and \ - occluded_elem.text == '1' - - pose_elem = object_elem.find('pose') - if pose_elem is not None: - attributes['pose'] = pose_elem.text - - point_elem = object_elem.find('point') - if point_elem is not None: - point_x = point_elem.find('x') - point_y = point_elem.find('y') - point = [float(point_x.text), float(point_y.text)] - attributes['point'] = point - - actions_elem = object_elem.find('actions') - actions = {a: False - for a in self._categories[AnnotationType.label] \ - .items[obj_label_id].attributes} - if actions_elem is not None: - for action_elem in actions_elem: - actions[action_elem.tag] = (action_elem.text == '1') - for action, present in actions.items(): - attributes[action] = present - - has_parts = False - for part_elem in object_elem.findall('part'): - part = part_elem.find('name').text - part_label_id = self._get_label_id(part) - part_bbox = self._parse_bbox(part_elem) - - if self._task is not VocTask.person_layout: - break - if part_bbox is None: - continue - has_parts = True - item_annotations.append(Bbox(*part_bbox, label=part_label_id, - group=group)) - - attributes_elem = object_elem.find('attributes') - if attributes_elem is not None: - for attr_elem in attributes_elem.iter('attribute'): - attributes[attr_elem.find('name').text] = \ - attr_elem.find('value').text - - if self._task is VocTask.person_layout and not has_parts: - continue - if self._task is VocTask.action_classification and not actions: - continue - - item_annotations.append(Bbox(*obj_bbox, label=obj_label_id, - attributes=attributes, id=obj_id, group=group)) - - return item_annotations - - @staticmethod - def _parse_bbox(object_elem): - bbox_elem = object_elem.find('bndbox') - xmin = float(bbox_elem.find('xmin').text) - xmax = float(bbox_elem.find('xmax').text) - ymin = float(bbox_elem.find('ymin').text) - ymax = float(bbox_elem.find('ymax').text) - return [xmin, ymin, xmax - xmin, ymax - ymin] - -class VocDetectionExtractor(_VocXmlExtractor): - def __init__(self, path): - super().__init__(path, task=VocTask.detection) - -class VocLayoutExtractor(_VocXmlExtractor): - def __init__(self, path): - super().__init__(path, task=VocTask.person_layout) - -class VocActionExtractor(_VocXmlExtractor): - def __init__(self, path): - super().__init__(path, task=VocTask.action_classification) - -class VocSegmentationExtractor(_VocExtractor): - def __iter__(self): - for item_id in self._items: - log.debug("Reading item '%s'" % item_id) - image = osp.join(self._dataset_dir, VocPath.IMAGES_DIR, - item_id + VocPath.IMAGE_EXT) - anns = self._load_annotations(item_id) - yield DatasetItem(id=item_id, subset=self._subset, - image=image, annotations=anns) - - @staticmethod - def _lazy_extract_mask(mask, c): - return lambda: mask == c - - def _load_annotations(self, item_id): - item_annotations = [] - - class_mask = None - segm_path = osp.join(self._dataset_dir, VocPath.SEGMENTATION_DIR, - item_id + VocPath.SEGM_EXT) - if osp.isfile(segm_path): - inverse_cls_colormap = \ - self._categories[AnnotationType.mask].inverse_colormap - class_mask = lazy_mask(segm_path, inverse_cls_colormap) - - instances_mask = None - inst_path = osp.join(self._dataset_dir, VocPath.INSTANCES_DIR, - item_id + VocPath.SEGM_EXT) - if osp.isfile(inst_path): - instances_mask = lazy_mask(inst_path, _inverse_inst_colormap) - - if instances_mask is not None: - compiled_mask = CompiledMask(class_mask, instances_mask) - - if class_mask is not None: - label_cat = self._categories[AnnotationType.label] - instance_labels = compiled_mask.get_instance_labels() - else: - instance_labels = {i: None - for i in range(compiled_mask.instance_count)} - - for instance_id, label_id in instance_labels.items(): - image = compiled_mask.lazy_extract(instance_id) - - attributes = {} - if label_id is not None: - actions = {a: False - for a in label_cat.items[label_id].attributes - } - attributes.update(actions) - - item_annotations.append(Mask( - image=image, label=label_id, - attributes=attributes, group=instance_id - )) - elif class_mask is not None: - log.warn("item '%s': has only class segmentation, " - "instance masks will not be available" % item_id) - class_mask = class_mask() - classes = np.unique(class_mask) - for label_id in classes: - image = self._lazy_extract_mask(class_mask, label_id) - item_annotations.append(Mask(image=image, label=label_id)) - - return item_annotations diff --git a/datumaro/datumaro/plugins/voc_format/format.py b/datumaro/datumaro/plugins/voc_format/format.py deleted file mode 100644 index a03446d5..00000000 --- a/datumaro/datumaro/plugins/voc_format/format.py +++ /dev/null @@ -1,206 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -from enum import Enum -from itertools import chain -import numpy as np - -from datumaro.components.extractor import (AnnotationType, - LabelCategories, MaskCategories -) - - -VocTask = Enum('VocTask', [ - 'classification', - 'detection', - 'segmentation', - 'action_classification', - 'person_layout', -]) - -VocLabel = Enum('VocLabel', [ - ('background', 0), - ('aeroplane', 1), - ('bicycle', 2), - ('bird', 3), - ('boat', 4), - ('bottle', 5), - ('bus', 6), - ('car', 7), - ('cat', 8), - ('chair', 9), - ('cow', 10), - ('diningtable', 11), - ('dog', 12), - ('horse', 13), - ('motorbike', 14), - ('person', 15), - ('pottedplant', 16), - ('sheep', 17), - ('sofa', 18), - ('train', 19), - ('tvmonitor', 20), - ('ignored', 255), -]) - -VocPose = Enum('VocPose', [ - 'Unspecified', - 'Left', - 'Right', - 'Frontal', - 'Rear', -]) - -VocBodyPart = Enum('VocBodyPart', [ - 'head', - 'hand', - 'foot', -]) - -VocAction = Enum('VocAction', [ - 'other', - 'jumping', - 'phoning', - 'playinginstrument', - 'reading', - 'ridingbike', - 'ridinghorse', - 'running', - 'takingphoto', - 'usingcomputer', - 'walking', -]) - -def generate_colormap(length=256): - def get_bit(number, index): - return (number >> index) & 1 - - colormap = np.zeros((length, 3), dtype=int) - indices = np.arange(length, dtype=int) - - for j in range(7, -1, -1): - for c in range(3): - colormap[:, c] |= get_bit(indices, c) << j - indices >>= 3 - - return OrderedDict( - (id, tuple(color)) for id, color in enumerate(colormap) - ) - -VocColormap = {id: color for id, color in generate_colormap(256).items() - if id in [l.value for l in VocLabel]} -VocInstColormap = generate_colormap(256) - -class VocPath: - IMAGES_DIR = 'JPEGImages' - ANNOTATIONS_DIR = 'Annotations' - SEGMENTATION_DIR = 'SegmentationClass' - INSTANCES_DIR = 'SegmentationObject' - SUBSETS_DIR = 'ImageSets' - IMAGE_EXT = '.jpg' - SEGM_EXT = '.png' - LABELMAP_FILE = 'labelmap.txt' - - TASK_DIR = { - VocTask.classification: 'Main', - VocTask.detection: 'Main', - VocTask.segmentation: 'Segmentation', - VocTask.action_classification: 'Action', - VocTask.person_layout: 'Layout', - } - - -def make_voc_label_map(): - labels = sorted(VocLabel, key=lambda l: l.value) - label_map = OrderedDict( - (label.name, [VocColormap[label.value], [], []]) for label in labels) - label_map[VocLabel.person.name][1] = [p.name for p in VocBodyPart] - label_map[VocLabel.person.name][2] = [a.name for a in VocAction] - return label_map - -def parse_label_map(path): - if not path: - return None - - label_map = OrderedDict() - with open(path, 'r') as f: - for line in f: - # skip empty and commented lines - line = line.strip() - if not line or line and line[0] == '#': - continue - - # name, color, parts, actions - label_desc = line.strip().split(':') - name = label_desc[0] - - if name in label_map: - raise ValueError("Label '%s' is already defined" % name) - - if 1 < len(label_desc) and len(label_desc[1]) != 0: - color = label_desc[1].split(',') - assert len(color) == 3, \ - "Label '%s' has wrong color, expected 'r,g,b', got '%s'" % \ - (name, color) - color = tuple([int(c) for c in color]) - else: - color = None - - if 2 < len(label_desc) and len(label_desc[2]) != 0: - parts = label_desc[2].split(',') - else: - parts = [] - - if 3 < len(label_desc) and len(label_desc[3]) != 0: - actions = label_desc[3].split(',') - else: - actions = [] - - label_map[name] = [color, parts, actions] - return label_map - -def write_label_map(path, label_map): - with open(path, 'w') as f: - f.write('# label:color_rgb:parts:actions\n') - for label_name, label_desc in label_map.items(): - if label_desc[0]: - color_rgb = ','.join(str(c) for c in label_desc[0]) - else: - color_rgb = '' - - parts = ','.join(str(p) for p in label_desc[1]) - actions = ','.join(str(a) for a in label_desc[2]) - - f.write('%s\n' % ':'.join([label_name, color_rgb, parts, actions])) - -def make_voc_categories(label_map=None): - if label_map is None: - label_map = make_voc_label_map() - - categories = {} - - label_categories = LabelCategories() - label_categories.attributes.update(['difficult', 'truncated', 'occluded']) - - for label, desc in label_map.items(): - label_categories.add(label, attributes=desc[2]) - for part in OrderedDict((k, None) for k in chain( - *(desc[1] for desc in label_map.values()))): - label_categories.add(part) - categories[AnnotationType.label] = label_categories - - has_colors = any(v[0] is not None for v in label_map.values()) - if not has_colors: # generate new colors - colormap = generate_colormap(len(label_map)) - else: # only copy defined colors - label_id = lambda label: label_categories.find(label)[0] - colormap = { label_id(name): desc[0] - for name, desc in label_map.items() if desc[0] is not None } - mask_categories = MaskCategories(colormap) - mask_categories.inverse_colormap # pylint: disable=pointless-statement - categories[AnnotationType.mask] = mask_categories - - return categories diff --git a/datumaro/datumaro/plugins/voc_format/importer.py b/datumaro/datumaro/plugins/voc_format/importer.py deleted file mode 100644 index e9354e6c..00000000 --- a/datumaro/datumaro/plugins/voc_format/importer.py +++ /dev/null @@ -1,56 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from glob import glob -import os.path as osp - -from datumaro.components.extractor import Importer - -from .format import VocTask, VocPath - - -class VocImporter(Importer): - _TASKS = [ - (VocTask.classification, 'voc_classification', 'Main'), - (VocTask.detection, 'voc_detection', 'Main'), - (VocTask.segmentation, 'voc_segmentation', 'Segmentation'), - (VocTask.person_layout, 'voc_layout', 'Layout'), - (VocTask.action_classification, 'voc_action', 'Action'), - ] - - @classmethod - def detect(cls, path): - return len(cls.find_subsets(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - subset_paths = self.find_subsets(path) - if len(subset_paths) == 0: - raise Exception("Failed to find 'voc' dataset at '%s'" % path) - - for task, extractor_type, subset_path in subset_paths: - project.add_source('%s-%s' % - (task.name, osp.splitext(osp.basename(subset_path))[0]), - { - 'url': subset_path, - 'format': extractor_type, - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_subsets(path): - subset_paths = [] - for task, extractor_type, task_dir in __class__._TASKS: - task_dir = osp.join(path, VocPath.SUBSETS_DIR, task_dir) - if not osp.isdir(task_dir): - continue - task_subsets = [p for p in glob(osp.join(task_dir, '*.txt')) - if '_' not in osp.basename(p)] - subset_paths += [(task, extractor_type, p) for p in task_subsets] - return subset_paths diff --git a/datumaro/datumaro/plugins/yolo_format/__init__.py b/datumaro/datumaro/plugins/yolo_format/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/datumaro/plugins/yolo_format/converter.py b/datumaro/datumaro/plugins/yolo_format/converter.py deleted file mode 100644 index 9217c774..00000000 --- a/datumaro/datumaro/plugins/yolo_format/converter.py +++ /dev/null @@ -1,108 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import logging as log -import os -import os.path as osp -from collections import OrderedDict - -from datumaro.components.converter import Converter -from datumaro.components.extractor import AnnotationType - -from .format import YoloPath - - -def _make_yolo_bbox(img_size, box): - # https://github.com/pjreddie/darknet/blob/master/scripts/voc_label.py - # - values relative to width and height of image - # - are center of rectangle - x = (box[0] + box[2]) / 2 / img_size[0] - y = (box[1] + box[3]) / 2 / img_size[1] - w = (box[2] - box[0]) / img_size[0] - h = (box[3] - box[1]) / img_size[1] - return x, y, w, h - -class YoloConverter(Converter): - # https://github.com/AlexeyAB/darknet#how-to-train-to-detect-your-custom-objects - DEFAULT_IMAGE_EXT = '.jpg' - - def apply(self): - extractor = self._extractor - save_dir = self._save_dir - - os.makedirs(save_dir, exist_ok=True) - - label_categories = extractor.categories()[AnnotationType.label] - label_ids = {label.name: idx - for idx, label in enumerate(label_categories.items)} - with open(osp.join(save_dir, 'obj.names'), 'w') as f: - f.writelines('%s\n' % l[0] - for l in sorted(label_ids.items(), key=lambda x: x[1])) - - subset_lists = OrderedDict() - - for subset_name in extractor.subsets() or [None]: - if subset_name and subset_name in YoloPath.SUBSET_NAMES: - subset = extractor.get_subset(subset_name) - elif not subset_name: - subset_name = YoloPath.DEFAULT_SUBSET_NAME - subset = extractor - else: - log.warn("Skipping subset export '%s'. " - "If specified, the only valid names are %s" % \ - (subset_name, ', '.join( - "'%s'" % s for s in YoloPath.SUBSET_NAMES))) - continue - - subset_dir = osp.join(save_dir, 'obj_%s_data' % subset_name) - os.makedirs(subset_dir, exist_ok=True) - - image_paths = OrderedDict() - - for item in subset: - if not item.has_image: - raise Exception("Failed to export item '%s': " - "item has no image info" % item.id) - height, width = item.image.size - - image_name = self._make_image_filename(item) - if self._save_images: - if item.has_image and item.image.has_data: - self._save_image(item, osp.join(subset_dir, image_name)) - else: - log.warning("Item '%s' has no image" % item.id) - image_paths[item.id] = osp.join('data', - osp.basename(subset_dir), image_name) - - yolo_annotation = '' - for bbox in item.annotations: - if bbox.type is not AnnotationType.bbox: - continue - if bbox.label is None: - continue - - yolo_bb = _make_yolo_bbox((width, height), bbox.points) - yolo_bb = ' '.join('%.6f' % p for p in yolo_bb) - yolo_annotation += '%s %s\n' % (bbox.label, yolo_bb) - - annotation_path = osp.join(subset_dir, '%s.txt' % item.id) - os.makedirs(osp.dirname(annotation_path), exist_ok=True) - with open(annotation_path, 'w') as f: - f.write(yolo_annotation) - - subset_list_name = '%s.txt' % subset_name - subset_lists[subset_name] = subset_list_name - with open(osp.join(save_dir, subset_list_name), 'w') as f: - f.writelines('%s\n' % s for s in image_paths.values()) - - with open(osp.join(save_dir, 'obj.data'), 'w') as f: - f.write('classes = %s\n' % len(label_ids)) - - for subset_name, subset_list_name in subset_lists.items(): - f.write('%s = %s\n' % (subset_name, - osp.join('data', subset_list_name))) - - f.write('names = %s\n' % osp.join('data', 'obj.names')) - f.write('backup = backup/\n') diff --git a/datumaro/datumaro/plugins/yolo_format/extractor.py b/datumaro/datumaro/plugins/yolo_format/extractor.py deleted file mode 100644 index c8c39c42..00000000 --- a/datumaro/datumaro/plugins/yolo_format/extractor.py +++ /dev/null @@ -1,201 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict -import os.path as osp -import re - -from datumaro.components.extractor import (SourceExtractor, Extractor, - DatasetItem, AnnotationType, Bbox, LabelCategories -) -from datumaro.util import split_path -from datumaro.util.image import Image - -from .format import YoloPath - - -class YoloExtractor(SourceExtractor): - class Subset(Extractor): - def __init__(self, name, parent): - super().__init__() - self._name = name - self._parent = parent - self.items = OrderedDict() - - def __iter__(self): - for item_id in self.items: - yield self._parent._get(item_id, self._name) - - def __len__(self): - return len(self.items) - - def categories(self): - return self._parent.categories() - - def __init__(self, config_path, image_info=None): - super().__init__() - - if not osp.isfile(config_path): - raise Exception("Can't read dataset descriptor file '%s'" % - config_path) - - rootpath = osp.dirname(config_path) - self._path = rootpath - - assert image_info is None or isinstance(image_info, (str, dict)) - if image_info is None: - image_info = osp.join(rootpath, YoloPath.IMAGE_META_FILE) - if not osp.isfile(image_info): - image_info = {} - if isinstance(image_info, str): - if not osp.isfile(image_info): - raise Exception("Can't read image meta file '%s'" % image_info) - with open(image_info) as f: - image_info = {} - for line in f: - image_name, h, w = line.strip().split() - image_info[image_name] = (int(h), int(w)) - self._image_info = image_info - - with open(config_path, 'r') as f: - config_lines = f.readlines() - - subsets = OrderedDict() - names_path = None - - for line in config_lines: - match = re.match(r'(\w+)\s*=\s*(.+)$', line) - if not match: - continue - - key = match.group(1) - value = match.group(2) - if key == 'names': - names_path = value - elif key in YoloPath.SUBSET_NAMES: - subsets[key] = value - else: - continue - - if not names_path: - raise Exception("Failed to parse labels path from '%s'" % \ - config_path) - - for subset_name, list_path in subsets.items(): - list_path = osp.join(self._path, self.localize_path(list_path)) - if not osp.isfile(list_path): - raise Exception("Not found '%s' subset list file" % subset_name) - - subset = YoloExtractor.Subset(subset_name, self) - with open(list_path, 'r') as f: - subset.items = OrderedDict( - (self.name_from_path(p), self.localize_path(p)) - for p in f - ) - subsets[subset_name] = subset - - self._subsets = subsets - - self._categories = { - AnnotationType.label: - self._load_categories( - osp.join(self._path, self.localize_path(names_path))) - } - - @staticmethod - def localize_path(path): - path = path.strip() - default_base = osp.join('data', '') - if path.startswith(default_base): # default path - path = path[len(default_base) : ] - return path - - @classmethod - def name_from_path(cls, path): - path = cls.localize_path(path) - parts = split_path(path) - if 1 < len(parts) and not osp.isabs(path): - # NOTE: when path is like [data/]/ - # drop everything but - # can be , so no just basename() - path = osp.join(*parts[1:]) - return osp.splitext(path)[0] - - def _get(self, item_id, subset_name): - subset = self._subsets[subset_name] - item = subset.items[item_id] - - if isinstance(item, str): - image_size = self._image_info.get(item_id) - image = Image(path=osp.join(self._path, item), size=image_size) - - anno_path = osp.splitext(image.path)[0] + '.txt' - annotations = self._parse_annotations(anno_path, image) - - item = DatasetItem(id=item_id, subset=subset_name, - image=image, annotations=annotations) - subset.items[item_id] = item - - return item - - @staticmethod - def _parse_annotations(anno_path, image): - lines = [] - with open(anno_path, 'r') as f: - for line in f: - line = line.strip() - if line: - lines.append(line) - - annotations = [] - if lines: - size = image.size # use image info as late as possible - if size is None: - raise Exception("Can't find image info for '%s'" % image.path) - image_height, image_width = size - for line in lines: - label_id, xc, yc, w, h = line.split() - label_id = int(label_id) - w = float(w) - h = float(h) - x = float(xc) - w * 0.5 - y = float(yc) - h * 0.5 - annotations.append(Bbox( - round(x * image_width, 1), round(y * image_height, 1), - round(w * image_width, 1), round(h * image_height, 1), - label=label_id - )) - - return annotations - - @staticmethod - def _load_categories(names_path): - label_categories = LabelCategories() - - with open(names_path, 'r') as f: - for label in f: - label_categories.add(label.strip()) - - return label_categories - - def categories(self): - return self._categories - - def __iter__(self): - for subset in self._subsets.values(): - for item in subset: - yield item - - def __len__(self): - length = 0 - for subset in self._subsets.values(): - length += len(subset) - return length - - def subsets(self): - return list(self._subsets) - - def get_subset(self, name): - return self._subsets[name] \ No newline at end of file diff --git a/datumaro/datumaro/plugins/yolo_format/format.py b/datumaro/datumaro/plugins/yolo_format/format.py deleted file mode 100644 index 02a07669..00000000 --- a/datumaro/datumaro/plugins/yolo_format/format.py +++ /dev/null @@ -1,11 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - - -class YoloPath: - DEFAULT_SUBSET_NAME = 'train' - SUBSET_NAMES = ['train', 'valid'] - - IMAGE_META_FILE = 'images.meta' \ No newline at end of file diff --git a/datumaro/datumaro/plugins/yolo_format/importer.py b/datumaro/datumaro/plugins/yolo_format/importer.py deleted file mode 100644 index a040ea4e..00000000 --- a/datumaro/datumaro/plugins/yolo_format/importer.py +++ /dev/null @@ -1,46 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from glob import glob -import logging as log -import os.path as osp - -from datumaro.components.extractor import Importer - - -class YoloImporter(Importer): - @classmethod - def detect(cls, path): - return len(cls.find_configs(path)) != 0 - - def __call__(self, path, **extra_params): - from datumaro.components.project import Project # cyclic import - project = Project() - - config_paths = self.find_configs(path) - if len(config_paths) == 0: - raise Exception("Failed to find 'yolo' dataset at '%s'" % path) - - for config_path in config_paths: - log.info("Found a dataset at '%s'" % config_path) - - source_name = '%s_%s' % ( - osp.basename(osp.dirname(config_path)), - osp.splitext(osp.basename(config_path))[0]) - project.add_source(source_name, { - 'url': config_path, - 'format': 'yolo', - 'options': dict(extra_params), - }) - - return project - - @staticmethod - def find_configs(path): - if path.endswith('.data') and osp.isfile(path): - config_paths = [path] - else: - config_paths = glob(osp.join(path, '**', '*.data'), recursive=True) - return config_paths \ No newline at end of file diff --git a/datumaro/datumaro/util/__init__.py b/datumaro/datumaro/util/__init__.py deleted file mode 100644 index 0a75756b..00000000 --- a/datumaro/datumaro/util/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import os -import os.path as osp -from itertools import islice - - -def find(iterable, pred=lambda x: True, default=None): - return next((x for x in iterable if pred(x)), default) - -def dir_items(path, ext, truncate_ext=False): - items = [] - for f in os.listdir(path): - ext_pos = f.rfind(ext) - if ext_pos != -1: - if truncate_ext: - f = f[:ext_pos] - items.append(f) - return items - -def split_path(path): - path = osp.normpath(path) - parts = [] - - while True: - path, part = osp.split(path) - if part: - parts.append(part) - else: - if path: - parts.append(path) - break - parts.reverse() - - return parts - -def cast(value, type_conv, default=None): - if value is None: - return default - try: - return type_conv(value) - except Exception: - return default - -def to_snake_case(s): - if not s: - return '' - - name = [s[0].lower()] - for idx, char in enumerate(s[1:]): - idx = idx + 1 - if char.isalpha() and char.isupper(): - prev_char = s[idx - 1] - if not (prev_char.isalpha() and prev_char.isupper()): - # avoid "HTML" -> "h_t_m_l" - name.append('_') - name.append(char.lower()) - else: - name.append(char) - return ''.join(name) - -def pairs(iterable): - a = iter(iterable) - return zip(a, a) - -def take_by(iterable, count): - """ - Returns elements from the input iterable by batches of N items. - ('abcdefg', 3) -> ['a', 'b', 'c'], ['d', 'e', 'f'], ['g'] - """ - - it = iter(iterable) - while True: - batch = list(islice(it, count)) - if len(batch) == 0: - break - - yield batch - -def str_to_bool(s): - t = s.lower() - if t in {'true', '1', 'ok', 'yes', 'y'}: - return True - elif t in {'false', '0', 'no', 'n'}: - return False - else: - raise ValueError("Can't convert value '%s' to bool" % s) - -def filter_dict(d, exclude_keys): - return { k: v for k, v in d.items() if k not in exclude_keys } \ No newline at end of file diff --git a/datumaro/datumaro/util/annotation_util.py b/datumaro/datumaro/util/annotation_util.py deleted file mode 100644 index 3daa313f..00000000 --- a/datumaro/datumaro/util/annotation_util.py +++ /dev/null @@ -1,212 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from itertools import groupby - -import numpy as np - -from datumaro.components.extractor import _Shape, Mask, AnnotationType, RleMask -from datumaro.util.mask_tools import mask_to_rle - - -def find_instances(instance_anns): - instance_anns = sorted(instance_anns, key=lambda a: a.group) - ann_groups = [] - for g_id, group in groupby(instance_anns, lambda a: a.group): - if not g_id: - ann_groups.extend(([a] for a in group)) - else: - ann_groups.append(list(group)) - - return ann_groups - -def find_group_leader(group): - return max(group, key=lambda x: x.get_area()) - -def _get_bbox(ann): - if isinstance(ann, (_Shape, Mask)): - return ann.get_bbox() - else: - return ann - -def max_bbox(annotations): - boxes = [_get_bbox(ann) for ann in annotations] - x0 = min((b[0] for b in boxes), default=0) - y0 = min((b[1] for b in boxes), default=0) - x1 = max((b[0] + b[2] for b in boxes), default=0) - y1 = max((b[1] + b[3] for b in boxes), default=0) - return [x0, y0, x1 - x0, y1 - y0] - -def mean_bbox(annotations): - le = len(annotations) - boxes = [_get_bbox(ann) for ann in annotations] - mlb = sum(b[0] for b in boxes) / le - mtb = sum(b[1] for b in boxes) / le - mrb = sum(b[0] + b[2] for b in boxes) / le - mbb = sum(b[1] + b[3] for b in boxes) / le - return [mlb, mtb, mrb - mlb, mbb - mtb] - -def softmax(x): - return np.exp(x) / sum(np.exp(x)) - -def nms(segments, iou_thresh=0.5): - """ - Non-maxima suppression algorithm. - """ - - indices = np.argsort([b.attributes['score'] for b in segments]) - ious = np.array([[iou(a, b) for b in segments] for a in segments]) - - predictions = [] - while len(indices) != 0: - i = len(indices) - 1 - pred_idx = indices[i] - to_remove = [i] - predictions.append(segments[pred_idx]) - for i, box_idx in enumerate(indices[:i]): - if iou_thresh < ious[pred_idx, box_idx]: - to_remove.append(i) - indices = np.delete(indices, to_remove) - - return predictions - -def bbox_iou(a, b): - """ - IoU computations for simple cases with bounding boxes - """ - bbox_a = _get_bbox(a) - bbox_b = _get_bbox(b) - - aX, aY, aW, aH = bbox_a - bX, bY, bW, bH = bbox_b - in_right = min(aX + aW, bX + bW) - in_left = max(aX, bX) - in_top = max(aY, bY) - in_bottom = min(aY + aH, bY + bH) - - in_w = max(0, in_right - in_left) - in_h = max(0, in_bottom - in_top) - intersection = in_w * in_h - if not intersection: - return -1 - - a_area = aW * aH - b_area = bW * bH - union = a_area + b_area - intersection - return intersection / union - -def segment_iou(a, b): - """ - Generic IoU computation with masks, polygons, and boxes. - Returns -1 if no intersection, [0; 1] otherwise - """ - from pycocotools import mask as mask_utils - - a_bbox = a.get_bbox() - b_bbox = b.get_bbox() - - is_bbox = AnnotationType.bbox in [a.type, b.type] - if is_bbox: - a = [a_bbox] - b = [b_bbox] - else: - w = max(a_bbox[0] + a_bbox[2], b_bbox[0] + b_bbox[2]) - h = max(a_bbox[1] + a_bbox[3], b_bbox[1] + b_bbox[3]) - - def _to_rle(ann): - if ann.type == AnnotationType.polygon: - return mask_utils.frPyObjects([ann.points], h, w) - elif isinstance(ann, RleMask): - return [ann.rle] - elif ann.type == AnnotationType.mask: - return mask_utils.frPyObjects([mask_to_rle(ann.image)], h, w) - else: - raise TypeError("Unexpected arguments: %s, %s" % (a, b)) - a = _to_rle(a) - b = _to_rle(b) - return float(mask_utils.iou(a, b, [not is_bbox])) - -def PDJ(a, b, eps=None, ratio=0.05, bbox=None): - """ - Percentage of Detected Joints metric. - Counts the number of matching points. - """ - - assert eps is not None or ratio is not None - - p1 = np.array(a.points).reshape((-1, 2)) - p2 = np.array(b.points).reshape((-1, 2)) - if len(p1) != len(p2): - return 0 - - if not eps: - if bbox is None: - bbox = mean_bbox([a, b]) - - diag = (bbox[2] ** 2 + bbox[3] ** 2) ** 0.5 - eps = ratio * diag - - dists = np.linalg.norm(p1 - p2, axis=1) - return np.sum(dists < eps) / len(p1) - -def OKS(a, b, sigma=None, bbox=None, scale=None): - """ - Object Keypoint Similarity metric. - https://cocodataset.org/#keypoints-eval - """ - - p1 = np.array(a.points).reshape((-1, 2)) - p2 = np.array(b.points).reshape((-1, 2)) - if len(p1) != len(p2): - return 0 - - if not sigma: - sigma = 0.1 - else: - assert len(sigma) == len(p1) - - if not scale: - if bbox is None: - bbox = mean_bbox([a, b]) - scale = bbox[2] * bbox[3] - - dists = np.linalg.norm(p1 - p2, axis=1) - return np.sum(np.exp(-(dists ** 2) / (2 * scale * (2 * sigma) ** 2))) - -def smooth_line(points, segments): - assert 2 <= len(points) // 2 and len(points) % 2 == 0 - - if len(points) // 2 == segments: - return points - - points = list(points) - if len(points) == 2: - points.extend(points) - points = np.array(points).reshape((-1, 2)) - - lengths = np.linalg.norm(points[1:] - points[:-1], axis=1) - dists = [0] - for l in lengths: - dists.append(dists[-1] + l) - - step = dists[-1] / segments - - new_points = np.zeros((segments + 1, 2)) - new_points[0] = points[0] - - old_segment = 0 - for new_segment in range(1, segments + 1): - pos = new_segment * step - while dists[old_segment + 1] < pos and old_segment + 2 < len(dists): - old_segment += 1 - - segment_start = dists[old_segment] - segment_len = lengths[old_segment] - prev_p = points[old_segment] - next_p = points[old_segment + 1] - r = (pos - segment_start) / segment_len - - new_points[new_segment] = prev_p * (1 - r) + next_p * r - - return new_points, step diff --git a/datumaro/datumaro/util/attrs_util.py b/datumaro/datumaro/util/attrs_util.py deleted file mode 100644 index e631f35a..00000000 --- a/datumaro/datumaro/util/attrs_util.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import attr - -_NOTSET = object() - -def not_empty(inst, attribute, x): - assert len(x) != 0, x - -def default_if_none(conv): - def validator(inst, attribute, value): - default = attribute.default - if value is None: - if callable(default): - value = default() - elif isinstance(default, attr.Factory): - value = default.factory() - else: - value = default - elif not isinstance(value, attribute.type or conv): - value = conv(value) - setattr(inst, attribute.name, value) - return validator - -def ensure_cls(c): - def converter(arg): - if isinstance(arg, c): - return arg - else: - return c(**arg) - return converter \ No newline at end of file diff --git a/datumaro/datumaro/util/command_targets.py b/datumaro/datumaro/util/command_targets.py deleted file mode 100644 index 50c854f2..00000000 --- a/datumaro/datumaro/util/command_targets.py +++ /dev/null @@ -1,113 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -from enum import Enum - -from datumaro.components.project import Project -from datumaro.util.image import load_image - - -TargetKinds = Enum('TargetKinds', - ['project', 'source', 'external_dataset', 'inference', 'image']) - -def is_project_name(value, project): - return value == project.config.project_name - -def is_project_path(value): - if value: - try: - Project.load(value) - return True - except Exception: - pass - return False - -def is_project(value, project=None): - if is_project_path(value): - return True - elif project is not None: - return is_project_name(value, project) - - return False - -def is_source(value, project=None): - if project is not None: - try: - project.get_source(value) - return True - except KeyError: - pass - - return False - -def is_external_source(value): - return False - -def is_inference_path(value): - return False - -def is_image_path(value): - try: - return load_image(value) is not None - except Exception: - return False - - -class Target: - def __init__(self, kind, test, is_default=False, name=None): - self.kind = kind - self.test = test - self.is_default = is_default - self.name = name - - def _get_fields(self): - return [self.kind, self.test, self.is_default, self.name] - - def __str__(self): - return self.name or str(self.kind) - - def __len__(self): - return len(self._get_fields()) - - def __iter__(self): - return iter(self._get_fields()) - -def ProjectTarget(kind=TargetKinds.project, test=None, - is_default=False, name='project name or path', - project=None): - if test is None: - test = lambda v: is_project(v, project=project) - return Target(kind, test, is_default, name) - -def SourceTarget(kind=TargetKinds.source, test=None, - is_default=False, name='source name', - project=None): - if test is None: - test = lambda v: is_source(v, project=project) - return Target(kind, test, is_default, name) - -def ExternalDatasetTarget(kind=TargetKinds.external_dataset, - test=is_external_source, - is_default=False, name='external dataset path'): - return Target(kind, test, is_default, name) - -def InferenceTarget(kind=TargetKinds.inference, test=is_inference_path, - is_default=False, name='inference path'): - return Target(kind, test, is_default, name) - -def ImageTarget(kind=TargetKinds.image, test=is_image_path, - is_default=False, name='image path'): - return Target(kind, test, is_default, name) - - -def target_selector(*targets): - def selector(value): - for (kind, test, is_default, _) in targets: - if (is_default and (value == '' or value is None)) or test(value): - return (kind, value) - raise argparse.ArgumentTypeError('Value should be one of: %s' \ - % (', '.join([str(t) for t in targets]))) - return selector diff --git a/datumaro/datumaro/util/image.py b/datumaro/datumaro/util/image.py deleted file mode 100644 index 626d8499..00000000 --- a/datumaro/datumaro/util/image.py +++ /dev/null @@ -1,246 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# pylint: disable=unused-import - -from enum import Enum -from io import BytesIO -import numpy as np -import os -import os.path as osp - -_IMAGE_BACKENDS = Enum('_IMAGE_BACKENDS', ['cv2', 'PIL']) -_IMAGE_BACKEND = None -try: - import cv2 - _IMAGE_BACKEND = _IMAGE_BACKENDS.cv2 -except ImportError: - import PIL - _IMAGE_BACKEND = _IMAGE_BACKENDS.PIL - -from datumaro.util.image_cache import ImageCache as _ImageCache - - -def load_image(path): - """ - Reads an image in the HWC Grayscale/BGR(A) float [0; 255] format. - """ - - if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: - import cv2 - image = cv2.imread(path, cv2.IMREAD_UNCHANGED) - image = image.astype(np.float32) - elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: - from PIL import Image - image = Image.open(path) - image = np.asarray(image, dtype=np.float32) - if len(image.shape) == 3 and image.shape[2] in {3, 4}: - image[:, :, :3] = image[:, :, 2::-1] # RGB to BGR - else: - raise NotImplementedError() - - assert len(image.shape) in {2, 3} - if len(image.shape) == 3: - assert image.shape[2] in {3, 4} - return image - -def save_image(path, image, create_dir=False, **kwargs): - # NOTE: Check destination path for existence - # OpenCV silently fails if target directory does not exist - dst_dir = osp.dirname(path) - if dst_dir: - if create_dir: - os.makedirs(dst_dir, exist_ok=True) - elif not osp.isdir(dst_dir): - raise FileNotFoundError("Directory does not exist: '%s'" % dst_dir) - - if not kwargs: - kwargs = {} - - if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: - import cv2 - - params = [] - - ext = path[-4:] - if ext.upper() == '.JPG': - params = [ - int(cv2.IMWRITE_JPEG_QUALITY), kwargs.get('jpeg_quality', 75) - ] - - image = image.astype(np.uint8) - cv2.imwrite(path, image, params=params) - elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: - from PIL import Image - - params = {} - params['quality'] = kwargs.get('jpeg_quality') - if kwargs.get('jpeg_quality') == 100: - params['subsampling'] = 0 - - image = image.astype(np.uint8) - if len(image.shape) == 3 and image.shape[2] in {3, 4}: - image[:, :, :3] = image[:, :, 2::-1] # BGR to RGB - image = Image.fromarray(image) - image.save(path, **params) - else: - raise NotImplementedError() - -def encode_image(image, ext, **kwargs): - if not kwargs: - kwargs = {} - - if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: - import cv2 - - params = [] - - if not ext.startswith('.'): - ext = '.' + ext - - if ext.upper() == '.JPG': - params = [ - int(cv2.IMWRITE_JPEG_QUALITY), kwargs.get('jpeg_quality', 75) - ] - - image = image.astype(np.uint8) - success, result = cv2.imencode(ext, image, params=params) - if not success: - raise Exception("Failed to encode image to '%s' format" % (ext)) - return result.tobytes() - elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: - from PIL import Image - - if ext.startswith('.'): - ext = ext[1:] - - params = {} - params['quality'] = kwargs.get('jpeg_quality') - if kwargs.get('jpeg_quality') == 100: - params['subsampling'] = 0 - - image = image.astype(np.uint8) - if len(image.shape) == 3 and image.shape[2] in {3, 4}: - image[:, :, :3] = image[:, :, 2::-1] # BGR to RGB - image = Image.fromarray(image) - with BytesIO() as buffer: - image.save(buffer, format=ext, **params) - return buffer.getvalue() - else: - raise NotImplementedError() - -def decode_image(image_bytes): - if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: - import cv2 - image = np.frombuffer(image_bytes, dtype=np.uint8) - image = cv2.imdecode(image, cv2.IMREAD_UNCHANGED) - image = image.astype(np.float32) - elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: - from PIL import Image - image = Image.open(BytesIO(image_bytes)) - image = np.asarray(image, dtype=np.float32) - if len(image.shape) == 3 and image.shape[2] in {3, 4}: - image[:, :, :3] = image[:, :, 2::-1] # RGB to BGR - else: - raise NotImplementedError() - - assert len(image.shape) in {2, 3} - if len(image.shape) == 3: - assert image.shape[2] in {3, 4} - return image - - -class lazy_image: - def __init__(self, path, loader=None, cache=None): - if loader is None: - loader = load_image - self.path = path - self.loader = loader - - # Cache: - # - False: do not cache - # - None: use the global cache - # - object: an object to be used as cache - assert cache in {None, False} or isinstance(cache, object) - self.cache = cache - - def __call__(self): - image = None - image_id = hash(self) # path is not necessary hashable or a file path - - cache = self._get_cache(self.cache) - if cache is not None: - image = cache.get(image_id) - - if image is None: - image = self.loader(self.path) - if cache is not None: - cache.push(image_id, image) - return image - - @staticmethod - def _get_cache(cache): - if cache is None: - cache = _ImageCache.get_instance() - elif cache == False: - return None - return cache - - def __hash__(self): - return hash((id(self), self.path, self.loader)) - -class Image: - def __init__(self, data=None, path=None, loader=None, cache=None, - size=None): - assert size is None or len(size) == 2 - if size is not None: - assert len(size) == 2 and 0 < size[0] and 0 < size[1], size - size = tuple(size) - self._size = size # (H, W) - - assert path is None or isinstance(path, str) - if path is None: - path = '' - self._path = path - - assert data is not None or path or loader, "Image can not be empty" - if data is None and (path or loader): - if osp.isfile(path) or loader: - data = lazy_image(path, loader=loader, cache=cache) - self._data = data - - @property - def path(self): - return self._path - - @property - def data(self): - if callable(self._data): - return self._data() - return self._data - - @property - def has_data(self): - return self._data is not None - - @property - def size(self): - if self._size is None: - data = self.data - if data is not None: - self._size = data.shape[:2] - return self._size - - def __eq__(self, other): - if isinstance(other, np.ndarray): - return self.has_data and np.array_equal(self.data, other) - - if not isinstance(other, __class__): - return False - return \ - (np.array_equal(self.size, other.size)) and \ - (self.has_data == other.has_data) and \ - (self.has_data and np.array_equal(self.data, other.data) or \ - not self.has_data) \ No newline at end of file diff --git a/datumaro/datumaro/util/image_cache.py b/datumaro/datumaro/util/image_cache.py deleted file mode 100644 index 08f02582..00000000 --- a/datumaro/datumaro/util/image_cache.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from collections import OrderedDict - - -_instance = None - -DEFAULT_CAPACITY = 2 - -class ImageCache: - @staticmethod - def get_instance(): - global _instance - if _instance is None: - _instance = ImageCache() - return _instance - - def __init__(self, capacity=DEFAULT_CAPACITY): - self.capacity = int(capacity) - self.items = OrderedDict() - - def push(self, item_id, image): - if self.capacity <= len(self.items): - self.items.popitem(last=True) - self.items[item_id] = image - - def get(self, item_id): - default = object() - item = self.items.get(item_id, default) - if item is default: - return None - - self.items.move_to_end(item_id, last=False) # naive splay tree - return item - - def size(self): - return len(self.items) - - def clear(self): - self.items.clear() \ No newline at end of file diff --git a/datumaro/datumaro/util/log_utils.py b/datumaro/datumaro/util/log_utils.py deleted file mode 100644 index 6c8d8421..00000000 --- a/datumaro/datumaro/util/log_utils.py +++ /dev/null @@ -1,16 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from contextlib import contextmanager -import logging - -@contextmanager -def logging_disabled(max_level=logging.CRITICAL): - previous_level = logging.root.manager.disable - logging.disable(max_level) - try: - yield - finally: - logging.disable(previous_level) \ No newline at end of file diff --git a/datumaro/datumaro/util/mask_tools.py b/datumaro/datumaro/util/mask_tools.py deleted file mode 100644 index 95c8633a..00000000 --- a/datumaro/datumaro/util/mask_tools.py +++ /dev/null @@ -1,289 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import numpy as np - -from datumaro.util.image import lazy_image, load_image - - -def generate_colormap(length=256): - """ - Generates colors using PASCAL VOC algorithm. - - Returns index -> (R, G, B) mapping. - """ - - def get_bit(number, index): - return (number >> index) & 1 - - colormap = np.zeros((length, 3), dtype=int) - indices = np.arange(length, dtype=int) - - for j in range(7, -1, -1): - for c in range(3): - colormap[:, c] |= get_bit(indices, c) << j - indices >>= 3 - - return { - id: tuple(color) for id, color in enumerate(colormap) - } - -def invert_colormap(colormap): - return { - tuple(a): index for index, a in colormap.items() - } - -def check_is_mask(mask): - assert len(mask.shape) in {2, 3} - if len(mask.shape) == 3: - assert mask.shape[2] == 1 - -_default_colormap = generate_colormap() -_default_unpaint_colormap = invert_colormap(_default_colormap) - -def unpaint_mask(painted_mask, inverse_colormap=None): - # Covert color mask to index mask - - # mask: HWC BGR [0; 255] - # colormap: (R, G, B) -> index - assert len(painted_mask.shape) == 3 - if inverse_colormap is None: - inverse_colormap = _default_unpaint_colormap - - if callable(inverse_colormap): - map_fn = lambda a: inverse_colormap( - (a >> 16) & 255, (a >> 8) & 255, a & 255 - ) - else: - map_fn = lambda a: inverse_colormap[( - (a >> 16) & 255, (a >> 8) & 255, a & 255 - )] - - painted_mask = painted_mask.astype(int) - painted_mask = painted_mask[:, :, 0] + \ - (painted_mask[:, :, 1] << 8) + \ - (painted_mask[:, :, 2] << 16) - uvals, unpainted_mask = np.unique(painted_mask, return_inverse=True) - palette = np.array([map_fn(v) for v in uvals], dtype=np.float32) - unpainted_mask = palette[unpainted_mask].reshape(painted_mask.shape[:2]) - - return unpainted_mask - -def paint_mask(mask, colormap=None): - # Applies colormap to index mask - - # mask: HW(C) [0; max_index] mask - # colormap: index -> (R, G, B) - check_is_mask(mask) - - if colormap is None: - colormap = _default_colormap - if callable(colormap): - map_fn = colormap - else: - map_fn = lambda c: colormap.get(c, (-1, -1, -1)) - palette = np.array([map_fn(c)[::-1] for c in range(256)], dtype=np.float32) - - mask = mask.astype(np.uint8) - painted_mask = palette[mask].reshape((*mask.shape[:2], 3)) - return painted_mask - -def remap_mask(mask, map_fn): - # Changes mask elements from one colormap to another - - # mask: HW(C) [0; max_index] mask - check_is_mask(mask) - - return np.array([map_fn(c) for c in range(256)], dtype=np.uint8)[mask] - -def make_index_mask(binary_mask, index): - return np.choose(binary_mask, np.array([0, index], dtype=np.uint8)) - -def make_binary_mask(mask): - return np.nonzero(mask) - - -def load_mask(path, inverse_colormap=None): - mask = load_image(path) - mask = mask.astype(np.uint8) - if inverse_colormap is not None: - if len(mask.shape) == 3 and mask.shape[2] != 1: - mask = unpaint_mask(mask, inverse_colormap) - return mask - -def lazy_mask(path, inverse_colormap=None): - return lazy_image(path, lambda path: load_mask(path, inverse_colormap)) - -def mask_to_rle(binary_mask): - # walk in row-major order as COCO format specifies - bounded = binary_mask.ravel(order='F') - - # add borders to sequence - # find boundary positions for sequences and compute their lengths - difs = np.diff(bounded, prepend=[1 - bounded[0]], append=[1 - bounded[-1]]) - counts, = np.where(difs != 0) - - # start RLE encoding from 0 as COCO format specifies - if bounded[0] != 0: - counts = np.diff(counts, prepend=[0]) - else: - counts = np.diff(counts) - - return { - 'counts': counts, - 'size': list(binary_mask.shape) - } - -def mask_to_polygons(mask, tolerance=1.0, area_threshold=1): - """ - Convert an instance mask to polygons - - Args: - mask: a 2d binary mask - tolerance: maximum distance from original points of - a polygon to the approximated ones - area_threshold: minimal area of generated polygons - - Returns: - A list of polygons like [[x1,y1, x2,y2 ...], [...]] - """ - from pycocotools import mask as mask_utils - from skimage import measure - - polygons = [] - - # pad mask with 0 around borders - padded_mask = np.pad(mask, pad_width=1, mode='constant', constant_values=0) - contours = measure.find_contours(padded_mask, 0.5) - # Fix coordinates after padding - contours = np.subtract(contours, 1) - - for contour in contours: - if not np.array_equal(contour[0], contour[-1]): - contour = np.vstack((contour, contour[0])) # make polygon closed - - contour = measure.approximate_polygon(contour, tolerance) - if len(contour) <= 2: - continue - - contour = np.flip(contour, axis=1).flatten().clip(0) # [x0, y0, ...] - - # Check if the polygon is big enough - rle = mask_utils.frPyObjects([contour], mask.shape[0], mask.shape[1]) - area = sum(mask_utils.area(rle)) - if area_threshold <= area: - polygons.append(contour) - return polygons - -def crop_covered_segments(segments, width, height, - iou_threshold=0.0, ratio_tolerance=0.001, area_threshold=1, - return_masks=False): - """ - Find all segments occluded by others and crop them to the visible part only. - Input segments are expected to be sorted from background to foreground. - - Args: - segments: 1d list of segment RLEs (in COCO format) - width: width of the image - height: height of the image - iou_threshold: IoU threshold for objects to be counted as intersected - By default is set to 0 to process any intersected objects - ratio_tolerance: an IoU "handicap" value for a situation - when an object is (almost) fully covered by another one and we - don't want make a "hole" in the background object - area_threshold: minimal area of included segments - - Returns: - A list of input segments' parts (in the same order as input): - [ - [[x1,y1, x2,y2 ...], ...], # input segment #0 parts - mask1, # input segment #1 mask (if source segment is mask) - [], # when source segment is too small - ... - ] - """ - from pycocotools import mask as mask_utils - - segments = [[s] for s in segments] - input_rles = [mask_utils.frPyObjects(s, height, width) for s in segments] - - for i, rle_bottom in enumerate(input_rles): - area_bottom = sum(mask_utils.area(rle_bottom)) - if area_bottom < area_threshold: - segments[i] = [] if not return_masks else None - continue - - rles_top = [] - for j in range(i + 1, len(input_rles)): - rle_top = input_rles[j] - iou = sum(mask_utils.iou(rle_bottom, rle_top, [0, 0]))[0] - - if iou <= iou_threshold: - continue - - area_top = sum(mask_utils.area(rle_top)) - area_ratio = area_top / area_bottom - - # If a segment is fully inside another one, skip this segment - if abs(area_ratio - iou) < ratio_tolerance: - continue - - # Check if the bottom segment is fully covered by the top one. - # There is a mistake in the annotation, keep the background one - if abs(1 / area_ratio - iou) < ratio_tolerance: - rles_top = [] - break - - rles_top += rle_top - - if not rles_top and not isinstance(segments[i][0], dict) \ - and not return_masks: - continue - - rle_bottom = rle_bottom[0] - bottom_mask = mask_utils.decode(rle_bottom).astype(np.uint8) - - if rles_top: - rle_top = mask_utils.merge(rles_top) - top_mask = mask_utils.decode(rle_top).astype(np.uint8) - - bottom_mask -= top_mask - bottom_mask[bottom_mask != 1] = 0 - - if not return_masks and not isinstance(segments[i][0], dict): - segments[i] = mask_to_polygons(bottom_mask, - area_threshold=area_threshold) - else: - segments[i] = bottom_mask - - return segments - -def rles_to_mask(rles, width, height): - from pycocotools import mask as mask_utils - - rles = mask_utils.frPyObjects(rles, height, width) - rles = mask_utils.merge(rles) - mask = mask_utils.decode(rles) - return mask - -def find_mask_bbox(mask): - cols = np.any(mask, axis=0) - rows = np.any(mask, axis=1) - x0, x1 = np.where(cols)[0][[0, -1]] - y0, y1 = np.where(rows)[0][[0, -1]] - return [x0, y0, x1 - x0, y1 - y0] - -def merge_masks(masks): - """ - Merges masks into one, mask order is responsible for z order. - """ - if not masks: - return None - - merged_mask = masks[0] - for m in masks[1:]: - merged_mask = np.where(m != 0, m, merged_mask) - - return merged_mask \ No newline at end of file diff --git a/datumaro/datumaro/util/os_util.py b/datumaro/datumaro/util/os_util.py deleted file mode 100644 index b4d05e37..00000000 --- a/datumaro/datumaro/util/os_util.py +++ /dev/null @@ -1,17 +0,0 @@ - -# Copyright (C) 2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import subprocess - - -def check_instruction_set(instruction): - return instruction == str.strip( - # Let's ignore a warning from bandit about using shell=True. - # In this case it isn't a security issue and we use some - # shell features like pipes. - subprocess.check_output( - 'lscpu | grep -o "%s" | head -1' % instruction, - shell=True).decode('utf-8') # nosec - ) \ No newline at end of file diff --git a/datumaro/datumaro/util/test_utils.py b/datumaro/datumaro/util/test_utils.py deleted file mode 100644 index db2767db..00000000 --- a/datumaro/datumaro/util/test_utils.py +++ /dev/null @@ -1,121 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import inspect -import os -import os.path as osp -import shutil -import tempfile - -from datumaro.components.extractor import AnnotationType -from datumaro.util import find - - -def current_function_name(depth=1): - return inspect.getouterframes(inspect.currentframe())[depth].function - -class FileRemover: - def __init__(self, path, is_dir=False, ignore_errors=False): - self.path = path - self.is_dir = is_dir - self.ignore_errors = ignore_errors - - def __enter__(self): - return self.path - - # pylint: disable=redefined-builtin - def __exit__(self, type=None, value=None, traceback=None): - if self.is_dir: - shutil.rmtree(self.path, ignore_errors=self.ignore_errors) - else: - os.remove(self.path) - # pylint: enable=redefined-builtin - -class TestDir(FileRemover): - def __init__(self, path=None, ignore_errors=False): - if path is None: - path = osp.abspath('temp_%s-' % current_function_name(2)) - path = tempfile.mkdtemp(dir=os.getcwd(), prefix=path) - else: - os.makedirs(path, exist_ok=ignore_errors) - - super().__init__(path, is_dir=True, ignore_errors=ignore_errors) - -def compare_categories(test, expected, actual): - test.assertEqual( - sorted(expected, key=lambda t: t.value), - sorted(actual, key=lambda t: t.value) - ) - - if AnnotationType.label in expected: - test.assertEqual( - expected[AnnotationType.label].items, - actual[AnnotationType.label].items, - ) - if AnnotationType.mask in expected: - test.assertEqual( - expected[AnnotationType.mask].colormap, - actual[AnnotationType.mask].colormap, - ) - if AnnotationType.points in expected: - test.assertEqual( - expected[AnnotationType.points].items, - actual[AnnotationType.points].items, - ) - -def _compare_annotations(expected, actual, ignored_attrs=None): - if not ignored_attrs: - return expected == actual - - a_attr = expected.attributes - b_attr = actual.attributes - - expected.attributes = {k:v for k,v in a_attr.items() if k not in ignored_attrs} - actual.attributes = {k:v for k,v in b_attr.items() if k not in ignored_attrs} - r = expected == actual - - expected.attributes = a_attr - actual.attributes = b_attr - return r - -def compare_datasets(test, expected, actual, ignored_attrs=None): - compare_categories(test, expected.categories(), actual.categories()) - - test.assertEqual(sorted(expected.subsets()), sorted(actual.subsets())) - test.assertEqual(len(expected), len(actual)) - for item_a in expected: - item_b = find(actual, lambda x: x.id == item_a.id and \ - x.subset == item_a.subset) - test.assertFalse(item_b is None, item_a.id) - test.assertEqual(item_a.attributes, item_b.attributes) - test.assertEqual(len(item_a.annotations), len(item_b.annotations)) - for ann_a in item_a.annotations: - # We might find few corresponding items, so check them all - ann_b_matches = [x for x in item_b.annotations - if x.type == ann_a.type] - test.assertFalse(len(ann_b_matches) == 0, 'ann id: %s' % ann_a.id) - - ann_b = find(ann_b_matches, lambda x: - _compare_annotations(x, ann_a, ignored_attrs=ignored_attrs)) - if ann_b is None: - test.fail('ann %s, candidates %s' % (ann_a, ann_b_matches)) - item_b.annotations.remove(ann_b) # avoid repeats - -def compare_datasets_strict(test, expected, actual): - # Compares datasets for strong equality - - test.assertEqual(expected.categories(), actual.categories()) - - test.assertListEqual(sorted(expected.subsets()), sorted(actual.subsets())) - test.assertEqual(len(expected), len(actual)) - - for subset_name in expected.subsets(): - e_subset = expected.get_subset(subset_name) - a_subset = actual.get_subset(subset_name) - test.assertEqual(len(e_subset), len(a_subset)) - for idx, (item_a, item_b) in enumerate(zip(e_subset, a_subset)): - test.assertEqual(item_a, item_b, - '%s:\n%s\nvs.\n%s\n' % \ - (idx, item_a, item_b)) \ No newline at end of file diff --git a/datumaro/datumaro/util/tf_util.py b/datumaro/datumaro/util/tf_util.py deleted file mode 100644 index 9eda97ba..00000000 --- a/datumaro/datumaro/util/tf_util.py +++ /dev/null @@ -1,80 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - - -def check_import(): - # Workaround for checking import availability: - # Official TF builds include AVX instructions. Once we try to import, - # the program crashes. We raise an exception instead. - - import subprocess - import sys - - from .os_util import check_instruction_set - - result = subprocess.run([sys.executable, '-c', 'import tensorflow'], - timeout=60, - universal_newlines=True, # use text mode for output stream - stdout=subprocess.PIPE, stderr=subprocess.PIPE) # capture output - - if result.returncode != 0: - message = result.stderr - if not message: - message = "Can't import tensorflow. " \ - "Test process exit code: %s." % result.returncode - if not check_instruction_set('avx'): - # The process has probably crashed for AVX unavalability - message += " This is likely because your CPU does not " \ - "support AVX instructions, " \ - "which are required for tensorflow." - - raise ImportError(message) - -def import_tf(check=True): - import sys - - not_found = object() - tf = sys.modules.get('tensorflow', not_found) - if tf is None: - import tensorflow as tf # emit default error - elif tf is not not_found: - return tf - - # Reduce output noise, https://stackoverflow.com/questions/38073432/how-to-suppress-verbose-tensorflow-logging - import os - os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' - - if check: - try: - check_import() - except Exception: - sys.modules['tensorflow'] = None # prevent further import - raise - - import tensorflow as tf - - try: - tf.get_logger().setLevel('WARNING') - except AttributeError: - pass - try: - tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.WARN) - except AttributeError: - pass - - # Enable eager execution in early versions to unlock dataset operations - eager_enabled = False - try: - tf.compat.v1.enable_eager_execution() - eager_enabled = True - except AttributeError: - pass - try: - if not eager_enabled: - tf.enable_eager_execution() - except AttributeError: - pass - - return tf diff --git a/datumaro/datumaro/version.py b/datumaro/datumaro/version.py deleted file mode 100644 index 8589c063..00000000 --- a/datumaro/datumaro/version.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = '0.1.0' \ No newline at end of file diff --git a/datumaro/docs/cli_design.mm b/datumaro/docs/cli_design.mm deleted file mode 100644 index 0ff17cb2..00000000 --- a/datumaro/docs/cli_design.mm +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/datumaro/docs/design.md b/datumaro/docs/design.md deleted file mode 100644 index 528b2adf..00000000 --- a/datumaro/docs/design.md +++ /dev/null @@ -1,185 +0,0 @@ -# Datumaro - - - -## Table of contents - -- [Concept](#concept) -- [RC 1 vision](#rc-1-vision) - -## Concept - -Datumaro is: -- a tool to build composite datasets and iterate over them -- a tool to create and maintain datasets - - Version control of annotations and images - - Publication (with removal of sensitive information) - - Editing - - Joining and splitting - - Exporting, format changing - - Image preprocessing -- a dataset storage -- a tool to debug datasets - - A network can be used to generate - informative data subsets (e.g. with false-positives) - to be analyzed further - -### Requirements - -- User interfaces - - a library - - a console tool with visualization means -- Targets: single datasets, composite datasets, single images / videos -- Built-in support for well-known annotation formats and datasets: - CVAT, COCO, PASCAL VOC, Cityscapes, ImageNet -- Extensibility with user-provided components -- Lightweightness - it should be easy to start working with Datumaro - - Minimal dependency on environment and configuration - - It should be easier to use Datumaro than writing own code - for computation of statistics or dataset manipulations - -### Functionality and ideas - -- Blur sensitive areas on dataset images -- Dataset annotation filters, relabelling etc. -- Dataset augmentation -- Calculation of statistics: - - Mean & std, custom stats -- "Edit" command to modify annotations -- Versioning (for images, annotations, subsets, sources etc., comparison) -- Documentation generation -- Provision of iterators for user code -- Dataset downloading -- Dataset generation -- Dataset building (export in a specific format, indexation, statistics, documentation) -- Dataset exporting to other formats -- Dataset debugging (run inference, generate dataset slices, compute statistics) -- "Explainable AI" - highlight network attention areas ([paper](https://arxiv.org/abs/1901.04592)) - - Black-box approach - - Classification, Detection, Segmentation, Captioning - - White-box approach - -### Research topics - -- exploration of network prediction uncertainty (aka Bayessian approach) - Use case: explanation of network "quality", "stability", "certainty" -- adversarial attacks on networks -- dataset minification / reduction - Use case: removal of redundant information to reach the same network quality with lesser training time -- dataset expansion and filtration of additions - Use case: add only important data -- guidance for key frame selection for tracking ([paper](https://arxiv.org/abs/1903.11779)) - Use case: more effective annotation, better predictions - -## RC 1 vision - -In the first version Datumaro should be a project manager for CVAT. -It should only consume data from CVAT. The collected dataset -can be downloaded by user to be operated on with Datumaro CLI. - - -``` - User - | - v - +------------------+ - | CVAT | - +--------v---------+ +------------------+ +--------------+ - | Datumaro module | ----> | Datumaro project | <---> | Datumaro CLI | <--- User - +------------------+ +------------------+ +--------------+ -``` - - -### Interfaces - -- [x] Python API for user code - - [x] Installation as a package -- [x] A command-line tool for dataset manipulations - -### Features - -- Dataset format support (reading, writing) - - [x] Own format - - [x] CVAT - - [x] COCO - - [x] PASCAL VOC - - [x] YOLO - - [x] TF Detection API - - [ ] Cityscapes - - [ ] ImageNet - -- Dataset visualization (`show`) - - [ ] Ability to visualize a dataset - - [ ] with TensorBoard - -- Calculation of statistics for datasets - - [x] Pixel mean, std - - [x] Object counts (detection scenario) - - [x] Image-Class distribution (classification scenario) - - [x] Pixel-Class distribution (segmentation scenario) - - [ ] Image similarity clusters - - [ ] Custom statistics - -- Dataset building - - [x] Composite dataset building - - [x] Class remapping - - [x] Subset splitting - - [x] Dataset filtering (`extract`) - - [x] Dataset merging (`merge`) - - [ ] Dataset item editing (`edit`) - -- Dataset comparison (`diff`) - - [x] Annotation-annotation comparison - - [x] Annotation-inference comparison - - [x] Annotation quality estimation (for CVAT) - - Provide a simple method to check - annotation quality with a model and generate summary - -- Dataset and model debugging - - [x] Inference explanation (`explain`) - - [x] Black-box approach ([RISE paper](https://arxiv.org/abs/1806.07421)) - - [x] Ability to run a model on a dataset and read the results - -- CVAT-integration features - - [x] Task export - - [x] Datumaro project export - - [x] Dataset export - - [x] Original raw data (images, a video file) can be downloaded (exported) - together with annotations or just have links - on CVAT server (in future, support S3, etc) - - [x] Be able to use local files instead of remote links - - [ ] Specify cache directory - - [x] Use case "annotate for model training" - - create a task - - annotate - - export the task - - convert to a training format - - train a DL model - - [x] Use case "annotate - reannotate problematic images - merge" - - [x] Use case "annotate and estimate quality" - - create a task - - annotate - - estimate quality of annotations - -### Optional features - -- Dataset publishing - - [ ] Versioning (for annotations, subsets, sources, etc.) - - [ ] Blur sensitive areas on images - - [ ] Tracking of legal information - - [ ] Documentation generation - -- Dataset building - - [ ] Dataset minification / Extraction of the most representative subset - - Use case: generate low-precision calibration dataset - -- Dataset and model debugging - - [ ] Training visualization - - [ ] Inference explanation (`explain`) - - [ ] White-box approach - -### Properties - -- Lightweightness -- Modularity -- Extensibility diff --git a/datumaro/docs/developer_guide.md b/datumaro/docs/developer_guide.md deleted file mode 100644 index e2fd101d..00000000 --- a/datumaro/docs/developer_guide.md +++ /dev/null @@ -1,200 +0,0 @@ -## Basics - -The center part of the library is the `Dataset` class, which allows to iterate -over its elements. `DatasetItem`, an element of a dataset, represents a single -dataset entry with annotations - an image, video sequence, audio track etc. -It can contain only annotated data or meta information, only annotations, or -all of this. - -Basic library usage and data flow: - -```lang-none -Extractors -> Dataset -> Converter - | - Filtration - Transformations - Statistics - Merging - Inference - Quality Checking - Comparison - ... -``` - -1. Data is read (or produced) by one or many `Extractor`s and merged - into a `Dataset` -1. A dataset is processed in some way -1. A dataset is saved with a `Converter` - -Datumaro has a number of dataset and annotation features: -- iteration over dataset elements -- filtering of datasets and annotations by a custom criteria -- working with subsets (e.g. `train`, `val`, `test`) -- computing of dataset statistics -- comparison and merging of datasets -- various annotation operations - -```python -from datumaro.components.project import Environment - -# Import and save a dataset -env = Environment() -dataset = env.make_importer('voc')('src/dir').make_dataset() -env.converters.get('coco').convert(dataset, save_dir='dst/dir') -``` - -## Library contents - -### Dataset Formats - -Dataset reading is supported by `Extractor`s and `Importer`s: -- An `Extractor` produces a list of `DatasetItem`s corresponding -to the dataset. -- An `Importer` creates a project from the data source location. - -It is possible to add custom Extractors and Importers. To do this, you need -to put an `Extractor` and `Importer` implementations to a plugin directory. - -Dataset writing is supported by `Converter`s. -A Converter produces a dataset of a specific format from dataset items. -It is possible to add custom `Converter`s. To do this, you need to put a -Converter implementation script to a plugin directory. - -### Dataset Conversions ("Transforms") - -A `Transform` is a function for altering a dataset and producing a new one. -It can update dataset items, annotations, classes, and other properties. -A list of available transforms for dataset conversions can be extended by -adding a `Transform` implementation script into a plugin directory. - -### Model launchers - -A list of available launchers for model execution can be extended by -adding a `Launcher` implementation script into a plugin directory. - -## Plugins - -Datumaro comes with a number of built-in formats and other tools, -but it also can be extended by plugins. Plugins are optional components, -which dependencies are not installed by default. -In Datumaro there are several types of plugins, which include: -- `extractor` - produces dataset items from data source -- `importer` - recognizes dataset type and creates project -- `converter` - exports dataset to a specific format -- `transformation` - modifies dataset items or other properties -- `launcher` - executes models - -A plugin is a regular Python module. It must be present in a plugin directory: -- `/.datumaro/plugins` for project-specific plugins -- `/plugins` for global plugins - -A plugin can be used either via the `Environment` class instance, -or by regular module importing: - -```python -from datumaro.components.project import Environment, Project -from datumaro.plugins.yolo_format.converter import YoloConverter - -# Import a dataset -dataset = Environment().make_importer('voc')(src_dir).make_dataset() - -# Load an existing project, save the dataset in some project-specific format -project = Project.load('project/dir') -project.env.converters.get('custom_format').convert(dataset, save_dir=dst_dir) - -# Save the dataset in some built-in format -Environment().converters.get('yolo').convert(dataset, save_dir=dst_dir) -YoloConverter.convert(dataset, save_dir=dst_dir) -``` - -### Writing a plugin - -A plugin is a Python module with any name, which exports some symbols. -To export a symbol, inherit it from one of special classes: - -```python -from datumaro.components.extractor import Importer, SourceExtractor, Transform -from datumaro.components.launcher import Launcher -from datumaro.components.converter import Converter -``` - -The `exports` list of the module can be used to override default behaviour: -```python -class MyComponent1: ... -class MyComponent2: ... -exports = [MyComponent2] # exports only MyComponent2 -``` - -There is also an additional class to modify plugin appearance in command line: - -```python -from datumaro.components.cli_plugin import CliPlugin -``` - -#### Plugin example - - - -``` -datumaro/plugins/ -- my_plugin1/file1.py -- my_plugin1/file2.py -- my_plugin2.py -``` - - - -`my_plugin1/file2.py` contents: - -```python -from datumaro.components.extractor import Transform, CliPlugin -from .file1 import something, useful - -class MyTransform(Transform, CliPlugin): - NAME = "custom_name" # could be generated automatically - - """ - Some description. The text will be displayed in the command line output. - """ - - @classmethod - def build_cmdline_parser(cls, **kwargs): - parser = super().build_cmdline_parser(**kwargs) - parser.add_argument('-q', help="Very useful parameter") - return parser - - def __init__(self, extractor, q): - super().__init__(extractor) - self.q = q - - def transform_item(self, item): - return item -``` - -`my_plugin2.py` contents: - -```python -from datumaro.components.extractor import SourceExtractor - -class MyFormat: ... -class MyFormatExtractor(SourceExtractor): ... - -exports = [MyFormat] # explicit exports declaration -# MyFormatExtractor won't be exported -``` - -## Command-line - -Basically, the interface is divided on contexts and single commands. -Contexts are semantically grouped commands, related to a single topic or target. -Single commands are handy shorter alternatives for the most used commands -and also special commands, which are hard to be put into any specific context. -[Docker](https://www.docker.com/) is an example of similar approach. - -![cli-design-image](images/cli_design.png) - -- The diagram above was created with [FreeMind](http://freemind.sourceforge.net/wiki/index.php/Main_Page) - -Model-View-ViewModel (MVVM) UI pattern is used. - -![mvvm-image](images/mvvm.png) diff --git a/datumaro/docs/images/cli_design.png b/datumaro/docs/images/cli_design.png deleted file mode 100644 index f83b1430ec53a28546cd82d9c2d4b2031474d01d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35845 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 diff --git a/datumaro/docs/images/mvvm.png b/datumaro/docs/images/mvvm.png deleted file mode 100644 index 88257123ac7db353ec1919203576b3b29d37ee91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30318 zcmXt91yGyK)5o1sXmE;@0zq4xQV8x)f(0qXU5mQ}Demr2v`BHcKp|Lhr?_izw=ey_ z^JONJ$vt;_zm?nD+uJ7${7D8MhXMx)2?<|LR#FuS2}KME33&|*1JPo6VzrF;L30w9 zQ^!Jlys=C|5&yBj%4$0yA-(AN_eD-(eL;z6BzKn5a#pi9b9OUyG(~cAbK|hIvvx8z z{A$W!?`WR!SBL@$i55vtQcT@F^DxV;iOO{TS%irx>Lro*F7&1NF>O0DOMp5d`p55; z{{A8!gv36!TF6p_FNx6$n6bivDB`hth1W0gW`h8 zMAyRRN~$5LQZV!&s5zYK7Fx~f=Bli+lQs1Ff$Zn+%H$7y1WEgM2J)SLT{eFh?eUb3^Lb7~C=R8P*INxuJt0I`3&O z6X+tdF;=ksM&>}d!b~1efC-X?0kZVzG__>uT4-+A-U~62BVdqCiho1CryKgD%B+R9 zh(?7RIjR6-9E$;bcLM9Go_TemP)V4xc>O~_9t2w5fK3J^`5%w90Ir3|@x7_t{VyC+ z(YZf2T}aHcE6GmdyGQDJ9+&vjd4Ll1=s>jXlvBlqYGO+h*c9 zR7?yR8f$cv;aH;&0a@hDVe==0?WC!Ya{}Vt_R9a9t z8r*(;5}L09PbAOGQrXseAp0aW@BELnr9_9U;Qb!k)ZCPMA9(ekL|xRGjTE(b6=wNw zuq|0wVwMSf7W83oioHC>Grl+Gs2y)vy5}EhB{P)0bP-aNp>1aM6iz_%s~uG#xINPG zup02+*pzu{_p}L?QOs>-a7r!uiBRu`FikV=;ULuQlXTQdqe`GP|VpP$0jWX}DNmp{!2?Ysf z4gN_nz55A<63)NWnftATQ6&1iI^msZ$NE2*OMR}1W=vm#h;v=n{FrTLhB^JR5hBe+KY+{9Xj*1&J{`W{Qhr$0NaM{3K4BA}uC=j=P%!4~etM7mSmpZa7 z9?n0rqY~a=sr(H^Q{|zZ8Z~+3KWq{oVkg8yKX0)_{Rnb^Z2!ef!$?*x_d<5B z`{twZ>s~JB`5I}GqU!h(`Z12x326?!sRj9-7r}&QKm<_d)zf^yDLmKt7?WA)_NQ|i zawdk2+E&x)F*$qj%(Vu~8$Byp9aqcMr%viW$La>osMod#PMq1jRN9~;Xb{VFtzWTc z+<^;;Ib)8PRT9A)Ze>JM%9hu+uPB@B|H+(nub8K3GnO&BUeNImkNj=ey9g*Q*&@nB zh5SpHG~m9K9c{+$0b#=}p!4I6yjPn_Gp9;#K0Rk~IDRbFQ` znyB@l83`$>1Ww^x_ly|i6$x$C_I^~DT#37p$cfUb(|=}A$NVU37|QmlmCrJ>0{l9I z4R?eAhN)t3Y;b}62c;8z`Ng4qmKbPdIGNV}6lN1XlUqM%Z}-oB7%{% zUP1h`bI-K0Uk)KNsg)XBv=E`4)XtF&*Bt&^Jg*gK!^TRg& z+1l&AG=P142(RI@0?NC%j?+;zh;_GnE?IG0pv@$!{FrpLgC{R|k<54@oX!K+0LgUE zr{u(w`m66NxB!wCbJY_jtX7GPz<}_5qul*p9%@k%esJjn{KKG8Bqs)|*im;hKyA8G zjGdWcSPbe?>yT^5+AMJ8nX_2%9!`evuU4d>mtH=WudYPM=h&*AVq^)qD%RrX4$NAX z5rR?oPs+VoU8e-7a9%AOaU-Ud`8Rb90)N$riH1$~{J*ik zkzUtE_N+N1hDT z^zVgU8fUyu{t?-a&&Q<6jF4L2KdIFkRn~Gf?;Cm?*D%(L6kUt_ss2b zeDNzt5hw?K(ykholUi^U=Mdpw;{b#LG|SQIF=yd=$KwT@)aWDl*at zVhmRI%I6+7=D|t)f}_nmgHgn1Sq@rFfu#)Da1-B9;w<>0xaU`~iO+nW1|LMdh6)eZ z{i^0T(MogR5U-YS(8J7Ty+$uxRa~8gUgFB~moE`Fms2Q-oV=7On``LTF+*TaMd>Wq zO~+npq8y&ZY7HJ_KQEiR!Z+9wGr?;9MbZ2KG+uCmMd<#f@(Xw(&pL@}-LzV4qrCXYo4^M(Q8(LwPOr zTVhUEK;DLPD^Z=F%`?H+jX)cNzlOgCbdCIv4GI;$O}duf%daMZTb3BDbhjy$P9m5n z`jww4j1^}MYQ68PiB5Rl6KSjS6}ub}-+{sB9k5pDaDPX20M{V3^eMY0svqVyM8KGI;atg%3q9a1~@Q-2F_0{{4X-UF53XSV{6UK(Z0CroCC>*?y8 zR+T^TCTyvkMKY%*W4>*84aiE2pK+XH|04ws#e4gXI;4N(f zNS1Ln-wmVE+89aXsYE!=*caefn;x82%F5i}#aCw4MG|880&ef1>#)1H$=GbjO!eh;K^Y%6F`%Qp}|reaS`VfiFyBKIB)8 zQet6d#h`R3{c!XEuW=^!ZjW%; z{yEDFNsZ87V^J6;DmNc{rOS%i$zNoOjVoQMIivneX@|ChZv%w==b^UCVh-RWv_gB# zNZQ5j4bxS5z@)RJVJBf3t-{o?zwO{*C&6X+9~AA>Y>q=G>9nJj2wm7Yl=8=6lWowV zt1(!5z0-e(^(GTxgM8a;h|SO|Hn_h|q_gm*RX7~rFa6cSFegOwwwX;~EWcnWEskNA z2=I%?e?4a1ySB=gwV)t~O^g+}G5!T8=fxputOKC;m{i|40x%fMV8Q0d$tc22K>Va= zq=4|@&M$|M-hne0pCbe3|787QW+$%t5_t6yjhPJP_Pw1TWCZfLD{$7)kP$k$Oa|G zgf8LwDdV)cfli!Og?B%;2Xifaqm3AO=($q!?^B^n6HqBj0@p5SLaqG?@&ocmljEkH znet;9e7p^{NqPs^zqtDe^qvGT;69HQrT%`|TQQ8eEXg}oMEWFMx%6C#TSYWQ{y>;^ zA0y2lajwFDBVC}Iai@)oP8##>!H;|EzGk}nclQcI9BYZjtCOsfPt)emS1P#|8o;lD z5d*=UF&N9A{S(uVVlxiK!vD7SabI1NSu$8WcCT#U+$$0hAL?SWiY@iKUr;KGhub|I zOR7uO&9{9kPf;8Hz*k=-6j&ZB7rb0Y2MG`id6po31Od0#N3{s8FlWRdA;2$>*d|bm z3p9aOyvbi)@Rr$XAS=c4?xLA=k*nQwQqkl1Nz7ZHUeXi!tqjhA>Od_6?a**n#<2Y_ z!+34xf+2A*j1tD_ZzXeE(z4CN8C$qRB+zRT+O0>i)u-+iObb0V8$?)Zm}vHkO>+2y z5~v)tP4@KKoho&#=aeJ2p{dH#C;$mkFlS~$AytUp%_*h{n6b^E8F z|2_AFF~KR;NIIi2`MsA@>}XWJ1h2=TMAm%s=_uJ0!|{|la8R+X+sIT=lQUCQ$^!YM zP?up}X*ufm+W7Ga+1h$=l!;e(c)!6QWYI^xk_fHlNeKPnZ8fL>&8M@9W2(`Xy&$yb zv(2GACpX)GBOn8(FDW;Nct&HxwT@`Yw>(t0Vuvn8by;4%ETv?6*r7xk(#$7Sggy6q z#^bk)Sl-U}#Era?@qI?1|FrM!w$ppT?eU;Xfpl2<^hefpGk}c`*QWHbr*Rl1qOlk>5vb%u_AN2? zhO;wxKgNTXBlE3<%EFydI7x}Z*znVG_g$}rc$7sD12Cf8C`_NN|C9vhP^6Id0I3vB z_)Ybr*SL};2JRW5?o1tb z$<$(zjk=stCb=|V;+!9)3^tGNNYm{0g|Wg!#_csCq+AuIW)BDp`hSaerF1eb6(2rY zI4*yveoBGOhq+jjHarR@Id~i1o1z}K(`Z0_iKkmy2sAQAN^{%s2Zb`ibfZW~H*bv$ zBd1Yvv?+;AyxZQxhf2U3VFkwV(J~GiMYoZXa#Vf=pk8x2RhAKqj&E$T!;!7lfM4L3 z*Gy33@%ks6OJ+#|dd*tmH^xMV{&qa$y)|UQ#4#M#bZUe?{g1pE6JfWzjx?qIlQ{P} zLFMFrVs@XN&59D{k$M~3KH2i$Y4?Bkp~8vPE2>}T&q^AX7?a!ef=84=tmy>PPkx1P z3J>f9`QG^E356;46!Ae&e->S&b|@}%7!s+eK3g14kidmsDvGt;{`(c77q0-{;uxCUV{#u7qrdcp5&O$ZX z(?x3N7J&PPFTfY@AdqsgdBs;Fpd{7RnZf^6*O;OUE*n?L7xnnTd2)sFGbo`vOq}XT z&Rq}KC=^DWoov-S1+OEgRJAc6XSb(&Rr@a1<}UWfouz!tk~QED4^Mg_*x#{Pbg&sP zIp`O*m{WoC#KZWBrY1;Fo>QPxwRh*O^WOy{74rO8g%c_sKg|tQA#9CNz9Mt-=o*~w z6s+TCZ%#aPsp$s1+mOJ zi`vP2S^L}z??)#|F{D%N=KCdIHW0D~e0*SLX+7l-XTZObAXhCqEe~3}R0cPT3!#*Io)hwEj-nY$<+b!tLod*8JC(0uqc5o ziFeHX?bi&RAQPB}K{)XcB9S^W?RYAE3(xc=6{+Rey$elYXOv#iNe|iVKo#AIaRHKko)kLRPKoYqGkNdOZQJh@VknKOwq-ysY(<6M z%-Az|7!3Q8`2Ds1vRKDv`>r?tZ;bw<6g=1HpP`pd z@k7r1$AvXK*HXJs%U16m9#b;tb}eQJo7pbUKZKcbuyH7<2-K0$7Jy2UxwxLe zG~uXnSGfPr(IIBPtaj+){81u7QiEfD;I7ua+i+t?6Mbh6w$El65o1b!>yu?o z7UHgR&kHhnAFb3S_Ja`(k(@5?ac&>=CHciCKSzr3*LT%GT?Z6ICtcZ~R;9gVGfXM` zDvEK4NEYo+{uL$Z#DjEIk+3`ImX{AZl5c>WC;aU^R#Vg&&c$(D(h4G3lO5O3azTL; z&AT*&oJip6Lor9kzo%F{7Goqdq#gVhd8S`M)Hw1-vXTn={17-Zd5>Ber8ECw^8N#m zwLevU9z{F8cH~DIOvW)`gv_|qgv_(si4lWP37Td9?A zS(2iISsrJR@!U&|yAJ9k%J*3V(I(fAl%nLqLpzLFdyH2_-dR8V3>5M?ivr04%Ec<_ z4q|gQ1tVheREe~ecbMY*>_sNOC`uOc*8uYFasMEa0DbSt>&cqQFMYU=lxXBlM;w)Z z_m~nYo+A?6hip>242yH~WRr%Hw#0f8{Bvu~^2Rk}(DeP}p*d|~1h4N-MM_9bc7?og zd2M*%snJHJtH&@O*!G7ZBYAD2rg$6lT^#kdyGLATwyFx^L3aQZ=MzoXl%+PDpR!NaUc9_yR zks>7=g027A-JbBg_85Rp#6 zHFI zXOXqmt0s~rY>_>N`EOrI|4P9+5GWP7_b@r=%CVNv@J5w+5EBXc0Ht?%O^6-ym9+?` zwQ-iPIx_Qh;dKEju!^A3XEn#fAw*gp{?-8JD?ycZ$M?;_uQeaLpV<&7EE!nekZSD; z3cMGoM)2ivk>@F1nR_SRZgB^SI_dFaXoyc+HZq>V5=i81(1>;eN1Ed@ z*VY->!DbbW$!DyGUr^A{XHuO|y$Ah|x4lEmwjj?q_Ox2IVKXHIlDz?6zA7=q_yqpG zSH1!_Oh5U}BKI8Obfn`6FfXU%LhVSTzl_2I?=V2=|DdBv_zz*Tiid3d(_CGoFgWF6 z(!X%f=9@ygk@AL|4%X`o$^U`u)}0a-^)Jd_}l3$}8wzL1mWAZkzi*7nKnQRko~0XY8ru%j4z>y zg%C%UJvmS6s!tJH?~5pJKkOY?(^WZSiz5u~<9oatq?6By%zXX<32xToHSTZU<%%V($1^=jd~uO zo=gpQd+I^iyc0LQwRTGwWy!@g51`T@au4)V{AZH@!T0Xyh|lk$Ill}?!|Ch zMcRThy>B-RKnoTS>$A~8)OstBb2%1 zt5s$?wNQ%~_C6{ElTW-Wj55FFjdi}kz^8TFRl`-}i$D-qtDK5_GJ6rtCP&NdW0lfB zxw6^>>l(tW*v<~=VJF2w%&c;EgHOQB{TK<&Ed`X_bNPe(FDx4 zz>}#utMhPNGc_Z0>aq&D$X`*_GMlNQF;?7gf5)<(Y+|n7*H1Pn^n`JkpmmMK^$kzG zCBaoQ;4U_JX9?B-djadg0rC4UDvg4j`IVyDeUXWEh5{LYEjx^=&f`ibGbqWG1~d_I zy}R4pj>3+`|Jnbu#6!Z|ApBuuDezttTg>n?jbQ1k&W-ntOARGTOD#U%5` z$=&g+Vif?{ryYM#_6c0Pej(kr$~f4F9hzK>^XE9STm_!GT}`+aS(W9OIFPEh@vfk< z>);Gf0)e1Mqy3b&D=w#eL|OghT>GrG=F|>*Y2dSp3#0Mx16IBqM&b}>ZuzNZq`2`R zG<|h&Pq?e6c2E_=XUf{o4fE(&2+iY=-K>=sA&S>Z3oTdpU)RWhne)?PlVt*31 zz!-zrKzBg}x^z@st+^SJH^%$s#N7ne_gjb+DLhzTP}$qL&7wm74TVN}T4RT(!?F`6 zGm1_4@5|30UP0e|uUl-yqWNP9v)_HmKA+0BxM-ubh}W;8;SW>v#9rz556GAVfR&qAk?y*H^DB!iv`?8kIm5+_2)xE=v?qBG*2=) zy$l-LdfKu)^~+#vv28kqpt6z%Odnv_$J*kUl84Cxb{ zkL@SR>D3|BPU*+$cxcngubE(1GoMwrf-k0%V5-^B!x)N5wTFO3<)xVZz$UI?#yg&E)RiH7|e zs^b}QnRf*cDdM4D=UV+bl~Jaxh;fD1fWgQ7K%?rpombr81rFY81&dx~3c{ZNEb$~e z5DN1bIf_kl3#Y{1uFR`du)E2)&t)VHGU3WX%WLTBt7n50*OrRKj*Nq%VNMjK!={mz z1j*tIZ1J95+pwi5G6S9(qPG#0)MKGp=I>FIKyL9fC#BkEd5I58CF~5(a2Uu>eZvih zkmZI4yk^d1;_bR5XiD*IJzzbUos?d)_~1jDo}qwes;r*=$1Pw2C!=CXjurzvNKBj5 zg(K$phD{x-TH71VJ^ci~BYVqXbwk~a{#c3uXe2z<&)adV;P5+T4<96Xx8RAb8Om4m z`W9>N;PQjjx%XpAdn^OP!rGsH;VHIclo1ARWBW}<{v`9Cmo-Fa=_fJ8Jdj=N0$TFP z;fJ`3dKV+aIx?=&3iw(abY!)(CIeLR@k5zwvg54PrtUpSRMT4)d%e>ctRr$5y&l9T zQ_V($-T}qoW8|KVA7UgPoIa2T_vn=@xbeWtfV|_Wb_F?AtTtu;_O-+y@iLTlen^jOo?`&N8rSG$T-h#nWcQj8|on53Q3mxf|&-7mtKLarvOuxKlAAvil z->Z(a&M?obsUa4iF^e8wE%_-!fwme-3D(Z}^V{J!{(pS~GolJL|nt@w$cY>PCl zy%(;shpTVWS3(ahy7|w{$6tBU&xe2S9gqhfu@NlNP#!ZZbP^uOhR>rqjW<8yC+i$PinXG;* zrsi4KG%zZ^Id%3_DJ<*eY?OcJz(vgCFICglog1v^((H3J=fkxedB$=zk&G^gC!n$a~!{C`5&wv7KTeEhztpdb{q(c zDxlm%E*Z6mR$=8CWkUr&x?+6ujCIvV!+Z9VaR{u%`ZGr`XAwkv)_S)E13>f2QhtX| z;X4u??$8jA$jeq88*GZqwZx(kCNK7_;WF8uI=Di7zYs+k%IaRs`@L>jM*4ILV-CT# zzJ#$`e}b<+#Z*M7O1~qpp&3Z1Lwl{Sb-e{MpOKi)9{@Qn8DZmmQRB80>0KsE7={*9 zZYZo%N+#!q?P3%0togR{o^gH)vMhzIjH6i#M%=DP>F2pny*iQSJ0HyQuL{XL{_ zG&Wco-gQS@PX$t<9yBnK1d^kp)5wpXoq3srS)w1mp-O$oK?I5vqo%-fD_r#j1Gmiv zOH4Cb(y=+17eU@{&e#ac6$o~4^r-X2l%t5CtB9D`qPC*AU7b0~u@dI8=)BUeG1T6I zZZBo@hMbUc-GiQ)OH}Lc8Be_AT#Ax^tE+(twHa8wbxVtmhysMm69CInjib#{O~1k! zmMrpKu?0E>(kH7tf9)gwzczVzMW>me<+!<8DXuC3)H2~+7zJe^ddi$+e%z&+0sB8_ zg$Qn5)kynbehD!rI7>6^KT9@EbI1I0C=o{l5Z(-81La-*LPTKfQ*cJfI^Q+g8rL^@ zAc@90T^wV@_Ql8==Z!;AMeK!mBWUXZwum-g1UUW3XsTk_nZr*4->olKtC(t>@^-6(${bZ++?38v8t3x|J@gsTuE9y7V|*!PP%V&!UnTxmY00yG zyh#_l8L*Mqq3HGCTVX`D$M+sruFsX+cp)d2s80LB2&<4+QRlIzL*8JCt>t{6CM8vZ zGuss4;(ToTzLPfIvy?M-{fkFTew<_jc?L_}b-H%>95y zUCBSkujhO*3ltfeG0>B&w^JG)l>CDARzT_08Yar zuVaQ!h-$>XJWvf^KIsQ8cyWXj%%<0)D*N=ZFAp<*h3E;N|2r?b(r2Z9C4HB3DPzH) zLa@rrO^wq}BHrYZOQp=Hu;BMXiq)_sqKX9Eu>>oIZWr`e$75F zW5>+6d@4Fn6k9av?%vajO_o*y<~5bBE>xnN3@t4Yf6vh`-)frl&_)me$Zc0YOEOtu zcV}?9te=$SA}WxBbwDOgm774@XH-J&r5mk+dWqJ$Iqsi#(#q^|(8VUd>939>oIq=C=C-s7X@y+#slC3w{S-sIb7F zuvUy}x0BjBwGtp$>KBPwGTsZlh1h91UT;B}e_^(1iR%JU7Vs6w+9znuQPA}xP*VDZ9_B1=DJjQYHp z^9c4>U>wUYJv-z#v`Rn{I*<30wjyi#RqAWrzH-+eJztC!z%G$71LLKhAK^R!?lf%{ z+CR`Y{sPDvXH2PaupSB5tXAQYir??q#YEmbzV5{Jbd1b?e-5%WsHRTTPAYp{AK5l zxjOtA-&eQ4RHK-52PNkIs33XMm8Wh;u1|=*cZ}v8IpK;-=mQk+eKVNoEK;|;?b1>^ zC6&n!Bsq!F!Y2C=G~p{y4~Q)b@xs#g|6H#XEsu36jl;}QPqd)PbZs0yn`WWW3QZdC z^=4tQ<44u@?m)J|7YgIbENYOYp7f~8XDFf26XUJw2&gdx$p{=GAu4qT*yD#%+tSi+ zI%CA)a} zDy@*v6ZsA@(<7~JGXvR#BEVIT;_@-SsGPHTRV{g!21I1_Wm*#-B{$}n-(MkJGF3!M z{f#bHtg&lEAwy-^i4(eZqYMam`u?rZTFvkF+3HrtU+yUu3DzPX&ITF8fVyNQ z#}PRoNikfB@0UF$ZhqM{(eoc!;#NUj)lgZzw~)7|IFDu6$1?Z;xK1u7vQ?A>5_fzt zTCg%@bc`}3W)~*>!Vh=m&O315+Ld-;WC_6d%2OIP$uffMD=XN4^L2C3f>b>C2*Soj zhyFuHyk1DK+HiwNb}jTqBJkRe-|F$}+x?7sK2_%$9K9DZV$a3&3S(M4YXnlDb#36) z{*mBw&Yv;+c^^Cp2jD1b%2)?^;1K#u`-~$q#i-HhOXv2(RCjyRL{5k$`Z-&&#HlOg zcaw~*SdA+UXFPj}BWE3r9tX9}QM&88M@6 z!B8DdeZl-oV%{wO?Vk)Pp*_x<>uu;u^n!WCOGLYkb1#}RQ^cHLWk9nFob;Sva+dYG z6;N#cn4F+LM>2%J?SbBq`v`^Q`8CVKElFXY0U=H}uIVrv)D9_Oe6sd^lQ_7~UU>tCApv--`Em`@c6*R2yzuGtn31TM z$+^t{=~y8@lChQ*Bc|?I2YTzK3b|@gJ8zHd1@V{Pa#v1Cy3dUrJurnA?Y{K6X=Tps zJceDnNf0Om4mi@U?XMCdisHek{Ra1Um@I-?tQ#t$KNaG>bI-Yta}j93D*ISDnX6Xh zBN(3EpUk%#9lwKax9t&oryN+=?W+XP zdK)h3T)j#nz?<^F7G{xvhBLaVP3DP@z0 zaePENP}yYK+U2JcQGv)eI5$CXyP=2gMy+{1L*TE<$KZR35m_@l<(&{<%x7A8_B^b9Y)2GdWTxBR$#-GYfP1lUp1tGSf8b zg7rCpLyT0Tcr1jm9(Z%(t&>7ba{5LU?~}O7GmXS!7h^uQ7mn(SbkpdZ$yJ|sP(W&2 z^=#gAAO6{sEYFWo3FS1W*!V}Y*UvIhW@TF*tHS+}oo@y0U1d1Z<4UgQkH-@(+bFK| z35GCc+7Us5PM2kWVW!DDmUnSA43378l`7vrtF+FQ^GSCHllWpJaWNXV&2hn_?a{g} zxz=Vl)1MjOxyCiT)Af1jqK~{gcVOvN3}CjaZ`OTTN>r?A@dD;3oorkdLFTkpT)^zv zO%o9h%?(lM+l7!jD_xm`^+4aqn)eM#<@%^D%}*$6MX}T1<5$zj2` z^75mrG=H37zqm68%iqRaXdhjKo=U)9$u)u@rWq#V;2N#h&D?$Hi0UIiC}kY66Pcsb zv6$A6IVxWE<+wY$#g*^s$T~q>;>VIWBVV20%?DmQ2;=o~{EQiy6k#yQ3b~_M4jDld z2zmA&@qJTG#_$as+0daT5`|>-`FpAS9;e)ex?32w;g1LQctS`2hO-Up|8O{zu3{T3 zie?q%hFXtZr$e_BT;kdW?1+7K#jdR6DOG0`c6DBSIe&=%{$p2TB1qX4$Bk6jewQw(k6qG7+%^(&sdEU4n4<{Q$%_JR%jn%@uSilTVGiPwBts%{e=Fc$Kh zFGM#Q-|%Eel6Rkj`z7K{hsl&V?|GZmDMqn-x3e2_4;a-~xq7Z-2XMb|C$ZFxxw~n# zA)g)q2bd2!ngHoE;(aG>8r~uDTFmdItAOXSfE^;+3xRnib?(&A@4Z7xk=(I}YSat$ zXX=92l~6$X>v6T`nAmkdIDhy%N)0-Fn)KM(t4lV|?neu;1&4}HEd6QRiu1QWy`MAqFVBAi-jt`Ar?eAfp+4xDv`6-U=%U#q}(qkH4i_+HW=oa z-}}}k=2n_V^3I`ltB~iE)$OjU_8VnCJcw@^l?4?L0YvPhOTW^XV}PF7dQ;&Z7B6Tb zeK&>W{vHJ2Y#}iqwYP>JZQx1GsyaR>l+N`Pz;+H)0={oyJ1hST%E17MB)}OzNoxDZ zkq4HChL1{7g2GTJKKF7ZYVuIj3n|$Kk4e)%J8*BN=>`gUFpGUux#JND{(As5qZk63 zY$I&vd_1TTY51c#owaI-D};MK;)2&1g4?(9A8-QmNDU4FPqOe<1>bE)z>o^_5ZyZo zM-jiDC+FJoV?Q+d-)B#RG;TVY5Z~+Y(@uWmX=pV0@GWokhbI<2npel%Apv6WpS=$GF`aM)o#KT||gilYB>DHiV#W7Np^vevo z_K_p8D#)6SfMM4sak>9zANs7K2b`XN4#{&OJEZ)(h%{uv#9OKi+cbt};jMmj7Rm;{ z0`?%qL4ql$R^Q;>t>mcd$O-B*T-^4N{SD7fN4TBi^j9$h5!8|_wR*($Df*Qf$79EM z(8v-h>%(CI%=|zjphIVEXTzJRDo2{pJhAH5&2j?j|vVU}-FXF?AuyFTy0DFyRQ4m}l|Ry(58wSZ~7D50Fy>!?0Z$ zrUfj3q7bSn&gQt!<7qdM@Q17Xxgygs&I~=Rbd`G(zF|#yO$jBkjjo8DT&G+O(-Wng z32fd#>nz2)Z#)F1chiG_33;&WI7Q@be=vfV#JwN=^)sP9yxr{| z(S*S<5;NJ3&~DsU&$Bwee&|`T(?0Ndrbc@av#^utGM_UGUx#Q~Mf*}qRXzi591WL> z<5+jWI+N6NJ#Q#J%jCwfi6^H@e>T=pQlC0B2fW)4uA8B-P-MlLjdAT@X%vN&5Te?cI%xv3dzO+$y|dzToNhdQ7906Hy92T$=51}H2!*D zL>yF(eQ6Tgz^#pbTf=rhSNJ2jDdsI5zh%8VcmiLY05MS^o>Um-)&*zYnXDc*e)A@suaBOR?y4#$`!=)?|1*18$R=u36;b@$EnNHWo3woYl|?7K)y zbMe1bcJ*k%D6u(of078piAxJny&3K;Oly>uy(6nooVibMo&B=DtaX6Gl5!o$XX&C>_TBPfo9!vM2QD9+@LKCc6aX(uT!!inmH)+me` zlWrA(kP7$y~^*0Y+r)s_*9rho2fn);G-6`C}?&$pECEok>@3o*u& zQ-3x;tddcQF1;uZ8>CW{`un@-%!##hr-sSVi(-;BNSE8gbdD!s^kp<@^Y`E8;w0Za zKQl)$X2O>cuVI-bj0z)lf^4e`A#R{+_J9hDa*C%wHBg z?h~7;FaexkCk|f=l#UXLFxyGZWB1xay?GzI*E~oU+H(1&OP8H}dZj?R-5^}-Xmdkff(Sa47?5n6H>E;FV(UQVlKc#WT zfr+bFZ*F?Q*}5xtVM#l1ubsUA78 zejA|_%#X%2Yb$AC7C%xle1y3~Xn5Kd{Iahq0*Z3c8lWY3pgUHU{%4l7#36>Io*TQdu#J=LOKQu9oD2MOz_rP(ip*& zvb;4{d5r3Ty^QJ5JIP@3vI_F?i1KfMEZn$&^LL38)XGjd5z)6#{VC61_R9_}75TZ? zPoJ?&PIsNJBuy2>$-6okTVo)ZoftE(MQt6_T4R5T3yaz(tznsVRgh`%lO4Y~)Zs2f z7+tbLoB{D_8LU0=_{kUdD@ql?pv|t!jAX*-F?gfHvF|TSf%BEl5x%k28fap6ya4g? zrJBK^XKQ~9x^LM2aL|>H$h=Qae55!>?I!5cPt_tq***Ec#S>Qw0~}}9DJwz=8mL@T zIuoWLunu3|e)A5>o8Jx^(H}Dp@68J@m)1wgg>J|Vd0L=e z>+@UZJ0CIkzs+(nrV$nMG^ZPyAW`=vbbB*IyK)=1D|cu*NRz=oj_pkwbqupsY;+>t zv$pE{51>v`F8&xkZ$X{p&^a)-fg&3J(46x`gxI$u$B1rev&K4Yw0DmL5fng2e;1sU zx2{?I9KG|YKubE4nW*{uS-HAL`donJ#U-wZ?-NqhXG1xRvkDC@(B~UJ&OymmnqCrN ze;tre;G$?2jO-$dkByBoKg_0^HiuGl~_*%Ehg+Kkn~M(A$rEtj^mlJTAd(Ahpd?J8Blce{n@UVhrNky7|pZOSfG>eyywy< z8D=rLY-Y9{;wPF2)s{vSJ{C6Xmwc3!>RJuVwa@6Xq4D6YaP@Q$$4PdYM)vvhxa)PA-L4KD4hd5n@%AOf zxM?{``fTXe7L#Hw(l48NxIRKsjcS|W6w2c|wTg!>`C6fD3UMyXclQ*9KbwDCKz@lD zFQk3a2w0Z>TU-n_{N)uFygJSp?ePjn>4xR2KoV)PHyXNDP6*p!`v?!<&pG=hNqNC0 z%nA6kkLQiYO6QC(f?D5d*^)#jOGptIt>nxVotp}60H}U_<2KJ`QlTBmrj9DRwx@Ca ze?@(DTvgBWH(a{m(wCA>0qO1#1f*0tq#FbQ>F$t{?(PoBD+&l)y8BYn0s_+T9Q=L0 zuZO=l=j_hT&hF06?!0Gi&5LXmn)Z^-*-b<~QofFa6R`Fg%AM*K_1F@4JRa2Gq4?!$ z8*^BO=9+cD{nbSWK3V1yMq`TAUO^8dBkTZ$djQnqcYq~W7c@E&a7;Sx0i-Nj zq3y#->{ZABG%F1eZX9^g(W-}Urt;<-sRMboG#Oy&#lqpM#UekCCr|*YnfK(}(JLwA ze^{>4dLp(Ol3W$iP3=#>4BS*=moD95djbHnXuW)0rug(fQ?!H$+n(Lu@4`b*$kpL* zMaAS&wLP|AZ1iz6SL5#i0Qs&~Mxwo=L)k7oRZA zW0WTFI%RJ3aEga10La~E_4Vd~e}uM>ifX6k0!{yq(5NOIoyJ)tjqiT-Y=C zlty3=EJJum^W@PbtQT9K7ndvp(7QF3Czc>K0}{aeEmMYUs!&Vh0O2ov3x1(!v5 zf;#0%ls5qbO2(OrzW_u~zZ-=yz4(MgWUfH$VAAIlcg~hcAnsFE7gx1r5aTi!U_mAR z%wuwH>(kSqyf7TPE@ED`1K3PkwJN^-Py13wyocd|FfRh&UG_8{q;B*&Nju5_ zfT+!~l-I^R;En=NOe)W^NFGoBVF4YjxF!jYWlTn%pnn5Gf#)Bu__+E0p&6ILabBTFO0R)XM*e@Os`b*M+8)6G#{WL8LtTrQH(Y_IG-?xz z-mbgu?0<8{j#AEx&Is8)5qNSF?qOPf?x*r^q-!Ve#FV{cx`m!te$PZOitc&~F^d@t zeEm7+Gbz&279bnLp|3@?t)u~rf1Y55UMaL?Li!)xcQ>Bo0JuH-Xlm}nxkb|dV<5W= z<6Xjo4?ucK9$OlZWYF%A)kFsX^O&jB#DD~lSHs`C{n=2SBs{N#P6880rPY~x9WB=P zU+s($Armcghie{ZurtaDSs&km?oaI;=7T~J=|QENou?rn<;L}lJ#W4^{wF_$aWs33 zLo^@a;+_YJlpcSyK7>w`P7GKHACt)oZsN3g+k9T3NN2=8oF{?c?z{`Bt-Scx6r>LV zJ%wWTG98cekXl5={FGkhXvooVVm5x4{UUp)rR?w9KN%t~{tX2=aIKWD|2dI2wGPCG zcV(%@gRAiNu=m|Rn&!wS_;Jm={pVy!fl~>CqQ4(9;;ZD3M_)HdlY^F3yf*yMbl3r` z?H*qoNga~6dMbYRsgB6fhB3L}plrla0#tMSbC3Svn1`3XOb9D)MqGNIeu5}|VIABZ}p zIicA~q~YN(%kvR)3o}#Es(%Wb(O}tu(`$&oKSVhF1nGzLzh!m7gFKIw2I|D7M5}_# zJ2INkDo4I%Q_TL+AEJ5_k5uTIJe=lkA=q2}qDG$66T=8N-`LO5SLNAr0E)g@1~Oa_^TW|YJ7 zHsWdJcQ>D4(xNYr+cHi*{u!j)28vfi*xoOPWKdA!#MEw_eczR6yhKhu{gSn8wO-NM zk8qb(2Rgs~Cq!jk3?bCw=GW3upDP(i7lNIuz86C00U}IJe`$Rj#Be!M3JppSW2B4E z2M{as>6|AXYdme*1F6*A=lHg68Z78DwJ;=)9;oKCrTm|sS9n2+)B(%bW+=o{`?=L9e z*weey=DgrG>Up-4)fdo9eYV&a*F30gI67P>tKa5{XEpl~mkN;{v9!~UaHk84Cd1;Y zjVuu^kx~noVf5-Er^+}I0{wDTk&}zYiaRT#SEdzuZ}XeL?oAvZiGNt}#C+&>$gx9@ zO$vUO(5DM}*Za3nRA^Y}BQu}LniGBW2q;P!lc-wEMM2}xkIKIbJJuyhRgtwH4+*b8 zxzmIGRnm(YZe&jy&4yOwQJJLaMj%hko_+X!4)*J+++d<%eLc>E7uDPEpzDDbtsv3- z8$H!h(&RjgKnKj>7He=V)3`0_2XXOmru&@1^052?6+U2jp-JOwM zV5;EBKeI*NQ=ab--4VM{?}%B&w&O{L8@Mve z=1rX;9JWjAo3qtb0a4@<;ZYR76))0bOC-L?eAyl6_e6RdqT*3UwU0t3GWIA41O-MZ z{WScDw;=d?E>My-X({UK%LUySX>!&clpNaIHZ}Qb?A4U$5nj$xxFJp=Ya!3ttu_Z({Qfn&9&MgxY@xF}RV-R4x?d-;1Yvk31tiC7QgSJJE^_iZO0jBDEwLXs>b_=cdl(G#i9y*j5>s6=Ln zRVMX4R@EN1fFhnz{@E3i_Q%y9Iw-2swA(vs+Jg(E{@(a2289KmPc1M-u2m&CLTggW zq<(!9s%s%!NIz7Wv(w#s<27d%IUIZ)A%v84`;b>raoVBBZE zNZH{VDsKm0^Jy`@D!8gPmn=g0*>2Tn;rQ{Ub5c7==lSBL$j9>jO`O!ofb4>iVBQ(uf^6dsS)471L96yL%obsLB*%Pi@Y*zZ zK%@btlfrqXfjz2-!S1;2>C`Vlkx(N67!W{s_%n7}}J8iRv*h zgQAZgKP^XBK*F@_txkz5j?Cm61a)af((@18X+;Eq(3asmsr#zi$=tFB#4h#)H1V!} zGx<3A5w)ezZPqoJ_XsF#0#OR=(i1M?RlTSB`N$gcYqVun`Ua!58+!cPIv6X{If|H{ zmvqV0YrmKN%t2D?6Qr7g011=cHIDXaIn&Ak0Zr<9*2gHx?^hhP3z7}Sw@xh!DU4=6 zuB+7JZ zsuL}CWz(fkgI#fsmc|Ycl`RZa$}a#eX$9HZ#fy+v-mP2Bfz+OL#Qo$jUtm+LOnIih z@VF)HB3?uO0&JnYiY6Au^tl)?SDQrX0~&!aDkC(B&zWu|`s7g!>W!lv3((cCN^=V5 zNd9@}a7x+RjQ$pedo!bGk!t2Sn$^4)fFFlWK>By$_~6yBv_xh&AZ5woj|`6af=)Mf z1sK~Omq&ZGU(;x&HF~je7Ip0yPU%(wmPiZeoQ^XYYd1(VS2J3mQd1&~;WZ-*L~<1Zcl44ah_b!?v7|fy(R6>^461Lwmc?1< z`~0MY_X>aEIX9|$S(x2h;t2iMBn6I}M=)^Fijk9++r+Zs4VshOaf1S@H2&KSRF>S( zlvXfu9ZIoH&)k%7cEfEMC&EV#Thmi?M(B%j4#8v8B(Xz zNh+l=U%Q!*Am;vjmlbuzut#&zIe)8&vMO1RxGA8Cu_#ejiwL)~keI?%#{aoUf>(F@ zF?ygjG~s9Ln@DgIXNFwKraMW=*BFICifu<7(n$)EcUqGAg_G>MJCpYCO+eCNRq-4w z6Nopq-5c7lnIivXPqw?LqTv7l0H5JRpOR-9vI+8g`-}Ie067pOw7HG3SnjT!s05XT z0@r3!@f+SHdG_6#0T`;OFfte9?O|WBScn0H@ICSgeZ!J5HUeHD(I+hv^kVr_P8iS! z-Poo-Cv@$ReP6TAmFk?b%>*?uKqzxemzUu@ORY&j<^(-?o=!xL)FjGnPr;~WJvpH` zD-@!#8_Ex#r~>R-(tPH_{_q=+AX%v|uJ)&82Pm>Ssftlu2CKNR^dX&Nb2Aycu>B&N zlsq{NJdz-p|2-)(;MfEvVk`HzOg~yu1!7ieiq2*1n7d`_6Sij}FALmBoC+Y4u@U6F zB}N6Fah};NTRb>@rNZp>t>odq>OSlY*_BCrRsu5Lye>eRVHLLzdlvfqS~fgtDwcst ztP4TsYuxD(?yE*fA4h5^OAeEh0)l^0+Tg(qVb2e< z&fN%=W(-)qa3F^0OKnOV(Tz6>gs-zIDQ;qeVX@wcxhow|kU-{YGNa4Zx7eAsg zR&yIUX6pEgUtdmWsed(DmgwCJYIc`%N(MI(^y#D&p&E0tv>F16fJ2L6Oj$NxRG5t?_xHjF_)Y=T2u{;kFkdH87uiA2{`9!8OE!BcHv{D31uiB6%n2R)> zz}JJnEIh?Sn{#{vzb5;|N`|?A7a_M*-BeGH^9+3`G9%P`xwh9u$qrr}JBq-4sP;s}$5CYiG6bF@0meRymd1=@)}F!bI{su*>|CgUe$|Qq?Lt zQ7Ogt1M8Dhqc?@0ssL!am-MIN_L0;O@R(S=}6^hx%P=?9LgtLXx$;R8i#Ru#%axk1W-n(-Vg=(4TBNybd|dl*7B>^ zyYoDo`^#{Q#)}I^2=OO2pp$Jey`;Ub+1u{d7?&Sv*fw4&2ic0Zv-;~bEF|UGMZRC` z35=f##SA%$^8S?W5Dqa()#F&OfIK<&y)4XuGq2^>gQ8nJ5WWOA} zu->@l;BQ*mwDy2cvH7%#fIa9*D_6wXQWX3^S}|?M#dXMpQt~IDz;4VMDxC8cvy|>~N@{)- z`I?3JbNM)^jw}Z0_1223Dj%>~zlh|Zb;lf}@h{sK(A*?L>y!WB*yj4NeO%U&7E=p~ zI*@V765pMA)yO<=nQckr_1ZUOXq7${;ax$<&+MN`M3$)z?v;3jH|-`mb$?d5K{(OgkjuS1cjdx3yei@gaj;YQ3H6mBb z(8W;Js&?+87LK_ABht4F2;JrntuS2%wT}&Lv$2j+f4Xi4ezop z+0tvG__~;@D7f$Lj9d3HYAnBcMvtYOq78Dz>9uYwwO&&`9#%knaSb4&iwY&3vA!-% zUCQtaOsZHf4lc;W+CP$dJ~!d8eEXG}kJnicLM}!kSg?Dw zIAUjrM8p_<7m4Wg4boCUi}DiEu?+Dq52Tjz({As8o&vgv9{I7E{W5CTIUX=ykCbTi@zkcC)s91y5SD6diTewb14_3_2^_Q8UA=O{A1jyQi93$Bb@)Z!R3Xvtdcn;GUYCM`@4 z?Yr=NKPwRi!uy=bNJI53&R8q0e#`;BdC$Fmnp1dHrcfG>d=w+mWkGZKv55%^gH;vB zO7YV*Ft>fQM%2;k&vW>E&3xqwxk~e95;MYE{FPk|xp)6&xN+Xdn%y@%bVW>foGM}Ww8TB5(Ouc)yk09(ynvMg1YUV;?_ zW=A&IZ0ICw?d>E&?B?n~HCp?~jQ9P7`4`YWxqH2;i;sx8()50(YZpmn<$3Apx&tEA zo+-dD>Wm?gxWpmJ23Uv|q2&MZifzk-Yf(>a(`o+e0&mJMd0NnL+8K zVH9Imk`Q)6{*A~fg;g`PAF;Y$f?$knnLkUVB)NnGr5R67Wf#x<7#3-ry=HkwJ5^@TC!IC6@~-#1I9p`Q+RVQ z&_Q&?RV{tRzW>Yd2pFVj1(NnxnrbV4kP=O+UHMcGP7b%xUShiah?rqyhvKW=z)Kwa z=Ma3WS23z7c!GXX3Ogm`1DRW9ThX0}o!8HL0B}K<2Je_*V6|Oa&xjnx z#FDkCi~CS{CX3Qy$ger++`0w_=`ez>^_XXZd*reX2WhKJkQFaClpnhTN(REhFEbE0HLWkx$HL_n65i zo&*Ky{4$bFqdQ{feoYv@Y5RyRv=y~2*uLn?l+P4^h0@qAiaUcLiXH2Gi6{}F@FiDj za9jkQ$i1Ep{1wx8oLo!6Q41EOL_@KMV8FsjK+VrC%pJSC1vT}bd@~{VR}2nB$;|h> zmZq1c!6J+UUg>UTql!iA;aMC7yO-c9cZNQ_i(aDA3E3~`i$(NH_7ehhfMu*8rYC{W z*PoBx(oH~#=4=V*2y{Ci=>@>ptEo?AHXojgV*tw)rZ;2?wJS3QR$RKSB3Zl_E;X?T z{uB32=SaU1NAaj4p1x#oqFHW)EdYdPrPWY!WS2Ic>WcKbY2&-hjR!u}F1FwR=06r4 zpq+r`{Gj%5@BcN24S$>Gv?~SFfK7gY7@<1=bf_$y5=FmfJ{7LUfcn5g{znDhHp)x- zzrt?(&V)7pSA0thZff%Ke?&O;v3r(2H47sk5Dz^4Mu6&nA9#p83Fo=oo;``cW&zWa z{L1rxU&z{MuGIc}m_%6yEeCi6J#`K3S`f}@VgeU>Vo&o-7tU(p_Fu6-h)u+P9sgsD z%jCr>rqdJa=H77N&LVJ=`$?E1=x`VKtN*(C${&sk5C7i-4V)DEh~SGf@E)l3B_leZ zCvRx{i8ZQq6}Y^~@PCxIh2S_QJO4c}>SOIf|JS6qHrDPBsLMa`T4JAI*PxjHYaijn z3V!?lTj>cfMl=jG`!}{D7SI*we*|Xq>ydZtpE?v(|BuFdF=yr8?ExgLfTKyA0a(-? zsR%+N6F(VQx=g#b z|G*ON{b)~^DqftSo_PH_nB+Fq>nDS|LW}E%-9e6sqX@sk(1&1&!Wa$iFL2YaRR$?V zK}%ppQQ7aIZIOv5vxZmTd{dn< zQ7#R|O{X7(d`K!doMR5OuvqRP76e_{{i@-C~rPFm?e3gy+I3iA==bdJl zDRXZYusP^5IK5(fZHoGu`foB1MT%XZa_785j~Utfm6o>`AoxV*W=clkZ%R^xokH__ z0h8w^Bq!;gpJ=HCRguS_;e!4$DSP(6bH25ZD};;@#yWDyVW@kE2RjhxBj^*V67-j1 zGHW|a4hIgX%bcS-)0}7;(mUYEs%vIbEqGISLfX*D;u7Zw(rX(7y+J>$Ek9);>!K$G z1_xeRcB`>&3aqev{Y2#XW!ZdSN+^X~!STa6rwJb4%zRJkl0_HpG8Sym$2VwHg>SPP zWT4XYw+c;F+)+Vt>YKIpE(f2yrKKuV>2t9u#F63DW>cVN_f>0k1Ww4Goqwvk+VJLU$!KW4bTte zZ(}7c<)aTb$379(QD2cR(*PRQK~G4`r^_l9| zA(pM^s`pehPx`qso|7+wH)(6H#sW%gZED}~{6n*wL%4!}4znkblLUMzd$n~(=kD0ATjG!}|>qMgd$j|h!dTD55*Bc{tD<7k&Dwq*vCAr@Jv@@;L= z$ri9o%o0;S*C8bHor{~U^n5#_gFImtFlK5;L789yp}(5ENZ!Vj`z8< zg?^+ky+j4Fj^7l=c>-Mr_55xh4sXz~;?~yki z)$(Jeh(OLwqUTNi#WA<4!%W(HcbwX{w(NXhH!e297wkH~#`N}`cLAZ7&#CKk6g=6o zotNXzaDA2u77zXVVdwzsa{k-MY>lpB9AYuYzd9fTl*2UVp5ThMp7#Tej5*(5u&`GC z>NZB;9Q?7Q!h{%4(D#)uawF7&0aMEqj( zbVgBDqVt*!gY8h6G*<>G5+|onm^9bJ5QY($W z8#F}HJURsp()0BB)Z@mJ^8h#GcP(-86ERBoEECa^V>r+WX?mVUcqdaCe=N#Fz-(>+ zIIl3k;u{_2!7J#t{KN6%czbU6c-aEex$6?CYV;N}YSKg*;4OOh#)%2(G12+j3+@5U z4YUgEW0|19jzMTab4UBDs4C-qE4qyABl-7OHUR0YM$6H3uocWHN-R|V^P`i@n?7Ud zF+{!=L>Ub{paqx^k4GjN~ITsNcfUl6Y6+3^^?sM=VS32z zEjf2n(Cs|pb2-1HUsM=F`|*iWS8HPt|75oC5V(uuL%!HGwO^qcgj$fK^WgCK(t`Q) z{-s)ovP1c(u}>Ennbx<>{)_z1w1afyF46UF$I_dq4J4b`iERz*QiH$3k39B(G5R*H ze9L`Yg4SN>qm~2M&9e81S{BQc{bDcVw!J3xAYQHQ;dQ0wvhoZsh6pWylGh6-)Bnb~ z#nip}0$2rG4|46y%T3xz$QRODM%S(0PAM-KR@S_tS|JO<>-9Zvw$0uZ-Xc)&<3DBw!!IR~)#vA_+*!n{_cdymcz5=*m7E4;gl>IF&e7X#?`Y z%o)bmBK7$a9o^UxpAmA3rp{V7YvPGR*Q~mO+{tlqNdZfpaUs=u-jBMv{;CN)qY}Q7 z;hd?*ZhhLC zJmvoU&OkP&+`vVjWIHanqBYKh%>^F&8~;dOCG6m7`A>|W^0nv98td#r>E=~ zW6QIf3Sb8#h;dTS8Pq_yijf-Sy|KaLqdl?#W)m{o`0W$Zs~@ccuTOV{4`uf*BTu0h z&{gO=2$>nZIONuE&O{?ET{n(~0B4GZK6V0-VfiRY~*c; zY1um`yH9m2Fpf8BK^ewA(SJLFpIS}KOB$zJd3)LsGTg%9l35fw)kW-P-&C|yC%>;T z&Do8ZZx}6;p-asyJ>ukec&k@Q+nRJcF?-lX3vEUEp;=wDS9iham^97HX+D{7PAO1C zEq}C1cz^vu|S2o$oA;m>o5nhn#CVOZ>qk3*JZH7r>(`z ziov2tdeTGG@8=h} zF^hbIVN$-L*Um%^oK>oR+2)J4k6`R*bsbn1`KuW)xbSQP^kAhtb9<=2kw;7JY|4+u z^)l`}=kk>&i^bBMm#ow`1c{&jd2Utj)^fa{mwb{P;jWaj82~f;HE_yTohs)O&+_2^ zZwZvmtX7H4n3zxlZXl#XTrqNq@uZt&yuJ!wa|S<}vP2nkWuzOvu^vgxFjU zBNiD{3hR)+QECqg50(a00%x(TPce$)$5u+ePNfQ8iRL@pg(r$tf1*CQr;8`ORDx&l z)TBB=zSd?Q1iaodw8F^<@{dZyIF!ngBQ!8 zXS7H>yp1wzv^Py&-f*4$>Msd-RY)3mRjU=`@4S+E!P>Q(JJw#cl)mbUH_9|-t(~`D z0e=#UyS?0?ZsphHUEyUg;I-gof6s8jG(qt<()4?Ir5A&A-d@=wUB#XLa6R_bTdw$z zUI|NYysALmYTJbk!Q;~-VVKnHk{InJYGdofdal)r9TSe7_TX0Ud1t_i7d)K^?}N?) zq%yr2rFFd#-X&!6eR1WGvkOv_u?taNKzW|m9lLRm7)4c!Bw6(LEsZ{cv0R~uYv4N# z&^tBoG@k0EVbCmjHP?rZ_XC^IITn8^4c@)izZWaWO~1cr#0U2LW;Y-|?D6oOw}3OY zt*uSyO0(AlIzRFzS&)t5~q)=0{UU-J2d z;|ur&GwnP(704oHZw`ZIxg4S)WpQ#Bk#7sOZZ(?NYg6V%VNwo~Qni8NFKa#l#*?Z! z-3iGA^L%A*nAz+H3DxttZ$UUUE76hC&vpf=Vzt0Z@fl`*1GeXhJ z7vx>fH~4vlaA)L(9!9oOg@98B33zRF3CSR0?o&FWaQbrSTB9=-1W-gCFTO^OH$wwH zBc}7Ga}hT8?Mfdh|G_fL0B(??Qtyuuf1~!J#s1{z1d@wO-#Kks4zc^2SXw^cI{x`Q ztCdWM2QbIhRiuw3H!#MrwTFNU^~G_GEyvt7c-ItGzW-|(z!<+E`J-ay$A?m zcm^!LhQ=o>pG;O|+Xv6y{xH5h3-AT?1080iK)4H7?#rXls4ZVd+~lzf@hkHkVEaIr zm#LoO%*%Tqg;w|$|N6dOZNQGcN}N(@eKEHfIv|4zSU)4CED++MV}S>T6T!Bc&&?(G z8poY?B{hNwg#WA~Rck>wHRfxGzdTmOh~76o_$5(yT<-S{THq8Cf`=HdC%@9+eO?WGCRV^MWk?uspBxYwyuTfo*UKU~XU}xw0>#=ib7zPW1VE7PRVGRzB zTLO1bu2A>D_f5Y9NrZ^sxEQ_qW4EUZ7cIL7)TXXhdWid$nCu^=Dl|_F)pV-J*5@AW zR1?~BM?@qCFzQzU?CKv~YlVR;z%O-zdOvPN`UndwfMcVLr?ee2S=he1A!4WU>0BQs zTu-~G(J4X-IGFF}b2+ovjwCb{3wygEy)Go&Vm{F;aIZtL?fM{GA(qqB&D>q~UqmW`J1y zb%(AT27_gUB|Wn%qJP*9X??}17XrZj{@_R`HN z0c}CtM$yLa%Hd!VV`<6n3O2Wage%~$Mk^4mBAv6lWa6+83^6Ih=Z7vtQ;=FJl4BgI zB=4%AxtCrNh>e$x@~|*GKqo5x78HKe{b@ z{40Maw6qXdn_GSOl?E0aGOX(+Va&VC5807uY8-}DSpauhmW@bX3&Gx+pB@{wS;+O$ zgh?#AyUA}oIvAAc10tazu({Juk2xTF-F071+Kf0@98^5qRtA_KILy+snEhZzxsLq3 zOjc{JYP|yu&2vbGp1#JGr|9t-MF4;L(wwZMiO`~jAUHN8cF4#0a6XY`fn4*uSuF!Z z-aCqAPgm!=Z$^4EHb84gAwhJck$1vvY(Dw@e1u45@a!N5*N3ikxr1EPgjIplH(P2H z{iV31#YDltO!)vdKeA#YsFCAf6Pv^eGx!Gy&%T diff --git a/datumaro/docs/user_manual.md b/datumaro/docs/user_manual.md deleted file mode 100644 index 9e68f8f9..00000000 --- a/datumaro/docs/user_manual.md +++ /dev/null @@ -1,1003 +0,0 @@ -# User manual - -## Contents - -- [Installation](#installation) -- [Interfaces](#interfaces) -- [Supported dataset formats and annotations](#supported-formats) -- [Command line workflow](#command-line-workflow) - - [Project structure](#project-structure) -- [Command reference](#command-reference) - - [Convert datasets](#convert-datasets) - - [Create project](#create-project) - - [Add and remove data](#add-and-remove-data) - - [Import project](#import-project) - - [Filter project](#filter-project) - - [Update project (merge)](#update-project) - - [Merge projects](#merge-projects) - - [Export project](#export-project) - - [Compare projects](#compare-projects) - - [Obtaining project info](#get-project-info) - - [Obtaining project statistics](#get-project-statistics) - - [Register model](#register-model) - - [Run inference](#run-inference) - - [Run inference explanation](#explain-inference) - - [Transform project](#transform-project) -- [Extending](#extending) -- [Links](#links) - -## Installation - -### Prerequisites - -- Python (3.5+) -- OpenVINO (optional) - -### Installation steps - -Optionally, set up a virtual environment: - -``` bash -python -m pip install virtualenv -python -m virtualenv venv -. venv/bin/activate -``` - -Install: -``` bash -pip install 'git+https://github.com/opencv/cvat#egg=datumaro&subdirectory=datumaro' -``` - -> You can change the installation branch with `.../cvat@#egg...` -> Also note `--force-reinstall` parameter in this case. - -## Interfaces - -As a standalone tool: - -``` bash -datum --help -``` - -As a python module: -> The directory containing Datumaro should be in the `PYTHONPATH` -> environment variable or `cvat/datumaro/` should be the current directory. - -``` bash -python -m datumaro --help -python datumaro/ --help -python datum.py --help -``` - -As a python library: - -``` python -import datumaro -``` - -## Supported Formats - -List of supported formats: -- MS COCO (`image_info`, `instances`, `person_keypoints`, `captions`, `labels`*) - - [Format specification](http://cocodataset.org/#format-data) - - [Dataset example](../tests/assets/coco_dataset) - - `labels` are our extension - like `instances` with only `category_id` -- PASCAL VOC (`classification`, `detection`, `segmentation` (class, instances), `action_classification`, `person_layout`) - - [Format specification](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/htmldoc/index.html) - - [Dataset example](../tests/assets/voc_dataset) -- YOLO (`bboxes`) - - [Format specification](https://github.com/AlexeyAB/darknet#how-to-train-pascal-voc-data) - - [Dataset example](../tests/assets/yolo_dataset) -- TF Detection API (`bboxes`, `masks`) - - Format specifications: [bboxes](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/using_your_own_dataset.md), [masks](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/instance_segmentation.md) - - [Dataset example](../tests/assets/tf_detection_api_dataset) -- MOT sequences - - [Format specification](https://arxiv.org/pdf/1906.04567.pdf) - - [Dataset example](../tests/assets/mot_dataset) -- CVAT - - [Format specification](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) - - [Dataset example](../tests/assets/cvat_dataset) -- LabelMe - - [Format specification](http://labelme.csail.mit.edu/Release3.0) - - [Dataset example](../tests/assets/labelme_dataset) - -List of supported annotation types: -- Labels -- Bounding boxes -- Polygons -- Polylines -- (Segmentation) Masks -- (Key-)Points -- Captions - -## Command line workflow - -The key object is a project, so most CLI commands operate on projects. -However, there are few commands operating on datasets directly. -A project is a combination of a project's own dataset, a number of -external data sources and an environment. -An empty Project can be created by `project create` command, -an existing dataset can be imported with `project import` command. -A typical way to obtain projects is to export tasks in CVAT UI. - -If you want to interact with models, you need to add them to project first. - -### Project structure - - -``` -└── project/ - ├── .datumaro/ - | ├── config.yml - │   ├── .git/ - │   ├── models/ - │   └── plugins/ - │   ├── plugin1/ - │   | ├── file1.py - │   | └── file2.py - │   ├── plugin2.py - │   ├── custom_extractor1.py - │   └── ... - ├── dataset/ - └── sources/ - ├── source1 - └── ... -``` - - -## Command reference - -> **Note**: command invocation syntax is subject to change, -> **always refer to command --help output** - -Available CLI commands: -![CLI design doc](images/cli_design.png) - -### Convert datasets - -This command allows to convert a dataset from one format into another. In fact, this -command is a combination of `project import` and `project export` and just provides a simpler -way to obtain the same result when no extra options is needed. A list of supported -formats can be found in the `--help` output of this command. - -Usage: - -``` bash -datum convert --help - -datum convert \ - -i \ - -if \ - -o \ - -f \ - -- [extra parameters for output format] -``` - -Example: convert a VOC-like dataset to a COCO-like one: - -``` bash -datum convert --input-format voc --input-path \ - --output-format coco -``` - -### Import project - -This command creates a Project from an existing dataset. - -Supported formats are listed in the command help. Check [extending tips](#extending) -for information on extra format support. - -Usage: - -``` bash -datum project import --help - -datum project import \ - -i \ - -o \ - -f -``` - -Example: create a project from COCO-like dataset - -``` bash -datum project import \ - -i /home/coco_dir \ - -o /home/project_dir \ - -f coco -``` - -An _MS COCO_-like dataset should have the following directory structure: - - -``` -COCO/ -├── annotations/ -│   ├── instances_val2017.json -│   ├── instances_train2017.json -├── images/ -│   ├── val2017 -│   ├── train2017 -``` - - -Everything after the last `_` is considered a subset name in the COCO format. - -### Create project - -The command creates an empty project. Once a Project is created, there are -a few options to interact with it. - -Usage: - -``` bash -datum project create --help - -datum project create \ - -o -``` - -Example: create an empty project `my_dataset` - -``` bash -datum project create -o my_dataset/ -``` - -### Add and remove data - -A Project can contain a number of external Data Sources. Each Data Source -describes a way to produce dataset items. A Project combines dataset items from -all the sources and its own dataset into one composite dataset. You can manage -project sources by commands in the `source` command line context. - -Datasets come in a wide variety of formats. Each dataset -format defines its own data structure and rules on how to -interpret the data. For example, the following data structure -is used in COCO format: - -``` -/dataset/ -- /images/.jpg -- /annotations/ -``` - - -Supported formats are listed in the command help. Check [extending tips](#extending) -for information on extra format support. - -Usage: - -``` bash -datum source add --help -datum source remove --help - -datum source add \ - path \ - -p \ - -n - -datum source remove \ - -p \ - -n -``` - -Example: create a project from a bunch of different annotations and images, -and generate TFrecord for TF Detection API for model training - -``` bash -datum project create -# 'default' is the name of the subset below -datum source add path -f coco_instances -datum source add path -f cvat -datum source add path -f voc_detection -datum source add path -f datumaro -datum source add path -f image_dir -datum project export -f tf_detection_api -``` - -### Filter project - -This command allows to create a sub-Project from a Project. The new project -includes only items satisfying some condition. [XPath](https://devhints.io/xpath) -is used as a query format. - -There are several filtering modes available (`-m/--mode` parameter). -Supported modes: -- `i`, `items` -- `a`, `annotations` -- `i+a`, `a+i`, `items+annotations`, `annotations+items` - -When filtering annotations, use the `items+annotations` -mode to point that annotation-less dataset items should be -removed. To select an annotation, write an XPath that -returns `annotation` elements (see examples). - -Usage: - -``` bash -datum project filter --help - -datum project filter \ - -p \ - -e '' -``` - -Example: extract a dataset with only images which `width` < `height` - -``` bash -datum project filter \ - -p test_project \ - -e '/item[image/width < image/height]' -``` - -Example: extract a dataset with only large annotations of class `cat` and any non-`persons` - -``` bash -datum project filter \ - -p test_project \ - --mode annotations -e '/item/annotation[(label="cat" and area > 99.5) or label!="person"]' -``` - -Example: extract a dataset with only occluded annotations, remove empty images - -``` bash -datum project filter \ - -p test_project \ - -m i+a -e '/item/annotation[occluded="True"]' -``` - -Item representations are available with `--dry-run` parameter: - -``` xml - - 290768 - minival2014 - - 612 - 612 - 3 - - - 80154 - bbox - 39 - 264.59 - 150.25 - 11.199999999999989 - 42.31 - 473.87199999999956 - - - 669839 - bbox - 41 - 163.58 - 191.75 - 76.98999999999998 - 73.63 - 5668.773699999998 - - ... - -``` - -### Update project - -This command updates items in a project from another one -(check [Merge Projects](#merge-projects) for complex merging). - -Usage: - -``` bash -datum project merge --help - -datum project merge \ - -p \ - -o \ - -``` - -Example: update annotations in the `first_project` with annotations -from the `second_project` and save the result as `merged_project` - -``` bash -datum project merge \ - -p first_project \ - -o merged_project \ - second_project -``` - -### Merge projects - -This command merges items from 2 or more projects and checks annotations for errors. - -Spatial annotations are compared by distance and intersected, labels and attributes -are selected by voting. -Merge conflicts, missing items and annotations, other errors are saved into a `.json` file. - -Usage: - -``` bash -datum merge --help - -datum merge -``` - -Example: merge 4 (partially-)intersecting projects, -- consider voting succeeded when there are 3+ same votes -- consider shapes intersecting when IoU >= 0.6 -- check annotation groups to have `person`, `hand`, `head` and `foot` (`?` for optional) - -``` bash -datum merge project1/ project2/ project3/ project4/ \ - --quorum 3 \ - -iou 0.6 \ - --groups 'person,hand?,head,foot?' -``` - -### Export project - -This command exports a Project as a dataset in some format. - -Supported formats are listed in the command help. Check [extending tips](#extending) -for information on extra format support. - -Usage: - -``` bash -datum project export --help - -datum project export \ - -p \ - -o \ - -f \ - -- [additional format parameters] -``` - -Example: save project as VOC-like dataset, include images, convert images to `PNG` - -``` bash -datum project export \ - -p test_project \ - -o test_project-export \ - -f voc \ - -- --save-images --image-ext='.png' -``` - -### Get project info - -This command outputs project status information. - -Usage: - -``` bash -datum project info --help - -datum project info \ - -p -``` - -Example: - -``` bash -datum project info -p /test_project - -Project: - name: test_project - location: /test_project -Sources: - source 'instances_minival2014': - format: coco_instances - url: /coco_like/annotations/instances_minival2014.json -Dataset: - length: 5000 - categories: label - label: - count: 80 - labels: person, bicycle, car, motorcycle (and 76 more) - subsets: minival2014 - subset 'minival2014': - length: 5000 - categories: label - label: - count: 80 - labels: person, bicycle, car, motorcycle (and 76 more) -``` - -### Get project statistics - -This command computes various project statistics, such as: -- image mean and std. dev. -- class and attribute balance -- mask pixel balance -- segment area distribution - -Usage: - -``` bash -datum project stats --help - -datum project stats \ - -p -``` - -Example: - -
- -``` bash -datum project stats -p /test_project - -{ - "annotations": { - "labels": { - "attributes": { - "gender": { - "count": 358, - "distribution": { - "female": [ - 149, - 0.41620111731843573 - ], - "male": [ - 209, - 0.5837988826815642 - ] - }, - "values count": 2, - "values present": [ - "female", - "male" - ] - }, - "view": { - "count": 340, - "distribution": { - "__undefined__": [ - 4, - 0.011764705882352941 - ], - "front": [ - 54, - 0.1588235294117647 - ], - "left": [ - 14, - 0.041176470588235294 - ], - "rear": [ - 235, - 0.6911764705882353 - ], - "right": [ - 33, - 0.09705882352941177 - ] - }, - "values count": 5, - "values present": [ - "__undefined__", - "front", - "left", - "rear", - "right" - ] - } - }, - "count": 2038, - "distribution": { - "car": [ - 340, - 0.16683022571148184 - ], - "cyclist": [ - 194, - 0.09519136408243375 - ], - "head": [ - 354, - 0.17369970559371933 - ], - "ignore": [ - 100, - 0.04906771344455348 - ], - "left_hand": [ - 238, - 0.11678115799803729 - ], - "person": [ - 358, - 0.17566241413150147 - ], - "right_hand": [ - 77, - 0.037782139352306184 - ], - "road_arrows": [ - 326, - 0.15996074582924436 - ], - "traffic_sign": [ - 51, - 0.025024533856722278 - ] - } - }, - "segments": { - "area distribution": [ - { - "count": 1318, - "max": 11425.1, - "min": 0.0, - "percent": 0.9627465303140978 - }, - { - "count": 1, - "max": 22850.2, - "min": 11425.1, - "percent": 0.0007304601899196494 - }, - { - "count": 0, - "max": 34275.3, - "min": 22850.2, - "percent": 0.0 - }, - { - "count": 0, - "max": 45700.4, - "min": 34275.3, - "percent": 0.0 - }, - { - "count": 0, - "max": 57125.5, - "min": 45700.4, - "percent": 0.0 - }, - { - "count": 0, - "max": 68550.6, - "min": 57125.5, - "percent": 0.0 - }, - { - "count": 0, - "max": 79975.7, - "min": 68550.6, - "percent": 0.0 - }, - { - "count": 0, - "max": 91400.8, - "min": 79975.7, - "percent": 0.0 - }, - { - "count": 0, - "max": 102825.90000000001, - "min": 91400.8, - "percent": 0.0 - }, - { - "count": 50, - "max": 114251.0, - "min": 102825.90000000001, - "percent": 0.036523009495982466 - } - ], - "avg. area": 5411.624543462382, - "pixel distribution": { - "car": [ - 13655, - 0.0018431496518735067 - ], - "cyclist": [ - 939005, - 0.12674674030446592 - ], - "head": [ - 0, - 0.0 - ], - "ignore": [ - 5501200, - 0.7425510702956085 - ], - "left_hand": [ - 0, - 0.0 - ], - "person": [ - 954654, - 0.12885903974805205 - ], - "right_hand": [ - 0, - 0.0 - ], - "road_arrows": [ - 0, - 0.0 - ], - "traffic_sign": [ - 0, - 0.0 - ] - } - } - }, - "annotations by type": { - "bbox": { - "count": 548 - }, - "caption": { - "count": 0 - }, - "label": { - "count": 0 - }, - "mask": { - "count": 0 - }, - "points": { - "count": 669 - }, - "polygon": { - "count": 821 - }, - "polyline": { - "count": 0 - } - }, - "annotations count": 2038, - "dataset": { - "image mean": [ - 107.06903686941979, - 79.12831698580979, - 52.95829558185416 - ], - "image std": [ - 49.40237673503467, - 43.29600731496902, - 35.47373007603151 - ], - "images count": 100 - }, - "images count": 100, - "subsets": {}, - "unannotated images": [ - "img00051", - "img00052", - "img00053", - "img00054", - "img00055", - ], - "unannotated images count": 5 -} -``` - -
- -### Register model - -Supported models: -- OpenVINO -- Custom models via custom `launchers` - -Usage: - -``` bash -datum model add --help -``` - -Example: register an OpenVINO model - -A model consists of a graph description and weights. There is also a script -used to convert model outputs to internal data structures. - -``` bash -datum project create -datum model add \ - -n openvino \ - -d -w -i -``` - -Interpretation script for an OpenVINO detection model (`convert.py`): - -``` python -from datumaro.components.extractor import * - -max_det = 10 -conf_thresh = 0.1 - -def process_outputs(inputs, outputs): - # inputs = model input, array or images, shape = (N, C, H, W) - # outputs = model output, shape = (N, 1, K, 7) - # results = conversion result, [ [ Annotation, ... ], ... ] - results = [] - for input, output in zip(inputs, outputs): - input_height, input_width = input.shape[:2] - detections = output[0] - image_results = [] - for i, det in enumerate(detections): - label = int(det[1]) - conf = det[2] - if conf <= conf_thresh: - continue - - x = max(int(det[3] * input_width), 0) - y = max(int(det[4] * input_height), 0) - w = min(int(det[5] * input_width - x), input_width) - h = min(int(det[6] * input_height - y), input_height) - image_results.append(Bbox(x, y, w, h, - label=label, attributes={'score': conf} )) - - results.append(image_results[:max_det]) - - return results - -def get_categories(): - # Optionally, provide output categories - label map etc. - # Example: - label_categories = LabelCategories() - label_categories.add('person') - label_categories.add('car') - return { AnnotationType.label: label_categories } -``` - -### Run model - -This command applies model to dataset images and produces a new project. - -Usage: - -``` bash -datum model run --help - -datum model run \ - -p \ - -m \ - -o -``` - -Example: launch inference on a dataset - -``` bash -datum project import <...> -datum model add mymodel <...> -datum model run -m mymodel -o inference -``` - -### Compare projects - -The command compares two datasets and saves the results in the -specified directory. The current project is considered to be -"ground truth". - -``` bash -datum project diff --help - -datum project diff -o -``` - -Example: compare a dataset with model inference - -``` bash -datum project import <...> -datum model add mymodel <...> -datum project transform <...> -o inference -datum project diff inference -o diff -``` - -### Explain inference - -Usage: - -``` bash -datum explain --help - -datum explain \ - -m \ - -o \ - -t \ - \ - -``` - -Example: run inference explanation on a single image with visualization - -``` bash -datum project create <...> -datum model add mymodel <...> -datum explain \ - -m mymodel \ - -t 'image.png' \ - rise \ - -s 1000 --progressive -``` - -### Transform Project - -This command allows to modify images or annotations in a project all at once. - -``` bash -datum project transform --help - -datum project transform \ - -p \ - -o \ - -t \ - -- [extra transform options] -``` - -Example: split a dataset randomly to `train` and `test` subsets, ratio is 2:1 - -``` bash -datum project transform -t random_split -- --subset train:.67 --subset test:.33 -``` - -Example: convert polygons to masks, masks to boxes etc.: - -``` bash -datum project transform -t boxes_to_masks -datum project transform -t masks_to_polygons -datum project transform -t polygons_to_masks -datum project transform -t shapes_to_boxes -``` - -Example: remap dataset labels, `person` to `car` and `cat` to `dog`, keep `bus`, remove others - -``` bash -datum project transform -t remap_labels -- \ - -l person:car -l bus:bus -l cat:dog \ - --default delete -``` - -Example: rename dataset items by a regular expression -- Replace `pattern` with `replacement` -- Remove `frame_` from item ids - -``` bash -datum project transform -t rename -- -e '|pattern|replacement|' -datum project transform -t rename -- -e '|frame_(\d+)|\\1|' -``` - -## Extending - -There are few ways to extend and customize Datumaro behaviour, which is supported by plugins. -Check [our contribution guide](../CONTRIBUTING.md) for details on plugin implementation. -In general, a plugin is a Python code file. It must be put into a plugin directory: -- `/.datumaro/plugins` for project-specific plugins -- `/plugins` for global plugins - -### Dataset Formats - -Dataset reading is supported by Extractors and Importers. -An Extractor produces a list of dataset items corresponding -to the dataset. An Importer creates a project from the data source location. -It is possible to add custom Extractors and Importers. To do this, you need -to put an Extractor and Importer implementation scripts to a plugin directory. - -Dataset writing is supported by Converters. -A Converter produces a dataset of a specific format from dataset items. -It is possible to add custom Converters. To do this, you need to put a Converter -implementation script to a plugin directory. - -### Dataset Conversions ("Transforms") - -A Transform is a function for altering a dataset and producing a new one. It can update -dataset items, annotations, classes, and other properties. -A list of available transforms for dataset conversions can be extended by adding a Transform -implementation script into a plugin directory. - -### Model launchers - -A list of available launchers for model execution can be extended by adding a Launcher -implementation script into a plugin directory. - -## Links -- [TensorFlow detection model zoo](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md) -- [How to convert model to OpenVINO format](https://docs.openvinotoolkit.org/latest/_docs_MO_DG_prepare_model_convert_model_tf_specific_Convert_Object_Detection_API_Models.html) -- [Model conversion script example](https://github.com/opencv/cvat/blob/3e09503ba6c6daa6469a6c4d275a5a8b168dfa2c/components/tf_annotation/install.sh#L23) diff --git a/datumaro/requirements.txt b/datumaro/requirements.txt deleted file mode 100644 index 6bc3c7ee..00000000 --- a/datumaro/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -attrs>=19.3.0 -Cython>=0.27.3 # include before pycocotools -defusedxml>=0.6.0 -GitPython>=3.0.8 -lxml>=4.4.1 -matplotlib>=3.3.1 -opencv-python-headless>=4.1.0.25 -Pillow>=6.1.0 -pycocotools>=2.0.0 -PyYAML>=5.3.1 -scikit-image>=0.15.0 -tensorboardX>=1.8 diff --git a/datumaro/setup.py b/datumaro/setup.py deleted file mode 100644 index cf6d0433..00000000 --- a/datumaro/setup.py +++ /dev/null @@ -1,73 +0,0 @@ - -# Copyright (C) 2019-2020 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import os.path as osp -import re -import setuptools - - -def find_version(file_path=None): - if not file_path: - file_path = osp.join(osp.dirname(osp.abspath(__file__)), - 'datumaro', 'version.py') - - with open(file_path, 'r') as version_file: - version_text = version_file.read() - - # PEP440: - # https://www.python.org/dev/peps/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions - pep_regex = r'([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*))?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?' - version_regex = r'VERSION\s*=\s*.(' + pep_regex + ').' - match = re.match(version_regex, version_text) - if not match: - raise RuntimeError("Failed to find version string in '%s'" % file_path) - - version = version_text[match.start(1) : match.end(1)] - return version - - -with open('README.md', 'r') as fh: - long_description = fh.read() - -setuptools.setup( - name="datumaro", - version=find_version(), - author="Intel", - author_email="maxim.zhiltsov@intel.com", - description="Dataset Management Framework (Datumaro)", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/opencv/cvat/datumaro", - packages=setuptools.find_packages(exclude=['tests*']), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires='>=3.5', - install_requires=[ - 'attrs', - 'defusedxml', - 'GitPython', - 'lxml', - 'matplotlib', - 'numpy', - 'opencv-python', - 'Pillow', - 'pycocotools', - 'PyYAML', - 'scikit-image', - 'tensorboardX', - ], - extras_require={ - 'tf': ['tensorflow'], - 'tf-gpu': ['tensorflow-gpu'], - }, - entry_points={ - 'console_scripts': [ - 'datum=datumaro.cli.__main__:main', - ], - }, -) diff --git a/datumaro/tests/__init__.py b/datumaro/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/datumaro/tests/assets/coco_dataset/annotations/instances_val.json b/datumaro/tests/assets/coco_dataset/annotations/instances_val.json deleted file mode 100644 index b5d9bd86..00000000 --- a/datumaro/tests/assets/coco_dataset/annotations/instances_val.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "licenses": [ - { - "name": "", - "id": 0, - "url": "" - } - ], - "info": { - "contributor": "", - "date_created": "", - "description": "", - "url": "", - "version": "", - "year": "" - }, - "categories": [ - { - "id": 1, - "name": "TEST", - "supercategory": "" - } - ], - "images": [ - { - "id": 1, - "width": 5, - "height": 10, - "file_name": "000000000001.jpg", - "license": 0, - "flickr_url": "", - "coco_url": "", - "date_captured": 0 - } - ], - "annotations": [ - { - "id": 1, - "image_id": 1, - "category_id": 1, - "segmentation": [[0, 0, 1, 0, 1, 2, 0, 2]], - "area": 2, - "bbox": [0, 0, 1, 2], - "iscrowd": 0 - }, - { - "id": 2, - "image_id": 1, - "category_id": 1, - "segmentation": { - "counts": [0, 10, 5, 5, 5, 5, 0, 10, 10, 0], - "size": [10, 5] - }, - "area": 30, - "bbox": [0, 0, 10, 4], - "iscrowd": 1 - } - ] - } diff --git a/datumaro/tests/assets/coco_dataset/images/val/000000000001.jpg b/datumaro/tests/assets/coco_dataset/images/val/000000000001.jpg deleted file mode 100644 index 8bce84d3bf50bd756621338e0da944a42428fb06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf}^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<gTWM0TY@5u?V53ptdXHXalWy7)oGIH{I3zSIJR&kGIVCkMJtH%#xTLhKyrQzI zxuvzOy`!^h(&Q;qr%j(RbJn88OO`HMzGCI7O`ErD-L`$l&RvHNA31vL_=%IJE?vHI z_1g6tH*YuS~;l_iU%Emz-M3agxa*3&!JXHM%@*3D@ k#CfcVET6$WhVa)d1|DWcVB|3iGT1YG;L=#sVE_Ln0Q-o|ng9R* diff --git a/datumaro/tests/assets/cvat_dataset/for_images/images/img1.jpg b/datumaro/tests/assets/cvat_dataset/for_images/images/img1.jpg deleted file mode 100644 index ee889d22692144aa0004b545eb7c097f101aa3de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf} - 1.1 - - - True - annotation - - - - - - - - - true - v3 - - - - - - - - diff --git a/datumaro/tests/assets/cvat_dataset/for_video/annotations.xml b/datumaro/tests/assets/cvat_dataset/for_video/annotations.xml deleted file mode 100644 index 5a68f811..00000000 --- a/datumaro/tests/assets/cvat_dataset/for_video/annotations.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - 1.1 - - - 5 - v1 - 4 - interpolation - 2 - - 2020-04-23 08:57:24.614217+00:00 - 2020-04-23 09:04:48.168008+00:00 - 10 - 19 - step=3 - True - - - - - - - - 3 - 0 - 3 - http://localhost:7000/?id=3 - - - 4 - 2 - 3 - http://localhost:7000/?id=4 - - - - max - - - - - 25 - 20 - - - 2020-04-23 09:05:02.335612+00:00 - t.mp4 - - - - - - - - - - - - hgkf - - - jk - - - - - - - - - diff --git a/datumaro/tests/assets/cvat_dataset/for_video/images/frame_000010.png b/datumaro/tests/assets/cvat_dataset/for_video/images/frame_000010.png deleted file mode 100644 index 14996e0c4f1204c754fcc5ab6ffc99f4e9e43f52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111 zcmeAS@N?(olHy`uVBq!ia0vp^l0YoN!2~3~i!#0fQk(@Ik;M!Q+`=Ht$S`Y;1W-`X z)5S5w;&k$#|Nrfor!ojLe?71wa&cPkEYqxODNn{1`ISV`@iy0XB4ude`@%$AjK*0=87srr_xVI+`@;U?vxL#bycvQnnei5hc zhLCnYowqYjtas39DV{Xjd?rva4$x4&YSVo4ZO12EPC5RyNn{1`ISV`@iy0XB4ude`@%$AjKn;?fE{-7;ac{31>ahgMuzgTe;cE4J%pt_u z%eaGheZXkv$pvXG0a(833^=Pp#(6nQr1-oLt&-|y8g|NHFh znJ}Oo3=9SvHkRpMIhQl-X`0-L#L%_-w->8>>8IJ94m0LY+59@`*zLU`|95_r+pV`W ztjbXS?Z3<3MYCj7s!w`;db;qgwdpGLXEw`|%vau5b^p0{{vEaaldhL`2HF}1SN>PI zKW);^Ct9wu-`AC_`I7W+(~O+ub6QhWKS#aK3Z1*P`ni|>Gpn81$Kqf4zwAvho#`=e z((DwCDRDl3cg)H=womV~#P0m(=cfL%Pcu60WBmQ=oTRwhl3+)p5eX6@SJh`Nn{1`ISV`@iy0XB4ude`@%$AjK*3~B7srr_xVPsI@;W#Ov|W7o>cJYhySe4J z3IdswmoXIz-1GidGkI?=P$>>@z&7^l=WiXF#?RaC%#hE2{yX~XRr?#Ovh#NO0d>q1 ZEM(}&Wm)G^ko^K=fTydU%Q~loCII=|J+=S< diff --git a/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png b/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png deleted file mode 100644 index 415e1f88b2cafe6d77e4e9b4555664a005460d5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 387 zcmeAS@N?(olHy`uVBq!ia0vp^X+Z4D!3HElwg^rKQjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{1`ISV`@iy0XB4ude`@%$AjKn)U}E{-7;ac{5bd$Tw)v_G``Kh4?2Vhcyp z(FBnz3QcaV-6u;r*eL8?AX362lWO7;64@S>5uG=90N5U)pT{QaStLiKg{Se$_7x zoV)C;$<$4mq4wXOU-tZbV14${&Y#D8Z+W`E)VaHS`xWmVFFz-9-dU&JOlMaWKP@%- zKJ9X))a>xc?0u&;y@-~)cKP@7BLRPpt+@U`@$NJCze_ATo!l| OB<<8wB`-xB}__|NjF?B0==h_NhRnoCO|{#S9F5he4R}c>anMprC=Li(^Q{ z;kTz33Nk1NFmFg_-t~VIhs?TZ-Zg5r&*$0pKegz4Y_c3gTJ(MX?#b7j^*rxUX47<# N-JY&~F6*2UngCPUH#YzP diff --git a/datumaro/tests/assets/labelme_dataset/img1.xml b/datumaro/tests/assets/labelme_dataset/img1.xml deleted file mode 100644 index ff8ae1b4..00000000 --- a/datumaro/tests/assets/labelme_dataset/img1.xml +++ /dev/null @@ -1 +0,0 @@ -img1.pngexample_folderThe MIT-CSAIL database of objects and scenesLabelMe Webtoolwindow0025-May-2012 00:09:480admin433445344537433777102license plate00no27-Jul-2014 02:58:501brussell58666268img1_mask_1.png58666268img1_scribble_1.pngo100yesa13,415-Nov-2019 14:38:512anonymous3012422124261522181422122712q100nokj215-Nov-2019 14:39:003anonymous352143224028283131223225b100yeshg215-Nov-2019 14:39:094bounding_boxanonymous1319231923301330m100nod615-Nov-2019 14:39:305bounding_boxanonymous56147023img1_mask_5.png55137023img1_scribble_5.pnghg00nogfd lkj lkj hi515-Nov-2019 14:41:576anonymous642174247232623460276222 \ No newline at end of file diff --git a/datumaro/tests/assets/mot_dataset/gt/gt.txt b/datumaro/tests/assets/mot_dataset/gt/gt.txt deleted file mode 100644 index f4b7c0d4..00000000 --- a/datumaro/tests/assets/mot_dataset/gt/gt.txt +++ /dev/null @@ -1 +0,0 @@ -1,-1,0,4,4,8,1,3,1.0 diff --git a/datumaro/tests/assets/mot_dataset/gt/labels.txt b/datumaro/tests/assets/mot_dataset/gt/labels.txt deleted file mode 100644 index 6d9c393d..00000000 --- a/datumaro/tests/assets/mot_dataset/gt/labels.txt +++ /dev/null @@ -1,10 +0,0 @@ -label_0 -label_1 -label_2 -label_3 -label_4 -label_5 -label_6 -label_7 -label_8 -label_9 \ No newline at end of file diff --git a/datumaro/tests/assets/mot_dataset/img1/000001.jpg b/datumaro/tests/assets/mot_dataset/img1/000001.jpg deleted file mode 100644 index 3588867b5a1d36b5ce00b0439f94e90ff453aa95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 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
ZoQIU!Zd>`FzwtmIF_6Ox#_zl)=Sda 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∾K!b-ynBRvaouX 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 T)1A!6mKyk|Gn?rdc4Phraz4W3 diff --git a/datumaro/tests/assets/pytorch_launcher/samplenet.py b/datumaro/tests/assets/pytorch_launcher/samplenet.py deleted file mode 100644 index 7282e43a..00000000 --- a/datumaro/tests/assets/pytorch_launcher/samplenet.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Copyright (C) 2019-2020 Intel Corporation - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import torch.nn as nn -import torch.nn.functional as F - - -class SampLeNet(nn.Module): - def __init__(self): - super(SampLeNet, self).__init__() - self.conv1 = nn.Conv2d(3, 6, 5) - self.pool = nn.MaxPool2d(2, 2) - self.conv2 = nn.Conv2d(6, 16, 5) - self.fc1 = nn.Linear(16 * 5 * 5, 120) - self.fc2 = nn.Linear(120, 84) - self.fc3 = nn.Linear(84, 10) - - def forward(self, x): - x = self.pool(F.relu(self.conv1(x))) - x = self.pool(F.relu(self.conv2(x))) - x = x.view(-1, 16 * 5 * 5) - x = F.relu(self.fc1(x)) - x = F.relu(self.fc2(x)) - x = self.fc3(x) - return x diff --git a/datumaro/tests/assets/tf_detection_api_dataset/label_map.pbtxt b/datumaro/tests/assets/tf_detection_api_dataset/label_map.pbtxt deleted file mode 100644 index dbf2b339..00000000 --- a/datumaro/tests/assets/tf_detection_api_dataset/label_map.pbtxt +++ /dev/null @@ -1,50 +0,0 @@ -item { - id: 1 - name: 'label_0' -} - -item { - id: 2 - name: 'label_1' -} - -item { - id: 3 - name: 'label_2' -} - -item { - id: 4 - name: 'label_3' -} - -item { - id: 5 - name: 'label_4' -} - -item { - id: 6 - name: 'label_5' -} - -item { - id: 7 - name: 'label_6' -} - -item { - id: 8 - name: 'label_7' -} - -item { - id: 9 - name: 'label_8' -} - -item { - id: 10 - name: 'label_9' -} - diff --git a/datumaro/tests/assets/tf_detection_api_dataset/test.tfrecord b/datumaro/tests/assets/tf_detection_api_dataset/test.tfrecord deleted file mode 100644 index 81dafa705b5016e1aca25a4d245a292681d529fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 803 zcmWe;W&i`Rg9}Z#Ca`gda`9y5CZ?zAXQXDPXOsxBN-=XWvT{jq@k4}*^Gl18Q{yvJ zgjl(NGR9o}tX#YhsnopW{FKxbp}#C#zgW1wv;4pD{{e%5mz$>>10y2?10xVJ{J+iM z%)r6M#?Hpd!OqUk$;rXRBf`tW&CMezB+M@&D_WTR(nVgxdTk&}~?hnq)&msi3_QAW{-Wbpq0gCGY3D+3ENqY?v?AS1IN z|EI`>>pgaSMAghp~ zp(C4cU?RIxp@>oA#DyHnP8$!323`E1Vw_ae#K|QlE+HwUs-~`?sbyknW^Q3= z=I-I?6&w;879J59m7J2AmY$KBRa{b9R$ftA)!fqB*51+CHEHscsne#9glAUcUPH>GPMb z-@gC&`3vMPMh0exw}2$XXK4Ns1p14Kg@u`g9po=YrgD(S1zA`X4cUYo1KAS`g_VpN zIYgW$F5GyKQ`tD^gJ@FGMJ_QFlZUDwL0$vxYE#}NLy#lXYN2#h>tK?Zw< z4_X?F8tnhyIGdQ@kxWqDZ6Vp@m^OLetlS}lIlJYC`D{?dQgam~6xOgCn!K59R zG#4L4ZCYkdYF=V)st_j^I~S{=URFUmE`t&)U7(t zzqBYhH9j*%2M)G;tLF)@)>x3sk|ve7d(F#;LF$jQmc!_6bX%PV1|D5GdZGWdUhL6Cz%fI)zn zQHg;`kdaxC@&6G9d7wL48NmRSCK#ERSymaka3YSZQ|TeofBv2)j3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iRy8F3zKBFkrRk0JbZi-Cuk k5g2*Qf(-TyAGkCYHQ4{Zi7Q3yiD#gsj$ibz?B@PkJPs&P7F40fU zNh~hbFG;N^5#r|JBV$krWc<7m<~cmy?x}kx@|5Q&&*Z zQI?U>FxS*EFf=hSkyp30wJ@^LGd3{-8N$fP$;rdbBf-lnVWcRdXhbsje}F-dg8>Mc z8I>5A1R0qH8UG()kO#Vxl@SaeVGQImF|)9;v2$>8aRU`>6<}auWM*b!VP<7z0fqo$ zEl{3;MUYiU(a@1iI53f2sZhkIapFP_Wv7h?MT0JWP%%y_YU1P)6PJ*bQdLve(9|+9 zH8Z!cv~qTFb#wRd^a>6M4GWKmj7m;PO-s+n%qlJ^Ei136tZHs)ZENr7?3y%r%G7Do zXUv?nXz`Mz%a*TLxoXqqEnBy3-?4Mop~FXx9y@;G#!v`*nMGf} - - VOC2007 - 2007_000001.jpg - - 10 - 20 - 3 - - 1 - - cat - Unspecified - 1 - 0 - - 1 - 2 - 3 - 4 - - - - person - - 4 - 5 - 6 - 7 - - - head - - 5.5 - 6 - 7.5 - 8 - - - - 1 - 0 - 1 - 0 - 1 - 0 - 1 - 0 - 1 - 0 - 1 - - - diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Action/test.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Action/test.txt deleted file mode 100644 index c9fdc251..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Action/test.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000002 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Action/train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Action/train.txt deleted file mode 100644 index 640b0d53..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Action/train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Layout/test.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Layout/test.txt deleted file mode 100644 index c9fdc251..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Layout/test.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000002 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Layout/train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Layout/train.txt deleted file mode 100644 index 640b0d53..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Layout/train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/aeroplane_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/aeroplane_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/aeroplane_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/background_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/background_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/background_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bicycle_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/bicycle_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bicycle_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bird_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/bird_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bird_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/boat_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/boat_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/boat_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bottle_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/bottle_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bottle_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bus_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/bus_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/bus_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/car_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/car_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/car_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/cat_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/cat_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/cat_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/chair_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/chair_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/chair_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/cow_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/cow_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/cow_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/diningtable_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/diningtable_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/diningtable_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/dog_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/dog_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/dog_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/horse_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/horse_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/horse_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/ignored_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/ignored_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/ignored_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/motorbike_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/motorbike_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/motorbike_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/person_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/person_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/person_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/pottedplant_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/pottedplant_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/pottedplant_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/sheep_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/sheep_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/sheep_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/sofa_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/sofa_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/sofa_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/test.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/test.txt deleted file mode 100644 index c9fdc251..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/test.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000002 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/train.txt deleted file mode 100644 index 640b0d53..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/train_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/train_train.txt deleted file mode 100644 index a3decd42..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/train_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Main/tvmonitor_train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Main/tvmonitor_train.txt deleted file mode 100644 index d4385b69..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Main/tvmonitor_train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 -1 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/test.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/test.txt deleted file mode 100644 index c9fdc251..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/test.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000002 diff --git a/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/train.txt b/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/train.txt deleted file mode 100644 index 640b0d53..00000000 --- a/datumaro/tests/assets/voc_dataset/ImageSets/Segmentation/train.txt +++ /dev/null @@ -1 +0,0 @@ -2007_000001 diff --git a/datumaro/tests/assets/voc_dataset/JPEGImages/2007_000002.jpg b/datumaro/tests/assets/voc_dataset/JPEGImages/2007_000002.jpg deleted file mode 100644 index 3c81296b31dcb791dacede93e4be1f2d08b98347..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 635 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<W=16jCP7AKLB{__803NOWMu>c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGYWq|NkZcN!rf* diff --git a/datumaro/tests/assets/voc_dataset/SegmentationClass/2007_000001.png b/datumaro/tests/assets/voc_dataset/SegmentationClass/2007_000001.png deleted file mode 100644 index 0b92051452392a7827f09a27d7b5a179f93b3748..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 87 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!p6OjDO_R1be$$7dshH%K%9%N($@|qNC?|qTu iFly>iP(Hz7bcpfk8>Tj~#tAclatxlXelF{r5}E++^A#un diff --git a/datumaro/tests/assets/voc_dataset/SegmentationObject/2007_000001.png b/datumaro/tests/assets/voc_dataset/SegmentationObject/2007_000001.png deleted file mode 100644 index ebbeee61dd687d6cb27a7bfcef77b503095d10d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!pkYI@9SK0!kBt2amLpWqj4;nH8d508U#^1_5 ba0m#B7;6}~8DEFI0x9-%^>bP0l+XkKf*}+L diff --git a/datumaro/tests/assets/yolo_dataset/obj.data b/datumaro/tests/assets/yolo_dataset/obj.data deleted file mode 100644 index 16ca4090..00000000 --- a/datumaro/tests/assets/yolo_dataset/obj.data +++ /dev/null @@ -1,4 +0,0 @@ -classes = 10 -train = data/train.txt -names = data/obj.names -backup = backup/ diff --git a/datumaro/tests/assets/yolo_dataset/obj.names b/datumaro/tests/assets/yolo_dataset/obj.names deleted file mode 100644 index b24c644d..00000000 --- a/datumaro/tests/assets/yolo_dataset/obj.names +++ /dev/null @@ -1,10 +0,0 @@ -label_0 -label_1 -label_2 -label_3 -label_4 -label_5 -label_6 -label_7 -label_8 -label_9 diff --git a/datumaro/tests/assets/yolo_dataset/obj_train_data/1.jpg b/datumaro/tests/assets/yolo_dataset/obj_train_data/1.jpg deleted file mode 100644 index 8689b956311969f2efc9e3334f375c0ad65e24f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!v`*nMGf} 1]') - - self.assertEqual(2, len(filtered)) - - def test_annotations_filter_can_be_applied(self): - class SrcExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=0), - DatasetItem(id=1, annotations=[ - Label(0), - Label(1), - ]), - DatasetItem(id=2, annotations=[ - Label(0), - Label(2), - ]), - ]) - - class DstExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=0), - DatasetItem(id=1, annotations=[ - Label(0), - ]), - DatasetItem(id=2, annotations=[ - Label(0), - ]), - ]) - - extractor = SrcExtractor() - - filtered = XPathAnnotationsFilter(extractor, - '/item/annotation[label_id = 0]') - - self.assertListEqual(list(filtered), list(DstExtractor())) - - def test_annotations_filter_can_remove_empty_items(self): - class SrcExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=0), - DatasetItem(id=1, annotations=[ - Label(0), - Label(1), - ]), - DatasetItem(id=2, annotations=[ - Label(0), - Label(2), - ]), - ]) - - class DstExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=2, annotations=[ - Label(2), - ]), - ]) - - extractor = SrcExtractor() - - filtered = XPathAnnotationsFilter(extractor, - '/item/annotation[label_id = 2]', remove_empty=True) - - self.assertListEqual(list(filtered), list(DstExtractor())) - -class ConfigTest(TestCase): - def test_can_produce_multilayer_config_from_dict(self): - schema_low = SchemaBuilder() \ - .add('options', dict) \ - .build() - schema_mid = SchemaBuilder() \ - .add('desc', lambda: Config(schema=schema_low)) \ - .build() - schema_top = SchemaBuilder() \ - .add('container', lambda: DefaultConfig( - lambda v: Config(v, schema=schema_mid))) \ - .build() - - value = 1 - source = Config({ - 'container': { - 'elem': { - 'desc': { - 'options': { - 'k': value - } - } - } - } - }, schema=schema_top) - - self.assertEqual(value, source.container['elem'].desc.options['k']) - -class ExtractorTest(TestCase): - def test_custom_extractor_can_be_created(self): - class CustomExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=0, subset='train'), - DatasetItem(id=1, subset='train'), - DatasetItem(id=2, subset='train'), - - DatasetItem(id=3, subset='test'), - DatasetItem(id=4, subset='test'), - - DatasetItem(id=1), - DatasetItem(id=2), - DatasetItem(id=3), - ]) - - extractor_name = 'ext1' - project = Project() - project.env.extractors.register(extractor_name, CustomExtractor) - project.add_source('src1', { - 'url': 'path', - 'format': extractor_name, - }) - - dataset = project.make_dataset() - - compare_datasets(self, CustomExtractor(), dataset) - -class DatasetTest(TestCase): - def test_create_from_extractors(self): - class SrcExtractor1(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='train', annotations=[ - Bbox(1, 2, 3, 4), - Label(4), - ]), - DatasetItem(id=1, subset='val', annotations=[ - Label(4), - ]), - ]) - - class SrcExtractor2(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='val', annotations=[ - Label(5), - ]), - ]) - - class DstExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='train', annotations=[ - Bbox(1, 2, 3, 4), - Label(4), - ]), - DatasetItem(id=1, subset='val', annotations=[ - Label(4), - Label(5), - ]), - ]) - - dataset = Dataset.from_extractors(SrcExtractor1(), SrcExtractor2()) - - compare_datasets(self, DstExtractor(), dataset) - - -class DatasetItemTest(TestCase): - def test_ctor_requires_id(self): - with self.assertRaises(Exception): - # pylint: disable=no-value-for-parameter - DatasetItem() - # pylint: enable=no-value-for-parameter - - @staticmethod - def test_ctors_with_image(): - for args in [ - { 'id': 0, 'image': None }, - { 'id': 0, 'image': 'path.jpg' }, - { 'id': 0, 'image': np.array([1, 2, 3]) }, - { 'id': 0, 'image': lambda f: np.array([1, 2, 3]) }, - { 'id': 0, 'image': Image(data=np.array([1, 2, 3])) }, - ]: - DatasetItem(**args) \ No newline at end of file diff --git a/datumaro/tests/test_tfrecord_format.py b/datumaro/tests/test_tfrecord_format.py deleted file mode 100644 index f2dbd160..00000000 --- a/datumaro/tests/test_tfrecord_format.py +++ /dev/null @@ -1,210 +0,0 @@ -from functools import partial -import numpy as np -import os.path as osp - -from unittest import TestCase, skipIf -from datumaro.components.project import Dataset -from datumaro.components.extractor import (Extractor, DatasetItem, - AnnotationType, Bbox, Mask, LabelCategories -) -from datumaro.components.project import Project -from datumaro.util.image import Image -from datumaro.util.test_utils import TestDir, compare_datasets -from datumaro.util.tf_util import check_import - -try: - from datumaro.plugins.tf_detection_api_format.importer import TfDetectionApiImporter - from datumaro.plugins.tf_detection_api_format.extractor import TfDetectionApiExtractor - from datumaro.plugins.tf_detection_api_format.converter import TfDetectionApiConverter - import_failed = False -except ImportError: - import_failed = True - - import importlib - module_found = importlib.util.find_spec('tensorflow') is not None - - @skipIf(not module_found, "Tensorflow package is not found") - class TfImportTest(TestCase): - def test_raises_when_crashes_on_import(self): - # Should fire if import can't be done for any reason except - # module unavailability and import crash - with self.assertRaisesRegex(ImportError, 'Test process exit code'): - check_import() - -@skipIf(import_failed, "Failed to import tensorflow") -class TfrecordConverterTest(TestCase): - def _test_save_and_load(self, source_dataset, converter, test_dir, - target_dataset=None, importer_args=None): - converter(source_dataset, test_dir) - - if importer_args is None: - importer_args = {} - parsed_dataset = TfDetectionApiImporter()(test_dir, **importer_args) \ - .make_dataset() - - if target_dataset is None: - target_dataset = source_dataset - - compare_datasets(self, expected=target_dataset, actual=parsed_dataset) - - def test_can_save_bboxes(self): - test_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', - image=np.ones((16, 16, 3)), - annotations=[ - Bbox(0, 4, 4, 8, label=2), - Bbox(0, 4, 4, 4, label=3), - Bbox(2, 4, 4, 4), - ], attributes={'source_id': ''} - ), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(label) for label in range(10)), - }) - - with TestDir() as test_dir: - self._test_save_and_load( - test_dataset, - partial(TfDetectionApiConverter.convert, save_images=True), - test_dir) - - def test_can_save_masks(self): - test_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', image=np.ones((4, 5, 3)), - annotations=[ - Mask(image=np.array([ - [1, 0, 0, 1], - [0, 1, 1, 0], - [0, 1, 1, 0], - [1, 0, 0, 1], - ]), label=1), - ], - attributes={'source_id': ''} - ), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(label) for label in range(10)), - }) - - with TestDir() as test_dir: - self._test_save_and_load( - test_dataset, - partial(TfDetectionApiConverter.convert, save_masks=True), - test_dir) - - def test_can_save_dataset_with_no_subsets(self): - test_dataset = Dataset.from_iterable([ - DatasetItem(id=1, - image=np.ones((16, 16, 3)), - annotations=[ - Bbox(2, 1, 4, 4, label=2), - Bbox(4, 2, 8, 4, label=3), - ], - attributes={'source_id': ''} - ), - - DatasetItem(id=2, - image=np.ones((8, 8, 3)) * 2, - annotations=[ - Bbox(4, 4, 4, 4, label=3), - ], - attributes={'source_id': ''} - ), - - DatasetItem(id=3, - image=np.ones((8, 4, 3)) * 3, - attributes={'source_id': ''} - ), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(label) for label in range(10)), - }) - - with TestDir() as test_dir: - self._test_save_and_load( - test_dataset, - partial(TfDetectionApiConverter.convert, save_images=True), - test_dir) - - def test_can_save_dataset_with_image_info(self): - test_dataset = Dataset.from_iterable([ - DatasetItem(id='1/q.e', - image=Image(path='1/q.e', size=(10, 15)), - attributes={'source_id': ''} - ) - ], categories={ - AnnotationType.label: LabelCategories(), - }) - - with TestDir() as test_dir: - self._test_save_and_load(test_dataset, - TfDetectionApiConverter.convert, test_dir) - - def test_labelmap_parsing(self): - text = """ - { - id: 4 - name: 'qw1' - } - { - id: 5 name: 'qw2' - } - - { - name: 'qw3' - id: 6 - } - {name:'qw4' id:7} - """ - expected = { - 'qw1': 4, - 'qw2': 5, - 'qw3': 6, - 'qw4': 7, - } - parsed = TfDetectionApiExtractor._parse_labelmap(text) - - self.assertEqual(expected, parsed) - - -DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), - 'assets', 'tf_detection_api_dataset') - -@skipIf(import_failed, "Failed to import tensorflow") -class TfrecordImporterTest(TestCase): - def test_can_detect(self): - self.assertTrue(TfDetectionApiImporter.detect(DUMMY_DATASET_DIR)) - - def test_can_import(self): - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', - image=np.ones((16, 16, 3)), - annotations=[ - Bbox(0, 4, 4, 8, label=2), - Bbox(0, 4, 4, 4, label=3), - Bbox(2, 4, 4, 4), - ], - attributes={'source_id': '1'} - ), - - DatasetItem(id=2, subset='val', - image=np.ones((8, 8, 3)), - annotations=[ - Bbox(1, 2, 4, 2, label=3), - ], - attributes={'source_id': '2'} - ), - - DatasetItem(id=3, subset='test', - image=np.ones((5, 4, 3)) * 3, - attributes={'source_id': '3'} - ), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(label) for label in range(10)), - }) - - dataset = Project.import_from(DUMMY_DATASET_DIR, 'tf_detection_api') \ - .make_dataset() - - compare_datasets(self, target_dataset, dataset) diff --git a/datumaro/tests/test_transforms.py b/datumaro/tests/test_transforms.py deleted file mode 100644 index ed072a67..00000000 --- a/datumaro/tests/test_transforms.py +++ /dev/null @@ -1,415 +0,0 @@ -import logging as log -import numpy as np - -from unittest import TestCase -from datumaro.components.project import Dataset -from datumaro.components.extractor import (Extractor, DatasetItem, - Mask, Polygon, PolyLine, Points, Bbox, Label, - LabelCategories, MaskCategories, AnnotationType -) -import datumaro.util.mask_tools as mask_tools -import datumaro.plugins.transforms as transforms -from datumaro.util.test_utils import compare_datasets - - -class TransformsTest(TestCase): - def test_reindex(self): - class SrcExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=10), - DatasetItem(id=10, subset='train'), - DatasetItem(id='a', subset='val'), - ]) - - class DstExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=5), - DatasetItem(id=6, subset='train'), - DatasetItem(id=7, subset='val'), - ]) - - actual = transforms.Reindex(SrcExtractor(), start=5) - compare_datasets(self, DstExtractor(), actual) - - def test_mask_to_polygons(self): - class SrcExtractor(Extractor): - def __iter__(self): - items = [ - DatasetItem(id=1, image=np.zeros((5, 10, 3)), - annotations=[ - Mask(np.array([ - [0, 1, 1, 1, 0, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 0, 1, 1, 1, 0, 0], - [0, 0, 0, 1, 0, 1, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ]), - ), - ] - ), - ] - return iter(items) - - class DstExtractor(Extractor): - def __iter__(self): - return iter([ - DatasetItem(id=1, image=np.zeros((5, 10, 3)), - annotations=[ - Polygon([3.0, 2.5, 1.0, 0.0, 3.5, 0.0, 3.0, 2.5]), - Polygon([5.0, 3.5, 4.5, 0.0, 8.0, 0.0, 5.0, 3.5]), - ] - ), - ]) - - actual = transforms.MasksToPolygons(SrcExtractor()) - compare_datasets(self, DstExtractor(), actual) - - def test_mask_to_polygons_small_polygons_message(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 10, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 0], - [0, 1, 0], - [0, 0, 0], - ]), - ), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 10, 3))), ]) - - with self.assertLogs(level=log.DEBUG) as logs: - actual = transforms.MasksToPolygons(source_dataset) - - compare_datasets(self, target_dataset, actual) - self.assertRegex('\n'.join(logs.output), 'too small polygons') - - def test_polygons_to_masks(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 10, 3)), - annotations=[ - Polygon([0, 0, 4, 0, 4, 4]), - Polygon([5, 0, 9, 0, 5, 5]), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 10, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 0, 0, 0, 1, 1, 1, 1, 0], - [0, 0, 0, 0, 0, 1, 1, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ]), - ), - Mask(np.array([ - [0, 1, 1, 1, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ]), - ), - ] - ), - ]) - - actual = transforms.PolygonsToMasks(source_dataset) - compare_datasets(self, target_dataset, actual) - - def test_crop_covered_segments(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - # The mask is partially covered by the polygon - Mask(np.array([ - [0, 0, 1, 1, 1], - [0, 0, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 0, 0], - [1, 1, 1, 0, 0]], - ), - z_order=0), - Polygon([1, 1, 4, 1, 4, 4, 1, 4], - z_order=1), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 1, 1, 1], - [0, 0, 0, 0, 1], - [1, 0, 0, 0, 1], - [1, 0, 0, 0, 0], - [1, 1, 1, 0, 0]], - ), - z_order=0), - Polygon([1, 1, 4, 1, 4, 4, 1, 4], - z_order=1), - ] - ), - ]) - - actual = transforms.CropCoveredSegments(source_dataset) - compare_datasets(self, target_dataset, actual) - - def test_merge_instance_segments(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 1, 1, 1], - [0, 0, 0, 0, 1], - [1, 0, 0, 0, 1], - [1, 0, 0, 0, 0], - [1, 1, 1, 0, 0]], - ), - z_order=0, group=1), - Polygon([1, 1, 4, 1, 4, 4, 1, 4], - z_order=1, group=1), - Polygon([0, 0, 0, 2, 2, 2, 2, 0], - z_order=1), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 1, 1, 1], - [0, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 0], - [1, 1, 1, 0, 0]], - ), - z_order=0, group=1), - Mask(np.array([ - [1, 1, 0, 0, 0], - [1, 1, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0]], - ), - z_order=1), - ] - ), - ]) - - actual = transforms.MergeInstanceSegments(source_dataset, - include_polygons=True) - compare_datasets(self, target_dataset, actual) - - def test_map_subsets(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='a'), - DatasetItem(id=2, subset='b'), - DatasetItem(id=3, subset='c'), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset=''), - DatasetItem(id=2, subset='a'), - DatasetItem(id=3, subset='c'), - ]) - - actual = transforms.MapSubsets(source_dataset, - { 'a': '', 'b': 'a' }) - compare_datasets(self, target_dataset, actual) - - def test_shapes_to_boxes(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Mask(np.array([ - [0, 0, 1, 1, 1], - [0, 0, 0, 0, 1], - [1, 0, 0, 0, 1], - [1, 0, 0, 0, 0], - [1, 1, 1, 0, 0]], - ), id=1), - Polygon([1, 1, 4, 1, 4, 4, 1, 4], id=2), - PolyLine([1, 1, 2, 1, 2, 2, 1, 2], id=3), - Points([2, 2, 4, 2, 4, 4, 2, 4], id=4), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Bbox(0, 0, 4, 4, id=1), - Bbox(1, 1, 3, 3, id=2), - Bbox(1, 1, 1, 1, id=3), - Bbox(2, 2, 2, 2, id=4), - ] - ), - ]) - - actual = transforms.ShapesToBoxes(source_dataset) - compare_datasets(self, target_dataset, actual) - - def test_id_from_image(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image='path.jpg'), - DatasetItem(id=2), - ]) - target_dataset = Dataset.from_iterable([ - DatasetItem(id='path', image='path.jpg'), - DatasetItem(id=2), - ]) - - actual = transforms.IdFromImageName(source_dataset) - compare_datasets(self, target_dataset, actual) - - def test_boxes_to_masks(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Bbox(0, 0, 3, 3, z_order=1), - Bbox(0, 0, 3, 1, z_order=2), - Bbox(0, 2, 3, 1, z_order=3), - ] - ), - ]) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1, image=np.zeros((5, 5, 3)), - annotations=[ - Mask(np.array([ - [1, 1, 1, 0, 0], - [1, 1, 1, 0, 0], - [1, 1, 1, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0]], - ), - z_order=1), - Mask(np.array([ - [1, 1, 1, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0]], - ), - z_order=2), - Mask(np.array([ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [1, 1, 1, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0]], - ), - z_order=3), - ] - ), - ]) - - actual = transforms.BoxesToMasks(source_dataset) - compare_datasets(self, target_dataset, actual) - - def test_random_split(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset="a"), - DatasetItem(id=2, subset="a"), - DatasetItem(id=3, subset="b"), - DatasetItem(id=4, subset="b"), - DatasetItem(id=5, subset="b"), - DatasetItem(id=6, subset=""), - DatasetItem(id=7, subset=""), - ]) - - actual = transforms.RandomSplit(source_dataset, splits=[ - ('train', 4.0 / 7.0), - ('test', 3.0 / 7.0), - ]) - - self.assertEqual(4, len(actual.get_subset('train'))) - self.assertEqual(3, len(actual.get_subset('test'))) - - def test_random_split_gives_error_on_wrong_ratios(self): - source_dataset = Dataset.from_iterable([DatasetItem(id=1)]) - - with self.assertRaises(Exception): - transforms.RandomSplit(source_dataset, splits=[ - ('train', 0.5), - ('test', 0.7), - ]) - - with self.assertRaises(Exception): - transforms.RandomSplit(source_dataset, splits=[]) - - with self.assertRaises(Exception): - transforms.RandomSplit(source_dataset, splits=[ - ('train', -0.5), - ('test', 1.5), - ]) - - def test_remap_labels(self): - src_dataset = Dataset.from_iterable([ - DatasetItem(id=1, annotations=[ - # Should be remapped - Label(1), - Bbox(1, 2, 3, 4, label=2), - Mask(image=np.array([1]), label=3), - - # Should be kept - Polygon([1, 1, 2, 2, 3, 4], label=4), - PolyLine([1, 3, 4, 2, 5, 6]) - ]) - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label%s' % i for i in range(5)), - AnnotationType.mask: MaskCategories( - colormap=mask_tools.generate_colormap(5)), - }) - - dst_dataset = Dataset.from_iterable([ - DatasetItem(id=1, annotations=[ - Label(1), - Bbox(1, 2, 3, 4, label=0), - Mask(image=np.array([1]), label=1), - - Polygon([1, 1, 2, 2, 3, 4], label=2), - PolyLine([1, 3, 4, 2, 5, 6], label=None) - ]), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - ['label0', 'label9', 'label4']), - AnnotationType.mask: MaskCategories(colormap={ - k: v for k, v in mask_tools.generate_colormap(5).items() - if k in { 0, 1, 3, 4 } - }) - }) - - actual = transforms.RemapLabels(src_dataset, mapping={ - 'label1': 'label9', - 'label2': 'label0', - 'label3': 'label9', - }, default='keep') - - compare_datasets(self, dst_dataset, actual) - - def test_remap_labels_delete_unspecified(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, annotations=[ Label(0) ]) - ], categories=['label0']) - - target_dataset = Dataset.from_iterable([ - DatasetItem(id=1), - ], categories=[]) - - actual = transforms.RemapLabels(source_dataset, - mapping={}, default='delete') - - compare_datasets(self, target_dataset, actual) diff --git a/datumaro/tests/test_voc_format.py b/datumaro/tests/test_voc_format.py deleted file mode 100644 index e83a7430..00000000 --- a/datumaro/tests/test_voc_format.py +++ /dev/null @@ -1,677 +0,0 @@ -from collections import OrderedDict -from functools import partial -import numpy as np -import os.path as osp - -from unittest import TestCase - -from datumaro.components.extractor import (Extractor, DatasetItem, - AnnotationType, Label, Bbox, Mask, LabelCategories, -) -import datumaro.plugins.voc_format.format as VOC -from datumaro.plugins.voc_format.converter import ( - VocConverter, - VocClassificationConverter, - VocDetectionConverter, - VocLayoutConverter, - VocActionConverter, - VocSegmentationConverter, -) -from datumaro.plugins.voc_format.importer import VocImporter -from datumaro.components.project import Project -from datumaro.util.image import Image -from datumaro.util.test_utils import TestDir, compare_datasets - - -class VocFormatTest(TestCase): - def test_colormap_generator(self): - reference = np.array([ - [ 0, 0, 0], - [128, 0, 0], - [ 0, 128, 0], - [128, 128, 0], - [ 0, 0, 128], - [128, 0, 128], - [ 0, 128, 128], - [128, 128, 128], - [ 64, 0, 0], - [192, 0, 0], - [ 64, 128, 0], - [192, 128, 0], - [ 64, 0, 128], - [192, 0, 128], - [ 64, 128, 128], - [192, 128, 128], - [ 0, 64, 0], - [128, 64, 0], - [ 0, 192, 0], - [128, 192, 0], - [ 0, 64, 128], - [224, 224, 192], # ignored - ]) - - self.assertTrue(np.array_equal(reference, list(VOC.VocColormap.values()))) - - def test_can_write_and_parse_labelmap(self): - src_label_map = VOC.make_voc_label_map() - src_label_map['qq'] = [None, ['part1', 'part2'], ['act1', 'act2']] - src_label_map['ww'] = [(10, 20, 30), [], ['act3']] - - with TestDir() as test_dir: - file_path = osp.join(test_dir, 'test.txt') - - VOC.write_label_map(file_path, src_label_map) - dst_label_map = VOC.parse_label_map(file_path) - - self.assertEqual(src_label_map, dst_label_map) - -class TestExtractorBase(Extractor): - def _label(self, voc_label): - return self.categories()[AnnotationType.label].find(voc_label)[0] - - def categories(self): - return VOC.make_voc_categories() - - -DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets', 'voc_dataset') - -class VocImportTest(TestCase): - def test_can_import(self): - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='2007_000001', subset='train', - image=Image(path='2007_000001.jpg', size=(20, 10)), - annotations=[ - Label(self._label(l.name)) - for l in VOC.VocLabel if l.value % 2 == 1 - ] + [ - Bbox(1, 2, 2, 2, label=self._label('cat'), - attributes={ - 'pose': VOC.VocPose(1).name, - 'truncated': True, - 'difficult': False, - 'occluded': False, - }, - id=1, group=1, - ), - Bbox(4, 5, 2, 2, label=self._label('person'), - attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - **{ - a.name: a.value % 2 == 1 - for a in VOC.VocAction - } - }, - id=2, group=2, - ), - Bbox(5.5, 6, 2, 2, label=self._label( - VOC.VocBodyPart(1).name), - group=2 - ), - Mask(image=np.ones([5, 10]), - label=self._label(VOC.VocLabel(2).name), - group=1, - ), - ] - ), - DatasetItem(id='2007_000002', subset='test', - image=np.zeros((20, 10, 3))), - ]) - - dataset = Project.import_from(DUMMY_DATASET_DIR, 'voc').make_dataset() - - compare_datasets(self, DstExtractor(), dataset) - - def test_can_detect_voc(self): - self.assertTrue(VocImporter.detect(DUMMY_DATASET_DIR)) - -class VocConverterTest(TestCase): - def _test_save_and_load(self, source_dataset, converter, test_dir, - target_dataset=None, importer_args=None): - converter(source_dataset, test_dir) - - if importer_args is None: - importer_args = {} - parsed_dataset = VocImporter()(test_dir, **importer_args).make_dataset() - - if target_dataset is None: - target_dataset = source_dataset - - compare_datasets(self, expected=target_dataset, actual=parsed_dataset) - - def test_can_save_voc_cls(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/0', subset='a', annotations=[ - Label(1), - Label(2), - Label(3), - ]), - - DatasetItem(id=1, subset='b', annotations=[ - Label(4), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocClassificationConverter.convert, label_map='voc'), - test_dir) - - def test_can_save_voc_det(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/1', subset='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, - attributes={ 'occluded': True } - ), - Bbox(2, 3, 4, 5, label=3, - attributes={ 'truncated': True }, - ), - ]), - - DatasetItem(id=2, subset='b', annotations=[ - Bbox(5, 4, 6, 5, label=3, - attributes={ 'difficult': True }, - ), - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/1', subset='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, id=1, group=1, - attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': True, - } - ), - Bbox(2, 3, 4, 5, label=3, id=2, group=2, - attributes={ - 'truncated': True, - 'difficult': False, - 'occluded': False, - }, - ), - ]), - - DatasetItem(id=2, subset='b', annotations=[ - Bbox(5, 4, 6, 5, label=3, id=1, group=1, - attributes={ - 'truncated': False, - 'difficult': True, - 'occluded': False, - }, - ), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocDetectionConverter.convert, label_map='voc'), - test_dir, target_dataset=DstExtractor()) - - def test_can_save_voc_segm(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/b/1', subset='a', annotations=[ - # overlapping masks, the first should be truncated - # the second and third are different instances - Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, - z_order=3), - Mask(image=np.array([[0, 1, 1, 1, 0]]), label=4, - z_order=1), - Mask(image=np.array([[1, 1, 0, 0, 0]]), label=3, - z_order=2), - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/b/1', subset='a', annotations=[ - Mask(image=np.array([[0, 0, 1, 0, 0]]), label=4, - group=1), - Mask(image=np.array([[1, 1, 0, 0, 0]]), label=3, - group=2), - Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, - group=3), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocSegmentationConverter.convert, label_map='voc'), - test_dir, target_dataset=DstExtractor()) - - def test_can_save_voc_segm_unpainted(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='a', annotations=[ - # overlapping masks, the first should be truncated - # the second and third are different instances - Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, - z_order=3), - Mask(image=np.array([[0, 1, 1, 1, 0]]), label=4, - z_order=1), - Mask(image=np.array([[1, 1, 0, 0, 0]]), label=3, - z_order=2), - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='a', annotations=[ - Mask(image=np.array([[0, 0, 1, 0, 0]]), label=4, - group=1), - Mask(image=np.array([[1, 1, 0, 0, 0]]), label=3, - group=2), - Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, - group=3), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocSegmentationConverter.convert, - label_map='voc', apply_colormap=False), - test_dir, target_dataset=DstExtractor()) - - def test_can_save_voc_segm_with_many_instances(self): - def bit(x, y, shape): - mask = np.zeros(shape) - mask[y, x] = 1 - return mask - - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='a', annotations=[ - Mask(image=bit(x, y, shape=[10, 10]), - label=self._label(VOC.VocLabel(3).name), - z_order=10 * y + x + 1 - ) - for y in range(10) for x in range(10) - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='a', annotations=[ - Mask(image=bit(x, y, shape=[10, 10]), - label=self._label(VOC.VocLabel(3).name), - group=10 * y + x + 1 - ) - for y in range(10) for x in range(10) - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocSegmentationConverter.convert, label_map='voc'), - test_dir, target_dataset=DstExtractor()) - - def test_can_save_voc_layout(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/b/1', subset='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, id=1, group=1, - attributes={ - 'pose': VOC.VocPose(1).name, - 'truncated': True, - 'difficult': False, - 'occluded': False, - } - ), - Bbox(2, 3, 1, 1, label=self._label( - VOC.VocBodyPart(1).name), group=1), - Bbox(5, 4, 3, 2, label=self._label( - VOC.VocBodyPart(2).name), group=1), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocLayoutConverter.convert, label_map='voc'), test_dir) - - def test_can_save_voc_action(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/b/1', subset='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, - attributes={ - 'truncated': True, - VOC.VocAction(1).name: True, - VOC.VocAction(2).name: True, - } - ), - Bbox(5, 4, 3, 2, label=self._label('person'), - attributes={ - 'truncated': True, - VOC.VocAction(1).name: True, - VOC.VocAction(2).name: True, - } - ), - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a/b/1', subset='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, - id=1, group=1, attributes={ - 'truncated': True, - 'difficult': False, - 'occluded': False, - # no attributes here in the label categories - } - ), - Bbox(5, 4, 3, 2, label=self._label('person'), - id=2, group=2, attributes={ - 'truncated': True, - 'difficult': False, - 'occluded': False, - VOC.VocAction(1).name: True, - VOC.VocAction(2).name: True, - **{ - a.name: False for a in VOC.VocAction - if a.value not in {1, 2} - } - } - ), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocActionConverter.convert, - label_map='voc', allow_attributes=False), test_dir, - target_dataset=DstExtractor()) - - def test_can_save_dataset_with_no_subsets(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, annotations=[ - Label(2), - Label(3), - ]), - - DatasetItem(id=2, annotations=[ - Label(3), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocConverter.convert, label_map='voc'), test_dir) - - def test_can_save_dataset_with_images(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, subset='a', image=np.ones([4, 5, 3])), - DatasetItem(id=2, subset='a', image=np.ones([5, 4, 3])), - - DatasetItem(id=3, subset='b', image=np.ones([2, 6, 3])), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocConverter.convert, label_map='voc', save_images=True), - test_dir) - - def test_dataset_with_voc_labelmap(self): - class SrcExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=self._label('cat'), id=1), - Bbox(1, 2, 3, 4, label=self._label('non_voc_label'), id=2), - ]) - - def categories(self): - label_cat = LabelCategories() - label_cat.add(VOC.VocLabel.cat.name) - label_cat.add('non_voc_label') - return { - AnnotationType.label: label_cat, - } - - class DstExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - # drop non voc label - Bbox(2, 3, 4, 5, label=self._label('cat'), id=1, group=1, - attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - ]) - - def categories(self): - return VOC.make_voc_categories() - - with TestDir() as test_dir: - self._test_save_and_load(SrcExtractor(), - partial(VocConverter.convert, label_map='voc'), - test_dir, target_dataset=DstExtractor()) - - def test_dataset_with_source_labelmap_undefined(self): - class SrcExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=0, id=1), - Bbox(1, 2, 3, 4, label=1, id=2), - ]) - - def categories(self): - label_cat = LabelCategories() - label_cat.add('Label_1') - label_cat.add('label_2') - return { - AnnotationType.label: label_cat, - } - - class DstExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=self._label('Label_1'), - id=1, group=1, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - Bbox(1, 2, 3, 4, label=self._label('label_2'), - id=2, group=2, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - ]) - - def categories(self): - label_map = OrderedDict() - label_map['background'] = [None, [], []] - label_map['Label_1'] = [None, [], []] - label_map['label_2'] = [None, [], []] - return VOC.make_voc_categories(label_map) - - with TestDir() as test_dir: - self._test_save_and_load(SrcExtractor(), - partial(VocConverter.convert, label_map='source'), - test_dir, target_dataset=DstExtractor()) - - def test_dataset_with_source_labelmap_defined(self): - class SrcExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=0, id=1), - Bbox(1, 2, 3, 4, label=2, id=2), - ]) - - def categories(self): - label_map = OrderedDict() - label_map['label_1'] = [(1, 2, 3), [], []] - label_map['background'] = [(0, 0, 0), [], []] # can be not 0 - label_map['label_2'] = [(3, 2, 1), [], []] - return VOC.make_voc_categories(label_map) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=self._label('label_1'), - id=1, group=1, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - Bbox(1, 2, 3, 4, label=self._label('label_2'), - id=2, group=2, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - ]) - - def categories(self): - label_map = OrderedDict() - label_map['background'] = [(0, 0, 0), [], []] - label_map['label_1'] = [(1, 2, 3), [], []] - label_map['label_2'] = [(3, 2, 1), [], []] - return VOC.make_voc_categories(label_map) - - with TestDir() as test_dir: - self._test_save_and_load(SrcExtractor(), - partial(VocConverter.convert, label_map='source'), - test_dir, target_dataset=DstExtractor()) - - def test_dataset_with_fixed_labelmap(self): - class SrcExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=self._label('foreign_label'), id=1), - Bbox(1, 2, 3, 4, label=self._label('label'), id=2, group=2, - attributes={'act1': True}), - Bbox(2, 3, 4, 5, label=self._label('label_part1'), group=2), - Bbox(2, 3, 4, 6, label=self._label('label_part2'), group=2), - ]) - - def categories(self): - label_cat = LabelCategories() - label_cat.add('foreign_label') - label_cat.add('label', attributes=['act1', 'act2']) - label_cat.add('label_part1') - label_cat.add('label_part2') - return { - AnnotationType.label: label_cat, - } - - label_map = OrderedDict([ - ('label', [None, ['label_part1', 'label_part2'], ['act1', 'act2']]) - ]) - - dst_label_map = OrderedDict([ - ('background', [None, [], []]), - ('label', [None, ['label_part1', 'label_part2'], ['act1', 'act2']]) - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(1, 2, 3, 4, label=self._label('label'), id=1, group=1, - attributes={ - 'act1': True, - 'act2': False, - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - Bbox(2, 3, 4, 5, label=self._label('label_part1'), group=1), - Bbox(2, 3, 4, 6, label=self._label('label_part2'), group=1), - ]) - - def categories(self): - return VOC.make_voc_categories(dst_label_map) - - with TestDir() as test_dir: - self._test_save_and_load(SrcExtractor(), - partial(VocConverter.convert, label_map=label_map), - test_dir, target_dataset=DstExtractor()) - - def test_can_save_dataset_with_image_info(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id=1, image=Image(path='1.jpg', size=(10, 15))), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocConverter.convert, label_map='voc'), test_dir) - - def test_relative_paths(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='1', image=np.ones((4, 2, 3))), - DatasetItem(id='subdir1/1', image=np.ones((2, 6, 3))), - DatasetItem(id='subdir2/1', image=np.ones((5, 4, 3))), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocConverter.convert, - label_map='voc', save_images=True), - test_dir) - - def test_can_save_attributes(self): - class TestExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, - attributes={ 'occluded': True, 'x': 1, 'y': '2' } - ), - ]), - ]) - - class DstExtractor(TestExtractorBase): - def __iter__(self): - return iter([ - DatasetItem(id='a', annotations=[ - Bbox(2, 3, 4, 5, label=2, id=1, group=1, - attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': True, - 'x': '1', 'y': '2', # can only read strings - } - ), - ]), - ]) - - with TestDir() as test_dir: - self._test_save_and_load(TestExtractor(), - partial(VocConverter.convert, label_map='voc'), test_dir, - target_dataset=DstExtractor()) diff --git a/datumaro/tests/test_yolo_format.py b/datumaro/tests/test_yolo_format.py deleted file mode 100644 index 1f6425d1..00000000 --- a/datumaro/tests/test_yolo_format.py +++ /dev/null @@ -1,140 +0,0 @@ -import numpy as np -import os.path as osp - -from unittest import TestCase - -from datumaro.components.extractor import (Extractor, DatasetItem, - AnnotationType, Bbox, LabelCategories, -) -from datumaro.components.project import Project, Dataset -from datumaro.plugins.yolo_format.importer import YoloImporter -from datumaro.plugins.yolo_format.converter import YoloConverter -from datumaro.util.image import Image, save_image -from datumaro.util.test_utils import TestDir, compare_datasets - - -class YoloFormatTest(TestCase): - def test_can_save_and_load(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', image=np.ones((8, 8, 3)), - annotations=[ - Bbox(0, 2, 4, 2, label=2), - Bbox(0, 1, 2, 3, label=4), - ]), - DatasetItem(id=2, subset='train', image=np.ones((10, 10, 3)), - annotations=[ - Bbox(0, 2, 4, 2, label=2), - Bbox(3, 3, 2, 3, label=4), - Bbox(2, 1, 2, 3, label=4), - ]), - - DatasetItem(id=3, subset='valid', image=np.ones((8, 8, 3)), - annotations=[ - Bbox(0, 1, 5, 2, label=2), - Bbox(0, 2, 3, 2, label=5), - Bbox(0, 2, 4, 2, label=6), - Bbox(0, 7, 3, 2, label=7), - ]), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(i) for i in range(10)), - }) - - with TestDir() as test_dir: - - YoloConverter.convert(source_dataset, test_dir, save_images=True) - parsed_dataset = YoloImporter()(test_dir).make_dataset() - - compare_datasets(self, source_dataset, parsed_dataset) - - def test_can_save_dataset_with_image_info(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', - image=Image(path='1.jpg', size=(10, 15)), - annotations=[ - Bbox(0, 2, 4, 2, label=2), - Bbox(3, 3, 2, 3, label=4), - ]), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(i) for i in range(10)), - }) - - with TestDir() as test_dir: - - YoloConverter.convert(source_dataset, test_dir) - - save_image(osp.join(test_dir, 'obj_train_data', '1.jpg'), - np.ones((10, 15, 3))) # put the image for dataset - parsed_dataset = YoloImporter()(test_dir).make_dataset() - - compare_datasets(self, source_dataset, parsed_dataset) - - def test_can_load_dataset_with_exact_image_info(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', - image=Image(path='1.jpg', size=(10, 15)), - annotations=[ - Bbox(0, 2, 4, 2, label=2), - Bbox(3, 3, 2, 3, label=4), - ]), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(i) for i in range(10)), - }) - - with TestDir() as test_dir: - - YoloConverter.convert(source_dataset, test_dir) - - parsed_dataset = YoloImporter()(test_dir, - image_info={'1': (10, 15)}).make_dataset() - - compare_datasets(self, source_dataset, parsed_dataset) - - def test_relative_paths(self): - source_dataset = Dataset.from_iterable([ - DatasetItem(id='1', subset='train', - image=np.ones((4, 2, 3))), - DatasetItem(id='subdir1/1', subset='train', - image=np.ones((2, 6, 3))), - DatasetItem(id='subdir2/1', subset='train', - image=np.ones((5, 4, 3))), - ], categories={ - AnnotationType.label: LabelCategories(), - }) - - for save_images in {True, False}: - with self.subTest(save_images=save_images): - with TestDir() as test_dir: - - YoloConverter.convert(source_dataset, test_dir, - save_images=save_images) - parsed_dataset = YoloImporter()(test_dir).make_dataset() - - compare_datasets(self, source_dataset, parsed_dataset) - - -DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets', 'yolo_dataset') - -class YoloImporterTest(TestCase): - def test_can_detect(self): - self.assertTrue(YoloImporter.detect(DUMMY_DATASET_DIR)) - - def test_can_import(self): - expected_dataset = Dataset.from_iterable([ - DatasetItem(id=1, subset='train', - image=np.ones((10, 15, 3)), - annotations=[ - Bbox(0, 2, 4, 2, label=2), - Bbox(3, 3, 2, 3, label=4), - ]), - ], categories={ - AnnotationType.label: LabelCategories.from_iterable( - 'label_' + str(i) for i in range(10)), - }) - - dataset = Project.import_from(DUMMY_DATASET_DIR, 'yolo') \ - .make_dataset() - - compare_datasets(self, expected_dataset, dataset)