Az/yolo format support (#619)

* added yolo loader/dumper
* changed format_spec
* updated reamde, changelog
* Used bold font for default dump format
main
Andrey Zhavoronkov 7 years ago committed by Nikita Manovich
parent f17847ff33
commit 1454ec7ecc

@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ability to create a custom extractors for unsupported media types
- Added in PDF extractor
- Added in a command line model manager tester
- Pascal VOC format support
- Ability to dump/load annotations in several formats from UI (CVAT, Pascal VOC, YOLO)
### Changed
- Outside and keyframe buttons in the side panel for all interpolation shapes (they were only for boxes before)

@ -27,13 +27,15 @@ CVAT is free, online, interactive video and image annotation tool for computer v
- [Tutorial for polygons](https://www.youtube.com/watch?v=XTwfXDh4clI)
- [Semi-automatic segmentation](https://www.youtube.com/watch?v=vnqXZ-Z-VTQ)
## Supported formats
## Supported annotation formats
Format selection is possible after clicking on the Upload annotation / Dump annotation button.
| Annotation format | Dumper | Loader |
| ------------------------- | ------ | ------ |
| CVAT XML v1.1 for images | X | X |
| CVAT XML v1.1 for a video | X | X |
| Pascal VOC | X | X |
| YOLO | X | X |
## Links
- [Intel AI blog: New Computer Vision Tool Accelerates Annotation of Digital Images and Video](https://www.intel.ai/introducing-cvat)

@ -119,9 +119,9 @@ class Annotation:
self._create_callback=create_callback
self._MAX_ANNO_SIZE=30000
db_labels = self._db_task.label_set.all().prefetch_related('attributespec_set')
db_labels = self._db_task.label_set.all().prefetch_related('attributespec_set').order_by('pk')
self._label_mapping = {db_label.id: db_label for db_label in db_labels}
self._label_mapping = OrderedDict((db_label.id, db_label) for db_label in db_labels)
self._attribute_mapping = {
'mutable': {},

@ -45,7 +45,7 @@ def load(file_object, annotations):
def parse_xml_file(annotation_file):
import xml.etree.ElementTree as ET
root = ET.parse(annotation_file).getroot()
filename = root.find('filename').text
frame_number = match_frame(annotations.frame_info, root.find('filename').text)
for obj_tag in root.iter('object'):
bbox_tag = obj_tag.find("bndbox")
@ -57,14 +57,14 @@ def load(file_object, annotations):
annotations.add_shape(annotations.LabeledShape(
type='rectangle',
frame=match_frame(annotations.frame_info, filename),
frame=frame_number,
label=label,
points=[xmin, ymin, xmax, ymax],
occluded=False,
attributes=[],
))
archive_file = file_object if isinstance(file_object, str) else getattr(file_object, 'name')
archive_file = getattr(file_object, 'name')
with TemporaryDirectory() as tmp_dir:
Archive(archive_file).extractall(tmp_dir)

@ -8,4 +8,5 @@ path_prefix = os.path.join('cvat', 'apps', 'annotation')
BUILTIN_FORMATS = (
os.path.join(path_prefix, 'cvat.py'),
os.path.join(path_prefix,'pascal_voc.py'),
os.path.join(path_prefix,'yolo.py'),
)

@ -0,0 +1,136 @@
format_spec = {
"name": "YOLO",
"dumpers": [
{
"display_name": "{name} {format} {version}",
"format": "ZIP",
"version": "1.0",
"handler": "dump"
},
],
"loaders": [
{
"display_name": "{name} {format} {version}",
"format": "ZIP",
"version": "1.0",
"handler": "load"
},
],
}
def get_filename(path):
import os
return os.path.splitext(os.path.basename(path))[0]
def load(file_object, annotations):
from pyunpack import Archive
import os
from tempfile import TemporaryDirectory
from glob import glob
def convert_from_yolo(img_size, box):
# convertation formulas are based on https://github.com/pjreddie/darknet/blob/master/scripts/voc_label.py
# <x> <y> <width> <height> - float values relative to width and height of image
# <x> <y> - are center of rectangle
def clamp(value, _min, _max):
return max(min(_max, value), _min)
xtl = clamp(img_size[0] * (box[0] - box[2] / 2), 0, img_size[0])
ytl = clamp(img_size[1] * (box[1] - box[3] / 2), 0, img_size[1])
xbr = clamp(img_size[0] * (box[0] + box[2] / 2), 0, img_size[0])
ybr = clamp(img_size[1] * (box[1] + box[3] / 2), 0, img_size[1])
return [xtl, ytl, xbr, ybr]
def parse_yolo_obj(img_size, obj):
label_id, x, y, w, h = obj.split(" ")
return int(label_id), convert_from_yolo(img_size, (float(x), float(y), float(w), float(h)))
def match_frame(frame_info, filename):
import re
# try to match by filename
yolo_filename = get_filename(filename)
for frame_number, info in frame_info.items():
cvat_filename = get_filename(info["path"])
if cvat_filename == yolo_filename:
return frame_number
# try to extract frame number from filename
numbers = re.findall(r"\d+", filename)
if numbers and len(numbers) == 1:
return int(numbers[0])
raise Exception("Cannot match filename or determinate framenumber for {} filename".format(filename))
def parse_yolo_file(annotation_file, labels_mapping):
frame_number = match_frame(annotations.frame_info, annotation_file)
with open(annotation_file, "r") as fp:
line = fp.readline()
while line:
frame_info = annotations.frame_info[frame_number]
label_id, points = parse_yolo_obj((frame_info["width"], frame_info["height"]), line)
annotations.add_shape(annotations.LabeledShape(
type="rectangle",
frame=frame_number,
label=labels_mapping[label_id],
points=points,
occluded=False,
attributes=[],
))
line = fp.readline()
def load_labels(labels_file):
with open(labels_file, "r") as f:
return {idx: label.strip() for idx, label in enumerate(f.readlines()) if label.strip()}
archive_file = file_object if isinstance(file_object, str) else getattr(file_object, "name")
with TemporaryDirectory() as tmp_dir:
Archive(archive_file).extractall(tmp_dir)
labels_file = glob(os.path.join(tmp_dir, "*.names"))
if not labels_file:
raise Exception("Could not find '*.names' file with labels in uploaded archive")
elif len(labels_file) == 1:
labels_mapping = load_labels(labels_file[0])
else:
raise Exception("Too many '*.names' files in uploaded archive: {}".format(labels_file))
for dirpath, _, filenames in os.walk(tmp_dir):
for file in filenames:
if ".txt" == os.path.splitext(file)[1]:
parse_yolo_file(os.path.join(dirpath, file), labels_mapping)
def dump(file_object, annotations):
from zipfile import ZipFile
# convertation formulas are based on https://github.com/pjreddie/darknet/blob/master/scripts/voc_label.py
# <x> <y> <width> <height> - float values relative to width and height of image
# <x> <y> - are center of rectangle
def convert_to_yolo(img_size, box):
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
labels_ids = {label[1]["name"]: idx for idx, label in enumerate(annotations.meta["task"]["labels"])}
with ZipFile(file_object, "w") as output_zip:
for frame_annotation in annotations.group_by_frame():
image_name = frame_annotation.name
annotation_name = "{}.txt".format(get_filename(image_name))
width = frame_annotation.width
height = frame_annotation.height
yolo_annotation = ""
for shape in frame_annotation.labeled_shapes:
if shape.type != "rectangle":
continue
label = shape.label
yolo_bb = convert_to_yolo((width, height), shape.points)
yolo_bb = " ".join("{:.6f}".format(p) for p in yolo_bb)
yolo_annotation += "{} {}\n".format(labels_ids[label], yolo_bb)
output_zip.writestr(annotation_name, yolo_annotation)
output_zip.writestr("obj.names", "\n".join(l[0] for l in sorted(labels_ids.items(), key=lambda x:x[1])))

@ -10,8 +10,6 @@ import rq
import shutil
import tempfile
import itertools
import sys
import traceback
from django.db import transaction
from django.utils import timezone
@ -26,7 +24,8 @@ from cvat.apps.engine.annotation import put_task_data, patch_task_data
from .models import AnnotationModel, FrameworkChoice
from .model_loader import ModelLoader, load_labelmap
from .image_loader import ImageLoader
from cvat.apps.engine.utils.import_modules import import_modules
from cvat.apps.engine.utils import import_modules, execute_python_code
def _remove_old_file(model_file_field):
@ -269,9 +268,6 @@ class Results():
"attributes": attributes or {},
}
class InterpreterError(Exception):
pass
def _process_detections(detections, path_to_conv_script, restricted=True):
results = Results()
local_vars = {
@ -296,21 +292,10 @@ def _process_detections(detections, path_to_conv_script, restricted=True):
imports = import_modules(source_code)
global_vars.update(imports)
try:
exec(source_code, global_vars, local_vars)
except SyntaxError as err:
error_class = err.__class__.__name__
detail = err.args[0]
line_number = err.lineno
except Exception as err:
error_class = err.__class__.__name__
detail = err.args[0]
cl, exc, tb = sys.exc_info()
line_number = traceback.extract_tb(tb)[-1][1]
else:
return results
raise InterpreterError("%s at line %d: %s" % (error_class, line_number, detail))
execute_python_code(source_code, global_vars, local_vars)
return results
def run_inference_engine_annotation(data, model_file, weights_file,
labels_mapping, attribute_spec, convertation_file, job=None, update_progress=None, restricted=True):

@ -11,6 +11,7 @@
LabelsInfo:false
showMessage:false
showOverlay:false
isDefaultFormat:false
*/
class TaskView {
@ -138,10 +139,15 @@ class TaskView {
for (const format of this._annotationFormats) {
for (const dumper of format.dumpers) {
dropdownDownloadMenu.append($(`<li>${dumper.display_name}</li>`).on('click', () => {
const listItem = $(`<li>${dumper.display_name}</li>`).on('click', () => {
dropdownDownloadMenu.addClass('hidden');
this._dump(downloadButton[0], dumper.display_name);
}));
});
if (isDefaultFormat(dumper.display_name, this._task.mode)) {
listItem.addClass('bold');
}
dropdownDownloadMenu.append(listItem);
}
for (const loader of format.loaders) {

@ -13,12 +13,12 @@ from django.db import transaction
from cvat.apps.profiler import silk_profile
from cvat.apps.engine.plugins import plugin_decorator
from cvat.apps.annotation.annotation import AnnotationIR, Annotation
from cvat.apps.engine.utils import execute_python_code, import_modules
from . import models
from .data_manager import DataManager
from .log import slogger
from . import serializers
from .utils.import_modules import import_modules
class PatchAction(str, Enum):
CREATE = "create"
@ -593,12 +593,13 @@ class JobAnnotation:
global_vars = globals()
imports = import_modules(source_code)
global_vars.update(imports)
exec(source_code, global_vars)
execute_python_code(source_code, global_vars)
global_vars["file_object"] = file_object
global_vars["annotations"] = annotation_importer
exec("{}(file_object, annotations)".format(loader.handler), global_vars)
execute_python_code("{}(file_object, annotations)".format(loader.handler), global_vars)
self.create(annotation_importer.data.slice(self.start_frame, self.stop_frame).serialize())
class TaskAnnotation:
@ -679,11 +680,11 @@ class TaskAnnotation:
global_vars = globals()
imports = import_modules(source_code)
global_vars.update(imports)
exec(source_code, global_vars)
execute_python_code(source_code, global_vars)
global_vars["file_object"] = dump_file
global_vars["annotations"] = anno_exporter
exec("{}(file_object, annotations)".format(dumper.handler), global_vars)
execute_python_code("{}(file_object, annotations)".format(dumper.handler), global_vars)
def upload(self, annotation_file, loader):
annotation_importer = Annotation(
@ -698,12 +699,12 @@ class TaskAnnotation:
global_vars = globals()
imports = import_modules(source_code)
global_vars.update(imports)
exec(source_code, global_vars)
execute_python_code(source_code, global_vars)
global_vars["file_object"] = file_object
global_vars["annotations"] = annotation_importer
exec("{}(file_object, annotations)".format(loader.handler), global_vars)
execute_python_code("{}(file_object, annotations)".format(loader.handler), global_vars)
self.create(annotation_importer.data.serialize())
@property

@ -46,6 +46,7 @@
buildAnnotationSaver:false
LabelsInfo:false
uploadJobAnnotationRequest:false
isDefaultFormat:false
*/
async function initLogger(jobID) {
@ -389,7 +390,7 @@ function setupMenu(job, task, shapeCollectionModel,
for (const format of annotationFormats) {
for (const dumpSpec of format.dumpers) {
$(`<li>${dumpSpec.display_name}</li>`).on('click', async () => {
const listItem = $(`<li>${dumpSpec.display_name}</li>`).on('click', async () => {
$('#downloadAnnotationButton')[0].disabled = true;
$('#downloadDropdownMenu').addClass('hidden');
try {
@ -399,7 +400,11 @@ function setupMenu(job, task, shapeCollectionModel,
} finally {
$('#downloadAnnotationButton')[0].disabled = false;
}
}).appendTo('#downloadDropdownMenu');
});
if (isDefaultFormat(dumpSpec.display_name, task.mode)) {
listItem.addClass('bold');
}
$('#downloadDropdownMenu').append(listItem);
}
for (const loader of format.loaders) {

@ -11,6 +11,7 @@
showOverlay
uploadJobAnnotationRequest
uploadTaskAnnotationRequest
isDefaultFormat
*/
/* global
@ -221,3 +222,8 @@ $(document).ready(() => {
height: `${window.screen.height * 0.95}px`,
});
});
function isDefaultFormat(dumperName, taskMode) {
return (dumperName === 'CVAT XML 1.1 for videos' && taskMode === 'interpolation')
|| (dumperName === 'CVAT XML 1.1 for images' && taskMode === 'annotation');
}

@ -1,6 +1,8 @@
import ast
from collections import namedtuple
import importlib
import sys
import traceback
Import = namedtuple("Import", ["module", "name", "alias"])
@ -34,3 +36,21 @@ def import_modules(source_code: str):
results[import_.name] = loaded_module
return results
class InterpreterError(Exception):
pass
def execute_python_code(source_code, global_vars=None, local_vars=None):
try:
exec(source_code, global_vars, local_vars)
except SyntaxError as err:
error_class = err.__class__.__name__
details = err.args[0]
line_number = err.lineno
raise InterpreterError("{} at line {}: {}".format(error_class, line_number, details))
except Exception as err:
error_class = err.__class__.__name__
details = err.args[0]
_, _, tb = sys.exc_info()
line_number = traceback.extract_tb(tb)[-1][1]
raise InterpreterError("{} at line {}: {}".format(error_class, line_number, details))

@ -306,8 +306,9 @@ class TaskViewSet(auth.TaskGetQuerySetMixin, viewsets.ModelViewSet):
finally:
rq_job.delete()
elif rq_job.is_failed:
exc_info = str(rq_job.exc_info)
rq_job.delete()
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(data=exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
return Response(status=status.HTTP_202_ACCEPTED)
@ -534,7 +535,8 @@ def load_data_proxy(request, rq_id, rq_func, pk):
elif rq_job.is_failed:
os.close(rq_job.meta['tmp_file_descriptor'])
os.remove(rq_job.meta['tmp_file'])
exc_info = str(rq_job.exc_info)
rq_job.delete()
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(data=exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(status=status.HTTP_202_ACCEPTED)

Loading…
Cancel
Save