├── .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 | [](https://travis-ci.com/jankrepl/pychubby)
2 | [](https://codecov.io/gh/jankrepl/pychubby)
3 | [](https://badge.fury.io/py/pychubby)
4 | [](https://pychubby.readthedocs.io/en/latest/?badge=latest)
5 | 
6 |
7 | # PyChubby
8 | **Tool for automated face warping**
9 |
10 | 
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 |
--------------------------------------------------------------------------------