#!/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 .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())