春休みの自由研究(?)として、生の動画データをRTPに乗せて送るプログラムを書いていろいろと学んだので書き残す。
RAW Video (Uncompressed Video)とは
直訳すると「生の動画」。その名の通り何の圧縮もしてない動画データのこと。
RAW Videoにもいくつか形式があるが、わかりやすいのはRGB。次のように前からR, G, Bの画素が並んでいる画像が1フレームごとに格納されている。
RGBそれぞれの色が8bitの形式だと、8 * 3 = 24bit (3 bytes) が1ピクセルとなる。 よって、たとえば320x240 の動画だと1フレームあたり 320 * 240 * 3 = 230400 bytesの長さのデータになる。
RTPとは
RTPとは、ネットワーク越しに動画や音声のデータを運ぶことができるプロトコルである。
動画のデータを送るとなると、ネットワーク的にはUDPの使用が適している1
- データの一部が失われても、次のフレームが来たら修復できる
- データの順序が一部変わっても、映像の見た目はそこまで損なわれない
しかし、動画のデータはサイズがでかいので、基本的にUDPのパケットに収まりきらない。 そこで、RTPは動画フレームを複数のパケットに分割することで、その問題を解決してくれる!
RFC4175 について
RFC4175はRTPの上でRAW Videoデータを運ぶためのパケットの構造を規定した文書。これに沿ってRTPパケットを作って送ると受信先で動画が再生できるということらしい。
でかい動画フレームをどうやって分割しているか
さっそく、RFC4175ではでかい動画のフレームをどうやって分割しているか調べた。
動画フレームは(幅)x(高さ)の行列になっているが、まず下の図のように1行ずつをScan lineという単位で分割する。
RFC4175ではこのScan lineの情報を最初にヘッダーに並べて、ヘッダーが終わったら実際の画像データが並ぶ。
たとえば、真っ白な映像を流してWiresharkでパケットを見てみると、ヘッダー部分(00 00 03 ...)と、そのあとの0xff地帯(真っ白な画像)があるのがわかりやすい。
複数のScan lineを1パケットに詰め込む
このScan lineがある程度短い場合は1パケットの中に複数詰め込むこともできる。その場合は、Continuation flagというフラグが活用される。 このフラグが1になっていると、「まだ次のヘッダーがあるよ」という指示になる。
Continuation flagが0になるまでヘッダーは複数個並ぶようになり、ヘッダーが終わったら画像データもヘッダーと同じ個数分が並ぶ。
たとえば、Scan lineが2つあると次のように(length, line No, offset)のヘッダーが2つ最初に並んでいることがわかる。青文字で C と書いているのが Continuation flag。
1つのScan lineを複数パケットに分割する
動画の横幅がかなり大きくて、ひとつのScan lineすらパケットに収まりきらない場合はどうするか?
RTPではちゃんと対処できる!なぜなら、Scan lineのヘッダーにはOffsetというパラメータがあり、Scan lineの途中の位置からのデータが入ってることを示せる。
ひとつ注意なのが、Offsetの単位はバイトではなくピクセルであること。 1ピクセルあたり3バイトのRGBだとすると、Offsetはバイト数的には3倍進んでいるということになるのでパケットを作るときに間違えないように(自分はここで一度間違えて変なことになった)
RAW VideoをRTP経由で送る
といっても文章で見ただけでは何の感動もないので実際にRTPパケットを作り出してみることにした。
- 320x240のRGB形式のRAW VideoをRTPでひたすら送る
- 色は真っ白から始まって、だんだん暗くなる→真っ白に戻るの繰り返し
この例はC# (.NET Core 3.1) で書いたけど、バイナリを作ってUDPで送信するだけなのでどんな言語でも大して変わらないはず。
using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; namespace ConsoleApp1 { class Program { private static int seqNo = 0; static void Main(string[] args) { var udpClient = new UdpClient("127.0.0.1", 9999); byte color = 0xff; while (true) { var frameData = Enumerable.Repeat(color, 320 * 240 * 3).ToArray(); SendRawVideoFrameOnRtp(udpClient, 320, 240, frameData); color--; Thread.Sleep(10); } } static void SendRawVideoFrameOnRtp(UdpClient udpClient, int width, int height, byte[] frameData) { const int RtpHeaderSize = 12; const int PayloadHeaderSize = 2; const int ScanLineHeaderSize = 6; const int MTU = 1500; const int bpp = 3; var rtpHeader = new byte[12]; var ssrc = 0; rtpHeader[0] = 0x80; rtpHeader[1] = 0x7f; rtpHeader[8] = 0x00; // SSRC rtpHeader[9] = 0x00; rtpHeader[10] = 0x00; rtpHeader[11] = 0x00; var timestamp = (int)new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds(); rtpHeader[4] = (byte) (timestamp >> 24 & 0b11111111); rtpHeader[5] = (byte) (timestamp >> 16 & 0b11111111); rtpHeader[6] = (byte) (timestamp >> 8 & 0b11111111); rtpHeader[7] = (byte) (timestamp & 0b11111111); var linesPerPacket = Math.Max(1, (MTU - RtpHeaderSize - PayloadHeaderSize) / width / bpp); var frameBytes = width * height * bpp; var maxPayloadBytes = MTU - RtpHeaderSize - PayloadHeaderSize - ScanLineHeaderSize * linesPerPacket; var bytesLeft = frameBytes; var lineNo = 0; var offsetPixels = 0; var bytesWritten = 0; var sendTasks = new List<Task<int>>(); var sendBuffer = new MemoryStream(MTU); while (bytesLeft > 0) { sendBuffer.Seek(0, SeekOrigin.Begin); // write RTP header rtpHeader[2] = (byte) (seqNo >> 8 & 0xff); rtpHeader[3] = (byte) (seqNo & 0xff); seqNo++; var videoPayloadBytes = 0; var payloadHeader = new byte[PayloadHeaderSize + ScanLineHeaderSize * linesPerPacket]; payloadHeader[0] = 0x00; // Extended Sequence Number payloadHeader[1] = 0x00; for (var i = 0; i < linesPerPacket; i++) { // write RFC4175 header var linePixelsLeft = width - offsetPixels; var lineBytesLeft = linePixelsLeft * bpp; var lineFragmented = lineBytesLeft > maxPayloadBytes; var lineSegmentPixels = lineFragmented ? maxPayloadBytes / bpp: linePixelsLeft; var lineSegmentBytes = lineFragmented ? maxPayloadBytes : linePixelsLeft * bpp; payloadHeader[2 + i * ScanLineHeaderSize + 0] = (byte) (lineSegmentBytes >> 8 & 0b11111111); payloadHeader[2 + i * ScanLineHeaderSize + 1] = (byte) (lineSegmentBytes & 0b11111111); payloadHeader[2 + i * ScanLineHeaderSize + 2] = (byte) (lineNo >> 8 & 0b01111111); payloadHeader[2 + i * ScanLineHeaderSize + 3] = (byte) (lineNo & 0b11111111); payloadHeader[2 + i * ScanLineHeaderSize + 4] = (byte) (offsetPixels >> 8 & 0b11111111); payloadHeader[2 + i * ScanLineHeaderSize + 5] = (byte) (offsetPixels & 0b11111111); var continuation = i < linesPerPacket - 1; if (continuation) { payloadHeader[2 + i * ScanLineHeaderSize + 4] |= 0b10000000; // set continuation bit } if (lineFragmented) { offsetPixels += lineSegmentPixels; } else { lineNo++; offsetPixels = 0; } videoPayloadBytes += lineSegmentBytes; bytesLeft -= lineSegmentBytes; if (lineNo > height - 1) { break; } } if (bytesLeft <= 0) { rtpHeader[1] |= 0b10000000; // set marker bit } sendBuffer.Write(rtpHeader, 0, RtpHeaderSize); sendBuffer.Write(payloadHeader, 0, payloadHeader.Length); // write video frame payload var span = new ReadOnlySpan<byte>(frameData).Slice(bytesWritten, videoPayloadBytes); sendBuffer.Write(span); sendTasks.Add(udpClient.SendAsync(sendBuffer.ToArray(), (int) sendBuffer.Length)); } rtpHeader[1] &= 0b01111111; // reset marker bit Task.WaitAll(sendTasks.ToArray()); } } }
では、GStreamerを使って、RTPを受信して動画を表示させてみる。
capsという変数を指定しているが、これはRAW Videoがどのような形式か、表示するために必要になる。(細かいことはGStreamerの話になってしまうのでここでは省略…)
$ export caps="application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)RAW, sampling=(string)RGB, depth=(string)8, width=(string)320, height=(string)240, colorimetry=(string)BT601-5, payload=(int)127, ssrc=(uint)0, timestamp-offset=(uint)0, seqnum-offset=(uint)0" $ gst-launch-1.0 -v udpsrc port=9999 caps="$caps" ! rtpvrawdepay ! autovideosink
ちゃんと動画として再生された! とても単純な仕組みではあるけど、こうやって自分でコードを書いて確かめると勉強になった感があって楽しい!
-
RTPはUDPで使うことが多いものの、UDP以外のプロトコルでも使えるように設計されているらしい)↩