Lesson 5 of 5 · 22 min
Camera and boot-time services
A camera is the main reason to put a Pi on a robot instead of a microcontroller. But a robot that only works while you are logged in over SSH is a demo, not a robot. This lesson covers two things: getting images out of the camera with Python, and turning your script into a service that starts on boot, restarts when it crashes, and keeps a log you can read afterwards.
Capturing images with picamera2
Raspberry Pi OS uses the libcamera stack, and picamera2 is its Python interface. Plug the ribbon cable into the camera port (contacts facing the right way, which the connector latch makes obvious), then check that the system sees it:
rpicam-hello --timeout 3000
A preview window needs a screen, but on a headless Pi the command will still report the detected camera. Then, in Python:
from picamera2 import Picamera2
import time
cam = Picamera2()
config = cam.create_still_configuration()
cam.configure(config)
cam.start()
time.sleep(2) # let auto exposure and white balance settle
cam.capture_file("test.jpg")
cam.stop()
The two-second sleep is not decoration. The sensor needs a few frames for auto exposure and white balance to converge, and the first frame after start is often too dark.
Configurations: choosing resolution on purpose
picamera2 asks you to pick a configuration, because the best settings for a photo and for a live robot are different. A still configuration uses the full sensor resolution and favours quality. A preview or video configuration uses a smaller size and favours frame rate.
Resolution costs bandwidth and CPU, so choose the smallest that your task needs. One uncompressed RGB frame at 640 x 480 is 640 x 480 x 3 = 921,600 bytes, and at 30 frames per second that is about 27.6 MB every second. A line follower or colour tracker rarely needs more than that, while a full-resolution frame would be many times larger.
config = cam.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"})
cam.configure(config)
cam.start()
frame = cam.capture_array() # a NumPy array with shape (480, 640, 3)
capture_array() returns the pixels as a NumPy array, which is the format OpenCV and most vision code expect.
Running the robot on boot with systemd
When the Pi powers up, systemd starts the services that make up the system. A unit file tells systemd how to run your script. This gives you three things that running python3 main.py by hand does not: it starts at boot, it restarts after a crash, and its output is collected in one place.
Create the file /etc/systemd/system/robot.service:
[Unit]
Description=Robot controller
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/robot
ExecStart=/usr/bin/python3 -u /home/pi/robot/main.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
Go through the lines:
After=network.targetwaits for networking, which matters if the script uses the network.User=piruns the script as a normal user. Never run robot code as root without a reason, since a bug in a root process can do much more damage.ExecStartmust use absolute paths, because a service does not inherit your shell's working directory orPATH. The-uflag turns off output buffering so log lines appear immediately.Restart=on-failurewithRestartSec=3relaunches the program 3 seconds after a crash.WantedBy=multi-user.targetis what makes it start at boot.
Load and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now robot.service
systemctl status robot.service
enable registers it for boot and --now also starts it immediately. Use sudo systemctl stop robot.service before you edit and test the script manually, otherwise two copies fight over the same pins and the camera.
Logging with journalctl
Anything your script prints to standard output or standard error is captured by the journal, so you do not need to manage log files. That also protects the SD card from endless small writes you did not plan. Use Python's logging module so each line has a level and a timestamp:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("robot")
log.info("started, camera ready")
log.warning("distance sensor timeout, retrying")
log.error("motor driver not responding")
journald already adds its own timestamp, so there is no need to add a second one. Read the logs from your SSH session:
journalctl -u robot.service -f # follow live output
journalctl -u robot.service -b # everything since the last boot
journalctl -u robot.service -p err # only errors and worse
journalctl -u robot.service --since "10 min ago"
When a robot misbehaves in the field, -b after the next reboot shows you what happened in the minutes before it failed. That is why you log events, not only errors.
A simple HTTP stream
You often want to watch what the robot sees from your laptop. The simplest approach is MJPEG over HTTP: the server keeps one connection open and sends a long sequence of JPEG images, one after another. The browser draws each as it arrives. The response uses the content type multipart/x-mixed-replace, and each frame is a part separated by a boundary marker:
HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=FRAME
--FRAME
Content-Type: image/jpeg
Content-Length: 31244
(jpeg bytes)
--FRAME
...
A minimal sketch with Python's standard library:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from picamera2 import Picamera2
cam = Picamera2()
cam.configure(cam.create_video_configuration(main={"size": (640, 480)}))
cam.start()
class Stream(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=FRAME")
self.end_headers()
while True:
buf = BytesIO()
cam.capture_file(buf, format="jpeg")
data = buf.getvalue()
self.wfile.write(b"--FRAME\r\nContent-Type: image/jpeg\r\n")
self.wfile.write(b"Content-Length: %d\r\n\r\n" % len(data))
self.wfile.write(data + b"\r\n")
ThreadingHTTPServer(("0.0.0.0", 8000), Stream).serve_forever()
Open http://robot.local:8000 in a browser. A 640 x 480 JPEG is often 20 to 40 KB, so 15 frames per second needs under 1 MB per second, which Wi-Fi handles easily. Compression is the reason streaming JPEG works where raw frames would not.
Check yourself
Why does the systemd unit use absolute paths in ExecStart?
Check yourself
A raw 640 x 480 RGB frame is 921,600 bytes. Roughly how much data per second is that at 30 frames per second?