diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 5f48024..4693387 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -714,6 +714,53 @@ feature vocabularies differ, so copying another tower's XML does not work: | `IntensityAutoPrecedence` | `Minimizeexposuretime` / `Minimizenoise` | **`MinimizeBlur` / `MinimizeNoise`** | | Sensor | 12 MP | 16 MP, 5328x3040 | +### Older GigE cameras (Manta) `[partial]` + +Rietschen has three Allied Vision Manta G-1236 on one GbE port (`enp1s0`, +MTU 8000): RGB (G-1236C), NIR and ACR (both G-1236B). Their firmware +(00.01.54) predates the names the program and the Alvium use: + +| Program / Alvium | Manta | +|---|---| +| `ExposureTime` | `ExposureTimeAbs` | +| `AcquisitionFrameRate` (+ `…Enable`) | `AcquisitionFrameRateAbs` - which a free-running Manta runs at, up to `AcquisitionFrameRateLimit` | +| `IntensityControllerTarget` / `…Tolerance` | `ExposureAutoTarget` / `ExposureAutoAdjustTol` | +| `DeviceLinkThroughputLimit` | `StreamBytesPerSecond` (max 124 MB/s, shared by every camera on the port) | +| `BayerRG12p` | `BayerRG12Packed`: the GigE Vision 1.x packing, p0's high bits first - its own unpacking, see `RawImage.h` | +| chunk data, `DeviceReset` | none | + +The program looks a missing feature up under its older name, so `test camera` +and the telemetry work. `export_camera_profile.py` picks its `gige` recipe by +itself when a camera has `ExposureTimeAbs` and no `ExposureTime`: frame trigger +off (the legacy cameras booted with `TriggerMode = On`, `Line1`), `BayerRG8` on +the colour camera (`BayerRG12Packed`, 12 bits, when it has it) and `Mono8` on a +mono one, 60 MB/s each, the frame rate at +the camera's maximum (rietschen's RGB camera came set to 0.955 fps), and +auto-exposure on the frame mean (`ExposureAutoAlg = Mean`) at 30 % +- 5 %, down +to 20 us by day. The keep rule waits for the frame mean to reach the camera's +own target and tolerance, read once the camera is open. With the RGB camera's +`FitRange`, which fits the histogram wherever its mean lands, no waypoint +settled. 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. + +Give `--camera` the id: with several cameras the first one the SDK lists is +not the colour one, and the order changes from run to run. `install.sh +--probe` lists them all, `install.sh --hardware-ini` writes `CHOOSE_ONE` +without `--camera-id`, and `install-tower` asks (`camera_id`). + +What is still missing: a Manta has no `DeviceReset`, so the program cannot +reset a stuck camera (it says so and goes on); and without chunk data the keep +rule cannot tell a dark scene at the exposure limit, so at night it keeps the +newest frame after `frame_timeout_ms`. + +`[verified]` on rietschen's NIR camera, 2026-10-20, with the motor mocked and +the old program running beside it: `test camera` PASS (5 frames 4112x3008 +Mono8 in 1.7 s, dark 100 % <= 20, bright mean 255), `snapshot` writes a +3008x4112 grey JXL (`rotation_deg = 270`, as the old program rotated), and a +scan keeps a picture every 5 s without a timeout. The colour camera is not yet +tested: the old program has it open. + ### Binning is not optional, and the frame-rate ceiling is not what it looks like Unbinned RGB8 on the 16 MP sensor is a **48.6 MB frame** - over the ~20 MB diff --git a/hardware/common/kit.sh b/hardware/common/kit.sh index 8974487..09f3dea 100644 --- a/hardware/common/kit.sh +++ b/hardware/common/kit.sh @@ -100,6 +100,9 @@ probe_mcu() { # therefore taken only from a block whose transport layer is the USB or GigE # TL. A tower configured with DEV_Cam1 would "find its camera" and capture # synthetic images forever. +# +# One id per line, like probe_mcu: rietschen has three GigE cameras on one port, +# and the first one listed is not the colour one, so the caller has to choose. probe_camera_id() { local tool for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do @@ -113,7 +116,7 @@ probe_camera_id() { done "$tool" 2>/dev/null | awk ' /Camera ID/ { id = $NF } - /Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } } + /Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) print id; id = "" } ' return done @@ -134,7 +137,8 @@ probe_camera_lpm_path() { probe_report() { local board mcu cam lpm - board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)"; lpm="$(probe_camera_lpm_path)" + board="$(probe_board)"; mcu="$(probe_mcu)"; lpm="$(probe_camera_lpm_path)" + cam="$(probe_camera_id | paste -sd ' ')" echo "board : $board" echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))" echo "motor MCU : ${mcu:-NOT FOUND}" @@ -270,7 +274,7 @@ do_vimba() { local id; id="$(probe_camera_id)" if [[ -n $id ]]; then - say "camera found: $id" + say "camera found: ${id//$'\n'/, }" else warn "no camera reported by ListCameras yet" warn "the SDK asks for a reboot before the transport layers are usable" @@ -374,6 +378,11 @@ emit_hardware_ini() { warn "$mcu" mcu="CHOOSE_ONE" fi + if [[ $(wc -l <<< "$cam") -gt 1 ]]; then + warn "several cameras found; pick one with --camera-id:" + warn "$cam" + cam="CHOOSE_ONE" + fi cat <> 4 | b2 << 4) +// BayerRG12Packed +// the same, packed the GigE Vision 1.x way that older GigE +// cameras use (the Manta G-1236C on rietschen): the first byte +// holds p0's HIGH bits, not its low ones +// p0 = b0 << 4 | (b1 & 0x0F), +// p1 = b2 << 4 | b1 >> 4 +// Same size as BayerRG12p; read as 12p it would scramble every +// other pixel. // Mono16 two bytes per pixel, grey, host byte order - the thermal // camera's counts (TarisCameraSource.h), all 16 bits used // // OpenCV names Bayer patterns by the second row, so the camera's BayerRG is // OpenCV's COLOR_BayerBG2* (measured 2026-09-30 against the camera's own RGB8). -enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p, Mono16 }; +enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p, Mono16, BayerRG12Packed }; -// The GenICam name ("BayerRG12p"), or "Unknown". +// The GenICam name ("BayerRG12p", "BayerRG12Packed"), or "Unknown". const char* layoutName(PixelLayout layout); // From a GenICam name; Unknown for anything the program does not handle. PixelLayout layoutFromName(const std::string& name); // True for the raw mosaic layouts, which need de-Bayering before saving as colour. bool layoutIsBayer(PixelLayout layout); -// Bits per value as delivered: 8, 12 for BayerRG12p, 16 for Mono16. 0 for Unknown. +// Bits per value as delivered: 8, 12 for the 12-bit mosaics, 16 for Mono16. 0 +// for Unknown. int layoutBits(PixelLayout layout); // Bytes one frame of width x height takes in this layout (0 for Unknown). size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height); @@ -40,6 +49,10 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height); // (0..4095). `in` holds ceil(pixels * 3 / 2) bytes; an odd last pixel uses the // low half of its pair, as GenICam specifies. void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels); +// The same for BayerRG12Packed (GigE Vision 1.x packing, see PixelLayout). +void unpack12Packed(const uint8_t* in, uint16_t* out, size_t pixels); +// Either of the two, by layout: the 12-bit mosaic unpacked to 16-bit words. +void unpack12(PixelLayout layout, const uint8_t* in, uint16_t* out, size_t pixels); // Brightness of a frame from a sparse sample of its pixels: every `stride`-th // pixel, so all colour sites of a mosaic are visited. Cheap enough to run on diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index 48372b1..0fc5ea6 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -120,6 +120,102 @@ RECIPES = { ], } +# 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: @@ -131,6 +227,10 @@ def apply_feature(cam, name, value, dry_run): 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 @@ -168,6 +268,8 @@ def main(): "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() @@ -191,10 +293,12 @@ def main(): 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}{' (dry run)' if args.dry_run else ''}") + print(f"profile : {args.profile} ({family} recipe){' (dry run)' if args.dry_run else ''}") refused = [] - for name, value in RECIPES[args.profile]: + 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] @@ -205,13 +309,14 @@ def main(): refused.append(n) # What the settings actually cost per frame - the number that decides - # whether this profile can be delivered over USB at all. + # 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 = cam.get_feature_by_name("AcquisitionFrameRate").get() + 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") diff --git a/scripts/install-tower b/scripts/install-tower index d7c91da..0ce1d22 100755 --- a/scripts/install-tower +++ b/scripts/install-tower @@ -334,22 +334,35 @@ set -euo pipefail S_MCU="$(probe_mcu)" S_MCU_N=$(grep -c . <<< "$S_MCU" || true) -# Another program on the motor MCU's port - a legacy tower's old program, started -# by start_gimbal.service - would fight this one for it, and the flash would -# fail half-way. Our own program is fine: it is stopped before anything needs -# the port. +# Another program on the motor MCU's port - a legacy tower's old program - would +# fight this one for it, and the flash would fail half-way. Our own program is +# fine: it is stopped before anything needs the port. The old program is started +# differently from tower to tower: start_gimbal.service from +# ~/scripts/startup_gimbal.sh, which also runs the picture transfer, on +# staeffelsberg; gimbal_control.service on its own on rietschen. if (( S_MCU_N == 1 )) && [[ -e $S_MCU ]]; then for pid in $(fuser "$(readlink -f "$S_MCU")" 2>/dev/null); do args="$(ps -o args= -p "$pid" 2>/dev/null)" [[ $args == */fire_gimbal_control* ]] && continue - die "the motor MCU's port is held by another program: pid $pid ($args). -Stop it for good before installing. On a legacy tower it is started by -start_gimbal.service from ~/scripts/startup_gimbal.sh, which also runs the picture -transfer: comment out the old program's line there, keep the transfer, then + unit="$(ps -o unit= -p "$pid" 2>/dev/null | tr -d ' ')" + if [[ $unit == start_gimbal.service ]]; then + how="It is started by start_gimbal.service from ~/scripts/startup_gimbal.sh, which +also runs the picture transfer: comment out the old program's line there, keep the +transfer, then sudo systemctl restart start_gimbal" + elif [[ $unit == *.service ]]; then + how="It is started by $unit: + sudo systemctl disable --now $unit" + else + how="Find what starts it (systemctl status $pid) and stop that." + fi + die "the motor MCU's port is held by another program: pid $pid ($args). +Stop it for good before installing. $how" done fi S_CAM="$(probe_camera_id)" +S_CAM_N=$(grep -c . <<< "$S_CAM" || true) +S_CAM_SHOWN="$(paste -sd ' ' <<< "$S_CAM")" S_LPM="$(probe_camera_lpm_path)" S_USBFS="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')" S_PIO="$(pio_path || true)" @@ -377,7 +390,7 @@ board : $S_BOARD (kit hardware/$S_BOARD, firmware env $KIT_FW_ENV) software / fw : $S_SW_REV / $S_FW_REV motor MCU : ${S_MCU:-NOT FOUND} [$S_MODE] running firmware : ${S_FW_NOW:-unknown} -camera : ${S_CAM:-not found}${S_LPM:+ (LPM $(cat "$S_LPM") at $S_LPM)} +camera : ${S_CAM_SHOWN:-not found}${S_LPM:+ (LPM $(cat "$S_LPM") at $S_LPM)} usbfs_memory_mb : $S_USBFS hardware.ini : $([[ -r $HW ]] && echo "present, $(hw_todos | grep -c . || true) TODO(s)" || echo absent) config.ini : $([[ -r $CFG ]] && echo "$CFG, tower_name $(ini_get "$CFG" General tower_name 2>/dev/null || echo '?'), [Startup] home $(ini_get "$CFG" Startup home 2>/dev/null || echo unset), mode $(ini_get "$CFG" Startup mode 2>/dev/null || echo unset)" || echo absent) @@ -604,7 +617,11 @@ if [[ ! -r $HW ]]; then ask serial "No motor MCU found - its /dev/serial/by-id path" KIT_ARGS+=(--serial "$REPLY_VAL") fi - if [[ $CAMS_A != none && -z $S_CAM ]]; then + if [[ $CAMS_A != none ]] && (( S_CAM_N > 1 )); then + echo "Several cameras:"; nl -w2 -s') ' <<< "$S_CAM" + ask camera_id "Which is the colour camera (its DEV_ id)" + KIT_ARGS+=(--camera-id "$REPLY_VAL") + elif [[ $CAMS_A != none && -z $S_CAM ]]; then ask camera_id "Camera DEV_ id (none found; the SDK may need a reboot first)" KIT_ARGS+=(--camera-id "$REPLY_VAL") fi @@ -1092,9 +1109,13 @@ if [[ ${DO[profiles]} == change ]]; then "$HOME/.venvs/vmbpy/bin/pip" install -q /opt/VimbaX/api/python/vmbpy-*.whl; }; }; then PROFDIR="$(prof_dir)" # the config may have moved since the plan was made mkdir -p "$PROFDIR" + # The camera hardware.ini names, not the first one the SDK lists: on a + # tower with several (rietschen: three GigE) that is another camera. + cam_id="$(ini_get "$HW" Camera.rgb id 2>/dev/null || true)" + cam_arg=(); [[ $cam_id == DEV_* ]] && cam_arg=(--camera "$cam_id") for prof in "${PROF_MISSING[@]}"; do ( cd "$REPO" && GENICAM_GENTL64_PATH=/opt/VimbaX/cti "$HOME/.venvs/vmbpy/bin/python" \ - scripts/export_camera_profile.py --profile "$prof" --out "$PROFDIR" ) || + scripts/export_camera_profile.py --profile "$prof" --out "$PROFDIR" "${cam_arg[@]}" ) || warn "could not export the $prof profile; export it later (docs/tower-installation.md section 8)" done done_step profiles diff --git a/src/camera/ImagePipeline.cpp b/src/camera/ImagePipeline.cpp index 2cf7abc..e3d854a 100644 --- a/src/camera/ImagePipeline.cpp +++ b/src/camera/ImagePipeline.cpp @@ -231,14 +231,14 @@ bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std: img = cv::Mat(h, w, CV_16UC1, data); bits = 16; distance = 0.f; - } else if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) { + } else if (layoutIsBayer(frame.layout)) { cv::Mat mosaic; if (frame.layout == PixelLayout::BayerRG8) { mosaic = cv::Mat(h, w, CV_8UC1, data); } else { mosaic.create(h, w, CV_16UC1); - unpack12p(frame.data.data(), reinterpret_cast(mosaic.data), - static_cast(w) * h); + unpack12(frame.layout, frame.data.data(), reinterpret_cast(mosaic.data), + static_cast(w) * h); bits = 12; } // The camera's BayerRG is OpenCV's BayerBG (see RawImage.h). diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index be51cea..542040f 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -67,6 +67,8 @@ PixelLayout layoutOf(VmbPixelFormatType f) { case VmbPixelFormatRgb8: return PixelLayout::RGB8; case VmbPixelFormatBayerRG8: return PixelLayout::BayerRG8; case VmbPixelFormatBayerRG12p: return PixelLayout::BayerRG12p; + // The older GigE cameras' 12 bits (Manta): packed differently, see RawImage.h. + case VmbPixelFormatBayerRG12Packed: return PixelLayout::BayerRG12Packed; default: return PixelLayout::Unknown; } } @@ -125,7 +127,7 @@ public: warned_layout_ = true; LOG_WARN << "camera cam" << cam_id_ << ": pixel format 0x" << std::hex << pf << std::dec << " is not one the program handles " - << "(Mono8, RGB8, BayerRG8, BayerRG12p)"; + << "(Mono8, RGB8, BayerRG8, BayerRG12p, BayerRG12Packed)"; } } f.data.assign(buf, buf + bytes); @@ -188,12 +190,37 @@ private: bool has_ = false; }; +// The older GigE cameras (Manta G-1236 on rietschen, firmware 00.01.54) predate +// the SFNC names the program uses and the Alvium answers to. Where a camera has +// no feature by the program's name, it is looked up by the older one. The +// IntensityController pair is the Alvium's auto-exposure target and tolerance; +// the Manta's ExposureAuto* pair means the same, in percent too. +const char* legacyFeatureName(const std::string& name) { + static const std::pair kAliases[] = { + {"ExposureTime", "ExposureTimeAbs"}, + {"AcquisitionFrameRate", "AcquisitionFrameRateAbs"}, + {"IntensityControllerTarget", "ExposureAutoTarget"}, + {"IntensityControllerTolerance", "ExposureAutoAdjustTol"}, + }; + for (const auto& [sfnc, legacy] : kAliases) + if (name == sfnc) return legacy; + return nullptr; +} + +// A feature by the program's name, or by its older name on a camera that has only +// that one. +bool findFeature(const CameraPtr& cam, const std::string& name, FeaturePtr& f) { + if (SP_ACCESS(cam)->GetFeatureByName(name.c_str(), f) == VmbErrorSuccess) return true; + const char* legacy = legacyFeatureName(name); + return legacy && SP_ACCESS(cam)->GetFeatureByName(legacy, f) == VmbErrorSuccess; +} + // Read a numeric/string feature, returning `fallback`/"" if absent (tolerant, for // telemetry). Numeric takes an integer feature too: some are Int on one model and // Float on another (IntensityControllerTolerance is Int on the Alvium 1800 U-1240c). double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) { FeaturePtr f; - if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback; + if (!findFeature(cam, name, f)) return fallback; double v = fallback; if (f->GetValue(v) == VmbErrorSuccess) return v; VmbInt64_t i = 0; @@ -203,7 +230,7 @@ double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) { std::string getString(const CameraPtr& cam, const char* name) { FeaturePtr f; std::string v; - if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) + if (findFeature(cam, name, f) && f->GetValue(v) == VmbErrorSuccess) return v; return {}; } @@ -649,8 +676,7 @@ bool VimbaCameraSource::setFeature(int camera_index, const std::string& name, if (camera_index < 0 || idx >= impl_->cameras.size()) return false; FeaturePtr f; VmbFeatureDataType type = VmbFeatureDataUnknown; - if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess || - f->GetDataType(type) != VmbErrorSuccess) + if (!findFeature(impl_->cameras[idx], name, f) || f->GetDataType(type) != VmbErrorSuccess) return false; // The value before the first change, so restoreProfile() can put it back // live. Only the first: a second change must not record the test's own value. @@ -741,8 +767,7 @@ bool VimbaCameraSource::readFeature(int camera_index, const std::string& name, d if (camera_index < 0 || idx >= impl_->cameras.size()) return false; FeaturePtr f; VmbFeatureDataType type = VmbFeatureDataUnknown; - if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess || - f->GetDataType(type) != VmbErrorSuccess) + if (!findFeature(impl_->cameras[idx], name, f) || f->GetDataType(type) != VmbErrorSuccess) return false; if (type == VmbFeatureDataFloat) return f->GetValue(value) == VmbErrorSuccess; if (type == VmbFeatureDataInt) { @@ -760,8 +785,7 @@ bool VimbaCameraSource::featureRange(int camera_index, const std::string& name, if (camera_index < 0 || idx >= impl_->cameras.size()) return false; FeaturePtr f; VmbFeatureDataType type = VmbFeatureDataUnknown; - if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess || - f->GetDataType(type) != VmbErrorSuccess) + if (!findFeature(impl_->cameras[idx], name, f) || f->GetDataType(type) != VmbErrorSuccess) return false; if (type == VmbFeatureDataFloat) return f->GetRange(min, max) == VmbErrorSuccess; if (type == VmbFeatureDataInt) { @@ -788,7 +812,7 @@ bool VimbaCameraSource::restoreProfile(int camera_index) { for (auto it = undo.rbegin(); ok && it != undo.rend(); ++it) { FeaturePtr f; VmbFeatureDataType type = VmbFeatureDataUnknown; - if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(it->first.c_str(), f) != VmbErrorSuccess || + if (!findFeature(impl_->cameras[idx], it->first, f) || f->GetDataType(type) != VmbErrorSuccess) { ok = false; break; } VmbErrorType rc = VmbErrorWrongType; try { diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 7d94ba2..11cbb1b 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -3627,6 +3627,12 @@ struct Application::Impl { return 3; // distinct from the config (1) and lock (2) exits } + // Again, now the camera is open: the keep rule reads the camera's own + // auto-exposure target, tolerance and limits, and before open() it got + // none of them and judged every frame against its 50 +- 5 % default - + // until the first profile switch. Rietschen's cameras aim at 35 %. + configureSettle(); + pipeline->start(); channel->publishStatus(static_cast(Mode::Manual)); // until [Startup] mode says otherwise diff --git a/src/core/RawImage.cpp b/src/core/RawImage.cpp index 62c24c2..238a29b 100644 --- a/src/core/RawImage.cpp +++ b/src/core/RawImage.cpp @@ -10,6 +10,7 @@ const char* layoutName(PixelLayout layout) { case PixelLayout::RGB8: return "RGB8"; case PixelLayout::BayerRG8: return "BayerRG8"; case PixelLayout::BayerRG12p: return "BayerRG12p"; + case PixelLayout::BayerRG12Packed: return "BayerRG12Packed"; case PixelLayout::Mono16: return "Mono16"; case PixelLayout::Unknown: break; } @@ -18,13 +19,15 @@ const char* layoutName(PixelLayout layout) { PixelLayout layoutFromName(const std::string& name) { for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8, - PixelLayout::BayerRG12p, PixelLayout::Mono16}) + PixelLayout::BayerRG12p, PixelLayout::BayerRG12Packed, + PixelLayout::Mono16}) if (name == layoutName(l)) return l; return PixelLayout::Unknown; } bool layoutIsBayer(PixelLayout layout) { - return layout == PixelLayout::BayerRG8 || layout == PixelLayout::BayerRG12p; + return layout == PixelLayout::BayerRG8 || layout == PixelLayout::BayerRG12p || + layout == PixelLayout::BayerRG12Packed; } int layoutBits(PixelLayout layout) { @@ -32,7 +35,8 @@ int layoutBits(PixelLayout layout) { case PixelLayout::Mono8: case PixelLayout::RGB8: case PixelLayout::BayerRG8: return 8; - case PixelLayout::BayerRG12p: return 12; + case PixelLayout::BayerRG12p: + case PixelLayout::BayerRG12Packed: return 12; case PixelLayout::Mono16: return 16; case PixelLayout::Unknown: break; } @@ -45,7 +49,8 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) { case PixelLayout::Mono8: case PixelLayout::BayerRG8: return n; case PixelLayout::RGB8: return n * 3; - case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2; + case PixelLayout::BayerRG12p: + case PixelLayout::BayerRG12Packed: return (n * 3 + 1) / 2; case PixelLayout::Mono16: return n * 2; case PixelLayout::Unknown: break; } @@ -66,6 +71,12 @@ inline uint16_t px12p(const uint8_t* in, size_t i) { : static_cast((p[1] >> 4) | (p[2] << 4)); } +inline uint16_t px12Packed(const uint8_t* in, size_t i) { + const uint8_t* p = in + (i >> 1) * 3; + return (i & 1) == 0 ? static_cast((p[0] << 4) | (p[1] & 0x0F)) + : static_cast((p[2] << 4) | (p[1] >> 4)); +} + } // namespace void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels) { @@ -77,6 +88,20 @@ void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels) { if (pixels & 1) out[0] = static_cast(in[0] | ((in[1] & 0x0F) << 8)); } +void unpack12Packed(const uint8_t* in, uint16_t* out, size_t pixels) { + const size_t pairs = pixels / 2; + for (size_t k = 0; k < pairs; ++k, in += 3, out += 2) { + out[0] = static_cast((in[0] << 4) | (in[1] & 0x0F)); + out[1] = static_cast((in[2] << 4) | (in[1] >> 4)); + } + if (pixels & 1) out[0] = static_cast((in[0] << 4) | (in[1] & 0x0F)); +} + +void unpack12(PixelLayout layout, const uint8_t* in, uint16_t* out, size_t pixels) { + if (layout == PixelLayout::BayerRG12Packed) unpack12Packed(in, out, pixels); + else unpack12p(in, out, pixels); +} + BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayout layout, uint32_t width, uint32_t height, size_t stride) { BrightnessSample s; @@ -90,9 +115,10 @@ BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayou double sum = 0; size_t hi = 0; for (size_t i = 0; i < values; i += stride) { - double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 - : layout == PixelLayout::Mono16 ? px16(frame, i) / 256.0 - : frame[i]; + double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 + : layout == PixelLayout::BayerRG12Packed ? px12Packed(frame, i) / 16.0 + : layout == PixelLayout::Mono16 ? px16(frame, i) / 256.0 + : frame[i]; sum += v; hi += v >= 250.0; ++s.samples; diff --git a/src/core/TestRunner.cpp b/src/core/TestRunner.cpp index b5da229..d991185 100644 --- a/src/core/TestRunner.cpp +++ b/src/core/TestRunner.cpp @@ -1097,13 +1097,26 @@ void TestRunner::testHwEnv(TestReport& r) { } setProgress(s.id, 2, 2, "reading sensor"); + std::optional sample = hw_.env ? hw_.env->sample() : std::nullopt; + // The firmware reads its DHT11 every 10 s, the first time 2 s after it + // starts, so a run straight after the host connected may have no reading + // yet: install-tower --verify failed on rietschen 1.4 s into its run, 36 s + // after the same test had passed. Wait one read period before calling it + // missing. + if (hw_.env && !sample && on_mcu) { + const auto deadline = clock_t_::now() + 12s; + while (!sample && clock_t_::now() < deadline && !cancelled()) { + std::this_thread::sleep_for(200ms); + sample = hw_.env->sample(); + } + } CheckResult m; if (!hw_.env) { m.pass = false; m.detail = "declared as '" + hw_.declared->env + "' but no sensor was built -- check [Env] i2c_device; the declaration itself is " "what enables it"; - } else if (auto sample = hw_.env->sample()) { + } else if (sample) { m.detail = "CRC-valid reading from " + hw_.env->name(); } else if (on_mcu) { m.pass = false; diff --git a/tests/test_rawimage.cpp b/tests/test_rawimage.cpp index 327dd4b..54ae248 100644 --- a/tests/test_rawimage.cpp +++ b/tests/test_rawimage.cpp @@ -22,11 +22,26 @@ std::vector pack12p(const std::vector& v) { return out; } +// Pack 12-bit values the GigE Vision 1.x way (BayerRG12Packed, the Manta's): +// the first byte carries p0's high bits. +std::vector pack12Packed(const std::vector& v) { + std::vector out((v.size() * 3 + 1) / 2); + for (size_t i = 0, o = 0; i < v.size(); i += 2, o += 3) { + const uint16_t p0 = v[i]; + const uint16_t p1 = i + 1 < v.size() ? v[i + 1] : 0; + out[o] = static_cast(p0 >> 4); + out[o + 1] = static_cast((p0 & 0x0F) | ((p1 & 0x0F) << 4)); + if (o + 2 < out.size()) out[o + 2] = static_cast(p1 >> 4); + } + return out; +} + } // namespace TEST_CASE("layout names round-trip, and unknown formats are refused") { for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8, - PixelLayout::BayerRG12p, PixelLayout::Mono16}) + PixelLayout::BayerRG12p, PixelLayout::BayerRG12Packed, + PixelLayout::Mono16}) CHECK(layoutFromName(layoutName(l)) == l); CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown); @@ -45,6 +60,10 @@ TEST_CASE("layout properties") { CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120); CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360); CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up + // The Manta G-1236C's full frame in BayerRG12Packed: its PayloadSize. + CHECK(layoutIsBayer(PixelLayout::BayerRG12Packed)); + CHECK(layoutBits(PixelLayout::BayerRG12Packed) == 12); + CHECK(layoutFrameBytes(PixelLayout::BayerRG12Packed, 4112, 3008) == 18553344); // The thermal camera: 640 x 480 counts, two bytes each. CHECK_FALSE(layoutIsBayer(PixelLayout::Mono16)); CHECK(layoutBits(PixelLayout::Mono16) == 16); @@ -85,6 +104,36 @@ TEST_CASE("unpack12p round-trips every value, odd counts included") { } } +TEST_CASE("unpack12Packed matches GigE Vision 12Packed, by hand") { + // p0 = 0xABC, p1 = 0x123: bytes AB, 3C, 12. + const uint8_t in[3] = {0xAB, 0x3C, 0x12}; + uint16_t out[2] = {}; + unpack12Packed(in, out, 2); + CHECK(out[0] == 0xABC); + CHECK(out[1] == 0x123); + // The same bytes read as 12p are other values: the two must not be confused. + unpack12p(in, out, 2); + CHECK(out[0] != 0xABC); +} + +TEST_CASE("unpack12Packed round-trips every value, odd counts included") { + std::mt19937 rng(1236); + std::vector src(1001); + for (auto& v : src) v = static_cast(rng() % 4096); + src[0] = 0; + src[1] = 4095; + for (size_t n : {size_t(1000), size_t(1001)}) { + CAPTURE(n); + std::vector part(src.begin(), src.begin() + n); + const auto packed = pack12Packed(part); + std::vector out(n, 0xFFFF); + unpack12(PixelLayout::BayerRG12Packed, packed.data(), out.data(), n); + CHECK(out == part); + unpack12(PixelLayout::BayerRG12p, pack12p(part).data(), out.data(), n); + CHECK(out == part); + } +} + TEST_CASE("brightness sample: 8-bit layouts") { const uint32_t w = 400, h = 300; SUBCASE("mid grey") { @@ -117,6 +166,12 @@ TEST_CASE("brightness sample: 12-bit is reported on the 0..255 scale") { s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12p, w, h); CHECK(s.mean == doctest::Approx(4095 / 16.0)); CHECK(s.clipped == 1); + // GigE 12Packed, with values that read differently as 12p: 0x640 = 1600. + std::fill(v.begin(), v.end(), 1600); + f = pack12Packed(v); + s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12Packed, w, h); + REQUIRE(s.valid()); + CHECK(s.mean == doctest::Approx(100)); } TEST_CASE("brightness sample refuses what it cannot read") {