coloriz / cv-video-playback

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Video Playback using OpenCV-Python

Motivation

비디오를 재생할 때 하드웨어 디코딩이나 하드웨어 렌더링 없이 순수하게 OpenCV에서 제공하는 VideoCapture()와 GUI 함수만 이용하여 PTS에 맞게 프레임을 렌더링할 수 있을까?

주제의 의문을 해결하기 위해 직접 구현해보았습니다!

본 레포에서는 OpenCV와 Python을 이용해서 실시간 비디오 재생 & 렌더링을 구현하고 그 성능을 실험을 통하여 확인합니다. 비디오 디코딩은 Video I/O 모듈의 VideoCapture 클래스를 이용했으며 프레임 렌더링은 High-level GUI 함수 중 imshow()waitKey()를 사용해 소프트웨어만으로 렌더링합니다.

playback window

console output

프로그래밍 고려 사항

기존 프로그램의 문제점

1. 렌더링 함수 호출 주기의 부정확성

대부분의 비디오는 고정 프레임레이트를 갖는다. 다시 말해, 렌더링 함수는 프레임레이트에 따라 반드시 같은 시간 간격을 두고 반복적으로 호출되어야 한다. 예를 들어 어떤 비디오의 프레임레이트가 fps = 30 이라고 가정하자. 그러면 각 프레임을 렌더링하는 함수는 interval = 1 / fps ≈ 33.3 ms 마다 호출되어야 하며, 각 함수 호출은 interval 안에 끝나야 한다는 제약을 가진다. 또한, 렌더링 함수의 실행 시간은 뒤이은 렌더링 함수 호출 시점의 결정과 독립이어야 한다. 따라서 병행 프로그래밍(Concurrent programming) 기법이 반드시 요구된다. 기존의 단일 스레드 동기식 프로그래밍 패러다임으로 위 제약사항을 구현하는 것은 굉장히 까다롭다.

2. 메인 스레드의 과도한 연산 부담

연산량과 디스크 I/O가 많은 비디오 디코딩 작업을 포함한 모든 작업을 메인 스레드에서 수행하는 단일 스레드 프로그램이다.

제약 조건

  • 정확한 주기로 렌더링 함수 호출 스케쥴링
  • 렌더링 함수의 실행 시간과 독립적인 호출 스케쥴링
  • GUI 관련 함수(imshow(), waitKey())는 메인 스레드에서만 호출 (권한 문제)
  • GUI 관련 호출을 제외한 비디오 디코딩 등의 연산을 다른 스레드로 분산
  • 프로그램 구조는 간단하게 유지

➡️ 이벤트 기반 비동기 프로그래밍 라이브러리의 필요성

프로그래밍 전략

  • 렌더링 함수는 메인 스레드에서 실행한다.
  • 렌더링 함수 내부에는 imshowwaitKey를 제외하고 blocking 호출, 혹은 많은 시간을 소비하는 불필요한 연산을 최대한 줄여야 한다.
  • 비동기 프로그래밍 라이브러리를 이용하여 렌더링 작업을 스케쥴링 한다.
  • 만약 렌더링 함수의 실행 시간이 interval 이상 소요되는 경우가 발생해도 실행 스케쥴에는 문제가 없어야 한다.
    • 이 경우 최대한 빨리 밀린 작업을 수행하여 기존 실행 스케쥴로 복귀해야 한다.
  • 연산 부담이 크고 디스크 I/O가 발생하는 비디오 디코딩과 비디오 저장은 별도의 스레드로 분리한다.
  • 프로그램 구조를 짜임새 있게 유지하기 위해 템플릿 메서드 등의 디자인 패턴을 적극 활용한다.

프로그램 설명

Prerequisites

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가 빈번하게 발생하는 프로그램의 경우 스레드를 쓰는 것 보다 느려질 수도 있다.

Source code

videoreader.py

비디오를 디코딩하는 VideoReader 클래스의 핵심 동작은 다음과 같다.

  1. 디코딩한 프레임을 큐에 채울 생산자 worker 생성 (스레드 혹은 프로세스)
  2. workercv.VideoCapture를 이용해 비디오 프레임 디코딩하고 큐에 put
  3. 소비자는 read() 메서드를 통해 큐에서 프레임을 get

소비자가 큐에서 가져가는 주기가 생성자가 큐를 채우는 속도보다 느린 것이 보장된다면, read() 메서드의 동작은 큐에서 프레임을 꺼내는 것이 전부이므로 그 실행 시간은 매우 짧을 것이다. 추가적으로 reverse 플래그를 통해 프레임을 읽는 순서를 역순으로 바꿀 수 있다.

videoplayer.py

비디오를 재생하는 VideoPlayer 클래스의 핵심 동작은 다음과 같다.

  1. 재생하려는 비디오의 fps 를 통해 render_frame() 의 호출 간격인 interval을 계산
  2. 이벤트 루프 생성
  3. 사용자가 play() 를 호출하면 다음 실행 시간 next_execution값을 현재 시간 + 1초 로 설정
  4. next_execution값이 지정한 시간에 render_frame() 실행 예약
  5. render_frame()이 실행되면, next_execution값을 next_execution + interval로 설정하고 다음 실행 예약
  6. 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.py

비디오를 저장하는 VideoWriter 클래스의 핵심 동작은 다음과 같다.

  1. 큐에 있는 프레임을 가져와 인코딩하고 저장하는 소비자 worker 생성
  2. workercv.VideoWriter를 이용해 프레임 인코딩하고 저장
  3. 생산자는 write() 메서드를 통해 저장할 프레임을 큐에 put

playback.py

프로그램의 메인 실행 소스이며 여기엔 MyVideoPlayer가 정의되어 있다. MyVideoPlayerVideoPlayer를 상속받아 기본 재생 기능 뿐만 아니라 과제에서 요구하는 비디오 저장, 프레임 좌측 절반 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 FPS29.97이면 실시간 재생이 된 것이다.
  • Max exec timerender_frame() 함수의 실행 시간 중 가장 오래 걸린 시간이다.
  • Min queue sizeVideoReader 객체 내부의 큐 사이즈 변화 중 가장 작은 값이다. 큐의 최대 크기를 여기선 16으로 설정했다. Min queue size0이 되었다는 것은 CPU의 비디오 프레임 디코딩 속도보다 렌더링 속도가 더 빠르다는 뜻이다.

✏️ 결론

  • H.264 코덱은 모든 해상도와 환경에서 디코딩 속도가 부족하다. (CPU 사용률 100%)
  • 실시간 재생 가능 여부는 대체로 CPU 디코딩 속도에 달려있다.
  • B 환경에서, 압축률이 낮은 MPEG-2 와 같은 코덱은 해상도가 높아져도 CPU만으로 실시간 디코딩이 가능하다.
  • imshow() 함수는 OS, GUI 백엔드 간의 차이가 굉장히 심하다.

🧪 추가 실험 결과

  • 854x480 해상도의 경우 H.264 코덱도 실시간 디코딩이 가능하다.
  • A 환경에서 GUI 백엔드를 Qt로 변경할 경우 실행 시간이 급격히 오래 걸린다.
  • worker_type을 프로세스로 변경하면 스레드보다 느리다. 대용량 데이터의 pickling은 오래 걸린다.

About


Languages

Language:Python 100.0%