diff --git a/bob/bio/face/__init__.py b/bob/bio/face/__init__.py index 5064b9bc683a1d816728b6aec7a63fb1cdfcd9ea..4f3858c5fbc180e24a8368de4c55b4e90e73e3c1 100644 --- a/bob/bio/face/__init__.py +++ b/bob/bio/face/__init__.py @@ -3,6 +3,7 @@ from . import extractor from . import algorithm from . import script from . import database +from . import annotator from . import test diff --git a/bob/bio/face/annotator/Base.py b/bob/bio/face/annotator/Base.py new file mode 100644 index 0000000000000000000000000000000000000000..a31efd46e09d8d9d23e5d55788f502b1131d502e --- /dev/null +++ b/bob/bio/face/annotator/Base.py @@ -0,0 +1,25 @@ +from bob.bio.base import read_original_data as base_read + + +class Base(object): + """Base class for all annotators""" + + def __init__(self, read_original_data=None, **kwargs): + super(Base, self).__init__(**kwargs) + self.read_original_data = read_original_data or base_read + + def annotate(self, image, **kwargs): + """Annotates an image and returns annotations in a dictionary + + Parameters + ---------- + image : object + The image is what comes out of ``read_original_data``. + **kwargs + The extra arguments that may be passed. + """ + raise NotImplementedError() + + # Alisa call to annotate + __call__ = annotate + __call__.__doc__ = annotate.__doc__ diff --git a/bob/bio/face/annotator/FailSafe.py b/bob/bio/face/annotator/FailSafe.py new file mode 100644 index 0000000000000000000000000000000000000000..d6fa02023490b7be06dcccfdfc549e17c44a60b4 --- /dev/null +++ b/bob/bio/face/annotator/FailSafe.py @@ -0,0 +1,34 @@ +import logging +from . import Base + +logger = logging.getLogger(__name__) + + +class FailSafe(Base): + """A fail-safe annotator. + This annotator takes a list of annotator and tries them until you get your + annotations. + The annotations of previous annotator is passed to the next one. + """ + + def __init__(self, annotators, required_keys, **kwargs): + super(FailSafe, self).__init__(**kwargs) + self.annotators = list(annotators) + self.required_keys = list(required_keys) + + def annotate(self, image, **kwargs): + if 'annotations' not in kwargs: + kwargs['annotations'] = {} + for annotator in self.annotators: + try: + annotations = annotator(image, **kwargs) + except Exception: + logger.warning( + "The annotator `%s' failed to annotate!", annotator, + exc_info=True) + annotations = {} + kwargs['annotations'].update(annotations) + # check if we have all the required annotations + if all(key in kwargs['annotations'] for key in self.required_keys): + break + return kwargs['annotations'] diff --git a/bob/bio/face/annotator/__init__.py b/bob/bio/face/annotator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7f91254ab71b4a618fa7ce11884a4e2d68f7828b --- /dev/null +++ b/bob/bio/face/annotator/__init__.py @@ -0,0 +1,69 @@ +from .Base import Base +from bob.ip.facedetect import bounding_box_from_annotation + + +def bounding_box_to_annotations(bbx): + landmarks = {} + landmarks['topleft'] = bbx.topleft_f + landmarks['bottomright'] = bbx.bottomright_f + return landmarks + + +def normalize_annotations(annotations, validator, max_age=-1): + """Normalizes the annotations of one video sequence. It fills the + annotations for frames from previous ones if the annotation for the current + frame is not valid. + + Parameters + ---------- + annotations : dict + A dict of dict where the keys to the first dict are frame indices as + strings (starting from 0). The inside dicts contain annotations for + that frame. + validator : callable + Takes a dict (annotations) and returns True if the annotations are + valid. This can be check based on minimal face size for example. + max_age : :obj:`int`, optional + An integer indicating for a how many frames a detected face is valid if + no detection occurs after such frame. A value of -1 == forever + + Yields + ------ + dict + The corrected annotations of frames. + """ + # the annotations for the current frame + current = {} + age = 0 + + for k, annot in annotations.items(): + if validator(annot): + current = annot + age = 0 + elif max_age < 0 or age < max_age: + age += 1 + else: # no detections and age is larger than maximum allowed + current = {} + + yield current + + +def min_face_size_validator(annotations, min_face_size=32): + """Validates annotations based on face's minimal size. + + Parameters + ---------- + annotations : dict + The annotations in dictionary format. + min_face_size : int, optional + The minimal size of a face. + + Returns + ------- + bool + True, if the face is large enough. + """ + bbx = bounding_box_from_annotation(**annotations) + if bbx.size < 32: + return False + return True diff --git a/bob/bio/face/annotator/bobipfacedetect.py b/bob/bio/face/annotator/bobipfacedetect.py new file mode 100644 index 0000000000000000000000000000000000000000..a648c3d174492c9380a237dfb33d3402a972bb52 --- /dev/null +++ b/bob/bio/face/annotator/bobipfacedetect.py @@ -0,0 +1,49 @@ +import math +from bob.io.base import HDF5File +from bob.ip.facedetect import ( + detect_single_face, Sampler, default_cascade, Cascade, + expected_eye_positions) +from . import Base, bounding_box_to_annotations + + +class BobIpFacedetect(Base): + """Annotator using bob.ip.facedetect""" + + def __init__(self, cascade=None, + detection_overlap=0.2, distance=2, + scale_base=math.pow(2., -1. / 16.), lowest_scale=0.125, + **kwargs): + super(BobIpFacedetect, self).__init__(**kwargs) + self.sampler = Sampler( + scale_factor=scale_base, lowest_scale=lowest_scale, + distance=distance) + if cascade is None: + self.cascade = default_cascade() + else: + self.cascade = Cascade(HDF5File(cascade)) + self.detection_overlap = detection_overlap + + def annotate(self, image, **kwargs): + """Return topleft and bottomright and expected eye positions + + Parameters + ---------- + image : array + Image gray scale. + **kwargs + Ignored. + + Returns + ------- + dict + The annotations in a dictionary. The keys are topleft, bottomright, + quality, leye, reye. + """ + if image.ndim != 2: + raise ValueError("The image must be gray scale (two dimensions).") + bounding_box, quality = detect_single_face( + image, self.cascade, self.sampler, self.detection_overlap) + landmarks = expected_eye_positions(bounding_box) + landmarks.update(bounding_box_to_annotations(bounding_box)) + landmarks['quality'] = quality + return landmarks diff --git a/bob/bio/face/annotator/bobipflandmark.py b/bob/bio/face/annotator/bobipflandmark.py new file mode 100644 index 0000000000000000000000000000000000000000..414c2d66b1fca3f2009d3fd3ace4f34a1fdb4058 --- /dev/null +++ b/bob/bio/face/annotator/bobipflandmark.py @@ -0,0 +1,46 @@ +from . import Base +from bob.ip.flandmark import Flandmark + + +class BobIpFlandmark(Base): + """Annotator using bob.ip.flandmark. + This annotator needs the topleft and bottomright annotations provided.""" + + def __init__(self, **kwargs): + super(BobIpFlandmark, self).__init__(**kwargs) + self.flandmark = Flandmark() + + def annotate(self, image, annotations, **kwargs): + """Annotates a gray-scale image + + Parameters + ---------- + image : array + Image in gray-scale. + annotations : dict + The topleft and bottomright annotations are required. + **kwargs + Ignored. + + Returns + ------- + dict + Annotations with reye and leye keys or an empty dict if it fails. + """ + top, left = annotations['topleft'] + top, left = max(top, 0), max(left, 0) + height = annotations['bottomright'][0] - top + width = annotations['bottomright'][1] - left + height, width = min(height, image.shape[0]), min(width, image.shape[1]) + + landmarks = self.flandmark.locate(image, top, left, height, width) + + if landmarks is not None and len(landmarks): + return { + 'reye': ((landmarks[1][0] + landmarks[5][0]) / 2., + (landmarks[1][1] + landmarks[5][1]) / 2.), + 'leye': ((landmarks[2][0] + landmarks[6][0]) / 2., + (landmarks[2][1] + landmarks[6][1]) / 2.) + } + else: + return {} diff --git a/bob/bio/face/annotator/bobipmtcnn.py b/bob/bio/face/annotator/bobipmtcnn.py new file mode 100644 index 0000000000000000000000000000000000000000..32669d953367955d67168bdceb63e680ddd733f5 --- /dev/null +++ b/bob/bio/face/annotator/bobipmtcnn.py @@ -0,0 +1,15 @@ +from . import Base, bounding_box_to_annotations +from bob.ip.mtcnn import FaceDetector + + +class BobIpMTCNN(Base): + """Annotator using bob.ip.mtcnn""" + + def __init__(self, **kwargs): + super(BobIpMTCNN, self).__init__(**kwargs) + self.detector = FaceDetector() + + def annotate(self, image, **kwargs): + bounding_box, landmarks = self.detector.detect_single_face(image) + landmarks.update(bounding_box_to_annotations(bounding_box)) + return landmarks diff --git a/bob/bio/face/script/annotate.py b/bob/bio/face/script/annotate.py new file mode 100644 index 0000000000000000000000000000000000000000..92daef2fa1c7fb22f97c798a1bc601f1a716300c --- /dev/null +++ b/bob/bio/face/script/annotate.py @@ -0,0 +1,80 @@ +"""A script to help annotate databases. +""" +import logging +import json +import click +from os.path import dirname +from bob.extension.scripts.click_helper import ( + verbosity_option, Command, Option) +from bob.io.base import create_directories_safe +from bob.bio.base.tools.grid import indices + +logger = logging.getLogger(__name__) + + +@click.command(entry_point_group='bob.bio.config', cls=Command) +@click.option('--database', '-d', required=True, cls=Option, + entry_point_group='bob.bio.database') +@click.option('--annotator', '-a', required=True, cls=Option, + entry_point_group='bob.bio.annotator') +@click.option('--output-dir', '-o', required=True, cls=Option) +@click.option('--force', '-f', is_flag=True, cls=Option) +@click.option('--jobs', type=click.INT, default=1,) +@verbosity_option(cls=Option) +def annotate(database, annotator, output_dir, force, jobs, **kwargs): + """Annotates a database. + The annotations are written in text file (json) format which can be read + back using :any:`bob.db.base.read_annotation_file` (annotation_type='json') + + \b + Parameters + ---------- + database : :any:`bob.bio.database` + The database that you want to annotate. Can be a ``bob.bio.database`` + entry point or a path to a Python file which contains a variable + named `database`. + annotator : callable + A function that takes the database and a sample (biofile) of the + database and returns the annotations in a dictionary. Can be a + ``bob.bio.annotator`` entry point or a path to a Python file which + contains a variable named `annotator`. + output_dir : str + The directory to save the annotations. + force : bool, optional + Wether to overwrite existing annotations. + jobs : int, optional + Use this option alongside gridtk to submit this script as an array job. + verbose : int, optional + Increases verbosity (see help for --verbose). + + \b + [CONFIG]... Configuration files. It is possible to pass one or + several Python files (or names of ``bob.bio.config`` + entry points) which contain the parameters listed + above as Python variables. The options through the + command-line (see below) will override the values of + configuration files. + """ + logger.debug('database: %s', database) + logger.debug('annotator: %s', annotator) + logger.debug('force: %s', force) + logger.debug('output_dir: %s', output_dir) + logger.debug('jobs: %s', jobs) + logger.debug('kwargs: %s', kwargs) + biofiles = database.all_files(groups=None) + if jobs > 1: + start, end = indices(biofiles, jobs) + biofiles = biofiles[start:end] + logger.info("Saving annotations in %s", output_dir) + total = len(biofiles) + logger.info("Processing %d files ...", total) + for i, biofile in enumerate(biofiles): + logger.info( + "Extracting annotations for file %d out of %d", i + 1, total) + data = annotator.read_original_data( + biofile, database.original_directory, database.original_extension) + annot = annotator(data, annotations=database.annotations(biofile)) + outpath = biofile.make_path(output_dir, '.json') + create_directories_safe(dirname(outpath)) + with open(outpath, 'w') as f: + json.dump(annot, f) diff --git a/setup.py b/setup.py index e0e5768d9cc0d6326553634cf47a3e230eea933d..1fef71cafc4f0402b3845d77a5e923f2fae69f15 100644 --- a/setup.py +++ b/setup.py @@ -180,6 +180,11 @@ setup( 'histogram = bob.bio.face.config.algorithm.histogram:algorithm', # LGBPHS histograms 'bic-jets = bob.bio.face.config.algorithm.bic_jets:algorithm', # BIC on gabor jets ], + + 'bob.bio.cli': [ + 'annotate = bob.bio.face.script.annotate:annotate' + ] + }, # Classifiers are important if you plan to distribute this package through