Skip to content

Commit 0eb005c

Browse files
Support relative paths in import and export (#1463)
* Move annotations to dm * Refactor dm * Rename data manager * Move anno dump and upload functions * Join server host and port in cvat cli * Move export templates dir * add dm project exporter * update mask format support * Use decorators for formats definition * Update formats * Update format implementations * remove parameter * Add dm views * Move annotation components to dm * restore extension for export formats * update rest api * use serializers, update views * merge develop * Update format names * Update docs * Update tests * move test * fix import * Extend format tests * django compatibility for directory access * move tests * update module links * fixes * fix git application * fixes * add extension recommentation * fixes * api * join api methods * Add trim whitespace to workspace config * update tests * fixes * Update format docs * join format queries * fixes * update new ui * ui tests * old ui * update js bundles * linter fixes * add image with loader tests * fix linter * fix frame step and frame access * use server file name for annotations export * update cvat core * add import hack for rest api tests * move cli tests * fix cvat format converter args parsing * remove folder on extract error * print error message on incorrect xpath expression * use own categories when no others exist * update changelog * really add text to changelog * Fix annotation window menu * fix ui * fix replace * update extra apps * format readme * readme * linter * Fix old ui * Update CHANGELOG.md * update user guide * linter * more linter fixes * update changelog * Add image attributes * add directory check in save image * update image tests * update image dir format with relative paths * update datumaro format * update coco format * update cvat format * update labelme format * update mot format * update image dir format * update voc format * update mot format * update yolo format * update labelme test * update voc format * update tfrecord format * fixes * update save_image usage * remove item name conversion * fix merge * fix export * prohibit relative paths in labelme format * Add test for relative name matching * move code * implement frame matching * fix yolo * fix merge * fix merge * prettify code * fix methid call * fix frame matching in yolo * add tests * regularize function output * update changelog * fixes * fix z_order use * fix slash replacement * linter * t * t2 Co-authored-by: Nikita Manovich <[email protected]>
1 parent e1e90e1 commit 0eb005c

File tree

4 files changed

+159
-31
lines changed

4 files changed

+159
-31
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
### Changed
2020
- Removed information about e-mail from the basic user information (<https://github.com/opencv/cvat/pull/1627>)
2121
- Update https install manual. Makes it easier and more robust. Includes automatic renewing of lets encrypt certificates.
22+
- Implemented import and export of annotations with relative image paths (<https://github.com/opencv/cvat/pull/1463>)
2223

2324
### Deprecated
2425
-

cvat/apps/dataset_manager/bindings.py

+48-24
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import os.path as osp
77
from collections import OrderedDict, namedtuple
8+
from pathlib import Path
89

910
from django.utils import timezone
1011

@@ -125,8 +126,8 @@ def _init_frame_info(self):
125126
} for db_image in self._db_task.data.images.all()}
126127

127128
self._frame_mapping = {
128-
self._get_filename(info["path"]): frame
129-
for frame, info in self._frame_info.items()
129+
self._get_filename(info["path"]): frame_number
130+
for frame_number, info in self._frame_info.items()
130131
}
131132

132133
def _init_meta(self):
@@ -398,16 +399,27 @@ def db_task(self):
398399

399400
@staticmethod
400401
def _get_filename(path):
401-
return osp.splitext(osp.basename(path))[0]
402-
403-
def match_frame(self, filename):
404-
# try to match by filename
405-
_filename = self._get_filename(filename)
406-
if _filename in self._frame_mapping:
407-
return self._frame_mapping[_filename]
408-
409-
raise Exception(
410-
"Cannot match filename or determine frame number for {} filename".format(filename))
402+
return osp.splitext(path)[0]
403+
404+
def match_frame(self, path, root_hint=None):
405+
path = self._get_filename(path)
406+
match = self._frame_mapping.get(path)
407+
if not match and root_hint and not path.startswith(root_hint):
408+
path = osp.join(root_hint, path)
409+
match = self._frame_mapping.get(path)
410+
return match
411+
412+
def match_frame_fuzzy(self, path):
413+
# Preconditions:
414+
# - The input dataset is full, i.e. all items present. Partial dataset
415+
# matching can't be correct for all input cases.
416+
# - path is the longest path of input dataset in terms of path parts
417+
418+
path = Path(self._get_filename(path)).parts
419+
for p, v in self._frame_mapping.items():
420+
if Path(p).parts[-len(path):] == path: # endswith() for paths
421+
return v
422+
return None
411423

412424
class CvatTaskDataExtractor(datumaro.SourceExtractor):
413425
def __init__(self, task_data, include_images=False):
@@ -450,8 +462,7 @@ def categories(self):
450462
def _load_categories(cvat_anno):
451463
categories = {}
452464

453-
label_categories = datumaro.LabelCategories(
454-
attributes=['occluded', 'z_order'])
465+
label_categories = datumaro.LabelCategories(attributes=['occluded'])
455466

456467
for _, label in cvat_anno.meta['task']['labels']:
457468
label_categories.add(label['name'])
@@ -537,20 +548,14 @@ def convert_attrs(label, cvat_attrs):
537548

538549
return item_anno
539550

540-
def match_frame(item, task_data):
551+
def match_dm_item(item, task_data, root_hint=None):
541552
is_video = task_data.meta['task']['mode'] == 'interpolation'
542553

543554
frame_number = None
544555
if frame_number is None and item.has_image:
545-
try:
546-
frame_number = task_data.match_frame(item.image.path)
547-
except Exception:
548-
pass
556+
frame_number = task_data.match_frame(item.image.path, root_hint)
549557
if frame_number is None:
550-
try:
551-
frame_number = task_data.match_frame(item.id)
552-
except Exception:
553-
pass
558+
frame_number = task_data.match_frame(item.id, root_hint)
554559
if frame_number is None:
555560
frame_number = cast(item.attributes.get('frame', item.id), int)
556561
if frame_number is None and is_video:
@@ -561,6 +566,19 @@ def match_frame(item, task_data):
561566
item.id)
562567
return frame_number
563568

569+
def find_dataset_root(dm_dataset, task_data):
570+
longest_path = max(dm_dataset, key=lambda x: len(Path(x.id).parts)).id
571+
longest_match = task_data.match_frame_fuzzy(longest_path)
572+
if longest_match is None:
573+
return None
574+
575+
longest_match = osp.dirname(task_data.frame_info[longest_match]['path'])
576+
prefix = longest_match[:-len(osp.dirname(longest_path)) or None]
577+
if prefix.endswith('/'):
578+
prefix = prefix[:-1]
579+
return prefix
580+
581+
564582
def import_dm_annotations(dm_dataset, task_data):
565583
shapes = {
566584
datumaro.AnnotationType.bbox: ShapeType.RECTANGLE,
@@ -569,10 +587,16 @@ def import_dm_annotations(dm_dataset, task_data):
569587
datumaro.AnnotationType.points: ShapeType.POINTS,
570588
}
571589

590+
if len(dm_dataset) == 0:
591+
return
592+
572593
label_cat = dm_dataset.categories()[datumaro.AnnotationType.label]
573594

595+
root_hint = find_dataset_root(dm_dataset, task_data)
596+
574597
for item in dm_dataset:
575-
frame_number = task_data.abs_frame_id(match_frame(item, task_data))
598+
frame_number = task_data.abs_frame_id(
599+
match_dm_item(item, task_data, root_hint=root_hint))
576600

577601
# do not store one-item groups
578602
group_map = {0: 0}

cvat/apps/dataset_manager/formats/yolo.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
from pyunpack import Archive
1010

1111
from cvat.apps.dataset_manager.bindings import (CvatTaskDataExtractor,
12-
import_dm_annotations, match_frame)
12+
import_dm_annotations, match_dm_item, find_dataset_root)
1313
from cvat.apps.dataset_manager.util import make_zip_archive
1414
from datumaro.components.extractor import DatasetItem
1515
from datumaro.components.project import Dataset
16+
from datumaro.plugins.yolo_format.extractor import YoloExtractor
1617

1718
from .registry import dm_env, exporter, importer
1819

@@ -33,17 +34,20 @@ def _import(src_file, task_data):
3334
Archive(src_file.name).extractall(tmp_dir)
3435

3536
image_info = {}
36-
anno_files = glob(osp.join(tmp_dir, '**', '*.txt'), recursive=True)
37-
for filename in anno_files:
38-
filename = osp.splitext(osp.basename(filename))[0]
37+
frames = [YoloExtractor.name_from_path(osp.relpath(p, tmp_dir))
38+
for p in glob(osp.join(tmp_dir, '**', '*.txt'), recursive=True)]
39+
root_hint = find_dataset_root(
40+
[DatasetItem(id=frame) for frame in frames], task_data)
41+
for frame in frames:
3942
frame_info = None
4043
try:
41-
frame_id = match_frame(DatasetItem(id=filename), task_data)
44+
frame_id = match_dm_item(DatasetItem(id=frame), task_data,
45+
root_hint=root_hint)
4246
frame_info = task_data.frame_info[frame_id]
4347
except Exception:
4448
pass
4549
if frame_info is not None:
46-
image_info[filename] = (frame_info['height'], frame_info['width'])
50+
image_info[frame] = (frame_info['height'], frame_info['width'])
4751

4852
dataset = dm_env.make_importer('yolo')(tmp_dir, image_info=image_info) \
4953
.make_dataset()

cvat/apps/dataset_manager/tests/_test_formats.py

+100-1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def _setUpModule():
7070
from rest_framework.test import APITestCase, APIClient
7171
from rest_framework import status
7272

73+
from cvat.apps.dataset_manager.annotation import AnnotationIR
74+
from cvat.apps.dataset_manager.bindings import TaskData, find_dataset_root
75+
from cvat.apps.engine.models import Task
76+
7377
_setUpModule()
7478

7579
from cvat.apps.dataset_manager.annotation import AnnotationIR
@@ -256,7 +260,7 @@ def _generate_annotations(self, task):
256260
self._put_api_v1_task_id_annotations(task["id"], annotations)
257261
return annotations
258262

259-
def _generate_task_images(self, count):
263+
def _generate_task_images(self, count): # pylint: disable=no-self-use
260264
images = {
261265
"client_files[%d]" % i: generate_image_file("image_%d.jpg" % i)
262266
for i in range(count)
@@ -385,6 +389,7 @@ def load_dataset(src):
385389

386390
# NOTE: can't import cvat.utils.cli
387391
# for whatever reason, so remove the dependency
392+
#
388393
project.config.remove('sources')
389394

390395
return project.make_dataset()
@@ -436,3 +441,97 @@ def test_can_make_abs_frame_id_from_known(self):
436441
task_data = TaskData(AnnotationIR(), Task.objects.get(pk=task['id']))
437442

438443
self.assertEqual(5, task_data.abs_frame_id(2))
444+
445+
class FrameMatchingTest(_DbTestBase):
446+
def _generate_task_images(self, paths): # pylint: disable=no-self-use
447+
f = BytesIO()
448+
with zipfile.ZipFile(f, 'w') as archive:
449+
for path in paths:
450+
archive.writestr(path, generate_image_file(path).getvalue())
451+
f.name = 'images.zip'
452+
f.seek(0)
453+
454+
return {
455+
'client_files[0]': f,
456+
'image_quality': 75,
457+
}
458+
459+
def _generate_task(self, images):
460+
task = {
461+
"name": "my task #1",
462+
"owner": '',
463+
"assignee": '',
464+
"overlap": 0,
465+
"segment_size": 100,
466+
"z_order": False,
467+
"labels": [
468+
{
469+
"name": "car",
470+
"attributes": [
471+
{
472+
"name": "model",
473+
"mutable": False,
474+
"input_type": "select",
475+
"default_value": "mazda",
476+
"values": ["bmw", "mazda", "renault"]
477+
},
478+
{
479+
"name": "parked",
480+
"mutable": True,
481+
"input_type": "checkbox",
482+
"default_value": False
483+
},
484+
]
485+
},
486+
{"name": "person"},
487+
]
488+
}
489+
return self._create_task(task, images)
490+
491+
def test_frame_matching(self):
492+
task_paths = [
493+
'a.jpg',
494+
'a/a.jpg',
495+
'a/b.jpg',
496+
'b/a.jpg',
497+
'b/c.jpg',
498+
'a/b/c.jpg',
499+
'a/b/d.jpg',
500+
]
501+
502+
images = self._generate_task_images(task_paths)
503+
task = self._generate_task(images)
504+
task_data = TaskData(AnnotationIR(), Task.objects.get(pk=task["id"]))
505+
506+
for input_path, expected, root in [
507+
('z.jpg', None, ''), # unknown item
508+
('z/a.jpg', None, ''), # unknown item
509+
510+
('d.jpg', 'a/b/d.jpg', 'a/b'), # match with root hint
511+
('b/d.jpg', 'a/b/d.jpg', 'a'), # match with root hint
512+
] + list(zip(task_paths, task_paths, [None] * len(task_paths))): # exact matches
513+
with self.subTest(input=input_path):
514+
actual = task_data.match_frame(input_path, root)
515+
if actual is not None:
516+
actual = task_data.frame_info[actual]['path']
517+
self.assertEqual(expected, actual)
518+
519+
def test_dataset_root(self):
520+
for task_paths, dataset_paths, expected in [
521+
([ 'a.jpg', 'b/c/a.jpg' ], [ 'a.jpg', 'b/c/a.jpg' ], ''),
522+
([ 'b/a.jpg', 'b/c/a.jpg' ], [ 'a.jpg', 'c/a.jpg' ], 'b'), # 'images from share' case
523+
([ 'b/c/a.jpg' ], [ 'a.jpg' ], 'b/c'), # 'images from share' case
524+
([ 'a.jpg' ], [ 'z.jpg' ], None),
525+
]:
526+
with self.subTest(expected=expected):
527+
images = self._generate_task_images(task_paths)
528+
task = self._generate_task(images)
529+
task_data = TaskData(AnnotationIR(),
530+
Task.objects.get(pk=task["id"]))
531+
dataset = [
532+
datumaro.components.extractor.DatasetItem(
533+
id=osp.splitext(p)[0])
534+
for p in dataset_paths]
535+
536+
root = find_dataset_root(dataset, task_data)
537+
self.assertEqual(expected, root)

0 commit comments

Comments
 (0)