diff --git a/bob/bio/face/database/replaymobile.py b/bob/bio/face/database/replaymobile.py index 8117e1a1a70b28b3107023ec93b387f3db34314e..e53d88cdd7b869931492742fbdf9a7e49f68fab3 100644 --- a/bob/bio/face/database/replaymobile.py +++ b/bob/bio/face/database/replaymobile.py @@ -1,232 +1,280 @@ #!/usr/bin/env python # Yannick Dayer <yannick.dayer@idiap.ch> -from bob.pipelines.datasets import FileListDatabase, CSVToSamples +from bob.bio.base.database import CSVDataset, CSVToSampleLoaderBiometrics +from bob.pipelines.datasets.sample_loaders import AnnotationsLoader from bob.pipelines.sample import DelayedSample -from bob.extension.download import list_dir, search_file -from bob.db.base.utils import check_parameters_for_validity -from bob.db.base.annotations import read_annotation_file -from bob.io.video import reader as video_reader -from bob.bio.base.pipelines.vanilla_biometrics import Database +from bob.extension.download import get_file +from bob.io.video import reader +from bob.extension import rc +import bob.core from sklearn.base import TransformerMixin, BaseEstimator from sklearn.pipeline import make_pipeline import functools import os.path -import logging import numpy -logger = logging.getLogger(__name__) +logger = bob.core.log.setup("bob.bio.face") -class VideoReader(TransformerMixin, BaseEstimator): - """Transformer that loads the video data from a file - """ - def __init__(self, data_path, file_ext=".mov"): - self.data_path = data_path - self.file_ext = file_ext - - def transform(self, X, y=None): - all_samples = [] - for sample in X: - all_samples.append( - DelayedSample( - load=functools.partial(video_reader, os.path.join(self.data_path, sample.PATH + self.file_ext)), - parent=sample, - ) - ) - return all_samples +def load_frame_from_file_replaymobile(file_name, frame, capturing_device): + """Loads a single frame from a video file for replay-mobile. + + This function uses bob's video reader utility that does not load the full + video in memory to just access one frame. + + Parameters + ---------- - def fit(self, X, y=None): - return self + file_name: str + The video file to load the frames from - def _more_tags(self): - return { - "stateless": True, - "requires_fit": False, - } + frame: None or list of int + The index of the frame to load. + capturing device: str + 'mobile' devices' frames will be flipped vertically. + Other devices' frames will not be flipped. -def get_frame_from_sample(video_sample, frame_id): - """Returns one frame's data from a replay-mobile video sample. + Returns + ------- - Flips the image according to the sample's metadata. + images: 3D numpy array + The frame of the video in bob format (channel, height, width) """ - frame = video_sample.data[frame_id] - if video_sample.SHOULD_FLIP: # TODO include this field in the csv files - frame = numpy.flip(frame, 2) + logger.debug(f"Extracting frame {frame} from '{file_name}'") + video_reader = reader(file_name) + image = video_reader[frame] + # Image captured by the 'mobile' device are flipped vertically. + # (Images were captured horizontally and bob.io.video does not read the + # metadata correctly, whether it was on the right or left side) + if capturing_device == "mobile": + image = numpy.flip(image, 2) # Convert to bob format (channel, height, width) - frame = numpy.transpose(frame, (0, 2, 1)) - return frame + image = numpy.transpose(image, (0, 2, 1)) + return image -class VideoToFrames(TransformerMixin, BaseEstimator): - """Transformer that creates a list of frame samples from a video sample. +def read_frame_annotation_file_replaymobile(file_name, frame): + """Returns the bounding-box for one frame of a video file of replay-mobile. - Parameters - ---------- + Given an annnotation file location and a frame number, returns the bounding + box coordinates corresponding to the frame. - frame_indices: None or Sequence[int] - The list of frames to keep. Will keep all the frames if None or empty. - """ - def __init__(self, frame_indices=None): - super().__init__() - self.frame_indices = frame_indices - - def transform(self, X, y=None): - all_samples = [] - # Iterate over each video sample - for video_sample in X: - # Extract frames from the file - [ - all_samples.append(DelayedSample( - load=functools.partial(get_frame_from_sample, video_sample, frame_id), - parent=video_sample, - frame=frame_id, - key=f"{video_sample.ID}_{frame_id}") - ) - for frame_id in range(len(video_sample.data)) - if not self.frame_indices or frame_id in self.frame_indices - ] - return all_samples + The replay-mobile annotation files are composed of 4 columns and N rows for + N frames of the video: - def fit(self, X, y=None): - return self + 120 230 40 40 + 125 230 40 40 + ... + <x> <y> <w> <h> - def _more_tags(self): - return { - "stateless": True, - "requires_fit": False, - } + Parameters + ---------- + file_name: str + The complete annotation file path and name (with extension). -def read_frame_annotations_file(file_name, frame_id): - """Reads an annotations file and extracts one frame's annotations. + frame: int + The video frame index. """ - video_annotations = read_annotation_file(file_name, annotation_type="json") - # read_annotation_file returns an ordered dict with string keys - return video_annotations[f"{frame_id}"] + logger.debug(f"Reading annotation file '{file_name}', frame {frame}.") + if not file_name: + return None -class AnnotationsAdder(TransformerMixin, BaseEstimator): - """Transformer that adds an 'annotations' field to the samples. + if not os.path.exists(file_name): + raise IOError(f"The annotation file '{file_name}' was not found") - This reads a json file containing coordinates for each frame of a video. - """ - def __init__(self, annotation_directory): - self.annotation_directory=annotation_directory - - def transform(self, X, y=None): - all_samples = [] - for sample in X: - delayed_annotations = functools.partial( - read_frame_annotations_file, - file_name=f"{self.annotation_directory}:{sample.PATH}.json", - frame_id=sample.frame, - ) - all_samples.append( - DelayedSample( - load=sample._load, - parent=sample, - delayed_attributes = {"annotations": delayed_annotations}, - ) - ) - return all_samples + with open(file_name, 'r') as f: + # One line is one frame, each line contains a bounding box coordinates + line = f.readlines()[frame] - def fit(self, X, y=None): - return self + positions = line.split(' ') - def _more_tags(self): - return { - "stateless": True, - "requires_fit": False, - } + if len(positions) != 4: + raise ValueError(f"The content of '{file_name}' was not correct for frame {frame} ({positions})") + annotations = { + 'topleft': (float(positions[1]), float(positions[0])), + 'bottomright':( + float(positions[1])+float(positions[3]), + float(positions[0])+float(positions[2]) + ) + } -class CSVToBioSamples(CSVToSamples): - """Iterator that reads a CSV and creates Samples. - """ - def __iter__(self): - for sample in super().__iter__(): - # TODO test that fields are present? (attack_type for vuln?) - yield sample + return annotations +class ReplayMobileCSVFrameSampleLoader(CSVToSampleLoaderBiometrics): + """A loader transformer returning a specific frame of a video file. -class ReplayMobileBioDatabase(FileListDatabase, Database): - """Database for Replay-mobile-img for vulnerability analysis + This is specifically tailored for replay-mobile. It uses a specific loader + that takes the capturing device as input. """ def __init__( self, - dataset_protocols_path, - protocol, - data_path, - data_extension=".mov", - annotations_path=None, - **kwargs, + dataset_original_directory="", + extension="", + reference_id_equal_subject_id=True, ): super().__init__( - dataset_protocols_path, - protocol, - reader_cls=CSVToBioSamples, - transformer=make_pipeline( - VideoReader(data_path=data_path, file_ext=data_extension), - VideoToFrames(range(12,251,24)), - AnnotationsAdder(annotations_path), + data_loader=None, + extension=extension, + dataset_original_directory=dataset_original_directory, + ) + self.reference_id_equal_subject_id = reference_id_equal_subject_id + + def convert_row_to_sample(self, row, header): + """Creates a set of samples given a row of the CSV protocol definition. + """ + path = row[0] + reference_id = row[1] + id = row[2] # Will be used as 'key' + + kwargs = dict([[str(h).lower(), r] for h, r in zip(header[3:], row[3:])]) + if self.reference_id_equal_subject_id: + kwargs["subject_id"] = reference_id + else: + if "subject_id" not in kwargs: + raise ValueError(f"`subject_id` not available in {header}") + # One row leads to multiple samples (different frames) + all_samples = [DelayedSample( + functools.partial( + load_frame_from_file_replaymobile, + file_name=os.path.join(self.dataset_original_directory, path + self.extension), + frame=frame, + capturing_device=kwargs["capturing_device"], ), + key=f"{id}_{frame}", + path=path, + reference_id=reference_id, + frame=frame, + **kwargs, + ) for frame in range(12,251,24)] + return all_samples + + +class FrameBoundingBoxAnnotationLoader(AnnotationsLoader): + """A transformer that adds bounding-box to a sample from annotations files. + + Parameters + ---------- + + annotation_directory: str or None + """ + def __init__(self, + annotation_directory=None, + annotation_extension=".face", + **kwargs + ): + super().__init__( + annotation_directory=annotation_directory, + annotation_extension=annotation_extension, **kwargs ) - self.annotations_path = self.dataset_protocols_path if not annotations_path else annotations_path # TODO default to protocol_path? - self.annotation_type = "eyes-center" - self.fixed_positions = None - def groups(self): - names = list_dir(self.dataset_protocols_path, self.protocol, files=False) - names = [os.path.splitext(n)[0] for n in names] - return names - - def list_file(self, group, purpose): - if purpose == "enroll": - purpose_name = "for_models" - elif purpose == "probe": - purpose_name = "for_probes" - elif purpose == "train": - purpose_name = "train_world" - else: - raise ValueError(f"Unknown purpose '{purpose}'.") - # Protocol files are in the form <db_name>/{dev,eval,train}/{for_models,for_probes}.csv - list_file = search_file( - self.dataset_protocols_path, - os.path.join(self.protocol, group, purpose_name + ".csv"), - ) - return list_file + def transform(self, X): + """Adds the bounding-box annotations to a series of samples. + """ + if self.annotation_directory is None: + return None - def get_reader(self, group, purpose): # TODO use the standard csv format instead? - key = (self.protocol, group, purpose) - if key not in self.readers: - self.readers[key] = self.reader_cls( - list_file=self.list_file(group, purpose), transformer=self.transformer + annotated_samples = [] + for x in X: + + # Build the path to the annotation files structure + annotation_file = os.path.join( + self.annotation_directory, x.path + self.annotation_extension ) - reader = self.readers[key] - return reader + annotated_samples.append( + DelayedSample( + x._load, + parent=x, + delayed_attributes=dict( + annotations=functools.partial( + read_frame_annotation_file_replaymobile, + file_name=annotation_file, + frame=int(x.frame), + ) + ), + ) + ) - def samples(self, groups, purpose): - groups = check_parameters_for_validity( - groups, "groups", self.groups(), self.groups() - ) - all_samples = [] - for grp in groups: + return annotated_samples - for sample in self.get_reader(grp, purpose): - all_samples.append(sample) +class ReplayMobileBioDatabase(CSVDataset): + """Database interface that loads a csv definition for replay-mobile - return all_samples + Looks for the protocol definition files (structure of CSV files). If not + present, downloads them. + Then sets the data and annotation paths from __init__ parameters or from + the configuration (``bob config`` command). - def background_model_samples(self): - return self.samples(groups="train", purpose="train") + Parameters + ---------- - def references(self, group): - return self.samples(groups=group, purpose="enroll") + protocol_name: str + The protocol to use - def probes(self, group): - return self.samples(groups=group, purpose="probe") + protocol_definition_path: str or None + Specifies a path to download the database definition to. + If None: Downloads and uses the ``bob_data_folder`` config. + (See :py:fct:`bob.extension.download.get_file`) - def all_samples(self, groups): - return super().all_samples(groups=groups) + data_path: str or None + Overrides the config-defined data location. + If None: uses the ``bob.db.replaymobile.directory`` config. + If None and the config does not exist, set as cwd. + + annotation_path: str or None + Overrides the config-defined annotation files location. + If None: uses the ``bob.db.replaymobile.annotation_directory`` config. + If None and the config does not exist, set as + ``{data_path}/faceloc/rect``. + """ + def __init__( + self, + protocol_name="bio-grandtest", + protocol_definition_path=None, + data_path=None, + annotation_path=None, + **kwargs + ): + if protocol_definition_path is None: + # Downloading database description files if it is not specified + urls = [ + "https://www.idiap.ch/software/bob/databases/latest/replay-mobile-csv.tar.gz", + "http://www.idiap.ch/software/bob/databases/latest/replay-mobile-csv.tar.gz", + ] + protocol_definition_path = get_file("replay-mobile-csv.tar.gz", urls) + + if data_path is None: + # Defaults to cwd if config not defined + data_path = rc.get("bob.db.replaymobile.directory", "") + + if annotation_path is None: + # Defaults to {data_path}/faceloc/rect if config not defined + annotation_path = rc.get( + "bob.db.replaymobile.annotation_directory", + os.path.join(data_path, "faceloc/rect/") + ) + + logger.info(f"Database: Loading database definition from '{protocol_definition_path}'.") + logger.info(f"Database: Defining data files path as '{data_path}'.") + logger.info(f"Database: Defining annotation files path as '{annotation_path}'.") + super().__init__( + protocol_definition_path, + protocol_name, + csv_to_sample_loader=make_pipeline( + ReplayMobileCSVFrameSampleLoader( + dataset_original_directory=data_path, + extension=".mov", + ), + FrameBoundingBoxAnnotationLoader( + annotation_directory=annotation_path, + annotation_extension=".face", + ), + ), + **kwargs + ) + self.annotation_type = "bounding-box" + self.fixed_positions = None