diff --git a/bob/pad/face/preprocessor/BlockPatch.py b/bob/pad/face/preprocessor/BlockPatch.py new file mode 100644 index 0000000000000000000000000000000000000000..99e9270bdbcc8e1ad0f8e843effc82b98ee7f792 --- /dev/null +++ b/bob/pad/face/preprocessor/BlockPatch.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Mon Aug 6 14:14:28 2018 + +@author: Olegs Nikisins +""" + +# ============================================================================= +# Import what is needed here: + +from bob.bio.base.preprocessor import Preprocessor + +import numpy as np + + +# ============================================================================= +class BlockPatch(Preprocessor, object): + """ + This class is designed to extract patches from the ROI in the input image. + The ROI/block to extract patches from is defined by the top-left and + bottom-right coordinates of the bounding box. Patches can be extracted + from the loactions of the nodes of the uniform grid. + Size of the grid cell is defined by the step parameter. + Patches are of the square shape, and the number of extracted patches is + equal to the number of nodes. All possible patches will be extracted from + the ROI. If ROI is not defined, the entire image will be considered as ROI. + + **Parameters:** + + ``patch_size`` : :py:class:`int` + The size of the square patch to extract from image. + The dimensionality of extracted patches: + ``num_channels x patch_size x patch_size``, where ``num_channels`` is + the number of channels in the input image. + + ``step`` : :py:class:`int` + Defines the size of the cell of the uniform grid to extract patches + from. Patches will be extracted from the locations of the grid nodes. + + ``use_annotations_flag`` : bool + A flag defining if annotations should be used in the call method. + If ``False``, patches from the whole image will be extracted. + If ``True``, patches from the ROI defined by the annotations will be + extracted, + Default: True. + """ + + # ========================================================================== + def __init__(self, patch_size, + step, + use_annotations_flag = True): + + super(BlockPatch, self).__init__(patch_size=patch_size, + step=step, + use_annotations_flag=use_annotations_flag) + + self.patch_size = patch_size + self.step = step + self.use_annotations_flag = use_annotations_flag + + + # ========================================================================== + def __call__(self, image, annotations=None): + """ + This class is designed to extract patches from the ROI in the input + image. ROI is defined by the ``annotations`` argument. If + annotations are not given, patches will be extracted from the whole + image. + + **Parameters:** + + ``image`` : 2D to ND :py:class:`numpy.ndarray` + Input image (gray-scale, RGB or multi-spectral). + The expected dimensionality of the image is: + ``num_channels x w x h``. + + ``annotations`` : [] or None + A list containing annotations of bounding box defining ROI. + ``annotations[0] = [x_top_left, y_top_left]`` + ``annotations[1] = [x_bottom_right, y_bottom_right]`` + Non-integer annotations are also allowed. + + **Returns:** + + ``patches_array`` : 2D :py:class:`numpy.ndarray` + An array containing flattened patches. To get a list of patches + with original dimensions you can do the following: + ``patches_reconstructed = [patch.reshape(n_channels, patch_size, patch_size) for patch in patches_array]``. + """ + + if not self.use_annotations_flag: + + annotations = None # don't use annotations + + """ + Get the ROI: + """ + if annotations is not None: + + x1 = np.max([0, np.int(annotations[0][0])]) + x2 = np.min([np.int(annotations[1][0]), image.shape[-1]]) + y1 = np.max([0, np.int(annotations[0][1])]) + y2 = np.min([np.int(annotations[1][1]), image.shape[-2]]) + + if len(image.shape) == 2: # for gray-scale images + + roi = image[y1:y2, x1:x2] + + elif len(image.shape) == 3: # for multi-spectral images + + roi = image[:,y1:y2, x1:x2] + + else: # input data of higher dimensions is not handled + + return None + + else: # if annotations are not defined + + roi = image + + """ + Get patches from ROI: + """ + n_blocks_x = np.int((roi.shape[-1] - self.patch_size)/self.step + 1) # Number of full patches horizontally + n_blocks_y = np.int((roi.shape[-2] - self.patch_size)/self.step + 1) # Number of full patches vertically + + patch_indices_x = np.arange(n_blocks_x)*self.step # Horizontal starting indices of the patches + patch_indices_y = np.arange(n_blocks_y)*self.step # Vorizontal starting indices of the patches + + # Function to get vertical blocks from image, given starting indices of the blocks: + get_vert_block = lambda im, x_vec : [im[:, x:x+self.patch_size] if len(im.shape)==2 else im[:, :, x:x+self.patch_size] for x in x_vec] + + # Function to get horizontal blocks from image, given starting indices of the blocks: + get_hor_block = lambda im, y_vec : [im[y:y+self.patch_size, :] if len(im.shape)==2 else im[:, y:y+self.patch_size, :] for y in y_vec] + + # Get all the patches from ROI, patches are returned row-wise: + patches = [hor_block for vert_block in get_vert_block(roi, patch_indices_x) for hor_block in get_hor_block(vert_block, patch_indices_y)] + + if not patches: # if no patches can be computed + return None + + patches_array = np.vstack([np.ndarray.flatten(patch) for patch in patches]) + + return patches_array + + diff --git a/bob/pad/face/preprocessor/VideoFaceCropAlignBlockPatch.py b/bob/pad/face/preprocessor/VideoFaceCropAlignBlockPatch.py new file mode 100644 index 0000000000000000000000000000000000000000..4aa913090e9272739d879a184f077637511a0884 --- /dev/null +++ b/bob/pad/face/preprocessor/VideoFaceCropAlignBlockPatch.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Wed Aug 22 15:38:28 2018 + +@author: Olegs Nikisins +""" + +# ============================================================================= +# Import what is needed here: + +from bob.bio.base.preprocessor import Preprocessor + +import bob.bio.video + +import numpy as np + +from bob.bio.video.utils import FrameSelector + +from bob.bio.video.preprocessor import Wrapper + +import bob.ip.dlib + +import cv2 + +from skimage import morphology + +from bob.bio.video.utils.FrameContainer import FrameContainer + + +# ============================================================================= +def get_face_contour_mask(image, filt_size = None, erode_flag = False, crop_flag = False): + """ + This function computes the binary mask for the facial region using + landmarks of the face contour. The following steps are executed: + + 1. Facial landmarks are detected in the input facial image. + 2. Binary mask of the face region is computed using landmarks + corresponding to face contour. + 3. Mask can next be cropped, like if it was filtered with a filter + of the size ``(filt_size x filt_size)``. + 4. Binary erosion can also be applied to the mask. The disk structuring + element is used for dilation. + The diameter of the disk is ``filt_size``. + + **Parameters:** + + ``image`` : 2D to ND :py:class:`numpy.ndarray` + Input image (gray-scale, RGB or multi-spectral). + The expected dimensionality of the image is: + ``num_channels x w x h``. + + ``filt_size`` : int + If given, a binary mask can be cropped like if it was fileter + with a filter of size ``(filt_size x filt_size)``. + Also, the mask can be eroded with disk of the size ``filt_size``. + + ``erode_flag`` : bool + If set to ``True``, a binary mask will be eroded with a disk + structuring element of the diameter ``filt_size``. + + ``crop_flag`` : bool + If set to ``True``, a binary mask will be cropped like if it + was fileter with a filter of size ``(filt_size x filt_size)``. + + **Returns:** + + ``mask`` : 2D :py:class:`numpy.ndarray` + A binary maks of the face region. + """ + + # landmarks + detector = bob.ip.dlib.DlibLandmarkExtraction() + points = detector(image) + +# mask_rgb = 1-np.transpose(np.any(image==255, axis=0)).astype(np.int) + + if points is not None: + + temp_mask = np.zeros((image.shape[-2], image.shape[-1])) + + # face_contour = points[0:27] # this is a complete contour + # face_contour = points[:16] # this is a lower half-face + face_contour = points[:16] # this is a lower half-face + # go vertical from to the top of image from lower half of the face: + face_contour = [(0, face_contour[0][1])] + face_contour + [(0, face_contour[-1][1])] + + hull = cv2.convexHull(np.array(face_contour)) + + mask = cv2.drawContours(temp_mask, [hull], 0, 1, -1) + +# mask = mask*mask_rgb + + else: + + mask = np.ones((image.shape[-2], image.shape[-1])) + +# mask = mask*mask_rgb + + if filt_size is not None and erode_flag: + + selem = morphology.disk(filt_size) + # invert and dilate the mask to obtain erosion without black border: + mask = morphology.binary_dilation(1-mask, selem=selem) + # invert the mask back + mask = 1-mask + + if filt_size is not None and crop_flag: + + start = np.int(np.floor(filt_size/2.)) + end = np.int(np.ceil(filt_size/2.)) + + mask = mask[start:mask.shape[0]-end+1, start:mask.shape[1]-end+1] + + return mask + + +class VideoFaceCropAlignBlockPatch(Preprocessor, object): + """ + This class is designed to first detect, crop and align face in all input + channels, and then to extract patches from the ROI in the cropped faces. + + The computation flow is the following: + + 1. Detect, crop and align facial region in all input channels. + 2. Concatenate all channels forming a single multi-channel video data. + 3. Extract multi-channel patches from the ROI of the multi-channel video data. + 4. Vectorize extracted patches. + + **Parameters:** + + ``preprocessors`` : :py:class:`dict` + A dictionary containing preprocessors for all channels. Dictionary + structure is the following: + ``{channel_name_1: bob.bio.video.preprocessor.Wrapper, `` + ``channel_name_2: bob.bio.video.preprocessor.Wrapper, ...}`` + Note: video, not image, preprocessors are expected. + + ``channel_names`` : [str] + A list of chanenl names. Channels will be processed in this order. + + ``return_multi_channel_flag`` : bool + If this flag is set to ``True``, a multi-channel video data will be + returned. Otherwise, patches extracted from ROI of the video are + returned. + Default: ``False``. + + ``block_patch_preprocessor`` : object + An instance of the ``bob.pad.face.preprocessor.BlockPatch`` class, + which is used to extract multi-spectral patches from ROI of the facial + region. + + ``get_face_contour_mask_dict`` : dict or None + Kwargs for the ``get_face_contour_mask()`` function. See description + of this function for more details. If not ``None``, a binary mask of + the face will be computed. Patches outside of the mask are set to zero. + Default: None + + ``append_mask_flag`` : bool + If set to ``True``, mask will be flattened and concatenated to output + array of patches. NOTE: mame sure extractor is capable of handling this + case in case you set this flag to ``True``. + Default: ``False`` + + ``feature_extractor`` : object + An instance of the feature extractor to be applied to the patches. + Default is ``None``, meaning that **patches** are returned by the + preprocessor, and no feature extraction is applied. + Defining ``feature_extractor`` instance can be usefull, for example, + when saving the pathes is taking too much memory. + Note, that ``feature_extractor`` should be able to process + FrameContainers. + Default: ``None`` + """ + + # ========================================================================= + def __init__(self, preprocessors, + channel_names, + return_multi_channel_flag = False, + block_patch_preprocessor = None, + get_face_contour_mask_dict = None, + append_mask_flag = False, + feature_extractor = None): + + super(VideoFaceCropAlignBlockPatch, self).__init__(preprocessors = preprocessors, + channel_names = channel_names, + return_multi_channel_flag = return_multi_channel_flag, + block_patch_preprocessor = block_patch_preprocessor, + get_face_contour_mask_dict = get_face_contour_mask_dict, + append_mask_flag = append_mask_flag, + feature_extractor = feature_extractor) + + self.preprocessors = preprocessors + + self.channel_names = channel_names + + self.return_multi_channel_flag = return_multi_channel_flag + + self.block_patch_preprocessor = block_patch_preprocessor + + self.get_face_contour_mask_dict = get_face_contour_mask_dict + + self.append_mask_flag = append_mask_flag + + self.feature_extractor = feature_extractor + + + # ========================================================================= + def __call__(self, frames, annotations): + """ + This function is designed to first detect, crop and align face in all + input channels, and then to extract patches from the ROI in the + cropped faces. + + The computation flow is the following: + 1. Detect, crop and align facial region in all input channels. + 2. Concatenate all channels forming a single multi-channel video data. + 3. Extract multi-channel patches from the ROI of the multi-channel video + data. + 4. Vectorize extracted patches. + 5. If ``feature_extractor`` is defined, the extractor will be applied + to the patches. By default, no extractor is applied. + + **Parameters:** + + ``frames`` : :py:class:`dict` + A dictionary containing FrameContainers for multiple channels. + + ``annotations`` : :py:class:`dict` + A dictionary containing annotations for + each frame in the video. + Dictionary structure (non-SWIR channels): + ``annotations = {'1': frame1_dict, '2': frame1_dict, ...}``. + Where + ``frameN_dict`` contains coordinates of the + face bounding box and landmarks in frame N. + + Also, ``annotations`` dictionary is expected to have a key namely + ``face_roi``. This key point to annotations defining ROI in the + facial region. ROI is annotated as follows: + ``annotations['face_roi'][0] = [x_top_left, y_top_left]`` + ``annotations['face_roi'][1] = [x_bottom_right, y_bottom_right]`` + + **Returns:** + + FrameContainer + Contains either multi-channel preprocessed data, or patches + extracted from this data. The output is controlled by + ``return_multi_channel_flag`` of this class. + """ + + # If an input is a FrameContainer convert it to the dictionary with the key from the self.channel_names: + if isinstance(frames, FrameContainer): + frames = dict(zip(self.channel_names, [frames])) + + # Preprocess all channels: + preprocessed = [self.preprocessors[channel](frames[channel], annotations) for channel in self.channel_names] + + if None in preprocessed: + + return None + + # convert all channels to arrays: + preprocessed_arrays = [item.as_array() for item in preprocessed] + + # Convert arrays of dimensionality 3 to 4 if necessary: + preprocessed_arrays = [np.expand_dims(item, axis=1) if len(item.shape)==3 else item for item in preprocessed_arrays] + + # Concatenate streams channel-wise: + preprocessed_arrays = np.concatenate(preprocessed_arrays, axis=1) + + # Convert to frame container: + preprocessed_fc = bob.bio.video.FrameContainer() # initialize the FrameContainer + [preprocessed_fc.add(idx, item) for idx, item in enumerate(preprocessed_arrays)] + + if self.return_multi_channel_flag: + + return preprocessed_fc + + if self.block_patch_preprocessor is not None: + + frame_selector = FrameSelector(selection_style = "all") + + video_block_patch = Wrapper(preprocessor = self.block_patch_preprocessor, + frame_selector = frame_selector) + else: + return None + + if 'face_roi' in annotations: # if ROI annotations are given + + roi_annotations={} + + roi_annotations['0'] = annotations['face_roi'] + + else: # extract patches from the whole image + + roi_annotations = None + + patches = video_block_patch(frames = preprocessed_fc, annotations = roi_annotations) + + # compute face masks if needed: + if self.get_face_contour_mask_dict is not None: + + patches_masked = bob.bio.video.FrameContainer() # initialize the FrameContainer + + for idx, (frame, frame_patches) in enumerate(zip(preprocessed_arrays, patches)): + + # here we assume that first three slices 0:3 correspond to RGB image: + mask = get_face_contour_mask(image = frame[0:3, :, :], **self.get_face_contour_mask_dict) + + if mask is not None: + + mask = mask.flatten() + + if self.append_mask_flag: + + patches_masked.add(idx, np.c_[frame_patches[1], mask]) + + else: + + patches_masked.add(idx, np.transpose(np.transpose(frame_patches[1])*mask)) + + patches = patches_masked + + # Features can be extracted in the preprocessing stage, if feature extractor is given. + # For example, this can be used, when memory needed for saving the patches is too big. + if self.feature_extractor is not None: + + features = self.feature_extractor(patches) + + return features + + return patches + + + # ========================================================================= + def write_data(self, frames, file_name): + """ + Writes the given data (that has been generated using the __call__ + function of this class) to file. This method overwrites the write_data() + method of the Preprocessor class. + + **Parameters:** + + ``frames`` : + data returned by the __call__ method of the class. + + ``file_name`` : :py:class:`str` + name of the file. + """ + + self.preprocessors[self.channel_names[0]].write_data(frames, file_name) + + + # ========================================================================= + def read_data(self, file_name): + """ + Reads the preprocessed data from file. + This method overwrites the read_data() method of the Preprocessor class. + + **Parameters:** + + ``file_name`` : :py:class:`str` + name of the file. + + **Returns:** + + ``frames`` : :py:class:`bob.bio.video.FrameContainer` + Frames stored in the frame container. + """ + + frames = self.preprocessors[self.channel_names[0]].read_data(file_name) + + return frames + + diff --git a/bob/pad/face/preprocessor/__init__.py b/bob/pad/face/preprocessor/__init__.py index 4badf05c20a8e153516d99f8b3a89e5036bfc5b8..9c061901c9379473af0815eba039e973b5b42829 100644 --- a/bob/pad/face/preprocessor/__init__.py +++ b/bob/pad/face/preprocessor/__init__.py @@ -1,6 +1,8 @@ from .FaceCropAlign import FaceCropAlign from .FrameDifference import FrameDifference from .VideoSparseCoding import VideoSparseCoding +from .VideoFaceCropAlignBlockPatch import VideoFaceCropAlignBlockPatch +from .BlockPatch import BlockPatch from .LiPulseExtraction import LiPulseExtraction from .Chrom import Chrom @@ -33,5 +35,7 @@ __appropriate__( Chrom, SSR, PPGSecure, + VideoFaceCropAlignBlockPatch, + BlockPatch, ) __all__ = [_ for _ in dir() if not _.startswith('_')] diff --git a/bob/pad/face/test/test.py b/bob/pad/face/test/test.py index f7c7ce29622448a0b655c72e880e0ac7b7871d21..f2dacebefb043d6f8b9738bd78165c97456392e0 100644 --- a/bob/pad/face/test/test.py +++ b/bob/pad/face/test/test.py @@ -42,6 +42,12 @@ from ..preprocessor.FaceCropAlign import detect_face_landmarks_in_image from bob.bio.video.preprocessor import Wrapper +from ..preprocessor import VideoFaceCropAlignBlockPatch + +from bob.bio.video.utils import FrameSelector + +from ..preprocessor import BlockPatch + def test_detect_face_landmarks_in_image_mtcnn(): @@ -214,6 +220,95 @@ def test_video_face_crop(): assert np.sum(faces[-1][1]) == 1238664 +# ============================================================================= +def test_video_face_crop_align_block_patch(): + """ + Test VideoFaceCropAlignBlockPatch preprocessor. + """ + + # ========================================================================= + # prepare the test data: + + image = load(datafile('test_image.png', 'bob.pad.face.test')) + + annotations = None + + video, annotations = convert_image_to_video_data(image, annotations, 2) + + mc_video = {} + mc_video["color_1"] = video + mc_video["color_2"] = video + mc_video["color_3"] = video + + # ========================================================================= + # Initialize the VideoFaceCropAlignBlockPatch. + + # names of the channels to process: + _channel_names = ['color_1', 'color_2', 'color_3'] + + # dictionary containing preprocessors for all channels: + _preprocessors = {} + + """ + All channels are color, so preprocessors for all of them are identical. + """ + FACE_SIZE = 128 # The size of the resulting face + RGB_OUTPUT_FLAG = False # BW output + USE_FACE_ALIGNMENT = True # use annotations + MAX_IMAGE_SIZE = None # no limiting here + FACE_DETECTION_METHOD = "mtcnn" # use ANNOTATIONS + MIN_FACE_SIZE = 50 # skip small faces + + _image_preprocessor = FaceCropAlign(face_size = FACE_SIZE, + rgb_output_flag = RGB_OUTPUT_FLAG, + use_face_alignment = USE_FACE_ALIGNMENT, + max_image_size = MAX_IMAGE_SIZE, + face_detection_method = FACE_DETECTION_METHOD, + min_face_size = MIN_FACE_SIZE) + + _frame_selector = FrameSelector(selection_style = "all") + + _preprocessor_rgb = Wrapper(preprocessor = _image_preprocessor, + frame_selector = _frame_selector) + + _preprocessors[_channel_names[0]] = _preprocessor_rgb + _preprocessors[_channel_names[1]] = _preprocessor_rgb + _preprocessors[_channel_names[2]] = _preprocessor_rgb + + """ + The instance of the BlockPatch preprocessor. + """ + + PATCH_SIZE = 64 + STEP = 32 + + _block_patch = BlockPatch(patch_size = PATCH_SIZE, + step = STEP, + use_annotations_flag = False) + + preprocessor = VideoFaceCropAlignBlockPatch(preprocessors = _preprocessors, + channel_names = _channel_names, + return_multi_channel_flag = True, + block_patch_preprocessor = _block_patch) + + # ========================================================================= + # pre-process the data and assert the result: + + data_preprocessed = preprocessor(frames = mc_video, annotations = annotations) + + assert len(data_preprocessed) == 2 + assert data_preprocessed[0][1].shape == (3, 128, 128) + assert data_preprocessed[1][1].shape == (3, 128, 128) + + preprocessor.return_multi_channel_flag = False # now extract patches + + data_preprocessed = preprocessor(frames = mc_video, annotations = annotations) + + assert len(data_preprocessed) == 2 + assert data_preprocessed[0][1].shape == (9, 12288) + assert data_preprocessed[1][1].shape == (9, 12288) + + #============================================================================== def test_frame_difference(): """ @@ -388,7 +483,7 @@ def test_preprocessor_LiPulseExtraction(): image = load(datafile('test_image.png', 'bob.pad.face.test')) annotations = {'topleft': (95, 155), 'bottomright': (215, 265)} video, annotations = convert_image_to_video_data(image, annotations, 100) - + preprocessor = LiPulseExtraction(debug=False) pulse = preprocessor(video, annotations) assert pulse.shape == (100, 3) @@ -401,7 +496,7 @@ def test_preprocessor_Chrom(): image = load(datafile('test_image.png', 'bob.pad.face.test')) annotations = {'topleft': (95, 155), 'bottomright': (215, 265)} video, annotations = convert_image_to_video_data(image, annotations, 100) - + preprocessor = Chrom(debug=False) pulse = preprocessor(video, annotations) assert pulse.shape[0] == 100 @@ -414,7 +509,7 @@ def test_preprocessor_PPGSecure(): image = load(datafile('test_image.png', 'bob.pad.face.test')) annotations = {'topleft': (456, 212), 'bottomright': (770, 500)} video, annotations = convert_image_to_video_data(image, annotations, 100) - + preprocessor = PPGPreprocessor(debug=False) pulse = preprocessor(video, annotations) assert pulse.shape == (100, 5) @@ -423,29 +518,29 @@ def test_preprocessor_PPGSecure(): def test_preprocessor_SSR(): """ Test the pulse extraction using SSR algorithm. """ - + image = load(datafile('test_image.png', 'bob.pad.face.test')) annotations = {'topleft': (95, 155), 'bottomright': (215, 265)} video, annotations = convert_image_to_video_data(image, annotations, 100) - + preprocessor = SSR(debug=False) pulse = preprocessor(video, annotations) assert pulse.shape[0] == 100 def test_extractor_LTSS(): - """ Test Long Term Spectrum Statistics (LTSS) Feature Extractor + """ Test Long Term Spectrum Statistics (LTSS) Feature Extractor """ - + # "pulse" in 3 color channels data = np.random.random((200, 3)) - + extractor = LTSS(concat=True) features = extractor(data) # n = number of FFT coefficients (default is 64) # (n/2 + 1) * 2 (mean and std) * 3 (colors channels) assert features.shape[0] == 33*2*3 - + extractor = LTSS(concat=False) features = extractor(data) # only one "channel" is considered @@ -453,23 +548,23 @@ def test_extractor_LTSS(): def test_extractor_LiSpectralFeatures(): - """ Test Li's ICPR 2016 Spectral Feature Extractor + """ Test Li's ICPR 2016 Spectral Feature Extractor """ - + # "pulse" in 3 color channels data = np.random.random((200, 3)) - + extractor = LiSpectralFeatures() features = extractor(data) - assert features.shape[0] == 6 - + assert features.shape[0] == 6 + def test_extractor_PPGSecure(): - """ Test PPGSecure Spectral Feature Extractor + """ Test PPGSecure Spectral Feature Extractor """ - # 5 "pulses" + # 5 "pulses" data = np.random.random((200, 5)) - + extractor = PPGExtractor() features = extractor(data) # n = number of FFT coefficients (default is 32) diff --git a/conda/meta.yaml b/conda/meta.yaml index ba29310df2b9b246cee748b6d9356420d0e0073d..15ea77a3ace3461765597ad4f14b5a686e17be38 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -42,6 +42,8 @@ requirements: - six - numpy >=1.11 - scikit-learn + - scikit-image + - opencv test: imports: diff --git a/requirements.txt b/requirements.txt index e77e1db4afde0822bf74bacc67fae85a4bbf5a8a..1343f41480a69d5d96ab5ddebc8406a729dcc026 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ bob.learn.libsvm bob.learn.linear scikit-learn bob.rppg.base >= 2.0.0 +scikit-image