├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── conf.py ├── index.rst └── source │ ├── CLI.rst │ ├── adding_custom_actions.rst │ ├── basic_example.rst │ ├── building_blocks.rst │ ├── gallery.rst │ ├── installation.rst │ ├── multiple_example.rst │ ├── pipeline_example.rst │ ├── pychubby.actions.rst │ ├── pychubby.base.rst │ ├── pychubby.data.rst │ ├── pychubby.detect.rst │ ├── pychubby.reference.rst │ ├── pychubby.rst │ ├── pychubby.utils.rst │ └── pychubby.visualization.rst ├── pychubby ├── __init__.py ├── actions.py ├── base.py ├── cli.py ├── data.py ├── detect.py ├── reference.py ├── utils.py └── visualization.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── data │ ├── brad.jpg │ └── kids.jpg ├── test_actions.py ├── test_base.py ├── test_detect.py ├── test_dummy.py ├── test_reference.py ├── test_utils.py └── test_visualization.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | pychubby/data.py 4 | tests/* 5 | venv/* 6 | setup.py 7 | *cli.py 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | cache: pip 4 | 5 | dist: xenial # required for python3.7 6 | 7 | python: 8 | - "3.5" 9 | - "3.6" 10 | - "3.7" 11 | 12 | install: 13 | - pip install .[dev] 14 | 15 | script: 16 | - pytest 17 | - flake8 pychubby 18 | - pydocstyle pychubby 19 | 20 | after_success: 21 | - codecov 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/jankrepl/pychubby.svg?branch=master)](https://travis-ci.com/jankrepl/pychubby) 2 | [![codecov](https://codecov.io/gh/jankrepl/pychubby/branch/master/graph/badge.svg)](https://codecov.io/gh/jankrepl/pychubby) 3 | [![PyPI version](https://badge.fury.io/py/pychubby.svg)](https://badge.fury.io/py/pychubby) 4 | [![Documentation Status](https://readthedocs.org/projects/pychubby/badge/?version=latest)](https://pychubby.readthedocs.io/en/latest/?badge=latest) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pychubby) 6 | 7 | # PyChubby 8 | **Tool for automated face warping** 9 | 10 | ![intro](https://user-images.githubusercontent.com/18519371/64875224-ed621f00-d64c-11e9-92ad-8f76a4b95bcc.gif) 11 | 12 | ### Installation 13 | 14 | ```bash 15 | pip install pychubby 16 | ``` 17 | 18 | If you get an error `FileNotFoundError: [Errno 2] No such file or directory: 'cmake': 'cmake'`, you 19 | need to make sure [cmake](www.cmake.org) is installed. If you're on OSX you can install this via 20 | Homebrew with: 21 | 22 | ```shell 23 | brew install cmake 24 | ``` 25 | 26 | For other platforms please consult the Cmake documentation at 27 | 28 | ### Description 29 | For each face in an image define what **actions** are to be performed on it, `pychubby` will do the rest. 30 | 31 | ### Documentation 32 | 33 | 34 | ### Minimal Example 35 | ```python 36 | import matplotlib.pyplot as plt 37 | 38 | from pychubby.actions import Chubbify, Multiple, Pipeline, Smile 39 | from pychubby.detect import LandmarkFace 40 | 41 | img_path = 'path/to/your/image' 42 | img = plt.imread(img_path) 43 | 44 | lf = LandmarkFace.estimate(img) 45 | 46 | a_per_face = Pipeline([Chubbify(), Smile()]) 47 | a_all = Multiple(a_per_face) 48 | 49 | new_lf, _ = a_all.perform(lf) 50 | new_lf.plot(show_landmarks=False, show_numbers=False) 51 | ``` 52 | 53 | ### CLI 54 | `pychubby` also comes with a CLI that exposes some 55 | of its functionality. You can list the commands with `pc --help`: 56 | 57 | ```text 58 | Usage: pc [OPTIONS] COMMAND [ARGS]... 59 | 60 | Automated face warping tool. 61 | 62 | Options: 63 | --help Show this message and exit. 64 | 65 | Commands: 66 | list List available actions. 67 | perform Take an action. 68 | ``` 69 | 70 | To perform an action (Smile in the example below) and plot the result on the screen 71 | ```bash 72 | pc perform Smile INPUT_IMG_PATH 73 | ``` 74 | 75 | or if you want to create a new image and save it 76 | ```bash 77 | pc perform Smile INPUT_IMG_PATH OUTPUT_IMG_PATH 78 | ``` 79 | 80 | ### Development 81 | ```bash 82 | git clone https://github.com/jankrepl/pychubby.git 83 | cd pychubby 84 | pip install -e .[dev] 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('.')) 16 | print(sys.path) 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'PyChubby' 21 | copyright = '2019, Jan Krepl' 22 | author = 'Jan Krepl' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.1' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | # Set the welcome page for readthedocs 30 | master_doc = 'index' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.mathjax', 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.napoleon' 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 48 | 49 | # Mocking 50 | autodoc_mock_imports = ["dlib"] 51 | 52 | # -- Options for HTML output ------------------------------------------------- 53 | 54 | # The theme to use for HTML and HTML Help pages. See the documentation for 55 | # a list of builtin themes. 56 | # 57 | html_theme = 'sphinx_rtd_theme' 58 | 59 | # Add any paths that contain custom static files (such as style sheets) here, 60 | # relative to this directory. They are copied after the builtin static files, 61 | # so a file named "default.css" will overwrite the builtin "default.css". 62 | html_static_path = ['_static'] 63 | 64 | html_sidebars = { 65 | '**': [ 66 | 'about.html', 67 | 'navigation.html', 68 | 'relations.html', # needs 'show_related': True theme option to display 69 | 'searchbox.html', 70 | 'donate.html', 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyChubby 2 | ======== 3 | :code:`pychubby` is a package for automated face warping. It allows the user to programatically 4 | change facial expressions and shapes of any person in an image. 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: User Guide: 9 | 10 | source/installation 11 | source/basic_example 12 | source/pipeline_example 13 | source/multiple_example 14 | source/gallery 15 | source/building_blocks 16 | source/adding_custom_actions 17 | source/CLI 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | :caption: API Reference: 22 | 23 | source/pychubby.actions 24 | source/pychubby.base 25 | source/pychubby.data 26 | source/pychubby.detect 27 | source/pychubby.reference 28 | source/pychubby.utils 29 | source/pychubby.visualization 30 | 31 | Indices and tables 32 | ================== 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | -------------------------------------------------------------------------------- /docs/source/CLI.rst: -------------------------------------------------------------------------------- 1 | .. _CLI: 2 | 3 | CLI 4 | === 5 | :code:`pychubby` also offers a simple Command Line Interface that exposes some of the functionality of the 6 | Python package. 7 | 8 | Usage 9 | ----- 10 | After installation of :code:`pychubby` an entry point :code:`pc` becomes available. 11 | 12 | To see the basic information write :code:`pc --help`: 13 | 14 | .. code-block:: bash 15 | 16 | Usage: pc [OPTIONS] COMMAND [ARGS]... 17 | 18 | Automated face warping tool. 19 | 20 | Options: 21 | --help Show this message and exit. 22 | 23 | Commands: 24 | list List available actions. 25 | perform Take an action. 26 | 27 | 28 | To perform actions one uses the :code:`perform` subcommand. :code:`pc perform --help`: 29 | 30 | .. code-block:: bash 31 | 32 | Usage: pc perform [OPTIONS] COMMAND [ARGS]... 33 | 34 | Take an action. 35 | 36 | Options: 37 | --help Show this message and exit. 38 | 39 | Commands: 40 | Chubbify Make a chubby face. 41 | LinearTransform Linear transformation. 42 | OpenEyes Open eyes. 43 | ... 44 | ... 45 | ... 46 | 47 | 48 | The syntax for all actions is identical. The **positional arguments** are 49 | 50 | 1. Input image path (required) 51 | 2. Output image path (not required) 52 | 53 | If the output image path is not provided the resulting image is simply going to be plotted. 54 | 55 | All the **options** correspond to the keyword arguments of the constructor of the respective action classes in :code:`pychubby.actions` module. 56 | 57 | To give a specific example let us use the :code:`Smile` action. To get info on the parameters write 58 | :code:`pc perform Smile --help`: 59 | 60 | .. code-block:: bash 61 | 62 | Usage: pc perform Smile [OPTIONS] INP_IMG [OUT_IMG] 63 | 64 | Make a smiling face. 65 | 66 | Options: 67 | --scale FLOAT 68 | --help Show this message and exit. 69 | 70 | 71 | In particular, one can then warp an image in the following fashion 72 | 73 | .. code-block:: bash 74 | 75 | pc perform Smile --scale 0.3 img_cousin.jpg img_cousin_smiling.jpg 76 | 77 | 78 | 79 | 80 | Limitations 81 | ----------- 82 | The features that are unavailable via the CLI are the following: 83 | 84 | 1. :code:`AbsoluteMove`, :code:`Lambda` and :code:`Pipeline` actions 85 | 2. Different actions for different people 86 | 3. Lower level control 87 | 88 | Specifically, if the user provides a photo with multiple faces the same action 89 | will be performed on all of them. 90 | -------------------------------------------------------------------------------- /docs/source/adding_custom_actions.rst: -------------------------------------------------------------------------------- 1 | .. _custom_actions: 2 | 3 | ============== 4 | Custom Actions 5 | ============== 6 | 7 | :code:`pychubby` makes it very easy to add custom actions. There are 3 main ingredients: 8 | 9 | 1. Each action needs to be a subclass of :code:`pychubby.actions.Action` 10 | 2. All parameters of the action are specified via the constructor (:code:`__init__`) 11 | 3. The method :code:`perform` needs to be implemented such that 12 | 13 | - It inputs an instance of the :code:`pychubby.detect.LandmarkFace` 14 | - It returns a new instance of :code:`pychubby.detect.LandmarkFace` and :code:`pychubby.base.DisplacementField` representing the pixel by pixel transformation from the new image to the old one. 15 | 16 | 17 | Clearly the main workhorse is the 3rd step. In order to avoid dealing with lower level details 18 | a good start is to use the utility action :code:`Lambda`. 19 | 20 | .. _custom_actions_lambda: 21 | 22 | ------ 23 | Lambda 24 | ------ 25 | The simplest way how to implement a new action is to use the :code:`Lambda` action. Before explaining 26 | the action itself the reader is encouraged to review the :code:`DefaultRS` reference space in 27 | :ref:`building_blocks_reference_space` which is by default used by :code:`Lambda`. 28 | 29 | 30 | 31 | .. image:: https://i.imgur.com/dLcFQNI.gif 32 | :width: 500 33 | :align: center 34 | 35 | The lambda action works purely in the reference space and expects the following input: 36 | 37 | - **scale** - float representing the absolute size (norm) of the largest displacement in the reference space (this would be the chin displacement in the figure) 38 | - **specs** - dictionary where keys are landmarks (either name or number) and the values are tuples (angle, relative size) 39 | 40 | 41 | That means that the user simply specifies for landmark of interest what is the displacement 42 | angle and relative size with respect to all other displacements through the :code:`specs` dictionary. 43 | After that the :code:`scale` parameter controls the absolute size of the biggest displacement while the other 44 | displacements are scaled linearly based on the provided relative sizes. 45 | 46 | See below an example that replicates the figure: 47 | 48 | .. code-block:: python 49 | 50 | from pychubby.actions import Action, Lambda 51 | 52 | class CustomAction(Action): 53 | 54 | def __init__(self, scale=0.3): 55 | self.scale = scale 56 | 57 | def perform(self, lf): 58 | a_l = Lambda(scale=self.scale, 59 | specs={'CHIN': (90, 2), 60 | 'CHIN_L': (110, 1), 61 | 'CHIN_R': (70, 1), 62 | 'OUTER_NOSTRIL_L': (-135, 1), 63 | 'OUTER_NOSTRIL_R': (-45, 1) 64 | } 65 | ) 66 | 67 | return a_l.perform(lf) 68 | 69 | 70 | 71 | .. image:: https://i.imgur.com/VqmXtzU.gif 72 | :width: 300 73 | :align: center 74 | -------------------------------------------------------------------------------- /docs/source/basic_example.rst: -------------------------------------------------------------------------------- 1 | Basic Example 2 | ============= 3 | To illustrate the simplest use case let us assume that we start with a photo with 4 | a single face in it. 5 | 6 | .. image:: https://i.imgur.com/3yAhFzi.jpg 7 | :width: 400 8 | :alt: Original image 9 | :align: center 10 | 11 | 12 | :code:`pychubby` implements a class :code:`LandmarkFace` which stores all relevant data that enable face warping. 13 | Namely it is the image itself and 68 landmark points. To instantiate a :code:`LandmarkFace` one needs 14 | to use a utility class method :code:`estimate`. 15 | 16 | 17 | .. code-block:: python 18 | 19 | import matplotlib.pyplot as plt 20 | from pychubby.detect import LandmarkFace 21 | 22 | img = plt.imread("path/to/the/image") 23 | lf = LandmarkFace.estimate(img) 24 | lf.plot() 25 | 26 | .. image:: https://i.imgur.com/y4AL171.png 27 | :width: 400 28 | :alt: Face with landmarks 29 | :align: center 30 | 31 | Note that it might be necessary to upsample the image before the estimation. For convenience 32 | the :code:`estimate` method has an optional parameter :code:`n_upsamples`. 33 | 34 | Once the landmark points are estimated we can move on with performing actions on the face. 35 | Let's try to make the person smile: 36 | 37 | .. code-block:: python 38 | 39 | from pychubby.actions import Smile 40 | 41 | a = Smile(scale=0.2) 42 | new_lf, df = a.perform(lf) # lf defined above 43 | new_lf.plot(show_landmarks=False) 44 | 45 | .. image:: https://i.imgur.com/RytGu0t.png 46 | :width: 400 47 | :alt: Smiling face 48 | :align: center 49 | 50 | There are 2 important things to note. Firstly the :code:`new_lf` now contains both the warped version 51 | of the original image as well as the transformed landmark points. Secondly, the :code:`perform` 52 | method also returns a :code:`df` which is an instance of :code:`pychubby.base.DisplacementField` and 53 | represents the pixel by pixel transformation between the old and the new (smiling) image. 54 | 55 | To see all currently available actions go to :ref:`gallery`. 56 | 57 | To create an animation of the action we can use the :code:`visualization` module. 58 | 59 | .. code-block:: python 60 | 61 | from pychubby.visualization import create_animation 62 | 63 | ani = create_animation(df, img) # the displacement field and the original image 64 | 65 | .. image:: https://i.imgur.com/jB6Vlnc.gif 66 | :width: 400 67 | :align: center 68 | -------------------------------------------------------------------------------- /docs/source/building_blocks.rst: -------------------------------------------------------------------------------- 1 | .. _building_blocks: 2 | 3 | Building Blocks 4 | =============== 5 | This page is dedicated to explaining the logic behind :code:`pychubby`. 6 | 7 | 68 landmarks 8 | ------------ 9 | :code:`pychubby` relies on the standard 68 facial landmarks framework. Specifically, 10 | a pretrained :code:`dlib` model is used to achieve this task. See :code:`pychubby.data` for credits 11 | and references. Once the landmarks are detected 12 | one can query them via their index. Alternatively, for the ease of defining new actions 13 | a dictionary :code:`pychubby.detect.LANDMARK_NAMES` defines a name for each of the 68 landmarks. 14 | 15 | .. _building_blocks_landmarkface: 16 | 17 | LandmarkFace 18 | ------------ 19 | :code:`pychubby.detect.LandmarkFace` is one of the most important classes that :code:`pychubby` uses. 20 | To construct a :code:`LandmarkFace` one needs to provide 21 | 22 | 1. Image of the face 23 | 2. 68 landmark points 24 | 25 | Rather than using the lower level constructor the user will mostly create instances through the class 26 | method :code:`estimate` which detect the landmark points automatically. 27 | 28 | Once instantiated, one can use actions (:code:`pychubby.actions.Action`) to generate a new (warped) 29 | :code:`LandmarkFace`. 30 | 31 | 32 | LandmarkFaces 33 | ------------- 34 | :code:`pychubby.detect.LandmarkFaces` is a container holding multiple instances of :code:`LandmarkFace`. It 35 | additionally provides functionality that allows for performing the :code:`Multiple` action on them. 36 | 37 | 38 | Action 39 | ------ 40 | Action is a specific warping recipe that might depend on some parameters. Once instantiated, 41 | one can use their :code:`perform` method to warp a :code:`LandmarkFace`. To see already available 42 | actions go to :ref:`gallery` or read how to create your own actions :ref:`custom_actions`. 43 | 44 | .. _building_blocks_reference_space: 45 | 46 | ReferenceSpace 47 | -------------- 48 | In general, faces in images appear in different positions, angles and sizes. Defining actions purely 49 | based on coordinates of a given face in a given image is not a great idea. Mainly for two reasons: 50 | 51 | 1) Resizing, cropping, rotating, etc of the image will render the action useless 52 | 2) These actions are image specific and cannot be applied to any other image. One would be 53 | better off using some graphical interface. 54 | 55 | One way to solve the above issues is to first transform all the landmarks into some reference space, 56 | define actions in this reference space and then map it back into the original domain. :code:`pychubby` 57 | defines these reference spaces in :code:`pychubby.reference` module. Each reference space needs to implement 58 | the following three methods: 59 | 60 | - :code:`estimate` 61 | - :code:`inp2ref` 62 | - :code:`ref2inp` 63 | 64 | The default reference space is the :code:`DefaultRS` and its logic is captured in the below figure. 65 | 66 | .. image:: https://i.imgur.com/HRBKTr4.gif 67 | :width: 800 68 | :align: center 69 | 70 | 71 | Five selected landmarks are used to estimate an affine transformation between the reference and input space. 72 | This trasformation is endcoded in a 2 x 3 matrix **A**. Transforming from reference to input space 73 | and vice versa is then just a simple matrix multiplication. 74 | 75 | .. math:: 76 | 77 | \textbf{x}_{inp}A = \textbf{x}_{ref} 78 | 79 | .. math:: 80 | 81 | \textbf{x}_{ref}A^{-1} = \textbf{x}_{inp} 82 | 83 | 84 | 85 | DisplacementField 86 | ----------------- 87 | Displacement field represents a 2D to 2D transformations between two images. 88 | To instantiate a :code:`pychubby.base.DisplacementField` one can either use the standard 89 | constructor (:code:`delta_x`, :code:`delta_y` arrays). 90 | Alternatively, one can use a factory method :code:`generate` that creates a :code:`DisplacemetField` based on 91 | displacement of landmark points. 92 | -------------------------------------------------------------------------------- /docs/source/gallery.rst: -------------------------------------------------------------------------------- 1 | .. _gallery: 2 | 3 | Gallery 4 | ======= 5 | This page gives overview of all currently available actions. 6 | 7 | AbsoluteMove 8 | ------------ 9 | Low-level action that allows the user to manually select the pixel distances between the old and new 10 | landmarks. Note that this action is image specific and therefore not invariant to affine transformations. 11 | 12 | .. code-block:: python 13 | 14 | AbsoluteMove(y_shifts={30: 20, 29: 20}) # move lower part of the nose 20 pixels down 15 | 16 | .. image:: https://i.imgur.com/2ZCRCiM.gif 17 | :width: 300 18 | :align: center 19 | 20 | Chubbify 21 | -------- 22 | Make a face chubby. Affine transformations invariant. 23 | 24 | .. code-block:: python 25 | 26 | Chubbify(0.2) 27 | 28 | .. image:: https://i.imgur.com/yNUNNCw.gif 29 | :width: 300 30 | :align: center 31 | 32 | Lambda 33 | ------ 34 | Low-level action where only angles and relative sizes in the reference space are to be specified. 35 | Affine transformation invariant. For more details see :ref:`custom_actions_lambda`. 36 | 37 | .. code-block:: python 38 | 39 | Lambda(0.2, {'OUTERMOST_EYEBROW_L': (-90, 1), 40 | 'OUTER_EYEBROW_L': (-90, 0.8)}) 41 | 42 | .. image:: https://i.imgur.com/xuIgqFK.gif 43 | :width: 300 44 | :align: center 45 | 46 | LinearTransform 47 | --------------- 48 | Apply a linear transformation to all landmarks on a single face. 49 | 50 | .. code-block:: python 51 | 52 | LinearTransform(scale_x=0.9, scale_y=0.95) 53 | 54 | .. image:: https://i.imgur.com/s57gnkj.gif 55 | :width: 300 56 | :align: center 57 | 58 | Multiple 59 | -------- 60 | Metaaction enabling handling of multiple faces in a single image. 61 | 62 | OpenEyes 63 | -------- 64 | Open eyes. Affine transformation invariant. 65 | 66 | .. code-block:: python 67 | 68 | OpenEyes(0.06) 69 | 70 | .. image:: https://i.imgur.com/H4kP9lI.gif 71 | :width: 300 72 | :align: center 73 | 74 | Pipeline 75 | -------- 76 | Metaaction allowing for multiple actions on a single face. 77 | 78 | .. code-block:: python 79 | 80 | Pipeline([Smile(-0.08), OpenEyes(-0.06)]) 81 | 82 | .. image:: https://i.imgur.com/Hh6KtKa.gif 83 | :width: 300 84 | :align: center 85 | 86 | RaiseEyebrow 87 | ------------ 88 | Raise an eyebrow. Left, right or both. Affine transformation invariant. 89 | 90 | .. code-block:: python 91 | 92 | RaiseEyebrow(scale=0.1, side='left') 93 | 94 | .. image:: https://i.imgur.com/6S9fpM1.gif 95 | :width: 300 96 | :align: center 97 | 98 | Smile 99 | ----- 100 | Smile. Affine transformation invariant. 101 | 102 | .. code-block:: python 103 | 104 | Smile(0.1) 105 | 106 | .. image:: https://i.imgur.com/1oR046T.gif 107 | :width: 300 108 | :align: center 109 | 110 | StretchNostrils 111 | --------------- 112 | Stretch nostrils. Affine transformation invariant. 113 | 114 | .. code-block:: python 115 | 116 | StretchNostrils(0.1) 117 | 118 | .. image:: https://i.imgur.com/IxnUh6u.gif 119 | :width: 300 120 | :align: center 121 | 122 | 123 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | From PyPI 5 | --------- 6 | .. code-block:: bash 7 | 8 | pip install pychubby 9 | 10 | 11 | From source 12 | ----------- 13 | .. code-block:: bash 14 | 15 | pip install git+https://github.com/jankrepl/pychubby.git 16 | 17 | 18 | Notes 19 | ----- 20 | By default we are using a pretrained landmark model from https://github.com/davisking/dlib-models. 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/source/multiple_example.rst: -------------------------------------------------------------------------------- 1 | Multiple Faces 2 | ============== 3 | So far we assumed that there is a single face in the image. However, the real power of :code:`pychubby` 4 | lies in its ability to handle multiple faces. Let's start with the following image. 5 | 6 | .. image:: https://i.imgur.com/TTHS1VR.jpg 7 | :width: 500 8 | :alt: Original image 9 | :align: center 10 | 11 | If more than one face is detected in an image :code:`pychubby` uses the :code:`LandmarkFaces` class rather than 12 | :code:`LandmarkFace`. :code:`LandmarkFaces` is essentially a container containing :code:`LandmarkFace` 13 | instances for each face in the image. 14 | 15 | .. code-block:: python 16 | 17 | import matplotlib.pyplot as plt 18 | from pychubby.detect import LandmarkFace 19 | 20 | img = plt.imread("path/to/the/image") 21 | lfs = LandmarkFace.estimate(img) # lfs is an instance of LandmarkFaces 22 | lfs.plot(show_landmarks=False, show_numbers=True) 23 | 24 | 25 | .. image:: https://i.imgur.com/CgmwO8Q.jpg 26 | :width: 500 27 | :alt: Original image 28 | :align: center 29 | 30 | Each face is assigned a unique integer (starting from 0). This ordering is very important since it 31 | allows us to specify which action to apply to which face. 32 | 33 | In order to apply actions we use the metaaction :code:`Multiple`. It has two modes: 34 | 35 | 1. Same action on each face 36 | 2. Face specific actions 37 | 38 | 39 | Same action 40 | ----------- 41 | The first possibility is to apply exactly the same action to each face in the image. 42 | Below is an example of making all faces more chubby. 43 | 44 | .. code-block:: python 45 | 46 | from pychubby.actions import Chubbify, Multiple 47 | 48 | a_single = Chubbify(0.2) 49 | a = Multiple(a_single) 50 | new_lfs, df = a.perform(lfs) 51 | new_lfs.plot(show_landmarks=False, show_numbers=False) 52 | 53 | .. image:: https://i.imgur.com/mxsLqll.jpg 54 | :width: 500 55 | :alt: Single action image 56 | :align: center 57 | 58 | .. code-block:: python 59 | 60 | from pychubby.visualization import create_animation 61 | 62 | ani = create_animation(df, img) 63 | 64 | .. image:: https://i.imgur.com/qaxuHMs.gif 65 | :width: 500 66 | :alt: Single action gif 67 | :align: center 68 | 69 | Different actions 70 | ----------------- 71 | Alternetively, we might want to apply different action to each face (or potentially no action). 72 | Below is an example of face specific actions. 73 | 74 | 75 | .. code-block:: python 76 | 77 | from pychubby.actions import Chubbify, LinearTransform, Multiple, OpenEyes, Pipeline, Smile 78 | 79 | a_0 = LinearTransform(scale_x=0.9, scale_y=0.9) 80 | a_1 = Smile(0.14) 81 | a_2 = None 82 | a_3 = Pipeline([OpenEyes(0.05), LinearTransform(scale_x=1.02, scale_y=1.02), Chubbify(0.2)]) 83 | 84 | a = Multiple([a_0, a_1, a_2, a_3]) 85 | new_lfs, df = a.perform(lfs) 86 | new_lfs.plot(show_landmarks=False, show_numbers=False) 87 | 88 | .. image:: https://i.imgur.com/45NxDyI.jpg 89 | :width: 500 90 | :alt: Multiple actions image 91 | :align: center 92 | 93 | .. code-block:: python 94 | 95 | from pychubby.visualization import create_animation 96 | 97 | ani = create_animation(df, img) 98 | 99 | .. image:: https://i.imgur.com/ZWYEIZN.jpg 100 | :width: 500 101 | :alt: Single action gif 102 | :align: center 103 | -------------------------------------------------------------------------------- /docs/source/pipeline_example.rst: -------------------------------------------------------------------------------- 1 | Pipelines 2 | ========= 3 | Rather than applying a single action at a time :code:`pychubby` enables piping multiple actions together. 4 | To achieve this one can use the metaaction :code:`Pipeline`. 5 | 6 | Let us again assume that we start with an image with a single face in it. 7 | 8 | .. image:: https://i.imgur.com/3yAhFzi.jpg 9 | :width: 400 10 | :alt: Original image 11 | :align: center 12 | 13 | 14 | Let's try to make the person smile but also close her eyes slightly. 15 | 16 | .. code-block:: python 17 | 18 | import matplotlib.pyplot as plt 19 | 20 | from pychubby.actions import OpenEyes, Pipeline, Smile 21 | from pychubby.detect import LandmarkFace 22 | 23 | img = plt.imread("path/to/the/image") 24 | lf = LandmarkFace.estimate(img) 25 | 26 | a_s = Smile(0.1) 27 | a_e = OpenEyes(-0.03) 28 | a = Pipeline([a_s, a_e]) 29 | 30 | new_lf, df = a.perform(lf) 31 | new_lf.plot(show_landmarks=False) 32 | 33 | 34 | 35 | .. image:: https://i.imgur.com/E1BdvBq.jpg 36 | :width: 400 37 | :alt: Warped image 38 | :align: center 39 | 40 | To create an animation we can use the `visualization` module. 41 | 42 | .. code-block:: python 43 | 44 | from pychubby.visualization import create_animation 45 | 46 | ani = create_animation(df, img) 47 | 48 | .. image:: https://i.imgur.com/PlCqUZr.gif 49 | :width: 400 50 | :alt: Animation 51 | :align: center 52 | -------------------------------------------------------------------------------- /docs/source/pychubby.actions.rst: -------------------------------------------------------------------------------- 1 | pychubby.actions module 2 | ======================= 3 | 4 | .. automodule:: pychubby.actions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.base.rst: -------------------------------------------------------------------------------- 1 | pychubby.base module 2 | ==================== 3 | 4 | .. automodule:: pychubby.base 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.data.rst: -------------------------------------------------------------------------------- 1 | pychubby.data module 2 | ==================== 3 | 4 | .. automodule:: pychubby.data 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.detect.rst: -------------------------------------------------------------------------------- 1 | pychubby.detect module 2 | ====================== 3 | 4 | .. automodule:: pychubby.detect 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.reference.rst: -------------------------------------------------------------------------------- 1 | pychubby.reference module 2 | ========================= 3 | 4 | .. automodule:: pychubby.reference 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.rst: -------------------------------------------------------------------------------- 1 | pychubby package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | pychubby.actions 10 | pychubby.base 11 | pychubby.cli 12 | pychubby.data 13 | pychubby.detect 14 | pychubby.reference 15 | pychubby.utils 16 | pychubby.visualization 17 | 18 | Module contents 19 | --------------- 20 | 21 | .. automodule:: pychubby 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | -------------------------------------------------------------------------------- /docs/source/pychubby.utils.rst: -------------------------------------------------------------------------------- 1 | pychubby.utils module 2 | ===================== 3 | 4 | .. automodule:: pychubby.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/pychubby.visualization.rst: -------------------------------------------------------------------------------- 1 | pychubby.visualization module 2 | ============================= 3 | 4 | .. automodule:: pychubby.visualization 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /pychubby/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for making chubby faces. 2 | 3 | Release markers: 4 | X.Y 5 | X.Y.Z for bug fixes 6 | """ 7 | 8 | __version__ = '0.2' 9 | -------------------------------------------------------------------------------- /pychubby/actions.py: -------------------------------------------------------------------------------- 1 | """Definition of actions. 2 | 3 | Note that for each action (class) the first line of the docstring 4 | as well as the default parameters of the constructor are used by 5 | the CLI. 6 | """ 7 | 8 | from abc import ABC, abstractmethod 9 | 10 | import numpy as np 11 | from skimage.transform import AffineTransform 12 | 13 | from pychubby.base import DisplacementField 14 | from pychubby.detect import LANDMARK_NAMES, LandmarkFace, LandmarkFaces 15 | from pychubby.reference import DefaultRS 16 | 17 | 18 | class Action(ABC): 19 | """General Action class to be subclassed.""" 20 | 21 | @abstractmethod 22 | def perform(self, lf, **kwargs): 23 | """Perfom action on an instance of a LandmarkFace. 24 | 25 | Parameters 26 | ---------- 27 | lf : LandmarkFace 28 | Instance of a ``LandmarkFace``. 29 | 30 | kwargs : dict 31 | Action specific parameters. 32 | 33 | Returns 34 | ------- 35 | new_lf : LandmarkFace 36 | Instance of a ``LandmarkFace`` after a specified action was 37 | taken on the input `lf`. 38 | 39 | """ 40 | 41 | @staticmethod 42 | def pts2inst(new_points, lf, **interpolation_kwargs): 43 | """Generate instance of LandmarkFace via interpolation. 44 | 45 | Parameters 46 | ---------- 47 | new_points : np.ndarray 48 | Array of shape `(N, 2)` representing the x and y coordinates of the 49 | new landmark points. 50 | 51 | lf : LandmarkFace 52 | Instance of a ``LandmarkFace`` before taking any actions. 53 | 54 | interpolation_kwargs : dict 55 | Interpolation parameters passed onto scipy. 56 | 57 | Returns 58 | ------- 59 | new_lf : LandmarkFace 60 | Instance of a ``LandmarkFace`` after taking an action. 61 | 62 | df : DisplacementField 63 | Displacement field representing per pixel displacements between the 64 | old and new image. 65 | 66 | """ 67 | if not interpolation_kwargs: 68 | interpolation_kwargs = {"function": "linear"} 69 | 70 | df = DisplacementField.generate( 71 | lf.img.shape[:2], 72 | lf.points, 73 | new_points, 74 | anchor_edges=True, 75 | **interpolation_kwargs 76 | ) 77 | 78 | new_img = df.warp(lf.img) 79 | 80 | return LandmarkFace(new_points, new_img), df 81 | 82 | 83 | class AbsoluteMove(Action): 84 | """Absolute offsets of any landmark points. 85 | 86 | Parameters 87 | ---------- 88 | x_shifts : dict or None 89 | Keys are integers from 0 to 67 representing a chosen landmark points. The 90 | values represent the shift in the x direction to be made. If a landmark 91 | not specified assumed shift is 0. 92 | 93 | y_shifts : dict or None 94 | Keys are integers from 0 to 67 representing a chosen landmark points. The 95 | values represent the shift in the y direction to be made. If a landmark 96 | not specified assumed shift is 0. 97 | 98 | """ 99 | 100 | def __init__(self, x_shifts=None, y_shifts=None): 101 | """Construct.""" 102 | self.x_shifts = x_shifts or {} 103 | self.y_shifts = y_shifts or {} 104 | 105 | def perform(self, lf): 106 | """Perform absolute move. 107 | 108 | Specified landmarks will be shifted in either the x or y direction. 109 | 110 | Parameters 111 | ---------- 112 | lf : LandmarkFace 113 | Instance of a ``LandmarkFace``. 114 | 115 | Returns 116 | ------- 117 | new_lf : LandmarkFace 118 | Instance of a ``LandmarkFace`` after taking the action. 119 | 120 | df : DisplacementField 121 | Displacement field representing the transformation between the old and 122 | new image. 123 | 124 | """ 125 | offsets = np.zeros((68, 2)) 126 | 127 | # x shifts 128 | for k, v in self.x_shifts.items(): 129 | offsets[k, 0] = v 130 | # y shifts 131 | for k, v in self.y_shifts.items(): 132 | offsets[k, 1] = v 133 | 134 | new_points = lf.points + offsets 135 | 136 | new_lf, df = self.pts2inst(new_points, lf) 137 | 138 | return new_lf, df 139 | 140 | 141 | class Lambda(Action): 142 | """Custom action for specifying actions with angles and norms in a reference space. 143 | 144 | Parameters 145 | ---------- 146 | scale : float 147 | Absolute norm of the maximum shift. All the remaining shifts are scaled linearly. 148 | 149 | specs : dict 150 | Dictionary where keyrs represent either the index or a name of the landmark. 151 | The values are tuples of two elements: 152 | 1) Angle in degrees. 153 | 2) Proportional shift. Only the relative size towards other landmarks matters. 154 | 155 | reference_space : None or ReferenceSpace 156 | Reference space to be used. 157 | 158 | """ 159 | 160 | def __init__(self, scale, specs, reference_space=None): 161 | """Construct.""" 162 | self.scale = scale 163 | self.specs = specs 164 | self.reference_space = reference_space or DefaultRS() 165 | 166 | def perform(self, lf): 167 | """Perform action. 168 | 169 | Parameters 170 | ---------- 171 | lf : LandmarkFace 172 | Instance of a ``LandmarkFace`` before taking the action. 173 | 174 | Returns 175 | ------- 176 | new_lf : LandmarkFace 177 | Instance of a ``LandmarkFace`` after taking the action. 178 | 179 | df : DisplacementField 180 | Displacement field representing the transformation between the old and new image. 181 | 182 | """ 183 | self.reference_space.estimate(lf) 184 | ref_points = self.reference_space.inp2ref(lf.points) 185 | 186 | # Create entry for AbsoluteMove 187 | x_shifts = {} 188 | y_shifts = {} 189 | 190 | for k, (angle, prop) in self.specs.items(): 191 | key = k if isinstance(k, int) else LANDMARK_NAMES[k] 192 | 193 | ref_shift = ( 194 | np.array([[np.cos(np.radians(angle)), np.sin(np.radians(angle))]]) 195 | * prop 196 | * self.scale 197 | ) 198 | new_inp_point = self.reference_space.ref2inp(ref_points[key] + ref_shift)[0] 199 | shift = new_inp_point - lf.points[key] 200 | 201 | x_shifts[key] = shift[0] 202 | y_shifts[key] = shift[1] 203 | 204 | am = AbsoluteMove(x_shifts=x_shifts, y_shifts=y_shifts) 205 | 206 | return am.perform(lf) 207 | 208 | 209 | class Chubbify(Action): 210 | """Make a chubby face. 211 | 212 | Parameters 213 | ---------- 214 | scale : float 215 | Absolute shift size in the reference space. 216 | 217 | """ 218 | 219 | def __init__(self, scale=0.2): 220 | """Construct.""" 221 | self.scale = scale 222 | 223 | def perform(self, lf): 224 | """Perform an action. 225 | 226 | Parameters 227 | ---------- 228 | lf : LandmarkFace 229 | Instance of a ``LandmarkFace``. 230 | 231 | """ 232 | specs = { 233 | "LOWER_TEMPLE_L": (170, 0.4), 234 | "LOWER_TEMPLE_R": (10, 0.4), 235 | "UPPERMOST_CHEEK_L": (160, 1), 236 | "UPPERMOST_CHEEK_R": (20, 1), 237 | "UPPER_CHEEK_L": (150, 1), 238 | "UPPER_CHEEK_R": (30, 1), 239 | "LOWER_CHEEK_L": (140, 1), 240 | "LOWER_CHEEK_R": (40, 1), 241 | "LOWERMOST_CHEEK_L": (130, 0.8), 242 | "LOWERMOST_CHEEK_R": (50, 0.8), 243 | "CHIN_L": (120, 0.7), 244 | "CHIN_R": (60, 0.7), 245 | "CHIN": (90, 0.7), 246 | } 247 | 248 | return Lambda(self.scale, specs).perform(lf) 249 | 250 | 251 | class LinearTransform(Action): 252 | """Linear transformation. 253 | 254 | Parameters 255 | ---------- 256 | scale_x : float 257 | Scaling of the x axis. 258 | 259 | scale_y : float 260 | Scaling of the y axis. 261 | 262 | rotation : float 263 | Rotation in radians. 264 | 265 | shear : float 266 | Shear in radians. 267 | 268 | translation_x : float 269 | Translation in the x direction. 270 | 271 | translation_y : float 272 | Translation in the y direction. 273 | 274 | reference_space : None or pychubby.reference.ReferenceSpace 275 | Instace of the ``ReferenceSpace`` class. 276 | 277 | """ 278 | 279 | def __init__( 280 | self, 281 | scale_x=1., 282 | scale_y=1., 283 | rotation=0., 284 | shear=0., 285 | translation_x=0., 286 | translation_y=0., 287 | reference_space=None, 288 | ): 289 | """Construct.""" 290 | self.scale_x = scale_x 291 | self.scale_y = scale_y 292 | self.rotation = rotation 293 | self.shear = shear 294 | self.translation_x = translation_x 295 | self.translation_y = translation_y 296 | self.reference_space = reference_space or DefaultRS() 297 | 298 | def perform(self, lf): 299 | """Perform action. 300 | 301 | Parameters 302 | ---------- 303 | lf : LandmarkFace 304 | Instance of a ``LandmarkFace`` before taking the action. 305 | 306 | Returns 307 | ------- 308 | new_lf : LandmarkFace 309 | Instance of a ``LandmarkFace`` after taking the action. 310 | 311 | df : DisplacementField 312 | Displacement field representing the transformation between the old and new image. 313 | 314 | """ 315 | # estimate reference space 316 | self.reference_space.estimate(lf) 317 | 318 | # transform reference space landmarks 319 | ref_points = self.reference_space.inp2ref(lf.points) 320 | 321 | tform = AffineTransform( 322 | scale=(self.scale_x, self.scale_y), 323 | rotation=self.rotation, 324 | shear=self.shear, 325 | translation=(self.translation_x, self.translation_y), 326 | ) 327 | tformed_ref_points = tform(ref_points) 328 | 329 | # ref2inp 330 | tformed_inp_points = self.reference_space.ref2inp(tformed_ref_points) 331 | 332 | x_shifts = {i: (tformed_inp_points[i] - lf[i])[0] for i in range(68)} 333 | y_shifts = {i: (tformed_inp_points[i] - lf[i])[1] for i in range(68)} 334 | 335 | return AbsoluteMove(x_shifts=x_shifts, y_shifts=y_shifts).perform(lf) 336 | 337 | 338 | class Multiple(Action): 339 | """Applying actions to multiple faces. 340 | 341 | Parameters 342 | ---------- 343 | per_face_action : list or Action 344 | If list then elements are instances of some actions (subclasses of ``Action``) that 345 | exactly match the order of ``LandmarkFace`` instances within the ``LandmarkFaces`` 346 | instance. It is also posible to use None for no action. If ``Action`` then the 347 | same action will be performed on each available ``LandmarkFace``. 348 | 349 | """ 350 | 351 | def __init__(self, per_face_action): 352 | """Construct.""" 353 | if isinstance(per_face_action, list): 354 | if not all([isinstance(a, Action) or a is None for a in per_face_action]): 355 | raise TypeError("All elements of per_face_action need to be actions.") 356 | 357 | self.per_face_action = per_face_action 358 | 359 | elif isinstance(per_face_action, Action) or per_face_action is None: 360 | self.per_face_action = [per_face_action] 361 | 362 | else: 363 | raise TypeError( 364 | "per_face_action needs to be an action or a list of actions" 365 | ) 366 | 367 | def perform(self, lfs): 368 | """Perform actions on multiple faces. 369 | 370 | Parameters 371 | ---------- 372 | lfs : LandmarkFaces 373 | Instance of ``LandmarkFaces``. 374 | 375 | Returns 376 | ------- 377 | new_lfs : LandmarkFaces 378 | Instance of a ``LandmarkFaces`` after taking the action on each face. 379 | 380 | df : DisplacementField 381 | Displacement field representing the transformation between the old and new image. 382 | 383 | """ 384 | if isinstance(lfs, LandmarkFace): 385 | lfs = LandmarkFaces(lfs) 386 | 387 | n_actions = len(self.per_face_action) 388 | n_faces = len(lfs) 389 | 390 | if n_actions not in {1, n_faces}: 391 | raise ValueError( 392 | "Number of actions ({}) is different from number of faces({})".format( 393 | n_actions, n_faces 394 | ) 395 | ) 396 | 397 | lf_list_new = [] 398 | for lf, a in zip( 399 | lfs, 400 | self.per_face_action if n_actions != 1 else n_faces * self.per_face_action, 401 | ): 402 | lf_new, _ = a.perform(lf) if a is not None else (lf, None) 403 | lf_list_new.append(lf_new) 404 | 405 | # Overall displacement 406 | img = lfs[0].img 407 | shape = img.shape[:2] 408 | old_points = np.vstack([lf.points for lf in lfs]) 409 | new_points = np.vstack([lf.points for lf in lf_list_new]) 410 | 411 | df = DisplacementField.generate( 412 | shape, old_points, new_points, anchor_corners=True, function="linear" 413 | ) 414 | 415 | # Make sure same images 416 | img_final = df.warp(img) 417 | lfs_new = LandmarkFaces( 418 | *[LandmarkFace(lf.points, img_final) for lf in lf_list_new] 419 | ) 420 | 421 | return lfs_new, df 422 | 423 | 424 | class OpenEyes(Action): 425 | """Open eyes. 426 | 427 | Parameters 428 | ---------- 429 | scale : float 430 | Absolute shift size in the reference space. 431 | 432 | """ 433 | 434 | def __init__(self, scale=0.1): 435 | """Construct.""" 436 | self.scale = scale 437 | 438 | def perform(self, lf): 439 | """Perform action. 440 | 441 | Parameters 442 | ---------- 443 | lf : LandmarkFace 444 | Instance of a ``LandmarkFace`` before taking the action. 445 | 446 | Returns 447 | ------- 448 | new_lf : LandmarkFace 449 | Instance of a ``LandmarkFace`` after taking the action. 450 | 451 | df : DisplacementField 452 | Displacement field representing the transformation between the old and new image. 453 | 454 | """ 455 | specs = { 456 | "INNER_EYE_LID_R": (-100, 0.8), 457 | "OUTER_EYE_LID_R": (-80, 1), 458 | "INNER_EYE_BOTTOM_R": (100, 0.5), 459 | "OUTER_EYE_BOTTOM_R": (80, 0.5), 460 | "INNERMOST_EYEBROW_R": (-100, 1), 461 | "INNER_EYEBROW_R": (-100, 1), 462 | "MIDDLE_EYEBROW_R": (-100, 1), 463 | "OUTER_EYEBROW_R": (-100, 1), 464 | "OUTERMOST_EYEBROW_R": (-100, 1), 465 | "INNER_EYE_LID_L": (-80, 0.8), 466 | "OUTER_EYE_LID_L": (-100, 1), 467 | "INNER_EYE_BOTTOM_L": (80, 0.5), 468 | "OUTER_EYE_BOTTOM_L": (10, 0.5), 469 | "INNERMOST_EYEBROW_L": (-80, 1), 470 | "INNER_EYEBROW_L": (-80, 1), 471 | "MIDDLE_EYEBROW_L": (-80, 1), 472 | "OUTER_EYEBROW_L": (-80, 1), 473 | "OUTERMOST_EYEBROW_L": (-80, 1), 474 | } 475 | return Lambda(self.scale, specs=specs).perform(lf) 476 | 477 | 478 | class Pipeline(Action): 479 | """Pipe multiple actions together. 480 | 481 | Parameters 482 | ---------- 483 | steps : list 484 | List of different actions that are going to be performed in the given order. 485 | 486 | """ 487 | 488 | def __init__(self, steps): 489 | """Construct.""" 490 | self.steps = steps 491 | 492 | def perform(self, lf): 493 | """Perform action. 494 | 495 | Parameters 496 | ---------- 497 | lf : LandmarkFace 498 | Instance of a ``LandmarkFace`` before taking the action. 499 | 500 | Returns 501 | ------- 502 | new_lf : LandmarkFace 503 | Instance of a ``LandmarkFace`` after taking the action. 504 | 505 | df : DisplacementField 506 | Displacement field representing the transformation between the old and new image. 507 | 508 | """ 509 | df_list = [] 510 | lf_composed = lf 511 | 512 | for a in self.steps: 513 | lf_composed, df_temp = a.perform(lf_composed) 514 | df_list.append(df_temp) 515 | 516 | df_list = df_list[::-1] 517 | 518 | df_composed = df_list[0] 519 | for df_temp in df_list[1:]: 520 | df_composed = df_composed(df_temp) 521 | 522 | return lf_composed, df_composed 523 | 524 | 525 | class RaiseEyebrow(Action): 526 | """Raise an eyebrow. 527 | 528 | Parameters 529 | ---------- 530 | scale : float 531 | Absolute shift size in the reference space. 532 | 533 | side : str, {'left', 'right', 'both'} 534 | Which eyebrow to raise. 535 | 536 | """ 537 | 538 | def __init__(self, scale=0.1, side="both"): 539 | """Construct.""" 540 | self.scale = scale 541 | self.side = side 542 | 543 | if self.side not in {"left", "right", "both"}: 544 | raise ValueError( 545 | "Allowed side options are 'left', 'right' and 'both'.".format(self.side) 546 | ) 547 | 548 | def perform(self, lf): 549 | """Perform action. 550 | 551 | Parameters 552 | ---------- 553 | lf : LandmarkFace 554 | Instance of a ``LandmarkFace`` before taking the action. 555 | 556 | Returns 557 | ------- 558 | new_lf : LandmarkFace 559 | Instance of a ``LandmarkFace`` after taking the action. 560 | 561 | df : DisplacementField 562 | Displacement field representing the transformation between the old and new image. 563 | 564 | """ 565 | sides = [] 566 | if self.side in {"both", "left"}: 567 | sides.append("L") 568 | 569 | if self.side in {"both", "right"}: 570 | sides.append("R") 571 | 572 | specs = {} 573 | for side in sides: 574 | specs.update( 575 | { 576 | "OUTERMOST_EYEBROW_{}".format(side): (-90, 1), 577 | "OUTER_EYEBROW_{}".format(side): (-90, 0.7), 578 | "MIDDLE_EYEBROW_{}".format(side): (-90, 0.4), 579 | "INNER_EYEBROW_{}".format(side): (-90, 0.2), 580 | "INNERMOST_EYEBROW_{}".format(side): (-90, 0.1), 581 | } 582 | ) 583 | 584 | return Lambda(self.scale, specs).perform(lf) 585 | 586 | 587 | class Smile(Action): 588 | """Make a smiling face. 589 | 590 | Parameters 591 | ---------- 592 | scale : float 593 | Absolute shift size in the reference space. 594 | 595 | """ 596 | 597 | def __init__(self, scale=0.1): 598 | """Construct.""" 599 | self.scale = scale 600 | 601 | def perform(self, lf): 602 | """Perform action. 603 | 604 | Parameters 605 | ---------- 606 | lf : LandmarkFace 607 | Instance of a ``LandmarkFace`` before taking the action. 608 | 609 | Returns 610 | ------- 611 | new_lf : LandmarkFace 612 | Instance of a ``LandmarkFace`` after taking the action. 613 | 614 | df : DisplacementField 615 | Displacement field representing the transformation between the old and new image. 616 | 617 | """ 618 | specs = { 619 | "OUTSIDE_MOUTH_CORNER_L": (-110, 1), 620 | "OUTSIDE_MOUTH_CORNER_R": (-70, 1), 621 | "INSIDE_MOUTH_CORNER_L": (-110, 0.8), 622 | "INSIDE_MOUTH_CORNER_R": (-70, 0.8), 623 | "OUTER_OUTSIDE_UPPER_LIP_L": (-100, 0.3), 624 | "OUTER_OUTSIDE_UPPER_LIP_R": (-80, 0.3), 625 | } 626 | 627 | return Lambda(self.scale, specs).perform(lf) 628 | 629 | 630 | class StretchNostrils(Action): 631 | """Stratch nostrils. 632 | 633 | Parameters 634 | ---------- 635 | scale : float 636 | Absolute shift size in the reference space. 637 | 638 | """ 639 | 640 | def __init__(self, scale=0.1): 641 | """Construct.""" 642 | self.scale = scale 643 | 644 | def perform(self, lf): 645 | """Perform action. 646 | 647 | Parameters 648 | ---------- 649 | lf : LandmarkFace 650 | Instance of a ``LandmarkFace`` before taking the action. 651 | 652 | Returns 653 | ------- 654 | new_lf : LandmarkFace 655 | Instance of a ``LandmarkFace`` after taking the action. 656 | 657 | df : DisplacementField 658 | Displacement field representing the transformation between the old and new image. 659 | 660 | """ 661 | specs = {"OUTER_NOSTRIL_L": (-135, 1), "OUTER_NOSTRIL_R": (-45, 1)} 662 | 663 | return Lambda(self.scale, specs).perform(lf) 664 | -------------------------------------------------------------------------------- /pychubby/base.py: -------------------------------------------------------------------------------- 1 | """Base classes and functions.""" 2 | 3 | import pathlib 4 | 5 | import cv2 6 | import numpy as np 7 | import scipy 8 | 9 | CACHE_FOLDER = pathlib.Path.home() / '.pychubby/' 10 | 11 | CACHE_FOLDER.mkdir(parents=True, exist_ok=True) 12 | 13 | 14 | class DisplacementField: 15 | """Represents a coordinate transformation.""" 16 | 17 | @classmethod 18 | def generate(cls, shape, old_points, new_points, anchor_corners=True, **interpolation_kwargs): 19 | """Create a displacement field based on old and new landmark points. 20 | 21 | Parameters 22 | ---------- 23 | shape : tuple 24 | Tuple representing the height and the width of the displacement field. 25 | 26 | old_points : np.ndarray 27 | Array of shape `(N, 2)` representing the x and y coordinates of the 28 | old landmark points. 29 | 30 | new_points : np.ndarray 31 | Array of shape `(N, 2)` representing the x and y coordinates of the 32 | new landmark points. 33 | 34 | anchor_corners : bool 35 | If True, then assumed that the 4 corners of the image correspond. 36 | 37 | interpolation_kwargs : dict 38 | Additional parameters related to the interpolation. 39 | 40 | Returns 41 | ------- 42 | df : DisplacementField 43 | DisplacementField instance representing the transformation that 44 | allows for warping the old image with old landmarks into the new 45 | coordinate space. 46 | 47 | """ 48 | if not (isinstance(old_points, np.ndarray) and isinstance(new_points, np.ndarray)): 49 | raise TypeError("The old and new points need to be numpy arrays.") 50 | 51 | if old_points.shape != new_points.shape: 52 | raise ValueError("The old and new points do not have the same dimensions.") 53 | 54 | if len(shape) != 2: 55 | raise ValueError("Shape has to be 2 dimensional.") 56 | 57 | points_delta_x = old_points[:, 0] - new_points[:, 0] 58 | points_delta_y = old_points[:, 1] - new_points[:, 1] 59 | 60 | if anchor_corners: 61 | corners = np.array([[0, 0], 62 | [0, shape[0] - 1], 63 | [shape[1] - 1, 0], 64 | [shape[1] - 1, shape[0] - 1]]) 65 | new_points = np.vstack([new_points, corners]) 66 | old_points = np.vstack([old_points, corners]) 67 | points_delta_x = np.concatenate([points_delta_x, [0, 0, 0, 0]]) 68 | points_delta_y = np.concatenate([points_delta_y, [0, 0, 0, 0]]) 69 | 70 | # Prepare kwargs 71 | if not interpolation_kwargs: 72 | interpolation_kwargs = {'function': 'linear'} 73 | 74 | # Fitting 75 | rbf_x = scipy.interpolate.Rbf(new_points[:, 0], new_points[:, 1], points_delta_x, 76 | **interpolation_kwargs) 77 | rbf_y = scipy.interpolate.Rbf(new_points[:, 0], new_points[:, 1], points_delta_y, 78 | **interpolation_kwargs) 79 | 80 | # Prediction 81 | x_grid, y_grid = np.meshgrid(range(shape[1]), range(shape[0])) 82 | x_grid_r, y_grid_r = x_grid.ravel(), y_grid.ravel() 83 | 84 | delta_x = rbf_x(x_grid_r, y_grid_r).reshape(shape) 85 | delta_y = rbf_y(x_grid_r, y_grid_r).reshape(shape) 86 | 87 | return cls(delta_x, delta_y) 88 | 89 | def __init__(self, delta_x, delta_y): 90 | """Construct.""" 91 | if not (isinstance(delta_x, np.ndarray) and isinstance(delta_y, np.ndarray)): 92 | raise TypeError('The deltas need to be a numpy array.') 93 | 94 | if not (delta_x.ndim == delta_y.ndim == 2): 95 | raise ValueError('The dimensions of delta_x and delta_y need to be 2.') 96 | 97 | if delta_x.shape != delta_y.shape: 98 | raise ValueError('The shapes of deltas need to be equal') 99 | 100 | self.shape = delta_x.shape 101 | 102 | self.delta_x = delta_x.astype(np.float32) 103 | self.delta_y = delta_y.astype(np.float32) 104 | 105 | def __call__(self, other): 106 | """Composition. 107 | 108 | Parameters 109 | ---------- 110 | other : DisplacementField 111 | Another instance of ``DisplacementField``. Represents the inner transformation. 112 | 113 | Returns 114 | ------- 115 | composition : DisplacementField 116 | Composition of `self` (outer transformation) and `other` (inner transformation). 117 | 118 | """ 119 | shape = other.delta_x.shape 120 | t_x_outer, t_y_outer = self.transformation 121 | x, y = np.meshgrid(range(shape[1]), range(shape[0])) 122 | 123 | delta_x_comp = other.warp(t_x_outer) - x 124 | delta_y_comp = other.warp(t_y_outer) - y 125 | 126 | return self.__class__(delta_x_comp, delta_y_comp) 127 | 128 | def __eq__(self, other): 129 | """Elementwise equality of displacements. 130 | 131 | Parameters 132 | ---------- 133 | other : DisplacementField 134 | Another instance of ``DisplacementField``. 135 | 136 | Returns 137 | ------- 138 | bool 139 | True if elementwise equal. 140 | 141 | """ 142 | delta_x_equal = np.allclose(self.delta_x, other.delta_x) 143 | delta_y_equal = np.allclose(self.delta_y, other.delta_y) 144 | 145 | return delta_x_equal and delta_y_equal 146 | 147 | def __mul__(self, other): 148 | """Multiply with a constant from the right. 149 | 150 | Parameters 151 | ---------- 152 | other : int or float 153 | Constant to multiply the displacements with. 154 | 155 | Returns 156 | ------- 157 | DisplacemntField 158 | Scaled instance of ``DisplacementField``. 159 | 160 | """ 161 | if not isinstance(other, (int, float)): 162 | raise TypeError('The constant needs to be a single number') 163 | 164 | delta_x_scaled = self.delta_x * other 165 | delta_y_scaled = self.delta_y * other 166 | 167 | return DisplacementField(delta_x_scaled, delta_y_scaled) 168 | 169 | def __rmul__(self, other): 170 | """Multiply with a constant from the left. 171 | 172 | Parameters 173 | ---------- 174 | other : int or float 175 | Constant to multiply the displacements with. 176 | 177 | Returns 178 | ------- 179 | DisplacemntField 180 | Scaled instance of ``DisplacementField``. 181 | 182 | """ 183 | return self.__mul__(other) 184 | 185 | def __truediv__(self, other): 186 | """Divide by a constant. 187 | 188 | Parameters 189 | ---------- 190 | other : int or float 191 | Constant to divide the displacements by. 192 | 193 | Returns 194 | ------- 195 | DisplacemntField 196 | Scaled instance of ``DisplacementField``. 197 | 198 | """ 199 | if not isinstance(other, (int, float)): 200 | raise TypeError('The constant needs to be a single number') 201 | 202 | delta_x_scaled = self.delta_x / other 203 | delta_y_scaled = self.delta_y / other 204 | 205 | return DisplacementField(delta_x_scaled, delta_y_scaled) 206 | 207 | @property 208 | def is_valid(self): 209 | """Check whether both delta_x and delta_y finite.""" 210 | return np.all(np.isfinite(self.delta_x)) and np.all(np.isfinite(self.delta_y)) 211 | 212 | @property 213 | def norm(self): 214 | """Compute per element euclidean norm.""" 215 | return np.sqrt(np.square(self.delta_x) + np.square(self.delta_y)) 216 | 217 | @property 218 | def transformation(self): 219 | """Compute actual transformation rather then displacements.""" 220 | x, y = np.meshgrid(range(self.shape[1]), range(self.shape[0])) 221 | transformation_x = self.delta_x + x.astype('float32') 222 | transformation_y = self.delta_y + y.astype('float32') 223 | 224 | return transformation_x, transformation_y 225 | 226 | def warp(self, img, order=1): 227 | """Warp image into new coordinate system. 228 | 229 | Parameters 230 | ---------- 231 | img : np.ndarray 232 | Image to be warped. Any number of channels and dtype either uint8 or float32. 233 | 234 | order : int 235 | Interpolation order. 236 | * 0 - nearest neigbours 237 | * 1 - linear 238 | * 2 - cubic 239 | 240 | Returns 241 | ------- 242 | warped_img : np.ndarray 243 | Warped image. The same number of channels and same dtype as the `img`. 244 | 245 | """ 246 | tform_x, tform_y = self.transformation 247 | warped_img = cv2.remap(img, tform_x, tform_y, order) 248 | 249 | return warped_img 250 | -------------------------------------------------------------------------------- /pychubby/cli.py: -------------------------------------------------------------------------------- 1 | """Command line interface.""" 2 | 3 | import inspect 4 | 5 | import click 6 | import matplotlib.pyplot as plt 7 | 8 | import pychubby.actions 9 | from pychubby.detect import LandmarkFace 10 | 11 | EXCLUDED_ACTIONS = ["Action", "AbsoluteMove", "Lambda", "Multiple", "Pipeline"] 12 | ALL_ACTIONS = [ 13 | m[0] 14 | for m in inspect.getmembers(pychubby.actions, inspect.isclass) 15 | if m[1].__module__ == "pychubby.actions" and m[0] not in EXCLUDED_ACTIONS 16 | ] 17 | 18 | 19 | @click.group() 20 | def cli(): 21 | """Automated face warping tool.""" 22 | pass 23 | 24 | 25 | @cli.group() 26 | def perform(): 27 | """Take an action.""" 28 | pass 29 | 30 | 31 | class ActionFactory: 32 | """Utility for defining subcommands of the perform command dynamically. 33 | 34 | The goal is to have a separate CLI command for each action. To 35 | achieve this we use the specific structure of the `pychubby.actions` 36 | module. Namely we use the fact that each class corresponds to an 37 | action. 38 | 39 | Parameters 40 | ---------- 41 | name : str 42 | Name of the action. The subcommand will be named the same. 43 | 44 | doc : str 45 | First line of the class docstring. Will be used for the 46 | help of the subcommand. 47 | 48 | Attributes 49 | ---------- 50 | kwargs : dict 51 | All parameters (keys) of a given action together with their defaults (values). 52 | 53 | """ 54 | 55 | def __init__(self, name, doc): 56 | """Construct.""" 57 | self.name = name 58 | self.doc = doc 59 | 60 | try: 61 | spec = inspect.getargspec(getattr(pychubby.actions, self.name).__init__) 62 | self.kwargs = dict(zip(spec.args[-len(spec.defaults):], spec.defaults)) 63 | except Exception: 64 | self.kwargs = {} 65 | 66 | def generate(self): 67 | """Define a subsubcommand.""" 68 | operators = [ 69 | perform.command(name=self.name, help=self.doc), 70 | click.argument("inp_img", type=click.Path(exists=True)), 71 | click.argument("out_img", type=click.Path(), required=False), 72 | ] 73 | 74 | operators += [ 75 | click.option("--{}".format(k), default=v, show_default=True) for k, v in self.kwargs.items() 76 | ] 77 | 78 | def f(*args, **kwargs): 79 | """Perform an action.""" 80 | inp_img = kwargs.pop("inp_img") 81 | out_img = kwargs.pop("out_img") 82 | 83 | img = plt.imread(str(inp_img)) 84 | lf = LandmarkFace.estimate(img) 85 | cls = getattr(pychubby.actions, self.name) 86 | a = pychubby.actions.Multiple(cls(**kwargs)) 87 | 88 | new_lf, df = a.perform(lf) 89 | 90 | if out_img is not None: 91 | plt.imsave(str(out_img), new_lf[0].img) 92 | else: 93 | new_lf.plot(show_landmarks=False, show_numbers=False) 94 | 95 | for op in operators[::-1]: 96 | f = op(f) 97 | 98 | return f 99 | 100 | 101 | for action in ALL_ACTIONS: 102 | doc = getattr(pychubby.actions, action).__doc__.split("\n")[0] 103 | ActionFactory(action, doc).generate() 104 | 105 | 106 | @cli.command() 107 | def list(*args, **kwargs): 108 | """List available actions.""" 109 | print("\n".join(ALL_ACTIONS)) 110 | -------------------------------------------------------------------------------- /pychubby/data.py: -------------------------------------------------------------------------------- 1 | """Collection of functions focused on obtaining data.""" 2 | 3 | import bz2 4 | import pathlib 5 | import urllib.request 6 | 7 | from pychubby.base import CACHE_FOLDER 8 | 9 | 10 | def get_pretrained_68(folder=None, verbose=True): 11 | """Get pretrained landmarks model for dlib. 12 | 13 | Parameters 14 | ---------- 15 | folder : str or pathlib.Path or None 16 | Folder where to save the .dat file. 17 | 18 | verbose : bool 19 | Print some output. 20 | 21 | References 22 | ---------- 23 | [1] C. Sagonas, E. Antonakos, G, Tzimiropoulos, S. Zafeiriou, M. Pantic. 24 | 300 faces In-the-wild challenge: Database and results. Image and Vision Computing (IMAVIS), 25 | Special Issue on Facial Landmark Localisation "In-The-Wild". 2016. 26 | 27 | [2] C. Sagonas, G. Tzimiropoulos, S. Zafeiriou, M. Pantic. 28 | A semi-automatic methodology for facial landmark annotation. Proceedings of IEEE Int’l Conf. 29 | Computer Vision and Pattern Recognition (CVPR-W), 5th Workshop on Analysis and Modeling of 30 | Faces and Gestures (AMFG 2013). Oregon, USA, June 2013. 31 | 32 | [3] C. Sagonas, G. Tzimiropoulos, S. Zafeiriou, M. Pantic. 33 | 300 Faces in-the-Wild Challenge: The first facial landmark localization Challenge. 34 | Proceedings of IEEE Int’l Conf. on Computer Vision (ICCV-W), 300 Faces in-the-Wild 35 | 36 | """ 37 | url = "https://raw.githubusercontent.com/" 38 | url += "davisking/dlib-models/master/shape_predictor_68_face_landmarks.dat.bz2" 39 | 40 | folder = pathlib.Path(CACHE_FOLDER) if folder is None else pathlib.Path(folder) 41 | filepath = folder / "shape_predictor_68_face_landmarks.dat" 42 | 43 | if filepath.is_file(): 44 | return 45 | 46 | if verbose: 47 | print("Downloading and decompressing {} to {}.".format(url, filepath)) 48 | 49 | req = urllib.request.urlopen(url) 50 | CHUNK = 16 * 1024 51 | 52 | decompressor = bz2.BZ2Decompressor() 53 | with open(str(filepath), 'wb') as fp: 54 | while True: 55 | chunk = req.read(CHUNK) 56 | if not chunk: 57 | break 58 | fp.write(decompressor.decompress(chunk)) 59 | req.close() 60 | -------------------------------------------------------------------------------- /pychubby/detect.py: -------------------------------------------------------------------------------- 1 | """Collection of detection algorithms.""" 2 | import math 3 | import pathlib 4 | 5 | import dlib 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | from skimage.util import img_as_ubyte 9 | 10 | from pychubby.base import CACHE_FOLDER 11 | from pychubby.data import get_pretrained_68 12 | 13 | LANDMARK_NAMES = { 14 | "UPPER_TEMPLE_L": 0, 15 | "MIDDLE_TEMPLE_L": 1, 16 | "LOWER_TEMPLE_L": 2, 17 | "UPPERMOST_CHEEK_L": 3, 18 | "UPPER_CHEEK_L": 4, 19 | "LOWER_CHEEK_L": 5, 20 | "LOWERMOST_CHEEK_L": 6, 21 | "CHIN_L": 7, 22 | "CHIN": 8, 23 | "CHIN_R": 9, 24 | "LOWERMOST_CHEEK_R": 10, 25 | "LOWER_CHEEK_R": 11, 26 | "UPPER_CHEEK_R": 12, 27 | "UPPERMOST_CHEEK_R": 13, 28 | "LOWER_TEMPLE_R": 14, 29 | "MIDDLE_TEMPLE_R": 15, 30 | "UPPER_TEMPLE_R": 16, 31 | "OUTERMOST_EYEBROW_L": 17, 32 | "OUTER_EYEBROW_L": 18, 33 | "MIDDLE_EYEBROW_L": 19, 34 | "INNER_EYEBROW_L": 20, 35 | "INNERMOST_EYEBROW_L": 21, 36 | "INNERMOST_EYEBROW_R": 22, 37 | "INNER_EYEBROW_R": 23, 38 | "MIDDLE_EYEBROW_R": 24, 39 | "OUTER_EYEBROW_R": 25, 40 | "OUTERMOST_EYEBROW_R": 26, 41 | "UPPERMOST_NOSE": 27, 42 | "UPPER_NOSE": 28, 43 | "LOWER_NOSE": 29, 44 | "LOWERMOST_NOSE": 30, 45 | "OUTER_NOSTRIL_L": 31, 46 | "INNER_NOSTRIL_L": 32, 47 | "MIDDLE_NOSTRIL": 33, 48 | "INNER_NOSTRIL_R": 34, 49 | "OUTER_NOSTRIL_R": 35, 50 | "OUTER_EYE_CORNER_L": 36, 51 | "OUTER_EYE_LID_L": 37, 52 | "INNER_EYE_LID_L": 38, 53 | "INNER_EYE_CORNER_L": 39, 54 | "INNER_EYE_BOTTOM_L": 40, 55 | "OUTER_EYE_BOTTOM_L": 41, 56 | "INNER_EYE_CORNER_R": 42, 57 | "INNER_EYE_LID_R": 43, 58 | "OUTER_EYE_LID_R": 44, 59 | "OUTER_EYE_CORNER_R": 45, 60 | "OUTER_EYE_BOTTOM_R": 46, 61 | "INNER_EYE_BOTTOM_R": 47, 62 | "OUTSIDE_MOUTH_CORNER_L": 48, 63 | "OUTER_OUTSIDE_UPPER_LIP_L": 49, 64 | "INNER_OUTSIDE_UPPER_LIP_L": 50, 65 | "MIDDLE_OUTSIDE_UPPER_LIP": 51, 66 | "INNER_OUTSIDE_UPPER_LIP_R": 52, 67 | "OUTER_OUTSIDE_UPPER_LIP_R": 53, 68 | "OUTSIDE_MOUTH_CORNER_R": 54, 69 | "OUTER_OUTSIDE_LOWER_LIP_R": 55, 70 | "INNER_OUTSIDE_LOWER_LIP_R": 56, 71 | "MIDDLE_OUTSIDE_LOWER_LIP": 57, 72 | "INNER_OUTSIDE_LOWER_LIP_L": 58, 73 | "OUTER_OUTSIDE_LOWER_LIP_L": 59, 74 | "INSIDE_MOUTH_CORNER_L": 60, 75 | "INSIDE_UPPER_LIP_L": 61, 76 | "MIDDLE_INSIDE_UPPER_LIP": 62, 77 | "INSIDE_UPPER_LIP_R": 63, 78 | "INSIDE_MOUTH_CORNER_R": 64, 79 | "INSIDE_LOWER_LIP_R": 65, 80 | "MIDDLE_INSIDE_LOWER_LIP": 66, 81 | "INSIDE_LOWER_LIP_L": 67, 82 | } 83 | 84 | 85 | def face_rectangle(img, n_upsamples=1): 86 | """Find a face rectangle. 87 | 88 | Parameters 89 | ---------- 90 | img : np.ndarray 91 | Image of any dtype and number of channels. 92 | 93 | Returns 94 | ------- 95 | corners : list 96 | List of tuples where each tuple represents the top left and bottom right coordinates of 97 | the face rectangle. Note that these coordinates use the `(row, column)` convention. The 98 | length of the list is equal to the number of detected faces. 99 | 100 | faces : list 101 | Instance of ``dlib.rectagles`` that can be used in other algorithm. 102 | 103 | n_upsamples : int 104 | Upsample factor to apply to the image before detection. Allows to recognize 105 | more faces. 106 | 107 | """ 108 | if not isinstance(img, np.ndarray): 109 | raise TypeError("The input needs to be a np.ndarray") 110 | 111 | dlib_detector = dlib.get_frontal_face_detector() 112 | 113 | faces = dlib_detector(img_as_ubyte(img), n_upsamples) 114 | 115 | corners = [] 116 | for face in faces: 117 | x1, y1, x2, y2 = face.left(), face.top(), face.right(), face.bottom() 118 | top_left = (y1, x1) 119 | bottom_right = (y2, x2) 120 | corners.append((top_left, bottom_right)) 121 | 122 | return corners, faces 123 | 124 | 125 | def landmarks_68(img, rectangle, model_path=None): 126 | """Predict 68 face landmarks. 127 | 128 | Parameters 129 | ---------- 130 | img : np.ndarray 131 | Image of any dtype and number of channels. 132 | 133 | rectangle : dlib.rectangle 134 | Rectangle that represents the bounding box around a single face. 135 | 136 | model_path : str or pathlib.Path, default=None 137 | Path to where the pretrained model is located. If None then using the `CACHE_FOLDER` model. 138 | 139 | Returns 140 | ------- 141 | lm_points : np.ndarray 142 | Array of shape `(68, 2)` where rows are different landmark points and the columns 143 | are x and y coordinates. 144 | 145 | original : dlib.full_object_detection 146 | Instance of ``dlib.full_object_detection``. 147 | 148 | """ 149 | if model_path is None: 150 | model_path = CACHE_FOLDER / "shape_predictor_68_face_landmarks.dat" 151 | get_pretrained_68(model_path.parent) 152 | 153 | else: 154 | model_path = pathlib.Path(str(model_path)) 155 | 156 | if not model_path.is_file(): 157 | raise IOError("Invalid landmark model, {}".format(str(model_path))) 158 | 159 | lm_predictor = dlib.shape_predictor(str(model_path)) 160 | 161 | original = lm_predictor(img_as_ubyte(img), rectangle) 162 | 163 | lm_points = np.array([[p.x, p.y] for p in original.parts()]) 164 | 165 | return lm_points, original 166 | 167 | 168 | class LandmarkFace: 169 | """Class representing a combination of a face image and its landmarks. 170 | 171 | Parameters 172 | ---------- 173 | points : np.ndarray 174 | Array of shape `(68, 2)` where rows are different landmark points and the columns 175 | are x and y coordinates. 176 | 177 | img : np.ndarray 178 | Representing an image of a face. Any dtype and any number of channels. 179 | 180 | rectangle : tuple 181 | Two containing two tuples where the first one represents the top left corner 182 | of a rectangle and the second one the bottom right corner of a rectangle. 183 | 184 | Attributes 185 | ---------- 186 | shape : tuple 187 | Tuple representing the height and width of the image. 188 | 189 | """ 190 | 191 | @classmethod 192 | def estimate(cls, img, model_path=None, n_upsamples=1, allow_multiple=True): 193 | """Estimate the 68 landmarks. 194 | 195 | Parameters 196 | ---------- 197 | img : np.ndarray 198 | Array representing an image of a face. Any dtype and any number of channels. 199 | 200 | model_path : str or pathlib.Path, default=None 201 | Path to where the pretrained model is located. If None then using 202 | the `CACHE_FOLDER` model. 203 | 204 | n_upsamples : int 205 | Upsample factor to apply to the image before detection. Allows to recognize 206 | more faces. 207 | 208 | allow_multiple : bool 209 | If True, multiple faces are allowed. In case more than one face detected then instance 210 | of ``LandmarkFaces`` is returned. If False, raises error if more faces detected. 211 | 212 | Returns 213 | ------- 214 | LandmarkFace or LandmarkFaces 215 | If only one face detected, then returns instance of ``LandmarkFace``. If multiple faces 216 | detected and `allow_multiple=True` then instance of ``LandmarFaces`` is returned. 217 | 218 | """ 219 | corners, faces = face_rectangle(img, n_upsamples=n_upsamples) 220 | 221 | if len(corners) == 0: 222 | raise ValueError("No faces detected.") 223 | 224 | elif len(corners) == 1: 225 | _, face = corners[0], faces[0] 226 | points, _ = landmarks_68(img, face) 227 | 228 | return cls(points, img) 229 | 230 | else: 231 | if not allow_multiple: 232 | raise ValueError( 233 | "Only possible to model one face, {} found. Consider using allow_multiple.".format( 234 | len(corners) 235 | ) 236 | ) 237 | else: 238 | all_lf = [] 239 | for face in faces: 240 | points, _ = landmarks_68(img, face) 241 | try: 242 | all_lf.append(cls(points, img)) 243 | 244 | except ValueError: 245 | pass 246 | 247 | return LandmarkFaces(*all_lf) 248 | 249 | def __init__(self, points, img, rectangle=None): 250 | """Construct.""" 251 | # Checks 252 | if points.shape != (68, 2): 253 | raise ValueError("There needs to be 68 2D landmarks.") 254 | 255 | if np.unique(points, axis=0).shape != (68, 2): 256 | raise ValueError("There are some duplicates.") 257 | 258 | self.points = points 259 | self.img = img 260 | self.rectangle = rectangle 261 | self.img_shape = self.img.shape[ 262 | :2 263 | ] # only first two dims matter - height and width 264 | 265 | def __getitem__(self, val): 266 | """Return a corresponding coordinate. 267 | 268 | Supports both integer and string indexing. 269 | 270 | """ 271 | if isinstance(val, (int, slice)): 272 | return self.points[val] 273 | 274 | elif isinstance(val, list): 275 | if np.all([isinstance(x, int) for x in val]): 276 | return self.points[val] 277 | elif np.all([isinstance(x, str) for x in val]): 278 | ixs = [LANDMARK_NAMES[x] for x in val] 279 | return self.points[ixs] 280 | else: 281 | raise TypeError('All elements must be either int or str') 282 | 283 | elif isinstance(val, np.ndarray): 284 | if val.ndim > 1: 285 | raise ValueError('Only 1D arrays allowed') 286 | return self.points[val] 287 | 288 | elif isinstance(val, str): 289 | return self.points[LANDMARK_NAMES[val]] 290 | 291 | else: 292 | raise TypeError('Unsupported type {}'.format(type(val))) 293 | 294 | def angle(self, landmark_1, landmark_2, reference_vector=None, use_radians=False): 295 | """Angle between two landmarks and positive part of the x axis. 296 | 297 | The possible values range from (-180, 180) in degrees. 298 | 299 | Parameters 300 | ---------- 301 | landmark_1 : int 302 | An integer from [0,57] representing a landmark point. The start 303 | of the vector. 304 | 305 | landmark_2 : int 306 | An integer from [0,57] representing a landmark point. The end 307 | of the vector. 308 | 309 | reference_vector : None or tuple 310 | If None, then positive part of the x axis used (1, 0). Otherwise 311 | specified by the user. 312 | 313 | use_radians : bool 314 | If True, then radians used. Otherwise degrees. 315 | 316 | Returns 317 | ------- 318 | angle : float 319 | The angle between the two landmarks and positive part of the x axis. 320 | 321 | """ 322 | v_1 = ( 323 | np.array([1, 0]) if reference_vector is None else np.array(reference_vector) 324 | ) 325 | v_2 = self.points[landmark_2] - self.points[landmark_1] 326 | 327 | res_radians = math.atan2( 328 | v_1[0] * v_2[1] - v_1[1] * v_2[0], v_1[0] * v_2[0] + v_1[1] * v_2[1] 329 | ) 330 | 331 | if use_radians: 332 | return res_radians 333 | else: 334 | return math.degrees(res_radians) 335 | 336 | def euclidean_distance(self, landmark_1, landmark_2): 337 | """Euclidean distance between 2 landmarks. 338 | 339 | Parameters 340 | ---------- 341 | landmark_1 : int 342 | An integer from [0,57] representing a landmark point. 343 | 344 | landmark_2 : int 345 | An integer from [0,57] representing a landmark point. 346 | 347 | 348 | Returns 349 | ------- 350 | dist : float 351 | Euclidean distance between `landmark_1` and `landmark_2`. 352 | 353 | """ 354 | return np.linalg.norm(self.points[landmark_1] - self.points[landmark_2]) 355 | 356 | def plot(self, figsize=(12, 12), show_landmarks=True): 357 | """Plot face together with landmarks. 358 | 359 | Parameters 360 | ---------- 361 | figsize : tuple 362 | Size of the figure - (height, width). 363 | 364 | show_landmarks : bool 365 | Show all 68 landmark points on the face. 366 | 367 | """ 368 | plt.figure(figsize=figsize) 369 | if show_landmarks: 370 | plt.scatter(self.points[:, 0], self.points[:, 1], c="black") 371 | plt.imshow(self.img, cmap="gray") 372 | plt.show() 373 | 374 | 375 | class LandmarkFaces: 376 | """Class enclosing multiple instances of ``LandmarkFace``. 377 | 378 | Parameters 379 | ---------- 380 | *lf_list : list 381 | Sequence of ``LandmarkFace`` instances. 382 | 383 | """ 384 | 385 | def __init__(self, *lf_list): 386 | """Construct.""" 387 | self.lf_list = lf_list 388 | 389 | # checks 390 | if not lf_list: 391 | raise ValueError("No LandmarkFace available.") 392 | 393 | if not all([isinstance(x, LandmarkFace) for x in lf_list]): 394 | print([type(x) for x in lf_list]) 395 | raise TypeError("All entries need to be a LandmarkFace instance") 396 | 397 | ref_img = lf_list[0].img 398 | for lf in lf_list[1:]: 399 | if not np.allclose(ref_img, lf.img): 400 | raise ValueError("Each LandmarkFace image needs to be identical.") 401 | 402 | def __len__(self): 403 | """Compute length.""" 404 | return len(self.lf_list) 405 | 406 | def __getitem__(self, ix): 407 | """Access item.""" 408 | return self.lf_list[ix] 409 | 410 | def plot(self, figsize=(12, 12), show_numbers=True, show_landmarks=False): 411 | """Plot. 412 | 413 | Parameters 414 | ---------- 415 | figsize : tuple 416 | Size of the figure - (height, width). 417 | 418 | show_numbers : bool 419 | If True, then a number is shown on each face representing its order. This order is 420 | useful when using the metaaction ``Multiple``. 421 | 422 | show_landmarks : bool 423 | Show all 68 landmark points on each of the faces. 424 | 425 | """ 426 | plt.figure(figsize=figsize) 427 | for i, lf in enumerate(self): 428 | if show_numbers: 429 | plt.annotate(str(i), 430 | lf['LOWERMOST_NOSE'], 431 | size=lf.euclidean_distance(8, 27), 432 | ha='center', 433 | va='center') 434 | 435 | if show_landmarks: 436 | plt.scatter(lf.points[:, 0], lf.points[:, 1], c='black') 437 | 438 | plt.imshow(self[0].img, cmap='gray') 439 | plt.show() 440 | -------------------------------------------------------------------------------- /pychubby/reference.py: -------------------------------------------------------------------------------- 1 | """Module focused on creation of reference spaces.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | import numpy as np 6 | from skimage.transform import AffineTransform 7 | 8 | 9 | class ReferenceSpace(ABC): 10 | """Abstract class for reference spaces.""" 11 | 12 | @abstractmethod 13 | def estimate(*args, **kwargs): 14 | """Fit parameters of the model.""" 15 | 16 | @abstractmethod 17 | def ref2inp(*args, **kwargs): 18 | """Transform from reference to input.""" 19 | 20 | @abstractmethod 21 | def inp2ref(*args, **kwargs): 22 | """Transform from input to reference.""" 23 | 24 | 25 | class DefaultRS(ReferenceSpace): 26 | """Default reference space. 27 | 28 | Attributes 29 | ---------- 30 | tform : skimage.transform.GeometricTransform 31 | Affine transformation. 32 | 33 | keypoints : dict 34 | Defining landmarks used for estimating the parameters of the model. 35 | 36 | """ 37 | 38 | def __init__(self): 39 | """Construct.""" 40 | self.tform = AffineTransform() 41 | self.keypoints = { 42 | 'CHIN': (0, 1), 43 | 'UPPER_TEMPLE_L': (-1, -1), 44 | 'UPPER_TEMPLE_R': (1, -1), 45 | 'UPPERMOST_NOSE': (0, -1), 46 | 'MIDDLE_NOSTRIL': (0, 0) 47 | } 48 | 49 | def estimate(self, lf): 50 | """Estimate parameters of the affine transformation. 51 | 52 | Parameters 53 | ---------- 54 | lf : pychubby.detect.LandmarFace 55 | Instance of the ``LandmarkFace``. 56 | 57 | """ 58 | src = [] 59 | dst = [] 60 | for name, ref_coordinate in self.keypoints.items(): 61 | dst.append(ref_coordinate) 62 | src.append(lf[name]) 63 | 64 | src = np.array(src) 65 | dst = np.array(dst) 66 | 67 | self.tform.estimate(src, dst) 68 | 69 | def ref2inp(self, coords): 70 | """Transform from reference to input space. 71 | 72 | Parameters 73 | ---------- 74 | coords : np.ndarray 75 | Array of shape `(N, 2)` where the columns represent x and y reference coordinates. 76 | 77 | Returns 78 | ------- 79 | tformed_coords : np.ndarray 80 | Array of shape `(N, 2)` where the columns represent x and y coordinates in the input image 81 | correspoding row-wise to `coords`. 82 | 83 | """ 84 | return self.tform.inverse(coords) 85 | 86 | def inp2ref(self, coords): 87 | """Transform from input to reference space. 88 | 89 | Parameters 90 | ---------- 91 | coords : np.ndarray 92 | Array of shape `(N, 2)` where the columns represent x and y coordinates in the input space. 93 | 94 | Returns 95 | ------- 96 | tformed_coords : np.ndarray 97 | Array of shape `(N, 2)` where the columns represent x and y coordinates in the reference space 98 | correspoding row-wise to `coords`. 99 | 100 | """ 101 | return self.tform(coords) 102 | -------------------------------------------------------------------------------- /pychubby/utils.py: -------------------------------------------------------------------------------- 1 | """Collection of utilities.""" 2 | 3 | import numpy as np 4 | from skimage.draw import rectangle_perimeter 5 | from skimage.morphology import dilation, square 6 | 7 | 8 | def points_to_rectangle_mask(shape, top_left, bottom_right, width=1): 9 | """Convert two points into a rectangle boolean mask. 10 | 11 | Parameters 12 | ---------- 13 | shape : tuple 14 | Represents the `(height, width)` of the final mask. 15 | 16 | top_left : tuple 17 | Two element tuple representing `(row, column)` of the top left corner of the inner rectangle. 18 | 19 | bottom_right : tuple 20 | Two element tuple representing `(row, column)` of the bottom right corner of the inner rectangle. 21 | 22 | width : int 23 | Width of the edge of the rectangle. Note that it is generated by dilation. 24 | 25 | Returns 26 | ------- 27 | rectangle_mask : np.ndarray 28 | Boolean mask of shape `shape` where True entries represent the edge of the rectangle. 29 | 30 | Notes 31 | ----- 32 | The output can be easily used for quickly visualizing a rectangle in an image. One simply does 33 | something like img[rectangle_mask] = 255. 34 | 35 | """ 36 | if len(shape) != 2: 37 | raise ValueError('Only works for 2 dimensional arrays') 38 | 39 | rectangle_mask = np.zeros(shape, dtype=np.bool) 40 | rr, cc = rectangle_perimeter(top_left, bottom_right) 41 | rectangle_mask[rr, cc] = True 42 | rectangle_mask = dilation(rectangle_mask, square(width)) 43 | 44 | return rectangle_mask 45 | -------------------------------------------------------------------------------- /pychubby/visualization.py: -------------------------------------------------------------------------------- 1 | """Collection of tools for visualization.""" 2 | 3 | import matplotlib.pyplot as plt 4 | from matplotlib.animation import ArtistAnimation 5 | 6 | 7 | def create_animation(df, img, include_backwards=True, fps=24, n_seconds=2, figsize=(8, 8), repeat=True): 8 | """Create animation from a displacement field. 9 | 10 | Parameters 11 | ---------- 12 | df : DisplacementField 13 | Instance of the ``DisplacementField`` representing the coordinate transformation. 14 | 15 | img : np.ndarray 16 | Image. 17 | 18 | include_backwards : bool 19 | If True, then animation also played backwards after its played forwards. 20 | 21 | fps : int 22 | Frames per second. 23 | 24 | n_seconds : int 25 | Number of seconds to play the animation forwards. 26 | 27 | figsize : tuple 28 | Size of the figure. 29 | 30 | repeat : bool 31 | If True, then animation always replayed at the end. 32 | 33 | Returns 34 | ------- 35 | ani : matplotlib.animation.ArtistAnimation 36 | Animation showing the transformation. 37 | 38 | Notes 39 | ----- 40 | To enable animation viewing in a jupyter notebook write: 41 | ``` 42 | from matplotlib import rc 43 | rc('animation', html='html5') 44 | ``` 45 | 46 | """ 47 | n_frames = int(n_seconds * fps) 48 | interval = (1 / fps) * 1000 49 | frames = [] 50 | 51 | fig = plt.figure(figsize=figsize) 52 | plt.axis('off') 53 | 54 | for i in range(n_frames + 1): 55 | df_new = df * (i / n_frames) 56 | warped_img = df_new.warp(img) 57 | frames.append([plt.imshow(warped_img, cmap='gray')]) 58 | 59 | if include_backwards: 60 | frames += frames[::-1] 61 | 62 | ani = ArtistAnimation(fig, frames, interval=interval, repeat=repeat) 63 | 64 | return ani 65 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | count = True 3 | max-line-length = 120 4 | 5 | [tool:pytest] 6 | addopts = -v 7 | --cov=pychubby/ 8 | --cov-report=term 9 | --disable-warnings 10 | --tb=short 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | import pychubby 5 | 6 | INSTALL_REQUIRES = [ 7 | "click>=7.0", 8 | "matplotlib>=2.0.0", 9 | "numpy>=1.16.4", 10 | "opencv-python>=4.1.0.25", 11 | "scikit-image", 12 | ] 13 | 14 | if "RTD_BUILD" not in os.environ: 15 | # ReadTheDocs cannot handle compilation 16 | INSTALL_REQUIRES += ["dlib"] 17 | 18 | LONG_DESCRIPTION = "Automated face warping tool" 19 | PROJECT_URLS = { 20 | "Bug Tracker": "https://github.com/jankrepl/pychubby/issues", 21 | "Documentation": "https://pychubby.readthedocs.io", 22 | "Source Code": "https://github.com/jankrepl/pychubby", 23 | } 24 | VERSION = pychubby.__version__ 25 | 26 | setup( 27 | name="pychubby", 28 | version=VERSION, 29 | author="Jan Krepl", 30 | author_email="kjan.official@gmail.com", 31 | description="Automated face warping tool", 32 | long_description=LONG_DESCRIPTION, 33 | url="https://github.com/jankrepl/pychubby", 34 | project_urls=PROJECT_URLS, 35 | packages=["pychubby"], 36 | license="MIT", 37 | classifiers=[ 38 | "License :: OSI Approved :: MIT License", 39 | "Intended Audience :: Science/Research", 40 | "Intended Audience :: Developers", 41 | "Programming Language :: C", 42 | "Programming Language :: Python", 43 | "Topic :: Software Development", 44 | "Topic :: Scientific/Engineering", 45 | "Operating System :: Microsoft :: Windows", 46 | "Operating System :: POSIX", 47 | "Operating System :: Unix", 48 | "Operating System :: MacOS", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3.5", 51 | "Programming Language :: Python :: 3.6", 52 | "Programming Language :: Python :: 3.7", 53 | ("Programming Language :: Python :: " "Implementation :: CPython"), 54 | ], 55 | python_requires='>=3.5', 56 | install_requires=INSTALL_REQUIRES, 57 | extras_require={ 58 | "dev": ["codecov", "flake8", "pydocstyle", "pytest>=3.6", "pytest-cov", "tox"], 59 | "docs": ["sphinx", "sphinx_rtd_theme"], 60 | }, 61 | entry_points={"console_scripts": ["pc = pychubby.cli:cli"]}, 62 | ) 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankrepl/pychubby/3d7916f42241dc2a8bfefee2e1bb432042372e5e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import cv2 4 | import numpy as np 5 | import pytest 6 | 7 | 8 | TEST_DATA_PATH = pathlib.Path(__file__).parent / 'data' 9 | TEST_FACE_IMAGE_PATH = TEST_DATA_PATH / 'brad.jpg' 10 | TEST_FACES_IMAGE_PATH = TEST_DATA_PATH / 'kids.jpg' 11 | 12 | 13 | @pytest.fixture() 14 | def face_img_gs_uint(): 15 | """Load a grayscale image of dtype uint8.""" 16 | img = cv2.imread(str(TEST_FACE_IMAGE_PATH), 0) 17 | 18 | assert img.ndim == 2 19 | assert img.dtype == np.uint8 20 | 21 | return img 22 | 23 | 24 | @pytest.fixture() 25 | def face_img_rgb_uint(): 26 | """Load a RGB image of dtype uint8.""" 27 | img = cv2.imread(str(TEST_FACE_IMAGE_PATH), 1) 28 | img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) 29 | 30 | assert img.ndim == 3 31 | assert img.dtype == np.uint8 32 | 33 | return img 34 | 35 | 36 | @pytest.fixture() 37 | def face_img_gs_float(): 38 | """Load a grayscale image of dtype float32.""" 39 | img = cv2.imread(str(TEST_FACE_IMAGE_PATH), 0) 40 | img = img.astype(np.float32) / 255 41 | 42 | assert img.ndim == 2 43 | assert img.dtype == np.float32 44 | 45 | return img 46 | 47 | 48 | @pytest.fixture() 49 | def face_img_rgb_float(): 50 | """Load a rgb image of dtype float32.""" 51 | img = cv2.imread(str(TEST_FACE_IMAGE_PATH), 1) 52 | img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255 53 | 54 | assert img.ndim == 3 55 | assert img.dtype == np.float32 56 | 57 | return img 58 | 59 | 60 | @pytest.fixture(params=['gs_uint', 'rgb_uint', 'gs_float', 'rgb_float']) 61 | def face_img(request, face_img_gs_uint, face_img_rgb_uint, face_img_gs_float, face_img_rgb_float): 62 | """Load an image of a face.""" 63 | img_type = request.param 64 | 65 | if img_type == 'gs_uint': 66 | return face_img_gs_uint 67 | elif img_type == 'rgb_uint': 68 | return face_img_rgb_uint 69 | elif img_type == 'gs_float': 70 | return face_img_gs_float 71 | elif img_type == 'rgb_float': 72 | return face_img_rgb_float 73 | else: 74 | raise ValueError('Invalid img_type {}'.format(img_type)) 75 | 76 | print(TEST_DATA_PATH) 77 | pass 78 | 79 | 80 | @pytest.fixture(params=['gs_uint', 'rgb_uint', 'gs_float', 'rgb_float']) 81 | def blank_img(request): 82 | """Create a black image.""" 83 | img_type = request.param 84 | shape = (50, 60) 85 | 86 | if img_type == 'gs_uint': 87 | return np.zeros(shape, dtype=np.uint8) 88 | elif img_type == 'rgb_uint': 89 | return np.zeros((*shape, 3), dtype=np.uint8) 90 | elif img_type == 'gs_float': 91 | return np.zeros(shape, dtype=np.float32) 92 | elif img_type == 'rgb_float': 93 | return np.zeros((*shape, 3), dtype=np.float32) 94 | else: 95 | raise ValueError('Invalid img_type {}'.format(img_type)) 96 | 97 | 98 | @pytest.fixture(params=['gs_uint', 'rgb_uint', 'gs_float', 'rgb_float']) 99 | def faces_img(request): 100 | """Load an image of multiple faces.""" 101 | img_type = request.param 102 | 103 | if img_type == 'gs_uint': 104 | return cv2.imread(str(TEST_FACES_IMAGE_PATH), 0) 105 | elif img_type == 'rgb_uint': 106 | return cv2.cvtColor(cv2.imread(str(TEST_FACES_IMAGE_PATH)), cv2.COLOR_BGR2RGB) 107 | elif img_type == 'gs_float': 108 | return cv2.imread(str(TEST_FACES_IMAGE_PATH), 0).astype(np.float32) / 255 109 | elif img_type == 'rgb_float': 110 | return cv2.cvtColor(cv2.imread(str(TEST_FACES_IMAGE_PATH)), cv2.COLOR_BGR2RGB).astype(np.float32) / 255 111 | else: 112 | raise ValueError('Invalid img_type {}'.format(img_type)) 113 | -------------------------------------------------------------------------------- /tests/data/brad.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankrepl/pychubby/3d7916f42241dc2a8bfefee2e1bb432042372e5e/tests/data/brad.jpg -------------------------------------------------------------------------------- /tests/data/kids.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankrepl/pychubby/3d7916f42241dc2a8bfefee2e1bb432042372e5e/tests/data/kids.jpg -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | """Collection of tests focused on the `actions` module.""" 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pychubby.actions import (Action, AbsoluteMove, Chubbify, Lambda, LinearTransform, Multiple, 7 | Pipeline, RaiseEyebrow, Smile, OpenEyes, StretchNostrils) 8 | from pychubby.base import DisplacementField 9 | from pychubby.detect import LandmarkFace, LandmarkFaces 10 | 11 | # FIXTURES 12 | @pytest.fixture() 13 | def random_lf(): 14 | return LandmarkFace(np.random.random((68, 2)), np.zeros((10, 12))) 15 | 16 | 17 | class TestAction: 18 | """Tests focused on the metaclass ``Action``.""" 19 | 20 | def test_not_possible_to_inst(self): 21 | with pytest.raises(TypeError): 22 | Action() 23 | 24 | class SubAction(Action): 25 | pass 26 | 27 | with pytest.raises(TypeError): 28 | SubAction() 29 | 30 | def test_possible_to_inst(self): 31 | class SubAction(Action): 32 | def perform(*args, **kwargs): 33 | pass 34 | 35 | SubAction() 36 | 37 | def test_pts2inst(self, random_lf): 38 | new_points = np.random.random((68, 2)) 39 | 40 | new_lf, df = Action.pts2inst(new_points, random_lf) 41 | 42 | assert isinstance(new_lf, LandmarkFace) 43 | assert isinstance(df, DisplacementField) 44 | 45 | 46 | class TestAbsoluteMove: 47 | """Collection of tests focused on the `AbsoluteMove` action.""" 48 | 49 | def test_attributes_dicts(self): 50 | a = AbsoluteMove() 51 | 52 | assert isinstance(a.x_shifts, dict) 53 | assert isinstance(a.y_shifts, dict) 54 | 55 | def test_default_constructor(self, random_lf): 56 | a = AbsoluteMove() 57 | 58 | new_lf, df = a.perform(random_lf) 59 | 60 | assert isinstance(new_lf, LandmarkFace) 61 | assert isinstance(df, DisplacementField) 62 | assert df.is_valid 63 | 64 | def test_interpolation_works(self, random_lf): 65 | a = AbsoluteMove(x_shifts={3: 4}, 66 | y_shifts={32: 5}) 67 | 68 | new_lf, df = a.perform(random_lf) 69 | 70 | old_points = random_lf.points 71 | new_points = new_lf.points 72 | 73 | for i in range(68): 74 | if i == 3: 75 | assert np.allclose(new_points[i], old_points[i] + np.array([4, 0])) 76 | elif i == 32: 77 | assert np.allclose(new_points[i], old_points[i] + np.array([0, 5])) 78 | else: 79 | assert np.allclose(new_points[i], old_points[i]) 80 | 81 | 82 | class TestLambda: 83 | """Collection of tests focused on the ``Lambda`` action.""" 84 | 85 | def test_noop(self, random_lf): 86 | 87 | a = Lambda(0.5, {}) 88 | 89 | new_lf, df = a.perform(random_lf) 90 | 91 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 92 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 93 | 94 | def test_simple(self, random_lf): 95 | 96 | a = Lambda(0.5, {'CHIN': (90, 2)}) 97 | 98 | new_lf, df = a.perform(random_lf) 99 | 100 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 101 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 102 | 103 | 104 | class TestChubbify: 105 | """Collection of tests focused on the ``Chubbify`` action.""" 106 | 107 | @pytest.mark.parametrize('scale', (0.2, 0.4, 1, 0)) 108 | def test_simple(self, random_lf, scale): 109 | a = Chubbify(scale) 110 | new_lf, df = a.perform(random_lf) 111 | 112 | if scale == 0: 113 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 114 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 115 | else: 116 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 117 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 118 | 119 | 120 | class TestLinearTransform: 121 | """Collection of tests focused on the ``LinearTransform`` action.""" 122 | 123 | def test_noop(self, random_lf): 124 | a = LinearTransform() 125 | new_lf, df = a.perform(random_lf) 126 | 127 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 128 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 129 | 130 | def test_simple(self, random_lf): 131 | a = LinearTransform(translation_x=1) 132 | new_lf, df = a.perform(random_lf) 133 | 134 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 135 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 136 | 137 | 138 | class TestOpenEyes: 139 | """Collection of tests focused on the ``OpenEyes`` action.""" 140 | 141 | @pytest.mark.parametrize('scale', (0.2, 0.4, 1, 0)) 142 | def test_simple(self, random_lf, scale): 143 | a = OpenEyes(scale) 144 | new_lf, df = a.perform(random_lf) 145 | 146 | if scale == 0: 147 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 148 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 149 | else: 150 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 151 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 152 | 153 | 154 | class TestMultiple: 155 | """Collection of tests focused on the ``Multiple`` action.""" 156 | 157 | @pytest.mark.parametrize('per_face_action', [Smile(), [Smile(), Smile()]], 158 | ids=['single', 'many']) 159 | def test_overall(self, random_lf, per_face_action): 160 | lf_1 = random_lf 161 | lf_2 = LandmarkFace(random_lf.points + np.random.random((68, 2)), random_lf.img) 162 | 163 | lfs = LandmarkFaces(lf_1, lf_2) 164 | 165 | a = Multiple(per_face_action) 166 | 167 | new_lfs, df = a.perform(lfs) 168 | 169 | assert isinstance(new_lfs, LandmarkFaces) 170 | assert isinstance(df, DisplacementField) 171 | assert len(lfs) == len(new_lfs) 172 | 173 | def test_wrong_n_of_action(self, random_lf): 174 | lfs = LandmarkFaces(random_lf, random_lf, random_lf) 175 | 176 | a = Multiple([Smile(), Smile()]) 177 | 178 | with pytest.raises(ValueError): 179 | a.perform(lfs) 180 | 181 | def test_wrong_constructor(self): 182 | 183 | with pytest.raises(TypeError): 184 | Multiple([Smile(), 'WRONG']) 185 | 186 | with pytest.raises(TypeError): 187 | Multiple('WRONG') 188 | 189 | def test_lf_to_lfs_casting(self, random_lf): 190 | a = Multiple(Smile()) 191 | 192 | new_lfs, df = a.perform(random_lf) 193 | 194 | assert isinstance(new_lfs, LandmarkFaces) 195 | assert isinstance(df, DisplacementField) 196 | 197 | 198 | class TestPipeline: 199 | """Collection of tests focused on the ``Smile`` action.""" 200 | 201 | def test_overall(self, random_lf): 202 | steps = [Smile(), Chubbify()] 203 | 204 | a = Pipeline(steps) 205 | 206 | new_lf, df = a.perform(random_lf) 207 | 208 | assert df.is_valid 209 | 210 | 211 | class TestRaiseEyebrow: 212 | """Collection of tests focused on the ``RaiseEyebrow`` action.""" 213 | 214 | @pytest.mark.parametrize('side', ('left', 'right', 'both')) 215 | @pytest.mark.parametrize('scale', (0.2, 0.4, 1, 0)) 216 | def test_simple(self, random_lf, scale, side): 217 | a = RaiseEyebrow(scale=scale, side=side) 218 | new_lf, df = a.perform(random_lf) 219 | 220 | if scale == 0: 221 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 222 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 223 | else: 224 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 225 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 226 | 227 | def test_wrong_side(self): 228 | with pytest.raises(ValueError): 229 | RaiseEyebrow(scale=0.1, side='WRONG') 230 | 231 | 232 | class TestSmile: 233 | """Collection of tests focused on the ``Smile`` action.""" 234 | 235 | @pytest.mark.parametrize('scale', (0.2, 0.4, 1, 0)) 236 | def test_simple(self, random_lf, scale): 237 | a = Smile(scale) 238 | new_lf, df = a.perform(random_lf) 239 | 240 | if scale == 0: 241 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 242 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 243 | else: 244 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 245 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 246 | 247 | 248 | class TestStretchNostrils: 249 | """Collection of tests focused on the ``StrechNostrils`` action.""" 250 | 251 | @pytest.mark.parametrize('scale', (0.2, 0.4, 1, 0)) 252 | def test_simple(self, random_lf, scale): 253 | a = StretchNostrils(scale) 254 | new_lf, df = a.perform(random_lf) 255 | 256 | if scale == 0: 257 | assert np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 258 | assert np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 259 | else: 260 | assert not np.allclose(df.delta_x, np.zeros_like(df.delta_x)) 261 | assert not np.allclose(df.delta_y, np.zeros_like(df.delta_y)) 262 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | """Collection of tests focused on the base module.""" 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pychubby.base import DisplacementField 7 | 8 | 9 | class TestConstructor: 10 | def test_incorrect_input_type(self): 11 | delta_x = "aa" 12 | delta_y = 12 13 | 14 | with pytest.raises(TypeError): 15 | DisplacementField(delta_x, delta_y) 16 | 17 | def test_incorrect_ndim(self): 18 | delta_x = np.ones((2, 3, 4)) 19 | delta_y = np.ones((2, 3, 4)) 20 | 21 | with pytest.raises(ValueError): 22 | DisplacementField(delta_x, delta_y) 23 | 24 | def test_different_shape(self): 25 | delta_x = np.ones((2, 3)) 26 | delta_y = np.ones((2, 4)) 27 | 28 | with pytest.raises(ValueError): 29 | DisplacementField(delta_x, delta_y) 30 | 31 | def test_dtype(self): 32 | 33 | delta_x = np.ones((2, 3)) 34 | delta_y = np.ones((2, 3)) 35 | 36 | df = DisplacementField(delta_x, delta_y) 37 | 38 | assert df.delta_x.dtype == np.float32 39 | assert df.delta_y.dtype == np.float32 40 | 41 | 42 | class TestCall: 43 | """Collection of tests focused on the `__call__` method.""" 44 | 45 | def test_identity(self): 46 | delta = np.zeros((11, 12)) 47 | 48 | df = DisplacementField(delta, delta) 49 | 50 | assert df == df(df) 51 | 52 | 53 | class TestEquality: 54 | """Test that __eq__ works.""" 55 | 56 | def test_itself(self): 57 | delta_x = np.ones((10, 12)) * 1.9 58 | delta_y = np.ones((10, 12)) * 1.2 59 | 60 | df = DisplacementField(delta_x, delta_y) 61 | 62 | assert df == df 63 | 64 | 65 | class TestGenerate: 66 | """Tests focused on the `generate` class method.""" 67 | 68 | def test_incorrect_input(self): 69 | new_points = [1, 12] 70 | old_points = "a" 71 | 72 | with pytest.raises(TypeError): 73 | DisplacementField.generate((4, 5), old_points, new_points) 74 | 75 | def test_incorrect_output_shape(self): 76 | points = np.random.random((10, 2)) 77 | shape = (12, 13, 3) 78 | 79 | with pytest.raises(ValueError): 80 | DisplacementField.generate(shape, points, points) 81 | 82 | def test_incorrect_input_shape(self): 83 | new_points = np.array([[1, 1], [2, 2]]) 84 | old_points = np.array([[1, 1], [2, 2], [3, 3]]) 85 | 86 | with pytest.raises(ValueError): 87 | DisplacementField.generate((10, 11), old_points, new_points) 88 | 89 | @pytest.mark.parametrize( 90 | "interpolation_kwargs", 91 | [ 92 | {"function": "cubic"}, 93 | {"function": "gaussian"}, 94 | {"function": "inverse"}, 95 | {"function": "linear"}, 96 | {"function": "multiquadric"}, 97 | {"function": "quintic"}, 98 | {"function": "thin_plate"}, 99 | {}, 100 | ], 101 | ) 102 | @pytest.mark.parametrize( 103 | "anchor_corners", [True, False], ids=["do_anchor", "dont_anchor"] 104 | ) 105 | def test_identity(self, anchor_corners, interpolation_kwargs): 106 | """Specifying identical old and new points leads to identity.""" 107 | shape = (12, 13) 108 | old_points = np.array([[1, 8], [10, 10], [5, 2]]) 109 | new_points = old_points 110 | df = DisplacementField.generate( 111 | shape, 112 | old_points, 113 | new_points, 114 | anchor_corners=anchor_corners, 115 | **interpolation_kwargs 116 | ) 117 | print(df.delta_x.mean()) 118 | assert np.all(df.delta_x == 0) 119 | assert np.all(df.delta_y == 0) 120 | 121 | @pytest.mark.parametrize( 122 | "interpolation_kwargs", 123 | [ 124 | {"function": "cubic"}, 125 | {"function": "gaussian"}, 126 | {"function": "inverse"}, 127 | {"function": "linear"}, 128 | {"function": "multiquadric"}, 129 | {"function": "quintic"}, 130 | {"function": "thin_plate"}, 131 | {}, 132 | ], 133 | ) 134 | @pytest.mark.parametrize( 135 | "anchor_corners", [True, False], ids=["do_anchor", "dont_anchor"] 136 | ) 137 | def test_interpolation_on_nodes(self, anchor_corners, interpolation_kwargs): 138 | """Make sure that the transformation on the landmarks are precise.""" 139 | shape = (20, 30) 140 | old_points = np.array([[7, 7], [7, 14], [14, 7], [14, 14]]) 141 | new_points = old_points.copy() + np.random.randint(-3, 3, size=(4, 2)) 142 | 143 | df = DisplacementField.generate( 144 | shape, 145 | old_points, 146 | new_points, 147 | anchor_corners=anchor_corners, 148 | **interpolation_kwargs 149 | ) 150 | 151 | for new_p, old_p in zip(new_points, old_points): 152 | assert df.delta_x[new_p[1], new_p[0]] == pytest.approx(old_p[0] - new_p[0]) 153 | assert df.delta_y[new_p[1], new_p[0]] == pytest.approx(old_p[1] - new_p[1]) 154 | 155 | 156 | class TestMulAndDiv: 157 | """Tests focus on the __mul__, __truediv__ and __rmul__ dunders.""" 158 | 159 | @pytest.mark.parametrize('inp', ['a', [1, 2]]) 160 | def test_incorrect_type(self, inp): 161 | delta_x = np.ones((10, 12)) * 3 162 | delta_y = np.ones((10, 12)) * 2 163 | df = DisplacementField(delta_x, delta_y) 164 | 165 | with pytest.raises(TypeError): 166 | df * inp 167 | 168 | with pytest.raises(TypeError): 169 | inp * df 170 | 171 | with pytest.raises(TypeError): 172 | df / inp 173 | 174 | def test_works(self): 175 | delta_x = np.ones((10, 12)) * 3 176 | delta_y = np.ones((10, 12)) * 2 177 | 178 | delta_x_true = np.ones((10, 12)) * 6 179 | delta_y_true = np.ones((10, 12)) * 4 180 | 181 | df = DisplacementField(delta_x, delta_y) 182 | df_true = DisplacementField(delta_x_true, delta_y_true) 183 | 184 | assert df * 2 == df_true 185 | assert 2 * df == df_true 186 | assert df_true / 2 == df 187 | 188 | 189 | class TestProperties: 190 | def test_is_valid(self): 191 | 192 | delta_x = np.ones((2, 3)) 193 | delta_y = np.ones((2, 3)) 194 | delta_y_inv_1 = np.ones((2, 3)) 195 | delta_y_inv_1[0, 1] = np.inf 196 | delta_y_inv_2 = np.ones((2, 3)) 197 | delta_y_inv_2[0, 1] = np.nan 198 | 199 | df_val = DisplacementField(delta_x, delta_y) 200 | df_inv_1 = DisplacementField(delta_x, delta_y_inv_1) 201 | df_inv_2 = DisplacementField(delta_x, delta_y_inv_2) 202 | 203 | assert df_val.is_valid 204 | assert not df_inv_1.is_valid 205 | assert not df_inv_2.is_valid 206 | 207 | def test_norm(self): 208 | 209 | shape = (2, 3) 210 | delta_x = np.ones(shape) * 3 211 | delta_y = np.ones(shape) * 4 212 | 213 | df = DisplacementField(delta_x, delta_y) 214 | 215 | assert np.allclose(df.norm, np.ones(shape) * 5) 216 | 217 | def test_transformation(self): 218 | 219 | delta_x = np.zeros((2, 3)) 220 | delta_y = np.zeros((2, 3)) 221 | 222 | transformation_x = np.array([[0, 1, 2], 223 | [0, 1, 2]]) 224 | transformation_y = np.array([[0, 0, 0], 225 | [1, 1, 1]]) 226 | 227 | df = DisplacementField(delta_x, delta_y) 228 | 229 | tf_x, tf_y = df.transformation 230 | 231 | assert np.all(tf_x == transformation_x) 232 | assert np.all(tf_y == transformation_y) 233 | 234 | 235 | class TestWarp: 236 | """Collection of tests focused on the "warp" method.""" 237 | 238 | @pytest.mark.parametrize('order', [0, 1, 2]) 239 | def test_identity_transformation(self, face_img, order): 240 | shape = face_img.shape[:2] 241 | delta_x = np.zeros(shape) 242 | delta_y = np.zeros(shape) 243 | 244 | df = DisplacementField(delta_x, delta_y) 245 | warped_img = df.warp(face_img, order) 246 | 247 | assert np.allclose(warped_img, face_img) 248 | assert warped_img.dtype == face_img.dtype 249 | -------------------------------------------------------------------------------- /tests/test_detect.py: -------------------------------------------------------------------------------- 1 | """Collection of tests focused on the `detect` module.""" 2 | from collections import namedtuple 3 | from unittest.mock import Mock 4 | import math 5 | import sys 6 | 7 | import dlib 8 | import numpy as np 9 | import pytest 10 | 11 | import pychubby.detect 12 | from pychubby.detect import LANDMARK_NAMES, LandmarkFace, LandmarkFaces, face_rectangle, landmarks_68 13 | 14 | 15 | PYTHON_VERSION = sys.version_info[0] + 0.1 * sys.version_info[1] 16 | 17 | 18 | class TestLandmarkNames: 19 | """Tests focused on the `LANDMARK_NAMES` dictionary.""" 20 | def test_unique(self): 21 | assert len(set(LANDMARK_NAMES.keys())) == 68 22 | assert len(set(LANDMARK_NAMES.values())) == 68 23 | 24 | 25 | class TestFaceRectangle: 26 | """Tests of the `face_rectangle` function.""" 27 | 28 | def test_incorrect_input(self): 29 | with pytest.raises(TypeError): 30 | face_rectangle('123') 31 | 32 | def test_output_face(self, face_img): 33 | res, _ = face_rectangle(face_img) 34 | assert isinstance(res, list) 35 | assert len(res) == 1 36 | 37 | def test_output_blank(self, blank_img): 38 | res, _ = face_rectangle(blank_img) 39 | assert isinstance(res, list) 40 | assert len(res) == 0 41 | 42 | def test_output_faces(self, faces_img): 43 | res, _ = face_rectangle(faces_img) 44 | assert isinstance(res, list) 45 | assert len(res) == 2 46 | 47 | 48 | class TestLandmarks68: 49 | """Tests of the `landmarks_68` function.""" 50 | @pytest.fixture() 51 | def dlib_rectangle(self): 52 | return Mock(spec=dlib.rectangle) 53 | 54 | def test_incorrect_input_model(self, face_img, dlib_rectangle, tmp_path, monkeypatch): 55 | with pytest.raises(IOError): 56 | lm_points, original = landmarks_68(face_img, dlib_rectangle, tmp_path / 'fake_model.dat') 57 | # nasty hack 58 | monkeypatch.setattr('pychubby.detect.CACHE_FOLDER', tmp_path) 59 | monkeypatch.setattr('pychubby.detect.get_pretrained_68', lambda *x: None) 60 | 61 | with pytest.raises(IOError): 62 | lm_points, original = pychubby.detect.landmarks_68(face_img, dlib_rectangle) 63 | 64 | def test_correct_output(self, face_img, dlib_rectangle, tmp_path, monkeypatch): 65 | 66 | fake_model = tmp_path / 'fake_model.dat' 67 | fake_model.touch() 68 | 69 | def fake_shape_predictor(model_path): 70 | trained_model = Mock() 71 | Point = namedtuple('Point', ['x', 'y']) 72 | trained_model.parts = Mock(return_value=68 * [Point(0, 0)]) 73 | dlib_predictor = Mock(return_value=trained_model) 74 | 75 | return dlib_predictor 76 | 77 | monkeypatch.setattr('dlib.shape_predictor', fake_shape_predictor) 78 | 79 | lm_points, original = landmarks_68(face_img, dlib_rectangle, fake_model) 80 | 81 | assert isinstance(lm_points, np.ndarray) 82 | assert lm_points.shape == (68, 2) 83 | 84 | 85 | class TestLandmarkFaceAngle: 86 | """Collection of tests focused on the `angle` method.""" 87 | 88 | def test_identical(self): 89 | lf = LandmarkFace(np.random.random((68, 2)), np.zeros((12, 13))) 90 | 91 | for i in range(68): 92 | for j in range(68): 93 | ref_vector = lf.points[j] - lf.points[i] 94 | assert lf.angle(i, j, reference_vector=ref_vector) == 0 95 | 96 | def test_rad2deg(self): 97 | lf = LandmarkFace(np.random.random((68, 2)), np.zeros((12, 13))) 98 | 99 | for i in range(68): 100 | for j in range(68): 101 | res_rad = lf.angle(i, j, use_radians=True) 102 | res_deg = lf.angle(i, j) 103 | assert math.degrees(res_rad) == res_deg 104 | 105 | 106 | class TestLandmarkFaceEssentials: 107 | """Tests focused on attributes and properties of the LandmarkFace class.""" 108 | 109 | def test_constructor_wrong_input(self): 110 | 111 | img = np.zeros((10, 11)) 112 | points = np.random.random((12, 2)) 113 | 114 | with pytest.raises(ValueError): 115 | LandmarkFace(points, img) 116 | 117 | def test_duplicate_landmarks(self): 118 | img = np.zeros((10, 11)) 119 | points = np.random.random((67, 2)) 120 | points = np.vstack([points, np.array([points[-1]])]) 121 | 122 | with pytest.raises(ValueError): 123 | LandmarkFace(points, img) 124 | 125 | 126 | class TestLandmarkFaceEstimate: 127 | """Tests focused on the class method `estimate` of the LandmarkFace.""" 128 | 129 | def test_overall(self, monkeypatch): 130 | img = np.random.random((10, 11)) 131 | 132 | monkeypatch.setattr('pychubby.detect.face_rectangle', 133 | lambda *args, **kwargs: ([None], [None])) 134 | 135 | monkeypatch.setattr('pychubby.detect.landmarks_68', 136 | lambda *args: (np.random.random((68, 2)), None)) 137 | 138 | lf = LandmarkFace.estimate(img) 139 | 140 | assert isinstance(lf, LandmarkFace) 141 | assert lf.points.shape == (68, 2) 142 | assert lf.img.shape == (10, 11) 143 | 144 | def test_no_faces(self, monkeypatch): 145 | monkeypatch.setattr('pychubby.detect.face_rectangle', 146 | lambda *args, **kwargs: ([], [])) 147 | img = np.random.random((10, 11)) 148 | 149 | with pytest.raises(ValueError): 150 | LandmarkFace.estimate(img) 151 | 152 | def test_multiple_faces(self, monkeypatch): 153 | img = np.random.random((10, 11)) 154 | 155 | monkeypatch.setattr('pychubby.detect.face_rectangle', 156 | lambda *args, **kwargs: (2 * [None], 2 * [None])) 157 | 158 | monkeypatch.setattr('pychubby.detect.landmarks_68', 159 | lambda *args: (np.random.random((68, 2)), None)) 160 | 161 | with pytest.raises(ValueError): 162 | LandmarkFace.estimate(img, allow_multiple=False) 163 | 164 | lfs = LandmarkFace.estimate(img, allow_multiple=True) 165 | 166 | assert isinstance(lfs, LandmarkFaces) 167 | assert len(lfs) == 2 168 | 169 | # only feed invalid, empty entry for LandmarkFaces constructor 170 | monkeypatch.setattr('pychubby.detect.landmarks_68', 171 | lambda *args: (np.zeros((68, 2)), None)) 172 | 173 | with pytest.raises(ValueError): 174 | LandmarkFace.estimate(img, allow_multiple=True) 175 | 176 | 177 | class TestLandmakrFaceEuclideanDistance: 178 | """Collection of tests focused on the `euclidean_distance` method.""" 179 | 180 | def test_identical(self): 181 | lf = LandmarkFace(np.random.random((68, 2)), np.zeros((12, 13))) 182 | 183 | for lix in range(68): 184 | assert lf.euclidean_distance(lix, lix) == 0 185 | 186 | def test_precise_value(self): 187 | points = np.random.random((68, 2)) 188 | points[0] = [2, 3] 189 | points[1] = [5, 7] 190 | 191 | lf = LandmarkFace(points, np.zeros((12, 13))) 192 | 193 | assert lf.euclidean_distance(0, 1) == 5 194 | assert lf.euclidean_distance(1, 0) == 5 195 | 196 | 197 | class TestLandmarkFaceGetItem: 198 | """Collection of tests focused on the `__get_item__` method.""" 199 | 200 | def test_with_int(self): 201 | random_state = 2 202 | np.random.seed(random_state) 203 | 204 | points = np.random.random((68, 2)) 205 | lf = LandmarkFace(points, np.zeros((12, 13))) 206 | 207 | # one by one 208 | for i in range(68): 209 | assert np.allclose(lf[i], points[i]) 210 | # random list of indices 211 | ixs = np.random.randint(0, 68, size=10) 212 | 213 | assert np.allclose(lf[ixs], points[ixs]) 214 | assert np.allclose(lf[[x.item() for x in ixs]], points[ixs]) 215 | 216 | def test_with_str(self): 217 | random_state = 2 218 | np.random.seed(random_state) 219 | 220 | ix2name = {v: k for k, v in LANDMARK_NAMES.items()} 221 | 222 | points = np.random.random((68, 2)) 223 | lf = LandmarkFace(points, np.zeros((12, 13))) 224 | 225 | # one by one 226 | for i in range(68): 227 | assert np.allclose(lf[ix2name[i]], points[i]) 228 | # random list of indices 229 | ixs = np.random.randint(0, 68, size=10) 230 | strings = [ix2name[x] for x in ixs] 231 | 232 | assert np.allclose(lf[strings], points[ixs]) 233 | 234 | def test_incorrect_input(self): 235 | points = np.random.random((68, 2)) 236 | lf = LandmarkFace(points, np.zeros((12, 13))) 237 | 238 | with pytest.raises(TypeError): 239 | lf[(1, 2)] 240 | 241 | with pytest.raises(TypeError): 242 | lf[[32.1]] 243 | 244 | with pytest.raises(ValueError): 245 | lf[np.zeros((2, 2))] 246 | 247 | 248 | class TestLandmarkFacePlot: 249 | def test_plot(self, monkeypatch): 250 | mock = Mock() 251 | 252 | monkeypatch.setattr('pychubby.detect.plt', mock) 253 | 254 | lf = LandmarkFace(np.random.random((68, 2)), np.random.random((12, 13))) 255 | 256 | lf.plot() 257 | 258 | if PYTHON_VERSION > 3.5: 259 | mock.figure.assert_called() 260 | mock.scatter.assert_called() 261 | mock.imshow.assert_called() 262 | 263 | 264 | @pytest.fixture() 265 | def lf(): 266 | points = np.random.random((68, 2)) 267 | return LandmarkFace(points, np.zeros((12, 13))) 268 | 269 | 270 | class TestLandmarkFacesAll: 271 | """Collection of tests focused on the ``LandmarkFaces`` class.""" 272 | 273 | def test_constructor(self): 274 | with pytest.raises(ValueError): 275 | LandmarkFaces() 276 | 277 | with pytest.raises(TypeError): 278 | LandmarkFaces('a') 279 | 280 | with pytest.raises(ValueError): 281 | points = np.random.random((68, 2)) 282 | lf_1 = LandmarkFace(points, np.zeros((12, 13))) 283 | lf_2 = LandmarkFace(points, np.ones((12, 13))) 284 | LandmarkFaces(lf_1, lf_2) 285 | 286 | def test_length(self, lf): 287 | assert len(LandmarkFaces(lf, lf, lf)) == 3 288 | assert len(LandmarkFaces(lf, lf, lf, lf, lf)) == 5 289 | 290 | def test_getitem(self, lf): 291 | lfs = LandmarkFaces(lf) 292 | 293 | assert np.allclose(lfs[0].points, lf.points) 294 | assert np.allclose(lfs[0].img, lf.img) 295 | 296 | def test_plot(self, lf, monkeypatch): 297 | mock = Mock() 298 | 299 | monkeypatch.setattr('pychubby.detect.plt', mock) 300 | 301 | lfs = LandmarkFaces(lf) 302 | 303 | lfs.plot(show_numbers=True, show_landmarks=True) 304 | 305 | if PYTHON_VERSION > 3.5: 306 | mock.figure.assert_called() 307 | mock.scatter.assert_called() 308 | mock.annotate.assert_called() 309 | mock.imshow.assert_called() 310 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | '''Dummy test just to check whether pytest works.''' 2 | 3 | 4 | def test_dummy(): 5 | assert True 6 | -------------------------------------------------------------------------------- /tests/test_reference.py: -------------------------------------------------------------------------------- 1 | """Tests focused on the reference module.""" 2 | 3 | import numpy as np 4 | 5 | from pychubby.detect import LandmarkFace 6 | from pychubby.reference import DefaultRS 7 | 8 | 9 | class TestDefaultRS: 10 | """Tests focused on the ``DefaultRS``.""" 11 | 12 | def test_all(self): 13 | lf = LandmarkFace(np.random.random((68, 2)), np.zeros((12, 13))) 14 | 15 | rs = DefaultRS() 16 | 17 | rs.estimate(lf) 18 | 19 | random_ref_points = np.random.random((10, 2)) 20 | 21 | assert np.allclose(rs.inp2ref(rs.ref2inp(random_ref_points)), random_ref_points) 22 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Collections of tests focused on the utils.py module""" 2 | 3 | import pytest 4 | 5 | from pychubby.utils import points_to_rectangle_mask 6 | 7 | 8 | class TestPointsToRectangleMask: 9 | """Tests focused on the `points_to_rectangle_mask` method.""" 10 | 11 | def test_wrong_input(self): 12 | shape = (10, 12, 3) 13 | top_left = (2, 3) 14 | bottom_right = (4, 8) 15 | 16 | with pytest.raises(ValueError): 17 | points_to_rectangle_mask(shape, top_left, bottom_right) 18 | 19 | @pytest.mark.parametrize('coords', [((2, 3), (4, 9)), 20 | ((4, 4), (9, 10))]) 21 | def test_output_shape_and_count(self, coords): 22 | 23 | shape = (13, 15) 24 | top_left, bottom_right = coords 25 | 26 | out = points_to_rectangle_mask(shape, top_left, bottom_right, width=1) 27 | 28 | assert out.shape == shape 29 | assert out.sum() == -4 + 2 * (3 + bottom_right[0] - top_left[0]) + 2 * (3 + bottom_right[1] - top_left[1]) 30 | -------------------------------------------------------------------------------- /tests/test_visualization.py: -------------------------------------------------------------------------------- 1 | """Collection of tests focused on the `visualization` module.""" 2 | 3 | import numpy as np 4 | from matplotlib.animation import ArtistAnimation 5 | 6 | from pychubby.base import DisplacementField 7 | from pychubby.visualization import create_animation 8 | 9 | 10 | class TestCreateAnimation: 11 | """Collection of tests focused on the `create_animation` function.""" 12 | 13 | def test_overall(self, face_img): 14 | shape = (10, 11) 15 | 16 | delta_x = np.random.random(shape) 17 | delta_y = np.random.random(shape) 18 | 19 | df = DisplacementField(delta_x, delta_y) 20 | ani = create_animation(df, face_img, fps=2, n_seconds=1) 21 | 22 | assert isinstance(ani, ArtistAnimation) 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,flake8,docstring 3 | 4 | [testenv] 5 | description = test runner 6 | extras = dev 7 | commands = 8 | python --version 9 | pytest 10 | 11 | [testenv:flake8] 12 | description = style checker 13 | basepython = python3.6 14 | skip_install = true 15 | deps = flake8 16 | commands = 17 | python --version 18 | flake8 pychubby tests 19 | 20 | [testenv:docstring] 21 | description = docstring checker 22 | basepython = python3.6 23 | skip_install = true 24 | deps = pydocstyle 25 | commands = 26 | python --version 27 | pydocstyle --count pychubby 28 | --------------------------------------------------------------------------------