Module autocrop.autocrop
None
None
View Source
import itertools
import cv2
import numpy as np
import os
import sys
from PIL import Image
from .constants import (
MINFACE,
GAMMA_THRES,
GAMMA,
CV2_FILETYPES,
PILLOW_FILETYPES,
CASCFILE,
)
COMBINED_FILETYPES = CV2_FILETYPES + PILLOW_FILETYPES
INPUT_FILETYPES = COMBINED_FILETYPES + [s.upper() for s in COMBINED_FILETYPES]
class ImageReadError(BaseException):
"""Custom exception to catch an OpenCV failure type."""
pass
def perp(a):
b = np.empty_like(a)
b[0] = -a[1]
b[1] = a[0]
return b
def intersect(v1, v2):
a1, a2 = v1
b1, b2 = v2
da = a2 - a1
db = b2 - b1
dp = a1 - b1
dap = perp(da)
denom = np.dot(dap, db).astype(float)
num = np.dot(dap, dp)
return (num / denom) * db + b1
def distance(pt1, pt2):
"""Returns the euclidian distance in 2D between 2 pts."""
distance = np.linalg.norm(pt2 - pt1)
return distance
def bgr_to_rbg(img):
"""Given a BGR (cv2) numpy array, returns a RBG (standard) array."""
dimensions = len(img.shape)
if dimensions == 2:
return img
return img[..., ::-1]
def gamma(img, correction):
"""Simple gamma correction to brighten faces"""
img = cv2.pow(img / 255.0, correction)
return np.uint8(img * 255)
def check_underexposed(image, gray):
"""
Returns the (cropped) image with GAMMA applied if underexposition
is detected.
"""
uexp = cv2.calcHist([gray], [0], None, [256], [0, 256])
if sum(uexp[-26:]) < GAMMA_THRES * sum(uexp):
image = gamma(image, GAMMA)
return image
def check_positive_scalar(num):
"""Returns True if value if a positive scalar."""
if num > 0 and not isinstance(num, str) and np.isscalar(num):
return int(num)
raise ValueError("A positive scalar is required")
def open_file(input_filename):
"""Given a filename, returns a numpy array"""
extension = os.path.splitext(input_filename)[1].lower()
if extension in CV2_FILETYPES:
# Try with cv2
return cv2.imread(input_filename)
if extension in PILLOW_FILETYPES:
# Try with PIL
with Image.open(input_filename) as img_orig:
return np.asarray(img_orig)
return None
class Cropper:
"""
Crops the largest detected face from images.
This class uses the `CascadeClassifier` from OpenCV to
perform the `crop` by taking in either a filepath or
Numpy array, and returning a Numpy array. By default,
also provides a slight gamma fix to lighten the face
in its new context.
Parameters:
-----------
* `width` : `int`, default=500
- The width of the resulting array.
* `height` : `int`, default=`500`
- The height of the resulting array.
* `face_percent`: `int`, default=`50`
- Aka zoom factor. Percent of the overall size of
the cropped image containing the detected coordinates.
* `fix_gamma`: `bool`, default=`True`
- Cropped faces are often underexposed when taken
out of their context. If under a threshold, sets the
gamma to 0.9.
* `resize`: `bool`, default=`True`
- Resizes the image to the specified width and height,
otherwise, returns the original image pixels.
"""
def __init__(
self,
width=500,
height=500,
face_percent=50,
padding=None,
fix_gamma=True,
resize=True,
):
self.height = check_positive_scalar(height)
self.width = check_positive_scalar(width)
self.aspect_ratio = width / height
self.gamma = fix_gamma
self.resize = resize
# Face percent
if face_percent > 100 or face_percent < 1:
fp_error = "The face_percent argument must be between 1 and 100"
raise ValueError(fp_error)
self.face_percent = check_positive_scalar(face_percent)
# XML Resource
directory = os.path.dirname(sys.modules["autocrop"].__file__)
self.casc_path = os.path.join(directory, CASCFILE)
def crop(self, path_or_array):
"""
Given a file path or np.ndarray image with a face,
returns cropped np.ndarray around the largest detected
face.
Parameters
----------
- `path_or_array` : {`str`, `np.ndarray`}
* The filepath or numpy array of the image.
Returns
-------
- `image` : {`np.ndarray`, `None`}
* A cropped numpy array if face detected, else None.
"""
if isinstance(path_or_array, str):
image = open_file(path_or_array)
else:
image = path_or_array
# Some grayscale color profiles can throw errors, catch them
try:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
except cv2.error:
gray = image
# Scale the image
try:
img_height, img_width = image.shape[:2]
except AttributeError:
raise ImageReadError
minface = int(np.sqrt(img_height**2 + img_width**2) / MINFACE)
# Create the haar cascade
face_cascade = cv2.CascadeClassifier(self.casc_path)
# ====== Detect faces in the image ======
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(minface, minface),
flags=cv2.CASCADE_FIND_BIGGEST_OBJECT | cv2.CASCADE_DO_ROUGH_SEARCH,
)
# Handle no faces
if len(faces) == 0:
return None
# Make padding from biggest face found
x, y, w, h = faces[-1]
pos = self._crop_positions(
img_height,
img_width,
x,
y,
w,
h,
)
# ====== Actual cropping ======
image = image[pos[0] : pos[1], pos[2] : pos[3]]
# Resize
if self.resize:
image = cv2.resize(
image, (self.width, self.height), interpolation=cv2.INTER_AREA
)
# Underexposition fix
if self.gamma:
image = check_underexposed(image, gray)
return bgr_to_rbg(image)
def _determine_safe_zoom(self, imgh, imgw, x, y, w, h):
"""
Determines the safest zoom level with which to add margins
around the detected face. Tries to honor `self.face_percent`
when possible.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
Diagram:
--------
i / j := zoom / 100
+
h1 | h2
+---------|---------+
| MAR|GIN |
| (x+w, y+h)|
| +-----|-----+ |
| | FA|CE | |
| | | | |
| ├──i──┤ | |
| | cen|ter | |
| | | | |
| +-----|-----+ |
| (x, y)| |
| | |
+---------|---------+
├────j────┤
+
"""
# Find out what zoom factor to use given self.aspect_ratio
corners = itertools.product((x, x + w), (y, y + h))
center = np.array([x + int(w / 2), y + int(h / 2)])
i = np.array(
[(0, 0), (0, imgh), (imgw, imgh), (imgw, 0), (0, 0)]
) # image_corners
image_sides = [(i[n], i[n + 1]) for n in range(4)]
corner_ratios = [self.face_percent] # Hopefully we use this one
for c in corners:
corner_vector = np.array([center, c])
a = distance(*corner_vector)
intersects = list(intersect(corner_vector, side) for side in image_sides)
for pt in intersects:
if (pt >= 0).all() and (pt <= i[2]).all(): # if intersect within image
dist_to_pt = distance(center, pt)
corner_ratios.append(100 * a / dist_to_pt)
return max(corner_ratios)
def _crop_positions(
self,
imgh,
imgw,
x,
y,
w,
h,
):
"""
Retuns the coordinates of the crop position centered
around the detected face with extra margins. Tries to
honor `self.face_percent` if possible, else uses the
largest margins that comply with required aspect ratio
given by `self.height` and `self.width`.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
"""
zoom = self._determine_safe_zoom(imgh, imgw, x, y, w, h)
# Adjust output height based on percent
if self.height >= self.width:
height_crop = h * 100.0 / zoom
width_crop = self.aspect_ratio * float(height_crop)
else:
width_crop = w * 100.0 / zoom
height_crop = float(width_crop) / self.aspect_ratio
# Calculate padding by centering face
xpad = (width_crop - w) / 2
ypad = (height_crop - h) / 2
# Calc. positions of crop
h1 = x - xpad
h2 = x + w + xpad
v1 = y - ypad
v2 = y + h + ypad
return [int(v1), int(v2), int(h1), int(h2)]
Variables
CASCFILE
COMBINED_FILETYPES
CV2_FILETYPES
GAMMA
GAMMA_THRES
INPUT_FILETYPES
MINFACE
PILLOW_FILETYPES
Functions
bgr_to_rbg
def bgr_to_rbg(
img
)
Given a BGR (cv2) numpy array, returns a RBG (standard) array.
View Source
def bgr_to_rbg(img):
"""Given a BGR (cv2) numpy array, returns a RBG (standard) array."""
dimensions = len(img.shape)
if dimensions == 2:
return img
return img[..., ::-1]
check_positive_scalar
def check_positive_scalar(
num
)
Returns True if value if a positive scalar.
View Source
def check_positive_scalar(num):
"""Returns True if value if a positive scalar."""
if num > 0 and not isinstance(num, str) and np.isscalar(num):
return int(num)
raise ValueError("A positive scalar is required")
check_underexposed
def check_underexposed(
image,
gray
)
Returns the (cropped) image with GAMMA applied if underexposition
is detected.
View Source
def check_underexposed(image, gray):
"""
Returns the (cropped) image with GAMMA applied if underexposition
is detected.
"""
uexp = cv2.calcHist([gray], [0], None, [256], [0, 256])
if sum(uexp[-26:]) < GAMMA_THRES * sum(uexp):
image = gamma(image, GAMMA)
return image
distance
def distance(
pt1,
pt2
)
Returns the euclidian distance in 2D between 2 pts.
View Source
def distance(pt1, pt2):
"""Returns the euclidian distance in 2D between 2 pts."""
distance = np.linalg.norm(pt2 - pt1)
return distance
gamma
def gamma(
img,
correction
)
Simple gamma correction to brighten faces
View Source
def gamma(img, correction):
"""Simple gamma correction to brighten faces"""
img = cv2.pow(img / 255.0, correction)
return np.uint8(img * 255)
intersect
def intersect(
v1,
v2
)
View Source
def intersect(v1, v2):
a1, a2 = v1
b1, b2 = v2
da = a2 - a1
db = b2 - b1
dp = a1 - b1
dap = perp(da)
denom = np.dot(dap, db).astype(float)
num = np.dot(dap, dp)
return (num / denom) * db + b1
open_file
def open_file(
input_filename
)
Given a filename, returns a numpy array
View Source
def open_file(input_filename):
"""Given a filename, returns a numpy array"""
extension = os.path.splitext(input_filename)[1].lower()
if extension in CV2_FILETYPES:
# Try with cv2
return cv2.imread(input_filename)
if extension in PILLOW_FILETYPES:
# Try with PIL
with Image.open(input_filename) as img_orig:
return np.asarray(img_orig)
return None
perp
def perp(
a
)
View Source
def perp(a):
b = np.empty_like(a)
b[0] = -a[1]
b[1] = a[0]
return b
Classes
Cropper
class Cropper(
width=500,
height=500,
face_percent=50,
padding=None,
fix_gamma=True,
resize=True
)
View Source
class Cropper:
"""
Crops the largest detected face from images.
This class uses the `CascadeClassifier` from OpenCV to
perform the `crop` by taking in either a filepath or
Numpy array, and returning a Numpy array. By default,
also provides a slight gamma fix to lighten the face
in its new context.
Parameters:
-----------
* `width` : `int`, default=500
- The width of the resulting array.
* `height` : `int`, default=`500`
- The height of the resulting array.
* `face_percent`: `int`, default=`50`
- Aka zoom factor. Percent of the overall size of
the cropped image containing the detected coordinates.
* `fix_gamma`: `bool`, default=`True`
- Cropped faces are often underexposed when taken
out of their context. If under a threshold, sets the
gamma to 0.9.
* `resize`: `bool`, default=`True`
- Resizes the image to the specified width and height,
otherwise, returns the original image pixels.
"""
def __init__(
self,
width=500,
height=500,
face_percent=50,
padding=None,
fix_gamma=True,
resize=True,
):
self.height = check_positive_scalar(height)
self.width = check_positive_scalar(width)
self.aspect_ratio = width / height
self.gamma = fix_gamma
self.resize = resize
# Face percent
if face_percent > 100 or face_percent < 1:
fp_error = "The face_percent argument must be between 1 and 100"
raise ValueError(fp_error)
self.face_percent = check_positive_scalar(face_percent)
# XML Resource
directory = os.path.dirname(sys.modules["autocrop"].__file__)
self.casc_path = os.path.join(directory, CASCFILE)
def crop(self, path_or_array):
"""
Given a file path or np.ndarray image with a face,
returns cropped np.ndarray around the largest detected
face.
Parameters
----------
- `path_or_array` : {`str`, `np.ndarray`}
* The filepath or numpy array of the image.
Returns
-------
- `image` : {`np.ndarray`, `None`}
* A cropped numpy array if face detected, else None.
"""
if isinstance(path_or_array, str):
image = open_file(path_or_array)
else:
image = path_or_array
# Some grayscale color profiles can throw errors, catch them
try:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
except cv2.error:
gray = image
# Scale the image
try:
img_height, img_width = image.shape[:2]
except AttributeError:
raise ImageReadError
minface = int(np.sqrt(img_height**2 + img_width**2) / MINFACE)
# Create the haar cascade
face_cascade = cv2.CascadeClassifier(self.casc_path)
# ====== Detect faces in the image ======
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(minface, minface),
flags=cv2.CASCADE_FIND_BIGGEST_OBJECT | cv2.CASCADE_DO_ROUGH_SEARCH,
)
# Handle no faces
if len(faces) == 0:
return None
# Make padding from biggest face found
x, y, w, h = faces[-1]
pos = self._crop_positions(
img_height,
img_width,
x,
y,
w,
h,
)
# ====== Actual cropping ======
image = image[pos[0] : pos[1], pos[2] : pos[3]]
# Resize
if self.resize:
image = cv2.resize(
image, (self.width, self.height), interpolation=cv2.INTER_AREA
)
# Underexposition fix
if self.gamma:
image = check_underexposed(image, gray)
return bgr_to_rbg(image)
def _determine_safe_zoom(self, imgh, imgw, x, y, w, h):
"""
Determines the safest zoom level with which to add margins
around the detected face. Tries to honor `self.face_percent`
when possible.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
Diagram:
--------
i / j := zoom / 100
+
h1 | h2
+---------|---------+
| MAR|GIN |
| (x+w, y+h)|
| +-----|-----+ |
| | FA|CE | |
| | | | |
| ├──i──┤ | |
| | cen|ter | |
| | | | |
| +-----|-----+ |
| (x, y)| |
| | |
+---------|---------+
├────j────┤
+
"""
# Find out what zoom factor to use given self.aspect_ratio
corners = itertools.product((x, x + w), (y, y + h))
center = np.array([x + int(w / 2), y + int(h / 2)])
i = np.array(
[(0, 0), (0, imgh), (imgw, imgh), (imgw, 0), (0, 0)]
) # image_corners
image_sides = [(i[n], i[n + 1]) for n in range(4)]
corner_ratios = [self.face_percent] # Hopefully we use this one
for c in corners:
corner_vector = np.array([center, c])
a = distance(*corner_vector)
intersects = list(intersect(corner_vector, side) for side in image_sides)
for pt in intersects:
if (pt >= 0).all() and (pt <= i[2]).all(): # if intersect within image
dist_to_pt = distance(center, pt)
corner_ratios.append(100 * a / dist_to_pt)
return max(corner_ratios)
def _crop_positions(
self,
imgh,
imgw,
x,
y,
w,
h,
):
"""
Retuns the coordinates of the crop position centered
around the detected face with extra margins. Tries to
honor `self.face_percent` if possible, else uses the
largest margins that comply with required aspect ratio
given by `self.height` and `self.width`.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
"""
zoom = self._determine_safe_zoom(imgh, imgw, x, y, w, h)
# Adjust output height based on percent
if self.height >= self.width:
height_crop = h * 100.0 / zoom
width_crop = self.aspect_ratio * float(height_crop)
else:
width_crop = w * 100.0 / zoom
height_crop = float(width_crop) / self.aspect_ratio
# Calculate padding by centering face
xpad = (width_crop - w) / 2
ypad = (height_crop - h) / 2
# Calc. positions of crop
h1 = x - xpad
h2 = x + w + xpad
v1 = y - ypad
v2 = y + h + ypad
return [int(v1), int(v2), int(h1), int(h2)]
Methods
crop
def crop(
self,
path_or_array
)
Given a file path or np.ndarray image with a face,
returns cropped np.ndarray around the largest detected face.
Parameters:
Name | Type | Description | Default |
---|---|---|---|
- path_or_array |
{str , np.ndarray } |
* The filepath or numpy array of the image. | None |
Returns:
Type | Description |
---|---|
{np.ndarray , None } |
* A cropped numpy array if face detected, else None. |
View Source
def crop(self, path_or_array):
"""
Given a file path or np.ndarray image with a face,
returns cropped np.ndarray around the largest detected
face.
Parameters
----------
- `path_or_array` : {`str`, `np.ndarray`}
* The filepath or numpy array of the image.
Returns
-------
- `image` : {`np.ndarray`, `None`}
* A cropped numpy array if face detected, else None.
"""
if isinstance(path_or_array, str):
image = open_file(path_or_array)
else:
image = path_or_array
# Some grayscale color profiles can throw errors, catch them
try:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
except cv2.error:
gray = image
# Scale the image
try:
img_height, img_width = image.shape[:2]
except AttributeError:
raise ImageReadError
minface = int(np.sqrt(img_height**2 + img_width**2) / MINFACE)
# Create the haar cascade
face_cascade = cv2.CascadeClassifier(self.casc_path)
# ====== Detect faces in the image ======
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(minface, minface),
flags=cv2.CASCADE_FIND_BIGGEST_OBJECT | cv2.CASCADE_DO_ROUGH_SEARCH,
)
# Handle no faces
if len(faces) == 0:
return None
# Make padding from biggest face found
x, y, w, h = faces[-1]
pos = self._crop_positions(
img_height,
img_width,
x,
y,
w,
h,
)
# ====== Actual cropping ======
image = image[pos[0] : pos[1], pos[2] : pos[3]]
# Resize
if self.resize:
image = cv2.resize(
image, (self.width, self.height), interpolation=cv2.INTER_AREA
)
# Underexposition fix
if self.gamma:
image = check_underexposed(image, gray)
return bgr_to_rbg(image)
ImageReadError
class ImageReadError(
/,
*args,
**kwargs
)
View Source
class ImageReadError(BaseException):
"""Custom exception to catch an OpenCV failure type."""
pass
Ancestors (in MRO)
- builtins.BaseException
Class variables
args
Methods
with_traceback
def with_traceback(
...
)
Exception.with_traceback(tb) --
set self.traceback to tb and return self.