← All projects

Hardware & Automation

Raspberry Pi QR Scanner

Inside a Raspberry Pi check-in kiosk: camera processing, QR decoding, SQLite durability, asynchronous API delivery, and measured performance.

A kiosk with a simple job and several moving parts

The scanner is a headless check-in kiosk for the Ohio Furniture Market. A Raspberry Pi Zero 2 W and Camera Module 3 Wide read attendee QR badges, while Python handles decoding, local persistence, and delivery to the check-in API. Three traffic-light LEDs, a 60-pixel WS281x strip, and a passive buzzer provide the operating interface.

The interesting engineering problem is keeping those parts independent. A badge can be in view while a previous API request is still running. A buzzer can be sounding while another frame arrives. A network outage should not turn a locally recorded scan into an invisible failure.

Keep frames fresh instead of building a backlog

Picamera2 captures YUV420 frames. The capture worker copies the luminance plane needed for QR decoding and releases the camera request before the decoder starts work. A one-slot buffer keeps the latest frame: if the decoder falls behind, a newer image replaces the waiting image rather than joining a growing queue.

The decoder searches a center crop first. If that misses, it immediately tries the full frame; it also searches the full frame every third processed frame even after a crop succeeds. That periodic wider search helps find badges outside the center. The source supports both pyzbar and an optional ZXing backend, restricted to QR codes.

Camera → latest luminance frame → QR decoder
                                  ↓
                         bounded persistence queue
                                  ↓
                            SQLite commit
                             ↙          ↘
                    local feedback    API workers
                                          ↓
                                confirmation or retry

Source defaults and the installed configuration

The source defaults request 640 × 480 capture at 30 FPS, a 384 × 384 center crop, pyzbar decoding, and manual focus. The installed kiosk profile documented on October 7, 2026 uses 1280 × 960 capture, a 768-pixel crop, ZXing, and continuous autofocus with full range and fast speed. Requested camera FPS is a configuration value, not a measurement of completed scans per second.

SCANNER_WIDTH=1280
SCANNER_HEIGHT=960
SCANNER_CROP_SIZE=768
SCANNER_DECODER=zxingcpp
SCANNER_FOCUS_MODE=continuous
SCANNER_AF_RANGE=full
SCANNER_AF_SPEED=fast

Camera settings live in configuration rather than being scattered through the application. Capture, decoding, persistence, feedback, and metrics also have separate modules, which makes it easier to tune one part without changing the API contract.

A recorded scan is different from a confirmed check-in

A valid badge enters a bounded persistence queue with 20 slots. The persistence worker commits it to a SQLite outbox before producing the local SAVED feedback: a brief green strip flash and two beeps. That feedback means the scan is recorded locally. Server confirmation happens afterward.

If the persistence queue is full, the scanner reports a busy condition rather than pretending to accept the badge. This is a deliberate boundary: a frame that was decoded but never committed is not a durable scan. Items still waiting in memory can be lost during abrupt power loss.

The QR parser extracts the first company_id and attendee query values. The application sends those with a scanner_id in a JSON POST to a configured endpoint, using an X-Scanner-Token header. Two API workers normally run with reusable HTTP sessions. The scanned URL itself is not fetched; its host and path are not currently validated by the payload parser.

Recovery, retries, and duplicate delivery

Retryable outcomes stay in the outbox, with exponential retry delays starting at five seconds and capped at five minutes. On restart, pending records are available for replay. Definitive responses remove the corresponding outbox entry. A unique payload constraint prevents multiple pending copies of the same badge.

Delivery is at least once. If the API accepts a check-in and the kiosk loses power before recording that outcome, the same record can be sent again. The server therefore needs idempotent check-in handling. Local deduplication reduces repeats, but cannot replace that server-side rule.

A separate in-memory history is bounded to 10,000 payloads and 24 hours; it resets when the program restarts. Operational logs use a shortened SHA-256 fingerprint instead of printing the complete badge payload. The SQLite outbox still contains badge data: owner-only file permissions restrict access but do not encrypt it.

What the documented measurements actually show

The project notes record five repeated decodes of one saved 1280 × 960 badge frame. ZXing had a median decode time of 16.6 ms, compared with 154.5 ms for pyzbar using the scaled center crop, and both returned the same payload. This compares decoders on that image; it is not a general throughput or reliability benchmark.

A separate five-presentation walk-up test successfully read all five presentations, with a mean GO-cue-to-first-read time of 1.146 seconds. Those timings include the person’s reaction and badge movement. They do not isolate autofocus time. A stationary lighting comparison read 226/226 frames with white illumination and 228/228 with the strip off, with medians around 14 ms; that does not establish performance for moving badges or other rooms.

These are previously documented project measurements, not new benchmarks run for this article. The distinction matters: decoding a saved image, acquiring a moving badge, and waiting for a server reply measure different parts of the experience.

Testing the failure paths

Hardware-independent tests cover the commit-before-feedback boundary, a blocked API while new scans continue saving, queue backpressure, retry recovery, and shutdown behavior. Metrics separate capture, decoding, persistence, API, and feedback latency rather than combining them into one number.

The application can run under systemd for restart after failure or reboot. The main lesson from the design is to make each state observable: seeing a QR code, saving a scan, and confirming a check-in are separate events, and the software and physical feedback should preserve that distinction.