티스토리 뷰

카메라 스트리밍 모듈을 만들 일이 있어서 조사하던 도중 ffmpeg를 이용해 영상을 압축하고 스트리밍 할 수 있다는 것을 알아 만들어보고 정리한다.


*RTP


ffmpeg는 멀티미디어 분야에서 많이 쓰는 라이브러리다. 원격지로 영상 전송을 위해 RTP(Realtime Transport Protocol)을 사용하였는데 간단히 알아보자


1. 개요

RTP는 멀티미디어 실시간 스트리밍을 위해 만들어진 응용층 프로토콜이다. 또한 IP 네트워크에서 영상/오디오 전송 표준으로 여겨진다. 

RTP는 원래 전송층으로 TCP를 기준으로 하도록 만들어졌으나, TCP는 데이터 전송 딜레이 같은 시간에 대한 요소보다 신뢰성을 더 중시하기 때문에 스트리밍에 적합하지 않아 UDP를 많이 사용한다. 

멀티미디어 스트리밍을 하려는 어플리케이션은 정해진 시간(FPS)마다 데이터를 전송해야하고, 데이터 손실에 대한 처리, 데이터 순서에 대한 처리가 있어야만 스트리밍을 할 수 있다. RTP는 이러한 요구들을 다 처리해준다.


2. RTP의 요소들

RTP는 실시간 전송을 위해 다음과 같은 요소들을 포함한다.

1) 타임스탬프(timestamp)

동기화를 위해 타임스탬프를 포함한다.

2) 순서 (sequence number)

TCP는 알아서 해주겠지만 UDP를 사용하는 경우 패킷 손실 처리나, 데이터 순서를 재배열 해야할 때 데이터의 순서가 필요하다.

3) 데이터 인코딩 정보

인코딩된 데이터의 경우 디코딩이 필요하기 때문에 이러한 정보를 포함해야한다.


이 외에는 아래 헤더에 자세히 나와있다.


3. RTP 헤더

RTP 패킷의 헤더는 아래 그림 1과 같이 구성된다.

[그림 1] RTP packet header (출처: wikipedia)


RTP 헤더는 최소 12바이트를 요구한다. 이 최소 헤더 다음에는 확장 헤더가 필요할 경우 확장 헤더가 붙는다.

헤더의 필드들은 아래와 같다.

1) Version / 2비트: 프로토콜의 버전을 나타낸다. 현재 버전은 2이다.

2) P(Padding) / 1비트: RTP 패킷 끝에 padding byte가 있을 경우 사용된다. padding byte는 특정한 크기의 블록으로 채워지는데, 암호화 알고리즘등에 사용된다. padding byte의 마지막 byte는 padding byte들의 갯수를 나타낸다.

3) X(Extension / 1비트: 최소 헤더와 데이터 사이에 확장 헤더가 존재함을 나타낸다. 

4) CC(CSRC count) / 4비트: 그림 1의 CSRC identifiers의 갯수를 나타낸다.

5) M(Maker) / 1비트: profile에 의해 값이 매겨지고 응용층에서 사용된다. 이 비트가 설정되면 데이터가 어플리케이션에 뭔가 특별한 관련성이 있다는 것을 의미한다.

6) PT(Payload type) / 7비트: payload의 타입을 나타낸다. RTP가 전송하고 있는 데이터가 MPEG4를 사용하는지 H264를 사용하는지 등을 나타낸다. 수신측 어플리케이션에서 해석을 하는데, 수신측 프로필로 알 수가 없다면 수신측 어플리케이션은 수신하지 않는다.

7) Sequence number / 16비트: 송신측이 RTP 데이터 패킷을 하나 보낼때마다 하나씩 증가하는 숫자인데, 수신측에서는 패킷 손실 감지와 패킷 순서 복구를 위해 사용한다.

RTP는 패킷 손실에 대해 아무런 처리를 하지 않는다. 그래서 손실에 대한 처리를 하고싶으면 어플리케이션 레벨에서 처리를 해야한다.

8) Timestamp / 32비트: 수신측이 데이터를 받았을 때 그 데이터를 적절한 간격으로 재생할 수 있도록 하기위해 사용된다. 스트림이 여러개 있으면 서로 독립적인 값을 갖는다. 사용하고 있는 코덱에 따라서 샘플링 레이트에 맞게 증가된다.

9) SSRC / 32비트: 스트림의 소스를 식별하는 동기화 소스(Synchronization source)이다. RTP 내에서 동기화 소스는 고유성을 지니고 있어야한다.

10) CSRC / 32비트: 다수의 스트림이 존재할 때, 모든 스트림의 SSRC를 나타낸다. (예를 들어 다수의 오디오 스트림이 하나로 합쳐질 때)


4. 경험으로 알게된 RTP

처음에 RTSP(Real Time Streaming Protocol)을 이용해 개발을 했는데, RTP와의 차이점은 RTSP는 요청 메시지가 존재한다는 것이다. RTSP는 클라이언트에서 서버로 데이터를 전송한다. 또한 RTSP는 데이터를 전송하는 포트외에도 요청 메시지를 위한 포트(554)를 사용한다. (스트림을 위한 포트, 요청을 위한 포트)

나는 PLAY, PAUSE 같은 요청 메시지가 필요없고, 단순히 실시간 영상 전송만이 필요하기때문에 RTP를 사용했다.



*FFMPEG for Windows C++


https://ffmpeg.zeranoe.com/builds/


위 링크는 ffmpeg windows C++ 버전을 위해 빌드된 파일을 제공해주는 사이트이다.

ffmpeg는 리눅스 기반으로 개발된 라이브러리라 윈도우즈에서 빌드하기가 쉽지가 않다. 따라서 보통 위 링크에서 빌드한 것을 이용해 ffmpeg 라이브러리를 사용한다. 

개발을 위해 홈페이지에서 dev, shared 두가지를 다운로드 하자. dev는 header, lib를 위해, shared는 dll을 위해 다운로드 받는 것이다. 

나는 개발 환경 설정을 위해 아래 그림 2와 같이 C드라이브에 dev에 shared의 bin폴더를 합친 내용을 ffmpeg라는 폴더 이름으로 저장했다.

[그림 2] include, lib, bin 폴더를 합친 ffmpeg 폴더


그 다음으로는 여타 라이브러리를 사용할 때와 마찬가지로 bin폴더(DLL)를 path환경변수에 설정한다. (그림 3)

[그림 3] Path 환경변수 설정


그럼 이제 DLL을 위한 세팅은 끝났고 프로젝트 세팅에서 header, lib를 위해 include, lib 폴더를 링크해야한다.

Visual Studio 2013을 개발환경으로 사용하고 있다. (그림 4, 그림 5)


[그림 4] include 폴더 세팅


[그림 5] lib 폴더 세팅



이제 ffmpeg 라이브러리를 Windows C++ 환경에서 사용할 준비가 되었다.

RTP를 이용해서 스트리밍을 하기 위해 ffmpeg API를 어떻게 사용하는지 알아보자.



*Streaming from Cam using FFMPEG & OpenCV


먼저 아래 함수를 호출해 코덱을 등록시키고, 네트워크를 초기화 해야한다.


av_register_all();
avformat_network_init();


그다음 output context를 할당해야하는데, format name은 "rtp", filename은 "rtp://ip:port/나머지"로 등록해야한다.


std::string tempUrl("");
tempUrl.append("rtp://");
tempUrl.append(ip + ":");
tempUrl.append(std::to_string(port));
//tempUrl.append("/live.sdp");

// 맨 마지막 파라미터의 rtp url로 내보내는 context를 할당한다.
AVFormatContext* oc;
avformat_alloc_output_context2(&oc, NULL, "rtp", tempUrl.c_str());
if (!oc)
{
	// ERROR
	return false;
}


output context의 format에 codec을 설정하고 stream을 생성한다.

AVOutputFormat* fmt = oc->oformat;
if (!fmt)
{
	// ERROR
	return false;
}

// set codec (여기선 MPEG1 사용)
fmt->video_codec = AV_CODEC_ID_MPEG1VIDEO;

AVStream* video_st;
AVCodec* video_codec;
if (fmt->video_codec != AV_CODEC_ID_NONE)
	video_st = add_stream(oc, &video_codec, fmt->video_codec, img_width, img_height);

#define STREAM_FPS 30
#define STREAM_PIX_FMT	AV_PIX_FMT_YUV420P
AVStream* add_stream(AVFormatContext *oc, AVCodec **codec, enum AVCodecID codec_id,
							int img_width, int img_height)
{
	AVCodecContext *c;
	AVStream *st;

	/* find the encoder */
	*codec = avcodec_find_encoder(codec_id);
	if (!(*codec)) {
		fprintf(stderr, "Could not find encoder for '%s'\n",
			avcodec_get_name(codec_id));
		exit(1);
	}

	// output context에 대한 스트림을 생성한다.
	st = avformat_new_stream(oc, *codec);
	if (!st) {
		fprintf(stderr, "Could not allocate stream\n");
		exit(1);
	}
	st->id = oc->nb_streams - 1;
	c = st->codec;

	// 오디오 전송을 안할거면 AVMEDIA_TYPE_VIDEO만 신경쓰면 된다.
	switch ((*codec)->type) {
	case AVMEDIA_TYPE_AUDIO:
		c->sample_fmt = (*codec)->sample_fmts ?
			(*codec)->sample_fmts[0] : AV_SAMPLE_FMT_FLTP;
		c->bit_rate = 64000;
		c->sample_rate = 44100;
		c->channels = 2;
		break;

	// st->codec에 비디오 코덱, 전송률, 이미지 해상도, FPS등을 설정한다.
	case AVMEDIA_TYPE_VIDEO:
		c->codec_id = codec_id;
		c->bit_rate = 3500000;
		/* Resolution must be a multiple of two. */
		c->width = img_width;
		c->height = img_height;
		/* timebase: This is the fundamental unit of time (in seconds) in terms
		* of which frame timestamps are represented. For fixed-fps content,
		* timebase should be 1/framerate and timestamp increments should be
		* identical to 1. */
		c->time_base.den = STREAM_FPS;
		c->time_base.num = 1;
		c->gop_size = 12; /* emit one intra frame every twelve frames at most */
		c->pix_fmt = STREAM_PIX_FMT;
		if (c->codec_id == AV_CODEC_ID_MPEG2VIDEO) {
			/* just for testing, we also add B frames */
			c->max_b_frames = 2;
		}
		if (c->codec_id == AV_CODEC_ID_MPEG1VIDEO) {
			/* Needed to avoid using macroblocks in which some coeffs overflow.
			* This does not happen with normal video, it just happens here as
			* the motion of the chroma plane does not match the luma plane. */
			c->mb_decision = 2;
		}
		break;

	default:
		break;
	}

	/* Some formats want stream headers to be separate. */
	if (oc->oformat->flags & AVFMT_GLOBALHEADER)
		c->flags |= CODEC_FLAG_GLOBAL_HEADER;

	return st;
}


위에 과정을 거쳐서 rtp output context, video codec, stream 이렇게 세가지가 생성이 된다.

이제 할당한 스트림을 열고 frame을 카메라로 부터 얻어 스트리밍을 해야한다.

// 비디오 코덱을 열고, 필요한 버퍼(frame)을 할당하는 함수이다.
if (video_st)
	open_video(oc, video_codec, video_st);

// 전송률, 코덱 정보등 output format에 대한 자세한 정보를 보낸다.
av_dump_format(oc, 0, tempUrl.c_str(), 1);

char errorBuff[80];
// rtp가 제대로 열렸는지 체크
if (!(fmt->flags & AVFMT_NOFILE)) {
	ret = avio_open(&oc->pb, tempUrl.c_str(), AVIO_FLAG_WRITE);
	if (ret < 0) {
		// ERROR
		fprintf(stderr, "Could not open outfile '%s': %s", tempUrl.c_str(), av_make_error_string(errorBuff, 80, ret));
		return false;
	}
}

// 헤더 쓰기
ret = avformat_write_header(oc, NULL);
if (ret < 0) {
	// ERROR
	fprintf(stderr, "Error occurred when writing header: %s", av_make_error_string(errorBuff, 80, ret));
	return false;
}

// 전역 변수, 클래스 멤버변수 등으로 재사용할 버퍼를 할당해놓자 
AVFrame* frame;
// 아래 두개 변수는 각각 OpenCV 이미지(BGR24), ffmpeg 이미지(이 예제에선 YUV420)를 위한 변수이다.
AVPicture src_picture, dst_picture;
void open_video(AVFormatContext *oc, AVCodec *codec, AVStream *st)
{
	int ret;
	AVCodecContext *c = st->codec;

	/* open the codec */
	ret = avcodec_open2(c, codec, NULL);
	if (ret < 0) {
		// ERROR
		fprintf(stderr, "Could not open video codec: ");
		return;
	}

	/* allocate and init a re-usable frame */
	// av_frame_alloc 함수는 frame의 데이터 버퍼를 할당하지 않는다.
	// 데이터를 제외한 나머지 부분만을 할당한다.
	frame = av_frame_alloc();
	if (!frame) {
		// ERROR
		fprintf(stderr, "Could not allocate video frame\n");
		return;
	}
	frame->format = c->pix_fmt;
	frame->width = c->width;
	frame->height = c->height;

	/* Allocate the encoded raw picture. */
	ret = avpicture_alloc(&dst_picture, c->pix_fmt, c->width, c->height);
	if (ret < 0) {
		fprintf(stderr, "Could not allocate picture: ");
		exit(1);
	}
	ret = avpicture_alloc(&src_picture, AV_PIX_FMT_BGR24, c->width, c->height);
	if (ret < 0) {
		fprintf(stderr, "Could not allocate temporary picture:");
		exit(1);
	}

	/* copy data and linesize picture pointers to frame */
	// AVFrame이 AVPicture의 확장 구조체라고 할 수 있다.
	// AVFrame과 AVPicture 구조체의 정의를 보면 알 수 있겠지만, AVFrame의 처음 두개 멤버(이미지 데이터)가 AVPicture와 같다.
	// 위 주석에서 설명됐듯이 av_frame_alloc함수는 데이터 버퍼를 할당하지 않기 때문에, dst_picture에 할당된 데이터 버퍼를 frame 변수가 쓰게 하는 것이다.
	*((AVPicture *)(frame)) = dst_picture;
}


버퍼를 할당하고 스트림을 열었으면 RTP로 송신할 준비가 되었다.

이제 아래와 같은 방법으로 frame을 보내면 된다.

int video_is_eof = 0;
// cv_img는 전송할 이미지 데이터이다.
bool StreamImage(cv::Mat cv_img, bool is_end)
{
	if (video_st && !video_is_eof)
	{
		write_video_frame(oc, video_st, cv_img, is_end);
		return true;
	}
	else
		return false;
}

int frame_count = 0;
void write_video_frame(AVFormatContext *oc, AVStream *st, cv::Mat cv_img, int flush)
{
	int ret;
	static struct SwsContext *sws_ctx;
	AVCodecContext *c = st->codec;

	if (!flush) {
		// BGR opencv image to AV_PIX_FMT_YUV420P
		cv::resize(cv_img, cv_img, cv::Size(c->width, c->height));

		// BGR24 이미지를 YUV420 이미지로 변환하기 위한 context를 할당받는다.
		if (!sws_ctx) {
			sws_ctx = sws_getContext(c->width, c->height, AV_PIX_FMT_BGR24,
				c->width, c->height, c->pix_fmt,
				SWS_BICUBIC, NULL, NULL, NULL);
			if (!sws_ctx) {
				fprintf(stderr,
					"Could not initialize the conversion context\n");
				exit(1);
			}
		}

		// OpenCV Mat 데이터를 ffmpeg 이미지 데이터로 복사
		avpicture_fill(&src_picture, cv_img.data, AV_PIX_FMT_BGR24, c->width, c->height);

		// BGR24 이미지를 YUV420이미지로 복사
		sws_scale(sws_ctx,
			(const uint8_t * const *)(src_picture.data), src_picture.linesize,
			0, c->height, dst_picture.data, dst_picture.linesize);
	}

	AVPacket pkt = { 0 };
	int got_packet;
	av_init_packet(&pkt);

	/* encode the image */
	// 스트림의 코덱에 맞게 인코딩하여 pkt변수에 할당된다.
	frame->pts = frame_count;
	ret = avcodec_encode_video2(c, &pkt, flush ? NULL : frame, &got_packet);
	if (ret < 0) {
		fprintf(stderr, "Error encoding video frame:");
		exit(1);
	}
	/* If size is zero, it means the image was buffered. */
	if (got_packet) {
		// 제대로 이미지가 인코딩 됐으면 스트림에 이미지를 쓴다.
		ret = write_frame(oc, &c->time_base, st, &pkt);
	}
	else {
		if (flush)
			video_is_eof = 1;
		ret = 0;
	}

	if (ret < 0) {
		fprintf(stderr, "Error while writing video frame: ");
		exit(1);
	}
	frame_count++;
}

int write_frame(AVFormatContext *fmt_ctx, const AVRational *time_base, AVStream *st, AVPacket *pkt)
{
	/* rescale output packet timestamp values from codec to stream timebase */
	pkt->pts = av_rescale_q_rnd(pkt->pts, *time_base, st->time_base, AVRounding(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
	pkt->dts = av_rescale_q_rnd(pkt->dts, *time_base, st->time_base, AVRounding(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
	pkt->duration = av_rescale_q(pkt->duration, *time_base, st->time_base);
	pkt->stream_index = st->index;

	/* Write the compressed frame to the media file. */
	return av_interleaved_write_frame(fmt_ctx, pkt);
}


위의 StreamImage 함수를 호출할 때마다 프레임이 한장씩 보내지는 것이다.

FPS에 맞게 이미지를 카메라로부터 얻어서 StreamImage 함수를 호출하려면 아래와 같이 하면 된다.

#include <mutex>
#include <thread>

// 스레드 멤버
std::thread* sender;
std::mutex mtx_lock;
// 비동기로 스트리밍을 중지하기 위한 변수
bool is_streaming = false;
// 비디오 캡처를 위한 멤버
cv::VideoCapture video_cap;

// ...

video_cap.open(device_id);
if (!video_cap.isOpened())
{
	// ERROR
	return false;
}

mtx_lock.lock();
is_streaming = true;
mtx_lock.unlock();

sender = new std::thread(&SendStream, this);
if (!sender)
{
	// ERROR
	return false;
}

//...

// 이미지를 지속적으로 카메라로부터 얻어 전송하는 스레드 함수
void SendStream()
{
	cv::Mat cam_img;

	while (true)
	{
		// 비동기 스트리밍 중지
		mtx_lock.lock();
		bool thread_end = is_streaming;
		mtx_lock.unlock();

		video_cap >> cam_img;

		// end of video stream
		if (cam_img.empty())
		{
			EndStream();
			break;
		}

		// user finish
		if (!thread_end)
		{
			// write last frame
			if (!ffmpeg.StreamImage(cam_img, true))
			{
				// ERROR
				return;
			}
			break;
		}
		
		// write frame
		if(!ffmpeg.StreamImage(cam_img, false))
		{
			// ERROR
			return;
		}

		// FPS만큼 기다린다.
		std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_FPS));
	}
}


스트리밍이 끝나면 ffmpeg의 버퍼와 스레드를 release해야한다.

void EndStream()
{
	if (sender)
	{
		mtx_lock.lock();
		is_streaming = false;
		mtx_lock.unlock();

		// wait until finish
		sender->join();

		delete sender;
	}
	if (video_cap.isOpened())
		video_cap.release();
	sender = NULL;

	// 아래 av_write_trailer는 output context를 닫기 전에 호출해야 한다는데 정확히는 몰라서 더 찾아봐야한다..
	/* Write the trailer, if any. The trailer must be written before you
	* close the CodecContexts open when you wrote the header; otherwise
	* av_write_trailer() may try to use memory that was freed on
	* av_codec_close(). */
	if (oc)
		av_write_trailer(oc);

	/* Close each codec. */
	if (video_st)
		close_video(video_st);
	//if (audio_st)
	//	close_audio(audio_st);

	if (fmt && oc)
		if (!(fmt->flags & AVFMT_NOFILE))
			/* Close the output file. */
			avio_close(oc->pb);

	/* free the stream */
	if (oc)
		avformat_free_context(oc);

	oc = NULL;
	fmt = NULL;
	video_st = NULL;
}



127.0.0.1, 포트 9000으로 테스트해보자

ffmpeg shared를 받으면 bin 폴더 안에 ffplay라는 exe파일도 있을것이다.

이것을 cmd를 이용해 아래 그림 6과 같이 수신해 볼 수 있다.

[그림 6] ffplay를 이용한 RTP 스트리밍 수신




다음엔 RTP Streaming을 ffmpeg 라이브러리를 이용해 수신 하는 방법을 포스팅 해야겠다.

'프로그래밍 > C++' 카테고리의 다른 글

Union-Find  (0) 2018.05.23
Sort  (0) 2018.05.14
Const Reference  (0) 2017.04.20
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함