비디오를 재생할 때 하드웨어 디코딩이나 하드웨어 렌더링 없이 순수하게 OpenCV에서 제공하는
VideoCapture()
와 GUI 함수만 이용하여 PTS에 맞게 프레임을 렌더링할 수 있을까?
주제의 의문을 해결하기 위해 직접 구현해보았습니다!
본 레포에서는 OpenCV와 Python을 이용해서 실시간 비디오 재생 & 렌더링을 구현하고 그 성능을 실험을 통하여 확인합니다. 비디오 디코딩은 Video I/O 모듈의 VideoCapture
클래스를 이용했으며 프레임 렌더링은 High-level GUI 함수 중 imshow()
와 waitKey()
를 사용해 소프트웨어만으로 렌더링합니다.
1. 렌더링 함수 호출 주기의 부정확성
대부분의 비디오는 고정 프레임레이트를 갖는다. 다시 말해, 렌더링 함수는 프레임레이트에 따라 반드시 같은 시간 간격을 두고 반복적으로 호출되어야 한다. 예를 들어 어떤 비디오의 프레임레이트가 fps = 30 이라고 가정하자. 그러면 각 프레임을 렌더링하는 함수는 interval = 1 / fps ≈ 33.3 ms 마다 호출되어야 하며, 각 함수 호출은 interval 안에 끝나야 한다는 제약을 가진다. 또한, 렌더링 함수의 실행 시간은 뒤이은 렌더링 함수 호출 시점의 결정과 독립이어야 한다. 따라서 병행 프로그래밍(Concurrent programming) 기법이 반드시 요구된다. 기존의 단일 스레드 동기식 프로그래밍 패러다임으로 위 제약사항을 구현하는 것은 굉장히 까다롭다.
2. 메인 스레드의 과도한 연산 부담
연산량과 디스크 I/O가 많은 비디오 디코딩 작업을 포함한 모든 작업을 메인 스레드에서 수행하는 단일 스레드 프로그램이다.
- 정확한 주기로 렌더링 함수 호출 스케쥴링
- 렌더링 함수의 실행 시간과 독립적인 호출 스케쥴링
- GUI 관련 함수(
imshow()
,waitKey()
)는 메인 스레드에서만 호출 (권한 문제) - GUI 관련 호출을 제외한 비디오 디코딩 등의 연산을 다른 스레드로 분산
- 프로그램 구조는 간단하게 유지
➡️ 이벤트 기반 비동기 프로그래밍 라이브러리의 필요성
- 렌더링 함수는 메인 스레드에서 실행한다.
- 렌더링 함수 내부에는
imshow
와waitKey
를 제외하고 blocking 호출, 혹은 많은 시간을 소비하는 불필요한 연산을 최대한 줄여야 한다. - 비동기 프로그래밍 라이브러리를 이용하여 렌더링 작업을 스케쥴링 한다.
- 만약 렌더링 함수의 실행 시간이 interval 이상 소요되는 경우가 발생해도 실행 스케쥴에는 문제가 없어야 한다.
- 이 경우 최대한 빨리 밀린 작업을 수행하여 기존 실행 스케쥴로 복귀해야 한다.
- 연산 부담이 크고 디스크 I/O가 발생하는 비디오 디코딩과 비디오 저장은 별도의 스레드로 분리한다.
- 프로그램 구조를 짜임새 있게 유지하기 위해 템플릿 메서드 등의 디자인 패턴을 적극 활용한다.
asyncio — 파이썬 비동기 프로그래밍 라이브러리 (https://docs.python.org/3/library/asyncio.html)
asyncio
는 비동기 프로그래밍 지원을 위해 파이썬 3.4부터 추가된 모듈이다.
비동기 프로그래밍의 핵심은 이벤트 루프
(Event loop)와 코루틴
(Coroutine)이다. 이벤트 루프
는 단일 스레드 모델이며 메인 스레드에서 생성되기 때문에 이벤트 큐
에 등록된 코루틴
은 메인 스레드에서 실행된다.
asyncio
모듈은 high-level APIs와 low-level APIs 모두 제공한다. 특정 코루틴
을 특정한 시간에 스케쥴링하고 실행하는 작업은 low-level APIs를 통해서만 가능하므로 이 프로그램에서는 low-level APIs만 사용하며 async/await 문법은 사용하지 않는다.
threading — Thread-based parallelism (https://docs.python.org/3/library/threading.html)
파이썬에서는 threading
모듈을 통해서 동시성 프로그래밍 인터페이스를 제공한다.
파이썬 인터프리터에는 **GIL(Global Interpreter Lock)**이라는 정책이 있다. 이것은 파이썬 인터프리터가 한 스레드만, 하나의 바이트코드만 실행할 수 있도록 하는 정책이다. 그래서 파이썬에서는 스레드를 사용하더라도 멀티 코어 활용은 할 수 없다.
위의 이유로 스레드를 쓰더라도 CPU bound task에서는 이득을 볼 수 없지만, I/O 작업이 발생하면 인터프리터 락을 release하기 때문에 I/O bound task의 경우 성능을 개선할 수 있다.
이 프로그램에서는 비디오 디코딩 부분과 비디오 저장 부분에서 스레드를 활용한다.
multiprocessing — Process-based parallelism (https://docs.python.org/3/library/multiprocessing.html)
파이썬 인터프리터의 GIL을 회피하기 위해 스레드가 아닌 프로세스 기반으로(인터프리터를 여러 개 생성) 동시성 프로그래밍 인터페이스를 제공한다.
프로세스 간 데이터는 pickling
을 통해 교환한다. 때문에 단순 연산 작업이 아닌 대용량 데이터의 IPC가 빈번하게 발생하는 프로그램의 경우 스레드를 쓰는 것 보다 느려질 수도 있다.
비디오를 디코딩하는 VideoReader
클래스의 핵심 동작은 다음과 같다.
- 디코딩한 프레임을 큐에 채울 생산자
worker
생성 (스레드 혹은 프로세스) worker
는cv.VideoCapture
를 이용해 비디오 프레임 디코딩하고 큐에 put- 소비자는
read()
메서드를 통해 큐에서 프레임을 get
소비자가 큐에서 가져가는 주기가 생성자가 큐를 채우는 속도보다 느린 것이 보장된다면, read()
메서드의 동작은 큐에서 프레임을 꺼내는 것이 전부이므로 그 실행 시간은 매우 짧을 것이다. 추가적으로 reverse
플래그를 통해 프레임을 읽는 순서를 역순으로 바꿀 수 있다.
비디오를 재생하는 VideoPlayer
클래스의 핵심 동작은 다음과 같다.
- 재생하려는 비디오의
fps
를 통해render_frame()
의 호출 간격인interval
을 계산 - 이벤트 루프 생성
- 사용자가
play()
를 호출하면 다음 실행 시간next_execution
값을현재 시간 + 1초
로 설정 next_execution
값이 지정한 시간에render_frame()
실행 예약render_frame()
이 실행되면,next_execution
값을next_execution + interval
로 설정하고 다음 실행 예약imshow()
,waitKey(1)
호출하여 렌더링
play()
호출 이후 next_execution
의 값은 interval
을 계속 더하며 다음 실행을 예약하기 때문에, render_frame()
의 실행 시간이 interval
보다 작다는 것만 보장되면 render_frame()
은 이벤트 루프 내부의 타이머에 의해 같은 간격으로 호출되는 것이 보장된다.
만약 어떤 이유(ex. 소프트웨어 렌더링의 한계, OS의 CPU 스케쥴링)로 render_frame()
의 실행 시간이 interval
보다 잠시 길어진다 하더라도, 이벤트 루프는 이벤트 큐에 들어온 작업의 예약 시간이 현재 시간보다 앞서면 해당 작업이 이벤트 큐에 들어가자마자 실행시키므로, 기존 실행 스케쥴로 최대한 빨리 복귀하려고 할 것이다.
추가적으로 VideoPlayer
클래스에는 프레임이 렌더링 되기 전 호출되는 템플릿 메서드인 pre_render_hook(frame) -> NoReturn
이 있다. 만약 프레임에 추가적인 전처리를 원한다면, VideoPlayer
를 상속받아 이 메서드를 오버라이딩하면 된다.
비디오를 저장하는 VideoWriter
클래스의 핵심 동작은 다음과 같다.
- 큐에 있는 프레임을 가져와 인코딩하고 저장하는 소비자
worker
생성 worker
는cv.VideoWriter
를 이용해 프레임 인코딩하고 저장- 생산자는
write()
메서드를 통해 저장할 프레임을 큐에 put
프로그램의 메인 실행 소스이며 여기엔 MyVideoPlayer
가 정의되어 있다. MyVideoPlayer
는 VideoPlayer
를 상속받아 기본 재생 기능 뿐만 아니라 과제에서 요구하는 비디오 저장, 프레임 좌측 절반 1.5배 밝기 증가 기능이 추가된 서브클래스이다. VideoPlayer
생성자에 reverse=True
를 전달해 역방향으로 재생한다.
공통
- Python 3.7
- OpenCV 4.2
A 환경
- OS : macOS 10.15.4 Catalina
- CPU : 8-Core Intel Core i9 (i9-9880H)
- VideoCapture Backend : FFmpeg
- GUI Backend : Cocoa
B 환경
- OS : Windows 10 1903 64-bit
- CPU : 6-Core Intel Core i7 (i7-8700K)
- VideoCapture Backend : FFmpeg
- GUI Backend : Win32 UI
- 해상도:
1280x720
,1920x1080
- FPS:
29.97 FPS
- 길이:
30초
- Video codec:
H.264
,MPEG-2 Part 2
- 순수한 재생 성능만 보기 위해 순방향 재생, 전처리 X (
VideoPlayer
클래스 사용) worker_type
은 스레드 사용- 2초에 한 번씩 성능 리포트
Env | Min FPS | Max exec time (ms) | Min queue size |
---|---|---|---|
A / 720p / H.264 | 19.00 | 103.311 | 0 |
A / 720p / MPEG-2 | 29.97 | 22.900 | 16 |
A / 1080p / H.264 | 12.02 | 138.265 | 0 |
A / 1080p / MPEG-2 | 29.29 | 36.045 | 16 |
B / 720p / H.264 | 19.70 | 105.879 | 0 |
B / 720p / MPEG-2 | 29.97 | 2.810 | 16 |
B / 1080p / H.264 | 12.07 | 143.743 | 0 |
B / 1080p / MPEG-2 | 29.97 | 3.749 | 16 |
- 동영상의 원본 FPS가
29.97
이므로 Min FPS가29.97
이면 실시간 재생이 된 것이다. - Max exec time은
render_frame()
함수의 실행 시간 중 가장 오래 걸린 시간이다. - Min queue size는
VideoReader
객체 내부의 큐 사이즈 변화 중 가장 작은 값이다. 큐의 최대 크기를 여기선16
으로 설정했다. Min queue size가0
이 되었다는 것은 CPU의 비디오 프레임 디코딩 속도보다 렌더링 속도가 더 빠르다는 뜻이다.
H.264
코덱은 모든 해상도와 환경에서 디코딩 속도가 부족하다. (CPU 사용률 100%)- 실시간 재생 가능 여부는 대체로 CPU 디코딩 속도에 달려있다.
- B 환경에서, 압축률이 낮은
MPEG-2
와 같은 코덱은 해상도가 높아져도 CPU만으로 실시간 디코딩이 가능하다. imshow()
함수는 OS, GUI 백엔드 간의 차이가 굉장히 심하다.
854x480
해상도의 경우 H.264 코덱도 실시간 디코딩이 가능하다.- A 환경에서 GUI 백엔드를 Qt로 변경할 경우 실행 시간이 급격히 오래 걸린다.
worker_type
을 프로세스로 변경하면 스레드보다 느리다. 대용량 데이터의 pickling은 오래 걸린다.