#!/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), # 12 bits where the camera has them in the GigE packing the program reads # (rietschen's RGB camera, chosen there 2026-10-26: 18.6 MB, 3.1 fps at # 60 MB/s); 8 bits otherwise. The mono cameras' Mono12Packed is not read. ("PixelFormat", ("BayerRG12Packed", "BayerRG8", "Mono8")), ("StreamBytesPerSecond", 60000000), # Free-running, a Manta still runs at AcquisitionFrameRateAbs, not at its # limit: rietschen's RGB camera came with 0.955 and delivered 5 frames in # 5.2 s. A short fixed exposure first, so the rate is not capped by the # exposure the camera happens to hold, as in the Alvium recipe. ("ExposureAuto", "Off"), ("ExposureTimeAbs", 1000.0), ("AcquisitionFrameRateAbs", 1000.0), # clamped to the camera's maximum ("ChunkModeActive", False), # The keep rule waits for the frame mean to reach the camera's target, +- its # tolerance, which it reads from the camera. The camera has to aim at the # mean: the RGB camera came with FitRange (which fits the histogram, wherever # the mean lands) and no waypoint settled (2026-10-21). 30 % rather than the # Alvium's 50: at 50 the sky over the horizon burnt out at noon, where smoke # would rise, and at 35 it was still too bright; the old program ran these # cameras at 23-25. ("ExposureAutoAlg", "Mean"), ("ExposureAutoAdjustTol", 5), ("ExposureAutoTarget", 30), ("ExposureAuto", "Continuous"), ("GainAuto", "Continuous"), ] GIGE_RECIPES = { "standard": _GIGE_COMMON + [ ("ExposureAutoMax", 20000), # Rietschen's RGB camera sat at 200-600 us at noon: a floor of 200 left it # no room to darken a bright view, and without chunk data the host cannot # tell a camera at its limit from one still settling. The Manta goes to 16. ("ExposureAutoMin", 20), ("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 .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. 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 = "AcquisitionFrameRateAbs" 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())