Zulko/moviepy

Zoom-In Effect for moviepy

Opened this issue Β· 16 comments

I am trying to create a video from slides. Each slide has a zoom-in effect. All the code examples for creating the zoom-in effect gave poor results if the zoom speed is very slow. The side effect was a slight twitching.

Below I will provide examples that the documentation offers and the function that I offer.

The problem I faced too: #183

Example from documentation

https://zulko.github.io/moviepy/ref/videofx/moviepy.video.fx.all.resize.html

import moviepy.editor as mp

size = (1920, 1080)

img = 'https://www.colorado.edu/cumuseum/sites/default/files/styles/widescreen/public/slider/coachwhip2_1.jpg'

slide = mp.ImageClip(img).set_fps(25).set_duration(10).resize(size)

slide = slide.resize(lambda t: 1 + 0.04 * t)  # Zoom-in effect
slide = slide.set_position(('center', 'center'))
slide = mp.CompositeVideoClip([slide], size=size)

slide.write_videofile('zoom-test-1.mp4')

Result: https://youtu.be/qlU_4hVFm6I

My function

Slideshow example: https://gist.github.com/mowshon/2a0664fab0ae799734594a5e91e518d5

import moviepy.editor as mp
import math
from PIL import Image
import numpy


def zoom_in_effect(clip, zoom_ratio=0.04):
    def effect(get_frame, t):
        img = Image.fromarray(get_frame(t))
        base_size = img.size

        new_size = [
            math.ceil(img.size[0] * (1 + (zoom_ratio * t))),
            math.ceil(img.size[1] * (1 + (zoom_ratio * t)))
        ]

        # The new dimensions must be even.
        new_size[0] = new_size[0] + (new_size[0] % 2)
        new_size[1] = new_size[1] + (new_size[1] % 2)

        img = img.resize(new_size, Image.LANCZOS)

        x = math.ceil((new_size[0] - base_size[0]) / 2)
        y = math.ceil((new_size[1] - base_size[1]) / 2)

        img = img.crop([
            x, y, new_size[0] - x, new_size[1] - y
        ]).resize(base_size, Image.LANCZOS)

        result = numpy.array(img)
        img.close()

        return result

    return clip.fl(effect)


size = (1920, 1080)

img = 'https://www.colorado.edu/cumuseum/sites/default/files/styles/widescreen/public/slider/coachwhip2_1.jpg'

slide = mp.ImageClip(img).set_fps(25).set_duration(10).resize(size)
slide = zoom_in_effect(slide, 0.04)

slide.write_videofile('zoom-test-2.mp4')

Result: https://youtu.be/U-A54E00sC8

In my example, there is also a slight wobble, but not as obvious as in the first example. Below is a link to a video where you can compare both options.

Comparation video: https://www.youtube.com/watch?v=UPyYdrwWE14

  • Left side: My function
  • Right side: Example from docs

I would be glad to get advice on improving the code.

Possible explanation for wobbling (in your solution)

The wobbling effect might be caused by the use of math.ceil and evening for new_size. This may result in one or both of the following:

  1. A dimension's delta / change-per-frame modulates inconsistently. For example, the width might progress like 640 638 638 336 334 334, alternating between 1 and 2 frames for a given quantity.
  2. The width and height are modulating out of phase. This would result in one axis appearing slightly stretched every couple frames or so. For example, the width and height progress like so:
t w h
0 640 480
1 638 480
2 638 478
3 636 478
4 634 476
5 634 476

It's likely a combination of both, depending on the circumstance. Given the out-of-phase nature of the hypothesized causes, I think wobbling occurs whenever the number of frames that the effect lasts (duration * FPS) does not share a common factor with the width and height of the image (or something along those lines).

Possible reason your solution wobbles less

resize might be rounding the width and height under the hood. So compared to the width and height modulation for your solution (shown in the earlier table), resize by itself modulates more erratically.

t w h
0 640 480
1 639 479
2 637 478
3 636 477
4 635 476
5 633 475

By evening the width and height, you're essentially forcing the ratio between the width and height to fluctuate less.

Looking at the various resizers in moviepy/video/fx/resize.py it looks like they are in fact casting the width and height to ints. I'm wondering if the aliasing algorithms can account for fractions of pixels. But that may be beyond the scope of what moviepys contract covers, as it's using OpenCV, Pillow, and SciPy to resize.

Proposed solution

Always maintain the ratio of width-to-height by first calculating the new width, then calculating the height based on the width. So replace

        new_size = [
            math.ceil(img.size[0] * (1 + (zoom_ratio * t))),
            math.ceil(img.size[1] * (1 + (zoom_ratio * t)))
        ]

        # The new dimensions must be even.
        new_size[0] = new_size[0] + (new_size[0] % 2)
        new_size[1] = new_size[1] + (new_size[1] % 2)

with something like

w, h = img.size
new_w = w * (1 + (zoom_ratio * t))
new_h = new_w * (h / w)
# Determine the height based on the new width to maintain the aspect ratio.
new_size = (new_w, new_h)

Problem: resize can't handle floating numbers where a smooth zoom would need it.

Solution: Only increase the width & height with the aspect ratio (or multiple of it).
I.E. if the aspect ratio is 4:3, increase the width by 4 and height by 3.
This might be too fast for a zoom effect but won't be wobbling.

An ideal aspect ratio would be a 1:1 ratio and place your content in it masking the space left.
I.E. If your image is 640x480, you can create an empty image of 640x640 and place your image centered inside of it. This way every time you increase the aspect ratio, it will be an integer, which will be digestible by the resize function

I'm about to try out the theory. If anybody's still interested in an example code let me know

vloe commented

@kalloszsolty I'm interested!! :)

Use this function to create a perfectly square image with even width and height:

def expand2square(img_path):
    pil_img = Image.open(img_path)
    width, height = pil_img.size
    if width == height and width % 2 == 0:
        return pil_img

    if width % 2 != 0:
        width += 1
    if height % 2 != 0:
        height += 1

    elif width > height:
        result = Image.new('RGBA', (width, width))
        result.paste(pil_img, (0, (width - height) // 2))
        return result
    else:
        result = Image.new('RGBA', (height, height))
        result.paste(pil_img, ((height - width) // 2, 0))
        return result

Then just use a lambda function to increase the width and height with the same integer:

starting_scale = 400
scale_speed = 2
expanded_img_1 = expand2square(img_path)
expanded_img_1 = numpy.array(expanded_img_1)
img_clip = (ImageClip(expanded_img_1)
    .set_fps(25)
    .set_duration(4)
    .set_position(('center', 'center'))
    .resize(width=lambda t: starting_scale + round(t * 25 * scale_speed),
            height=lambda t: starting_scale + round(t * 25 * scale_speed)))

@kalloszsolty I'm interested!! :)

At the end, I ended up generating separate videos of zooming with command line ffmpeg
(credits to: https://superuser.com/a/1112680)

import subprocess

def run_ffmpeg_zoom(image_path, output_file, screensize, duration=5, fps=25, zoom_ratio=0.0015, zoom_smooth=5):
    ffmpeg_command = f"""./ffmpeg -framerate {fps} -loop 1 -i {image_path} -filter_complex "[0:v]scale={screensize[0] * zoom_smooth}x{screensize[1] * zoom_smooth},
    zoompan=z='min(zoom+{zoom_ratio},1.5)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={duration * fps},trim=duration={duration}[v1];[ 
    v1]scale={screensize[0]}:{screensize[1]}[v]" -map "[v]" -y {output_file}"""
    process = subprocess.Popen(ffmpeg_command, shell=True, stdout=subprocess.PIPE)
    process.wait()

increase the zoom_smooth parameter for example to 10 to get a smoother zoom (it will also take you more time/resources)

For anyone wanting a periodic zoom-in and out effect. I've used a sin wave to replicate that.

# Zoom In and Out effect
def zoom_in_out(t):
    return 2 + 0.5*np.sin(t/6)

clip.resize(zoom_in_out)

I am getting AttributeError: 'ImageClip' object has no attribute 'resize'. Did you mean: 'size'? and I couldn't understand why. Could you help?

I am getting AttributeError: 'ImageClip' object has no attribute 'resize'. Did you mean: 'size'? and I couldn't understand why. Could you help?

Maybe this will help.
#1004

Just curious which is the solution? Because the output video still seems to wobble...

the input is 1 video, what to do!
Looking forward to your support

i made this one (pretty fast and almost without wobbling) with opencv warpAffine plus some additional options :

def Zoom(clip,mode='in',position='center',speed=1):
    fps = clip.fps
    duration = clip.duration
    total_frames = int(duration*fps)
    def main(getframe,t):
        frame = getframe(t)
        h,w = frame.shape[:2]
        i = t*fps
        if mode == 'out':
            i = total_frames-i
        zoom = 1+(i*((0.1*speed)/total_frames))
        positions = {'center':[(w-(w*zoom))/2,(h-(h*zoom))/2],
                     'left':[0,(h-(h*zoom))/2],
                     'right':[(w-(w*zoom)),(h-(h*zoom))/2],
                     'top':[(w-(w*zoom))/2,0],
                     'topleft':[0,0],
                     'topright':[(w-(w*zoom)),0],
                     'bottom':[(w-(w*zoom))/2,(h-(h*zoom))],
                     'bottomleft':[0,(h-(h*zoom))],
                     'bottomright':[(w-(w*zoom)),(h-(h*zoom))]}
        tx,ty = positions[position]
        M = np.array([[zoom,0,tx], [0,zoom,ty]])
        frame = cv2.warpAffine(frame,M,(w,h))
        return frame
    return clip.fl(main)

you can use this way:

from moviepy.editor import *
import cv2
import numpy as np

img = 'https://www.colorado.edu/cumuseum/sites/default/files/styles/widescreen/public/slider/coachwhip2_1.jpg' #using  the image link above

clip = ImageClip(img).set_fps(30).set_duration(5)
clip = Zoom(clip,mode='in',position='center',speed=1.2) #zoom function above

clip.write_videofile('test.mp4',preset='superfast')

sorry my english.

Worked!

At the end, I ended up generating separate videos of zooming with command line ffmpeg (credits to: https://superuser.com/a/1112680)

import subprocess

def run_ffmpeg_zoom(image_path, output_file, screensize, duration=5, fps=25, zoom_ratio=0.0015, zoom_smooth=5):
    ffmpeg_command = f"""./ffmpeg -framerate {fps} -loop 1 -i {image_path} -filter_complex "[0:v]scale={screensize[0] * zoom_smooth}x{screensize[1] * zoom_smooth},
    zoompan=z='min(zoom+{zoom_ratio},1.5)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={duration * fps},trim=duration={duration}[v1];[ 
    v1]scale={screensize[0]}:{screensize[1]}[v]" -map "[v]" -y {output_file}"""
    process = subprocess.Popen(ffmpeg_command, shell=True, stdout=subprocess.PIPE)
    process.wait()

increase the zoom_smooth parameter for example to 10 to get a smoother zoom (it will also take you more time/resources)

Thanks! This is a command version that also works based on this.

ffmpeg -y -loop 1 -t 17 -i /PATH_TO_IMAGE/image-0.png -vf "scale=iw5:ih5,zoompan=z='min(zoom+0.0015,1.5)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=510,scale=1344:768,trim=duration=17,fps=30" -c:v prores /PATH_TO_OUTPUT/output.mov

'ImageClip' object has no attribute 'fl'

im using the updated version of moviepy

@steinathan thanks for your comment, I'll see what changes have been made and update the code.

Thanks @mowshon I was able to update it and it works perfectly

the old β€œ.fl” is now β€œtransform” and it works