From 5daca1881932c922d11fb85ba757a94db4a73407 Mon Sep 17 00:00:00 2001 From: Luka Cehovin Zajc Date: Mon, 19 Apr 2021 17:38:02 +0200 Subject: [PATCH] Documentation, some refactoring --- README.md | 2 +- vot/__init__.py | 43 ++++- vot/__main__.py | 3 + vot/analysis/__init__.py | 4 +- vot/analysis/_processor.py | 4 +- vot/dataset/__init__.py | 4 +- vot/experiment/__init__.py | 5 +- vot/region/__init__.py | 2 +- vot/stack/tests.py | 4 +- vot/tracker/__init__.py | 6 +- vot/utilities/__init__.py | 45 +++-- vot/utilities/cli.py | 56 +++--- vot/utilities/net.py | 116 ++++++------- vot/workspace.py | 342 ------------------------------------- vot/workspace/__init__.py | 194 +++++++++++++++++++++ vot/workspace/storage.py | 326 +++++++++++++++++++++++++++++++++++ vot/workspace/tests.py | 59 +++++++ 17 files changed, 754 insertions(+), 461 deletions(-) delete mode 100644 vot/workspace.py create mode 100644 vot/workspace/__init__.py create mode 100644 vot/workspace/storage.py create mode 100644 vot/workspace/tests.py diff --git a/README.md b/README.md index 0103cb7..5dd7eca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The VOT evaluation toolkit ========================== -This repository contains the official evaluation toolkit for the [Visual Object Tracking (VOT) challenge](http://votchallenge.net/). This is the new version of the toolkit, implemented in Python 3 language, if you are looking for the old Matlab version, you can find it [here](https://github.com/vicoslab/toolkit). +This repository contains the official evaluation toolkit for the [Visual Object Tracking (VOT) challenge](http://votchallenge.net/). This is the official version of the toolkit, implemented in Python 3 language. If you are looking for the old Matlab version, you can find an archived repository [here](https://github.com/vicoslab/toolkit-legacy). For more detailed informations consult the documentation available in the source or a compiled version of the documentation [here](http://www.votchallenge.net/howto/). You can also subscribe to the VOT [mailing list](https://service.ait.ac.at/mailman/listinfo/votchallenge) to receive news about challenges and important software updates or join our [support form](https://groups.google.com/forum/?hl=en#!forum/votchallenge-help) to ask questions. diff --git a/vot/__init__.py b/vot/__init__.py index 22d33ad..ea9adbc 100644 --- a/vot/__init__.py +++ b/vot/__init__.py @@ -4,13 +4,37 @@ from .version import __version__ -class VOTException(Exception): +_logger = logging.getLogger("vot") + +def get_logger() -> logging.Logger: + """Returns the default logger object used to log different messages. + + Returns: + logging.Logger: Logger handle + """ + return _logger + +class ToolkitException(Exception): + """Base class for all toolkit related exceptions + """ pass -def toolkit_version(): + +def toolkit_version() -> str: + """Returns toolkit version as a string + + Returns: + str: Version of the toolkit + """ return __version__ -def check_updates(): +def check_updates() -> bool: + """Checks for toolkit updates on Github, requires internet access, fails silently on errors. + + Returns: + bool: True if an update is available, False otherwise. + """ + import re import packaging.version as packaging import requests @@ -18,13 +42,11 @@ def check_updates(): version_url = "https://github.com/votchallenge/vot-toolkit-python/raw/master/vot/version.py" - logger = logging.getLogger("vot") - try: - logger.debug("Checking for new version") + get_logger().debug("Checking for new version") response = requests.get(version_url, timeout=2) except Exception as e: - logger.debug("Unable to retrieve version information %s", e) + get_logger().debug("Unable to retrieve version information %s", e) return False, None if not response: @@ -40,7 +62,12 @@ def check_updates(): else: return False, None -def check_debug(): +def check_debug() -> bool: + """Checks if debug is enabled for the toolkit via an environment variable. + + Returns: + bool: True if debug is enabled, False otherwise + """ var = os.environ.get("VOT_TOOLKIT_DEBUG", "false").lower() return var in ["true", "1"] diff --git a/vot/__main__.py b/vot/__main__.py index cf2b33c..ab9bbe2 100644 --- a/vot/__main__.py +++ b/vot/__main__.py @@ -1,4 +1,7 @@ + +# Just a shortcut for the CLI interface so that it can be run as a "vot" module. + from vot.utilities.cli import main if __name__ == '__main__': diff --git a/vot/analysis/__init__.py b/vot/analysis/__init__.py index c94fe3d..83baddc 100644 --- a/vot/analysis/__init__.py +++ b/vot/analysis/__init__.py @@ -13,7 +13,7 @@ from attributee import Attributee, String -from vot import VOTException +from vot import ToolkitException from vot.tracker import Tracker from vot.dataset import Sequence from vot.experiment import Experiment @@ -23,7 +23,7 @@ analysis_registry = ClassRegistry("vot_analysis") -class MissingResultsException(VOTException): +class MissingResultsException(ToolkitException): """Exception class that denotes missing results during analysis """ pass diff --git a/vot/analysis/_processor.py b/vot/analysis/_processor.py index 9e3d1c1..e7794e0 100644 --- a/vot/analysis/_processor.py +++ b/vot/analysis/_processor.py @@ -11,7 +11,7 @@ from cachetools import Cache from bidict import bidict -from vot import VOTException +from vot import ToolkitException from vot.dataset import Sequence from vot.tracker import Tracker from vot.experiment import Experiment @@ -42,7 +42,7 @@ def unwrap(arg): else: return arg -class AnalysisError(VOTException): +class AnalysisError(ToolkitException): def __init__(self, cause, task=None): self._tasks = [] self._cause = cause diff --git a/vot/dataset/__init__.py b/vot/dataset/__init__.py index 26b47e7..78b9f2b 100644 --- a/vot/dataset/__init__.py +++ b/vot/dataset/__init__.py @@ -7,13 +7,13 @@ from PIL.Image import Image import numpy as np -from vot import VOTException +from vot import ToolkitException from vot.utilities import read_properties from vot.region import parse import cv2 -class DatasetException(VOTException): +class DatasetException(ToolkitException): pass class Channel(ABC): diff --git a/vot/experiment/__init__.py b/vot/experiment/__init__.py index 3a253ba..8f4b55d 100644 --- a/vot/experiment/__init__.py +++ b/vot/experiment/__init__.py @@ -1,12 +1,9 @@ -import os -import json -import glob import logging import typing from datetime import datetime -from abc import abstractmethod, ABC +from abc import abstractmethod from class_registry import ClassRegistry diff --git a/vot/region/__init__.py b/vot/region/__init__.py index b263539..32bb161 100644 --- a/vot/region/__init__.py +++ b/vot/region/__init__.py @@ -2,7 +2,7 @@ from typing import Tuple from enum import Enum -from vot import VOTException +from vot import ToolkitException from vot.utilities.draw import DrawHandle class RegionException(Exception): diff --git a/vot/stack/tests.py b/vot/stack/tests.py index eb9f7e4..083f5c5 100644 --- a/vot/stack/tests.py +++ b/vot/stack/tests.py @@ -2,14 +2,14 @@ import unittest import yaml -from vot.workspace import Workspace, VoidStorage +from vot.workspace import Workspace, NullStorage from vot.stack import Stack, list_integrated_stacks, resolve_stack class NoWorkspace: @property def storage(self): - return VoidStorage() + return NullStorage() class TestStacks(unittest.TestCase): diff --git a/vot/tracker/__init__.py b/vot/tracker/__init__.py index de0b0bc..d7f89d5 100644 --- a/vot/tracker/__init__.py +++ b/vot/tracker/__init__.py @@ -10,14 +10,14 @@ import yaml -from vot import VOTException +from vot import ToolkitException from vot.dataset import Frame from vot.region import Region from vot.utilities import to_string logger = logging.getLogger("vot") -class TrackerException(VOTException): +class TrackerException(ToolkitException): def __init__(self, *args, tracker, tracker_log=None): super().__init__(*args) self._tracker_log = tracker_log @@ -138,7 +138,7 @@ def resolve(self, *references, storage=None, skip_unknown=True, resolve_plural=T if not identifier in self._trackers: if not skip_unknown: - raise VOTException("Unable to resolve tracker reference: {}".format(reference)) + raise ToolkitException("Unable to resolve tracker reference: {}".format(reference)) else: continue diff --git a/vot/utilities/__init__.py b/vot/utilities/__init__.py index 693c77d..aebccaa 100644 --- a/vot/utilities/__init__.py +++ b/vot/utilities/__init__.py @@ -11,6 +11,8 @@ from numbers import Number from typing import Tuple +import typing +from vot import get_logger import six import colorama @@ -68,6 +70,7 @@ def flip(size: Tuple[Number, Number]) -> Tuple[Number, Number]: from tqdm import tqdm class Progress(object): + class StreamProxy(object): def write(self, x): @@ -83,38 +86,47 @@ def logstream(): return Progress.StreamProxy() def __init__(self, description="Processing", total=100): - self._tqdm = tqdm(disable=False if is_notebook() else None, - bar_format=" {desc:20.20} |{bar}| {percentage:3.0f}% [{elapsed}<{remaining}]") - self._tqdm.desc = description - self._tqdm.total = total - if self._tqdm.disable: + silent = get_logger().level > logging.INFO + + if not silent: + self._tqdm = tqdm(disable=False if is_notebook() else None, + bar_format=" {desc:20.20} |{bar}| {percentage:3.0f}% [{elapsed}<{remaining}]") + self._tqdm.desc = description + self._tqdm.total = total + if silent or self._tqdm.disable: self._tqdm = None self._value = 0 - self._total = total + self._total = total if not silent else 0 def _percent(self, n): return int((n * 100) / self._total) def absolute(self, value): if self._tqdm is None: + if self._total == 0: + return prev = self._value self._value = max(0, min(value, self._total)) if self._percent(prev) != self._percent(self._value): - print("%d %%" % self._percent(self._value), end=' ') + print("%d %%" % self._percent(self._value)) else: self._tqdm.update(value - self._tqdm.n) # will also set self.n = b * bsize def relative(self, n): if self._tqdm is None: + if self._total == 0: + return prev = self._value self._value = max(0, min(self._value + n, self._total)) if self._percent(prev) != self._percent(self._value): - print("%d %%" % self._percent(self._value), end=' ') + print("%d %%" % self._percent(self._value)) else: self._tqdm.update(n) # will also set self.n = b * bsize def total(self, t): if self._tqdm is None: + if self._total == 0: + return self._total = t else: if self._tqdm.total == t: @@ -131,8 +143,6 @@ def __exit__(self, exc_type, exc_value, traceback): def close(self): if self._tqdm: self._tqdm.close() - else: - print("") def extract_files(archive, destination, callback = None): from zipfile import ZipFile @@ -148,11 +158,16 @@ def extract_files(archive, destination, callback = None): if callback: callback(1, total) -def read_properties(filename, delimiter='='): - ''' Reads a given properties file with each line of the format key=value. - Returns a dictionary containing the pairs. - filename -- the name of the file to be read - ''' +def read_properties(filename: str, delimiter: str = '=') -> typing.Dict[str, str]: + """Reads a given properties file with each line of the format key=value. Returns a dictionary containing the pairs. + + Args: + filename (str): The name of the file to be read. + delimiter (str, optional): Key-value delimiter. Defaults to '='. + + Returns: + [typing.Dict[str, str]]: Resuting properties as a dictionary + """ if not os.path.exists(filename): return {} open_kwargs = {'mode': 'r', 'newline': ''} if six.PY3 else {'mode': 'rb'} diff --git a/vot/utilities/cli.py b/vot/utilities/cli.py index 56a649f..16ba965 100644 --- a/vot/utilities/cli.py +++ b/vot/utilities/cli.py @@ -1,19 +1,22 @@ import os import sys import argparse -import traceback import logging import yaml from datetime import datetime -import colorama -from vot import check_updates, check_debug, __version__ -from vot.tracker import Registry, TrackerException -from vot.stack import resolve_stack, list_integrated_stacks -from vot.workspace import Workspace, Cache -from vot.utilities import Progress, normalize_path, ColoredFormatter +from .. import check_updates, check_debug, toolkit_version, get_logger +from ..tracker import Registry, TrackerException +from ..stack import resolve_stack, list_integrated_stacks +from ..workspace import Workspace +from ..workspace.storage import Cache +from . import Progress, normalize_path, ColoredFormatter + +logger = get_logger() class EnvDefault(argparse.Action): + """Argparse action that resorts to a value in a specified envvar if no value is provided via program arguments. + """ def __init__(self, envvar, required=True, default=None, separator=None, **kwargs): if not default and envvar: if envvar in os.environ: @@ -31,7 +34,12 @@ def __call__(self, parser, namespace, values, option_string=None): values = values.split(self.separator) setattr(namespace, self.dest, values) -def do_test(config, logger): +def do_test(config: argparse.Namespace): + """Run a test for a tracker + + Args: + config (argparse.Namespace): Configuration + """ from vot.dataset.dummy import DummySequence from vot.dataset import load_sequence trackers = Registry(config.registry) @@ -113,7 +121,7 @@ def do_test(config, logger): if runtime: runtime.stop() -def do_workspace(config, logger): +def do_workspace(config: argparse.Namespace): from vot.workspace import WorkspaceException @@ -144,7 +152,7 @@ def do_workspace(config, logger): except WorkspaceException as we: logger.error("Error during workspace initialization: %s", we) -def do_evaluate(config, logger): +def do_evaluate(config: argparse.Namespace): from vot.experiment import run_experiment @@ -180,7 +188,7 @@ def do_evaluate(config, logger): except TrackerException as te: logger.error("Evaluation interrupted by tracker error: {}".format(te)) -def do_analysis(config, logger): +def do_analysis(config: argparse.Namespace): from vot.analysis import AnalysisProcessor, process_stack_analyses from vot.document import generate_document @@ -224,7 +232,7 @@ def do_analysis(config, logger): from cachetools import LRUCache cache = LRUCache(1000) else: - cache = Cache(workspace.cache("analysis")) + cache = Cache(workspace.storage.substorage("cache").substorage("analysis")) try: @@ -251,7 +259,12 @@ def do_analysis(config, logger): executor.shutdown(wait=True) -def do_pack(config, logger): +def do_pack(config: argparse.Namespace): + """Package results to a ZIP file so that they can be submitted to a challenge. + + Args: + config ([type]): [description] + """ import zipfile, io from shutil import copyfileobj @@ -297,7 +310,7 @@ def do_pack(config, logger): manifest = dict(identifier=tracker.identifier, configuration=tracker.configuration(), timestamp="{:%Y-%m-%dT%H-%M-%S.%f%z}".format(timestamp), platform=sys.platform, - python=sys.version, toolkit=__version__) + python=sys.version, toolkit=toolkit_version()) with zipfile.ZipFile(workspace.storage.write(archive_name, binary=True)) as archive: for f in all_files: @@ -313,7 +326,8 @@ def do_pack(config, logger): logger.info("Result packaging successful, archive available in %s", archive_name) def main(): - logger = logging.getLogger("vot") + """Entrypoint to the VOT Command Line Interface utility, should be executed as a program and provided with arguments. + """ stream = logging.StreamHandler() stream.setFormatter(ColoredFormatter()) logger.addHandler(stream) @@ -364,18 +378,18 @@ def main(): update, version = check_updates() if update: - logger.warning("A newer version of VOT toolkit is available (%s), please update.", version) + logger.warning("A newer version of the VOT toolkit is available (%s), please update.", version) if args.action == "test": - do_test(args, logger) + do_test(args) elif args.action == "initialize": - do_workspace(args, logger) + do_workspace(args) elif args.action == "evaluate": - do_evaluate(args, logger) + do_evaluate(args) elif args.action == "analysis": - do_analysis(args, logger) + do_analysis(args) elif args.action == "pack": - do_pack(args, logger) + do_pack(args) else: parser.print_help() diff --git a/vot/utilities/net.py b/vot/utilities/net.py index d909ae7..488485b 100644 --- a/vot/utilities/net.py +++ b/vot/utilities/net.py @@ -7,9 +7,9 @@ import requests -from vot import VOTException +from vot import ToolkitException -class NetworkException(VOTException): +class NetworkException(ToolkitException): pass def get_base_url(url): @@ -56,69 +56,69 @@ def download_json(url): def download(url, output, callback=None, chunk_size=1024*32): - sess = requests.session() + with requests.session() as sess: - is_gdrive = is_google_drive_url(url) - - while True: - res = sess.get(url, stream=True) - - if not res.status_code == 200: - raise NetworkException("File not available") + is_gdrive = is_google_drive_url(url) - if 'Content-Disposition' in res.headers: - # This is the file - break - if not is_gdrive: - break - - # Need to redirect with confiramtion - gurl = get_url_from_gdrive_confirmation(res.text) - - if gurl is None: - raise NetworkException("Permission denied for {}".format(gurl)) - url = gurl - - if output is None: - if is_gdrive: - m = re.search('filename="(.*)"', - res.headers['Content-Disposition']) - output = m.groups()[0] + while True: + res = sess.get(url, stream=True) + + if not res.status_code == 200: + raise NetworkException("File not available") + + if 'Content-Disposition' in res.headers: + # This is the file + break + if not is_gdrive: + break + + # Need to redirect with confiramtion + gurl = get_url_from_gdrive_confirmation(res.text) + + if gurl is None: + raise NetworkException("Permission denied for {}".format(gurl)) + url = gurl + + if output is None: + if is_gdrive: + m = re.search('filename="(.*)"', + res.headers['Content-Disposition']) + output = m.groups()[0] + else: + output = os.path.basename(url) + + output_is_path = isinstance(output, str) + + if output_is_path: + tmp_file = tempfile.mktemp() + filehandle = open(tmp_file, 'wb') else: - output = os.path.basename(url) + tmp_file = None + filehandle = output - output_is_path = isinstance(output, str) + try: + total = res.headers.get('Content-Length') - if output_is_path: - tmp_file = tempfile.mktemp() - filehandle = open(tmp_file, 'wb') - else: - tmp_file = None - filehandle = output + if total is not None: + total = int(total) - try: - total = res.headers.get('Content-Length') - - if total is not None: - total = int(total) - - for chunk in res.iter_content(chunk_size=chunk_size): - filehandle.write(chunk) - if callback: - callback(len(chunk), total) - if tmp_file: - filehandle.close() - shutil.copy(tmp_file, output) - except IOError: - raise NetworkException("Error when downloading file") - finally: - try: + for chunk in res.iter_content(chunk_size=chunk_size): + filehandle.write(chunk) + if callback: + callback(len(chunk), total) if tmp_file: - os.remove(tmp_file) - except OSError: - pass - - return output + filehandle.close() + shutil.copy(tmp_file, output) + except IOError: + raise NetworkException("Error when downloading file") + finally: + try: + if tmp_file: + os.remove(tmp_file) + except OSError: + pass + + return output def download_uncompress(url, path): from vot.utilities import extract_files diff --git a/vot/workspace.py b/vot/workspace.py deleted file mode 100644 index 38c9ad0..0000000 --- a/vot/workspace.py +++ /dev/null @@ -1,342 +0,0 @@ - -import os -import logging -import typing -from abc import ABC, abstractmethod -import pickle -import importlib - -import yaml -import cachetools - -from attributee import Attribute, Attributee, Nested, List, String - -from vot import VOTException -from vot.dataset import Sequence, Dataset, load_dataset -from vot.tracker import Tracker, Results -from vot.experiment import Experiment -from vot.stack import Stack, resolve_stack -from vot.utilities import normalize_path, class_fullname -from vot.document import ReportConfiguration - -logger = logging.getLogger("vot") - -class WorkspaceException(VOTException): - pass - -class Storage(ABC): - - @abstractmethod - def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence): - pass - - @abstractmethod - def documents(self): - pass - - @abstractmethod - def folders(self): - pass - - @abstractmethod - def write(self, name, binary=False): - pass - - @abstractmethod - def read(self, name, binary=False): - pass - - @abstractmethod - def isdocument(self, name): - pass - - @abstractmethod - def isfolder(self, name): - pass - - @abstractmethod - def substorage(self, name): - pass - - @abstractmethod - def copy(self, localfile, destination): - pass - -class VoidStorage(Storage): - - def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence): - return Results(self) - - def write(self, name, binary=False): - if binary: - return open(os.devnull, "wb") - else: - return open(os.devnull, "w") - - def documents(self): - return [] - - def folders(self): - return [] - - def read(self, name, binary=False): - return None - - def isdocument(self, name): - return False - - def isfolder(self, name): - return False - - def substorage(self, name): - return VoidStorage() - - def copy(self, localfile, destination): - return - -class LocalStorage(Storage): - - def __init__(self, root: str): - self._root = root - self._results = os.path.join(root, "results") - - def __repr__(self) -> str: - return "".format(self._root) - - @property - def base(self) -> str: - return self._root - - def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence): - storage = LocalStorage(os.path.join(self._results, tracker.reference, experiment.identifier, sequence.name)) - return Results(storage) - - def documents(self): - return [name for name in os.listdir(self._root) if os.path.isfile(os.path.join(self._root, name))] - - def folders(self): - return [name for name in os.listdir(self._root) if os.path.isdir(os.path.join(self._root, name))] - - def write(self, name, binary=False): - if os.path.isabs(name): - raise IOError("Only relative paths allowed") - - full = os.path.join(self.base, name) - os.makedirs(os.path.dirname(full), exist_ok=True) - - if binary: - return open(full, mode="wb") - else: - return open(full, mode="w", newline="") - - - def read(self, name, binary=False): - if os.path.isabs(name): - raise IOError("Only relative paths allowed") - - full = os.path.join(self.base, name) - - if binary: - return open(full, mode="rb") - else: - return open(full, mode="r", newline="") - - def isdocument(self, name): - return os.path.isfile(os.path.join(self._root, name)) - - def isfolder(self, name): - return os.path.isdir(os.path.join(self._root, name)) - - def substorage(self, name): - return LocalStorage(os.path.join(self.base, name)) - - def copy(self, localfile, destination): - import shutil - if os.path.isabs(destination): - raise IOError("Only relative paths allowed") - - full = os.path.join(self.base, destination) - os.makedirs(os.path.dirname(full), exist_ok=True) - - shutil.move(localfile, os.path.join(self.base, full)) - - def directory(self, *args): - segments = [] - for arg in args: - if arg is None: - continue - if isinstance(arg, str): - segments.append(arg) - elif isinstance(arg, (int, float)): - segments.append(str(arg)) - else: - segments.append(class_fullname(arg)) - - path = os.path.join(self._root, *segments) - os.makedirs(path, exist_ok=True) - - return path - -class Cache(cachetools.Cache): - - def __init__(self, storage: LocalStorage): - super().__init__(10000) - self._storage = storage - - def _filename(self, key): - if isinstance(key, tuple): - filename = key[-1] - if len(key) > 1: - directory = self._storage.directory(*key[:-1]) - else: - directory = self._storage.base - else: - filename = str(key) - directory = self._storage.base - return os.path.join(directory, filename) - - def __getitem__(self, key): - try: - return super().__getitem__(key) - except KeyError as e: - filename = self._filename(key) - if not os.path.isfile(filename): - raise e - try: - with open(filename, mode="rb") as filehandle: - data = pickle.load(filehandle) - super().__setitem__(key, data) - return data - except pickle.PickleError: - raise e - - def __setitem__(self, key, value): - super().__setitem__(key, value) - - filename = self._filename(key) - try: - with open(filename, mode="wb") as filehandle: - return pickle.dump(value, filehandle) - except pickle.PickleError: - pass - - def __contains__(self, key): - filename = self._filename(key) - return os.path.isfile(filename) - -class StackLoader(Attribute): - - def coerce(self, value, ctx): - importlib.import_module("vot.analysis") - importlib.import_module("vot.experiment") - if isinstance(value, str): - - stack_file = resolve_stack(value, ctx["parent"].directory) - - if stack_file is None: - raise WorkspaceException("Experiment stack does not exist") - - with open(stack_file, 'r') as fp: - stack_metadata = yaml.load(fp, Loader=yaml.BaseLoader) - return Stack(value, ctx["parent"], **stack_metadata) - else: - return Stack(None, ctx["parent"], **value) - - def dump(self, value): - if value.name is None: - return value.dump() - else: - return value.name - -class Workspace(Attributee): - - registry = List(String(transformer=lambda x, ctx: normalize_path(x, ctx["parent"].directory))) - stack = StackLoader() - sequences = String(default="sequences") - report = Nested(ReportConfiguration) - - @staticmethod - def initialize(directory, config=None, download=True): - config_file = os.path.join(directory, "config.yaml") - if os.path.isfile(config_file): - raise WorkspaceException("Workspace already initialized") - - os.makedirs(directory, exist_ok=True) - - with open(config_file, 'w') as fp: - yaml.dump(config if config is not None else dict(), fp) - - os.makedirs(os.path.join(directory, "sequences"), exist_ok=True) - os.makedirs(os.path.join(directory, "results"), exist_ok=True) - - if not os.path.isfile(os.path.join(directory, "trackers.ini")): - open(os.path.join(directory, "trackers.ini"), 'w').close() - - if download: - # Try do retrieve dataset from stack and download it - stack_file = resolve_stack(config["stack"], directory) - dataset_directory = normalize_path(config.get("sequences", "sequences"), directory) - if stack_file is None: - return - dataset = None - with open(stack_file, 'r') as fp: - stack_metadata = yaml.load(fp, Loader=yaml.BaseLoader) - dataset = stack_metadata["dataset"] - if dataset: - Workspace.download_dataset(dataset, dataset_directory) - - @staticmethod - def download_dataset(dataset, directory): - if os.path.exists(os.path.join(directory, "list.txt")): - return False - - from vot.dataset import download_dataset - download_dataset(dataset, directory) - - logger.info("Download completed") - - @staticmethod - def load(directory): - directory = normalize_path(directory) - config_file = os.path.join(directory, "config.yaml") - if not os.path.isfile(config_file): - raise WorkspaceException("Workspace not initialized") - - with open(config_file, 'r') as fp: - config = yaml.load(fp, Loader=yaml.BaseLoader) - - return Workspace(directory, **config) - - def __init__(self, directory, **kwargs): - self._directory = directory - self._storage = LocalStorage(directory) if directory is not None else VoidStorage() - - super().__init__(**kwargs) - dataset_directory = normalize_path(self.sequences, directory) - - if not self.stack.dataset is None: - Workspace.download_dataset(self.stack.dataset, dataset_directory) - - self._dataset = load_dataset(dataset_directory) - - @property - def directory(self) -> str: - return self._directory - - @property - def dataset(self) -> Dataset: - return self._dataset - - @property - def storage(self) -> LocalStorage: - return self._storage - - def cache(self, identifier) -> LocalStorage: - if not isinstance(identifier, str): - identifier = class_fullname(identifier) - - return self._storage.substorage("cache").substorage(identifier) - - def list_results(self, registry: "Registry") -> typing.List["Tracker"]: - references = self._storage.substorage("results").folders() - return registry.resolve(*references) diff --git a/vot/workspace/__init__.py b/vot/workspace/__init__.py new file mode 100644 index 0000000..7fc0626 --- /dev/null +++ b/vot/workspace/__init__.py @@ -0,0 +1,194 @@ + +import os +import typing +import importlib + +import yaml + +from attributee import Attribute, Attributee, Nested, List, String + +from .. import ToolkitException, get_logger +from ..dataset import Dataset, load_dataset +from ..tracker import Registry, Tracker +from ..stack import Stack, resolve_stack +from ..utilities import normalize_path, class_fullname +from ..document import ReportConfiguration +from .storage import LocalStorage, Storage, NullStorage + +_logger = get_logger() + +class WorkspaceException(ToolkitException): + pass + +class StackLoader(Attribute): + """Special attribute that converts a string or a dictionary input to a Stack object. + """ + + def coerce(self, value, ctx): + importlib.import_module("vot.analysis") + importlib.import_module("vot.experiment") + if isinstance(value, str): + + stack_file = resolve_stack(value, ctx["parent"].directory) + + if stack_file is None: + raise WorkspaceException("Experiment stack does not exist") + + with open(stack_file, 'r') as fp: + stack_metadata = yaml.load(fp, Loader=yaml.BaseLoader) + return Stack(value, ctx["parent"], **stack_metadata) + else: + return Stack(None, ctx["parent"], **value) + + def dump(self, value): + if value.name is None: + return value.dump() + else: + return value.name + +class Workspace(Attributee): + """Workspace class represents the main junction of trackers, datasets and experiments. Each workspace performs + given experiments on a provided dataset. + """ + + registry = List(String(transformer=lambda x, ctx: normalize_path(x, ctx["parent"].directory))) + stack = StackLoader() + sequences = String(default="sequences") + report = Nested(ReportConfiguration) + + @staticmethod + def initialize(directory: str, config: typing.Optional[typing.Dict] = None, download: bool = True) -> None: + """[summary] + + Args: + directory (str): Root for workspace storage + config (typing.Optional[typing.Dict], optional): Workspace initial configuration. Defaults to None. + download (bool, optional): Download the dataset immediately. Defaults to True. + + Raises: + WorkspaceException: When a workspace cannot be created. + """ + + config_file = os.path.join(directory, "config.yaml") + if os.path.isfile(config_file): + raise WorkspaceException("Workspace already initialized") + + os.makedirs(directory, exist_ok=True) + + with open(config_file, 'w') as fp: + yaml.dump(config if config is not None else dict(), fp) + + os.makedirs(os.path.join(directory, "sequences"), exist_ok=True) + os.makedirs(os.path.join(directory, "results"), exist_ok=True) + + if not os.path.isfile(os.path.join(directory, "trackers.ini")): + open(os.path.join(directory, "trackers.ini"), 'w').close() + + if download: + # Try do retrieve dataset from stack and download it + stack_file = resolve_stack(config["stack"], directory) + dataset_directory = normalize_path(config.get("sequences", "sequences"), directory) + if stack_file is None: + return + dataset = None + with open(stack_file, 'r') as fp: + stack_metadata = yaml.load(fp, Loader=yaml.BaseLoader) + dataset = stack_metadata["dataset"] + if dataset: + Workspace.download_dataset(dataset, dataset_directory) + + @staticmethod + def download_dataset(dataset: str, directory: str) -> None: + """Download the dataset if no dataset is present already. + + Args: + dataset (str): Dataset URL or ID + directory (str): Directory where the dataset is saved + + """ + if os.path.exists(os.path.join(directory, "list.txt")): #TODO: this has to be improved now that we also support other datasets that may not have list.txt + return False + + from vot.dataset import download_dataset + download_dataset(dataset, directory) + + _logger.info("Download completed") + + @staticmethod + def load(directory): + """Load a workspace from a given location. This + + Args: + directory ([type]): [description] + + Raises: + WorkspaceException: [description] + + Returns: + [type]: [description] + """ + directory = normalize_path(directory) + config_file = os.path.join(directory, "config.yaml") + if not os.path.isfile(config_file): + raise WorkspaceException("Workspace not initialized") + + with open(config_file, 'r') as fp: + config = yaml.load(fp, Loader=yaml.BaseLoader) + + return Workspace(directory, **config) + + def __init__(self, directory: str, **kwargs): + """Do not call this constructor directly unless you know what you are doing, + instead use the static Workspace.load method. + + Args: + directory ([type]): [description] + """ + self._directory = directory + self._storage = LocalStorage(directory) if directory is not None else NullStorage() + + super().__init__(**kwargs) + dataset_directory = normalize_path(self.sequences, directory) + + if not self.stack.dataset is None: + Workspace.download_dataset(self.stack.dataset, dataset_directory) + + self._dataset = load_dataset(dataset_directory) + + @property + def directory(self) -> str: + """Returns the root directory for the workspace. + + Returns: + str: The absolute path to the root of the workspace. + """ + return self._directory + + @property + def dataset(self) -> Dataset: + """Returns dataset associated with the workspace + + Returns: + Dataset: The dataset object. + """ + return self._dataset + + @property + def storage(self) -> Storage: + """Returns the storage object associated with this workspace. + + Returns: + Storage: The storage object. + """ + return self._storage + + def list_results(self, registry: "Registry") -> typing.List["Tracker"]: + """Utility method that looks for all subfolders in the results folder and tries to resolve them + as tracker references. It returns a list of Tracker objects, i.e. trackers that have at least + some results or an existing results directory. + + Returns: + [typing.List[Tracker]]: A list of trackers with results. + """ + references = self._storage.substorage("results").folders() + return registry.resolve(*references) diff --git a/vot/workspace/storage.py b/vot/workspace/storage.py new file mode 100644 index 0000000..14fa639 --- /dev/null +++ b/vot/workspace/storage.py @@ -0,0 +1,326 @@ + +import os +import pickle + +from abc import ABC, abstractmethod +import typing + +import cachetools + +from attributee.object import class_fullname + +from ..experiment import Experiment +from ..dataset import Sequence +from ..tracker import Tracker, Results + +class Storage(ABC): + """Abstract superclass for workspace storage abstraction + """ + + @abstractmethod + def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence) -> Results: + """Returns results object for the given tracker, experiment, sequence combination + + Args: + tracker (Tracker): Selected tracker + experiment (Experiment): Selected experiment + sequence (Sequence): Selected sequence + """ + pass + + @abstractmethod + def documents(self) -> typing.List[str]: + """Lists documents in the storage. + """ + pass + + @abstractmethod + def folders(self) -> typing.List[str]: + """Lists folders in the storage. + """ + pass + + @abstractmethod + def write(self, name:str, binary: bool = False): + """Opens the given file entry for writing, returns opened handle. + + Args: + name (str): File name. + binary (bool, optional): Open file in binary mode. Defaults to False. + """ + pass + + @abstractmethod + def read(self, name, binary=False): + """Opens the given file entry for reading, returns opened handle. + + Args: + name (str): File name. + binary (bool, optional): Open file in binary mode. Defaults to False. + """ + pass + + @abstractmethod + def isdocument(self, name: str) -> bool: + """Checks if given name is a document/file in this storage. + + Args: + name (str): Name of the entry to check + + Returns: + bool: Returns True if entry is a document, False otherwise. + """ + pass + + @abstractmethod + def isfolder(self, name) -> bool: + """Checks if given name is a folder in this storage. + + Args: + name (str): Name of the entry to check + + Returns: + bool: Returns True if entry is a folder, False otherwise. + """ + pass + + @abstractmethod + def delete(self, name) -> bool: + """Deletes a given document. + + Args: + name (str): File name. + + + Returns: + bool: Returns True if successful, False otherwise. + """ + pass + + @abstractmethod + def substorage(self, name: str) -> "Storage": + """Returns a substorage, storage object with root in a subfolder. + + Args: + name (str): Name of the entry, must be a folder + + Returns: + Storage: Storage object + """ + pass + + @abstractmethod + def copy(self, localfile: str, destination: str): + """Copy a document to another location + + Args: + localfile (str): Original location + destination (str): New location + """ + pass + +class NullStorage(Storage): + """An implementation of dummy storage that does not save anything + """ + + def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence): + return Results(self) + + def __repr__(self) -> str: + return "".format(self._root) + + def write(self, name, binary=False): + if binary: + return open(os.devnull, "wb") + else: + return open(os.devnull, "w") + + def documents(self): + return [] + + def folders(self): + return [] + + def read(self, name, binary=False): + return None + + def isdocument(self, name): + return False + + def isfolder(self, name): + return False + + def delete(self, name) -> bool: + return False + + def substorage(self, name): + return NullStorage() + + def copy(self, localfile, destination): + return + +class LocalStorage(Storage): + """Storage backed by the local filesystem. + """ + + def __init__(self, root: str): + self._root = root + self._results = os.path.join(root, "results") + + def __repr__(self) -> str: + return "".format(self._root) + + @property + def base(self) -> str: + return self._root + + def results(self, tracker: Tracker, experiment: Experiment, sequence: Sequence): + storage = LocalStorage(os.path.join(self._results, tracker.reference, experiment.identifier, sequence.name)) + return Results(storage) + + def documents(self): + return [name for name in os.listdir(self._root) if os.path.isfile(os.path.join(self._root, name))] + + def folders(self): + return [name for name in os.listdir(self._root) if os.path.isdir(os.path.join(self._root, name))] + + def write(self, name: str, binary: bool = False): + if os.path.isabs(name): + raise IOError("Only relative paths allowed") + + full = os.path.join(self.base, name) + os.makedirs(os.path.dirname(full), exist_ok=True) + + if binary: + return open(full, mode="wb") + else: + return open(full, mode="w", newline="") + + + def read(self, name, binary=False): + if os.path.isabs(name): + raise IOError("Only relative paths allowed") + + full = os.path.join(self.base, name) + + if binary: + return open(full, mode="rb") + else: + return open(full, mode="r", newline="") + + def delete(self, name) -> bool: + full = os.path.join(self.base, name) + if os.path.isfile(full): + os.unlink(full) + return True + return False + + def isdocument(self, name): + return os.path.isfile(os.path.join(self._root, name)) + + def isfolder(self, name): + return os.path.isdir(os.path.join(self._root, name)) + + def substorage(self, name): + return LocalStorage(os.path.join(self.base, name)) + + def copy(self, localfile, destination): + import shutil + if os.path.isabs(destination): + raise IOError("Only relative paths allowed") + + full = os.path.join(self.base, destination) + os.makedirs(os.path.dirname(full), exist_ok=True) + + shutil.move(localfile, os.path.join(self.base, full)) + + def directory(self, *args): + segments = [] + for arg in args: + if arg is None: + continue + if isinstance(arg, str): + segments.append(arg) + elif isinstance(arg, (int, float)): + segments.append(str(arg)) + else: + segments.append(class_fullname(arg)) + + path = os.path.join(self._root, *segments) + os.makedirs(path, exist_ok=True) + + return path + +class Cache(cachetools.Cache): + """Persistent cache, extends the cache from cachetools package by storing cached objects (using picke serialization) to + the underlying storage. + """ + + def __init__(self, storage: Storage): + """Creates a new cache backed by the given storage. + + Args: + storage (Storage): The storage used to save objects. + """ + super().__init__(10000) + self._storage = storage + + def _filename(self, key: typing.Union[typing.Tuple, str]) -> str: + """Generates a filename for the given object key. + + Args: + key (typing.Union[typing.Tuple, str]): Cache key, either tuple or a single string + + Returns: + str: Relative path as a string + """ + if isinstance(key, tuple): + filename = key[-1] + if len(key) > 1: + directory = self._storage.directory(*key[:-1]) + else: + directory = "" + else: + filename = str(key) + directory = "" + return os.path.join(directory, filename) + + def __getitem__(self, key: str): + try: + return super().__getitem__(key) + except KeyError as e: + filename = self._filename(key) + if not self._storage.isdocument(filename): + raise e + try: + with self._storage.read(filename, binary=True) as filehandle: + data = pickle.load(filehandle) + super().__setitem__(key, data) + return data + except pickle.PickleError: + raise e + + def __setitem__(self, key: str, value: typing.Any): + super().__setitem__(key, value) + + filename = self._filename(key) + try: + with self._storage.write(filename, binary=True) as filehandle: + return pickle.dump(value, filehandle) + except pickle.PickleError: + pass + + def __delitem__(self, key: str): + try: + super().__delitem__(key) + filename = self._filename(key) + try: + self._storage.delete(filename) + except IOError: + pass + except KeyError: + pass + + def __contains__(self, key: str): + filename = self._filename(key) + return self._storage.isdocument(filename) diff --git a/vot/workspace/tests.py b/vot/workspace/tests.py new file mode 100644 index 0000000..bfd1b64 --- /dev/null +++ b/vot/workspace/tests.py @@ -0,0 +1,59 @@ + + +import logging +import tempfile +import unittest +from vot import get_logger +from vot.workspace.storage import Cache, LocalStorage + +from vot.workspace import Workspace, NullStorage + +class TestStacks(unittest.TestCase): + + def test_void_storage(self): + + storage = NullStorage() + + with storage.write("test.data") as handle: + handle.write("test") + + self.assertIsNone(storage.read("test.data")) + + def test_local_storage(self): + + with tempfile.TemporaryDirectory() as testdir: + storage = LocalStorage(testdir) + + with storage.write("test.txt") as handle: + handle.write("Test") + + self.assertTrue(storage.isdocument("test.txt")) + + # TODO: more tests + + def test_workspace_create(self): + + get_logger().setLevel(logging.WARN) # Disable progress bar + + default_config = dict(stack="testing", registry=["./trackers.ini"]) + + with tempfile.TemporaryDirectory() as testdir: + Workspace.initialize(testdir, default_config, download=True) + Workspace.load(testdir) + + def test_cache(self): + with tempfile.TemporaryDirectory() as testdir: + + cache = Cache(LocalStorage(testdir)) + + self.assertFalse("test" in cache) + + cache["test"] = 1 + + self.assertTrue("test" in cache) + + self.assertTrue(cache["test"] == 1) + + del cache["test"] + + self.assertRaises(KeyError, lambda: cache["test"]) \ No newline at end of file