Python
WebRTC
drone
tello
aiortc

トイドローン Tello をWebRTCでつなげてみた

トイドローン Tello をWebRTCでつなげてみた

tello用のpythonライブラリtellpyから取得できるstreamをフレームに変換してWebRTCで映像送信をしてみました。

tellpyから取得できるstreamはキューイングされているのでWebRTCで接続した際に1分程のbig-lag(遅延)が発生していました。苦肉の策でDatachannelが接続が確立した段階で一度そのバッファをクリアしています。

注意

たまに接続が切れたり制御不能になったりするので、自己責任にてお願い致します。

環境

mac osx high sierra 10.13.5
python 3.6.1
ffmpeg 4.0.1

コード

requirement.txt

aiohttp==3.3.2
aioice==0.6.2
aiortc==0.8.0
asn1crypto==0.24.0
async-timeout==3.0.0
attrs==18.1.0
av==0.4.1
cffi==1.11.5
chardet==3.0.4
crcmod==1.7
cryptography==2.2.2
Django==2.0.7
idna==2.7
idna-ssl==1.0.1
image==1.5.24
multidict==4.3.1
netifaces==0.10.7
numpy==1.14.5
opencv-python==3.4.1.15
Pillow==5.2.0
pycparser==2.18
pyee==5.0.0
pygame==1.9.3
pylibsrtp==0.5.5
pyOpenSSL==18.0.0
pytz==2018.5
six==1.11.0
tellopy==0.4.0
websockets==5.0.1
yarl==1.2.6

server.py

import argparse
import asyncio
import json
import logging
import math
import os
import time
import wave

import cv2
import numpy
from aiohttp import web

import sys
import traceback
import tellopy
import av

from aiortc import RTCPeerConnection, RTCSessionDescription
from aiortc.mediastreams import (AudioFrame, AudioStreamTrack, VideoFrame,
                                 VideoStreamTrack)

ROOT = os.path.dirname(__file__)

def frame_from_bgr(data_bgr):
    data_yuv = cv2.cvtColor(data_bgr, cv2.COLOR_BGR2YUV_YV12)
    return VideoFrame(width=data_bgr.shape[1], height=data_bgr.shape[0], data=data_yuv.tobytes())


def frame_from_gray(data_gray):
    data_bgr = cv2.cvtColor(data_gray, cv2.COLOR_GRAY2BGR)
    data_yuv = cv2.cvtColor(data_bgr, cv2.COLOR_BGR2YUV_YV12)
    return VideoFrame(width=data_bgr.shape[1], height=data_bgr.shape[0], data=data_yuv.tobytes())


def frame_to_bgr(frame):
    data_flat = numpy.frombuffer(frame.data, numpy.uint8)
    data_yuv = data_flat.reshape((math.ceil(frame.height * 12 / 8), frame.width))
    return cv2.cvtColor(data_yuv, cv2.COLOR_YUV2BGR_YV12)


class VideoTransformTrack(VideoStreamTrack):
    def __init__(self, container, video_stream, width, height):
        self.container = container
        self.video_stream = video_stream
        self.frame_skip = 300
        self.total_frame = 0
        self.frame = VideoFrame(width=width,height=height)

    async def recv(self):
        for packet in self.container.demux(self.video_stream):
            try:
                frames = packet.decode()
                self.total_frame = self.total_frame + len(frames)
                for frm in frames:
                    if 0 < self.frame_skip:
                        self.frame_skip = self.frame_skip - 1
                        continue
                    start_time = time.time()
                    self.frame = frame_from_bgr(numpy.array(frm.to_image()))
                    self.frame_skip = 1 
                    #self.frame_skip = int((time.time() - start_time)/frm.time_base) 
                    cv2.waitKey(1)
                    return self.frame
            except AVError as err:
                print(err)
                return self.frame

        return self.frame

async def index(request):
    content = open(os.path.join(ROOT, 'index.html'), 'r').read()
    return web.Response(content_type='text/html', text=content)

async def javascript(request):
    content = open(os.path.join(ROOT, 'client.js'), 'r').read()
    return web.Response(content_type='application/javascript', text=content)

async def offer(request):
    params = await request.json()
    offer = RTCSessionDescription(
        sdp=params['sdp'],
        type=params['type'])

    pc = RTCPeerConnection()
    pc._consumers = []
    pcs.append(pc)

    width = 1920
    height = 1080
    local_video = VideoTransformTrack(container=container,video_stream=video_stream,width=width,height=height)
    pc.addTrack(local_video)

    @pc.on('datachannel')
    def on_datachannel(channel):
        @channel.on('message')
        def on_message(message):
            if message == "connect":
                 vs.queue = []  #キューイングされているデータをクリア
            elif message == "ping":
                channel.send('pong')
            elif message == "takeoff":
                drone.takeoff() 
            elif message == "down":
                drone.down(50)
            elif message == "land":
                drone.land() 

    await pc.setRemoteDescription(offer)
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)

    return web.Response(
        content_type='application/json',
        text=json.dumps({
            'sdp': pc.localDescription.sdp,
            'type': pc.localDescription.type
        }))


pcs = []
drone = {}
container = {}
video_stream = {}
vs = {}

async def on_shutdown(app):
    # close peer connections
    coros = [pc.close() for pc in pcs]
    await asyncio.gather(*coros)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='WebRTC audio / video / data-channels demo')
    parser.add_argument('--port', type=int, default=8080,
                        help='Port for HTTP server (default: 8080)')
    parser.add_argument('--verbose', '-v', action='count')
    args = parser.parse_args()

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)

    drone = tellopy.Tello()
    drone.connect()
    drone.wait_for_connection(60.0)    
    drone.start_video()
    vs = drone.get_video_stream()
    container = av.open(vs)
    video_stream = next(s for s in container.streams if s.type == 'video')

    app = web.Application()
    app.on_shutdown.append(on_shutdown)
    app.router.add_get('/', index)
    app.router.add_get('/client.js', javascript)
    app.router.add_post('/offer', offer)
    web.run_app(app, port=args.port)

index.html

<html>
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebRTC demo</title>
    <style>
    button {
        padding: 8px 16px;
    }

    pre {
        overflow-x: hidden;
        overflow-y: auto;
    }

    video {
        width: 100%;
    }

    .option {
        margin-bottom: 8px;
    }

    #media {
        max-width: 1280px;
    }
    </style>
</head>
<body>

<h2>Options</h2>
<div class="option">
    <input id="use-datachannel" checked="checked" type="checkbox"/>
    <label for="use-datachannel">Use datachannel</label>
</div>

<button id="start" onclick="start()">Start</button>
<button id="stop" style="display: none" onclick="stop()">Stop</button>
<button onclick="command('takeoff')">takeoff</button>
<button onclick="command('down')">down</button>
<button onclick="command('land')">land</button>

<h2>State</h2>
<p>
    ICE gathering state: <span id="ice-gathering-state"></span>
</p>
<p>
    ICE connection state: <span id="ice-connection-state"></span>
</p>
<p>
    Signaling state: <span id="signaling-state"></span>
</p>

<div id="media" style="display: none">
    <h2>Media</h2>

    <p>You should see a video alternating between the raw and transformed video.</p>
    <audio id="audio" autoplay="true"></audio>
    <video id="video" autoplay="true"></video>
</div>

<h2>Data channel</h2>
<pre id="data-channel" style="height: 200px;"></pre>

<h2>SDP</h2>

<h3>Offer</h3>
<pre id="offer-sdp"></pre>

<h3>Answer</h3>
<pre id="answer-sdp"></pre>

<script src="client.js"></script>
</body>
</html>

client.js

var pc = new RTCPeerConnection();

// get DOM elements
var dataChannelLog = document.getElementById('data-channel'),
    iceConnectionLog = document.getElementById('ice-connection-state'),
    iceGatheringLog = document.getElementById('ice-gathering-state'),
    signalingLog = document.getElementById('signaling-state');

// register some listeners to help debugging
pc.addEventListener('icegatheringstatechange', function() {
    iceGatheringLog.textContent += ' -> ' + pc.iceGatheringState;
}, false);
iceGatheringLog.textContent = pc.iceGatheringState;

pc.addEventListener('iceconnectionstatechange', function() {
    iceConnectionLog.textContent += ' -> ' + pc.iceConnectionState;
}, false);
iceConnectionLog.textContent = pc.iceConnectionState;

pc.addEventListener('signalingstatechange', function() {
    signalingLog.textContent += ' -> ' + pc.signalingState;
}, false);
signalingLog.textContent = pc.signalingState;

// connect audio / video
pc.addEventListener('track', function(evt) {
    if (evt.track.kind == 'video')
        document.getElementById('video').srcObject = evt.streams[0];
    else
        document.getElementById('audio').srcObject = evt.streams[0];
});

// data channel
var dc = null, dcInterval = null;

function negotiate() {
    return pc.createOffer({offerToReceiveVideo : true, offerToReceiveAudio: false}).then(function(offer) {
        return pc.setLocalDescription(offer);
    }).then(function() {
        // wait for ICE gathering to complete
        return new Promise(function(resolve) {
            if (pc.iceGatheringState === 'complete') {
                resolve();
            } else {
                function checkState() {
                    if (pc.iceGatheringState === 'complete') {
                        pc.removeEventListener('icegatheringstatechange', checkState);
                        resolve();
                    }
                }
                pc.addEventListener('icegatheringstatechange', checkState);
            }
        });
    }).then(function() {
        var offer = pc.localDescription;
        document.getElementById('offer-sdp').textContent = offer.sdp;
        return fetch('/offer', {
            body: JSON.stringify({
                sdp: offer.sdp,
                type: offer.type
            }),
            headers: {
                'Content-Type': 'application/json'
            },
            method: 'POST'
        });
    }).then(function(response) {
        return response.json();
    }).then(function(answer) {
        document.getElementById('answer-sdp').textContent = answer.sdp;
        return pc.setRemoteDescription(answer);
    });
}

function start() {
    document.getElementById('start').style.display = 'none';

    if (document.getElementById('use-datachannel').checked) {
        dc = pc.createDataChannel('chat');
        dc.onclose = function() {
            clearInterval(dcInterval);
            dataChannelLog.textContent += '- close\n';
        };
        dc.onopen = function() {
            dataChannelLog.textContent += '- open\n';
            dc.send('connect');
            dcInterval = setInterval(function() {
                var message = 'ping';
                dataChannelLog.textContent += '> ' + message + '\n';
                dc.send(message);
            }, 10000);
        };
        dc.onmessage = function(evt) {
            dataChannelLog.textContent += '< ' + evt.data + '\n';
        };
    }

    document.getElementById('media').style.display = 'block';
    negotiate();

    document.getElementById('stop').style.display = 'inline-block';
}
function command(text) {
    dc.send(text);
}
function stop() {
    document.getElementById('stop').style.display = 'none';

    // close data channel
    if (dc) {
        dc.close();
    }

    // close audio / video
    pc.getSenders().forEach(function(sender) {
        sender.track.stop();
    });

    // close peer connection
    setTimeout(function() {
        pc.close();
    }, 500);
}

動かし方

pip install -r requirement.txt

1.telloの電源をオン
2.macよりwifi接続
3.python server.py
4.chromeブラウザでhttp://localhost:8080 にアクセス
5.startボタンをクリック
6.シグナリングが開始されます。(しばらく待つ 以外と時間がかかります)
7.WebRTCの接続完了するとchromeブラウザにtelloの映像が表示されます。

ライブラリ

https://github.com/hanyazou/TelloPy
https://github.com/jlaine/aiortc

追記:重複フレームで遅延が発生していましたので修正しました。