Add the LattePanda IOTA setup kit and headless camera profile export

This commit is contained in:
pgdalmeida 2026-09-16 18:27:39 +02:00
parent 08a6f7c1fa
commit 2c7c62ace1
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
7 changed files with 697 additions and 0 deletions

29
hardware/README.md Normal file
View File

@ -0,0 +1,29 @@
# Board setup kits
One directory per board the fleet runs on. A kit holds everything that turns a
freshly imaged machine into a tower host, and nothing that differs between two
towers built on the same board:
```
<board>/
packages.txt apt packages the build and the runtime need
preconditions/ udev rules, tmpfiles.d - the settings that must survive a reboot
install.sh apply the preconditions, install the packages, probe the
machine and emit a starting hardware.ini
NOTES.md quirks found on this board, and when they were last verified
```
`install.sh` fills in what a machine can be *asked* - board model, the motor
MCU's by-id path, the camera's `DEV_` id - and leaves to a human what no probe
can distinguish from a fault: which axes the gimbal has, whether an IMU or an
ambient sensor was fitted, and the drivetrain figures. See
[docs/hardware-config-plan.md](../docs/hardware-config-plan.md) §8 and §10.
The file it writes is `/etc/fire_gimbal_control/hardware.ini`, deliberately
outside the directory `deploy.sh` rsyncs: a deploy cannot overwrite what it never
touches.
| Kit | Board | State |
|-----|-------|-------|
| [lattepanda-iota/](lattepanda-iota/) | LattePanda IOTA (N150, RP2040 MCU) | in use on FWT tower 10.11.12.121 |
| lattepanda-3-delta/ | LattePanda 3 Delta (N5105, ATmega32U4 MCU) | not written - the Delta fleet predates the kits |

View File

@ -0,0 +1,393 @@
#!/usr/bin/env bash
#
# install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host.
#
# Run it ON the device:
# sudo ./install.sh # everything: deps, preconditions, hardware.ini
# sudo ./install.sh --deps # apt packages only
# sudo ./install.sh --preconditions # udev + tmpfiles only, applied immediately
# ./install.sh --probe # print what this machine looks like, change nothing
# sudo ./install.sh --vimba ~/VimbaX_2025-3 # install a staged Vimba X SDK
# ./install.sh --stdout # print the hardware.ini it would write
#
# What it can work out for itself: the board, the motor MCU's stable by-id path,
# the camera's DEV_ id, whether the USB preconditions took. What it cannot, and
# takes from flags, is what was ASSEMBLED - a missing pitch axis and a broken
# pitch axis look identical to a probe:
#
# --axes yaw (default: yaw,pitch) which axes the gimbal has
# --imu none (default: none) IMU model, or none
# --env none (default: none) ambient sensor model, or none
# --cameras rgb (default: rgb) expected [Camera.<name>] blocks
#
# Other flags:
# --force overwrite an existing hardware.ini (backs it up first)
# --no-apt-update skip 'apt-get update'
#
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ETC_DIR=/etc/fire_gimbal_control
HW_FILE="$ETC_DIR/hardware.ini"
AXES="yaw,pitch"
IMU="none"
ENV_SENSOR="none"
CAMERAS="rgb"
DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1
VIMBA_SRC=""
EXPLICIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--deps) DO_DEPS=1; EXPLICIT=1 ;;
--preconditions) DO_PRE=1; EXPLICIT=1 ;;
--hardware-ini) DO_HW=1; EXPLICIT=1 ;;
--probe) PROBE_ONLY=1; EXPLICIT=1 ;;
--stdout) TO_STDOUT=1; DO_HW=1; EXPLICIT=1 ;;
--axes) AXES="$2"; shift ;;
--imu) IMU="$2"; shift ;;
--env) ENV_SENSOR="$2"; shift ;;
--cameras) CAMERAS="$2"; shift ;;
--vimba) VIMBA_SRC="$2"; DO_VIMBA=1; EXPLICIT=1; shift ;;
--force) FORCE=1 ;;
--no-apt-update) APT_UPDATE=0 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
shift
done
if [[ $EXPLICIT -eq 0 ]]; then DO_DEPS=1; DO_PRE=1; DO_HW=1; fi
say() { printf '>> %s\n' "$*"; }
warn() { printf '!! %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
need_root() { [[ $EUID -eq 0 ]] || die "$1 needs root - re-run with sudo"; }
# ---------------------------------------------------------------- probing ---
# Board, from DMI. A kit applied to the wrong board is a real mistake, not a
# nuisance: the pin map and the device paths below are IOTA-specific.
probe_board() {
local dmi=/sys/class/dmi/id/product_name
[[ -r $dmi ]] || { echo "unknown"; return; }
case "$(tr -d '\0' < "$dmi")" in
*IOTA*|*Iota*|*iota*) echo "lattepanda-iota" ;;
*"3 Delta"*) echo "lattepanda-3-delta" ;;
*Sigma*) echo "lattepanda-sigma" ;;
*) echo "unknown" ;;
esac
}
# The motor MCU's stable path. /dev/ttyACM0 is enumeration order and moves when
# any other USB serial device appears - and this tower has a 5G modem that
# claims three ttyUSB nodes. Match on what the MCU calls itself:
# RP2040 running the gimbal firmware (arduino-pico) : "Raspberry_Pi_Pico"
# RP2040 as shipped, still on MicroPython : "MicroPython_Board"
# ATmega32U4 (Delta fleet) : "Arduino_LLC_Arduino_Leonardo"
probe_mcu() {
local d found=()
for d in /dev/serial/by-id/*; do
[[ -e $d ]] || continue
case "$d" in
*Raspberry_Pi_Pico*|*MicroPython_Board*|*Arduino_LLC_Arduino*|*Pico*) found+=("$d") ;;
esac
done
(( ${#found[@]} == 1 )) && { echo "${found[0]}"; return; }
(( ${#found[@]} == 0 )) && { echo ""; return; }
printf '%s\n' "${found[@]}"
}
# The camera's DEV_ id, which is what [Camera.<name>] id takes. Only Vimba knows
# it - it is NOT the USB serial number - so this needs the SDK installed.
#
# Two traps, both hit on tower 121:
# - The example is called ListCameras_VmbC, not ListCameras.
# - The SDK ships a Camera Simulator transport layer that is enabled by
# default and reports three cameras that do not exist (DEV_Cam1..3). Ids are
# therefore taken only from a block whose transport layer is the USB or GigE
# TL. A tower configured with DEV_Cam1 would "find its camera" and capture
# synthetic images forever.
probe_camera_id() {
local tool
for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do
[[ -x $tool ]] || continue
# Non-interactive shells do not source /etc/profile.d, so the GenTL path
# has to be set here or VmbStartup() fails with error -16 (no transport
# layer) on a perfectly good installation.
local cti
for cti in "${GENICAM_GENTL64_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do
[[ -d $cti ]] && { export GENICAM_GENTL64_PATH="$cti"; break; }
done
"$tool" 2>/dev/null | awk '
/Camera ID/ { id = $NF }
/Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } }
'
return
done
echo ""
}
probe_report() {
local board mcu cam
board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)"
echo "board : $board"
echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))"
echo "motor MCU : ${mcu:-NOT FOUND}"
echo "camera DEV_ id : ${cam:-NOT FOUND (needs the Vimba X SDK installed)}"
echo "alvium on usb : $(lsusb 2>/dev/null | grep -c '1ab2:' || true) device(s)"
echo "usbfs_memory_mb : $(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?') (want >= 1000)"
local p
for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do
[[ -r $p ]] || continue
local dev; dev="$(basename "$(dirname "$(dirname "$p")")")"
local vid; vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)"
[[ $vid == 1ab2 ]] && echo "camera lpm_permit: $(cat "$p") (want 0)"
done
echo "i2c buses : $(ls /dev/i2c-* 2>/dev/null | tr '\n' ' ')"
echo "ttyS : $(ls /dev/ttyS[0-9] 2>/dev/null | tr '\n' ' ')"
}
# ------------------------------------------------------------------- deps ---
do_deps() {
need_root "--deps"
local pkgs=()
while read -r line; do
line="${line%%#*}"; line="$(echo "$line" | tr -d '[:space:]')"
[[ -n $line ]] && pkgs+=("$line")
done < "$HERE/packages.txt"
say "installing ${#pkgs[@]} packages"
[[ $APT_UPDATE -eq 1 ]] && apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}"
say "packages done"
}
# ---------------------------------------------------------- preconditions ---
do_preconditions() {
need_root "--preconditions"
install -m 0644 "$HERE/preconditions/99-fwt-alvium-usb.rules" /etc/udev/rules.d/
install -m 0644 "$HERE/preconditions/99-fwt-rp2040.rules" /etc/udev/rules.d/
install -m 0644 "$HERE/preconditions/10-fwt-usbfs.conf" /etc/tmpfiles.d/
say "installed 2 udev rules + tmpfiles.d entry"
udevadm control --reload
udevadm trigger --subsystem-match=usb --action=add
systemd-tmpfiles --create /etc/tmpfiles.d/10-fwt-usbfs.conf
say "applied to the running system"
# Report rather than assume: a rule that did not take is the whole point of
# known-issues #19, and it fails quietly.
local now; now="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')"
[[ $now == 1000 ]] && say "usbfs_memory_mb = $now" || warn "usbfs_memory_mb is $now, expected 1000"
local p any=0
for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do
[[ -r $p ]] || continue
local dev vid; dev="$(basename "$(dirname "$(dirname "$p")")")"
vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)"
[[ $vid == 1ab2 ]] || continue
any=1
[[ "$(cat "$p")" == 0 ]] && say "camera USB3 LPM disabled ($dev)" \
|| warn "camera USB3 LPM still $(cat "$p") on $dev"
done
[[ $any -eq 0 ]] && warn "no Alvium camera on the bus - LPM rule untested until one is plugged in"
# The MCU rule is what makes the firmware flashable over ssh, so check it the
# same way rather than trusting that the rule took.
local mcu_dev
mcu_dev="$(grep -l '^2e8a$' /sys/bus/usb/devices/*/idVendor 2>/dev/null | head -1)"
if [[ -n $mcu_dev ]]; then
local devnum busnum node
busnum="$(cat "$(dirname "$mcu_dev")/busnum" 2>/dev/null)"
devnum="$(cat "$(dirname "$mcu_dev")/devnum" 2>/dev/null)"
node="$(printf '/dev/bus/usb/%03d/%03d' "$busnum" "$devnum")"
if [[ -w $node ]]; then
say "MCU (2e8a) writable at $node - picotool can flash it"
else
warn "MCU (2e8a) at $node is not writable; flashing will need sudo"
ls -l "$node" >&2
fi
else
warn "no 2e8a MCU on the bus - the flashing rule is untested until one is plugged in"
fi
return 0
}
# ------------------------------------------------------------------ vimba ---
# The Vimba X SDK is proprietary and login-walled, so it cannot be fetched by a
# script. Stage it on the device first (rsync it from a machine that has it),
# then point this at the staged directory:
#
# rsync -a ~/Programs/VimbaX_2025-3 ggs@<tower>:~/ # from the dev box
# sudo ./install.sh --vimba ~ggs/VimbaX_2025-3 # on the tower
#
do_vimba() {
need_root "--vimba"
[[ -d $VIMBA_SRC ]] || die "--vimba: no such directory: $VIMBA_SRC"
[[ -x $VIMBA_SRC/cti/Install_GenTL_Path.sh ]] || die "--vimba: $VIMBA_SRC does not look like a Vimba X SDK (no cti/Install_GenTL_Path.sh)"
local name dest
name="$(basename "$VIMBA_SRC")"
dest="/opt/$name"
if [[ -d $dest && $FORCE -ne 1 ]]; then
say "$dest already present - not re-copying (--force to replace)"
else
say "installing $name to $dest"
rm -rf "$dest.new"
cp -a "$VIMBA_SRC" "$dest.new"
rm -rf "$dest"
mv "$dest.new" "$dest"
fi
# cp -a preserves the staging copy's ownership, which is the ggs user. The
# SDK is read-only system software and belongs to root.
chown -R root:root "$dest"
ln -sfn "$dest" /opt/VimbaX
say "/opt/VimbaX -> $dest"
# GENICAM_GENTL64_PATH, or VmbStartup() fails with "Could not start Vimba X
# API". The SDK's own installer writes /etc/profile.d + the 1ab2 udev rule.
"$dest/cti/Install_GenTL_Path.sh"
# Non-interactive ssh shells do not source /etc/profile.d, which is why
# deploy.sh resolves the path itself - but the loader needs the libraries
# findable regardless of how the app was started.
echo "$dest/api/lib" > /etc/ld.so.conf.d/vimbax.conf
ldconfig
say "registered $dest/api/lib with the dynamic loader"
udevadm control --reload && udevadm trigger --subsystem-match=usb --action=add
local id
id="$(probe_camera_id)"
if [[ -n $id ]]; then
say "camera found: $id"
else
warn "no camera reported by ListCameras yet"
warn "the SDK asks for a reboot before the transport layers are usable"
fi
return 0
}
# ------------------------------------------------------------ hardware.ini ---
emit_hardware_ini() {
local board mcu cam fw_env
board="$(probe_board)"
mcu="$(probe_mcu)"
cam="$(probe_camera_id)"
case "$board" in
lattepanda-iota) fw_env="iota_rp2040" ;;
lattepanda-3-delta|lattepanda-sigma) fw_env="leonardo" ;;
*) fw_env="UNKNOWN" ;;
esac
if [[ $(wc -l <<< "$mcu") -gt 1 ]]; then
warn "several candidate MCU ports found; pick one by hand:"
warn "$mcu"
mcu="CHOOSE_ONE"
fi
cat <<EOF
; hardware.ini - what this machine IS. Generated by hardware/$board/install.sh
; on $(date -Is) from what the machine reports, plus the assembly
; facts given on the command line. See config/hardware.example.ini for the full
; reference, and docs/hardware-config-plan.md for why this file exists.
[Board]
model = $board
firmware_env = $fw_env
[Hardware]
; DECLARED, not probed: these are what the tower was built with, so that a part
; that stops answering is a fault and not a silent reconfiguration.
axes = $AXES
imu = $IMU
env = $ENV_SENSOR
cameras = $CAMERAS
[Serial]
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
; enumerates first.
device = ${mcu:-NOT_FOUND_SET_THIS_BY_HAND}
baud = 115200
EOF
if [[ $IMU != none ]]; then
cat <<'EOF'
[IMU]
; On the IOTA the only PCH serial on the header is COM1. Confirm it enumerates
; and at what levels before trusting this path.
device = /dev/ttyS0
EOF
fi
if [[ $ENV_SENSOR != none ]]; then
cat <<'EOF'
[Env]
; MUST be a real I2C adapter, not SMBus-only: check with `i2cdetect -F <n>`.
; The IOTA exposes no PCH-side I2C on the header, so this is normally a USB-I2C
; bridge (MCP2221A), whose bus number depends on enumeration order.
i2c_device = SET_THIS_BY_HAND
i2c_addr = 0x44
EOF
fi
local c
for c in ${CAMERAS//,/ }; do
c="$(echo "$c" | tr -d '[:space:]')"
cat <<EOF
[Camera.$c]
; The Vimba DEV_ id of the physical camera fitted to this tower. Not the USB
; serial: read it with VimbaX's ListCameras, which is what this probe does.
id = ${cam:-NOT_FOUND_SET_THIS_BY_HAND}
EOF
done
}
do_hardware_ini() {
if [[ $TO_STDOUT -eq 1 ]]; then emit_hardware_ini; return; fi
need_root "--hardware-ini"
mkdir -p "$ETC_DIR"
if [[ -f $HW_FILE && $FORCE -ne 1 ]]; then
warn "$HW_FILE exists - leaving it alone (--force to replace)"
warn "the file it would write instead:"
emit_hardware_ini | sed 's/^/ /' >&2
return
fi
if [[ -f $HW_FILE ]]; then
cp -p "$HW_FILE" "$HW_FILE.bak-$(date +%Y%m%d-%H%M%S)"
say "backed the previous hardware.ini up"
fi
emit_hardware_ini > "$HW_FILE"
chmod 0644 "$HW_FILE"
say "wrote $HW_FILE"
grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" >/dev/null 2>&1 && {
warn "it still has placeholders - edit them before starting the app:"
grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" | sed 's/^/ /' >&2
}
return 0
}
# ------------------------------------------------------------------- main ---
board="$(probe_board)"
if [[ $board != lattepanda-iota ]]; then
warn "this kit is for the LattePanda IOTA; this machine reports '$board'"
[[ $PROBE_ONLY -eq 1 ]] || die "refusing to apply IOTA preconditions to a $board"
fi
if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi
# --stdout has to emit the file and nothing else: its whole purpose is being
# redirected into one. The banner belongs on stderr in that case, or it lands in
# the middle of the hardware.ini somebody just generated.
if [[ $TO_STDOUT -eq 1 ]]; then
do_hardware_ini
exit 0
fi
say "LattePanda IOTA setup kit"
probe_report | sed 's/^/ /'
# if/fi rather than '[[ ]] && cmd': under `set -e` a false test as the last
# command of an AND list exits the script.
if [[ $DO_DEPS -eq 1 ]]; then do_deps; fi
if [[ $DO_VIMBA -eq 1 ]]; then do_vimba; fi
if [[ $DO_PRE -eq 1 ]]; then do_preconditions; fi
if [[ $DO_HW -eq 1 ]]; then do_hardware_ini; fi
say "done"

View File

@ -0,0 +1,30 @@
# apt packages for a LattePanda IOTA tower host. One per line, '#' comments.
# Consumed by install.sh --deps.
# --- build toolchain -----------------------------------------------------
build-essential
cmake
git
pkg-config
# --- fire_gimbal_control build deps --------------------------------------
# Paho MQTT is fetched by cmake; OpenCV, Boost and libjxl are not.
libopencv-dev
libboost-program-options-dev
libjxl-dev
libssl-dev
# --- runtime -------------------------------------------------------------
# tmux: scripts/fgc runs the app detached inside a session.
tmux
rsync
# --- firmware build (PlatformIO runs ON the tower, see NOTES.md) ---------
python3-venv
python3-pip
# --- diagnostics ---------------------------------------------------------
# i2c-tools: `i2cdetect -F` proves a bus is real I2C and not SMBus-only.
# usbutils: lsusb, for camera and MCU enumeration.
i2c-tools
usbutils

View File

@ -0,0 +1,12 @@
# systemd-tmpfiles: raise the USB filesystem buffer for large camera frames.
#
# WHY: known-issues #19. usbcore.usbfs_memory_mb defaults to 16 MB, far below a
# single full-resolution frame, and reverts on every reboot. 1000 is the value
# the Allied Vision documentation asks for.
#
# 'w' writes the value if the file exists and never creates it, so this is inert
# on a machine without usbcore. Applied at boot by systemd-tmpfiles-setup.
# The kernel-cmdline alternative (usbcore.usbfs_memory_mb=1000 in GRUB) does the
# same job earlier; this one is chosen because it needs no bootloader edit and is
# undone by deleting one file.
w /sys/module/usbcore/parameters/usbfs_memory_mb - - - - 1000

View File

@ -0,0 +1,16 @@
# Alvium USB3 camera preconditions - fire-watch tower hosts.
#
# WHY: known-issues #19. On the camera's USB3 link the host re-enables hardware
# LPM (U1/U2) on every enumeration; the camera then logs link errors and frame
# delivery stalls. The setting lives on the PORT, not on the device, which is
# why this writes through the device's 'port' symlink rather than using ATTR{}.
#
# Verified on the IOTA 2026-09-15: /sys/bus/usb/devices/2-1/port ->
# ../2-0:1.0/usb2-port1, usb3_lpm_permit was 'u1_u2' out of the box.
#
# The MODE line duplicates what the Vimba X transport-layer installer writes to
# 99-AVTUSBTL.rules. Kept here so a host that has not run that installer yet
# still gets a camera it can open, and harmless where it has.
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", MODE="0666"
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", RUN+="/bin/sh -c 'echo 0 > /sys%p/port/usb3_lpm_permit 2>/dev/null || true'"

View File

@ -0,0 +1,18 @@
# RP2040 motor-controller MCU - fire-watch tower hosts (LattePanda IOTA).
#
# WHY: flashing the firmware needs write access to the board's USB device, and
# the default mode is root-only. Without this, picotool finds the board and then
# refuses it:
#
# RP2040 device at bus 1, address 4 appears to be in BOOTSEL mode, but
# picotool was unable to connect. Maybe try 'sudo' or check your permissions.
#
# Matching on the vendor alone covers every mode the board appears in, which is
# the point - it changes identity as it is flashed:
# 2e8a:0003 RP2 Boot BOOTSEL / mass storage, what picotool writes to
# 2e8a:0005 MicroPython the image these boards ship with
# 2e8a:000a Pico SDK CDC the gimbal firmware, once flashed
#
# uaccess covers a logged-in user; the 0666 mode covers a plain ssh session,
# which is how a tower is actually reached.
SUBSYSTEM=="usb", ATTRS{idVendor}=="2e8a", MODE="0666", TAG+="uaccess"

199
scripts/export_camera_profile.py Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
export_camera_profile.py - build a camera profile XML on the tower, headlessly.
The documented way to make a profile is the VimbaX Viewer: configure the camera
in a GUI, save the settings XML. On a tower that is awkward - the machine is
headless and reached over ssh - and it is impossible during an unattended
install. This does the same job through the SDK's Python API:
apply a recipe feature by feature, let the CAMERA validate each write,
then export what it actually accepted.
That keeps the property the Viewer workflow is there to provide. GenICam
validity is stateful - AcquisitionFrameRate is only writable once
AcquisitionFrameRateEnable is set, Width's maximum depends on binning - so a
hand-written XML can be individually plausible and collectively wrong. Here
every value is written to the real camera in dependency order and read back, and
anything the camera refused is reported rather than silently kept.
Usage, on the tower:
GENICAM_GENTL64_PATH=/opt/VimbaX/cti ~/.venvs/vmbpy/bin/python \
scripts/export_camera_profile.py --profile standard --out config/camera_profiles/rgb
... --dry-run report what would change, write nothing
... --camera DEV_xxx pick a camera explicitly
Needs vmbpy, which ships with the SDK:
python3 -m venv ~/.venvs/vmbpy
~/.venvs/vmbpy/bin/pip install /opt/VimbaX/api/python/vmbpy-*.whl
"""
import argparse
import os
import sys
try:
import vmbpy
except ImportError:
sys.exit("vmbpy not installed - see the header of this file")
# Recipes are ordered: binning changes the sensor geometry, and the throughput
# limit caps the frame rate, so both come before the rate is set. A feature the
# camera does not have is reported and skipped, not fatal: the fleet runs several
# Alvium generations and their feature vocabularies differ.
RECIPES = {
# Daylight. 2x2 binning is not optional at 10 Hz: unbinned RGB8 on a 16 MP
# sensor is a 48 MB frame, which is both over the USB ceiling in
# known-issues #19 and far too slow to reach 10 fps under any throughput
# limit this camera offers.
"standard": [
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 10.0),
("ExposureAuto", "Continuous"),
("ExposureAutoMin", 200.0),
("ExposureAutoMax", 20000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 24.0),
("IntensityAutoPrecedence", "MinimizeBlur"),
("DeviceIndicatorMode", "Active"),
("DeviceIndicatorLuminance", 10),
],
# Night: 1 Hz, long exposures, more gain allowed, noise over motion blur,
# and the status LED off so it cannot contaminate a long exposure.
"night": [
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 1.0),
("ExposureAuto", "Continuous"),
("ExposureAutoMin", 1000.0),
("ExposureAutoMax", 500000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 48.0),
("IntensityAutoPrecedence", "MinimizeNoise"),
("DeviceIndicatorMode", "Inactive"),
("DeviceIndicatorLuminance", 0),
],
}
def apply_feature(cam, name, value, dry_run):
try:
feat = cam.get_feature_by_name(name)
except Exception:
return ("missing", name, None, value)
try:
before = feat.get()
except Exception:
before = None
# Clamp a numeric value the camera cannot reach, rather than letting the
# write fail and leaving whatever was there before. Without this, exporting
# 'standard' right after 'night' wrote a standard profile running at the
# night rate: the 10 fps write was refused and 1 fps simply stayed. A
# refused write is not a no-op, it is a silently inherited value.
clamped = False
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
lo, hi = feat.get_range()
if value > hi:
value, clamped = (type(value)(hi) if not isinstance(value, int) else int(hi)), True
elif value < lo:
value, clamped = (type(value)(lo) if not isinstance(value, int) else int(lo)), True
except Exception:
pass
if dry_run:
return ("would-clamp" if clamped else "would-set", name, before, value)
try:
feat.set(value)
except Exception as exc:
return ("refused", name, before, f"{value} ({type(exc).__name__}: {exc})")
try:
after = feat.get()
except Exception:
after = value
return ("clamped" if clamped else "set", name, before, after)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--profile", default="standard", choices=sorted(RECIPES))
ap.add_argument("--out", default="config/camera_profiles/rgb",
help="directory to write <profile>.xml into")
ap.add_argument("--camera", default=None, help="camera id (default: the first real one)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if not os.environ.get("GENICAM_GENTL64_PATH"):
for cti in ("/opt/VimbaX/cti",):
if os.path.isdir(cti):
os.environ["GENICAM_GENTL64_PATH"] = cti
break
with vmbpy.VmbSystem.get_instance() as vmb:
cams = vmb.get_all_cameras()
if args.camera:
cams = [c for c in cams if c.get_id() == args.camera]
else:
# The SDK's camera simulator transport layer is enabled by default
# and offers DEV_Cam1..3. Exporting a profile from one of those
# would produce a file that configures nothing real.
cams = [c for c in cams if not c.get_id().startswith("DEV_Cam")]
if not cams:
sys.exit("no real camera found")
cam = cams[0]
with cam:
print(f"camera : {cam.get_id()} {cam.get_model()}")
print(f"profile : {args.profile}{' (dry run)' if args.dry_run else ''}")
refused = []
for name, value in RECIPES[args.profile]:
state, n, before, after = apply_feature(cam, name, value, args.dry_run)
mark = {"set": " ", "would-set": " ?", "missing": " !", "refused": " X",
"clamped": " ~", "would-clamp": " ~"}[state]
note = " (clamped: the camera cannot reach the recipe's value)" \
if state in ("clamped", "would-clamp") else ""
print(f"{mark} {n:28} {before} -> {after}{note}")
if state in ("missing", "refused"):
refused.append(n)
# What the settings actually cost per frame - the number that decides
# whether this profile can be delivered over USB at all.
try:
w = cam.get_feature_by_name("Width").get()
h = cam.get_feature_by_name("Height").get()
fmt = cam.get_feature_by_name("PixelFormat").get()
bpp = 3 if str(fmt) in ("RGB8", "BGR8") else 1
rate = cam.get_feature_by_name("AcquisitionFrameRate").get()
mb = w * h * bpp / 1e6
print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame "
f"{rate:.2f} fps = {mb * rate:.0f} MB/s")
if mb > 20:
print("WARNING : over the ~20 MB/frame ceiling in known-issues #19")
except Exception:
pass
if args.dry_run:
return 0
os.makedirs(args.out, exist_ok=True)
path = os.path.join(args.out, f"{args.profile}.xml")
cam.save_settings(path, vmbpy.PersistType.All)
print(f"\nwrote : {path}")
if refused:
print(f"NOTE : not applied: {', '.join(refused)}")
print(" the XML records what the camera accepted, not the recipe")
return 0
if __name__ == "__main__":
sys.exit(main())