From 8850337250ef552fd24356220d97efcef10a8053 Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Tue, 20 Oct 2026 17:13:04 +0200 Subject: [PATCH 1/6] Support the older GigE cameras (Manta) on rietschen Rietschen has three Manta G-1236 on one GbE port, whose firmware predates the names the program uses. A feature missing by its SFNC name is now looked up by the older one (ExposureTimeAbs, AcquisitionFrameRateAbs, ExposureAutoTarget, ExposureAutoAdjustTol), so test camera, the telemetry and the keep rule's reads work. export_camera_profile.py picks a gige recipe for such a camera: frame trigger off, BayerRG8 or Mono8, 60 MB/s, auto-exposure target 50 %. At the cameras' old target of 25 every waypoint ran into frame_timeout_ms. The camera probe now lists every camera. With several, the hardware.ini kit writes CHOOSE_ONE, install-tower asks which is the colour one, and the profile export is given that id - the first one the SDK lists was the NIR camera. install-tower names the unit that holds the motor port instead of assuming start_gimbal.service. Tested on rietschen's NIR camera with the motor mocked, beside the running old program: test camera PASS, a snapshot as a 3008x4112 grey JXL, a scan keeping a picture every 5 s. Tests 542/542 (mock build). The colour camera is not yet tested; the old program has it open. Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 40 ++++++++++++++ hardware/common/kit.sh | 15 ++++- scripts/export_camera_profile.py | 95 ++++++++++++++++++++++++++++++-- scripts/install-tower | 43 +++++++++++---- src/camera/VimbaCameraSource.cpp | 40 +++++++++++--- 5 files changed, 206 insertions(+), 27 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 5f48024..8ce50c3 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -714,6 +714,46 @@ 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`, free-running at `AcquisitionFrameRateLimit` | +| `IntensityControllerTarget` / `…Tolerance` | `ExposureAutoTarget` / `ExposureAutoAdjustTol` | +| `DeviceLinkThroughputLimit` | `StreamBytesPerSecond` (max 124 MB/s, shared by every camera on the port) | +| `BayerRG12p`, 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 and `Mono8` on a mono one, 60 MB/s each, and an auto-exposure +target of 50 %. That last one matters: the keep rule waits for the frame mean +to reach 50 %, and at the cameras' old 25 every waypoint ran into +`frame_timeout_ms`. + +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 </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/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index be51cea..068de83 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -188,12 +188,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 +228,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 +674,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 +765,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 +783,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 +810,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 { From fdb3c50b68fbffba53dad0da2c41738d1d3a100b Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Tue, 20 Oct 2026 17:54:52 +0200 Subject: [PATCH 2/6] Run a Manta at its frame-rate limit, and wait for a DHT11's first reading A free-running Manta runs at AcquisitionFrameRateAbs, not at its limit: rietschen's RGB camera came set to 0.955 fps and delivered 5 frames in 5.2 s. The gige recipe now writes a short fixed exposure and then the rate, clamped to the camera's maximum (4.7 fps at 60 MB/s), before auto-exposure goes back on. The export summary shows the rate set, not the limit, which had read 4.71 fps for a camera running at 0.96. test hardware env asked for a DHT11 reading at once, but the firmware reads it every 10 s, the first time 2 s after it starts. install-tower --verify failed on rietschen 1.4 s into its run, 36 s after the same test had passed. It now waits one read period for the first reading. Tests 542/542 (mock). Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 7 ++++--- scripts/export_camera_profile.py | 12 +++++++++--- src/core/TestRunner.cpp | 15 ++++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 8ce50c3..208182a 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -723,7 +723,7 @@ MTU 8000): RGB (G-1236C), NIR and ACR (both G-1236B). Their firmware | Program / Alvium | Manta | |---|---| | `ExposureTime` | `ExposureTimeAbs` | -| `AcquisitionFrameRate` (+ `…Enable`) | `AcquisitionFrameRateAbs`, free-running at `AcquisitionFrameRateLimit` | +| `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`, chunk data, `DeviceReset` | none | @@ -732,8 +732,9 @@ 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 and `Mono8` on a mono one, 60 MB/s each, and an auto-exposure -target of 50 %. That last one matters: the keep rule waits for the frame mean +the colour camera 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 an +auto-exposure target of 50 %. That last one matters: the keep rule waits for the frame mean to reach 50 %, and at the cameras' old 25 every waypoint ran into `frame_timeout_ms`. diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index 6fd3223..58a8d9e 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -143,6 +143,13 @@ _GIGE_COMMON = [ ("Height", 100000), ("PixelFormat", ("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. The # host starts on its default band of 50 % - rietschen's cameras were set to @@ -290,14 +297,13 @@ def main(): 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. A - # free-running GigE camera reports its ceiling as the rate limit. + # 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 = "AcquisitionFrameRateLimit" if family == "gige" else "AcquisitionFrameRate" + 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 " 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; From c62d8270b3c8aa0f4b285cef0c9683d4e77359ee Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Wed, 21 Oct 2026 12:03:08 +0200 Subject: [PATCH 3/6] Let a Manta aim at the frame mean the keep rule waits for Rietschen's RGB camera came with ExposureAutoAlg = FitRange and a 10 % tolerance. FitRange fits the histogram wherever the mean lands, and the camera stopped adjusting 10 % off its target, so the keep rule's 50 +- 5 % band was never reached: at a 3 s interval every waypoint ran into its 2 s frame_timeout (2026-10-21). The gige recipe now sets Mean and 5 %, as the NIR camera already had. By day the exposure floor goes from 200 to 20 us: at noon the camera sat at 200-600 us with no room to darken a bright view, and without chunk data the host cannot tell a camera at its limit from one still settling. Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 10 ++++++---- scripts/export_camera_profile.py | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 208182a..edfff4d 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -733,10 +733,12 @@ 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 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 an -auto-exposure target of 50 %. That last one matters: the keep rule waits for the frame mean -to reach 50 %, and at the cameras' old 25 every waypoint ran into -`frame_timeout_ms`. +the camera's maximum (rietschen's RGB camera came set to 0.955 fps), and +auto-exposure on the frame mean (`ExposureAutoAlg = Mean`) at 50 % +- 5 %, down +to 20 us by day. That last one matters: the keep rule waits for the frame mean +to reach 50 % +- 5 %. At the cameras' old target of 25 every waypoint ran into +`frame_timeout_ms`, and so it did with the RGB camera's `FitRange` and 10 % +tolerance, which fit the histogram wherever its mean lands. 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 diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index 58a8d9e..2b972b4 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -154,6 +154,11 @@ _GIGE_COMMON = [ # The keep rule waits for the frame mean to reach the camera's target. The # host starts on its default band of 50 % - rietschen's cameras were set to # 25, and every waypoint then ran into frame_timeout_ms (7.5 s, 2026-10-20). + # The camera has to aim at the mean, and stop adjusting only inside the same + # +-5 %: the RGB camera came with FitRange (which fits the histogram, wherever + # the mean lands) and a 10 % tolerance, and no waypoint settled (2026-10-21). + ("ExposureAutoAlg", "Mean"), + ("ExposureAutoAdjustTol", 5), ("ExposureAutoTarget", 50), ("ExposureAuto", "Continuous"), ("GainAuto", "Continuous"), @@ -162,7 +167,10 @@ _GIGE_COMMON = [ GIGE_RECIPES = { "standard": _GIGE_COMMON + [ ("ExposureAutoMax", 20000), - ("ExposureAutoMin", 200), + # 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 + [ From 7332c47d6d093cefa86dec830a861002b61ca201 Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Wed, 21 Oct 2026 12:40:44 +0200 Subject: [PATCH 4/6] Judge the keep rule against the camera's own target, and aim Mantas at 35 % configureSettle() read the camera's auto-exposure target, tolerance and limits before the camera was opened, so it got none of them and judged every frame against its 50 +- 5 % default until the first profile switch - on every tower. It now runs again once the camera is open. On the Alviums this brings their own IntensityControllerTolerance and, at night, the at-limit verdict from their chunk data into play. The gige recipe aims at 35 % instead of 50: at 50 the sky over the horizon burnt out at noon on rietschen, where smoke would rise. The old program ran these cameras at 23-25. Tests 542/542 (mock). Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 11 ++++++----- scripts/export_camera_profile.py | 14 +++++++------- src/core/Application.cpp | 6 ++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index edfff4d..447eb19 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -734,11 +734,12 @@ 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 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 50 % +- 5 %, down -to 20 us by day. That last one matters: the keep rule waits for the frame mean -to reach 50 % +- 5 %. At the cameras' old target of 25 every waypoint ran into -`frame_timeout_ms`, and so it did with the RGB camera's `FitRange` and 10 % -tolerance, which fit the histogram wherever its mean lands. +auto-exposure on the frame mean (`ExposureAutoAlg = Mean`) at 35 % +- 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. 35 rather than the Alvium's 50: at 50 the sky over the horizon burnt +out at noon, where smoke would rise; 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 diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index 2b972b4..f572fb5 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -151,15 +151,15 @@ _GIGE_COMMON = [ ("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. The - # host starts on its default band of 50 % - rietschen's cameras were set to - # 25, and every waypoint then ran into frame_timeout_ms (7.5 s, 2026-10-20). - # The camera has to aim at the mean, and stop adjusting only inside the same - # +-5 %: the RGB camera came with FitRange (which fits the histogram, wherever - # the mean lands) and a 10 % tolerance, and no waypoint settled (2026-10-21). + # 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). 35 % rather than the + # Alvium's 50: at 50 the sky over the horizon burnt out at noon, where smoke + # would rise; the old program ran these cameras at 23-25. ("ExposureAutoAlg", "Mean"), ("ExposureAutoAdjustTol", 5), - ("ExposureAutoTarget", 50), + ("ExposureAutoTarget", 35), ("ExposureAuto", "Continuous"), ("GainAuto", "Continuous"), ] 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 From df11808d78ec2d58f547e56312734b86092e0dbf Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Wed, 21 Oct 2026 12:59:50 +0200 Subject: [PATCH 5/6] Aim the Mantas at 30 %: at 35 the sky was still too bright Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 7 ++++--- scripts/export_camera_profile.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 447eb19..1d236b1 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -734,12 +734,13 @@ 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 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 35 % +- 5 %, down +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. 35 rather than the Alvium's 50: at 50 the sky over the horizon burnt -out at noon, where smoke would rise; the old program ran these cameras at 23-25. +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 diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index f572fb5..5438d89 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -154,12 +154,13 @@ _GIGE_COMMON = [ # 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). 35 % rather than the + # 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; the old program ran these cameras at 23-25. + # would rise, and at 35 it was still too bright; the old program ran these + # cameras at 23-25. ("ExposureAutoAlg", "Mean"), ("ExposureAutoAdjustTol", 5), - ("ExposureAutoTarget", 35), + ("ExposureAutoTarget", 30), ("ExposureAuto", "Continuous"), ("GainAuto", "Continuous"), ] From 44b8a2e84a122d950b5c6ab7239f0fabb21b8b15 Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Mon, 26 Oct 2026 11:46:32 +0100 Subject: [PATCH 6/6] Read the Manta's 12 bits: BayerRG12Packed, packed the GigE Vision 1.x way Rietschen's RGB camera switched to 12 bits offers BayerRG12Packed, not BayerRG12p, and the program took it for an unknown format: from 2026-10-26 11:33 every picture was saved as "legacy", undebayered raw bytes. The two are not the same format under two names. Both put two pixels in three bytes, but the GigE Vision 1.x packing carries the first pixel's high bits in the first byte, where GenICam's 12p carries its low ones; read as 12p every other pixel is scrambled. BayerRG12Packed is now a layout of its own, unpacked and sampled for brightness its own way and de-Bayered like 12p. Checked against the hardware on rietschen's NIR camera (Mono12Packed, the same packing): the 12-bit frame unpacked this way differs from an 8-bit frame of the same view by 2.8 on average, unpacked as 12p by 29. The gige recipe prefers BayerRG12Packed, so a re-export keeps the 12 bits. Tests 544/544 (mock). Co-Authored-By: Claude Opus 5.5 --- docs/tower-installation.md | 6 ++-- include/fgc/RawImage.h | 19 +++++++++-- scripts/export_camera_profile.py | 5 ++- src/camera/ImagePipeline.cpp | 6 ++-- src/camera/VimbaCameraSource.cpp | 4 ++- src/core/RawImage.cpp | 40 ++++++++++++++++++---- tests/test_rawimage.cpp | 57 +++++++++++++++++++++++++++++++- 7 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 1d236b1..4693387 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -726,13 +726,15 @@ MTU 8000): RGB (G-1236C), NIR and ACR (both G-1236B). Their firmware | `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`, chunk data, `DeviceReset` | none | +| `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 and `Mono8` on a mono one, 60 MB/s each, the frame rate at +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 diff --git a/include/fgc/RawImage.h b/include/fgc/RawImage.h index 90a2888..8adaf7c 100644 --- a/include/fgc/RawImage.h +++ b/include/fgc/RawImage.h @@ -17,21 +17,30 @@ namespace fgc { // BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes // (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8, // p1 = b1 >> 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 5438d89..0fc5ea6 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -141,7 +141,10 @@ _GIGE_COMMON = [ ("OffsetY", 0), ("Width", 100000), ("Height", 100000), - ("PixelFormat", ("BayerRG8", "Mono8")), + # 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 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 068de83..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); 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/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") {