Spaces:
Running
on
Zero
Running
on
Zero
""" From https://github.com/LeviBorodenko/motionblur """ | |
import numpy as np | |
from PIL import Image, ImageDraw, ImageFilter | |
from numpy.random import uniform, triangular, beta | |
from math import pi | |
from pathlib import Path | |
from scipy.signal import convolve | |
# tiny error used for nummerical stability | |
eps = 0.1 | |
def softmax(x): | |
"""Compute softmax values for each sets of scores in x.""" | |
e_x = np.exp(x - np.max(x)) | |
return e_x / e_x.sum() | |
def norm(lst: list) -> float: | |
"""[summary] | |
L^2 norm of a list | |
[description] | |
Used for internals | |
Arguments: | |
lst {list} -- vector | |
""" | |
if not isinstance(lst, list): | |
raise ValueError("Norm takes a list as its argument") | |
if lst == []: | |
return 0 | |
return (sum((i**2 for i in lst)))**0.5 | |
def polar2z(r: np.ndarray, θ: np.ndarray) -> np.ndarray: | |
"""[summary] | |
Takes a list of radii and angles (radians) and | |
converts them into a corresponding list of complex | |
numbers x + yi. | |
[description] | |
Arguments: | |
r {np.ndarray} -- radius | |
θ {np.ndarray} -- angle | |
Returns: | |
[np.ndarray] -- list of complex numbers r e^(i theta) as x + iy | |
""" | |
return r * np.exp(1j * θ) | |
class Kernel(object): | |
"""[summary] | |
Class representing a motion blur kernel of a given intensity. | |
[description] | |
Keyword Arguments: | |
size {tuple} -- Size of the kernel in px times px | |
(default: {(100, 100)}) | |
intensity {float} -- Float between 0 and 1. | |
Intensity of the motion blur. | |
: 0 means linear motion blur and 1 is a highly non linear | |
and often convex motion blur path. (default: {0}) | |
Attribute: | |
kernelMatrix -- Numpy matrix of the kernel of given intensity | |
Properties: | |
applyTo -- Applies kernel to image | |
(pass as path, pillow image or np array) | |
Raises: | |
ValueError | |
""" | |
def __init__(self, size: tuple = (100, 100), intensity: float=0): | |
# checking if size is correctly given | |
if not isinstance(size, tuple): | |
raise ValueError("Size must be TUPLE of 2 positive integers") | |
elif len(size) != 2 or type(size[0]) != type(size[1]) != int: | |
raise ValueError("Size must be tuple of 2 positive INTEGERS") | |
elif size[0] < 0 or size[1] < 0: | |
raise ValueError("Size must be tuple of 2 POSITIVE integers") | |
# check if intensity is float (int) between 0 and 1 | |
if type(intensity) not in [int, float, np.float32, np.float64]: | |
raise ValueError("Intensity must be a number between 0 and 1") | |
elif intensity < 0 or intensity > 1: | |
raise ValueError("Intensity must be a number between 0 and 1") | |
# saving args | |
self.SIZE = size | |
self.INTENSITY = intensity | |
# deriving quantities | |
# we super size first and then downscale at the end for better | |
# anti-aliasing | |
self.SIZEx2 = tuple([2 * i for i in size]) | |
self.x, self.y = self.SIZEx2 | |
# getting length of kernel diagonal | |
self.DIAGONAL = (self.x**2 + self.y**2)**0.5 | |
# flag to see if kernel has been calculated already | |
self.kernel_is_generated = False | |
def _createPath(self): | |
"""[summary] | |
creates a motion blur path with the given intensity. | |
[description] | |
Proceede in 5 steps | |
1. Get a random number of random step sizes | |
2. For each step get a random angle | |
3. combine steps and angles into a sequence of increments | |
4. create path out of increments | |
5. translate path to fit the kernel dimensions | |
NOTE: "random" means random but might depend on the given intensity | |
""" | |
# first we find the lengths of the motion blur steps | |
def getSteps(): | |
"""[summary] | |
Here we calculate the length of the steps taken by | |
the motion blur | |
[description] | |
We want a higher intensity lead to a longer total motion | |
blur path and more different steps along the way. | |
Hence we sample | |
MAX_PATH_LEN =[U(0,1) + U(0, intensity^2)] * diagonal * 0.75 | |
and each step: beta(1, 30) * (1 - self.INTENSITY + eps) * diagonal) | |
""" | |
# getting max length of blur motion | |
self.MAX_PATH_LEN = 0.75 * self.DIAGONAL * \ | |
(uniform() + uniform(0, self.INTENSITY**2)) | |
# getting step | |
steps = [] | |
while sum(steps) < self.MAX_PATH_LEN: | |
# sample next step | |
step = beta(1, 30) * (1 - self.INTENSITY + eps) * self.DIAGONAL | |
if step < self.MAX_PATH_LEN: | |
steps.append(step) | |
# note the steps and the total number of steps | |
self.NUM_STEPS = len(steps) | |
self.STEPS = np.asarray(steps) | |
def getAngles(): | |
"""[summary] | |
Gets an angle for each step | |
[description] | |
The maximal angle should be larger the more | |
intense the motion is. So we sample it from a | |
U(0, intensity * pi) | |
We sample "jitter" from a beta(2,20) which is the probability | |
that the next angle has a different sign than the previous one. | |
""" | |
# same as with the steps | |
# first we get the max angle in radians | |
self.MAX_ANGLE = uniform(0, self.INTENSITY * pi) | |
# now we sample "jitter" which is the probability that the | |
# next angle has a different sign than the previous one | |
self.JITTER = beta(2, 20) | |
# initialising angles (and sign of angle) | |
angles = [uniform(low=-self.MAX_ANGLE, high=self.MAX_ANGLE)] | |
while len(angles) < self.NUM_STEPS: | |
# sample next angle (absolute value) | |
angle = triangular(0, self.INTENSITY * | |
self.MAX_ANGLE, self.MAX_ANGLE + eps) | |
# with jitter probability change sign wrt previous angle | |
if uniform() < self.JITTER: | |
angle *= - np.sign(angles[-1]) | |
else: | |
angle *= np.sign(angles[-1]) | |
angles.append(angle) | |
# save angles | |
self.ANGLES = np.asarray(angles) | |
# Get steps and angles | |
getSteps() | |
getAngles() | |
# Turn them into a path | |
#### | |
# we turn angles and steps into complex numbers | |
complex_increments = polar2z(self.STEPS, self.ANGLES) | |
# generate path as the cumsum of these increments | |
self.path_complex = np.cumsum(complex_increments) | |
# find center of mass of path | |
self.com_complex = sum(self.path_complex) / self.NUM_STEPS | |
# Shift path s.t. center of mass lies in the middle of | |
# the kernel and a apply a random rotation | |
### | |
# center it on COM | |
center_of_kernel = (self.x + 1j * self.y) / 2 | |
self.path_complex -= self.com_complex | |
# randomly rotate path by an angle a in (0, pi) | |
self.path_complex *= np.exp(1j * uniform(0, pi)) | |
# center COM on center of kernel | |
self.path_complex += center_of_kernel | |
# convert complex path to final list of coordinate tuples | |
self.path = [(i.real, i.imag) for i in self.path_complex] | |
def _createKernel(self, save_to: Path=None, show: bool=False): | |
"""[summary] | |
Finds a kernel (psf) of given intensity. | |
[description] | |
use displayKernel to actually see the kernel. | |
Keyword Arguments: | |
save_to {Path} -- Image file to save the kernel to. {None} | |
show {bool} -- shows kernel if true | |
""" | |
# check if we haven't already generated a kernel | |
if self.kernel_is_generated: | |
return None | |
# get the path | |
self._createPath() | |
# Initialise an image with super-sized dimensions | |
# (pillow Image object) | |
self.kernel_image = Image.new("RGB", self.SIZEx2) | |
# ImageDraw instance that is linked to the kernel image that | |
# we can use to draw on our kernel_image | |
self.painter = ImageDraw.Draw(self.kernel_image) | |
# draw the path | |
self.painter.line(xy=self.path, width=int(self.DIAGONAL / 150)) | |
# applying gaussian blur for realism | |
self.kernel_image = self.kernel_image.filter( | |
ImageFilter.GaussianBlur(radius=int(self.DIAGONAL * 0.01))) | |
# Resize to actual size | |
self.kernel_image = self.kernel_image.resize( | |
self.SIZE, resample=Image.LANCZOS) | |
# convert to gray scale | |
self.kernel_image = self.kernel_image.convert("L") | |
# flag that we have generated a kernel | |
self.kernel_is_generated = True | |
def displayKernel(self, save_to: Path=None, show: bool=True): | |
"""[summary] | |
Finds a kernel (psf) of given intensity. | |
[description] | |
Saves the kernel to save_to if needed or shows it | |
is show true | |
Keyword Arguments: | |
save_to {Path} -- Image file to save the kernel to. {None} | |
show {bool} -- shows kernel if true | |
""" | |
# generate kernel if needed | |
self._createKernel() | |
# save if needed | |
if save_to is not None: | |
save_to_file = Path(save_to) | |
# save Kernel image | |
self.kernel_image.save(save_to_file) | |
else: | |
# Show kernel | |
self.kernel_image.show() | |
def kernelMatrix(self) -> np.ndarray: | |
"""[summary] | |
Kernel matrix of motion blur of given intensity. | |
[description] | |
Once generated, it stays the same. | |
Returns: | |
numpy ndarray | |
""" | |
# generate kernel if needed | |
self._createKernel() | |
kernel = np.asarray(self.kernel_image, dtype=np.float32) | |
kernel /= np.sum(kernel) | |
return kernel | |
def kernelMatrix(self, *kargs): | |
raise NotImplementedError("Can't manually set kernel matrix yet") | |
def applyTo(self, image, keep_image_dim: bool = False) -> Image: | |
"""[summary] | |
Applies kernel to one of the following: | |
1. Path to image file | |
2. Pillow image object | |
3. (H,W,3)-shaped numpy array | |
[description] | |
Arguments: | |
image {[str, Path, Image, np.ndarray]} | |
keep_image_dim {bool} -- If true, then we will | |
conserve the image dimension after blurring | |
by using "same" convolution instead of "valid" | |
convolution inside the scipy convolve function. | |
Returns: | |
Image -- [description] | |
""" | |
# calculate kernel if haven't already | |
self._createKernel() | |
def applyToPIL(image: Image, keep_image_dim: bool = False) -> Image: | |
"""[summary] | |
Applies the kernel to an PIL.Image instance | |
[description] | |
converts to RGB and applies the kernel to each | |
band before recombining them. | |
Arguments: | |
image {Image} -- Image to convolve | |
keep_image_dim {bool} -- If true, then we will | |
conserve the image dimension after blurring | |
by using "same" convolution instead of "valid" | |
convolution inside the scipy convolve function. | |
Returns: | |
Image -- blurred image | |
""" | |
# convert to RGB | |
image = image.convert(mode="RGB") | |
conv_mode = "valid" | |
if keep_image_dim: | |
conv_mode = "same" | |
result_bands = () | |
for band in image.split(): | |
# convolve each band individually with kernel | |
result_band = convolve( | |
band, self.kernelMatrix, mode=conv_mode).astype("uint8") | |
# collect bands | |
result_bands += result_band, | |
# stack bands back together | |
result = np.dstack(result_bands) | |
# Get image | |
return Image.fromarray(result) | |
# If image is Path | |
if isinstance(image, str) or isinstance(image, Path): | |
# open image as Image class | |
image_path = Path(image) | |
image = Image.open(image_path) | |
return applyToPIL(image, keep_image_dim) | |
elif isinstance(image, Image.Image): | |
# apply kernel | |
return applyToPIL(image, keep_image_dim) | |
elif isinstance(image, np.ndarray): | |
# ASSUMES we have an array of the form (H, W, 3) | |
### | |
# initiate Image object from array | |
image = Image.fromarray(image) | |
return applyToPIL(image, keep_image_dim) | |
else: | |
raise ValueError("Cannot apply kernel to this type.") | |
if __name__ == '__main__': | |
image = Image.open("./images/moon.png") | |
image.show() | |
k = Kernel() | |
k.applyTo(image, keep_image_dim=True).show() | |