DEV Community

Carlos Vieira
Carlos Vieira

Posted on • Edited on

Dockerizing a Flask-based web camera application

In this tutorial we'll be:

Creating our web camera app

First off, we'll use Flask, a light web framework, to quickly set up a web application. This app will also use OpenCV for taking pictures.

For this part of the tutorial, we'll give a brief overview, since we're really focused on the dockerization aspect of this app, but all code can be found on github:

There are two main pages for the app, index, where you can take a picture, and capture, where you can see a captured picture, and download it or email it to yourself or someone else.

So we define routes for each of these pages, which will render the respective templates:

# route for index (picture-taking) page
@app.route('/index/')
def index():
    return render_template('index.html')

# route for capturing picture
@app.route('/capture/')
def capture():
    camera = get_camera()
    stamp = camera.capture()
    return redirect(url_for('show_capture', timestamp=stamp))

# route for showing captured picture
@app.route('/capture/image/<timestamp>', methods=['POST', 'GET'])
def show_capture(timestamp):
    path = stamp_file(timestamp)
    ...
    return render_template('capture.html',
        stamp=timestamp, path=path, email_msg=email_msg)
Enter fullscreen mode Exit fullscreen mode

Note that we omit the code that handles email sending, which will be a POST request on the same route as that for showing a captured picture (implemented by show_capture).

Besides these functions, a critical part of our code is the one that gets the camera feed:

def get_camera():
    global camera
    if not camera:
        camera = Camera()

    return camera

def gen(camera):
    while True:
        frame = camera.get_feed()
        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')

@app.route('/video_feed/')
def video_feed():
    camera = get_camera()
    return Response(gen(camera),
        mimetype='multipart/x-mixed-replace; boundary=frame')
Enter fullscreen mode Exit fullscreen mode

So Camera is a class we created, and which we'll get into in a minute. get_camera implements what is basically a singleton pattern, lazily loading a single instance of the Camera when necessary.

video_feed is a route used inside index, in the index.html template, as an image link:

<div id="camera">
  <img src="{{ url_for('video_feed') }}" alt="video feed">
</div>
Enter fullscreen mode Exit fullscreen mode

So the video_feed returns a Response object containing an iterator which continuously generates frames from our Camera, and the corresponding mimetype multipart/x-mixed-replace, which allows us to continuously replace one frame with the next.

gen is a generator function which constantly gets a camera frame (a binary jpeg image) and returns binary data containing that image as well as the necessary headers.

Now, as for the Camera class: it basically instantiates an OpenCV VideoCapture object and gives frames as necessary, also capturing a particular frame when requested. Capturing implies saving the image locally (temporarily, at least), and returning a timestamp which serves as an unique identifier.

class Camera(object):
    CAPTURES_DIR = "static/captures/"
    def __init__(self):
        self.video = cv.VideoCapture(0)

    def get_frame(self):
        success, frame = self.video.read()
        ...  
        return frame

    def get_feed(self):
        frame = self.get_frame()
        if frame is not None:
            ret, jpeg = cv.imencode('.jpg', frame)
            return jpeg.tobytes()

    def capture(self):
        frame = self.get_frame()
        timestamp = strftime("%d-%m-%Y-%Hh%Mm%Ss", localtime())
        filename = Camera.CAPTURES_DIR + timestamp +".jpg"
        if not cv.imwrite(filename, frame):
            raise RuntimeError("Unable to capture image "+timestamp)
        return timestamp

Enter fullscreen mode Exit fullscreen mode

We can now run this code with python web.py. Note that it's running on debug mode on localhost:8080. That can be changed in a number of ways, such as editing the line below on main, but be sure to check out the Flask docs for more details.

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080, debug=True)
Enter fullscreen mode Exit fullscreen mode

We can see below what our image capturing page and capture viewing page look like:
Image capturing page
Capture viewing page

Dockerizing our camera app

When building our Dockerfile, we'll start from a Linux base (Ubuntu, in this case) instead of using a straight Python environment, as it's a little more flexible, and makes dependency management clearer. But, you always have the option of building off of the Python image instead. This is done in the first line: FROM ubuntu:18.04.

Now, we embed some metadata into our image with the LABEL instruction. In this case, we simply define the project maintainer, like so: LABEL maintainer="me@mail.com" (this is of course a template, which should be replaced with your own information).

Now we get into configuring our custom environment. First we update and install necessary packages:

RUN apt-get update -y && apt-get install -y build-essential cmake \
libsm6 libxext6 libxrender-dev \
python3 python3-pip python3-dev
Enter fullscreen mode Exit fullscreen mode

The libsm6 libxext6 libxrender-dev sequence of packages is important for a proper installation of OpenCV later on. Note that we will be using python 3, and pip as a package manager.

Next is WORKDIR /app. This creates a directory at root, and sets it as the working directory for any following commands. We copy our requirements file first, before any source code, with COPY ./requirements.txt /app/requirements.txt. We do this to take advantage of Docker's layer-based cache system.

Finally, the instruction RUN pip3 install -r requirements.txt installs all of our dependencies (Flask and OpenCV), and we are ready to run our app. Note that we don't have to specify the whole path to the requirements file, as it is already in our working directory.

So, as our container will run as an executable (as opposed to a shell), we define our entry point: ENTRYPOINT ["python3"], then run our application with CMD ["web.py"]. Below, we can review our whole Dockerfile.

FROM ubuntu:18.04
LABEL maintainer="me@mail.com"

RUN apt-get update -y && apt-get install -y build-essential cmake \
libsm6 libxext6 libxrender-dev \
python3 python3-pip python3-dev

COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip3 install -r requirements.txt

COPY . /app
ENTRYPOINT ["python3"]
CMD ["web.py"]
Enter fullscreen mode Exit fullscreen mode

Running docker build -t camera-app . will now build our image named camera-app. We can then instantiate a container from it with the following command: docker run -d --name cam --device /dev/video0 -p 8080:8080 camera-app.

  • -d, or --detach, makes the container run in the background.
  • --device /dev/video0 adds a device from the host, in this case a camera, to the container.
  • -p 8080:8080, for --publish, will publish or map a container's port (the one on the right side of the colon) to a port on the host (on the left).

Photo storage with docker-compose

Now, we have our Flask web app working with Docker. But, pictures captured do not persist and are not easily accessible, being in a temporary volume created by Docker.

So we will use a self-hosted file storage server with a web interface called droppy:

GitHub logo silverwind / droppy

**ARCHIVED** Self-hosted file storage

We use droppy because it is very lightweight, has a simple web interface, and has a very easy setup, since it provides a ready to use Docker image. But keep in mind another type of server for image storage could also be used with Docker, and would have a very similar setup.

Droppy will run as a separate container from the one running our camera app. But these two container can be orchestrated together easily, thanks to Docker Compose.

Our docker-compose file (which is a yaml data file) will have two important parts: volumes and services. In volumes, we create a volume called captures, which will store our captured images and be shared between the two containers.

In services, we configure our two services, which will run off of our two images: camera-app, and droppy. In the file below, we can see these two services have some things in common. For both, we define

  • container_name, the name of the container , once it is instantiated;
  • image, the image which will be used to instantiate the container;
  • ports, similarly to the -p flag discussed previously, will map the necessary ports for the localhost; and
  • volumes will mount the volume we created (captures) into the directory where the images are saved in the camera-app container, and also into the file storage directory in the droppy container.

This last instruction makes it so that, essentially, images are uploaded to droppy as soon as they are captured by our camera app. Besides these shared fields, we still have to map our camera device from the host to the camera app container, as we have before, on devices.

version: '3'
volumes:
  captures:
services:
  droppy:
    container_name: droppy
    image: silverwind/droppy
    ports:
      - '127.0.0.1:8989:8989'
    volumes:
      - captures:/files
  camera-app:
    container_name: cam
    image: camera-app
    ports:
      - '127.0.0.1:8080:8080'
    devices:
      - '/dev/video0:/dev/video0'
    volumes:
      - captures:/app/static/captures
Enter fullscreen mode Exit fullscreen mode

Running this setup with docker-compose up, we can now access our camera at localhost:8080, and our droppy image server at localhost:8989. Any image taken on the camera app will show up on droppy server, like we see below.

droppy screenshot

When you're done, make sure to use the -v flag when running docker-compose down if you wish to clear the volume we created, containing all images captured. Docker Compose makes integrating applications like this very straightforward, so have fun configuring this setup for your own needs, or running your very own containers!

Top comments (3)

Collapse
 
brunooliveira profile image
Bruno Oliveira

Awesome!!

Collapse
 
tanpl profile image
TanPL

Adding "frame=cv.flip(frame, 1)" after "success, frame = self.video.read()" can make it works like a mirror.

Collapse
 
marcelofa profile image
João Marcelo Freitas de Almeida

Muito bom!!