Stereo calibration and camera QC for ZED X / ZED X Mini cameras on Jetson robots, shipped as a container image so the ZED SDK, OpenCV, NumPy and this code move as one pinned artefact.
Two audiences, one image:
- Operators click a desktop icon, follow an on-screen guide while moving the official checkerboard, and finish. The UI is a local web app, so it works equally on the robot's own screen and on a tablet across the room.
- Engineering runs the same image headless to re-derive calibrations, bootstrap dispersion and optical-quality metrics over stored sessions.
Containerising removes the software-drift failures (interrupted apt, stale package sources, half-upgraded system libraries). It does not virtualise the platform.
| Pinned by the image | Must be standardised on every robot |
|---|---|
ZED SDK + pyzed |
JetPack / L4T release (r36.4 ≠ r36.5, warned at runtime) |
| Python, NumPy, OpenCV | stereolabs-zedx GMSL driver — pinned to L4T and capture-card SKU |
| CUDA / cuDNN toolkit | zed_x_daemon + nvargus-daemon (host services) |
| This application | udev rules (/etc/udev/rules.d/99-slabs.rules) |
nvidia-container-toolkit + Docker's nvidia runtime |
|
per-camera factory calibration in /usr/local/zed/settings |
If the fleet is not on one JetPack version, this converts software drift into an image matrix. Standardise JetPack first.
alohacalib check verifies the whole contract and prints the fix for anything missing.
Run as the operator's own account — not under sudo. The desktop-icon trust step
writes per-user metadata and silently does nothing as root.
ALOHACALIB_IMAGE=<registry>/alohacalib:0.1.0 ./install/install.shIt preflights the host, pulls the image, creates /var/lib/alohacalib, installs the
launcher and icon, and runs the container's own self-check.
Docker group membership only takes effect in a new login session:
sudo usermod -aG docker "$USER" # then log out and back inThe desktop icon Calibración de cámaras is the operator path. By hand:
./install/alohacalib-launch.sh # start if needed, then open the UI
./install/alohacalib-run.sh # start onlyThe station listens on 8420. Port 80 is refused on purpose: robots already run a
native service there. Because ZED X requires --network host, -p is ignored and the
server binds the host port directly.
docker run --rm --runtime nvidia --privileged --network host \
-v /dev/:/dev/ -v /tmp/:/tmp/ \
-v /usr/local/zed/settings:/usr/local/zed/settings \
-v /var/lib/alohacalib:/data -e ALOHACALIB_DATA=/data \
<image> calibrate /data/sessions/<unit>_<serial>/<session> --bootstrap 40--detector classic re-detects corners with the detector the fielded pipeline uses,
which is how you check a result is not an artefact of corner detection.
Commands, all of which work on a stored session and never touch a camera:
| Command | What it does | Exit codes |
|---|---|---|
check |
verifies the host contract and prints the fix for anything missing | 1 on any failure |
calibrate <session> |
the calibration alone | 0 accepted, 2 not accepted, 1 unusable |
optical <session> |
MTF50, contrast and residual structure by radial field zone | 0, 1 unusable |
qc <session> |
everything: calibration, dispersion, factory comparison, optical, verdicts, HTML report | 0 pass, 2 warn, 3 fail, 1 error |
report <result-id> |
re-renders the HTML from a stored result | 0, 1 not found |
qc splits warn from fail on purpose: a bench script needs to tell "measured, borderline"
from "measured, out of spec", which calibrate's single non-zero code cannot express.
The station measures and never judges a camera on evidence it does not have. Most of what
it reports carries no threshold, and says so on screen rather than leaving a blank that reads
as a pass. The reasons are in config.QcGates, next to each value:
- Fresh minus factory, per unit, is the primary statistic — not unit against unit. Across three units the largest deviation was 1.76 px, and that is an upper bound on the detection floor rather than a measured effect, so the gate sits above it at 2 px.
- All twelve focal deltas in that corpus were negative (mean −1.14 px): a systematic fisheye-vs-factory model bias. A focal delta inside that band is reported without a verdict, because it is arithmetically indistinguishable from the bias.
- Distortion has no factory reference. The
.confstores a plumb-bob model in[LEFT_CAM_*]and a rational one in[LEFT_DISTO]; we producecv::fisheyek1..k4. They are never compared.[STEREO].Baselineis read, but ungated — no fresh-vs-factory baseline measurement exists yet. - Michelson contrast has an auto-exposure confound and is never evidence of no degradation: AE renormalises the histogram, so a lens transmitting less light can read identically. No exposure metadata is saved, so no threshold can fix this.
- The outer field zone is edge-coverage limited, not optics limited, so it carries no threshold either. How many edges were rejected, and why, is reported per zone.
- MTF50 is accurate where these cameras live and over-reports elsewhere. Against analytic ground truth the estimator tracks theory to within 4% for blur sigma 0.5–1.0 px — which covers the observed 0.37 cyc/px — then climbs to +34% at sigma 2.0, with a floor near 0.10 cyc/px. It is good for catching gross defocus and nothing finer.
- The fielded
RMS < 1.0accept gate is a weak discriminator — all six study calibrations passed it, including the weakest unit — so the interface shows it in grey and labelled, never as a green pass.
python -m unittest discover -s testsNo camera required: the MTF core is checked against the closed-form Gaussian-blur MTF, the
factory comparison reproduces the study's published 1.76 px figure from the real corpus in
tests/data/, and optical.measure() runs end to end on a synthesised session. CI runs the
suite against the OpenCV 4 the image ships and the version-sensitive files against OpenCV 5,
because the fisheye flag values differ between them and a regression there yields wrong
numbers rather than an error.
| Variable | Default | Purpose |
|---|---|---|
ALOHACALIB_DATA |
./alohacalib-data |
sessions, results, reports — must be a persistent volume |
ALOHACALIB_ZED_SETTINGS |
/usr/local/zed/settings |
factory calibration cache (host bind mount) |
ALOHACALIB_BOARD |
official |
calibration target profile |
ALOHACALIB_PORT |
8420 |
web station port |
ALOHACALIB_CV_THREADS |
4 |
OpenCV threads per worker |
Never point ALOHACALIB_DATA at /tmp: these robots wipe it on reboot, which has
already cost one study its raw captures. alohacalib check warns if you do.
- OpenCV version is not assumed. The fisheye calibration flags moved namespaces
between OpenCV 4 and 5 and their values differ (
MONO_FLAGSis 10 on 4.x and 41943040 on 5.x), so every version-sensitive call goes throughcvcompat.py. It is verified against both. - The stock base image cannot import NumPy and OpenCV together. JetPack's OpenCV
4.8.0 is built against the NumPy 1.x C-API while the image ships NumPy 2.2.6;
import cv2then raisesnumpy.core.multiarray failed to import. The Dockerfile installs a NumPy-2-capable OpenCV and asserts the pair imports at build time. - CUDA will not speed this up. OpenCV has no CUDA implementation of
findChessboardCorners,cornerSubPix, or anything incalib3d/fisheye— the bottleneck is CPU corner detection. What made the preview fluid was threading the camera loop apart from the detector, not the GPU. - The bootstrap is still serial, and it is the slowest thing here. Roughly 15 s a draw on
an Orin, so about ten minutes at the default 40.
ALOHACALIB_WORKERSandSettings.workersexist for a process pool that has not been written yet; until it is, they are unread. Whoever writes it: use aspawncontext, notfork— analysis can run while a capture holds the camera and a CUDA context open, and forking that hangs — and have each worker callcvcompat.set_threads(1), because the LM solve barely threads and one thread per process beats four threads in one. - Two gate sets, and the difference matters.
Gatesmirrors the fielded recipe; changing anything in it breaks comparability with calibrations produced on a rig.QcGatesis ours, derived from the mx-test three-unit corpus, and safe to retune as fleet data arrives. Its absences are load-bearing and documented in place: a quantity with no defensible threshold has no field there rather than a guessed one.