229 lines
9.9 KiB
Python
Executable File
229 lines
9.9 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 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),
|
|
("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),
|
|
],
|
|
}
|
|
|
|
|
|
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=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("--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 = {"RGB8": 3, "BGR8": 3, "BayerRG12p": 1.5, "BayerRG12": 2}.get(str(fmt), 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")
|
|
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())
|