Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New command line tool for working with tasks #732

Merged
merged 2 commits into from
Sep 30, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ability to dump/load annotations in several formats from UI (CVAT, Pascal VOC, YOLO, MS COCO, png mask, TFRecord)
- Auth for REST API (api/v1/auth/): login, logout, register, ...
- Preview for the new CVAT UI (dashboard only) is available: http://localhost:9080/
- Added command line tool for performing common task operations (/utils/cli/)

### Changed
- Outside and keyframe buttons in the side panel for all interpolation shapes (they were only for boxes before)
Expand Down
185 changes: 185 additions & 0 deletions utils/cli/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
import functools
import json
import logging
import requests
import sys
from io import BytesIO
from PIL import Image
from http.client import HTTPConnection
from definition import parser, ResourceType
log = logging.getLogger(__name__)


class CLI():

def __init__(self, session, api):
self.api = api
self.session = session

def exception(func):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably you want to define the method outside class CLI. It thinks that func argument is self.

""" Provide generic exception handling for web requests."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (requests.exceptions.HTTPError,
requests.exceptions.ConnectionError,
requests.exceptions.RequestException) as e:
log.info(e)
return wrapper

@exception
def tasks_data(self, task_id, resource_type, resources):
""" Add local, remote, or shared files to an existing task. """
url = self.api.tasks_id_data(task_id)
data = None
files = None
if resource_type == ResourceType.LOCAL:
files = {f'client_files[{i}]': open(f, 'rb') for i, f in enumerate(resources)}
elif resource_type == ResourceType.REMOTE:
data = {f'remote_files[{i}]': f for i, f in enumerate(resources)}
elif resource_type == ResourceType.SHARE:
data = {f'server_files[{i}]': f for i, f in enumerate(resources)}
response = self.session.post(url, data=data, files=files)
response.raise_for_status()

@exception
def tasks_list(self, use_json_output, **kwargs):
""" List all tasks in either basic or JSON format. """
url = self.api.tasks
response = self.session.get(url)
response.raise_for_status()
page = 1
while True:
response_json = response.json()
for r in response_json['results']:
if use_json_output:
log.info(json.dumps(r, indent=4))
else:
log.info(f'{r["id"]},{r["name"]},{r["status"]}')
if not response_json['next']:
return
page += 1
url = self.api.tasks_page(page)
response = self.session.get(url)
response.raise_for_status()

@exception
def tasks_create(self, name, labels, bug, resource_type, resources, **kwargs):
""" Create a new task with the given name and labels JSON and
add the files to it. """
url = self.api.tasks
data = {'name': name,
'labels': labels,
'bug_tracker': bug,
'image_quality': 50}
response = self.session.post(url, json=data)
response.raise_for_status()
response_json = response.json()
log.info(f'Created task ID: {response_json["id"]} '
f'NAME: {response_json["name"]}')
self.tasks_data(response_json['id'], resource_type, resources)

@exception
def tasks_delete(self, task_ids, **kwargs):
""" Delete a list of tasks, ignoring those which don't exist. """
for task_id in task_ids:
url = self.api.tasks_id(task_id)
response = self.session.delete(url)
try:
response.raise_for_status()
log.info(f'Task ID {task_id} deleted')
except requests.exceptions.HTTPError as e:
if response.status_code == 404:
log.info(f'Task ID {task_id} not found')
else:
raise e

@exception
def tasks_frame(self, task_id, frame_ids, **kwargs):
""" Download the requested frame numbers for a task and save images as
task_<ID>_frame_<FRAME>.jpg."""
for frame_id in frame_ids:
url = self.api.tasks_id_frame_id(task_id, frame_id)
response = self.session.get(url)
response.raise_for_status()
im = Image.open(BytesIO(response.content))
outfile = f'task_{task_id}_frame_{frame_id:06d}.jpg'
im.save(outfile)

@exception
def tasks_dump(self, task_id, fileformat, filename, **kwargs):
""" Download annotations for a task in the specified format
(e.g. 'YOLO ZIP 1.0')."""
url = self.api.tasks_id(task_id)
response = self.session.get(url)
response.raise_for_status()
response_json = response.json()

url = self.api.tasks_id_annotations_filename(task_id,
response_json['name'],
fileformat)
while True:
response = self.session.get(url)
response.raise_for_status()
if response.status_code == 201:
break

response = self.session.get(url + '&action=download')
response.raise_for_status()

with open(filename, 'wb') as fp:
fp.write(response.content)


class CVAT_API_V1():
""" Build parameterized API URLs """

def __init__(self, host, port):
self.base = f'http://{host}:{port}/api/v1/'

@property
def tasks(self):
return f'{self.base}tasks'

def tasks_page(self, page_id):
return f'{self.tasks}?page={page_id}'

def tasks_id(self, task_id):
return f'{self.tasks}/{task_id}'

def tasks_id_data(self, task_id):
return f'{self.tasks}/{task_id}/data'

def tasks_id_frame_id(self, task_id, frame_id):
return f'{self.tasks}/{task_id}/frames/{frame_id}'

def tasks_id_annotations_filename(self, task_id, name, fileformat):
return f'{self.tasks}/{task_id}/annotations/{name}?format={fileformat}'


def config_log(level):
log.addHandler(logging.StreamHandler(sys.stdout))
log.setLevel(level)
if level <= logging.DEBUG:
HTTPConnection.debuglevel = 1


def main():
actions = {'create': CLI.tasks_create,
'delete': CLI.tasks_delete,
'ls': CLI.tasks_list,
'frames': CLI.tasks_frame,
'dump': CLI.tasks_dump}
args = parser.parse_args()
config_log(args.loglevel)
with requests.Session() as session:
session.auth = args.auth
api = CVAT_API_V1(args.server_host, args.server_port)
cli = CLI(session, api)
actions[args.action](cli, **args.__dict__)


if __name__ == '__main__':
main()
204 changes: 204 additions & 0 deletions utils/cli/definition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import argparse
import getpass
import json
import logging
import os
from enum import Enum


def get_auth(s):
""" Parse USER[:PASS] strings and prompt for password if none was
supplied. """
user, _, password = s.partition(':')
password = password or os.environ.get('PASS') or getpass.getpass()
return user, password


def parse_label_arg(s):
""" If s is a file load it as JSON, otherwise parse s as JSON."""
if os.path.exists(s):
fp = open(s, 'r')
return json.load(fp)
else:
return json.loads(s)


class ResourceType(Enum):

LOCAL = 0
SHARE = 1
REMOTE = 2

def __str__(self):
return self.name.lower()

def __repr__(self):
return str(self)

@staticmethod
def argparse(s):
try:
return ResourceType[s.upper()]
except KeyError:
return s


#######################################################################
# Command line interface definition
#######################################################################

parser = argparse.ArgumentParser(
description='Perform common operations related to CVAT tasks.\n\n'
)
task_subparser = parser.add_subparsers(dest='action')

#######################################################################
# Positional arguments
#######################################################################

parser.add_argument(
'--auth',
type=get_auth,
metavar='USER:[PASS]',
default=getpass.getuser(),
help='''defaults to the current user and supports the PASS
environment variable or password prompt
(default user: %(default)s).'''
)
parser.add_argument(
'--server-host',
type=str,
default='localhost',
help='host (default: %(default)s)'
)
parser.add_argument(
'--server-port',
type=int,
default='8080',
help='port (default: %(default)s)'
)
parser.add_argument(
'--debug',
action='store_const',
dest='loglevel',
const=logging.DEBUG,
default=logging.INFO,
help='show debug output'
)

#######################################################################
# Create
#######################################################################

task_create_parser = task_subparser.add_parser(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be cool to accept a task definition as json file as well.

'create',
description='Create a new CVAT task.'
)
task_create_parser.add_argument(
'--name',
default='new task',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that we need a default value here.

type=str,
help='name of the task (default: %(default)s)'
)
task_create_parser.add_argument(
'--labels',
default='[]',
type=parse_label_arg,
help='string or file containing JSON labels specification'
)
task_create_parser.add_argument(
Copy link
Contributor

@nmanovic nmanovic Sep 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is about other attributes? I'm OK to have a minimal patch. Just curious if you like to add more parameters (images quality, owner, assignee, etc...). All of them can be viewed in http://localhost:8080/api/swagger documentation

'--bug',
default='',
type=str,
help='bug tracker URL'
)
task_create_parser.add_argument(
'resource_type',
default='local',
choices=list(ResourceType),
type=ResourceType.argparse,
help='type of files specified'
)
task_create_parser.add_argument(
'resources',
type=str,
help='list of paths or URLs',
nargs='+'
)

#######################################################################
# Delete
#######################################################################

delete_parser = task_subparser.add_parser(
'delete',
description='Delete a CVAT task.'
)
delete_parser.add_argument(
'task_ids',
type=int,
help='list of task IDs',
nargs='+'
)

#######################################################################
# List
#######################################################################

ls_parser = task_subparser.add_parser(
'ls',
description='List all CVAT tasks in simple or JSON format.'
)
ls_parser.add_argument(
'--json',
dest='use_json_output',
default=False,
action='store_true',
help='output JSON data'
)

#######################################################################
# Frames
#######################################################################

frames_parser = task_subparser.add_parser(
'frames',
description='Download all frame images for a CVAT task.'
)
frames_parser.add_argument(
'task_id',
type=int,
help='task ID'
)
frames_parser.add_argument(
'frame_ids',
type=int,
help='list of frame IDs to download',
nargs='+'
)

#######################################################################
# Dump
#######################################################################

dump_parser = task_subparser.add_parser(
'dump',
description='Download annotations for a CVAT task.'
)
dump_parser.add_argument(
'task_id',
type=int,
help='task ID'
)
dump_parser.add_argument(
'filename',
type=str,
help='output file'
)
dump_parser.add_argument(
'--format',
dest='fileformat',
type=str,
default='CVAT XML 1.1 for images',
help='annotation format (default: %(default)s)'
)
Loading