189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
nao_video_server.py - RUNS ON THE ROBOT, under its own Python 2.7.
|
|
|
|
Opens a qi session to NAOqi over localhost (zero network hop - it's
|
|
the same box) and serves an MJPEG video stream over plain HTTP. Only
|
|
compressed JPEG bytes ever cross the phone tunnel, instead of raw RGB
|
|
frames.
|
|
|
|
Needs the SDK's own env vars, since the bindings aren't on a login
|
|
shell's default path:
|
|
|
|
export PYTHONPATH=/opt/aldebaran/lib/python2.7/site-packages
|
|
export LD_LIBRARY_PATH=/opt/aldebaran/lib
|
|
python2 nao_video_server.py
|
|
|
|
No Flask here on purpose - it's very unlikely to be installed on this
|
|
locked-down embedded OS and there's no easy way to pip install it
|
|
without network access on the robot. This uses only the stdlib
|
|
(BaseHTTPServer/SocketServer) plus qi and cv2, which we've confirmed
|
|
are already present.
|
|
"""
|
|
import json
|
|
import time
|
|
import threading
|
|
|
|
import BaseHTTPServer
|
|
import SocketServer
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import qi
|
|
|
|
# ---- tunables ----
|
|
NAOQI_PORT = 9561 # local qi session port (confirmed working)
|
|
HTTP_PORT = 8000 # forward this through the phone tunnel
|
|
CAMERA_RESOLUTION = 1 # 0=kQQVGA(160x120) 1=kQVGA(320x240) 2=kVGA(640x480)
|
|
CAMERA_COLORSPACE = 11 # kRGBColorSpace
|
|
CAMERA_FPS = 20
|
|
JPEG_QUALITY = 60 # 1-100 - tune this first if the link is still tight
|
|
|
|
|
|
class FrameGrabber(object):
|
|
"""Background thread: keeps the single most recent JPEG-encoded
|
|
frame available. Same pattern as naowalk.py's own _video_loop -
|
|
a stalled capture never blocks a client's HTTP request."""
|
|
|
|
def __init__(self, session):
|
|
self.video = session.service("ALVideoDevice")
|
|
self.client = self.video.subscribeCamera(
|
|
"MjpegServer", 0, CAMERA_RESOLUTION, CAMERA_COLORSPACE, CAMERA_FPS)
|
|
self._lock = threading.Lock()
|
|
self._jpeg = None
|
|
self._stop = threading.Event()
|
|
self._thread = threading.Thread(target=self._loop)
|
|
self._thread.daemon = True
|
|
self._thread.start()
|
|
|
|
def _loop(self):
|
|
while not self._stop.is_set():
|
|
try:
|
|
image = self.video.getImageRemote(self.client)
|
|
if not image or len(image) < 7:
|
|
continue
|
|
w, h = image[0], image[1]
|
|
arr = np.frombuffer(bytearray(image[6]), dtype=np.uint8).reshape((h, w, 3))
|
|
bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
|
|
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
|
|
if ok:
|
|
with self._lock:
|
|
self._jpeg = buf.tobytes()
|
|
except Exception as e:
|
|
print("frame grab error: %s" % e)
|
|
time.sleep(0.2)
|
|
|
|
def latest(self):
|
|
with self._lock:
|
|
return self._jpeg
|
|
|
|
def close(self):
|
|
self._stop.set()
|
|
try:
|
|
self.video.unsubscribe(self.client)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class Handler(BaseHTTPServer.BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args):
|
|
pass # keep the robot's console quiet
|
|
|
|
def do_GET(self):
|
|
if self.path == "/video":
|
|
self._serve_video()
|
|
elif self.path == "/battery":
|
|
self._serve_battery()
|
|
elif self.path == "/health":
|
|
self._ok()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.getheader("content-length", 0))
|
|
raw = self.rfile.read(length) if length else "{}"
|
|
try:
|
|
payload = json.loads(raw)
|
|
except ValueError:
|
|
payload = {}
|
|
|
|
if self.path == "/move":
|
|
x = float(payload.get("x", 0.0))
|
|
y = float(payload.get("y", 0.0))
|
|
theta = float(payload.get("theta", 0.0))
|
|
self.server.motion.moveToward(x, y, theta)
|
|
self._ok()
|
|
elif self.path == "/stop":
|
|
self.server.motion.stopMove()
|
|
self._ok()
|
|
elif self.path == "/say":
|
|
self.server.tts.say(str(payload.get("text", "")))
|
|
self._ok()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def _ok(self):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(b'{"ok": true}')
|
|
|
|
def _serve_battery(self):
|
|
try:
|
|
pct = self.server.battery.getBatteryCharge() if self.server.battery else -1
|
|
except Exception:
|
|
pct = -1
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"battery": pct}).encode())
|
|
|
|
def _serve_video(self):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
self.end_headers()
|
|
try:
|
|
while True:
|
|
jpeg = self.server.grabber.latest()
|
|
if jpeg is not None:
|
|
self.wfile.write(b"--frame\r\n")
|
|
self.wfile.write(b"Content-Type: image/jpeg\r\n")
|
|
self.wfile.write(b"Content-Length: %d\r\n\r\n" % len(jpeg))
|
|
self.wfile.write(jpeg)
|
|
self.wfile.write(b"\r\n")
|
|
time.sleep(1.0 / CAMERA_FPS)
|
|
except Exception:
|
|
pass # client disconnected - totally normal, not an error
|
|
|
|
|
|
class ThreadingHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def main():
|
|
session = qi.Session()
|
|
session.connect("tcp://127.0.0.1:%d" % NAOQI_PORT)
|
|
print("connected to local NAOqi on port %d" % NAOQI_PORT)
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), Handler)
|
|
server.grabber = FrameGrabber(session)
|
|
server.motion = session.service("ALMotion")
|
|
server.tts = session.service("ALTextToSpeech")
|
|
try:
|
|
server.battery = session.service("ALBattery")
|
|
except Exception:
|
|
server.battery = None
|
|
|
|
print("serving on :%d (/video, /move, /stop, /say, /battery, /health)" % HTTP_PORT)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.grabber.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|