Repository: smartcrop/smartcrop.py (language: python)

Issue:
Add pyproject.toml
Be inspired by my other projects ...
Idea : Compile using mypyc (thanks to type hints)



Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..5170e31
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,16 @@
+[flake8]
+exclude =
+ignore =
+    D100
+    D101
+    D102
+    D103
+    D105
+    D204
+    D205
+    D400
+    E401
+    N814
+    W503
+max-line-length = 100
+
diff --git a/.gitignore b/.gitignore
index 70da0bd..a9ae238 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
 .tox
 .travis-solo/
 .build-*/
+.coverage
 README.html
 __pycache__
 build
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..86d1853
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,517 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-whitelist=
+
+# Add files or directories to the blacklist. They should be base names, not
+# paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the blacklist. The
+# regex matches against base names, not paths.
+
+# Django migrations e.g. 0001_initial.py
+
+ignore-patterns=
+        \d{4}_\S+\.py
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=0
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Specify a configuration file.
+#rcfile=
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=
+        fixme,                           # Cannot improve anything at once
+        logging-format-interpolation,    # Its overkill for now
+        logging-fstring-interpolation,   # Its overkill for now
+        missing-class-docstring,         # FIXME enable this sometime
+        missing-function-docstring,      # FIXME enable this sometime
+        missing-module-docstring,        # FIXME enable this sometime
+        multiple-imports,                # Its David Fischer style
+        useless-object-inheritance,      # Explicit is better than implicit https://stackoverflow.com/questions/1238606
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'error', 'warning', 'refactor', and 'convention'
+# which contain the number of messages in each category, as well as 'statement'
+# which is the total number of statements analyzed. This score is used by the
+# global evaluation report (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style.
+#class-attribute-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=e,
+           f,
+           i,
+           j,
+           k,
+           r,
+           g,
+           b,
+           x,
+           y,
+           z,
+           df,
+           ex,
+           tz,
+           _
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style.
+#variable-rgx=
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )?<?https?://\S+>?$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[LOGGING]
+
+# Format style used to check logging format string. `old` means using %
+# formatting, `new` is for `{}` formatting,and `fstr` is for f-strings.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
+
+
+[SIMILARITIES]
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+# Ignore imports when computing similarities.
+ignore-imports=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the python-enchant package.
+spelling-dict=
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether the implicit-str-concat-in-sequence should
+# generate a warning on implicit string concatenation in sequences defined over
+# several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[CLASSES]
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[DESIGN]
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=optparse,tkinter.tix
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled).
+ext-import-graph=
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled).
+import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+                       Exception
diff --git a/.travis.yml b/.travis.yml
index 72bf64a..2ffa109 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,10 +16,13 @@ env:
   - PILLOW=9.*
   - PILLOW=10.*
 install:
-  - pip install pytest Pillow==$PILLOW
-  - pip install -e .
+  - travis_retry sudo apt-get update -qq
+  - travis_retry sudo apt-get install -qq libcairo2 libcairo2-dev libexiv2-dev libgexiv2-dev libgirepository1.0-dev
+  - travis_retry pip install --upgrade pip setuptools wheel
+  - travis_retry pip install coveralls Pillow==$PILLOW
+  - travis_retry pip install -U -e .[test]
 script:
-  - pytest tests/
+  - pytest
 jobs:
   allow_failures:
     - python: "pypy3"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fda67f9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,89 @@
+[![image](https://travis-ci.com/smartcrop/smartcrop.py.svg?branch=master)](https://travis-ci.com/smartcrop/smartcrop.py)
+
+# smartcrop.py
+
+smartcrop implementation in Python.
+
+smartcrop finds good crops for arbitrary images and crop sizes, based on
+Jonas Wagner\'s [smartcrop.js](https://github.com/jwagner/smartcrop.js).
+
+![image](https://i.gyazo.com/c602d20e025e58f5b15180cd9a262814.jpg){width="50.0%"}
+
+![image](https://i.gyazo.com/5fbc9026202f54b13938de621562ed3d.jpg){width="25.0%"}
+
+![image](https://i.gyazo.com/88ee22ca9e1dd7e9eba7ea96db084e5e.jpg){width="50.0%"}
+
+## Requirements
+
+Python requirements are defined in [pyproject.toml](pyproject.toml).
+
+Moreover the packages such as `PyGObject` and `GExiv2` requires additional system packages that has to be installed using your OS package manager. Please check [requirements.install.apt](requirements.install.apt) for that purpose (tuned on Ubuntu 22.04).
+
+## Installation
+
+``` sh
+sudo apt-get install <...> # list from requirements.install.apt
+pip3 install -U pip setuptools wheel # optional but recommended
+pip3 install smartcrop
+```
+
+or directly from GitHub:
+
+``` sh
+sudo apt-get install <...> # list from requirements.install.apt
+pip3 install -U pip setuptools wheel # optional but recommended
+pip install -e git+git://github.com/hhatto/smartcrop.py.git@master#egg=smartcrop
+```
+
+## Usage
+
+Use the basic command-line tool:
+
+``` sh
+$ smartcroppy --help
+usage: smartcroppy [-h] [--debug-file DEBUG_FILE] [--width WIDTH] [--height HEIGHT] INPUT_FILE OUTPUT_FILE
+
+positional arguments:
+  INPUT_FILE            Input image file
+  OUTPUT_FILE           Output image file
+
+options:
+  -h, --help            show this help message and exit
+  --debug-file DEBUG_FILE
+                        Debugging image file
+  --width WIDTH         Crop width
+  --height HEIGHT       Crop height
+```
+
+Processing an image:
+
+``` sh
+smartcroppy --width 300 --height 300 tests/images/business-work-1.jpg output.jpg --debug-file debug.jpg
+```
+
+Or use the module it in your code (this is a really basic example, see [examples/](examples/) and [smartcrop/cli.py](smartcrop/cli.py) for inspiration):
+
+``` python
+import json
+import sys
+
+import smartcrop
+from PIL import Image
+
+image = Image.open(sys.argv[1])
+cropper = smartcrop.SmartCrop()
+result = cropper.crop(image, 100, 100)
+print(json.dumps(result, indent=2))
+```
+
+## Testing
+
+Install dependencies for testing, then call `pytest`:
+``` sh
+pip3 install smartcrop[test]
+pytest
+```
+
+## License
+
+MIT
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 90497b6..0000000
--- a/README.rst
+++ /dev/null
@@ -1,87 +0,0 @@
-.. image:: https://travis-ci.com/smartcrop/smartcrop.py.svg?branch=master
-    :target: https://travis-ci.com/smartcrop/smartcrop.py
-
-smartcrop.py
-============
-
-smartcrop implementation in Python.
-
-smartcrop finds good crops for arbitrary images and crop sizes, based on Jonas Wagner's `smartcrop.js`_.
-
-.. _`smartcrop.js`: https://github.com/jwagner/smartcrop.js
-
-.. image:: https://i.gyazo.com/c602d20e025e58f5b15180cd9a262814.jpg
-    :width: 50%
-
-.. image:: https://i.gyazo.com/5fbc9026202f54b13938de621562ed3d.jpg
-    :width: 25%
-
-.. image:: https://i.gyazo.com/88ee22ca9e1dd7e9eba7ea96db084e5e.jpg
-    :width: 50%
-
-Requirements
-------------
-
-* numpy
-* PIL or Pillow
-
-Installation
-------------
-
-.. code-block:: sh
-
-    pip3 install smartcrop
-
-or directly from GitHub:
-
-.. code-block:: sh
-
-    pip install -e git+git://github.com/hhatto/smartcrop.py.git@master#egg=smartcrop
-
-Usage
------
-
-Use the basic command-line tool:
-
-.. code-block:: sh
-
-    $ smartcroppy --help
-    usage: smartcroppy [-h] [--debug] [--width WIDTH] [--height HEIGHT]
-                       INPUT_FILE OUTPUT_FILE
-
-    positional arguments:
-      INPUT_FILE       input image file
-      OUTPUT_FILE      output image file
-
-    optional arguments:
-      -h, --help       show this help message and exit
-      --debug          debug mode
-      --width WIDTH    crop width
-      --height HEIGHT  crop height
-
-Processing an image:
-
-.. code-block:: sh
-
-  smartcroppy --width 300 --height 300 tests/images/business-work-1.jpg output.jpg --debug-file debug.jpg
-
-Or use the module it in your code (this is a really basic example):
-
-.. code-block:: python
-
-    import json
-    import sys
-
-    import smartcrop
-    from PIL import Image
-
-    image = Image.open(sys.argv[1])
-
-    sc = smartcrop.SmartCrop()
-    result = sc.crop(image, 100, 100)
-    print(json.dumps(result, indent=2))
-
-License
--------
-
-MIT
diff --git a/examples/use_module.py b/examples/use_module.py
deleted file mode 100755
index 9681137..0000000
--- a/examples/use_module.py
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env python
-
-import sys
-
-import smartcrop
-from PIL import Image
-
-image = Image.open(sys.argv[1])
-
-sc = smartcrop.SmartCrop()
-result = sc.crop(image, 100, 100)
-print(result)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..601e415
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,73 @@
+[build-system]
+requires = ['setuptools', 'setuptools-scm']
+build-backend = 'setuptools.build_meta'
+
+[project]
+name = 'smartcrop'
+version = '0.3.4'
+
+# Metadata
+authors = [
+    {name = 'David Fischer', email = 'david@fisch3r.net'},
+    {name = 'Hideo Hattori', email = 'hhatto.jp@gmail.com'}
+]
+description = 'smartcrop implementation in Python'
+keywords = ['image', 'crop', 'PIL', 'Pillow']
+readme = {file = 'README.md', content-type = "text/markdown"}
+classifiers = [
+    # # https://pypi.python.org/pypi?%3Aaction=list_classifiers
+    'Development Status :: 3 - Alpha',
+    'License :: OSI Approved :: MIT License',
+    'Intended Audience :: Developers',
+    'Operating System :: OS Independent',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: 3',
+    'Topic :: Utilities'
+]
+
+# Requirements
+requires-python = '>=3.9'
+dependencies = [
+    'numpy',
+    'pillow>=4.0.0',
+    'PyGObject',
+    'pytoolbox[imaging]>=14.5.1'
+]
+
+[project.optional-dependencies]
+test = [
+    # Copied from https://github.com/davidfischer-ch/pytoolbox/blob/main/setup.py#L142
+    'coverage>=7.2.7,<8',        # 07-06-2023 Released 29-05-2023
+    'flake8>=6,<7',              # 07-06-2023 Released 23-11-2022
+    'pylint>=2.17.4,<3',         # 07-06-2023 Released 06-05-2023
+    'pytest>=7.3.1,<8',          # 07-06-2023 Released 14-04-2023
+    'pytest-cov>=4.1.0,<5',      # 07-06-2023 Released 24-05-2023
+    'pytest-pylint>=0.19.0,<1',  # 07-06-2023 Released 10-09-2022
+
+    # For MyPy
+    # Based on mypy --install-types
+    'PyGObject-stubs>=2.8.0',
+    'types-Pillow',
+    'types-Pygments',
+    'types-colorama',
+    'types-setuptools'
+
+     # 07-06-2023 Bug still not resolved
+     # Bug https://github.com/tholo/pytest-flake8/issues/87
+     # Fix https://github.com/tholo/pytest-flake8/pull/88/files
+     # 'pytest-flake8'
+]
+
+[project.scripts]
+smartcroppy = 'smartcrop.cli:main'
+
+# ... other project metadata fields as specified in:
+#     https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
+
+# [tool.setuptools-git-versioning]
+# enabled = true
+
+[project.urls]
+homepage = 'https://github.com/smartcrop/smartcrop.py'
+documentation = 'https://github.com/smartcrop/smartcrop.py'
+repository = 'https://github.com/smartcrop/smartcrop.py'
diff --git a/requirements.install.apt b/requirements.install.apt
new file mode 100644
index 0000000..80c3892
--- /dev/null
+++ b/requirements.install.apt
@@ -0,0 +1,9 @@
+# For PyGObject
+# See https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-getting-started
+libcairo2
+libcairo2-dev
+libgirepository1.0-dev
+
+# For GExiv2
+libexiv2-dev
+libgexiv2-dev
diff --git a/setup.py b/setup.py
deleted file mode 100644
index cddd483..0000000
--- a/setup.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""
-    smartcrop.py
-    ~~~~~~~~~~~~
-
-    smartcrop.js implementation in Python
-
-    :license: MIT
-"""
-from setuptools import setup
-
-setup(
-    name='smartcrop',
-    entry_points={
-        'console_scripts': ['smartcroppy=smartcrop:main']
-    },
-    include_package_data=True,
-    install_requires=['numpy', 'Pillow>=4.0.0'],
-    py_modules=['smartcrop'],
-    zip_safe=False,
-
-    # Meta-data for upload to PyPI
-    author='Hideo Hattori',
-    author_email='hhatto.jp@gmail.com',
-    classifiers=[
-        'Development Status :: 3 - Alpha',
-        'License :: OSI Approved :: MIT License',
-        'Intended Audience :: Developers',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3',
-        'Topic :: Utilities'
-    ],
-    description='smartcrop implementation in Python',
-    keywords=['image', 'crop', 'PIL', 'Pillow'],
-    license='MIT',
-    long_description=open('README.rst').read(),
-    platforms='any',
-    url='https://github.com/smartcrop/smartcrop.py',
-    version='0.3.4'
-)
diff --git a/smartcrop/__init__.py b/smartcrop/__init__.py
new file mode 100644
index 0000000..56ba90d
--- /dev/null
+++ b/smartcrop/__init__.py
@@ -0,0 +1,3 @@
+from .library import SmartCrop, saturation, thirds
+
+__all__ = ['SmartCrop', 'saturation', 'thirds']
diff --git a/smartcrop/cli.py b/smartcrop/cli.py
new file mode 100644
index 0000000..92ba714
--- /dev/null
+++ b/smartcrop/cli.py
@@ -0,0 +1,55 @@
+import argparse
+import json
+import sys
+
+from PIL import Image
+from pytoolbox.multimedia import exif
+
+from .library import SmartCrop
+
+
+def parse_argument() -> argparse.Namespace:
+    parser = argparse.ArgumentParser()
+    arg = parser.add_argument
+    arg('inputfile', metavar='INPUT_FILE', help='Input image file')
+    arg('outputfile', metavar='OUTPUT_FILE', help='Output image file')
+    arg('--debug-file', metavar='DEBUG_FILE', help='Debugging image file')
+    arg('--width', type=int, default=100, help='Crop width')
+    arg('--height', type=int, default=100, help='Crop height')
+    return parser.parse_args()
+
+
+def main() -> None:
+    options = parse_argument()
+
+    image = Image.open(options.inputfile)
+
+    # Apply orientation from EXIF metadata
+    metadata = exif.Metadata(options.inputfile)
+    image = image.rotate(metadata.image.rotation, expand=True)
+
+    # Ensure image is in RGB (convert it otherwise)
+    if image.mode not in ('RGB', 'RGBA'):
+        sys.stderr.write(f"{image.mode} convert from mode='{options.inputfile}' to mode='RGB'\n")
+        new_image = Image.new('RGB', image.size)
+        new_image.paste(image)
+        image = new_image
+
+    cropper = SmartCrop()
+    result = cropper.crop(image, width=100, height=int(options.height / options.width * 100))
+
+    box = (
+        result['top_crop']['x'],
+        result['top_crop']['y'],
+        result['top_crop']['width'] + result['top_crop']['x'],
+        result['top_crop']['height'] + result['top_crop']['y']
+    )
+
+    if options.debug_file:
+        analyse_image = result.pop('analyse_image')
+        cropper.debug_crop(analyse_image, result['top_crop']).save(options.debug_file)
+        print(json.dumps(result))
+
+    cropped_image = image.crop(box)
+    cropped_image.thumbnail((options.width, options.height), Image.Resampling.LANCZOS)
+    cropped_image.save(options.outputfile, 'JPEG', quality=90)
diff --git a/smartcrop.py b/smartcrop/library.py
old mode 100755
new mode 100644
similarity index 69%
rename from smartcrop.py
rename to smartcrop/library.py
index 101088d..1f3285a
--- a/smartcrop.py
+++ b/smartcrop/library.py
@@ -1,7 +1,5 @@
-#!/usr/bin/env python
+from __future__ import annotations
 
-import argparse
-import json
 import math
 import sys
 
@@ -10,14 +8,14 @@
 from PIL.ImageFilter import Kernel
 
 
-def saturation(image):
+def saturation(image) -> np.ndarray:
     r, g, b = image.split()
     r, g, b = np.array(r), np.array(g), np.array(b)
     r, g, b = r.astype(float), g.astype(float), b.astype(float)
     maximum = np.maximum(np.maximum(r, g), b)  # [0; 255]
     minimum = np.minimum(np.minimum(r, g), b)  # [0; 255]
-    s = (maximum + minimum) / 255  # [0.0; 1.0]
-    d = (maximum - minimum) / 255  # [0.0; 1.0]
+    s = (maximum + minimum) / 255  # [0.0; 1.0] pylint:disable=invalid-name
+    d = (maximum - minimum) / 255  # [0.0; 1.0] pylint:disable=invalid-name
     d[maximum == minimum] = 0  # if maximum == minimum:
     s[maximum == minimum] = 1  # -> saturation = 0 / 1 = 0
     mask = s > 1
@@ -32,29 +30,29 @@ def thirds(x):
     return max(1 - x * x, 0)
 
 
-class SmartCrop(object):
+class SmartCrop(object):  # pylint:disable=too-many-instance-attributes
 
-    DEFAULT_SKIN_COLOR = [0.78, 0.57, 0.44]
+    DEFAULT_SKIN_COLOR: tuple[float, float, float] = (0.78, 0.57, 0.44)
 
-    def __init__(
+    def __init__(  # pylint:disable=too-many-arguments,too-many-locals
         self,
-        detail_weight=0.2,
-        edge_radius=0.4,
-        edge_weight=-20,
-        outside_importance=-0.5,
-        rule_of_thirds=True,
-        saturation_bias=0.2,
-        saturation_brightness_max=0.9,
-        saturation_brightness_min=0.05,
-        saturation_threshold=0.4,
-        saturation_weight=0.3,
-        score_down_sample=8,
-        skin_bias=0.01,
-        skin_brightness_max=1,
-        skin_brightness_min=0.2,
-        skin_color=None,
-        skin_threshold=0.8,
-        skin_weight=1.8
+        detail_weight: float = 0.2,
+        edge_radius: float = 0.4,
+        edge_weight: float = -20,
+        outside_importance: float = -0.5,
+        rule_of_thirds: bool = True,
+        saturation_bias: float = 0.2,
+        saturation_brightness_max: float = 0.9,
+        saturation_brightness_min: float = 0.05,
+        saturation_threshold: float = 0.4,
+        saturation_weight: float = 0.3,
+        score_down_sample: int = 8,
+        skin_bias: float = 0.01,
+        skin_brightness_max: float = 1,
+        skin_brightness_min: float = 0.2,
+        skin_color: tuple[float, float, float] | None = None,
+        skin_threshold: float = 0.8,
+        skin_weight: float = 1.8
     ):
         self.detail_weight = detail_weight
         self.edge_radius = edge_radius
@@ -74,16 +72,16 @@ def __init__(
         self.skin_threshold = skin_threshold
         self.skin_weight = skin_weight
 
-    def analyse(
+    def analyse(  # pylint:disable=too-many-arguments,too-many-locals
         self,
         image,
-        crop_width,
-        crop_height,
-        max_scale=1,
-        min_scale=0.9,
-        scale_step=0.1,
-        step=8
-    ):
+        crop_width: int,
+        crop_height: int,
+        max_scale: float = 1,
+        min_scale: float = 0.9,
+        scale_step: float = 0.1,
+        step: int = 8
+    ) -> dict:
         """
         Analyze image and return some suggestions of crops (coordinates).
         This implementation / algorithm is really slow for large images.
@@ -130,17 +128,17 @@ def analyse(
 
         return {'analyse_image': analyse_image, 'crops': crops, 'top_crop': top_crop}
 
-    def crop(
+    def crop(  # pylint:disable=too-many-arguments,too-many-locals
         self,
         image,
-        width,
-        height,
-        prescale=True,
-        max_scale=1,
-        min_scale=0.9,
-        scale_step=0.1,
-        step=8
-    ):
+        width: int,
+        height: int,
+        prescale: bool = True,
+        max_scale: float = 1,
+        min_scale: float = 0.9,
+        scale_step: float = 0.1,
+        step: int = 8
+    ) -> dict:
         """Not yet fully cleaned from https://github.com/hhatto/smartcrop.py."""
         scale = min(image.size[0] / width, image.size[1] / height)
         crop_width = int(math.floor(width * scale))
@@ -181,16 +179,16 @@ def crop(
             result['crops'][i] = crop
         return result
 
-    def crops(
+    def crops(  # pylint:disable=too-many-arguments
         self,
         image,
-        crop_width,
-        crop_height,
-        max_scale=1,
-        min_scale=0.9,
-        scale_step=0.1,
-        step=8
-    ):
+        crop_width: int,
+        crop_height: int,
+        max_scale: float = 1,
+        min_scale: float = 0.9,
+        scale_step: float = 0.1,
+        step: int = 8
+    ) -> list[dict]:
         image_width, image_height = image.size
         crops = []
         for scale in (
@@ -200,10 +198,10 @@ def crops(
                 -int(scale_step * 100))
         ):
             for y in range(0, image_height, step):
-                if not (y + crop_height * scale <= image_height):
+                if y + crop_height * scale > image_height:
                     break
                 for x in range(0, image_width, step):
-                    if not (x + crop_width * scale <= image_width):
+                    if x + crop_width * scale > image_width:
                         break
                     crops.append({
                         'x': x,
@@ -215,7 +213,7 @@ def crops(
             raise ValueError(locals())
         return crops
 
-    def debug_crop(self, analyse_image, crop):
+    def debug_crop(self, analyse_image, crop: dict):
         debug_image = analyse_image.copy()
         debug_pixels = debug_image.getdata()
         debug_crop_image = Image.new(
@@ -227,28 +225,31 @@ def debug_crop(self, analyse_image, crop):
             (255, 0, 0, 25)
         )
         ImageDraw.Draw(debug_crop_image).rectangle(
-            ((0, 0), (crop['width'], crop['height'])),
+            (
+                (0, 0),
+                (crop['width'], crop['height'])
+            ),
             outline=(255, 0, 0))
 
         for y in range(analyse_image.size[1]):        # height
             for x in range(analyse_image.size[0]):    # width
-                p = y * analyse_image.size[0] + x
+                index = y * analyse_image.size[0] + x
                 importance = self.importance(crop, x, y)
                 if importance > 0:
                     debug_pixels.putpixel(
                         (x, y),
                         (
-                            debug_pixels[p][0],
-                            int(debug_pixels[p][1] + importance * 32),
-                            debug_pixels[p][2]
+                            debug_pixels[index][0],
+                            int(debug_pixels[index][1] + importance * 32),
+                            debug_pixels[index][2]
                         ))
                 elif importance < 0:
                     debug_pixels.putpixel(
                         (x, y),
                         (
-                            int(debug_pixels[p][0] + importance * -64),
-                            debug_pixels[p][1],
-                            debug_pixels[p][2]
+                            int(debug_pixels[index][0] + importance * -64),
+                            debug_pixels[index][1],
+                            debug_pixels[index][2]
                         ))
         debug_image.paste(debug_crop_image, (crop['x'], crop['y']), debug_crop_image.split()[3])
         return debug_image
@@ -256,7 +257,7 @@ def debug_crop(self, analyse_image, crop):
     def detect_edge(self, cie_image):
         return cie_image.filter(Kernel((3, 3), (0, -1, 0, -1, 4, -1, 0, -1, 0), 1, 1))
 
-    def detect_saturation(self, cie_array, source_image):
+    def detect_saturation(self, cie_array: np.ndarray, source_image):
         threshold = self.saturation_threshold
         saturation_data = saturation(source_image)
         mask = (
@@ -269,13 +270,13 @@ def detect_saturation(self, cie_array, source_image):
 
         return Image.fromarray(saturation_data.astype('uint8'))
 
-    def detect_skin(self, cie_array, source_image):
+    def detect_skin(self, cie_array: np.ndarray, source_image):
         r, g, b = source_image.split()
         r, g, b = np.array(r), np.array(g), np.array(b)
         r, g, b = r.astype(float), g.astype(float), b.astype(float)
-        rd = np.ones_like(r) * -self.skin_color[0]
-        gd = np.ones_like(g) * -self.skin_color[1]
-        bd = np.ones_like(b) * -self.skin_color[2]
+        rd = np.ones_like(r) * -self.skin_color[0]  # pylint:disable=invalid-name
+        gd = np.ones_like(g) * -self.skin_color[1]  # pylint:disable=invalid-name
+        bd = np.ones_like(b) * -self.skin_color[2]  # pylint:disable=invalid-name
 
         mag = np.sqrt(r * r + g * g + b * b)
         mask = ~(abs(mag) < 1e-6)
@@ -294,7 +295,7 @@ def detect_skin(self, cie_array, source_image):
 
         return Image.fromarray(skin_data.astype('uint8'))
 
-    def importance(self, crop, x, y):
+    def importance(self, crop: dict, x: int, y: int) -> float:
         if (
             crop['x'] > x or x >= crop['x'] + crop['width'] or
             crop['y'] > y or y >= crop['y'] + crop['height']
@@ -303,20 +304,21 @@ def importance(self, crop, x, y):
 
         x = (x - crop['x']) / crop['width']
         y = (y - crop['y']) / crop['height']
-        px, py = abs(0.5 - x) * 2, abs(0.5 - y) * 2
+        px, py = abs(0.5 - x) * 2, abs(0.5 - y) * 2  # pylint:disable=invalid-name
 
         # distance from edge
-        dx = max(px - 1 + self.edge_radius, 0)
-        dy = max(py - 1 + self.edge_radius, 0)
-        d = (dx * dx + dy * dy) * self.edge_weight
-        s = 1.41 - math.sqrt(px * px + py * py)
+        dx = max(px - 1 + self.edge_radius, 0)      # pylint:disable=invalid-name
+        dy = max(py - 1 + self.edge_radius, 0)      # pylint:disable=invalid-name
+        d = (dx * dx + dy * dy) * self.edge_weight  # pylint:disable=invalid-name
+        s = 1.41 - math.sqrt(px * px + py * py)     # pylint:disable=invalid-name
 
         if self.rule_of_thirds:
+            # pylint:disable=invalid-name
             s += (max(0, s + d + 0.5) * 1.2) * (thirds(px) + thirds(py))
 
         return s + d
 
-    def score(self, target_image, crop):
+    def score(self, target_image, crop: dict) -> dict:  # pylint:disable=too-many-locals
         score = {
             'detail': 0,
             'saturation': 0,
@@ -333,71 +335,23 @@ def score(self, target_image, crop):
 
         for y in range(0, target_height_down_sample, down_sample):
             for x in range(0, target_width_down_sample, down_sample):
-                p = int(
+                index = int(
                     math.floor(y * inv_down_sample) * target_width +
                     math.floor(x * inv_down_sample)
                 )
                 importance = self.importance(crop, x, y)
-                detail = target_data[p][1] / 255
+                detail = target_data[index][1] / 255
                 score['skin'] += (
-                    target_data[p][0] / 255 *
-                    (detail + self.skin_bias) *
-                    importance
+                    target_data[index][0] / 255 * (detail + self.skin_bias) * importance
                 )
                 score['detail'] += detail * importance
                 score['saturation'] += (
-                    target_data[p][2] / 255 *
-                    (detail + self.saturation_bias) *
-                    importance
+                    target_data[index][2] / 255 * (detail + self.saturation_bias) * importance
                 )
         score['total'] = (
             score['detail'] * self.detail_weight +
             score['skin'] * self.skin_weight +
             score['saturation'] * self.saturation_weight
         ) / (crop['width'] * crop['height'])
-        return score
-
-
-def parse_argument():
-    parser = argparse.ArgumentParser()
-    parser.add_argument('inputfile', metavar='INPUT_FILE', help='Input image file')
-    parser.add_argument('outputfile', metavar='OUTPUT_FILE', help='Output image file')
-    parser.add_argument('--debug-file', metavar='DEBUG_FILE', help='Debugging image file')
-    parser.add_argument('--width', dest='width', type=int, default=100, help='Crop width')
-    parser.add_argument('--height', dest='height', type=int, default=100, help='Crop height')
-    return parser.parse_args()
-
 
-def main():
-    options = parse_argument()
-
-    image = Image.open(options.inputfile)
-    if image.mode != 'RGB' and image.mode != 'RGBA':
-        sys.stderr.write("{1} convert from mode='{0}' to mode='RGB'\n".format(
-            image.mode, options.inputfile))
-        new_image = Image.new('RGB', image.size)
-        new_image.paste(image)
-        image = new_image
-
-    cropper = SmartCrop()
-    result = cropper.crop(image, width=100, height=int(options.height / options.width * 100))
-
-    box = (
-        result['top_crop']['x'],
-        result['top_crop']['y'],
-        result['top_crop']['width'] + result['top_crop']['x'],
-        result['top_crop']['height'] + result['top_crop']['y']
-    )
-
-    if options.debug_file:
-        analyse_image = result.pop('analyse_image')
-        cropper.debug_crop(analyse_image, result['top_crop']).save(options.debug_file)
-        print(json.dumps(result))
-
-    cropped_image = image.crop(box)
-    cropped_image.thumbnail((options.width, options.height), Image.Resampling.LANCZOS)
-    cropped_image.save(options.outputfile, 'JPEG', quality=90)
-
-
-if __name__ == '__main__':
-    main()
+        return score
diff --git a/smartcrop_v0_2_1.py b/smartcrop_v0_2_1.py
deleted file mode 100755
index a73c2f1..0000000
--- a/smartcrop_v0_2_1.py
+++ /dev/null
@@ -1,345 +0,0 @@
-#!/usr/bin/env python
-import argparse
-import copy
-import json
-import math
-import sys
-
-from PIL import Image, ImageDraw, ImageMath
-from PIL.ImageFilter import Kernel
-
-import numpy as np
-
-# Laplace kernel for edge detection
-LAPLACE_KERNEL = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]])
-
-
-def conv2d(a, f):
-    """Source: https://stackoverflow.com/a/43087771/1211607 """
-    s = f.shape + tuple(np.subtract(a.shape, f.shape) + 1)
-    strd = np.lib.stride_tricks.as_strided
-    subM = strd(a, shape = s, strides = a.strides * 2, writeable=False)
-    return np.einsum('ij,ijkl->kl', f, subM)
-
-
-def edge_detection(image):
-    edges = conv2d(np.pad(image, 1, 'edge'), LAPLACE_KERNEL)
-    edges = np.clip(edges, 0, 255).astype(np.uint8)
-    return edges
-
-
-def importance_map(width, height, rule_of_thirds=True, edge_radius=0.4, edge_weight=-20):
-    """Draw a importance map
-
-    Regions which are bright are considered important, dark regions unimportant
-    """
-    lx = np.linspace(0, 1, width)
-    ly = np.linspace(0, 1, height)
-
-    # Importance Implementation of smartcrop
-    px = np.abs(0.5 - lx) * 2
-    py = np.abs(0.5 - ly) * 2
-    dx = px - 1 + edge_radius
-    dy = py - 1 + edge_radius
-    dx[dx < 0] = 0
-    dy[dy < 0] = 0
-
-    PX, PY = np.meshgrid(px, py, sparse=True)
-    DX, DY = np.meshgrid(dx, dy, sparse=True)
-
-    d = (DX**2 + DY**2) * edge_weight
-    s = 1.41 - np.sqrt(PX**2 + PY**2)
-
-    if rule_of_thirds:
-        a = ((lx * 2 - 1 / 3) % 2 * 0.5 - 0.5) * 16
-        a = 1 - a**2
-        a[a < 0] = 0
-        b = ((ly * 2 - 1 / 3) % 2 * 0.5 - 0.5) * 16
-        b = 1 - b**2
-        b[b < 0] = 0
-
-        thirds = (a[:,np.newaxis].T + b[:,np.newaxis])
-        thirds += np.flip(thirds)
-
-        # Apply rule of thirds
-        t = (s + d + 0.5) * 1.2
-        t[t<0] = 0
-        s += t * thirds
-
-    return s + d
-
-
-class SmartCrop(object):
-
-    DEFAULT_SKIN_COLOR = [0.78, 0.57, 0.44]
-
-    def __init__(
-        self,
-        detail_weight=0.2,
-        edge_radius=0.4,
-        edge_weight=-20,
-        outside_importance=-0.5,
-        rule_of_thirds=True,
-        saturation_bias=0.2,
-        saturation_brightness_max=0.9,
-        saturation_brightness_min=0.05,
-        saturation_threshold=0.4,
-        saturation_weight=0.3,
-        score_down_sample=8,
-        skin_bias=0.01,
-        skin_brightness_max=1,
-        skin_brightness_min=0.2,
-        skin_color=None,
-        skin_threshold=0.8,
-        skin_weight=1.8,
-    ):
-        self.detail_weight = detail_weight
-        self.edge_radius = edge_radius
-        self.edge_weight = edge_weight
-        self.outside_importance = outside_importance
-        self.rule_of_thirds = rule_of_thirds
-        self.saturation_bias = saturation_bias
-        self.saturation_brightness_max = saturation_brightness_max
-        self.saturation_brightness_min = saturation_brightness_min
-        self.saturation_threshold = saturation_threshold
-        self.saturation_weight = saturation_weight
-        self.score_down_sample = score_down_sample
-        self.skin_bias = skin_bias
-        self.skin_brightness_max = skin_brightness_max
-        self.skin_brightness_min = skin_brightness_min
-        self.skin_color = skin_color or self.DEFAULT_SKIN_COLOR
-        self.skin_threshold = skin_threshold
-        self.skin_weight = skin_weight
-
-    def analyse(self, image, crop_width, crop_height, debug=False,
-                max_scale=1, min_scale=0.8, scale_step=0.1):
-        """
-        Analyze image and return some suggestions of crops (coordinates).
-        This implementation / algorithm is really slow for large images.
-        Use `crop()` which is pre-scaling the image before analyzing it.
-        """
-        # It's not realy grayscale, we use the CIE transformation here 
-        self._gray = np.array(image.convert('L', (0.2126, 0.7152, 0.0722, 0))) / 255.0
-        self._hsv = image.convert('HSV')
-
-        self.img_edges = edge_detection(self._gray * 255)
-        self.detect_skin(image)
-        self.detect_saturation(image)
-
-        output_image = Image.merge('RGB', [
-            Image.fromarray(self.img_skin),
-            Image.fromarray(self.img_edges),
-            Image.fromarray(self.img_saturation)
-        ])
-
-        # Delete images which are no longer required
-        del self.img_skin
-        del self.img_edges
-        del self.img_saturation
-
-        if debug:
-            skin, edge, sat = output_image.split()
-            edge.save('debug/out_edge.jpg')
-            sat.save('debug/out_sat.jpg')
-            skin.save('debug/out_skin.jpg')
-
-        # Scale down image for the scoring
-        score_output_image = output_image.copy()
-        score_output_image.thumbnail(
-            (
-                image.size[0] / self.score_down_sample,
-                image.size[1] / self.score_down_sample
-            ),
-            Image.Resampling.LANCZOS)
-
-        if debug:
-            score_output_image.save('debug/score_image.jpg')
-
-        # Build the arrays for the scoring
-        skin, detail, saturation = score_output_image.split()
-        skin, detail, saturation = map(np.array, (skin, detail, saturation))
-        skin = skin / 255.0
-        detail = detail / 255.0
-        saturation = saturation / 255.0
-
-        # Calculate the score for each pixel
-        self._img_total_score = (detail * self.detail_weight) +\
-            (saturation * (detail + self.saturation_bias) * self.saturation_weight) +\
-            (skin * (detail + self.skin_bias) * self.skin_weight)
-
-        crops = []
-        top_score = -sys.maxsize
-        top_crop = None
-        for scale in np.arange(max_scale, min_scale - scale_step, -scale_step):
-            # Create a importance map for the given scale
-            mp = importance_map(int(crop_width * scale / self.score_down_sample), 
-                                int(crop_height * scale / self.score_down_sample), 
-                                self.edge_radius, self.edge_weight)
-                                
-            # Convolve with the score image
-            tst = conv2d(self._img_total_score, mp)
-            # Extract the location of the maximal score    
-            argmax = np.unravel_index(np.argmax(tst), tst.shape)
-            # Get the score, adjust the score by the scale
-            score = tst[argmax[0],argmax[1]] / scale
-            # Build the crop information
-            crop = {
-                'scale': scale, 
-                'score': score,
-                'width': crop_width * scale,
-                'height': crop_height * scale,
-                'x': argmax[1] * scale * self.score_down_sample,
-                'y': argmax[0] * scale * self.score_down_sample
-            }
-            crops.append(crop)
-            if top_score < score:
-                top_score = score
-                top_crop = crop
-
-        return {'crops': crops, 'top_crop': top_crop}
-
-    def crop(self, image, width, height, debug=False, prescale=True,
-             max_scale=1, min_scale=0.9, scale_step=0.1):
-        """Not yet fully cleaned from https://github.com/hhatto/smartcrop.py."""
-        scale = min(image.size[0] / width, image.size[1] / height)
-        crop_width = int(math.floor(width * scale))
-        crop_height = int(math.floor(height * scale))
-        # img = 100x100, width = 95x95, scale = 100/95, 1/scale > min
-        # don't set minscale smaller than 1/scale
-        # -> don't pick crops that need upscaling
-        min_scale = min(max_scale, max(1 / scale, min_scale))
-
-        prescale_size = 1
-        if prescale:
-            prescale_size = 1 / scale / min_scale
-            if prescale_size < 1:
-                image = image.copy()
-                image.thumbnail(
-                    (int(image.size[0] * prescale_size), int(image.size[1] * prescale_size)),
-                    Image.Resampling.LANCZOS)
-                crop_width = int(math.floor(crop_width * prescale_size))
-                crop_height = int(math.floor(crop_height * prescale_size))
-            else:
-                prescale_size = 1
-
-        if debug:
-            image.save('debug/prescaled.jpg')
-
-        result = self.analyse(
-            image,
-            crop_width=crop_width,
-            crop_height=crop_height,
-            min_scale=min_scale,
-            max_scale=max_scale,
-            scale_step=scale_step,
-            debug=debug)
-
-        for i in range(len(result['crops'])):
-            crop = result['crops'][i]
-            crop['x'] = int(crop['x'] / prescale_size)
-            crop['y'] = int(crop['y'] / prescale_size)
-            crop['width'] = int(crop['width'] / prescale_size)
-            crop['height'] = int(crop['height'] / prescale_size)
-        return result
-
-
-    def detect_saturation(self, source_image):
-        brightness_max = self.saturation_brightness_max
-        brightness_min = self.saturation_brightness_min
-        threshold = self.saturation_threshold
-
-        gray = self._gray
-        sat = np.array(self._hsv.split()[1]) / 255
-
-        sat = (sat - threshold) * (255 / (1 - threshold))
-        mask = (sat < 0) | ~((gray >= brightness_min) & (gray <= brightness_max))
-        sat[mask] = 0
-
-        self.img_saturation = sat.astype('uint8')
-
-    def detect_skin(self, source_image):
-        brightness_max = self.skin_brightness_max
-        brightness_min = self.skin_brightness_min
-        threshold = self.skin_threshold
-
-        r, g, b = source_image.split()
-        r, g, b = np.array(r, float), np.array(g, float), np.array(b, float)
-        mag = np.sqrt(r * r + g * g + b * b)
-        rd = np.ones_like(r) * -self.skin_color[0]
-        gd = np.ones_like(g) * -self.skin_color[1]        
-        bd = np.ones_like(b) * -self.skin_color[2]
-
-        mask = ~(abs(mag) < 1e-6)
-        rd[mask] = r[mask] / mag[mask] - self.skin_color[0]
-        gd[mask] = g[mask] / mag[mask] - self.skin_color[1]
-        bd[mask] = b[mask] / mag[mask] - self.skin_color[2]
-
-        skin = 1 - np.sqrt(rd * rd + gd * gd + bd * bd)
-        skinimg = (skin - threshold) * (255 / (1 - threshold))
-        mask = (skin > threshold) & (self._gray >= brightness_min) & (self._gray <= brightness_max)
-        skinimg[~mask] = 0
-        self.img_skin = skinimg.astype('uint8')
-
-
-def parse_argument():
-    parser = argparse.ArgumentParser()
-    parser.add_argument('inputfile', metavar='INPUT_FILE', help='input image file')
-    parser.add_argument('outputfile', metavar='OUTPUT_FILE', help='output image file')
-    parser.add_argument('--debug', dest='debug', action='store_true', help='debug mode')
-    parser.add_argument('--width', dest='width', type=int, default=100, help='crop width')
-    parser.add_argument('--height', dest='height', type=int, default=100, help='crop height')
-    return parser.parse_args()
-
-
-def main():
-    import time
-    options = parse_argument()
-
-    image = Image.open(options.inputfile)
-    if image.mode != 'RGB' and image.mode != 'RGBA':
-        sys.stderr.write("{1} convert from mode='{0}' to mode='RGB'\n".format(
-            image.mode, options.inputfile))
-        new_image = Image.new('RGB', image.size)
-        new_image.paste(image)
-        image = new_image
-
-    start = time.time()
-    result = SmartCrop().crop(
-        image,
-        width=options.width,
-        height=options.height,
-        debug=options.debug,
-        min_scale=0.8,
-        max_scale=1.0)
-    
-    if options.debug:
-        pass
-        # print(json.dumps(result))
-    box = (
-        result['top_crop']['x'],
-        result['top_crop']['y'],
-        result['top_crop']['width'] + result['top_crop']['x'],
-        result['top_crop']['height'] + result['top_crop']['y']
-    )
-
-    # Due to rounding issues, the box might be slightly to big
-    # Scale it to fit the image correctly
-    if image.size[0] < box[2]:
-        print('Fixing Width: %s -> %s' % (box[2], image.size[0]))
-        scale = image.size[0] / box[2]
-        box = tuple(int(e * scale) for e in box)
-
-    if image.size[1] < box[3]:
-        print('Fixing Height: %s -> %s' % (box[3], image.size[1]))
-        scale = image.size[1] / box[3]
-        box = tuple(int(e * scale) for e in box)
-
-
-    image = Image.open(options.inputfile)
-    image2 = image.crop(box)
-    image2.thumbnail((options.width, options.height), Image.Resampling.LANCZOS)
-    image2.save(options.outputfile, 'JPEG', quality=90)
-
-
-if __name__ == '__main__':
-    main()


Test patch:
diff --git a/examples/test_bed.py b/examples/test_bed.py
index 9d2c260..46dad83 100755
--- a/examples/test_bed.py
+++ b/examples/test_bed.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 
-from __future__ import print_function
+from __future__ import annotations
 
 import argparse, collections, os, subprocess, sys, time
 
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..d3c5ae0
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,16 @@
+[pytest]
+
+addopts =
+    --cov=smartcrop
+    --cov-fail-under=75
+    --cov-report term-missing
+    --cov-report html
+    --cov-report xml
+    --doctest-modules
+    --ignore=examples
+    --pylint
+    # --flake8
+
+doctest_optionflags =
+    ELLIPSIS
+    NORMALIZE_WHITESPACE
diff --git a/tests/images/orientation.jpg b/tests/images/orientation.jpg
new file mode 100644
index 0000000..543b595
Binary files /dev/null and b/tests/images/orientation.jpg differ
diff --git a/tests/test_smartcrop.py b/tests/test_smartcrop.py
index caaf554..3d235c6 100644
--- a/tests/test_smartcrop.py
+++ b/tests/test_smartcrop.py
@@ -1,23 +1,27 @@
 import os
+
 import pytest
 from PIL import Image
 from smartcrop import SmartCrop
 
+
 def load_image(name):
     here = os.path.abspath(os.path.dirname(__file__))
     img = Image.open(os.path.join(here, 'images', name))
     return img
 
+
 @pytest.mark.parametrize('image, crop', [
     ('business-work-1.jpg', (41, 0, 1193, 1152)),
     ('nature-1.jpg', (705, 235, 3639, 3169)),
     ('travel-1.jpg', (52, 52, 1370, 1370)),
+    ('orientation.jpg', (972, 216, 3669, 2913))
 ])
 def test_square_thumbs(image, crop):
-    sc = SmartCrop()
+    cropper = SmartCrop()
 
     img = load_image(image)
-    ret = sc.crop(img.copy(), 200, 200)
+    ret = cropper.crop(img.copy(), 200, 200)
 
     box = (ret['top_crop']['x'],
            ret['top_crop']['y'],