Tutorial – Python MJPEG Video Streaming on Raspberry Pi with Overlays

Tutorial – Python MJPEG Video Streaming on Raspberry Pi with Overlays

This article talks about how to stream video in MJPEG/MJPG format from your Raspberry Pi using Pure Python and openCV.   It supports a frame rate > 24FPS and allows overlays and information to be added on a frame by frame basis.

This software is used the the new SkyWeather system to stream live video to the WeatherSTEM cloud for use by the public.

SkyWeather  allows you to build your own weather station with a Sky Cam to take pictures of your Sky and display them the cloud on WeatherSTEM.  You might even make it to the WeatherChannel!  

All up-to-date information for SkyWeather is here.

What is MJPEG

Motion JPEG (M-JPEG or MJPEG) is a video compression format in which each video frame or interlaced field of a digital video sequence is compressed separately as an individual JPEG image. Originally developed for multimedia PC applications, M-JPEG is now used by video-capture devices such as digital cameras, IP cameras, and webcams, as well as by non-linear video editing systems. It is natively supported by the QuickTime Player, the PlayStation console, and web browsers such as Safari, Google Chrome, Mozilla Firefox and Microsoft Edge.

This format works really well for applications that want to be able to easily process each frame on the fly, versus a compressed format like mp4 or mpeg which needs to be decompressed on the fly to be manipulated by the Raspberry Pi.

MJPEG Video Streaming in Pure Python

This software gives an example of how to stream MJPEG/MJPG video on the Raspberry Pi using the PiCamera library using overlays. This is the same software that is being used in the SwitchDoc Labs SkyWeather product in conjunction with WeatherSTEM.

It  is designed to do Python video streaming in thread on SkyWeather, with single picture capability and the ability to add overlays / information on the fly.

This software requires a Rapsberry Pi 3B+ or greater.

Software Contents

streamtest.py

This Python program streams video using the openCV and the PiCamera library. Each frame is intercepted and various overlays and information is placed on the frame then sent to the video stream.

Defaults to streaming on:

http://localhost:443/

cvgrab.py

Grabs a single frame from the MJPEG video stream using openCV. Writes to a file “test.jpg”

GitHub Repository

You can find this software here:

https://github.com/switchdoclabs/SDL_Pi_MJPEGStream

Software Description

The streamtest.py software is pretty straight forward.  There are three main sections to the streamtest.py software.

Streaming Handler

class StreamingHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            self.send_response(301)
            self.send_header('Location', '/index.html')
            self.end_headers()
        elif self.path == '/index.html':
            content = PAGE.encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'text/html')
            self.send_header('Content-Length', len(content))
            self.end_headers()
            self.wfile.write(content)
        elif self.path == '/stream.mjpg':
            self.send_response(200)
            self.send_header('Age', 0)
            self.send_header('Cache-Control', 'no-cache, private')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME')
            self.end_headers()
            try:
                while True:
                    with output.condition:
                        output.condition.wait()
                        frame = output.frame
                        # now add timestamp to jpeg
                        # Convert to PIL Image
                        cv2.CV_LOAD_IMAGE_COLOR = 1 # set flag to 1 to give colour image
                        npframe = np.fromstring(frame, dtype=np.uint8)
                        pil_frame = cv2.imdecode(npframe,cv2.CV_LOAD_IMAGE_COLOR)
                        #pil_frame = cv2.imdecode(frame,-1)
                        cv2_im_rgb = cv2.cvtColor(pil_frame, cv2.COLOR_BGR2RGB)
                        pil_im = Image.fromarray(cv2_im_rgb)

                        draw = ImageDraw.Draw(pil_im)

                        # Choose a font
                        font = ImageFont.truetype("/usr/share/fonts/truetype/freefont/FreeSans.ttf", 25)
                        myText = "SkyWeather "+dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

                        # Draw the text
                        color = 'rgb(255,255,255)'
                        #draw.text((0, 0), myText,fill = color, font=font)

                        # get text size
                        text_size = font.getsize(myText)

                        # set button size + 10px margins
                        button_size = (text_size[0]+20, text_size[1]+10)

                        # create image with correct size and black background
                        button_img = Image.new('RGBA', button_size, "black")
                        
                        #button_img.putalpha(128)
                        # put text on button with 10px margins
                        button_draw = ImageDraw.Draw(button_img)
                        button_draw.text((10, 5), myText, fill = color, font=font)

                        # put button on source image in position (0, 0)

                        pil_im.paste(button_img, (0, 0))
                        bg_w, bg_h = pil_im.size 
                        # WeatherSTEM logo in lower left
                        size = 64
                        WSLimg = Image.open("WeatherSTEMLogoSkyBackground.png")
                        WSLimg.thumbnail((size,size),Image.ANTIALIAS)
                        pil_im.paste(WSLimg, (0, bg_h-size))

                        # SkyWeather log in lower right
                        SWLimg = Image.open("SkyWeatherLogoSymbol.png")
                        SWLimg.thumbnail((size,size),Image.ANTIALIAS)
                        pil_im.paste(SWLimg, (bg_w-size, bg_h-size))

                        # Save the image
                        buf= StringIO.StringIO()
                        pil_im.save(buf, format= 'JPEG')
                        frame = buf.getvalue()
                    self.wfile.write(b'--FRAME\r\n')
                    self.send_header('Content-Type', 'image/jpeg')
                    self.send_header('Content-Length', len(frame))
                    self.end_headers()
                    self.wfile.write(frame)
                    self.wfile.write(b'\r\n')
            except Exception as e:
                traceback.print_exc()
                logging.warning(
                    'Removed streaming client %s: %s',
                    self.client_address, str(e))
        else:
            self.send_error(404)
            self.end_headers()

The function has three sections in the mpeg stream. First you manipulate the jpeg file from the mjpeg stream to a numpy image and then to a PIL image to work on.

                        frame = output.frame
                        # now add timestamp to jpeg
                        # Convert to PIL Image
                        cv2.CV_LOAD_IMAGE_COLOR = 1 # set flag to 1 to give colour image
                        npframe = np.fromstring(frame, dtype=np.uint8)
                        pil_frame = cv2.imdecode(npframe,cv2.CV_LOAD_IMAGE_COLOR)
                        #pil_frame = cv2.imdecode(frame,-1)
                        cv2_im_rgb = cv2.cvtColor(pil_frame, cv2.COLOR_BGR2RGB)
                        pil_im = Image.fromarray(cv2_im_rgb)

Next you manipulate the PIL image by adding information, overlays, images, etc.

Then finally, you convert it back to a jpeg file and send it off to the video stream. The resulting image looks like this in SkyWeather:


This gives us great flexibility to add overlays and more data as the situation requires.

Streaming Service

 

class StreamingServer(SocketServer.ThreadingMixIn, HTTPServer):
    allow_reuse_address = True
    daemon_threads = True

#with picamera.PiCamera(resolution='640x480', framerate=24) as camera:
#with picamera.PiCamera(resolution='1920x1080', framerate=24) as camera:
with picamera.PiCamera(resolution='1296x730', framerate=24) as camera:
    output = StreamingOutput()
    camera.start_recording(output, format='mjpeg')
    camera.annotate_foreground = picamera.Color(y=0.2,u=0, v=0)
    camera.annotate_background = picamera.Color(y=0.8, u=0, v=0)
    try:
        address = ('', 443)
        server = StreamingServer(address, StreamingHandler)
        server.serve_forever()
    finally:
        camera.stop_recording()

 

Frame Grabbing Software

The cvgrab.py software used to grab individual frames is very short:

import cv2
print "starting grab"
cap = cv2.VideoCapture('http://localhost:443/stream.mjpg')

ret, frame = cap.read()
print "found frame"
#cv2.imshow('Video', frame)
cv2.imwrite("test.jpg",frame)
print "done"
cap.release()
print "after release"

 

Known Issues

We have been running this software for weeks with no significant issues.   One issue that we have run into with the cvgrab software is that the streaming software throws a handled exception each time a frame is grabbed, which while it doesn’t kill the streamer, it prints unwanted data printed to the console.    We have experimented with a couple of different function overrides to catch this text, but don’t have a solution that works inside of SkyWeather that doesn’t have bad side effects.

If you do solve this problem, please let us know!

 

127.0.0.1 - - [15/Jul/2019 09:53:56] "GET /stream.mjpg HTTP/1.1" 200 -
WARNING:root:Removed streaming client ('127.0.0.1', 33240): [Errno 104] Connection reset by peer
Traceback (most recent call last):
  File "/usr/lib/python2.7/SocketServer.py", line 599, in process_request_thread
    self.finish_request(request, client_address)
  File "/usr/lib/python2.7/SocketServer.py", line 334, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/usr/lib/python2.7/SocketServer.py", line 657, in __init__
    self.finish()
  File "/usr/lib/python2.7/SocketServer.py", line 716, in finish
    self.wfile.close()
  File "/usr/lib/python2.7/socket.py", line 283, in close
    self.flush()
  File "/usr/lib/python2.7/socket.py", line 307, in flush
    self._sock.sendall(view[write_offset:write_offset+buffer_size])
error: [Errno 32] Broken pipe

 

Acknowledgements

For the PiCamera basic streaming software:

https://picamera.readthedocs.io/en/release-1.13/recipes2.html

For the idea for using OpenCV for frame grabbing

https://www.pyimagesearch.com/2015/03/30/accessing-the-raspberry-pi-camera-with-opencv-and-python/