Spaces:
Sleeping
Sleeping
| """ 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() | |