323 lines
14 KiB
Python
Executable File
323 lines
14 KiB
Python
Executable File
#!/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
|
|
|
|
It writes into the tower's own folder, ~/.config/fire_gimbal_control/
|
|
camera_profiles/rgb, where a deploy cannot reach it; --out picks another.
|
|
|
|
... --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. Frame
|
|
# triggering is turned off first: the host streams free-running frames and never
|
|
# sends a trigger, and a camera that boots with it on (a legacy tower's, whose
|
|
# old program triggered by software) delivers nothing - and keeps its frame rate
|
|
# read-only - until it is off. A feature the camera does not have is reported
|
|
# and skipped, not fatal: the fleet runs several Alvium generations and their
|
|
# feature vocabularies differ.
|
|
# Raw capture (docs/raw12-pipeline-measurements.md): full resolution, packed
|
|
# 12-bit Bayer, de-Bayered on the tower. At the 250 MB/s link cap that is
|
|
# 9.86 fps on the 16 MP 1800 U-1620c (RGB8: 4.95), so auto-exposure settles in
|
|
# about half the time, with no lost frames measured on either computer.
|
|
# Binning goes to 1 vertical-first (the horizontal minimum depends on it), and
|
|
# Width/Height ask for more than any sensor so the clamp lands on the maximum.
|
|
# The exposure the camera already holds caps its frame rate too, so a short
|
|
# fixed exposure is written before the rate, and auto-exposure is turned back
|
|
# on afterwards. Without it, tower 111's camera, left at 3.5 s by the old
|
|
# profile's 9 s auto limit, got a 'standard' profile clamped to 0.28 fps. A dry
|
|
# run writes nothing, so its frame rate still reflects the old exposure.
|
|
# The per-frame chunk data is what the keep rule reads; each item must be
|
|
# enabled before chunk mode itself, which locks them.
|
|
_RAW_COMMON = [
|
|
("TriggerSelector", "FrameStart"),
|
|
("TriggerMode", "Off"),
|
|
("BinningVertical", 1),
|
|
("BinningHorizontal", 1),
|
|
("OffsetX", 0),
|
|
("OffsetY", 0),
|
|
("Width", 100000),
|
|
("Height", 100000),
|
|
("PixelFormat", "BayerRG12p"),
|
|
("DeviceLinkThroughputLimit", 250000000),
|
|
("ExposureAuto", "Off"), # so the short exposure below sticks
|
|
("ExposureTime", 1000.0), # before the rate: see the note above
|
|
("AcquisitionFrameRateEnable", True),
|
|
("AcquisitionFrameRate", 1000.0), # clamped to the camera's maximum
|
|
("ChunkModeActive", False),
|
|
("ChunkSelector", "ExposureTime"),
|
|
("ChunkEnable", True),
|
|
("ChunkSelector", "Gain"),
|
|
("ChunkEnable", True),
|
|
("ChunkSelector", "BalanceRatioRed"),
|
|
("ChunkEnable", True),
|
|
("ChunkSelector", "BalanceRatioBlue"),
|
|
("ChunkEnable", True),
|
|
("ChunkSelector", "Timestamp"),
|
|
("ChunkEnable", True),
|
|
("ChunkModeActive", True),
|
|
("BalanceWhiteAuto", "Off"), # fixed ratios, as before
|
|
("ExposureAuto", "Continuous"),
|
|
("GainAuto", "Continuous"),
|
|
("IntensityControllerRate", 100), # fastest
|
|
]
|
|
|
|
RECIPES = {
|
|
# Daylight. Exposure capped at 20 ms; the gimbal is at rest when a frame is
|
|
# kept, so noise matters more than motion blur.
|
|
"standard": _RAW_COMMON + [
|
|
("ExposureAutoMin", 200.0),
|
|
("ExposureAutoMax", 20000.0),
|
|
("GainAutoMax", 24.0),
|
|
("IntensityAutoPrecedence", "MinimizeNoise"),
|
|
("DeviceIndicatorMode", "Active"),
|
|
("DeviceIndicatorLuminance", 10),
|
|
],
|
|
# Night: long exposures (the frame rate then follows the exposure), more gain
|
|
# allowed, and the status LED off so it cannot contaminate a long exposure.
|
|
# 12 bits matter most here: deep shadows are where they carry information.
|
|
"night": _RAW_COMMON + [
|
|
("ExposureAutoMin", 1000.0),
|
|
("ExposureAutoMax", 500000.0),
|
|
("GainAutoMax", 48.0),
|
|
("IntensityAutoPrecedence", "MinimizeNoise"),
|
|
("DeviceIndicatorMode", "Inactive"),
|
|
("DeviceIndicatorLuminance", 0),
|
|
],
|
|
}
|
|
|
|
# The older GigE cameras (Manta G-1236 on rietschen, firmware 00.01.54) name
|
|
# things differently - ExposureTimeAbs, AcquisitionFrameRateAbs, ExposureAutoTarget
|
|
# - and have no IntensityController, no DeviceLinkThroughputLimit and no 12-bit
|
|
# packed Bayer the program reads. Measured on rietschen's NIR camera, 2026-10-20.
|
|
# The link is shared: the camera free-runs as fast as StreamBytesPerSecond lets
|
|
# it (41 MB/s gave 3.2 fps at 12.4 MB a frame), and the cameras of one tower sit
|
|
# on one GbE port (~124 MB/s), so each gets half of it - room for RGB and NIR.
|
|
# PixelFormat takes the first the camera offers: raw Bayer on the colour camera,
|
|
# de-Bayered on the tower and a third of RGB8 on the wire; Mono8 on a mono one.
|
|
# The packet size is left alone: it has to fit the port's MTU, which is the
|
|
# tower's network setup, not the camera's.
|
|
_GIGE_COMMON = [
|
|
("TriggerSelector", "FrameStart"),
|
|
("TriggerMode", "Off"),
|
|
("AcquisitionMode", "Continuous"),
|
|
("BinningVertical", 1),
|
|
("BinningHorizontal", 1),
|
|
("OffsetX", 0),
|
|
("OffsetY", 0),
|
|
("Width", 100000),
|
|
("Height", 100000),
|
|
("PixelFormat", ("BayerRG8", "Mono8")),
|
|
("StreamBytesPerSecond", 60000000),
|
|
("ChunkModeActive", False),
|
|
# The keep rule waits for the frame mean to reach the camera's target. The
|
|
# host starts on its default band of 50 % - rietschen's cameras were set to
|
|
# 25, and every waypoint then ran into frame_timeout_ms (7.5 s, 2026-10-20).
|
|
("ExposureAutoTarget", 50),
|
|
("ExposureAuto", "Continuous"),
|
|
("GainAuto", "Continuous"),
|
|
]
|
|
|
|
GIGE_RECIPES = {
|
|
"standard": _GIGE_COMMON + [
|
|
("ExposureAutoMax", 20000),
|
|
("ExposureAutoMin", 200),
|
|
("GainAutoMax", 24.0),
|
|
],
|
|
"night": _GIGE_COMMON + [
|
|
("ExposureAutoMax", 500000),
|
|
("ExposureAutoMin", 1000),
|
|
("GainAutoMax", 48.0), # clamped to the camera's 40
|
|
],
|
|
}
|
|
|
|
|
|
def has_feature(cam, name):
|
|
try:
|
|
cam.get_feature_by_name(name)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def recipe_family(cam):
|
|
"""'gige' for a camera that only knows the older names, else 'alvium'."""
|
|
if not has_feature(cam, "ExposureTime") and has_feature(cam, "ExposureTimeAbs"):
|
|
return "gige"
|
|
return "alvium"
|
|
|
|
|
|
def resolve_value(feat, value):
|
|
"""The value to write to this feature; None if a choice list has no match.
|
|
|
|
A tuple is a list of choices for an enum: the first one the camera offers.
|
|
An integer feature takes no float, even a whole one (ExposureAutoMin is Int
|
|
on the Manta and Float on the Alvium)."""
|
|
if isinstance(value, tuple):
|
|
try:
|
|
offered = {str(e) for e in feat.get_available_entries()}
|
|
except Exception:
|
|
offered = set()
|
|
return next((v for v in value if v in offered), None)
|
|
if isinstance(feat, vmbpy.IntFeature) and isinstance(value, float):
|
|
return int(round(value))
|
|
return value
|
|
|
|
|
|
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
|
|
|
|
value = resolve_value(feat, value)
|
|
if value is None:
|
|
return ("refused", name, before, "none of the choices is offered")
|
|
|
|
# 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=os.path.join(
|
|
os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
|
|
"fire_gimbal_control", "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("--family", default="auto", choices=("auto", "alvium", "gige"),
|
|
help="which recipe set (default: from the features the camera has)")
|
|
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:
|
|
family = recipe_family(cam) if args.family == "auto" else args.family
|
|
recipe = (GIGE_RECIPES if family == "gige" else RECIPES)[args.profile]
|
|
print(f"camera : {cam.get_id()} {cam.get_model()}")
|
|
print(f"profile : {args.profile} ({family} recipe){' (dry run)' if args.dry_run else ''}")
|
|
refused = []
|
|
for name, value in recipe:
|
|
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 the link at all. A
|
|
# free-running GigE camera reports its ceiling as the rate limit.
|
|
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 = {"RGB8": 3, "BGR8": 3, "BayerRG12p": 1.5, "BayerRG12": 2}.get(str(fmt), 1)
|
|
rate_name = "AcquisitionFrameRateLimit" if family == "gige" else "AcquisitionFrameRate"
|
|
rate = cam.get_feature_by_name(rate_name).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")
|
|
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())
|