matplotlib 애니메이션 (+ 병렬로 처리하기)

matplotlib에서 어떻게 gif를 만드는가 & 오래걸리는 gif 생성, 동시에 실행해서 병렬로 처리하기

example gif

학교 과제로 지질도의 3차원 분석을 작성하며 matplotlib의 3D projection을 사용하였다. 삼차원으로 그린 지형의 모습을 360도 돌리며 보여주는 자료를 보고서에 추가해야 해서, matplotlib에 내장된 animation 모듈을 사용하였다.

아래에서 언급하는 fig, ax는 아래 코드에서 왔다.

1
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})

animation 모듈 사용법

animation을 사용하기 위해서는 애니메이션 모듈을 임포트한다.

1
from matplotlib import animation

animation.FuncAnimation 함수를 보자. (간략한 설명만 한다. 자세한 내용은 링크를 참고하자)

1
anim = animation.FuncAnimation(fig, animate, init_func=draw, frames=range(0, 360), interval=500, blit=True)
  • 이 함수를 호출할 때는 반환값을 변수로 받아주어야 한다. (예시의 anim =) 그렇지 않으면 애니메이션이 GC에 의해 소멸된다.
  • fig는 플롯 변수
  • animate는 매 프레임마다 실행되는 함수다. animate(frame) 꼴로 실행되고, fig의 콜렉션을 반환하여야 한다. (ex return fig,)
  • init_funcanimate 함수를 처음 실행하기 전 실행되는 함수다. (필수는 아니다) 호출 인자는 없다. animate와 똑같이 fig의 콜렉션을 반환하여야 한다.
  • framesanimate 함수의 인자로 넘겨지기 위한 프레임들이다. 정수(range(0,정수)를 넘기는 것과 동일) 또는 iterable 자료형을 넘길 수 있다. 예시에서는 range()를 넘겼다.
  • interval은 프레임 사이의 시간 차이다. 단위는 ms. 그런데 여기서 interval 지정해도, 이따 gif 저장할 때 fps 설정하면 의미 없는 값이다.
  • blitTrue면, 매번 그려지는 물체들은 각각의 zorder에 따라 그려지지만, Falsezorder에 상관없이 이전에 그려진 그림 위에 새로 그려진다.

draw 함수는 대략 아래와 같이 구성할 수 있다.

1
2
3
4
5
6
def draw():
	print("initial drawing")

	# 초기 드로잉 (중략)

	return fig,

animate 함수는 아래처럼 구현할 수 있다. 각 프레임마다 시야의 고도를 변화시키는 코드다.

1
2
3
4
def animate(frame):
    print(f"ANIMATE [frame {frame}]", end="\r")
    ax.view_init(elev=30., azim=frame)
    return fig,

이제 애니메이션을 만들었으니, gif를 저장해야 한다. 저장을 위해서는 ffmpeg가 필요하다.

1
anim.save('path/to/output.gif', fps=20)
  • 첫번째 인자는 파일이 저장될 경로를 지정한다.
  • fps는 frame per second로, gif의 속도를 조절할 수 있다.
  • 저장은 그리 오래 걸리지 않는다.

만약 실제 작동하는 예시를 확인하고 싶다면, 보고서에서 사용된 코드를 참고해보자. (결과 gif. 용량 매우 큼)

multiprocessing으로 애니메이션 병렬 처리

FuncAnimation 함수는 차례차례 한 프레임 한 프레임 animate 함수를 호출한다. 하지만 위의 코드처럼 시야 고도를 바꾸는 것만으로도 꽤 시간이 걸리고, animate 함수에 복잡한 과정이 포함되었다면(만약 처음부터 다시 그린다거나…) 필자의 경우에는 8시간 이상 실행해도 360 프레임 중 200 프레임도 채 진행이 안 된 경우도 있었다. 오래 켜놓는 것도 능사가 아닌게, 180 프레임쯤에서 멈추면(경-험-담) 다시 시작하는 것 밖에 답이 없다!

그러므로 렌더링 시간 단축을 위해서 multiprocessing을 사용해보자.

필자의 계획은 총 360 프레임을 90개씩 4세트로 나눈 다음, 이 4세트를 각각 5개의 프로세스로 나눠 실행하는 것이다. 그러니까, 한 프로세스당 90/5=18 프레임을 처리한다. 예를 들어, 0-89 1세트를 5개 프로세스로 병렬 처리 한 다음, 모든 프로세스가 끝나면 그 다음 2세트인 90-179를 또다시 5개 프로세스로 병렬 처리한다. 360개의 프레임을 곧바로 n개의 프로세스로 나누면 n이 작으면 각 프로세스마다 수행 시간이 너무 길고, 그렇다고 n을 키우면(프로세스의 수를 늘리면) 노트북의 발열이 심했다.

프로세스가 실행할 FuncAnimationsave를 동시에 수행하는 함수를 만든다.

1
2
3
4
5
6
def anim_and_save(frame_from, frame_to):
    anim = animation.FuncAnimation(fig, animate, frames=range(frame_from, frame_to), blit=True)
    print(f"[SAVE] {frame_from} - {frame_to} SAVING...")

    anim.save(f'path/to/output_{frame_from}.gif', fps=20)
    print(f"[SAVE] {frame_from} - {frame_to} SAVE COMPLETE")

이제 multiprocessingProcess 모듈을 임포트한다.

1
from multiprocessing import Process

프로세스 코드를 작성한다. 프로세스를 실행하는 코드는 if __name__ == "__main__": 구문으로 감싸야 한다. 그렇지 않으면 Frozen 에러가 발생한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
process_num = 5 # 동시에 실행할 프로세스 개수. 90의 약수로 지정해야 한다.

if __name__ == "__main__":

    for start in range(0, 360, 90): # 360을 90씩 나눈다
        end = start + 90

        processes = []

        step = (end-start) // process_num # 90을 5세트로 나눈다
        for k in range(start, end - step + 1, step):
            process = Process(target=anim_and_save, args=(k, k+step))
            processes.append(process)

        for process in processes:
            process.start() # 프로세스 시작

        for process in processes:
            process.join() # 프로세스가 종료할 때까지 대기
	
	print("Animation Complete.")

(왜 종료할 때까지 대기하는 함수의 이름이 join일까? 이에 대한 해답)

anim_and_save에서 지정한 이미지 저장 경로에 output_0.gif, output_18.gif, …, output_342.gif로 총 20개(=5x4)의 gif 파일이 생성되었다. 이제 하나의 gif 파일로 합쳐주자.

imageio 모듈을 이용한다. pip로 먼저 설치해주자. 이하는 하나로 병합하는 코드다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import imageio

gifs = []
step = 360//20
for frame_from in range(0, 360-step+1, step): # 아까 생성한 gif 파일을 모두 불러온다.
    gif = imageio.get_reader(f"/path/to/output_{frame_from}.gif")
    gifs.append(gif)

print(f"total {len(gifs)} gifs")

merged = imageio.get_writer("/path/to/output_merged.gif") # 병합된 파일이 저장될 경로

frame_number = 0
for gif in gifs:
    for _ in range(gif.get_length()):
        print(f"frame {frame_number}")
        image = gif.get_next_data() # 이 gif의 다음 프레임을 불러온다.
        merged.append_data(image) # 프레임을 추가한다.
        frame_number += 1
    gif.close()

merged.close()
댓글을 불러올까요?