Make a flying-photo movie

This weekend I was building a DVD to share with my family. I wanted to make a little movie for the DVD menu that consisted of a sequence of photo images flying in towards and past the camera from far away, in a nice pleasing random motion.

Naturally, I used Panda to do this. The following script took me about two hours to write, and I’m pleased with the results. It loads up all of the images and builds one monster Interval that, once started, flies all of the images towards the camera, one after the other. Then I used base.movie() to capture the animation as a series of numbered video frame images, which I imported into my favorite video maker to make my DVD menu. (I could just as easily have used FRAPS or some such, but base.movie() has the advantage of guaranteeing a smooth capture with no dropped frames.)

It then occurred to me that I might as well post the code I used, so here it is. Enjoy!

David

from direct.directbase.DirectStart import *
from pandac.PandaModules import *
from direct.interval.IntervalGlobal import *
import math
import random
import os

class PhotoCard:
    """ A single card with a photo image on it. """
    
    cornerRadius = 32
    
    def __init__(self, filename):
        self.filename = filename
        image = PNMImage(filename)
        self.valid = (image.getXSize() > 0)
        if not self.valid:
            return

        self.roundCorners(image)

        # Scale the image to 256x256 for the purposes of putting it on
        # a texture.
        square = PNMImage(256, 256, 4)
        square.quickFilterFrom(image)

        self.tex = Texture(filename.getBasename())
        self.tex.load(square)
        self.tex.setMinfilter(Texture.FTLinearMipmapLinear)
        self.tex.setWrapU(Texture.WMClamp)
        self.tex.setWrapV(Texture.WMClamp)

        # Now create a card that matches the original aspect ratio of
        # the image.
        x = image.getXSize()
        y = image.getYSize()
        dim = float(max(x, y))

        cm = CardMaker(filename.getBasename())
        cm.setFrame(-x / dim, x / dim, -y / dim, y / dim)
            
        self.card = NodePath(cm.generate())
        self.card.setTexture(self.tex)
        self.card.setTransparency(TransparencyAttrib.MAlpha)
        
    def roundCorners(self, image):
        """ Chop off a round alpha corner from the indicated image, to
        make the outline a little more pleasing. """
        
        image.addAlpha()
        image.alphaFill(1)

        x = image.getXSize()
        y = image.getYSize()

        r = self.cornerRadius
        for yi in range(r):
            for xi in range(r):
                rv = math.sqrt(xi * xi + yi * yi)
                alpha = r - rv - 0.5
                if alpha < 0.0:
                    alpha = 0.0
                if alpha > 1.0:
                    alpha = 1.0

                image.setAlpha(r - 1 - xi, r - 1 - yi, alpha)
                image.setAlpha(x - r + xi, r - 1 - yi, alpha)
                image.setAlpha(r - 1 - xi, y - r + yi, alpha)
                image.setAlpha(x - r + xi, y - r + yi, alpha)

class FlyPath:
    """ A randomly chosen path from far away to just off camera, for
    an image to fly in on from the horizon. """
    
    far = 100
    near = 1
    distExp = 2
    radius = 3
    rExp = 4
    steps = 20
    wanderFactor = 7
    time = 10
    
    def __init__(self):
        # Compute a number of sample points, representing the curve
        # the image will follow from the horizon to the camera.
        startAngle = random.uniform(0, math.pi * 2.0)
        noise = PerlinNoise2(self.steps, 1)
        verts = []
        for vi in range(self.steps):
            t = float(vi) / float(self.steps - 1)

            # The object moves from the center to the outside, but not
            # linearly--we want it to move slowly at first, and then
            # suddenly move quickly to the outside as it comes closer.
            # Thus, the rExp exponent.
            r = math.pow(t, self.rExp) * self.radius

            # The object moves randomly around an arc
            angle = noise.noise(vi, 0) * self.wanderFactor + startAngle
            x = r * math.cos(angle)
            z = r * math.sin(angle)

            # The object moves smoothly from far to near, but not
            # linearly--we want it to move really fast when it's far
            # away, but slow down as it comes closer.  Thus, the
            # distExp exponent.
            y = math.pow(1.0 - t, self.distExp)
            y = y * (self.far - self.near) + self.near

            verts.append(Point3(x, y, z))

        # Now use a NurbsCurveEvaluator to smoothly interpolate a
        # curve along those sample points.

        self.curve = NurbsCurveEvaluator()
        self.curve.reset(len(verts))
        self.curve.setOrder(3)
        for i in range(len(verts)):
            self.curve.setVertex(i, verts[i])
        self.ncr = self.curve.evaluate()
        

    def getFlyInterval(self, object):
        """ Returns an interval that, when played, will fly the
        indicated object along the path. """
        
        fly = LerpFunctionInterval(self.__flyObject, duration = self.time,
                                   fromData = self.ncr.getStartT(),
                                   toData = self.ncr.getEndT(),
                                   extraArgs = [object])
        return Sequence(Func(self.__startFlyObject, object),
                        fly,
                        Func(self.__endFlyObject, object))

    def __startFlyObject(self, object):
        object.reparentTo(render)

    def __flyObject(self, t, object):
        pt = Point3()
        self.ncr.evalPoint(t, pt)
        object.setPos(pt)

    def __endFlyObject(self, object):
        object.detachNode()

class FlyGroup:
    """ Creates a group of images flying in from the horizon, based on
    all of the image files in a given directory. """
    
    repeatBuffer = 4
    photoDelay = 3
    
    def __init__(self, dirname):
        self.images = self.getImages(dirname)
        self.shuffled = None

    def getInterval(self, duration):
        """ Returns an interval of no more than duration seconds
        (maybe a few seconds less) that represents the images in the
        directory flying forward, in random order.  If there are not
        enough images in the directory to fill out duration seconds,
        they will start to repeat as necessary. """

        self.shuffleImages()
        si = 0

        s = Track()
        time = 0.0
        while time + FlyPath.time < duration:
            if si >= len(self.shuffled):
                self.shuffleImages()
                si = 0

            pc = PhotoCard(self.shuffled[si])
            si += 1

            if pc.valid:
                path = FlyPath()
                fly = path.getFlyInterval(pc.card)
                s.append((time, fly))
                time += self.photoDelay
                

        return s

    def getImages(self, dirname):
        images = []
        
        filelist = os.listdir(dirname)
        for filename in filelist:
            f = Filename.fromOsSpecific(os.path.join(dirname, filename))
            images.append(f)

        return images

    def shuffleImages(self):
        """Sorts the image list into random order for display, storing
        the result in self.shuffled.  The last repeatBuffer images
        from the previous shuffle will not appear in the front
        repeatBuffer images of the new list (unless there are not
        enough images to guarantee this). """

        origShuffled = self.shuffled
        newShuffled = self.images[:]
        random.shuffle(newShuffled)
        self.lastShuffled = origShuffled
        self.shuffled = newShuffled

        if not origShuffled:
            # There wasn't a previous list, so just do the new one.
            return

        # Try to guarantee a minimal buffer of non-repeating images.
        buffer = min(self.repeatBuffer, len(self.images) - self.repeatBuffer)
        if buffer <= 0:
            return

        initial = newShuffled[:buffer]
        tail = origShuffled[-buffer:]
        for filename in tail:
            if filename in initial:
                newShuffled.remove(filename)
                newShuffled.append(filename)

def doVideo():

    # Attach a card to the camera to represent the background.
    cm = CardMaker('bg')

    # We want the card to exactly fill the screen, so extrude a point
    # from the lens to figure out what the upper-right corner of the
    # screens corresponds to in 3-d space.  Then make the card exactly
    # that big.
    np = Point3()
    fp = Point3()
    base.camLens.extrude(Point2(1, 1), np, fp)

    # Any point between the near plane and the far plane would be
    # equally good.  We pick the exact midpoint.
    p = (np + fp) / 2
    
    cm.setFrame(-p[0], p[0], -p[2], p[2])
    bgCard = camera.attachNewNode(cm.generate())
    bgCard.setY(p[1])
    bgCard.setBin('background', 0)
    bgCard.setDepthWrite(0)
    bgTex = loader.loadTexture('movie_background.png')
    bgCard.setTexture(bgTex)

    # Put another card (with lots of transparency) to cover render2d,
    # to represent the foreground labels and stuff.
    cm = CardMaker('fg')
    cm.setFrame(-1, 1, -1, 1)
    fgTex = loader.loadTexture('movie_foreground.png')
    fgCard = render2d.attachNewNode(cm.generate())
    fgCard.setTexture(fgTex)
    fgCard.setTransparency(TransparencyAttrib.MAlpha)

    duration = 100
    group = FlyGroup('movie_photo_dir')
    ival = group.getInterval(duration)
    ival.start()

    # Now capture those frames in a movie.
    base.movie(duration = duration)

doVideo()
run()

Very nice! Thanks for sharing.

This is so kewl David! Thanks for sharing :slight_smile:

Thanks!