fwt_software/scripts/export_camera_profile.py

224 lines
9.7 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 --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. 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="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 = {"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())