Títols de crèdit com els d’Star Wars (efecte “rolling”) amb Python

1 Fa molt temps (però no en una galàxia molt llunyana)…

… vaig a anar al cine -i era un dels primers cops que hi anava- per veure “La guerra de las galaxias”, que era com es va traduir el nom de “Star Wars: A new hope”.
La pel·lícula em va impressionar des del primer segon, començant pels imponents títols de crèdit que introduïen la història i que s’allunyaven majestuosament per l’espai.
Per a la realització de l’efecte dels títols allunyant-se es van utilitzar tècniques “artesanals”. En aquest enllaç expliquen com, però una foto val més que mil paraules:
Star-Wars-Intro-Creation-Secret-2
Des de l’arribada de la informàtica personal s’han inventat de forma recurrent sistemes per reproduir l’efecte amb ordinadors. Una cerca a Google ens mostra un munt de resultats.
L’efecte es pot aconseguir directament amb diverses aplicacions, però segueix sent un repte interessant la seva realització per programa. En el post d’avui, doncs, presento un “prova de concepte” feta amb Python per a generar aquest efecte de rolling a uns títols de crèdit.

2 Com es fa una animació?

Es sabut que una animació no és més que la successió ràpida d’imatges (frames, o fotogrames). Per tant, per a fer una animació només cal mostrar una imatge durant un breu instant, a continuació una altre amb un petit canvi, després un altre… la integració de la successió de les imatges estàtiques al nostre cervell és el que provoca la sensació de moviment. Aleshores, per a fer una animació el que he de fer és crear els diferents fotogrames que la formen. La sensació de moviment suau depèn de quantes imatges (o frames) per segon es fan servir: amb 12 frames per segon (fps) s’arriben a notar alguns salts; a 25fps la sensació de moviment és pràcticament perfecte, i és el valor que es fa servir a les càmeres de vídeo.

2.1 Un experiment

Fem un petit experiment. Agafem aquesta imatge que és de 606×700
swtfa

En comptes de visualitzar-la sencera…

  • en visualitzaré només un quadre de 320×240;
  • aniré desplaçant el quadre un píxel per cop cap avall;
  • a una velocitat de 10 píxels per segon, és dir, a 10fps.

Faig servir la funció blit de la llibreria pygame per mostrar només una part de la imatge.

blit-image.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pygame as pg
from pygame.locals import *

pg.init()                           # init pygame
w = 320                             # width
h = 240                             # height
size=(w,h)                          # size
screen = pg.display.set_mode(size)  # init screen
pg.display.set_caption('Star Wars TFA')  # caption
filename = "./swtfa.jpg"            # filename
img=pg.image.load(filename)         # image 606x700
x = 0
y = 0
FPS = 10                            # frames per second setting
framerate = pg.time.Clock()
repeat = True
   
while repeat:                       # iterate
    # put image
    screen.blit(img, (0,0), (x, y, 320, 240)) 
    pg.display.flip() 
          
    # scroll down image
    y = y + 1
    if (y + 240 > 800):
      y = 0

    #10fps
    framerate.tick(FPS)

    # capture events
    for event in pg.event.get():
      if event.type == QUIT:
          repeat = False

# exit          
pg.quit()    
print "done!"

Si executo el programa anterior sembla que la imatge dins el requadre es vagi movent cap avall. He aconseguit crear la sensació de moviment a partir d’una imatge estàtica. Amb els títols de crèdit he de fer el mateix: construiré una imatge amb el text i aniré obtenint-ne els diferents frames sense més que anar desplaçant-me un píxel cap avall per a cada frame. Aleshores, un cop tingui tots els frames base, aplicaré una transformació sobre cada frame per afegir l’efecte d’allunyament.

3 Esquema general

La idea és partir d’un text d’entrada inicial i obtenir, com a resultat final, un vídeo amb l’scrolling del text.
Especifico més: el vídeo serà de format mp4 de 320×240 px.

El procés es pot dividir en sub-processos més senzills. Em plantejo aquests quatre passos:

  • donar al text un format adequat per a ser processat.
  • crear els frames base del vídeo (imatges o frames de 320×240)
  • processar els frames per afegir l’efecte d’allunyament: el resultat de processar cada frame serà un nou frame, també de 320×240.
  • crear un vídeo amb els frames processats.

Som hi.

4 Donar al text un format adequat

Primer de tot, el text a mostrar:

EPISODI V
L'IMPERI CONTRAATACA
Les forces imperials avancen implacablement 
de victòria en victòria sobre els rebels. 
L'Imperi prepara el cop definitiu: 
ha descobert la principal base rebel 
al planeta gelat de Hoth 
i es llença a l'atac per destruir-la.
Però no tot està perdut per 
als rebels. 
Si aconsegueixen retenir prou temps 
a les tropes d'assalt, la flota rebel 
podrà escapar a la nova base secreta...

Ara he de preparar aquest text per a que em sigui fàcil generar els frames de la imatge i processar-los.

La idea és aquesta: vaig a convertir el text en una imatge de 320px d’ample (ample de imatge que coincideixi amb l’ample de frame) per el llarg suficient per encabir-hi el text, més 240px (un frame) addicionals en blanc al principi i 240px (un frame addicional) en blanc al final.

Amb aquestes restriccions (i després d’algunes proves) resulta que la mida 320×800 em serveix.

I quin format? La restricció és fer-m’ho fàcil i que tot sigui molt evident i clar. El format més adequat que he trobat és el pbm:

https://en.wikipedia.org/wiki/Netpbm_format

Es tracta d’un format monocrom molt senzill. Reviso l’exemple que apareix a la wiki:

P1
# This is an example bitmap of the letter "J"
6 10
0 0 0 0 1 0
0 0 0 0 1 0
0 0 0 0 1 0
0 0 0 0 1 0
0 0 0 0 1 0
0 0 0 0 1 0
1 0 0 0 1 0
0 1 1 1 0 0
0 0 0 0 0 0
0 0 0 0 0 0
  • ‘P1’ indica que és el format pbm, es dir monocrom amb dades en format ascii
  • ‘# This is…’ és un comentari
  • ‘6 10’ diu que és una imatge de 6×10 píxels
  • ‘0’ indica punt en blanc ‘1’ punt en negre
  • A més, els espais en blanc, i salts de línia es descarten

Tenint en compte tot l’anterior, genero amb GIMP un llenç de 320×800 de fons negre; hi afegeixo el text deixant 240px abans de l’inici i 240px després.

El resultat és aquest:

intro

5 crear el fitxer “master”

Si examino intro.pbm veig el següent:

albert@eowyn:~/workspace/python/starwars-credits$ head intro.pbm
P1
# CREATOR: GIMP PNM Filter Version 1.1
320 800
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111111111111111111111111111111111111111
albert@eowyn:~/workspace/python/starwars-credits$ wc intro.pbm
  3660   3668 259707 intro.pbm
albert@eowyn:~/workspace/python/starwars-credits$

La part de la imatge es divideix en línies de 70 caràcters. L’especificació dels formats pbm, pgm, ppm recomana que les línies de dades no superin els 70 caràcters, però només és una recomanació. Aprofito aquest grau de llibertat perquè em sembla que és més senzill transformar el bloc de 70×3657 (3660 línies menys les tres de capçalera) en una bloc de 320(+ 1 de retorn de carro)x800 per a poder moure’m directament als inicis de cada fila d’imatge i agafar blocs de 240 files per a generar els frames. O sigui, em preparo un “master” per a poder generar més senzillament els frames. Ho faig amb el següent codi:

w = 320                                   # width of a frame and base image
h_frame = 240                             # height of a frame
h_img_base = 800                          # height of base image
c = 0                                     # counter
frame_size=(w,h_frame)                    # size of frame
filename_read = "intro.pbm"
frames_folder = "./frames/"
filename_master = "master"
bit = ""

# create master
with open(filename_read, "r") as fr:    
    with open(frames_folder + filename_master, "w") as fm:
        # discards three first header lines
        fr.readline()
        fr.readline()
        fr.readline()

        for i in xrange(0, w * h_img_base):
            c = c + 1
            bit = fr.read(1)
            if bit == '\n':
                bit = fr.read(1)
            fm.write(bit)

            if c == w:
                fm.write('\n')
                c = 0

El resultat de l’anterior és un fitxer master de 800 files de 320 caràcters (més un caràcter de retorn de carro a cada línia).

6 creació dels frames

A partir del master és molt senzill generar els frames. Això ho faig amb el següent codi

# create frames
filename_frame = ""
filename_pattern = "frame_%0#5d.pbm"
frame_count = 0

with open(frames_folder + filename_master, "r") as fr:
    for frame_count in xrange(0, h_img_base - h_frame): 
        filename_frame = filename_pattern % frame_count
        print "[Frame %d]" % frame_count

        with open(frames_folder + filename_frame, "w") as fw:
            fw.write("P1\n")
            fw.write("# CREATOR: Albert Baranguer Codina\n")
            fw.write("%d %d\n" % frame_size)

            fr.seek(frame_count * (w + 1))

            for i in xrange(h_frame ):
                fw.write(fr.readline())

Remarcar que amb

fr.seek(frame_count * (w + 1))

Em situo a l’inici de cada frame, i amb

for i in xrange(h_frame ):
    fw.write(fr.readline())

llegeixo el bloc de 240 línies. En aquest moment, després d’executar el bloc anterior, tinc 560 fitxers pbm (560 = 800 – 240) amb noms de frame_00000.pbm a frame_00559.pbm a la carpeta frames. Cada fitxer té un bloc d’imatge de 240 línies de 320 caràcters, més un de retorn de carro, per fila.

7 EL vídeo sense processar

Puc fer servir els frames generats per visualitzar la versió del vídeo sense l’efecte d’allunyament. Faig servir el següent visualitzador:

player1.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pygame as pg
from pygame.locals import *

pg.init()                           # init pygame
w = 320                             # width
h = 240                             # height
size=(w,h)                          # size
screen = pg.display.set_mode(size)  # init screen  
FPS = 18                            # frames per second setting
framerate = pg.time.Clock()
pg.display.set_caption('STAR WARS CAPTION FX')

filename_processed_pattern = "frame_%0#5d.pbm"
processed_frames_folder = "./frames/"  

filename_frame = ""
repeat = True
while repeat: # iterate
    for frame_count in range(0, 560):
        filename_frame = filename_processed_pattern % frame_count
 
        img=pg.image.load(processed_frames_folder + filename_frame)
        screen.blit(img, (0,0)) # put image 
        pg.display.update()
        pg.display.flip() 
        framerate.tick(FPS)

        for event in pg.event.get():
            if event.type == QUIT:
                repeat = False

        if not repeat:
            break

pg.quit()    
print "done!"

8 Filtre d’allunyament

Un cop tinc tots els frames, aplico a cadascun el filtre “d’allunyament”. La idea és mapejar  el frame rectangular a  un trapezi:

(0,0)--------------------------------------(319,0)
  |                                           |
  |                                           |
  |     (c, e)----------------------(d, e)    |
  |       |                           |       |
  |      |                             |      |
  |     |                               |     |
  |    |                                 |    |
  |   |                                   |   |
  |  |                                     |  |
  | |                                       | |
  ||                                         ||
  |                                           |
(239,0)----------------------------------(319,239)

és dir, vull una funció que transformi els punts del rectangle del frame en punts del trapezi definit per (c,e) (d,e) i els cantons inferiors del frame

  • (0,0) –> (c, e)
  • (319, 0) –> (d, e)
  • (239, 0) –> (239, 0)
  • (319, 239) –> (319, 239)

Després d’algunes proves, trio els valors e = 40, d = 199 i, per simetria, c = 319 – d = 120.

Horitzontalment, la transformació serà un escalat lineal depenent de l’alçada dins del trapezi.

A l’eix vertical la densitat del mapeig ha d’anar creixent a mida que pugem pel trapezi. És a dir, a mida que ens apropem al costat superior del trapezi han d’haver-hi cada cop “més línies”.

Combinant aquestes dues transformacions s’aconsegueix fer les lletres cada cop més petites a mida que “van pujant”.

Donat un punt del frame (x0, y0) caldrà, doncs, determinar a quina posició vertical es desplaça yt i, amb aquesta dada, determinar quin escalat horitzontal li correspon yt.

És dir, una mapeig del punt (x0, y0) al punt (xt, yt)

T:(x0, y0)——> (xt, yt)

Aquesta transformació la resolc amb la següent funció

def transform(x0, y0):
     a = 319.0
     b = 239.0
     d = 199.0
     e = 40.0
     c = a - d

     # lineal
     # y1 = e + (y0 * ((b - e) / b))
     
     # parabolic
     c0 = (b - e) / (b * b)
     c1 = e
     y1 = (c0 * y0 * y0) + c1


     x2 = (((y1 * c) - (b * c)) / (e - b))
     x3 = a - x2
     x1 = x2 + (((x3 - x2) / a) *  x0)

     return (x1, y1)

El mapeig en vertical es fa amb

# lineal
# y1 = e + (y0 * ((b - e) / b))

# parabolic
c0 = (b - e) / (b * b)
c1 = e
y1 = (c0 * y0 * y0) + c1

Al codi està comentada la línia que caldria per a fer un mapeig lineal. És dir, amb “densitat” de línies homogènia.

Si proveu el mapeig vertical lineal veureu que les lletres mantenen l’alçada durant tot el seu recorregut per la pantalla.

El que aplico és el mapeig “parabòlic”, en comptes de lineal.

La següent línia defineix una paràbola amb el vèrtex (0, c1), on c1 és 40.

y1 = (c0 * y0 * y0) + c1

El valor de c0 es determina amb la següent línia

c0 = (b - e) / (b * b)

Amb aquesta definició de c0, quan y0 és b (que té el valors de 239, és dir, última línia) y1 val b, com es pot comprovar fàcilment.

C0 és molt petit (està dividit pel quadrat de 239), de forma que amb valors “petits” de y0 tinc valors petits de y1.

El resultat és que n línies y0 es mapegen a la mateixa línia y1 (n > 1)

Però el creixement parabòlic fa que a mida que creix y0, en el mapeig d’n a 1 la n  cada cop es fa més petita.

De fet, a mida que y0 s’apropa al valor de 239 (la línia inferior) la n passa a ser fraccionària. Caldrà tenir-ho en compte mes tard.

He fet servir una funció “parabòlica” per a augmentar la densitat de línies prop de la línia superior, però és podria provar una altre funció que donés un resultat similar, a veure quin efecte té.

Un cop tinc la y1, aleshores puc calcular l’escalat horitzontal. En aquest cas es tracta d’una escalat lineal.

x2 = (((y1 * c) - (b * c)) / (e - b))
x3 = a - x2
x1 = x2 + (((x3 - x2) / a) *  x0)

A partir de y1 es calcula el punt x2 fent servir l’equació de la recta que passa per (0,239) i (c=120, e=40).

Per simetria horitzontal es calcula x3.

El punt x1 es calcula considerant el mapeig lineal entre (0, 319) i (x2, x3)

9 Optimització i línies en blanc

El mapeig és el mateix per a tots els frames. Aleshores, en comptes de recalcular el mapeig per a cada frame, es pot calcular un cop al començament i mantenir-lo en un array.
Un array, en principi, de 320×240. Però el cas és que el creixement en y1 de la funció de mapeig entre els punts d’un frame (x0,y0) i els punts del trapezi (x1, y1) fa que per a increments d’1 píxel en la variació de y0 el trapezi tingui línies buides a la seva part inferior, o sigui, y1 té “forats”. La solució és fer que els increments en y0 siguin més petits que un píxel, per a que y1 no tingui forats. Fent algunes proves, he vist que amb increments de y0 de 0.5 ja n’hi ha prou per a que la funció de transformació no deixi línies buides.

Al final, doncs, el precàlcul de la transformació es pot fer amb el següent codi:

# create frame transform master
num_steps = 2.0
frame_transform = [["1" for j in xrange(0,int(num_steps))] for i in range(0, w * h_frame)]
for y in xrange(0, h_frame):
    for x in xrange(0, w):
        for inc_y in xrange(0, int(num_steps)):
            frame_transform[x + y * w][inc_y] = transform(x, y + inc_y * (1 / num_steps) )

10 Generació dels frames processats (i afegir el color)

La generació dels frames processats és directa: per a cada frame…

for frame_count in xrange(0, h_img_base - h_frame): 
      filename_frame = filename_pattern % frame_count
      filename_processed_frame = filename_processed_pattern % frame_count

      # read and transform frame 
      with open(frames_folder + filename_frame, "r") as fr:
          # discards three first header lines 
          fr.readline()
          fr.readline()
          fr.readline()

… aplico la transformació,tenint en compte que els increments de y0 seran fraccionaris. Per simplificar, en comptes de generar directament el frame transformat faig servir un array de 320×240 com a pas intermig…

# initialize black frame
frame_bits = ["1" for i in xrange(0, w * h_frame)]

for y in xrange(0, h_frame):
    for x in xrange(0, w):
        bit = fr.read(1)
        if bit == '\n':
            bit = fr.read(1)

        # parabolic. trick for filling gaps
        for inc_y in xrange(0, int(num_steps)):
            (xt, yt) = frame_transform[x + y * w][inc_y]
            if int(xt) < w and int(yt)< h_frame:
                frame_bits[int(xt) + int(yt) * w] = bit

Finalment, escric el frame transformat. Aprofito aquest últim pas per afegir el color ja que durant tot el procés he considerat la imatge només en blanc i negre.

Simplement, genero el frame transformat amb el format PPM

10.1 Format PPM

De la viquipèdia, un exemple de PPM.

P3
# The P3 means colors are in ASCII, then 3 columns and 2 rows,
# then 255 for max color, then RGB triplets
3 2
255
255   0   0     0 255   0     0   0 255
255 255   0   255 255 255     0   0   0

Per tant,

# write transformed frame
# c = 0
rgb = ""
black = " 0 0 0"
yellow = " 255 255 0"  
print "[Processed Frame %0#5d]" % frame_count
with open(processed_frames_folder + filename_processed_frame, "w") as fw:
    fw.write("P3\n")
    fw.write("# CREATOR: Albert Baranguer Codina\n")
    fw.write("%d %d 255\n" % frame_size)
    for bit in frame_bits:
        if bit == "1":
            rgb = black
        else:
            rgb = yellow 
        fw.write(rgb)
        c = c + 1
        if (c == w):
            c = 0
            fw.write('\n')

El resultat de l’execució d’aquest codi és que tindré 560 frames processats a carpeta processed-frames

11 generació del video mp4

Arribats a aquest punt ja podem visualitzar el vídeo fent servir una petita modificació del player1.py que he mostrat abans. Però com que el resultat demanat era un mp4, amb un parell de passos addicionals fent servir convert de imagemagick i ffmpeg obtinc el resultat final.

La idea original era fer servir ffmpeg amb els pbm generats en el pas anterior per a obtenir el vídeo, pero en proves he vist que al ffmpeg no sembla agradar-li aquest format de imatge. O sigui que he transformat els pbm a png amb el següent script: pbm-to-png.sh

#!/bin/bash

for filename in $(ls -b ./processed-frames)
do
    convert ./processed-frames/"$filename" ./png/"$filename".png
done
echo "done!"

I un cop he obtingut els frame en format png a la carpeta png, finalment, he generat l’mp4 amb create-mp4.sh

#!/bin/bash
ffmpeg -i png/frame_%05d.processed.pbm.png \
       -c:v libx264 \
       -pix_fmt yuv420p png/starwars-credits-fx.v1.mp4

I vet aquí el resultat

 

12 Repositori a GitHub

Podeu trobar el codi ue he fet servir al meu repositori de GitHub

https://github.com/abaranguer/sw-rolling-fx

“Revolution OS”, the movie.

És estiu i és moment de relaxar-se.  Avui toca cine: El documental “Revolution OS”.

Vet aquí la ressenya de la pel·lícula que es pot trobar al YouTube:

“Revolution OS is a 2001 documentary which traces the history of GNU, Linux, and the open source and free software movements.

It features several interviews with prominent hackers and entrepreneurs (and hackers-cum-entrepreneurs), including Richard Stallman, Michael Tiemann, Linus Torvalds, Larry Augustin, Eric S. Raymond, Bruce Perens, Frank Hecker and Brian Behlendorf.

The film begins in medias res with an IPO, and then sets the historical stage by showing the beginnings of software development back in the day when software was shared on paper tape for the price of the paper itself.

It then segues to Bill Gates’s Open Letter to Hobbyists in which he asks Computer Hobbyists to not share, but to buy software. (This letter was written by Gates when Microsoft was still based in Arizona and spelled “Micro-Soft”.)

Richard Stallman then explains how and why he left the MIT Lab for Artificial Intelligence in order to devote his life to the development of free software, as well as how he started with the GNU project.

Linus Torvalds is interviewed on his development of the Linux kernel as well as on the GNU/Linux naming controversy and Linux’s further evolution, including its commercialization.

Richard Stallman remarks on some of the ideological aspects of open source vis-á-vis Communism and capitalism and well as on several aspects of the development of GNU/Linux.

Michael Tiemann (interviewed in a desert) tells how he met Stallman and got an early version of Stallman’s GCC and founded Cygnus Solutions.

Larry Augustin tells how he combined the resulting GNU software and a normal PC to create a UNIX-like Workstation which cost one third the price of a workstation by Sun Microsystems even though it was three times as powerful. His narrative includes his early dealings with venture capitalists, the eventual capitalization and commodification of Linux for his own company, VA Linux, and ends with its IPO.

Frank Hecker of Netscape tells how Netscape executives released the source code for Netscape’s browser, one of the signal events which made Open Source a force to be reckoned with by business executives, the mainstream media, and the public at large.

(this text is available under the terms of the GNU Free Documentation License)

I sense més dil·lació, amb tots vosaltres els gurús i hackers que són a l’origen del Linux, del Programari Lliure (el “Free Software”, amb “free as in freedom”) , del sistema GNU i del Free and Open Source Software (FOSS).

Apaguem els llums. “Revolution OS”, the movie: